diff --git a/docs/superpowers/contracts/app-icons-daily-limits-hourglass-2026-10-09.json b/docs/superpowers/contracts/app-icons-daily-limits-hourglass-2026-10-09.json
new file mode 100644
index 00000000..9890e993
--- /dev/null
+++ b/docs/superpowers/contracts/app-icons-daily-limits-hourglass-2026-10-09.json
@@ -0,0 +1,15 @@
+{
+ "title": "app_icons: daily_limits gets the hourglass glyph in the shared family",
+ "objective": "The daily-limits phone app ships a family launcher icon (charcoal #211D1B field, shared accent #B8862E) instead of the stock Flutter icon, with no change to any other app's icon.",
+ "acceptance_criteria": [
+ "`generate --app daily_limits --android` writes assets/icon and the Android mipmaps for ~/src/daily-limits/app",
+ "The registry keeps one accent (#B8862E) and one field (style.BACKGROUND) for every app",
+ "The diff to python_pkg/app_icons is additive only, so every other app renders byte-identically",
+ "python_pkg/app_icons tests pass at 100% line and branch coverage; pre-commit is clean"
+ ],
+ "out_of_scope": [
+ "A per-app background or accent override (decided 2026-10-09: the generator's shared colours are right)",
+ "Committing the daily-limits app itself"
+ ],
+ "verifier": ".venv/bin/python -m pytest -q python_pkg/app_icons --cov=python_pkg/app_icons --cov-branch; pre-commit run"
+}
diff --git a/docs/superpowers/evidence/app-icons-daily-limits-hourglass-2026-10-09.json b/docs/superpowers/evidence/app-icons-daily-limits-hourglass-2026-10-09.json
new file mode 100644
index 00000000..100ebaf1
--- /dev/null
+++ b/docs/superpowers/evidence/app-icons-daily-limits-hourglass-2026-10-09.json
@@ -0,0 +1,41 @@
+{
+ "intent": "Give the daily-limits phone app an on-style launcher icon: an hourglass (a daily allowance that runs out) in the shared accent on the family charcoal.",
+ "scope": [
+ "python_pkg/app_icons/_glyph_art_more.py, glyphs.py: the `hourglass` glyph",
+ "python_pkg/app_icons/apps.py: the `daily_limits` registry entry",
+ "python_pkg/app_icons/tests/test_registry.py: no glyph reused across apps; hourglass sand takes the accent marker"
+ ],
+ "changes": [
+ "New `hourglass` glyph: capped bars, crossed sides, sand filled in the accent so the monochrome layer recolours it",
+ "daily_limits registered with accent #B8862E and the default style.BACKGROUND field",
+ "Invariant test: apps are told apart by glyph, so a new app may not reuse one (track-bars is the pre-existing exception)"
+ ],
+ "verification": [
+ {
+ "command": "PATH=~/sdk/fvm/versions/3.47.7/bin:$PATH python3 -m python_pkg.app_icons generate --app daily_limits --android",
+ "result": "pass",
+ "evidence": "daily_limits: wrote 6 asset files; regenerated Android mipmaps; ic_launcher_background #211D1B"
+ },
+ {
+ "command": "phone_deploy.sh ~/src/daily-limits/app (adb install -r on 23181JEGR08034)",
+ "result": "pass",
+ "evidence": "Install Success; the APK's mipmap-xxxhdpi ic_launcher.png is byte-identical to the generated one (hourglass in #B8862E on #211D1B); confirmed by kuhy"
+ },
+ {
+ "command": ".venv/bin/python -m pytest -q python_pkg/app_icons --cov=python_pkg/app_icons --cov-branch",
+ "result": "pass",
+ "evidence": "47 passed; every python_pkg/app_icons module 100% line and branch"
+ },
+ {
+ "command": "git diff python_pkg/app_icons | grep -c '^-[^-]'",
+ "result": "pass",
+ "evidence": "0 removed lines: renderer and other registry entries untouched, so other apps' icons are byte-identical"
+ }
+ ],
+ "risks": [
+ "`generate` runs `dart run`, which needs a Dart SDK satisfying the app's ^3.13.5; the system dart 3.13.3 fails, so put the fvm 3.47.7 SDK first on PATH"
+ ],
+ "rollback": [
+ "git revert this commit; daily_limits disappears from the registry and the glyph library"
+ ]
+}
diff --git a/docs/superpowers/evidence/hosts-guard-rollback-fresh-install-2026-08-24.json b/docs/superpowers/evidence/hosts-guard-rollback-fresh-install-2026-08-24.json
deleted file mode 100644
index 0e386a08..00000000
--- a/docs/superpowers/evidence/hosts-guard-rollback-fresh-install-2026-08-24.json
+++ /dev/null
@@ -1,47 +0,0 @@
-{
- "intent": "Give a freshly installed machine a working escape hatch from the hosts file-guards. Until now --rollback refused to run on exactly the machines the previous commit created.",
- "scope": [
- "linux_configuration/fixes/lib/hosts_guard_rollback.sh only.",
- "Follow-up to 4c426eae, which made hosts-blocker's install.sh register the guard-lib instances directly.",
- "Non-goal: changing what the migration path records; a migrated machine keeps its existing legacy backup and restore behaviour unchanged."
- ],
- "changes": [
- "do_rollback no longer exits when /var/lib/guard-lib-migration is absent. That state dir is written by the one-shot migration, but a machine set up by hosts-blocker's install.sh has no legacy layer to back up and therefore no state dir -- so --rollback bailed out while the very instances it exists to remove were live. It now proceeds whenever a guard-lib instance is registered, warns that there is no legacy layer to restore, and still refuses when there is genuinely nothing to roll back.",
- "After uninstalling the instances, re-assert chattr +i on /etc/hosts when no legacy unit did it. The existing re-assertion relied on starting hosts-guard.service / hosts-bind-mount.service, which do not exist on a fresh machine, so every systemctl cat skipped and the rollback left /etc/hosts with no immutable flag and no bind mount -- strictly less protected than before the rollback."
- ],
- "verification": [
- {
- "command": "sudo bash linux_configuration/fixes/migrate_hosts_guard_to_guard_lib.sh --rollback --dry-run (pristine vmbox guest, guards installed by install_core_system.sh --all)",
- "result": "pass",
- "evidence": "DRYRUN_RC=0, reporting it would uninstall the hosts, nsswitch and resolved instances. Before this change the same command printed 'no rollback state at /var/lib/guard-lib-migration - nothing to roll back to' and exited 1."
- },
- {
- "command": "sudo bash linux_configuration/fixes/migrate_hosts_guard_to_guard_lib.sh --rollback (same guest)",
- "result": "pass",
- "evidence": "ROLLBACK_RC=0. Uninstalled file-guard hosts, nsswitch and resolved, then logged 're-asserted chattr +i on /etc/hosts (no legacy unit to do it)'."
- },
- {
- "command": "ls /etc/guard-lib/targets; lsattr /etc/hosts; findmnt /etc/hosts; wc -l /etc/hosts (after rollback)",
- "result": "pass",
- "evidence": "Only shutdown-schedule.conf remains, which belongs to a different feature and must not be touched. /etc/hosts shows ----i----------------- and still holds all 178410 lines; the bind mount is gone, which is the intended post-rollback state."
- },
- {
- "command": "shellcheck -x linux_configuration/fixes/lib/hosts_guard_rollback.sh",
- "result": "pass",
- "evidence": "Clean. An initial A && B || C form was rewritten as if/else after SC2015 pointed out the C branch can run when A succeeds, which matters because `run` is a no-op under --dry-run."
- },
- {
- "command": "bash ./linux_configuration/fixes/lib/tests/run_all.sh",
- "result": "pass",
- "evidence": "hosts_guard_rollback: 31 passed, 0 failed; whole suite exits 0. The first attempt failed one case: the refusal message is asserted verbatim by test_hosts_guard_rollback.sh:115, and the new no-instance branch had dropped the trailing 'to'. The wording was restored rather than the assertion loosened, since the test is checking the user-visible explanation."
- }
- ],
- "risks": [
- "Rollback is now reachable in more situations, so an accidental invocation can unregister live guards. It still refuses when no instance is registered, and it keeps the canonical copy via --keep-canonical, so the file itself is never lost.",
- "The re-assertion only covers /etc/hosts. nsswitch.conf and resolved.conf are left to their plugins, matching the behaviour the migration path already had."
- ],
- "rollback": [
- "git revert this commit; the previous behaviour (refuse without a state dir) returns.",
- "After reverting, confirm with sudo bash linux_configuration/fixes/migrate_hosts_guard_to_guard_lib.sh --status that the instance list matches expectations before relying on it."
- ]
-}
diff --git a/python_pkg/app_icons/_glyph_art_more.py b/python_pkg/app_icons/_glyph_art_more.py
index 06379c35..2e715750 100644
--- a/python_pkg/app_icons/_glyph_art_more.py
+++ b/python_pkg/app_icons/_glyph_art_more.py
@@ -207,3 +207,25 @@ _OPEN_BOOK = """\
"""
+
+
+# An hourglass with sand still in the top bulb -- daily-limits, today's PC
+# shutdown time and gaming budget: a daily allowance that runs out. Chosen over
+# a clock-with-a-cap because `clock` is already wake_alarm_sync's glyph.
+#
+# Geometry, inside SAFE_BOX (ink x=284..740, y=236..788):
+# caps bars at y=272 and y=752, x=320..704.
+# glass straight sides (352,272)->(512,512)->(352,752) and mirror; the
+# sides cross at the neck, so the waist is solid, not a sliver.
+# sand filled to the glass centrelines, so it fuses with the outline:
+# top bulb from the neck up to y=392, leaving an empty band 84 tall
+# and 73+ wide below the cap; bottom bulb a pile up to y=680. Every
+# gap clears MIN_NEGATIVE_SPACE=36.
+_HOURGLASS = """\
+
+
+
+
+ """
diff --git a/python_pkg/app_icons/apps.py b/python_pkg/app_icons/apps.py
index e85d3e7b..a2594d20 100644
--- a/python_pkg/app_icons/apps.py
+++ b/python_pkg/app_icons/apps.py
@@ -211,6 +211,14 @@ APPS: Final[dict[str, AppIcon]] = {
icon_name="book-guard",
linux=False,
),
+ AppIcon(
+ key="daily_limits",
+ repo=_HOME / "src/daily-limits/app",
+ accent="#B8862E",
+ glyph="hourglass",
+ icon_name="daily-limits",
+ linux=False,
+ ),
)
}
diff --git a/python_pkg/app_icons/glyphs.py b/python_pkg/app_icons/glyphs.py
index 80aa4b56..e1c86205 100644
--- a/python_pkg/app_icons/glyphs.py
+++ b/python_pkg/app_icons/glyphs.py
@@ -31,6 +31,7 @@ from python_pkg.app_icons._glyph_art import (
)
from python_pkg.app_icons._glyph_art_more import (
_BOWL_STAR,
+ _HOURGLASS,
_HOUSE_TICK,
_NOTE_ASCENDER,
_OPEN_BOOK,
@@ -94,6 +95,7 @@ GLYPHS: Final[dict[str, Glyph]] = {
Glyph("house-tick", "House outline with a tick inside", _HOUSE_TICK),
Glyph("umbrella", "Filled umbrella canopy over a J-handle", _UMBRELLA),
Glyph("open-book", "Open book, two filled pages", _OPEN_BOOK),
+ Glyph("hourglass", "Capped hourglass with sand still in the top", _HOURGLASS),
Glyph(
"remote-wave",
"Handset with punched buttons under a broadcast arc",
diff --git a/python_pkg/app_icons/tests/test_registry.py b/python_pkg/app_icons/tests/test_registry.py
index b21b8cc5..9ea3df65 100644
--- a/python_pkg/app_icons/tests/test_registry.py
+++ b/python_pkg/app_icons/tests/test_registry.py
@@ -45,6 +45,13 @@ class TestFamilyInvariants:
assert len(app.accent) == 7
int(app.accent[1:], 16)
+ def test_no_glyph_is_reused(self) -> None:
+ """Apps are told apart by glyph; a new app must not borrow one."""
+ shared_by_design = {"track-bars"} # kuhylog + betting_sim, pre-existing
+ used = [app.glyph for app in apps.APPS.values()]
+ reused = {glyph for glyph in used if used.count(glyph) > 1}
+ assert reused <= shared_by_design
+
def test_safe_box_fits_inside_canvas(self) -> None:
assert style.SAFE_BOX < style.CANVAS
assert style.MIN_NEGATIVE_SPACE == style.STROKE_WIDTH / 2
@@ -64,3 +71,14 @@ class TestFamilyInvariants:
f"{glyph.name} paints style.BACKGROUND; cut the hole with "
f'fill-rule="evenodd" instead'
)
+
+
+class TestHourglassGlyph:
+ def test_daily_limits_uses_it(self) -> None:
+ assert apps.get_app("daily_limits").glyph == "hourglass"
+
+ def test_sand_is_filled_in_the_accent(self) -> None:
+ """The sand takes the accent marker, so the monochrome layer recolours it."""
+ body = glyphs.get_glyph("hourglass").body
+ assert f'fill="{style.ACCENT_MARKER}"' in body
+ assert 'stroke="none"' in body