@bongos/core 1.19.395 → 1.19.397
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 +139 -24
- package/.claude/skills/design/SKILL.md +1 -1
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/api/openapi.json +4 -0
- package/docs/api-reference.md +1 -1
- package/docs/copy-inventory.md +10 -8
- package/docs/copy-registry.json +29 -11
- package/docs/file-map.md +1 -1
- package/docs/recipes/ui-look-before-you-ship.md +113 -0
- package/modules/hall-ui/public/my-projects.css +22 -0
- package/modules/hall-ui/public/my-projects.html +7 -1
- package/modules/hall-ui/public/my-projects.js +17 -11
- package/modules/hall-ui/public/ranks.states.json +15 -0
- package/modules/platform-identity/routes/my-projects.js +50 -3
- package/modules/ui-design/kit/README.md +12 -2
- package/modules/ui-design/kit/check-mock.js +147 -0
- package/modules/ui-design/kit/contrast.js +156 -0
- package/modules/ui-design/kit/fixtures/cost-summary.json +1 -0
- package/modules/ui-design/kit/fixtures/featured.json +1 -0
- package/modules/ui-design/kit/fixtures/leaderboard.json +1 -0
- package/modules/ui-design/kit/fixtures/me.json +9 -0
- package/modules/ui-design/kit/fixtures/my-projects.json +1 -0
- package/modules/ui-design/kit/fixtures/progress.json +1 -0
- package/modules/ui-design/kit/fixtures/recent-shipped.json +1 -0
- package/modules/ui-design/kit/lib.js +389 -0
- package/modules/ui-design/kit/png.js +51 -0
- package/modules/ui-design/kit/probe.js +100 -0
- package/modules/ui-design/kit/render.js +137 -0
- package/modules/ui-design/kit/serve.js +178 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/index.probes.json +37 -0
- package/public-landing/index.states.json +16 -0
- package/public-landing/projects.probes.json +79 -0
- package/public-landing/projects.states.json +25 -0
- package/scripts/gds/ui-kit-transform.js +43 -0
- package/src/bongos/module-scope-map.js +1 -0
- package/src/module-api.js +5 -1
- package/tests/join_application_note.mjs +112 -0
- package/tests/ui_design_kit.mjs +217 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# Look before you ship — the ui-design kit
|
|
2
|
+
|
|
3
|
+
> The `ui-design` module's kit (`modules/ui-design/kit/`, [task 1003320](https://cloudbongos.com/builders#/task/1003320), [ADR 0197](../adr/<redacted>.md)): a stub, a renderer/auditor, the interaction probes, the mock checker and the contrast probe any builder points at any surface of any instance. Plain Node; the browser is the system Edge or Chrome (nothing is downloaded). The `/design` playbook sends you here.
|
|
4
|
+
|
|
5
|
+
The loop is five commands and one habit: **stub → render → probe → read the shots → fix in one batch → re-run once.** A surface describes itself in two small JSON files beside it; the kit never hardcodes a page.
|
|
6
|
+
|
|
7
|
+
## 0. Once per machine
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
npm install # playwright-core is a devDependency; no browser download
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Edge or Chrome must be installed. If neither launches, every kit command prints one line saying so and exits 3.
|
|
14
|
+
|
|
15
|
+
## 1. Stub — serve the surface the way the instance would
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
node modules/ui-design/kit/serve.js # public-landing at http://127.0.0.1:3412/
|
|
19
|
+
AUTH=1 STUB_PORT=3413 node modules/ui-design/kit/serve.js # the same, signed in (/me answers { builder })
|
|
20
|
+
SURFACE=hall-ui PREFIX=/builders/ node modules/ui-design/kit/serve.js
|
|
21
|
+
CHILD=1 node modules/ui-design/kit/serve.js # the child-instance shape (apex-only regions stripped)
|
|
22
|
+
LIVE=1 node modules/ui-design/kit/serve.js # public feeds proxied to the pack's public origin
|
|
23
|
+
FAIL=featured,progress node modules/ui-design/kit/serve.js # those feeds answer 500
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The stub runs the page through the **instance's real transforms** — the apex-only strip, the brand-token stamp, the pack's theme injection, run by the core-side command `scripts/gds/ui-kit-transform.js` (a module may not require core, so the stub spawns the command and memoises the page) — then the content-hash `?v=` stamp, with the instance's own branding pack, and answers the public feeds from `kit/fixtures/`. It also serves `docs/` raw, so a mock under `docs/design/mocks/` renders with its relative links: `http://127.0.0.1:3412/docs/design/mocks/hub/v2.html`.
|
|
27
|
+
|
|
28
|
+
`SURFACE` is a checkout directory (`public-landing`, `modules/hall-ui/public`) or a module key whose manifest declares a `webSurfaces` dir. `PREFIX` is the URL prefix the surface's pages and assets live under (the hall's is `/builders/`).
|
|
29
|
+
|
|
30
|
+
## 2. Render — every state, every width, both modes, audited
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
node modules/ui-design/kit/render.js --page /projects
|
|
34
|
+
node modules/ui-design/kit/render.js --page / # MSYS_NO_PATHCONV=1 in Git Bash, or the kit reads the mangled root back
|
|
35
|
+
node modules/ui-design/kit/render.js --page /builders/ranks --surface hall-ui
|
|
36
|
+
node modules/ui-design/kit/render.js --page /projects --only in-map,card --modes dark --sizes 390
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`render.js` starts its own two stubs (signed out, signed in), renders each state of the page's **states file** in dark + light at 1440×900 / 390×844 / 320×700, drives the state's actions, and audits every render against the **platform floors**:
|
|
40
|
+
|
|
41
|
+
| Floor | What fails it |
|
|
42
|
+
|---|---|
|
|
43
|
+
| `console-errors` | a `console.error` or an uncaught page error (the file's `ignoreConsole` regexes excepted) |
|
|
44
|
+
| `failed-requests` | a 4xx/5xx response the file did not declare in `ignoreRequests` |
|
|
45
|
+
| `horizontal-overflow` | `scrollWidth` wider than the viewport |
|
|
46
|
+
| `one-header-main-h1` | not exactly one *rendered* `<header>`, `<main>` and `<h1>` |
|
|
47
|
+
| `controls-24px` | a visible control under 24px on either side (`ignoreControls` for the skip link and the like) |
|
|
48
|
+
| `top-bar-80px` | a `<header>` taller than 80px |
|
|
49
|
+
| `reduced-motion` | under `prefers-reduced-motion` an animation still runs or a `requestAnimationFrame` loop ticks |
|
|
50
|
+
|
|
51
|
+
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.
|
|
52
|
+
|
|
53
|
+
### The states file — `<surface>/<page>.states.json`
|
|
54
|
+
|
|
55
|
+
```json
|
|
56
|
+
{
|
|
57
|
+
"page": "/projects",
|
|
58
|
+
"surface": "public-landing",
|
|
59
|
+
"modeQuery": "mode",
|
|
60
|
+
"ignoreRequests": ["/api/(gds|bongos)/me$", "/my-projects"],
|
|
61
|
+
"ignoreConsole": ["\\b401\\b"],
|
|
62
|
+
"ignoreControls": [".skip", ".vh"],
|
|
63
|
+
"states": {
|
|
64
|
+
"out-map": { "auth": false, "expect": { "expanded": false } },
|
|
65
|
+
"in-map-projects": { "auth": true, "actions": [["click", "#projTrig"], ["wait", 350]], "expect": { "expanded": true, "open": "#menuProjects" } },
|
|
66
|
+
"card": { "auth": true, "actions": [["dispatchClick", "#orbField a.worldOrb:not(#selfOrb)"], ["wait", 600]], "expect": { "dialog": true } }
|
|
67
|
+
},
|
|
68
|
+
"reducedMotion": { "auth": true, "actions": [["click", "#projTrig"], ["wait", 100]] }
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Actions: `click`, `focus`, `press`, `fill`, `wait`, `clickAt x y`, `clickIfVisible`, `dispatchClick`, `check`, `back`, `forward`. Expectations: `expanded`, `dialog`, `visible[]`, `hidden[]`, `open`, `closed`, `activeId`, `activeText`, `expandedIds[]`, `param{}`, `attr[[sel,name,value]]`, `text[[sel,value]]`, `href[[sel,value]]`, `count[[sel,n]]`, `value[[sel,v]]`, `hasClass[[sel,class,bool]]`, `checked`, `noOverflow`. The kit validates the file before it opens a browser and names every problem.
|
|
73
|
+
|
|
74
|
+
The worked examples: `public-landing/projects.states.json` (the hub's thirteen), `public-landing/index.states.json` (the landing's four), `modules/hall-ui/public/ranks.states.json` (a second surface under its own prefix — it renders **red**, deliberately: the hall predates the 24px floor and Ranks has no `<h1>`, and the states file declares the shell's un-fixtured feeds rather than hiding the findings; idea 1000863 carries them).
|
|
75
|
+
|
|
76
|
+
## 3. Probe — the interaction contract, as PASS/FAIL lines
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
node modules/ui-design/kit/probe.js --page /projects
|
|
80
|
+
node modules/ui-design/kit/probe.js --page / --only "Escape"
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
A **probes file** (`<surface>/<page>.probes.json`) is a sequence: each probe opens the page (or continues the previous probe's page with `"continue": true`), drives steps from the same action vocabulary, and states what must be true with the same expectations. An uncaught page error during a probe fails it. The disclosure contract ships first — `public-landing/projects.probes.json` (24 probes: Enter opens on the first item, arrows cycle and skip hidden cells, Escape returns focus, Tab out closes, a click outside closes, one band closes the other, the views, the card yields Escape, the fold under 480, signed out nothing throws) and `index.probes.json` (10, the landing).
|
|
84
|
+
|
|
85
|
+
## 4. Read the shots, fix in one batch, re-run once
|
|
86
|
+
|
|
87
|
+
Open the shots directory. Look at the 390 and 320 renders first (the fold, the bar, the bands), then dark, then the states you touched. Fix everything you see in **one** batch — a render pass costs a minute, a change-render-change loop costs the afternoon — then re-run `render.js` and `probe.js` once. Ship the tests with the change: a new interactive behaviour gets a pin in the page's own test file in the same diff.
|
|
88
|
+
|
|
89
|
+
## 5. Two more probes, for a mock and for colour
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
node modules/ui-design/kit/check-mock.js --file docs/design/mocks/hub/v2.html [--states out-map,card] [--keys THESIS,BAR]
|
|
93
|
+
node modules/ui-design/kit/contrast.js --pack
|
|
94
|
+
node modules/ui-design/kit/contrast.js --url http://127.0.0.1:3412/projects --glyphs "#projTrig,.foot a"
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`check-mock.js` runs the mechanical rules over a self-contained mock before anyone looks at it: the **platform's** (no page-local `:root`, page tokens on `body`, every dark rule written twice byte-identical, no literal colour beyond black/white with alpha, no pack colour pinned, the `?state=` contract, a head comment after `<body>`, one header/main/h1 + a skip link, apex-only sentinels balanced, no `innerHTML`, no motion in JavaScript, transitions ≤ 400ms) and the **instance's taste bans**, read from its `DESIGN.md` "Don't" section (an em/en dash, emoji, gradient text, a coloured glow, a box shadow). The em dash ban is Cloud Bongos's, not the platform's: an instance whose `DESIGN.md` never mentions it is not held to it.
|
|
98
|
+
|
|
99
|
+
`contrast.js --pack` measures the instance's own pack — every ink on every ground, light values — with no browser. `--url`/`--file` walks every visible text element in both modes and computes the real WCAG ratio against its *composited* background; `--glyphs` screenshots named elements and reads the glyph pixels against the local ground's median, for text over a moving or textured ground where the stylesheet cannot say what is under it.
|
|
100
|
+
|
|
101
|
+
## When it goes wrong
|
|
102
|
+
|
|
103
|
+
| Symptom | Cause | Do |
|
|
104
|
+
|---|---|---|
|
|
105
|
+
| `no states file beside the surface: …/Git.states.json` | Git Bash turned `--page /` into the MSYS root | the kit reads it back as `/`; or `MSYS_NO_PATHCONV=1 node …` |
|
|
106
|
+
| `no system browser` (exit 3) | neither Edge nor Chrome is installed | install one; the kit never downloads a browser |
|
|
107
|
+
| `failed-requests: 404 …/api/bongos/goals` on a hall page | the shell polls feeds the stub does not fixture | declare them in the states file's `ignoreRequests` (the hall's does) — or add a fixture under `kit/fixtures/` and the route in `serve.js` |
|
|
108
|
+
| `controls-24px: button.trig "builder" 13x44` | the chip sized itself by a missing avatar | the fixture builder's avatar is served by the stub (`/__kit/avatar.svg`); a page that rejects non-http avatars gets it absolutised |
|
|
109
|
+
| a probe times out on `click` | an earlier probe in the chain left the page in another state | fix the earlier FAIL first; chains are sequences |
|
|
110
|
+
|
|
111
|
+
## What the kit is not
|
|
112
|
+
|
|
113
|
+
Not CI: `tests/ui_design_kit.mjs` pins the contract (the floors, the file shapes, the audit, the mock rules, the pack, the exit codes, the stub) without a browser; the renders are yours to run and look at. Not a screenshot-diff tool: it audits against floors and expectations, not against a golden image. Not an instance's taste: everything <redacted> lives in the states/probes files beside Cloud Bongos's pages and in its `DESIGN.md`.
|
|
@@ -37,4 +37,26 @@
|
|
|
37
37
|
background: var(--surface);
|
|
38
38
|
color: var(--ink);
|
|
39
39
|
}
|
|
40
|
+
/* The application's optional note (task 1002330). Sits under the one-click join
|
|
41
|
+
row so the click stays the primary act and the note is plainly skippable. */
|
|
42
|
+
.join-note-label {
|
|
43
|
+
display: block;
|
|
44
|
+
margin: 10px 0 4px;
|
|
45
|
+
color: var(--ink-soft);
|
|
46
|
+
font-size: 12px;
|
|
47
|
+
}
|
|
48
|
+
.join-note {
|
|
49
|
+
display: block;
|
|
50
|
+
width: 100%;
|
|
51
|
+
box-sizing: border-box;
|
|
52
|
+
font: inherit;
|
|
53
|
+
font-size: 13px;
|
|
54
|
+
line-height: 1.4;
|
|
55
|
+
padding: 6px 10px;
|
|
56
|
+
resize: vertical;
|
|
57
|
+
border: 1px solid var(--border-strong);
|
|
58
|
+
border-radius: var(--radius-sm);
|
|
59
|
+
background: var(--surface);
|
|
60
|
+
color: var(--ink);
|
|
61
|
+
}
|
|
40
62
|
.note { margin: 10px 0 0; color: var(--ink-soft); font-size: 13px; min-height: 1.2em; }
|
|
@@ -46,7 +46,13 @@
|
|
|
46
46
|
<input id="join-origin" type="url" placeholder="https://project.cloudbongos.com" autocomplete="off">
|
|
47
47
|
<button id="join-btn" type="button" class="btn-ghost">Request to join</button>
|
|
48
48
|
</div>
|
|
49
|
-
|
|
49
|
+
<!-- An application is one click; the note is the applicant's own words,
|
|
50
|
+
and it is optional on purpose (task 1002330 — lightweight, never an
|
|
51
|
+
owner-defined form). It reaches the project owner's queue. -->
|
|
52
|
+
<label class="join-note-label" for="join-note">Add a note (optional)</label>
|
|
53
|
+
<textarea id="join-note" class="join-note" rows="2" maxlength="400"
|
|
54
|
+
placeholder="Anything the project's owner should know — what you'd like to work on, who you are."></textarea>
|
|
55
|
+
<p id="join-status" class="note"></p>
|
|
50
56
|
</section>
|
|
51
57
|
</main>
|
|
52
58
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// my-projects.js — the signed-in builder's cross-project view (ADR 0141 §3, task
|
|
2
2
|
// #1002112). Fetches /api/gds/my-projects (session cookie, same-origin) and renders
|
|
3
|
-
// owned + building-on lists with self-service leave, plus a join-by-origin form
|
|
3
|
+
// owned + building-on lists with self-service leave, plus a join-by-origin form
|
|
4
|
+
// whose application is one click plus an optional note (task 1002330).
|
|
4
5
|
// All network values are rendered via textContent — never innerHTML.
|
|
5
6
|
(function () {
|
|
6
7
|
'use strict';
|
|
@@ -8,7 +9,8 @@
|
|
|
8
9
|
var buildingEl = document.getElementById('building');
|
|
9
10
|
var joinBtn = document.getElementById('join-btn');
|
|
10
11
|
var joinInput = document.getElementById('join-origin');
|
|
11
|
-
var
|
|
12
|
+
var joinNoteInput = document.getElementById('join-note'); // the applicant's own words (optional)
|
|
13
|
+
var joinStatus = document.getElementById('join-status'); // what the project answered
|
|
12
14
|
|
|
13
15
|
function el(tag, cls) { var e = document.createElement(tag); if (cls) e.className = cls; return e; }
|
|
14
16
|
|
|
@@ -119,27 +121,31 @@
|
|
|
119
121
|
|
|
120
122
|
joinBtn.addEventListener('click', function () {
|
|
121
123
|
var origin = (joinInput.value || '').trim();
|
|
122
|
-
if (!origin) {
|
|
124
|
+
if (!origin) { joinStatus.textContent = 'Enter a project URL.'; return; }
|
|
125
|
+
var note = (joinNoteInput.value || '').trim();
|
|
123
126
|
joinBtn.disabled = true;
|
|
124
|
-
|
|
127
|
+
joinStatus.textContent = 'Requesting…';
|
|
125
128
|
fetch('/api/gds/my-projects/join', {
|
|
126
129
|
method: 'POST',
|
|
127
130
|
headers: { 'Content-Type': 'application/json' },
|
|
128
|
-
|
|
131
|
+
/* note is omitted entirely when blank — an application with nothing written
|
|
132
|
+
is still an application (task 1002330). */
|
|
133
|
+
body: JSON.stringify(note ? { origin: origin, note: note } : { origin: origin }),
|
|
129
134
|
})
|
|
130
135
|
.then(function (r) { return r.json().then(function (b) { return { ok: r.ok, body: b }; }); })
|
|
131
136
|
.then(function (res) {
|
|
132
137
|
joinBtn.disabled = false;
|
|
133
138
|
if (res.ok && res.body && res.body.message) {
|
|
134
|
-
|
|
135
|
-
/* a door refusal rides a 200 (task 1003044): keep the address
|
|
139
|
+
joinStatus.textContent = res.body.message;
|
|
140
|
+
/* a door refusal rides a 200 (task 1003044): keep the address AND the
|
|
141
|
+
note the applicant wrote, and don't repaint as if a request went out */
|
|
136
142
|
var refused = res.body.status === 'invite_only' || res.body.status === 'open_enrollment' || res.body.status === 'members_only';
|
|
137
|
-
if (!refused) { joinInput.value = ''; load(); }
|
|
143
|
+
if (!refused) { joinInput.value = ''; joinNoteInput.value = ''; load(); }
|
|
138
144
|
}
|
|
139
|
-
else if (res.body && res.body.error && res.body.error.code === 'unknown_project') {
|
|
140
|
-
else {
|
|
145
|
+
else if (res.body && res.body.error && res.body.error.code === 'unknown_project') { joinStatus.textContent = 'That project isn’t on the platform.'; }
|
|
146
|
+
else { joinStatus.textContent = 'Could not request access right now.'; }
|
|
141
147
|
})
|
|
142
|
-
.catch(function () { joinBtn.disabled = false;
|
|
148
|
+
.catch(function () { joinBtn.disabled = false; joinStatus.textContent = 'Could not request access right now.'; });
|
|
143
149
|
});
|
|
144
150
|
|
|
145
151
|
load();
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_": "The hall's Ranks page as the SECOND surface through modules/ui-design/kit/render.js (task 1003320): the hall serves under /builders/, signed out and in.",
|
|
3
|
+
"page": "/builders/ranks",
|
|
4
|
+
"surface": "hall-ui",
|
|
5
|
+
"stub": { "prefix": "/builders/" },
|
|
6
|
+
"modeQuery": false,
|
|
7
|
+
"_feeds": "The hall's shell polls goals + inbox + profile feeds on every page; the stub fixtures only the public feeds, so those 404s are declared here, not hidden by the kit. The 24px-floor and h1 findings the render reports are REAL (the hall predates the floor) and are deliberately not ignored.",
|
|
8
|
+
"ignoreRequests": ["/api/(gds|bongos)/me$", "/api/(gds|bongos)/goals", "/api/(gds|bongos)/inbox/", "/api/(gds|bongos)/me/"],
|
|
9
|
+
"ignoreConsole": ["/api/(gds|bongos)/me", "\\b401\\b", "Unauthorized", "404 \\(Not Found\\)", "goals load failed"],
|
|
10
|
+
"states": {
|
|
11
|
+
"out": { "auth": false },
|
|
12
|
+
"in": { "auth": true }
|
|
13
|
+
},
|
|
14
|
+
"reducedMotion": { "auth": true }
|
|
15
|
+
}
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
// project "My Projects" view + self-service join / leave (ADR 0141 §3, task #1002112).
|
|
3
3
|
//
|
|
4
4
|
// GET /my-projects — projects this account builds on, owned grouped apart
|
|
5
|
-
// POST /my-projects/join —
|
|
5
|
+
// POST /my-projects/join — apply to a project: one click plus an optional
|
|
6
|
+
// note in the applicant's own words (task 1002330).
|
|
7
|
+
// Relayed; the project executes and decides.
|
|
6
8
|
// POST /my-projects/:clientId/leave — self-service offboard (reflected to the hub)
|
|
7
9
|
//
|
|
8
10
|
// All gated on a live HUB session (requireBuilder). Authority stays LOCAL: JOIN only
|
|
@@ -44,6 +46,40 @@ function joinRelayOutcome(status, body) {
|
|
|
44
46
|
return { status: outcome, message: RELAY_SENTENCE[outcome] };
|
|
45
47
|
}
|
|
46
48
|
|
|
49
|
+
// The provenance line the project's owner reads when an application arrives
|
|
50
|
+
// through the platform rather than off their own landing page. It is the HUB's
|
|
51
|
+
// sentence, not the applicant's — see relayApplicationNote.
|
|
52
|
+
const RELAY_PROVENANCE = 'via platform My Projects';
|
|
53
|
+
|
|
54
|
+
// What an applicant may write, and what the project will store. The project's
|
|
55
|
+
// public POST /access-requests caps `note` at 500 chars and `display_name` at
|
|
56
|
+
// 100, and refuses anything longer with a 400 — which joinRelayOutcome cannot
|
|
57
|
+
// read, so the hub would tell the applicant the project "could not be reached".
|
|
58
|
+
// A lie about someone else's door because our own field was too long is the bug
|
|
59
|
+
// these two caps exist to prevent: the hub asks for less than the project takes,
|
|
60
|
+
// and clamps anyway.
|
|
61
|
+
const APPLICANT_NOTE_MAX = 400;
|
|
62
|
+
const APPLICANT_NAME_MAX = 100;
|
|
63
|
+
|
|
64
|
+
// relayApplicationNote — the note that reaches the project's queue: the
|
|
65
|
+
// applicant's own words plus where the request came from (task 1002330). An
|
|
66
|
+
// application with nothing written is still an application, so an absent note
|
|
67
|
+
// relays the provenance line alone — exactly what this route sent before the
|
|
68
|
+
// applicant could write anything. Pure; clamped so the composed string always
|
|
69
|
+
// fits the project's 500-char column.
|
|
70
|
+
function relayApplicationNote(note) {
|
|
71
|
+
const written = String(note == null ? '' : note).trim().slice(0, APPLICANT_NOTE_MAX);
|
|
72
|
+
return written ? `${written} (${RELAY_PROVENANCE})` : RELAY_PROVENANCE;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// relayApplicantName — the applicant's display name as the project should show
|
|
76
|
+
// it, or null when they have none (the queue then reads as the bare @login it
|
|
77
|
+
// always did). Clamped to the project's column.
|
|
78
|
+
function relayApplicantName(displayName) {
|
|
79
|
+
const name = String(displayName == null ? '' : displayName).trim().slice(0, APPLICANT_NAME_MAX);
|
|
80
|
+
return name || null;
|
|
81
|
+
}
|
|
82
|
+
|
|
47
83
|
module.exports = function myProjectsRoutes() {
|
|
48
84
|
const router = express.Router();
|
|
49
85
|
|
|
@@ -75,7 +111,10 @@ module.exports = function myProjectsRoutes() {
|
|
|
75
111
|
// stays local). SSRF guard: only relay to an origin that is a REGISTERED, active
|
|
76
112
|
// client — never an arbitrary builder-supplied URL.
|
|
77
113
|
router.post('/my-projects/join', requireBuilder, async (req, res) => {
|
|
78
|
-
if (validateOrRespond(req, res, {
|
|
114
|
+
if (validateOrRespond(req, res, {
|
|
115
|
+
origin: { required: true, type: 'string', maxLength: 512 },
|
|
116
|
+
note: { type: 'string', maxLength: APPLICANT_NOTE_MAX },
|
|
117
|
+
})) return;
|
|
79
118
|
try {
|
|
80
119
|
const client = await pi.getClientByOrigin(req.body.origin, { pool });
|
|
81
120
|
if (!client || client.status !== 'active' || !client.origin) {
|
|
@@ -89,7 +128,11 @@ module.exports = function myProjectsRoutes() {
|
|
|
89
128
|
const r = await fetch(`${client.origin}/api/gds/access-requests`, {
|
|
90
129
|
method: 'POST',
|
|
91
130
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': userAgent() },
|
|
92
|
-
body: JSON.stringify({
|
|
131
|
+
body: JSON.stringify({
|
|
132
|
+
github_login: req.builder.github_login,
|
|
133
|
+
display_name: relayApplicantName(req.builder.display_name),
|
|
134
|
+
note: relayApplicationNote(req.body.note),
|
|
135
|
+
}),
|
|
93
136
|
});
|
|
94
137
|
const body = await r.json().catch(() => null);
|
|
95
138
|
outcome = joinRelayOutcome(r.status, body);
|
|
@@ -164,3 +207,7 @@ module.exports = function myProjectsRoutes() {
|
|
|
164
207
|
return router;
|
|
165
208
|
};
|
|
166
209
|
module.exports.joinRelayOutcome = joinRelayOutcome;
|
|
210
|
+
module.exports.relayApplicationNote = relayApplicationNote;
|
|
211
|
+
module.exports.relayApplicantName = relayApplicantName;
|
|
212
|
+
module.exports.APPLICANT_NOTE_MAX = APPLICANT_NOTE_MAX;
|
|
213
|
+
|
|
@@ -1,3 +1,13 @@
|
|
|
1
|
-
# `modules/ui-design/kit/` —
|
|
1
|
+
# `modules/ui-design/kit/` — look before you ship
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The stub, the renderer/auditor, the interaction probes, the mock checker and the contrast probe any builder points at any surface of any instance ([task 1003320](https://cloudbongos.com/builders#/task/1003320), [ADR 0197](../../../docs/adr/<redacted>.md)). Plain Node; the browser is the system Edge or Chrome via `playwright-core` (a devDependency — nothing is downloaded).
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
node modules/ui-design/kit/serve.js # the stub (SURFACE, PREFIX, AUTH=1, CHILD=1, LIVE=…, FAIL=…, STUB_PORT)
|
|
7
|
+
node modules/ui-design/kit/render.js --page /projects # every state × 1440/390/320 × dark/light, audited against the floors
|
|
8
|
+
node modules/ui-design/kit/probe.js --page /projects # the interaction contract as PASS/FAIL lines
|
|
9
|
+
node modules/ui-design/kit/check-mock.js --file docs/design/mocks/hub/v2.html
|
|
10
|
+
node modules/ui-design/kit/contrast.js --pack | --url <url> [--glyphs sel,sel]
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
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.
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// check-mock.js — the mechanical rule set for a MOCK (a self-contained HTML page
|
|
4
|
+
// under docs/design/mocks/) before anyone looks at it (task 1003320, ADR 0197).
|
|
5
|
+
// Two kinds of rule, kept apart on purpose:
|
|
6
|
+
// • the PLATFORM's — token discipline (no page-local :root, page tokens on
|
|
7
|
+
// body, every dark rule written twice byte-identical, no literal colour
|
|
8
|
+
// beyond black/white with alpha, no pack colour pinned as a literal), the
|
|
9
|
+
// state contract (the mock reads ?state=), the head comment after <body>,
|
|
10
|
+
// one <header>/<main>/<h1> + a skip link, apex-only sentinels balanced, no
|
|
11
|
+
// innerHTML, no motion in JavaScript, state transitions ≤ 400ms;
|
|
12
|
+
// • the INSTANCE's taste bans — read from its DESIGN.md "Don't" section (an
|
|
13
|
+
// em/en dash in copy, emoji, gradient text, a coloured text glow, a box
|
|
14
|
+
// shadow). The em dash ban is Cloud Bongos's, not the platform's: an instance
|
|
15
|
+
// whose DESIGN.md never mentions it is not held to it.
|
|
16
|
+
//
|
|
17
|
+
// node modules/ui-design/kit/check-mock.js --file <mock.html> [--states a,b] [--keys THESIS,BAR] [--design <DESIGN.md>] [--no-taste]
|
|
18
|
+
//
|
|
19
|
+
// Prints PASS/FAIL/WARN per rule; exit 1 on any FAIL, 2 on usage.
|
|
20
|
+
const fs = require('node:fs');
|
|
21
|
+
const path = require('node:path');
|
|
22
|
+
const lib = require('./lib.js');
|
|
23
|
+
|
|
24
|
+
// The taste bans an instance's DESIGN.md may switch on, by the words its "Don't"
|
|
25
|
+
// section uses. Pure: text in, a Set of ban keys out.
|
|
26
|
+
const TASTE_BANS = Object.freeze({
|
|
27
|
+
'em-dash': /em dash|en dash|em-dash|en-dash/i,
|
|
28
|
+
'emoji': /\bemoji\b/i,
|
|
29
|
+
'gradient-text': /gradient text/i,
|
|
30
|
+
'text-glow': /\bglow\b/i,
|
|
31
|
+
'box-shadow': /box[- ]shadow|\bshadow\b/i,
|
|
32
|
+
});
|
|
33
|
+
function readDesignBans(mdText) {
|
|
34
|
+
const bans = new Set();
|
|
35
|
+
const m = String(mdText || '').match(/^###\s*Don'?ts?\b[\s\S]*?(?=^##\s|^###\s|(?![\s\S]))/mi);
|
|
36
|
+
const section = m ? m[0] : '';
|
|
37
|
+
for (const [key, re] of Object.entries(TASTE_BANS)) if (re.test(section)) bans.add(key);
|
|
38
|
+
return bans;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function stripToText(html) {
|
|
42
|
+
return html.replace(/<!--[\s\S]*?-->/g, '').replace(/<script[\s\S]*?<\/script>/gi, '').replace(/<style[\s\S]*?<\/style>/gi, '').replace(/<[^>]+>/g, ' ');
|
|
43
|
+
}
|
|
44
|
+
// Top-level (or nested, when `selectorRe` names an @media) blocks of a stylesheet.
|
|
45
|
+
function cssBlocks(css, selectorRe) {
|
|
46
|
+
const out = [];
|
|
47
|
+
let i = 0;
|
|
48
|
+
while (i < css.length) {
|
|
49
|
+
const open = css.indexOf('{', i);
|
|
50
|
+
if (open < 0) break;
|
|
51
|
+
const selStart = Math.max(css.lastIndexOf('}', open), css.lastIndexOf(';', open)) + 1;
|
|
52
|
+
const sel = css.slice(selStart, open).trim();
|
|
53
|
+
let depth = 1, j = open + 1;
|
|
54
|
+
while (j < css.length && depth > 0) { if (css[j] === '{') depth++; else if (css[j] === '}') depth--; j++; }
|
|
55
|
+
const inner = css.slice(open + 1, j - 1);
|
|
56
|
+
if (selectorRe.test(sel)) out.push({ sel, inner });
|
|
57
|
+
i = /^@(media|supports)/.test(sel) ? open + 1 : j;
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
const norm = (s) => s.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\s+/g, ' ').replace(/\s*([{}:;,])\s*/g, '$1').trim();
|
|
62
|
+
const BLACK_OR_WHITE = /^(000|000000|fff|ffffff|0{8}|f{8}|000000[0-9a-f]{2}|ffffff[0-9a-f]{2})$/;
|
|
63
|
+
|
|
64
|
+
// Pure: the rules over a mock's source. `packColors` = the instance's fifteen
|
|
65
|
+
// (name → value) so a literal equal to a pack colour is caught; `bans` = the
|
|
66
|
+
// instance's taste bans; `states`/`keys` = names the mock must carry.
|
|
67
|
+
function checkMock(html, { bans = new Set(), packColors = {}, states = [], keys = [] } = {}) {
|
|
68
|
+
const raw = String(html).replace(/\r\n/g, '\n');
|
|
69
|
+
const results = [];
|
|
70
|
+
const ok = (rule, pass, detail) => results.push([pass ? 'PASS' : 'FAIL', rule, detail || '']);
|
|
71
|
+
const warn = (rule, detail) => results.push(['WARN', rule, detail || '']);
|
|
72
|
+
const text = stripToText(raw);
|
|
73
|
+
const styles = [...raw.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/gi)].map((m) => m[1]).join('\n');
|
|
74
|
+
const scripts = [...raw.matchAll(/<script[^>]*>([\s\S]*?)<\/script>/gi)].map((m) => m[1]).join('\n');
|
|
75
|
+
const css = styles.replace(/\/\*[\s\S]*?\*\//g, '').replace(/url\([^)]*\)/g, 'url()');
|
|
76
|
+
|
|
77
|
+
// ── token discipline (platform) ──
|
|
78
|
+
const rootBlocks = css.replace(/:root\[data-mode="dark"\]\s+body/g, 'X').replace(/:root:not\(\[data-mode="light"\]\)\s+body/g, 'X');
|
|
79
|
+
ok('no page-local :root block (the pack\'s :root is spliced at the end of <head>)', !/(^|[}\s;]):root\s*(\[[^\]]*\]\s*)?(:not\([^)]*\)\s*)?\{/m.test(rootBlocks.replace(/:root\[data-mode="dark"\]\s*\{/g, 'D{').replace(/:root:not\(\[data-mode="light"\]\)\s*\{/g, 'D{')));
|
|
80
|
+
const bodyBlocks = cssBlocks(styles, /^body$/);
|
|
81
|
+
ok('page tokens declared on body', bodyBlocks.some((b) => /--[\w-]+\s*:/.test(b.inner)), `${bodyBlocks.length} body block(s)`);
|
|
82
|
+
const darkAttr = cssBlocks(styles, /^:root\[data-mode="dark"\]/);
|
|
83
|
+
const darkMedia = cssBlocks(styles, /^@media[^{]*prefers-color-scheme:\s*dark/).map((b) => cssBlocks(b.inner, /^:root:not\(\[data-mode="light"\]\)/)).flat();
|
|
84
|
+
const a = darkAttr.map((b) => norm(b.sel.replace(/^:root\[data-mode="dark"\]/, '') + '{' + b.inner + '}')).sort();
|
|
85
|
+
const m = darkMedia.map((b) => norm(b.sel.replace(/^:root:not\(\[data-mode="light"\]\)/, '') + '{' + b.inner + '}')).sort();
|
|
86
|
+
if (!a.length && !m.length) warn('dark twins: the mock declares no dark rules at all', 'fine for a light-only fragment; a page needs both');
|
|
87
|
+
else ok('every dark rule written twice, byte-identical (attribute block == prefers-color-scheme twin)', a.length > 0 && JSON.stringify(a) === JSON.stringify(m), `${a.length} attribute block(s), ${m.length} media twin(s)`);
|
|
88
|
+
const hexes = [...css.matchAll(/#([0-9a-fA-F]{3,8})\b/g)].map((x) => x[1].toLowerCase()).filter((h) => !BLACK_OR_WHITE.test(h));
|
|
89
|
+
ok('no literal hex colour (the fifteen via var()/color-mix(); black or white with alpha only)', hexes.length === 0, hexes.length ? [...new Set(hexes)].slice(0, 12).join(',') : '');
|
|
90
|
+
const rgbLit = [...css.matchAll(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/g)].filter((x) => !((x[1] === '0' && x[2] === '0' && x[3] === '0') || (x[1] === '255' && x[2] === '255' && x[3] === '255')));
|
|
91
|
+
ok('rgb() literals only as black or white with alpha', rgbLit.length === 0, rgbLit.length ? `${rgbLit.length} found` : '');
|
|
92
|
+
const pinned = Object.entries(packColors).filter(([, v]) => typeof v === 'string' && /^#[0-9a-f]{6}$/i.test(v) && new RegExp(v.replace('#', '#'), 'i').test(css)).map(([k]) => k);
|
|
93
|
+
ok('no pack colour pinned as a literal (a var() of the contract token instead)', pinned.length === 0, pinned.join(','));
|
|
94
|
+
|
|
95
|
+
// ── the taste bans the instance declared ──
|
|
96
|
+
if (bans.has('em-dash')) { const n = (text.match(/[–—]/g) || []).length; ok('taste: no em/en dash in visible copy', n === 0, n ? `${n} found` : ''); }
|
|
97
|
+
if (bans.has('emoji')) { const n = (text.match(/[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}\u{2B50}\u{FE0F}]/gu) || []).length; ok('taste: no emoji in visible copy', n === 0, n ? `${n} found` : ''); }
|
|
98
|
+
if (bans.has('gradient-text')) ok('taste: no gradient text', !/background-clip\s*:\s*text/i.test(css));
|
|
99
|
+
if (bans.has('text-glow')) { const glow = [...css.matchAll(/text-shadow\s*:\s*([^;]+);/g)].map((x) => x[1]).filter((v) => !/^(var\(|none)/.test(v.trim()) && /rgba?\((?!0,\s*0,\s*0)|#(?!000)/.test(v)); ok('taste: no coloured text glow', glow.length === 0, glow.join(' | ')); }
|
|
100
|
+
if (bans.has('box-shadow')) { const sh = [...css.matchAll(/box-shadow\s*:\s*([^;]+);/g)].map((x) => x[1].trim()).filter((v) => !/^(var\(|none|inset|0 0 0 \d+px var\()/.test(v) && !/^0 0 0 0/.test(v)); ok('taste: no box shadow (hairlines only; a 0-spread halo for focus is the exception)', sh.length === 0, sh.join(' | ')); }
|
|
101
|
+
|
|
102
|
+
// ── structure + the state contract (platform) ──
|
|
103
|
+
ok('one <header>', (raw.match(/<header\b/g) || []).length === 1);
|
|
104
|
+
ok('one <main>', (raw.match(/<main\b/g) || []).length === 1);
|
|
105
|
+
ok('one <h1>', (raw.match(/<h1\b/g) || []).length === 1);
|
|
106
|
+
ok('a skip link', /class="[^"]*\bskip\b[^"]*"/.test(raw));
|
|
107
|
+
ok('state contract: the mock reads ?state=', /(searchParams|location\.search)/.test(scripts) && /['"]state['"]/.test(scripts));
|
|
108
|
+
if (states.length) { const missing = states.filter((s) => !new RegExp(`['"\`]${s}['"\`]`).test(scripts) && !new RegExp(`data-state="${s}"`).test(raw)); ok('every state of the contract is named in the script or markup', missing.length === 0, missing.join(', ')); }
|
|
109
|
+
const headComment = (raw.match(/<body[^>]*>\s*<!--([\s\S]*?)-->/) || [])[1];
|
|
110
|
+
ok('a head comment right after <body> (what the mock claims, for the reviewer)', !!headComment && headComment.trim().length > 40);
|
|
111
|
+
if (keys.length && headComment) { const missing = keys.filter((k) => !headComment.includes(k)); ok('the head comment names every required section', missing.length === 0, missing.join(', ')); }
|
|
112
|
+
const sentStart = (raw.match(/<!--apex-only:start-->/g) || []).length, sentEnd = (raw.match(/<!--apex-only:end-->/g) || []).length;
|
|
113
|
+
ok('apex-only sentinels balanced', sentStart === sentEnd, `${sentStart} start / ${sentEnd} end`);
|
|
114
|
+
ok('apex-only sentinels never nested', !/<!--apex-only:start-->(?:(?!<!--apex-only:end-->)[\s\S])*<!--apex-only:start-->/.test(raw));
|
|
115
|
+
const menus = (raw.match(/<button[^>]*aria-expanded="(true|false)"[^>]*aria-controls="[^"]+"|<button[^>]*aria-controls="[^"]+"[^>]*aria-expanded="(true|false)"/g) || []).length;
|
|
116
|
+
if (/aria-expanded/.test(raw)) ok('menus use the disclosure pattern (button aria-expanded + aria-controls)', menus >= 1);
|
|
117
|
+
|
|
118
|
+
// ── motion hygiene (platform: the kill switch rule) ──
|
|
119
|
+
ok('no motion in JavaScript (no requestAnimationFrame, no .animate()', !/requestAnimationFrame|\.animate\(/.test(scripts));
|
|
120
|
+
ok('no innerHTML', !/innerHTML/.test(scripts));
|
|
121
|
+
const durs = [...css.matchAll(/transition\s*:[^;]*?(\d+)ms/g)].map((x) => +x[1]).filter((d) => d > 400);
|
|
122
|
+
ok('state transitions at most 400ms', durs.length === 0, durs.join(','));
|
|
123
|
+
return results;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function main() {
|
|
127
|
+
const args = process.argv.slice(2);
|
|
128
|
+
const file = lib.argOf(args, '--file', null);
|
|
129
|
+
if (!file || lib.hasFlag(args, '--help')) { process.stderr.write('usage: node modules/ui-design/kit/check-mock.js --file <mock.html> [--states a,b] [--keys A,B] [--design <DESIGN.md>] [--no-taste]\n'); process.exit(lib.EXIT.USAGE); }
|
|
130
|
+
if (!fs.existsSync(file)) { process.stderr.write(`check-mock: not found: ${file}\n`); process.exit(lib.EXIT.USAGE); }
|
|
131
|
+
const designPath = lib.argOf(args, '--design', path.join(lib.REPO_ROOT, 'DESIGN.md'));
|
|
132
|
+
let bans = new Set();
|
|
133
|
+
if (!lib.hasFlag(args, '--no-taste')) {
|
|
134
|
+
if (fs.existsSync(designPath)) bans = readDesignBans(fs.readFileSync(designPath, 'utf8'));
|
|
135
|
+
else process.stdout.write(`WARN taste bans off: no DESIGN.md at ${designPath} (the platform rules still apply)\n`);
|
|
136
|
+
}
|
|
137
|
+
let packColors = {};
|
|
138
|
+
try { packColors = lib.packColors(lib.instancePack()); } catch { packColors = {}; }
|
|
139
|
+
const results = checkMock(fs.readFileSync(file, 'utf8'), { bans, packColors, states: lib.listArg(args, '--states', []), keys: lib.listArg(args, '--keys', []) });
|
|
140
|
+
for (const [s, rule, detail] of results) process.stdout.write(`${s.padEnd(4)} ${rule}${detail ? ' (' + detail + ')' : ''}\n`);
|
|
141
|
+
const fails = results.filter((r) => r[0] === 'FAIL').length;
|
|
142
|
+
process.stdout.write(`\n${fails ? `${fails} RULE(S) FAIL` : 'ALL RULES PASS'} (taste bans: ${[...bans].join(', ') || 'none declared'})\n`);
|
|
143
|
+
process.exit(fails ? lib.EXIT.PROBLEMS : lib.EXIT.CLEAN);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (require.main === module) main();
|
|
147
|
+
module.exports = { checkMock, readDesignBans, TASTE_BANS, cssBlocks };
|