app_icons: tutor gets a lightbulb; generate --web writes PWA icons
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (mypy, mypy) (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (pylint, pylint) (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (rest, ) (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (shellcheck, shellcheck) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / lib-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
Python tests / test (push) Has been cancelled

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YADHKXzZhhePzQ2rNoXFcK
This commit is contained in:
Krzysztof Rudnicki 2026-10-12 17:18:05 +02:00
parent 66160cd6da
commit 1c50cfb028
12 changed files with 243 additions and 15 deletions

View File

@ -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"
}

View File

@ -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"
]
}

View File

@ -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

View File

@ -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"

View File

@ -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 = """\
<path fill="{{ACCENT}}" d="M 448 556 C 448 510 362 500 362 442 \
A 150 150 0 0 1 662 442 C 662 500 576 510 576 556 Z"/>
<path d="M 440 668 L 584 668"/>
<path d="M 480 780 L 544 780"/>"""

View File

@ -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,
),
)
}

View File

@ -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,
)

View File

@ -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.

View File

@ -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),
)
}

View File

@ -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):

View File

@ -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"

View File

@ -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