From 1c50cfb028e5e6ef0aea12cb118e60477cedfe84 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 12 Oct 2026 17:18:05 +0200 Subject: [PATCH] app_icons: tutor gets a lightbulb; generate --web writes PWA icons New `lightbulb` glyph (filled bulb over a two-bar screw base) in a new _glyph_art_extra.py module, registered for ~/src/tutor/app. `generate --web` overwrites a Flutter app's web/favicon.png and web/icons/Icon-*.png from the same registry entry; the maskable variants reuse the full render because SAFE_BOX fits inside the 40% maskable safe circle. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YADHKXzZhhePzQ2rNoXFcK --- .../app-icons-tutor-lightbulb-2026-10-12.json | 14 +++++++ .../app-icons-tutor-lightbulb-2026-10-12.json | 38 +++++++++++++++++++ python_pkg/app_icons/README.md | 5 +++ python_pkg/app_icons/_app_icon.py | 5 +++ python_pkg/app_icons/_glyph_art_extra.py | 26 +++++++++++++ python_pkg/app_icons/apps.py | 30 +++++++++------ python_pkg/app_icons/cli.py | 24 ++++++++++++ python_pkg/app_icons/generate.py | 37 ++++++++++++++++++ python_pkg/app_icons/glyphs.py | 5 +-- python_pkg/app_icons/tests/test_cli.py | 32 ++++++++++++++++ python_pkg/app_icons/tests/test_generate.py | 31 +++++++++++++++ python_pkg/app_icons/tests/test_registry.py | 11 ++++++ 12 files changed, 243 insertions(+), 15 deletions(-) create mode 100644 docs/superpowers/contracts/app-icons-tutor-lightbulb-2026-10-12.json create mode 100644 docs/superpowers/evidence/app-icons-tutor-lightbulb-2026-10-12.json create mode 100644 python_pkg/app_icons/_glyph_art_extra.py diff --git a/docs/superpowers/contracts/app-icons-tutor-lightbulb-2026-10-12.json b/docs/superpowers/contracts/app-icons-tutor-lightbulb-2026-10-12.json new file mode 100644 index 00000000..fdf53268 --- /dev/null +++ b/docs/superpowers/contracts/app-icons-tutor-lightbulb-2026-10-12.json @@ -0,0 +1,14 @@ +{ + "title": "Tutor launcher icon (lightbulb) and generate --web", + "objective": "The shared app_icons generator registers `tutor` with a new `lightbulb` glyph that stays inside SAFE_BOX and reads at 48dp, and `generate --web` replaces a Flutter app's stock web favicon and PWA icons from the same registry entry, without a per-repo copy of the generator.", + "acceptance_criteria": [ + "safe_box_overflow for the lightbulb is (0, 0) and every gap is >= MIN_NEGATIVE_SPACE", + "generate --app tutor --android --web writes assets/icon/*, the Android mipmaps and web/favicon.png + web/icons/Icon-{192,512,maskable-192,maskable-512}.png", + "python_pkg/app_icons stays at 100% line and branch coverage; every file <= 250 lines" + ], + "out_of_scope": [ + "Changing style.py or any other app's glyph", + "Committing the generated files in ~/src/tutor" + ], + "verifier": "pytest --cov=python_pkg/app_icons --cov-branch; the generate and preview commands; pre-commit hooks on the staged files" +} diff --git a/docs/superpowers/evidence/app-icons-tutor-lightbulb-2026-10-12.json b/docs/superpowers/evidence/app-icons-tutor-lightbulb-2026-10-12.json new file mode 100644 index 00000000..3dce3236 --- /dev/null +++ b/docs/superpowers/evidence/app-icons-tutor-lightbulb-2026-10-12.json @@ -0,0 +1,38 @@ +{ + "intent": "Give the new Tutor Flutter app an on-style launcher icon (filled lightbulb in the shared accent on the family charcoal) and let the shared generator also replace the stock Flutter web favicon and PWA icons.", + "scope": [ + "python_pkg/app_icons/_glyph_art_extra.py, glyphs.py: the `lightbulb` glyph in a new glyph module (_glyph_art_more.py is at the line cap)", + "python_pkg/app_icons/apps.py: the `tutor` registry entry; four comments condensed to stay under 250 lines", + "python_pkg/app_icons/_app_icon.py, generate.py, cli.py: `generate --web` writes web/favicon.png and web/icons/Icon-*.png", + "python_pkg/app_icons/tests/: registry, web-icon and CLI tests; README usage line" + ], + "changes": [ + "New `lightbulb` glyph: filled bulb over two stroked screw-base bars, 40px gaps (> MIN_NEGATIVE_SPACE), ink 372x560 inside SAFE_BOX", + "tutor registered with accent #B8862E, linux=True like todo/home_inventory (desktop = web build in a Chrome --app window)", + "write_web_icons renders the full icon at 32/192/512 into the Flutter web template paths; maskable variants reuse it because SAFE_BOX's half-diagonal (396px) is inside the 40% maskable radius (410px)", + "--web skips apps without a web/ directory" + ], + "verification": [ + { + "command": "PATH=~/sdk/fvm/versions/3.47.7/bin:$PATH PYTHONPATH=. python3 -m python_pkg.app_icons generate --app tutor --android --web", + "result": "pass", + "evidence": "tutor: wrote 6 asset files; wrote 5 web icons; regenerated Android mipmaps; mipmap-xxxhdpi and web Icon-192 viewed: lightbulb on #211D1B, no Flutter logo" + }, + { + "command": "PYTHONPATH=. python3 -m python_pkg.app_icons preview -o ~/data/claude-scratch/tutor-icons-sheet.png", + "result": "pass", + "evidence": "Sheet viewed: lightbulb reads at full size, circle-masked and 48dp next to the other 21 glyphs; safe_box_overflow (0, 0)" + }, + { + "command": ".venv/bin/python -m pytest -q python_pkg/app_icons --cov=python_pkg/app_icons --cov-branch", + "result": "pass", + "evidence": "53 passed; every python_pkg/app_icons module 100% line and branch" + } + ], + "risks": [ + "`generate --android` runs `dart run`, which needs the fvm 3.47.7 SDK first on PATH (system dart 3.13.3 is below the app's ^3.13.5)" + ], + "rollback": [ + "git revert this commit; tutor and the lightbulb glyph leave the registry and --web disappears; other apps' icons are byte-identical either way" + ] +} diff --git a/python_pkg/app_icons/README.md b/python_pkg/app_icons/README.md index 29800e3a..910faf69 100644 --- a/python_pkg/app_icons/README.md +++ b/python_pkg/app_icons/README.md @@ -19,6 +19,11 @@ PYTHONPATH=~/src/testsAndMisc python3 -m python_pkg.app_icons generate --android # one app, plus the hicolor PNGs for a Linux desktop target PYTHONPATH=~/src/testsAndMisc python3 -m python_pkg.app_icons \ generate --app todo --android --linux-out ~/src/todo/linux/icons + +# overwrite web/favicon.png + web/icons/Icon-*.png (apps with a web/ dir); +# the maskable ones are safe because SAFE_BOX fits the 40% maskable circle +PYTHONPATH=~/src/testsAndMisc python3 -m python_pkg.app_icons \ + generate --app tutor --android --web ``` ## What gets written diff --git a/python_pkg/app_icons/_app_icon.py b/python_pkg/app_icons/_app_icon.py index 455721eb..0393135f 100644 --- a/python_pkg/app_icons/_app_icon.py +++ b/python_pkg/app_icons/_app_icon.py @@ -34,3 +34,8 @@ class AppIcon: def asset_dir(self) -> Path: """Return the directory holding this app's icon source assets.""" return self.repo / "assets" / "icon" + + @property + def web_dir(self) -> Path: + """Return the Flutter ``web/`` directory holding favicon and PWA icons.""" + return self.repo / "web" diff --git a/python_pkg/app_icons/_glyph_art_extra.py b/python_pkg/app_icons/_glyph_art_extra.py new file mode 100644 index 00000000..f3401888 --- /dev/null +++ b/python_pkg/app_icons/_glyph_art_extra.py @@ -0,0 +1,26 @@ +"""Third batch of SVG path fragments, continued from :mod:`_glyph_art_more`. + +Split off purely to keep every glyph module under the 250-line cap; which file +a glyph lives in carries no meaning. :mod:`glyphs` imports from all three. +""" + +from __future__ import annotations + +# A lightbulb -- tutor, "learn any subject": the idea arriving. Filled bulb +# over two stroked screw-base bars. Filled rather than outlined because the +# filled bulb still reads as a bulb at 48dp, while the outline's 56px neck +# opening closes up to a speck there. ``open-book`` (book_guard_app) was the +# other obvious "learning" glyph and is taken. +# +# Geometry, ink inside SAFE_BOX (y=256..816 = 560 tall, x=326..698 = 372 wide): +# bulb circle r=150 about (512,442), tapering on cubics to a 128-wide neck +# at y=556, closed across the neck; fill + family stroke, so the ink +# reaches 36px past the centreline (top y=256, neck y=592). +# base bar x=440..584 at y=668 (ink 632..704) and a shorter tip bar +# x=480..544 at y=780 (ink 744..816). Gaps neck->bar and bar->tip +# are both 40px, clear of MIN_NEGATIVE_SPACE=36. +_LIGHTBULB = """\ + + + """ diff --git a/python_pkg/app_icons/apps.py b/python_pkg/app_icons/apps.py index a2594d20..13f1913a 100644 --- a/python_pkg/app_icons/apps.py +++ b/python_pkg/app_icons/apps.py @@ -24,9 +24,8 @@ APPS: Final[dict[str, AppIcon]] = { for app in ( AppIcon( key="dufs_client", - # The live app: ~/dufs_client is a stale, remote-less checkout that - # was superseded when dufs-cloud absorbed it (see testsAndMisc - # CLAUDE.md). Same package name, so it is easy to icon the wrong one. + # The live app; ~/dufs_client is a stale checkout dufs-cloud + # absorbed. Same package name, so it is easy to icon the wrong one. repo=_HOME / "src/dufs-cloud" / "app", accent="#B8862E", glyph="cloud-down", @@ -125,9 +124,8 @@ APPS: Final[dict[str, AppIcon]] = { accent="#B8862E", glyph="quill-nib", icon_name="epopeja-karta", - # A linux/ target exists, but only so the app can be driven on the - # desktop during development. It is a character sheet you bring to - # the table, so it ships no desktop entry and gets no hicolor set. + # linux/ exists only to drive the app during development; it is a + # table-side character sheet, so no desktop entry, no hicolor set. linux=False, ), AppIcon( @@ -148,9 +146,8 @@ APPS: Final[dict[str, AppIcon]] = { accent="#B8862E", glyph="track-bars", icon_name="kuhylog", - # Android is the real target (home-screen widget, quick-settings - # tile); the Linux build exists for trying the UI without a phone, - # so it still wants the hicolor set. + # Android is the real target; the Linux build is for trying the UI + # without a phone, so it still wants the hicolor set. linux=True, ), AppIcon( @@ -169,9 +166,8 @@ APPS: Final[dict[str, AppIcon]] = { accent="#B8862E", glyph="track-bars", icon_name="betting_sim", - # Ships to Android natively and to Linux via a Chrome-wrapped web - # build, so the GTK embedder is never used and the hicolor set - # would go unread. + # Android natively, Linux via a Chrome-wrapped web build: no GTK + # embedder, and no desktop entry to read a hicolor set. linux=False, ), AppIcon( @@ -219,6 +215,16 @@ APPS: Final[dict[str, AppIcon]] = { icon_name="daily-limits", linux=False, ), + AppIcon( + key="tutor", + repo=_HOME / "src/tutor/app", + accent="#B8862E", + glyph="lightbulb", + icon_name="tutor", + # Desktop is the web build in a Chrome --app window, as for todo + # and home_inventory, whose desktop entries use the hicolor set. + linux=True, + ), ) } diff --git a/python_pkg/app_icons/cli.py b/python_pkg/app_icons/cli.py index 9fa82ec4..076e1f9a 100644 --- a/python_pkg/app_icons/cli.py +++ b/python_pkg/app_icons/cli.py @@ -75,6 +75,11 @@ def build_parser() -> argparse.ArgumentParser: action="store_true", help="also run flutter_launcher_icons to rebuild the Android mipmaps", ) + generate_parser.add_argument( + "--web", + action="store_true", + help="also overwrite favicon + PWA icons in the app's web/ directory", + ) generate_parser.add_argument( "--linux-out", type=Path, @@ -132,10 +137,24 @@ def _cmd_preview(app_keys: list[str] | None, output: Path) -> int: return 0 +def _write_web(app: apps.AppIcon) -> None: + """Write web icons for one app, skipping apps without a web build. + + Parameters: + app (apps.AppIcon): App to act on. + """ + if not app.web_dir.is_dir(): + _emit(f" skipped web icons: no {app.web_dir}") + return + icons = generate.write_web_icons(app) + _emit(f" wrote {len(icons)} web icons to {app.web_dir}") + + def _cmd_generate( app_keys: list[str] | None, *, android: bool, + web: bool, linux_out: Path | None, ) -> int: """Write icon assets, optionally driving the platform generators. @@ -143,6 +162,7 @@ def _cmd_generate( Parameters: app_keys (list[str] | None): Selection from the command line. android (bool): Run flutter_launcher_icons afterwards. + web (bool): Write favicon + PWA icons into apps that have a ``web/`` dir. linux_out (Path | None): Directory for hicolor PNGs, or None to skip. Returns: @@ -164,6 +184,9 @@ def _cmd_generate( icons = generate.write_linux_icons(app, linux_out) _emit(f" wrote {len(icons)} hicolor PNGs to {linux_out}") + if web: + _write_web(app) + if android: generate.run_flutter_launcher_icons(app) _emit(" regenerated Android mipmaps") @@ -187,5 +210,6 @@ def main(argv: list[str] | None = None) -> int: return _cmd_generate( args.app_keys, android=args.android, + web=args.web, linux_out=args.linux_out, ) diff --git a/python_pkg/app_icons/generate.py b/python_pkg/app_icons/generate.py index 6342c962..1bfc6858 100644 --- a/python_pkg/app_icons/generate.py +++ b/python_pkg/app_icons/generate.py @@ -42,6 +42,18 @@ _LAYERS: Final[tuple[tuple[str, bool, str | None], ...]] = ( ("icon_monochrome", False, style.MONOCHROME), ) +# (path under web/, size) for the files `flutter create` puts there. +_WEB_ICONS: Final[tuple[tuple[str, int], ...]] = ( + ("favicon.png", 32), + ("icons/Icon-192.png", 192), + ("icons/Icon-512.png", 512), + ("icons/Icon-maskable-192.png", 192), + ("icons/Icon-maskable-512.png", 512), +) + +# Radius of the web maskable-icon safe zone, as a fraction of the icon size. +MASKABLE_SAFE_RADIUS: Final[float] = 0.4 + def write_assets(app: AppIcon) -> list[Path]: """Write every icon layer for one app as SVG plus 1024px PNG. @@ -92,6 +104,31 @@ def write_linux_icons(app: AppIcon, destination: Path) -> list[Path]: return written +def write_web_icons(app: AppIcon) -> list[Path]: + """Overwrite the Flutter web template's favicon and PWA manifest icons. + + The maskable variants are the same full-bleed render as the plain ones: + every glyph is kept inside ``style.SAFE_BOX``, whose corners sit inside + the maskable safe zone (the inner circle of radius 40% of the icon), so a + circle mask never clips the glyph. + + Parameters: + app (AppIcon): App whose ``web/`` directory should be written. + + Returns: + list[Path]: Every PNG written. + """ + glyph = glyphs.get_glyph(app.glyph) + offset = render.centre_offset(glyph, app.accent) + svg_text = render.build_svg(glyph, app.accent, with_background=True, offset=offset) + written: list[Path] = [] + for relative, size in _WEB_ICONS: + png_path = app.web_dir / relative + render.rasterise(svg_text, png_path, size) + written.append(png_path) + return written + + def sync_pubspec_background(app: AppIcon) -> bool: """Rewrite pubspec.yaml's ``adaptive_icon_background`` to match style.BACKGROUND. diff --git a/python_pkg/app_icons/glyphs.py b/python_pkg/app_icons/glyphs.py index e1c86205..3ba015c5 100644 --- a/python_pkg/app_icons/glyphs.py +++ b/python_pkg/app_icons/glyphs.py @@ -29,6 +29,7 @@ from python_pkg.app_icons._glyph_art import ( _SHIELD_CUTLERY, _STORAGE_BOX, ) +from python_pkg.app_icons._glyph_art_extra import _LIGHTBULB from python_pkg.app_icons._glyph_art_more import ( _BOWL_STAR, _HOURGLASS, @@ -58,9 +59,6 @@ class Glyph: body: str -# A cloud silhouette outline with a download arrow underneath: a client that -# pulls files off a remote dufs server. - GLYPHS: Final[dict[str, Glyph]] = { glyph.name: glyph for glyph in ( @@ -101,6 +99,7 @@ GLYPHS: Final[dict[str, Glyph]] = { "Handset with punched buttons under a broadcast arc", _REMOTE_WAVE, ), + Glyph("lightbulb", "Filled lightbulb over a two-bar screw base", _LIGHTBULB), ) } diff --git a/python_pkg/app_icons/tests/test_cli.py b/python_pkg/app_icons/tests/test_cli.py index e3d63fdb..2783f305 100644 --- a/python_pkg/app_icons/tests/test_cli.py +++ b/python_pkg/app_icons/tests/test_cli.py @@ -101,6 +101,38 @@ class TestGenerateCommand: assert linux.call_count == 1 +class TestWebFlag: + @patch(f"{MOD}.generate.write_web_icons", return_value=[Path("w")]) + @patch(f"{MOD}.render.safe_box_overflow", return_value=(0, 0)) + @patch(f"{MOD}.generate.write_assets", return_value=[]) + def test_writes_into_an_existing_web_dir( + self, + mock_write: MagicMock, + mock_overflow: MagicMock, + web: MagicMock, + capsys: pytest.CaptureFixture[str], + ) -> None: + with patch.object(Path, "is_dir", return_value=True): + cli.main(["generate", "--app", "tutor", "--web"]) + assert web.call_count == 1 + assert "wrote 1 web icons" in capsys.readouterr().out + + @patch(f"{MOD}.generate.write_web_icons") + @patch(f"{MOD}.render.safe_box_overflow", return_value=(0, 0)) + @patch(f"{MOD}.generate.write_assets", return_value=[]) + def test_skips_apps_without_a_web_dir( + self, + mock_write: MagicMock, + mock_overflow: MagicMock, + web: MagicMock, + capsys: pytest.CaptureFixture[str], + ) -> None: + with patch.object(Path, "is_dir", return_value=False): + cli.main(["generate", "--app", "tutor", "--web"]) + assert web.call_count == 0 + assert "skipped web icons" in capsys.readouterr().out + + class TestParser: def test_requires_a_subcommand(self) -> None: with pytest.raises(SystemExit): diff --git a/python_pkg/app_icons/tests/test_generate.py b/python_pkg/app_icons/tests/test_generate.py index c4e87118..4d086949 100644 --- a/python_pkg/app_icons/tests/test_generate.py +++ b/python_pkg/app_icons/tests/test_generate.py @@ -2,6 +2,7 @@ from __future__ import annotations +import math from typing import TYPE_CHECKING from unittest.mock import MagicMock, patch @@ -73,6 +74,36 @@ class TestWriteLinuxIcons: assert written[0] == tmp_path / "h" / "16" / "demo.png" +class TestWriteWebIcons: + @patch(f"{MOD}.render.rasterise") + @patch(f"{MOD}.render.centre_offset", return_value=(0.0, 0.0)) + def test_replaces_the_flutter_web_template_icons( + self, mock_offset: MagicMock, raster: MagicMock, tmp_path: Path + ) -> None: + app = _app(tmp_path) + written = generate.write_web_icons(app) + assert [path.relative_to(app.web_dir).as_posix() for path in written] == [ + "favicon.png", + "icons/Icon-192.png", + "icons/Icon-512.png", + "icons/Icon-maskable-192.png", + "icons/Icon-maskable-512.png", + ] + assert [call.args[2] for call in raster.call_args_list] == [ + 32, + 192, + 512, + 192, + 512, + ] + assert style.BACKGROUND in raster.call_args.args[0] + + def test_safe_box_fits_the_maskable_safe_zone(self) -> None: + """The safe box's corners sit inside the maskable circle, so no clip.""" + half_diagonal = style.SAFE_BOX / 2 * math.sqrt(2) + assert half_diagonal <= generate.MASKABLE_SAFE_RADIUS * style.CANVAS + + def _write_pubspec(app: AppIcon, *, background: str = "#1B1D21") -> Path: app.repo.mkdir(parents=True, exist_ok=True) pubspec = app.repo / "pubspec.yaml" diff --git a/python_pkg/app_icons/tests/test_registry.py b/python_pkg/app_icons/tests/test_registry.py index 9ea3df65..db595d80 100644 --- a/python_pkg/app_icons/tests/test_registry.py +++ b/python_pkg/app_icons/tests/test_registry.py @@ -82,3 +82,14 @@ class TestHourglassGlyph: body = glyphs.get_glyph("hourglass").body assert f'fill="{style.ACCENT_MARKER}"' in body assert 'stroke="none"' in body + + +class TestLightbulbGlyph: + def test_tutor_uses_it(self) -> None: + app = apps.get_app("tutor") + assert app.glyph == "lightbulb" + assert app.web_dir == app.repo / "web" + + def test_bulb_is_filled_in_the_accent(self) -> None: + """The bulb takes the accent marker, so the monochrome layer recolours it.""" + assert f'fill="{style.ACCENT_MARKER}"' in glyphs.get_glyph("lightbulb").body