@passioncode-ai/fabric-agent-adapter 0.8.3 → 0.9.0

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.
Files changed (23) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +14 -3
  3. package/package.json +1 -1
  4. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  5. package/plugins/fabric-agent-adapter/hooks/hooks.json +17 -0
  6. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
  8. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +21 -8
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/demo.html +110 -0
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +198 -0
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +253 -0
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +28 -0
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/themes.json +350 -0
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +133 -0
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard.md +4 -3
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +7 -2
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +295 -0
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +31 -0
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +437 -0
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +51 -1
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +65 -2
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +21 -3
  23. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
@@ -0,0 +1,133 @@
1
+ # The dashboard kit — `fabric-dashboard/0.1`
2
+
3
+ Fabric Dashboards shows a service's page in a pane beside its sidebar and the agent console, and the
4
+ operator drags the console wider whenever they need it. The page never knows how much room it will
5
+ get. Fabric Agent Contract DEC-0036 (`docs/specification/embedded-dashboard.md`, ED-1…ED-10) says what
6
+ the page promises; the kit in [`../assets/dashboard-kit/`](../assets/dashboard-kit/) keeps those
7
+ promises so a service does not re-derive them.
8
+
9
+ ## Contents
10
+
11
+ - The rules the page keeps
12
+ - Using the kit
13
+ - Themes and the host's appearance
14
+ - Declaring it
15
+ - Proving it
16
+ - Upgrading an existing dashboard
17
+ - When something is missing
18
+
19
+ ## The rules the page keeps
20
+
21
+ | Rule | What it means for the page | How the kit does it |
22
+ |---|---|---|
23
+ | ED-1 floor | usable from 360 CSS px up (or the larger `minWidth` it declares), no sideways document scroll | fluid grids `repeat(auto-fit, minmax(min(100%, N), 1fr))`, no fixed widths, `overflow-wrap` on text |
24
+ | ED-2 below the floor | keeps the floor layout and lets the document scroll | `html { min-width: 360px }` |
25
+ | ED-3 navigation | every destination reachable at every width, never a strip that only scrolls sideways | the list wraps; with the script, links that do not fit move into a **More** menu |
26
+ | ED-4 wide content | tables, logs, code, charts scroll in their own box, which shows it scrolls and takes keyboard focus | `.fd-scroll`: edge shades, a visible scrollbar, `tabindex="0"` and a name added by the script |
27
+ | ED-5 one scroller | the document is the only full-height scroller; sticky chrome ≤ min(120 px, 25 % of the height); anchors land below it; a refresh keeps scroll and focus | `.fd-chrome` sticky; when header + navigation are taller than the budget the header scrolls away and the navigation stays; `scroll-margin-top` from the measured height |
28
+ | ED-6 appearance | follows `prefers-color-scheme`; the host's cookie wins over the page's own choice and hides the page's controls; colour never carries meaning alone | the script reads `fabric_appearance`, sets `data-theme`/`data-scheme`, hides `[data-fd-appearance-control]` |
29
+
30
+ The kit cannot keep two of them for you: **a refresh must keep the scroll position and focus**
31
+ (replace changed regions, never the whole page; see [dashboard principles](dashboard.md) point 3),
32
+ and **meaning never by colour alone** (put the word next to the dot: `.fd-chip` with a `.fd-dot`).
33
+
34
+ ## Using the kit
35
+
36
+ ```bash
37
+ python3 <skill-dir>/scripts/dashboard_kit.py install <the directory your page's static files are served from>
38
+ ```
39
+
40
+ It copies `fabric-dashboard.css` and `fabric-dashboard.js` (each starts with
41
+ `fabric-dashboard-kit@<version>`). Serve both same-origin and link them; nothing is inline:
42
+
43
+ ```html
44
+ <html lang="en" data-fd-appearance="scheme theme">
45
+ <link rel="stylesheet" href="/static/fabric-dashboard.css">
46
+ <link rel="stylesheet" href="/static/app.css"> <!-- the page's own rules, var(--…) only -->
47
+ <script src="/static/fabric-dashboard.js" defer></script>
48
+ ```
49
+
50
+ Never edit the copied files: the page's own rules go in its own stylesheet, consuming the tokens.
51
+ `install` refuses to replace a copy the service changed. The page's skeleton is in
52
+ [`demo.html`](../assets/dashboard-kit/demo.html); the parts:
53
+
54
+ | Part | Markup |
55
+ |---|---|
56
+ | Sticky chrome | `<div class="fd-chrome"><header class="fd-header">…</header><nav class="fd-nav" aria-label="Sections"><ul class="fd-nav-list"><li><a href="#jobs">Jobs</a></li>…</ul></nav></div>` |
57
+ | Page body | `<main class="fd-page">`, sections with `id`s the navigation links to |
58
+ | Grids | `.fd-grid` (cards, 320 px columns), `.fd-grid--tiles` (160 px), `.fd-grid--wide` (480 px); `--fd-col` sets your own |
59
+ | Wide content | `<div class="fd-scroll" data-fd-label="Jobs table"><table class="fd-table">…</table></div>`, `<pre class="fd-scroll">` |
60
+ | Atoms | `.fd-card`, `.fd-tile`, `.fd-chip` + `.fd-dot` + `.fd-s-ok\|warn\|danger\|info`, `.fd-btn`, `.fd-btn--primary`, `.fd-input`, `.fd-select`, `.fd-banner`, `.fd-empty`, `.fd-pairs` |
61
+ | Own appearance controls | `<select data-fd-theme-picker>`, `<select data-fd-scheme-picker>` (the script fills them; hidden while the host owns appearance) |
62
+
63
+ Tokens are the workbench layer (`--bg`, `--panel`, `--panel-2`, `--ink`, `--muted`, `--border`,
64
+ `--border-strong`, `--accent`, `--accent-weak`, `--accent-ink`, the four status pairs) plus `--edge`,
65
+ a border that carries meaning (inputs, menus, scroll boxes) at 3:1. Accent-coloured text sits only on
66
+ `--panel`. Status words stay in `--ink`; the colour is in the dot and the tint.
67
+
68
+ ## Themes and the host's appearance
69
+
70
+ Seven reference themes, each light and dark, all WCAG 2.2 AA (ED-10): `graphite` (default, workbench
71
+ verbatim), `fabric` (PassionCode.ai plum), `paper`, `forest`, `ocean`, `ember`, `contrast`. They are
72
+ generated from `themes.json` into the CSS by `dashboard_kit.py build`; `test/test_dashboard_kit.py`
73
+ measures every pair. A page selects one with `<html data-theme="ocean" data-scheme="dark">`; without
74
+ `data-scheme` it follows the operator's system.
75
+
76
+ The host carries the operator's choice as the cookie `fabric_appearance=v=1&scheme=dark&theme=ocean`
77
+ (ED-8). The script applies it, marks the root `data-fd-host-appearance` and hides the page's own
78
+ controls while it is present; an unknown `theme` is `graphite`. `data-fd-appearance="scheme"` limits
79
+ what the page applies (declare the same in `appearance`). The page re-reads the cookie when it
80
+ changes (`cookieStore`) and when it becomes visible again.
81
+
82
+ ## Declaring it
83
+
84
+ When the page meets ED-1…ED-6, say so in the well-known document (ED-9):
85
+
86
+ ```python
87
+ fs.build_well_known(..., extensions=fs.dashboard_extension()) # minWidth 360, both fields, 7 themes, the kit
88
+ ```
89
+
90
+ Node: `buildWellKnown({ ..., extensions: dashboardExtension() })`. Pass `min_width`/`minWidth` when the
91
+ page needs more than 360 (a host then gives it `max(480, minWidth)`), and `themes` when the page ships
92
+ fewer. A dashboard without the declaration is a *legacy dashboard*: the host still gives it 480 px
93
+ and promises nothing else.
94
+
95
+ ## Proving it
96
+
97
+ ```bash
98
+ node <skill-dir>/scripts/check_dashboard_layout.mjs --service <id>[.<instance>] # signs in with a login code
99
+ node <skill-dir>/scripts/check_dashboard_layout.mjs http://127.0.0.1:<port>/dashboard
100
+ python3 <skill-dir>/scripts/check_service.py <id> # dashboard.declared (ED-9)
101
+ ```
102
+
103
+ The layout probe renders the page in WebKit (Safari's engine; `--browser chromium` on request) at
104
+ the floor minus 40 and at 360, 480, 768 and 1280 px, and reports ED-1…ED-5 per width. It needs
105
+ Playwright where it runs (`npm i -D playwright && npx playwright install webkit`, or `--playwright
106
+ <dir>`); without it every rule is `NOT_RUN`. It does not measure edge shades or that a refresh keeps
107
+ the scroll position: say so in the report. A `NOT_RUN` is not a pass.
108
+
109
+ ## Upgrading an existing dashboard
110
+
111
+ A session that starts in a repository whose dashboard has no kit or an older one gets one line from
112
+ the plugin's SessionStart hook. That line is a notice, not an instruction to act: **tell the
113
+ operator and update only with their go-ahead** — the page is theirs and may be open while you work.
114
+
115
+ 1. `dashboard_kit.py status` names each copy: `current`, `older`, `changed` (edited after it was
116
+ copied) or `unmarked`, and the services built from this repository.
117
+ 2. A page with no kit: move its colour, type and spacing values onto the kit's tokens (its own
118
+ palette becomes a theme in its stylesheet: `:root[data-theme="…"]`), replace fixed widths with the
119
+ kit's grids, put the navigation in `.fd-nav`, wrap wide tables and logs in `.fd-scroll`, remove
120
+ any nested full-height scroller, and drop a scheme toggle the kit's picker replaces.
121
+ 3. A page with an older kit: `dashboard_kit.py install <dir>`. A `changed` copy is refused; move the
122
+ edits into the page's own files, then install (or `--force` when the operator says the edits go).
123
+ 4. Raise `kit` in the declaration (`dashboard_extension()` does it), restart the service, and run
124
+ both probes above. Report the layout table with every `NOT_RUN`.
125
+
126
+ ## When something is missing
127
+
128
+ - **No Python for the CLI:** copy the two files from `assets/dashboard-kit/` by hand and compare the
129
+ first line's version with `kit.json`.
130
+ - **The page is built by a framework:** import the two files as static assets; keep them unbundled
131
+ or keep the version marker comment, so `status` can still read the version.
132
+ - **The page needs a fixed width:** not decided by the profile; declare the `minWidth` it needs
133
+ (up to 1280) and say why in the README.
@@ -23,9 +23,10 @@ service are not.
23
23
  when it returns.
24
24
  7. **Approvals show the diff and its hash.** Any write the operator approves shows
25
25
  before → after and the hash the service will verify at apply time.
26
- 8. **Theme.** Follow the operator's product design system (PassionCode.ai tokens for
27
- Fabric-family services); respect `prefers-color-scheme` unless the product fixes a
28
- theme; never encode meaning by colour alone.
26
+ 8. **Theme and width.** Build the page from the dashboard kit: its seven AA themes, light and
27
+ dark, follow `prefers-color-scheme` and the host's `fabric_appearance` cookie, and its layout
28
+ holds from 360 CSS px inside a host pane (`fabric-dashboard/0.1`, DEC-0036;
29
+ [the dashboard kit reference](dashboard-kit.md)). Never encode meaning by colour alone.
29
30
  9. **Links into the page are stable.** Event `link`s point at hash routes that survive
30
31
  a reload (`/dashboard#/approvals/77`) so a notification opens the exact item.
31
32
  10. **No second browser tab required.** With Fabric Dashboards installed, the page is
@@ -1,8 +1,8 @@
1
1
  # fabric-service/0.1 — wire reference
2
2
 
3
- Pinned to `fabric-agent-contract` commit `52da526d7cc1e063d74c5b520dbc03d0ace15246`
3
+ Pinned to `fabric-agent-contract` commit `a83bec6b47776af688402c18efec507143061f68`
4
4
  (`docs/specification/service.md`, DEC-0015; the usage report DEC-0021; Windows and Linux supervision
5
- DEC-0032 and DEC-0033; the operator channel DEC-0034 and DEC-0035). The contract's schemas are normative;
5
+ DEC-0032 and DEC-0033; the operator channel DEC-0034 and DEC-0035; the embedded dashboard DEC-0036). The contract's schemas are normative;
6
6
  this page is the working summary. Extension key:
7
7
  `https://fabric.passioncode.ai/agent-contract/extensions/service/0.1`.
8
8
 
@@ -54,6 +54,11 @@ No auth; the Host/Origin guard applies; answered from memory in under 100 ms.
54
54
  `operatorChannel.path` (DEC-0034) when the service has an operator channel — its status
55
55
  document follows the contract's `operator-channel-status.schema.json` and is token-protected
56
56
  like the events feed. The kits do not serve it yet.
57
+ - `extensions` MAY carry the embedded dashboard declaration under
58
+ `https://fabric.passioncode.ai/agent-contract/extensions/dashboard/0.1` (`fabric-dashboard/0.1`,
59
+ ED-9): `minWidth` 360…1280 (required), `appearance` ⊆ `scheme`, `theme`, `themes`, `kit`
60
+ (`name@semver`), nothing else. The kits build it with `dashboard_extension()` /
61
+ `dashboardExtension()`; see [the dashboard kit reference](dashboard-kit.md).
57
62
 
58
63
  ## Events page — `GET /fabric/v1/events?after=<cursor>&limit=<n>`
59
64
 
@@ -0,0 +1,295 @@
1
+ #!/usr/bin/env node
2
+ // Layout probe for an embedded dashboard (fabric-dashboard/0.1, Fabric Agent Contract DEC-0036).
3
+ //
4
+ // node check_dashboard_layout.mjs <url> # a page you can open as is
5
+ // node check_dashboard_layout.mjs --service <id>[.<instance>] # a registered service: signs in with a
6
+ // # one-time login code, reads minWidth (ED-9)
7
+ // options: --widths 360,480,768,1280 --floor N --height 800 --browser webkit|chromium|firefox
8
+ // --services-dir DIR --playwright DIR (where node_modules/playwright lives) --json
9
+ //
10
+ // It renders the page in a real browser at each width and measures ED-1 (no sideways document
11
+ // scroll from the floor up), ED-2 (the floor layout kept below the floor), ED-3 (every navigation
12
+ // link visible or inside an open-able menu), ED-4 (wide content in its own keyboard-reachable
13
+ // scroll box) and ED-5 (sticky chrome within min(120 px, a quarter of the height), anchors landing
14
+ // below it, no nested full-height scroller). What it cannot measure is NOT_RUN with the reason,
15
+ // never a pass. WebKit is the default engine (Safari's). Exit 0 when nothing FAILs, 1 when
16
+ // something does, 2 on a usage error. It reads; it never clicks a control of the page.
17
+ import { readFileSync, existsSync } from 'node:fs';
18
+ import { createRequire } from 'node:module';
19
+ import { homedir, platform } from 'node:os';
20
+ import path from 'node:path';
21
+ import { pathToFileURL } from 'node:url';
22
+
23
+ const EXTENSION_KEY = 'https://fabric.passioncode.ai/agent-contract/extensions/dashboard/0.1';
24
+ const RULES = ['ED-1', 'ED-2', 'ED-3', 'ED-4', 'ED-5'];
25
+
26
+ function usage(message) {
27
+ if (message) process.stderr.write(message + '\n');
28
+ process.stderr.write('usage: check_dashboard_layout.mjs <url> | --service <id>[.<instance>] [--widths 360,480,768,1280] [--json]\n');
29
+ process.exit(2);
30
+ }
31
+
32
+ export function parseArgs(argv) {
33
+ const opts = { widths: [360, 480, 768, 1280], height: 800, browser: 'webkit', json: false };
34
+ for (let i = 0; i < argv.length; i++) {
35
+ const a = argv[i];
36
+ const next = () => { if (i + 1 >= argv.length) usage(`${a} needs a value`); return argv[++i]; };
37
+ if (a === '--json') opts.json = true;
38
+ else if (a === '--service') opts.service = next();
39
+ else if (a === '--widths') opts.widths = next().split(',').map((w) => Number(w.trim()));
40
+ else if (a === '--floor') opts.floor = Number(next());
41
+ else if (a === '--height') opts.height = Number(next());
42
+ else if (a === '--browser') opts.browser = next();
43
+ else if (a === '--services-dir') opts.servicesDir = next();
44
+ else if (a === '--playwright') opts.playwright = next();
45
+ else if (a.startsWith('--')) usage(`unknown option ${a}`);
46
+ else if (!opts.url) opts.url = a;
47
+ else usage('one URL at a time');
48
+ }
49
+ if (!opts.url && !opts.service) usage();
50
+ if (opts.widths.some((w) => !Number.isInteger(w) || w < 200 || w > 4000)) usage('widths are whole CSS pixels, 200…4000');
51
+ if (!['webkit', 'chromium', 'firefox'].includes(opts.browser)) usage('browser is webkit, chromium or firefox');
52
+ return opts;
53
+ }
54
+
55
+ function servicesDir(override) {
56
+ if (override) return override;
57
+ if (process.env.FABRIC_SERVICES_DIR) return process.env.FABRIC_SERVICES_DIR;
58
+ if (platform() === 'darwin') return path.join(homedir(), 'Library/Application Support/ai.passioncode.fabric/services');
59
+ if (platform() === 'win32') return path.join(process.env.LOCALAPPDATA || path.join(homedir(), 'AppData/Local'), 'passioncode-fabric', 'services');
60
+ return path.join(process.env.XDG_DATA_HOME || path.join(homedir(), '.local/share'), 'passioncode-fabric/services');
61
+ }
62
+
63
+ async function loadPlaywright(dir) {
64
+ const bases = [dir, process.env.FABRIC_PLAYWRIGHT, process.cwd()].filter(Boolean);
65
+ for (const base of bases) {
66
+ try {
67
+ const req = createRequire(path.join(path.resolve(base), 'noop.js'));
68
+ const mod = await import(pathToFileURL(req.resolve('playwright')).href);
69
+ return mod.webkit ? mod : mod.default;
70
+ } catch { /* try the next place */ }
71
+ }
72
+ try { const mod = await import('playwright'); return mod.webkit ? mod : mod.default; } catch { return null; }
73
+ }
74
+
75
+ // Sign in to a registered service the protocol's way: the token stays in its file and a header;
76
+ // the page gets a one-time code, never the token.
77
+ async function serviceTarget(spec, dir) {
78
+ const [id, instance = 'default'] = spec.split(/\.(.+)/);
79
+ const file = path.join(servicesDir(dir), `${id}.${instance}.json`);
80
+ if (!existsSync(file)) throw new Error(`no descriptor for ${id}.${instance} in ${servicesDir(dir)}`);
81
+ const d = JSON.parse(readFileSync(file, 'utf8'));
82
+ const origin = String(d.origin || '').replace(/\/$/, '');
83
+ const auth = d.auth || {};
84
+ let token = '';
85
+ try { token = readFileSync(auth.tokenFile, 'utf8').trim(); } catch { /* reported below */ }
86
+ const header = auth.header || 'Authorization';
87
+ const value = (auth.scheme || 'Bearer') === 'Bearer' ? `Bearer ${token}` : token;
88
+ const remote = d.placement === 'remote';
89
+ const wkRes = await fetch(`${origin}/.well-known/fabric-service`, { headers: remote && token ? { [header]: value } : {} });
90
+ if (!wkRes.ok) throw new Error(`well-known document answered HTTP ${wkRes.status}`);
91
+ const wk = await wkRes.json();
92
+ const dashboard = wk.surfaces?.dashboard;
93
+ if (!dashboard?.path) throw new Error(`${id}.${instance} declares no surfaces.dashboard`);
94
+ const declared = wk.extensions?.[EXTENSION_KEY];
95
+ let url = origin + dashboard.path;
96
+ if (dashboard.login) {
97
+ if (!token) throw new Error(`no readable token file for ${id}.${instance}`);
98
+ const res = await fetch(`${origin}/fabric/v1/login-code`, { method: 'POST', headers: { [header]: value } });
99
+ if (!res.ok) throw new Error(`login-code answered HTTP ${res.status}`);
100
+ url = origin + (await res.json()).url;
101
+ }
102
+ return { url, declared, name: `${id}.${instance}` };
103
+ }
104
+
105
+ // Runs inside the page. Everything it reports is measured from the rendered layout.
106
+ function measure(floor) {
107
+ const de = document.documentElement;
108
+ const vw = de.clientWidth;
109
+ const vh = window.innerHeight;
110
+ const out = { vw, vh, scrollWidth: de.scrollWidth, nav: { total: 0, unreachable: [] }, boxes: [], past: [], nested: [], chrome: 0, anchor: null };
111
+ const cs = (el) => getComputedStyle(el);
112
+ const scrollsX = (el) => /(auto|scroll)/.test(cs(el).overflowX);
113
+ const clipsX = (el) => cs(el).overflowX !== 'visible';
114
+ const label = (el) => {
115
+ const id = el.id ? '#' + el.id : '';
116
+ const cls = typeof el.className === 'string' && el.className.trim() ? '.' + el.className.trim().split(/\s+/).join('.') : '';
117
+ return (el.tagName.toLowerCase() + id + cls).slice(0, 80);
118
+ };
119
+ const visible = (el) => { const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0 && cs(el).visibility !== 'hidden'; };
120
+
121
+ // ED-3: a link is reachable when it is fully inside the viewport and not clipped by an ancestor,
122
+ // or when it sits in a closed <details> whose summary is reachable, or in a menu a visible
123
+ // aria-controls button opens.
124
+ const inViewport = (el) => {
125
+ const r = el.getBoundingClientRect();
126
+ if (r.width <= 0 || r.height <= 0 || r.left < -1 || r.right > vw + 1) return false;
127
+ for (let a = el.parentElement; a && a !== de; a = a.parentElement) {
128
+ if (!clipsX(a)) continue;
129
+ const ar = a.getBoundingClientRect();
130
+ if (r.left < ar.left - 1 || r.right > ar.right + 1) return false;
131
+ }
132
+ return true;
133
+ };
134
+ const opener = (el) => {
135
+ const details = el.closest('details');
136
+ if (details && !details.open) {
137
+ const summary = details.querySelector(':scope > summary');
138
+ if (summary && inViewport(summary)) return true;
139
+ }
140
+ for (let a = el.parentElement; a; a = a.parentElement) {
141
+ if (!a.id) continue;
142
+ const btn = document.querySelector(`[aria-controls="${CSS.escape(a.id)}"]`);
143
+ if (btn && inViewport(btn)) return true;
144
+ }
145
+ return false;
146
+ };
147
+ for (const nav of document.querySelectorAll('nav, [role="navigation"]')) {
148
+ for (const link of nav.querySelectorAll('a[href]')) {
149
+ out.nav.total++;
150
+ if (!inViewport(link) && !opener(link)) out.nav.unreachable.push((link.textContent || link.getAttribute('href')).trim().slice(0, 40));
151
+ }
152
+ }
153
+
154
+ // ED-4: overflowing scroll boxes must be keyboard-reachable; nothing may poke past the viewport
155
+ // unless it is inside a scroll box or clipped by an ancestor that is itself inside the viewport.
156
+ const all = document.body ? document.body.querySelectorAll('*') : [];
157
+ const FOCUSABLE = 'a[href], button, input, select, textarea, [tabindex]';
158
+ let n = 0;
159
+ for (const el of all) {
160
+ if (++n > 6000) break;
161
+ if (!visible(el)) continue;
162
+ if (scrollsX(el) && el.scrollWidth > el.clientWidth + 1) {
163
+ const keyboard = el.tabIndex >= 0 || !!el.querySelector(FOCUSABLE);
164
+ const named = !(el.tabIndex >= 0 && !el.querySelector(FOCUSABLE)) || !!(el.getAttribute('aria-label') || el.getAttribute('aria-labelledby'));
165
+ out.boxes.push({ el: label(el), keyboard, named });
166
+ }
167
+ const r = el.getBoundingClientRect();
168
+ // Only the right edge: a page cannot scroll to the left of 0, so content parked there (a skip
169
+ // link) makes no sideways scroll; content past the right edge does, or is cut off.
170
+ if (r.right > vw + 1) {
171
+ let held = false;
172
+ for (let a = el.parentElement; a && a !== de && a !== document.body; a = a.parentElement) {
173
+ if (scrollsX(a)) { held = true; break; }
174
+ if (clipsX(a)) { const ar = a.getBoundingClientRect(); if (ar.right <= vw + 1 && ar.left >= -1) { held = true; break; } }
175
+ }
176
+ if (!held && out.past.length < 12) out.past.push(label(el));
177
+ }
178
+ // ED-5: a nested container that takes the viewport's height and scrolls on its own.
179
+ if (/(auto|scroll)/.test(cs(el).overflowY) && el.scrollHeight > el.clientHeight + 1 && el.clientHeight >= vh * 0.9) out.nested.push(label(el));
180
+ }
181
+ // ED-5: the sticky band at the top, measured after scrolling so sticky elements engage.
182
+ const startY = window.scrollY;
183
+ window.scrollTo(0, Math.min(400, de.scrollHeight));
184
+ for (const el of all) {
185
+ const p = cs(el).position;
186
+ if (p !== 'sticky' && p !== 'fixed') continue;
187
+ const r = el.getBoundingClientRect();
188
+ // A stuck element's visible part is what stays pinned; a negative top lets a header scroll away.
189
+ if (r.height > 0 && r.top <= 2 && r.bottom > 0 && r.bottom < vh / 2 + 200) out.chrome = Math.max(out.chrome, Math.round(r.bottom));
190
+ }
191
+ // ED-5: the first in-page navigation anchor must land below the chrome.
192
+ const anchor = [...document.querySelectorAll('nav a[href^="#"], [role="navigation"] a[href^="#"]')]
193
+ .map((a) => document.getElementById(decodeURIComponent(a.getAttribute('href').slice(1)))).find(Boolean);
194
+ if (anchor && de.scrollHeight > vh) {
195
+ anchor.scrollIntoView({ block: 'start' });
196
+ out.anchor = { el: label(anchor), top: Math.round(anchor.getBoundingClientRect().top) };
197
+ }
198
+ window.scrollTo(0, startY);
199
+ out.rootMinWidth = parseFloat(cs(de).minWidth) || 0;
200
+ out.floor = floor;
201
+ return out;
202
+ }
203
+
204
+ export function verdicts(width, floor, m) {
205
+ const rows = [];
206
+ const add = (rule, verdict, evidence) => rows.push({ rule, width, verdict, evidence });
207
+ if (width < floor) {
208
+ add('ED-2', m.scrollWidth >= floor - 1 ? 'PASS' : 'FAIL',
209
+ `below the floor ${floor}: document ${m.scrollWidth} px wide in ${m.vw} px (root min-width ${m.rootMinWidth || 'none'})`);
210
+ return rows;
211
+ }
212
+ add('ED-1', m.scrollWidth <= m.vw + 1 ? 'PASS' : 'FAIL', `document scrollWidth ${m.scrollWidth} in a ${m.vw} px viewport`);
213
+ if (!m.nav.total) add('ED-3', 'NOT_RUN', 'no <nav> or role="navigation" links on the page');
214
+ else add('ED-3', m.nav.unreachable.length ? 'FAIL' : 'PASS',
215
+ m.nav.unreachable.length ? `${m.nav.unreachable.length} of ${m.nav.total} links unreachable: ${m.nav.unreachable.slice(0, 6).join(', ')}` : `${m.nav.total} links reachable`);
216
+ const badBoxes = m.boxes.filter((b) => !b.keyboard || !b.named);
217
+ const problems = [];
218
+ if (m.past.length) problems.push(`past the viewport, not in a scroll box: ${m.past.slice(0, 4).join(', ')}`);
219
+ if (badBoxes.length) problems.push(`scroll box without keyboard access or a name: ${badBoxes.slice(0, 4).map((b) => b.el).join(', ')}`);
220
+ add('ED-4', problems.length ? 'FAIL' : 'PASS', problems.join('; ') || `${m.boxes.length} overflowing box(es), each keyboard-reachable; edge shades not measured`);
221
+ const limit = Math.min(120, m.vh / 4);
222
+ const ed5 = [];
223
+ if (m.chrome > limit) ed5.push(`sticky chrome ${m.chrome} px > ${Math.round(limit)} px`);
224
+ if (m.nested.length) ed5.push(`nested full-height scroller: ${m.nested.slice(0, 3).join(', ')}`);
225
+ if (m.anchor && m.anchor.top < m.chrome - 1) ed5.push(`anchor ${m.anchor.el} lands at ${m.anchor.top} px, under the ${m.chrome} px chrome`);
226
+ add('ED-5', ed5.length ? 'FAIL' : 'PASS', ed5.join('; ') ||
227
+ `sticky chrome ${m.chrome} px ≤ ${Math.round(limit)} px` + (m.anchor ? `; anchor lands at ${m.anchor.top} px` : '; no in-page anchor to jump to') + '; refresh keeps scroll: not measured');
228
+ return rows;
229
+ }
230
+
231
+ function report(rows, opts, target) {
232
+ const failed = rows.filter((r) => r.verdict === 'FAIL').length;
233
+ if (opts.json) {
234
+ process.stdout.write(JSON.stringify({ target, browser: opts.browser, results: rows, failed }, null, 2) + '\n');
235
+ } else {
236
+ for (const r of rows) process.stdout.write(`${r.verdict.padEnd(8)} ${(r.rule + (r.width ? '@' + r.width : '')).padEnd(10)} ${r.evidence}\n`);
237
+ process.stdout.write(`\n${rows.length} rule(s), ${failed} FAIL, ${rows.filter((r) => r.verdict === 'NOT_RUN').length} NOT_RUN\n`);
238
+ }
239
+ return failed ? 1 : 0;
240
+ }
241
+
242
+ async function main(argv) {
243
+ const opts = parseArgs(argv);
244
+ let target = opts.url;
245
+ let floor = opts.floor;
246
+ const notRun = (why) => report(RULES.map((rule) => ({ rule, width: 0, verdict: 'NOT_RUN', evidence: why })), opts, target || opts.service);
247
+ if (opts.service) {
248
+ try {
249
+ const t = await serviceTarget(opts.service, opts.servicesDir);
250
+ target = t.url;
251
+ if (floor === undefined && Number.isInteger(t.declared?.minWidth)) floor = t.declared.minWidth;
252
+ } catch (err) {
253
+ return notRun(`could not open the service's dashboard: ${err.message}`);
254
+ }
255
+ }
256
+ floor = floor ?? 360;
257
+ const pw = await loadPlaywright(opts.playwright);
258
+ if (!pw) return notRun('Playwright is not installed here: npm i -D playwright && npx playwright install webkit (or pass --playwright DIR)');
259
+ let browser;
260
+ try {
261
+ browser = await pw[opts.browser].launch();
262
+ } catch (err) {
263
+ return notRun(`the ${opts.browser} browser does not start: ${String(err.message).split('\n')[0]} (npx playwright install ${opts.browser})`);
264
+ }
265
+ const rows = [];
266
+ try {
267
+ const context = await browser.newContext({ viewport: { width: Math.max(...opts.widths), height: opts.height } });
268
+ const page = await context.newPage();
269
+ const response = await page.goto(target, { waitUntil: 'load' });
270
+ if (response && !response.ok()) {
271
+ return notRun(`the page answered HTTP ${response.status()}`);
272
+ }
273
+ // Measure a settled page: its own data requests done, so a header that grows when the data
274
+ // arrives is not caught between its new size and the script that reacts to it.
275
+ await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});
276
+ const widths = [...new Set([floor - 40, ...opts.widths])].filter((w) => w >= 200).sort((a, b) => a - b);
277
+ for (const width of widths) {
278
+ await page.setViewportSize({ width, height: opts.height });
279
+ await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
280
+ await page.waitForTimeout(250);
281
+ await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
282
+ const m = await page.evaluate(measure, floor);
283
+ rows.push(...verdicts(width, floor, m));
284
+ }
285
+ } catch (err) {
286
+ rows.push({ rule: 'ED-1', width: 0, verdict: 'NOT_RUN', evidence: `the page could not be measured: ${String(err.message).split('\n')[0]}` });
287
+ } finally {
288
+ await browser.close();
289
+ }
290
+ return report(rows, opts, opts.service || target);
291
+ }
292
+
293
+ if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
294
+ main(process.argv.slice(2)).then((code) => process.exit(code), (err) => { process.stderr.write(String(err?.stack || err) + '\n'); process.exit(2); });
295
+ }
@@ -366,6 +366,31 @@ class Probe:
366
366
  self.add("usage.report", "FAIL" if problems else "PASS",
367
367
  "; ".join(problems[:5]) if problems else "%d day(s), unknown cost kept null" % len(days or []))
368
368
 
369
+ # dashboard (fabric-dashboard/0.1, DEC-0036) --------------------------------
370
+ def dashboard_rules(self) -> None:
371
+ wk = self.wk or {}
372
+ if not (wk.get("surfaces") or {}).get("dashboard"):
373
+ self.add("dashboard.declared", "NOT_RUN", "no surfaces.dashboard: nothing to declare")
374
+ return
375
+ extensions = wk.get("extensions") if isinstance(wk.get("extensions"), dict) else {}
376
+ if fs.DASHBOARD_EXTENSION_KEY not in extensions:
377
+ self.add("dashboard.declared", "NOT_RUN", "a legacy dashboard: no extensions dashboard/0.1 declaration (ED-9 SHOULD); "
378
+ "a host still gives it 480 px (ED-7). Build it from the dashboard kit and declare it")
379
+ return
380
+ value = extensions[fs.DASHBOARD_EXTENSION_KEY]
381
+ problems = fs.dashboard_extension_problems(value)
382
+ if problems:
383
+ self.add("dashboard.declared", "FAIL", "; ".join(problems))
384
+ return
385
+ note = "minWidth %d, host floor %d" % (value["minWidth"], max(480, value["minWidth"]))
386
+ if value.get("kit"):
387
+ note += ", " + value["kit"]
388
+ name, _, version = str(value["kit"]).partition("@")
389
+ current = fs.DASHBOARD_KIT.partition("@")[2]
390
+ if name == fs.DASHBOARD_KIT.partition("@")[0] and _semver(version) < _semver(current):
391
+ note += " (kit %s is current: dashboard_kit.py status)" % current
392
+ self.add("dashboard.declared", "PASS", note + "; the page itself: check_dashboard_layout.mjs")
393
+
369
394
  def login_rules(self) -> None:
370
395
  dashboard = ((self.wk or {}).get("surfaces") or {}).get("dashboard")
371
396
  if not dashboard or not dashboard.get("login"):
@@ -696,12 +721,18 @@ class Probe:
696
721
  self.network_rules()
697
722
  self.auth_rules()
698
723
  self.usage_rules()
724
+ self.dashboard_rules()
699
725
  self.login_rules()
700
726
  self.interop_rules()
701
727
  self.lifecycle_rules()
702
728
  return self.results
703
729
 
704
730
 
731
+ def _semver(text: str) -> Tuple[int, int, int]:
732
+ m = re.match(r"^(\d+)\.(\d+)\.(\d+)", text or "")
733
+ return (int(m.group(1)), int(m.group(2)), int(m.group(3))) if m else (0, 0, 0)
734
+
735
+
705
736
  def usage_problems(report: Any, service_id: str, instance: str) -> List[str]:
706
737
  """What makes this not a DEC-0021 usage report of the service: shape, identity and the sums of
707
738
  FAC-SEM-025. An unknown cost must be null; a day's totals are the sums of its models."""