cbrowser 18.78.0 → 18.80.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/dist/analysis/accessibility-empathy.d.ts.map +1 -1
- package/dist/analysis/accessibility-empathy.js +395 -66
- package/dist/analysis/accessibility-empathy.js.map +1 -1
- package/dist/analysis/agent-ready-audit.d.ts.map +1 -1
- package/dist/analysis/agent-ready-audit.js +78 -0
- package/dist/analysis/agent-ready-audit.js.map +1 -1
- package/dist/analysis/page-understanding.d.ts +139 -1
- package/dist/analysis/page-understanding.d.ts.map +1 -1
- package/dist/analysis/page-understanding.js +179 -12
- package/dist/analysis/page-understanding.js.map +1 -1
- package/dist/artifact-store.d.ts +19 -5
- package/dist/artifact-store.d.ts.map +1 -1
- package/dist/artifact-store.js +41 -11
- package/dist/artifact-store.js.map +1 -1
- package/dist/browser.d.ts +9 -0
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +81 -5
- package/dist/browser.js.map +1 -1
- package/dist/cli.js +55 -10
- package/dist/cli.js.map +1 -1
- package/dist/cognitive/index.d.ts.map +1 -1
- package/dist/cognitive/index.js +3 -2
- package/dist/cognitive/index.js.map +1 -1
- package/dist/config.d.ts +57 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +234 -7
- package/dist/config.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp-server-remote.d.ts.map +1 -1
- package/dist/mcp-server-remote.js +108 -4
- package/dist/mcp-server-remote.js.map +1 -1
- package/dist/mcp-server.d.ts.map +1 -1
- package/dist/mcp-server.js +62 -938
- package/dist/mcp-server.js.map +1 -1
- package/dist/mcp-tools/account-personas.d.ts +41 -0
- package/dist/mcp-tools/account-personas.d.ts.map +1 -0
- package/dist/mcp-tools/account-personas.js +98 -0
- package/dist/mcp-tools/account-personas.js.map +1 -0
- package/dist/mcp-tools/base/analysis-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/analysis-tools.js +12 -2
- package/dist/mcp-tools/base/analysis-tools.js.map +1 -1
- package/dist/mcp-tools/base/artifact-tools.d.ts +3 -0
- package/dist/mcp-tools/base/artifact-tools.d.ts.map +1 -0
- package/dist/mcp-tools/base/artifact-tools.js +78 -0
- package/dist/mcp-tools/base/artifact-tools.js.map +1 -0
- package/dist/mcp-tools/base/audit-tools.d.ts +11 -0
- package/dist/mcp-tools/base/audit-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/audit-tools.js +595 -340
- package/dist/mcp-tools/base/audit-tools.js.map +1 -1
- package/dist/mcp-tools/base/browser-management-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/browser-management-tools.js +18 -0
- package/dist/mcp-tools/base/browser-management-tools.js.map +1 -1
- package/dist/mcp-tools/base/bug-analysis-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/bug-analysis-tools.js +59 -3
- package/dist/mcp-tools/base/bug-analysis-tools.js.map +1 -1
- package/dist/mcp-tools/base/capture-tools.d.ts +37 -0
- package/dist/mcp-tools/base/capture-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/capture-tools.js +267 -6
- package/dist/mcp-tools/base/capture-tools.js.map +1 -1
- package/dist/mcp-tools/base/cognitive-tools.d.ts +12 -0
- package/dist/mcp-tools/base/cognitive-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/cognitive-tools.js +63 -15
- package/dist/mcp-tools/base/cognitive-tools.js.map +1 -1
- package/dist/mcp-tools/base/extraction-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/extraction-tools.js +33 -2
- package/dist/mcp-tools/base/extraction-tools.js.map +1 -1
- package/dist/mcp-tools/base/index.d.ts +2 -0
- package/dist/mcp-tools/base/index.d.ts.map +1 -1
- package/dist/mcp-tools/base/index.js +9 -0
- package/dist/mcp-tools/base/index.js.map +1 -1
- package/dist/mcp-tools/base/marketing-tools.js +3 -3
- package/dist/mcp-tools/base/marketing-tools.js.map +1 -1
- package/dist/mcp-tools/base/persona-comparison-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/persona-comparison-tools.js +529 -40
- package/dist/mcp-tools/base/persona-comparison-tools.js.map +1 -1
- package/dist/mcp-tools/base/site-knowledge-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/site-knowledge-tools.js +36 -3
- package/dist/mcp-tools/base/site-knowledge-tools.js.map +1 -1
- package/dist/mcp-tools/base/values-tools.d.ts +113 -0
- package/dist/mcp-tools/base/values-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/values-tools.js +868 -78
- package/dist/mcp-tools/base/values-tools.js.map +1 -1
- package/dist/mcp-tools/base/visual-testing-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/visual-testing-tools.js +130 -10
- package/dist/mcp-tools/base/visual-testing-tools.js.map +1 -1
- package/dist/mcp-tools/index.d.ts +3 -1
- package/dist/mcp-tools/index.d.ts.map +1 -1
- package/dist/mcp-tools/index.js +3 -28
- package/dist/mcp-tools/index.js.map +1 -1
- package/dist/mcp-tools/persona-creation-tools.d.ts.map +1 -1
- package/dist/mcp-tools/persona-creation-tools.js +363 -20
- package/dist/mcp-tools/persona-creation-tools.js.map +1 -1
- package/dist/mcp-tools/persona-lifecycle-tools.d.ts +3 -0
- package/dist/mcp-tools/persona-lifecycle-tools.d.ts.map +1 -0
- package/dist/mcp-tools/persona-lifecycle-tools.js +406 -0
- package/dist/mcp-tools/persona-lifecycle-tools.js.map +1 -0
- package/dist/mcp-tools/screenshot-utils.d.ts.map +1 -1
- package/dist/mcp-tools/screenshot-utils.js +56 -10
- package/dist/mcp-tools/screenshot-utils.js.map +1 -1
- package/dist/mcp-tools/tier-gate.js +2 -2
- package/dist/mcp-tools/tier-gate.js.map +1 -1
- package/dist/mcp-tools/tool-categories.d.ts.map +1 -1
- package/dist/mcp-tools/tool-categories.js +4 -1
- package/dist/mcp-tools/tool-categories.js.map +1 -1
- package/dist/mcp-tools/ui-resources.d.ts +167 -0
- package/dist/mcp-tools/ui-resources.d.ts.map +1 -0
- package/dist/mcp-tools/ui-resources.js +707 -0
- package/dist/mcp-tools/ui-resources.js.map +1 -0
- package/dist/mcp-tools/widget-kit.d.ts +299 -0
- package/dist/mcp-tools/widget-kit.d.ts.map +1 -0
- package/dist/mcp-tools/widget-kit.js +2034 -0
- package/dist/mcp-tools/widget-kit.js.map +1 -0
- package/dist/mcp-ui-resources.d.ts.map +1 -1
- package/dist/mcp-ui-resources.js +14 -4
- package/dist/mcp-ui-resources.js.map +1 -1
- package/dist/persona-questionnaire.d.ts +69 -1
- package/dist/persona-questionnaire.d.ts.map +1 -1
- package/dist/persona-questionnaire.js +177 -76
- package/dist/persona-questionnaire.js.map +1 -1
- package/dist/persona-scope.d.ts +20 -0
- package/dist/persona-scope.d.ts.map +1 -0
- package/dist/persona-scope.js +59 -0
- package/dist/persona-scope.js.map +1 -0
- package/dist/personas.d.ts +51 -17
- package/dist/personas.d.ts.map +1 -1
- package/dist/personas.js +947 -286
- package/dist/personas.js.map +1 -1
- package/dist/recording/capture-player.d.ts +68 -0
- package/dist/recording/capture-player.d.ts.map +1 -0
- package/dist/recording/capture-player.js +298 -0
- package/dist/recording/capture-player.js.map +1 -0
- package/dist/recording/engine.d.ts +52 -0
- package/dist/recording/engine.d.ts.map +1 -1
- package/dist/recording/engine.js +241 -3
- package/dist/recording/engine.js.map +1 -1
- package/dist/recording/inline-player.d.ts +39 -0
- package/dist/recording/inline-player.d.ts.map +1 -0
- package/dist/recording/inline-player.js +272 -0
- package/dist/recording/inline-player.js.map +1 -0
- package/dist/recording/saliency-overlay.d.ts +133 -0
- package/dist/recording/saliency-overlay.d.ts.map +1 -0
- package/dist/recording/saliency-overlay.js +360 -0
- package/dist/recording/saliency-overlay.js.map +1 -0
- package/dist/recording/types.d.ts +32 -0
- package/dist/recording/types.d.ts.map +1 -1
- package/dist/recording/types.js +46 -0
- package/dist/recording/types.js.map +1 -1
- package/dist/site-model/manager.d.ts +20 -0
- package/dist/site-model/manager.d.ts.map +1 -1
- package/dist/site-model/manager.js +52 -0
- package/dist/site-model/manager.js.map +1 -1
- package/dist/trait-reference.d.ts +26 -2
- package/dist/trait-reference.d.ts.map +1 -1
- package/dist/trait-reference.js +129 -19
- package/dist/trait-reference.js.map +1 -1
- package/dist/types.d.ts +105 -18
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/url-resolution.d.ts +58 -0
- package/dist/url-resolution.d.ts.map +1 -0
- package/dist/url-resolution.js +143 -0
- package/dist/url-resolution.js.map +1 -0
- package/dist/values/big-five-inference.d.ts +121 -0
- package/dist/values/big-five-inference.d.ts.map +1 -0
- package/dist/values/big-five-inference.js +241 -0
- package/dist/values/big-five-inference.js.map +1 -0
- package/dist/values/big-five-values.d.ts +76 -0
- package/dist/values/big-five-values.d.ts.map +1 -0
- package/dist/values/big-five-values.js +152 -0
- package/dist/values/big-five-values.js.map +1 -0
- package/dist/values/index.d.ts +1 -1
- package/dist/values/index.d.ts.map +1 -1
- package/dist/values/index.js +1 -1
- package/dist/values/index.js.map +1 -1
- package/dist/values/persona-completeness.d.ts +91 -0
- package/dist/values/persona-completeness.d.ts.map +1 -0
- package/dist/values/persona-completeness.js +114 -0
- package/dist/values/persona-completeness.js.map +1 -0
- package/dist/values/persona-diff.d.ts +50 -0
- package/dist/values/persona-diff.d.ts.map +1 -0
- package/dist/values/persona-diff.js +126 -0
- package/dist/values/persona-diff.js.map +1 -0
- package/dist/values/persona-values.d.ts +16 -0
- package/dist/values/persona-values.d.ts.map +1 -1
- package/dist/values/persona-values.js +33 -0
- package/dist/values/persona-values.js.map +1 -1
- package/dist/values/schwartz-values.d.ts +9 -1
- package/dist/values/schwartz-values.d.ts.map +1 -1
- package/dist/values/schwartz-values.js +10 -2
- package/dist/values/schwartz-values.js.map +1 -1
- package/dist/values/value-mappings.d.ts +41 -2
- package/dist/values/value-mappings.d.ts.map +1 -1
- package/dist/values/value-mappings.js +65 -8
- package/dist/values/value-mappings.js.map +1 -1
- package/dist/visual/attention-quality.d.ts +27 -0
- package/dist/visual/attention-quality.d.ts.map +1 -1
- package/dist/visual/attention-quality.js +164 -2
- package/dist/visual/attention-quality.js.map +1 -1
- package/dist/visual/attention-transport.d.ts +41 -2
- package/dist/visual/attention-transport.d.ts.map +1 -1
- package/dist/visual/attention-transport.js +98 -26
- package/dist/visual/attention-transport.js.map +1 -1
- package/dist/visual/barrier-overlay-html.d.ts +10 -0
- package/dist/visual/barrier-overlay-html.d.ts.map +1 -1
- package/dist/visual/barrier-overlay-html.js.map +1 -1
- package/dist/visual/cognitive-models.d.ts +109 -13
- package/dist/visual/cognitive-models.d.ts.map +1 -1
- package/dist/visual/cognitive-models.js +166 -3
- package/dist/visual/cognitive-models.js.map +1 -1
- package/dist/visual/cognitive-transport-chain.d.ts +149 -5
- package/dist/visual/cognitive-transport-chain.d.ts.map +1 -1
- package/dist/visual/cognitive-transport-chain.js +558 -30
- package/dist/visual/cognitive-transport-chain.js.map +1 -1
- package/dist/visual/cognitive-transport.d.ts +45 -3
- package/dist/visual/cognitive-transport.d.ts.map +1 -1
- package/dist/visual/cognitive-transport.js +126 -12
- package/dist/visual/cognitive-transport.js.map +1 -1
- package/dist/visual/fixation-corpus.d.ts +52 -0
- package/dist/visual/fixation-corpus.d.ts.map +1 -1
- package/dist/visual/fixation-corpus.js +95 -0
- package/dist/visual/fixation-corpus.js.map +1 -1
- package/dist/visual/llm-relevance.d.ts +193 -0
- package/dist/visual/llm-relevance.d.ts.map +1 -0
- package/dist/visual/llm-relevance.js +480 -0
- package/dist/visual/llm-relevance.js.map +1 -0
- package/dist/visual/perceptual-transport.d.ts +60 -0
- package/dist/visual/perceptual-transport.d.ts.map +1 -1
- package/dist/visual/perceptual-transport.js +228 -19
- package/dist/visual/perceptual-transport.js.map +1 -1
- package/dist/visual/persona-context.d.ts +23 -0
- package/dist/visual/persona-context.d.ts.map +1 -0
- package/dist/visual/persona-context.js +44 -0
- package/dist/visual/persona-context.js.map +1 -0
- package/package.json +2 -1
|
@@ -0,0 +1,2034 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Widget kit for MCP Apps views.
|
|
3
|
+
*
|
|
4
|
+
* An MCP Apps resource is static: the host fetches it once, caches it against
|
|
5
|
+
* its URI, and hydrates it at runtime with the tool result. So a widget cannot
|
|
6
|
+
* be a rendered document -- it has to be a renderer plus a description of what
|
|
7
|
+
* to render, with the data arriving later.
|
|
8
|
+
*
|
|
9
|
+
* That shape is what makes a kit possible. Every widget ships the same chrome
|
|
10
|
+
* (tokens, hero, tables, chips, accordions), the same runtime, and the same
|
|
11
|
+
* hydration and safety rules; what differs per tool is a small declarative
|
|
12
|
+
* spec naming which fields become which blocks. Adding a view is writing a
|
|
13
|
+
* spec, not another copy of 400 lines of CSS that will drift from its
|
|
14
|
+
* siblings by the third one.
|
|
15
|
+
*
|
|
16
|
+
* The alternative -- one hand-written template per tool -- was rejected after
|
|
17
|
+
* the status widget, where the CSS, the ext-apps bundling, the theme handling,
|
|
18
|
+
* the textContent discipline and three separate blank-render fixes would all
|
|
19
|
+
* have had to be repeated verbatim eight more times.
|
|
20
|
+
*/
|
|
21
|
+
import { readFileSync } from "node:fs";
|
|
22
|
+
import { createRequire } from "node:module";
|
|
23
|
+
import { dirname, join } from "node:path";
|
|
24
|
+
import { fileURLToPath } from "node:url";
|
|
25
|
+
import { TRAIT_DEFINITIONS } from "../trait-reference.js";
|
|
26
|
+
/** The MIME the host keys on to render a resource as an interactive view. */
|
|
27
|
+
export const MCP_APP_MIME = "text/html;profile=mcp-app";
|
|
28
|
+
export const widgetUri = (id) => `ui://cbrowser/${id}`;
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Inlined assets
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
const here = () => dirname(fileURLToPath(import.meta.url));
|
|
33
|
+
/**
|
|
34
|
+
* The ext-apps browser bundle, inlined into every widget.
|
|
35
|
+
*
|
|
36
|
+
* Not a CDN import. The iframe CSP blocks esm.sh from fetching the transitive
|
|
37
|
+
* SDK dependencies, and the failure is a blank rectangle whose error appears
|
|
38
|
+
* only in the iframe's own devtools console -- nothing surfaces host-side,
|
|
39
|
+
* which is why an esm.sh import looks correct right up until nothing renders.
|
|
40
|
+
*
|
|
41
|
+
* The replacer is a function, not a string: String.replace interprets
|
|
42
|
+
* $-sequences and the minified bundle is full of them.
|
|
43
|
+
*/
|
|
44
|
+
let extAppsBundle;
|
|
45
|
+
export function getExtAppsBundle() {
|
|
46
|
+
if (extAppsBundle !== undefined)
|
|
47
|
+
return extAppsBundle;
|
|
48
|
+
try {
|
|
49
|
+
const req = createRequire(import.meta.url);
|
|
50
|
+
const raw = readFileSync(req.resolve("@modelcontextprotocol/ext-apps/app-with-deps"), "utf8");
|
|
51
|
+
extAppsBundle = raw.replace(/export\{([^}]+)\};?\s*$/, (_m, body) => "globalThis.ExtApps={" +
|
|
52
|
+
body.split(",").map((pair) => {
|
|
53
|
+
const [local, exported] = pair.split(" as ").map((x) => x.trim());
|
|
54
|
+
return `${exported ?? local}:${local}`;
|
|
55
|
+
}).join(",") + "};");
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
extAppsBundle = "globalThis.ExtApps=undefined;";
|
|
59
|
+
}
|
|
60
|
+
return extAppsBundle;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The cbrowser mark as a data URI, brightened for badge size.
|
|
64
|
+
*
|
|
65
|
+
* The sandbox will not fetch cbrowser.ai, so an external src renders broken.
|
|
66
|
+
* The asset is ~100 rects whose opacity ramps to 0.31 -- deliberate at poster
|
|
67
|
+
* size, and at 27px each dot renders around a pixel tall, so the faint end
|
|
68
|
+
* drops out of the raster entirely. Opacity is floored, fills lifted, and each
|
|
69
|
+
* dot stroked, because at that size a stroke is what gives a dot enough mass
|
|
70
|
+
* to survive rasterisation; brightness alone cannot save something that rounds
|
|
71
|
+
* away. Applied here rather than to the file so the brand asset stays canonical.
|
|
72
|
+
*/
|
|
73
|
+
let logoUri;
|
|
74
|
+
export function getLogoDataUri() {
|
|
75
|
+
if (logoUri !== undefined)
|
|
76
|
+
return logoUri;
|
|
77
|
+
try {
|
|
78
|
+
const raw = readFileSync(join(here(), "..", "..", "assets", "cbrowser-logo.svg"), "utf8")
|
|
79
|
+
.replace(/\s*\n\s*/g, " ");
|
|
80
|
+
const brighten = (c) => Math.round(c + (255 - c) * 0.34);
|
|
81
|
+
const svg = raw
|
|
82
|
+
.replace(/opacity="([\d.]+)"/g, (_m, v) => `opacity="${Math.min(1, 0.62 + parseFloat(v) * 0.38).toFixed(3)}"`)
|
|
83
|
+
.replace(/fill="rgb\((\d+),\s*(\d+),\s*(\d+)\)"/g, (_m, r, g, b) => {
|
|
84
|
+
const c = `rgb(${brighten(+r)},${brighten(+g)},${brighten(+b)})`;
|
|
85
|
+
return `fill="${c}" stroke="${c}" stroke-width="5" stroke-linejoin="round"`;
|
|
86
|
+
})
|
|
87
|
+
.replace(/stdDeviation="5"/, 'stdDeviation="7"')
|
|
88
|
+
.replace(/"/g, "'");
|
|
89
|
+
logoUri = "data:image/svg+xml," + encodeURIComponent(svg).replace(/'/g, "%27");
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
logoUri = "";
|
|
93
|
+
}
|
|
94
|
+
return logoUri;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Soft arcs and an angled facet over the hero, as an inline SVG.
|
|
98
|
+
*
|
|
99
|
+
* preserveAspectRatio=none so one small asset stretches to any hero width.
|
|
100
|
+
* This layering, rather than a flatter ramp, is what reads as a lit surface.
|
|
101
|
+
*/
|
|
102
|
+
const HERO_ART = (() => {
|
|
103
|
+
const svg = "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 800 200' preserveAspectRatio='none'>" +
|
|
104
|
+
"<path d='M0,128 C150,58 330,168 505,96 C645,38 730,78 800,52 L800,200 L0,200 Z' fill='#ffffff' opacity='.10'/>" +
|
|
105
|
+
"<path d='M0,171 C190,116 372,190 548,142 C688,104 754,132 800,116 L800,200 L0,200 Z' fill='#ffffff' opacity='.07'/>" +
|
|
106
|
+
"<path d='M556,0 L800,0 L800,200 L678,200 Z' fill='#ffffff' opacity='.05'/>" +
|
|
107
|
+
"</svg>";
|
|
108
|
+
return "data:image/svg+xml," + encodeURIComponent(svg);
|
|
109
|
+
})();
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// Chrome
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
/**
|
|
114
|
+
* Shared stylesheet.
|
|
115
|
+
*
|
|
116
|
+
* Hero stops are darker than the site's brand tokens on purpose: the white
|
|
117
|
+
* bloom lifts the ground under white text, and the contrast floor is measured
|
|
118
|
+
* off composited pixels rather than these declarations. Horizontal inset never
|
|
119
|
+
* drops below the host card's corner radius, or narrow widths put text back
|
|
120
|
+
* inside the arc.
|
|
121
|
+
*/
|
|
122
|
+
const CSS = `
|
|
123
|
+
:root{
|
|
124
|
+
--ink:var(--color-text-primary,#14161a);
|
|
125
|
+
--sub:var(--color-text-secondary,#606770);
|
|
126
|
+
--line:var(--color-border-default,#e0e3e8);
|
|
127
|
+
--brand:oklch(0.55 0.18 250);
|
|
128
|
+
--brand-2:oklch(0.6 0.18 180);
|
|
129
|
+
--hero-a:oklch(0.415 0.19 258);
|
|
130
|
+
--hero-b:oklch(0.45 0.13 202);
|
|
131
|
+
--accent:var(--color-accent-primary,oklch(0.55 0.18 250));
|
|
132
|
+
--warn:oklch(0.55 0.16 45);
|
|
133
|
+
--ok:oklch(0.62 0.16 150);
|
|
134
|
+
--bad:oklch(0.52 0.19 25);
|
|
135
|
+
--raise:color-mix(in srgb, var(--ink) 4%, transparent);
|
|
136
|
+
--r:var(--border-radius-md,8px);
|
|
137
|
+
--mono:ui-monospace,SFMono-Regular,Menlo,monospace;
|
|
138
|
+
}
|
|
139
|
+
:root.dark{--ink:#e9ebee;--sub:#98a0aa;--line:#333840;
|
|
140
|
+
--brand:oklch(0.65 0.18 250);--brand-2:oklch(0.7 0.15 180);
|
|
141
|
+
--hero-a:oklch(0.385 0.18 258);--hero-b:oklch(0.42 0.12 202);
|
|
142
|
+
--accent:oklch(0.65 0.18 250);--warn:oklch(0.72 0.14 55);--ok:oklch(0.72 0.15 150);
|
|
143
|
+
--bad:oklch(0.68 0.17 25);
|
|
144
|
+
--raise:color-mix(in srgb, #fff 6%, transparent)}
|
|
145
|
+
*{box-sizing:border-box}
|
|
146
|
+
html,body{background:transparent;color:var(--ink)}
|
|
147
|
+
body{margin:0;padding:0;font:15px/1.5 ui-sans-serif,system-ui,-apple-system,sans-serif}
|
|
148
|
+
|
|
149
|
+
.hero{padding:16px 18px 15px;color:#fff}
|
|
150
|
+
.hero.gradient{background:
|
|
151
|
+
radial-gradient(115% 95% at 10% -12%, rgba(255,255,255,.20), transparent 58%),
|
|
152
|
+
radial-gradient(95% 85% at 98% 118%, rgba(0,0,0,.26), transparent 60%),
|
|
153
|
+
url("${HERO_ART}") center/100% 100% no-repeat,
|
|
154
|
+
linear-gradient(118deg,var(--hero-a),var(--hero-b))}
|
|
155
|
+
.hero.solid{background:var(--hero-a)}
|
|
156
|
+
.hero.bare{background:transparent;color:var(--ink);padding-bottom:8px}
|
|
157
|
+
.hrow{display:flex;align-items:center;gap:.6rem}
|
|
158
|
+
.badge{flex:0 0 auto;width:37px;height:37px;border-radius:50%;display:grid;place-items:center;
|
|
159
|
+
background:#14171d;box-shadow:0 0 0 1px rgba(255,255,255,.18), 0 1px 3px rgba(0,0,0,.28)}
|
|
160
|
+
.hero.bare .badge{box-shadow:0 0 0 1px rgba(0,0,0,.12), 0 1px 3px rgba(0,0,0,.14)}
|
|
161
|
+
.badge img{width:27px;height:27px;display:block}
|
|
162
|
+
.htitle{font-size:1.08rem;font-weight:650;letter-spacing:-.01em}
|
|
163
|
+
.health{margin-left:auto;display:inline-flex;align-items:center;gap:.4rem;
|
|
164
|
+
padding:.28rem .6rem;border-radius:999px;background:rgba(255,255,255,.94);
|
|
165
|
+
font:650 .7rem/1 var(--mono);letter-spacing:.09em;color:#1b3a2b}
|
|
166
|
+
.hero.bare .health{background:color-mix(in srgb,var(--ok) 16%,transparent);color:var(--ink)}
|
|
167
|
+
.health .dot{width:7px;height:7px;border-radius:50%;background:var(--ok);flex:0 0 auto}
|
|
168
|
+
.health.bad{color:#4a2410} .health.bad .dot{background:var(--warn)}
|
|
169
|
+
.health.unknown{color:#33383f} .health.unknown .dot{background:#8a9099}
|
|
170
|
+
.hnote{margin:.5rem 0 0;font-size:.79rem;color:#fff}
|
|
171
|
+
.hero.bare .hnote{color:var(--sub)}
|
|
172
|
+
|
|
173
|
+
.subttl{font-size:.8rem;color:rgba(255,255,255,.92);margin:.3rem 0 0}
|
|
174
|
+
.hero.bare .subttl{color:var(--sub)}
|
|
175
|
+
.acts{display:flex;flex-wrap:wrap;gap:.4rem;margin:.75rem 0 0}
|
|
176
|
+
.act{font:600 .74rem/1 ui-sans-serif,system-ui,sans-serif;padding:.42rem .7rem;border-radius:6px;
|
|
177
|
+
border:1px solid rgba(255,255,255,.5);background:rgba(255,255,255,.96);color:#123;
|
|
178
|
+
cursor:pointer;transition:background 150ms ease}
|
|
179
|
+
.act:hover{background:#fff}
|
|
180
|
+
.act:focus-visible{outline:2px solid #fff;outline-offset:2px}
|
|
181
|
+
.hero.bare .act{background:var(--brand);color:#fff;border-color:transparent}
|
|
182
|
+
.facts{display:flex;flex-wrap:wrap;gap:.34rem;margin:.7rem 0 0}
|
|
183
|
+
.fact{display:inline-flex;align-items:baseline;gap:.34rem;padding:.24rem .52rem;
|
|
184
|
+
border:1px solid rgba(255,255,255,.32);border-radius:6px;background:rgba(255,255,255,.10)}
|
|
185
|
+
.fact b{font:650 .82rem/1 var(--mono);font-variant-numeric:tabular-nums;color:#fff}
|
|
186
|
+
.fact span{font-size:.74rem;color:#fff}
|
|
187
|
+
.fact.attn{background:rgba(255,255,255,.94);border-color:transparent}
|
|
188
|
+
.fact.attn b{color:#7a3410} .fact.attn span{color:#5d3520}
|
|
189
|
+
.hero.bare .fact{border-color:var(--line);background:var(--raise)}
|
|
190
|
+
.hero.bare .fact b{color:var(--brand)} .hero.bare .fact span{color:var(--sub)}
|
|
191
|
+
|
|
192
|
+
.body{padding:0 18px 16px}
|
|
193
|
+
.drawer{border-top:1px solid var(--line)}
|
|
194
|
+
.drawer>summary{display:flex;align-items:center;gap:.5rem;padding:.7rem 18px;cursor:pointer;
|
|
195
|
+
list-style:none;user-select:none;font-size:.87rem;font-weight:560}
|
|
196
|
+
.drawer>summary::-webkit-details-marker{display:none}
|
|
197
|
+
.drawer>summary:hover{color:var(--accent)}
|
|
198
|
+
.drawer>summary:focus-visible{outline:2px solid var(--accent);outline-offset:-3px}
|
|
199
|
+
.dcount{margin-left:auto;font:.75rem/1 var(--mono);color:var(--sub)}
|
|
200
|
+
.inner{padding:0 18px 16px}
|
|
201
|
+
|
|
202
|
+
details.sec{border-top:1px solid var(--line)}
|
|
203
|
+
details.sec:first-of-type{border-top:0}
|
|
204
|
+
details.sec>summary{display:flex;align-items:center;gap:.5rem;padding:.55rem 0;cursor:pointer;
|
|
205
|
+
list-style:none;user-select:none}
|
|
206
|
+
details.sec>summary::-webkit-details-marker{display:none}
|
|
207
|
+
details.sec>summary:focus-visible{outline:2px solid var(--accent);outline-offset:-2px;border-radius:4px}
|
|
208
|
+
details.sec>summary:hover .stitle{color:var(--accent)}
|
|
209
|
+
.chev{flex:0 0 auto;width:8px;height:8px;border-right:1.6px solid var(--sub);
|
|
210
|
+
border-bottom:1.6px solid var(--sub);transform:rotate(-45deg);
|
|
211
|
+
transition:transform 180ms cubic-bezier(.22,1,.36,1)}
|
|
212
|
+
details[open]>summary .chev{transform:rotate(45deg)}
|
|
213
|
+
.stitle{font-size:.88rem;font-weight:560;transition:color 150ms ease}
|
|
214
|
+
.scount{margin-left:auto;font:.74rem/1 var(--mono);color:var(--sub);font-variant-numeric:tabular-nums}
|
|
215
|
+
.sbody{padding:0 0 .65rem}
|
|
216
|
+
.btitle{font-size:.88rem;font-weight:560;margin:.9rem 0 .4rem}
|
|
217
|
+
|
|
218
|
+
.scroll{overflow-x:auto;max-width:100%}
|
|
219
|
+
table{border-collapse:collapse;width:100%;font-size:.85rem}
|
|
220
|
+
/* Fixed layout with a capped label column. The label was width:1% plus
|
|
221
|
+
white-space:nowrap -- shrink-to-fit, which works until a label cannot
|
|
222
|
+
shrink. Run info flattens nested keys into "A > B > C" strings, and an
|
|
223
|
+
unwrappable one took the whole row, leaving the value column 10-20px
|
|
224
|
+
wide. Labels wrap now and the split is enforced rather than negotiated. */
|
|
225
|
+
.kv{table-layout:fixed}
|
|
226
|
+
.kv th{width:38%;text-align:left;padding:.24rem .9rem .24rem 0;
|
|
227
|
+
font:.77rem/1.45 var(--mono);color:var(--sub);font-weight:400;vertical-align:top;
|
|
228
|
+
white-space:normal;overflow-wrap:anywhere}
|
|
229
|
+
.kv td{padding:.24rem 0;vertical-align:top;word-break:break-word;overflow-wrap:anywhere}
|
|
230
|
+
/* Below ~30rem a 38/62 split leaves neither side usable: stack instead. */
|
|
231
|
+
@media (max-width:30rem){
|
|
232
|
+
.kv,.kv tbody,.kv tr,.kv th,.kv td{display:block;width:auto}
|
|
233
|
+
.kv th{padding:.24rem 0 0;}
|
|
234
|
+
.kv td{padding:0 0 .35rem;border-bottom:1px solid color-mix(in srgb,var(--line) 45%,transparent)}
|
|
235
|
+
}
|
|
236
|
+
.grid{table-layout:auto}
|
|
237
|
+
.grid thead th{text-align:left;padding:.18rem .7rem .3rem 0;font:.67rem/1.3 var(--mono);
|
|
238
|
+
color:var(--sub);font-weight:400;text-transform:uppercase;letter-spacing:.07em;
|
|
239
|
+
border-bottom:1px solid var(--line);white-space:normal}
|
|
240
|
+
/* Cells wrap. Long URLs and selectors used to push the table past the panel
|
|
241
|
+
and rely on the horizontal scroller to hide it. */
|
|
242
|
+
.grid td{overflow-wrap:anywhere;word-break:break-word;vertical-align:top;max-width:22rem}
|
|
243
|
+
.grid td{padding:.28rem .7rem .28rem 0;border-bottom:1px solid color-mix(in srgb,var(--line) 55%,transparent)}
|
|
244
|
+
.grid tr:last-child td{border-bottom:0}
|
|
245
|
+
.grid tbody tr:hover td{background:var(--raise)}
|
|
246
|
+
.num{text-align:right;font-variant-numeric:tabular-nums;font-family:var(--mono);font-size:.79rem}
|
|
247
|
+
.path{font-family:var(--mono);font-size:.77rem;color:var(--sub);word-break:break-all}
|
|
248
|
+
.chip{display:inline-block;font:.67rem/1 var(--mono);padding:.15rem .36rem;border-radius:3px;
|
|
249
|
+
background:color-mix(in srgb,var(--ok) 18%,transparent);color:color-mix(in srgb,var(--ok) 80%,var(--ink))}
|
|
250
|
+
.chip.no{background:color-mix(in srgb,var(--warn) 18%,transparent);color:color-mix(in srgb,var(--warn) 82%,var(--ink))}
|
|
251
|
+
|
|
252
|
+
/* Findings: severity is encoded as a leading bar and a chip, so the ranking
|
|
253
|
+
reads without parsing the text of every row. */
|
|
254
|
+
.finds{list-style:none;padding:0;margin:0}
|
|
255
|
+
.finds li{display:grid;grid-template-columns:auto 1fr;gap:.55rem;align-items:baseline;
|
|
256
|
+
padding:.4rem 0;border-bottom:1px solid color-mix(in srgb,var(--line) 55%,transparent)}
|
|
257
|
+
.finds li:last-child{border-bottom:0}
|
|
258
|
+
.sev{font:650 .62rem/1 var(--mono);letter-spacing:.07em;text-transform:uppercase;
|
|
259
|
+
padding:.2rem .36rem;border-radius:3px;white-space:nowrap}
|
|
260
|
+
.sev.critical,.sev.blocker{background:color-mix(in srgb,var(--bad) 22%,transparent);color:color-mix(in srgb,var(--bad) 85%,var(--ink))}
|
|
261
|
+
.sev.high,.sev.major,.sev.serious{background:color-mix(in srgb,var(--warn) 22%,transparent);color:color-mix(in srgb,var(--warn) 85%,var(--ink))}
|
|
262
|
+
.sev.medium,.sev.moderate{background:color-mix(in srgb,var(--brand) 18%,transparent);color:color-mix(in srgb,var(--brand) 85%,var(--ink))}
|
|
263
|
+
.sev.low,.sev.minor,.sev.info,.sev.notice{background:var(--raise);color:var(--sub)}
|
|
264
|
+
.fdetail{grid-column:2;font-size:.79rem;color:var(--sub);margin:.15rem 0 0}
|
|
265
|
+
|
|
266
|
+
/* Score: one headline grade, then weighted categories as proportional bars.
|
|
267
|
+
The weight is shown because a 90 in a 15%-weighted category is not the
|
|
268
|
+
same finding as a 90 in a 35%-weighted one. */
|
|
269
|
+
.score{display:flex;align-items:center;gap:.9rem;margin:.2rem 0 .7rem}
|
|
270
|
+
.grade{font:700 2.1rem/1 ui-sans-serif,system-ui,sans-serif;letter-spacing:-.03em}
|
|
271
|
+
.grade.a{color:var(--ok)} .grade.b{color:var(--ok)} .grade.c{color:var(--warn)}
|
|
272
|
+
.grade.d{color:var(--bad)} .grade.f{color:var(--bad)}
|
|
273
|
+
.scoresub{font:.78rem/1.4 var(--mono);color:var(--sub)}
|
|
274
|
+
.cats{list-style:none;padding:0;margin:0}
|
|
275
|
+
.cats li{display:grid;grid-template-columns:1fr auto;gap:.4rem .6rem;padding:.28rem 0}
|
|
276
|
+
.catname{font-size:.82rem}
|
|
277
|
+
.catval{font:.78rem/1 var(--mono);color:var(--sub);font-variant-numeric:tabular-nums}
|
|
278
|
+
.bar{grid-column:1/-1;height:5px;border-radius:3px;background:var(--raise);overflow:hidden}
|
|
279
|
+
.bar i{display:block;height:100%;border-radius:3px;background:var(--brand)}
|
|
280
|
+
|
|
281
|
+
/* Trait meters, matching cbrowser.ai's persona editor: fixed-width right
|
|
282
|
+
aligned label, a pill track with a baseline tick at 0.5 so above- and
|
|
283
|
+
below-average read instantly, and a two-decimal monospace readout. */
|
|
284
|
+
.traits{display:flex;flex-direction:column;gap:.28rem}
|
|
285
|
+
.trait{display:flex;align-items:center;gap:.5rem}
|
|
286
|
+
/* Editor. The dirty state is a left border rather than a colour change on the
|
|
287
|
+
value, so it survives both themes and does not rely on hue alone --
|
|
288
|
+
WCAG 1.4.1: colour is not the only channel carrying the information. */
|
|
289
|
+
.trait.dirty{border-left:3px solid var(--accent);padding-left:.5rem;margin-left:-.75rem}
|
|
290
|
+
.mgrcols{display:flex;gap:1rem;align-items:flex-start;flex-wrap:wrap}
|
|
291
|
+
.mgrlist{flex:0 0 13rem;display:flex;flex-direction:column;gap:.15rem;
|
|
292
|
+
max-height:22rem;overflow-y:auto}
|
|
293
|
+
.mgrpane{flex:1 1 18rem;min-width:0}
|
|
294
|
+
.mgrrow{display:flex;flex-direction:column;align-items:flex-start;gap:.1rem;
|
|
295
|
+
font:inherit;text-align:left;background:none;border:0;border-radius:.375rem;
|
|
296
|
+
padding:.4rem .55rem;cursor:pointer;color:var(--ink)}
|
|
297
|
+
.mgrrow:hover{background:color-mix(in oklch,var(--accent) 12%,transparent)}
|
|
298
|
+
.mgrrow.on{background:color-mix(in oklch,var(--accent) 22%,transparent);
|
|
299
|
+
box-shadow:inset 3px 0 0 var(--accent)}
|
|
300
|
+
.mgrrow:focus-visible{outline:2px solid var(--accent);outline-offset:1px}
|
|
301
|
+
.mgrname{font-size:.85rem;font-weight:600}
|
|
302
|
+
.modetabs{display:flex;gap:.3rem;flex-wrap:wrap;margin:.4rem 0 .5rem}
|
|
303
|
+
.modetab{font:inherit;font-size:.78rem;padding:.3rem .6rem;border-radius:999px;
|
|
304
|
+
border:1px solid var(--line);background:none;color:var(--sub);cursor:pointer}
|
|
305
|
+
.modetab.on{background:var(--accent);border-color:var(--accent);color:#fff}
|
|
306
|
+
.modetab:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
|
307
|
+
.tin{width:100%;box-sizing:border-box;font:inherit;font-size:.85rem;
|
|
308
|
+
padding:.4rem .5rem;border-radius:.375rem;border:1px solid var(--line);
|
|
309
|
+
background:transparent;color:var(--ink);margin-bottom:.5rem}
|
|
310
|
+
.tin:focus-visible{outline:2px solid var(--accent);outline-offset:1px}
|
|
311
|
+
.fields{max-height:20rem;overflow-y:auto}
|
|
312
|
+
.mgrh{font-size:1rem;margin:0 0 .15rem}
|
|
313
|
+
/* Single column on narrow viewports: a 13rem list beside an 18rem pane
|
|
314
|
+
cannot both fit on a phone, and side-by-side would force a horizontal
|
|
315
|
+
scroll on the whole widget. */
|
|
316
|
+
@media (max-width:34rem){ .mgrlist{flex-basis:100%;max-height:11rem} }
|
|
317
|
+
.tedit{flex:1;min-width:6rem;accent-color:var(--accent);height:1.25rem}
|
|
318
|
+
.tedit:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
|
319
|
+
.tval{font-variant-numeric:tabular-nums;font-size:.78rem;width:2.6rem;text-align:right}
|
|
320
|
+
.editbar{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;margin-top:.75rem;
|
|
321
|
+
padding-top:.75rem;border-top:1px solid var(--line)}
|
|
322
|
+
.btn{font:inherit;font-size:.8rem;padding:.35rem .7rem;border-radius:.375rem;
|
|
323
|
+
border:1px solid var(--line);background:var(--accent);color:#fff;cursor:pointer}
|
|
324
|
+
.btn:disabled{opacity:.45;cursor:default}
|
|
325
|
+
.btn.ghost{background:none;color:var(--sub)}
|
|
326
|
+
.btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
|
327
|
+
/* Labels wrap instead of truncating, in a column wide enough to hold a real
|
|
328
|
+
trait name. At .68rem in 5.6rem with nowrap+ellipsis these read
|
|
329
|
+
"Metacognitive...", "Interrupt Reco...", "Fear Of Missin..." -- 10.88px
|
|
330
|
+
text that also would not say which trait the bar belonged to. A label you
|
|
331
|
+
cannot read is not a label. (2026-07-31) */
|
|
332
|
+
.tname{font-size:.78rem;line-height:1.25;color:var(--sub);width:9rem;text-align:right;
|
|
333
|
+
flex:0 0 auto;white-space:normal;overflow-wrap:break-word;hyphens:auto}
|
|
334
|
+
.track{flex:1;height:16px;border-radius:999px;background:var(--raise);position:relative;overflow:hidden}
|
|
335
|
+
.track .base{position:absolute;top:0;bottom:0;left:50%;width:1px;background:color-mix(in srgb,var(--ink) 22%,transparent);z-index:1}
|
|
336
|
+
.track i{display:block;height:100%;border-radius:999px}
|
|
337
|
+
.tval{font:.74rem/1 var(--mono);width:2.4rem;text-align:right;flex:0 0 auto;font-variant-numeric:tabular-nums}
|
|
338
|
+
button.tname{background:none;border:0;padding:0;font:inherit;font-size:.78rem;line-height:1.25;
|
|
339
|
+
color:var(--sub);cursor:help;text-align:right;white-space:normal;overflow-wrap:break-word;
|
|
340
|
+
text-decoration:underline dotted currentColor;text-underline-offset:2px}
|
|
341
|
+
/* Narrow viewports cannot afford 9rem of label AND a readable bar. */
|
|
342
|
+
@media (max-width:30rem){
|
|
343
|
+
.tname,button.tname{width:6.6rem;font-size:.72rem}
|
|
344
|
+
.tval{width:2.1rem;font-size:.7rem}
|
|
345
|
+
}
|
|
346
|
+
button.tname:hover,button.tname:focus-visible{color:var(--ink)}
|
|
347
|
+
button.tname:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:3px}
|
|
348
|
+
/* position:fixed, not absolute: the trait list sits inside scroll and
|
|
349
|
+
details containers, and an absolutely positioned tip would be clipped by
|
|
350
|
+
the first one with overflow set. */
|
|
351
|
+
.tip{position:fixed;z-index:50;max-width:270px;padding:.55rem .65rem;border-radius:7px;
|
|
352
|
+
background:#14171d;color:#f2f4f7;font-size:.76rem;line-height:1.45;
|
|
353
|
+
box-shadow:0 6px 20px rgba(0,0,0,.3);pointer-events:none;opacity:0;transition:opacity 120ms ease}
|
|
354
|
+
.tip.on{opacity:1}
|
|
355
|
+
.tip b{display:block;font-size:.72rem;letter-spacing:.04em;text-transform:uppercase;
|
|
356
|
+
color:#9fb4d8;margin-bottom:.2rem}
|
|
357
|
+
.tip .ends{margin-top:.35rem;color:#aab4c2;font-size:.72rem}
|
|
358
|
+
@media (prefers-reduced-motion:reduce){.tip{transition:none}}
|
|
359
|
+
/* Banded scale. Each band is tinted by its own position on the same
|
|
360
|
+
red-to-green ramp the persona meters use, so a value reads the same way in
|
|
361
|
+
both views, and the active band is raised rather than recoloured -- moving
|
|
362
|
+
the hue would break that correspondence. */
|
|
363
|
+
/* Top margin is the marker's room. Without it the value label collides with
|
|
364
|
+
the block heading above, and the tick ran the full height of the band and
|
|
365
|
+
struck through the label it was pointing at. */
|
|
366
|
+
.chain{margin:1rem 0}
|
|
367
|
+
.chain .seg{display:flex;align-items:center;gap:.5rem;margin:.35rem 0}
|
|
368
|
+
.chain .lbl{flex:0 0 9.5rem;font-size:.8rem;opacity:.85;text-align:right}
|
|
369
|
+
.chain .track{flex:1;height:1.35rem;background:rgba(127,127,127,.16);border-radius:3px;position:relative;overflow:hidden}
|
|
370
|
+
.chain .fill{height:100%;border-radius:3px}
|
|
371
|
+
.chain .num{flex:0 0 3.6rem;font-size:.78rem;font-variant-numeric:tabular-nums;opacity:.8}
|
|
372
|
+
.chain .seg.peak .lbl{font-weight:700;opacity:1}
|
|
373
|
+
.chain .peaktag{font-size:.68rem;letter-spacing:.04em;text-transform:uppercase;padding:.05rem .3rem;border:1px solid currentColor;border-radius:3px;margin-left:.35rem}
|
|
374
|
+
.chain button.seg{width:100%;background:none;border:0;padding:.1rem 0;font:inherit;color:inherit;cursor:pointer;border-radius:4px}
|
|
375
|
+
.chain button.seg:hover .track,.chain button.seg:focus-visible .track{outline:2px solid var(--brand);outline-offset:2px}
|
|
376
|
+
.chain button.seg:focus-visible{outline:2px solid var(--brand);outline-offset:2px}
|
|
377
|
+
.chain button.seg[aria-expanded="true"] .lbl{text-decoration:underline}
|
|
378
|
+
.chain .noov{flex:0 0 auto;font-size:.7rem;opacity:.55;margin-left:.35rem}
|
|
379
|
+
.ovpane{margin:.6rem 0 1rem;padding:.6rem;border:1px solid rgba(127,127,127,.28);border-radius:6px}
|
|
380
|
+
.ovpane img{max-width:100%;height:auto;display:block;border-radius:4px}
|
|
381
|
+
.ovpane .cap{font-size:.8rem;opacity:.8;margin:.45rem 0 0}
|
|
382
|
+
@media (max-width:520px){
|
|
383
|
+
/* The fixed 9.5rem label column took a third of a 393px viewport and left
|
|
384
|
+
the bars as stubs — the one thing the block exists to show. Labels move
|
|
385
|
+
above their track so the bar gets the full width. */
|
|
386
|
+
.chain .seg{flex-wrap:wrap;row-gap:.15rem}
|
|
387
|
+
.chain .lbl{flex:1 1 100%;text-align:left;font-size:.78rem}
|
|
388
|
+
.chain .track{flex:1 1 auto;min-width:0}
|
|
389
|
+
.chain .num{flex:0 0 3.2rem;text-align:right}
|
|
390
|
+
.chain button.seg{text-align:left}
|
|
391
|
+
}
|
|
392
|
+
.amp{margin:1.1rem 0 .3rem;padding-top:.8rem;border-top:1px solid rgba(127,127,127,.22)}
|
|
393
|
+
.amp .seg .lbl{flex:0 0 9.5rem}
|
|
394
|
+
.ampnote{font-size:.8rem;opacity:.75;margin:.45rem 0 0}
|
|
395
|
+
.scale{margin:1.5rem 0 .8rem}
|
|
396
|
+
.bands{display:flex;gap:3px;position:relative}
|
|
397
|
+
.band{flex:1;height:30px;border-radius:5px;display:grid;place-items:center;
|
|
398
|
+
font:.62rem/1 var(--mono);text-transform:uppercase;letter-spacing:.05em;
|
|
399
|
+
color:color-mix(in srgb, var(--ink) 78%, transparent);opacity:.42;
|
|
400
|
+
border:2px solid transparent;overflow:hidden;white-space:nowrap;padding:0 .2rem}
|
|
401
|
+
.band.on{opacity:1;border-color:var(--ink);font-weight:650}
|
|
402
|
+
.marker{position:absolute;top:-17px;transform:translateX(-50%);font:600 .64rem/1 var(--mono);
|
|
403
|
+
color:var(--ink);white-space:nowrap;pointer-events:none}
|
|
404
|
+
/* Stops at the band's top edge rather than crossing it. */
|
|
405
|
+
.marker::after{content:"";display:block;width:2px;height:7px;border-radius:1px;
|
|
406
|
+
background:var(--ink);margin:3px auto 0}
|
|
407
|
+
.behav{list-style:none;padding:0;margin:.6rem 0 0}
|
|
408
|
+
.behav li{position:relative;padding:.22rem 0 .22rem .95rem;font-size:.86rem}
|
|
409
|
+
.behav li::before{content:"";position:absolute;left:.15rem;top:.72rem;
|
|
410
|
+
width:5px;height:5px;border-radius:50%;background:var(--accent)}
|
|
411
|
+
.lvlname{font-size:.8rem;font-weight:600;margin:.7rem 0 0}
|
|
412
|
+
.ovwrap{position:relative;display:block;max-width:100%;border:1px solid var(--line);
|
|
413
|
+
border-radius:6px;overflow:hidden;background:var(--raise)}
|
|
414
|
+
.ovwrap img{display:block;width:100%;height:auto}
|
|
415
|
+
.bx{position:absolute;border:2px solid;border-radius:2px}
|
|
416
|
+
.bxn{position:absolute;top:-9px;left:-9px;min-width:17px;height:17px;
|
|
417
|
+
border-radius:9px;color:#fff;font-size:11px;font-weight:700;line-height:17px;
|
|
418
|
+
text-align:center;padding:0 4px;box-sizing:border-box}
|
|
419
|
+
.lg{list-style:none;display:flex;flex-wrap:wrap;gap:.7rem;padding:0;margin:.5rem 0 0;font-size:.76rem}
|
|
420
|
+
.lg li{display:flex;align-items:center;gap:.35rem;color:var(--sub)}
|
|
421
|
+
.lgnote{font-size:.72rem;color:var(--sub);margin:.35rem 0 0;opacity:.85}
|
|
422
|
+
.fmeta{display:flex;align-items:center;gap:.35rem}
|
|
423
|
+
/* Outlined, not filled. This read color:var(--bg) -- and --bg is defined
|
|
424
|
+
nowhere in this stylesheet, so the declaration was invalid at computed-
|
|
425
|
+
value time and the colour fell back to inherited: dark text on the
|
|
426
|
+
mid-dark --sub fill, in both themes. There is no --bg to define, either:
|
|
427
|
+
the widget body is transparent and sits on the host's background, so the
|
|
428
|
+
only foreground guaranteed to contrast is the one the body already uses.
|
|
429
|
+
Text is --ink on the page itself, which is exactly that. (2026-07-31) */
|
|
430
|
+
.fnum{min-width:17px;height:17px;border-radius:9px;
|
|
431
|
+
background:transparent;color:var(--ink);border:1px solid var(--line);
|
|
432
|
+
font-size:11px;font-weight:700;line-height:15px;text-align:center;
|
|
433
|
+
padding:0 4px;box-sizing:border-box;flex:none}
|
|
434
|
+
.tabs{display:flex;flex-wrap:wrap;gap:.3rem;margin:0 0 .7rem;padding:0;border-bottom:1px solid var(--line)}
|
|
435
|
+
.tab{background:none;border:0;border-bottom:2px solid transparent;padding:.35rem .6rem;
|
|
436
|
+
font:inherit;font-size:.8rem;color:var(--sub);cursor:pointer;border-radius:4px 4px 0 0;
|
|
437
|
+
display:flex;align-items:center;gap:.4rem;max-width:100%;overflow-wrap:anywhere;text-align:left}
|
|
438
|
+
.tab:hover{color:var(--ink)}
|
|
439
|
+
.tab[aria-selected="true"]{color:var(--ink);border-bottom-color:var(--accent);font-weight:600}
|
|
440
|
+
.tab:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
|
441
|
+
.tabn{font:.68rem/1 var(--mono);color:var(--sub);background:var(--raise);
|
|
442
|
+
border-radius:999px;padding:.15rem .35rem;flex:none}
|
|
443
|
+
.tab[aria-selected="true"] .tabn{color:var(--ink)}
|
|
444
|
+
.sw{width:11px;height:11px;border-radius:2px;display:inline-block}
|
|
445
|
+
.offlist{margin:.6rem 0 0;padding:0;list-style:none;font-size:.8rem}
|
|
446
|
+
.offlist li{padding:.18rem 0;color:var(--sub)}
|
|
447
|
+
.shot{max-width:100%;height:auto;display:block;border-radius:6px;border:1px solid var(--line)}
|
|
448
|
+
.cap{font-size:.77rem;color:var(--sub);margin:.35rem 0 0}
|
|
449
|
+
.foot{padding:0 18px 14px;font-size:.77rem;color:var(--sub)}
|
|
450
|
+
.msg{padding:16px 18px;font-size:.85rem;color:var(--sub)}
|
|
451
|
+
@media (max-width:420px){
|
|
452
|
+
.hero{padding:14px 18px 13px}
|
|
453
|
+
.health{letter-spacing:.06em}
|
|
454
|
+
}
|
|
455
|
+
@media (prefers-reduced-motion:reduce){.chev,.stitle{transition:none}}
|
|
456
|
+
`;
|
|
457
|
+
/**
|
|
458
|
+
* Shared runtime.
|
|
459
|
+
*
|
|
460
|
+
* Interprets the spec against structuredContent. Every value is written with
|
|
461
|
+
* textContent and never innerHTML: views are hydrated from tool output that
|
|
462
|
+
* contains strings this server does not control -- paths, page titles, audit
|
|
463
|
+
* findings scraped off third-party sites -- and interpolating those into
|
|
464
|
+
* markup is how a status panel becomes an injection sink.
|
|
465
|
+
*/
|
|
466
|
+
const RUNTIME = String.raw `
|
|
467
|
+
(async () => {
|
|
468
|
+
var SPEC = __SPEC__;
|
|
469
|
+
var LOGO = "__LOGO__";
|
|
470
|
+
var msg = document.getElementById("msg");
|
|
471
|
+
var root = document.getElementById("root");
|
|
472
|
+
if (!globalThis.ExtApps) { msg.textContent = "Widget runtime unavailable."; return; }
|
|
473
|
+
var App = globalThis.ExtApps.App;
|
|
474
|
+
var applyHostStyleVariables = globalThis.ExtApps.applyHostStyleVariables;
|
|
475
|
+
var app = new App({ name: SPEC.id, version: "1.0.0" }, {}, { autoResize: true });
|
|
476
|
+
|
|
477
|
+
var el = function (tag, cls, text) {
|
|
478
|
+
var n = document.createElement(tag);
|
|
479
|
+
if (cls) n.className = cls;
|
|
480
|
+
if (text !== undefined) n.textContent = text;
|
|
481
|
+
return n;
|
|
482
|
+
};
|
|
483
|
+
var at = function (obj, path) {
|
|
484
|
+
if (!path) return undefined;
|
|
485
|
+
return path.split(".").reduce(function (o, k) {
|
|
486
|
+
return (o === null || o === undefined) ? undefined : o[k];
|
|
487
|
+
}, obj);
|
|
488
|
+
};
|
|
489
|
+
var isObjArray = function (v) {
|
|
490
|
+
return Array.isArray(v) && v.length > 0 &&
|
|
491
|
+
v.every(function (x) { return x && typeof x === "object" && !Array.isArray(x); });
|
|
492
|
+
};
|
|
493
|
+
// Floating-point residue is not precision. computeTimeMs arrived as
|
|
494
|
+
// 574.0467359999966 and attentionMismatch as 0.300375; the trailing digits
|
|
495
|
+
// are artefacts of binary floating point, and printing them implies a
|
|
496
|
+
// confidence the measurement does not have. Trait values like 0.25 and
|
|
497
|
+
// integers are untouched.
|
|
498
|
+
var num = function (n) {
|
|
499
|
+
if (!isFinite(n)) return String(n);
|
|
500
|
+
if (Number.isInteger(n)) return String(n);
|
|
501
|
+
return String(Math.round(n * 1e4) / 1e4);
|
|
502
|
+
};
|
|
503
|
+
var fmt = function (v) {
|
|
504
|
+
if (v === true) return "yes";
|
|
505
|
+
if (v === false) return "no";
|
|
506
|
+
if (typeof v === "number") return num(v);
|
|
507
|
+
if (v === "" || v === null || v === undefined) return "—";
|
|
508
|
+
if (Array.isArray(v)) {
|
|
509
|
+
// An array of records has no useful join; String() on it yields a row of
|
|
510
|
+
// "[object Object]" separated by commas.
|
|
511
|
+
if (isObjArray(v)) return v.length + (v.length === 1 ? " entry" : " entries");
|
|
512
|
+
return v.length ? v.join(", ") : "—";
|
|
513
|
+
}
|
|
514
|
+
// Backstop for an object that reached a cell. Naming its fields beats both
|
|
515
|
+
// "[object Object]" (says nothing) and a raw JSON dump (says everything,
|
|
516
|
+
// illegibly, and blows the column width out). The empathy per-persona
|
|
517
|
+
// table carried six such columns -- perceptualTransport, scoreContext,
|
|
518
|
+
// cognitiveLoad and friends -- each printing serialised JSON into a table
|
|
519
|
+
// cell. (2026-07-31)
|
|
520
|
+
if (typeof v === "object") {
|
|
521
|
+
try {
|
|
522
|
+
var keys = Object.keys(v);
|
|
523
|
+
if (!keys.length) return "—";
|
|
524
|
+
var shown = keys.slice(0, 3).map(titleize).join(", ");
|
|
525
|
+
return keys.length > 3 ? shown + " +" + (keys.length - 3) + " more" : shown;
|
|
526
|
+
} catch (_) { return "(unreadable)"; }
|
|
527
|
+
}
|
|
528
|
+
return String(v);
|
|
529
|
+
};
|
|
530
|
+
var titleize = function (k) {
|
|
531
|
+
return k.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/^./, function (c) { return c.toUpperCase(); });
|
|
532
|
+
};
|
|
533
|
+
var used = {};
|
|
534
|
+
var GLOSSARY = __GLOSSARY__;
|
|
535
|
+
|
|
536
|
+
// One tip element reused by every label, created lazily.
|
|
537
|
+
var tip = null;
|
|
538
|
+
function ensureTip() {
|
|
539
|
+
if (tip) return tip;
|
|
540
|
+
tip = el("div", "tip");
|
|
541
|
+
tip.id = "kit-tip";
|
|
542
|
+
tip.setAttribute("role", "tooltip");
|
|
543
|
+
document.body.appendChild(tip);
|
|
544
|
+
return tip;
|
|
545
|
+
}
|
|
546
|
+
function showTip(btn, info) {
|
|
547
|
+
var t = ensureTip();
|
|
548
|
+
t.replaceChildren();
|
|
549
|
+
t.appendChild(el("b", null, info.label));
|
|
550
|
+
t.appendChild(document.createTextNode(info.description || ""));
|
|
551
|
+
if (info.lowEnd || info.highEnd) {
|
|
552
|
+
var e = el("div", "ends");
|
|
553
|
+
if (info.lowEnd) e.appendChild(el("div", null, "0.0 — " + info.lowEnd));
|
|
554
|
+
if (info.highEnd) e.appendChild(el("div", null, "1.0 — " + info.highEnd));
|
|
555
|
+
t.appendChild(e);
|
|
556
|
+
}
|
|
557
|
+
t.classList.add("on");
|
|
558
|
+
var r = btn.getBoundingClientRect();
|
|
559
|
+
var tr = t.getBoundingClientRect();
|
|
560
|
+
// Flip below when there is not room above, and clamp horizontally so the
|
|
561
|
+
// tip never leaves the frame.
|
|
562
|
+
var top = r.top - tr.height - 8;
|
|
563
|
+
if (top < 4) top = r.bottom + 8;
|
|
564
|
+
var left = Math.min(Math.max(4, r.left), Math.max(4, window.innerWidth - tr.width - 4));
|
|
565
|
+
t.style.top = top + "px";
|
|
566
|
+
t.style.left = left + "px";
|
|
567
|
+
}
|
|
568
|
+
function hideTip() { if (tip) tip.classList.remove("on"); }
|
|
569
|
+
document.addEventListener("keydown", function (e) { if (e.key === "Escape") hideTip(); });
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Flatten an object into label/value pairs, following nested objects.
|
|
573
|
+
*
|
|
574
|
+
* kv and rest previously descended exactly one level, so anything deeper
|
|
575
|
+
* reached fmt() as an object and rendered "[object Object]" -- scoreContext
|
|
576
|
+
* is several levels deep, and every branch of it came out as that string.
|
|
577
|
+
*
|
|
578
|
+
* Arrays of records are left intact for the caller to table; arrays of
|
|
579
|
+
* scalars join. Depth-limited so a cyclic or pathological payload cannot
|
|
580
|
+
* hang the view, and the cutoff is reported rather than silently truncating.
|
|
581
|
+
*/
|
|
582
|
+
function flattenPairs(obj, prefix, out, depth) {
|
|
583
|
+
out = out || [];
|
|
584
|
+
depth = depth === undefined ? 4 : depth;
|
|
585
|
+
Object.keys(obj || {}).forEach(function (k) {
|
|
586
|
+
var v = obj[k];
|
|
587
|
+
var label = prefix ? prefix + " › " + titleize(k) : titleize(k);
|
|
588
|
+
if (v && typeof v === "object" && !Array.isArray(v)) {
|
|
589
|
+
if (depth > 0) flattenPairs(v, label, out, depth - 1);
|
|
590
|
+
else out.push([label, "(nested further)"]);
|
|
591
|
+
} else if (isObjArray(v)) {
|
|
592
|
+
out.push([label, v.length + (v.length === 1 ? " entry" : " entries")]);
|
|
593
|
+
} else {
|
|
594
|
+
out.push([label, v]);
|
|
595
|
+
}
|
|
596
|
+
});
|
|
597
|
+
return out;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function kvTable(pairs) {
|
|
601
|
+
var t = el("table", "kv"), tb = el("tbody");
|
|
602
|
+
pairs.forEach(function (p) {
|
|
603
|
+
var tr = el("tr");
|
|
604
|
+
tr.appendChild(el("th", null, p[0]));
|
|
605
|
+
var td = el("td");
|
|
606
|
+
if (/^\//.test(String(p[1]))) td.className = "path";
|
|
607
|
+
td.textContent = fmt(p[1]);
|
|
608
|
+
tr.appendChild(td); tb.appendChild(tr);
|
|
609
|
+
});
|
|
610
|
+
t.appendChild(tb); return t;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function gridTable(rows, only) {
|
|
614
|
+
var cols = [];
|
|
615
|
+
if (only && only.length) {
|
|
616
|
+
// Keep only columns that at least one row actually carries, so a spec
|
|
617
|
+
// naming an optional field does not produce a column of dashes.
|
|
618
|
+
only.forEach(function (k) {
|
|
619
|
+
if (rows.some(function (r) { return r[k] !== undefined; })) cols.push(k);
|
|
620
|
+
});
|
|
621
|
+
} else {
|
|
622
|
+
rows.forEach(function (r) {
|
|
623
|
+
Object.keys(r).forEach(function (k) { if (cols.indexOf(k) < 0) cols.push(k); });
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
var t = el("table", "grid"), thead = el("thead"), htr = el("tr");
|
|
627
|
+
cols.forEach(function (c) { htr.appendChild(el("th", null, c)); });
|
|
628
|
+
thead.appendChild(htr);
|
|
629
|
+
var tb = el("tbody");
|
|
630
|
+
rows.forEach(function (r) {
|
|
631
|
+
var tr = el("tr");
|
|
632
|
+
cols.forEach(function (c) {
|
|
633
|
+
var v = r[c], td = el("td");
|
|
634
|
+
if (typeof v === "boolean") td.appendChild(el("span", "chip" + (v ? "" : " no"), v ? "yes" : "no"));
|
|
635
|
+
else if (typeof v === "number") { td.className = "num"; td.textContent = num(v); }
|
|
636
|
+
else if (/path/i.test(c) || /^\//.test(String(v))) { td.className = "path"; td.textContent = fmt(v); }
|
|
637
|
+
else td.textContent = fmt(v);
|
|
638
|
+
tr.appendChild(td);
|
|
639
|
+
});
|
|
640
|
+
tb.appendChild(tr);
|
|
641
|
+
});
|
|
642
|
+
t.appendChild(thead); t.appendChild(tb);
|
|
643
|
+
var box = el("div", "scroll"); box.appendChild(t); return box;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// Two vocabularies, because the tools genuinely use two. Bug hunting emits
|
|
647
|
+
// critical/high/medium/low; the accessibility audit emits major/minor. An
|
|
648
|
+
// unrecognised severity sorts last and renders in the low style, so omitting
|
|
649
|
+
// major/minor would have shown every accessibility barrier as least-severe
|
|
650
|
+
// -- the opposite of what an audit is for.
|
|
651
|
+
var SEV_ORDER = {
|
|
652
|
+
critical: 0, blocker: 0,
|
|
653
|
+
high: 1, major: 1, serious: 1,
|
|
654
|
+
medium: 2, moderate: 2,
|
|
655
|
+
low: 3, minor: 3,
|
|
656
|
+
info: 4, notice: 4,
|
|
657
|
+
};
|
|
658
|
+
function findingsList(rows, b) {
|
|
659
|
+
var sorted = rows.slice().sort(function (x, y) {
|
|
660
|
+
var a = SEV_ORDER[String(x[b.severityKey] || "").toLowerCase()];
|
|
661
|
+
var c = SEV_ORDER[String(y[b.severityKey] || "").toLowerCase()];
|
|
662
|
+
return (a === undefined ? 9 : a) - (c === undefined ? 9 : c);
|
|
663
|
+
});
|
|
664
|
+
var ul = el("ul", "finds");
|
|
665
|
+
sorted.forEach(function (r) {
|
|
666
|
+
var li = el("li");
|
|
667
|
+
var sev = String(r[b.severityKey] || "").toLowerCase();
|
|
668
|
+
// Number and severity travel together as ONE grid child. Appending the
|
|
669
|
+
// number as a third child put it in a column sized by the description
|
|
670
|
+
// text on the next row, so the badge stretched into a full-width bar.
|
|
671
|
+
var meta = el("span", "fmeta");
|
|
672
|
+
// The number drawn on this finding's boxes in the overlay above.
|
|
673
|
+
if (r.finding) meta.appendChild(el("span", "fnum", String(r.finding)));
|
|
674
|
+
meta.appendChild(el("span", "sev " + (SEV_ORDER[sev] !== undefined ? sev : "low"), sev || "note"));
|
|
675
|
+
li.appendChild(meta);
|
|
676
|
+
li.appendChild(el("span", null, fmt(r[b.textKey])));
|
|
677
|
+
if (b.detailKey && r[b.detailKey]) li.appendChild(el("p", "fdetail", fmt(r[b.detailKey])));
|
|
678
|
+
ul.appendChild(li);
|
|
679
|
+
});
|
|
680
|
+
return ul;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* Editable trait sliders that write back through the server.
|
|
685
|
+
*
|
|
686
|
+
* The widget sandbox has no network of its own -- it cannot POST anywhere --
|
|
687
|
+
* but app.callServerTool reaches the MCP server that rendered it, which is
|
|
688
|
+
* the same channel the image blocks already use to fetch artifacts. So an
|
|
689
|
+
* editor is possible here without any new transport.
|
|
690
|
+
*
|
|
691
|
+
* Save is explicit rather than live-on-drag. Every drag would otherwise be a
|
|
692
|
+
* write to two stores, and a slider dragged across its range would produce a
|
|
693
|
+
* dozen persona versions of which only the last was meant.
|
|
694
|
+
*/
|
|
695
|
+
function editorBlock(rows, b, data) {
|
|
696
|
+
var wrap = el("div", "traits");
|
|
697
|
+
var personaName = at(data, b.personaField || "persona") || at(data, "persona_name");
|
|
698
|
+
var edited = {};
|
|
699
|
+
var controls = [];
|
|
700
|
+
|
|
701
|
+
rows.forEach(function (r) {
|
|
702
|
+
var name = String(r.name), v = Number(r.value);
|
|
703
|
+
if (!isFinite(v)) return;
|
|
704
|
+
var line = el("div", "trait");
|
|
705
|
+
var label = el("span", "tname", titleize(name));
|
|
706
|
+
var input = document.createElement("input");
|
|
707
|
+
input.type = "range"; input.min = "0"; input.max = "1"; input.step = "0.05";
|
|
708
|
+
input.value = String(v); input.className = "tedit";
|
|
709
|
+
input.setAttribute("aria-label", titleize(name));
|
|
710
|
+
var out = el("span", "tval", v.toFixed(2));
|
|
711
|
+
// The original stays visible next to the new value: an editor that hides
|
|
712
|
+
// what you started from makes "did I mean to move that?" unanswerable.
|
|
713
|
+
var orig = el("span", "cap", " was " + v.toFixed(2));
|
|
714
|
+
orig.style.display = "none";
|
|
715
|
+
input.addEventListener("input", function () {
|
|
716
|
+
var nv = Number(input.value);
|
|
717
|
+
out.textContent = nv.toFixed(2);
|
|
718
|
+
if (Math.abs(nv - v) < 1e-9) { delete edited[name]; orig.style.display = "none"; line.classList.remove("dirty"); }
|
|
719
|
+
else { edited[name] = nv; orig.style.display = ""; line.classList.add("dirty"); }
|
|
720
|
+
status.textContent = Object.keys(edited).length
|
|
721
|
+
? Object.keys(edited).length + " unsaved change(s)" : "";
|
|
722
|
+
save.disabled = !Object.keys(edited).length;
|
|
723
|
+
});
|
|
724
|
+
line.appendChild(label); line.appendChild(input); line.appendChild(out); line.appendChild(orig);
|
|
725
|
+
wrap.appendChild(line);
|
|
726
|
+
controls.push({ name: name, input: input, out: out, base: v });
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
var bar = el("div", "editbar");
|
|
730
|
+
var save = el("button", "btn", "Save changes");
|
|
731
|
+
save.type = "button"; save.disabled = true;
|
|
732
|
+
var reset = el("button", "btn ghost", "Reset");
|
|
733
|
+
reset.type = "button";
|
|
734
|
+
var status = el("span", "cap", "");
|
|
735
|
+
bar.appendChild(save); bar.appendChild(reset); bar.appendChild(status);
|
|
736
|
+
wrap.appendChild(bar);
|
|
737
|
+
|
|
738
|
+
reset.addEventListener("click", function () {
|
|
739
|
+
controls.forEach(function (c) {
|
|
740
|
+
c.input.value = String(c.base); c.out.textContent = c.base.toFixed(2);
|
|
741
|
+
c.input.parentNode.classList.remove("dirty");
|
|
742
|
+
c.input.parentNode.querySelectorAll(".cap").forEach(function (n) { n.style.display = "none"; });
|
|
743
|
+
});
|
|
744
|
+
edited = {}; status.textContent = ""; save.disabled = true;
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
save.addEventListener("click", async function () {
|
|
748
|
+
if (!personaName) { status.textContent = "No persona name in this result — cannot save."; return; }
|
|
749
|
+
save.disabled = true; status.textContent = "Saving…";
|
|
750
|
+
try {
|
|
751
|
+
var res = await app.callServerTool({
|
|
752
|
+
name: b.saveTool || "persona_update",
|
|
753
|
+
arguments: { persona_name: personaName, traits: edited },
|
|
754
|
+
});
|
|
755
|
+
var txt = ((res && res.content) || []).filter(function (c) { return c.type === "text"; })[0];
|
|
756
|
+
var out = txt ? JSON.parse(txt.text) : {};
|
|
757
|
+
if (out.error) { status.textContent = "Not saved: " + (out.message || out.error); save.disabled = false; return; }
|
|
758
|
+
// The drift check comes back with the save, so the moment a trait edit
|
|
759
|
+
// contradicts the description is the moment it is said -- not the next
|
|
760
|
+
// time somebody happens to look.
|
|
761
|
+
var drift = (out.descriptionDrift || []).length;
|
|
762
|
+
status.textContent = "Saved. " + (out.changed || []).length + " field(s) changed"
|
|
763
|
+
+ (drift ? " — " + drift + " description claim(s) now contradicted" : "")
|
|
764
|
+
+ (out.stores && out.stores.cms !== "written" ? " — CMS NOT written" : "");
|
|
765
|
+
controls.forEach(function (c) {
|
|
766
|
+
if (edited[c.name] !== undefined) { c.base = edited[c.name]; }
|
|
767
|
+
c.input.parentNode.classList.remove("dirty");
|
|
768
|
+
});
|
|
769
|
+
edited = {};
|
|
770
|
+
// Tell the conversation, so the model knows the persona moved under it
|
|
771
|
+
// rather than answering later from the values it was handed.
|
|
772
|
+
app.sendMessage({ role: "user", content: [{ type: "text",
|
|
773
|
+
text: "Updated persona " + personaName + " — " + (out.changed || []).join(", ")
|
|
774
|
+
+ (drift ? ". Description now contradicts: " + out.descriptionDrift.map(function (d) { return d.trait; }).join(", ") : "") }] });
|
|
775
|
+
} catch (e) {
|
|
776
|
+
status.textContent = "Save failed: " + (e && e.message ? e.message : e);
|
|
777
|
+
save.disabled = false;
|
|
778
|
+
}
|
|
779
|
+
});
|
|
780
|
+
return wrap;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* The whole persona CRUD surface in one block.
|
|
785
|
+
*
|
|
786
|
+
* A roster on the left, the selected persona's editable traits on the right,
|
|
787
|
+
* and save / delete / new. Every write goes out through app.callServerTool to
|
|
788
|
+
* the tools that already do the work -- persona_update, persona_delete,
|
|
789
|
+
* persona_create_submit_traits -- so this is a surface over them rather than
|
|
790
|
+
* a second implementation of them. That matters: a duplicate write path is
|
|
791
|
+
* the exact defect that has produced nine divergent tool families here.
|
|
792
|
+
*
|
|
793
|
+
* The roster ships with the result, so switching personas is instant and
|
|
794
|
+
* costs no round trip. Only writes go back to the server.
|
|
795
|
+
*/
|
|
796
|
+
function managerBlock(data, b) {
|
|
797
|
+
var wrap = el("div", "mgr");
|
|
798
|
+
var roster = at(data, b.field) || [];
|
|
799
|
+
if (!roster.length) return wrap;
|
|
800
|
+
var selected = roster[0];
|
|
801
|
+
var edited = {};
|
|
802
|
+
|
|
803
|
+
var cols = el("div", "mgrcols");
|
|
804
|
+
var list = el("div", "mgrlist");
|
|
805
|
+
var pane = el("div", "mgrpane");
|
|
806
|
+
cols.appendChild(list); cols.appendChild(pane);
|
|
807
|
+
wrap.appendChild(cols);
|
|
808
|
+
|
|
809
|
+
var status = el("p", "cap", "");
|
|
810
|
+
wrap.appendChild(status);
|
|
811
|
+
|
|
812
|
+
function renderList() {
|
|
813
|
+
list.innerHTML = "";
|
|
814
|
+
roster.forEach(function (p, i) {
|
|
815
|
+
var row = el("button", "mgrrow" + (p === selected ? " on" : ""), "");
|
|
816
|
+
row.type = "button";
|
|
817
|
+
row.setAttribute("aria-pressed", p === selected ? "true" : "false");
|
|
818
|
+
row.appendChild(el("span", "mgrname", String(p.name || "(unnamed)")));
|
|
819
|
+
var meta = [];
|
|
820
|
+
if (p.builtin) meta.push("built-in");
|
|
821
|
+
if (p.valuesRoute) meta.push(String(p.valuesRoute));
|
|
822
|
+
if (meta.length) row.appendChild(el("span", "cap", meta.join(" · ")));
|
|
823
|
+
row.addEventListener("click", function () {
|
|
824
|
+
if (Object.keys(edited).length &&
|
|
825
|
+
!confirm("Discard " + Object.keys(edited).length + " unsaved change(s)?")) return;
|
|
826
|
+
selected = p; edited = {}; renderList(); renderPane();
|
|
827
|
+
});
|
|
828
|
+
list.appendChild(row);
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function renderPane() {
|
|
833
|
+
pane.innerHTML = "";
|
|
834
|
+
var p = selected;
|
|
835
|
+
pane.appendChild(el("h3", "mgrh", String(p.name)));
|
|
836
|
+
if (p.description) pane.appendChild(el("p", "cap", String(p.description)));
|
|
837
|
+
|
|
838
|
+
var traits = p.traits || {};
|
|
839
|
+
var names = Object.keys(traits);
|
|
840
|
+
var box = el("div", "traits");
|
|
841
|
+
names.forEach(function (k) {
|
|
842
|
+
var v = Number(traits[k]);
|
|
843
|
+
if (!isFinite(v)) return;
|
|
844
|
+
var line = el("div", "trait");
|
|
845
|
+
line.appendChild(el("span", "tname", titleize(k)));
|
|
846
|
+
var input = document.createElement("input");
|
|
847
|
+
input.type = "range"; input.min = "0"; input.max = "1"; input.step = "0.05";
|
|
848
|
+
input.value = String(v); input.className = "tedit";
|
|
849
|
+
input.setAttribute("aria-label", titleize(k));
|
|
850
|
+
var out = el("span", "tval", v.toFixed(2));
|
|
851
|
+
input.addEventListener("input", function () {
|
|
852
|
+
var nv = Number(input.value);
|
|
853
|
+
out.textContent = nv.toFixed(2);
|
|
854
|
+
if (Math.abs(nv - v) < 1e-9) { delete edited[k]; line.classList.remove("dirty"); }
|
|
855
|
+
else { edited[k] = nv; line.classList.add("dirty"); }
|
|
856
|
+
save.disabled = !Object.keys(edited).length;
|
|
857
|
+
status.textContent = Object.keys(edited).length
|
|
858
|
+
? Object.keys(edited).length + " unsaved change(s) on " + p.name : "";
|
|
859
|
+
});
|
|
860
|
+
line.appendChild(input); line.appendChild(out);
|
|
861
|
+
box.appendChild(line);
|
|
862
|
+
});
|
|
863
|
+
pane.appendChild(box);
|
|
864
|
+
|
|
865
|
+
var bar = el("div", "editbar");
|
|
866
|
+
var save = el("button", "btn", "Save");
|
|
867
|
+
save.type = "button"; save.disabled = true;
|
|
868
|
+
var del = el("button", "btn ghost", "Delete");
|
|
869
|
+
del.type = "button";
|
|
870
|
+
// A built-in is shared by every install; the server refuses to edit or
|
|
871
|
+
// delete one, so the UI does not offer to.
|
|
872
|
+
if (p.builtin) { save.disabled = true; del.disabled = true; }
|
|
873
|
+
bar.appendChild(save); bar.appendChild(del);
|
|
874
|
+
// The same three routes on update: revise from a new description,
|
|
875
|
+
// re-answer the survey, or supply a Big Five profile. Which fields are
|
|
876
|
+
// meaningful depends entirely on which route you are taking.
|
|
877
|
+
var via = el("button", "btn ghost", "Update via…");
|
|
878
|
+
via.type = "button";
|
|
879
|
+
via.disabled = !!p.builtin;
|
|
880
|
+
via.addEventListener("click", function () { renderForm("description", p); });
|
|
881
|
+
bar.appendChild(via);
|
|
882
|
+
pane.appendChild(bar);
|
|
883
|
+
|
|
884
|
+
save.addEventListener("click", async function () {
|
|
885
|
+
save.disabled = true; status.textContent = "Saving…";
|
|
886
|
+
try {
|
|
887
|
+
var res = await app.callServerTool({ name: "persona_update",
|
|
888
|
+
arguments: { persona_name: p.name, traits: edited } });
|
|
889
|
+
var t = ((res && res.content) || []).filter(function (c) { return c.type === "text"; })[0];
|
|
890
|
+
var out = t ? JSON.parse(t.text) : {};
|
|
891
|
+
if (out.error) { status.textContent = "Not saved: " + (out.message || out.error); save.disabled = false; return; }
|
|
892
|
+
Object.keys(edited).forEach(function (k) { p.traits[k] = edited[k]; });
|
|
893
|
+
var drift = (out.descriptionDrift || []).length;
|
|
894
|
+
status.textContent = "Saved " + p.name + ". " + (out.changed || []).length + " field(s)"
|
|
895
|
+
+ (drift ? " — " + drift + " description claim(s) now contradicted" : "")
|
|
896
|
+
+ (out.stores && out.stores.cms !== "written" ? " — CMS NOT written" : "");
|
|
897
|
+
edited = {}; renderPane();
|
|
898
|
+
app.sendMessage({ role: "user", content: [{ type: "text",
|
|
899
|
+
text: "Updated persona " + p.name + ": " + (out.changed || []).join(", ") }] });
|
|
900
|
+
} catch (e) { status.textContent = "Save failed: " + (e && e.message ? e.message : e); save.disabled = false; }
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
del.addEventListener("click", async function () {
|
|
904
|
+
if (!confirm("Delete " + p.name + " from both stores? This cannot be undone here.")) return;
|
|
905
|
+
del.disabled = true; status.textContent = "Deleting…";
|
|
906
|
+
try {
|
|
907
|
+
var res = await app.callServerTool({ name: "persona_delete",
|
|
908
|
+
arguments: { persona_name: p.name, confirm: true } });
|
|
909
|
+
var t = ((res && res.content) || []).filter(function (c) { return c.type === "text"; })[0];
|
|
910
|
+
var out = t ? JSON.parse(t.text) : {};
|
|
911
|
+
if (out.error) { status.textContent = "Not deleted: " + (out.message || out.error); del.disabled = false; return; }
|
|
912
|
+
roster = roster.filter(function (x) { return x !== p; });
|
|
913
|
+
status.textContent = "Deleted " + p.name + ". " + (out.note || "");
|
|
914
|
+
app.sendMessage({ role: "user", content: [{ type: "text", text: "Deleted persona " + p.name }] });
|
|
915
|
+
if (!roster.length) { pane.innerHTML = ""; list.innerHTML = ""; return; }
|
|
916
|
+
selected = roster[0]; edited = {}; renderList(); renderPane();
|
|
917
|
+
} catch (e) { status.textContent = "Delete failed: " + (e && e.message ? e.message : e); del.disabled = false; }
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
/**
|
|
922
|
+
* Create / update in three modes, because a persona's values come from one
|
|
923
|
+
* of three routes and the route decides which fields are even meaningful.
|
|
924
|
+
* A single form with every field would ask for a Big Five profile and a
|
|
925
|
+
* survey and a description at once, when supplying any ONE of them is the
|
|
926
|
+
* whole job.
|
|
927
|
+
*/
|
|
928
|
+
function renderForm(mode, existing) {
|
|
929
|
+
pane.innerHTML = "";
|
|
930
|
+
var modes = data.modes || {};
|
|
931
|
+
var spec = modes[mode] || {};
|
|
932
|
+
var isUpdate = !!existing;
|
|
933
|
+
|
|
934
|
+
var tabs = el("div", "modetabs");
|
|
935
|
+
["description", "survey", "bigfive"].forEach(function (m) {
|
|
936
|
+
var t = el("button", "modetab" + (m === mode ? " on" : ""), (modes[m] && modes[m].label) || m);
|
|
937
|
+
t.type = "button";
|
|
938
|
+
t.setAttribute("aria-pressed", m === mode ? "true" : "false");
|
|
939
|
+
t.addEventListener("click", function () { renderForm(m, existing); });
|
|
940
|
+
tabs.appendChild(t);
|
|
941
|
+
});
|
|
942
|
+
pane.appendChild(el("h3", "mgrh", isUpdate ? "Update " + existing.name : "New persona"));
|
|
943
|
+
pane.appendChild(tabs);
|
|
944
|
+
if (spec.note) pane.appendChild(el("p", "cap", spec.note));
|
|
945
|
+
|
|
946
|
+
var nameIn;
|
|
947
|
+
if (!isUpdate) {
|
|
948
|
+
nameIn = document.createElement("input");
|
|
949
|
+
nameIn.type = "text"; nameIn.className = "tin"; nameIn.placeholder = "persona name";
|
|
950
|
+
nameIn.setAttribute("aria-label", "Persona name");
|
|
951
|
+
pane.appendChild(nameIn);
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
var fields = el("div", "fields");
|
|
955
|
+
pane.appendChild(fields);
|
|
956
|
+
var collect = function () { return {}; };
|
|
957
|
+
|
|
958
|
+
if (mode === "description") {
|
|
959
|
+
var ta = document.createElement("textarea");
|
|
960
|
+
ta.className = "tin"; ta.rows = 5;
|
|
961
|
+
ta.placeholder = "A cautious retiree who double-checks everything before buying…";
|
|
962
|
+
ta.value = isUpdate ? String(existing.description || "") : "";
|
|
963
|
+
ta.setAttribute("aria-label", "Description");
|
|
964
|
+
fields.appendChild(ta);
|
|
965
|
+
collect = function () { return { description: ta.value.trim() }; };
|
|
966
|
+
} else if (mode === "survey") {
|
|
967
|
+
var criteria = data.traitCriteria || {};
|
|
968
|
+
var names = data.allTraits || Object.keys(criteria);
|
|
969
|
+
var vals = {};
|
|
970
|
+
names.forEach(function (k) {
|
|
971
|
+
var base = isUpdate && existing.traits && typeof existing.traits[k] === "number"
|
|
972
|
+
? Number(existing.traits[k]) : 0.5;
|
|
973
|
+
vals[k] = base;
|
|
974
|
+
var line = el("div", "trait");
|
|
975
|
+
var lab = el("span", "tname", titleize(k));
|
|
976
|
+
if (criteria[k]) lab.title = criteria[k];
|
|
977
|
+
var input = document.createElement("input");
|
|
978
|
+
input.type = "range"; input.min = "0"; input.max = "1"; input.step = "0.05";
|
|
979
|
+
input.value = String(base); input.className = "tedit";
|
|
980
|
+
input.setAttribute("aria-label", titleize(k));
|
|
981
|
+
var out = el("span", "tval", base.toFixed(2));
|
|
982
|
+
input.addEventListener("input", function () {
|
|
983
|
+
vals[k] = Number(input.value); out.textContent = vals[k].toFixed(2);
|
|
984
|
+
});
|
|
985
|
+
line.appendChild(lab); line.appendChild(input); line.appendChild(out);
|
|
986
|
+
fields.appendChild(line);
|
|
987
|
+
});
|
|
988
|
+
collect = function () { return { answers: vals }; };
|
|
989
|
+
} else {
|
|
990
|
+
var bf = {};
|
|
991
|
+
(data.bigFiveFactors || []).forEach(function (f) {
|
|
992
|
+
var base = isUpdate && existing.bigFive && typeof existing.bigFive[f.factor] === "number"
|
|
993
|
+
? Number(existing.bigFive[f.factor]) : 0.5;
|
|
994
|
+
bf[f.factor] = base;
|
|
995
|
+
var line = el("div", "trait");
|
|
996
|
+
var lab = el("span", "tname", titleize(f.factor));
|
|
997
|
+
lab.title = "low: " + f.low + "\nhigh: " + f.high + "\n" + (f.doNotConfuse || "");
|
|
998
|
+
var input = document.createElement("input");
|
|
999
|
+
input.type = "range"; input.min = "0"; input.max = "1"; input.step = "0.01";
|
|
1000
|
+
input.value = String(base); input.className = "tedit";
|
|
1001
|
+
input.setAttribute("aria-label", titleize(f.factor));
|
|
1002
|
+
var out = el("span", "tval", base.toFixed(2));
|
|
1003
|
+
input.addEventListener("input", function () {
|
|
1004
|
+
bf[f.factor] = Number(input.value); out.textContent = bf[f.factor].toFixed(2);
|
|
1005
|
+
});
|
|
1006
|
+
line.appendChild(lab); line.appendChild(input); line.appendChild(out);
|
|
1007
|
+
fields.appendChild(line);
|
|
1008
|
+
});
|
|
1009
|
+
collect = function () { return { bigFive: bf }; };
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
var bar = el("div", "editbar");
|
|
1013
|
+
var go = el("button", "btn", isUpdate ? "Apply" : "Create");
|
|
1014
|
+
go.type = "button";
|
|
1015
|
+
var back = el("button", "btn ghost", "Cancel");
|
|
1016
|
+
back.type = "button";
|
|
1017
|
+
back.addEventListener("click", function () { renderPane(); });
|
|
1018
|
+
bar.appendChild(go); bar.appendChild(back);
|
|
1019
|
+
pane.appendChild(bar);
|
|
1020
|
+
|
|
1021
|
+
go.addEventListener("click", async function () {
|
|
1022
|
+
var name = isUpdate ? existing.name : (nameIn.value || "").trim();
|
|
1023
|
+
if (!name) { status.textContent = "A name is required."; return; }
|
|
1024
|
+
var payload = collect();
|
|
1025
|
+
go.disabled = true; status.textContent = "Working…";
|
|
1026
|
+
try {
|
|
1027
|
+
if (mode === "description") {
|
|
1028
|
+
// Handed to the conversation rather than faked. The widget has no
|
|
1029
|
+
// model, and a form that silently did nothing would be worse than
|
|
1030
|
+
// one that says where the work happens.
|
|
1031
|
+
app.sendMessage({ role: "user", content: [{ type: "text",
|
|
1032
|
+
text: (isUpdate ? "Update the persona " + name + " from this description" : "Create a persona named " + name + " from this description")
|
|
1033
|
+
+ ", using persona_create_from_description and the completeness contract: " + payload.description }] });
|
|
1034
|
+
status.textContent = "Handed to the assistant — it will infer the traits and write the persona.";
|
|
1035
|
+
go.disabled = false;
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
var res, out, t;
|
|
1039
|
+
if (isUpdate) {
|
|
1040
|
+
res = await app.callServerTool({ name: "persona_update",
|
|
1041
|
+
arguments: mode === "survey"
|
|
1042
|
+
? { persona_name: name, traits: payload.answers }
|
|
1043
|
+
: { persona_name: name, bigFive: payload.bigFive } });
|
|
1044
|
+
} else if (mode === "survey") {
|
|
1045
|
+
res = await app.callServerTool({ name: "persona_questionnaire_build",
|
|
1046
|
+
arguments: { name: name, description: "Created from a survey in persona_manager", answers: payload.answers, save: true } });
|
|
1047
|
+
} else {
|
|
1048
|
+
// Two calls, both real: build establishes the persona, update
|
|
1049
|
+
// attaches the Big Five and moves it to that values route.
|
|
1050
|
+
await app.callServerTool({ name: "persona_questionnaire_build",
|
|
1051
|
+
arguments: { name: name, description: "Created from a Big Five profile in persona_manager", answers: {}, save: true } });
|
|
1052
|
+
res = await app.callServerTool({ name: "persona_update",
|
|
1053
|
+
arguments: { persona_name: name, bigFive: payload.bigFive } });
|
|
1054
|
+
}
|
|
1055
|
+
t = ((res && res.content) || []).filter(function (c) { return c.type === "text"; })[0];
|
|
1056
|
+
out = t ? JSON.parse(t.text) : {};
|
|
1057
|
+
if (out.error) { status.textContent = "Failed: " + (out.message || out.error); go.disabled = false; return; }
|
|
1058
|
+
status.textContent = (isUpdate ? "Updated " : "Created ") + name
|
|
1059
|
+
+ (out.valuesRoute ? " (" + out.valuesRoute + ")" : "")
|
|
1060
|
+
+ ((out.descriptionDrift || []).length ? " — " + out.descriptionDrift.length + " description claim(s) now contradicted" : "");
|
|
1061
|
+
app.sendMessage({ role: "user", content: [{ type: "text",
|
|
1062
|
+
text: (isUpdate ? "Updated" : "Created") + " persona " + name + " via " + mode }] });
|
|
1063
|
+
renderPane();
|
|
1064
|
+
} catch (e) {
|
|
1065
|
+
status.textContent = "Failed: " + (e && e.message ? e.message : e);
|
|
1066
|
+
go.disabled = false;
|
|
1067
|
+
}
|
|
1068
|
+
});
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
var newBar = el("div", "editbar");
|
|
1072
|
+
var newBtn = el("button", "btn", "New persona");
|
|
1073
|
+
newBtn.type = "button";
|
|
1074
|
+
newBtn.addEventListener("click", function () { renderForm("description", null); });
|
|
1075
|
+
newBar.appendChild(newBtn);
|
|
1076
|
+
wrap.insertBefore(newBar, status);
|
|
1077
|
+
|
|
1078
|
+
renderList(); renderPane();
|
|
1079
|
+
return wrap;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
function traitsBlock(rows, b) {
|
|
1083
|
+
var wrap = el("div", "traits");
|
|
1084
|
+
rows.forEach(function (r) {
|
|
1085
|
+
var name = r.name, v = Number(r.value);
|
|
1086
|
+
if (!isFinite(v)) return;
|
|
1087
|
+
var line = el("div", "trait");
|
|
1088
|
+
var label = titleize(String(name));
|
|
1089
|
+
var info = b.describe ? GLOSSARY[String(name)] : null;
|
|
1090
|
+
if (info) {
|
|
1091
|
+
// A button, not a span: the definition has to be reachable by keyboard,
|
|
1092
|
+
// and hover-only would hide it from anyone not using a mouse.
|
|
1093
|
+
var btn = el("button", "tname", label);
|
|
1094
|
+
btn.type = "button";
|
|
1095
|
+
btn.setAttribute("aria-describedby", "kit-tip");
|
|
1096
|
+
var payload = { label: label, description: info.d, lowEnd: info.l, highEnd: info.h };
|
|
1097
|
+
btn.addEventListener("mouseenter", function () { showTip(btn, payload); });
|
|
1098
|
+
btn.addEventListener("focus", function () { showTip(btn, payload); });
|
|
1099
|
+
btn.addEventListener("mouseleave", hideTip);
|
|
1100
|
+
btn.addEventListener("blur", hideTip);
|
|
1101
|
+
line.appendChild(btn);
|
|
1102
|
+
} else {
|
|
1103
|
+
line.appendChild(el("span", "tname", label));
|
|
1104
|
+
}
|
|
1105
|
+
var track = el("div", "track");
|
|
1106
|
+
track.appendChild(el("span", "base"));
|
|
1107
|
+
var fill = el("i");
|
|
1108
|
+
var pct = Math.max(0, Math.min(1, v)) * 100;
|
|
1109
|
+
fill.style.width = pct + "%";
|
|
1110
|
+
fill.style.backgroundColor =
|
|
1111
|
+
b.ramp === "value" ? "hsl(" + (220 + v * 80) + ", 55%, 55%)"
|
|
1112
|
+
: b.ramp === "accessibility" ? "hsl(" + (55 - v * 30) + ", 78%, 50%)"
|
|
1113
|
+
: "hsl(" + (v * 120) + ", 65%, 50%)";
|
|
1114
|
+
track.appendChild(fill);
|
|
1115
|
+
line.appendChild(track);
|
|
1116
|
+
line.appendChild(el("span", "tval", v.toFixed(2)));
|
|
1117
|
+
wrap.appendChild(line);
|
|
1118
|
+
});
|
|
1119
|
+
return wrap;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
// Traits arrive either as {name: number} or as [{name, value}]; both are in
|
|
1123
|
+
// use across the persona tools, so both are accepted rather than forcing a
|
|
1124
|
+
// shape the callers do not already produce.
|
|
1125
|
+
function normaliseTraits(v, b) {
|
|
1126
|
+
if (isObjArray(v)) {
|
|
1127
|
+
return v.map(function (r) {
|
|
1128
|
+
return { name: r[b.nameKey || "name"] || r.trait || r.key,
|
|
1129
|
+
value: r[b.valueKey || "value"] !== undefined ? r[b.valueKey || "value"] : r.score };
|
|
1130
|
+
});
|
|
1131
|
+
}
|
|
1132
|
+
if (v && typeof v === "object" && !Array.isArray(v)) {
|
|
1133
|
+
return Object.keys(v).filter(function (k) { return typeof v[k] === "number"; })
|
|
1134
|
+
.map(function (k) { return { name: k, value: v[k] }; });
|
|
1135
|
+
}
|
|
1136
|
+
return [];
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
async function chainBlock(data, b) {
|
|
1140
|
+
var layers = at(data, b.field);
|
|
1141
|
+
if (!isObjArray(layers) || !layers.length) return null;
|
|
1142
|
+
var nameKey = b.nameKey || "name";
|
|
1143
|
+
var costKey = b.costKey || "cost";
|
|
1144
|
+
var bottleneck = b.bottleneckField ? at(data, b.bottleneckField) : undefined;
|
|
1145
|
+
var additive = b.additiveField ? Number(at(data, b.additiveField)) : NaN;
|
|
1146
|
+
var total = b.totalField ? Number(at(data, b.totalField)) : NaN;
|
|
1147
|
+
|
|
1148
|
+
// One scale across every bar, including the two summary bars, or the
|
|
1149
|
+
// comparison the block exists to make would be drawn to different rulers.
|
|
1150
|
+
var costs = layers.map(function (l) { return Number(l[costKey]) || 0; });
|
|
1151
|
+
var scaleMax = Math.max.apply(null, costs.concat(
|
|
1152
|
+
[isFinite(additive) ? additive : 0, isFinite(total) ? total : 0]));
|
|
1153
|
+
if (!(scaleMax > 0)) scaleMax = 1;
|
|
1154
|
+
|
|
1155
|
+
var wrap = el("div", "chain");
|
|
1156
|
+
|
|
1157
|
+
// Overlays keyed by layer name, so a bar can find its own evidence.
|
|
1158
|
+
var ovs = {};
|
|
1159
|
+
var ovList = b.overlaysField ? at(data, b.overlaysField) : null;
|
|
1160
|
+
if (isObjArray(ovList)) {
|
|
1161
|
+
ovList.forEach(function (o) { if (o && o.layer) ovs[String(o.layer)] = o; });
|
|
1162
|
+
}
|
|
1163
|
+
var pane = el("div");
|
|
1164
|
+
|
|
1165
|
+
function bar(label, value, hue, isPeak, ov) {
|
|
1166
|
+
var interactive = !!(ov && ov.available && ov.file);
|
|
1167
|
+
// A real <button> when there is something behind it, so it is keyboard
|
|
1168
|
+
// operable and announced as a control. A plain div when there is not --
|
|
1169
|
+
// styling a dead row to look clickable is the lie this avoids.
|
|
1170
|
+
var seg = interactive
|
|
1171
|
+
? document.createElement("button")
|
|
1172
|
+
: el("div");
|
|
1173
|
+
seg.className = "seg" + (isPeak ? " peak" : "");
|
|
1174
|
+
if (interactive) { seg.type = "button"; seg.setAttribute("aria-expanded", "false"); }
|
|
1175
|
+
var lbl = el("div", "lbl", label);
|
|
1176
|
+
if (isPeak) lbl.appendChild(el("span", "peaktag", "bottleneck"));
|
|
1177
|
+
var track = el("div", "track");
|
|
1178
|
+
var fill = el("div", "fill");
|
|
1179
|
+
fill.style.width = Math.max(1, (value / scaleMax) * 100) + "%";
|
|
1180
|
+
fill.style.backgroundColor = "hsl(" + hue + ", 62%, 48%)";
|
|
1181
|
+
track.appendChild(fill);
|
|
1182
|
+
var num = el("div", "num", value.toFixed(3));
|
|
1183
|
+
seg.appendChild(lbl); seg.appendChild(track); seg.appendChild(num);
|
|
1184
|
+
if (ov && !ov.available) {
|
|
1185
|
+
var why = el("span", "noov", "no overlay");
|
|
1186
|
+
why.title = String(ov.reason || "no overlay for this layer");
|
|
1187
|
+
seg.appendChild(why);
|
|
1188
|
+
}
|
|
1189
|
+
if (interactive) {
|
|
1190
|
+
seg.addEventListener("click", function () {
|
|
1191
|
+
var open = seg.getAttribute("aria-expanded") === "true";
|
|
1192
|
+
// One pane, so opening a layer replaces the last rather than stacking
|
|
1193
|
+
// six screenshots down the panel.
|
|
1194
|
+
wrap.querySelectorAll("button.seg").forEach(function (n) { n.setAttribute("aria-expanded", "false"); });
|
|
1195
|
+
pane.innerHTML = "";
|
|
1196
|
+
if (open) return;
|
|
1197
|
+
seg.setAttribute("aria-expanded", "true");
|
|
1198
|
+
showOverlay(ov, label);
|
|
1199
|
+
});
|
|
1200
|
+
}
|
|
1201
|
+
return seg;
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
async function showOverlay(ov, label) {
|
|
1205
|
+
pane.className = "ovpane";
|
|
1206
|
+
pane.appendChild(el("p", "cap", "Loading " + label + " overlay…"));
|
|
1207
|
+
try {
|
|
1208
|
+
var res = await app.callServerTool({
|
|
1209
|
+
name: b.fetchTool || "artifact_fetch",
|
|
1210
|
+
arguments: { file: ov.file },
|
|
1211
|
+
});
|
|
1212
|
+
var img = ((res && res.content) || []).filter(function (c) { return c.type === "image"; })[0];
|
|
1213
|
+
pane.innerHTML = "";
|
|
1214
|
+
if (!img) {
|
|
1215
|
+
pane.appendChild(el("p", "cap", "The " + label + " overlay could not be loaded."));
|
|
1216
|
+
return;
|
|
1217
|
+
}
|
|
1218
|
+
var node = document.createElement("img");
|
|
1219
|
+
node.src = "data:" + (img.mimeType || "image/png") + ";base64," + img.data;
|
|
1220
|
+
// Named for what it shows, not "overlay image" -- a screen reader user
|
|
1221
|
+
// gets the same sentence a sighted one reads under it.
|
|
1222
|
+
node.alt = label + " overlay for this page: " + (ov.legend || "");
|
|
1223
|
+
pane.appendChild(node);
|
|
1224
|
+
if (ov.legend) pane.appendChild(el("p", "cap", String(ov.legend)));
|
|
1225
|
+
} catch (e) {
|
|
1226
|
+
pane.innerHTML = "";
|
|
1227
|
+
pane.appendChild(el("p", "cap", "Could not load the " + label + " overlay: " + (e && e.message ? e.message : e)));
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
// Width and colour deliberately use DIFFERENT denominators.
|
|
1232
|
+
//
|
|
1233
|
+
// Width is on the shared scale, so a layer bar and the sequential-total bar
|
|
1234
|
+
// below it can be compared honestly. Colour is relative to the largest
|
|
1235
|
+
// LAYER, because the shared scale made every layer green: the biggest layer
|
|
1236
|
+
// was 0.171 against a 0.742 total, which is 23% of the ramp, so a page at
|
|
1237
|
+
// 63% abandonment risk rendered as six healthy green bars. Ramping colour
|
|
1238
|
+
// within the layers is what makes the bottleneck the hottest thing on
|
|
1239
|
+
// screen -- which is the one thing this block exists to show.
|
|
1240
|
+
var layerMax = Math.max.apply(null, costs);
|
|
1241
|
+
if (!(layerMax > 0)) layerMax = 1;
|
|
1242
|
+
layers.forEach(function (l) {
|
|
1243
|
+
var name = String(l[nameKey] == null ? "" : l[nameKey]);
|
|
1244
|
+
var cost = Number(l[costKey]) || 0;
|
|
1245
|
+
var isPeak = bottleneck != null && String(bottleneck) === name;
|
|
1246
|
+
// Cost, not capability: high is bad, so the ramp runs green to red.
|
|
1247
|
+
wrap.appendChild(bar(name, cost, 120 - Math.min(1, cost / layerMax) * 120, isPeak, ovs[name]));
|
|
1248
|
+
});
|
|
1249
|
+
wrap.appendChild(pane);
|
|
1250
|
+
|
|
1251
|
+
if (isFinite(additive) && isFinite(total) && additive > 0) {
|
|
1252
|
+
var amp = el("div", "amp");
|
|
1253
|
+
// Computed BEFORE the bars that read it. var hoists, so declaring this
|
|
1254
|
+
// below its first use left the guard comparing undefined !== null, which
|
|
1255
|
+
// is true -- the guard passed and the bar was drawn with no value.
|
|
1256
|
+
//
|
|
1257
|
+
// raw, not total: total is a sigmoid of raw, so plotting additive against
|
|
1258
|
+
// it drew a 1.034 bar beside a 0.410 bar and told the 60%-less story in
|
|
1259
|
+
// pixels while the caption underneath said +2%. Both bars are in
|
|
1260
|
+
// layer-cost units or the comparison is omitted.
|
|
1261
|
+
var rawTotal = at(data, "cognitiveTransportCost.raw");
|
|
1262
|
+
var ratio = typeof rawTotal === "number" ? rawTotal / additive : null;
|
|
1263
|
+
var pct = ratio === null ? 0 : Math.round(Math.abs(1 - ratio) * 100);
|
|
1264
|
+
if (ratio !== null) {
|
|
1265
|
+
amp.appendChild(bar("sum of layers", additive, 205, false, null));
|
|
1266
|
+
amp.appendChild(bar("actual, in sequence", rawTotal, 205, false, null));
|
|
1267
|
+
}
|
|
1268
|
+
// Describes the coefficient it actually measured.
|
|
1269
|
+
//
|
|
1270
|
+
// The first version assumed the chain AMPLIFIES, matching the old
|
|
1271
|
+
// sequentialAmplification name, and fell back to "close to additive"
|
|
1272
|
+
// otherwise. NOTE: no backticks anywhere in this block -- it is inside a
|
|
1273
|
+
// template literal, so one silently ends the string and the whole widget
|
|
1274
|
+
// stops compiling. Against real data the coefficient is 0.30-0.39, so
|
|
1275
|
+
// the widget confidently called a 61-70% compression "close to additive".
|
|
1276
|
+
// The mock payload used to build it had additive < total, which is the
|
|
1277
|
+
// one shape the tool has never actually produced. (2026-08-02)
|
|
1278
|
+
amp.appendChild(el("p", "ampnote",
|
|
1279
|
+
// No raw total, no claim. Falling back to the sigmoid-squashed total
|
|
1280
|
+
// here is what produced "about 60% less" on a chain that actually adds
|
|
1281
|
+
// ~1%: total is a sigmoid and additive is not, so their ratio measures
|
|
1282
|
+
// the squash, not the chain. A payload from before raw existed cannot
|
|
1283
|
+
// answer this, and saying so beats answering it wrongly.
|
|
1284
|
+
ratio === null
|
|
1285
|
+
? "This result predates the raw cost being reported, so the chain's effect cannot be computed from it. The bars above are still exact; only the comparison below them is unavailable."
|
|
1286
|
+
: ratio > 1.0005
|
|
1287
|
+
? "The layers run in sequence, so each one spends what the last left over. That adds "
|
|
1288
|
+
+ pct + "% over their sum (" + ratio.toFixed(3) + "x)"
|
|
1289
|
+
+ (bottleneck ? ", concentrated in " + bottleneck + "." : ".")
|
|
1290
|
+
: ratio < 0.995
|
|
1291
|
+
? "Running the layers in sequence costs " + ratio.toFixed(3) + "x their sum, about "
|
|
1292
|
+
+ pct + "% less"
|
|
1293
|
+
+ (bottleneck ? ", and " + bottleneck + " is where the cost concentrates." : ".")
|
|
1294
|
+
: "Sequential and additive cost agree to within a rounding error: no layer is arriving on a materially depleted budget"
|
|
1295
|
+
+ (bottleneck ? ", and " + bottleneck + " carries the chain on its own." : ".")));
|
|
1296
|
+
wrap.appendChild(amp);
|
|
1297
|
+
}
|
|
1298
|
+
return wrap;
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
function levelsBlock(data, b) {
|
|
1302
|
+
var levels = at(data, b.field);
|
|
1303
|
+
if (!isObjArray(levels)) return null;
|
|
1304
|
+
var value = b.valueField ? at(data, b.valueField) : undefined;
|
|
1305
|
+
var label = b.labelField ? at(data, b.labelField) : undefined;
|
|
1306
|
+
var behaviors = b.behaviorsField ? at(data, b.behaviorsField) : undefined;
|
|
1307
|
+
|
|
1308
|
+
var wrap = el("div", "scale");
|
|
1309
|
+
var bands = el("div", "bands");
|
|
1310
|
+
levels.forEach(function (lv) {
|
|
1311
|
+
var band = el("div", "band", String(lv.label || ""));
|
|
1312
|
+
var v = Number(lv.value);
|
|
1313
|
+
if (isFinite(v)) band.style.backgroundColor = "hsl(" + (v * 120) + ", 65%, 50%)";
|
|
1314
|
+
// Active band chosen by nearest level, since the queried value rarely
|
|
1315
|
+
// lands exactly on a band's own value.
|
|
1316
|
+
if (label && String(lv.label) === String(label)) band.classList.add("on");
|
|
1317
|
+
band.title = String(lv.label || "");
|
|
1318
|
+
bands.appendChild(band);
|
|
1319
|
+
});
|
|
1320
|
+
if (typeof value === "number" && isFinite(value)) {
|
|
1321
|
+
var m = el("div", "marker", value.toFixed(2));
|
|
1322
|
+
m.style.left = Math.max(2, Math.min(98, value * 100)) + "%";
|
|
1323
|
+
bands.appendChild(m);
|
|
1324
|
+
}
|
|
1325
|
+
wrap.appendChild(bands);
|
|
1326
|
+
|
|
1327
|
+
if (label) wrap.appendChild(el("p", "lvlname", String(label)));
|
|
1328
|
+
if (Array.isArray(behaviors) && behaviors.length) {
|
|
1329
|
+
var ul = el("ul", "behav");
|
|
1330
|
+
behaviors.forEach(function (x) { ul.appendChild(el("li", null, String(x))); });
|
|
1331
|
+
wrap.appendChild(ul);
|
|
1332
|
+
}
|
|
1333
|
+
return wrap;
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
function scoreBlock(data, b) {
|
|
1337
|
+
var wrap = el("div");
|
|
1338
|
+
var grade = at(data, b.gradeField);
|
|
1339
|
+
var score = at(data, b.scoreField);
|
|
1340
|
+
if (grade !== undefined || score !== undefined) {
|
|
1341
|
+
var row = el("div", "score");
|
|
1342
|
+
if (grade !== undefined) {
|
|
1343
|
+
row.appendChild(el("span", "grade " + String(grade).toLowerCase().charAt(0), String(grade)));
|
|
1344
|
+
}
|
|
1345
|
+
if (score !== undefined) row.appendChild(el("span", "scoresub", String(score)));
|
|
1346
|
+
wrap.appendChild(row);
|
|
1347
|
+
}
|
|
1348
|
+
var cats = at(data, b.categoriesField);
|
|
1349
|
+
if (isObjArray(cats)) {
|
|
1350
|
+
var ul = el("cats" === "" ? "ul" : "ul", "cats");
|
|
1351
|
+
// Normalised against the largest value present, so bars stay comparable
|
|
1352
|
+
// even when a source reports out of something other than 100.
|
|
1353
|
+
var max = Math.max.apply(null, cats.map(function (c) { return Number(c[b.valueKey]) || 0; }).concat([1]));
|
|
1354
|
+
cats.forEach(function (c) {
|
|
1355
|
+
var li = el("li");
|
|
1356
|
+
li.appendChild(el("span", "catname", fmt(c[b.labelKey])));
|
|
1357
|
+
var v = Number(c[b.valueKey]) || 0;
|
|
1358
|
+
var w = b.weightKey && c[b.weightKey] !== undefined ? " · weight " + c[b.weightKey] : "";
|
|
1359
|
+
li.appendChild(el("span", "catval", v + w));
|
|
1360
|
+
var bar = el("div", "bar"), fill = el("i");
|
|
1361
|
+
fill.style.width = Math.max(2, Math.round((v / max) * 100)) + "%";
|
|
1362
|
+
bar.appendChild(fill); li.appendChild(bar);
|
|
1363
|
+
ul.appendChild(li);
|
|
1364
|
+
});
|
|
1365
|
+
wrap.appendChild(ul);
|
|
1366
|
+
}
|
|
1367
|
+
return wrap;
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
async function imageBlock(data, b) {
|
|
1371
|
+
var wrap = el("div");
|
|
1372
|
+
var img = document.createElement("img");
|
|
1373
|
+
img.className = "shot"; img.alt = "";
|
|
1374
|
+
var inline = b.dataField ? at(data, b.dataField) : undefined;
|
|
1375
|
+
if (inline) {
|
|
1376
|
+
img.src = String(inline).startsWith("data:") ? inline : "data:image/png;base64," + inline;
|
|
1377
|
+
wrap.appendChild(img);
|
|
1378
|
+
} else if (b.fetchTool) {
|
|
1379
|
+
// Fetched after mount rather than shipped in the result: hosts cap tool
|
|
1380
|
+
// results near 150k characters and swap in a file pointer, which reaches
|
|
1381
|
+
// the widget as unparseable text. Heavy assets have to come over this
|
|
1382
|
+
// channel instead.
|
|
1383
|
+
var note = el("p", "cap", "Loading image…");
|
|
1384
|
+
wrap.appendChild(img); wrap.appendChild(note);
|
|
1385
|
+
try {
|
|
1386
|
+
var args = b.fetchArgsField ? at(data, b.fetchArgsField) : {};
|
|
1387
|
+
if (b.fetchArgs) {
|
|
1388
|
+
args = {};
|
|
1389
|
+
Object.keys(b.fetchArgs).forEach(function (k) { args[k] = at(data, b.fetchArgs[k]); });
|
|
1390
|
+
}
|
|
1391
|
+
var res = await app.callServerTool({ name: b.fetchTool, arguments: args || {} });
|
|
1392
|
+
var blk = (res && res.content || []).filter(function (c) { return c.type === "image"; })[0];
|
|
1393
|
+
if (blk && blk.data) { img.src = "data:" + (blk.mimeType || "image/png") + ";base64," + blk.data; note.remove(); }
|
|
1394
|
+
else { note.textContent = "No image was returned."; }
|
|
1395
|
+
} catch (e) {
|
|
1396
|
+
note.textContent = "Could not load image: " + (e && e.message ? e.message : e);
|
|
1397
|
+
}
|
|
1398
|
+
} else {
|
|
1399
|
+
var url = at(data, b.urlField);
|
|
1400
|
+
// A URL on a host outside the sandbox allowlist cannot load here, so it
|
|
1401
|
+
// is offered as a link rather than an <img> that would render broken.
|
|
1402
|
+
var a = document.createElement("a");
|
|
1403
|
+
a.textContent = url ? "Open image" : "No image available";
|
|
1404
|
+
if (url) { a.href = url; a.addEventListener("click", function (ev) { ev.preventDefault(); app.openLink({ url: url }); }); }
|
|
1405
|
+
wrap.appendChild(a);
|
|
1406
|
+
return wrap;
|
|
1407
|
+
}
|
|
1408
|
+
if (b.caption) { var c = at(data, b.caption); if (c) wrap.appendChild(el("p", "cap", fmt(c))); }
|
|
1409
|
+
return wrap;
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
var SEV_COLOR = {
|
|
1413
|
+
critical: "#dc2626", blocker: "#dc2626",
|
|
1414
|
+
major: "#ea580c", high: "#ea580c", serious: "#ea580c",
|
|
1415
|
+
medium: "#ca8a04", moderate: "#ca8a04",
|
|
1416
|
+
minor: "#2563eb", low: "#2563eb", info: "#64748b", notice: "#64748b",
|
|
1417
|
+
};
|
|
1418
|
+
|
|
1419
|
+
// Whichever of near-white / near-black contrasts better with a fill, by WCAG
|
|
1420
|
+
// relative luminance rather than by eye.
|
|
1421
|
+
function readableOn(hex) {
|
|
1422
|
+
var c = String(hex).replace("#", "");
|
|
1423
|
+
if (c.length !== 6) return "#fff";
|
|
1424
|
+
var lin = function (v) {
|
|
1425
|
+
v = parseInt(v, 16) / 255;
|
|
1426
|
+
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
|
|
1427
|
+
};
|
|
1428
|
+
var L = 0.2126 * lin(c.slice(0, 2)) + 0.7152 * lin(c.slice(2, 4)) + 0.0722 * lin(c.slice(4, 6));
|
|
1429
|
+
var onWhite = 1.05 / (L + 0.05);
|
|
1430
|
+
var onBlack = (L + 0.05) / 0.05;
|
|
1431
|
+
return onWhite >= onBlack ? "#ffffff" : "#111418";
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
async function overlayBlock(data, b) {
|
|
1435
|
+
var shots = at(data, b.field);
|
|
1436
|
+
var shot = Array.isArray(shots) ? shots[0] : shots;
|
|
1437
|
+
if (!shot) return null;
|
|
1438
|
+
var rects = shot[b.rectsKey || "barrierRects"] || [];
|
|
1439
|
+
// The region of the document the image actually covers. captureSize is
|
|
1440
|
+
// read from the PNG header server-side, so it cannot disagree with the
|
|
1441
|
+
// picture; viewportSize is the fallback for older payloads.
|
|
1442
|
+
var cs = shot.captureSize || {};
|
|
1443
|
+
var origin = shot.captureOrigin || { x: 0, y: 0 };
|
|
1444
|
+
var ox = origin.x || 0, oy = origin.y || 0;
|
|
1445
|
+
var w = cs.width || (shot[b.widthKey || "viewportSize"] || {}).width;
|
|
1446
|
+
var h = cs.height || shot.captureHeight ||
|
|
1447
|
+
(shot[b.widthKey || "viewportSize"] || {}).height;
|
|
1448
|
+
if (!w || !h) return null;
|
|
1449
|
+
|
|
1450
|
+
var wrap = el("div");
|
|
1451
|
+
var frame = el("div", "ovwrap");
|
|
1452
|
+
var img = document.createElement("img");
|
|
1453
|
+
img.alt = "Page screenshot with accessibility barriers outlined";
|
|
1454
|
+
frame.appendChild(img);
|
|
1455
|
+
wrap.appendChild(frame);
|
|
1456
|
+
|
|
1457
|
+
var drawn = [], off = [];
|
|
1458
|
+
rects.forEach(function (r) {
|
|
1459
|
+
var rc = r.rect || r;
|
|
1460
|
+
// Image space: document coordinate minus the capture origin.
|
|
1461
|
+
var ix = rc ? rc.x - ox : 0, iy = rc ? rc.y - oy : 0;
|
|
1462
|
+
var inBounds = rc && typeof rc.x === "number" &&
|
|
1463
|
+
ix + (rc.width || 0) > 0 && iy + (rc.height || 0) > 0 &&
|
|
1464
|
+
ix < w && iy < h && !r.outsideScreenshot;
|
|
1465
|
+
(inBounds ? drawn : off).push(r);
|
|
1466
|
+
});
|
|
1467
|
+
|
|
1468
|
+
drawn.forEach(function (r) {
|
|
1469
|
+
var rc = r.rect || r;
|
|
1470
|
+
// The persona-weighted grade, which is what the findings list is ranked
|
|
1471
|
+
// and coloured by. Falling back to the raw grade keeps older payloads
|
|
1472
|
+
// rendering; using the raw grade FIRST is what made the map disagree
|
|
1473
|
+
// with the list directly beneath it.
|
|
1474
|
+
var sev = String(r.severityForPersona || r.severity || "").toLowerCase();
|
|
1475
|
+
var color = SEV_COLOR[sev] || "#64748b";
|
|
1476
|
+
var box = el("div", "bx");
|
|
1477
|
+
// Percentages, so the boxes track the image at whatever width the host
|
|
1478
|
+
// renders it -- pixel offsets would drift the moment the frame resized.
|
|
1479
|
+
box.style.left = (((rc.x - ox) / w) * 100).toFixed(3) + "%";
|
|
1480
|
+
box.style.top = (((rc.y - oy) / h) * 100).toFixed(3) + "%";
|
|
1481
|
+
box.style.width = (((rc.width || 0) / w) * 100).toFixed(3) + "%";
|
|
1482
|
+
box.style.height = (((rc.height || 0) / h) * 100).toFixed(3) + "%";
|
|
1483
|
+
box.style.borderColor = color;
|
|
1484
|
+
box.style.boxShadow = "0 0 0 1px " + color + "55";
|
|
1485
|
+
// The finding number, so a box on the map can be found in the list
|
|
1486
|
+
// below it. Ten boxes group into five findings, so the numbers repeat --
|
|
1487
|
+
// that repetition is the information: it says these four boxes are one
|
|
1488
|
+
// problem, not four.
|
|
1489
|
+
if (r.finding) {
|
|
1490
|
+
var tag = el("span", "bxn", String(r.finding));
|
|
1491
|
+
tag.style.background = color;
|
|
1492
|
+
// White was hardcoded, which fails on the lighter severity fills:
|
|
1493
|
+
// white on the amber #ca8a04 is 2.6:1 and on the orange #ea580c is
|
|
1494
|
+
// 3.4:1, both under the 4.5:1 an 11px bold label needs. Chosen by
|
|
1495
|
+
// measured luminance so a new severity colour cannot reintroduce it.
|
|
1496
|
+
tag.style.color = readableOn(color);
|
|
1497
|
+
box.appendChild(tag);
|
|
1498
|
+
}
|
|
1499
|
+
var label = (r.finding ? "Finding " + r.finding + " — " : "") +
|
|
1500
|
+
(r.severityForPersona || r.severity || "barrier") + ": " + (r.type || "issue") +
|
|
1501
|
+
((r.wcag || r.wcagCriteria || []).length ? " (WCAG " + (r.wcag || r.wcagCriteria).join(", ") + ")" : "") +
|
|
1502
|
+
(r.element ? " — " + r.element : "");
|
|
1503
|
+
box.title = label;
|
|
1504
|
+
box.setAttribute("aria-label", label);
|
|
1505
|
+
box.setAttribute("role", "img");
|
|
1506
|
+
frame.appendChild(box);
|
|
1507
|
+
});
|
|
1508
|
+
|
|
1509
|
+
var sevs = [];
|
|
1510
|
+
var anyWeighted = false;
|
|
1511
|
+
drawn.forEach(function (r) {
|
|
1512
|
+
if (r.severityForPersona) anyWeighted = true;
|
|
1513
|
+
var s2 = String(r.severityForPersona || r.severity || "").toLowerCase();
|
|
1514
|
+
if (s2 && sevs.indexOf(s2) < 0) sevs.push(s2);
|
|
1515
|
+
});
|
|
1516
|
+
sevs.sort(function (a, c) {
|
|
1517
|
+
return (SEV_ORDER[a] === undefined ? 9 : SEV_ORDER[a]) -
|
|
1518
|
+
(SEV_ORDER[c] === undefined ? 9 : SEV_ORDER[c]);
|
|
1519
|
+
});
|
|
1520
|
+
if (sevs.length) {
|
|
1521
|
+
var lg = el("ul", "lg");
|
|
1522
|
+
sevs.forEach(function (s2) {
|
|
1523
|
+
var li = el("li");
|
|
1524
|
+
var sw = el("span", "sw"); sw.style.background = SEV_COLOR[s2] || "#64748b";
|
|
1525
|
+
li.appendChild(sw);
|
|
1526
|
+
// Counted by the SAME grade the swatch is labelled with. Counting the
|
|
1527
|
+
// raw grade under a weighted label printed "critical (0)" next to ten
|
|
1528
|
+
// red boxes.
|
|
1529
|
+
li.appendChild(document.createTextNode(s2 + " (" + drawn.filter(function (r) {
|
|
1530
|
+
return String(r.severityForPersona || r.severity || "").toLowerCase() === s2;
|
|
1531
|
+
}).length + ")"));
|
|
1532
|
+
lg.appendChild(li);
|
|
1533
|
+
});
|
|
1534
|
+
wrap.appendChild(lg);
|
|
1535
|
+
// Name the scale. The same colours on an unweighted map would mean
|
|
1536
|
+
// something else, and a legend that does not say which is which is why
|
|
1537
|
+
// the colours looked like they aligned with nothing.
|
|
1538
|
+
var counted = drawn.filter(function (r) { return r.finding; }).length;
|
|
1539
|
+
wrap.appendChild(el("p", "lgnote",
|
|
1540
|
+
(anyWeighted
|
|
1541
|
+
? "Coloured by severity for this persona — the same grade the findings below are ranked by."
|
|
1542
|
+
: "Coloured by WCAG severity.") +
|
|
1543
|
+
(counted ? " Numbers match the findings list; boxes sharing a number are one finding." : "")));
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
// Barriers that fall outside the capture are listed, not silently dropped.
|
|
1547
|
+
// They are real findings; the image just cannot show them.
|
|
1548
|
+
if (off.length) {
|
|
1549
|
+
var ol = el("ul", "offlist");
|
|
1550
|
+
ol.appendChild(el("li", null, off.length + " barrier" + (off.length === 1 ? "" : "s") +
|
|
1551
|
+
" outside the captured area, listed rather than drawn:"));
|
|
1552
|
+
off.slice(0, 8).forEach(function (r) {
|
|
1553
|
+
ol.appendChild(el("li", null, "• " + (r.severity || "barrier") + " " + (r.type || "") +
|
|
1554
|
+
(r.element ? " — " + r.element : "")));
|
|
1555
|
+
});
|
|
1556
|
+
wrap.appendChild(ol);
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
var file = shot[b.fileKey || "screenshotFile"];
|
|
1560
|
+
if (b.fetchTool && file) {
|
|
1561
|
+
try {
|
|
1562
|
+
var res = await app.callServerTool({ name: b.fetchTool, arguments: { file: file } });
|
|
1563
|
+
var blk = (res && res.content || []).filter(function (c) { return c.type === "image"; })[0];
|
|
1564
|
+
if (blk && blk.data) img.src = "data:" + (blk.mimeType || "image/png") + ";base64," + blk.data;
|
|
1565
|
+
} catch (e) { /* boxes still convey positions even without the picture */ }
|
|
1566
|
+
}
|
|
1567
|
+
if (!img.src && shot.screenshot) {
|
|
1568
|
+
img.src = String(shot.screenshot).startsWith("data:")
|
|
1569
|
+
? shot.screenshot : "data:image/png;base64," + shot.screenshot;
|
|
1570
|
+
}
|
|
1571
|
+
if (!img.src) {
|
|
1572
|
+
// Never leave boxes floating over nothing: say the image is missing.
|
|
1573
|
+
img.remove();
|
|
1574
|
+
frame.appendChild(el("p", "cap", "Screenshot unavailable — barrier positions listed below rather than drawn."));
|
|
1575
|
+
}
|
|
1576
|
+
return wrap;
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
function section(title, count, node, open) {
|
|
1580
|
+
var d = el("details", "sec");
|
|
1581
|
+
if (open) d.open = true;
|
|
1582
|
+
var sm = el("summary");
|
|
1583
|
+
sm.appendChild(el("span", "chev"));
|
|
1584
|
+
sm.appendChild(el("span", "stitle", title));
|
|
1585
|
+
if (count !== null && count !== undefined) sm.appendChild(el("span", "scount", String(count)));
|
|
1586
|
+
d.appendChild(sm);
|
|
1587
|
+
var body = el("div", "sbody"); body.appendChild(node); d.appendChild(body);
|
|
1588
|
+
return d;
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
var tabSeq = 0;
|
|
1592
|
+
// Tabs, built to the WAI-ARIA tabs pattern rather than as styled buttons:
|
|
1593
|
+
// roles, aria-selected, aria-controls, a roving tabindex so Tab enters the
|
|
1594
|
+
// strip once, and Arrow/Home/End to move between tabs. This widget reports
|
|
1595
|
+
// accessibility barriers; it does not get to introduce one.
|
|
1596
|
+
async function tabsBlock(data, b) {
|
|
1597
|
+
var rows = at(data, b.field);
|
|
1598
|
+
if (!isObjArray(rows)) return null;
|
|
1599
|
+
var order = [], groups = {};
|
|
1600
|
+
rows.forEach(function (r) {
|
|
1601
|
+
var key = String(r[b.groupBy] === undefined ? "unknown" : r[b.groupBy]);
|
|
1602
|
+
if (!groups[key]) { groups[key] = []; order.push(key); }
|
|
1603
|
+
groups[key].push(r);
|
|
1604
|
+
});
|
|
1605
|
+
if (order.length < 2) {
|
|
1606
|
+
// One group is not a tab strip; render the inner block on its own.
|
|
1607
|
+
var solo = await buildBlock(data, Object.assign({}, b.render, { field: b.field }));
|
|
1608
|
+
return solo ? solo.node : null;
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
var wrap = el("div");
|
|
1612
|
+
var strip = el("div", "tabs");
|
|
1613
|
+
strip.setAttribute("role", "tablist");
|
|
1614
|
+
var uid = "tb" + (++tabSeq);
|
|
1615
|
+
var panels = [];
|
|
1616
|
+
|
|
1617
|
+
// A full URL is a poor tab label; the path is what distinguishes them.
|
|
1618
|
+
var shortLabel = function (v) {
|
|
1619
|
+
try {
|
|
1620
|
+
var u = new URL(v);
|
|
1621
|
+
return u.pathname === "/" ? u.hostname : u.pathname;
|
|
1622
|
+
} catch (_) { return titleize(v); }
|
|
1623
|
+
};
|
|
1624
|
+
|
|
1625
|
+
for (var i = 0; i < order.length; i++) {
|
|
1626
|
+
var key = order[i];
|
|
1627
|
+
var btn = el("button", "tab");
|
|
1628
|
+
btn.type = "button";
|
|
1629
|
+
btn.id = uid + "-t" + i;
|
|
1630
|
+
btn.setAttribute("role", "tab");
|
|
1631
|
+
btn.setAttribute("aria-controls", uid + "-p" + i);
|
|
1632
|
+
btn.setAttribute("aria-selected", i === 0 ? "true" : "false");
|
|
1633
|
+
btn.tabIndex = i === 0 ? 0 : -1;
|
|
1634
|
+
btn.appendChild(document.createTextNode(shortLabel(key)));
|
|
1635
|
+
btn.appendChild(el("span", "tabn", String(groups[key].length)));
|
|
1636
|
+
// The full value stays reachable for anything the label abbreviates.
|
|
1637
|
+
btn.title = key;
|
|
1638
|
+
strip.appendChild(btn);
|
|
1639
|
+
|
|
1640
|
+
var panel = el("div");
|
|
1641
|
+
panel.id = uid + "-p" + i;
|
|
1642
|
+
panel.setAttribute("role", "tabpanel");
|
|
1643
|
+
panel.setAttribute("aria-labelledby", btn.id);
|
|
1644
|
+
if (i !== 0) panel.hidden = true;
|
|
1645
|
+
// The inner block reads its rows from a scoped object, so any block type
|
|
1646
|
+
// works here without knowing it is inside a tab.
|
|
1647
|
+
var scoped = {};
|
|
1648
|
+
scoped[b.groupBy + "__rows"] = groups[key];
|
|
1649
|
+
var inner = await buildBlock(scoped, Object.assign({}, b.render, { field: b.groupBy + "__rows" }));
|
|
1650
|
+
if (inner) panel.appendChild(inner.node);
|
|
1651
|
+
panels.push(panel);
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
var tabEls = [].slice.call(strip.children);
|
|
1655
|
+
var select = function (idx) {
|
|
1656
|
+
tabEls.forEach(function (t, j) {
|
|
1657
|
+
var on = j === idx;
|
|
1658
|
+
t.setAttribute("aria-selected", on ? "true" : "false");
|
|
1659
|
+
t.tabIndex = on ? 0 : -1;
|
|
1660
|
+
panels[j].hidden = !on;
|
|
1661
|
+
});
|
|
1662
|
+
tabEls[idx].focus();
|
|
1663
|
+
};
|
|
1664
|
+
tabEls.forEach(function (t, j) {
|
|
1665
|
+
t.addEventListener("click", function () { select(j); });
|
|
1666
|
+
t.addEventListener("keydown", function (e) {
|
|
1667
|
+
var k = e.key, next = null;
|
|
1668
|
+
if (k === "ArrowRight" || k === "ArrowDown") next = (j + 1) % tabEls.length;
|
|
1669
|
+
else if (k === "ArrowLeft" || k === "ArrowUp") next = (j - 1 + tabEls.length) % tabEls.length;
|
|
1670
|
+
else if (k === "Home") next = 0;
|
|
1671
|
+
else if (k === "End") next = tabEls.length - 1;
|
|
1672
|
+
if (next !== null) { e.preventDefault(); select(next); }
|
|
1673
|
+
});
|
|
1674
|
+
});
|
|
1675
|
+
|
|
1676
|
+
wrap.appendChild(strip);
|
|
1677
|
+
panels.forEach(function (p) { wrap.appendChild(p); });
|
|
1678
|
+
return wrap;
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
async function buildBlock(data, b) {
|
|
1682
|
+
if (b.type === "kv") {
|
|
1683
|
+
var pairs = [];
|
|
1684
|
+
if (b.field) {
|
|
1685
|
+
used[b.field.split(".")[0]] = true;
|
|
1686
|
+
var o = at(data, b.field);
|
|
1687
|
+
if (o && typeof o === "object") flattenPairs(o, "", pairs);
|
|
1688
|
+
}
|
|
1689
|
+
(b.fields || []).forEach(function (f) {
|
|
1690
|
+
used[f.split(".")[0]] = true;
|
|
1691
|
+
var v = at(data, f);
|
|
1692
|
+
if (v !== undefined) pairs.push([titleize(f.split(".").pop()), v]);
|
|
1693
|
+
});
|
|
1694
|
+
return pairs.length ? { node: kvTable(pairs), count: pairs.length } : null;
|
|
1695
|
+
}
|
|
1696
|
+
if (b.type === "table") {
|
|
1697
|
+
used[b.field.split(".")[0]] = true;
|
|
1698
|
+
var rows = at(data, b.field);
|
|
1699
|
+
if (!isObjArray(rows)) return null;
|
|
1700
|
+
// One row is a record, not a table. Laying a single result out
|
|
1701
|
+
// horizontally gives every field a column, so sixteen fields became
|
|
1702
|
+
// sixteen columns of a one-row grid -- unreadable at any width, and the
|
|
1703
|
+
// nested ones printed JSON. Flattened into label/value pairs it reads
|
|
1704
|
+
// like the rest of the widget.
|
|
1705
|
+
if (rows.length === 1) {
|
|
1706
|
+
var recPairs = [];
|
|
1707
|
+
flattenPairs(rows[0], "", recPairs);
|
|
1708
|
+
return { node: kvTable(recPairs), count: 1 };
|
|
1709
|
+
}
|
|
1710
|
+
return { node: gridTable(rows, b.columns), count: rows.length };
|
|
1711
|
+
}
|
|
1712
|
+
if (b.type === "findings") {
|
|
1713
|
+
used[b.field.split(".")[0]] = true;
|
|
1714
|
+
var f = at(data, b.field);
|
|
1715
|
+
return isObjArray(f) ? { node: findingsList(f, b), count: f.length } : null;
|
|
1716
|
+
}
|
|
1717
|
+
if (b.type === "score") {
|
|
1718
|
+
[b.gradeField, b.scoreField, b.categoriesField].forEach(function (p) { if (p) used[p.split(".")[0]] = true; });
|
|
1719
|
+
var node = scoreBlock(data, b);
|
|
1720
|
+
return node.childNodes.length ? { node: node, count: null } : null;
|
|
1721
|
+
}
|
|
1722
|
+
if (b.type === "image") {
|
|
1723
|
+
[b.urlField, b.dataField, b.fetchArgsField, b.caption].forEach(function (p) { if (p) used[p.split(".")[0]] = true; });
|
|
1724
|
+
return { node: await imageBlock(data, b), count: null };
|
|
1725
|
+
}
|
|
1726
|
+
if (b.type === "traits") {
|
|
1727
|
+
used[b.field.split(".")[0]] = true;
|
|
1728
|
+
var rows = normaliseTraits(at(data, b.field), b);
|
|
1729
|
+
return rows.length ? { node: traitsBlock(rows, b), count: rows.length } : null;
|
|
1730
|
+
}
|
|
1731
|
+
if (b.type === "manager") {
|
|
1732
|
+
used[b.field.split(".")[0]] = true;
|
|
1733
|
+
var mnode = managerBlock(data, b);
|
|
1734
|
+
return mnode.childNodes.length ? { node: mnode, count: null } : null;
|
|
1735
|
+
}
|
|
1736
|
+
if (b.type === "editor") {
|
|
1737
|
+
used[b.field.split(".")[0]] = true;
|
|
1738
|
+
if (b.personaField) used[b.personaField.split(".")[0]] = true;
|
|
1739
|
+
var erows = normaliseTraits(at(data, b.field), b);
|
|
1740
|
+
return erows.length ? { node: editorBlock(erows, b, data), count: erows.length } : null;
|
|
1741
|
+
}
|
|
1742
|
+
if (b.type === "chain") {
|
|
1743
|
+
[b.field, b.bottleneckField, b.additiveField, b.totalField, b.overlaysField].forEach(function (f) {
|
|
1744
|
+
if (f) used[f.split(".")[0]] = true;
|
|
1745
|
+
});
|
|
1746
|
+
var node = await chainBlock(data, b);
|
|
1747
|
+
return node ? { node: node, count: null } : null;
|
|
1748
|
+
}
|
|
1749
|
+
if (b.type === "levels") {
|
|
1750
|
+
[b.field, b.valueField, b.labelField, b.behaviorsField].forEach(function (f) {
|
|
1751
|
+
if (f) used[f.split(".")[0]] = true;
|
|
1752
|
+
});
|
|
1753
|
+
var node = levelsBlock(data, b);
|
|
1754
|
+
return node ? { node: node, count: null } : null;
|
|
1755
|
+
}
|
|
1756
|
+
if (b.type === "tabs") {
|
|
1757
|
+
used[b.field.split(".")[0]] = true;
|
|
1758
|
+
var node = await tabsBlock(data, b);
|
|
1759
|
+
return node ? { node: node, count: null } : null;
|
|
1760
|
+
}
|
|
1761
|
+
if (b.type === "overlay") {
|
|
1762
|
+
used[b.field.split(".")[0]] = true;
|
|
1763
|
+
var node = await overlayBlock(data, b);
|
|
1764
|
+
return node ? { node: node, count: null } : null;
|
|
1765
|
+
}
|
|
1766
|
+
if (b.type === "note") {
|
|
1767
|
+
used[b.field.split(".")[0]] = true;
|
|
1768
|
+
var t = at(data, b.field);
|
|
1769
|
+
return t ? { node: el("p", null, fmt(t)), count: null } : null;
|
|
1770
|
+
}
|
|
1771
|
+
if (b.type === "rest") {
|
|
1772
|
+
var rest = [];
|
|
1773
|
+
Object.keys(data).forEach(function (k) {
|
|
1774
|
+
if (used[k]) return;
|
|
1775
|
+
var v = data[k];
|
|
1776
|
+
if (isObjArray(v)) return;
|
|
1777
|
+
if (v && typeof v === "object" && !Array.isArray(v)) {
|
|
1778
|
+
flattenPairs(v, titleize(k), rest);
|
|
1779
|
+
} else rest.push([titleize(k), v]);
|
|
1780
|
+
});
|
|
1781
|
+
return rest.length ? { node: kvTable(rest), count: rest.length } : null;
|
|
1782
|
+
}
|
|
1783
|
+
return null;
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
async function buildSections(data, blocks) {
|
|
1787
|
+
var out = [];
|
|
1788
|
+
for (var i = 0; i < blocks.length; i++) {
|
|
1789
|
+
var b = blocks[i];
|
|
1790
|
+
if (b.type === "drawer") continue;
|
|
1791
|
+
var built = await buildBlock(data, b);
|
|
1792
|
+
if (built) out.push(section(b.title || titleize(b.type), built.count, built.node, false));
|
|
1793
|
+
}
|
|
1794
|
+
return out;
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
function healthOf(data, spec) {
|
|
1798
|
+
if (!spec || !spec.checks || !spec.checks.length) return null;
|
|
1799
|
+
var issues = [], measured = false;
|
|
1800
|
+
spec.checks.forEach(function (c) {
|
|
1801
|
+
var arr = at(data, c.field);
|
|
1802
|
+
if (!isObjArray(arr)) return;
|
|
1803
|
+
measured = true;
|
|
1804
|
+
arr.forEach(function (row) {
|
|
1805
|
+
if (!row[c.flag]) issues.push(c.failLabel + ": " + (row[c.nameKey || "name"] || "?"));
|
|
1806
|
+
});
|
|
1807
|
+
});
|
|
1808
|
+
// Three states: "measured nothing" is a different claim from "nothing is
|
|
1809
|
+
// wrong", and collapsing them reports healthy on no evidence.
|
|
1810
|
+
if (!measured) return { state: "unknown", label: "UNKNOWN", issues: [] };
|
|
1811
|
+
return issues.length ? { state: "bad", label: "DEGRADED", issues: issues }
|
|
1812
|
+
: { state: "ok", label: "HEALTHY", issues: [] };
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
// Renders are generation-stamped because render() is async: image blocks
|
|
1816
|
+
// await callServerTool, so two overlapping renders interleave -- the second
|
|
1817
|
+
// clears the root, then the first resumes and appends its blocks into it,
|
|
1818
|
+
// and the panel shows everything twice. Any render whose generation is stale
|
|
1819
|
+
// stops appending.
|
|
1820
|
+
var renderGen = 0;
|
|
1821
|
+
|
|
1822
|
+
async function render(data) {
|
|
1823
|
+
if (!data) return;
|
|
1824
|
+
var gen = ++renderGen;
|
|
1825
|
+
root.replaceChildren();
|
|
1826
|
+
used = {};
|
|
1827
|
+
|
|
1828
|
+
var hero = SPEC.hero || {};
|
|
1829
|
+
var variant = hero.variant || "gradient";
|
|
1830
|
+
var band = el("div", "hero " + variant);
|
|
1831
|
+
var hrow = el("div", "hrow");
|
|
1832
|
+
if (LOGO) {
|
|
1833
|
+
var badge = el("span", "badge");
|
|
1834
|
+
var im = document.createElement("img"); im.src = LOGO; im.alt = "";
|
|
1835
|
+
badge.appendChild(im); hrow.appendChild(badge);
|
|
1836
|
+
}
|
|
1837
|
+
// Payload title when the view is about a named thing; the spec title is the
|
|
1838
|
+
// fallback and the label for views that are about the tool itself.
|
|
1839
|
+
var titleResolved = SPEC.titleField && at(data, SPEC.titleField);
|
|
1840
|
+
var titleText = titleResolved
|
|
1841
|
+
? (SPEC.titlePrefix || "") + fmt(titleResolved)
|
|
1842
|
+
: SPEC.title;
|
|
1843
|
+
hrow.appendChild(el("span", "htitle", String(titleText)));
|
|
1844
|
+
|
|
1845
|
+
var h = healthOf(data, hero.health);
|
|
1846
|
+
if (h) {
|
|
1847
|
+
var pill = el("span", "health " + h.state);
|
|
1848
|
+
pill.appendChild(el("i", "dot"));
|
|
1849
|
+
pill.appendChild(document.createTextNode(h.label));
|
|
1850
|
+
hrow.appendChild(pill);
|
|
1851
|
+
}
|
|
1852
|
+
band.appendChild(hrow);
|
|
1853
|
+
|
|
1854
|
+
if (h && h.issues.length) {
|
|
1855
|
+
band.appendChild(el("p", "hnote", h.issues.length === 1 ? h.issues[0]
|
|
1856
|
+
: h.issues.length + " problems: " + h.issues.join("; ")));
|
|
1857
|
+
} else if (hero.subtitle) {
|
|
1858
|
+
var sub = typeof hero.subtitle === "string" ? hero.subtitle : at(data, hero.subtitle.field);
|
|
1859
|
+
if (sub) band.appendChild(el("p", "hnote", fmt(sub)));
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
var actionList = (hero.actions || []).slice();
|
|
1863
|
+
if (SPEC.toolPage) actionList.push({ label: "View tool docs", url: SPEC.toolPage });
|
|
1864
|
+
if (actionList.length) {
|
|
1865
|
+
var acts = el("div", "acts");
|
|
1866
|
+
actionList.forEach(function (a) {
|
|
1867
|
+
var url = a.urlField ? at(data, a.urlField) : a.url;
|
|
1868
|
+
if (!url) return;
|
|
1869
|
+
var btn = el("button", "act", a.label);
|
|
1870
|
+
btn.type = "button";
|
|
1871
|
+
btn.addEventListener("click", function () { app.openLink({ url: String(url) }); });
|
|
1872
|
+
acts.appendChild(btn);
|
|
1873
|
+
});
|
|
1874
|
+
if (acts.childNodes.length) band.appendChild(acts);
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
if (hero.facts && hero.facts.length) {
|
|
1878
|
+
var strip = el("div", "facts");
|
|
1879
|
+
hero.facts.forEach(function (f) {
|
|
1880
|
+
var v = at(data, f.field);
|
|
1881
|
+
var value = null, attn = false;
|
|
1882
|
+
if (f.countFlag && isObjArray(v)) {
|
|
1883
|
+
var ok = v.filter(function (x) { return x[f.countFlag]; }).length;
|
|
1884
|
+
value = ok + "/" + v.length;
|
|
1885
|
+
attn = !!f.attnWhenShort && ok < v.length;
|
|
1886
|
+
} else if (Array.isArray(v)) {
|
|
1887
|
+
value = String(v.length);
|
|
1888
|
+
} else if (v && typeof v === "object") {
|
|
1889
|
+
// A bag of named numbers is a countable collection too. Without this
|
|
1890
|
+
// a fact pointing at a traits object silently produced nothing, which
|
|
1891
|
+
// reads as a missing field rather than an unhandled shape.
|
|
1892
|
+
value = String(Object.keys(v).length);
|
|
1893
|
+
} else if (v !== undefined) {
|
|
1894
|
+
value = String(v);
|
|
1895
|
+
}
|
|
1896
|
+
if (value === null) return;
|
|
1897
|
+
used[f.field.split(".")[0]] = true;
|
|
1898
|
+
var chip = el("span", "fact" + (attn ? " attn" : ""));
|
|
1899
|
+
chip.appendChild(el("b", null, value));
|
|
1900
|
+
chip.appendChild(el("span", null, f.label));
|
|
1901
|
+
strip.appendChild(chip);
|
|
1902
|
+
});
|
|
1903
|
+
if (strip.childNodes.length) band.appendChild(strip);
|
|
1904
|
+
}
|
|
1905
|
+
root.appendChild(band);
|
|
1906
|
+
|
|
1907
|
+
// Top-level blocks render inline; a drawer collects its children so the
|
|
1908
|
+
// default state stays a card rather than a page.
|
|
1909
|
+
for (var i = 0; i < SPEC.blocks.length; i++) {
|
|
1910
|
+
if (gen !== renderGen) return;
|
|
1911
|
+
var b = SPEC.blocks[i];
|
|
1912
|
+
if (b.type === "drawer") {
|
|
1913
|
+
var kids = await buildSections(data, b.blocks || []);
|
|
1914
|
+
if (gen !== renderGen) return;
|
|
1915
|
+
if (!kids.length) continue;
|
|
1916
|
+
var d = el("details", "drawer");
|
|
1917
|
+
if (b.open) d.open = true;
|
|
1918
|
+
var sm = el("summary");
|
|
1919
|
+
sm.appendChild(el("span", "chev"));
|
|
1920
|
+
sm.appendChild(el("span", null, b.title || "Details"));
|
|
1921
|
+
sm.appendChild(el("span", "dcount", kids.length + (kids.length === 1 ? " section" : " sections")));
|
|
1922
|
+
d.appendChild(sm);
|
|
1923
|
+
var inner = el("div", "inner");
|
|
1924
|
+
kids.forEach(function (k) { inner.appendChild(k); });
|
|
1925
|
+
d.appendChild(inner);
|
|
1926
|
+
root.appendChild(d);
|
|
1927
|
+
} else {
|
|
1928
|
+
var built = await buildBlock(data, b);
|
|
1929
|
+
if (gen !== renderGen) return;
|
|
1930
|
+
if (!built) continue;
|
|
1931
|
+
var wrap = el("div", "body");
|
|
1932
|
+
if (b.title) wrap.appendChild(el("h2", "btitle", b.title));
|
|
1933
|
+
wrap.appendChild(built.node);
|
|
1934
|
+
root.appendChild(wrap);
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
if (SPEC.footer) {
|
|
1939
|
+
var fv = at(data, SPEC.footer.field);
|
|
1940
|
+
if (fv) {
|
|
1941
|
+
var p = el("p", "foot");
|
|
1942
|
+
p.appendChild(document.createTextNode(SPEC.footer.label + " "));
|
|
1943
|
+
p.appendChild(el("span", "path", fmt(fv)));
|
|
1944
|
+
root.appendChild(p);
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
function applyHostContext(ctx) {
|
|
1950
|
+
document.documentElement.classList.toggle("dark", ctx && ctx.theme === "dark");
|
|
1951
|
+
if (ctx && ctx.styles && ctx.styles.variables && applyHostStyleVariables) {
|
|
1952
|
+
applyHostStyleVariables(ctx.styles.variables);
|
|
1953
|
+
}
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
// Handlers before connect(), or the first result is delivered into nothing.
|
|
1957
|
+
var lastPayload = null;
|
|
1958
|
+
|
|
1959
|
+
app.ontoolresult = function (res) {
|
|
1960
|
+
try {
|
|
1961
|
+
var data = res && res.structuredContent;
|
|
1962
|
+
if (!data && res && res.content && res.content[0] && res.content[0].text) {
|
|
1963
|
+
data = JSON.parse(res.content[0].text);
|
|
1964
|
+
}
|
|
1965
|
+
// Hosts may deliver the same result more than once (re-mount, replay,
|
|
1966
|
+
// reconnect). Re-rendering identical data is pure churn, and while the
|
|
1967
|
+
// generation guard keeps it correct, skipping it avoids refetching every
|
|
1968
|
+
// image over callServerTool for a panel that already shows them.
|
|
1969
|
+
var sig = null;
|
|
1970
|
+
try { sig = JSON.stringify(data); } catch (_) { sig = null; }
|
|
1971
|
+
if (sig !== null && sig === lastPayload) return;
|
|
1972
|
+
lastPayload = sig;
|
|
1973
|
+
render(data);
|
|
1974
|
+
} catch (e) {
|
|
1975
|
+
msg.textContent = "Could not read the payload: " + (e && e.message ? e.message : e);
|
|
1976
|
+
}
|
|
1977
|
+
};
|
|
1978
|
+
app.onhostcontextchanged = applyHostContext;
|
|
1979
|
+
|
|
1980
|
+
try {
|
|
1981
|
+
await app.connect();
|
|
1982
|
+
applyHostContext(app.getHostContext());
|
|
1983
|
+
} catch (e) {
|
|
1984
|
+
msg.textContent = "Could not connect to the host: " + (e && e.message ? e.message : e);
|
|
1985
|
+
}
|
|
1986
|
+
})();
|
|
1987
|
+
`;
|
|
1988
|
+
/**
|
|
1989
|
+
* Render a spec to a complete MCP Apps resource document.
|
|
1990
|
+
*
|
|
1991
|
+
* Top-level await is deliberately absent from the emitted script: older iframe
|
|
1992
|
+
* contexts throw on it, and the throw surfaces only as a blank widget.
|
|
1993
|
+
*/
|
|
1994
|
+
/**
|
|
1995
|
+
* Definitions baked into every widget, keyed by trait name.
|
|
1996
|
+
*
|
|
1997
|
+
* Sourced from TRAIT_DEFINITIONS, the authoritative reference matrix, rather
|
|
1998
|
+
* than written fresh -- a tooltip that disagrees with the docs is worse than
|
|
1999
|
+
* no tooltip. Keys are short because this ships inside every view; ~4KB for
|
|
2000
|
+
* all 26 traits, against a 300KB runtime bundle.
|
|
2001
|
+
*
|
|
2002
|
+
* Schwartz values have no runtime description map (they are interfaces with
|
|
2003
|
+
* JSDoc), so value meters currently render without tooltips rather than with
|
|
2004
|
+
* invented ones.
|
|
2005
|
+
*/
|
|
2006
|
+
function buildGlossary() {
|
|
2007
|
+
const out = {};
|
|
2008
|
+
for (const [key, def] of Object.entries(TRAIT_DEFINITIONS)) {
|
|
2009
|
+
out[key] = {
|
|
2010
|
+
d: def.description,
|
|
2011
|
+
...(def.lowEnd ? { l: def.lowEnd } : {}),
|
|
2012
|
+
...(def.highEnd ? { h: def.highEnd } : {}),
|
|
2013
|
+
};
|
|
2014
|
+
}
|
|
2015
|
+
return out;
|
|
2016
|
+
}
|
|
2017
|
+
let glossaryCache;
|
|
2018
|
+
export function buildWidget(spec) {
|
|
2019
|
+
if (glossaryCache === undefined)
|
|
2020
|
+
glossaryCache = JSON.stringify(buildGlossary());
|
|
2021
|
+
const script = RUNTIME
|
|
2022
|
+
.replace("__SPEC__", () => JSON.stringify(spec))
|
|
2023
|
+
.replace("__GLOSSARY__", () => glossaryCache)
|
|
2024
|
+
.replace("__LOGO__", () => getLogoDataUri());
|
|
2025
|
+
return `<!doctype html><meta charset="utf-8">
|
|
2026
|
+
<meta name="color-scheme" content="light dark">
|
|
2027
|
+
<style>${CSS}</style>
|
|
2028
|
+
<div id="root"><p class="msg" id="msg">Waiting for the host…</p></div>
|
|
2029
|
+
<script type="module">
|
|
2030
|
+
${getExtAppsBundle()}
|
|
2031
|
+
${script}
|
|
2032
|
+
</script>`;
|
|
2033
|
+
}
|
|
2034
|
+
//# sourceMappingURL=widget-kit.js.map
|