mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 19:07:04 +01:00
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
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:
parent
66160cd6da
commit
1c50cfb028
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
]
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
26
python_pkg/app_icons/_glyph_art_extra.py
Normal file
26
python_pkg/app_icons/_glyph_art_extra.py
Normal 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"/>"""
|
||||
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user