@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.
- package/CHANGELOG.md +49 -0
- package/README.md +14 -3
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/hooks/hooks.json +17 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +21 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/demo.html +110 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +198 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +253 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +28 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/themes.json +350 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +133 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard.md +4 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +7 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +295 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +31 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +437 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +51 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +65 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +21 -3
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md
ADDED
|
@@ -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.
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard.md
CHANGED
|
@@ -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.**
|
|
27
|
-
|
|
28
|
-
|
|
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 `
|
|
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
|
+
}
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py
CHANGED
|
@@ -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."""
|