@bongos/core 1.19.992 → 1.19.994
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 +45 -25
- package/.claude/skills/ship-check/SKILL.md +5 -0
- package/docs/adr/0329-the-rendered-fit-check-is-a-kit-floor-and-an-advisory-ship-check-row.md +28 -0
- package/docs/adr/README.md +1 -0
- package/docs/file-map.md +1 -1
- package/docs/module-api-changelog.md +4 -0
- package/docs/packs/engineer.md +1 -1
- package/docs/recipes/ui-look-before-you-ship.md +4 -0
- package/modules/lifecycle/cascade.js +1 -0
- package/modules/ui-design/kit/README.md +2 -0
- package/modules/ui-design/kit/lib.js +75 -3
- package/modules/ui-design/kit/render.js +4 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/render-check.js +206 -0
- package/scripts/gds/ship-check.js +12 -6
- package/scripts/gds/visual-review-nudge.js +1 -0
- package/src/module-api.js +1 -1
- package/tests/render_check.mjs +127 -0
- package/tests/ui_design_kit.mjs +7 -2
- package/tests/ui_design_kit_fit.mjs +75 -0
- package/tests/visual_review.mjs +1 -0
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.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.994",
|
|
6
|
+
"core_contract": "1.19.994",
|
|
7
|
+
"source_commit": "2d0c99af93af0f1d1728f6fce1dbce057f209d4c",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-23T22:40:55.441Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
|
-
"docs_redacted":
|
|
12
|
+
"docs_redacted": 519,
|
|
13
13
|
"agent_docs_stubbed": 26,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2404,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2949,
|
|
20
|
+
"tree_sha256": "4b408d832b68004c07a3d8ad8143cc76a7a5c40c651ba47878f8ae27230568c6",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -242,7 +242,7 @@
|
|
|
242
242
|
{
|
|
243
243
|
"path": ".claude/skills/ship-check/SKILL.md",
|
|
244
244
|
"mode": "0000644",
|
|
245
|
-
"sha256": "
|
|
245
|
+
"sha256": "da8d8fe4cd1979c18d3fe644293ee49da37a1112b3defc4442483170236ba74f"
|
|
246
246
|
},
|
|
247
247
|
{
|
|
248
248
|
"path": ".claude/skills/status/SKILL.md",
|
|
@@ -2139,10 +2139,15 @@
|
|
|
2139
2139
|
"mode": "0000644",
|
|
2140
2140
|
"sha256": "8a59c1aa7f81d335e96c6305bb4f22689c4a0fee627eecdec9585e76a3477bea"
|
|
2141
2141
|
},
|
|
2142
|
+
{
|
|
2143
|
+
"path": "docs/adr/0329-the-rendered-fit-check-is-a-kit-floor-and-an-advisory-ship-check-row.md",
|
|
2144
|
+
"mode": "0000644",
|
|
2145
|
+
"sha256": "e340529dfadefde589ac92a24e2c0d0e52729c3c54e2350ee8e201ff52679c9e"
|
|
2146
|
+
},
|
|
2142
2147
|
{
|
|
2143
2148
|
"path": "docs/adr/README.md",
|
|
2144
2149
|
"mode": "0000644",
|
|
2145
|
-
"sha256": "
|
|
2150
|
+
"sha256": "24e9de3d847413d66e61aa90c24422a836f9c6c560894166373c26b9f540240e"
|
|
2146
2151
|
},
|
|
2147
2152
|
{
|
|
2148
2153
|
"path": "docs/api-reference.md",
|
|
@@ -2702,7 +2707,7 @@
|
|
|
2702
2707
|
{
|
|
2703
2708
|
"path": "docs/file-map.md",
|
|
2704
2709
|
"mode": "0000644",
|
|
2705
|
-
"sha256": "
|
|
2710
|
+
"sha256": "152674a5c94919cfac381bccea17b915d665e77008307d59d3802041c06b0d1d"
|
|
2706
2711
|
},
|
|
2707
2712
|
{
|
|
2708
2713
|
"path": "docs/handoff-template.md",
|
|
@@ -2712,7 +2717,7 @@
|
|
|
2712
2717
|
{
|
|
2713
2718
|
"path": "docs/module-api-changelog.md",
|
|
2714
2719
|
"mode": "0000644",
|
|
2715
|
-
"sha256": "
|
|
2720
|
+
"sha256": "aff2b4b93d88dd46eb74dacd0d51c4a959d1a860b9fda1646f3bd2c842780885"
|
|
2716
2721
|
},
|
|
2717
2722
|
{
|
|
2718
2723
|
"path": "docs/modules-contract.md",
|
|
@@ -2792,7 +2797,7 @@
|
|
|
2792
2797
|
{
|
|
2793
2798
|
"path": "docs/packs/engineer.md",
|
|
2794
2799
|
"mode": "0000644",
|
|
2795
|
-
"sha256": "
|
|
2800
|
+
"sha256": "942fcc04e8e47520f6ea071fae30ab0d1ad6c86f4c241c489ded38793650cfa9"
|
|
2796
2801
|
},
|
|
2797
2802
|
{
|
|
2798
2803
|
"path": "docs/packs/ideator.md",
|
|
@@ -2957,7 +2962,7 @@
|
|
|
2957
2962
|
{
|
|
2958
2963
|
"path": "docs/recipes/ui-look-before-you-ship.md",
|
|
2959
2964
|
"mode": "0000644",
|
|
2960
|
-
"sha256": "
|
|
2965
|
+
"sha256": "d142999612eaa8b8663835379fbce04a825ac1cf006f28ce41cbfce77a16827f"
|
|
2961
2966
|
},
|
|
2962
2967
|
{
|
|
2963
2968
|
"path": "docs/recipes/upgrading-the-core.md",
|
|
@@ -5712,7 +5717,7 @@
|
|
|
5712
5717
|
{
|
|
5713
5718
|
"path": "modules/lifecycle/cascade.js",
|
|
5714
5719
|
"mode": "0000644",
|
|
5715
|
-
"sha256": "
|
|
5720
|
+
"sha256": "4ced170bd5acf06b98663514e3f93664c8d2a1416e8539837a127ad80c1ee79c"
|
|
5716
5721
|
},
|
|
5717
5722
|
{
|
|
5718
5723
|
"path": "modules/lifecycle/check-rollup.js",
|
|
@@ -7782,7 +7787,7 @@
|
|
|
7782
7787
|
{
|
|
7783
7788
|
"path": "modules/ui-design/kit/README.md",
|
|
7784
7789
|
"mode": "0000644",
|
|
7785
|
-
"sha256": "
|
|
7790
|
+
"sha256": "3af3f265b8b4a864f1454fb56a904b252cba6593188e419e629f700e847769e0"
|
|
7786
7791
|
},
|
|
7787
7792
|
{
|
|
7788
7793
|
"path": "modules/ui-design/kit/check-mock.js",
|
|
@@ -7847,7 +7852,7 @@
|
|
|
7847
7852
|
{
|
|
7848
7853
|
"path": "modules/ui-design/kit/lib.js",
|
|
7849
7854
|
"mode": "0000644",
|
|
7850
|
-
"sha256": "
|
|
7855
|
+
"sha256": "b0f15b680cefd4f3e94a698ad1d70dfa47eb94828e911198f39cd3c79c8d6e5a"
|
|
7851
7856
|
},
|
|
7852
7857
|
{
|
|
7853
7858
|
"path": "modules/ui-design/kit/png.js",
|
|
@@ -7862,7 +7867,7 @@
|
|
|
7862
7867
|
{
|
|
7863
7868
|
"path": "modules/ui-design/kit/render.js",
|
|
7864
7869
|
"mode": "0000644",
|
|
7865
|
-
"sha256": "
|
|
7870
|
+
"sha256": "85bfb7a8b4e0ba0f3d09be42852b5670c4012e1db370205252c1523f0cc499c7"
|
|
7866
7871
|
},
|
|
7867
7872
|
{
|
|
7868
7873
|
"path": "modules/ui-design/kit/serve.js",
|
|
@@ -8177,12 +8182,12 @@
|
|
|
8177
8182
|
{
|
|
8178
8183
|
"path": "package-lock.json",
|
|
8179
8184
|
"mode": "0000644",
|
|
8180
|
-
"sha256": "
|
|
8185
|
+
"sha256": "886b77d3e78846e2ac7160ee992c62370feca07cedeac66ae1e71c0f4723f5a7"
|
|
8181
8186
|
},
|
|
8182
8187
|
{
|
|
8183
8188
|
"path": "package.json",
|
|
8184
8189
|
"mode": "0000644",
|
|
8185
|
-
"sha256": "
|
|
8190
|
+
"sha256": "c6b01ed3391c36e6a05936826c2926683568412b7d3b15d50deeeb5e970dce15"
|
|
8186
8191
|
},
|
|
8187
8192
|
{
|
|
8188
8193
|
"path": "public-docs/index.html",
|
|
@@ -9259,6 +9264,11 @@
|
|
|
9259
9264
|
"mode": "0000644",
|
|
9260
9265
|
"sha256": "f73e495ab63fbca755e652d284e378d9b2afef67fe43c4c5a66e2962c92cef1d"
|
|
9261
9266
|
},
|
|
9267
|
+
{
|
|
9268
|
+
"path": "scripts/gds/render-check.js",
|
|
9269
|
+
"mode": "0000644",
|
|
9270
|
+
"sha256": "f7e3a3fd8aec3b23ffa146b15410836be42c61fa9e0c03a5215247c7f6ae8944"
|
|
9271
|
+
},
|
|
9262
9272
|
{
|
|
9263
9273
|
"path": "scripts/gds/render-ideas.js",
|
|
9264
9274
|
"mode": "0000644",
|
|
@@ -9522,7 +9532,7 @@
|
|
|
9522
9532
|
{
|
|
9523
9533
|
"path": "scripts/gds/ship-check.js",
|
|
9524
9534
|
"mode": "0000644",
|
|
9525
|
-
"sha256": "
|
|
9535
|
+
"sha256": "230a6ee6d37952e380e4cdeecfabea14258d0877c35ca2c4c71de9e4eb7f0b69"
|
|
9526
9536
|
},
|
|
9527
9537
|
{
|
|
9528
9538
|
"path": "scripts/gds/ship-deploy-target.js",
|
|
@@ -9787,7 +9797,7 @@
|
|
|
9787
9797
|
{
|
|
9788
9798
|
"path": "scripts/gds/visual-review-nudge.js",
|
|
9789
9799
|
"mode": "0000644",
|
|
9790
|
-
"sha256": "
|
|
9800
|
+
"sha256": "38e98180ddfd59189dedc3d3655d0a0672bde9a364cbaea5846aa4f16296107e"
|
|
9791
9801
|
},
|
|
9792
9802
|
{
|
|
9793
9803
|
"path": "scripts/gds/worktree-claim-guard.js",
|
|
@@ -10222,7 +10232,7 @@
|
|
|
10222
10232
|
{
|
|
10223
10233
|
"path": "src/module-api.js",
|
|
10224
10234
|
"mode": "0000644",
|
|
10225
|
-
"sha256": "
|
|
10235
|
+
"sha256": "bc208fde6a6f87de913925e670e21c54db09f8797b7e0a0f773b5d83d2fe200f"
|
|
10226
10236
|
},
|
|
10227
10237
|
{
|
|
10228
10238
|
"path": "src/module-loader/catalog.js",
|
|
@@ -13674,6 +13684,11 @@
|
|
|
13674
13684
|
"mode": "0000644",
|
|
13675
13685
|
"sha256": "39eaa32121eae07fa7f508d60324e896e4c1b98c53ff390bb8bdca4b952c874f"
|
|
13676
13686
|
},
|
|
13687
|
+
{
|
|
13688
|
+
"path": "tests/render_check.mjs",
|
|
13689
|
+
"mode": "0000644",
|
|
13690
|
+
"sha256": "591864e7700b47198754ede53e45488b3143743bb46349b545ea4f0d96b5571f"
|
|
13691
|
+
},
|
|
13677
13692
|
{
|
|
13678
13693
|
"path": "tests/render_prefs.mjs",
|
|
13679
13694
|
"mode": "0000644",
|
|
@@ -14537,7 +14552,12 @@
|
|
|
14537
14552
|
{
|
|
14538
14553
|
"path": "tests/ui_design_kit.mjs",
|
|
14539
14554
|
"mode": "0000644",
|
|
14540
|
-
"sha256": "
|
|
14555
|
+
"sha256": "a7c3dae0463bba01a767b0a6cc92082451537a1082567a72b1e47c7020b59666"
|
|
14556
|
+
},
|
|
14557
|
+
{
|
|
14558
|
+
"path": "tests/ui_design_kit_fit.mjs",
|
|
14559
|
+
"mode": "0000644",
|
|
14560
|
+
"sha256": "0f07a002d400c4e90ead7206334425bd80647a2cb844bbc9733758eb3f719a12"
|
|
14541
14561
|
},
|
|
14542
14562
|
{
|
|
14543
14563
|
"path": "tests/ui_design_skills.mjs",
|
|
@@ -14662,7 +14682,7 @@
|
|
|
14662
14682
|
{
|
|
14663
14683
|
"path": "tests/visual_review.mjs",
|
|
14664
14684
|
"mode": "0000644",
|
|
14665
|
-
"sha256": "
|
|
14685
|
+
"sha256": "102e80ed4c6c61583d9dfeca23db321be9b871cab3d8f47e4594674b7ecd3cf5"
|
|
14666
14686
|
},
|
|
14667
14687
|
{
|
|
14668
14688
|
"path": "tests/wandering_prefs.mjs",
|
|
@@ -28,6 +28,11 @@ It changes nothing: every step is a `--check`; the **fix →** column is what to
|
|
|
28
28
|
1. Every `✗` row names the generator to run. Run it, look at the diff it produced, commit it on your
|
|
29
29
|
branch. Then re-run `/ship-check` until every row is `✓`.
|
|
30
30
|
2. `fitness.js` failures name their own fix inline; warnings are advisory (budgets, ratchets).
|
|
31
|
+
The `render-fit` row is advisory too (`!`, never a `✗`): when the branch changed a page, it renders
|
|
32
|
+
it at 1280 + 390 and names any text past its box or overlapping siblings by element and page
|
|
33
|
+
(`scripts/gds/render-check.js`, ADR 0329). Look at the shots it names; fix the element, or say in the
|
|
34
|
+
ship notes why it is right. `·` means no Edge or Chrome on this machine, so nothing was rendered.
|
|
35
|
+
A big page takes minutes; `--no-render` leaves the row out when you are iterating on something else.
|
|
31
36
|
3. With `--tests`: a failing test is only "environmental" if the SAME test fails on an untouched
|
|
32
37
|
checkout of `origin/main`. Compare by test name, never by count — a Windows baseline of ~25
|
|
33
38
|
environment failures has masked real regressions before (see `docs/recipes/windows-builders.md`).
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# ADR 0329 — The rendered fit check is a kit floor and an advisory ship-check row, not a grader worker
|
|
2
|
+
|
|
3
|
+
- **Status:** Accepted (2026-09-23)
|
|
4
|
+
- **Task:** [task 1004203](https://cloudbongos.com/builders#/task/1004203) (goal 1000095 — UI quality)
|
|
5
|
+
- **Relates to:** [ADR 0162](0162-review-gates-retired-rank-consistent-ci-only.md) (no new gates), [ADR 0197](0197-the-ui-design-module.md) (the kit), [ADR 0223](0223-the-detector-is-two-tiers-in-the-kit.md) (floors and tells), [ADR 0241](0241-the-artist-gate-is-a-per-project-deploy-gate-that-reads-a-state.md) (the artist gate holds the deploy)
|
|
6
|
+
|
|
7
|
+
## Context
|
|
8
|
+
|
|
9
|
+
Nothing in the pipeline renders a page. The grader reads the diff, so a layout bug that looks right in code ships unseen. Two did in September 2026: goal titles clipped to a character count still ran past their cards, on the goal forest ([task 1003480](https://cloudbongos.com/builders#/task/1003480)) and on the goal map ([task 1004204](https://cloudbongos.com/builders#/task/1004204)); and a second child in a `#view-manage .mSec` card would have landed on the first, because every `.mSec > div` takes the same grid cell (caught by hand in [task 1003078](https://cloudbongos.com/builders#/task/1003078)). Both were found only by opening a browser. The owner's rule since then is that every page change gets a before/after review by a person, and the deploy holds on an open artist review (ADR 0241). This check is the machine half of that: something that looks at the rendered page before a person has to.
|
|
10
|
+
|
|
11
|
+
The task left open where the check should live: an advisory row in `/ship-check`, a grader worker whose findings ride the panel, or both. It had to stay a signal (ADR 0162).
|
|
12
|
+
|
|
13
|
+
## Decision
|
|
14
|
+
|
|
15
|
+
1. **The measurement is two new floors in the kit** (`modules/ui-design/kit/lib.js`), beside the seven that exist. `factsInPage` collects them in the real page and `auditFloors` reports them, so `render.js` shows them in every render of every states file:
|
|
16
|
+
- **`text-fit`**: text runs more than 1px past its box. HTML text is measured word by word with a `Range` against the padding box of its nearest non-inline ancestor. Measuring by word means the space a soft wrap hangs past the line never counts. A box that clips on purpose (a horizontal scroller, `text-overflow: ellipsis`, a visually-hidden label under 4px) is skipped. SVG text is measured against the smallest `<rect>` in its own `<g>` (up to three levels) that contains its start point. That is the card, so a title past its card is caught even when a `clipPath` hides the overflow.
|
|
17
|
+
- **`sibling-overlap`**: two children of the same parent overlap by more than 2px in both directions, where both carry text and both are in-flow and block-level. A wrapped inline's box spans lines, and absolute, fixed, sticky and negative-margin layering is deliberate, so all of those are excluded.
|
|
18
|
+
2. **`scripts/gds/render-check.js` runs it on the pages a branch changed.** It maps the changed paths to states files: a page or its own script reaches `<page>.states.json`, and a shared script or sheet reaches every page in its directory whose HTML names it. It renders them with the kit at 1280 and 390, using the hall-preview harness for `hall-ui` (the hall's states files are written against it) and the kit's own stubs for everything else. It renders one colour mode (the file's first): the fit floors measure geometry, and the pack's two font stacks are the same in both modes. It also skips the reduced-motion pass (`render.js --no-reduced-motion`). It reads the two fit floors from each render's structured facts and reports one finding per element, with every render it appeared in and its largest measure. Element paths carry `:nth-of-type`, so two same-class siblings are told apart. The whole run has a 12-minute budget inside ship-check's 15-minute cap, and a render that times out is killed with everything it started. At most six states files are rendered: the pages the branch changed come first, then the pages a shared file reaches. The rest are listed with the command that renders them.
|
|
19
|
+
3. **`/ship-check` runs it as an advisory row.** A new `advisory` step kind: a finding shows as `!` with the elements, and a machine with no Edge or Chrome shows `·`. Neither changes ship-check's exit code, and a missing script reads `skipped`, not stale. `--no-render` leaves the row out. `ship.js` does not run it and the grader does not read it. The claim-time nudge for UI work names it.
|
|
20
|
+
4. **Not a grader worker.** The grader runs on the server and grades the diff. To render, it would need a browser on the grader host, the fixtures and the harness it serves, and a model of which states to load. That is a new dependency and a new cost on every graded ship, and it would carry signal the builder has already seen at ship-check. The artist gate (ADR 0241) is the person who looks before deploy. If a later look at the grade audit shows fit bugs reaching the artist review anyway, a worker can read `render-check.js`'s output. Nothing here stops that.
|
|
21
|
+
|
|
22
|
+
## Consequences
|
|
23
|
+
|
|
24
|
+
- **Measured quiet on the pages as they stand.** A sweep of every states file in the repo (1280 and 390, one colour mode each, 276 renders) found one text overflow and no overlaps. The overflow is real: on the Work board's backlog tab at 390, an unbreakable `People/Operations/Security` pushes a title 44px out of its column. It is filed separately. The false-positive shapes the first sweep hit (wrapped inline siblings, a sticky top bar over scrolled content, the hanging wrap space) are excluded by rule, and `tests/ui_design_kit_fit.mjs` pins each of them.
|
|
25
|
+
- **The done-when is reproduced**, and the result is recorded in the task's ship notes. Re-introducing the 24-character title clip on the goal map reports 14 titles 3–15px past their cards, by element and page. A second child in the `#mVisibility` `.mSec` card reports `#mVis ∩ #mVisibility > div.projMsg:nth-of-type(2)` on `/projects` at 1280, as one finding across the eight manage states. At 390 the card stacks to one column, so nothing overlaps there. The clean pages report nothing.
|
|
26
|
+
- **`render.js` exits 1 on these floors** like on any other floor. It is a builder's tool, not a pipeline step, so that is a finding, not a gate.
|
|
27
|
+
- **The unit lane gains one browser test** (`tests/ui_design_kit_fit.mjs`). It skips where there is no system browser, so the pure pins in `tests/ui_design_kit.mjs` stay browser-free.
|
|
28
|
+
- **The kit's `SIZES` gains 1280**, the review sheet's desktop width. `render.js` still renders 1440/390/320 unless `--sizes` names it.
|
package/docs/adr/README.md
CHANGED
|
@@ -420,3 +420,4 @@ This keeps the decision history honest and traceable.
|
|
|
420
420
|
| 0326 | [**An upgrade may ADD a deny rule to a file the instance owns** ([task 1004156](https://cloudbongos.com/builders#/task/1004156), goal 1000090 — BONGOS-V2). Task 1003966 gave the instance starter its deny rules — no reading `.env` files, keys or session tokens, no shelving with the repo-global stash — and they reached **nobody who was already here**. The starter is written only when `.claude/settings.json` is ABSENT, and `bongos upgrade` materializes from the published package, which excludes that file on purpose; so an upgrade always takes the starter branch, always finds a settings.json already there, and left it alone. Every instance on the allow-only starter from task 1003962 — the live one included — kept it forever. That was deliberate ("rewriting a file the instance owns is the line task 1003962 drew"), and the recorded remedy was manual: re-run `bongos init`, or delete the file. **A manual remedy for a security gap protects the instances whose owners read release notes, which is not the set that needs protecting.** The owner's call: ADD the missing deny rules and NAME each one in the run output. The argument is that a deny rule is **monotonic in the safe direction** — an allow rule widens what the local agent may do, a deny rule can only narrow, so appending one cannot make any instance less safe than it was a moment earlier. That is what makes this the one merge worth doing without asking per-instance, and it extends [ADR 0121](0121-greenfield-vs-brownfield-onboarding-adopt-existing-repo.md) §2's already-licensed "merge OR leave-and-report" from `--adopt` to every materialize. "Never clobber" survives in the sense that was load-bearing: **nothing the instance wrote is removed, reordered or rewritten** — their allow rules, own denies, `ask` list, hooks, env and every key outside `permissions` come back exactly as they were, with new rules appended after. Deliberately NOT done: **no provenance**, so an owner who deletes a rule we add gets it back next upgrade and (deny beating allow in Claude Code's order) cannot override it with an allow — rejected as speculative machinery for a case nobody has hit, revisit on a real complaint; a file that will not parse, or whose `permissions`/`permissions.deny` is not the expected shape, is left BYTE-FOR-BYTE alone and reported, the posture task 1004023 took for the core's own file, because a half-understood rewrite of a permissions file is worse than the gap; and **the allow half is never topped up** — the safety argument is directionality and does not survive being applied to a rule that grants. `materializeClaude` gains `summary.settingsDenyAdded` + `settingsDenyUncheckable`, both printed through `log()` since `upgrade.js` does not iterate `summary.notes`. The test asserting an instance's settings.json comes back byte-identical is superseded by a strictly stronger one — every rule still there, unmoved, deny-only growth — the weaker spelling being what froze the gap in place. **AMENDED 2026-09-22 by [task 1004158](https://cloudbongos.com/builders#/task/1004158): the two shelf rules are now starter-only and the top-up asks for the CREDENTIAL half alone.** 0326 itself named this exclusion as the remedy if the rule ever bit; the owner took it ahead of that, because the monotonicity argument licenses a rule about reading a secret and does not reach one that guards a workflow — with no provenance and deny beating allow, an instance that loses the shelf cannot give it back. A fresh instance still gets both. The top-up only ever appends, so this is **forward-looking only**: an instance that already took them keeps them, live included.](0326-an-upgrade-may-add-a-deny-rule-to-a-file-the-instance-owns.md) | security / instance onboarding |
|
|
421
421
|
| 0327 | [**A `cloud-host` runs on the owner's own account, and the key is borrowed, not stored** ([task 1004175](https://cloudbongos.com/builders#/task/1004175), goal 1000106 — BONGOS-V2). [ADR 0323](0323-hosting-is-three-shapes-and-we-are-not-the-landlord.md) moved a `cloud-host` to Render to stop being a landlord but left **whose account** unexamined; [ADR 0325](0325-the-managed-fleet-control-plane-is-the-hub-we-already-run.md) §4 then ruled the platform token is ours and "never per-customer". Standing it up disproved the premise: a Render API key is issued against a **user** and carries their rights in **every workspace they belong to** — the owner's reached three, one of them another builder's, holding their live production. The same check found `mercury` **already on Render**, run by its own owner, which 0323 would have migrated OFF a correct setup onto our bill. The remaining landlord relationship was never the servers; it was the **account**. **Decisions:** (1) each project brings its own platform key — we hold no billing relationship for anyone's compute, and a leaked key's blast radius is exactly its own owner's account, a property that comes from the decision and not from cryptography; (2) the key is **borrowed for standup and cleared**, never stored, because nothing afterwards needs it — Render auto-deploys from Git, the deploy door ([ADR 0293](0293-the-owners-deploy-door-is-an-intent-the-control-plane-drains.md)) is a push, liveness is the [ADR 0252](0252-the-self-hosted-visibility-report-is-a-pull-and-auto-appear-buys-a-retraction-lever.md) pull — so it rides the queue as a short-lived value encrypted with the existing `secret-box.js`, the [ADR 0133](0133-github-app-manifest-onboarding-signin.md) handshake's shape; (3) we **never destroy a customer's infrastructure** — teardown disconnects, the owner deletes their own services, a safety property first and the reason (2) works; (4) **two shapes, not three** (`dedicated-cloud-host` removed — on someone's own account a bigger plan is a setting, finishing 0323 §1's own "plan size, not a second code path"), and the **free tier is free**, 0323 §7's cap retired with its premise. **Deliberately NOT built, because nothing is stored:** an asymmetric sealing module, per-row credential binding, rotation/revocation, and the separate-unix-user precondition — each designed in full, then deleted when the retention question was asked instead of assumed. **Stated honestly:** encrypting the in-transit value protects it from a database dump, NOT from a compromised web tier, because the owner's key arrives there in plaintext first; that defence was never available at any retention length. **§5 records a measurement, fixed elsewhere:** [ADR 0111](0111-instance-hosting-provisioning-module.md) §2's boundary is enforced by the CODE, not the kernel — `cloudbongos.service` runs `User=lars` with no sandboxing and `/etc/cloudbongos/box.env` is mode 600 owned by `lars`, so the web tier CAN read the DigitalOcean, Cloudflare and platform tokens; 0325 §4's "root-only" is not what is on the box. (A second finding in the same section — a "mode 777" key file — was **void and is corrected in place**: the path is a symlink, whose mode display is meaningless, to a 600 target. It was inferred from an `ls` rather than an attempted read.) **Resolved 2026-09-23 by [task 1004176](https://cloudbongos.com/builders#/task/1004176)**, and more narrowly than §5 proposed — not the unix split (which would mean relocating the checkout, config dir, `web.env` and the secret-box master key the web tier legitimately needs), but a systemd drop-in with `InaccessiblePaths`, verified as the service user; proving it as **root** reports "no effect" against a working config, because `CAP_DAC_OVERRIDE` bypasses the mode-000 over-mount. It is emphatically NOT a precondition here — that is the point of storing nothing, and had we sealed per-project keys with a private key in that same file the two would have had to land together. **Consequences:** the migration shrinks to an **adoption**; the cost ledger's question inverts (our spend on a `cloud-host` is ~\$0 and correct — what an owner is worth telling is THEIR Render cost, a different column, [task 1004148](https://cloudbongos.com/builders#/task/1004148)); and metering loses its forcing function, cost *visibility* surviving as a feature while billing for hosting does not ([task 1001949](https://cloudbongos.com/builders#/task/1001949)). Rejected: storing each key sealed with a key pair (three days plus a live-site infra change, to keep a value needed once); one platform account holding every project (0325 §4 as written — keeps us the billing landlord and makes one credential reach every customer); generating a Blueprint and having the owner click once in Render (simplest, but the click is the exact friction `cloud-host` exists to remove); and keeping the key in the browser to call Render directly (standup is a runner job with retries and an audit trail, not a page that stays open).](0327-a-cloud-host-runs-on-the-owners-account-and-the-key-is-borrowed.md) | hosting / provisioning / credentials |
|
|
422
422
|
| 0328 | [**A hosted project's authority is its membership row, not its owner column** ([task 1003882](https://cloudbongos.com/builders#/task/1003882), goal 1000094 — BONGOS-V2). The owner's decision (2026-09-13): every API action must name its project, and a non-member may only apply. Two "project" concepts exist and answer different questions: `provisioning_instances` (the hub's fleet registry, single `owner_builder_id`, [ADR 0325](0325-the-managed-fleet-control-plane-is-the-hub-we-already-run.md)) and `<redacted>` (`client_id, github_id, membership_kind ∈ {owner,member,visitor,pending}`, already correct, already backing `GET /my-projects`). The composition this needs already exists once, in the opposite direction: `provisioning.projectOwnedBy` is a published port (task 1003580) that `platform-identity/invite-authz.js` already consumes via `resolveOptional` to ask "does this builder own this project" without importing provisioning (ADR 0083). **The gap is that the module which owns the fact does not use it on itself, and has grown three separate local copies of it instead of one** — `instanceAccessDenied` (6 routes), `resolveOwnInstance` (4 routes) and `disconnect.js`'s own inline copy (1 route) all implement "owner OR archon", while 3 routes (`teardown`, `domain`, `github-app`) implement only "owner" with no archon override at all — an existing inconsistency between sibling routes in the same module, not just duplication, and none of the three implementations is visible to `route-rank-check.js`. Decision: (1) project scope stays the existing path segment, resolved through `slug` (the `client_id = slug` join key [ADR 0246](0246-catalog-membership-is-a-projection-not-a-federation-side-effect.md) already establishes); (2) Phase 1 extracts one local `requireProjectOwner` helper replacing all three implementations — a behavior-preserving swap for 11 routes and a stated, intentional archon-override widening for the 3 that lacked it — no new port needed since provisioning already holds the column; Phase 2 adds a new port `platform-identity.projectMembershipKind` and widens the same helper to admit `owner` OR `member`, falling back to the Phase 1 column check when platform-identity isn't installed (ADR 0083's degrade-gracefully contract) — a NEW parallel axis, not a fifth branch on `requirePermission`, mirroring why the ownership axis ([ADR 0151](0151-governance-permissions-as-atom-ranks-as-roles.md) §3, R100) is kept separate; (3) a caller with NO membership row still gets 404 `instance_not_found` (existence-hidden, unchanged), but a `pending`/`visitor` row gets 403 `project_membership_required` naming `POST /my-projects/join` (ADR 0141/0201/0247's existing apply flow) — the "apply to join it" the owner's decision names, routed into the flow that already ships rather than a second one; (4) `route-rank-check.js` gains a `project_scope_missing` finding (blocker on write, concerns on read) for any `/provisioning/instances/:id...` route missing the gate, the same structural read it already gives `requirePermission`; (5) migration order is the 14 evidenced routes only — the task's own "96 write routes" figure is the whole app's census, and the other ~82 act on a single-tenant instance's own Bongos board, which [ADR 0100](0100-two-repo-instance-model.md) §3 already isolates one-database-per-repo and has no project dimension to name. Filed: [task 1004159](https://cloudbongos.com/builders#/task/1004159) (the helper) · [task 1004160](https://cloudbongos.com/builders#/task/1004160) (route-rank-check.js, depends on 1004159) · [task 1004161](https://cloudbongos.com/builders#/task/1004161) (the membership port, depends on 1004159) · [task 1004162](https://cloudbongos.com/builders#/task/1004162) (the 403/404 split, depends on 1004161). Rejected: a second `provisioning_instance_members` table (the membership table already exists and joins on the same key — a second one repeats the mistake ADR 0325 already refused); folding project authority into `requirePermission` as a fifth axis (same reasoning R100 already applied to `.own` keys); migrating all 96 write routes (re-litigates ADR 0100's single-tenant model, not this decision); dropping 404 existence-hiding entirely (a total stranger should learn nothing, exactly as `invite-authz.js` already guarantees).](0328-a-hosted-projects-authority-is-its-membership-row-not-its-owner-column.md) | security / authorization / provisioning |
|
|
423
|
+
| 0329 | [**The rendered fit check is a kit floor and an advisory ship-check row, not a grader worker** ([task 1004203](https://cloudbongos.com/builders#/task/1004203), goal 1000095 — UI quality). Nothing in the pipeline rendered a page, so titles clipped by character count ran past their cards (task 1003480, task 1004204) and a second `.mSec` child would have landed on the first (task 1003078), each found only in a browser. Two new kit floors, `text-fit` (text measured word by word past its box; SVG text past its card rect) and `sibling-overlap` (in-flow, block-level, text-bearing siblings), run in every `render.js` render; `scripts/gds/render-check.js` renders the pages a branch changed at 1280 + 390 and `/ship-check` shows them as an ADVISORY row that never flips its exit (ADR 0162). Not a grader worker: that would put a browser, fixtures and a harness on the grader host for signal the builder already saw; the artist gate (ADR 0241) stays the human look before deploy.](0329-the-rendered-fit-check-is-a-kit-floor-and-an-advisory-ship-check-row.md) | ui-design / kit + ship-check |
|
package/docs/file-map.md
CHANGED
|
@@ -433,7 +433,7 @@ tests/
|
|
|
433
433
|
├── recall/SKILL.md ← /recall — one-call project-knowledge search across repo docs + DB prose (tasks, learnings, session logs)
|
|
434
434
|
├── scan-before-install/SKILL.md ← /scan-before-install — quarantine-fetch a third-party repo/plugin, deterministic floor + tiered subagent panel over the scrubbed mirror, arithmetic verdict + honest-limits report to the builder's own config dir; prints install commands (pinned SHA only), never installs (goal 1000055)
|
|
435
435
|
├── session-handoff/SKILL.md ← /session-handoff — compose a clean, paste-ready next-steps prompt to START a fresh session with (keeps context light by curating what carries forward); read-only, distinct from /builder-ship's ledger notes and /builder-end's close-out
|
|
436
|
-
├── ship-check/SKILL.md ← /ship-check — run every freshness + fitness check ship.js will run (repo-map/session-index/file-map/api-docs/copy-registry --check, linkify, skill-lint, fitness; --tests adds the DB-free lane) in ONE command before shipping, with the healing command per stale artifact (task 1003548; script scripts/gds/ship-check.js)
|
|
436
|
+
├── ship-check/SKILL.md ← /ship-check — run every freshness + fitness check ship.js will run (repo-map/session-index/file-map/api-docs/copy-registry --check, linkify, skill-lint, fitness; --tests adds the DB-free lane; an ADVISORY render-fit row renders the changed pages via scripts/gds/render-check.js, ADR 0329) in ONE command before shipping, with the healing command per stale artifact (task 1003548; script scripts/gds/ship-check.js)
|
|
437
437
|
├── status/SKILL.md ← /status — what's the status of X / what's left for criterion Cn (read-only rollup)
|
|
438
438
|
├── strand-fix/SKILL.md ← /strand-fix N — walk ONE stranded confirmed task (strand:no_branch_no_tip / land_not_proven — the shape the reconciler gave up on) to shipped: find the work (main commit / unpushed branch / conflicting PR / nowhere), do the one sanctioned move, verify the flip; also clears a 409 REBASE_REQUIRED claim gate (task 1003365)
|
|
439
439
|
└── worktree-clean/SKILL.md ← /worktree-clean — list the per-claim worktrees whose branch already landed on main (scripts/gds/stale-worktrees.js) and remove each through the junction-safe worktree.js remove; never raw git worktree remove (task 1003365)
|
|
@@ -2458,5 +2458,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
2458
2458
|
landed since 1.19.990 with no explicit bump. run 35916061384. (task 1002620)
|
|
2459
2459
|
1.19.992 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2460
2460
|
landed since 1.19.991 with no explicit bump. run 35918717050. (task 1002620)
|
|
2461
|
+
1.19.993 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2462
|
+
landed since 1.19.992 with no explicit bump. run 35920872835. (task 1002620)
|
|
2463
|
+
1.19.994 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2464
|
+
landed since 1.19.993 with no explicit bump. run 35929622479. (task 1002620)
|
|
2461
2465
|
---------------------------------------------------------------------------
|
|
2462
2466
|
```
|
package/docs/packs/engineer.md
CHANGED
|
@@ -23,7 +23,7 @@ Engineering is the project's **default loop**, so this pack introduces no new dy
|
|
|
23
23
|
- If the change is observable in the running app, use the preview/verification workflow (preview → reload → console + page check → screenshot proof) yourself first. Verify, then share the proof.
|
|
24
24
|
- **A change to a page people see is reviewed by your human before it ships — the project default for every builder (owner, 2026-09-23).** Render it before and after, in both modes, at desktop and phone width; open the shots for them with `node scripts/gds/review-sheet.js` (a terminal shows no images — this opens one page in their browser); wait for their OK, then ship. With nobody to ask, list the shots in the ship notes and ship: the change also files a visual review on the board that holds the deploy (ADR 0241), never the ship.
|
|
25
25
|
- If it is not browser-observable (a different runtime, types, tooling), say so and verify the way that *does* exercise it.
|
|
26
|
-
- `/ship-check` runs every freshness and fitness gate `ship.js` will run, and prints the healing command for each stale generated file.
|
|
26
|
+
- `/ship-check` runs every freshness and fitness gate `ship.js` will run, and prints the healing command for each stale generated file. When your branch changed a page, it also renders it at 1280 + 390 and names any text past its box or overlapping siblings — an advisory row, never a gate (ADR 0329).
|
|
27
27
|
|
|
28
28
|
5. **Ship with a real handoff.** `/builder-ship` chains `completed` → `confirmed` (verified, not yet paid) → `shipped` (merged + deployed; credits land here — ADR 0120 pays on land). Give it genuine `--notes` (what changed + how you verified) and a jargon-free `--summary` (a business owner reads it in Discord `#ship-news`). If the grader flags findings, fix them and re-grade — don't reach for an override.
|
|
29
29
|
|
|
@@ -45,6 +45,8 @@ node modules/ui-design/kit/render.js --page / --style expedition # the sa
|
|
|
45
45
|
| `console-errors` | a `console.error` or an uncaught page error (the file's `ignoreConsole` regexes excepted) |
|
|
46
46
|
| `failed-requests` | a 4xx/5xx response the file did not declare in `ignoreRequests` |
|
|
47
47
|
| `horizontal-overflow` | `scrollWidth` wider than the viewport |
|
|
48
|
+
| `text-fit` | a word of text more than 1px past its box (the padding box of its nearest non-inline ancestor), or an SVG `<text>` past the smallest `<rect>` in its own `<g>` that holds its start — a scroller, `text-overflow: ellipsis` and a visually-hidden label are skipped ([ADR 0329](../adr/0329-the-rendered-fit-check-is-a-kit-floor-and-an-advisory-ship-check-row.md)) |
|
|
49
|
+
| `sibling-overlap` | two in-flow, block-level children of one parent that both carry text overlapping by more than 2px each way — absolute, fixed, sticky and negative-margin layering is deliberate and skipped |
|
|
48
50
|
| `one-header-main-h1` | not exactly one *rendered* `<header>`, `<main>` and `<h1>` |
|
|
49
51
|
| `controls-24px` | a visible control under 24px on either side (`ignoreControls` for the skip link and the like) |
|
|
50
52
|
| `top-bar-80px` | a `<header>` taller than 80px |
|
|
@@ -52,6 +54,8 @@ node modules/ui-design/kit/render.js --page / --style expedition # the sa
|
|
|
52
54
|
|
|
53
55
|
Then a state's own `expect` (a band open, a dialog present, a field's value, a class, a query param). Shots and `report.json` land in `%TMP%/ui-design-kit/<surface>/<page>/` (or `--out`). Exit 0 is `ALL CLEAN`; 1 lists the problems; 2 is a broken states file, named line by line.
|
|
54
56
|
|
|
57
|
+
**The fit floors run on their own before you ship.** `/ship-check` renders the pages your branch changed (`node scripts/gds/render-check.js`: a page or its script reaches its own states file, a shared script or sheet reaches every page whose HTML names it) at 1280 + 390 and lists any `text-fit` / `sibling-overlap` finding by element and page — an advisory row, never a gate ([ADR 0329](../adr/0329-the-rendered-fit-check-is-a-kit-floor-and-an-advisory-ship-check-row.md)). `--files a,b` checks named paths instead of the branch diff.
|
|
58
|
+
|
|
55
59
|
### The states file — `<surface>/<page>.states.json`
|
|
56
60
|
|
|
57
61
|
```json
|
|
@@ -12,6 +12,8 @@ node modules/ui-design/kit/contrast.js --pack [look] | --url <url> [--glyphs sel
|
|
|
12
12
|
|
|
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
|
+
Two of the floors are about fit ([task 1004203](https://cloudbongos.com/builders#/task/1004203), [ADR 0329](../../../docs/adr/0329-the-rendered-fit-check-is-a-kit-floor-and-an-advisory-ship-check-row.md)): `text-fit` (text past its box, SVG text past its card) and `sibling-overlap` (text-bearing siblings on top of each other). `scripts/gds/render-check.js` runs just those two over the pages a branch changed, and `/ship-check` shows them as an advisory row.
|
|
16
|
+
|
|
15
17
|
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
18
|
|
|
17
19
|
## The anti-pattern detector (`tells.js`, and the rendered twins in `lib.js`)
|
|
@@ -26,13 +26,17 @@ const FLOORS = Object.freeze([
|
|
|
26
26
|
'console-errors', // no console.error and no uncaught page error
|
|
27
27
|
'failed-requests', // no 4xx/5xx response the states file did not declare expected
|
|
28
28
|
'horizontal-overflow', // documentElement.scrollWidth fits the viewport
|
|
29
|
-
'
|
|
29
|
+
'text-fit', // no text runs past its box (HTML) or its card rect (SVG)
|
|
30
|
+
'sibling-overlap', // no two in-flow, block-level, text-bearing siblings overlap
|
|
31
|
+
'one-header-main-h1', // exactly one <header>, one <main>, one <h1>
|
|
30
32
|
'controls-24px', // every visible control clears 24px on both sides
|
|
31
33
|
'top-bar-80px', // the page's <header> is at most 80px tall
|
|
32
34
|
'reduced-motion', // under prefers-reduced-motion nothing runs and no rAF loop
|
|
33
35
|
]);
|
|
34
36
|
|
|
35
|
-
|
|
37
|
+
// 1280 is the review sheet's desktop width; render.js renders DEFAULT_SIZES unless --sizes names it.
|
|
38
|
+
const SIZES = Object.freeze({ 1440: [1440, 900], 1280: [1280, 800], 390: [390, 844], 320: [320, 700] });
|
|
39
|
+
const DEFAULT_SIZES = Object.freeze([1440, 390, 320]);
|
|
36
40
|
const MODES = Object.freeze(['dark', 'light']);
|
|
37
41
|
const CHANNELS = Object.freeze(['msedge', 'chrome']);
|
|
38
42
|
const SETTLE_MS = 900; // fonts + the pages' own boot fetches; the scratchpad value that never flaked
|
|
@@ -205,6 +209,71 @@ function factsInPage(spec) {
|
|
|
205
209
|
if (hLines > 3) tells.push(`headline-lines: the h1 wraps to ${hLines} lines`);
|
|
206
210
|
if (overshoot.length) tells.push(`bounce-easing: ${overshoot.join(' | ')}`);
|
|
207
211
|
out.tells = tells;
|
|
212
|
+
// The fit floors (task 1004203): both defects were found only by opening a browser — titles run past
|
|
213
|
+
// their cards (task 1003480), a second grid child landing on the first (task 1003078). Text is measured
|
|
214
|
+
// word by word, so the space a soft wrap hangs past the line never counts; scrollers and ellipsis clip on purpose.
|
|
215
|
+
// A selector-ish path up to the nearest id; :nth-of-type tells same-tag siblings apart.
|
|
216
|
+
const where = (el) => {
|
|
217
|
+
const parts = [];
|
|
218
|
+
for (let a = el; a && a !== document.body && parts.length < 3; a = a.parentElement) {
|
|
219
|
+
const cls = String(a.getAttribute('class') || '').trim();
|
|
220
|
+
const same = a.parentElement ? [...a.parentElement.children].filter((c) => c.tagName === a.tagName) : [a];
|
|
221
|
+
const nth = same.length > 1 ? `:nth-of-type(${same.indexOf(a) + 1})` : '';
|
|
222
|
+
parts.unshift(a.id ? '#' + a.id : a.tagName.toLowerCase() + (cls ? '.' + cls.split(/\s+/)[0] : '') + nth);
|
|
223
|
+
if (a.id) break;
|
|
224
|
+
}
|
|
225
|
+
return parts.join(' > ');
|
|
226
|
+
};
|
|
227
|
+
const quote = (s) => String(s).trim().replace(/[\u0000-\u001f\u007f]+/g, ' ').slice(0, 32); // page text echoed to a terminal
|
|
228
|
+
const seen = new Map();
|
|
229
|
+
const vis = (el) => { if (!seen.has(el)) seen.set(el, shown(el)); return seen.get(el); };
|
|
230
|
+
const NO_TEXT = /^(SCRIPT|STYLE|NOSCRIPT|TEMPLATE|TEXTAREA|OPTION|SELECT|TITLE)$/;
|
|
231
|
+
const textFit = [], overlaps = [], boxed = new Set();
|
|
232
|
+
const walk = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
|
233
|
+
for (let n = walk.nextNode(); n; n = walk.nextNode()) {
|
|
234
|
+
const el = n.parentElement;
|
|
235
|
+
if (!n.nodeValue.trim() || !el || NO_TEXT.test(el.tagName) || el.closest('svg') || !vis(el)) continue;
|
|
236
|
+
let box = el;
|
|
237
|
+
while (box && /^(inline|contents)$/.test(getComputedStyle(box).display)) box = box.parentElement;
|
|
238
|
+
if (!box || box === document.body || boxed.has(box)) continue;
|
|
239
|
+
const cs = getComputedStyle(box);
|
|
240
|
+
if (/auto|scroll/.test(cs.overflowX) || cs.textOverflow === 'ellipsis' || box.clientWidth < 4) continue; // < 4: a visually-hidden label
|
|
241
|
+
const b = box.getBoundingClientRect(), left = b.left + box.clientLeft, right = left + box.clientWidth;
|
|
242
|
+
const past = (range) => Math.max(0, ...[...range.getClientRects()].filter((r) => r.width > 0).flatMap((r) => [r.right - right, left - r.left]));
|
|
243
|
+
const all = document.createRange(); all.selectNodeContents(n);
|
|
244
|
+
if (past(all) <= 1) continue;
|
|
245
|
+
let over = 0;
|
|
246
|
+
for (const m of n.nodeValue.matchAll(/\S+/g)) { const w = document.createRange(); w.setStart(n, m.index); w.setEnd(n, m.index + m[0].length); over = Math.max(over, past(w)); }
|
|
247
|
+
if (over > 1) { boxed.add(box); textFit.push(`${where(box)} "${quote(n.nodeValue)}" ${Math.round(over)}px past its box`); }
|
|
248
|
+
}
|
|
249
|
+
// An SVG text's box is the smallest <rect> in its own <g> (up to three levels) holding where it starts.
|
|
250
|
+
qa('svg text').forEach((tx) => {
|
|
251
|
+
if (!(tx.textContent || '').trim() || !vis(tx)) return;
|
|
252
|
+
const t = tx.getBoundingClientRect();
|
|
253
|
+
if (!t.width) return;
|
|
254
|
+
const x = t.left + 1, y = t.top + t.height / 2;
|
|
255
|
+
let card = null;
|
|
256
|
+
for (let g = tx.parentElement, k = 0; g && k < 3 && !card && g.tagName.toLowerCase() !== 'svg'; g = g.parentElement, k++) {
|
|
257
|
+
for (const r of [...g.children].filter((c) => c.tagName.toLowerCase() === 'rect').map((c) => c.getBoundingClientRect())) {
|
|
258
|
+
if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom && (!card || r.width * r.height < card.width * card.height)) card = r;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
const over = card ? Math.max(t.right - card.right, card.left - t.left) : 0;
|
|
262
|
+
if (over > 1) textFit.push(`${where(tx)} "${quote(tx.textContent)}" ${Math.round(over)}px past its card`);
|
|
263
|
+
});
|
|
264
|
+
// Overlap counts only block-level, in-flow children that both carry text: a wrapped inline's box spans
|
|
265
|
+
// lines, and absolute/sticky/negative-margin layering is deliberate.
|
|
266
|
+
const inFlowBlock = (c) => { const s = getComputedStyle(c); return !/^(inline|contents)/.test(s.display) && !/absolute|fixed|sticky/.test(s.position) && s.float === 'none' && ![s.marginTop, s.marginRight, s.marginBottom, s.marginLeft].some((m) => parseFloat(m) < 0); };
|
|
267
|
+
qa('body, body *').forEach((parent) => {
|
|
268
|
+
if (parent.children.length < 2 || parent.closest('svg') || !vis(parent)) return;
|
|
269
|
+
const kids = [...parent.children].slice(0, 200).filter((c) => !NO_TEXT.test(c.tagName) && (c.textContent || '').trim() && vis(c) && inFlowBlock(c));
|
|
270
|
+
const rs = kids.map((c) => c.getBoundingClientRect());
|
|
271
|
+
for (let i = 0; i < kids.length; i++) for (let j = i + 1; j < kids.length; j++) {
|
|
272
|
+
const ix = Math.min(rs[i].right, rs[j].right) - Math.max(rs[i].left, rs[j].left), iy = Math.min(rs[i].bottom, rs[j].bottom) - Math.max(rs[i].top, rs[j].top);
|
|
273
|
+
if (ix > 2 && iy > 2) overlaps.push(`${where(kids[i])} ∩ ${where(kids[j])} ${Math.round(ix)}x${Math.round(iy)}`);
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
out.textFit = textFit; out.overlaps = overlaps;
|
|
208
277
|
return out;
|
|
209
278
|
}
|
|
210
279
|
// Pure: compare collected facts against an expectation. Returns problem strings.
|
|
@@ -239,6 +308,9 @@ function expectProblems(facts, expect = {}) {
|
|
|
239
308
|
function auditFloors(facts, { width, mode } = {}) {
|
|
240
309
|
const p = [];
|
|
241
310
|
if (facts.scrollWidth > facts.clientWidth + 1) p.push(`horizontal-overflow: scrollWidth ${facts.scrollWidth} > ${width || facts.clientWidth}`);
|
|
311
|
+
const some = (list) => list.slice(0, 5).join(' | ') + (list.length > 5 ? ` (+${list.length - 5} more)` : '');
|
|
312
|
+
if (facts.textFit && facts.textFit.length) p.push(`text-fit: ${some(facts.textFit)}`);
|
|
313
|
+
if (facts.overlaps && facts.overlaps.length) p.push(`sibling-overlap: ${some(facts.overlaps)}`);
|
|
242
314
|
if (facts.headers !== 1 || facts.mains !== 1 || facts.h1s !== 1) p.push(`one-header-main-h1: header ${facts.headers} main ${facts.mains} h1 ${facts.h1s}`);
|
|
243
315
|
if (facts.smallControls && facts.smallControls.length) p.push(`controls-24px: ${facts.smallControls.join(' | ')}`);
|
|
244
316
|
if (facts.bar && facts.bar.h > 80) p.push(`top-bar-80px: header is ${facts.bar.h}px`);
|
|
@@ -448,7 +520,7 @@ function packColors(pack) {
|
|
|
448
520
|
}
|
|
449
521
|
|
|
450
522
|
module.exports = {
|
|
451
|
-
REPO_ROOT, KIT_DIR, STYLES_DIR, EXIT, FLOORS, SIZES, MODES, CHANNELS, SETTLE_MS, ACTIONS, EXPECT_KEYS,
|
|
523
|
+
REPO_ROOT, KIT_DIR, STYLES_DIR, EXIT, FLOORS, SIZES, DEFAULT_SIZES, MODES, CHANNELS, SETTLE_MS, ACTIONS, EXPECT_KEYS,
|
|
452
524
|
argOf, hasFlag, listArg, pageName, normalizePageArg, resolveSurfaceDir, sidecarPath, readJson,
|
|
453
525
|
validateActions, runActions, validateExpect, factsInPage, expectProblems, auditFloors, reducedMotionProblems, craftTells,
|
|
454
526
|
validateStatesFile, validateProbesFile, NoBrowserError, launchBrowser, attachCollectors, withMode,
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
// --modes dark,light --sizes 1440,390,320 --only a,b --out <dir>
|
|
15
15
|
// --style <name> render under a look from the style library (STYLE= on the stubs; task 1003323)
|
|
16
16
|
// --base-out <url> --base-in <url> use running stubs instead of starting two
|
|
17
|
+
// --no-reduced-motion skip the reduced-motion pass
|
|
17
18
|
//
|
|
18
19
|
// Exit 0 ALL CLEAN · 1 problems · 2 usage/contract · 3 no browser.
|
|
19
20
|
const fs = require('node:fs');
|
|
@@ -68,7 +69,7 @@ async function main() {
|
|
|
68
69
|
if (lib.hasFlag(args, '--help')) usage('');
|
|
69
70
|
const { file, json, surface } = locate(args);
|
|
70
71
|
const modes = lib.listArg(args, '--modes', json.modes || [...lib.MODES]).filter((m) => lib.MODES.includes(m));
|
|
71
|
-
const sizes = lib.listArg(args, '--sizes',
|
|
72
|
+
const sizes = lib.listArg(args, '--sizes', lib.DEFAULT_SIZES).map(Number).filter((w) => lib.SIZES[w]);
|
|
72
73
|
const only = lib.listArg(args, '--only', null);
|
|
73
74
|
const names = Object.keys(json.states).filter((n) => !only || only.includes(n));
|
|
74
75
|
if (!names.length) usage(`--only names no state of ${path.basename(file)}`);
|
|
@@ -116,8 +117,9 @@ async function main() {
|
|
|
116
117
|
await ctx.close();
|
|
117
118
|
}
|
|
118
119
|
// reduced motion: the kill switch, exercised with the file's own interaction
|
|
120
|
+
// (--no-reduced-motion skips it: render-check reads only the fit floors)
|
|
119
121
|
const rm = json.reducedMotion || {};
|
|
120
|
-
for (const mode of modes) {
|
|
122
|
+
for (const mode of lib.hasFlag(args, '--no-reduced-motion') ? [] : modes) {
|
|
121
123
|
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, colorScheme: mode, reducedMotion: 'reduce' });
|
|
122
124
|
const page = await ctx.newPage();
|
|
123
125
|
await page.goto((rm.auth === false ? baseOut : baseIn) + lib.withMode(rm.url || json.page, mode, json.modeQuery), { waitUntil: 'load' });
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.994",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.994",
|
|
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.
|
|
3
|
+
"version": "1.19.994",
|
|
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",
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// scripts/gds/render-check.js — render the pages a branch changed and report text wider than its box and
|
|
4
|
+
// overlapping siblings, naming the element and the page (task 1004203, ADR 0329).
|
|
5
|
+
//
|
|
6
|
+
// Why: nothing else in the pipeline renders a page. The grader reads the diff, so a title clipped by character
|
|
7
|
+
// count that still ran past its card (task 1003480) and a second grid child landing on the first (task 1003078)
|
|
8
|
+
// both shipped looking correct in code and were found only by opening a browser.
|
|
9
|
+
//
|
|
10
|
+
// A SIGNAL, never a gate (ADR 0162): /ship-check shows it as an advisory row; ship.js and the grader never
|
|
11
|
+
// read it. The measurement is the kit's `text-fit` + `sibling-overlap` floors (modules/ui-design/kit/lib.js);
|
|
12
|
+
// this file only picks the pages, runs the kit's renderer at 1280 + 390, and keeps those two floors.
|
|
13
|
+
//
|
|
14
|
+
// node scripts/gds/render-check.js the pages this branch changed against origin/main
|
|
15
|
+
// node scripts/gds/render-check.js --base <ref> ... against another base
|
|
16
|
+
// node scripts/gds/render-check.js --files a,b the pages these repo paths reach
|
|
17
|
+
//
|
|
18
|
+
// Exit 0 nothing found or nothing to render · 1 findings · 3 no system browser.
|
|
19
|
+
const fs = require('node:fs');
|
|
20
|
+
const os = require('node:os');
|
|
21
|
+
const path = require('node:path');
|
|
22
|
+
const { spawn, spawnSync } = require('node:child_process');
|
|
23
|
+
const { isUiSurfacePath } = require('../../modules/lifecycle/cascade');
|
|
24
|
+
const kit = require('../../modules/ui-design/kit/lib');
|
|
25
|
+
|
|
26
|
+
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
27
|
+
const RENDER_JS = path.join(REPO_ROOT, 'modules', 'ui-design', 'kit', 'render.js');
|
|
28
|
+
const HARNESS_JS = path.join(REPO_ROOT, 'scripts', 'hall-preview', 'server.js');
|
|
29
|
+
const FIT_FLOORS = Object.freeze(['text-fit', 'sibling-overlap']);
|
|
30
|
+
const SIZES = Object.freeze([1280, 390]);
|
|
31
|
+
const MAX_STATES_FILES = 6; // a shared sheet reaches every page; past this the builder runs render.js by hand
|
|
32
|
+
// The whole run fits inside ship-check's 15-minute cap on a slow step, so ship-check never has to kill it
|
|
33
|
+
// (a killed parent cannot stop its harness and renderers). Files that would start past it are listed instead.
|
|
34
|
+
const RUN_BUDGET_MS = 12 * 60 * 1000;
|
|
35
|
+
const MIN_RENDER_MS = 60 * 1000;
|
|
36
|
+
const HARNESS_BOOT_MS = 30 * 1000;
|
|
37
|
+
const rel = (file) => path.relative(REPO_ROOT, file).split(path.sep).join('/');
|
|
38
|
+
|
|
39
|
+
// statesFilesFor — the states files a list of changed repo paths reaches. A page, or a file named like one
|
|
40
|
+
// (goal-map.js), reaches its own <page>.states.json; any other script, sheet or image reaches every page in
|
|
41
|
+
// the same public dir whose HTML names it (text-fit.js → goal-map, goals). Own pages come first, so the
|
|
42
|
+
// MAX_STATES_FILES cut never drops the page the builder changed for pages a shared sheet reaches.
|
|
43
|
+
function statesFilesFor(paths, root = REPO_ROOT) {
|
|
44
|
+
const own = new Set(), reached = new Set();
|
|
45
|
+
for (const p of paths) {
|
|
46
|
+
if (!isUiSurfacePath(p)) continue;
|
|
47
|
+
const dir = p.slice(0, p.indexOf('/public/') + '/public'.length);
|
|
48
|
+
const file = p.slice(dir.length + 1);
|
|
49
|
+
const beside = path.join(root, dir, `${file.replace(/\.[^./]+$/, '')}.states.json`);
|
|
50
|
+
if (!file.includes('/') && fs.existsSync(beside)) { own.add(beside); continue; }
|
|
51
|
+
if (/\.html?$/i.test(file) || !fs.existsSync(path.join(root, dir))) continue;
|
|
52
|
+
const named = new RegExp(`(^|[/"'=\\s])${path.basename(file).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`);
|
|
53
|
+
for (const html of fs.readdirSync(path.join(root, dir)).filter((f) => f.endsWith('.html'))) {
|
|
54
|
+
const states = path.join(root, dir, html.replace(/\.html$/, '.states.json'));
|
|
55
|
+
if (fs.existsSync(states) && named.test(fs.readFileSync(path.join(root, dir, html), 'utf8'))) reached.add(states);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return [...[...own].sort(), ...[...reached].filter((f) => !own.has(f)).sort()];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// fitFindings — every element the two fit floors named in one render.js report.json, read from the
|
|
62
|
+
// structured facts (auditFloors' problem line stops at five). One finding per element across all the
|
|
63
|
+
// renders it appeared in, with its largest measure — a title past its card at 1280 and 390 is one finding.
|
|
64
|
+
const MEASURE = / (\d+)px past its (?:box|card)$| (\d+)x(\d+)$/;
|
|
65
|
+
function fitFindings(report) {
|
|
66
|
+
const byElement = new Map();
|
|
67
|
+
for (const [render, r] of Object.entries((report && report.renders) || {})) {
|
|
68
|
+
const facts = (r && r.facts) || {};
|
|
69
|
+
for (const [floor, items] of [['text-fit', facts.textFit], ['sibling-overlap', facts.overlaps]]) {
|
|
70
|
+
for (const item of items || []) {
|
|
71
|
+
const m = MEASURE.exec(item);
|
|
72
|
+
const key = `${floor}: ${m ? item.slice(0, m.index) : item}`;
|
|
73
|
+
const size = m ? (m[1] ? Number(m[1]) : Number(m[2]) * Number(m[3])) : 0;
|
|
74
|
+
if (!byElement.has(key)) byElement.set(key, { key, measure: '', size: -1, renders: [] });
|
|
75
|
+
const f = byElement.get(key);
|
|
76
|
+
f.renders.push(render);
|
|
77
|
+
if (size > f.size) { f.size = size; f.measure = m ? m[0].trim() : ''; }
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return [...byElement.values()].map((f) => ({ page: report.page, problem: f.measure ? `${f.key} — ${f.measure}` : f.key, renders: f.renders }));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Two pages of one name (hall-ui/index, public-landing/index) must never share a shots folder.
|
|
85
|
+
function shotsDir(outRoot, file) {
|
|
86
|
+
return path.join(outRoot, rel(file).replace(/\.states\.json$/, '').replace(/[\\/]/g, '_'));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function changedFiles(base) {
|
|
90
|
+
const git = (...args) => spawnSync('git', args, { cwd: REPO_ROOT, encoding: 'utf8' });
|
|
91
|
+
const mergeBase = git('merge-base', base, 'HEAD').stdout.trim();
|
|
92
|
+
if (!mergeBase) return null;
|
|
93
|
+
const listed = `${git('diff', '--name-only', mergeBase).stdout}\n${git('ls-files', '--others', '--exclude-standard').stdout}`;
|
|
94
|
+
return [...new Set(listed.split(/\r?\n/).filter(Boolean))];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Kill a child and everything it started (render.js starts two stubs and a browser).
|
|
98
|
+
const DETACH = process.platform !== 'win32';
|
|
99
|
+
function killTree(child) {
|
|
100
|
+
if (!child || child.exitCode !== null || child.signalCode !== null) return;
|
|
101
|
+
try {
|
|
102
|
+
if (process.platform === 'win32') spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' });
|
|
103
|
+
else process.kill(-child.pid, 'SIGKILL');
|
|
104
|
+
} catch { /* already gone */ }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// The hall's states files are written against the hall-preview harness (their notes say so): its fixtures
|
|
108
|
+
// carry the populated pages, where the kit's own stub serves the hall its signed-out, empty reads. Its
|
|
109
|
+
// output is read for the whole run, since a pipe left unread fills and stalls it mid-render.
|
|
110
|
+
async function startHarness({ script = HARNESS_JS, bootMs = HARNESS_BOOT_MS } = {}) {
|
|
111
|
+
const port = await kit.freePort();
|
|
112
|
+
const proc = spawn(process.execPath, [script, '--port', String(port), '--fixture-me'], { cwd: REPO_ROOT, stdio: ['ignore', 'pipe', 'pipe'], detached: DETACH });
|
|
113
|
+
const stop = () => killTree(proc);
|
|
114
|
+
await new Promise((resolve, reject) => {
|
|
115
|
+
let said = '', ready = false;
|
|
116
|
+
const fail = (why) => { clearTimeout(timer); stop(); reject(new Error(`${why}: ${said.trim()}`)); };
|
|
117
|
+
const timer = setTimeout(() => fail(`the hall-preview harness did not start within ${bootMs / 1000}s`), bootMs);
|
|
118
|
+
const hear = (d) => {
|
|
119
|
+
if (ready) return;
|
|
120
|
+
said += d;
|
|
121
|
+
if (/hall-preview: http/.test(said)) { ready = true; clearTimeout(timer); resolve(); }
|
|
122
|
+
};
|
|
123
|
+
proc.stdout.on('data', hear);
|
|
124
|
+
proc.stderr.on('data', hear);
|
|
125
|
+
proc.on('exit', (code) => { if (!ready) fail(`the hall-preview harness exited ${code}`); });
|
|
126
|
+
});
|
|
127
|
+
return { base: `http://localhost:${port}`, pid: proc.pid, stop };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Run a node script without blocking the event loop (the harness's pipes keep draining meanwhile);
|
|
131
|
+
// past timeoutMs the script and everything it started are killed.
|
|
132
|
+
function runNode(args, timeoutMs) {
|
|
133
|
+
return new Promise((resolve) => {
|
|
134
|
+
const child = spawn(process.execPath, args, { cwd: REPO_ROOT, stdio: ['ignore', 'ignore', 'pipe'], detached: DETACH });
|
|
135
|
+
let stderr = '';
|
|
136
|
+
child.stderr.on('data', (d) => { if (stderr.length < 4000) stderr += d; });
|
|
137
|
+
const timer = setTimeout(() => killTree(child), timeoutMs);
|
|
138
|
+
child.on('error', (e) => { clearTimeout(timer); resolve({ status: null, signal: null, stderr: e.message }); });
|
|
139
|
+
child.on('close', (status, signal) => { clearTimeout(timer); resolve({ status, signal, stderr }); });
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// One states file through the kit's renderer: { findings, note?, noBrowser? }. ONE colour mode (the file's
|
|
144
|
+
// first): the fit floors measure geometry, and the pack's two font stacks are the same in both modes. No
|
|
145
|
+
// reduced-motion pass either — only the fit floors are read. render.js still runs everything when asked.
|
|
146
|
+
async function renderStatesFile(file, { outRoot, harness, timeoutMs }) {
|
|
147
|
+
const json = kit.readJson(file);
|
|
148
|
+
const out = shotsDir(outRoot, file);
|
|
149
|
+
const mode = (json.modes || kit.MODES)[0];
|
|
150
|
+
const args = [RENDER_JS, '--states', file, '--sizes', SIZES.join(','), '--modes', mode, '--no-reduced-motion', '--out', out];
|
|
151
|
+
if (json.surface === 'hall-ui' && harness) args.push('--base-in', harness.base, '--base-out', harness.base);
|
|
152
|
+
const r = await runNode(args, timeoutMs);
|
|
153
|
+
if (r.status === kit.EXIT.NO_BROWSER) return { findings: [], noBrowser: true, note: String(r.stderr || '').trim().split('\n')[0] };
|
|
154
|
+
const reportFile = path.join(out, 'report.json');
|
|
155
|
+
if (r.status === kit.EXIT.USAGE || !fs.existsSync(reportFile)) {
|
|
156
|
+
const why = String(r.stderr || `exit ${r.status}${r.signal ? ` (${r.signal})` : ''}`).trim().split('\n').slice(0, 2).join(' ');
|
|
157
|
+
return { findings: [], note: `${rel(file)}: the renderer produced no report — ${why}` };
|
|
158
|
+
}
|
|
159
|
+
return { findings: fitFindings(kit.readJson(reportFile)), out };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function main() {
|
|
163
|
+
const started = Date.now();
|
|
164
|
+
const args = process.argv.slice(2);
|
|
165
|
+
const explicit = kit.listArg(args, '--files', null);
|
|
166
|
+
const paths = explicit || changedFiles(kit.argOf(args, '--base', 'origin/main'));
|
|
167
|
+
if (!paths) { console.log('render-check: no merge base with origin/main — nothing to compare against, nothing rendered'); return 0; }
|
|
168
|
+
const all = statesFilesFor(paths);
|
|
169
|
+
const uiCount = paths.filter(isUiSurfacePath).length;
|
|
170
|
+
if (!all.length) {
|
|
171
|
+
console.log(uiCount
|
|
172
|
+
? `render-check: ${uiCount} rendered-surface file(s) changed, but no <page>.states.json reaches them — nothing to measure (docs/recipes/ui-look-before-you-ship.md)`
|
|
173
|
+
: 'render-check: no rendered surface changed — nothing to render');
|
|
174
|
+
return 0;
|
|
175
|
+
}
|
|
176
|
+
const outRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'render-check-'));
|
|
177
|
+
console.log(`render-check — text past its box and overlapping siblings at ${SIZES.join(' + ')} (a signal, never a gate)`);
|
|
178
|
+
let harness = null;
|
|
179
|
+
const findings = [], notes = [], unrendered = all.slice(MAX_STATES_FILES).map((f) => [f, `past ${MAX_STATES_FILES}`]);
|
|
180
|
+
try {
|
|
181
|
+
const files = all.slice(0, MAX_STATES_FILES);
|
|
182
|
+
if (files.some((f) => kit.readJson(f).surface === 'hall-ui')) harness = await startHarness();
|
|
183
|
+
for (const [i, file] of files.entries()) {
|
|
184
|
+
const left = RUN_BUDGET_MS - (Date.now() - started);
|
|
185
|
+
if (left < MIN_RENDER_MS) { unrendered.unshift(...files.slice(i).map((f) => [f, 'out of time'])); break; }
|
|
186
|
+
const res = await renderStatesFile(file, { outRoot, harness, timeoutMs: left });
|
|
187
|
+
if (res.noBrowser) { console.log(`render-check: no system browser, nothing rendered — ${res.note}`); return kit.EXIT.NO_BROWSER; }
|
|
188
|
+
if (res.note) notes.push(res.note);
|
|
189
|
+
findings.push(...res.findings.map((x) => ({ ...x, shots: res.out })));
|
|
190
|
+
console.log(` ${rel(file)}: ${res.findings.length ? `${res.findings.length} finding(s)` : res.note ? 'no report' : 'clean'}${res.out ? ` (shots: ${res.out})` : ''}`);
|
|
191
|
+
}
|
|
192
|
+
} finally {
|
|
193
|
+
if (harness) harness.stop();
|
|
194
|
+
}
|
|
195
|
+
for (const [f, why] of unrendered) console.log(` not rendered (${why}): node modules/ui-design/kit/render.js --states ${rel(f)} --sizes 1280,390`);
|
|
196
|
+
for (const n of notes) console.log(`✗ ${n}`);
|
|
197
|
+
for (const f of findings) console.log(`✗ ${f.page} ${f.problem} [${f.renders.join(', ')}] shots: ${f.shots}`);
|
|
198
|
+
const unmeasured = notes.length ? `; ${notes.length} states file(s) not measured` : '';
|
|
199
|
+
console.log(findings.length || notes.length ? `${findings.length} finding(s)${unmeasured} — look at the shots, then fix the element or say in the ship notes why it is right` : 'nothing past its box, no overlapping siblings');
|
|
200
|
+
return findings.length || notes.length ? 1 : 0;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
module.exports = { FIT_FLOORS, SIZES, statesFilesFor, fitFindings, shotsDir, startHarness };
|
|
204
|
+
if (require.main === module) {
|
|
205
|
+
main().then((code) => { process.exitCode = code; }, (e) => { console.error(`render-check: ${e.stack || e.message}`); process.exitCode = 1; });
|
|
206
|
+
}
|
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
// call with one table. It CHANGES NOTHING: every step is a --check; the "fix" column is what to run.
|
|
11
11
|
//
|
|
12
12
|
// Usage:
|
|
13
|
-
// node scripts/gds/ship-check.js checks only (fast; no tests)
|
|
13
|
+
// node scripts/gds/ship-check.js checks only (fast; no tests) — plus, when the branch changed a
|
|
14
|
+
// page, an ADVISORY render of it (render-check.js; never flips the exit;
|
|
15
|
+
// minutes on a big page — --no-render skips it)
|
|
14
16
|
// node scripts/gds/ship-check.js --tests also run the DB-free unit lane (npm test) and list failures
|
|
15
17
|
// node scripts/gds/ship-check.js --json
|
|
16
18
|
// Exit 1 when any check is stale/failing, 0 when everything is fresh.
|
|
@@ -22,7 +24,9 @@ const path = require('node:path');
|
|
|
22
24
|
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
23
25
|
|
|
24
26
|
// Each step: a label, the argv to run (node scripts run with THIS node), the command that heals it,
|
|
25
|
-
// and how to read a non-zero exit. `optional` steps are skipped when their script is absent.
|
|
27
|
+
// and how to read a non-zero exit. `optional` steps are skipped when their script is absent. An `advisory`
|
|
28
|
+
// step is a signal, never a gate (ADR 0162): a non-zero exit reads `warn` (3 = no browser reads `skipped`).
|
|
29
|
+
// `onlyWith` names the flag that adds a step; `skipWith` the flag that leaves one out.
|
|
26
30
|
const STEPS = [
|
|
27
31
|
{ key: 'fitness', label: 'fitness.js (architecture + budgets + freshness gate)', argv: ['scripts/gds/fitness.js'], fix: 'read the ✗ lines above; each names its fix' },
|
|
28
32
|
{ key: 'repo-map', label: 'nested CLAUDE.md symbol blocks fresh', argv: ['scripts/gds/gen-repo-map.js', '--check-block'], fix: 'node scripts/gds/gen-repo-map.js' },
|
|
@@ -32,6 +36,7 @@ const STEPS = [
|
|
|
32
36
|
{ key: 'copy-registry', label: 'docs/copy-registry.json fresh', argv: ['scripts/gds/copy-inventory.js', '--check'], fix: 'node scripts/gds/copy-inventory.js', optional: true },
|
|
33
37
|
{ key: 'linkify', label: 'no bare #NNN task refs in committed markdown', argv: ['scripts/gds/linkify-refs.js', '--check'], fix: 'node scripts/gds/linkify-refs.js', optional: true },
|
|
34
38
|
{ key: 'skill-lint', label: 'SKILL.md frontmatter parses', argv: ['scripts/gds/skill-lint.js'], fix: 'fix the ✗ lines (description: >- block scalars)' },
|
|
39
|
+
{ key: 'render-fit', label: 'rendered text fit + sibling overlap on the pages this branch changed (advisory)', argv: ['scripts/gds/render-check.js'], fix: 'look at the shots it names; fix the element, or say in the ship notes why it is right', advisory: true, optional: true, skipWith: '--no-render', slow: true },
|
|
35
40
|
{ key: 'tests', label: 'DB-free unit lane (npm test)', argv: ['scripts/gds/run-unit-tests.js'], fix: 'read the FAIL lines; compare against origin/main before calling any of them environmental', onlyWith: '--tests', slow: true },
|
|
36
41
|
];
|
|
37
42
|
|
|
@@ -42,18 +47,18 @@ function runStep(step, { cwd = REPO_ROOT } = {}) {
|
|
|
42
47
|
const r = spawnSync(process.execPath, [script, ...step.argv.slice(1)], { cwd, encoding: 'utf8', timeout: step.slow ? 15 * 60 * 1000 : 5 * 60 * 1000, env: { ...process.env, GDS_QUIET: '1' } });
|
|
43
48
|
const out = ((r.stdout || '') + (r.stderr || '')).trim().split(/\r?\n/);
|
|
44
49
|
const failing = out.filter((l) => /^\s*(✗|FAIL|not ok|STALE|✘)/.test(l) || /STALE|is stale|would update|failed/i.test(l));
|
|
45
|
-
return { ...step, status: r.status === 0 ? 'ok' : 'fail', code: r.status, signal: r.signal, ms: Date.now() - t0, tail: (failing.length ? failing : out.slice(-4)).slice(0, 12).join('\n') };
|
|
50
|
+
return { ...step, status: r.status === 0 ? 'ok' : !step.advisory ? 'fail' : r.status === 3 ? 'skipped' : 'warn', code: r.status, signal: r.signal, ms: Date.now() - t0, tail: (failing.length ? failing : out.slice(-4)).slice(0, 12).join('\n') };
|
|
46
51
|
}
|
|
47
52
|
|
|
48
53
|
function summarize(results) {
|
|
49
54
|
const stale = results.filter((r) => r.status === 'fail' || r.status === 'missing');
|
|
50
|
-
return { ok: stale.length === 0, stale: stale.map((r) => r.key), results };
|
|
55
|
+
return { ok: stale.length === 0, stale: stale.map((r) => r.key), warned: results.filter((r) => r.status === 'warn').map((r) => r.key), results };
|
|
51
56
|
}
|
|
52
57
|
|
|
53
58
|
function format(summary) {
|
|
54
59
|
const lines = ['ship-check — what ship.js will trip on', ''];
|
|
55
60
|
for (const r of summary.results) {
|
|
56
|
-
const mark = r.status === 'ok' ? '✓' : r.status === 'skipped' ? '·' : '✗';
|
|
61
|
+
const mark = r.status === 'ok' ? '✓' : r.status === 'skipped' ? '·' : r.status === 'warn' ? '!' : '✗';
|
|
57
62
|
lines.push(`${mark} ${r.label} (${r.ms} ms)`);
|
|
58
63
|
if (r.status !== 'ok' && r.status !== 'skipped') {
|
|
59
64
|
for (const t of String(r.tail).split('\n')) lines.push(` ${t}`);
|
|
@@ -62,12 +67,13 @@ function format(summary) {
|
|
|
62
67
|
}
|
|
63
68
|
lines.push('');
|
|
64
69
|
lines.push(summary.ok ? 'all fresh — ship.js preflight should pass; the grader and CI are the remaining gates.' : `${summary.stale.length} stale: ${summary.stale.join(', ')} — heal, commit, re-run.`);
|
|
70
|
+
if (summary.warned.length) lines.push(`advisory (a signal, never a gate): ${summary.warned.join(', ')}`);
|
|
65
71
|
return lines.join('\n');
|
|
66
72
|
}
|
|
67
73
|
|
|
68
74
|
function main() {
|
|
69
75
|
const argv = process.argv.slice(2);
|
|
70
|
-
const steps = STEPS.filter((s) => !s.onlyWith || argv.includes(s.onlyWith));
|
|
76
|
+
const steps = STEPS.filter((s) => (!s.onlyWith || argv.includes(s.onlyWith)) && !(s.skipWith && argv.includes(s.skipWith)));
|
|
71
77
|
const results = steps.map((s) => runStep(s));
|
|
72
78
|
const summary = summarize(results);
|
|
73
79
|
console.log(argv.includes('--json') ? JSON.stringify(summary, null, 1) : format(summary));
|
|
@@ -31,6 +31,7 @@ function visualReviewNudge(task) {
|
|
|
31
31
|
'🎨 This task changes a page people see.',
|
|
32
32
|
' Build it with /design, and check it in a real browser at desktop and phone width, light and dark.',
|
|
33
33
|
' Before you ship: open the before/after for your human (node scripts/gds/review-sheet.js) and wait for their OK.',
|
|
34
|
+
' /ship-check renders the pages you changed and names any text past its box or overlapping siblings (a signal, not a gate).',
|
|
34
35
|
];
|
|
35
36
|
}
|
|
36
37
|
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.994'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// tests/render_check.mjs — the rendered fit check (task 1004203, ADR 0329): which pages a changed path
|
|
2
|
+
// reaches, which report rows it keeps, and that /ship-check shows it as a signal that never flips its exit
|
|
3
|
+
// (ADR 0162). DB-free and browser-free; the in-page measurement is pinned in tests/ui_design_kit_fit.mjs.
|
|
4
|
+
import assert from 'node:assert/strict';
|
|
5
|
+
import { test } from 'node:test';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { spawnSync } from 'node:child_process';
|
|
11
|
+
import { FIT_FLOORS, SIZES, statesFilesFor, fitFindings, shotsDir, startHarness } from '../scripts/gds/render-check.js';
|
|
12
|
+
import { STEPS, runStep, summarize, format } from '../scripts/gds/ship-check.js';
|
|
13
|
+
|
|
14
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
15
|
+
const names = (paths) => statesFilesFor(paths, ROOT).map((f) => path.basename(f));
|
|
16
|
+
|
|
17
|
+
test('a changed page or its own script reaches the states file beside it', () => {
|
|
18
|
+
assert.deepEqual(names(['modules/hall-ui/public/goal-map.js']), ['goal-map.states.json']);
|
|
19
|
+
assert.deepEqual(names(['modules/hall-ui/public/goal-map.html']), ['goal-map.states.json']);
|
|
20
|
+
assert.deepEqual(names(['modules/public-landing/public/projects.html']), ['projects.states.json']);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('a shared asset reaches every page in its dir whose HTML names it', () => {
|
|
24
|
+
assert.deepEqual(names(['modules/hall-ui/public/text-fit.js']), ['goal-map.states.json', 'goals.states.json']);
|
|
25
|
+
assert.deepEqual(names(['modules/public-landing/public/assets/cosmos.css']), ['projects.states.json']);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('the page a builder changed comes before the pages a shared file reaches, so the render cap never drops it', () => {
|
|
29
|
+
assert.deepEqual(names(['modules/hall-ui/public/text-fit.js', 'modules/hall-ui/public/work.js']), ['work.states.json', 'goal-map.states.json', 'goals.states.json']);
|
|
30
|
+
assert.deepEqual(names(['modules/hall-ui/public/text-fit.js', 'modules/hall-ui/public/goal-map.js']), ['goal-map.states.json', 'goals.states.json'], 'a page reached both ways is listed once, as its own');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('a path that is not a rendered surface, or a page with no states file, reaches nothing', () => {
|
|
34
|
+
assert.deepEqual(names(['src/bongos/db.js', 'modules/hall-ui/public/goal-map.states.json', 'modules/hall-ui/routes/x.js']), []);
|
|
35
|
+
assert.deepEqual(names(['modules/hall-ui/public/deploy.html']), [], 'a page without a states file does not fall back to the pages that link to it');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('fitFindings names every element from the structured facts, once per element across renders, with its largest measure', () => {
|
|
39
|
+
const title = (n, px) => `g.gm-nodes > g.gm-node:nth-of-type(${n}) > text.gm-name "Title ${n}…" ${px}px past its card`;
|
|
40
|
+
const report = {
|
|
41
|
+
page: '/builders/goal-map',
|
|
42
|
+
renders: {
|
|
43
|
+
// seven titles: more than auditFloors' five-item problem line names
|
|
44
|
+
'dark-1280-map': { facts: { textFit: [1, 2, 3, 4, 5, 6, 7].map((n) => title(n, 10)), overlaps: [] }, problems: ['controls-24px: a.x "Docs" 40x17'] },
|
|
45
|
+
'dark-390-map': { facts: { textFit: [title(1, 14)], overlaps: ['#mVisibility > div:nth-of-type(1) ∩ #mVisibility > div:nth-of-type(2) 640x48'] } },
|
|
46
|
+
'dark-390-out': { facts: {} },
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
const found = fitFindings(report);
|
|
50
|
+
assert.equal(found.filter((f) => f.problem.startsWith('text-fit: ')).length, 7, 'every element is named, not the first five');
|
|
51
|
+
assert.deepEqual(found[0], {
|
|
52
|
+
page: '/builders/goal-map',
|
|
53
|
+
problem: 'text-fit: g.gm-nodes > g.gm-node:nth-of-type(1) > text.gm-name "Title 1…" — 14px past its card',
|
|
54
|
+
renders: ['dark-1280-map', 'dark-390-map'],
|
|
55
|
+
}, 'one finding across widths, carrying the largest overflow');
|
|
56
|
+
assert.deepEqual(found.at(-1), {
|
|
57
|
+
page: '/builders/goal-map',
|
|
58
|
+
problem: 'sibling-overlap: #mVisibility > div:nth-of-type(1) ∩ #mVisibility > div:nth-of-type(2) — 640x48',
|
|
59
|
+
renders: ['dark-390-map'],
|
|
60
|
+
});
|
|
61
|
+
assert.deepEqual(fitFindings({ page: '/x', renders: {} }), []);
|
|
62
|
+
assert.deepEqual(fitFindings(null), []);
|
|
63
|
+
assert.deepEqual([...FIT_FLOORS], ['text-fit', 'sibling-overlap']);
|
|
64
|
+
assert.deepEqual([...SIZES], [1280, 390]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('two pages of one name never share a shots folder', () => {
|
|
68
|
+
const out = path.join(os.tmpdir(), 'rc');
|
|
69
|
+
const hall = shotsDir(out, path.join(ROOT, 'modules', 'hall-ui', 'public', 'index.states.json'));
|
|
70
|
+
const landing = shotsDir(out, path.join(ROOT, 'modules', 'public-landing', 'public', 'index.states.json'));
|
|
71
|
+
assert.notEqual(hall, landing);
|
|
72
|
+
assert.equal(path.basename(hall), 'modules_hall-ui_public_index');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const alive = (pid) => { try { process.kill(pid, 0); return true; } catch { return false; } };
|
|
76
|
+
const waitDead = async (pid) => { for (let i = 0; i < 50 && alive(pid); i++) await new Promise((r) => setTimeout(r, 100)); return !alive(pid); };
|
|
77
|
+
|
|
78
|
+
test('a harness that never says it is up is killed on the boot timeout, not left holding its port', async () => {
|
|
79
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rc-harness-'));
|
|
80
|
+
const pidFile = path.join(dir, 'pid');
|
|
81
|
+
const script = path.join(dir, 'silent.js');
|
|
82
|
+
fs.writeFileSync(script, `require('fs').writeFileSync(${JSON.stringify(pidFile)}, String(process.pid)); setInterval(() => {}, 1000);`);
|
|
83
|
+
await assert.rejects(startHarness({ script, bootMs: 1500 }), /did not start within 1\.5s/);
|
|
84
|
+
assert.ok(await waitDead(Number(fs.readFileSync(pidFile, 'utf8'))), 'the silent harness was killed');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('a harness that comes up is handed back with a stop that ends it', async () => {
|
|
88
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rc-harness-'));
|
|
89
|
+
const script = path.join(dir, 'up.js');
|
|
90
|
+
fs.writeFileSync(script, `console.log('hall-preview: http://localhost:' + process.argv[3] + '/'); setInterval(() => {}, 1000);`);
|
|
91
|
+
const harness = await startHarness({ script, bootMs: 5000 });
|
|
92
|
+
assert.match(harness.base, /^http:\/\/localhost:\d+$/);
|
|
93
|
+
assert.ok(alive(harness.pid));
|
|
94
|
+
harness.stop();
|
|
95
|
+
assert.ok(await waitDead(harness.pid), 'stop() ended the harness');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('the CLI renders nothing, and says why, when no page a states file describes changed', () => {
|
|
99
|
+
const cli = (files) => spawnSync(process.execPath, [path.join(ROOT, 'scripts', 'gds', 'render-check.js'), '--files', files], { cwd: ROOT, encoding: 'utf8' });
|
|
100
|
+
const none = cli('src/bongos/db.js');
|
|
101
|
+
assert.equal(none.status, 0);
|
|
102
|
+
assert.match(none.stdout, /no rendered surface changed/);
|
|
103
|
+
const noStates = cli('modules/hall-ui/public/deploy.html');
|
|
104
|
+
assert.equal(noStates.status, 0);
|
|
105
|
+
assert.match(noStates.stdout, /1 rendered-surface file\(s\) changed, but no <page>\.states\.json reaches them/);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('ship-check runs the render check as an advisory row: findings warn, no browser skips, neither flips the exit', () => {
|
|
109
|
+
const step = STEPS.find((s) => s.key === 'render-fit');
|
|
110
|
+
assert.ok(step && step.advisory && step.argv[0] === 'scripts/gds/render-check.js');
|
|
111
|
+
assert.ok(step.optional, 'an absent script is skipped, never counted as stale');
|
|
112
|
+
assert.equal(step.skipWith, '--no-render');
|
|
113
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ship-check-advisory-'));
|
|
114
|
+
const script = (name, code) => { fs.writeFileSync(path.join(dir, name), `console.log('✗ a finding'); process.exit(${code});`); return name; };
|
|
115
|
+
const warn = runStep({ key: 'r', label: 'r', argv: [script('found.js', 1)], fix: 'f', advisory: true }, { cwd: dir });
|
|
116
|
+
const skip = runStep({ key: 's', label: 's', argv: [script('nobrowser.js', 3)], fix: 'f', advisory: true }, { cwd: dir });
|
|
117
|
+
const gate = runStep({ key: 'g', label: 'g', argv: [script('stale.js', 1)], fix: 'f' }, { cwd: dir });
|
|
118
|
+
assert.equal(warn.status, 'warn');
|
|
119
|
+
assert.match(warn.tail, /✗ a finding/);
|
|
120
|
+
assert.equal(skip.status, 'skipped');
|
|
121
|
+
assert.equal(gate.status, 'fail', 'a non-advisory step still fails');
|
|
122
|
+
const advisoryOnly = summarize([warn, skip]);
|
|
123
|
+
assert.equal(advisoryOnly.ok, true);
|
|
124
|
+
assert.deepEqual(advisoryOnly.warned, ['r']);
|
|
125
|
+
assert.match(format(advisoryOnly), /! r[\s\S]*advisory \(a signal, never a gate\): r/);
|
|
126
|
+
assert.equal(summarize([warn, gate]).ok, false);
|
|
127
|
+
});
|
package/tests/ui_design_kit.mjs
CHANGED
|
@@ -27,9 +27,10 @@ const readJson = (rel) => JSON.parse(fs.readFileSync(path.join(ROOT, rel), 'utf8
|
|
|
27
27
|
|
|
28
28
|
// ── the contract ──────────────────────────────────────────────────────────────
|
|
29
29
|
test('the platform floors are a pinned list — a floor cannot vanish quietly', () => {
|
|
30
|
-
assert.deepEqual([...lib.FLOORS], ['console-errors', 'failed-requests', 'horizontal-overflow', 'one-header-main-h1', 'controls-24px', 'top-bar-80px', 'reduced-motion']);
|
|
30
|
+
assert.deepEqual([...lib.FLOORS], ['console-errors', 'failed-requests', 'horizontal-overflow', 'text-fit', 'sibling-overlap', 'one-header-main-h1', 'controls-24px', 'top-bar-80px', 'reduced-motion']);
|
|
31
31
|
assert.deepEqual(lib.EXIT, { CLEAN: 0, PROBLEMS: 1, USAGE: 2, NO_BROWSER: 3 });
|
|
32
|
-
assert.deepEqual(Object.keys(lib.SIZES), ['320', '390', '1440']);
|
|
32
|
+
assert.deepEqual(Object.keys(lib.SIZES), ['320', '390', '1280', '1440']);
|
|
33
|
+
assert.deepEqual([...lib.DEFAULT_SIZES], [1440, 390, 320], 'render.js renders these unless --sizes names 1280');
|
|
33
34
|
});
|
|
34
35
|
|
|
35
36
|
test('the shipped states files validate and sit beside the page they name', () => {
|
|
@@ -95,6 +96,10 @@ test('auditFloors names each floor a render breaks, and says nothing about a cle
|
|
|
95
96
|
assert.ok(p.some((x) => x.startsWith('top-bar-80px') && /96px/.test(x)));
|
|
96
97
|
assert.ok(p.some((x) => /data-mode "light" under a dark scheme/.test(x)));
|
|
97
98
|
assert.deepEqual(lib.auditFloors({ ...clean(), mode: null }, { mode: 'dark' }), [], 'a page that sets no data-mode is not held to one');
|
|
99
|
+
assert.deepEqual(lib.auditFloors({ ...clean(), textFit: [], overlaps: [] }, { width: 1440, mode: 'dark' }), []);
|
|
100
|
+
const fit = lib.auditFloors({ ...clean(), textFit: ['#t1 "Governance" 15px past its card', 'b', 'c', 'd', 'e', 'f', 'g'], overlaps: ['#mSec > div ∩ #mSec > div 640x48'] }, { width: 1280 });
|
|
101
|
+
assert.ok(fit.includes('text-fit: #t1 "Governance" 15px past its card | b | c | d | e (+2 more)'), fit.join(' / '));
|
|
102
|
+
assert.ok(fit.includes('sibling-overlap: #mSec > div ∩ #mSec > div 640x48'));
|
|
98
103
|
});
|
|
99
104
|
|
|
100
105
|
test('expectProblems compares every expectation the vocabulary allows', () => {
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// tests/ui_design_kit_fit.mjs — the kit's two fit floors measured in a real page (task 1004203, ADR 0329):
|
|
2
|
+
// text past its box (HTML, and SVG text past its card) and overlapping text-bearing siblings, with the shapes
|
|
3
|
+
// that clip or layer ON PURPOSE staying silent. Needs the system Edge or Chrome the kit drives; with neither,
|
|
4
|
+
// the test skips — tests/ui_design_kit.mjs pins the pure audit without one.
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { test, after } from 'node:test';
|
|
7
|
+
import { createRequire } from 'node:module';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
|
|
11
|
+
const require = createRequire(import.meta.url);
|
|
12
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
13
|
+
const lib = require(path.join(ROOT, 'modules', 'ui-design', 'kit', 'lib.js'));
|
|
14
|
+
|
|
15
|
+
let browser = null;
|
|
16
|
+
try { browser = await lib.launchBrowser(); } catch (e) { if (!(e instanceof lib.NoBrowserError)) throw e; }
|
|
17
|
+
after(() => browser && browser.close());
|
|
18
|
+
|
|
19
|
+
// Each shape the done-when names, beside the look-alikes that must NOT report: the .mSec one-cell grid with a
|
|
20
|
+
// second child (task 1003078), a card title past its rect (task 1003480), ellipsis, a scroller, an absolute
|
|
21
|
+
// overlay, a visually-hidden label, wrapped prose whose soft-wrap space hangs past the line, two wrapped
|
|
22
|
+
// inline siblings (their boxes span lines), and a sticky head over rows scrolled under it.
|
|
23
|
+
const PAGE = `<!doctype html><html><head><style>
|
|
24
|
+
body { margin: 0; font: 16px/1.4 monospace; }
|
|
25
|
+
.box { width: 120px; }
|
|
26
|
+
.nowrap { white-space: nowrap; }
|
|
27
|
+
.cell { display: grid; grid-template-columns: 100px 1fr; width: 400px; }
|
|
28
|
+
.cell > div { grid-column: 2; grid-row: 1; }
|
|
29
|
+
.vh { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
|
30
|
+
</style></head><body><header></header><main><h1>fit</h1>
|
|
31
|
+
<p id="prose" class="box">a paragraph of ordinary words that wraps across several lines inside its box</p>
|
|
32
|
+
<div id="spills" class="box nowrap">averyveryverylongunbreakabletitle</div>
|
|
33
|
+
<div id="ellipsis" class="box nowrap" style="overflow: hidden; text-overflow: ellipsis">averyveryverylongunbreakabletitle</div>
|
|
34
|
+
<div id="scroller" class="box nowrap" style="overflow-x: auto">averyveryverylongunbreakabletitle</div>
|
|
35
|
+
<span class="vh">a visually hidden label far wider than one pixel</span>
|
|
36
|
+
<section id="mSec" class="cell"><h2>Head</h2><div>the first child's text</div><div>a second child's text</div></section>
|
|
37
|
+
<section id="alone" class="cell"><h2>Head</h2><div>the only child</div></section>
|
|
38
|
+
<p id="inline" class="box"><code>the first inline code run</code> then <code>a second inline code run</code></p>
|
|
39
|
+
<div id="rows" style="height: 60px; overflow: auto"><div style="position: sticky; top: 0">a sticky head</div><div>row one</div><div>row two</div><div>row three</div><div>row four</div></div>
|
|
40
|
+
<div id="layer" style="position: relative"><div>the base text</div><div style="position: absolute; top: 0">an overlay</div></div>
|
|
41
|
+
<svg width="400" height="100">
|
|
42
|
+
<g id="fits"><rect width="200" height="40"/><text x="10" y="25">fits</text></g>
|
|
43
|
+
<g id="over" transform="translate(0 50)"><rect width="60" height="40"/><text x="10" y="25">WWWWWWWWWWWW</text></g>
|
|
44
|
+
</svg>
|
|
45
|
+
</main></body></html>`;
|
|
46
|
+
|
|
47
|
+
test('the fit floors report the overflow and the overlap by element, and nothing that clips or layers on purpose', { skip: !browser && 'no system Edge or Chrome' }, async () => {
|
|
48
|
+
const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
|
49
|
+
const page = await ctx.newPage();
|
|
50
|
+
await page.setContent(PAGE);
|
|
51
|
+
await page.evaluate(() => { document.getElementById('rows').scrollTop = 30; });
|
|
52
|
+
const facts = await page.evaluate(lib.factsInPage, {});
|
|
53
|
+
await ctx.close();
|
|
54
|
+
|
|
55
|
+
assert.equal(facts.textFit.length, 2, facts.textFit.join('\n'));
|
|
56
|
+
assert.match(facts.textFit.find((f) => f.startsWith('#spills')) || '', /^#spills "averyvery.*" \d+px past its box$/);
|
|
57
|
+
assert.match(facts.textFit.find((f) => f.startsWith('#over')) || '', /^#over > text "WWWW.*" \d+px past its card$/);
|
|
58
|
+
assert.deepEqual(facts.overlaps.map((o) => o.replace(/ \d+x\d+$/, '')), ['#mSec > div:nth-of-type(1) ∩ #mSec > div:nth-of-type(2)']);
|
|
59
|
+
|
|
60
|
+
const problems = lib.auditFloors(facts, { width: 1280 });
|
|
61
|
+
assert.ok(problems.some((p) => p.startsWith('text-fit: ') && p.includes('#spills') && p.includes('#over > text')));
|
|
62
|
+
assert.ok(problems.some((p) => p.startsWith('sibling-overlap: #mSec > div:nth-of-type(1) ∩ #mSec > div:nth-of-type(2)')));
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('an overlap among <body>\'s own children is caught too — the one-cell grid one level up', { skip: !browser && 'no system Edge or Chrome' }, async () => {
|
|
66
|
+
const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
|
67
|
+
const page = await ctx.newPage();
|
|
68
|
+
await page.setContent(`<!doctype html><html><head><style>
|
|
69
|
+
body { margin: 0; display: grid; grid-template-columns: 1fr; }
|
|
70
|
+
body > div { grid-column: 1; grid-row: 1; }
|
|
71
|
+
</style></head><body><div id="first">the first child's text</div><div id="second">a second child's text</div></body></html>`);
|
|
72
|
+
const facts = await page.evaluate(lib.factsInPage, {});
|
|
73
|
+
await ctx.close();
|
|
74
|
+
assert.deepEqual(facts.overlaps.map((o) => o.replace(/ \d+x\d+$/, '')), ['#first ∩ #second']);
|
|
75
|
+
});
|
package/tests/visual_review.mjs
CHANGED
|
@@ -75,6 +75,7 @@ await test('a task that names a page file in its description is told to use /des
|
|
|
75
75
|
assert.match(lines.join('\n'), /\/design/);
|
|
76
76
|
assert.match(lines.join('\n'), /review-sheet\.js/);
|
|
77
77
|
assert.match(lines.join('\n'), /OK/);
|
|
78
|
+
assert.match(lines.join('\n'), /\/ship-check renders the pages you changed/, 'the automatic fit check is named (task 1004203)');
|
|
78
79
|
});
|
|
79
80
|
|
|
80
81
|
await test('touches[], a UI module label, and the artist craft nudge too — the same predicate the review uses', () => {
|