Skip to content

38 โ€” The Theme Library

Scope. The backend half of theming: the one subtree it owns outright, the notification channel that carries two scopes in one argument, why deleting a bundled theme needs a tombstone, and which tags are stored versus derived. The card's token system and authoring UI are under frontend/.

themes/manager.py::ThemeManager is one of the three subsystems constructed without a reference to the manager (33 ยง1) โ€” it receives the root data dict and reads and writes its own subtree in place, with no storage handle of its own. That narrow construction is why it also owns its own callback list rather than borrowing the manager's.


1. One callback, two scopes, distinguished by nullability

Subscribers register through themes/manager.py::register_update_callback with a callable taking a single vacuum_entity_id argument, and the argument's nullability is the scope:

value means examples
None a library-wide mutation rename, delete, import
a vacuum id a per-vacuum mutation save-as-new, set-active, update-draft, revert

One channel serving both avoids the alternative โ€” two lists, two registrations, and a subscriber that must remember to join both to stay correct. The cost is that every subscriber has to handle None, which is visible in the signature rather than buried in a convention.

The sensor platform uses this to push state writes rather than poll.


2. Preloaded themes are re-seeded on every construction, so a delete needs a tombstone

themes/preloaded.py::ensure_preloaded_theme_library runs at construction and adds every built-in spec id not currently in the library. That is what makes a new built-in theme appear on upgrade without a migration.

It is also what made deleting one impossible. A user removing a bundled theme saw it go โ€” and it came back on the next restart, because the seeder could not distinguish never installed from deliberately removed.

themes/manager.py::delete_theme therefore records a tombstone for a bundled theme, which the seeder consults. The general shape is worth carrying: any seeder that fills gaps needs to know the difference between a gap and a decision, and absence alone cannot carry that.

A user-created theme needs no tombstone โ€” nothing would re-add it.


3. Stored tags and derived tags

Only the user-owned vibe tags live in the library entry. The facet tags โ€” mode, accent and the rest โ€” along with colorblind-safety, are derived from the palette and verified by the card, and never stored at all.

That split is a security property as much as a design one: a derived facet cannot be hand-set or spoofed by writing it into storage, because nothing reads it from there. A theme claiming to be colorblind-safe is checked against its own colours every time it is displayed, not trusted from a field somebody could set.


4. The cleaner is format-only, and the vocabulary lives in the card

themes/manager.py::_clean_theme_tags trims, lowercases, de-duplicates and caps. It does not strip system words like dark or core.

That is deliberate: the system vocabulary lives in the card, so stripping here would put a second copy of it in the backend, and the two would drift. A stored system word is simply ignored at display time rather than rejected at write time โ€” which keeps the vocabulary single-sourced and means adding a system word does not require a migration over stored tags.

The general rule: when a vocabulary belongs to the display layer, validate format at the boundary and let meaning be resolved where the vocabulary lives.


5. The cleaner discards silently and still reports success

_clean_theme_tags drops a tag that is empty, duplicate, or longer than the length limit, and used to stop entirely once the count limit was reached. themes/manager.py::set_theme_tags then returned a success response that said nothing about any of it.

A caller submitting twenty tags and a caller submitting sixteen got byte-identical responses. There was no field for what was dropped, so the card could not tell the user their last four tags did not save, and an automation could not detect it at all. This is the shape 36 ยง3 names as the bug in its three-way choice: a mutation either refuses with a reason or succeeds carrying what it applied, and quietly applying a subset is the third thing.

The limits stay โ€” they exist to keep stored entries small and clean, and they are defensible. What was missing was the report, so the response now carries it: themes/manager.py::_clean_theme_tags_with_report returns the dropped tags alongside the kept ones, and set_theme_tags publishes tags (what was applied) plus dropped_tags, each entry naming the offending value and a reason โ€” limit_reached, too_long, duplicate, empty. Four different things to tell a user, so they stay four different slugs.

Two details are deliberate. The reasons are slugs, not prose: the caller formats them, so this adds no user-facing string and no new key to the eighteen locale files. And dropped_tags is absent rather than empty on a clean submission, so its presence is a reliable signal โ€” a field that is always there and usually empty gets read as noise and stops being checked. The cleaner also no longer stops at the cap; it keeps walking so it can report the remainder, which is the whole point of you sent twenty and sixteen were kept.

The related phantom-draft finding on this subsystem has been closed, and closed in the right place: per-vacuum draft state used to be created for any well-formed entity id, so a theme service would return success for a vacuum that did not exist and persist a record nothing could reach. themes/services.py now calls services/_common.py::require_managed_vacuum at six sites. The accessor still creates defaults when absent โ€” correctly, since the guard belongs at the boundary rather than in every reader.

The invariant block in this module is explicit that its entries record which rule a finding produced and say nothing about whether it is fixed. Both findings above were re-checked against the code rather than read off that list.


6. Common wrong assumptions

assumption reality
theme callbacks need a vacuum None is the library-wide scope and every subscriber must handle it โ€” ยง1
deleting a built-in theme removes it without a tombstone the seeder re-adds it on the next restart โ€” ยง2
a theme's facet tags are stored they are derived from the palette every time and cannot be spoofed โ€” ยง3
the backend validates tag vocabulary it validates format only; meaning is resolved in the card โ€” ยง4
a successful tag write saved every tag over-limit and duplicate tags are dropped silently โ€” ยง5
the invariant block's entries are closed findings it says outright that it records rules, not closure โ€” ยง5

Registries

00b-invariants.md ยท 00c-replicas.md