16 commits. dev head 15d2e0c, CI green (run 3122, all six jobs including the integration lane running 0001 -> 0074 on pgvector/pg17).
Two bug fixes worth deploying on their own
293a143 — reminder emails and admin log filters were crashing. Both compared a timestamptz column against a raw request string; asyncpg binds str as VARCHAR and Postgres has no timestamptz >= text operator. Fixed in notifications.py and logging.py, with a source-inspection test that catches both shapes (#1727, #2257).
Milestone #251's design explorer (61e6e38, 3c0192d, d3ee24f, 4ca3ab0) — the /design page: live token inventory, a component gallery that marks what isn't there rather than mocking it up, and a rulebook-drift panel.
Milestone #254 — the design system becomes a record
A design system is a named set of tokens with an optional parent. A system with no parent is a family; one with a parent holds only what it changes, so "what does this app alter?" is a plain list rather than a diff. Resolution walks the chain, deepest wins by name — that is the CSS cascade, not an analogy to it.
03b3998
model + migration + the write-time cycle guard
839d690
resolution — provenance stored as the contest, not the verdict
143b968
REST + MCP surfaces at parity
0937b17
the editing UI: overrides, effective set, per-mode provenance
3da40ab
supersedes — declare what to write instead of, not what to ban
4dc57f8
import a design system out of a rulebook's prose
b0a7d9e
the master CSS sheet a system generates
46d88f9
check snippets against the sheet they claim to use
0f80b79
central prose: guidance on systems, rationale on tokens
New migrations
0072 (design_systems, design_tokens, projects.design_system_id), 0073 (supersedes), 0074 (guidance + rationale). All applied on the full chain in the integration lane.
Safe to deploy without doing anything
Rule #115 holds throughout: an install with zero design systems is entirely normal, not degraded. Nothing seeds one, nothing implies a default, and the project picker hides itself when none exist. The /design-systems page renders an empty state that reads as ordinary.
What this makes checkable
var(--x) where no such token exists renders as nothing at all — no error, no failing test, no visual clue. That is not hypothetical: --color-accent was used throughout a new view in this very branch, does not exist, and was caught by neither vue-tsc nor CI nor the browser. The snippet check exists because of it.
Known and filed, not fixed
#2293 — services/backup.py enumerates its tables by hand and has drifted. Five are absent from export and restore: systems, record_systems, note_usage_events, and both new design tables. A restore reports success and silently returns less than it was given. Four of the five predate this branch.
16 commits. `dev` head `15d2e0c`, CI green (run 3122, all six jobs including the integration lane running `0001 -> 0074` on `pgvector/pg17`).
## Two bug fixes worth deploying on their own
- `293a143` — **reminder emails and admin log filters were crashing.** Both compared a `timestamptz` column against a raw request string; asyncpg binds `str` as VARCHAR and Postgres has no `timestamptz >= text` operator. Fixed in `notifications.py` and `logging.py`, with a source-inspection test that catches both shapes (#1727, #2257).
- Milestone #251's design explorer (`61e6e38`, `3c0192d`, `d3ee24f`, `4ca3ab0`) — the `/design` page: live token inventory, a component gallery that marks what *isn't* there rather than mocking it up, and a rulebook-drift panel.
## Milestone #254 — the design system becomes a record
A design system is a named set of tokens with an optional parent. A system with no parent is a family; one with a parent holds **only what it changes**, so "what does this app alter?" is a plain list rather than a diff. Resolution walks the chain, deepest wins by name — that is the CSS cascade, not an analogy to it.
| | |
|---|---|
| `03b3998` | model + migration + the write-time cycle guard |
| `839d690` | resolution — provenance stored as the contest, not the verdict |
| `143b968` | REST + MCP surfaces at parity |
| `0937b17` | the editing UI: overrides, effective set, per-mode provenance |
| `3da40ab` | `supersedes` — declare what to write *instead of*, not what to ban |
| `4dc57f8` | import a design system out of a rulebook's prose |
| `b0a7d9e` | the master CSS sheet a system generates |
| `46d88f9` | check snippets against the sheet they claim to use |
| `0f80b79` | central prose: `guidance` on systems, `rationale` on tokens |
## New migrations
`0072` (design_systems, design_tokens, projects.design_system_id), `0073` (supersedes), `0074` (guidance + rationale). All applied on the full chain in the integration lane.
## Safe to deploy without doing anything
Rule #115 holds throughout: an install with **zero** design systems is entirely normal, not degraded. Nothing seeds one, nothing implies a default, and the project picker hides itself when none exist. The `/design-systems` page renders an empty state that reads as ordinary.
## What this makes checkable
`var(--x)` where no such token exists renders as **nothing at all** — no error, no failing test, no visual clue. That is not hypothetical: `--color-accent` was used throughout a new view in this very branch, does not exist, and was caught by neither `vue-tsc` nor CI nor the browser. The snippet check exists because of it.
## Known and filed, not fixed
**#2293** — `services/backup.py` enumerates its tables by hand and has drifted. Five are absent from export *and* restore: `systems`, `record_systems`, `note_usage_events`, and both new design tables. A restore reports success and silently returns less than it was given. Four of the five predate this branch.
Closes#1727 and #2257 — the same bug in two services written months apart.
AppLog.created_at is `timestamp with time zone`. asyncpg binds a Python str as
VARCHAR and Postgres has no `timestamptz >= text` operator, so both of these
raised when Postgres planned the query:
notifications.check_due_tasks AppLog.created_at >= today.isoformat()
logging.get_logs AppLog.created_at >= <raw request.args str>
#1727 was the worse of the two because a per-user `except Exception` swallowed
it: reminder emails silently never sent, and the only outward trace was an
hourly traceback in the Postgres log. It has been open since 2026-07-19 with the
diagnosis written and the fix never applied. #2257 has no swallowing handler, so
it merely breaks the admin log viewer's date filters outright.
notifications: `utc_day_start(day)` returns midnight UTC as an AWARE datetime.
Deliberately not the bare `date` the original diagnosis suggested — comparing
timestamptz to date does work via an implicit cast, but Postgres resolves that
cast in the SESSION's TimeZone, so the dedup window would drift with a server
setting nobody remembers is load-bearing.
logging: `parse_filter_datetime()` converts the query-string value to an aware
UTC datetime; unparseable input returns None so the filter is skipped rather
than 500ing the viewer. It also fixes a bug the naive fix would have introduced
— `date_to=2026-07-30` parses to midnight, so `<=` would exclude the entire day
the user asked for. Date-only upper bounds now run to 23:59:59.999999, while a
value carrying an explicit time is left as given.
The guard is the point. This class is invisible to ordinary testing: the failure
happens when Postgres plans the query, not when Python builds it, so no unit
test that doesn't execute SQL can see it. tests/test_timestamp_filters.py fails
CI on two shapes —
1. a local bound to .isoformat() compared against a *_at column (#1727)
2. a str-ANNOTATED PARAMETER compared against a *_at column (#2257)
Shape 2 is the one that matters. Nothing in logging.py looks date-ish, so a
guard built only from #1727's shape finds nothing there — which is exactly how
the second instance survived. Verified by replaying both checks against the
pre-fix files out of git: shape 1 catches `today_str`, shape 2 catches
`date_from`/`date_to`, and the current tree is clean.
Found by grepping for siblings after fixing #1727 — the third instance today of
"the second place nobody checked", after #2245.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
Milestone #251 step 1 (#2258). Foundation for the gallery and the drift panel.
Parses NAMES from theme.css and asks the BROWSER for every value. That split is
deliberate. Extracting `--foo` is a trivial regex; extracting its value is not —
theme.css has nested parens, commas inside rgba(), var() chains, multi-part
shadows and gradients. getComputedStyle already resolves all of it, reports what
actually won the cascade, and — the reason that matters here — reflects live
overrides set on a container, which is exactly what the preview surface needs
(#2261). Parsing values would report what the file says rather than what the
user is looking at.
It also keeps the error-prone half out of our code, which matters because the
frontend has no test runner: `vue-tsc --noEmit` is the entire check. Logic that
can't be unit-tested should be logic that can't be very wrong.
readTokens(host) takes an element, so the same function reads app-wide values
from :root and scoped values from inside a preview container.
A BUG CAUGHT BEFORE SHIPPING, worth recording because the first version looked
obviously right: the declaration regex originally required the match to follow
`{` or `;`, to avoid matching var() uses. That silently dropped every
declaration preceded by a COMMENT — including --color-bg, the first and
most-used token in the file. 67 of 70 tokens found, no error, no warning.
The anchor was never needed. A declaration is `--name:` and a reference is
`var(--name)` or `var(--name,` — the colon alone discriminates. Comments are
stripped first so commented-out declarations aren't counted. Verified against
the real stylesheet: 70 unique tokens, 60 dark-overridden, 10 light-only, every
group resolving, zero var()-only false positives.
Also records a constraint discovered while building, which shapes step 6: light
is declared on :root and dark on [data-theme="dark"], so an attribute selector
can ADD dark to a subtree but nothing can add light back. Dark-inside-light
previews work; light-inside-dark previews cannot, until a [data-theme="light"]
block exists. readTokensForMode documents this rather than pretending otherwise.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
Milestone #251 step 3 (#2260). New /design view, reachable from a Palette icon
beside Trash and Settings — a meta-surface like /rules, so an icon rather than a
sixth primary nav link, but not hidden either, since somewhere the design system
is visible is the entire point.
Renders three things, and refuses to render a fourth:
- REAL components, imported not recreated: StatusBadge, PriorityBadge, TagPill.
- REAL tokens, read at runtime via readTokens() so the page shows the live
cascade rather than what the stylesheet claims. Grouped, swatched where the
value is a colour, flagged where the token is mode-aware.
- Rule 65's four button variants and rule 60's type scale, listed as SPEC and
marked missing.
That last part is the point of the step rather than a shortfall of it. Step 3's
premise was "render the real components, not copies — a gallery of look-alikes
drifts from the app within a month and then lies." Buttons have no shared
implementation to import: .btn-primary is defined four separate times in four
<style scoped> blocks, and 30 of 54 SFCs carry their own button CSS (#2273).
Drawing a button here would have made this page the fifth copy — committing the
exact drift the surface exists to catch. Same for the type scale: the three
families load (rule 59) but rule 60's sizes and weights are not tokens, so there
is nothing to read and a rendered specimen would be invented.
So the gallery reports them as gaps. A design system nobody can point at is a
design system that isn't there, and saying so is more useful than a page that
looks complete.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
Milestone #251 step 2 (#2259). The half of the drift panel that needed to be
testable, which is why it is Python: the frontend has no test runner, so the
fiddly extraction lives server-side and the browser only does set arithmetic
over live token values.
BINDING. A per-user setting `design_rulebook_id` names the rulebook that
describes this install's design system. A setting rather than a column: no
migration, discoverable in the Settings UI (rule #25), and honest about being a
per-install choice rather than a property of the rulebook. No rulebook
designated returns an empty set with rulebook_id: null — the NORMAL case for any
install but the one that set it up (rule #115), which the client renders as an
explanatory empty state rather than an error. The id comes back alongside the
list so "not designated" and "designated but empty" stay distinguishable.
EXTRACTION. No NLP. Rule statements are prose written for humans and should stay
that way, so this takes only what is unambiguous in any prose — the hex colours
and custom-property names a rule mentions. Anything subtler needs a rule author
to opt into a structured form, deliberately left for when someone wants it.
Three things earn their complexity:
- SENTENCE-SCOPED NEGATION. A rule routinely states what the palette requires and
what it forbids in consecutive sentences ("Parchment #E8E4D8 …, Vellum #C2BFB4
…. Pure white #FFFFFF is NEVER used."). Detecting negation across the whole
statement would mark the required colours as forbidden — inverting the finding
rather than missing it, which is worse. Per sentence, all four come out right.
- HEX NORMALISATION is load-bearing, not tidiness. The rulebook writes #FFFFFF
and components write #fff; if those don't compare equal the largest drift
finding in the codebase — 67 hardcoded white text colours (#2275) — reads as
zero. Alpha forms keep their alpha, since #fff and #ffff are different colours
and collapsing them would manufacture equality.
- SLASH SHORTHAND. Rulebooks write token families as --fs-radius-sm/md/lg/xl and
--fs-obsidian/iron/slate/pewter. Both expand under one rule — prefix is
everything up to and including the LAST hyphen of the first segment — which
also handles --fs-dur-fast/base/slow. Verified against the real rule text: 18
tokens from three different shorthand shapes.
how_to_apply is read alongside statement, because rulebooks routinely keep the
statement declarative and put the concrete values in how_to_apply; ignoring it
would miss the checkable half.
Claims dedupe on (kind, value), first source winning, so a colour named by
several rules is one expectation attributed to the rule that introduced it.
Prose with nothing checkable yields nothing — most rules are judgement, not
specification, and a panel that reported unparseable rules as problems would be
unusable.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
Milestone #251 step 5 (#2262), plus the Settings control that makes it reachable.
The comparison is deliberately thin — set arithmetic over live token values,
which is the one thing the browser knows and the server doesn't. The hard half
(prose to claims) already lives in Python where pytest can assert on it.
Three claim kinds, and the third inverts the test: a `token` claim asks whether a
custom property of that name exists; a `color` claim asks whether any token
resolves to that value; a `prohibited_color` claim FAILS when present.
normalizeColour is the client-side twin of normalize_hex and has one job the
server cannot do: getComputedStyle reports colours as rgb()/rgba() regardless of
how they were authored. So one colour has three spellings in play — #FFFFFF in
the rulebook, #fff in the stylesheet, rgb(255,255,255) from the browser — and a
comparison that misses any of them under-reports silently rather than erroring.
THE PANEL STATES ITS OWN BLIND SPOT, which matters more than it sounds. This
compares the rulebook against TOKENS. A literal hardcoded in a component, where
a token should have been referenced, is invisible to it — the drift isn't in the
tokens at all (#2275: 67 hardcoded whites against a rule forbidding pure white).
Reading those would mean bundling every SFC's source into the app; the check
belongs in CI and is tracked at #2277. A drift report that silently omitted a
whole category would invite the reader to conclude the category is clean, so the
panel says so in the panel rather than in a comment nobody reads.
Findings are ranked violated → missing → ok, and `ok` rows are hidden behind a
toggle. Same principle the auto-inject menu is built on: a short list that gets
read beats a complete one that doesn't.
Settings gains a rulebook picker. "None" is a first-class choice, not an unset
error — most installs have no rulebook describing their design system, and
saving empty DELETES the setting rather than storing a zero. The panel's empty
state points at Settings and Settings points back at the panel, so neither is a
dead end.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
Milestone #254 step 1 (#2286). A design system becomes a record Scribe holds
rather than prose in a rulebook: a named set of tokens with an OPTIONAL parent,
so a family system carries the house style and an app system carries only what
it changes. Answering "what does this app alter?" is then `list its tokens` —
nothing to compute.
`parent_id` is the whole model. It replaces both an `always_on` flag (a family
system is one with no parent) and a subscription join table (a project points at
ONE system; the chain supplies the rest) — less schema than the rulebook shape
it mirrors.
Two decisions the task left open, settled here:
- **Token values are JSONB keyed by mode**, not `value_light`/`value_dark`
columns. The deciding argument was not flexibility, it was ambiguity: in a
child system an unset mode means "inherit", in a root it means "not
mode-dependent", and as columns both are NULL and the resolver cannot tell
them apart. As a map, resolution is `{**parent, **child}` at every level with
no special case for roots. Against it: queryability — but nothing filters
tokens by value in SQL, so that buys a query no caller makes.
- **`group_name` is free text, no CHECK enum.** Groupings are each design
system's own vocabulary; a whitelist would bake one install's kit into the
schema. No CHECK is introduced anywhere, so rule #36 does not fire.
The cascade lives in `services/design_cascade.py` as pure functions over a
`{id: parent_id}` map, importing nothing — which is what lets both the service
and `access.py` use it without a cycle, and lets a test state a whole hierarchy
in one literal. Cycles are refused on WRITE by walking up from the proposed
parent (the cheap direction), and survived on READ by a visited-set, because a
loop from a direct DB edit must truncate rather than hang.
ACL (rule #78) is deliberately asymmetric: owning a system grants write,
reaching one through a project you can see grants READ ONLY. An editor on a
shared project must not be able to rewrite the family system every other project
in that family resolves through.
Also renames `services/design_system.py` -> `design_rulebook_import.py`. It is
the #251 prose extractor, whose role is already scheduled to become a one-shot
importer (#2288), and leaving it one character away from the new
`design_systems.py` was a trap for every later session.
Rule #115 throughout: nothing seeds a system or implies a default. An install
with zero design systems is ordinary, not degraded.
Milestone #254 step 2 (#2287). `resolve_tokens` flattens a system's inheritance
chain into its effective token set — walk to the root, deepest wins by token
name. Pure and duck-typed, so a test states a whole hierarchy in literals and
the service hands the same function ORM rows.
**Provenance is stored as the contest, not the winner.** A ResolvedToken carries
every system that offered a value, per mode, deepest first — `[0]` won and
`[1:]` are what it shadowed. "Which system supplied this?" and "what did it
override?" are then two reads of one list and cannot disagree, where a winner
plus a separate provenance field would be two things to keep in step.
**Merging is per (name, MODE), and that is the storage decision paying off.** A
system that deepens one accent for light backgrounds while leaving dark alone
owns `base` and still inherits `dark`. A token-level "overridden here" flag
would have to lie about one of them, and the two-column shape could not have
represented it at all.
Metadata cascades separately by the same deepest-wins rule, with one exception:
`order_index` treats 0 as UNSTATED rather than "first", because 0 is the column
default. Reading it as a real value would let a colour-only override drag its
token to the top of its group — a visible reshuffle in return for a change that
touched nothing structural.
One fix to step 1 while wiring this up: `_parent_map` is now scoped to the
SYSTEM'S OWNER rather than the caller. A caller reading through a shared project
owns no link in the chain, so the caller-scoped version would have handed them
an empty forest and truncated the cascade to a single system — a page rendering
with plausible wrong values and no error anywhere. The ACL already grants read
along the whole chain; this is the loading side keeping that promise, and it now
has a test naming the shared-project case.
`BASE_MODE` moves from the model to the cascade module, where it belongs: it is
a resolution rule, not a storage fact, and design_cascade.py deliberately
imports nothing so both access.py and the service can depend on it.
Milestone #254 step 4 (#2290). Eleven capabilities, both surfaces, one service.
Design systems are owner-scoped top-level records rather than project-scoped
ones, so these do not nest under /api/projects/ the way systems do —
routes/rulebooks.py was the closer shape. The one exception is the project
pointer, which is genuinely about a project: PUT /api/projects/<id>/design-system,
PUT rather than PATCH because clearing it is a first-class outcome and not an
omission.
`/resolved` and `/tokens` are deliberately separate endpoints. One answers "what
does this system CHANGE", the other "what does it end up BEING", and a system
that overrides nothing has an empty token list and a full resolved set. Shipping
only one would have made the other a client-side computation of exactly the kind
the record model exists to remove.
ResolvedToken.to_dict carries the SHADOWED contributions, not just the winner.
Dropping them at the serialisation boundary would have discarded the one thing
step 2 was built to preserve, and it would have been invisible — the payload
still looks complete.
Three sentinel translations on the MCP side, each tested, because an agent
cannot omit an argument and a wrong mapping here is silent:
- parent_id: 0 = unchanged, -1 = clear (become a family system), positive =
set. Renaming a system must not silently re-root it.
- order_index: -1 = unchanged, since 0 is a valid position.
- value_by_mode: guarded on `is not None`, not truthiness, so `{}` can strip
every mode from a token instead of being unreachable.
DesignSystemCycle maps to 400 on REST and to a ValueError carrying the message
on MCP — kept apart from 404 throughout. An agent told "not found" retries the
same call; one told what the loop is can fix it.
Two structural guards beyond the parity list: every endpoint must be reachable
on the app (catching a decorator copied without its path, where the second
handler silently never runs), and every public coroutine in the tools module
must be registered (a tool written but never registered is invisible to an
agent, and nothing else would notice).
Milestone #254 step 5 (#2294). /design-systems is the editable half of the
surface /design already showed: that page is what the browser renders, this one
is the record that ought to decide it. Each links to the other.
The layout follows the model rather than decorating it. Two token lists, and
they are deliberately different questions:
Overrides — the system's own rows. Short by design, and EMPTY is the correct
state for an app that hasn't departed from its family yet, so
that empty state says so rather than looking unfinished.
Effective — what it resolves to with inheritance applied, each row labelled
with where its value came from.
Provenance renders PER MODE when the modes disagree. A system can own `base` and
inherit `dark` at once — that is the case the value column is a map for — and a
single badge per row would have to lie about one of them. Rows whose modes agree
(the common case) keep the single badge.
"Defined here" and "overridden here" are distinct labels. Introducing a token
and shadowing an ancestor's are different acts, and `is_overridden_in` is
already false for the first.
The parent picker filters out the selected system's descendants. The server
refuses those anyway with a message naming the loop — but a refusal you cannot
trigger beats a refusal explained well. Cycles that arrive some other way still
render a truncated chain rather than freezing the tab: the client keeps the same
defensive visited-set the server has.
Three drift bugs caught while writing the styles, all of the shape this
milestone exists to surface:
- `--color-accent` does not exist. I had used it for every focus ring and
active border; it would have rendered as nothing at all, silently. The
brand token is `--color-primary`.
- focus rings are ALREADY global in theme.css (`button:focus-visible` et al).
My per-element rules would have overridden the house ring with a different
one — the exact "bypassed abstraction" shape from #253.
- every existing `.btn-primary` copy uses `color: #fff`, which is rule 52's
prohibition and 67 live violations (#2275). This one uses Parchment and
says why in a comment, rather than becoming the 68th.
Also wires the project pointer into ProjectView's details panel, hidden entirely
when no design systems exist (rule #115 — that is the ordinary state, not a
degraded one) and saved through its own PUT, since clearing it is a real outcome
rather than an omission.
The two pages are halves of one surface — what the browser renders and the
record that should decide it — and only one direction was linked. Missed in
0937b17 because the patch that added it silently didn't apply; the commit went
out without it.
Milestone #254 step 6, first half (#2295) — and this reframes the task rather
than answering it. The operator's call:
"in this case we should declare what should be used in place of pure white,
it's not a prohibition it's what should be used in its place."
None of the three options on the table (a constraints record / the panel reads
both sources / negative token rows) was right, because all three kept the
prohibition as a KIND OF THING. It isn't one. "Pure white is never text" is the
shadow cast by a positive fact — text is Parchment — and a design system that
stores what things ARE has no row for a ban because it never needed one.
So `design_tokens` gains `supersedes`: the literal values this token should be
written instead of. `--color-text-on-action` supersedes `#fff` / `#ffffff`. Same
fact as the rule, stated forwards, and now actionable — a finding can say what
to write rather than only objecting.
It has to be DECLARED, not derived, and that is the crux: `#fff` and Parchment
`#E8E4D8` are different colours, so no value-matching check could ever have
connected them. That mismatch is precisely why the prohibition looked
unrepresentable until it was turned around.
`supersedes` cascades on EMPTINESS rather than on None. A child overriding a
colour says nothing about which literals it replaces, and blanking the family's
declaration there would silently disarm the check for every app that customises
the token — while a child that states its own list replaces it wholesale.
Two things this deliberately does NOT do:
- It does not feed the drift panel. Superseded literals live in component CSS,
which `designDrift.ts` cannot see and already documents as a blind spot.
This is input for the source lint (#2277). Declaring it with nothing
consuming it yet is honest; wiring it to a panel that cannot check it would
not be.
- It does not remove the panel's `prohibited_color` arm yet — that happens
when the panel is repointed at a resolved system, which needs #2288 first.
The declaration also exposes a missing token: most of the 67 hardcoded
`color: #fff` (#2275) are text on a coloured action button, and the system has
no token for that role at all. Every view hardcodes it. Declaring the token that
was never there is the first real output of the operator's framing.
Milestone #254 step 3 (#2288). Reuses #251's prose extractor as the reader and
adds the part that makes it an import rather than a list of claims.
**The join is the whole trick.** A rulebook states a design system in two places
and neither half is a token: one rule names the colours ("Obsidian #14171A (page
bg, deepest surface)"), another names the custom properties
(`--fs-obsidian/iron/slate`). The import pairs them on the word — `--fs-obsidian`
ends with `obsidian` — which is the only reason it produces something usable
instead of seventy empty names. The parenthetical becomes the token's purpose,
which is the field a bare hex could never carry.
**Prohibitions arrive as replacements, per the operator's reframe.** Rule 52
declares Parchment and forbids pure white in one breath, so the import emits
"write --fs-parchment instead of #ffffff" — the same fact stated forwards. It
attaches to the FIRST token that rule supplied a value for, not to every token
of that rule, because claiming Vellum is also the replacement for white would be
putting words in the rulebook's mouth.
**A token the rulebook names but states no readable value for is still
proposed, with an empty value.** Radius steps and type sizes are prose ("Small
4px") and nothing here parses them; inventing a parse per shape would be
guessing. The name is real and the value needs a human, so the proposal says
exactly that — and the UI leads with the COUNT of those, because an import that
hid them would look more complete than it is.
Preview is the default on both surfaces and in the UI. An import is a proposal:
rulebooks are written aspirationally and some of what they describe was never
built, so every entry carries the rule id and the sentence it came from and a
reviewer can check the claim rather than trust it.
Existing token names are never overwritten. A value already in the record was
put there deliberately — most likely correcting this importer — so a re-run
fills gaps and lists the rest as skipped, which also makes it safe to repeat.
Colours the rulebook names but never exposes as a custom property produce no
token: it never asked for one, and inventing a name would put something in the
record no rule sanctions.
Operator's new requirement (#2299, architecture in #2296): a design system does
not just hold tokens, it generates and manages a master CSS sheet. That settles
the milestone's open "authority mechanism" question — the record is
authoritative because the stylesheet comes out of it.
**The sheet is shaped by purpose and styles no elements.** It declares custom
properties, grouped by what they mean, and contains no `.btn-primary`, no
`table`, no `input`. That is the design, not a shortcut: a sheet that styled
elements would restate the same handful of values once per element and grow with
the UI, where purpose-named values are stated once and reused. Components live
as SNIPPETS that reference these names — a surface that already exists and
already carries prose, locations, drift checks, merge and write-path recall.
A token named after an element (`--fs-button-bg`) is the smell that the two have
been mixed; a purpose name (`--fs-action-primary`) is reused across all of them.
Alongside the CSS the endpoint returns what the text cannot say for itself:
which tokens are still valueless, and which VALUES are declared under more than
one name. The second is the operator's "reuse consistent values" constraint made
checkable — and it reports rather than refuses, because a design system
legitimately aligns colours on purpose ("Success = Moss, by design") and only a
human knows which case it is.
Mode maps to selector the way the codebase already does it: base on the root
selector, every other mode layered on `[data-theme="…"]`. The root selector is a
PARAMETER — #251 recorded that a container-scoped preview cannot use `:root`, so
hardcoding it would have made the generator useless to the preview surface.
A token the rulebook names but states no value for is emitted as a commented-out
declaration IN ITS GROUP rather than dropped. Its absence is the finding, and a
comment puts that finding where the reader already is.
Values are validated, not escaped, and this is a real boundary rather than
tidiness: design systems are shareable records (rule #47), so `red; } body {
display: none` in a system shared with you would otherwise inject CSS into your
page. A value containing `{ } ; @ < >`, a comment delimiter or a newline is
REFUSED and rendered as a comment saying so — rejecting beats stripping, since a
partially-sanitised value is one the operator never wrote and the sheet's whole
claim is that it is the record.
Not in scope, and deliberately: serving this as the app's actual stylesheet.
Generating and exposing a sheet is reversible; swapping theme.css for a
generated one is not, and it should be an explicit call rather than a side
effect.
"The snippets use the tags from the sheet" was a relation nobody could verify.
Now it is three checks, and all three currently fail SILENTLY in this codebase:
unknown `var(--x)` where the system declares no `--x`. Renders as
nothing at all — no error, no failing test, no visual clue
beyond the element quietly not being styled.
superseded literals a value the sheet said to stop writing, paired with the
token to write instead. Only possible because `supersedes`
is declared rather than inferred.
local definitions custom properties a snippet mints for itself instead of
reusing the sheet's — the bloat a shared sheet exists to
prevent, where a value stops being reused and starts being
restated per component.
The first is not hypothetical. Writing DesignSystemsView.vue earlier in this
same session I used `--color-accent` throughout; it does not exist, and nothing
in the toolchain noticed. This check is the thing that would have.
A token that is both defined and read locally is reported ONCE, as an unknown
reference — "--btn-bg does not exist in the sheet" is the more precise statement
of the same problem, and reporting both would double-count one fact.
Literal matching is boundary-aware and case-insensitive: `#fff` must not fire
inside `#ffffff` (different colours, and a finding on the wrong one sends
someone to change correct code), while `#FFFFFF` in a rulebook has to match
`#ffffff` in a stylesheet — the same trap `normalize_hex` exists for.
Snippets with nothing to report are omitted entirely. A list of everything that
is fine is a list nobody reads twice — the same principle the auto-inject menu
and the drift panel are both built on.
Two integration mistakes fixed while wiring it: `list_snippets` returns
`(rows, total)` and caps its limit at 100, and `get_snippet` returns a Note
model rather than a dict. The list rows carry a preview, not the code, so the
check reads each full body — checking the preview would have reported on a
truncation.
Last piece of the architecture in #2296. The operator: "the prose doesn't have
to live as one offs, there's a central system for managing it."
Two fields, both free-form:
design_systems.guidance the narrative a token table cannot hold — aesthetic,
voice and tone, what is deliberately out of scope.
design_tokens.rationale WHY a token is this value, which is a different
question from `purpose` (what it is FOR). "Success
equals Moss, aligned by design" is a rationale;
"page bg, deepest surface" is a purpose. Rules carry
the first routinely and a token row had nowhere to
put it.
Free-form rather than a column per category, deliberately. A schema with
`voice`, `aesthetic` and `scope` columns would bake one rulebook's table of
contents into every install (rule #115), leaving the next install three empty
columns and nowhere for what it actually cares about. Both nullable: a design
system with no prose at all is complete, not a draft.
`rationale` cascades like `purpose` — deepest non-empty wins — so an app
overriding a colour keeps the family's reasoning rather than blanking it. Same
argument as `supersedes`: the override was about the value, not the meaning.
In the generated sheet the inline comment prefers `purpose` and falls back to
`rationale`, so a token carrying only the why still says something instead of
rendering bare.
Broke the typecheck on 0f80b79. The scripted edit that added the three
`tokenRationale` usages and the one that declared the ref were separate
replacements, and only the declaration's anchor was wrong — so three usages
landed against a name that did not exist.
The declaration's replacement had no assertion on it while its neighbours did.
An anchor that matches nothing is a no-op, and a no-op looks exactly like
success.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
16 commits.
devhead15d2e0c, CI green (run 3122, all six jobs including the integration lane running0001 -> 0074onpgvector/pg17).Two bug fixes worth deploying on their own
293a143— reminder emails and admin log filters were crashing. Both compared atimestamptzcolumn against a raw request string; asyncpg bindsstras VARCHAR and Postgres has notimestamptz >= textoperator. Fixed innotifications.pyandlogging.py, with a source-inspection test that catches both shapes (#1727, #2257).61e6e38,3c0192d,d3ee24f,4ca3ab0) — the/designpage: live token inventory, a component gallery that marks what isn't there rather than mocking it up, and a rulebook-drift panel.Milestone #254 — the design system becomes a record
A design system is a named set of tokens with an optional parent. A system with no parent is a family; one with a parent holds only what it changes, so "what does this app alter?" is a plain list rather than a diff. Resolution walks the chain, deepest wins by name — that is the CSS cascade, not an analogy to it.
03b3998839d690143b9680937b173da40absupersedes— declare what to write instead of, not what to ban4dc57f8b0a7d9e46d88f90f80b79guidanceon systems,rationaleon tokensNew migrations
0072(design_systems, design_tokens, projects.design_system_id),0073(supersedes),0074(guidance + rationale). All applied on the full chain in the integration lane.Safe to deploy without doing anything
Rule #115 holds throughout: an install with zero design systems is entirely normal, not degraded. Nothing seeds one, nothing implies a default, and the project picker hides itself when none exist. The
/design-systemspage renders an empty state that reads as ordinary.What this makes checkable
var(--x)where no such token exists renders as nothing at all — no error, no failing test, no visual clue. That is not hypothetical:--color-accentwas used throughout a new view in this very branch, does not exist, and was caught by neithervue-tscnor CI nor the browser. The snippet check exists because of it.Known and filed, not fixed
#2293 —
services/backup.pyenumerates its tables by hand and has drifted. Five are absent from export and restore:systems,record_systems,note_usage_events, and both new design tables. A restore reports success and silently returns less than it was given. Four of the five predate this branch."The snippets use the tags from the sheet" was a relation nobody could verify. Now it is three checks, and all three currently fail SILENTLY in this codebase: unknown `var(--x)` where the system declares no `--x`. Renders as nothing at all — no error, no failing test, no visual clue beyond the element quietly not being styled. superseded literals a value the sheet said to stop writing, paired with the token to write instead. Only possible because `supersedes` is declared rather than inferred. local definitions custom properties a snippet mints for itself instead of reusing the sheet's — the bloat a shared sheet exists to prevent, where a value stops being reused and starts being restated per component. The first is not hypothetical. Writing DesignSystemsView.vue earlier in this same session I used `--color-accent` throughout; it does not exist, and nothing in the toolchain noticed. This check is the thing that would have. A token that is both defined and read locally is reported ONCE, as an unknown reference — "--btn-bg does not exist in the sheet" is the more precise statement of the same problem, and reporting both would double-count one fact. Literal matching is boundary-aware and case-insensitive: `#fff` must not fire inside `#ffffff` (different colours, and a finding on the wrong one sends someone to change correct code), while `#FFFFFF` in a rulebook has to match `#ffffff` in a stylesheet — the same trap `normalize_hex` exists for. Snippets with nothing to report are omitted entirely. A list of everything that is fine is a list nobody reads twice — the same principle the auto-inject menu and the drift panel are both built on. Two integration mistakes fixed while wiring it: `list_snippets` returns `(rows, total)` and caps its limit at 100, and `get_snippet` returns a Note model rather than a dict. The list rows carry a preview, not the code, so the check reads each full body — checking the preview would have reported on a truncation.