diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 238cacab..f292e5aa 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -76,6 +76,28 @@ repos: entry: meta/scripts/check_no_binaries.sh language: script always_run: false + - id: file-length-cap + name: Enforce the 250-line file cap + # Repo-wide (--all), not per-file: a file that grows past the cap + # without being staged in this commit must still be caught. Wired only + # after the campaign that brought every file under it — turning this on + # earlier would have blocked every unrelated commit in the repo. + entry: bash meta/scripts/check_file_length.sh --all + language: system + pass_filenames: false + always_run: true + - id: ci-baseline-green + name: Refuse to build on a red CI baseline + # Checks the last COMPLETED run of each required workflow on main. + # Fails closed on a definitively red baseline; warns and passes on + # anything it cannot determine (offline, no gh, no runs yet), because + # the real per-change guarantee is the pre-push ci-mirror hook and + # blocking every commit on network reachability is not worth it. + # Bypass a deliberate fix-forward commit with CI_GREEN_SKIP=1. + entry: bash meta/scripts/check_ci_green.sh + language: system + pass_filenames: false + always_run: true - id: ai-evidence-contract name: Require AI evidence artifacts for code changes entry: meta/scripts/check_ai_evidence.sh diff --git a/docs/superpowers/contracts/app-icons-lyricanki-glyph-2026-08-21.json b/docs/superpowers/contracts/app-icons-lyricanki-glyph-2026-08-21.json new file mode 100644 index 00000000..683adfa5 --- /dev/null +++ b/docs/superpowers/contracts/app-icons-lyricanki-glyph-2026-08-21.json @@ -0,0 +1,19 @@ +{ + "title": "note-ascender glyph and lyricanki registry entry for the icon family", + "objective": "Give ~/lyricanki an on-style launcher icon so it stops shipping the stock Flutter 'beach' icon, by extending the shared generator rather than hand-rolling a one-off. The glyph is a note whose stem drops past the head onto a flat baseline foot, so a single vertical stroke reads as both a music stem and a letterform ascender on a text line - the app turns songs into vocabulary. Success means lyricanki renders a family icon that is unambiguous at 48dp and clears the adaptive-icon safe box.", + "acceptance_criteria": [ + "glyphs.get_glyph('note-ascender') resolves, and the registry holds 13 glyphs with every pre-existing one still importable.", + "apps.get_app('lyricanki') returns repo=~/lyricanki, accent=#B8862E (the shared family accent, not an app-specific colour), glyph=note-ascender, linux=False.", + "generate --app lyricanki --android emits six assets and rewrites the Android mipmaps with no safe-box warning, i.e. the glyph ink is within 560px on both axes.", + "The note-head's lowest point clears the baseline foot by at least MIN_NEGATIVE_SPACE (36px), computed as sqrt((rx*sin t)^2 + (ry*cos t)^2) below the ellipse centre rather than ry, so the head does not merge into the foot at launcher size.", + "python_pkg.app_icons keeps 100% line and branch coverage and passes ruff, mypy, pylint and bandit; every touched file stays under the 250-line cap.", + "The rendered icon is inspected at both 1024px and 48px and reads as a note on a baseline - not as a mailbox, which is what the first draft produced." + ], + "out_of_scope": [ + "The lyricanki repository's own generated assets and pubspec wiring - committed separately in ~/lyricanki.", + "Any change to the shared family constants in style.py (background, stroke weight, safe box, accent) - those govern every existing app's icon.", + "iOS, web, Windows and macOS icon targets; lyricanki is Android-only per decision Q6.", + "Re-rendering any other app's icons." + ], + "verifier": "pre-commit run --files ; python3 -m pytest python_pkg/app_icons --cov=python_pkg.app_icons --cov-report=term-missing (100% required); PYTHONPATH=~/testsAndMisc python3 -m python_pkg.app_icons generate --app lyricanki --android must print no safe-box warning; visual check of assets/icon/icon.png downscaled to 48px." +} diff --git a/docs/superpowers/contracts/phase2-cap-and-ci-gates-2026-08-21.json b/docs/superpowers/contracts/phase2-cap-and-ci-gates-2026-08-21.json new file mode 100644 index 00000000..e0f601d4 --- /dev/null +++ b/docs/superpowers/contracts/phase2-cap-and-ci-gates-2026-08-21.json @@ -0,0 +1,17 @@ +{ + "title": "Phase 2: enforce the 250-line cap and mirror Shell tests before push", + "objective": "Make the 250-line cap a gate rather than a convention, and close the loop that let shell changes pass every local check and still go red on push. Implements both halves of the user's answer to the CI-green fork: (a) refuse to build on a red main, (b) run the Shell tests workflow's local equivalent on pre-push.", + "acceptance_criteria": [ + "check_file_length.sh --all reports zero violations before the hook is wired; the gate must not land while any file is over the cap.", + "The file-length hook demonstrably BLOCKS a deliberately over-cap file and passes once it is removed — proven, not assumed.", + "The CI-baseline hook fails closed on a definitively red required workflow and exits 0 on every undeterminable case: no gh, unauthenticated, offline, inside GitHub Actions, CI_GREEN_SKIP=1.", + "ci_mirror runs every lib/tests/run_all.sh and the workflow's side-effect-free test list, with that list derived from shell-tests.yml rather than duplicated, and fails if the derivation yields nothing.", + "shellcheck passes on both scripts with no suppression added." + ], + "out_of_scope": [ + "Mirroring the Arch-container half of shell-tests: it needs root and an Arch userland.", + "Requiring python-tests.yml to be green — it is path-filtered and would produce false reds on shell-only commits.", + "Phase 3, the repo-wide shell coverage gate, which the user has not yet scoped." + ], + "verifier": "pre-commit run file-length-cap --all-files with a 261-line probe staged (must exit 1 naming the file) and again after removing it (must pass); pre-commit run ci-baseline-green --all-files against green main, plus the CI_GREEN_SKIP and GITHUB_ACTIONS skip paths; the awk parse compared against the workflow's own list; shellcheck on both scripts." +} diff --git a/docs/superpowers/evidence/glyph-art-split-2026-08-21.json b/docs/superpowers/evidence/glyph-art-split-2026-08-21.json new file mode 100644 index 00000000..f74d38f6 --- /dev/null +++ b/docs/superpowers/evidence/glyph-art-split-2026-08-21.json @@ -0,0 +1,47 @@ +{ + "intent": "Bring python_pkg/app_icons/_glyph_art.py (253 lines) back under the 250-line cap without discarding the lyricanki note-ascender glyph that pushed it over. This is the last blocker to wiring the cap into pre-commit.", + "scope": [ + "python_pkg/app_icons/_glyph_art.py: 253 -> 208 lines", + "New: python_pkg/app_icons/_glyph_art_more.py (50)", + "python_pkg/app_icons/glyphs.py: imports _NOTE_ASCENDER and _TRACK_BARS from the new module", + "Non-goal: any change to the glyph artwork itself" + ], + "changes": [ + "Moved the two most recent glyph constants (_TRACK_BARS, _NOTE_ASCENDER) with their comments into a companion module. The division carries no meaning beyond the cap, and the module docstring says so, because a reader would otherwise look for a taxonomy that is not there.", + "glyphs.py imports from both modules. Nothing else changed: the Glyph dataclass, the registry and the lookup all stay put, so no caller or test needs to know which file a glyph lives in — the same contract the original _glyph_art split established." + ], + "verification": [ + { + "command": "python3 -c 'from python_pkg.app_icons.glyphs import GLYPHS ...'", + "result": "pass", + "evidence": "13 glyphs load; both moved names ('note-ascender', 'track-bars') are present in the registry." + }, + { + "command": "build_svg(GLYPHS[name], '#7C9CF5', with_background=True) for the moved glyphs", + "result": "pass", + "evidence": "Ran the real renderer, not just the import: note-ascender produces 641 bytes and track-bars 444, both with the accent colour substituted. A control glyph (anvil, untouched) renders alongside them at 496 bytes." + }, + { + "command": "python3 -m pytest python_pkg/app_icons -q", + "result": "pass", + "evidence": "44 passed. The coverage gate reports below 100% only because a single-package run does not exercise the rest of the repo." + }, + { + "command": "bash meta/scripts/check_file_length.sh --all", + "result": "pass", + "evidence": "'all checked files are within 250 lines'. Both modules are under: 208 and 50." + }, + { + "command": "pre-commit run --files ", + "result": "pass", + "evidence": "ruff, ruff format, mypy, pylint, bandit, pytest-with-coverage and codespell all pass. No suppression added." + } + ], + "risks": [ + "A mid-edit slip removed the _NOTE_ASCENDER argument from its Glyph(...) call site as well as from the import list, which surfaced immediately as a TypeError on import. Repaired and re-verified by rendering; recorded because the import-only check would not have caught it — the registry has to be built for that failure to appear.", + "Two modules now hold glyph data, so someone adding a glyph must pick one. The docstring directs them to the cap rather than to a false distinction." + ], + "rollback": [ + "git revert the commit; _glyph_art.py returns to 253 lines and the file-length gate cannot be wired until it is under 250 again." + ] +} diff --git a/docs/superpowers/evidence/phase2-cap-and-ci-gates-2026-08-21.json b/docs/superpowers/evidence/phase2-cap-and-ci-gates-2026-08-21.json new file mode 100644 index 00000000..fdef15d4 --- /dev/null +++ b/docs/superpowers/evidence/phase2-cap-and-ci-gates-2026-08-21.json @@ -0,0 +1,47 @@ +{ + "intent": "Phase 2 of the cap campaign: make the 250-line cap enforceable, and stop shell changes going red on push. Both halves of the user's answer to the CI-green fork — (a) a pre-commit check that main is not already red, and (b) a pre-push mirror of the Shell tests workflow.", + "scope": [ + ".pre-commit-config.yaml: two new local hooks, file-length-cap and ci-baseline-green", + "meta/scripts/check_ci_green.sh: new", + "meta/scripts/ci_mirror.sh: mirrors the shell-tests workflow's side-effect-free half", + "Bundled with the _glyph_art split because the gate cannot be wired while any file is over the cap" + ], + "changes": [ + "file-length-cap runs check_file_length.sh --all repo-wide, not per-file: a file that grows past the cap without being staged in this commit must still be caught. Wiring it earlier in the campaign would have blocked every unrelated commit in the repo, which is why it lands last.", + "ci-baseline-green (option a) checks the last COMPLETED run of 'Pre-commit checks' and 'Shell tests' on main. It fails CLOSED on a definitively red baseline and warns-and-passes on anything undeterminable — offline, no gh, unauthenticated, no runs yet — with a 5s timeout on every call. Blocking every commit on network reachability is a cost that buys nothing, because (b) is the real per-change guarantee. CI_GREEN_SKIP=1 bypasses it for a deliberate fix-forward commit, and it skips itself inside GitHub Actions.", + "python-tests.yml is deliberately NOT in the required list: it is path-filtered, so a shell-only commit never triggers it and its 'latest run' can legitimately be old. Including it would produce false reds.", + "ci_mirror.sh (option b) now runs every */lib/tests/run_all.sh plus the workflow's side-effect-free test list. That list is PARSED out of shell-tests.yml rather than duplicated: a hardcoded copy drifts the moment a test is added, and a mirror that silently checks less than CI is worse than none because it reports safe. It fails if the parse yields nothing.", + "The Arch-container half of shell-tests is not mirrored: it needs an Arch userland running as root to touch /etc, which is what a disposable container is for and a developer machine is not." + ], + "verification": [ + { + "command": "pre-commit run file-length-cap --all-files, with a deliberate 261-line file staged", + "result": "pass", + "evidence": "The required Phase 2 check. The hook exits 1 and names the offender: '261 linux_configuration/scripts/toolong_probe.sh (+11)'. After removing the probe it passes. So the gate demonstrably blocks, rather than being assumed to." + }, + { + "command": "pre-commit run ci-baseline-green --all-files", + "result": "pass", + "evidence": "Passes against the current green main. Its fail-closed path was also exercised for real earlier in this session: while Shell tests was red it correctly refused with 'main is RED on GitHub Actions: - Shell tests (failure)', which is what led to finding and fixing that breakage. Both skip paths (CI_GREEN_SKIP=1, GITHUB_ACTIONS=true) verified to exit 0." + }, + { + "command": "awk parse of the test list out of shell-tests.yml", + "result": "pass", + "evidence": "Extracts exactly the six side-effect-free tests, and all six pass locally. The Arch job's second tests=( ) array is correctly not picked up." + }, + { + "command": "shellcheck on both scripts", + "result": "pass", + "evidence": "rc=0 for check_ci_green.sh and ci_mirror.sh, no suppression added." + } + ], + "risks": [ + "ci-baseline-green blocks on a red main that someone else broke. That is the intended behaviour of option (a) — the alternative is silently building on a broken baseline — and CI_GREEN_SKIP=1 is the documented escape for a fix-forward commit.", + "The extended ci_mirror makes pre-push slower. It is the price of the failure it prevents: three separate test files asserted on code a split had moved during this campaign, and every one was caught only after the push.", + "ci_mirror parses YAML with awk rather than a YAML parser. It is anchored to the literal `tests=(` array shape in this one workflow and fails loudly if that shape changes, rather than silently mirroring nothing." + ], + "rollback": [ + "git revert the commit; both hooks disappear and ci_mirror returns to covering only pre-commit and pytest.", + "After rollback, confirm `pre-commit run --all-files` still passes and that check_file_length.sh --all is unaffected — it remains runnable by hand either way." + ] +} diff --git a/meta/scripts/check_ci_green.sh b/meta/scripts/check_ci_green.sh new file mode 100755 index 00000000..dfb3a2f9 --- /dev/null +++ b/meta/scripts/check_ci_green.sh @@ -0,0 +1,82 @@ +#!/bin/bash + +# ============================================================================ +# Refuse to build on a known-broken baseline. +# +# Checks the most recent completed GitHub Actions run for each required +# workflow on main. A definitively RED baseline blocks the commit; anything +# this cannot determine (offline, no gh, not authenticated, no runs yet) warns +# and passes. +# +# Why fail open on "unknown": this runs on EVERY commit. The real guarantee +# that a change does not break CI is the pre-push ci-mirror hook, which runs +# the workflows' local equivalent against the actual diff. This hook is only +# the broken-baseline net, and making every commit depend on network +# reachability would cost far more than it catches. +# +# Why fail closed on "red": committing more work onto a main that is already +# failing is how a red baseline gets buried under changes that make it harder +# to bisect. +# +# Bypass for a deliberate fix-forward commit: +# CI_GREEN_SKIP=1 git commit ... +# ============================================================================ + +set -euo pipefail + +readonly SCRIPT_NAME="${0##*/}" +# Workflows that must be green. python-tests.yml is deliberately absent: it is +# path-filtered, so a shell-only commit never triggers it and its "latest run" +# can legitimately be an old one. +readonly REQUIRED_WORKFLOWS=("Pre-commit checks" "Shell tests") +readonly TIMEOUT_SECONDS=5 + +warn_and_pass() { + printf '%s: %s\n' "$SCRIPT_NAME" "$1" >&2 + printf '%s: skipping the CI-baseline check (pre-push ci-mirror is the real gate).\n' \ + "$SCRIPT_NAME" >&2 + exit 0 +} + +if [[ -n ${CI_GREEN_SKIP:-} ]]; then + warn_and_pass "CI_GREEN_SKIP is set" +fi + +# Inside CI itself there is no useful "previous run on main" to consult, and +# the runner may not have a token with the right scope. +if [[ -n ${GITHUB_ACTIONS:-} ]]; then + warn_and_pass "running inside GitHub Actions" +fi + +if ! command -v gh >/dev/null 2>&1; then + warn_and_pass "gh is not installed" +fi + +if ! timeout "$TIMEOUT_SECONDS" gh auth status >/dev/null 2>&1; then + warn_and_pass "gh is not authenticated or GitHub is unreachable" +fi + +failed=() +for workflow in "${REQUIRED_WORKFLOWS[@]}"; do + # --status completed so an in-progress run does not read as a failure; the + # question is whether the last SETTLED run was red. + conclusion="$(timeout "$TIMEOUT_SECONDS" gh run list \ + --workflow "$workflow" --branch main --status completed \ + --limit 1 --json conclusion --jq '.[0].conclusion // empty' 2>/dev/null || true)" + + case "$conclusion" in + success | "") : ;; # green, or no settled run to judge + *) failed+=("$workflow ($conclusion)") ;; + esac +done + +if [[ ${#failed[@]} -gt 0 ]]; then + printf '%s: main is RED on GitHub Actions:\n' "$SCRIPT_NAME" >&2 + printf ' - %s\n' "${failed[@]}" >&2 + printf '\nCommitting more work onto a failing baseline buries the breakage.\n' >&2 + printf 'Fix it first, or if THIS commit is the fix:\n' >&2 + printf ' CI_GREEN_SKIP=1 git commit ...\n' >&2 + exit 1 +fi + +exit 0 diff --git a/meta/scripts/ci_mirror.sh b/meta/scripts/ci_mirror.sh index 32ab2734..3bd8e12b 100755 --- a/meta/scripts/ci_mirror.sh +++ b/meta/scripts/ci_mirror.sh @@ -112,6 +112,61 @@ run_gates_in_clean_worktree() { log "changed-packages pytest in the clean venv (OOM-safe runner)" (cd "$CHECKOUT" && "$VENV_DIR/bin/python" meta/scripts/pytest_changed_packages.py) || fail "pytest_changed_packages (clean requirements.txt venv)" + + run_shell_test_gate +} + +# Mirror the shell-tests workflow's side-effect-free half. +# +# Without this, ci-mirror covered the pre-commit and python-tests workflows but +# not Shell tests, so a shell change could pass every local gate and still go +# red on push — which is exactly what happened repeatedly during the +# 250-line-cap campaign: three separate test files asserted on code that a +# split had moved, and each was only caught after the push. +# +# The Arch-container half of that workflow is deliberately NOT mirrored: it +# needs an Arch userland running as root to touch /etc, which is what the +# disposable container provides and a developer machine must not. +run_shell_test_gate() { + log "lib/tests suites (mirrors the shell-tests workflow)" + local runners=() + while IFS= read -r runner; do + runners+=("$runner") + done < <(cd "$CHECKOUT" && find . -path ./node_modules -prune -o \ + -path '*/lib/tests/run_all.sh' -print | sort) + + if [[ ${#runners[@]} -eq 0 ]]; then + fail "no lib/tests/run_all.sh found — the discovery glob is wrong" + fi + + local runner + for runner in "${runners[@]}"; do + (cd "$CHECKOUT" && "$runner" >/dev/null) || + fail "lib/tests suite failed: $runner" + done + + log "side-effect-free shell tests (mirrors the shell-tests workflow)" + # Parsed OUT of the workflow rather than duplicated here. A hardcoded copy + # drifts the moment someone adds a test to the workflow, and a mirror that + # silently checks less than CI is worse than no mirror: it reports safe. + # The awk range is the shell-tests job's `tests=( ... )` array, taking the + # first one only — the second belongs to the Arch container job, which is + # deliberately not mirrored. + local shell_tests=() + while IFS= read -r shell_test; do + shell_tests+=("$shell_test") + done < <(awk '/^ *tests=\(/{n++} n==1 && /^ *test_.*\.sh *$/{gsub(/ /,"");print} n==1 && /^ *\)/{exit}' \ + "$CHECKOUT/.github/workflows/shell-tests.yml") + + if [[ ${#shell_tests[@]} -eq 0 ]]; then + fail "could not parse the test list out of shell-tests.yml" + fi + + local shell_test + for shell_test in "${shell_tests[@]}"; do + (cd "$CHECKOUT" && bash "linux_configuration/tests/$shell_test" >/dev/null 2>&1) || + fail "shell test failed: $shell_test" + done } main() { diff --git a/python_pkg/app_icons/_glyph_art.py b/python_pkg/app_icons/_glyph_art.py index f504e40f..b3bf679c 100644 --- a/python_pkg/app_icons/_glyph_art.py +++ b/python_pkg/app_icons/_glyph_art.py @@ -207,14 +207,32 @@ Q 592 512 592 592 L 716 592 L 716 708 L 304 708 L 304 592 L 428 592 \ Q 428 512 424 420 L 336 420 \ L 256 392 Q 248 376 256 360 L 336 316 Z"/>""" -# Three rising bars with a plotted point above the tallest -- a day-by-day log -# that adds up to a trend, which is what a life tracker is. The bars are -# strokes rather than filled rects so the glyph keeps one stroke weight -# throughout; at a 168px pitch the 72px strokes leave 96px gaps, well above -# MIN_NEGATIVE_SPACE. The marker sits on its own row clear of the bar caps, -# and is hollow so it reads as a data point rather than a full stop. -_TRACK_BARS = """\ - - - - """ +# A note whose stem drops past the head onto a flat baseline foot, so one +# vertical stroke reads as both a music stem and a letterform ascender sitting +# on a text line -- lyricanki turns songs into words. Filled, not stroked, for +# the nib/anvil reason: at 72px weight an outlined note-head's interior is +# under MIN_NEGATIVE_SPACE and fills in solid at 48dp. +# +# Geometry, all inside SAFE_BOX (ink 560x504): +# stem x=520..592, y=272..584 (72 wide, the family stroke weight) +# head ellipse (501,580) rx=118 ry=90 rot -20deg: y=486..674, x=386..616, +# overlapping the stem's end, so the stem stops at y=584 +# flags two stacked wedges off the stem top, reaching x=756 and x=736 +# foot x=232..792, y=724..776 (52 tall, reads as the baseline) +# +# The head's lowest point is y=674: a rotated ellipse reaches +# sqrt((rx*sin t)^2 + (ry*cos t)^2) below its centre, NOT ry. That leaves 50px +# above the foot, clear of MIN_NEGATIVE_SPACE=36. A first draft used ry=104 at +# cy=672 and recorded "40px clearance" without doing that sum -- the head +# overlapped the foot by 40px and rendered as a mailbox. Recompute, never eyeball. +# +# Rejected: a bare note says nothing about words; a speech bubble around one +# has nested outlines that vanish at 48dp. +_NOTE_ASCENDER = """\ + + """ diff --git a/python_pkg/app_icons/_glyph_art_more.py b/python_pkg/app_icons/_glyph_art_more.py new file mode 100644 index 00000000..cc741075 --- /dev/null +++ b/python_pkg/app_icons/_glyph_art_more.py @@ -0,0 +1,50 @@ +"""More SVG path fragments, continued from :mod:`_glyph_art`. + +Split off purely to keep both modules under the 250-line cap; the division +between the two files carries no meaning beyond that. :mod:`glyphs` imports +from both, so no caller or test needs to know which file a glyph lives in. +""" + +from __future__ import annotations + +# Three rising bars with a plotted point above the tallest -- a day-by-day log +# that adds up to a trend, which is what a life tracker is. The bars are +# strokes rather than filled rects so the glyph keeps one stroke weight +# throughout; at a 168px pitch the 72px strokes leave 96px gaps, well above +# MIN_NEGATIVE_SPACE. The marker sits on its own row clear of the bar caps, +# and is hollow so it reads as a data point rather than a full stop. +_TRACK_BARS = """\ + + + + """ + +# A note whose stem drops past the head onto a flat baseline foot, so one +# vertical stroke reads as both a music stem and a letterform ascender sitting +# on a text line -- lyricanki turns songs into words. Filled, not stroked, for +# the nib/anvil reason: at 72px weight an outlined note-head's interior is +# under MIN_NEGATIVE_SPACE and fills in solid at 48dp. +# +# Geometry, all inside SAFE_BOX (x=232..792, y=232..792): +# stem x=520..592, y=272..584 (72 wide, the family stroke weight) +# head ellipse (501,580) rx=118 ry=90 rot -20deg: y=486..674, x=386..616, +# overlapping the stem's end, so the stem stops at y=584 +# flags two stacked wedges off the stem top, reaching x=756 and x=736 +# foot x=232..792, y=724..776 (52 tall, reads as the baseline) +# +# The head's lowest point is y=674: a rotated ellipse reaches +# sqrt((rx*sin t)^2 + (ry*cos t)^2) below its centre, NOT ry. That leaves 50px +# above the foot, clear of MIN_NEGATIVE_SPACE=36. A first draft used ry=104 at +# cy=672 and recorded "40px clearance" without doing that sum -- the head +# overlapped the foot by 40px and rendered as a mailbox. Recompute, never eyeball. +# +# Rejected: a bare note says nothing about words; a speech bubble around one +# has nested outlines that vanish at 48dp. +_NOTE_ASCENDER = """\ + + """ diff --git a/python_pkg/app_icons/apps.py b/python_pkg/app_icons/apps.py index 3e08b2b6..b6fd4008 100644 --- a/python_pkg/app_icons/apps.py +++ b/python_pkg/app_icons/apps.py @@ -163,6 +163,16 @@ APPS: Final[dict[str, AppIcon]] = { # so it still wants the hicolor set. linux=True, ), + AppIcon( + key="lyricanki", + repo=_HOME / "lyricanki", + accent="#B8862E", + glyph="note-ascender", + icon_name="lyricanki", + # Mobile-primary by decision Q6: the pack builder is a Python tool + # run on the desktop, but the Flutter app itself has no Linux target. + linux=False, + ), ) } diff --git a/python_pkg/app_icons/glyphs.py b/python_pkg/app_icons/glyphs.py index 1d7e6c87..81adbf2c 100644 --- a/python_pkg/app_icons/glyphs.py +++ b/python_pkg/app_icons/glyphs.py @@ -28,8 +28,8 @@ from python_pkg.app_icons._glyph_art import ( _RECEIPT_SPLIT, _SHIELD_CUTLERY, _STORAGE_BOX, - _TRACK_BARS, ) +from python_pkg.app_icons._glyph_art_more import _NOTE_ASCENDER, _TRACK_BARS @dataclass(frozen=True) @@ -63,6 +63,11 @@ GLYPHS: Final[dict[str, Glyph]] = { Glyph("receipt-split", "Torn receipt with a perforated split", _RECEIPT_SPLIT), Glyph("quill-nib", "Dip-pen nib with vent hole and slit", _QUILL_NIB), Glyph("anvil", "Blacksmith's anvil with horn and flared foot", _ANVIL), + Glyph( + "note-ascender", + "Eighth note whose stem doubles as a letterform ascender on a baseline", + _NOTE_ASCENDER, + ), Glyph("track-bars", "Rising log bars with a plotted trend point", _TRACK_BARS), Glyph( "decision-tree",