ACCID – The Rules

Each rule below came from a measured failure in this codebase. The measurements are the point. A rule without one is a style preference, and it will be overridden the first time breaking it looks tidier.

If you are an AI reading this: every rule has a “You will want to” line. That is the specific move you are about to make. It is wrong here, and the Because says why. If you find yourself proposing it anyway, stop and ask.

On the line numbers. They are evidence of what happened, not pointers to current code. Several cite lines that have since been fixed or deleted — that is the rule working. Do not “correct” a citation by finding the nearest matching line today; check the commit if you need the original.

Rules 1–10 were written by a parallel session on 2026-08-25. Rules 11–16 came from the same day’s work, each from something that actually went wrong. Rule 17 is the one-document ruling. Rules 18–22 came from the night of 2026-08-27/28 — template pieces, the console sweep and the seed rebuild. Rules 23–27 came from SPI-558’s registry work. Rules 28–30 came from SPI-577’s type passes on 2026-09-21/22 — the key collision, the panel that two renames missed, and three suites that passed while the product was broken.

On where rulings live. The chain is a dated ruling on the ticket → a line in _ADMIN/DOM-Ladder.md → this file. DOM-Ladder is the working doctrine for SPI-558 / 568 / 577 and feeds RULES; this file is the master. SKILL_current.md is a snapshot — reference, not authority. LAYER-SYSTEM-FINAL-SPEC.md (May) is superseded wherever it disagrees with DOM-Ladder, and carries a header saying so.


1. A place is not an ownership

Rule. layer answers ONE question: where does this render. Not who owns it, not where it came from, not what kind of thing it is.

Because. layer: "templates" was a provenance fact wearing a place field. The proof it named no place: page-renderer.js:2703 re-routed templates to content one line after reading it, so the templates layer rendered empty on every page that has ever loaded. A value that is immediately translated into a different value is not a value.

Measured. 26 stored values across 9 files, ~30 code sites, 4 load-bearing. Their entire job turned out to be excluding 7 paragraphs (see rule 4).

You will want to fix a module whose stored layer disagrees with its rendered one. Don’t — the disagreement means the field is being asked two questions. Find the second question and give it its own answer.


2. Absence is a value. undefined is not empty.

Rule. Three distinct states, never collapsed:

  • absent — the key is not there. Unstated. Resolves to the default at read.
  • '' — the key is there, holding empty. The user cleared it. Authored.
  • undefined — a key asserting nothing. Always a bug.

Never store the default. Never write undefined.

Because. Storing the default converts a meaningful absence into a value you must now maintain, and closes a set that should stay open. And a create() that emits borderRadius: undefined looks defined and isn’t — the browser drops the invalid declaration, the fallback applies, and it looks like it worked.

Measured. A proposed fill of 192 modules: 44 were known-temporary at write time. Of the 22 || 'content' fallbacks that existed only because the field could be absent, 14 were already dead — overwritten one line later.

You will want to add a guard that skips undefined values. Don’t — fix the thing emitting them. A guard makes the bug survivable instead of absent, and the next writer inherits it.


3. Identity lives on the container. Per-node facts derive at render.

Rule. Mark the boundary, not every node inside it. Stamping the DOM is not storing — an attribute written at render from what the renderer already knows costs nothing in the data files. “Don’t store it” never meant “don’t tag it.”

Because. A subtree already has an edge. Descendant selectors reach every node inside it, so a per-node mark adds nothing and creates a second place the same fact can live, with no mechanism to keep the two agreeing.

Measured. container-module.js:390 emits no data-layer on child slots, so page-renderer.js:1505 paints the parent’s value over every descendant. Result: template-default‘s nested page-header is stored layer: "content" and renders data-layer="templates". A rendered per-node attribute was evidence of what an ancestor said, not of what the node was.

You will want to write a recursive stamp so every descendant carries the mark. Don’t — a missed branch is silently unmarked, which is worse than the problem you were solving.


4. Mark the exception, not the rule

Rule. If you are marking N things to exclude 1, the marking is inverted.

Because. The rule is usually already known from context — “being in a template is what makes something a template part, and the file already knows it’s a template.” Only the exception carries information.

Measured. 16 top-level modules tagged templates in order to exclude ONE untagged text module. 7 of the 23 marks sat on nested modules the flat filter could not read. In template-default the outer container was templates, its child content, its grandchild templates again — a partition that alternates down the tree is not a partition.

You will want to correct the marks so they’re consistent. Don’t — invert the marking and the inconsistency stops existing.


5. A type is a contract. An invented attribute is cosmetic.

Rule. Composition, save and rendering hang off type. Chrome and affordances hang off invented attributes. Never the reverse.

Because. type is validated on save, dispatched on by the registry, and depended on by ~24 other types. An attribute invented an hour ago is derived from a condition that has already been wrong once. If chrome hangs off it and it’s wrong, the editor greys the wrong box — annoying, visible, fixable. If composition hangs off it, the header silently vanishes from every page.

Corollary. New module types fail OPEN on save (base-module.js:598-600 — no fields[] means nothing is stripped), while an invented key on an existing type is silently eaten by the UNIVERSAL_FIELDS whitelist. That is why a new capability should be a TYPE, not a flag.

You will want to add a boolean flag to an existing type instead of creating a new one. Don’t — check the save whitelist first.


6. Does CSS already express this relationship?

Rule. Before building a mechanism, check whether inheritance, the cascade, layer order or document flow already does it.

Because. Three of this project’s most expensive messes were hand-built reimplementations of exactly those four things.

Measured. The auto-spacers measured heights across bands circularly and produced a band 2,183,997px tall. The replacement was document flow. Later, a proposed display: contents was kept on the theory that it preserved grid track placement — there were no tracks; .accid-layer-wrap is display: flex; flex-direction: column. Measured offsets with and without: identical, [0, 69, 276] both ways.

You will want to measure an element’s height in JS and write it somewhere else. Don’t — that is the 2,183,997px shape. Measurement is only safe when it is acyclic AND CSS genuinely cannot express the relationship.


7. Fixed once, copies never swept

Rule. When something looks expensive, count the corpses before budgeting the work. Half of what looks like N independent decisions is one decision plus N−1 stale copies.

Because. A real fix lands at one call site; nobody goes back to delete what it made redundant. The copies then look like deliberate parallel implementations.

Measured. 22 || 'content' fallbacks → 14 already dead. COMMON_FIELDS preserved 22 of 73 style fields while a second implementation preserved all of them. Adding a fifth layer touched 16 sites across 8 files → 3 irreducible. LAYER_ORDER and LOOK_SURFACES: declared, never read.

You will want to carefully update all N copies. Don’t — find out how many are reachable first.


8. Silence is the failure mode

Rule. A clean console is not evidence. In this codebase the characteristic failure is a querySelectorAll that returns empty, or a name missing from an array — indistinguishable from “there was nothing to do.”

Measured.

  • creator.js:961 arms module drag over .module-editable; the renderer has emitted .module-wrapper-v2 since the v2 rename. Page drag-and-drop has been dead ever since, silently.
  • page-renderer.js:2813 queries :scope > .module-wrapper-v2 on #moduleContainer, whose children are .accid-layer bands.
  • taxonomy-index-page.js:58 iterates contentLayer.children, which are .accid-layer-bg and .accid-layer-wrap, not modules.
  • After the layer rename the blank-add + list still named the pre-rename layers. orangesunshine was the only accidental survivor; three layers lost their add button with no error.

You will want to conclude a feature works because nothing threw. Don’t — verify it in the browser at ?chrome=1. Two static signals AND the running app.


9. Names are identity. Position is data.

Rule. A stored name is arbitrary and permanent. Order comes from the array index and is never written down. Display labels are the user’s and never reach the internal name.

Because. Every name that encodes something can become false. content and templates claimed what was inside them, and templates became a lie the day composition changed. page and body claimed roles and collided with two other meanings each.

Measured. Three meanings for “page” (the layer, the style scope, the element), two for “body”. A rename experiment produced four distinct failure classes, two invisible to grep: modulesByLayer.content (dot-notation, not a string) and body[data-active-layer="content"] (editor state, in no inventory).

Corollary. Deriving a colour from a name is fine and keeps working through reorders — the colour follows the name, the name never moves. Deriving it from position is not.

Measured again, 2026-09-23 — the corollary is violated in production, by tags[0]. Shawn: “The first tag is the one used for positioning in the spatial views. If anything ever reorders tags — a bulk edit, an import — photos move in Spatial and Universe without warning.” Verified: taxonomic-position.js:67,77,98,99, universe-map.html:419 and grouped.html:959,962 all read a.tags[0], and creator-shell.js:464 states the contract in a comment — “Text order IS array order: tags[0] (the encoder tag) stays first.” So index 0 of an ordinary-looking array carries a meaning nothing declares.

Zero tests assert it, and the failure is silent — items relocate in three views and nothing errors. Also measured, and the only reason this is a hazard rather than an incident: no live write path reorders a perspective’s tags[]. The four .sort()/Set sites (accid-filter.js:255, create-article-popup.js:168, page-manager.js:1672-1682) all build a vocabulary list for display or autocomplete; none writes back.

You will also want to “tidy” a tag array — dedupe it, sort it alphabetically, round-trip it through a Set. Any of those silently moves content in Spatial, Universe and Grouped.

You will want to name a new thing after what it contains or where it sits. Don’t.


10. Ask the source; don’t derive the answer

Rule. When the filesystem, the server or a config file already knows, fetch it. Every “compute this from globals / URL / string-search” is where edge cases hide.

Measured. The blobber was assumed dead because its comment described a job it no longer does (“loads dropper.json into localStorage”). Deleting its script tag decimated the layout — 321 references, 30 _findNested callers, two in the render path. The comment was stale; the code was load-bearing.

You will want to trust a comment, a filename or a variable name about what something does. Don’t — they age out silently and nothing announces it.


11. Read the whole document, then act

Rule. If a doc is canonical enough to consult, it is canonical enough to finish. Skimming the top is how you get the model right and the exceptions wrong.

Because. Docs put the model first and the traps last, because the traps only make sense once you have the model. The part you skip is the part written by someone who already made the mistake.

Measured. _ADMIN/PERMISSIONS.md is 282 lines. An assistant read the first 60, got the model right (dirs 2775, files 664, nothing executable) and then violated both caveats sitting at line ~205:

The blanket chmod 664 flattens anything genuinely executable — check first. And .accid-backups / .accid-snapshots are created 0700 on purpose (Apache-private); pulling them into 2775 is a deliberate choice.

Result: 14 Apache-private directories flattened to 2775, and tests/run.sh stripped from 755 to 664. Both were caught only by re-reading the doc afterwards, on a question about whether it had been consulted at all.

You will want to read enough to start and then start. Don’t — for a doc this size, the last 20% is the part that names what you are about to break.


12. Your measurement can be the bug

Rule. When a before/after comparison surprises you, suspect the harness before the code. Verify the measurement is measuring only what you changed.

Because. A comparison that toggles more than your edit will faithfully report someone else’s change as yours, and you will chase it.

Measured, three ways in one day.

  • git stash toggles everything uncommitted. A render diff reported CHANGED four times running. The cause was that the user was editing live — template-default.json (+15), ehhspa.json (−435), test-modules.json (+78) and four indexes were in the working tree, so every “before” run reverted their work and every “after” restored it. Worse than a bad measurement: their edits were rolled back on disk during each window. Use git stash push -- <paths> scoped to code, always, when the app under test writes to the same repo.
  • getComputedStyle during a transition returns the animated value. .layer-empty-add has transition: opacity .15s, so reading immediately after setting the attribute reported opacity: 0 and “the rule doesn’t match” — it matched fine.
  • A flat scan of cssRules misses @layer and @import nesting. accid-layers.css is imported as layer(layers), so its rules are inside a layer block. A CSSOM check reported “no rule mentions this” about rules that were present and winning.

You will want to trust a diff because you wrote the diffing script. Don’t — run the same code twice first. If two identical runs disagree, the harness is the subject.


13. A check that has never fired is not a check

Rule. Before trusting a guard, warning or assertion, break the thing it watches and confirm it complains.

Because. A check that cannot fire is worse than none: it produces the confidence of coverage with none of it, and it is invisible precisely because its silence looks like success.

Measured. A runtime check comparing the JS layer list against the JSON list the server reads was added, and reported agreement. It was inert — the fetch URL had a spurious ../, so it 404’d and the check returned early having read nothing. Found only by deliberately removing a layer from one list and noticing that nothing complained. Now proven both directions: fires with the missing name, silent when they agree.

You will want to ship a guard because the code path looks right. Don’t — make it fail on purpose once.


14. Ownership stops at the boundary — say so in every selector

Rule. Every ancestor-based test — closest(), a descendant combinator, :has() — must spell out where ownership ends. There is no implicit boundary.

Because. Once content from one owner renders inside another’s subtree, “walk up and see what you find” answers the wrong question. CSS and closest() cannot see a boundary that is not written into the selector.

Measured — the same fault four times in one day, once per pass, each time in newly written code by someone who had just fixed the previous one:

  1. chrome suppression — the article’s own modules got 0/2 controls shown
  2. the opacity dim — the article’s branch greyed with the template’s
  3. the table of contents — 4 of 6 headings discarded, TOC refused to build
  4. the diagonal hatching — the article’s own 50/50 columns hatched

The clause is :not(.article-cell-slot *) in CSS and if (el.closest('.article-cell-slot')) return true; in JS.

You will want to fix the one instance you found. Don’t — grep for every ancestor-walking test in the same file and check each.


15. Structural deletion gets brace counting, not regex

Rule. To delete a function, block or rule, find its bounds by counting delimiters. Never by a pattern that “looks like” the start and end.

Because. A lazy quantifier does not mean “the nearest one” — re.search still starts at the earliest match position and expands from there. The result is a deletion that begins somewhere you did not look.

Measured. A regex to remove one method from accid-layers.js had a lazy prefix anchored on /**. It matched from the FIRST docblock in the file and ran forward, deleting 311 lines — init, setActiveLayer, filterModulesByLayer, updateHasContentIndicators and createLayerSwitcher. The editor died with layerSystem.init is not a function, in the user’s browser, before anyone noticed.

You will want to write one clever pattern for a multi-line delete. Don’t — locate the opening line, then count braces to the close. And run node --check before moving on.

Corollary — unquoted shell variables glob. PRUNE='-not -path ./llm-relay/*' used unquoted as find . $PRUNE gets pathname-expanded by the shell before find ever sees it. The exclusions silently did nothing and the LLM secrets were chmod’d world-readable.


16. Say what you did not check

Rule. Report the boundary of your verification as plainly as the result. “I could not test X” is information; a bare PASS that quietly omits X is not.

Because. The person reading has no way to see what your harness could not reach, and will reasonably assume a green result covers the obvious cases.

Measured. The template-name chip mounts into #bbPermLayers, which only exists under ?edit=1 — and the verification harness runs ?chrome=1, which does not load the bottom bar. It was reported as shipped and unverified for several turns before a user screenshot happened to show it working. Had it been broken, nothing in the process would have caught it.

You will want to report the parts that passed. Don’t — lead with what the harness could not reach.

17. One document per project

Rule. One HTML page per project. A second document is wrong, whatever it wants to be — it’s a module. index LOADS editors; it never contains them.

Because. A separate document can’t inherit anything. It doesn’t get the header, the layers, the editor chrome, or template pieces, because it isn’t in the place those things exist. Every one of them has to be re-implemented or re-imported, and then maintained in parallel forever.

Measured. The eight view pages are exactly this — separate documents, each with its own nav (view-nav.js), its own CSS, its own bootstrap. That’s why they have no site header: they aren’t pages of the site, they’re separate apps that happen to read the same data. Making them mounted modules gets them the header, the layers, the editor and the pieces for free, because they’d no longer be somewhere else.

You will want to add a page for a feature that “isn’t really part of the site” — an editor, a viewer, a tool. Don’t — it’s a module that mounts, and the mounting is cheaper than the eight copies of nav you’re about to write.

18. An optional resource must not be requested unconditionally

Rule. If a file may legitimately not exist, do not fetch it on every page load. Know whether it is there — from a manifest, a declared flag, or the fact that something on this page needs it — or do not ask.

Because. The browser logs a failed subresource whatever you do with the result. There is no quiet way to ask. And a console with expected errors in it is a console nobody reads, so the next real error scrolls past unnoticed — rule 8 arriving from the other direction: noise is as silencing as silence.

Measured. Three instances in one project, all shipping. POV instance CSS/JS: 12 instances across 51 pages, 22 files absent, 4 console lines per POV (a 404 plus a MIME refusal each, because Apache’s 404 page is text/html and <script>/<link> refuse to run it). parsed.json: fetched on every page in every project, exists in none. ftp-client.js: POSTed to the bridge from its own constructor, before auth existed, taking a 401 as the first error of every edit-mode load.

And probing does not help — measured 2026-08-27. A fetch() that 404s logs Failed to load resource exactly like a <script> that 404s. Handling the rejection changes nothing. The fix is always to not send the request: create the file (POV), or gate on need (parsed.json is only read through a module’s binding, so no binding means no fetch), or defer to the point of use (ftpClient.ready()).

You will want to “check if it exists first” with a HEAD or a fetch. That is the same request and the same console line, plus a round trip.

19. One type, two renderers — the string one will silently render nothing

Rule. When a type needs the DOM (it resolves something, or its children must keep their own identity), it gets an element renderer AND it will still be reached through the string renderer when nested. Fill the placeholder afterwards by querySelector; never assume the element path ran.

Because. createModuleEl special-cases a few types into element renderers, but a container renders its children by building an HTML string — so any such type nested inside a container silently takes Module.render(), which cannot append elements or read a resolved cache. Nesting is the normal case: a sidebar, a column, any layout at all.

Measured. Twice, same shape. article-cell — fixed by fillArticleCell. Then piece-ref on duomo-dad, 2026-08-28: the piece resolved fine (ACCID_PIECES held it, one module) and rendered <div class="module-piece-ref"> with children=0, height=0, no warning, no error. The reference looked perfect in the file and in the panel and simply was not on the page.

⚠ And the fill runs AFTER the content that contains it. A nested ref sits inside the article’s container, which does not exist until the article has been placed. Filling first found nothing and reported success — filled 0, no warning, ref still blank, indistinguishable from the original bug. Calling the identical function by hand afterwards filled it instantly; that is what located the ordering.

You will want to register the type and stop, because the top-level case works and that is the one you tested.

20. A stored value that one render path ignores is a trap

Rule. When two paths read the same stored field and one of them overrides it, the field is not describing the system. Either both honour it or neither should store it.

Because. The value is right in one context and wrong in the other, so it renders correctly in the place most people look and incorrectly in the place almost nobody does. Nothing is broken enough to report.

Measured. 2026-08-28. The hero in template-default stored layer:"orangesunshine" — the front band (z=400) — while its siblings were whitelightning (z=300). The four bands stack from y=0, so on the TEMPLATE the hero sat at y=0..335 over the header piece (0..156), the text and 94px of the container: three overlaps, header completely hidden. On all 138 articles using that template it looked perfect, because Phase A unshifts a template’s top-level modules into the default layer and never reads the stored layer at all. One string, two renderings, and the broken one visible only to someone who opens the template.

You will want to debug the CSS. The stacking was correct; the data said to stack it there.

21. Do not persist a thing before its required parts are collected

Rule. Write when you have what makes the thing valid. If the flow is name-then-details, do not save after the name.

Because. Cancelling the second step then leaves a permanent, invalid object that the author never chose to create — and backing out of a two-step flow means “nothing happened”, not “half of it happened”.

Measured. createGroup() wrote {label, members: []} to disk and then prompted for members. An empty group can never match anything, and the assignments validator warns about it on every page load of every page in the project. testgroup in tomakeseed was exactly that: the residue of a prompt someone cancelled, warning on every load for days, with nothing in the UI explaining where it came from or that it was the default outcome of changing your mind.

You will want to save first “so the id exists” and patch it in the next step. The next step is the one that gets cancelled.

22. A repair must not live behind the surface it repairs

Rule. Put the fix somewhere the broken state cannot reach. If the only door to it is the thing that is broken, there is no door.

Because. Recovery UI is reached in exactly the state where the normal paths are failing. Anything gated on the healthy path is unavailable when it matters, and showing a problem without a way out is worse than staying quiet — it names a locked door and hands over no key.

Measured. tmplp-header was live on 142 pages and missing from perspectives-index.json. piece-ref fetches by slug, so it rendered perfectly; switchToPage() reads this.pages[pageId] and returns early (page-manager.js:338), so it could not be opened, edited, renamed, duplicated or deleted from anywhere in the editor. The templates overlay listed the problem — and the overlay was the surface the missing entry had broken. The re-derive that fixes it now lives in the left config panel, which needs no index entry to open, and names what needs it.

And say which findings the button cannot fix. Assignment problems appear in the same health list but re-deriving never resolves them — an empty group stays empty. They are shown under “re-deriving will NOT fix these”. A button that claims a fix it cannot deliver teaches people to distrust the button.

You will want to put the repair next to the thing it repairs, because that is where it is relevant. It is also where it is unreachable.


23. A null diff proves nothing regressed. It does not prove anything works.

Rule. “Identical before and after” is the acceptance for a refactor, never for a feature. A feature is proven by a positive control: set a value, see the page change where the design says and nowhere else, read the computed value back.

Because. The failure mode of a wiring bug is silence — the page renders, nothing throws, the value simply never arrives. A null diff cannot distinguish “the new path works” from “the old path is still doing all the work” from “nothing is wired at all.” All three are byte-identical.

Measured. SPI-558: text 90, button 2, link 2, nav 3, navLinks 12, nested containers 57 — all identical, reported as green. The registry was driving nothing; every heading resolved to its literal fallback because no writer existed for --site-h1-*. The first positive control (h1 72px at global, green at page, purple at a container, amber on a module) found it in one run, then found the resolver lying about precedence, then found the test that agreed with the resolver.

You will want to report a clean styleguide diff as proof the wiring landed. Don’t — the diff is the second check. The first is a value that moves.


24. Declare on the owner. Read below it. Never declare on a descendant.

Rule. Every custom property a module reads is declared on its owner element ([data-accid-id]) and nowhere inside it. Any rung may write there — site, page, type, instance — and CSS ranks them correctly: inline beats stylesheet, later layer beats earlier. The root and everything under it only read: var(--m-x, <default>).

Because. A property declared on a descendant shadows the one it would inherit. A type default written on .module-button (the root) out-ranks an instance value written inline on the owner — the exact override it exists to lose to — and the page shows the wrong colour with no error.

Measured. Two buttons, one carrying an instance colour #ff0000. Type rule on .module-button: instance rendered #0000ff, lost. Type rule on the owner [data-accid-type="button"]: instance rendered #ff0000, won. A scan of every shipped stylesheet and CSS-in-JS block found zero declarations below an owner — the discipline held by accident; it is now a suite, broken once.

You will want to put a type default on the type’s class, because that is where its CSS already lives. Don’t — that is where it reads. It declares on the owner or as a fallback in the read.


25. A new name is only ever for a thing with no name

Rule. Before writing a key, property or attribute, grep the stores and the emitter for the thing it names. If it exists, use its name — even if the existing name is worse.

Because. A registry entry, a contract comment or a brief reads as a decision, so a name invented there is never questioned. Four consumers wired to it break the live thing in a way that looks exactly like the new thing working.

Measured. In one ticket, five times: --site-text-color over --site-body-color, --site-pad-x over --site-padding-x (which the gutter clamp read), the link trio as colour pickers when the renderer wraps their value in var(), bg_alpha over bgOpacity (8,544 stored occurrences), and --m-bg for a module background that had already been collapsed into the composite. The rule against this sat at the top of the file the fourth one was written into.

Measured again, 2026-09-23 — and the rule holds for WIDGETS, not just names. Asked to put the article page’s tag/category pills onto the catalogue’s control, the count before writing anything came to five existing pill implementations: .acp-pill (create-article-popup.js — browse, count, toggle, filter, create-new), .afb-pill (accid-filter.js — the search bar, and the only one that already does POV), .accid-pill-container/-input (page-manager.js — free-type with autocomplete), the same widget again in bottom-bar-panel.js:155, and .accid-pill-cat/-tag (catalogue’s own read-only page output). Two of those five are already duplicates of each other. The natural move — build the picker the catalogue needs — would have made a sixth. The rule’s consequence for a widget is sharper than for a name: an extraction must convert both callers, or it is not an extraction, it is an addition wearing the word “shared”.

You will want to name the field after the audit table you are reading. Don’t — the audit table is derived; the store is the source.


26. A transitional double-read is deleted in the commit that rebuilds the data

Rule. When a rename needs the old name read “until the data is rebuilt,” the rebuild and the deletion of the old read are one commit. Never ship the double-read with a note saying to remove it later.

Because. A double-read is a second writer with a date on it. Nothing enforces the date; the note ages into a comment nobody reads; the next person sees two names accepted and adds a third. It is the retirement problem in its most respectable disguise.

Measured. Three transitional double-reads (container-module, navigation-module, backgroundDecls) accepting textColor / backgroundColor beside the registry keys, each with a comment saying to delete “when pages are rebuilt.” Reading only the new name cost 18 template containers their white text; the fix was to rename the 18 in one pass with the diff shown first and delete the old reads in the same commit. Nothing was ever going to trigger the comment.

You will want to leave the old read in “just for now” because deleting it breaks 18 pages today. Don’t — rename the 18 today.


27. Fix the writer. Never teach the test to forgive.

Rule. When a comparison fails on something that is not the thing under test — line endings, whitespace, key order — the fix goes in the writer that produced the noise, not in the comparison. Byte-exact stays byte-exact.

Because. A test that normalises before comparing has learned to ignore one kind of difference. The next difference it ignores will not be cosmetic. And a diff that is all noise gets waved through, which is how a real parity failure is dismissed later.

Measured. looks.css diffed on every line while its content was identical — CRLF from a snapshot. 168 tracked files carried CR. There was no second writer to kill; the exposure was historical. Fixed with .gitattributes (* text=auto eol=lf), .editorconfig, one renormalise commit proven to be line-endings-only (a first attempt swept in 30 unrelated dirty files and was reset). The tr -d '\r' used to verify that commit lives only in the verification, not in any suite.

You will want to add .replace(/\r\n/g, '\n') to the comparison because it is one line and the test goes green. Don’t — the test going green is the problem.


28. One name, two meanings — split the name, not the lookup

Rule. No two module types may declare the same stored key. A job word that two types would both claim takes the type prefix — galleryColumns, catalogueSource, dateSource — and the type that owns the word outright keeps it bare. CSS custom properties do the opposite: they stay job-named. --grid-columns means “how many columns in a grid” to everyone who reads it.

Because. The namespaces have different amounts of power, and the instinct is to treat them alike. A stored key is a contract between the store, the registry declaration, the editor and the save fence; two types sharing one means the fence cannot tell whose value it is holding. A custom property has exactly two ends — the emitter writes it, a var() reads it — so prefixing it buys nothing and costs a third name for one concept. A DOM attribute is a label read by the module’s own CSS; renaming it is pure churn.

A CSS class is a fourth namespace, and it behaves like a stored key, not like a property. A bare word emitted as a class is a claim on every element on the page, because a selector does not know which module wrote it. Modules prefix: accid-catalogue-item--card, not card.

Measured (the class half). 2026-09-23. Catalogue emitted its layout as a bare second class — class="accid-catalogue-item card". Recorded first as “real but inert”, because the only bare .card rule in the editor’s CSS (accid_grid.css:1526) is empty. That was the right measurement and the wrong conclusion. page-renderer.js:1605 makes card the default layout_class for every module wrapper — 684 of 1800 stored modules carry it, across 16 types — and pov-contacts.css:22-132 has 11 blocks that do paint, through body[data-pov-scope="contacts"] .card. That is reachable live: accid-filter.js:345 sets the attribute and injects the sheet together. The lens author had already hit it and hand-written a duplicate #pov-contacts block to win on specificity — but the overlap is not total, and .card > div:first-child has no twin, so with showImage off a catalogue item’s body is crushed into a 56×56 grey circle by a rule written for a different module type. The asymmetry is the tell: catalogue leaked nothing out and everything in, because its own rules were compound on its prefix and the wrapper’s were bare.

Measured. columns was gallery’s grid count, table’s column definitions and — nearly — container’s ruler pins. Declaring gallery’s columns in the registry fenced it straight out of table, a regression caught only by an unrelated suite. On the live styleguide the same collision silently dropped every gallery’s layout and source on save: the values were written, the fence ate them, and the page redrew from the defaults with nothing logged. 20 stored keys migrated to fix it. Container turned out never to have been in the collision at all — it stores rulerPins, and its two columns hits are comments.

You will want to make the lookup type-aware — ENTRY(key, type) — because it leaves every stored name alone. Don’t. It spreads the ambiguity into every reader instead of ending it, and once no two types share a key, ENTRY(key) is already unambiguous.


29. A rename lands in three places. The editor is the one you will forget.

Rule. Renaming a stored key means the module that reads it, the stored data that carries it, and the editor control that writes it. All three in one commit (rule 26). Grep for the old name as a quoted string, not just as a property access — panel controls pass the key as an argument.

Because. The module and the data are the two you are thinking about; the panel writes the key through a generic save that assigns by a string it was handed, so the old name survives as a literal nothing in the module can see. The result is a control that appears to work: it renders, it accepts a click, it writes — to a key nobody reads.

Measured. bottom-bar-panel.js, panelDate(), three misses in one function. display → format (SPI-577 1a) was applied to the module and the data and missed in the panel; the fix wrote a comment above the control explaining exactly that, ending “The panel was missed when the data and the module were migrated” — and the very next control, 14 lines below the comment, still reads m.display. Then source → dateSource was applied to the module and the data on 2026-09-22 and missed in the same function twice more, at the write key and the read. A comment describing the trap did not stop the trap being re-entered on the line beneath it.

You will want to grep \.source and conclude the module is the only reader. Don’t — pSelect('Source', 'source', …) has no dot in it.


30. A suite can pass because it is wrong, not because the code is right

Rule. When a suite is green and a user reports the thing broken, the suite is a suspect, not evidence. Four distinct ways it lies — check for each by name.

  • The fixture stubs the function under test. It defines the thing it is meant to prove exists.
  • The fixture is easier to build than reality. It constructs a shape the product never produces.
  • The assertion names the wrong property. It measures something adjacent to what broke.
  • The input set is incomplete. The assertion is correct and would have caught it; it was never shown the data.

Because. All four produce a green that is indistinguishable from a real one, and each is the natural way to write the test — stubbing is how you isolate, a minimal fixture is how you stay fast, and the nearest property is the one that comes to mind. The fourth is the hardest to see, because reviewing the assertion finds nothing wrong: you have to ask instead what the suite is allowed to look at. Rule 13 covers a check that never fires; this is a check that fires, passes, and is measuring the wrong thing — or the right thing about too little.

Corollary: red for the wrong reason costs what green for the wrong reason costs. A first draft of the fix for the fourth shape merged every type’s fields[] into the collision map and asserted “no key appears twice”. It went red on 20 keys, 18 of them harmless — content is listed by seven types, text and url by button and link, caption by three. None is declared by anybody, so the fence keeps them all. A suite that cries about 18 non-problems trains you to skim its output, which is how the two real ones get skimmed too. Assert the mechanism (one type declares a word another type stores), not the surface resemblance.

Measured. 2026-09-21/22, one session.

Measured again, 2026-09-30 — the fourth shape, in a guard written against the fourth shape. one-comment-stripper.mjs asserts that no suite hand-rolls the comment-stripping regex. It filtered the directory to *.mjs. Nine of the 59 node suites are CommonJS *.js, so a quarter of the tier was never shown to it — and the one remaining offender in the repo, css-emit-test.js, was sitting in exactly that quarter, with .replace(/\/\*[\s\S]*?\*\//g, '') and a comment beside it saying “comments may still discuss it”. The assertion was correct and would have caught it; it was never shown the file. A guard about a recurring blind spot, with a blind spot, is as green as any other.

And the fix for it reintroduced the bug it was fixing. strip.mjs is ESM and that suite is CJS, so the first attempt was import(...).then(m => { dropComments = m.dropComments }) — asynchronous. The binding was still the identity placeholder when the check ran twelve lines later, so nothing was stripped and the suite went red on its own prose. require() of an ES module is synchronous on Node 22+ and that is what it uses now. Ask what the suite is allowed to look at, and then ask whether the value it looks with has arrived.

  • navigation-on-module set window.reRenderModule in its own fixture. The product defined it nowhere — every caller sat behind if (window.reRenderModule), so data was written and the screen never redrew. The suite proved its own stub worked.
  • The re-render fixture built a wrapper whose first child was the module root. No rendered page does that — the first child is always the toolbar. Reading wrapper.firstElementChild therefore ate the toolbar on the live page, leaving a gallery with two roots and no delete button, while the suite stayed green.
  • column-add-button asserted display !== 'none'. The whole bug was a + that was displayed and invisible — a leftover inline background:#f9f9f9, pale grey on white. Restoring the bug proved it: the “…and displayed” assertion still PASSES. The suite now asserts the computed background is rgb(99,102,241).

You will want to add the missing coverage by writing a new test beside the green one. Don’t — first make the green one red with the bug restored. If it will not go red, it was never covering this.


31. A key does not have to be declared to be deleted

Rule. Declaring a stored key in one type’s Module.settings fences that key against every other type — including a type that never declared it and merely lists it in fields[]. Before adding a key to settings:, grep every other module’s fields[] for the bare word. Rule 28 governs who may declare a name; this governs who is already storing it.

Because. The save fence asks the registry, not the module. It collects every declaration for a key, and if all of them carry onlyTypes and none names this type, it drops the key. fields[] is a whitelist of what a type may keep, not a claim of ownership — so a type that has stored a key since before the registry existed has no standing in the fence at all. Declaring a word is therefore never a local act; it is a claim over that word everywhere, made on behalf of one type, against types that are not consulted and do not change.

Measured. 2026-09-23, catalogue’s own type pass, found by the step-0 sweep Shawn ruled should go first. Catalogue declared the bare title and showDate. Box stores title on 26 modules with a live control at bottom-bar-panel.js:387; calendar stores showDate on 6 with a control at :621. Both were one save from deletion on any page carrying one. The same sweep turned up a third, older instance with no connection to catalogue: background declares color; calendar stores it on 3 modules. 35 values, three victims, every one of them collateral from a different type’s declaration. Nothing had been lost — all 35 were counted on disk before the fix, which is the only reason this is a rule and not an autopsy.

showDate was the worst shape: flip “Show Date → No”, the module re-renders correctly, the page saves, the key is deleted, and the next load falls back to ?? '1' = Yes. It logs at accid-loader.js:672, and nothing reads that log.

You will want to trust key-collisions.mjs, which exists for rule 28 and was green through all of it — its only input was the merged registry, and every victim declares its key in fields[] instead. See rule 30’s fourth shape.


32. A suite arrives citing its rule, broken once, and in the map

Rule. A suite is not finished when it passes. Four things, every new suite, no exceptions:

  1. Its header names the RULES number it enforces. One line, at the top, before the docker run line.
  2. It has been made red on purpose — with the real bug restored, in the file, proven present — before its green is worth anything.
  3. Anything knowingly failing carries @known-red <reason> · <ticket> in the header. Never silently red.
  4. _ADMIN/SUITE-MAP.md is regenerated in the same commit that adds or changes the suite.

Because. Each one answers a question the green output cannot.

  • The citation is what stops a suite from being deleted in six months by someone who cannot tell what it was for. It also makes the reverse lookup possible — “which suite guards rule 31?” is the question you have when you are about to break rule 31, and a grep over prose is the only index that exists.
  • The break is the only evidence the suite is connected to the product. Everything else — green, assertion text, a plausible-looking fixture — is compatible with testing nothing. Rule 30 lists four ways a suite passes while measuring the wrong thing; the break catches all four at once without having to guess which one you are in.
  • The marker is what keeps a real failure visible. 103 passed, 1 failed with no marker cannot tell a reader whether that one is expected or theirs, so they learn to skim the tail — and the next real failure hides behind the one that is always there. A red with a reason is information; a red without one is noise that trains people to ignore the channel.
  • The map, in the same commit, because “what guards what” is the artefact that rots invisibly. Nothing breaks when it is stale. It simply stops being true, and it is consulted precisely by the reader who has no other way to know — a future session with no memory of this one.

Measured. 2026-09-24/25. 104 suites in the tree. 8 headers cite a RULES number. One declares its break. So this is a going-forward rule and it says so — a citation written months after the fact is a guess, and a break claimed in retrospect is worse than no claim.

The break requirement is not theoretical bookkeeping; it failed three times in one arc:

  • Three consecutive gallery break-runs “passed.” Each time the edit meant to restore the bug either targeted a gx-out-grid the suite never reached or was never applied at all. A break that does not land is indistinguishable from a suite that cannot bite, and both report green. Shawn supplied the case that separates them — no stored galleryColumns, --grid-columns: 4 on a parent — and the break was then grep-proved present in the file before the run.
  • The suite-map generator’s own first pass reported 15 suites that cannot fail. All 15 were false: process.exit(bad ? 1 : 0) starts with neither fail nor a digit. Fifteen phantom problems, from a check nobody had tried to make fire.
  • Its break-once pass then caught a second bug in the generator — the @known-red marker was landing in the guards column, so a suite’s stated claim vanished exactly when a reader needed it.

_ADMIN/audits/suite-map.mjs mechanises what can be mechanised: it reports the citation gap, counts known-reds, flags suites that cannot fail, and reports which suites declare a break. tests/node/suite-map-current.mjs asserts the committed map matches a fresh generation, so requirement 4 cannot be forgotten quietly — it fails in the same run as everything else.

You will want to add the citation and regenerate the map later, in a tidy-up pass. Don’t — later is where both of them went for 96 suites. The map check exists so that “later” is now, and it costs one command in the commit you are already making.


The shape, for adding rules later

Four parts, and the third does most of the work:

partjob
Rulewhat to do
Becausethe mechanism that makes it true
Measuredthe number. This is what makes it survive an AI’s base rate.
You will want tothe specific plausible-wrong move, intercepted

“Don’t hand-build layout” is advice. “The auto-spacers ran to 2,183,997px” is a fact about this system that cannot be pattern-matched away.

Use a JSON example only when the rule is about a SHAPE that is ambiguous in prose — absent vs '' vs undefined needs two lines of JSON. “A place is not an ownership” does not; it needs the sentence about the re-route.


33. A module’s own controls belong to the module — and live in the floating panel, not in its box.

⚠ AMENDED 2026-09-26 — WHERE they live changed; WHOSE they are did not.

The rule below said “on the module”, and that was read as inside the module’s own box, as an inline .cx-bar. Measured on the styleguide, that is what it cost:

typebarthe type’s OWN datashared rows
hero368px49px319px
gallery1618378
catalogue1216952
spacer711853

87% of hero’s bar was not hero’s, and the bar sat INSIDE the owner — so the owner measured 733px around a 300px hero, and every owner-level background painted behind the editor instead of behind the module. Shawn: “if you look closely you can see that the bg colour is filling the entire area — not just the hero itself? it is filling the containing area?” It was.

The editor must not change the thing being edited. DOM-Ladder already carries that as a hope (SPI-592, “edit mode decorates, it does not replace”); a bar that adds 368px to a module’s box is the clearest possible violation of it. Shawn: “this floating bar is even EASIER to understand since it does not mess with the layout.”

So: BottomBarBump. It already exists, already floats, already remembers a dragged position, already allows one panel at a time. It becomes the SELECTED MODULE’S settings and nothing else.

THE REMOTE SURVIVES UNCHANGED, and it is the point. A type still declares which registry rows are its business (data-style-rows-keys="background, h1_family,…"). That list used to render inline; it renders in the panel now. Same declaration, same ONE writer, different surface. Shawn: “we KEEP the remote control idea we have been using — YES use the standard settings but don’t tell the user that.” The panel shows a module’s settings; nothing in it says “shared”, and the generic 97-input catalogue is not what opens.

You will want to put the controls back in the module’s box because that is what “on the module” sounds like. It is not what it means: the controls are the module’s, the box is the published page.

Shawn, 2026-09-25, on finding hero still drawn by panelHero:

“I like this for ALL modules – it is easer for users sto undersntad – and that leaves the setting bar ofr bigger things.”

A type’s own data — a hero’s three text slots, a catalogue’s source and terms, a page-header’s four toggles — is edited on the module itself, in a .cx-bar directly above it. The STYLE panel keeps only what every type shares: Name, Hidden, Layer, Reusable, Tag, and the generic BOX / BG / IMG / TYPOGRAPHY / FX tabs.

Because the alternative is what this codebase kept producing: the same key reachable from two or three places, where whichever you touched last won and none of them told you the others existed. Hero had three writers at once — panelHero, getEditFields, and the bar it was about to get. getEditFields still offered textAlign and minHeight five weeks after panelHero had surrendered both to the generic tabs as “leaked keys the generic tabs already own”.

Measured. At the time of this rule, bottom-bar-panel.js‘s switch was a scoreboard: catalogue, gallery, navigation, page-header, article-cell and piece-ref already absent, hero removed by this pass, 13 per-type generators left. That file is not being rewritten — it is being emptied, one type pass at a time, and the switch says how far along it is.

You will want to put “just one more field” in the panel because it is easier than adding a row to the bar. That is how all three writers happened. If the field is the type’s own, it goes on the bar; if it is shared by every type, it belongs to a generic tab and the type should not be declaring it at all.

⚠ Count generators, not cases. text and html are switch cases that break with no generator — that is the finished state, not a panel. A suite counting cases goes red the day a type completes.


34. The four layers are interchangeable. No code may know which one it is.

Shawn, 2026-09-26, after I implemented “leave white alone” as a CSS exclusion:

“we do not want to exclude white lightning or make anything special about the other 3 layers — the WHOLE POINT was that they can be interchangeable. Even if we don’t do that, they should be agnostic and work the same — no differences.”

“if we want to colour 3 of them, we colour those 3.”

Not naming a layer is necessary and not sufficient. A layer may not behave differently either — not by name, not by origin / data-layer-origin, not by “the active one”, not by position in the list. Per-layer difference is config data (ACCID_LAYERS already carries label, swatch, tint, ink, and the band stamps them as inherited custom properties), never a selector or a branch.

Because a rule keyed on which band it is makes the bands non-swappable, and it fails silently and late: everything looks right until someone moves the origin, and then one band is quietly wrong in a way nothing asks about. “Colour three of them” means give three of them a colour in the config — not write a selector that skips the fourth.

Measured. 2026-09-26, three bugs were blamed on the layers over three days and none of them were the layers:

symptomactual cause
“can’t add anything in any layer”the blank-layer + passed {order:0}, so afterModule.id was undefined and _locate could never match — one add path, four layers
“emptied layer can’t be refilled”its + rendered at document top −84px, centred in a wrap that had collapsed to 32px
“the header is pushed below the blue and purple layers”a column+wrap container reserving 205px of max-content height (RULE 35)

Zeroing every layer margin live moved not one pixel. The layers were innocent every time — and the reason they kept looking guilty is that three things genuinely do single out a band: data-layer-origin, the active band’s z-index: 2000 (which makes the editor show a stacking order the published page does not have — white lightning 2000 in edit vs 300 live), and, at the time of this rule, three hardcoded layer names in code: sync-auto-spacers.js:125 and :136 querying orangesunshine by name, and two || 'whitelightning' fallbacks, in markdown-paste.js:240 and bottom-bar.js:960, both fixed by this rule. The content-middle case directly above the spacer ones already used ${window.ACCID_DEFAULT_LAYER} — two cases in one switch were converted at the rename and two were not.

You will want to implement “leave white alone” as :not([data-layer-origin]), because it is not a name and therefore feels compliant. It is not: it hard-codes the role instead of the string, and the exclusion moves the day the origin does. Ask instead what data would make all four render correctly with one rule.

⚠ Comments may name a layer; code may not. accid-layers.css mentions orangesunshine six times and every one is prose explaining a measurement. tests/node/layer-agnostic.mjs strips comments before it looks.


35. When the geometry is wrong and nothing sets it, stop looking for the declaration

2026-09-26. A template header rendered 282px tall around a 66px nav. I spent most of a session looking for what set the height. Nothing did.

Because CSS produces geometry that no declaration expresses. A flex-direction: column box with flex-wrap: wrap and no definite height is the undefined case, and the browser resolves it from the contents’ max-content height — how tall the children would be at their narrowest. The nav’s own <ul> is itself flex-wrap: wrap; asked “how tall could you be?” it answered 282 (every link on its own line) and the column above reserved it. Two nested wrapping flex boxes, the outer sizing itself from the inner one’s worst case.

Measured. Ruled out by direct experiment, in order, all negative: extra columns, the layout class, every CSS height/min-height rule (full CDP matched-rules dump on four boxes), container queries, aspect-ratio, the nav dropdowns, pseudo-elements, text nodes, transform/scale/zoom, sibling stretch, and margins. Eleven mechanisms that could set a height; none of them had. Then one experiment settled it:

same subtree, same stylesheets, cloned into a bare sandbox   ->  66
in place                                                     -> 282

Clone the subtree into an empty parent. Same height ⇒ the cause is in the markup or CSS you are already reading. Different ⇒ it is the context, and no amount of rule-dumping on the element will ever show it. After that, bisect by cloning each ancestor in turn — the level where the number changes is the cause. That took four minutes after two hours of rule dumps.

You will want to keep dumping matched rules, because the height is right there in getComputedStyle and it looks like something must have written it. getComputedStyle().height returns the used value; it is the answer, never the source. Two independent wrong theories died before the clone test (available-space resolution — falsified, the number held at every parent height; and the dropdowns reserving space — falsified, they are display:none and removing all four changed nothing).


36. The layer gate runs before every commit

Ruled 2026-09-26, and owed since three separate bugs were blamed on the layers in one night. None of them were the layers.

what was reportedwhat it actually wasfix
“we are locked out of adding anything, in any layer”the blank-layer + passed {order:0}, so the anchor id was undefinedcf8d472
“the emptied layer won’t let me add back”that + rendered at document top -84px81a696a
“the header is pushed below the blue and purple layers”a column+wrap container reserving 205pxff9c78d

Two suites, one contract, and neither subsumes the other. tests/node/layer-agnostic.mjs is the DATA half — no code names a layer, every layer accepts a module through the blobber. tests/browser/layer-gate.mjs is the RENDER half — what reaches the page. Add-below landed in the data correctly while the + that starts it was 84px off screen, which is exactly why one suite alone could not have caught it.

Because every one of those bugs was invisible to the whole test suite while each half of the mechanism looked correct alone. The layers are the most-suspected and least-guilty part of this system, and the only way to stop paying for that is a check that says so in seconds instead of days.

Measured. Twelve cases, 37 assertions, each one a regression that has already shipped. Broken twice on arrival to prove it fires: reverting the delete fix loses case 3, reverting the +‘s band area loses six assertions across three bands.

⚠ THE ONE THAT ONLY EDIT MODE HIDES. body.creator-mode .accid-layer--active { z-index: 2000 } lifts the band you are working on, so white lightning sits 2000 in edit and 300 live — the editor shows a stacking order the published page does not have. A gate that ran only in edit mode would pass on a page that is wrong, so the stacking cases run in both.

You will want to assert the + is reachable on every band. It is not, by design — an inactive band’s + is opacity: 0 and takes no pointer events, because you switch to a band to work in it. The first version of this suite claimed a contract the product deliberately does not have and failed on three bands. Activate each band in turn instead; that is also the only way to test what a user can actually press.


37. One file states the layer order

Rule. Exactly one @layer a, b, c; ordering statement exists, in a file linked first in every document. It names every layer in use. Every other sheet only wraps its rules in @layer name { … }, and anything that generates CSS emits blocks, never an order.

Because. CSS does not merge ordering statements. The first mention of a layer fixes its position, and a layer that the first statement leaves out is appended at the end, after everything it named. So it becomes the strongest layer, silently.

Measured. 2026-09-29, 47edbd7. The regenerated accid-palette.css (link index 0) carried @layer palette, project, defaults, look, page, modules, editor;. The real 18-layer list in accid-styles.css loaded second and could only name layers that were already placed. reset, grid, layers, decoration, site, category, tag, pov, grouped, wrap, container, module all landed after editor. The reset’s padding: 0 beat the whole editor, and elements with non-zero padding on the styleguide went from 762 to 416. .module-content padding went from 2px to 0, and the style panel came unmoored. Both test tiers were green. The first guard written for it missed it too: it scanned @layer x { } blocks, but reset is assigned by @import … layer(reset).

You will want to give a new sheet its own short order line “so it declares where it sits”, or fix the generated file and not the generator that writes it. The next palette save would put the line back.

38. A class name is live if anything can build it

Rule. Before deleting CSS as unused, a class counts as live if its name appears anywhere, or if it starts with a prefix that any file interpolates or concatenates. Then prove the delete with a computed-style diff that can reach every surface the rules style.

Because. A class built by interpolation appears in no source file. A grep says “unused” about rules the product renders on every page.

Measured. 2026-09-30, a4d902c. Asking “does a view renderer emit it?” found 41 orphans. That was too narrow: the Page Manager isn’t a view renderer. Asking “does the name appear in any source file?” found 102, and deleting those moved three real buttons from 12px 30px to 10px 24px, because button-module.js:58 writes class="button button-${btnSize}". Counting interpolated prefixes (100 of them) spared 31 rules. The final 71 showed zero computed differences across 2,840 elements × 10 properties in both modes. The 56 pm-* deletions were not reachable by that view-mode diff; they rest on the emitted-class evidence (rule 16).

You will want to grep each class name and delete whatever has no match.

39. The system may limit a user’s value — but it must say so

Rule. Any clamp, min, max, cap or save-time rewrite of a user’s value is shown in the panel where they set it: the value in effect next to the value typed, or what was stored and why. A silent limit is a bug. The colour generator’s chroma-cap note is the model.

Because. A value that is quietly ignored looks exactly like a broken control. The user retries, re-types and eventually stops trusting the panel, and nothing tells them the system decided.

Measured. 2026-09-29, Shawn’s Site Box test. Three of six settings did nothing, with no message:

  • padding_x 200px: the reader is clamp(12px, 5vw, var(--site-padding-x)), so the setting is only a ceiling (≈60–95px at desktop widths), and anything under 12px can never apply.
  • border 10px: valid shorthand with no style, so it draws nothing.
  • box-shadow: 5px 5px #888888; was accepted with the property name and semicolon, was invalid, and was dropped.

You will want to clamp quietly “to keep them from making a mess”. The clamp is fine. The silence isn’t.

40. A name that states a value must land on it

Rule. When a token’s name promises a number (-r45 = 4.5:1), the generator solves for that number within a stated tolerance. It must not produce “the nearest thing that meets or exceeds it”.

Because. Treating the number as a floor makes every step below the base collapse onto the base, so the names stop meaning anything while every check that asks “is it at least X?” still passes.

Measured. 2026-09-29, the contrast solver. The first version took “the lightness nearest the rule that meets or exceeds the target”, so r12, r15 and r20 all returned the base colour’s own ratio — 2.33 to 2.66 across the six bases, so three of six steps were the base in disguise. After the fix: 84/84 steps on their named ratio (±0.05) in both modes.

Where the check lives. tests/browser/palette-ratios.mjs — it resolves every step on the live styleguide and computes WCAG 2 itself, so it measures what the cascade actually produced rather than what the generator intended. (An earlier draft said “cross-checked against Leonardo in _ADMIN“. leonardocolor.io is the reference tool for this kind of contrast work, not a file in _ADMIN — there is no artifact of that name. The contrast check itself is real and is the suite above. Checking the ratios against an independent implementation, rather than our own arithmetic, has not been done and is still worth having.)

You will want to test ratio >= target, because it reads like “passes”.

41. One writer per generated file

Rule. A generated file has exactly one tool that writes it. A second tool that can write the same file is retired and unlinked, not kept “for reference”.

Because. Two writers that disagree mean the file describes whichever one ran last, and nothing records which that was.

Measured. 2026-09-28. colorpicker.html and accid-color-system.html both wrote accid-shared/accid-palette.css via write_shared_palette. One made brand c2 and the other brand c1. Only the older one wrote the 30 harmony colours, so pressing Apply in the newer one deleted them, and accid-placeholder.js and accid-term-color.js lost their colours. The palette belonged to whichever page had been opened last. Fixed in 74da715: colorpicker.html retired and both openers repointed.

You will want to keep the older tool linked “in case”, because it has features the new one lacks. Move the features over instead.


PART C — “Measured again” notes for existing rules

Rule 29 (a rename lands in three places), measured again 2026-09-29. The palette rename swept var(--…) calls and missed the stored value link_hover: "--c5-75", a bare string with no var(). --site-link-hover resolved to "" and links stopped changing colour on hover, with both tiers green. Fixed in f1348de, plus a guard that reads the palette and asserts every stored bare token resolves. Search stored data for the old name as a plain string.

Rule 16 (say what you did not check), measured again 2026-09-30. The orphan delete’s zero-diff proof covered view mode only, and the Page Manager is edit-only, so 56 of the 71 deletions were never rendered by the check. It was said in the commit, which is the rule working. The consequence for the editor-styles move (SPI-583): its computed diff must run in edit mode with the Page Manager, POV manager and style panel open, because that is the only place those rules paint.

Why this file exists

These have been articulated, lost and re-articulated repeatedly. Without them written down, every assistant — human or AI — pattern-matches on what the code looks like it should have. Files with absolute paths look intentional. Missing fields look like oversights. Globals named ACCID_* look load-bearing. Four copies of a list look like four decisions.

Helpful pattern-matching becomes accidental regression. This file is the durable statement.