@calliopelabs/ui 0.7.0 → 0.12.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/README.md +266 -3
- package/dist/appearance/AppearancePicker.js +4 -2
- package/dist/appearance/AppearanceProvider.d.ts +10 -2
- package/dist/appearance/AppearanceProvider.js +6 -2
- package/dist/appearance/AppearanceScript.d.ts +15 -4
- package/dist/appearance/AppearanceScript.js +11 -5
- package/dist/appearance/appearance.js +1 -1
- package/dist/appearance/boot.d.ts +13 -2
- package/dist/appearance/boot.js +35 -5
- package/dist/appearance/product.d.ts +13 -0
- package/dist/appearance/product.js +2 -2
- package/dist/appearance/runtime-brand.d.ts +106 -0
- package/dist/appearance/runtime-brand.js +420 -0
- package/dist/css/appearance.js +147 -0
- package/dist/css/calliope.css +1221 -0
- package/dist/css/components.css +729 -0
- package/dist/css/tokens.css +491 -0
- package/dist/data/DefinitionList.d.ts +16 -0
- package/dist/data/DefinitionList.js +9 -0
- package/dist/data/Stat.d.ts +25 -0
- package/dist/data/Stat.js +19 -0
- package/dist/data/Tag.d.ts +21 -0
- package/dist/data/Tag.js +18 -0
- package/dist/data/types.d.ts +3 -2
- package/dist/data/use-local-table.d.ts +36 -0
- package/dist/data/use-local-table.js +127 -0
- package/dist/index.d.ts +44 -3
- package/dist/index.js +40 -1
- package/dist/nav/Nav.d.ts +2 -1
- package/dist/nav/Nav.js +34 -15
- package/dist/nav/model.d.ts +21 -0
- package/dist/page/PageHeader.d.ts +5 -3
- package/dist/page/PageHeader.js +5 -4
- package/dist/page/Panel.d.ts +19 -0
- package/dist/page/Panel.js +12 -0
- package/dist/primitives/Avatar.d.ts +37 -0
- package/dist/primitives/Avatar.js +43 -0
- package/dist/primitives/Field.d.ts +33 -0
- package/dist/primitives/Field.js +30 -0
- package/dist/primitives/Segmented.d.ts +30 -0
- package/dist/primitives/Segmented.js +20 -0
- package/dist/primitives/Select.d.ts +40 -0
- package/dist/primitives/Select.js +47 -0
- package/dist/primitives/Switch.d.ts +7 -0
- package/dist/primitives/Switch.js +8 -0
- package/dist/primitives/Textarea.d.ts +3 -0
- package/dist/primitives/Textarea.js +6 -0
- package/dist/provider/messages.d.ts +430 -0
- package/dist/provider/messages.js +666 -0
- package/dist/shell/BrandMark.d.ts +25 -3
- package/dist/shell/BrandMark.js +34 -4
- package/dist/shell/RailPanel.js +2 -2
- package/dist/shell/RailTree.js +2 -2
- package/dist/shell/Sidebar.d.ts +9 -2
- package/dist/shell/Sidebar.js +10 -4
- package/dist/shell/UserMenu.d.ts +3 -1
- package/dist/shell/UserMenu.js +3 -11
- package/dist/shell/rail-boot.d.ts +28 -0
- package/dist/shell/rail-boot.js +45 -0
- package/dist/shell/rail-state.d.ts +6 -1
- package/dist/shell/rail-state.js +18 -2
- package/dist/styles/preset.css +4 -0
- package/dist/styles/theme.css +36 -13
- package/dist/theme/css-layer.d.ts +19 -0
- package/dist/theme/css-layer.js +171 -0
- package/dist/theme/css.d.ts +1 -0
- package/dist/theme/css.js +45 -9
- package/dist/workflow/ArtifactCard.d.ts +47 -0
- package/dist/workflow/ArtifactCard.js +45 -0
- package/dist/workflow/DocumentReview.d.ts +74 -0
- package/dist/workflow/DocumentReview.js +54 -0
- package/dist/workflow/FormReview.d.ts +53 -0
- package/dist/workflow/FormReview.js +83 -0
- package/dist/workflow/GateBadge.d.ts +11 -0
- package/dist/workflow/GateBadge.js +14 -0
- package/dist/workflow/MediaReview.d.ts +63 -0
- package/dist/workflow/MediaReview.js +166 -0
- package/dist/workflow/ReviewPanel.d.ts +41 -0
- package/dist/workflow/ReviewPanel.js +59 -0
- package/dist/workflow/RunTimeline.d.ts +25 -0
- package/dist/workflow/RunTimeline.js +56 -0
- package/dist/workflow/WorkflowCard.d.ts +39 -0
- package/dist/workflow/WorkflowCard.js +56 -0
- package/dist/workflow/WorkflowGallery.d.ts +43 -0
- package/dist/workflow/WorkflowGallery.js +69 -0
- package/dist/workflow/WorkflowQuestionCard.d.ts +18 -0
- package/dist/workflow/WorkflowQuestionCard.js +78 -0
- package/dist/workflow/canvas/WorkflowCanvas.d.ts +39 -0
- package/dist/workflow/canvas/WorkflowCanvas.js +648 -0
- package/dist/workflow/definition.d.ts +408 -0
- package/dist/workflow/definition.js +411 -0
- package/dist/workflow/editor/WorkflowDiff.d.ts +17 -0
- package/dist/workflow/editor/WorkflowDiff.js +25 -0
- package/dist/workflow/editor/WorkflowImportPreview.d.ts +19 -0
- package/dist/workflow/editor/WorkflowImportPreview.js +50 -0
- package/dist/workflow/editor/WorkflowInspector.d.ts +40 -0
- package/dist/workflow/editor/WorkflowInspector.js +349 -0
- package/dist/workflow/editor/WorkflowJsonView.d.ts +16 -0
- package/dist/workflow/editor/WorkflowJsonView.js +40 -0
- package/dist/workflow/editor/WorkflowPalette.d.ts +16 -0
- package/dist/workflow/editor/WorkflowPalette.js +36 -0
- package/dist/workflow/editor/WorkflowProblems.d.ts +20 -0
- package/dist/workflow/editor/WorkflowProblems.js +51 -0
- package/dist/workflow/editor/WorkflowWorkspace.d.ts +49 -0
- package/dist/workflow/editor/WorkflowWorkspace.js +154 -0
- package/dist/workflow/gallery.d.ts +21 -0
- package/dist/workflow/gallery.js +29 -0
- package/dist/workflow/layout.d.ts +49 -0
- package/dist/workflow/layout.js +162 -0
- package/dist/workflow/model.d.ts +50 -0
- package/dist/workflow/model.js +7 -0
- package/dist/workflow/palette.d.ts +34 -0
- package/dist/workflow/palette.js +70 -0
- package/dist/workflow/portable.d.ts +162 -0
- package/dist/workflow/portable.js +547 -0
- package/dist/workflow/problems.d.ts +38 -0
- package/dist/workflow/problems.js +409 -0
- package/dist/workflow/question.d.ts +26 -0
- package/dist/workflow/question.js +1 -0
- package/dist/workflow/run.d.ts +94 -0
- package/dist/workflow/run.js +31 -0
- package/dist/workflow/workspace.d.ts +33 -0
- package/dist/workflow/workspace.js +17 -0
- package/package.json +6 -1
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { BOOT_SOURCE, bootConfig } from "../appearance/boot.js";
|
|
2
|
+
import { astroliftBrand, chatStudioBrand, cykickBrand, loadrBrand, workbenchBrand, zentinelleBrand, } from "../appearance/brands.js";
|
|
3
|
+
import { CORNERS, defineProduct } from "../appearance/product.js";
|
|
4
|
+
import { accentDeclarations, block, renderThemeCss } from "./css.js";
|
|
5
|
+
/**
|
|
6
|
+
* The CSS layer: the same tokens and the same boot script, for pages that
|
|
7
|
+
* cannot render React (Django, Jinja, HTMX, Rails, Laravel, Phoenix). Both
|
|
8
|
+
* files are generated here from the token table and the boot source by
|
|
9
|
+
* `npm run theme`, and shipped as `@calliopelabs/ui/css/*`.
|
|
10
|
+
*/
|
|
11
|
+
/** The products the CSS layer knows by name (`data-product`): the family's brands. */
|
|
12
|
+
export const CSS_LAYER_PRODUCTS = [
|
|
13
|
+
astroliftBrand,
|
|
14
|
+
zentinelleBrand,
|
|
15
|
+
workbenchBrand,
|
|
16
|
+
chatStudioBrand,
|
|
17
|
+
loadrBrand,
|
|
18
|
+
cykickBrand,
|
|
19
|
+
].map((config) => defineProduct(config));
|
|
20
|
+
const TOKENS_HEADER = `/*
|
|
21
|
+
* @calliopelabs/ui CSS layer: tokens.
|
|
22
|
+
* Generated by \`npm run theme\` from src/theme/tokens.ts and the product
|
|
23
|
+
* brands. Do not edit by hand.
|
|
24
|
+
*
|
|
25
|
+
* The same values as the Tailwind preset (theme.css plus each product's brand
|
|
26
|
+
* tokens), for pages without React or Tailwind. Every axis is an attribute,
|
|
27
|
+
* on <html> or on any subtree:
|
|
28
|
+
* data-product the brand: its sidebar and its own accent
|
|
29
|
+
* data-mode dark | light (follows the ground)
|
|
30
|
+
* data-ground the surface ramp
|
|
31
|
+
* data-accent a family accent or the product's own; "custom" with an inline --brand-primary
|
|
32
|
+
* data-density compact | cards
|
|
33
|
+
* data-corners 0 | 2 | 4 | 10 (appearance.js sets --radius inline instead)
|
|
34
|
+
* appearance.js stamps all of them before first paint, or a server that
|
|
35
|
+
* keeps the preference renders them itself.
|
|
36
|
+
*/`;
|
|
37
|
+
/** The whole of css/tokens.css. */
|
|
38
|
+
export function renderTokensCss() {
|
|
39
|
+
const theme = renderThemeCss().replace(/^\/\*[\s\S]*?\*\/\n\n/, "");
|
|
40
|
+
const blocks = [TOKENS_HEADER, theme.trimEnd()];
|
|
41
|
+
for (const corners of CORNERS) {
|
|
42
|
+
blocks.push(block(`[data-corners="${corners}"]`, [["--radius", `${corners}px`]], `Corners: ${corners}px`));
|
|
43
|
+
}
|
|
44
|
+
for (const product of CSS_LAYER_PRODUCTS) {
|
|
45
|
+
blocks.push(block(`[data-product="${product.id}"]`, [["--brand-sidebar", product.sidebar]], `Product: ${product.name}`));
|
|
46
|
+
const accent = product.brandAccent;
|
|
47
|
+
if (!accent)
|
|
48
|
+
continue;
|
|
49
|
+
for (const mode of ["dark", "light"]) {
|
|
50
|
+
blocks.push(block(`[data-mode="${mode}"][data-accent="${accent.key}"]`, accentDeclarations(mode, accent), `Accent: ${accent.label} (${product.name}, ${mode})`));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return `${blocks.join("\n\n")}\n`;
|
|
54
|
+
}
|
|
55
|
+
/** JSON that is still safe when the file is pasted into an inline <script>. */
|
|
56
|
+
function scriptJson(value) {
|
|
57
|
+
return JSON.stringify(value).replace(/</g, "\\u003c");
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The whole of css/appearance.js: the boot script AppearanceScript renders
|
|
61
|
+
* (the same source text), the boot configs of the built-in products, and a
|
|
62
|
+
* small API over them. `set` stores the merged preference and runs the boot
|
|
63
|
+
* script again, so first paint and every later change resolve through one
|
|
64
|
+
* code path. Plain ES5, like the boot script.
|
|
65
|
+
*/
|
|
66
|
+
export function renderAppearanceJs() {
|
|
67
|
+
const products = Object.fromEntries(CSS_LAYER_PRODUCTS.map((p) => [p.id, bootConfig(p)]));
|
|
68
|
+
return `/*
|
|
69
|
+
* @calliopelabs/ui CSS layer: appearance.
|
|
70
|
+
* Generated by \`npm run theme\` from src/appearance/boot.ts. Do not edit by hand.
|
|
71
|
+
*
|
|
72
|
+
* Load it in <head>, before the stylesheets, as a plain script element
|
|
73
|
+
* (no async, no defer) with the product on it, so it runs before first
|
|
74
|
+
* paint: src=".../appearance.js" data-product="astrolift".
|
|
75
|
+
*
|
|
76
|
+
* It stamps the person's ground, accent, density and corners on <html> from
|
|
77
|
+
* localStorage, exactly as AppearanceScript does, and leaves an API on
|
|
78
|
+
* window.CalliopeAppearance:
|
|
79
|
+
* get() the appearance on <html> now
|
|
80
|
+
* set(partial) store and apply { ground, accent, density, corners }
|
|
81
|
+
* reset() forget the choice; the product's defaults apply
|
|
82
|
+
* boot(config) use another product's boot config
|
|
83
|
+
* config the active product's grounds, accents and corners
|
|
84
|
+
* products the built-in products' boot configs, by id
|
|
85
|
+
* Every change dispatches "calliope:appearance" on document.
|
|
86
|
+
*/
|
|
87
|
+
(function () {
|
|
88
|
+
var boot = ${BOOT_SOURCE};
|
|
89
|
+
var products = ${scriptJson(products)};
|
|
90
|
+
var d = document.documentElement;
|
|
91
|
+
var hex = /^#[0-9a-f]{6}$/;
|
|
92
|
+
var has = function (o, k) {
|
|
93
|
+
return typeof k === "string" && Object.prototype.hasOwnProperty.call(o, k);
|
|
94
|
+
};
|
|
95
|
+
var stored = function (c) {
|
|
96
|
+
try {
|
|
97
|
+
var parsed = JSON.parse(window.localStorage.getItem(c.key) || "null");
|
|
98
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
99
|
+
} catch (e) {
|
|
100
|
+
return {};
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
/* normalizePartial: only the axes that are valid for this product. A locked accent (22:1) takes no custom colour. */
|
|
104
|
+
var valid = function (c, v) {
|
|
105
|
+
var out = {};
|
|
106
|
+
if (has(c.grounds, v.ground)) out.ground = v.ground;
|
|
107
|
+
if (c.accents.indexOf(v.accent) > -1 || (hex.test(v.accent) && c.minContrast <= 21))
|
|
108
|
+
out.accent = v.accent;
|
|
109
|
+
if (v.density === "compact" || v.density === "cards") out.density = v.density;
|
|
110
|
+
if (c.corners.indexOf(v.corners) > -1) out.corners = v.corners;
|
|
111
|
+
return out;
|
|
112
|
+
};
|
|
113
|
+
var api = {
|
|
114
|
+
products: products,
|
|
115
|
+
config: null,
|
|
116
|
+
get: function () {
|
|
117
|
+
var custom = d.getAttribute("data-accent") === "custom";
|
|
118
|
+
return {
|
|
119
|
+
ground: d.getAttribute("data-ground"),
|
|
120
|
+
mode: d.getAttribute("data-mode"),
|
|
121
|
+
accent: custom ? d.style.getPropertyValue("--brand-primary") : d.getAttribute("data-accent"),
|
|
122
|
+
density: d.getAttribute("data-density"),
|
|
123
|
+
corners: parseInt(d.style.getPropertyValue("--radius"), 10)
|
|
124
|
+
};
|
|
125
|
+
},
|
|
126
|
+
set: function (next) {
|
|
127
|
+
var c = api.config;
|
|
128
|
+
if (!c || !next) return;
|
|
129
|
+
var merged = stored(c);
|
|
130
|
+
for (var k in next) if (has(next, k)) merged[k] = next[k];
|
|
131
|
+
if (typeof merged.accent === "string") merged.accent = merged.accent.toLowerCase();
|
|
132
|
+
try {
|
|
133
|
+
window.localStorage.setItem(c.key, JSON.stringify(valid(c, merged)));
|
|
134
|
+
} catch (e) {}
|
|
135
|
+
apply();
|
|
136
|
+
},
|
|
137
|
+
reset: function () {
|
|
138
|
+
if (!api.config) return;
|
|
139
|
+
try {
|
|
140
|
+
window.localStorage.removeItem(api.config.key);
|
|
141
|
+
} catch (e) {}
|
|
142
|
+
apply();
|
|
143
|
+
},
|
|
144
|
+
boot: function (config) {
|
|
145
|
+
api.config = config;
|
|
146
|
+
apply();
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
var apply = function () {
|
|
150
|
+
/* A custom accent is inline; a family accent comes from the stylesheet. */
|
|
151
|
+
d.style.removeProperty("--brand-primary");
|
|
152
|
+
d.style.removeProperty("--brand-on-primary");
|
|
153
|
+
try {
|
|
154
|
+
boot(api.config);
|
|
155
|
+
} catch (e) {}
|
|
156
|
+
var event;
|
|
157
|
+
try {
|
|
158
|
+
event = new CustomEvent("calliope:appearance", { detail: api.get() });
|
|
159
|
+
} catch (e) {
|
|
160
|
+
event = document.createEvent("CustomEvent");
|
|
161
|
+
event.initCustomEvent("calliope:appearance", false, false, api.get());
|
|
162
|
+
}
|
|
163
|
+
document.dispatchEvent(event);
|
|
164
|
+
};
|
|
165
|
+
window.CalliopeAppearance = api;
|
|
166
|
+
var me = document.currentScript;
|
|
167
|
+
var id = me && me.getAttribute("data-product");
|
|
168
|
+
if (has(products, id)) api.boot(products[id]);
|
|
169
|
+
})();
|
|
170
|
+
`;
|
|
171
|
+
}
|
package/dist/theme/css.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type Mode } from "./tokens.js";
|
|
2
2
|
type Declarations = readonly (readonly [property: string, value: string])[];
|
|
3
|
+
export declare function block(selector: string, declarations: Declarations, comment?: string): string;
|
|
3
4
|
/** The accent tokens one accent paints in one mode. */
|
|
4
5
|
export declare function accentDeclarations(mode: Mode, accent: {
|
|
5
6
|
dark: string;
|
package/dist/theme/css.js
CHANGED
|
@@ -1,11 +1,51 @@
|
|
|
1
1
|
import { ACCENTS, ACCENT_KEYS, BASE_SURFACES, DEFAULT_ACCENT, DENSITIES, GROUNDS, GROUND_KEYS, MODE_TOKENS, STATIC_TOKENS, SURFACES, } from "./tokens.js";
|
|
2
2
|
const STATUS_NAMES = ["success", "warning", "info", "danger"];
|
|
3
|
-
function block(selector, declarations, comment) {
|
|
3
|
+
export function block(selector, declarations, comment) {
|
|
4
4
|
const head = comment ? `/* ${comment} */\n` : "";
|
|
5
5
|
const body = declarations.map(([property, value]) => ` ${property}: ${value};`).join("\n");
|
|
6
6
|
return `${head}${selector} {\n${body}\n}`;
|
|
7
7
|
}
|
|
8
|
-
|
|
8
|
+
/**
|
|
9
|
+
* The sidebar of one ground. A runtime brand (`brandStyles`) reaches it only
|
|
10
|
+
* through `--brand-sidebar-*` variables, each with the ground's own value as
|
|
11
|
+
* the fallback, so a page without a brand paints exactly the token table:
|
|
12
|
+
* dark brand grounds --brand-sidebar, its foreground, secondary text and marker
|
|
13
|
+
* light grounds --brand-sidebar-light (opt-in), with its foreground,
|
|
14
|
+
* secondary text, marker, hover tint and hairline
|
|
15
|
+
* graphite neutral whatever the brand (Astrolift #2286)
|
|
16
|
+
*/
|
|
17
|
+
function sidebarDeclarations(s, mode) {
|
|
18
|
+
const foreground = MODE_TOKENS[mode].sidebarForeground;
|
|
19
|
+
if (s.sidebar === "brand") {
|
|
20
|
+
return [
|
|
21
|
+
["--sidebar", `var(--brand-sidebar, ${s.card})`],
|
|
22
|
+
["--sidebar-foreground", `var(--brand-sidebar-foreground, ${foreground})`],
|
|
23
|
+
["--sidebar-primary", "var(--brand-sidebar-primary, var(--brand-primary))"],
|
|
24
|
+
["--sidebar-muted-foreground", `var(--brand-sidebar-muted-foreground, ${s.mutedForeground})`],
|
|
25
|
+
["--sidebar-accent", s.sidebarAccent],
|
|
26
|
+
["--sidebar-border", s.sidebarBorder],
|
|
27
|
+
];
|
|
28
|
+
}
|
|
29
|
+
if (mode === "light") {
|
|
30
|
+
return [
|
|
31
|
+
["--sidebar", `var(--brand-sidebar-light, ${s.sidebar})`],
|
|
32
|
+
["--sidebar-foreground", `var(--brand-sidebar-foreground, ${foreground})`],
|
|
33
|
+
["--sidebar-primary", "var(--brand-sidebar-primary, var(--brand-primary))"],
|
|
34
|
+
["--sidebar-muted-foreground", `var(--brand-sidebar-muted-foreground, ${s.mutedForeground})`],
|
|
35
|
+
["--sidebar-accent", `var(--brand-sidebar-accent, ${s.sidebarAccent})`],
|
|
36
|
+
["--sidebar-border", `var(--brand-sidebar-border, ${s.sidebarBorder})`],
|
|
37
|
+
];
|
|
38
|
+
}
|
|
39
|
+
return [
|
|
40
|
+
["--sidebar", s.sidebar],
|
|
41
|
+
["--sidebar-foreground", foreground],
|
|
42
|
+
["--sidebar-primary", "var(--brand-primary)"],
|
|
43
|
+
["--sidebar-muted-foreground", s.mutedForeground],
|
|
44
|
+
["--sidebar-accent", s.sidebarAccent],
|
|
45
|
+
["--sidebar-border", s.sidebarBorder],
|
|
46
|
+
];
|
|
47
|
+
}
|
|
48
|
+
function surfaceDeclarations(s, mode) {
|
|
9
49
|
return [
|
|
10
50
|
["--background", s.background],
|
|
11
51
|
["--card", s.card],
|
|
@@ -16,9 +56,7 @@ function surfaceDeclarations(s) {
|
|
|
16
56
|
["--muted-foreground", s.mutedForeground],
|
|
17
57
|
["--border", s.border],
|
|
18
58
|
["--input", s.input],
|
|
19
|
-
|
|
20
|
-
["--sidebar-accent", s.sidebarAccent],
|
|
21
|
-
["--sidebar-border", s.sidebarBorder],
|
|
59
|
+
...sidebarDeclarations(s, mode),
|
|
22
60
|
];
|
|
23
61
|
}
|
|
24
62
|
function modeDeclarations(mode) {
|
|
@@ -26,7 +64,7 @@ function modeDeclarations(mode) {
|
|
|
26
64
|
const accent = ACCENTS[DEFAULT_ACCENT];
|
|
27
65
|
return [
|
|
28
66
|
["color-scheme", mode],
|
|
29
|
-
...surfaceDeclarations(BASE_SURFACES[mode]),
|
|
67
|
+
...surfaceDeclarations(BASE_SURFACES[mode], mode),
|
|
30
68
|
["--foreground", m.foreground],
|
|
31
69
|
["--card-foreground", m.foreground],
|
|
32
70
|
["--popover-foreground", m.foreground],
|
|
@@ -40,8 +78,6 @@ function modeDeclarations(mode) {
|
|
|
40
78
|
["--ring", "var(--brand-primary)"],
|
|
41
79
|
["--chart-1", "var(--brand-primary)"],
|
|
42
80
|
...Object.entries(m.chart).map(([key, value]) => [`--${key}`, value]),
|
|
43
|
-
["--sidebar-foreground", m.sidebarForeground],
|
|
44
|
-
["--sidebar-primary", "var(--brand-primary)"],
|
|
45
81
|
["--sidebar-primary-foreground", "var(--brand-on-primary)"],
|
|
46
82
|
["--sidebar-accent-foreground", m.foreground],
|
|
47
83
|
["--sidebar-ring", "var(--brand-primary)"],
|
|
@@ -89,7 +125,7 @@ export function renderThemeCss() {
|
|
|
89
125
|
blocks.push(block('[data-mode="light"]', modeDeclarations("light"), "Light: neutral ground, green accent"));
|
|
90
126
|
for (const ground of GROUND_KEYS) {
|
|
91
127
|
const { mode, label } = GROUNDS[ground];
|
|
92
|
-
blocks.push(block(`[data-mode="${mode}"][data-ground="${ground}"]`, surfaceDeclarations(SURFACES[ground]), `Ground: ${label} (${mode})`));
|
|
128
|
+
blocks.push(block(`[data-mode="${mode}"][data-ground="${ground}"]`, surfaceDeclarations(SURFACES[ground], mode), `Ground: ${label} (${mode})`));
|
|
93
129
|
}
|
|
94
130
|
for (const key of ACCENT_KEYS) {
|
|
95
131
|
const accent = ACCENTS[key];
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { type Artifact } from "./run.js";
|
|
3
|
+
export interface ArtifactCardProps {
|
|
4
|
+
artifact: Artifact;
|
|
5
|
+
/**
|
|
6
|
+
* The host's view of the artifact: a thumbnail, a player, or one of the
|
|
7
|
+
* module review viewers. Rendered as given, so it can be interactive.
|
|
8
|
+
*/
|
|
9
|
+
preview?: React.ReactNode;
|
|
10
|
+
/** Opens the artifact: the name becomes a link through the app's link component. */
|
|
11
|
+
href?: string;
|
|
12
|
+
/** Opens or selects the artifact without a route: the name becomes a button. Ignored with `href`. */
|
|
13
|
+
onOpen?: () => void;
|
|
14
|
+
/** Marks the artifact the host is showing beside the list. */
|
|
15
|
+
selected?: boolean;
|
|
16
|
+
/** The host's own controls (download, compare). */
|
|
17
|
+
actions?: React.ReactNode;
|
|
18
|
+
headingLevel?: "h2" | "h3" | "h4";
|
|
19
|
+
className?: string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* One typed output of a run: its type, name, status, size, version and the
|
|
23
|
+
* stage that made it, with a slot for the host's preview or a module viewer.
|
|
24
|
+
* Knows nothing of any module; the host decides what a kind looks like.
|
|
25
|
+
*/
|
|
26
|
+
export declare function ArtifactCard({ artifact, preview, href, onOpen, selected, actions, headingLevel, className, }: ArtifactCardProps): React.JSX.Element;
|
|
27
|
+
export interface ArtifactListProps {
|
|
28
|
+
artifacts: readonly Artifact[];
|
|
29
|
+
/** Names the list. Defaults to "Artifacts". */
|
|
30
|
+
label?: string;
|
|
31
|
+
/** The preview or viewer for each artifact. */
|
|
32
|
+
renderPreview?: (artifact: Artifact) => React.ReactNode;
|
|
33
|
+
/** How each artifact opens: a route, a handler, the host's controls. */
|
|
34
|
+
actionsFor?: (artifact: Artifact) => Pick<ArtifactCardProps, "href" | "onOpen" | "actions">;
|
|
35
|
+
selectedId?: string;
|
|
36
|
+
/** One column, for a side panel; the default flows into columns as the width allows. */
|
|
37
|
+
columns?: "auto" | "one";
|
|
38
|
+
loading?: boolean;
|
|
39
|
+
error?: {
|
|
40
|
+
message: string;
|
|
41
|
+
} | null;
|
|
42
|
+
onRetry?: () => void;
|
|
43
|
+
headingLevel?: ArtifactCardProps["headingLevel"];
|
|
44
|
+
className?: string;
|
|
45
|
+
}
|
|
46
|
+
/** A run's artifacts as cards, with loading, error and empty states. */
|
|
47
|
+
export declare function ArtifactList({ artifacts, label, renderPreview, actionsFor, selectedId, columns, loading, error, onRetry, headingLevel, className, }: ArtifactListProps): React.JSX.Element;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { cn } from "../lib/cn.js";
|
|
5
|
+
import { useUI } from "../provider/UIProvider.js";
|
|
6
|
+
import { EmptyState } from "../states/EmptyState.js";
|
|
7
|
+
import { ErrorState } from "../states/ErrorState.js";
|
|
8
|
+
import { StatusBadge } from "../status/StatusBadge.js";
|
|
9
|
+
import { ARTIFACT_TONE } from "./run.js";
|
|
10
|
+
import { WorkflowCardSkeleton } from "./WorkflowCard.js";
|
|
11
|
+
/**
|
|
12
|
+
* One typed output of a run: its type, name, status, size, version and the
|
|
13
|
+
* stage that made it, with a slot for the host's preview or a module viewer.
|
|
14
|
+
* Knows nothing of any module; the host decides what a kind looks like.
|
|
15
|
+
*/
|
|
16
|
+
export function ArtifactCard({ artifact, preview, href, onOpen, selected = false, actions, headingLevel = "h3", className, }) {
|
|
17
|
+
const { Link, messages } = useUI();
|
|
18
|
+
const t = messages.run;
|
|
19
|
+
const titleId = React.useId();
|
|
20
|
+
const Heading = headingLevel;
|
|
21
|
+
const meta = [
|
|
22
|
+
artifact.size,
|
|
23
|
+
artifact.version && t.version(artifact.version),
|
|
24
|
+
artifact.producedBy && t.producedBy(artifact.producedBy),
|
|
25
|
+
].filter(Boolean);
|
|
26
|
+
const nameClass = "rounded-sm text-left outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring";
|
|
27
|
+
return (_jsxs("article", { "aria-labelledby": titleId, "data-slot": "artifact-card", "data-kind": artifact.kind, "data-selected": selected || undefined, className: cn("flex min-w-0 flex-col overflow-hidden rounded-lg border bg-card text-card-foreground", selected && "border-primary ring-1 ring-primary", className), children: [_jsxs("div", { className: "flex min-w-0 flex-col gap-1.5 p-(--panel-pad)", children: [_jsxs("div", { className: "flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1", children: [_jsx("span", { className: "min-w-0 truncate font-mono text-xs text-muted-foreground", children: artifact.kindLabel }), artifact.status && (_jsx(StatusBadge, { tone: ARTIFACT_TONE[artifact.status], struck: artifact.status === "superseded", children: t.artifactStatus(artifact.status) })), actions && _jsx("span", { className: "ml-auto flex items-center gap-1", children: actions })] }), _jsx(Heading, { id: titleId, className: "text-sm font-medium [overflow-wrap:anywhere]", children: href ? (_jsx(Link, { href: href, "aria-current": selected || undefined, className: nameClass, children: artifact.name })) : onOpen ? (_jsx("button", { type: "button", onClick: onOpen, "aria-pressed": selected, className: nameClass, children: artifact.name })) : (artifact.name) }), artifact.summary && (_jsx("p", { className: "line-clamp-3 text-sm [overflow-wrap:anywhere] text-muted-foreground", children: artifact.summary })), meta.length > 0 && (_jsx("p", { className: "flex min-w-0 flex-wrap gap-x-3 font-mono text-xs text-muted-foreground", children: meta.map((item, i) => (_jsx("span", { className: "min-w-0 [overflow-wrap:anywhere]", children: item }, i))) }))] }), preview != null && (_jsx("div", { "data-slot": "artifact-preview", className: "mt-auto min-w-0 border-t", children: preview }))] }));
|
|
28
|
+
}
|
|
29
|
+
/** A run's artifacts as cards, with loading, error and empty states. */
|
|
30
|
+
export function ArtifactList({ artifacts, label, renderPreview, actionsFor, selectedId, columns = "auto", loading = false, error, onRetry, headingLevel, className, }) {
|
|
31
|
+
const { messages } = useUI();
|
|
32
|
+
const t = messages.run;
|
|
33
|
+
const name = label ?? t.artifacts;
|
|
34
|
+
const grid = cn("grid min-w-0 gap-3", columns === "auto" && "grid-cols-[repeat(auto-fill,minmax(min(100%,18rem),1fr))]", className);
|
|
35
|
+
if (error) {
|
|
36
|
+
return (_jsx(ErrorState, { title: t.loadFailed(name), error: error, onRetry: onRetry, className: className }));
|
|
37
|
+
}
|
|
38
|
+
if (loading) {
|
|
39
|
+
return (_jsxs("div", { "aria-busy": true, className: grid, children: [_jsx("p", { role: "status", className: "sr-only", children: messages.states.loading }), Array.from({ length: 3 }, (_, i) => (_jsx(WorkflowCardSkeleton, {}, i)))] }));
|
|
40
|
+
}
|
|
41
|
+
if (artifacts.length === 0) {
|
|
42
|
+
return (_jsx(EmptyState, { title: t.noArtifacts, description: t.noArtifactsDescription, className: className }));
|
|
43
|
+
}
|
|
44
|
+
return (_jsx("ul", { "aria-label": name, "data-slot": "artifact-list", className: grid, children: artifacts.map((artifact) => (_jsx("li", { className: "flex min-w-0", children: _jsx(ArtifactCard, { artifact: artifact, preview: renderPreview?.(artifact), selected: artifact.id === selectedId, headingLevel: headingLevel, className: "flex-1", ...actionsFor?.(artifact) }) }, artifact.id))) }));
|
|
45
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
/** A run of text: plain, inserted or deleted (a redline). */
|
|
3
|
+
export interface DocumentSegment {
|
|
4
|
+
text: string;
|
|
5
|
+
mark?: "insert" | "delete";
|
|
6
|
+
}
|
|
7
|
+
/** A paragraph or clause. */
|
|
8
|
+
export interface DocumentBlock {
|
|
9
|
+
id: string;
|
|
10
|
+
/** The clause reference ("4.2", "Schedule 1, 3"), when the block is a clause. */
|
|
11
|
+
clause?: string;
|
|
12
|
+
heading?: string;
|
|
13
|
+
segments: readonly DocumentSegment[];
|
|
14
|
+
}
|
|
15
|
+
export interface DocumentPage {
|
|
16
|
+
/** From 1. */
|
|
17
|
+
number: number;
|
|
18
|
+
blocks: readonly DocumentBlock[];
|
|
19
|
+
}
|
|
20
|
+
/** A comment or note pinned to a block. */
|
|
21
|
+
export interface DocumentAnnotation {
|
|
22
|
+
id: string;
|
|
23
|
+
blockId: string;
|
|
24
|
+
author: string;
|
|
25
|
+
body: string;
|
|
26
|
+
resolved?: boolean;
|
|
27
|
+
}
|
|
28
|
+
export type SignOffStatus = "pending" | "signed" | "declined";
|
|
29
|
+
export interface DocumentSignOff {
|
|
30
|
+
id: string;
|
|
31
|
+
/** The role that signs ("Counsel", "Policy owner"). */
|
|
32
|
+
role: string;
|
|
33
|
+
name?: string;
|
|
34
|
+
status: SignOffStatus;
|
|
35
|
+
/** Formatted by the host. */
|
|
36
|
+
at?: string;
|
|
37
|
+
}
|
|
38
|
+
export interface DocumentReviewProps {
|
|
39
|
+
/** Names the document. */
|
|
40
|
+
title: string;
|
|
41
|
+
pages: readonly DocumentPage[];
|
|
42
|
+
/** The page shown, from 1. Uncontrolled without it. */
|
|
43
|
+
page?: number;
|
|
44
|
+
defaultPage?: number;
|
|
45
|
+
onPageChange?: (page: number) => void;
|
|
46
|
+
annotations?: readonly DocumentAnnotation[];
|
|
47
|
+
activeAnnotationId?: string;
|
|
48
|
+
/** An annotation was chosen in the margin: the host shows it (in ReviewPanel, say). */
|
|
49
|
+
onAnnotationSelect?: (annotation: DocumentAnnotation) => void;
|
|
50
|
+
/**
|
|
51
|
+
* The person chose a block to comment on. `label` is the anchor in words
|
|
52
|
+
* ("p. 3, 4.2"), ready for ReviewPanel's `commentAnchor`.
|
|
53
|
+
*/
|
|
54
|
+
onAnchor?: (anchor: {
|
|
55
|
+
blockId: string;
|
|
56
|
+
page: number;
|
|
57
|
+
clause?: string;
|
|
58
|
+
label: string;
|
|
59
|
+
}) => void;
|
|
60
|
+
signOffs?: readonly DocumentSignOff[];
|
|
61
|
+
/** Signs off, after the confirm step. Shown when given and the person's sign-off is pending. */
|
|
62
|
+
onSignOff?: () => Promise<unknown> | unknown;
|
|
63
|
+
/** The role the person signs as, for the confirm step. */
|
|
64
|
+
signOffRole?: string;
|
|
65
|
+
className?: string;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Document review for legal and document modules: paged text with redline
|
|
69
|
+
* insertions and deletions, clause references, annotations in the margin and
|
|
70
|
+
* sign-off. Choosing a clause hands the host an anchor for its comment;
|
|
71
|
+
* choosing an annotation tells the host which one to show. The host supplies
|
|
72
|
+
* the text already segmented; the viewer renders, it does not diff.
|
|
73
|
+
*/
|
|
74
|
+
export declare function DocumentReview({ title, pages, page: pageProp, defaultPage, onPageChange, annotations, activeAnnotationId, onAnnotationSelect, onAnchor, signOffs, onSignOff, signOffRole, className, }: DocumentReviewProps): React.JSX.Element;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { ChevronLeftIcon, ChevronRightIcon, MessageSquareIcon, SignatureIcon } from "lucide-react";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
import { cn } from "../lib/cn.js";
|
|
6
|
+
import { ConfirmDialog } from "../overlays/ConfirmDialog.js";
|
|
7
|
+
import { Button } from "../primitives/Button.js";
|
|
8
|
+
import { useUI } from "../provider/UIProvider.js";
|
|
9
|
+
import { StatusBadge } from "../status/StatusBadge.js";
|
|
10
|
+
const SIGN_OFF_TONE = {
|
|
11
|
+
pending: "muted",
|
|
12
|
+
signed: "ok",
|
|
13
|
+
declined: "error",
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Document review for legal and document modules: paged text with redline
|
|
17
|
+
* insertions and deletions, clause references, annotations in the margin and
|
|
18
|
+
* sign-off. Choosing a clause hands the host an anchor for its comment;
|
|
19
|
+
* choosing an annotation tells the host which one to show. The host supplies
|
|
20
|
+
* the text already segmented; the viewer renders, it does not diff.
|
|
21
|
+
*/
|
|
22
|
+
export function DocumentReview({ title, pages, page: pageProp, defaultPage = 1, onPageChange, annotations = [], activeAnnotationId, onAnnotationSelect, onAnchor, signOffs = [], onSignOff, signOffRole, className, }) {
|
|
23
|
+
const { messages } = useUI();
|
|
24
|
+
const t = messages.document;
|
|
25
|
+
const [ownPage, setOwnPage] = React.useState(defaultPage);
|
|
26
|
+
const [confirming, setConfirming] = React.useState(false);
|
|
27
|
+
const total = pages.length;
|
|
28
|
+
const current = Math.min(Math.max(pageProp ?? ownPage, 1), Math.max(total, 1));
|
|
29
|
+
const shown = pages.find((p) => p.number === current) ?? pages[current - 1];
|
|
30
|
+
const go = (next) => {
|
|
31
|
+
setOwnPage(next);
|
|
32
|
+
onPageChange?.(next);
|
|
33
|
+
};
|
|
34
|
+
const byBlock = new Map();
|
|
35
|
+
for (const a of annotations)
|
|
36
|
+
byBlock.set(a.blockId, [...(byBlock.get(a.blockId) ?? []), a]);
|
|
37
|
+
const canSign = Boolean(onSignOff) && signOffs.some((s) => s.status === "pending");
|
|
38
|
+
const changes = pages
|
|
39
|
+
.flatMap((p) => p.blocks)
|
|
40
|
+
.flatMap((b) => b.segments)
|
|
41
|
+
.filter((s) => s.mark).length;
|
|
42
|
+
return (_jsxs("section", { "aria-label": title, "data-slot": "document-review", className: cn("flex min-w-0 flex-col gap-3", className), children: [_jsxs("div", { className: "flex min-w-0 flex-wrap items-center justify-between gap-2", children: [_jsxs("p", { className: "flex min-w-0 flex-wrap gap-x-3 font-mono text-xs text-muted-foreground", children: [_jsx("span", { children: t.changes(changes) }), _jsx("span", { children: t.annotations(annotations.length) })] }), total > 1 && (_jsxs("nav", { "aria-label": t.pages, className: "flex items-center gap-1", children: [_jsx(Button, { size: "icon-sm", variant: "ghost", "aria-label": t.previousPage, disabled: current <= 1, onClick: () => go(current - 1), children: _jsx(ChevronLeftIcon, { "aria-hidden": true }) }), _jsx("span", { "aria-live": "polite", className: "font-mono text-xs tabular-nums", children: t.pageOf(current, total) }), _jsx(Button, { size: "icon-sm", variant: "ghost", "aria-label": t.nextPage, disabled: current >= total, onClick: () => go(current + 1), children: _jsx(ChevronRightIcon, { "aria-hidden": true }) })] }))] }), _jsx("div", { className: "min-w-0 rounded-lg border bg-card p-(--panel-pad) text-card-foreground", children: shown ? (_jsx("ol", { "aria-label": t.pageOf(current, total), className: "flex min-w-0 flex-col gap-3", children: shown.blocks.map((block) => {
|
|
43
|
+
const notes = byBlock.get(block.id) ?? [];
|
|
44
|
+
const active = notes.some((n) => n.id === activeAnnotationId);
|
|
45
|
+
const label = t.anchor(current, block.clause);
|
|
46
|
+
return (_jsxs("li", { className: cn("grid min-w-0 grid-cols-[minmax(0,1fr)_auto] gap-x-3 gap-y-1 rounded-md border-l-2 border-transparent pl-2", notes.length > 0 && "border-warning", active && "border-primary bg-primary/5"), children: [_jsxs("div", { className: "min-w-0", children: [(block.clause || block.heading) && (_jsxs("p", { className: "mb-0.5 flex min-w-0 flex-wrap items-baseline gap-2 text-sm font-medium", children: [block.clause &&
|
|
47
|
+
(onAnchor ? (_jsx("button", { type: "button", onClick: () => onAnchor({
|
|
48
|
+
blockId: block.id,
|
|
49
|
+
page: current,
|
|
50
|
+
clause: block.clause,
|
|
51
|
+
label,
|
|
52
|
+
}), "aria-label": t.commentOnClause(block.clause), className: "rounded-sm font-mono text-xs text-primary underline-offset-2 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring", children: block.clause })) : (_jsx("span", { className: "font-mono text-xs text-muted-foreground", children: block.clause }))), block.heading && _jsx("span", { children: block.heading })] })), _jsx("p", { className: "text-sm leading-relaxed [overflow-wrap:anywhere]", children: block.segments.map((segment, i) => segment.mark === "insert" ? (_jsxs("ins", { className: "rounded-sm bg-success/15 text-success-fg underline decoration-success", children: [_jsx("span", { className: "sr-only", children: t.inserted(segment.text) }), _jsx("span", { "aria-hidden": true, children: segment.text })] }, i)) : segment.mark === "delete" ? (_jsxs("del", { className: "rounded-sm bg-danger/15 text-danger-fg line-through", children: [_jsx("span", { className: "sr-only", children: t.deleted(segment.text) }), _jsx("span", { "aria-hidden": true, children: segment.text })] }, i)) : (_jsx(React.Fragment, { children: segment.text }, i))) })] }), _jsxs("div", { className: "flex flex-col items-end gap-1", children: [notes.map((note) => (_jsx("button", { type: "button", onClick: () => onAnnotationSelect?.(note), disabled: !onAnnotationSelect, "aria-current": note.id === activeAnnotationId || undefined, "aria-label": t.annotationBy(note.author, label), title: note.body, className: cn("inline-flex size-6 items-center justify-center rounded-md border text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default", note.id === activeAnnotationId && "border-primary text-primary", note.resolved && "opacity-60"), children: _jsx(MessageSquareIcon, { "aria-hidden": true, className: "size-3.5" }) }, note.id))), onAnchor && !block.clause && (_jsx(Button, { size: "icon-xs", variant: "ghost", "aria-label": t.commentOnBlock(label), onClick: () => onAnchor({ blockId: block.id, page: current, label }), children: _jsx(MessageSquareIcon, { "aria-hidden": true }) }))] })] }, block.id));
|
|
53
|
+
}) })) : (_jsx("p", { className: "text-sm text-muted-foreground", children: t.empty })) }), signOffs.length > 0 && (_jsxs("div", { className: "flex min-w-0 flex-col gap-2 rounded-lg border p-3", children: [_jsxs("div", { className: "flex min-w-0 flex-wrap items-center justify-between gap-2", children: [_jsx("h3", { className: "font-mono text-2xs font-medium tracking-wider text-muted-foreground uppercase", children: t.signOff }), canSign && (_jsxs(Button, { size: "sm", onClick: () => setConfirming(true), children: [_jsx(SignatureIcon, { "aria-hidden": true }), t.signOffAction] }))] }), _jsx("ul", { className: "flex min-w-0 flex-col gap-1.5", children: signOffs.map((s) => (_jsxs("li", { className: "flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-sm", children: [_jsx("span", { className: "font-medium", children: s.role }), s.name && _jsx("span", { className: "text-muted-foreground", children: s.name }), _jsx(StatusBadge, { tone: SIGN_OFF_TONE[s.status], children: t.signOffStatus(s.status) }), s.at && _jsx("span", { className: "font-mono text-xs text-muted-foreground", children: s.at })] }, s.id))) })] })), _jsx(ConfirmDialog, { open: confirming, onOpenChange: setConfirming, title: t.signOffTitle(title), description: t.signOffDescription(signOffRole), confirmLabel: t.signOffAction, onConfirm: () => onSignOff?.() })] }));
|
|
54
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
/** A field's place on its source page, as fractions of the page's width and height (0 to 1). */
|
|
3
|
+
export interface FormFieldBox {
|
|
4
|
+
x: number;
|
|
5
|
+
y: number;
|
|
6
|
+
width: number;
|
|
7
|
+
height: number;
|
|
8
|
+
}
|
|
9
|
+
export type FormFieldStatus = "pending" | "accepted" | "corrected";
|
|
10
|
+
/** One value extracted from a form. */
|
|
11
|
+
export interface FormField {
|
|
12
|
+
id: string;
|
|
13
|
+
label: string;
|
|
14
|
+
/** What extraction read. */
|
|
15
|
+
value: string;
|
|
16
|
+
/** Extraction's confidence, 0 to 1. */
|
|
17
|
+
confidence: number;
|
|
18
|
+
/** The source page it came from, from 1. */
|
|
19
|
+
page?: number;
|
|
20
|
+
box?: FormFieldBox;
|
|
21
|
+
status?: FormFieldStatus;
|
|
22
|
+
/** The person's correction, when `status` is corrected. */
|
|
23
|
+
corrected?: string;
|
|
24
|
+
}
|
|
25
|
+
/** A scanned or rendered source page. */
|
|
26
|
+
export interface FormSourcePage {
|
|
27
|
+
number: number;
|
|
28
|
+
src: string;
|
|
29
|
+
alt: string;
|
|
30
|
+
}
|
|
31
|
+
export interface FormReviewProps {
|
|
32
|
+
/** Names the form. */
|
|
33
|
+
title: string;
|
|
34
|
+
pages: readonly FormSourcePage[];
|
|
35
|
+
fields: readonly FormField[];
|
|
36
|
+
/** Below this confidence a field is flagged. Defaults to 0.85. */
|
|
37
|
+
threshold?: number;
|
|
38
|
+
activeFieldId?: string;
|
|
39
|
+
onFieldSelect?: (field: FormField) => void;
|
|
40
|
+
/** Accepts the extracted value. Without it and `onCorrect` the review is read-only. */
|
|
41
|
+
onAccept?: (field: FormField) => Promise<unknown> | unknown;
|
|
42
|
+
/** Saves a correction. A rejection keeps the editor open with the error. */
|
|
43
|
+
onCorrect?: (field: FormField, value: string) => Promise<unknown> | unknown;
|
|
44
|
+
className?: string;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Form review for the paperwork module: the extracted fields beside the
|
|
48
|
+
* source page, each with its confidence (flagged below the threshold), and
|
|
49
|
+
* accept or correct per field. Choosing a field shows its page and boxes its
|
|
50
|
+
* place; the boxes on the page choose fields too. The host extracts, stores
|
|
51
|
+
* and decides; the viewer reports intent.
|
|
52
|
+
*/
|
|
53
|
+
export declare function FormReview({ title, pages, fields, threshold, activeFieldId, onFieldSelect, onAccept, onCorrect, className, }: FormReviewProps): React.JSX.Element;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { CheckIcon, PencilIcon } from "lucide-react";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
import { cn } from "../lib/cn.js";
|
|
6
|
+
import { Button } from "../primitives/Button.js";
|
|
7
|
+
import { Input } from "../primitives/Input.js";
|
|
8
|
+
import { Segmented } from "../primitives/Segmented.js";
|
|
9
|
+
import { useUI } from "../provider/UIProvider.js";
|
|
10
|
+
import { StatusBadge } from "../status/StatusBadge.js";
|
|
11
|
+
/**
|
|
12
|
+
* Form review for the paperwork module: the extracted fields beside the
|
|
13
|
+
* source page, each with its confidence (flagged below the threshold), and
|
|
14
|
+
* accept or correct per field. Choosing a field shows its page and boxes its
|
|
15
|
+
* place; the boxes on the page choose fields too. The host extracts, stores
|
|
16
|
+
* and decides; the viewer reports intent.
|
|
17
|
+
*/
|
|
18
|
+
export function FormReview({ title, pages, fields, threshold = 0.85, activeFieldId, onFieldSelect, onAccept, onCorrect, className, }) {
|
|
19
|
+
const { messages } = useUI();
|
|
20
|
+
const t = messages.form;
|
|
21
|
+
const active = fields.find((f) => f.id === activeFieldId);
|
|
22
|
+
const [ownPage, setOwnPage] = React.useState(pages[0]?.number ?? 1);
|
|
23
|
+
const [lastActive, setLastActive] = React.useState(activeFieldId);
|
|
24
|
+
// Follow the chosen field to its page, until the person turns the page.
|
|
25
|
+
if (activeFieldId !== lastActive) {
|
|
26
|
+
setLastActive(activeFieldId);
|
|
27
|
+
if (active?.page)
|
|
28
|
+
setOwnPage(active.page);
|
|
29
|
+
}
|
|
30
|
+
const page = pages.find((p) => p.number === ownPage) ?? pages[0];
|
|
31
|
+
const onPage = fields.filter((f) => f.box && (f.page ?? 1) === page?.number);
|
|
32
|
+
const done = fields.filter((f) => f.status === "accepted" || f.status === "corrected").length;
|
|
33
|
+
const flagged = fields.filter((f) => f.confidence < threshold && f.status !== "accepted" && f.status !== "corrected").length;
|
|
34
|
+
return (_jsxs("section", { "aria-label": title, "data-slot": "form-review", className: cn("grid min-w-0 gap-4 md:grid-cols-2", className), children: [_jsxs("div", { className: "flex min-w-0 flex-col gap-2", children: [_jsxs("div", { className: "flex min-w-0 flex-wrap items-center justify-between gap-2", children: [_jsx("h3", { className: "font-mono text-2xs font-medium tracking-wider text-muted-foreground uppercase", children: t.source }), pages.length > 1 && (_jsx(Segmented, { label: t.sourcePage, size: "sm", value: String(page?.number), onValueChange: (v) => setOwnPage(Number(v)), options: pages.map((p) => ({ value: String(p.number), label: t.page(p.number) })) }))] }), page && (_jsxs("div", { className: "relative min-w-0 overflow-hidden rounded-lg border bg-muted/40", children: [_jsx("img", { src: page.src, alt: page.alt, className: "block h-auto w-full" }), onPage.map((f) => (_jsx("button", { type: "button", onClick: () => onFieldSelect?.(f), disabled: !onFieldSelect, "aria-label": t.fieldOnSource(f.label), "aria-current": f.id === activeFieldId || undefined, style: {
|
|
35
|
+
left: `${f.box.x * 100}%`,
|
|
36
|
+
top: `${f.box.y * 100}%`,
|
|
37
|
+
width: `${f.box.width * 100}%`,
|
|
38
|
+
height: `${f.box.height * 100}%`,
|
|
39
|
+
}, className: cn("absolute rounded-sm border-2 outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default", f.confidence < threshold && (f.status ?? "pending") === "pending"
|
|
40
|
+
? "border-warning"
|
|
41
|
+
: "border-info/60", f.id === activeFieldId && "border-primary bg-primary/10") }, f.id)))] }))] }), _jsxs("div", { className: "flex min-w-0 flex-col gap-2", children: [_jsxs("div", { className: "flex min-w-0 flex-wrap items-center justify-between gap-2", children: [_jsx("h3", { className: "font-mono text-2xs font-medium tracking-wider text-muted-foreground uppercase", children: t.fields }), _jsx("p", { role: "status", className: "font-mono text-xs text-muted-foreground", children: t.progress(done, fields.length, flagged) })] }), _jsx("ul", { className: "flex min-w-0 flex-col gap-2", children: fields.map((field) => (_jsx(FieldRow, { field: field, low: field.confidence < threshold, active: field.id === activeFieldId, onSelect: onFieldSelect, onAccept: onAccept, onCorrect: onCorrect }, field.id))) })] })] }));
|
|
42
|
+
}
|
|
43
|
+
function FieldRow({ field, low, active, onSelect, onAccept, onCorrect, }) {
|
|
44
|
+
const { messages } = useUI();
|
|
45
|
+
const t = messages.form;
|
|
46
|
+
const id = React.useId();
|
|
47
|
+
const [editing, setEditing] = React.useState(false);
|
|
48
|
+
const [draft, setDraft] = React.useState("");
|
|
49
|
+
const [pending, setPending] = React.useState(false);
|
|
50
|
+
const [error, setError] = React.useState(null);
|
|
51
|
+
const input = React.useRef(null);
|
|
52
|
+
React.useEffect(() => {
|
|
53
|
+
if (editing)
|
|
54
|
+
input.current?.focus();
|
|
55
|
+
}, [editing]);
|
|
56
|
+
const status = field.status ?? "pending";
|
|
57
|
+
const shown = status === "corrected" && field.corrected != null ? field.corrected : field.value;
|
|
58
|
+
async function run(action, after) {
|
|
59
|
+
setPending(true);
|
|
60
|
+
setError(null);
|
|
61
|
+
try {
|
|
62
|
+
const result = await action();
|
|
63
|
+
if (result !== false)
|
|
64
|
+
after?.();
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
setError(err instanceof Error && err.message ? err.message : messages.confirm.failed);
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
setPending(false);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return (_jsxs("li", { "aria-current": active || undefined, className: cn("flex min-w-0 flex-col gap-1.5 rounded-lg border bg-card p-2.5 text-card-foreground", active && "border-primary"), children: [_jsxs("div", { className: "flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1", children: [_jsx("span", { className: "min-w-0 text-xs font-medium [overflow-wrap:anywhere]", children: onSelect ? (_jsx("button", { type: "button", onClick: () => onSelect(field), className: "rounded-sm text-left outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring", children: field.label })) : (field.label) }), _jsx(StatusBadge, { tone: low ? "warn" : "muted", children: t.confidence(field.confidence) }), status !== "pending" && (_jsx(StatusBadge, { tone: status === "accepted" ? "ok" : "info", children: t.status(status) }))] }), editing ? (_jsxs("form", { className: "flex min-w-0 flex-col gap-1.5", onSubmit: (event) => {
|
|
74
|
+
event.preventDefault();
|
|
75
|
+
void run(() => onCorrect?.(field, draft.trim()), () => setEditing(false));
|
|
76
|
+
}, children: [_jsx("label", { htmlFor: `${id}-input`, className: "sr-only", children: t.correctLabel(field.label) }), _jsx(Input, { id: `${id}-input`, value: draft, disabled: pending, ref: input, "aria-invalid": error != null || undefined, "aria-describedby": `${id}-extracted${error ? ` ${id}-error` : ""}`, onChange: (event) => setDraft(event.target.value) }), _jsx("p", { id: `${id}-extracted`, className: "text-xs [overflow-wrap:anywhere] text-muted-foreground", children: t.extracted(field.value) }), error && (_jsx("p", { id: `${id}-error`, role: "alert", className: "text-xs text-danger-fg", children: error })), _jsxs("div", { className: "flex flex-wrap gap-1.5", children: [_jsx(Button, { type: "submit", size: "xs", disabled: pending || !draft.trim(), children: pending ? messages.confirm.working : t.save }), _jsx(Button, { type: "button", size: "xs", variant: "ghost", disabled: pending, onClick: () => {
|
|
77
|
+
setEditing(false);
|
|
78
|
+
setError(null);
|
|
79
|
+
}, children: t.cancel })] })] })) : (_jsxs(_Fragment, { children: [_jsxs("p", { className: "font-mono text-sm [overflow-wrap:anywhere]", children: [shown || _jsx("span", { className: "text-muted-foreground", children: t.empty }), status === "corrected" && field.corrected != null && (_jsxs(_Fragment, { children: [" ", _jsxs("del", { className: "text-xs text-muted-foreground", children: [_jsx("span", { className: "sr-only", children: t.extracted(field.value) }), _jsx("span", { "aria-hidden": true, children: field.value })] })] }))] }), (onAccept || onCorrect) && (_jsxs("div", { className: "flex flex-wrap gap-1.5", children: [onAccept && status === "pending" && (_jsxs(Button, { size: "xs", variant: "outline", disabled: pending, "aria-label": t.actionName(t.accept, field.label), onClick: () => void run(() => onAccept(field)), children: [_jsx(CheckIcon, { "aria-hidden": true }), t.accept] })), onCorrect && (_jsxs(Button, { size: "xs", variant: "ghost", disabled: pending, "aria-label": t.actionName(t.correct, field.label), onClick: () => {
|
|
80
|
+
setDraft(shown);
|
|
81
|
+
setEditing(true);
|
|
82
|
+
}, children: [_jsx(PencilIcon, { "aria-hidden": true }), t.correct] }))] })), error && (_jsx("p", { role: "alert", className: "text-xs text-danger-fg", children: error }))] }))] }));
|
|
83
|
+
}
|