ACCID – Adding A Module – create · test · emit · review · view

The steps are: create, register, test, emit, panel, verify, commit.

Merged 2026-09-30. Codey’s version, measured against the tree that day, is the base. The claude.ai draft added the design rules and the traps.

Worked example: sidenote, a titled note box. Every code block is written to be copied, and each one is marked where it differs from what the tree does today.

If you are reading this months later, do Step 0 first. Line numbers rot. Every file:line below has a grep in Step 0 that finds it again. Rule numbers above 41 or DOM-Ladder lines dated after 2026-09-30 may amend a step here. Where they disagree, RULES and DOM-Ladder win (their order is: ticket ruling → DOM-Ladder → RULES → this guide).


Step 0 · Re-orient: 10 minutes that save a day

Everything below runs on the server, through the tools container. If you have not used it recently, read Appendix A first.

0a. Find the anchors again

cd brains
grep -n "spacer-module.js"             index.html                                  # script tags (Step 2a)
grep -n "window.CORE_MODULES *="       accid-beaker/htmlbuilder/js/modules/module-manifest.js
grep -n "validate\|type.*create.*render" accid-beaker/htmlbuilder/js/module-registry.js
grep -n "HIDDEN_TYPES\|TEMPLATE_ONLY_TYPES\|getAll()" accid-beaker/htmlbuilder/creator/creator.js
grep -n "_filterModuleForSave"         accid-shared/accid-loader.js                # the legacy save whitelist
grep -n "function types()"             accid-beaker/htmlbuilder/js/accid-panel-v2.js
grep -n "\.arm\b\|arm()"               accid-beaker/htmlbuilder/js/page-renderer.js
grep -n "^@layer"                      accid-shared/accid-layers-order.css         # must be exactly ONE line
ls ../tests/node/*-pass.mjs                                                        # the newest pass is your template

Run once on 2026-09-30, and four of these were wrong — which is the point of the step. Corrected above; recorded here so the same rot is recognisable next time.

Step 0a saidactually
js/modules/module-registry.jsjs/module-registry.js — not under modules/. The validate is at :10.
js/creator.jscreator/creator.js — getAll() at :790, HIDDEN_TYPES at :803.
ls tests/browser/*-pass.mjsnone exist. Three node ones do: hero, page-header, spacer. Step 8’s browser suite has no -pass sibling to copy.
grep -n "CORE_MODULES"4 hits, two assignments. :12 is inside a commented-out block and :42 is the live one — so the bare grep offers you the dead array first. Narrowed to window.CORE_MODULES *= above, which still returns both but puts them side by side.

Everything else resolved: index.html:508, _filterModuleForSave at accid-loader.js:579, types() at accid-panel-v2.js:58, arm() at page-renderer.js:4381, and @layer returned exactly one line (accid-layers-order.css:34).

If an anchor is gone, find out why before copying the step that uses it. The machinery may have moved (P3, the fence 2→1, the bump bar retiring).

0b. Is this a type at all? (RULES 5, 25)

QuestionAnswer
Does it have its own content shape (its own data, its own output)?New type.
Is it different styling of an existing shape?Not a type. It is a layout class, a look, or a registry setting.
Is it a flag on an existing type (“hero but centred”)?Not a type, unless it changes composition. An invented key on an existing type is eaten silently by UNIVERSAL_FIELDS, while a new type fails open.
Does every look it needs already exist as a shared key?Borrow them. Only a look the master list cannot express earns a private key.

List what the registry already has:

docker run --rm --memory=2g --user "$(id -u):$(id -g)" -v "$PWD":/w:ro \
  accid-tools:2 node -e '
const r = JSON.parse(require("fs").readFileSync("/w/brains/accid-shared/accid-settings-registry.json","utf8"));
for (const e of [...r.shared, ...r.module])
  console.log(e.key.padEnd(24), (e.prop||"-").padEnd(28), (e.scopes||[]).join(" "));
'

These are already there: height, width, the four-side padding / space-around / border keys, radius, shadow, background, body_*, h1_*…h6_*. If a type declares its own height, you now have two writers for one declaration, and whichever one you touched last wins. Spacer is the example (tests/node/spacer-pass.mjs). A spacer is its height, yet it declares only label and reaches height through the panel. It also used to declare auto, which three other types declare with three other meanings.

0c. The collision sweep: its own commit, before the type (RULES 28, 31)

Declaring a key fences it against every other type. That includes types that never declared the key and only list it in fields[]. The fence asks the registry, not the module. This is not theoretical.

The bare word title is taken. Box stores it on 26 modules, and code-screenshot stores it too. Catalogue declared it on 2026-09-23 and armed the deletion of all of them. Its keys became catalogueTitle / catalogueShowDate (bd35427). Codey’s first draft of this guide declared a bare title on its example type, which is the same trap. That is why the example below uses sidenoteTitle.

Sweep five namespaces. Each fix lands in its own commit.

THE TYPE NAME IS A CLAIM ON ALL FIVE, NOT JUST ON js/modules/. Running this for real on 2026-09-30 killed a candidate type name outright. The markdown document roles are a namespace of their own — in neither the module folder nor the registry — so a name can be fully taken there while all four of the original checks come back clean.

# 1. STORED KEYS: is any other type storing the word? Declared OR fields[]-only.
grep -rnw "sidenoteTitle\|sidenoteTone\|sidenoteBody" accid-beaker/htmlbuilder/js/modules/ tomakeseed-accid/   # expect 0
docker run --rm --user "$(id -u):$(id -g)" -v "$PWD":/w accid-tools:2 node /w/tests/node/key-collisions.mjs

# 2. CSS CLASSES: every class the module emits, starting with its type name
grep -rn "\.sidenote\b\|\"sidenote\"\|'sidenote'" accid-beaker/htmlbuilder/css/ accid-shared/   # no bare .sidenote-anything elsewhere

# 3. DOM IDS: what it emits, and who else emits or reads it
# 4. GLOBALS: one owner each
grep -rn "SidenoteModule\|sidenoteSet" accid-beaker/ accid-shared/                     # expect 0

# 5. MARKDOWN DOCUMENT ROLES. A `:::name` block compiles to [data-role="name"], is
#    styled in modules-markdown.css, and owns its own --mddoc-<name>-* properties.
#    Nothing in 1-4 looks here. A taken role means the TYPE NAME is taken.
grep -rn "data-role=\"sidenote\"\|:::sidenote" accid-beaker/                          # expect 0
grep -rn "mddoc-sidenote" accid-beaker/htmlbuilder/css/modules-markdown.css            # expect 0

The fields[] extractor that works steps over both spellings in the tree:

/fields:\s*(?:\(?window\.BaseModule\s*\?)?\s*\[([\s\S]*?)\]/

The canary comes first. Assert the extractor finds a key you know exists (box → title) before trusting a clean result. A pattern that wants [ straight after the colon matches neither spelling and reports every type clean. That produced 56 false findings once.

Naming convention.

  • Stored data keys use camelCase with the type prefix when the word is a job word (sidenoteTitle, galleryColumns).
  • Style keys use snake_case (sidenote_accent).
  • CSS custom properties are job-named and are not prefixed when the concept is shared (--grid-columns).
  • Classes start with the type (module-sidenote, sidenote-title). A bare word like card is a claim on the whole page (684 wrappers carry .card).

Scope. Shared code plus tomakeseed data only. No other tenants, _JUNK, waypoint-* or git history.


Step 1 · The file

brains/accid-beaker/htmlbuilder/js/modules/sidenote-module.js

module-registry.js validates exactly three members: type, create and render. Everything else is optional, and each optional member buys one thing.

/**
 * Sidenote Module - a titled note box.
 * Owns: sidenoteTitle, sidenoteTone, sidenoteBody (data), sidenote_accent (style).
 * Everything else visual is a shared registry key, set in the settings panel.
 */

window.SidenoteModule = {
  type: 'sidenote',

  // Palette label and icon. creator.js asks the module (mod.icon / mod.name || mod.label).
  // A type with neither shows a default glyph and its raw type string.
  name: 'Sidenote',
  icon: '📌',

  // -- LEGACY SAVE WHITELIST ---------------------------------------------------
  // _filterModuleForSave (accid-loader.js) DROPS every key not listed. It retires
  // when the fence goes 2 -> 1 (DOM-Ladder SITE CHANGES); until then it is required.
  // No BaseModule => undefined => no whitelist at all (fails OPEN).
  // NEVER write [] - an empty array drops id and type.
  fields: (window.BaseModule ? [
    ...BaseModule.UNIVERSAL_FIELDS,   // id type order layout_class layer perspective hidden ...
    ...BaseModule.STYLE_FIELDS,       // the legacy style keys
    'sidenoteTitle', 'sidenoteTone', 'sidenoteBody', 'sidenote_accent',
  ] : undefined),

  // -- WHAT THIS TYPE OWNS -----------------------------------------------------
  // Truth is HERE. module-settings.mjs generates the sidecar and merged file from it.
  // No onlyTypes: the generator adds it; writing it here is a second answer.
  // Every key the type stores is declared - including data - so the fence and the
  // inventory know it (RULES 31 cuts both ways).
  settings: [
    { key: 'sidenoteTitle', emit: 'none', scopes: ['module'], label: 'Title' },
    { key: 'sidenoteBody',  emit: 'none', scopes: ['module'], label: 'Body' },
    // A KEYWORD that picks a CLASS in render() - data, like `tag` picks the element.
    // Emitting a keyword as a custom property would need CSS to map it back (Step 5).
    { key: 'sidenoteTone', emit: 'none', control: 'select',
      options: ['note', 'warn', 'stop'], default: 'note',
      scopes: ['module'], label: 'Tone' },
    // A property used DIRECTLY as a value. It lands on the owner and reaches the
    // reader by inheritance. Earns a private key only because no shared key means
    // "a sidenote's accent".
    { key: 'sidenote_accent', prop: '--sidenote-accent', control: 'color',
      scopes: ['site', 'page', 'container', 'module'],
      reach: 'flows', group: 'Sidenote', label: 'Accent' },
  ],

  // -- create() ----------------------------------------------------------------
  // PASS-THROUGH, NOT A STAMP. Store a key only when a value was supplied, so
  // "stored" always means "set". No style keys, no '', no [] (DOM-Ladder 5).
  create: (options = {}) => ({
    id: `sidenote-${Date.now()}-${Math.random().toString(36).substr(2, 6)}`,
    type: 'sidenote',
    order: options.order || 0,
    ...(options.layout_class ? { layout_class: options.layout_class } : {}),
    ...(options.sidenoteTitle ? { sidenoteTitle: options.sidenoteTitle } : {}),
    ...(options.sidenoteTone  ? { sidenoteTone:  options.sidenoteTone }  : {}),
    ...(options.sidenoteBody  ? { sidenoteBody:  options.sidenoteBody }  : {}),
  }),

  // -- render() ----------------------------------------------------------------
  // Returns an HTML STRING. The published markup is built once; edit mode wraps it.
  // NO BACKTICKS IN COMMENTS INSIDE THE TEMPLATE LITERAL - one ends the string and
  // takes window.SidenoteModule with it.
  render: (data, registry, isEditMode) => {
    const esc = (s) => String(s == null ? '' : s)
      .replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/'/g, '&#39;').replace(/</g, '&lt;');
    const title = data.sidenoteTitle || '';
    const body  = data.sidenoteBody  || '';   // rich text from the editor, stored as HTML
    // Legal tones are the settings entry's own options - not a second copy.
    const TONES = window.SidenoteModule.settings.find((e) => e.key === 'sidenoteTone').options;
    const tone = TONES.includes(data.sidenoteTone) ? data.sidenoteTone : 'note';

    // RIGHT TAGS: the tag says what the content IS. A sidenote is an aside; its title
    // is a label, not a heading (h# only for real headings - the SPI-597 table).
    const published = `<aside class="module-sidenote sidenote-${tone}">
      ${title ? `<p class="sidenote-title">${esc(title)}</p>` : ''}
      <div class="sidenote-body">${body}</div>
    </aside>`;

    if (!isEditMode) return published;

    // EDIT: this type's OWN DATA only, one row, label then input (catalogue's cx-bar).
    // Style is the settings panel's - no style rows here (ruled 2026-09-29).
    // data-accid-chrome: hidden on export, survives a re-render.
    return `<div class="cx-bar" data-accid-chrome>
      <div class="cx-row">
        <label class="cx-f"><span>Title</span><input type="text"
               value="${esc(title)}" placeholder="Sidenote"
               onchange="sidenoteSet('${data.id}','sidenoteTitle',this.value)"></label>
        <label class="cx-f"><span>Tone</span><select
               onchange="sidenoteSet('${data.id}','sidenoteTone',this.value)">
          ${TONES.map((t) => `<option value="${t}"${t === tone ? ' selected' : ''}>${t}</option>`).join('')}
        </select></label>
      </div>
    </div>
    ${published}`;
  },
};

/* -- THE ONE WRITER FOR THIS TYPE'S OWN KEYS ------------------------------------
   Same shape as spacerSet / catalogueSet. Reads the STORED data, never the page's
   HTML. Blank DELETES the key: '' claims "set to nothing", absence means unset. */
window.sidenoteSet = function (moduleId, key, value) {
  const data = HTMLModuleBlobber.getData();
  if (!data) return;
  const mod = data.modules.find((m) => m.id === moduleId)
    || (HTMLModuleBlobber._findNested && HTMLModuleBlobber._findNested(data.modules, moduleId));
  if (!mod) return;
  const v = typeof value === 'string' ? value.trim() : value;
  if (v === '' || v == null) delete mod[key]; else mod[key] = v;
  HTMLModuleBlobber.updateModule(moduleId, mod);
  if (window.reRenderModule) window.reRenderModule(moduleId, mod);
};

console.log('SidenoteModule loaded');

What changed from the tree’s usual shape, and why:

LineCodey’s draftHereWhy
keystitle, tone, bodysidenoteTitle, sidenoteTone, sidenoteBodyBox and code-screenshot store bare title. Declaring it deletes theirs (RULES 31).
bodyin fields[], not in settingsdeclaredA stored key the registry doesn’t know is invisible to the fence and the inventory.
layout_class: options.layout_class \|\| 'card'stamppass-throughA default written into data is frozen at creation. page-renderer already defaults the wrapper to card. Codey: confirm nothing reads layout_class from the stored module before page-renderer fills it.
TONESsecond copy of the listread from settingsOne list, one place.
outer tagdivasideRight tags. Confirm against the SPI-597 table.
style rows on the bar (data-style-rows)mounted padding, radius, background, accentremoved09-29: STYLE lives in the panel, EDIT on the module. See Open questions.

The parse-time rule

Nothing may run when the file is parsed. No document, no addEventListener, no MutationObserver, no timers. tests/node/module-load.mjs evaluates every module in a vm with window = {} and nothing else, because module-settings.mjs has to read Module.settings outside a browser. Measured across SPI-558 P1c: 0 of 31 modules loaded before, 30 of 30 after. Page-level wiring goes in arm() (Step 7).


Step 2 · Register it: two places, in this order

2a. The script tag in brains/index.html, next to spacer’s:

<script src="accid-beaker/htmlbuilder/js/modules/sidenote-module.js"></script>

2b. The manifest, js/modules/module-manifest.js, in window.CORE_MODULES:

  { type: 'sidenote', class: 'SidenoteModule' },

module-loader.js reads the manifest and calls moduleRegistry.register(type, window[class]). There is no fetch, which is why the tag must come first. A typo in class is a silent no-op. registerModules() logs it and moves on.

The palette needs nothing. creator.js builds it from moduleRegistry.getAll(), so registration is the palette entry. There are two ways to keep a type out of it:

  • HIDDEN_TYPES. The type is registered so existing pages render, but it can never be placed (container, article-cell).
  • Context-gated (TEMPLATE_ONLY_TYPES). A type whose meaning depends on the document it sits in is offered only in documents where it has meaning (page-header). Guarded by palette-context.mjs.

The bridge needs nothing. The fence reads the merged registry. If you find yourself editing accid-bridge.php for a new type, stop: something upstream is wrong.


Step 3 · Generate the sidecars

Module.settings is the truth. Two files are generated from it and from nothing else:

FileWhat it is
<type>-module.settings.jsonBeside the .js, so a module travels with its own settings. The bridge reads it per type.
accid-shared/accid-settings-modules.jsonThe merged per-type set: one fetch for the client, one file for the fence.
docker run --rm --user "$(id -u):$(id -g)" -v "$PWD":/w accid-tools:2 \
  node /w/tests/node/module-settings.mjs --write

--user is not optional. The image runs as root. Without the flag, the files land as root:www-data and the next --write fails with “Permission denied”. This has happened.

Without --write, the same file is a suite. It goes red when:

  • a sidecar is stale
  • a sidecar outlives its module
  • the central registry has grown an onlyTypes entry

Step 4 · Test the node tier

tests/node/sidenote-pass.mjs. It is hermetic: no server, no browser.

// ACCID-RULE: 5, 19, 28, 31 . sidenote's type pass
// docker run --rm --memory=2g --user "$(id -u):$(id -g)" -v "$PWD":/w accid-tools:2 node tests/node/sidenote-pass.mjs
// @broken rename sidenoteTitle to title -> "declares no word another type stores" red; drop esc() -> "title is escaped" red; give sidenoteTone a prop -> "tone is data" red
// (ONE line: suite-map.mjs takes only the first line of the note, and a wrapped note is silently cut in the map)
import vm from 'vm';
import { readFileSync, readdirSync } from 'fs';

const DIR = '/w/brains/accid-beaker/htmlbuilder/js/modules/';
const SRC = readFileSync(DIR + 'sidenote-module.js', 'utf8');

let pass = 0, fail = 0;
const A = (l, c, got) => { c ? pass++ : fail++;
  console.log((c ? '  PASS  ' : '  FAIL  ') + l + (c ? '' : '\n           got: ' + JSON.stringify(got))); };

function boot(src) {
  /* BaseModule GOES IN THE CONTEXT, NOT JUST ON window. The module spreads
     ...BaseModule.UNIVERSAL_FIELDS BARE - in a browser window.BaseModule creates the
     global, in a vm context it does NOT, so the file throws ReferenceError at load
     and every assertion reports "render is undefined" instead of the real cause. */
  const base = { UNIVERSAL_FIELDS: ['id', 'type'], STYLE_FIELDS: ['height', 'width'] };
  const w = { BaseModule: base };
  const ctx = vm.createContext({ window: w, globalThis: w, BaseModule: base,
    console: { log: () => {} } });
  vm.runInContext(src, ctx, { timeout: 5000 });
  return w.SidenoteModule;
}
const M = boot(SRC);
/* `|| {}` MATTERS. Without it, a renamed key makes E() return undefined and the
   next assertion THROWS — which reports as a crash, not a failure, and hides every
   assertion after it. The @broken recipe above renames sidenoteTitle, and that is
   exactly what happened: the suite died at "sidenoteTitle is data" and never
   reached "declares no word another type stores", which is the line the note
   names. A guard clause here turns a crash into a red line. */
const E = (k) => M.settings.find((e) => e.key === k) || {};

console.log('\n  THE REGISTRY CONTRACT');
A('type, create and render are all present', !!(M.type && M.create && M.render), Object.keys(M));

console.log('\n  IT DECLARES ONLY WHAT IT OWNS');
A('four settings', M.settings.length === 4, M.settings.map((e) => e.key));
A('sidenoteTitle is data', E('sidenoteTitle').emit === 'none');
A('sidenoteTone is data, and becomes a class', E('sidenoteTone').emit === 'none' && !E('sidenoteTone').prop);
A('sidenote_accent declares a literal prop', E('sidenote_accent').prop === '--sidenote-accent');
A('no setting writes its own onlyTypes', !M.settings.some((e) => e.onlyTypes));
const SHARED = ['height', 'width', 'radius', 'shadow', 'background', 'body_color', 'auto', 'title', 'label'];
A('it redeclares no shared or claimed word',
  !M.settings.some((e) => SHARED.includes(e.key)), M.settings.map((e) => e.key));

/* RULES 31, asserted against the tree: no OTHER module's fields[] holds our words.
   Canary first - the extractor must see box's title, or a clean result means nothing. */
const FIELDS = /fields:\s*(?:\(?window\.BaseModule\s*\?)?\s*\[([\s\S]*?)\]/;
const others = readdirSync(DIR).filter((f) => f.endsWith('-module.js') && f !== 'sidenote-module.js');
const fieldsOf = (f) => ((readFileSync(DIR + f, 'utf8').match(FIELDS) || [])[1] || '');
A('CANARY: the extractor finds box\'s title', /['"]title['"]/.test(fieldsOf('box-module.js')));
const mine = M.settings.map((e) => e.key);
const victims = others.flatMap((f) => mine.filter((k) => new RegExp(`['"]${k}['"]`).test(fieldsOf(f))).map((k) => f + ':' + k));
A('declares no word another type stores', victims.length === 0, victims);

console.log('\n  create() STORES ONLY WHAT IT WAS GIVEN');
const bare = M.create();
A('a bare create stores no data key', !('sidenoteTitle' in bare) && !('sidenoteBody' in bare), Object.keys(bare));
A('...no empty strings anywhere', !Object.values(bare).some((v) => v === ''), bare);
A('...and no style key', !('sidenote_accent' in bare) && !('layout_class' in bare), Object.keys(bare));
/* The pass-through: every key a caller can supply is kept (54e8720 - piece-ref, five days dark). */
for (const k of ['sidenoteTitle', 'sidenoteTone', 'sidenoteBody', 'layout_class'])
  A(`create passes ${k} through`, M.create({ [k]: 'X' })[k] === 'X');

console.log('\n  render() IN BOTH MODES');
const d = { id: 'c1', sidenoteTitle: 'Heads up', sidenoteBody: '<p>x</p>' };
const view = M.render(d, null, false), edit = M.render(d, null, true);
A('view mode carries no chrome', !/data-accid-chrome/.test(view));
A('edit mode does', /data-accid-chrome/.test(edit));
A('the published markup is IDENTICAL in both', edit.includes(view.trim()));
A('right tag: an aside, and no heading', /^<aside/.test(view.trim()) && !/<h[1-6]/.test(view));
A('the title is escaped', !M.render({ id: 'c1', sidenoteTitle: '<img onerror=x>' }, null, false).includes('<img'));
A('an absent title emits no empty element', !/sidenote-title/.test(M.render({ id: 'c1' }, null, false)));
A('tone becomes a class', /sidenote-warn/.test(M.render({ id: 'c1', sidenoteTone: 'warn' }, null, false)));
A('an unset tone falls back to note', /sidenote-note/.test(M.render({ id: 'c1' }, null, false)));
A('...and so does a keyword no longer offered', /sidenote-note/.test(M.render({ id: 'c1', sidenoteTone: 'ancient' }, null, false)));
A('every emitted class starts with the type',
  (view.match(/class="([^"]+)"/g) || []).join(' ').split(/[\s"=]+/).filter((c) => c && c !== 'class')
    .every((c) => /^(module-)?sidenote/.test(c)));

console.log('\n  ' + pass + ' passed, ' + fail + ' failed\n');
process.exit(fail ? 1 : 0);

Run the suite on its own, then the whole node tier:

docker run --rm --memory=2g --user "$(id -u):$(id -g)" -v "$PWD":/w accid-tools:2 node tests/node/sidenote-pass.mjs
./tests/run.sh node

Break it once, for real (RULES 32)

Do each break named in the @broken line. Watch the named assertion go red, then restore it. The break must use the case the change is for. If the old and new code agree on the input, the break cannot bite. Three gallery break-runs once “passed” because the break never landed in the file, so grep that it’s there before you run.

Never git checkout a file to undo a break. It reverts to HEAD and throws away every uncommitted change in that file. Copy it aside first: cp "$F" "$CLAUDE_JOB_DIR/tmp/f.bak" … break, run … cp "$CLAUDE_JOB_DIR/tmp/f.bak" "$F"

Two traps this repo has paid for repeatedly

Blank the comments before scanning a source file. A guard looks for a pattern, the file’s own comment explains that pattern, and the guard reports its own documentation as the fault. That happened four times in only-types.mjs and eight in one session in one-homepage-writer.mjs. The regex lives in tests/lib/strip.mjs, and writing it again fails one-comment-stripper:

import { blankComments } from '../lib/strip.mjs';   // same length - line numbers stay true
import { stripComments } from '../lib/strip.mjs';   // one space - offsets shift

Use blankComments whenever a finding names a line.

A suite glob that filters to .mjs misses the nine CommonJS .js suites.


Step 5 · Emit: how a setting reaches CSS

BaseModule.cssVar(key, value, scope) looks the key up in the registry, and the entry decides both the name and the mechanism:

emitWhat happensWhen to use it
(absent) → property--sidenote-accent: #... on the ownerThe default. A rule reads it.
declarationfont-size: 16px on the element; needs declAlmost never.
compositeseveral declarations from one stored objectbackground, filter
inlineemits nothing here; the renderer or module writes itThe value lands some other way.
nonenothing reaches CSSData keys, and tag, which picks the element.

prop is a literal, never constructed. varName ('--m-' + kebab(key)) was deleted in SPI-558 step 3. A key with no prop emits nothing and logs one line.

A property with no reader is a value the panel saves and the page never shows (“nothing paints without a row” runs both ways). Write the reader in the same commit.

  • Module CSS goes in css/modules-misc.css or css/modules-styles.css. Both are @layer modules, position 5 of 19 in accid-shared/accid-layers-order.css, the only file that states the order.
  • Never add a second @layer a, b, c; line (RULES 37). Omitted layers are appended, so they become the strongest, not ignored. On 2026-09-29 one stray line in a palette sheet took padded elements from 762 to 416.
@layer modules {
  /* THREE DEEP, AND EACH LEVEL MEANS SOMETHING (the accid_grid.css nav-link shape):
       --sidenote-accent        the registry value, written on the OWNER by scopeDecls,
                               reaching here by INHERITANCE
       --sidenote-tone-default  the tone class's colour, on this element
       currentColor            what an unset, toneless sidenote inherits
     A new type has nothing to stay byte-identical to, so the innermost fallback
     INHERITS - it is not a private design choice (no module-private unset look,
     ruled 2026-09-29). Tone colours are palette tokens, never hex (SPI-577). */
  .module-sidenote {
    border-inline-start: 4px solid
      var(--sidenote-accent, var(--sidenote-tone-default, currentColor));
  }
  .module-sidenote.sidenote-warn { --sidenote-tone-default: var(--c3-r30); }   /* token: Shawn's pick */
  .module-sidenote.sidenote-stop { --sidenote-tone-default: var(--c1-r45); }   /* token: Shawn's pick */
  .sidenote-title { margin: 0; font-weight: 600; }
  .sidenote-body  { margin: 0; }
}

What is deliberately not here:

  • No padding, space-around, border, radius, shadow or background. Those are the shared BOX rows. They land on the owner (.module-wrapper-v2) with four sides stored. Re-implementing them inside the module makes a second box. Codey’s draft had padding: var(--site-padding-top, 12px) …, which is a private default and a second box, so it’s removed.
  • No typography or colour. Those come from the shared rows through the cascade.

Declare on the owner, read below it (RULES 24).

Never declare the default on the element that reads it. .module-sidenote { --sidenote-accent: #94a3b8; } beats the value inherited from the owner, so the registry row can never win. The browser suite caught this: rgb(148, 163, 184) came back from the wrong direction. The default belongs in the var() fallback, or on a different property name (--sidenote-tone-default).

Do not select on the inline style string. .module-sidenote[style*="--sidenote-tone: warn"] matched nothing. The property sits on the owner, not the reader. It is also whitespace-sensitive and matches substrings. Keywords become classes (Step 1).

A CSS or prop change needs the browser tier. The node tier cannot observe a cascade. Codey’s six-line stylesheet had two bugs that the node tier could not see, and one browser assertion found both.


Step 6 · The settings panel

The four-column grid is drawn from the registry. A new entry appears at every column it declares with no panel code. sidenote_accent shows at Site, Page, Container and This, with → because it has reach: 'flows'.

The walk list decides which panel owns the type (accid-panel-v2.js, function types()):

  function types() {
    return window.ACCID_PANEL_V2_TYPES
        || ['button', 'text', 'container', 'image', 'navigation', 'catalogue',
            'gallery', 'page-header', 'hero', 'spacer', 'sidenote'];
  }

Adding your type here is the last step of its pass, not a follow-up. Get it wrong and the type loses controls and gains none. When nav was off this list, its two rows were deleted and a registry entry added, the old panel kept owning the type, and the new row had no surface.

A type joins the list once:

  • its rows render at every column
  • its suite passes
  • its suite has been broken once
  • its screenshots have been looked at

Never add a case 'sidenote': to the old floating bar (BottomBarBump). It retires (SPI-616).

Say-so (RULES 39). If any control clamps, caps or rewrites a value, the panel shows the value in effect next to the value typed. A silent limit is a bug.


Step 7 · arm(), only if it needs page-level wiring

Anything that must touch document goes in arm(). page-renderer.js calls it once per page render from the registry, so it needs no second list:

window.SidenoteModule.arm = function () {
  if (window.SidenoteModule._armed) return false;   // called per render - guard it
  window.SidenoteModule._armed = true;
  document.addEventListener('click', (ev) => { /* ... */ });
  return true;
};

Sidenote needs none. Don’t add an arm() “for later”.

If the type needs a real DOM element (it resolves something, or its children keep their own identity), it gets an element renderer. Its string form then becomes a placeholder that a fill pass completes after the content that contains it is placed. That is piece-ref’s shape, and three repaint paths had to learn it separately (RULES 19). Avoid it if you can.


Step 8 · Test the browser tier

The node tier cannot see a cascade, a computed value or a click. A browser suite builds the owner shape that page-renderer really produces and asserts measured values. tests/browser/sidenote-pass.mjs:

// ACCID-RULE: 24 . sidenote's cascade: owner declares, descendant reads
// @broken declare --sidenote-accent on .module-sidenote -- "owner's accent reaches the sidenote" goes red
import { chromium } from 'playwright';
import { readFileSync } from 'fs';
const S = (p) => readFileSync('/w/brains/' + p, 'utf8');

const browser = await chromium.launch({ args: ['--no-sandbox'] });
const page = await browser.newPage();
let pass = 0, fail = 0;
const A = (l, c, got) => { c ? pass++ : fail++;
  console.log((c ? '  PASS  ' : '  FAIL  ') + l + (c ? '' : '\n           got: ' + JSON.stringify(got))); };

/* The owner carries data-accid-id and .module-wrapper-v2 - the element the ladder
   writes on. No backticks inside this template literal's comments. */
await page.setContent(`<body style="color: rgb(10, 20, 30)">
  <div class="module-wrapper-v2" data-accid-id="c1" data-accid-type="sidenote" style="--sidenote-accent: #eab308">
    <aside class="module-sidenote sidenote-note"><div class="sidenote-body">x</div></aside></div>
  <div class="module-wrapper-v2" data-accid-id="c2" data-accid-type="sidenote">
    <aside class="module-sidenote sidenote-note"><div class="sidenote-body">y</div></aside></div>
  <div class="module-wrapper-v2" data-accid-id="c3" data-accid-type="sidenote" style="--c1-r45: rgb(239, 68, 68)">
    <aside class="module-sidenote sidenote-stop"><div class="sidenote-body">z</div></aside></div>
</body>`);
await page.addStyleTag({ content: S('accid-shared/accid-layers-order.css') });   // the real order, first
await page.addStyleTag({ content: S('accid-beaker/htmlbuilder/css/modules-misc.css') });

const border = (id) => page.evaluate((i) =>
  getComputedStyle(document.querySelector('[data-accid-id="' + i + '"] .module-sidenote')).borderInlineStartColor, id);

console.log('\n  THE PROPERTY REACHES THE ELEMENT');
A('the owner\'s accent reaches the sidenote inside it', await border('c1') === 'rgb(234, 179, 8)', await border('c1'));
/* A custom property INHERITS: set too high, it reaches every sidenote. c2 is the
   control - the check that caught --site-gap poisoning three navs. */
A('the sidenote beside it inherits, untouched', await border('c2') === 'rgb(10, 20, 30)', await border('c2'));

console.log('\n  THE TONE CLASS WORKS WITHOUT AN ACCENT');
A('sidenote-stop takes its palette token', await border('c3') === 'rgb(239, 68, 68)', await border('c3'));

console.log('\n  ' + pass + ' passed, ' + fail + ' failed\n');
await browser.close();
process.exit(fail ? 1 : 0);
./tests/run.sh browser

Browser suites must mount under /opt/tools. tests/run.sh does that. A hand-rolled docker line that mounts elsewhere cannot find playwright. Test pages load the real registry and the real layer order. A fixture without them tests a world the product never runs in.

Run the full suite once, at the end of the type (it takes about 20 minutes). Per commit, run ./tests/run.sh node, which takes seconds. The exception is a commit that touches the bridge, a panel or a renderer: that is where the browser tier has an opinion.


Step 9 · Review: the audits that must stay green

Each one exists because something was lost silently once.

R="docker run --rm --memory=2g --user $(id -u):$(id -g) -v $PWD:/w accid-tools:2 node"
$R tests/node/module-load.mjs        # every module loads under window = {}; the COUNT is exact - bump it
$R tests/node/module-settings.mjs    # sidecars match; none outlives its module; no central onlyTypes
$R tests/node/key-collisions.mjs     # no two types declare one stored key; reads fields[]
$R tests/node/only-types.mjs         # a key the panel cannot set reaches neither emitter
$R tests/node/orphan-props.mjs       # every wired property still has BOTH ends; retiring one = edit BASELINE in that commit
$R tests/node/selection-owner.mjs    # only if the type selects/focuses: add its file to SELECTION_PATH

Then regenerate the two docs whose diff is the review:

$R tests/node/settings-inventory.mjs --write
docker run --rm --memory=2g --user "$(id -u):$(id -g)" -v "$PWD":/w:ro accid-tools:2 \
  node /w/_ADMIN/audits/suite-map.mjs > _ADMIN/SUITE-MAP.md

docs/settings-inventory.md has one row per type × entry, and “reaches the page as” is measured. A row reading none is a finding. Your keys must not be findings.

The generator knows emit, control and literal module.<key> access. A key built at run time (module['prefix_' + side]) is invisible to it. Fourteen Box sides were reported as unread while their reader was named two columns over. If you build key names at run time, declare the family in writerOf().

The layer gate runs before every commit (RULES 36).


Step 10 · Verify it for real, in both modes, without writing live data

Green suites mean you did not break anything. They do not mean the feature works. View and edit are different render paths.

The working tree is production. codey symlinks to /var/www/starter and there is no build step. A probe that drives the panel writes to real stores (RULES 12). On 2026-09-30 a probe overwrote the styleguide’s 120px gutter and a browser run added a module to a perspective. Both were reverted.

The method: change, observe, back out without saving (Shawn, 2026-09-23). Switch the control on the real page, watch every branch, leave without saving, then prove the back-out:

# BEFORE the probe - snapshot only what it could touch
S="$CLAUDE_JOB_DIR/tmp/probe-before"; mkdir -p "$S"
cp -a brains/tomakeseed-accid/dropper/perspectives brains/tomakeseed-accid/perspectives-index.json "$S/"
# ... probe ...
# AFTER - diff, and restore ONLY what the probe changed
diff -r "$S/perspectives" brains/tomakeseed-accid/dropper/perspectives
diff "$S/perspectives-index.json" brains/tomakeseed-accid/perspectives-index.json

Not git checkout -- brains/tomakeseed-accid/… (Codey’s draft). That reverts to the last commit, and Shawn edits on the live tree, so it would also erase any real editor work since then. Restore from the snapshot. A page visit alone dirties _saved_at, which is benign. A new module in a perspective is not.

View mode: plain HTTP is enough

docker run --rm --network host --user "$(id -u):$(id -g)" -e HOME=/tmp \
  --shm-size=1g --memory=3g -v "$PWD":/opt/tools/cfg:ro -w /opt/tools \
  accid-tools:2 node -e '
const {chromium} = require("playwright");
(async () => {
  const b = await chromium.launch({args:["--no-sandbox","--disable-dev-shm-usage"]});
  const p = await b.newPage({viewport:{width:1600,height:1100}});
  await p.goto("https://starter.shawns-machine.com/tomakeseed/styleguide/", {waitUntil:"networkidle",timeout:60000});
  await p.waitForTimeout(2500);
  console.log(JSON.stringify(await p.evaluate(() => [...document.querySelectorAll(".module-sidenote")].map((el) => ({
    tag: el.tagName, border: getComputedStyle(el).borderInlineStartColor,
    nested: !!el.closest("[data-container-id]"),
    chrome: el.closest("[data-accid-id]").querySelectorAll("[data-accid-chrome]").length })))));
  await b.close();
})();'

chrome: 0 on every entry is the view-mode assertion. You need at least one entry with nested: true (see the checklist).

Edit mode

Use the recipe in _ADMIN/VERIFYING.md and the harness in tests/live/edit-in.mjs. The edit gate needs a secure context (https:// with --network host), and the harness knows how to clear it. Do not copy the auth steps into this guide or any other doc (see Open questions). pageManager: "object" in the harness output means you have the live editor shell, not furniture.

A module re-render does not repaint the box. reRenderModule swaps the content root. Padding, space around, border, radius and shadow are declarations on the owner, re-applied through window.applyBoxAndLayout (SPI-616). If you add anything the owner carries, check that it moves without a reload.

The live checklist

  • Top level, and nested in a container. Nested is the case nobody tests (RULES 19).

  • Edit mode: the bar shows, and the owner’s box is the same size as in view apart from the bar. Compare the owner height in edit and view.

  • Edit: open the bar, change nothing, save. The stored module is byte-identical.

  • Edit: blank the title. The key is gone from the store, not ''.

  • View: no chrome, aside in the DOM, the title escaped.

  • Panel: select it. The This column shows Accent plus the shared rows. One style change lands on the page. Any limit is shown (say-so).

  • JS off: the view page still renders the sidenote, styled (acceptance #11).

  • Pixel diff on the styleguide: null, or each region listed with its key.

  • Probe snapshot diff: nothing the probe wrote survives.


Step 11 · Commit checklist

  • Collision sweep landed first, in its own commit (Step 0c)

  • sidenote-module.js: no parse-time side effects, no el.style, no <style> injection

  • Script tag in brains/index.html; row in module-manifest.js

  • fields[] spreads BaseModule.* and is never []

  • settings[] declares every stored key, prefixed where the word is a job word, with no onlyTypes

  • Sidecars regenerated with --user

  • A reader exists for every prop you declared. No default declared on a reader. No box or typography re-implemented.

  • Node suite and browser suite each carry ACCID-RULE:, and each has been broken once

  • module-load count bumped; orphan-props baseline edited if you retired anything

  • docs/settings-inventory.md regenerated, with your keys not findings

  • _ADMIN/SUITE-MAP.md regenerated in the same commit

  • Both tiers green, with the numbers in the message (the full run once, at the end)

  • Verified in view and edit on the live styleguide, top level and nested

  • Probe snapshot diff clean

  • On the panel walk list, or a stated reason why not yet

  • Tally row in the last commit: sidenote . new type . 3 data, 1 style . nested ✓ . NN node / NN browser, diff null . edit: decorates|replaces

Scope commits to one feature and say what you left dirty. Commit straight to main; no branch. Shawn signs off on the page.


The whole thing on one card

0  Re-find anchors. Type, not flag? Master keys cover the looks?     RULES 5, 25
   Sweep stored keys / classes / ids / globals. OWN COMMIT.          28, 31
1  <type>-module.js: type/create/render; fields[]; settings[];
   nothing at parse; create = pass-through; right tags; EDIT = data  19, 597, 09-29
2  <script> in index.html, then CORE_MODULES row. Palette free.
3  module-settings.mjs --write  (--user!)
4  Node suite: cites rule, canary, broken once.                      32
5  CSS in @layer modules: owner declares, element reads, fallback
   inherits, keyword = class, no box, no 2nd @layer line.            24, 37
6  Panel walk list - last step of the pass. No bump-bar case.        616
7  arm() only if it touches document.
8  Browser suite: measured computed values, a control element.
9  Audits + inventory + SUITE-MAP. Layer gate.                       36
10 Live: view + edit, top-level + nested; back out, snapshot diff.   12, 39
11 Checklist, tally row, Shawn signs off.

Traps: each one was paid for once

You will want toWhat happenedRule
name a key title / source / columnsfenced another type’s stored values on the next save (box.title, 26 modules)28, 31
write x: options.x \|\| '' in create()the only writer of an insert-time value was deleted; piece-ref was dark for 5 daysDOM-Ladder 5
put the default on .module-sidenotethe owner’s value lost to it silently24
select on [style*="--x: y"]matched nothing: the style is on the ownerStep 5
emit a bare class.card crushed catalogue items into a 56px circle28
add a second @layer order line12 layers landed after editor; padded elements went 762 → 41637
test only the top-level casepiece-ref nested: height 0, no error19
trust a suite you never broke3 gallery break-runs “passed” without the break applied30, 32
forget --user on --writeroot-owned sidecars; the next write failsStep 3
scan a source file with comments inthe guard reported its own documentation as the faultStep 4
clamp a value quietly“the control is broken”39
git checkout to undo a break or a probelost uncommitted work, twiceStep 4, 10
probe the live styleguide and saveoverwrote the gutter; added a module to a perspective12

Appendix A · The tools container (accid-tools:2)

Where it is

  • It lives on the server that serves starter.shawns-machine.com. The working tree is /var/www/starter, which is production. There is no separate dev copy. The user is spiffy-root. (Fill in the SSH host alias you use, for example from Zed’s remote list.)
  • The host has only python3 and docker. It has no node. node tests/... on the host fails with “No such file or directory”. Every node and Playwright run goes through docker run … accid-tools:2.
  • The image was built outside this repo. There is no Dockerfile for it here. The rebuild-from-scratch runbook is _ADMIN/SKILL-ai-mesh-toolchain.md. Host-side support files are in ~/.accid-tools/ (env.sh, node, node_modules, puppeteer-cache), and screenshots go to ~/accid-tools/out/.
  • Suite docs are in tests/README.md (the container section starts near line 72). Edit-mode probing is in _ADMIN/VERIFYING.md.

How to get in

ssh <your-server-alias>          # as spiffy-root
cd /var/www/starter              # ALWAYS run from here: every recipe mounts "$PWD"
docker images | grep accid-tools # sanity: the image exists
./tests/run.sh node              # sanity: node tier green (seconds)

To poke around inside it interactively:

docker run --rm -it --user "$(id -u):$(id -g)" -e HOME=/tmp -v "$PWD":/w -w /w accid-tools:2 bash

What it has, and what it doesn’t

HasDoesn’t have
Ubuntu 24.04pip / ensurepip. You cannot pip install inside it, and a derived-image attempt failed on exactly this (2026-09-28).
node, plus node_modules under /opt/toolsopenpyxl (the census workbook generator is the one thing that needed it)
Playwright and headless Chromiumany network except with --network host
Python 3.12.3 (stdlib only)your SSH keys or git credentials. Commit on the host, not inside.

Do not install anything onto the host to get around a missing tool. The whole test story is that it runs in the container. A host-installed dependency is reproducible on exactly one machine.

The four ways it gets run

# 1. A NODE SUITE / generator: project mounted at /w
docker run --rm --memory=2g --user "$(id -u):$(id -g)" -v "$PWD":/w accid-tools:2 node /w/tests/node/<suite>.mjs
#    read-only when it only reads:  -v "$PWD":/w:ro

# 2. THE TIERS: run.sh knows every mount; prefer it
./tests/run.sh node          # seconds - every commit
./tests/run.sh browser       # ~20 min - once, at the end of a pass
./tests/run.sh               # both

# 3. A PLAYWRIGHT SCRIPT against a fixture: the script MUST sit under /opt/tools
docker run --rm --memory=3g --shm-size=1g --user "$(id -u):$(id -g)" -e HOME=/tmp \
  -v "$PWD":/opt/tools/cfg:ro -v ~/accid-tools/out:/out -w /opt/tools \
  accid-tools:2 node /opt/tools/cfg/<script>.mjs

# 4. A LIVE PROBE of the real site: add --network host (and https:// for edit mode)
docker run --rm --network host --memory=3g --shm-size=1g --user "$(id -u):$(id -g)" -e HOME=/tmp \
  -v "$PWD":/opt/tools/cfg:ro -w /opt/tools accid-tools:2 node /opt/tools/cfg/tests/live/<probe>.mjs

What each flag is for (every one cost time once)

FlagWhy
--user "$(id -u):$(id -g)"The image runs as root. Without this, written files land as root:www-data and the next write fails “Permission denied”.
-v "$PWD":/w vs -v "$PWD":/opt/tools/cfgrequire and ESM resolve from the script’s own directory. /w has no node_modules, so anything that imports playwright must be mounted under /opt/tools. This is “the hour-costing note in run.sh“.
-e HOME=/tmpChromium needs a writable home when not root.
--shm-size=1g (or 2g)Chromium crashes on docker’s default 64MB /dev/shm.
--memory=2g / 3gA runaway suite can’t take the box down (it is production).
--network hostOnly for live probes. It reaches starter.shawns-machine.com; https:// gives the secure context the edit gate needs.
--rmNo stopped containers piling up.
:ro on the mountUse it whenever the job only reads. A read-only probe then cannot write production.

Scratch files

Break-once backups and probe snapshots go in $CLAUDE_JOB_DIR/tmp/ on the host, never inside the tree (anything in the tree is served). Remember that /w is the live tree: a container with a writable mount can change production.


Open questions for Shawn (remove each as it’s ruled)

  1. Does the edit bar inside the owner grow the box? Codey’s cx-bar is emitted by render(), which puts it inside the owner. That is exactly the RULES 33 (09-26) finding: hero’s bar made the owner 733px around a 300px hero, and owner backgrounds painted behind the editor. The 09-29 ruling put content on the module, but where the bar sits relative to the owner isn’t settled in writing. RULES 33’s text still says “floating panel” and needs its amendment.
  2. Shared style rows on the module bar. Codey’s draft mounted padding, radius, background and accent on the bar through data-style-rows. This guide removed that per 09-29 (style belongs in the panel). If that mechanism is meant to stay for some types, the rule for which rows is needed.
  3. Tone colours. Warn and stop need palette tokens. Status colours are for real status only, so this is a choice among c1–c6 steps. The tokens in Step 5 are placeholders.
  4. Edit-mode auth in docs. Codey’s draft wrote out how the edit gate is cleared in a probe, with the file and field that hold the credential. It is left out here and pointed to VERIFYING.md. Confirm that _ADMIN/ and each tenant’s secrets file cannot be fetched over HTTP. The working tree is the web root.