@bongos/core 1.19.464 → 1.19.465

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.bongos-core.json CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.464",
6
- "core_contract": "1.19.464",
7
- "source_commit": "ee6e86603d465d87cccb18bc7a53b4e732dd3ec6",
5
+ "core_version": "1.19.465",
6
+ "core_contract": "1.19.465",
7
+ "source_commit": "82872e3e08c740d3499036c81775be82f4ab5538",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-02T01:17:20.251Z",
9
+ "built_at": "2026-09-02T01:57:06.137Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
- "docs_redacted": 405,
12
+ "docs_redacted": 406,
13
13
  "agent_docs_stubbed": 24,
14
- "functional_verbatim": 1821,
14
+ "functional_verbatim": 1822,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2250,
20
- "tree_sha256": "0a8510092c669d2326c912c5d43bee468ee9474b55d62c40984bc017f03dcba4",
19
+ "file_count": 2252,
20
+ "tree_sha256": "ac3006fbcebed3829d82962a2c54bd4245e7f1b736f92b13c46ab9d56b58180b",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -1619,10 +1619,15 @@
1619
1619
  "mode": "0000644",
1620
1620
  "sha256": "29a762aef81944134084fd1c4d36ef3e27fcf8c3ab5a1ef0e724bcb6d8d29a67"
1621
1621
  },
1622
+ {
1623
+ "path": "docs/adr/0223-the-detector-is-two-tiers-in-the-kit.md",
1624
+ "mode": "0000644",
1625
+ "sha256": "736f27c4a9e86d3169954a2555209e75dfc93f84b4581417188c71a02954e731"
1626
+ },
1622
1627
  {
1623
1628
  "path": "docs/adr/README.md",
1624
1629
  "mode": "0000644",
1625
- "sha256": "f3bf966757af821e04a58fc8f1504bac9d93a055a3160f69bb542b8a6631ef5a"
1630
+ "sha256": "414345ac797782a5049cdae5b468149be343535055a261f257eec6979701c51b"
1626
1631
  },
1627
1632
  {
1628
1633
  "path": "docs/api-reference.md",
@@ -2102,7 +2107,7 @@
2102
2107
  {
2103
2108
  "path": "docs/file-map.md",
2104
2109
  "mode": "0000644",
2105
- "sha256": "8224dda275200da6c94e0096c7dd7f4b88d5e57e76bb602bcf8ff325515ec991"
2110
+ "sha256": "6aa7d3d13404a55afe9ab461eb404d86a4072b70e1565dd89e9d71b5e636364f"
2106
2111
  },
2107
2112
  {
2108
2113
  "path": "docs/handoff-template.md",
@@ -2112,7 +2117,7 @@
2112
2117
  {
2113
2118
  "path": "docs/module-api-changelog.md",
2114
2119
  "mode": "0000644",
2115
- "sha256": "3754b5edbe630ddb0e12f4f5c749eec704aaa7d5dd2be8b04f1680f77df387e8"
2120
+ "sha256": "5bf0804834ecb65b42a29cf2936f93021fb15762735a75129ab52294b0731246"
2116
2121
  },
2117
2122
  {
2118
2123
  "path": "docs/modules-contract.md",
@@ -2342,7 +2347,7 @@
2342
2347
  {
2343
2348
  "path": "docs/recipes/ui-look-before-you-ship.md",
2344
2349
  "mode": "0000644",
2345
- "sha256": "7484993a3437cc5b48841273b50b9015c3c92fbcd9f0f4bddba92d272c58e55f"
2350
+ "sha256": "02bd598f2a1552edb9086f84b8a9beff641cca3235ced4e2eae104875204a293"
2346
2351
  },
2347
2352
  {
2348
2353
  "path": "docs/recipes/upgrading-the-core.md",
@@ -6262,12 +6267,12 @@
6262
6267
  {
6263
6268
  "path": "modules/ui-design/kit/README.md",
6264
6269
  "mode": "0000644",
6265
- "sha256": "b2b98a60d054d774d2a30d03285589e780b4ab9bda6f526a971e09dd5500cab1"
6270
+ "sha256": "da56f82f7b020ea954b47a47e28c7bf2f5614171ef1d6192f8891d848008b9be"
6266
6271
  },
6267
6272
  {
6268
6273
  "path": "modules/ui-design/kit/check-mock.js",
6269
6274
  "mode": "0000644",
6270
- "sha256": "76c97faa8f04d51900ca00cb5f8a599d105ee44347bc8f285013f1439f6483f2"
6275
+ "sha256": "286268151ff986c22e2b04548ede0e978663ef0df5bb56593ebcbe9c6b7db5d9"
6271
6276
  },
6272
6277
  {
6273
6278
  "path": "modules/ui-design/kit/contrast.js",
@@ -6327,7 +6332,7 @@
6327
6332
  {
6328
6333
  "path": "modules/ui-design/kit/lib.js",
6329
6334
  "mode": "0000644",
6330
- "sha256": "c2e6471bf9623a2dbf3a816c355051315309221a03476f4f199f825bcef070f6"
6335
+ "sha256": "e619d3a55683defd9b3560567eeb40af28de71af63865f49e43bdcd44439c047"
6331
6336
  },
6332
6337
  {
6333
6338
  "path": "modules/ui-design/kit/png.js",
@@ -6342,13 +6347,18 @@
6342
6347
  {
6343
6348
  "path": "modules/ui-design/kit/render.js",
6344
6349
  "mode": "0000644",
6345
- "sha256": "9940f65e99d822c428ae8c76128752dbf55efb664ea40480bb098b9652e87da8"
6350
+ "sha256": "e521e6f1c9bc23dc16814303b04a3e95f9a31f8389a63ac20859959bbbec8ee3"
6346
6351
  },
6347
6352
  {
6348
6353
  "path": "modules/ui-design/kit/serve.js",
6349
6354
  "mode": "0000644",
6350
6355
  "sha256": "745fdc2ff5f72951b919ba49ab871b8cdbe0f09db73e79dfa248cd01e9232cdd"
6351
6356
  },
6357
+ {
6358
+ "path": "modules/ui-design/kit/tells.js",
6359
+ "mode": "0000644",
6360
+ "sha256": "26d8b2a846be3d1ff7a3798d4c3cea01a7d5a55c89bc223c2a8c4f263579e0c7"
6361
+ },
6352
6362
  {
6353
6363
  "path": "modules/ui-design/module.json",
6354
6364
  "mode": "0000644",
@@ -6417,7 +6427,7 @@
6417
6427
  {
6418
6428
  "path": "modules/ui-design/skills/impeccable/SKILL.md",
6419
6429
  "mode": "0000644",
6420
- "sha256": "b82bde6df7fd759d57c11e072a427bf5e88f9558deb8ce746b89fa031d06d051"
6430
+ "sha256": "ec144ae6ee19f59743e42e035f0a0fefc342bfd292353be73d50419d9df98491"
6421
6431
  },
6422
6432
  {
6423
6433
  "path": "modules/ui-design/skills/impeccable/reference/adapt.md",
@@ -6607,12 +6617,12 @@
6607
6617
  {
6608
6618
  "path": "package-lock.json",
6609
6619
  "mode": "0000644",
6610
- "sha256": "732ce8cbd9c2edfa91b96651f2267647929359f7374f36054766c037fcaef5c2"
6620
+ "sha256": "6db036356b674e4ea0fd8ef3a2d300b830ce95aa66ff9b19993188a41ca0bb66"
6611
6621
  },
6612
6622
  {
6613
6623
  "path": "package.json",
6614
6624
  "mode": "0000644",
6615
- "sha256": "a2e4a17551cc6ef235ebbb842898b99b596b006eac1176aafdbedddbc8f96c0b"
6625
+ "sha256": "fcd520ba5ac29e9f0db6fad4629c7316aed291ed6428b030af7543d5c6144883"
6616
6626
  },
6617
6627
  {
6618
6628
  "path": "public-docs/index.html",
@@ -8262,7 +8272,7 @@
8262
8272
  {
8263
8273
  "path": "src/module-api.js",
8264
8274
  "mode": "0000644",
8265
- "sha256": "95f84943307e1c1f0062ae9eb0420d5a649c62d9b461fb46e7a4a154bcde6c6d"
8275
+ "sha256": "641bc335f6f62f9d3a6a1fbfe9f83147c3e652b0816ad008616c3f93f3d16f13"
8266
8276
  },
8267
8277
  {
8268
8278
  "path": "src/module-loader/catalog.js",
@@ -11177,7 +11187,7 @@
11177
11187
  {
11178
11188
  "path": "tests/ui_design_kit.mjs",
11179
11189
  "mode": "0000644",
11180
- "sha256": "d98b6766db33020b379489011e72135eb00492c68f3faa5c0ab55366f12777c9"
11190
+ "sha256": "3559864426fc870316dbdbf9624e7b44e54f893f4cb84d07fc26206a809c0c14"
11181
11191
  },
11182
11192
  {
11183
11193
  "path": "tests/ui_design_skills.mjs",
@@ -0,0 +1,33 @@
1
+ # ADR 0223 — The anti-pattern detector is two tiers inside the kit: mechanical floors fail, craft tells warn, a false positive is waived in the file with its reason, and nothing new is installed
2
+
3
+ - **Status:** Accepted (2026-09-02)
4
+ - **Task:** [task 1003329](https://cloudbongos.com/builders#/task/1003329) (goal 1000083 — the ui-design module as a platform capability; the fourth rebuild-spec task, after [ADR 0220](<redacted>.md), [ADR 0221](<redacted>.md) and [ADR 0222](<redacted>.md))
5
+ - **Owner intent (2026-08-28):** the parts of impeccable worth rebuilding include "a static anti-pattern detector"; the task body says to land it "as RULES in the kit that already exists, never as a second tool, never as a browser bundle, never with a hook".
6
+
7
+ ## Context
8
+
9
+ The [detector] group of impeccable's REBUILD-SPEC (the body of task 1003326) describes a deterministic rule engine of about sixty rules split between generated-design tells and general quality, usable standalone and inside the skill, over three input kinds, with machine-readable output and a gating exit code, a cascade resolver for computed values, per-project ignores, in-file waivers, a browser-injected variant, a screenshot contrast pass, and the rule that a clean scan is a floor and never proof. The origin shipped it as its own CLI plus a browser bundle, wired to PostToolUse and Stop hooks, and the panel condemned the hooks and the bundle ([ADR 0198](<redacted>.md)).
10
+
11
+ The kit already had the engine's shape: `check-mock.js` runs pure rules over a mock's source and keeps the platform's rules apart from the instance's `DESIGN.md` taste bans; `render.js` evaluates `lib.js`'s `factsInPage` in the real page per state, width and mode and audits the facts against the floors; `contrast.js` measures the pairs; every CLI exits 0 / 1 / 2 / 3. What it did not have was the detector's rule content, a place for rules that are reads rather than floors, and a way to name a false positive without deleting the rule.
12
+
13
+ ## Decision
14
+
15
+ 1. **The rules live in the kit, in two tiers, beside the two that exist.** A new pure module `kit/tells.js` carries the static rules; `check-mock.js` runs them after its platform rules and taste bans; `lib.js`'s `factsInPage` collects their computed-value twins as `tells`, which `render.js` prints as `~ tell:` lines and records per render in `report.json`. **Floors** are mechanical and unambiguous on every instance and report `FAIL`: a bounce easing (a `cubic-bezier` whose y control points leave 0..1; the platform's motion has no spring and the Kill Switch has nothing to overshoot) and a card nested in a card (from a tag-stack walk of the markup, not a DOM). **Tells** are the craft floor's generated-design signatures and general-quality reads and report `WARN`: the identical icon-heading-text grid, hero metrics, decorative section numbering, a kicker above the h1, a coloured side stripe, a hard zero-blur offset shadow, a zero-offset coloured halo, decorative blur, a default typeface as the voice, a wide measure, a long headline, the same entrance on every section, a geometric mask on a plate, unicode glyphs as icons, a rainbow or mesh gradient, the browser surfaces nobody themed, proportional numerals in data, tiny type; rendered: prose measure, tiny text, proportional numerals in cells, headline lines, an overshooting computed curve. A tell never fails a mock or a render, because **a clean scan is a floor, never proof**, and the spec's own rule is that every finding is verified in context and a false positive called out.
16
+ 2. **The world decides which tells are floors.** A kicker above the h1 is a tell everywhere and a `FAIL` where the instance's `DESIGN.md` bans the eyebrow (a new `eyebrow` key in `TASTE_BANS`, read from the Don't section like the five before it); the chrome world bans it by name, the three library looks do not, and the platform does not decide for them. The same shape is open to any tell an instance later promotes.
17
+ 3. **A false positive is waived in the file, with its reason, and never in a config.** `kit-ignore: <rule prefix>, <rule prefix> (reason)` in any HTML or CSS comment turns that rule's hit into a `WAIVE` row carrying the detail and the reason; `--raw` runs with no waivers; `--no-tells` runs the platform rules and taste bans only; `--json` prints the rows with `fails` and `warns` counts for a job that gates on them. The spec's per-project ignore file (by rule, by glob, by value, with an admin action set) is not built: the place a false positive lives is the file that triggers it, a reviewer reads the reason where the rule fires, and a repository-wide suppression is the mechanism by which a floor quietly stops being one.
18
+ 4. **Not ported, on purpose.** The purple-to-blue gradient tell (a literal colour is already a platform failure, and under the fifteen-token contract a gradient's hue is unresolvable from source); a browser-injected overlay and a namespaced console channel (the rendered twins run inside the kit's own page evaluation, which already proves the page can be evaluated); the screenshot contrast sampler (`contrast.js --url --glyphs` is that pass); a cascade resolver (the rendered tier reads computed values from the browser, which is the only honest resolver); any run on native app code; and every hook.
19
+ 5. **The pins.** `tests/ui_design_kit.mjs` holds one clean fixture that passes every floor and tell and, for every rule class, a fixture that trips it at its tier, with the case count asserted equal to the rule count so a rule cannot be added unpinned; the eyebrow promotion; the waiver, `--raw` and `--json` paths through the real CLI; the detector inside `checkMock` and `--no-tells`; `craftTells` as warning lines that `auditFloors` never counts. The hub mock `docs/design/mocks/hub/v2.html` and the three style specimens still pass every rule; `/projects` and the grove specimen still render ALL CLEAN.
20
+
21
+ ## Consequences
22
+
23
+ - **`impeccable audit` and `polish` now have the detector they pointed at.** The skill's "not here" note names `tells.js` and the waiver instead of a future task.
24
+ - **Existing mocks surface tells.** The hub mock and the three specimens each report one to three `WARN` lines (an untitled `::selection`, a `p.label` above the h1 in the specimens, one 3px side stripe in the expedition look); none fails, each is a reviewer's call, and the specimens' kicker is not a defect under their own looks.
25
+ - **The tag-stack walk is not a DOM.** It sees nesting and sibling order well enough for the structural tells and no further; a rule that needs layout belongs in the rendered tier.
26
+ - **Two of the origin's "sixty" are floors and eighteen are tells.** The rest of its count was the same rule over three input kinds, the platform rules the kit already had, and the mechanisms decision 4 declines.
27
+
28
+ ## Rejected
29
+
30
+ - **A second CLI beside `check-mock.js`** ("`kit/detect.js`"): one more thing to run, one more exit code to learn, and the same rules; the task body forbade it.
31
+ - **Every tell as a floor:** the tells are reads a world can legitimately contradict (a hard offset shadow in a brutalist look, a stripe in a ledger); failing a mock on them would make the kit the taste and the instance the exception, the inversion the module exists to prevent.
32
+ - **A per-project ignore config with an admin action set:** a suppression nobody sees where the rule fires; the file-scoped waiver with a mandatory place for a reason keeps the finding and its dismissal on the same line.
33
+ - **A hook that runs the detector on every edit:** the pre-flight is the kit and the ship gate is the sweep ([ADR 0221](<redacted>.md) D3).
@@ -309,5 +309,6 @@ This keeps the decision history honest and traceable.
309
309
  | 0220 | [**An in-house rebuilt skill is a first-party skill: it lands at `skills/<name>/`, opens with the same preamble, carries a "Rebuilt from" section instead of a sidecar, and is counted** ([task 1003325](https://cloudbongos.com/builders#/task/1003325) · goal 1000083). ADR 0198 landed both scanned origins as rebuild specs and left the roster empty; this is the first rebuild — the taste core: `design-taste-frontend`, `design-taste-frontend-v1`, `high-end-visual-design`, `redesign-existing-projects` — and the policy had not said where a rebuild lives, what replaces `PROVENANCE.md`, whether a preamble written for vendored copies still fits, or whether five byte-identical copies of it trip the doc-entropy ratchet (they would have: baseline 6). Decisions: **D1** a rebuild is `modules/ui-design/skills/<name>/SKILL.md`, declared in `module.json`, materialised by step 1b's own-dir branch (no sidecar gate), first-party and counted by every ratchet — no `rebuilt/` exemption like `vendor/`, because these are our lines. **D2** one preamble worded for both origins ("vendored from a scanned origin, or rebuilt in-house from a spec"), still byte-identical everywhere; a rebuild's provenance is its `## Rebuilt from` section first after the block: the origin the SPEC was derived from (documentation only), the verdict, the spec task, "no upstream bytes consulted or copied", this ADR, and what changed in translation; no `PROVENANCE.md`, no `LICENSE`. **D3** the translation rules for every rebuilt taste skill: the palette is the pack and variants come from the style library (ADR 0219); the design system is the instance's `DESIGN.md`; imagery is hero plates and hero objects by token and never a step; every motion rule lives inside the Kill Switch (springs, magnetic hover, pointer-tracked borders are named *not available*); the upstream's bans are defaults an instance's `DESIGN.md` overrides, and where the world calls for what an audit would flag, the world is the answer key. **D4** `docs-entropy.js` skips a paragraph opening with the preamble marker beside its generated-block skip, calibrated against a plain duplicate that is still flagged. **D5** v1 is a distinct skill differing in content (the fixed triple, the stack conventions, the pattern vocabulary, the tile-grid archetypes), not a version stamp. **D6** the pins in `tests/ui_design_skills.mjs`: frontmatter name = dir, preamble first and once, the section's task link + ADR 0198 + "no upstream bytes", the style library and "hero plates" named, no image mandate, no sidecar, every door lists the four. Rejected: a sidecar with the spec's commit; a second preamble; a `rebuilt/` segment; vendoring anyway (an owner override, not taken); folding v1 into v2; raising the doc baseline.](<redacted>.md) | ui-design / skills |
310
310
  | 0221 | [**The impeccable method is one skill with sub-command playbooks: one preamble, one playbook per request, the three homes are the instance's own files, and the origin's mechanisms are dropped rather than disabled** ([task 1003326](https://cloudbongos.com/builders#/task/1003326) · goal 1000083). `pbakaus/impeccable` scanned `dangerous` (ADR 0198: hooks written into five harness configs, a child agent with permissions bypassed, an instruction override, a live-server credential in page source, an unverified remote bundle, default-on telemetry), so its method is rebuilt from the spec. ADR 0220 fixed where a rebuild lives; it had not said how a family of sub-commands lands, what the origin's three persisted homes become, or what to do with the six spec areas whose substance is the condemned mechanism. Decisions: **D1** one skill at `skills/impeccable/`: `SKILL.md` (invocation, the menu from live signals, session setup, the three homes, the routing table, the craft floor, the bounded finish, what is not here) plus `reference/<sub-command>.md`, eighteen playbooks, exactly one loaded per request (the spec's own entry rule); the preamble pinned once in `SKILL.md` and governing every playbook; the materialiser's tree copy ships `reference/` unchanged. **D2** the three homes are the instance's files (product truth = `docs/project-context.template.md` + the pack's identity; the visual system = the root `DESIGN.md` and a look's; the brief = the task's body + a fixed brief block in the ship notes); the skill creates none, `shape` proposes diffs, `document` drafts on the claim's branch with the refresh / overwrite / merge choice. **D3** dropped, not disabled, each with a destination: hooks → none (the ship gate is the sweep, the pre-flight is the kit); the bypass agent → an ordinary read-only subagent via the Agent tool; telemetry, the remote bundle, the credential → none; the live loop → the playwright plugin driving the kit's stub, variants as states, accept = commit, discard = checkout, nothing injected; the detector → task 1003329; the asset producer → the hero-plates rule and task 1003328; native → declines to `/design`; `init`/`doctor`/`pin`/installer → the materialiser is the install. **D4** the finish is the kit in bounded rounds (build, one batched inspection, one batch of fixes, at most one confirming round, stop, one budget) and the reviewer answers with one of `clean` / `recapture` / `defects` / `contradicted` after an evidence check and its own mock inventory. **D5** the craft floor is mechanics loaded late and never recited, translated for a world that may be hairlines by rule. **D6** the pins: sixteen routed playbooks, no orphan, no preamble copy in a playbook, and nothing in the tree directing a bypass, a hook write, a settings edit, telemetry, a remote download, a credential or an override. Rejected: eighteen skills; one inline file; a hooks-lite opt-in; an elevated reviewer; a `doctor`; the native playbooks.](<redacted>.md) | ui-design / skills |
311
311
  | 0222 | [**The image family under the hero-only rule: plates and hero objects, never screens; the mark is drawn; the board is a contact sheet; the pair and the sidecar are the output contract** ([task 1003328](https://cloudbongos.com/builders#/task/1003328) · goal 1000083). The four image skills of `Leonxlnx/taste-skill` (scanned `dangerous`, ADR 0198) are built around pictures of interfaces: one reference image per section, one app screen per flow step in a phone mockup, an identity board with the brand name rendered in, and a build loop whose truth is the generated image; the panel flagged the eagerness mandate, the image-count rule and the must-invoke-any-tool directive by name, and the owner's rule (2026-08-26/27) forbids the category. Decisions: **D1** the subject is the composition's plate slots and the count is the slot count; variety survives as anchors (field, beside-bleeding, masthead, orb, backdrop; text-left image-right refused), the palette lock is the pack, cross-frame consistency is one family (material, light, lens, ground). **D2** a screen is the surface itself: the mobile skill makes portrait plates for 390 / 320, the flow is the states file rendered by the kit, the bible is `DESIGN.md`, no phone mockup, native declines to `/design`. **D3** the source of truth for UI is the owner's references and the world; `image-to-code` reads them (analysis, extraction spec, faithful translation, drift as the failure mode, the clarity checklist) and the only image it may cause is the hero plate. **D4** the mark is drawn as inline SVG, the colour system is the pack, the type specimen is the faces on a real page, the applications are the kit's renders, the image direction is a hero-object family, the nine-panel board is a contact sheet of real parts. **D5** the output contract is DESIGN.md's pair and sidecar (JPEG dark plate, WebP alpha cutout, `<file>.<ext>.json` beside each, `--plate-*` tokens on `body`) through the instance's art pipeline key, the spend logged, a white ground through the remover, alpha verified with ffmpeg, never a crop. **D6** the worked example is the grove specimen's hero object, not the front door. **D7** the pins: the four declared, each naming the sidecar, "text-free" and the art key; no eagerness / count / per-section / must-invoke directive. Rejected: per-section images for reference only; phone mockups around real shots; a generated logo as a start; the plate on the front door; a shared base skill with wrappers.](<redacted>.md) | ui-design / skills / imagery |
312
+ | 0223 | [**The anti-pattern detector is two tiers inside the kit: mechanical floors fail, craft tells warn, a false positive is waived in the file with its reason, and nothing new is installed** ([task 1003329](https://cloudbongos.com/builders#/task/1003329) · goal 1000083). The [detector] group of impeccable's rebuild spec (about sixty rules, a CLI plus a browser bundle wired to hooks, per-project ignores, in-file waivers, "a clean scan is a floor and never proof") lands as RULES in the kit that already exists, per the task body: no second tool, no bundle, no hook. Decisions: **D1** `kit/tells.js` carries the static rules and `check-mock.js` runs them after the platform rules and taste bans; `lib.js`'s `factsInPage` collects the computed-value twins as `tells` and `render.js` prints them as `~ tell:` lines per render, recorded in `report.json`, never counted as problems. Two tiers: floors FAIL (a bounce easing; a card nested in a card) and tells WARN (the icon-heading-text grid, hero metrics, section numbering, a kicker above the h1, a side stripe, a hard offset shadow, a coloured halo, decorative blur, a default typeface, a wide measure, a long headline, the same entrance everywhere, a geometric mask on a plate, glyphs as icons, a rainbow gradient, the browser surfaces nobody themed, proportional numerals, tiny type; rendered: measure, tiny text, numerals in cells, headline lines, an overshooting curve). **D2** the world decides which tells are floors: a kicker above the h1 is a FAIL where the instance's `DESIGN.md` bans the eyebrow (a new `eyebrow` taste key), a WARN elsewhere. **D3** a false positive is waived in the file with its reason (`kit-ignore: <rule prefix> (reason)` → a WAIVE row), `--raw` runs with none, `--no-tells` runs the platform rules only, `--json` prints rows with `fails` / `warns` counts; no per-project ignore config. **D4** not ported: the purple-to-blue tell (a literal colour already fails; hue is unresolvable under the fifteen), an injected overlay, the screenshot sampler (`contrast.js --url` is that pass), a cascade resolver (the browser is the resolver), native code, every hook. **D5** the pins: a clean fixture passes every rule, every rule class trips at its tier with the case count equal to the rule count, the promotion, the waiver / raw / json paths through the real CLI, `craftTells` as warnings; the hub mock and the three specimens still pass, `/projects` and the grove specimen still render ALL CLEAN. Rejected: a second CLI; every tell as a floor (the kit as the taste and the instance as the exception); a project-wide ignore config; an edit-time hook.](0223-the-detector-is-two-tiers-in-the-kit.md) | ui-design / kit |
312
313
 
313
314
  > ⚠️ **Numbering collisions are now machine-enforced, not narrated.** Twenty numbers are shared by two ADRs each — assigned in parallel sessions before anything checked. The files keep their filenames (renumbering would break every existing citation, and a number once assigned is never reused), and this table disambiguates each pair as `NNNN-a` / `NNNN-b`. The authoritative list is `LEGACY_DUPLICATE_ADRS` in [`scripts/gds/adr-namespace.js`](../../scripts/gds/adr-namespace.js), frozen by exact filename: a **new** collision — or a third file joining a legacy number, or a rename of either half — hard-fails the `unit` CI gate, as does an index row that points at no file or an ADR with no row. The list may only shrink. This paragraph used to narrate the collisions one by one and had fallen eight behind reality; see [ADR 0195](<redacted>.md) for why the check exists and why the pairs are grandfathered rather than renumbered.
package/docs/file-map.md CHANGED
@@ -130,7 +130,7 @@
130
130
  │ ├── hall-ui/ ← CORE-DOMAIN WEB-SURFACE carve (BV1.R85, default-on): the authenticated builders' hall at builders.<apex>, moved from public-builders/ (~13k LOC, the largest). NO routes/db/port — declares contributes.webSurfaces:[{host:"builders.",dir:"public"}] for discovery, but UNLIKE status-ui keeps its DEDICATED serving block in serve-internal (the /builders shim + canonical redirects + citizen/Archon page gates + asset-stamping + hall-widget injection), partitioned out of the generic web-surface loop and served from BUILDERS_DIR. CLAUDE.md, module.json, public/{index.html,builders.js,style.css,work,watch,harbor,gate,pair,settings,ranks,sessions,primer,diagrams,atlas,...}.{html,js},cursors/,chime.wav. Three ARCHETYPE sheets sit beside the per-page ones: `record.css` (the task + idea records, task 1003305), `room.css` (the READING ROOMS — the primer + the diagrams index, task 1003306: the reading measure and rhythm, the table of contents, the progress hairline and the framed figure diagram-viewer.js upgrades on both pages) and `oversight.css` (the OVERSIGHT family — government, watch, harbor, gate, task 1003469: the row, the figure band inside a panel, the group head, the inline form, the grant toggle and the shared confirm modal; named oversight because the vocabulary wall bans "governance" on the government surface). A page sheet then holds only what one page has and the others do not — `primer.css` the acknowledge gate, `diagrams.css` the legend strip, `gate.css` the hold reasons and file chips, `watch.css` the severities, the report reader and the grade trend, `people.css` the directory's identity cell (task 1003470: `/scouting` is the LEGACY DOOR to `/people` and ships the same body and script — `scouting.html` loads `people.js`; there is no `scouting.js`).
131
131
  │ ├── lifecycle/ ← CORE-DOMAIN carve (BV1.R86, default-on): the build state machine — tasks · claims · versions · done-when · goals · dependencies · claim→ship→grade→credit · github-push + merge-lock + conflict-resolve + publish-reconciler. The LAST, most-coupled carve; registers the `lifecycle` kernel port (createTask, classifyTaskKind, tallyPeerVotes, work-tracking reads). CLAUDE.md, module.json, routes/{tasks,claims,versions,done-when,goals,dependencies,gate-approvals,analytics}.js, lifecycle.js, github-push.js, merge-lock.js, conflict-resolve.js, publish-reconciler.js, task-visuals.js + task-visual-db.js + routes/visuals.js (the optional ship-time visual, task 1003109 — the route file is NOT named task-*.js on purpose; see the module CLAUDE.md).
132
132
  │ ├── copy-desk/ ← CORE-DOMAIN module (task 1003113 / R02 of goal 1000074, default-on): the COPY half of the design contract — ADR 0081 gave design TOKENS a repo-owned source of truth, text had none. Owns the FLAG (anyone in any role marks a string or a whole surface as needing an artist's attention, with a reason required in BOTH the route and a CHECK constraint) and the QUEUE an artist reads it from. READS R01's committed registry (docs/copy-registry.json, generated by scripts/gds/copy-inventory.js) and never writes it. NO LIVE CMS is the hard non-goal: no column here could hold replacement text, and copy_no_cms.mjs pins that. Key is `copy-desk`, not `copy` — the branding contract already owns a `copy` block, so the bare word reds the "core carries no module code" fitness check. CLAUDE.md, module.json, registry.js, flags.js, routes/copy-desk.js, migrations/copy_desk_001_flags.sql, tests/{copy_flags,copy_queue,copy_no_cms}.mjs. Its hall surface lives with hall-ui (public/copy-desk.{html,js,css}), per the one-key-per-web-surface precedent.
133
- │ ├── ui-design/ ← CORE-DOMAIN module (task 1003324 / ADR 0197, default-on): the design capability every builder on every instance gets — ships METHOD, never a world. Absorbs ADR 0081's layer: adapters/{claude-design,figma}/index.js (moved from src/ui/adapters/), scripts/{design-sync,figma-design-sync,validate-design}.js (moved from scripts/gds/, which keeps thin shims), docs/design-contract.md (moved from docs/). Declaration-only: contributes.skills design (the /design playbook, new) + design-sync + figma-design-sync; no routes/port/migrations, so disabling it touches no served surface. CLAUDE.md separates the platform FLOORS (the Fifteen Rule, the dark twins, no page :root, the 24px floor, AA in both modes, the kill switch) from the instance's WORLD (its packs + DESIGN.md). kit/ (task 1003320) is the look-before-you-ship kit: lib.js (the floors, the states/probes contracts, the action + expectation vocabularies, the WCAG maths, the stub spawner), serve.js (any surface through the doorway's real transforms + fixtures/), render.js (states × 1440/390/320 × dark/light, audited), probe.js (PASS/FAIL contracts), check-mock.js (platform rules + the instance's DESIGN.md taste bans), contrast.js + png.js; a page's <page>.states.json / <page>.probes.json sit BESIDE the surface (modules/public-landing/public/, modules/hall-ui/public/). Recipe: docs/recipes/ui-look-before-you-ship.md. skills/ (task 1003321 / ADR 0198) is the vendoring policy: policy.json (licence allowlist, verdicts that may land, sidecar fields, preamble markers), PREAMBLE.md (the platform preamble every vendored SKILL.md opens with), README.md (the roster — both origins scanned dangerous on 2026-08-28, landed as rebuild specs; nothing vendored — and the landing recipe); a landable copy goes under skills/vendor/<name>/ with PROVENANCE.md + LICENSE and is materialised into an instance's .claude/skills/ by claude-materialize.js step 1b. skills/<name>/ (task 1003325 / ADR 0220) are the in-house rebuilds from the specs, first-party and counted: design-taste-frontend (the default taste skill), design-taste-frontend-v1 (the earlier generation, kept distinct), high-end-visual-design (the polish pass), redesign-existing-projects (the in-place upgrade); each opens with the same preamble and carries a "Rebuilt from" section instead of a sidecar. impeccable/ (task 1003326 / ADR 0221) is the design-craft method as ONE skill with sub-commands: SKILL.md (the router, the menu, the craft floor, the bounded finish) + reference/<sub-command>.md (eighteen playbooks, one loaded per request); no hooks, no bypass agent, no telemetry, no live server. image-to-code/, imagegen-frontend-web/, imagegen-frontend-mobile/, brandkit/ (task 1003328 / ADR 0222) are the image family under the hero-only rule: plates and hero objects only, the pair (JPEG dark plate + WebP alpha cutout) with a <file>.json sidecar beside each, through the instance's art pipeline key; the grove specimen's styles/grove/assets/ is the worked example. styles/ (task 1003323 / ADR 0219) is the style library: <name>/pack.json (the theme.ui overlay, exactly the fifteen) + DESIGN.md (the look's derived recipes, thirteen in lockstep with the pack) + mock.html (the specimen); chrome-world (pinned to the neutral pack), expedition, grove; STYLE=<name> on the kit lays a look over the instance's pack through GDS_BRANDING_FILE; tests/ui_design_styles.mjs runs the world/cosmos/hall token suites once per alternate (UI_DESIGN_PACK). config/design-tokens.* + design-sources.json STAY in config/ as the host boundary; public-landing keeps its own key (first customer, not owner).
133
+ │ ├── ui-design/ ← CORE-DOMAIN module (task 1003324 / ADR 0197, default-on): the design capability every builder on every instance gets — ships METHOD, never a world. Absorbs ADR 0081's layer: adapters/{claude-design,figma}/index.js (moved from src/ui/adapters/), scripts/{design-sync,figma-design-sync,validate-design}.js (moved from scripts/gds/, which keeps thin shims), docs/design-contract.md (moved from docs/). Declaration-only: contributes.skills design (the /design playbook, new) + design-sync + figma-design-sync; no routes/port/migrations, so disabling it touches no served surface. CLAUDE.md separates the platform FLOORS (the Fifteen Rule, the dark twins, no page :root, the 24px floor, AA in both modes, the kill switch) from the instance's WORLD (its packs + DESIGN.md). kit/ (task 1003320) is the look-before-you-ship kit: lib.js (the floors, the states/probes contracts, the action + expectation vocabularies, the WCAG maths, the stub spawner), serve.js (any surface through the doorway's real transforms + fixtures/), render.js (states × 1440/390/320 × dark/light, audited), probe.js (PASS/FAIL contracts), check-mock.js (platform rules + the instance's DESIGN.md taste bans, then the detector), tells.js (task 1003329 / ADR 0223: the anti-pattern detector as two tiers — floors FAIL, craft tells WARN, kit-ignore waivers per file; the rendered twins live in lib.js factsInPage), contrast.js + png.js; a page's <page>.states.json / <page>.probes.json sit BESIDE the surface (modules/public-landing/public/, modules/hall-ui/public/). Recipe: docs/recipes/ui-look-before-you-ship.md. skills/ (task 1003321 / ADR 0198) is the vendoring policy: policy.json (licence allowlist, verdicts that may land, sidecar fields, preamble markers), PREAMBLE.md (the platform preamble every vendored SKILL.md opens with), README.md (the roster — both origins scanned dangerous on 2026-08-28, landed as rebuild specs; nothing vendored — and the landing recipe); a landable copy goes under skills/vendor/<name>/ with PROVENANCE.md + LICENSE and is materialised into an instance's .claude/skills/ by claude-materialize.js step 1b. skills/<name>/ (task 1003325 / ADR 0220) are the in-house rebuilds from the specs, first-party and counted: design-taste-frontend (the default taste skill), design-taste-frontend-v1 (the earlier generation, kept distinct), high-end-visual-design (the polish pass), redesign-existing-projects (the in-place upgrade); each opens with the same preamble and carries a "Rebuilt from" section instead of a sidecar. impeccable/ (task 1003326 / ADR 0221) is the design-craft method as ONE skill with sub-commands: SKILL.md (the router, the menu, the craft floor, the bounded finish) + reference/<sub-command>.md (eighteen playbooks, one loaded per request); no hooks, no bypass agent, no telemetry, no live server. image-to-code/, imagegen-frontend-web/, imagegen-frontend-mobile/, brandkit/ (task 1003328 / ADR 0222) are the image family under the hero-only rule: plates and hero objects only, the pair (JPEG dark plate + WebP alpha cutout) with a <file>.json sidecar beside each, through the instance's art pipeline key; the grove specimen's styles/grove/assets/ is the worked example. styles/ (task 1003323 / ADR 0219) is the style library: <name>/pack.json (the theme.ui overlay, exactly the fifteen) + DESIGN.md (the look's derived recipes, thirteen in lockstep with the pack) + mock.html (the specimen); chrome-world (pinned to the neutral pack), expedition, grove; STYLE=<name> on the kit lays a look over the instance's pack through GDS_BRANDING_FILE; tests/ui_design_styles.mjs runs the world/cosmos/hall token suites once per alternate (UI_DESIGN_PACK). config/design-tokens.* + design-sources.json STAY in config/ as the host boundary; public-landing keeps its own key (first customer, not owner).
134
134
  │ ├── character-anim/ ← consistent-character generative animation (ADR 0088) — graded painterly frame sets + manifest, the otb-character-review skill. CLAUDE.md, module.json.
135
135
  │ ├── provisioning/ ← stands up + operates a Cloud Bongos instance's own infra (server/DB/domain/DNS/TLS) via the API (ADR 0111); web-tier request+state only, no cloud tokens — a separate control-plane runner drains the intents. Distinct from the `hosting` module (ADR 0092), which brokers compute workloads. CLAUDE.md, module.json.
136
136
  │ └── demo/ ← BV1.R52 acceptance-proof module: one rank-gated write route + one namespaced migration proving the loader mounts a scaffolded module with zero core edits. CLAUDE.md, module.json.
@@ -1365,5 +1365,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
1365
1365
  landed since 1.19.462 with no explicit bump. run 33577660347. (task 1002620)
1366
1366
  1.19.464 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1367
1367
  landed since 1.19.463 with no explicit bump. run 33578746178. (task 1002620)
1368
+ 1.19.465 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1369
+ landed since 1.19.464 with no explicit bump. run 33581328823. (task 1002620)
1368
1370
  ---------------------------------------------------------------------------
1369
1371
  ```
@@ -101,6 +101,8 @@ node modules/ui-design/kit/contrast.js --url http://127.0.0.1:3412/projects --gl
101
101
 
102
102
  `check-mock.js` runs the mechanical rules over a self-contained mock before anyone looks at it: the **platform's** (no page-local `:root`, page tokens on `body`, every dark rule written twice byte-identical, no literal colour beyond black/white with alpha, no pack colour pinned, the `?state=` contract, a head comment after `<body>`, one header/main/h1 + a skip link, apex-only sentinels balanced, no `innerHTML`, no motion in JavaScript, transitions ≤ 400ms) and the **instance's taste bans**, read from its `DESIGN.md` "Don't" section (an em/en dash, emoji, gradient text, a coloured glow, a box shadow). The em dash ban is Cloud Bongos's, not the platform's: an instance whose `DESIGN.md` never mentions it is not held to it.
103
103
 
104
+ After those, the **anti-pattern detector** (task 1003329, ADR 0223; `kit/tells.js`, listed rule by rule in [`kit/README.md`](../../modules/ui-design/kit/README.md)): two floors that FAIL (a bounce easing, a card nested in a card) and eighteen craft tells that WARN (the identical card grid, hero metrics, section numbering, a kicker above the h1, a side stripe, a hard offset shadow, a coloured halo, decorative blur, a default typeface, a wide measure, a long headline, the same entrance everywhere, a geometric mask on a plate, glyphs as icons, a rainbow gradient, the browser surfaces nobody themed, proportional numerals, tiny type). A tell is a reviewer's read, never a failure, and a false positive is named where it fires: `<!-- kit-ignore: tell: tiny type (why) -->` prints it as WAIVE with the reason; `--raw` ignores waivers, `--no-tells` skips the detector, `--json` prints the rows for a job to gate on. `render.js` reports the computed-value twins (measure, tiny text, numerals in cells, headline lines, an overshooting curve) as `~ tell:` lines under each render and keeps them in `report.json`.
105
+
104
106
  `contrast.js --pack` measures the instance's own pack — every ink on every ground, light values — with no browser. `--url`/`--file` walks every visible text element in both modes and computes the real WCAG ratio against its *composited* background; `--glyphs` screenshots named elements and reads the glyph pixels against the local ground's median, for text over a moving or textured ground where the stylesheet cannot say what is under it.
105
107
 
106
108
  ## When it goes wrong
@@ -13,3 +13,15 @@ node modules/ui-design/kit/contrast.js --pack [look] | --url <url> [--glyphs sel
13
13
  A **look** is a branding pack from the module's style library (`modules/ui-design/styles/<name>/`, task 1003323): `STYLE=<name>` lays it over the instance's own pack through `GDS_BRANDING_FILE`, so any surface renders under any look with no page edit.
14
14
 
15
15
  A surface describes itself in two JSON files beside it — `<page>.states.json` and `<page>.probes.json` — in the vocabularies `lib.js` defines and validates. The full loop, the file shapes and the failure table: [`docs/recipes/ui-look-before-you-ship.md`](../../../docs/recipes/ui-look-before-you-ship.md). Exit codes everywhere: 0 clean · 1 problems · 2 a broken file or usage · 3 no browser.
16
+
17
+ ## The anti-pattern detector (`tells.js`, and the rendered twins in `lib.js`)
18
+
19
+ The [detector] group of impeccable's rebuild spec, landed as rules in the kit that already exists ([task 1003329](https://cloudbongos.com/builders#/task/1003329), [ADR 0223](../../../docs/adr/0223-the-detector-is-two-tiers-in-the-kit.md)): no second tool, no browser bundle, no hook, no model, no key. `check-mock.js` runs the static rules over a mock's source; `render.js` reports the computed-value twins per state, width and mode. Two tiers, kept apart on purpose, beside the platform rules and the instance's taste bans:
20
+
21
+ - **Floors** (`FAIL`, mechanical, every instance): **bounce easing** (a `cubic-bezier` whose y control points leave 0..1; nothing on the platform overshoots and the switch has no spring) · **a card nested in a card** (a `card` class inside another, from a tag-stack walk of the markup).
22
+ - **Tells** (`WARN`; a clean scan is a floor, never proof, and a reviewer verifies each in context): **identical icon-heading-text card grid** (three or more adjacent siblings with the same icon, heading, paragraph shape) · **the hero-metric template** (three or more statistics in the first section) · **decorative section numbering** (`01`, `02` as labels) · **a kicker or eyebrow label directly above the h1** (a `FAIL` where the instance's `DESIGN.md` bans the eyebrow, as the chrome world does) · **a coloured side stripe** (a `border-left` wider than a hairline) · **a hard zero-blur offset shadow** · **a zero-offset coloured halo** as elevation · **decorative glass and blur** (`backdrop-filter` on more than two surfaces) · **a default typeface as the voice** (Inter, Roboto, Arial, Helvetica, system-ui and their kin named in a `font-family`, where the pack's two stacks are the voice) · **a prose measure wider than 80ch** · **a headline over 90 characters** · **the same entrance animation on three or more rules** · **a geometric mask on a plate** (`clip-path: circle | ellipse | polygon` on an `img` or plate rule) · **unicode glyphs standing in for an icon set** (three or more arrows, stars, ticks in copy) · **a rainbow or mesh gradient** (a conic gradient, or four or more coloured stops; a black, white or transparent scrim is not one) · **the browser surfaces nobody themed** (`::selection`, `caret-color` beside a field, `text-underline-offset` beside an underline) · **proportional numerals in data** (four-digit figures with no `tabular-nums`) · **tiny type** (a `font-size` under 11px).
23
+ - **Rendered tells** (`render.js`, from computed values, printed as `~ tell:` lines and kept in `report.json` under each render, never counted as problems): **prose-measure** (a paragraph wider than about 95 characters per line) · **tiny-type** (visible text under 11px) · **proportional-numerals** (a cell with a figure and no `tabular-nums`) · **headline-lines** (an h1 wrapping past three lines) · **bounce-easing** (an overshooting curve in a computed timing function).
24
+
25
+ **Not ported, on purpose:** the purple-to-blue gradient tell (a literal colour is already a platform failure; under the fifteen-token contract a gradient's hue is unresolvable from source), a browser-injected overlay (the rendered twins run inside the kit's own page evaluation), a per-project ignore config (the waiver below is per file, which is where a false positive lives), and any run on native app code (every surface here is a served page).
26
+
27
+ **Waivers and switches.** A false positive is named, not silenced: `<!-- kit-ignore: tell: tiny type (the mono label is 10px by the world's own rule) -->` anywhere in the file waives that rule for the file and prints it as `WAIVE` with the reason (a key matches the rule by prefix, so `kit-ignore: tell: a kicker` reads like the rule). `--raw` runs with no waivers; `--no-tells` runs the platform rules and taste bans only; `--json` prints the rows as `{ status, rule, detail }` with `fails` and `warns` counts for a job that gates on them. Every rule is pinned in `tests/ui_design_kit.mjs` with a fixture that trips it and a clean fixture that passes all of them.
@@ -22,6 +22,7 @@
22
22
  const fs = require('node:fs');
23
23
  const path = require('node:path');
24
24
  const lib = require('./lib.js');
25
+ const tells = require('./tells.js');
25
26
 
26
27
  // The taste bans an instance's DESIGN.md may switch on, by the words its "Don't"
27
28
  // section uses. Pure: text in, a Set of ban keys out.
@@ -31,6 +32,7 @@ const TASTE_BANS = Object.freeze({
31
32
  'gradient-text': /gradient text/i,
32
33
  'text-glow': /\bglow\b/i,
33
34
  'box-shadow': /box[- ]shadow|\bshadow\b/i,
35
+ 'eyebrow': /\b(eyebrow|kicker)\b/i,
34
36
  });
35
37
  function readDesignBans(mdText) {
36
38
  const bans = new Set();
@@ -66,7 +68,7 @@ const BLACK_OR_WHITE = /^(000|000000|fff|ffffff|0{8}|f{8}|000000[0-9a-f]{2}|ffff
66
68
  // Pure: the rules over a mock's source. `packColors` = the instance's fifteen
67
69
  // (name → value) so a literal equal to a pack colour is caught; `bans` = the
68
70
  // instance's taste bans; `states`/`keys` = names the mock must carry.
69
- function checkMock(html, { bans = new Set(), packColors = {}, states = [], keys = [] } = {}) {
71
+ function checkMock(html, { bans = new Set(), packColors = {}, states = [], keys = [], waivers = new Map(), noTells = false } = {}) {
70
72
  const raw = String(html).replace(/\r\n/g, '\n');
71
73
  const results = [];
72
74
  const ok = (rule, pass, detail) => results.push([pass ? 'PASS' : 'FAIL', rule, detail || '']);
@@ -123,13 +125,17 @@ function checkMock(html, { bans = new Set(), packColors = {}, states = [], keys
123
125
  ok('no innerHTML', !/innerHTML/.test(scripts));
124
126
  const durs = [...css.matchAll(/transition\s*:[^;]*?(\d+)ms/g)].map((x) => +x[1]).filter((d) => d > 400);
125
127
  ok('state transitions at most 400ms', durs.length === 0, durs.join(','));
128
+
129
+ // ── the anti-pattern detector (task 1003329, ADR 0223): floors FAIL, tells WARN, a
130
+ // per-file `kit-ignore:` waiver WAIVEs with its reason; --raw runs with none ──
131
+ if (!noTells) results.push(...tells.scanTells(raw, { bans, waivers }));
126
132
  return results;
127
133
  }
128
134
 
129
135
  function main() {
130
136
  const args = process.argv.slice(2);
131
137
  const file = lib.argOf(args, '--file', null);
132
- if (!file || lib.hasFlag(args, '--help')) { process.stderr.write('usage: node modules/ui-design/kit/check-mock.js --file <mock.html> [--states a,b] [--keys A,B] [--design <DESIGN.md>] [--no-taste] [--pack <style>]\n'); process.exit(lib.EXIT.USAGE); }
138
+ if (!file || lib.hasFlag(args, '--help')) { process.stderr.write('usage: node modules/ui-design/kit/check-mock.js --file <mock.html> [--states a,b] [--keys A,B] [--design <DESIGN.md>] [--no-taste] [--no-tells] [--raw] [--json] [--pack <style>]\n'); process.exit(lib.EXIT.USAGE); }
133
139
  if (!fs.existsSync(file)) { process.stderr.write(`check-mock: not found: ${file}\n`); process.exit(lib.EXIT.USAGE); }
134
140
  const styleArg = lib.argOf(args, '--pack', null);
135
141
  const style = styleArg ? lib.resolveStyle(styleArg) : null;
@@ -143,10 +149,17 @@ function main() {
143
149
  let packColors = {};
144
150
  if (style) packColors = lib.packColors(style.pack);
145
151
  else { try { packColors = lib.packColors(lib.instancePack()); } catch { packColors = {}; } }
146
- const results = checkMock(fs.readFileSync(file, 'utf8'), { bans, packColors, states: lib.listArg(args, '--states', []), keys: lib.listArg(args, '--keys', []) });
147
- for (const [s, rule, detail] of results) process.stdout.write(`${s.padEnd(4)} ${rule}${detail ? ' (' + detail + ')' : ''}\n`);
152
+ const source = fs.readFileSync(file, 'utf8');
153
+ const waivers = lib.hasFlag(args, '--raw') ? new Map() : tells.parseWaivers(source);
154
+ const results = checkMock(source, { bans, packColors, states: lib.listArg(args, '--states', []), keys: lib.listArg(args, '--keys', []), waivers, noTells: lib.hasFlag(args, '--no-tells') });
148
155
  const fails = results.filter((r) => r[0] === 'FAIL').length;
149
- process.stdout.write(`\n${fails ? `${fails} RULE(S) FAIL` : 'ALL RULES PASS'} (taste bans: ${[...bans].join(', ') || 'none declared'})\n`);
156
+ const warns = results.filter((r) => r[0] === 'WARN').length;
157
+ if (lib.hasFlag(args, '--json')) {
158
+ process.stdout.write(JSON.stringify({ file, fails, warns, tasteBans: [...bans], results: results.map(([status, rule, detail]) => ({ status, rule, detail })) }, null, 1) + '\n');
159
+ } else {
160
+ for (const [s, rule, detail] of results) process.stdout.write(`${s.padEnd(4)} ${rule}${detail ? ' (' + detail + ')' : ''}\n`);
161
+ process.stdout.write(`\n${fails ? `${fails} RULE(S) FAIL` : 'ALL RULES PASS'}${warns ? ` · ${warns} tell(s) to verify in context` : ''} (taste bans: ${[...bans].join(', ') || 'none declared'})\n`);
162
+ }
150
163
  process.exit(fails ? lib.EXIT.PROBLEMS : lib.EXIT.CLEAN);
151
164
  }
152
165
 
@@ -188,6 +188,23 @@ function factsInPage(spec) {
188
188
  if (e.value) out.value = e.value.map(([s]) => { const el = q(s); return el ? el.value : null; });
189
189
  if (e.hasClass) out.hasClass = e.hasClass.map(([s, c]) => { const el = q(s); return el ? el.classList.contains(c) : null; });
190
190
  if (e.top) out.top = e.top.map(([s]) => { const el = q(s); return el ? Math.round(el.getBoundingClientRect().top) : null; });
191
+ // The craft tells, from COMPUTED values (task 1003329): the rendered twins of tells.js.
192
+ // Reported as warnings by render.js, never as floor problems — a reviewer verifies each.
193
+ const px = (el) => parseFloat(getComputedStyle(el).fontSize) || 16;
194
+ const wide = [], tiny = [], prop = [], tells = [];
195
+ qa('p, li, blockquote, dd').filter(shown).forEach((el) => { if ((el.textContent || '').trim().length < 140 || /grid|flex|table/.test(getComputedStyle(el).display)) return; const cpl = el.clientWidth / (px(el) * 0.5); if (cpl > 95) wide.push(`${describe(el)} ~${Math.round(cpl)}ch`); });
196
+ qa('p, li, a, button, label, td, th, small, span, dt, dd').filter(shown).forEach((el) => { if ((el.textContent || '').trim().length < 2) return; const s = px(el); if (s < 11) tiny.push(`${describe(el)} ${s}px`); });
197
+ qa('td, th, dd').filter(shown).forEach((el) => { const t = (el.textContent || '').trim(); if (el.children.length || !/\d{3,}/.test(t)) return; if (!/tabular-nums/.test(getComputedStyle(el).fontVariantNumeric)) prop.push(`${describe(el)} "${t.slice(0, 16)}"`); });
198
+ const h1 = qa('h1').find(shown);
199
+ const hLines = h1 ? Math.round(h1.getBoundingClientRect().height / (parseFloat(getComputedStyle(h1).lineHeight) || px(h1) * 1.2)) : 0;
200
+ const curves = qa('[class]').filter(shown).slice(0, 400).flatMap((el) => { const cs = getComputedStyle(el); return ((cs.transitionTimingFunction || '') + ' ' + (cs.animationTimingFunction || '')).match(/cubic-bezier\([^)]*\)/g) || []; });
201
+ const overshoot = [...new Set(curves)].filter((b) => { const n = (b.match(/-?[\d.]+/g) || []).map(Number); return n[1] < 0 || n[1] > 1 || n[3] < 0 || n[3] > 1; });
202
+ if (wide.length) tells.push(`prose-measure: ${wide.slice(0, 3).join(' | ')}`);
203
+ if (tiny.length) tells.push(`tiny-type: ${tiny.slice(0, 3).join(' | ')}`);
204
+ if (prop.length) tells.push(`proportional-numerals: ${prop.slice(0, 3).join(' | ')}`);
205
+ if (hLines > 3) tells.push(`headline-lines: the h1 wraps to ${hLines} lines`);
206
+ if (overshoot.length) tells.push(`bounce-easing: ${overshoot.join(' | ')}`);
207
+ out.tells = tells;
191
208
  return out;
192
209
  }
193
210
  // Pure: compare collected facts against an expectation. Returns problem strings.
@@ -234,6 +251,10 @@ function reducedMotionProblems({ running = [], frames = 0 } = {}) {
234
251
  if (frames > 2) p.push(`reduced-motion: a requestAnimationFrame loop (${frames} frames in 800ms)`);
235
252
  return p;
236
253
  }
254
+ // Pure: the craft tells a render surfaced (the computed-value twins of tells.js), as warning
255
+ // lines the renderer prints and records — never floor problems, because a clean scan is a
256
+ // floor and not proof, and a reviewer verifies each tell in context (task 1003329, ADR 0223).
257
+ function craftTells(facts) { return (facts && facts.tells ? facts.tells : []).map((t) => `tell: ${t}`); }
237
258
 
238
259
  // ── the states file ───────────────────────────────────────────────────────────
239
260
  // { "page": "/projects", "surface"?: "public-landing" (a module key), "stub"?: { prefix?, child? },
@@ -429,7 +450,7 @@ function packColors(pack) {
429
450
  module.exports = {
430
451
  REPO_ROOT, KIT_DIR, STYLES_DIR, EXIT, FLOORS, SIZES, MODES, CHANNELS, SETTLE_MS, ACTIONS, EXPECT_KEYS,
431
452
  argOf, hasFlag, listArg, pageName, normalizePageArg, resolveSurfaceDir, sidecarPath, readJson,
432
- validateActions, runActions, validateExpect, factsInPage, expectProblems, auditFloors, reducedMotionProblems,
453
+ validateActions, runActions, validateExpect, factsInPage, expectProblems, auditFloors, reducedMotionProblems, craftTells,
433
454
  validateStatesFile, validateProbesFile, NoBrowserError, launchBrowser, attachCollectors, withMode,
434
455
  freePort, startStub, parseColor, composite, luminance, contrastRatio, instancePack, packColors,
435
456
  listStyles, resolveStyle, styleBrandingFile,
@@ -108,9 +108,11 @@ async function main() {
108
108
  const found = [...logs, ...lib.auditFloors(facts, { width: W, mode }), ...lib.expectProblems(facts, st.expect)];
109
109
  const key = `${mode}-${w}-${name}`;
110
110
  await page.screenshot({ path: path.join(out, key + '.png') });
111
- report.renders[key] = { facts, problems: found };
112
- process.stdout.write(`${mode} ${w} ${name}: ${found.length ? 'PROBLEMS' : 'clean'} bar ${facts.bar ? facts.bar.h : '-'} expanded [${facts.expanded.join('|')}] anims ${facts.running} focus ${facts.activeId || facts.activeText || '-'}\n`);
111
+ const tells = lib.craftTells(facts);
112
+ report.renders[key] = { facts, problems: found, tells };
113
+ process.stdout.write(`${mode} ${w} ${name}: ${found.length ? 'PROBLEMS' : 'clean'} bar ${facts.bar ? facts.bar.h : '-'} expanded [${facts.expanded.join('|')}] anims ${facts.running} focus ${facts.activeId || facts.activeText || '-'}${tells.length ? ` tells ${tells.length}` : ''}\n`);
113
114
  for (const f of found) { process.stdout.write(` ${f}\n`); problems.push(`${key}: ${f}`); }
115
+ for (const t of tells) process.stdout.write(` ~ ${t}\n`);
114
116
  await ctx.close();
115
117
  }
116
118
  // reduced motion: the kill switch, exercised with the file's own interaction
@@ -0,0 +1,204 @@
1
+ 'use strict';
2
+ // tells.js — the anti-pattern detector as RULES over a page's source (task 1003329, ADR 0223;
3
+ // the [detector] group of impeccable's rebuild spec, rebuilt in-house from its documentation
4
+ // only). Two tiers, kept apart on purpose:
5
+ // • FLOORS — mechanical and unambiguous on every instance: a bounce easing (nothing on the
6
+ // platform overshoots; the switch has no spring), a card nested in a card. check-mock
7
+ // reports them as FAIL.
8
+ // • TELLS — the craft floor's generated-design signatures and general-quality reads that a
9
+ // reviewer verifies in context: the identical icon-heading-text grid, hero metrics,
10
+ // decorative section numbering, a kicker above the h1, a coloured side stripe, a hard
11
+ // zero-blur offset shadow, a zero-offset coloured halo, decorative blur, a default typeface
12
+ // as the voice, a wide measure, a long headline, the same entrance on every section, a
13
+ // geometric mask on a plate, unicode glyphs as icons, a rainbow or mesh gradient, the
14
+ // browser surfaces nobody themed, proportional numerals in data, tiny type. check-mock
15
+ // reports them as WARN: a clean scan is a floor, never proof, and a false positive is
16
+ // named out loud rather than silenced.
17
+ // Pure: text in, [tier, rule, detail] out. No model, no key, no network, no browser — the
18
+ // rendered twins of these rules (computed values) live in lib.js's factsInPage as `tells`.
19
+ // Waivers: `kit-ignore: <key>, <key> (reason)` in any comment of the file; `--raw` bypasses.
20
+
21
+ const VOID = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
22
+ const CARD = /\bcard\b/i;
23
+ const KICKER = /\b(label|eyebrow|kicker|overline)\b/i;
24
+ const ICONISH = /^(svg|i|img|span|figure)$/;
25
+ const DEFAULT_FACES = /font-family\s*:\s*["']?(Inter|Roboto|Arial|Helvetica(?: Neue)?|Poppins|Montserrat|Open Sans|Lato|system-ui|-apple-system|Segoe UI)\b/gi;
26
+ const GLYPHS = /[←-↓▶◀★☆✓✔●◆■▲▼➜➔]/g;
27
+
28
+ const strip = (html) => String(html).replace(/<!--[\s\S]*?-->/g, '').replace(/<script[\s\S]*?<\/script>/gi, '').replace(/<style[\s\S]*?<\/style>/gi, '').replace(/<[^>]+>/g, ' ');
29
+ const stylesOf = (raw) => [...raw.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/gi)].map((m) => m[1]).join('\n').replace(/\/\*[\s\S]*?\*\//g, '');
30
+
31
+ // The elements of a page in document order, each with its tag, class list, parent, children and
32
+ // the text that follows its opening tag. A stack walk, not a DOM: enough to see nesting and
33
+ // sibling order, which is all the structural tells need.
34
+ function scanTags(html) {
35
+ const src = String(html).replace(/<!--[\s\S]*?-->/g, '').replace(/<(script|style)\b[\s\S]*?<\/\1>/gi, '');
36
+ const nodes = []; const stack = [];
37
+ const re = /<\/?([a-zA-Z][\w-]*)([^>]*)>/g; let m;
38
+ while ((m = re.exec(src))) {
39
+ const tag = m[1].toLowerCase();
40
+ if (m[0][1] === '/') { for (let i = stack.length - 1; i >= 0; i--) if (nodes[stack[i]].tag === tag) { stack.length = i; break; } continue; }
41
+ const cls = (m[2].match(/class="([^"]*)"/) || [, ''])[1];
42
+ const parent = stack.length ? stack[stack.length - 1] : -1;
43
+ const node = { tag, cls, depth: stack.length, parent, children: [], text: src.slice(re.lastIndex, re.lastIndex + 240).split('<')[0].trim() };
44
+ const idx = nodes.push(node) - 1;
45
+ if (parent >= 0) nodes[parent].children.push(idx);
46
+ if (!VOID.has(tag) && !/\/\s*$/.test(m[2])) stack.push(idx);
47
+ }
48
+ return nodes;
49
+ }
50
+ const describe = (n) => n.tag + (n.cls ? '.' + n.cls.split(/\s+/)[0] : '');
51
+ function subtreeText(nodes, idx) {
52
+ const parts = [nodes[idx].text];
53
+ for (let i = idx + 1; i < nodes.length && nodes[i].depth > nodes[idx].depth; i++) parts.push(nodes[i].text);
54
+ return parts.join(' ');
55
+ }
56
+ // box-shadow layers as [x, y, blur, rest] strings; a value that is not a shadow list yields none.
57
+ function shadowLayers(css) {
58
+ const out = [];
59
+ for (const m of css.matchAll(/(?<![-\w])box-shadow\s*:\s*([^;}]+)/g)) {
60
+ for (const layer of m[1].split(/,(?![^(]*\))/)) {
61
+ const t = layer.trim().replace(/^inset\s+/, 'INSET ').split(/\s+/);
62
+ if (t[0] === 'INSET' || t[0] === 'none' || /^var\(/.test(t[0])) continue;
63
+ const px = (s) => (s === '0' ? 0 : /^-?\d*\.?\d+px$/.test(s) ? parseFloat(s) : NaN);
64
+ if (t.length >= 3 && !Number.isNaN(px(t[0])) && !Number.isNaN(px(t[1]))) out.push({ x: px(t[0]), y: px(t[1]), blur: px(t[2]), rest: t.slice(3).join(' '), src: layer.trim() });
65
+ }
66
+ }
67
+ return out;
68
+ }
69
+ const list = (arr, n = 4) => arr.slice(0, n).join(' | ') + (arr.length > n ? ` (+${arr.length - n})` : '');
70
+
71
+ // ── the floors (FAIL) ──────────────────────────────────────────────────────────
72
+ const FLOORS = {
73
+ 'bounce easing (a cubic-bezier that overshoots; the switch has no spring)': ({ css }) => {
74
+ const bad = [...css.matchAll(/cubic-bezier\(\s*[-\d.]+\s*,\s*([-\d.]+)\s*,\s*[-\d.]+\s*,\s*([-\d.]+)\s*\)/g)].filter((m) => +m[1] < 0 || +m[1] > 1 || +m[2] < 0 || +m[2] > 1).map((m) => m[0]);
75
+ return bad.length ? list([...new Set(bad)]) : null;
76
+ },
77
+ 'a card nested in a card': ({ nodes }) => {
78
+ const hits = [];
79
+ nodes.forEach((n) => { if (!CARD.test(n.cls)) return; for (let p = n.parent; p >= 0; p = nodes[p].parent) if (CARD.test(nodes[p].cls)) { hits.push(`${describe(n)} inside ${describe(nodes[p])}`); break; } });
80
+ return hits.length ? list(hits) : null;
81
+ },
82
+ };
83
+
84
+ // ── the tells (WARN; a reviewer verifies each in context) ─────────────────────
85
+ const TELLS = {
86
+ 'identical icon-heading-text card grid as page structure': ({ nodes }) => {
87
+ const sig = (i) => { const n = nodes[i]; const kids = n.children.map((c) => nodes[c].tag); return kids.length >= 2 && ICONISH.test(kids[0]) && kids.some((t) => /^h[2-6]$/.test(t)) && kids.includes('p') ? kids.join(',') : null; };
88
+ const hits = [];
89
+ for (const n of nodes) { const sigs = n.children.map(sig); let run = 1; for (let i = 1; i < sigs.length; i++) { run = sigs[i] && sigs[i] === sigs[i - 1] ? run + 1 : 1; if (run === 3) { hits.push(`${describe(n)}: ${sigs[i]} × 3+`); break; } } }
90
+ return hits.length ? list(hits) : null;
91
+ },
92
+ 'the hero-metric template (three or more statistics in the first section)': ({ nodes }) => {
93
+ const hero = nodes.findIndex((n) => n.tag === 'section' || /\bhero\b/i.test(n.cls));
94
+ if (hero < 0) return null;
95
+ const stats = subtreeText(nodes, hero).match(/(?<![\w.])\d{1,3}(?:[.,]\d+)?\s*(%|\+|k|K|M|x)(?![\w])/g) || [];
96
+ return stats.length >= 3 ? list(stats) : null;
97
+ },
98
+ 'decorative section numbering (01 / 02 / 03 as labels)': ({ text }) => {
99
+ const n = (text.match(/(?:^|\s)0[1-9](?=\s|$|\s*\/)/g) || []).length;
100
+ return n >= 2 ? `${n} numbered labels` : null;
101
+ },
102
+ 'a kicker or eyebrow label directly above the h1': ({ nodes }) => {
103
+ const h1 = nodes.findIndex((n) => n.tag === 'h1');
104
+ if (h1 < 0 || nodes[h1].parent < 0) return null;
105
+ const sibs = nodes[nodes[h1].parent].children; const at = sibs.indexOf(h1);
106
+ if (at < 1) return null;
107
+ const prev = nodes[sibs[at - 1]];
108
+ return /^(p|span|div|small)$/.test(prev.tag) && KICKER.test(prev.cls) ? `${describe(prev)} above h1` : null;
109
+ },
110
+ 'a coloured side stripe (border-left wider than a hairline)': ({ css }) => {
111
+ const hits = [...css.matchAll(/border-left\s*:\s*((?:[2-9]|\d{2,})px)\s+solid/g)].map((m) => m[1]);
112
+ return hits.length ? `${hits.length} × ${[...new Set(hits)].join(', ')}` : null;
113
+ },
114
+ 'a hard zero-blur offset shadow (a world that did not choose that language)': ({ css }) => {
115
+ const hits = shadowLayers(css).filter((l) => (l.x !== 0 || l.y !== 0) && l.blur === 0).map((l) => l.src);
116
+ return hits.length ? list(hits) : null;
117
+ },
118
+ 'a zero-offset coloured halo as elevation': ({ css }) => {
119
+ const hits = shadowLayers(css).filter((l) => l.x === 0 && l.y === 0 && l.blur > 0 && /^(var\(|color-mix\()/.test(l.rest.replace(/^-?\d+px\s+/, ''))).map((l) => l.src);
120
+ return hits.length ? list(hits) : null;
121
+ },
122
+ 'decorative glass and blur (backdrop-filter on more than two surfaces)': ({ css }) => {
123
+ const n = (css.match(/backdrop-filter\s*:/g) || []).length;
124
+ return n > 2 ? `${n} declarations` : null;
125
+ },
126
+ 'a default typeface as the voice (the pack\'s stacks are var(--font-display) / var(--font-body))': ({ css }) => {
127
+ const hits = [...css.matchAll(DEFAULT_FACES)].map((m) => m[1]);
128
+ return hits.length ? [...new Set(hits)].join(', ') : null;
129
+ },
130
+ 'a prose measure wider than 80 characters': ({ css }) => {
131
+ const hits = [...css.matchAll(/max-width\s*:\s*(\d+)ch/g)].map((m) => +m[1]).filter((n) => n > 80);
132
+ return hits.length ? hits.map((n) => n + 'ch').join(', ') : null;
133
+ },
134
+ 'a headline longer than two lines can hold (over 90 characters)': ({ raw }) => {
135
+ const h = (raw.match(/<h1\b[^>]*>([\s\S]*?)<\/h1>/i) || [, ''])[1].replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
136
+ return h.length > 90 ? `${h.length} characters` : null;
137
+ },
138
+ 'the same entrance animation on three or more rules': ({ css }) => {
139
+ const count = {};
140
+ for (const m of css.matchAll(/(?<![-\w])animation(?:-name)?\s*:\s*([a-zA-Z_][\w-]*)/g)) if (!/^(none|inherit|initial|unset)$/.test(m[1])) count[m[1]] = (count[m[1]] || 0) + 1;
141
+ const hits = Object.entries(count).filter(([, n]) => n >= 3).map(([k, n]) => `${k} × ${n}`);
142
+ return hits.length ? hits.join(', ') : null;
143
+ },
144
+ 'a geometric mask faking an organic cutout on a plate': ({ css }) => {
145
+ const hits = [...css.matchAll(/([^{}]+)\{([^{}]*)\}/g)].filter((m) => /\b(img|picture|plate)\b/i.test(m[1]) && /clip-path\s*:\s*(circle|ellipse|polygon)\(/.test(m[2])).map((m) => m[1].trim());
146
+ return hits.length ? list(hits) : null;
147
+ },
148
+ 'unicode glyphs standing in for a drawn icon set': ({ text }) => {
149
+ const n = (text.match(GLYPHS) || []).length;
150
+ return n >= 3 ? `${n} glyphs in visible copy` : null;
151
+ },
152
+ 'a rainbow or mesh gradient (conic, or four or more stops)': ({ css }) => {
153
+ const hits = [];
154
+ if (/conic-gradient\(/.test(css)) hits.push('conic-gradient');
155
+ // coloured stops only: a black/white/transparent scrim or mask with many stops is not a rainbow
156
+ for (const m of css.matchAll(/(?:linear|radial)-gradient\(((?:[^()]|\([^()]*\))*)\)/g)) { const stops = m[1].split(/,(?![^(]*\))/).filter((s) => /var\(|color-mix\(|hsl|#(?!(?:000|fff)\b)/i.test(s)); if (stops.length >= 4) hits.push(`${stops.length} coloured stops`); }
157
+ return hits.length ? list(hits) : null;
158
+ },
159
+ 'the browser surfaces nobody themed (selection, caret, underline offset)': ({ raw, css }) => {
160
+ const missing = [];
161
+ if (!/::selection/.test(css)) missing.push('::selection');
162
+ if (/<(input|textarea)\b/i.test(raw) && !/caret-color/.test(css)) missing.push('caret-color');
163
+ if (/<a\b/i.test(raw) && /text-decoration\s*:\s*underline/.test(css) && !/text-underline-offset/.test(css)) missing.push('text-underline-offset');
164
+ return missing.length ? missing.join(', ') : null;
165
+ },
166
+ 'proportional numerals in data (no tabular-nums beside four-digit figures)': ({ text, css }) => {
167
+ const figures = (text.match(/(?<![\w.])\d{1,3}(?:,\d{3})+(?![\w.])|(?<![\w.])\d{4,}(?![\w.])/g) || []).length;
168
+ return figures >= 3 && !/tabular-nums/.test(css) ? `${figures} figures, no tabular-nums` : null;
169
+ },
170
+ 'tiny type (a font-size under 11px)': ({ css }) => {
171
+ const hits = [...css.matchAll(/font-size\s*:\s*(\d+(?:\.\d+)?)px/g)].map((m) => +m[1]).filter((n) => n < 11);
172
+ return hits.length ? [...new Set(hits)].map((n) => n + 'px').join(', ') : null;
173
+ },
174
+ };
175
+
176
+ // `kit-ignore: key words, other key (reason)` in any comment: a per-file waiver with its reason.
177
+ // A key is matched by prefix against the rule's first words, so a waiver reads like the rule.
178
+ function parseWaivers(html) {
179
+ const out = new Map();
180
+ const comments = [...String(html).matchAll(/<!--([\s\S]*?)-->|\/\*([\s\S]*?)\*\//g)].map((m) => m[1] || m[2] || '');
181
+ for (const c of comments) for (const m of c.matchAll(/kit-ignore:\s*([^()\n]+?)\s*(?:\(([^)]*)\))?\s*(?:\n|$)/g)) for (const key of m[1].split(',').map((s) => s.trim().toLowerCase()).filter(Boolean)) out.set(key, (m[2] || '').trim());
182
+ return out;
183
+ }
184
+ const waiverFor = (waivers, rule) => { for (const [k, why] of waivers) if (rule.toLowerCase().startsWith(k)) return why || 'waived'; return null; };
185
+
186
+ // The scan: every floor and every tell over one page's source, as check-mock's result rows.
187
+ // `bans` is the instance's taste set: an instance that bans the eyebrow makes that tell a FAIL.
188
+ // `waivers` (a Map from parseWaivers) turns a hit into a WAIVE row; pass none for --raw.
189
+ function scanTells(html, { bans = new Set(), waivers = new Map() } = {}) {
190
+ const raw = String(html).replace(/\r\n/g, '\n');
191
+ const ctx = { raw, css: stylesOf(raw), text: strip(raw), nodes: scanTags(raw) };
192
+ const rows = [];
193
+ const add = (tier, rule, fn) => {
194
+ const detail = fn(ctx);
195
+ if (detail == null) return rows.push(['PASS', rule, '']);
196
+ const why = waiverFor(waivers, rule);
197
+ rows.push([why ? 'WAIVE' : tier, rule, why ? `${detail}; ${why}` : detail]);
198
+ };
199
+ for (const [rule, fn] of Object.entries(FLOORS)) add('FAIL', `floor: ${rule}`, fn);
200
+ for (const [rule, fn] of Object.entries(TELLS)) add(rule.startsWith('a kicker') && bans.has('eyebrow') ? 'FAIL' : 'WARN', `tell: ${rule}`, fn);
201
+ return rows;
202
+ }
203
+
204
+ module.exports = { scanTells, scanTags, parseWaivers, shadowLayers, FLOOR_RULES: Object.keys(FLOORS), TELL_RULES: Object.keys(TELLS) };
@@ -136,7 +136,7 @@ Verification is **bounded passes, never an open loop**, and the budget covers th
136
136
 
137
137
  ## Not here, and where it went
138
138
 
139
- - **The anti-pattern detector**: task 1003329 lands it as rules in `kit/check-mock.js` and `kit/lib.js`; today `check-mock.js` (the platform rules and the instance's `DESIGN.md` bans over a mock), `render.js`'s floors and `contrast.js` are the deterministic floor. Run it once per review round, treat a clean scan as a floor and never as proof, verify every finding in context and name false positives out loud.
139
+ - **The anti-pattern detector**: landed as rules in the kit (task 1003329, ADR 0223): `kit/tells.js` runs inside `check-mock.js` (two floors that FAIL, eighteen tells that WARN, a kicker above the h1 a FAIL where the world bans the eyebrow), and the rendered twins ride `render.js` as `~ tell:` lines per state; `contrast.js` is the pair floor. Run it once per review round, treat a clean scan as a floor and never as proof, verify every finding in context, and name a false positive where it fires with `kit-ignore: <rule> (reason)` rather than silencing the rule.
140
140
  - **The live loop**: `claude plugin install playwright@claude-plugins-official`, a prerequisite the module never installs, driving `kit/serve.js`'s stub (`SURFACE=<dir or module key>`, `STYLE=<look>`). Variants are states in the states file, rendered by the kit; accepting one is a commit and discarding one is a checkout; there is no in-page overlay, no injected script, no journal, and no leftover to sweep because nothing was injected.
141
141
  - **Hooks, the bypass agent, telemetry, the remote bundle, the installer, `doctor`, `pin`, `unpin`**: gone. The ship gate is the sweep; the materialiser is the install; a reviewer runs with ordinary permissions.
142
142
  - **The asset producer**: images are hero plates and hero objects by token (task 1003328 for the image family); nothing here generates, crops or cleans an image.
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.464",
3
+ "version": "1.19.465",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.464",
9
+ "version": "1.19.465",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.464",
3
+ "version": "1.19.465",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
package/src/module-api.js CHANGED
@@ -33,7 +33,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
33
33
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
34
34
  // the entry to that file. Look for a version's history there, not here.
35
35
  // ---------------------------------------------------------------------------
36
- const CORE_VERSION = '1.19.464'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
36
+ const CORE_VERSION = '1.19.465'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
37
37
 
38
38
  // A namespaced logger so a module's log lines are attributable + consistent.
39
39
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -185,6 +185,92 @@ test('checkMock: a taste ban applies only when the instance declared it', () =>
185
185
  assert.ok(!withoutBan.some((r) => r[1].startsWith('taste:')), 'no taste rule is even evaluated without a ban');
186
186
  });
187
187
 
188
+ // ── the anti-pattern detector (task 1003329, ADR 0223): every rule trips on a fixture and passes on one ──
189
+ const tellsMod = require(path.join(KIT, 'tells.js'));
190
+ const CLEAN = `<!doctype html><html><head><style>
191
+ ::selection{background:var(--accent)} body{color:var(--ink);--font:var(--font-body)} p{max-width:64ch}
192
+ .x{transition:opacity 200ms cubic-bezier(.2,.7,.3,1)} .lift{box-shadow:0 10px 26px -14px var(--shade)}
193
+ </style></head><body><main><h1>Short title</h1><p>Plain copy.</p></main></body></html>`;
194
+ const withSnippet = (snippet) => (typeof snippet === 'function' ? snippet(CLEAN) : CLEAN.replace('</main>', snippet + '</main>'));
195
+ const tripped = (html, prefix, opts) => tellsMod.scanTells(html, opts).find((r) => r[1].includes(prefix) && r[0] !== 'PASS');
196
+ const DETECTOR_CASES = [
197
+ ['bounce easing', 'FAIL', '<style>.b{transition:transform 200ms cubic-bezier(.34,1.56,.64,1)}</style>'],
198
+ ['a card nested in a card', 'FAIL', '<div class="card"><div class="card">x</div></div>'],
199
+ ['identical icon-heading-text', 'WARN', '<ul><li><svg></svg><h3>a</h3><p>b</p></li><li><svg></svg><h3>a</h3><p>b</p></li><li><svg></svg><h3>a</h3><p>b</p></li></ul>'],
200
+ ['the hero-metric', 'WARN', '<section><b>99%</b> <b>10k</b> <b>24+</b> uptime</section>'],
201
+ ['decorative section numbering', 'WARN', '<p>01 Features</p><p>02 Pricing</p>'],
202
+ ['a kicker', 'WARN', (c) => c.replace('<h1>Short title</h1>', '<p class="label">Eyebrow</p><h1>Short title</h1>')],
203
+ ['a coloured side stripe', 'WARN', '<style>.q{border-left:4px solid var(--accent)}</style>'],
204
+ ['a hard zero-blur', 'WARN', '<style>.h{box-shadow:6px 6px 0 var(--ink)}</style>'],
205
+ ['a zero-offset coloured halo', 'WARN', '<style>.g{box-shadow:0 0 24px var(--accent)}</style>'],
206
+ ['decorative glass', 'WARN', '<style>.a{backdrop-filter:blur(8px)}.b{backdrop-filter:blur(8px)}.c{backdrop-filter:blur(8px)}</style>'],
207
+ ['a default typeface', 'WARN', '<style>h2{font-family:Inter,sans-serif}</style>'],
208
+ ['a prose measure', 'WARN', '<style>.wide{max-width:96ch}</style>'],
209
+ ['a headline longer', 'WARN', (c) => c.replace('<h1>Short title</h1>', `<h1>${'word '.repeat(22)}</h1>`)],
210
+ ['the same entrance', 'WARN', '<style>.a{animation:rise 1s}.b{animation:rise 1s}.c{animation:rise 1s}</style>'],
211
+ ['a geometric mask', 'WARN', '<style>img.plate{clip-path:circle(50%)}</style>'],
212
+ ['unicode glyphs', 'WARN', '<p>→ one ★ two ✓ three</p>'],
213
+ ['a rainbow', 'WARN', '<style>.m{background:conic-gradient(var(--a),var(--b))}</style>'],
214
+ ['the browser surfaces', 'WARN', '<style>a{text-decoration:underline}</style><a href="#">x</a>'],
215
+ ['proportional numerals', 'WARN', '<table><tr><td>1,200</td><td>34,000</td><td>12345</td></tr></table>'],
216
+ ['tiny type', 'WARN', '<style>.s{font-size:9px}</style>'],
217
+ ];
218
+
219
+ test('the detector: a clean page trips nothing, and every rule class trips on its own fixture at its own tier', () => {
220
+ const clean = tellsMod.scanTells(CLEAN);
221
+ assert.equal(clean.length, tellsMod.FLOOR_RULES.length + tellsMod.TELL_RULES.length, 'every rule reports a row');
222
+ assert.deepEqual(clean.filter((r) => r[0] !== 'PASS'), [], 'the clean fixture passes every floor and tell');
223
+ for (const [prefix, tier, snippet] of DETECTOR_CASES) {
224
+ const row = tripped(withSnippet(snippet), prefix);
225
+ assert.ok(row, `${prefix}: trips on its fixture`);
226
+ assert.equal(row[0], tier, `${prefix}: reported as ${tier}`);
227
+ assert.ok(row[2], `${prefix}: names what it saw`);
228
+ }
229
+ assert.equal(DETECTOR_CASES.length, tellsMod.FLOOR_RULES.length + tellsMod.TELL_RULES.length, 'one tripping fixture per rule, no rule unpinned');
230
+ });
231
+
232
+ test('the detector: a kicker above the h1 is a tell by default and a FAIL only where the instance bans the eyebrow', () => {
233
+ const page = withSnippet((c) => c.replace('<h1>Short title</h1>', '<p class="eyebrow">Eyebrow</p><h1>Short title</h1>'));
234
+ assert.equal(tripped(page, 'a kicker')[0], 'WARN');
235
+ assert.equal(tripped(page, 'a kicker', { bans: new Set(['eyebrow']) })[0], 'FAIL');
236
+ assert.ok(mock.readDesignBans(fs.readFileSync(path.join(ROOT, 'DESIGN.md'), 'utf8')).has('eyebrow'), 'the chrome world bans it by name');
237
+ });
238
+
239
+ test('the detector: a per-file kit-ignore waiver turns a hit into WAIVE with its reason, and --raw ignores waivers', () => {
240
+ const page = withSnippet('<style>.s{font-size:9px}</style><!-- kit-ignore: tell: tiny type (the mono label is 9px by the world\'s own rule) -->');
241
+ const waivers = tellsMod.parseWaivers(page);
242
+ assert.equal(waivers.get('tell: tiny type'), 'the mono label is 9px by the world\'s own rule');
243
+ const row = tellsMod.scanTells(page, { waivers }).find((r) => r[1].includes('tiny type'));
244
+ assert.equal(row[0], 'WAIVE'); assert.match(row[2], /9px; the mono label/);
245
+ assert.equal(tellsMod.scanTells(page).find((r) => r[1].includes('tiny type'))[0], 'WARN', 'with no waiver map (--raw) the tell stands');
246
+ const file = tmp('m.html', page);
247
+ const raw = run('check-mock.js', ['--file', file, '--no-taste', '--raw']);
248
+ assert.match(raw.stdout, /WARN\s+tell: tiny type/);
249
+ const json = run('check-mock.js', ['--file', file, '--no-taste', '--json']);
250
+ const parsed = JSON.parse(json.stdout);
251
+ assert.ok(parsed.results.some((r) => r.status === 'WAIVE' && /tiny type/.test(r.rule)), '--json carries the WAIVE row');
252
+ assert.ok(!parsed.results.some((r) => r.status === 'FAIL' && /^(floor|tell):/.test(r.rule)), 'a waived tell is not a failure (the fixture fails only the structural platform rules a bare fragment cannot meet)');
253
+ assert.equal(parsed.fails, parsed.results.filter((r) => r.status === 'FAIL').length, '--json counts its own FAIL rows');
254
+ });
255
+
256
+ test('the detector runs inside checkMock (floors fail the mock; a tell never does) and --no-tells switches it off', () => {
257
+ const f = fails(mock.checkMock(GOOD_MOCK.replace('<style>', '<style>.c{transition:transform 200ms cubic-bezier(.34,1.56,.64,1)}')));
258
+ assert.ok(f.some((r) => r.startsWith('floor: bounce easing')), f.join(' | '));
259
+ const rows = mock.checkMock(GOOD_MOCK.replace('<style>', '<style>.s{font-size:9px}'));
260
+ assert.ok(rows.some((r) => r[0] === 'WARN' && /tiny type/.test(r[1])));
261
+ assert.deepEqual(fails(rows), []);
262
+ assert.ok(!mock.checkMock(GOOD_MOCK, { noTells: true }).some((r) => /^(floor|tell):/.test(r[1])), '--no-tells evaluates no detector rule');
263
+ assert.ok(mock.TASTE_BANS.eyebrow.test('an eyebrow above the h1'));
264
+ });
265
+
266
+ test('the rendered tells: craftTells reads the in-page facts as warning lines and never as problems', () => {
267
+ assert.deepEqual(lib.craftTells(clean()), []);
268
+ const t = lib.craftTells({ ...clean(), tells: ['prose-measure: p.lede ~110ch', 'tiny-type: small 9px'] });
269
+ assert.deepEqual(t, ['tell: prose-measure: p.lede ~110ch', 'tell: tiny-type: small 9px']);
270
+ assert.deepEqual(lib.auditFloors({ ...clean(), tells: ['tiny-type: small 9px'] }, { width: 1440, mode: 'dark' }), [], 'a tell is not a floor');
271
+ assert.match(String(lib.factsInPage), /out\.tells = tells/, 'factsInPage collects the tells in the page');
272
+ });
273
+
188
274
  test('readDesignBans reads the Don\'t section — the live DESIGN.md declares the five, an empty file none', () => {
189
275
  const live = mock.readDesignBans(fs.readFileSync(path.join(ROOT, 'DESIGN.md'), 'utf8'));
190
276
  for (const k of ['em-dash', 'emoji', 'gradient-text', 'text-glow', 'box-shadow']) assert.ok(live.has(k), `live DESIGN.md bans ${k}`);