ACCID – DOM Ladder

Layer Structure

index.html
 └─ <link> accid-shared/accid-layers-order.css   ← FIRST link. The ONLY file that states the order.

           WEAKEST  (loses to everything below it)
 ┌────┬──────────────┬───────────────────────────────────────────────┬──────────────────────────────┐
 │ #  │ LAYER        │ WHAT FILLS IT                                 │ WRITTEN BY                   │
 ├────┼──────────────┼───────────────────────────────────────────────┼──────────────────────────────┤
 │  0 │ palette      │ accid-shared/accid-palette.css                │ accid-color-system.html      │
 │    │              │   c1–c6, -r12…-r70 steps, -aNN, neutrals,     │   (generator, ONLY writer)   │
 │    │              │   status colours, --page-surface              │                              │
 │  1 │ reset        │ accid-reset.css  (@import … layer(reset))     │ hand-written                 │
 │  2 │ grid         │ accid_grid.css   (@import … layer(grid))      │ hand-written                 │
 │    │              │   ⚠ holds view-module rules too (.module-     │                              │
 │    │              │   navigation, .accid-nav-link) despite name   │                              │
 │  3 │ defaults     │ tomakeseed-accid/site.css                     │ Save (the Site column)       │
 │    │              │   ⚠ named "site" but sits HERE                │                              │
 │    │              │ accid-shared/accid-page-defaults.css          │ hand-written (new, dbc8791)  │
 │    │              │   page background floor = --page-surface      │                              │
 │  4 │ modules      │ modules-styles.css, modules-markdown.css ?,   │ hand-written                 │
 │    │              │ responsive.css (generated) …                  │ + generator (responsive)     │
 │  5 │ layers       │ accid-layers.css  (the four bands)            │ hand-written                 │
 │  6 │ decoration   │ ?                                             │ ?                            │
 │  7 │ project      │ project_styles.css  — YOUR file, ships empty  │ you (by hand)                │
 │ ── │ ──────────── │ ─── everything above = built-in defaults ──── │                              │
 │  8 │ site         │ NO FILE — runtime only                        │ applySiteStyleVars (editor)  │
 │    │              │   ⚠ same Site values as site.css, other layer │                              │
 │  9 │ look         │ looks (rule-based styles) ?                   │ ?                            │
 │ 10 │ category     │ look by category  ?                           │ _assignments ?               │
 │ 11 │ tag          │ look by tag  ?                                │ _assignments ?               │
 │ 12 │ pov          │ look by POV  ?                                │ _assignments ?               │
 │ 13 │ grouped      │ ?                                             │ ?                            │
 │ 14 │ page         │ the Page column's values                      │ panel → emitter              │
 │ 15 │ wrap         │ ?                                             │ ?                            │
 │ 16 │ container    │ per-container values                          │ panel → emitter              │
 │ 17 │ module       │ per-module values ("This")                    │ panel → emitter              │
 │ 18 │ editor       │ accid-editor-chrome.css (+ chrome JS styles)  │ hand-written                 │
 │    │              │   edit mode only; reads NO site/palette vars  │                              │
 │    │              │   except --page-surface                       │                              │
 └────┴──────────────┴───────────────────────────────────────────────┴──────────────────────────────┘
           STRONGEST (beats everything above it)

How the four panel columns map onto it:

PANEL COLUMN      LAYER                 FLOW (→ settings)
  Site      ──→   defaults (saved file)   ─┐
                  site     (editor live)   │  value set furthest right wins:
  Page      ──→   page                     │  Site → Page → Container → This
  Container ──→   container                │
  This      ──→   module                  ─┘
  your hand CSS → project  (beats all built-in defaults, loses to any panel choice
                            for a specific page / container / module)

Where the arrows cross (worth checking before the light-on-light screenshot goes to Codey):

  1. The Site column lives in two places. The saved file is in defaults (#3, below your project file); the editor paints the same values live in site (#8, above it). A hand override could then win on the published page and lose in the editor.
  2. --page-surface is defined in palette (#0) and painted by defaults (#3). That background is what every contrast step is measured against, so if it doesn’t paint, you get your light-on-light screen.
  3. The names don’t match the layers. site.css sits in defaults, and accid_grid.css holds navigation styles. Codey chose not to rename files and to add a header note in each sheet instead.

Colour (SPI-577, 2026-09-28/29)

  • One generator: accid-shared/accid-color-system.html. It is the only writer of accid-palette.css. colorpicker.html is retired (74da715).
  • The stored thing is the rule: hue0, C, wheel, accent, L_light, L_dark, page_surface_light, page_surface_dark. Derived colours are values used in settings, never keys.
  • ⚠ Corrected 2026-09-30. This line said those “are the only colour entries in the registry”. They are not in the registry at all — the rule lives in accid-palette.css‘s own header, as the ACCID-RULE JSON comment the generator writes and parses back (one file, one writer; a second file could disagree with it). The registry has ten colour entries of its own: body_color, h1_color–h6_color, link_color, link_hover, link_visited, plus background. What is true, and is the point, is that each of those stores a reference to a palette token (link_hover: "--c5-r45"), never a derived colour value.
  • Brand = c1; the accent is picked from c2–c6. c2–c6 are 60° turns of c1.
  • Steps are named by the WCAG 2 contrast ratio against the page surface: -r12 -r15 -r20 -r30 -r45 -r70, solved per hue and per mode, and baked as light-dark(light, dark). Alpha is -aNN. There are no -l, -t or -h suffixes.
  • Neutrals are tinted toward c1 at C≈0.01, with the same ladder.
  • --danger / --warning / --success / --info are for real status only (errors, failed saves, confirmations). They are not in any colour picker.
  • Hand overrides go in project_style.css, in layer project, which beats layer palette.
  • Text-gradient stops are gated at r45 until the gradient moves to the element (SPI-612).
  • The live preview writes <style id="palette-preview"> inside @layer palette; Save writes the file, Cancel removes the preview.

Layers (2026-09-29)

  • project_styles.css sits at layer project (#8 of 19): it beats built-in defaults and loses to any panel choice, so the panel never lies. A true last-word override is an unlayered sheet added by hand; it beats every layer including editor, so its selectors must be scoped to site content. The order protects users who know nothing about CSS; anyone who knows more can still get around it. (Shawn, 2026-09-30 21:41.)
  • Measured, so the first clause is not taken on trust: an override of --site-body-color in project_styles.css wins in view AND in edit. The --site-* values go to layer(defaults) in both modes — site.css on disk and the accid-site-style element at runtime — so there is no view/edit split. Surfaces are the exception by design: background @ <target> is emitted into layer(site) (#9), above project, so a background chosen in the panel beats a hand-written one.
  • A sheet’s NAME says what is in it; its LAYER says where it sorts. They are separate facts and are not kept in step — site.css holds the Site column’s values and lands in defaults. Every sheet states its layer in a header note instead.
  • Exactly one file states the layer order: accid-shared/accid-layers-order.css, the first link in every document. palette is first (weakest) and editor is last. Every other sheet only wraps its rules in @layer name { … }.

Box properties (Shawn, 2026-09-29 19:30–19:36)

  • Radius cascades. Each level sets --site-radius on its own element and the module wrapper reads it, so the nearest level wins. The panel shows the inherited value faded. The registry decides which types take radius.
  • ⚠ The reader exists; the VALUE is what is missing (corrected 2026-09-30). I wrote here that nothing reads --site-radius on .module-wrapper-v2. It does — tomakeseed-accid/site.css:88–93 sets margin, border-radius, box-shadow and border on that class from --site-*. My search covered accid-beaker/htmlbuilder/css/*.css and never opened the GENERATED tenant file, which is RULES 30’s fourth shape: a reader that lives in output, not in source. (I had even read line 90 an hour earlier for a different question and did not connect it.) The other readers are real but incidental: .accid-box, .accid-box-title (modules-styles.css:1631,1644) and .accid-nav-link (accid_grid.css:2787).
  • What is genuinely absent is the value: the store has radius: null and nothing ever writes --site-radius, so Shawn’s 40px went nowhere. The work is finding where save or emit drops it, not building a reader.
  • Padding, margin, background, border and shadow do NOT cascade. They stack: boxes inside boxes.
  • Border is stored as parts (width / style / colour), following the twin rule.

Module defaults (Shawn, 2026-09-29 17:34)

  • A module never carries a private unset look in its CSS (hero’s own 3.5rem H1 was the example). Unset inherits the level above.
  • Stored module values are never reset; they are the module rung and win.
  • “Not special” means same pattern, not same values: a module may set anything, but through the panel’s keys, controls and cascade.

Names and tags (Shawn, 2026-09-29 17:36–17:43)

  • Settings are named for the module’s part (hero_headline_*) and use the standard row controls.
  • The HTML a module outputs uses the tag for what the content is (the SPI-597 table). The two are decided separately.
  • Generic look properties keep generic keys everywhere. Never hero_background.

Vocabulary — the two bars (2026-09-30)

  • Bump Bar — the floating module settings panel, the one with the tabs (SETTINGS / BOX / BG / IMG / TYPOGRAPHY / FX). accid-style-panel.js + bottom-bar-panel.js, asp-*. Opened by the bottom bar’s button labelled “Bump Bar” (#bbStyle, title “Module Settings Panel”). Retiring — its box-model widget moves to the new settings panel, but not until every module’s settings exist outside it.
  • bottom bar — the strip along the bottom. #accidBottomBar, bottom-bar.js, bb-*. Staying, including Save and the Snaps nav. The thirteen buttons SPI-577 ruled out are a separate matter from the bar itself.
  • ⚠ The codebase uses “bump bar” for BOTH. config-without-the-bar.mjs records a ruling as “the rest of the bump bar has to go” under the heading “CONFIG WORKS WITH NO BOTTOM BAR ON THE PAGE” — that one meant the bottom bar. Reading it the other way produced a wrong answer to Shawn that Save was homeless; it is on the bottom bar and stays.

Editor chrome (SPI-583)

  • The editor never reads site or palette values, with one exception: --page-surface, because layout has to be done on the real background.

##

Flagging semantic tags that shouldn’t be repurposed for styling.

The rule is only for HTML tags that carry meaning. Those get picked for what the content is, never for how you want it to look. The ones that matter for your modules:

TagWhat it meansWhere it matters in ACCID
h1–h6the page outlineone h1 per page; no skipped levels; hero parts as above
nava navigation menuthe nav module only, not a row of buttons
main, header, footer, asidepage landmarks that screen readers jump betweenone main per page; template header and footer pieces; never used for styling
button vs aan action on the page vs going somewherelink module → a. Button module: a if it goes somewhere, button if it submits or clears (this matches your “possible action” note on the button)
ul / ol / lia listnav links, catalogue items, gallery items. Not for layout
tabletabular datatable module only, never layout
figure / figcaptionan image and its captionimage module caption
timea machine-readable datedate module should output <time datetime="2026-09-29">
label / fieldset / legendform fieldsform module; every input needs its label
blockquotea quotationnot for indenting text
strong / emimportance / emphasis“make it bold” is a font-weight setting, not strong

So each module’s settings are named for their part and use the standard controls, and each module’s output uses the tag for what the content is. The two are decided separately, which is why the hero can have hero_tagline_size and still output a plain <p>.


Crib Sheet

Three lists, and they are not the same list

ListAnswersMembers
Panel levelsWhere can a user set a value?site · page · container · module — 4
Cascade rungs (ACCID_RUNGS)Who wins?site · look · category · tag · pov · grouped · page · wrap · container · module — 10
@layer order (LAYER_ORDER())The real CSS orderreset · grid · defaults · modules · layers · decoration · project · the 10 rungs · editor — 18

The rungs add two things the panel does not offer: the “which pages” filters (look, category, tag, pov, grouped) and wrap, a band’s measured column.

⚠ wrap is a rung with NO panel control. Measured — scopes actually in use across all 139 registry entries: site 84, page 85, container 105, module 132, wrap 0. Nothing a user can set targets it; its rules come from accid-level-style.js, not from a registry key. Do not add it to the panel looking for the missing column.

“Second-to-last” means the 18-list: module sits just before editor.

⚠ THE USER-FACING WORD IS “BAND”. THE CODE KEY STAYS layer.

Shawn, 2026-09-27: “bands — not layers for users as well — since it is not BY PAGE it is a different concept.” Ruled, and it is a VOCABULARY ruling, not a rename.

layer means three things in this codebase and the emitter has said so for weeks:

bands            montereypurple bluecheer …   z-order
CSS @layer       the 18-name statement         cascade precedence
.accid-layer     the DOM elements              structure

Every comment in the tree already says BAND in prose — “the ACTIVE band”, “a band’s measured column”, “THE BAND is a positioning context only”. Only the code and the UI still say layer.

⚠ THE DATA IS NOT RENAMED, AND THE NUMBERS ARE WHY. Measured 2026-09-27: 1,669 stored modules carry a layer key across 382 perspective files, plus ~500 code sites (.accid-layer 169, data-layer 132, ACCID_ACTIVE/DEFAULT_LAYER 76, accid-layers.* 82, ACCID_LAYER_NAMES 58). The working tree IS production, so a multi-file rename is live-broken between the first write and the last. Against the standing “nothing moves, no renames” ruling and [[registry-names-must-be-measured]]. Do not propose it again without new information.

⚠ AND IT IS NEARLY FREE ON THE SIDE THAT MATTERS. The UI hardly says the word: it shows band NAMES from ACCID_LAYERS’ label (“Blue Cheer”), not “the bluecheer layer”. Measured: ONE live user-facing string contains it (create-article-popup.js:267), two more are in accid-style-panel.js, which is the orphaned panel and loads nowhere.

So the rule is: user-facing text says BAND · internal keys, attributes and filenames stay layer · and every NEW thing — the band “+”, band pieces, panel headings, pickers — is BORN saying band, which costs nothing because none of it is built yet. An internal name differing from the user’s word is the normal state, not a smell; the smell was one word meaning three things where humans read it.

⚠ A FOURTH LIST — PIECE PLACEMENT — AND IT SHARES WORDS WITH THE RUNGS

Shawn, 2026-09-27: “pieces can be placed by template – by rule or by page. not by container or module though.” Correct, and it is a DIFFERENT AXIS from the three lists above. Style values and content placement are not the same question, and container / module appear in one list and not the other.

listanswersmembers
Cascade rungswhere a style VALUE comes fromsite · look · category · tag · pov · grouped · page · wrap · container · module
Piece placementwhere CONTENT comes fromtemplate · rule · page
Tree positionwhere the piece-REF physically sitsanywhere a module can, nested included

⚠ PLACEMENT AND TREE POSITION ARE ALSO DIFFERENT, and the data proves it. Measured across tomakeseed: 31 piece-refs at top level and 3 nested inside containers — tmplp-footer in sg-container-119, tpiece-blue in container-…krcr1831v, and tmplp-header in container-1a08c73fe06-69fcb6 inside template-stylesheet-guide. A ref nested in a container is NOT a rule violation: “not by container” means you cannot ASSIGN a piece at container scope, not that a ref may not sit inside one.

⚠ A PIECE HAS NO SCOPE OF ITS OWN — ITS REACH IS WHERE IT IS REFERENCED. The Templates modal already states it per piece: tmplp-header reads “on 155 pages · 14 tmpl”; orange-layer reads “on 1 page · 0 tmpl”. Same mechanism, both ends of the range. That count is the blast radius, shown before you open the pencil — editing a piece edits it everywhere, which is inherent to reuse rather than to any one design.

LEVELS and FILES — and neither is complete

  • LEVELS (accid-css-emit.js:109) — what a rung’s rules HIT. site → body · page → body[data-page="x"] · wrap → .accid-layer-wrap
  • FILES — which file a rung’s rules are SAVED INTO. site.css, page.css, …

⚠ Both hold 8 of the 10 rungs. container and module are in neither — not an oversight, they never needed one, because their values went inline onto the element. emitLevel refuses an unknown level and says so.

⚠ THERE ARE TWO FILES TABLES. accid-css-emit.js has plain filename strings; accid-level-style.js:62 has { file, layer } objects and is the one the write path gates on (FILES[level] at :115, :171, :312). A level absent from that table cannot be written by that path at all.

Task 2 adds container and module to LEVELS only, as [data-accid-id="ID"].

Where container/module rules live — SETTLED

Generated when the page renders, into one <style> whose blocks are @layer container { } / @layer module { }, slotting into the pre-declared order. No container.css, no module.css — a file per rung goes stale the moment a piece, a registry entry or the writer changes, which is the Elementor problem. JSON is the source of truth; CSS is always derived. Site and page keep their current site.css / page.css for now; folding those in is later, not launch.

(The stale “Files: … container.css, module.css” line further down this doc is corrected — see § Files.)

Why the switchover needs layers, in plain words

Today a user’s module value wins because it is inline. Once it becomes a rule, CSS would pick the winner by selector strength — and a site-level “all buttons” rule, body [data-accid-type="button"] at (0,1,1), is stronger than [data-accid-id="btn-7"] at (0,1,0). The site rule would beat the user’s own setting. Layers settle it regardless of strength: module comes after site.

A positive control is a test built so it goes red if the layer is missing — which is what proves the test is really testing the layer and not passing by accident.

Settled corrections

!important is NOT a new behaviour change. An earlier note here claimed accid_grid.css‘s 21 module-settable !important rules would beat user values “for the first time — they’ve always lost to inline.” That is backwards and is withdrawn. A stylesheet !important beats a normal inline style — the classic reason people reach for it, and accid_grid.css‘s own comment says so (“!important throughout because the inline styles would otherwise win”). Those rules beat user values today and will do so identically afterwards. The height/max-height test stays as a regression check, not as a change alarm.

There are no “targets”. Superseded by the cascade ruling: no per-instance selector reaches inside a module at any level. Where a value lands inside a module is decided once, by that type’s reader CSS in layer(modules). The module pass is readers, not targets — box values plain on the owner, inner parts via the type’s readers.

MOOT as of the cascade ruling (2026-09-27). This asked what the target name for the container’s child slot (slot) would be. There are no targets — no per-instance selector reaches inside a module at any level. Where a value lands inside a module is decided once, by that type’s reader CSS in layer(modules), so there is no name here to get right.

1. The three hooks and the “one owner per selector” rule. The hooks: data-accid-id (which one), data-accid-type (which kind), data-accid-target (which surface — band, wrap, body). Every emitted selector names exactly one of them, and nothing to its left except the optional body[data-page] prefix.

Why it’s a rule: the bug the styleguide was built to find. Before, a module inside a container obeyed different CSS than the same module outside, because .container .module beats .module on specificity — nesting changed precedence, so where you dropped something changed how it looked. Flat selectors plus @layer fix that: precedence comes from the layer order (site < page < container < module), never from depth. A module’s rule is identical at depth 1 and depth 6. The rule is what makes “all modules are the same” true, and it’s checkable by grep.

2. Why “a var on the owner + a reader rule inside.” The emitter only ever writes to the owner: [data-accid-id="x"] { --nav-link-bg: … }. The nav’s own static CSS says .accid-nav-link { background: var(--nav-link-bg, transparent) }. Three things you get:

  • The emitter is one loop for all 24 types. It never learns what a nav looks like inside. Change nav’s markup tomorrow, the emitter doesn’t care.
  • The ladder works for free: set --nav-link-bg at the page rung, it’s on body, it inherits down to every nav link on the page. No selector needed.
  • The page renders with JS off — it’s all CSS.

The cost is exactly §2: every styled sub-element needs a var and a reader. That’s the work the type passes do, and it’s why P3 waits for them — an emitter writing vars nothing reads is “correct CSS that does nothing.”

3. The inline styles you’re looking at — not final. That style="…" on the owner is the JS emitter: scopeDecls → el.style.setProperty on the owner. It’s the temporary path until P3, and it’s the thing P3 deletes. Final pattern:

html

<div class="module-wrapper-v2" data-accid-id="navigation-…c23755" data-accid-type="navigation">

css

/* module.css, @layer module */
[data-accid-id="navigation-…c23755"] { --nav-direction: row; --nav-link-bg: #f00f0f6e; background-image: linear-gradient(…); }

No style= attribute at all. Same for the container’s gap:0;padding:0;--site-gap:0 — that becomes a line in container.css. The flex:0 0 100% on slots is the column layout from your ruler pins; that becomes a class or a var the container’s base rule reads. And the two stacked background-images plus background-color:#667eea — residue of the old keys, gone with the alias-on-write.

So the markup you pasted is the before picture with the new ids already on it. The after is three attributes and no style.


DOM LADDER

Doctrine as of 2026-09-20. Every line here is a ruling on SPI-558, 568 or 577; where a ruling changed an earlier line, the newer one is here and the older one is gone.


0 · Which doc binds

Ruled 2026-09-22. The chain is dated ruling on the ticket → a line in this doc → ACCID-RULES.md.

docstanding
ACCID-RULES.mdthe master
DOM-Ladder.md (this)working doctrine for 558 / 568 / 577 for now; its lines are rulings from those tickets, and it feeds RULES
SKILL_current.mda snapshot. Reference, not authority — it lags by weeks
SKILL-POV.md, POV_CRIB_SHEET.mdbinding for the POV domain only
LAYER-SYSTEM-FINAL-SPEC.mdsuperseded wherever it disagrees with this doc

Subagents read this doc and RULES as authority. The May spec is not their peer — it has been read as one, and it is four months older than the rulings it appears to contradict.


1 · One ladder, two filters

Three lists used to stand in for one. Side by side:

listmemberswhat it actually is
registry scopessite · page · container · modulewhere the panel lets you set a value
cascade @layer ordersite look category tag pov grouped page wrap container modulewho wins
targets (surfaces)docbody · band · wrap · slotwhere a background (or slot rule) paints
panel columnsSite rung · Page rung · Container rung · Type · idwhat you clicked

The one model: a ladder of places, in DOM nesting order, plus two filters.

place        element                       hook                             takes
──────────────────────────────────────────────────────────────────────────────────────
site         <html>                        :root                            every setting
page         <body>                        body[data-page="x"]              every setting + background (docbody)
band         .accid-layer                  [data-accid-target~="bluecheer"] background only
wrap         .accid-layer-wrap             [data-accid-target~="wrap"]      background only
container    a container module's owner    [data-accid-id]                  every setting
module       a module's owner              [data-accid-id]                  every setting

Inner beats outer, because inheritance and the layer order both already work that way.

Filter 1 — which pages: look · category · tag · pov · grouped. Not places; ways of selecting some pages by assignment. They sit between site and page in precedence (“all pages” < “these pages” < “this page”). A page’s own setting beats what its category says about it — that is why page comes after the filters in the layer list, even though in anatomy page sits right under site.

Filter 2 — which modules: the type filter ([data-accid-type="button"]). Applies at site and at page (and at the filter-1 rungs). Never at container — “all buttons in this container” would need a container on the left of the selector, and that is a depth selector. Parked.

Precedence and anatomy are two axes and cannot be merged. Every value is a pair: rung (who set it) × target (what it lands on). “Band background set at page” is page × band, written into page.css, and it beats site × band because page is later in the layer list. band is never a rung; page is never a target.

The one cascade is ACCID_RUNGS. The targets are ACCID_LAYER_NAMES plus slot. Both are data the bridge reads; the @layer statement is generated from the first. The word is site everywhere — the old global scope and the old global layer are the same rung, renamed.


2 · What replaces depth: layers

Values flow down by inheritance: a container sets --site-gap, every module inside gets it, a module outside does not, and the module’s own rule is identical in both places. That is the tag flow carrying values, and it is what “all modules are the same” means.

Precedence comes from @layer site, look, …, container, module, not from nesting. layer(module) beats layer(container) whether the module is at depth 1 or depth 6.

The rule: every emitted selector names one owner. [data-accid-id="x"], [data-accid-type="button"], [data-accid-target~="band"]. Never [container] [module], never .a .b. One exception, and it is not depth: a body[...] prefix is allowed at the page rung and the filter-1 rungs, because <body> is one element that every module is inside at every depth. body[data-page="about"] [data-accid-type="button"] narrows to a page, never to a position. A container on the left of a selector is still banned.

Worst case, resolved by layers not specificity: page value 4px, page-type rule 12px, a module’s own 30px. The module gets 30px because layer(module) beats layer(page), even though the page-type selector is more specific. Without layers the module would be stuck at 12px.

Machine guard: at most one space in any emitted selector; if present, the left compound is literally body plus attributes and the right compound is one of the three hooks. Any >, any second space, any non-body left compound: red. Runs with the suite.

Files: one per rung — site.css, look.css, …, page.css, wrap.css — each wrapped in its @layer. page.css holds every page’s rules under its own body[data-page]; there is no page-{slug}.css. The file says who set it; the selector says what it hits.

⚠ container.css AND module.css WERE LISTED HERE AND ARE CANCELLED (2026-09-27). That was the older plan, where the bridge writes a file per rung. Ruled otherwise: container and module rules are generated when the page renders, into one <style> whose blocks are @layer container { } / @layer module { }. A file per rung for these two goes stale the moment a piece, a registry entry or the writer changes — the Elementor problem — and there is nothing to gain, because pages already render in the browser. JSON is the source of truth; this CSS is always derived. The two rungs get a LEVELS entry and no FILES entry. See § Crib Sheet.

Hand-written CSS (paste box, project_styles.css, a look) is unlayered and therefore beats every rung. That is correct and expected. A user who wants a rule to rank inside the ladder wraps it in @layer <rung>.


3 · The three hooks

hookquestionladder columnwho sets it
data-accid-idwhich onemodule / containerthe renderer, from the store
data-accid-typewhich kindall <type>the module
data-accid-targetwhich surfacebackground’s cellsthe template (bands, wraps, body)

Plus body[data-page], body[data-look], and the other filter attributes on <body> — the page’s identity, read only through the body[...] prefix above.

  • CSS reads hooks; it never carries state. Hidden is the hidden attribute set by the renderer. If a module needs to behave (open, active, playing) that is an attribute the module’s JS sets and the module’s own type rule reads; the level sheets never see it.
  • JS reads stores; it never reads the sheets. The panel reads values from the store and gets CSS back from the bridge’s save response.
  • A new hook needs a new question. “Which one, which kind, which surface” covers everything the cascade needs. A hook that answers “what is it doing” is functionality and belongs to the module.

Ownership is for the cascade, not for editing. A container is a place: the child carries data-accid-parent, the container knows nothing about its children. Values flow down through it. Identity does not: a panel, tab or column draws for exactly one data-accid-id. The Container-rung column is the parent’s id, its own rows, its own values — never the child’s fields under the parent’s label.


3a · A container is just a container — the two passes

SPI-577, ruled 2026-09-25 17:14→18:48, approved 21:06, corrected 21:46 and 2026-09-26. Pass 1 landed 2026-09-26. Pass 2 is NOT built.

The paragraph above — “a container is a place; the child carries data-accid-parent” — was doctrine before it was true. It became true in pass 1.

Why there were ever two of everything

A container rendered its children as TEXT (container-module.js, the four slot copies). Only createModuleEl builds an element, and only elements can be given id stamps, data-name, cell background, scopeDecls, fonts, applyBoxAndLayout, spacing, maxWords, hidden and addEditControlsV2. So a nested module got none of that, and the COLUMN borrowed the child’s identity to compensate: it carried data-accid-id, module-wrapper-v2, the child’s styles and a second toolbar.

Measured on the styleguide the day it was fixed: .module-toolbar 176/176 on top-level modules and 0 nested; .container-child-toolbar 89/89 nested and 0 top-level. Two styling systems and two toolbars, split cleanly by depth, produced by one accident of construction.

Pass 1 — one render path (done)

  • The column keeps its address (data-container-id + data-child-index) and its width. Nothing else. No data-accid-id: that is for stored modules, and the module element inside carries it.
  • page-renderer fills each column with createModuleEl(child) after the container mounts — the fillArticleCell / fillPieceRefs pattern — in both view and edit.
  • Edits are tree operations by id on the page data, in HTMLModuleBlobber: deleteModule (recursive), insertAfter, replaceModule. Nothing walks the DOM for a parent, and nothing reads data-accid-parent — it is doctrine, not a lookup.
  • One writer for module scope: page-renderer‘s scopeDecls onto the owner. _childSlotStyle and hero’s <style>#id were both deleted; each was correct for the shape that existed when it was written, and became a duplicate when pass 1 removed that shape. tests/node/one-writer.mjs holds the line.
  • An _autoStack wears no chrome — it is structure (two modules sharing one column), not something a user placed.
  • An empty column says so with data-empty, and the blue outline keys on that. It used to sit on every column and be switched off for occupied ones by :not([data-accid-id=""]), which inverted the moment the id left: an absent attribute never equals "".

Pass 2 — the stack model (NOT YET)

  • Columns always stack. There is no column level and no column settings.
  • <accid-stack> IS the column — no class, no id, a column index only.
  • One _autoStack record per pin track in the data, which removes insertAfter‘s pinned-column special case entirely.
  • The Structure-tab fixes ride along.

⚠ A fresh session reading only the code will rebuild the column level. It has already happened once — the _autoStack styling of 2026-08. The code cannot tell you that the column is meant to stop being a thing; only this can.

3b · The layer gate — and the night the layers were innocent (2026-09-26)

The bands are z-bands, not flow. All four .accid-layer elements are grid-area: 1 / 1 in the single cell of #moduleContainer.accid-page, so they sit at top: 0 and overlap by design. A module in one band cannot push a module in another. This is stated here because it was doubted for three days.

Three bugs were blamed on the layers in one night. None were the layers.

symptomcausefix
“we are locked out of adding anything, in any layer”the blank-layer + calls showModuleTypePicker(btn, { order: 0 }), so afterModule.id is undefined; _locate never matched and insertAfter returned false silentlycf8d472
“the emptied layer won’t let me add back”its + rendered at document top −84px — 200px tall, centred in a wrap collapsed to 32px because both its children are absolutely positioned81a696a
“the header is pushed below the blue and purple layers”a column+wrap container reserving 205px of max-content height — see RULE 35ff9c78d

The measurement that cleared the layers, and the one to repeat before ever suspecting them again: zero every layer margin live and re-measure. Nothing moved — not one module, not the document height.

The gate: tests/node/layer-agnostic.mjs, two halves, because “agnostic” is two claims and the first alone passes on a night when everything is broken.

  1. No code names a layer — comments may (and accid-layers.css does, six times, all prose); the config may. Four sites of known debt are named in the suite, and it goes red both when a fifth appears and when one is fixed without updating the list.
  2. Every layer can accept a module — through the real HTMLModuleBlobber, once per band, with both shapes the editor produces: no anchor at all, and an anchor belonging to the template. This is the half that would have caught the add-below regression in seconds.

⚠ Three things still single out a band, and none is precedent (RULE 34): 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, so it covers orange sunshine’s 400 while you edit and sits under it when published); and the four debt sites above.

The gate (RULES 36 — no commit without it)

tests/browser/layer-gate.mjs · 12 cases, 37 assertions, run before every commit. Pairs with tests/node/layer-agnostic.mjs; the data half and the render half are both required — add-below landed in the data correctly while the + that starts it was 84px off screen.

#case
1-2every authored-empty band gets a +, inside its band and clickable when that band is active
3…and it appears the moment the last module is deleted, no reload
4-5the bands share one grid cell at top: 0; z rises with list position
6⚠ and view mode does not lift the active band — 2000 in edit, 300 live
7a module renders in the band its data names
8…except a PIECE’s modules, which follow the REF
9an unstated layer means the ORIGIN band, found by flag not name
10a band is identified by its data, not by its chrome
11data-layer survives edit → save → re-render
12data-shared is hatched on a page, ordinary on its own template

⚠ Case 3 needed a code fix to pass. The delete handler called moduleEl.remove() and never re-rendered, so makeLayerEl never ran and the blank-layer + was never built — the layer stayed unreachable until a reload. Add-below had re-rendered from the start; delete now does the same.

Known, and deliberately left

  • accid_grid.css is edit-only by which sheet loads, not by selector. The front end never loads it (measured: gridSheetLoaded: false, 0 toolbars, 0 content wrappers). That is weaker than a selector saying so — if the sheet ever reaches a published page, its rules arrive with it.
  • The generated pov-*.js outputs are NOT regenerated. definer.js‘s template is fixed (the POV card inserts before firstEl, now the module element); the ~20 outputs wait, because no generated lens runs on the styleguide — its povs is ['_accid'] and its pov-sourced catalogues go through accid-pov-render.js, a different mechanism. Regenerate when a styleguide page actually runs a lens.

4 · The registry: one declaration per type

What it is. Data, not code. accid-settings-registry.json holds the shared entries and the site/page/container entries. Each module type declares its own entries as Module.settings = [...] in its own .js, in one slot of the module template; a node step in the test harness generates a JSON sidecar beside each module (<type>-module.settings.json), committed with the .js, and the suite goes red if they disagree. The bridge reads central + sidecars; the client fetches one merged file that rebuild_derived writes. Node exists only in the test container — sidecars are generated at dev time and committed, never at runtime.

The module set is 24 + 1. Container · Text · Image · Button · Link · Navigation · Form · Code · Hero · Gallery · HTML · Markdown · Background · Spacer · Calendar · Search · Tabs · Expand · Box · Catalogue · Table · Date · Page Header · Insert Piece (piece-ref), plus article-cell, the template’s content slot (registered, never placeable). Everything else is retired. The palette, the manifest, the sidecar generator and the fence all read this one list; nothing names a type by hand.

Declaration vs instance. The sidecar says what a type can hold — key, control, group, scopes, prop. The perspective says what one instance is set to. Every image is checked against the one image declaration. The declaration is never stripped; the instance holds only what was set.

prop means “is a CSS property.” Either a custom property (--site-gap) or a plain one (max-width, aspect-ratio, filter). The bridge emits both kinds into the rung sheets; the cascade decides; nothing in product JS writes el.style for a declared key. prop: null means data — src, alt, content, items, tabs, piece, rulerPins, _autoStack, maxWords — and the module’s own JS is its only reader. There is no third kind. No inline applier survives.

A prop key means its CSS property on every type. No type-specific meanings: expand’s collapsed budget is its own data key, not height. Filters are one composite filter, all rungs, ordinary.

Stored data keys are unique across types; CSS properties stay job-named (ruled 2026-09-22). Three namespaces, three different amounts of power:

namespaceexampleendson rename
stored data keygalleryColumnsstore · registry · editor · fenceevery reader follows + migrate the data
CSS custom property--grid-columnsthe emitter writes · var() reads3 files, no migration
DOM attributedata-layoutthe module’s own CSS onlydon’t rename

A job word that two types would both claim gets the type prefix — gallerySource, galleryLayout, galleryColumns, catalogueSource, catalogueLayout, dateSource. Table keeps columns: it was the only one left holding it. This is the rule §4’s own line above already authorises (“rename overloads to a data key”), and the precedents are in the per-type tables: expand’s height → collapsed height, date’s display → format.

The property does not take the prefix. The ambiguity being removed lives in the word columns — table’s column definitions, gallery’s count, container’s ruler pins. --grid-columns is none of those three; it means “how many columns in a grid” whoever reads it. Prefixing would make three names for one concept, which is the problem the key rename solves in the other direction. Catalogue declares its own entry reading the same --grid-columns when its pass comes.

No transitional double-reads (Rule 26). The renamed keys and the stored data move in one commit. A consequence worth stating: with no two types declaring the same key, ENTRY(key) is unambiguous and a type-aware ENTRY(key, type) is not needed — the suite asserting that no two types declare the same key is the guard.

Composites — background, filter, text_gradient — are one setting with parts, one row in the panel, composed into one value. background carries a target (docbody, band, wrap, slot); the container’s cell background is background at target slot.

One key per property across every rung. textColor on a module and body_color at site are one property; the ladder only works when they share a name. Where two names exist, the one live at the highest rung keeps it and the other migrates (SPI-577).


5 · The store: only what was set

  • create() writes no style keys. id, type, layer, order, perspective, the data-source field, and the type’s data defaults. A new module inherits everything from the cascade.
  • × removes the key. It never writes a default back.
  • Defaults live in the base stylesheet, one place. Change a default and every untouched module follows. A default written into data is a default frozen at creation.
  • So the store is the diff. Stored means set. No “non-default” comparison is ever needed.

6 · The bridge: fence and emitter

Modules save themselves. ACCID_SAVE and the merge=1 callers all end at write_perspective. No module writes a file itself.

The fence, at the write. Per module, recursing into children: a key the type’s declaration does not offer at that scope, or whose onlyTypes excludes the type, is dropped and named. In the same pass the value is checked against the entry’s control (a length is a length, a colour is a colour, a select is one of its options) and hostile values are dropped and named. Response: dropped: [{ id, key, reason }]. Nothing silent. A refused look key rejects the whole write. The fence is a schema, not an audit: it stays because the writers keep changing. article-cell is never dropped from a template — its presence is what makes a template a template.

The emitter. The bridge composes the rung sheets from the stores — one declaration per stored value, scopeDecls ported to PHP — and writes them as derived artifacts, like perspectives-index. rebuild_derived regenerates all ten, empty ones included. The save response carries the regenerated sheet for the affected rung; the client swaps it in. No JS emitter, no <style> tags from JS, no write-css byte sink. The page renders identically with JS off.

Module data (poster, start, ads on a video) lives on the module’s entry in the perspective, declared in its sidecar with no prop, fenced by the bridge, emitted as nothing, applied by the module’s JS at render. The bridge never learns what a poster is.


7 · Modules

  • A module’s file defines; it does not run. No <style>, <link> or <script> injected into document.head; static CSS is a file the template links. No handler on document, no observer, no self-invoked render at parse. Everything a module does to the page happens in init(), called by the owner when an instance renders. Acceptance: every module loads under window = {}.

  • A catalogue is a listing, not a lens. It reads labels; a POV page reads the lens. No listing module loads a page’s skin.

  • responsive.css is static — generated at dev time from module-responsive.json (or written by hand), committed, linked. Not emitted at runtime.

  • Pieces are perspectives with post_type: "template" that hold no article-cell, beside pages. Insert Piece (piece-ref) points at one, from a page or a template. There is no template-piece type.

    ⚠ The discriminator is the missing article-cell, not the slug (corrected 2026-09-25; the line above used to say “and a tmplp- slug”). Shawn: “the pieces are created in the template editor — you create a new template but DO NOT use the ‘article cell’; that automatically drops that piece into pieces.” Measured on tomakeseed: 21 post_type: "template" perspectives, 15 hold an article-cell, 6 do not — and only three of those six carry a tmplp- prefix (orange-layer, templatetest2, pieces-text-white do not). No code tests the prefix; every hit for tmplp is a comment or one of piece-mount.js‘s two hardcoded defaults. The classifier is page-manager.js:962, and it is the only one in the system — accid-bridge.php contains zero occurrences of piece, article-cell or tmplp, so the server never classifies a piece at all.

    ⚠ Do not “fix” this with a naming rule. It was considered and rejected 2026-09-25: pieces are referenced by slug, so renaming is a data migration with reference rewrites; and a prefix would give one fact a second encoding that can disagree with the first (add an article-cell to tmplp-header and its name lies). RULES 25 and 28.

    ⚠ There is no slug field to key on. The slug is the FILENAME. perspective.slug survives on 44 perspectives as a retired key; everything downstream uses perspective.id (page-manager.js:46-48, “id-first: slug is being retired”), which equals the filename stem.


8 · Panel

Columns are rungs and say so: Site rung · Page rung · Container rung · Tabs · sg-tabs-041. The fourth column is the selected module — type and id, or its Name — and it is never empty: the id is a set value and draws as the first row. A container is a module and gets the full panel. Per-type rows draw from Module.settings in that column. The chip reads where a value came from (page rung · buttons).


Superseded (for the record)

  • “Site, page, look, category… are files, and a page in category X loads category-x.css” — no. One file per rung; the page or filter is a body[...] prefix inside it.
  • “No ancestor in any selector, ever” — amended: body[...] is the one allowed prefix.
  • “The 28 prop-less entries keep a client-side applier” — withdrawn. They have props now.
  • “Filters are page-only” — withdrawn. Ordinary CSS.
  • “Style keys are stripped from create() in each type’s migration commit” — one commit, first.
  • looks.css / data-content-look → look.css / body[data-look].
  • global → site.
  • “Delete Hero, Box and Page Header” (SPI-558 description, 2026-09-10) — withdrawn 2026-09-24 20:40. Hero and Box stay; Page Header stays, its home is template pieces. Only Background goes, at its turn.

SITE CHANGES

What genuinely collapses to one:

  • Fences: 2 → 1. _filterModuleForSave retires; the bridge is the only gate. Today both exist, which is why textColor can be dropped client-side without the bridge ever knowing.
  • CSS writers: 8 → 1. I just counted — eight files emit CSS today (base-module, page-renderer, accid-css-emit, accid-ladder, accid-level-style, accid-build-looks, accid-panel-v2, and the orphaned accid-style-panel). After P3 the bridge writes all ten rung sheets and the client emits none.
  • PHP registry parsing: regex → json_decode — one reader, accid-looks-validate.php, already planned.

The check that proves it landed is acceptance #11: load the styleguide with JavaScript off and it renders identically. That’s only true when the sheets carry everything, and it’s the one check nobody’s suite does today.

So: one source file, one authority, and a panel that has opinions about layout and none about truth.


Per-type settings — the module column, by type

Source: _ADMIN/spi-577-mapping.md §3 (old panel labels and controls), plus rulings on 577. Shared rows (Identity, Dimensions, Flow, Box, HTML, then Typography/Headings/Links/Background/Effects) come first on every type and are not repeated here.

This section is the live copy. _ADMIN/recipeformodulesupdate/Ingredients.md is a pointer here (2026-09-24); it used to hold an older copy of these rows.

Rule (corrected 2026-09-20 06:42): a per-type setting exists if the module reads it. Whether anyone has stored a value yet is irrelevant — a control nobody has used is still a control. Only two things are dropped: keys nothing reads (residue) and editor/runtime state (disabledFields, spatialX/Y, currentIndex, _autoStack, rulerPins stay stored but draw no row).

[data] = no prop, applied by the module. [css] = has a prop, goes through the cascade.


Text

  • Content — rich text (inline editor) [data]
  • Format — Convert to Markdown (structural action)
  • Max words — number [data] (truncation)
  • Waypoints — structural editor [data]; Show waypoints — toggle [data]; Info terms — list [data]
  • Image URL, Image caption — text [data] (the text-with-image variant; keep only if the renderer still reads them — 6 and 1 readers, so yes)

Image

  • Change source — media picker (structural hook)
  • Caption — text [data]
  • Sizing — select [data] (sizingMode)
  • Fit — select [css: object-fit]; Position — text [css: object-position]; Aspect ratio — text [css: aspect-ratio]
  • Alt — text [data]
  • Tags, Categories — token entry [data] (0 stored, 16/14 readers: the taxonomy fields, keep)
  • dropped: backupUrl, cloudUrl, localPath, imageData, liveSiteUrl — storage plumbing, not settings; Codey confirms readers are dead paths

Button

  • Label — text [data]
  • URL — url [data]
  • Size — select [data]
  • Target — select [data] (_self / _blank)
  • Text — text [data]
  • URL — url [data]
  • Target — select [data]
  • Underline — toggle [data]
  • Items — structural editor (list of label + url) [data]
  • dropped: navStyle (1 reader, 0 stored — Codey says whether the reader is live; if so keep as select)

Container

  • Label — text [data]
  • Columns — the ruler/pin GUI (structural; rulerPins, _autoStack stored, no rows)
  • (Padding, Flow, Box come from shared rows)

Box

  • Title — text [data]
  • Content — textarea [data]
  • Title colour — colour [css: --site-h3-color] (rejoined heading vocabulary per 1a)
  • Box colour — colour [css: background.color]

Hero

  • Headline, Subheadline, Tagline — text [data]
  • Headline / Subheadline / Tagline size + colour → shared heading rows (h1 / h3 / h5) per 1a; no per-type rows
  • Background image — media [css: background.src]
  • Logo — media [data]; Logo side — select [data]; Logo size — text [data]; Logo pad — text [data]
  • Source — select [data] (Chosen photos / My articles) — key gallerySource
  • Images — structural picker [data]
  • Layout — select (Slideshow / Grid / Masonry) [data] — key galleryLayout
  • Columns — select 1–6 [css: --grid-columns] — key galleryColumns. Spec said “number”; the panel has no number control (its set is color/length/text/token/toggle/select/font/background/range), and the options are the 1–6 clamp gallery already applied by hand.
  • Show captions — toggle [data]
  • Thumbnail size — select [css: --thumb-size]. The default moved into the CSS: thumbnailSize had its 80px default in create() alone, so every already-stored gallery interpolated the string undefined into its width and the strip rendered full-width. A default in a constructor protects only what the constructor makes.
  • Auto-advance — toggle [data]; Interval — number [data]
  • Include — select [data] (indexTypes); Filter — text [data]; Limit — number [data]; Link to article — select [data]
  • dropped: captionSource (residue), currentIndex (runtime)

Catalogue

  • Title — text [data] — key catalogueTitle
  • Source — select [data] — key catalogueSource; Terms — text [data]
  • Per page — number [data]
  • Layout — select (Cards / List / Compact) [data] — key catalogueLayout
  • Show date — toggle [data] — key catalogueShowDate; Show excerpt / Show image — toggles [data]

⚠ The DOM attributes data-source / data-layout keep their names — they are labels read by catalogue’s own CSS, not stored keys. catalogueSource: "pov" is the POV term-page pour branch; renaming the key moved the branch with it.

⚠ catalogueTitle and catalogueShowDate are prefixed because the bare words belonged to other types’ stored data — title to box (26 modules), showDate to calendar (6). Declaring the bare words here had armed their deletion on the next save. RULES 31; fixed in bd35427. Five of the eight keys are prefixed and three (terms, perPage, showExcerpt, showImage) are not — those are single-owner today and are protected by the guard, not by a prefix (RULES 25: a new name is only for a thing with no name).

⚠ Item classes are accid-catalogue-item--card|--list|--compact. The bare words collided with layout_class, whose default card sits on 684 stored modules.

Layouts, all three verified live 2026-09-23 (switched, observed, not saved): Cards = 4-up grid, thumbnail above · List = full-width rows, thumbnail left, date right · Compact = one line, title + date, no image or excerpt. Only cards is a grid; list and compact are flex columns, so a column count must not be offered there. Shawn: “I feel like compact and list are mis-named but it’s fine” — the stored values stay; if it ever moves it is a labels-only change.

Form

  • Fields — structural editor [data]
  • Action — url [data]; Method — select [data]
  • Submit text — text [data]
  • Success message — text [data]
  • Processor config — structural [data]

Tabs

  • Tabs — structural editor (title + content per tab, add/remove) [data]

Expand

  • Content — textarea [data]
  • Show more label / Show less label — text [data]
  • Collapsed height — length [data] (renamed from height, 1a)

Markdown

  • Content — markdown editor [data]
  • The --mddoc-* vocabulary (body, headings, callout, code, caption, figure, table…) [css] — ~40 rows, drawn as its own group, settable at any rung (a look, a page, this module)

HTML

  • Content — code editor [data]

Code (code-screenshot)

  • Title — text [data]; Description — text [data]
  • Hotspots — structural editor [data]
  • dropped: imageUrl if no live reader (6 readers listed — Codey confirms)

Background (module)

⚠ Retiring (SPI-558, 2026-09-24 20:40). At its turn in the order, each stored Background is converted to a container with the same background setting, then the type is deleted. It predates containers and no longer has a purpose. Until then the rows below describe what it reads.

  • Type — select (colour / gradient / image / video) [data]
  • Colour — colour picker [data]; Gradient — gradient picker + presets [data]; Image URL — media [data]
  • (its opacity is the shared Effects row)

Spacer

  • Label — text [data]
  • Height — shared Dimensions row [css: height]
  • dropped unless read: auto, match (5 / 14 readers — Codey confirms live; if live, keep: Auto-size toggle, Match target)

Calendar

  • Calendar source — url [data]; View — select [data]
  • Show title / date / nav / print / tabs / calendars / timezone — toggles [data]
  • Timezone — text [data]; Week starts — select [data]
  • Colour, Bg colour — colour [data] (embed parameters)
  • Placeholder — text [data]
  • Max results — number [data]

Table

  • Caption — text [data]
  • Columns / Rows — structural editor [data]
  • First row headers — toggle [data]
  • Striped / Bordered / Compact / Sticky header — toggles [data]; Align — select [data]

Date

  • Source — select [data] (renamed from source, 2026-09-22); Fixed date — date [data]; End date — date [data]
  • Field key — text [data]
  • Format — select [data] (renamed from display, 1a); Relative — select [data]

⚠ Both of date’s renames were missed in the panel, which is the third place a rename has to land — the module, the stored data, and the editor that writes the key. See Rule 26.

  • Include time — toggle [data]; Locale, Timezone — text [data]
  • Prefix / Suffix / Range separator — text [data]

Home: template pieces (SPI-558, 2026-09-24 20:40). Page Header stays a module type, but it is used inside templates — the header piece — not placed on pages. Its type pass runs normally.

  • Show title — toggle [data] — key headerShowTitle
  • Show categories / tags / perspectives — toggles [data]

⚠ headerShowTitle is prefixed and the other three are not, and that is the ruling. The step-0 sweep found calendar storing showTitle on 6 modules with its own live toggle. Declaring the bare word here would have fenced it to page-header and deleted all six on the next save — catalogue’s trap exactly (RULES 31). The declaring side moves, the victim is untouched. The other three are single-owner and are protected by the guard, not a prefix (RULES 25). Proven by demonstrated reversal: declaring the bare key names calendar.showTitle as the victim; prefixed, zero.

⚠ It reads the page being VIEWED, not the document it is stored in. render() had data.perspective || window.ACCID_CURRENT_PAGE; the precedence was backwards. data.perspective is the module’s own back-reference, stamped at insert — on an ordinary article both are the same string, which is why this was invisible. Shawn, 2026-09-25: “I could not make it a piece and have it still read what page it is on.” Inside a piece the insert stamped the PIECE’s slug, so the header rendered the piece’s own label on every page including it. All four stored instances were unaffected by the flip — template-default‘s was '' (which is why it works in templates today, by accident) and aiko-tanaka‘s equalled its own page.

⚠ Offered in the palette only inside a template or a piece (TEMPLATE_ONLY_TYPES, creator.js). Shawn: “how do they get it back if it is NOT part of the modules list? Is it ONLY listed when they are in the template section?” — yes, and that is better than article-cell‘s permanent hiding, which can only be undone by hand-editing JSON. A conditional entry cannot be lost.

The rule it establishes: a type whose meaning depends on the document it sits in is offered only in documents where it has meaning. The palette filter is a GUARD, not the fix — it stops new mistakes; the precedence flip repairs the mechanism.

→ FOLLOW-UP, ruled 2026-09-25: article-cell gets the same treatment. Templates only, and only when one is not already present — at which point it can stop being permanently hidden. Shawn: “yes, follow up with article-cell, same rules, once we get header working correctly.”

Insert Piece (piece-ref)

  • Piece — select of pieces [data], key piece. The list is every template with no article-cell; not a tmplp- prefix match.

Step 0 is done: _ADMIN/spi-577-piece-ref-step0.md (2026-09-25). All four reported faults were measured and fixed the same day — 506868d (the table) through ea1700a. What the table found, kept here because the hypothesis it replaced was wrong in a way that would be re-derived:

⚠ The hypothesis said “three ways: template slot, insert-piece module, layer piece.” There is no layer-piece mechanism. accid-layers.js mentions “piece” three times and all three are prose; module.layer names a band, never a reference. The orange-layer / orangesunshine resemblance is a coincidence of naming. And the template slot (dropper/_templates.json) is how a page template is assigned, not how a piece is referenced. The real list is one key, piece, reached by four different render paths — top-level, nested in a container, travelling in from a template, and piece-mount.js‘s markup tag on the seven view documents.

⚠ Not one of the four faults was in the renderer, and that is the lesson. (1) create() dropped the chosen slug — 54e8720 deleted the line on 2026-09-20 while de-stamping, five days dark. (2) Nested is a different renderer: the container stringifies, piece-ref’s string form is an empty placeholder, and reRenderContainer never filled it. (3) ACCID_PIECES was built once per page load and never after an insert. (4) The usage counter walked only post_type: 'template' files, so a piece referenced from an article read as unplaced — four of six pieces were mislabelled, not one.

⚠ piece-ref is the only type whose string form is not its content. Every other type renders completely from Module.render(); this one emits a placeholder that fillPieceRefs fills with real elements. Three repaint paths had to learn that separately. If a future type does the same, it inherits the same three fixes.

Editor: the empty-state box (below) is its control today — there is still no Piece row in a panel, because piece is declared in neither settings JSON. That is step 1/5 when this type’s pass resumes.

article-cell — offered in a template that has no cell yet

  • Content slot — nothing to set

⚠ This heading used to read “template only, no palette entry” and BOTH halves were false. Measured 2026-09-25: article-cell was in neither HIDDEN_TYPES nor the admin- prefix, so the palette offered it on every document, articles included, where a content slot means nothing. The doc described an intention; the filter never implemented it. When page-header’s conditional entry landed I cited this as “the permanently-hidden precedent” — there was no such precedent.

Now gated on three conditions (creator.js, TEMPLATE_ONLY_TYPES + canTakeACell), and the second and third are what page-header does not need:

  • post_type === 'template' — an article has nowhere to put one
  • not a piece — a piece IS a template with no cell, and since 2026-09-25 that is a stored declaration with a structural mismatch report beside it. Offering the cell inside a piece is offering a one-click way to contradict the document’s own declaration.
  • no cell already present, at any depth — the cell is where the article goes; two of them is a question with no answer and page-renderer picks one. All 15 real templates hold exactly one today, so this keeps an invariant rather than inventing one. The count recurses, because template-default keeps its cell one level down inside a container.

Guarded by tests/node/palette-context.mjs, which runs the shipped filter rather than restating it — including an assertion that this section and the filter agree, since they did not for two months.


Controls the panel needs to draw these (SPI-568)

controlused bytoday
colour picker with alpha slider, writes #RRGGBBAAevery colour rowmissing — text field
gradient picker + the preset grid (Clean / Radial / Glass / Dark / Light / Wild)background.gradient, Background modulemissing
media pickerimage source, hero bg, logo, gallery, calendar srcmissing in v2
code / free-text areaHTML content, custom CSS paste, form processor configmissing
toggle~20 rows aboveexists
select with empty first choicemanyexists
structural hooks (tabs, form fields, nav items, table, hotspots, waypoints, columns)per typemodule-supplied, per Structure ruling
live feedback: the value applies on change, the chip shows the rungevery rowchip exists; on-change apply is P3’s save response

PORTABILITY — the constraint behind the next three tickets

Ruled 2026-09-26. ACCID beams content out to static sites through a pipeline of server-side stages — images pulled from imgbb, the article built, copy added — and exports JSON to React / Next / Astro as starter projects. Nothing may depend on the builder’s DOM, or on a module’s parent.

  • The CSS generator is a PURE function. Page JSON + registry + targets in, CSS text out. No DOM, no window, no BaseModule global. It carries a node test that runs it over the styleguide JSON and snapshots the output.
  • Targets are plain data — JSON per type, or one targets.json. Never code inside a module class.
  • New view-render code is DOM-free: JSON in, HTML string out. ⚠ Making page ASSEMBLY (createModuleEl and friends) DOM-free is PARKED for the beaming work — do not do it now, and do not make it worse.
  • Every image URL lives in the JSON as a value, never baked into render output, so a pipeline stage can rewrite it. The no-inline-styles rule already covers hero’s background.
  • data-accid-id stays on every module in view and static output.

Vocabulary, fixed: levels = site / page / container / module. Targets = where inside a module a key lands. ⚠ Never call targets “templates” — that word is taken three times over (ACCID_TEMPLATE, template perspectives, the templates layer).

What the unlayered sheets actually are — RESOLVED 2026-09-26 (7be767f)

A sweep for “unlayered CSS beats everything layered” found four candidates. The first read said three were deliberate and must not change. That read was correct about the code and wrong about the future, and three of the four then moved. Both states are recorded, because the reasoning is the point.

sheetbeforeaftertouches module-settable props?
responsive.cssunlayered, responsive-css.mjs:183 asserted no @layer@layer modules, and the assertion is inverted⚠ YES — the only one
accid-filter.cssunlayered — search’s own #accid-filter-bar@layer modulesno
view-nav.css@layer decoration — above module@layer modulesno
accid-palette.cssunlayered on purposeunchanged, still unlayeredno — two :root blocks, 0 non-token declarations

⚠ THE REASON INVERTED, AND THAT IS WHY THE FIRST READ LOOKED RIGHT. While module settings were inline, unlayered outranked every layer but still lost to inline — so a user’s own module value won and the floor held against everything else. “Unlayered on purpose” was a true statement about that world. After the switchover module settings are @layer module rules, and an unlayered sheet would beat them and lock a user out of their own module. The floor has to sit BENEATH the rungs, so it moves to modules — the last rung-free layer in LAYER_ORDER.

The cost, accepted: all three now lose to layer(project) and to the site/page rung sheets, where before they beat every layered sheet.

accid-palette.css stays out because the argument never applied to it: it declares only tokens, so there is no competing rule for it to outrank and no rung it can beat.

The check is the 420px/1400px fingerprint (tests/live/_fp2.mjs): the styleguide is identical at both widths on all three floor targets, and every responsive target still flips.

⚠ The nine view pages have no layer order at all — their own ticket

brains/accid-views/*/​*.html (grid, grouped, timeline, spatial, mystery, location, search, universe-map, swim) declare no @layer statement, and grid.html:263 carries an inline unlayered * { margin:0; padding:0 }. So on those pages unlayered beats layered, and both shared sheets above are dead:

view-grid@1400  .view-nav a        6px 16px → 0px   dead since SPI-558 layered it
view-grid@1400  #accid-filter-bar  8px 16px → 0px   dead since 7be767f
view-grid@1400  .afb-trigger       5px 14px → 0px   (width 82 → 54)

Do not fix this as a side effect of a layering commit. Ruled by Shawn 2026-09-26: “the view pages are their own ticket — hopefully once we get styleguide set, what needs to happen with them will be clear.” The likely fix is one AccidCssEmit.layerStatement() per page, but it waits on the styleguide landing, because what these pages need may change once it does. See [[styleguide-is-the-only-surface]].

And the !important census, because 108 sounds worse than it is

accid_grid.css holds 108 !important declarations. An important declaration beats a normal one regardless of layer, so these were the other candidate for wrecking level order. Measured against the registry’s 21 non-var CSS properties:

touching a module-settable property:  21
    opacity 6 · width 4 · position 3 · max-width 3 · max-height 3
    overflow 1 · height 1
chrome / layout only, no registry key: 87

21 sites, not 108. That is a list to audit, not a survey to fear.


One registry, two deliveries — how a setting reaches the page

Written 2026-09-26 because Shawn asked and the answer is not guessable from either half alone. He described the model as: “a background setting exists, all we have to do is stick #module-id .background { … } in front of it and it gets written into the CSS.” That is exactly right for two of the four rungs and not how the other two work at all — and the gap is the reason hero’s background painted the editor.

rungwhat a setting becomeswho does it
sitea CSS rule in an emitted sheetaccid-css-emit.js → emitLevel()
pagea CSS rule in an emitted sheetsame
containerbare declarations in a style attributeBaseModule.scopeDecls() → cssVar()
modulebare declarations in a style attributesame
/* site / page — a selector, exactly the model */
body[data-page="x"] [data-accid-target~="bluecheer"] { background: … }
body [data-accid-type="button"]                     { --site-body-color: #123 }
<!-- container / module — no selector; the declarations go ON the element -->
<div data-accid-id="btn-7" style="--site-body-color:#f00">

WHY THE BOTTOM TWO ARE INLINE, and it is deliberate. accid-css-emit says it in place: “The instance wins because it is inline on the element.” Inline beats every rule regardless of specificity, so “this module overrides the site” needs no specificity arithmetic, no !important, and no ordering discipline between five stores. That is a real benefit and it is why it was built this way.

⚠ AND THE COST IS THE ONE THAT BIT US. An inline style can only ever reach the element carrying the id. It cannot say “…and put it on the .module-hero inside.” So when a module’s owner grew to contain the module’s own settings bar — 733px around a 300px hero — the background painted the editor. Measured 2026-09-26; Shawn: “the bg colour is filling the entire area — not just the hero itself?” A selector would have been immune; inline could not be. See RULES 33 (amended) for the bar, and RULES 35 for why nothing looked like it was “setting” a height.

THE PLANNED RESOLUTION IS P3 — “the bridge emits the level sheets.” Moving module scope from inline declarations to emitted rules would let a type say which element inside it a key lands on. The cost is that the inline-always-wins trick has to be replaced by ordinary cascade order, which is why it has not happened yet. ⚠ Do not do it as a side effect of a type pass.

WHAT THIS MEANS FOR A TYPE PASS. Prefer master-list keys; invent as few module-only ones as possible. It is working — measured on the styleguide, of hero’s 368px settings bar 319px is shared registry rows and 49px is hero’s own; spacer owns exactly one key (label) and borrows one (height). A type that needs a private key for something the master list already names is the finding, not the feature (RULES 25, 31).


Recipe

The type pass — recipe

Feed Codey: “Run the type pass on <type>. Recipe and rows: _ADMIN/DOM-Ladder.md (§ The type pass — recipe; § Per-type settings).” One type per commit. Tally = one row (§8). No design; fill in the blanks.

This section is the live copy. _ADMIN/recipeformodulesupdate/Recipe.md is a pointer here (2026-09-24); it used to hold an older copy without the step-0 collision sweep.

Scope: all shared code, tomakeseed data only (the styleguide lives there). No other tenants — other *-accid projects, seed copies, FRIENDS, WAYPOINT_STANDALONE, clickety — unless Shawn names one (SPI-558). Reads from the tree, never from memory.


0 · Inventory and the collision sweep (read-only — then its own commit)

grep -n "fields\s*:" js/modules/<type>-module.js          # old whitelist
grep -n "function panel<Type>" js/bottom-bar-panel.js js/accid-style-panel.js
grep -n "el\.style\|\.style\.\|style=\"" js/modules/<type>-module.js     # inline writes
grep -n "data\.\w\+\|opts\.\w\+\|module\.\w\+" js/modules/<type>-module.js | sort -u   # reads

Produce two lists before touching anything:

  • DATA keys — read by the module, not CSS. (§ Per-type settings [data])
  • STYLE keys — reach the page as CSS. (§ Per-type settings [css]) Anything read by nothing: residue, delete. Editor state (spatialX/Y, rulerPins, _autoStack): declare, never draw.

Then sweep four namespaces, and land the fixes as their own commit

Ruled 2026-09-23 (Shawn: step 0 goes first, as its own commit). On catalogue this step found live data loss, armed, in three types — see RULES 31. It is not a formality and it is not ten minutes.

  1. Data keys — declared and fields[]-only. For every key the type is about to declare, grep every other module’s fields[] for the bare word. ⚠ A type does not have to declare a key to store one, and the fence does not care: declaring it here deletes it everywhere else (RULES 31).
  2. CSS classes. Any bare word the module emits as a class. ⚠ card is the default layout_class for every module wrapper — 684 stored modules — so a bare .card is a collision by construction (RULES 28, class half).
  3. DOM ids. What the module emits and who else emits or reads it. Distinguish shared by design and guarded (pov-{type}, co-emitted by 34 lens scripts, both sides guarded) from sole owner with no readers (free to fix) from unnamespaced and load-bearing (a bare slug as a document id — record it, do not casually change it).
  4. Globals. One owner each.
# the fields[] extractor that already works — steps over BOTH spellings in the tree
/fields:\s*(?:\(?window\.BaseModule\s*\?)?\s*\[([\s\S]*?)\]/

⚠ A pattern wanting [ straight after the colon matches neither spelling and reports every type as clean; that mistake produced 56 false findings. Carry panel-keys.mjs‘s canary — assert the extraction finds a key you know exists — before trusting a clean result, because every assertion here is “nothing collides” and a broken extractor makes them all green.

tests/node/key-collisions.mjs is the guard and now reads fields[]. If a rename falls out of this, the data migrates in the same commit (rule 26), with a before/after key-set audit per file that aborts on any unexpected change.

1 · Declare — Module.settings in <type>-module.js

js

settings: [
  // DATA — fence allows it, panel never draws it, module edits it
  { key: 'items',        emit: 'none', scopes: ['module'] },

  // STYLE, type-specific — registry entry with a prop; type tab + on-module renderer
  { key: 'nav_link_bg',  prop: '--nav-link-bg', control: 'color', group: 'Links',
    scopes: ['site','page','container','module'], label: 'Link bg' },
  { key: 'nav_align',    prop: '--nav-align',   control: 'select', options: ['start','center','end','space-between'],
    group: 'Layout', scopes: ['site','page','container','module'], label: 'Align' },
]

Rules: no onlyTypes (generator adds it) · every colour control: 'color' (alpha comes with the swatch) · composite parts follow background · a plain CSS property is a valid prop ('max-width') · prop on every type means the CSS property — no type-specific meanings (rename overloads to a data key).

Then: docker run … node tests/node/module-settings.mjs --write (with --user), settings-inventory.mjs --write.

2 · Read the vars — the type’s base rule, one CSS file

In css/modules-<type>.css (or accid_grid.css where it already lives), every styled element and sub-element reads its var with the shipped literal as the fallback, so unset is byte-identical:

css

.accid-nav-link { background: var(--nav-link-bg, transparent);
                  padding: var(--nav-link-padding, var(--site-padding-y, 6px) var(--site-padding-x, 10px)); }
.accid-nav-submenu { background: var(--nav-dropdown-bg, #fff); }

Check every sub-element (links, buttons, tabs, inputs, cells, captions): a styled sub-element with no var is a missing key → back to §1. Flow: the content box reads --flow-* (display: var(--flow-display, block) …).

3 · Delete the private applier

Every el.style… / inline style="…" for a declared key in <type>-module.js goes. Only BaseModule.cssVar(...) on the owner remains (until P3). Guard: grep -c "el.style" <type>-module.js → 0 for declared keys.

4 · create() clean

Writes: id, type, layer, order, perspective, …addDataSourceField(options), and data keys with a meaningful default only (content: 'Click to edit'). No style keys, no '', no [], no disabledFields, no bgGradientColor.

⚠ key: options.key || '' is TWO things — a stamp, and the only writer of a value passed in at insert. Remove the stamp, keep the pass-through:

...(options.key ? { key: options.key } : {})

It stores the value when one is supplied and stores nothing when it is not, which is what “stored means set” actually wants.

Measured, 2026-09-25. 54e8720 removed both halves from piece-ref — piece: options.piece || '' — while de-stamping 80 keys across 18 types. Its eligibility rule was “old create() wrote it AND new create() does not”, which is about the value, and could not see that the line was also the only writer. Insert Piece was broken for five days: the chooser resolved a slug, creator.js:620 handed it to create(), and create() returned an object without it. The module rendered nothing and showed no chrome, so it read as “nothing was inserted” and three separate investigations went to the insert and render paths. Ten empty references accumulated on disk before anyone could name the cause.

Guard: a per-type suite that calls create() with each key a caller can supply and asserts it is stored. That suite would have gone red the day 54e8720 landed. ⚠ It asserts the pass-through, not the removals — Shawn, 2026-09-25: “this is why we are going through the modules — to find if we removed things we should not have; if so we re-add them. That list is not final yet, so what is removed is fine until we find out we need it.” So a key missing from create() is not a finding; a key that a caller passes and create() discards is.

5 · The editor — on the module (data keys)

Lift, don’t rewrite: move panel<Type>() and its helpers from bottom-bar-panel.js into <type>-module.js. Change only:

  • host: renders inside the module’s edit chrome (ACCID_EDIT_MODE only; never in view render)
  • handlers: inline onclick="<type>SetX('${id}', …)" calling globals → _<type>Get(id) → mutate → HTMLModuleBlobber.updateModule(id, mod) (gallery pattern)
  • blank clears the key (delete mod[key]), never stores '' Count controls before/after (inputs + buttons): equal, or the row says what changed and why. Then delete panel<Type> + its case '<type>':.

The default shape (ruled 2026-09-23, 577 05:16). Catalogue’s cx-bar is the reference: one or two rows directly above the module, full width, label → input, the module’s own data only (style is the panel’s), ordered as decisions are made — what it is → where it comes from → how much → how it looks → what each entry shows. Structural list editors whose data is a structure (nav, tabs, table) are the justified exception. A picker inside the bar uses its compact form (e.g. the shared term picker at size: 'compact').

Ask the SPI-592 question — record the answer, don’t enforce it. At rest in edit mode, does this type show its published output plus chrome (decorates), or does its editor replace the output? Record decorates / replaces in the tally row. It is not a rule until SPI-592’s survey decides — see ⚠ A HOPE, NOT A RULE below.

Two rules every editor obeys (added 2026-09-23, before catalogue so every remaining type pass sees them):

  • Find a module by accidOwner(id). Never closest('.module-wrapper-v2'). Only a TOP-LEVEL module has a wrapper of its own; a nested one’s id sits on its .container-child-slot, so that walk lands on the ANCESTOR’s wrapper. Measured live 2026-09-22 three times in three files: reRenderModule (a duplicated gallery with no delete button), setFocus (the article-cell got raised, so the page’s h1 was armed and every keystroke went into “ACCID styleguide”), and glow() (a nested container’s outline skipped to the outermost one). Enforced by tests/node/selection-owner.mjs — add the type’s file to SELECTION_PATH if it selects or focuses anything.
  • An editor reads the STORED DATA, never the page’s HTML. Reading innerHTML back gives you the browser’s normalised markup, not what the author wrote — SPI-579’s fingerprint. Test: open the editor, change nothing, save → the stored content is byte-identical.

Style keys on the module — ⚠ being replaced (2026-09-24, in flight). The ladder’s row renderer is moving into the runtime as a pure function: registry entries + current value → HTML, in slot mode — rows only, the inherited value shown dimmed with its source in the tooltip, × to clear; no rung chip, no ladder expansion, no “Add a setting”. The module’s own render calls it, so the rows are part of the render output — no separate filler, no load-order race (the registry is loaded before the first render; that is an invariant). Do not add new callers of AccidLadderPanel.render for module slots; this paragraph gets the new call when it lands. The ladder itself — rungs, provenance, adding settings — lives only in the panel (STYLE).

6 · Identity

Root stops stamping data-module-id / data-module-type; any reader in the module uses closest('[data-accid-id]'). Fix the double-prefix id="…" if the type has it (hero, gallery, catalogue).

7 · Walk + load

  • accid-panel-v2.js types(): add the type to the walk (the tab draws itself from the declaration).
  • module-load.mjs: loads under window = {} — nothing runs at parse; everything in init().
  • Old bump bar: the type’s case gone; the bar must not open for this type.

8 · Verify and tally

  • ./tests/run.sh — count in the message, only goes up.

⚠ Run the FULL suite once per type pass, at the end — not per commit (Shawn, 2026-09-25: “considering full suite takes like 20 minutes can we only run it once at the very end? … for each module”). The browser tier is ~20 minutes and most commits in a pass touch no browser surface at all. Per commit: ./tests/run.sh node, which is seconds. The full run is the pass’s sign-off, and its count is what goes in the last commit message. A commit that changes the bridge, a panel or a renderer is the exception worth running early — that is where the browser tier actually has an opinion.

  • Type suite: declared keys present · vars read · no inline writes · editor renders in edit not view · add/remove/edit round-trips through updateModule · type tab lists exactly the type’s style keys · shared page has none of them. Broken once (say so in the row, don’t narrate).
  • Pixel diff on the styleguide: null, or each region listed with the key.

⚠ NEVER git checkout A FILE TO UNDO A DELIBERATE BREAK. It reverts to HEAD, which throws away every uncommitted change in that file — not just the break. This cost work twice in one session (2026-09-23): once on the regenerated registry, once on the whole of catalogue’s commit-2 CSS. The second time was worse than losing the edit, because the next break then ran against the original rule and passed for the wrong reason — a green that looked like proof. Copy the file first and copy it back:

cp "$F" "$CLAUDE_JOB_DIR/tmp/f.bak"   # break, run, then:  cp "$CLAUDE_JOB_DIR/tmp/f.bak" "$F"

A fix to a live fault is proved by a demonstrated reversal, not by a green suite. Write the assertion first, run it on the unfixed code and record the failure by name, then fix and run it again. “16 passed, 0 failed” is worth nothing on its own; “these four named victims failed, and now they don’t” is the proof. Catalogue’s step 0: box.title, calendar.showDate, calendar.color, code-screenshot.title → 0 failed.

A break-once must use the case the change is for. If the old and new code produce the same result on the test’s input, the break cannot bite. Gallery’s columns (2026-09-24): with a value stored on the module, the old inline style and the new property agree, so restoring the inline style stayed green. The case that separates them is the one the change fixed — nothing stored, --grid-columns inherited from a parent — where the old code gives its own default and the new code gives the inherited value.

Test pages load the real registry. A browser fixture without the merged registry tests a world the product never runs in: the emitter returns nothing and a working feature looks broken (gallery, 2026-09-24). Fixtures get the registry by default through the shared helper.

Checking a control live does not require saving. Shawn’s method, 2026-09-23, and it is better than the alternatives it replaced. Switch the control on the real page, observe every branch, and back out without saving — then prove the backing-out: _saved_at and the stored values must be identical before and after. It exercises the real render path across states that have no stored instance, needs no fixture data and no migration, and leaves the surface byte-identical. It closed catalogue’s list/compact coverage gap, which had otherwise been a choice between carrying a data change and reporting the branch as unverified.

  • Row: <type> · 1 ✓ (n data, m style) · 2 ✓ lifted, N controls → N · 3 ✓ (vars) · 4 ✓ · 5 ✓ · 6 ✓ · 7 ✓ · 8 ✓ NN suites, diff null · edit: decorates|replaces

Then Shawn signs off on the page: select one, see the tab, the module editor, and one style change land.



One dataset, three projections — the views unlock (SPI-588, measured 2026-09-23)

Shawn, on the /all pages: “that makes this another version of the views pages — same data rendered a different way. Solve it here and hopefully it unlocks views.” Recorded here because the measurement says he is right, and says what has to be true first.

Today the same fact is read from three files, in two vocabularies.

consumerreadsprojection
catalogue, term pagemanifests/{cat\|tag}-{term}.jsonposts carrying one term
every view — grid, grouped, timeline, searchperspectives-index.json → ACCID_PERSPECTIVESall posts, filtered client-side
the /all pagestaxonomy-index.jsonthe terms themselves

⚠ Do not “fix” this by collapsing to one file. Measured: perspectives-index.json is 112.9 KB; all 78 manifests together are 113.4 KB, 1.5 KB each. The manifests are a denormalisation of the same bytes, and a term page fetching 1.5 KB instead of 113 KB is a real difference. Two access patterns, honestly served.

The actual blocker is that one row has two vocabularies:

identity   manifest: slug     index: id
title      manifest: title    index: label
image      manifest: image    index: cloudUrl

That is RULES 28 one level down — same facts, renamed in transit — and it is exactly what stops a view being another catalogueLayout. Catalogue’s card renderer reads post.title/post.slug/post.image; a view’s reads label/id/cloudUrl. Neither can draw the other’s rows.

So the unlock is: make the row ONE shape, keep both files.

⚠ A POV is a label. It owns nothing, and it is NEVER indexed on disk.

REVERSED 2026-09-24. An earlier version of this section said “index POV where cat and tag are already indexed” and specified manifests/pov-{term}.json, a povs array in taxonomy-index.json and a $pov_posts loop in the bridge. Do not build any of that. Shawn stopped it before it was written:

“POVs do work — which is the main goal. All we needed was a way to list them all. Maybe using the cat and tags isn’t the right way to do that.”

The requirement was list them, and everything needed was already in memory. Measured on the live /pov/all/ page: ACCID_PERSPECTIVES held 187 entries and 14 POV terms with counts fell out of eight lines — while the page showed “No povs yet”, because it was asking taxonomy-index.json for a povs key nobody had ever built. The fix was one client function, not a bridge pipeline.

The rule, restored:

  • A POV is a label on an article. Membership is authored, never derived.
  • No POV manifests. There are 23 cat- and 55 tag- manifests and zero pov- ones, and that is correct, not a gap.
  • Lists are derived at read time from the labels, via AccidPovTerm.termCounts(list) — one counter, called by /pov/all/, the filter bar and the term picker.
  • The bridge knows nothing about POVs. It does not parse POV types and does not build POV indexes.
  • Catalogue’s if (source === 'pov') branch STAYS. It reads the index directly because there is no manifest to read, which is the design working, not a symptom. Its seven-line adapter into manifest vocabulary stays with it.
  • Plumbing is excluded by post_type, not by understanding POVs: AccidPovTerm.isPlumbing tests against ['pov-scaffold','pov-row'], and contentOnly() inside termCounts applies it. That was SPI-588’s stated open question and it needed nothing new.
  • _accid is not a POV. It is the default stamped on an article that has none, which is why it is the largest value in the set. termCounts drops it.

⚠ Why this is written as a reversal rather than deleted. The removed version was plausible, symmetric and wrong, and a fresh session reading only the code would reinvent it within an hour — the bridge builds categories and tags right next to each other and the absence of povs reads as an oversight. It is not. Shawn: “It has to be edited in the same commit, or the next fresh session builds exactly what you just dropped.”

The rest of this section — one row shape, two files — still stands, and is about categories and tags, where manifests genuinely exist:

Once a row is a row:

  • catalogueSource stays what data (category · tag · pov),
  • the route’s term selects which projection — a term gives posts, all gives the terms themselves,
  • and catalogueLayout becomes how it renders — cards/list/compact today, grid/grouped/timeline later, with no new data path for any of them.

⚠ Which is why the /all fix must not be an if (term === 'all') branch. A special case there forecloses the generalisation: all is a different dataset for the same renderer, not a different renderer. Build it that way and views inherit it; branch on it and views start over.


⚠ A HOPE, NOT A RULE — edit mode decorates, it does not replace (SPI-592)

Edit mode decorates what the module renders. It does not replace it.

Named by Shawn 2026-09-25. Do not cite this as doctrine and do not build on it. It is written here so it can be tested; SPI-592 carries the survey that either promotes it or kills it, and gallery is deliberately left alone until then.

Two types answer it differently today, measured:

 edit modewhat follows
cataloguetoolbar ABOVE the live outputchange Terms and the list repopulates in front of you — 3 items → 12
galleryeditor REPLACES the output (gx-editor, no .gx-out-grid)change the layout and nothing can happen; there is nothing on screen to change

That difference is not cosmetic. It decides whether a setting can preview, whether a data-style-rows slot in the toolbar is meaningful or blind, and whether a type needs one renderer or two — gallery currently has two for the same photos (SPI-591).

⚠ Shawn’s threshold, and it is the point of writing it down: “if 1/2 are and 1/2 are not then we have to rethink.” If the survey splits, the answer is NOT to make the majority the rule and the rest exceptions. It becomes a question the recipe asks — does editing this type need a workspace, or only settings? — with both shapes legitimate and each one’s reason stated. A rule that half the types argue with is not a rule.

Every type pass records its answer in the tally row (edit: decorates|replaces, recipe §5), so the survey fills in as the passes go.

Order

Done or under way: nav (reference) · catalogue (Terms picker still outstanding) · gallery partly — its remaining commits wait for SPI-592.

Next (Shawn, 2026-09-24):

piece-ref → page-header → hero → form → text → container → code → tabs → expand → box → table → image → markdown → html → button → link → background (convert each to a container with the same background, then delete the type) → spacer → calendar → search → date → gallery (remaining commits, after SPI-592) → article-cell

Hero, Box and Page Header stay (SPI-558, 2026-09-24 20:40); Page Header’s home is template pieces.