@crossworks/share-ui 0.230.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/LICENSE.md +135 -0
  2. package/package.json +68 -0
  3. package/src/app-bridge-protocol.ts +115 -0
  4. package/src/app-presenter.tsx +25 -0
  5. package/src/app-sandbox.tsx +552 -0
  6. package/src/appearance.ts +192 -0
  7. package/src/avatar.test.ts +229 -0
  8. package/src/avatar.ts +731 -0
  9. package/src/backgrounds.test.ts +119 -0
  10. package/src/backgrounds.ts +118 -0
  11. package/src/draw-presenter.tsx +39 -0
  12. package/src/event-presenter.tsx +62 -0
  13. package/src/file-presenter.tsx +76 -0
  14. package/src/formula-calculator.tsx +209 -0
  15. package/src/formula-presenter.test.ts +128 -0
  16. package/src/formula-presenter.tsx +301 -0
  17. package/src/help-topics.ts +104 -0
  18. package/src/lib/ink-audit.test.ts +314 -0
  19. package/src/lib/theme-css-blocks.ts +26 -0
  20. package/src/lib/theme-generator.test.ts +179 -0
  21. package/src/lib/theme-registry.gen.ts +352 -0
  22. package/src/lib/themes.test.ts +308 -0
  23. package/src/lib/themes.ts +75 -0
  24. package/src/lib/utils.ts +6 -0
  25. package/src/nav-items.ts +225 -0
  26. package/src/note-presenter.tsx +14 -0
  27. package/src/page-outline.tsx +127 -0
  28. package/src/table-presenter.tsx +226 -0
  29. package/src/task-presenter.tsx +60 -0
  30. package/src/ui/button.tsx +50 -0
  31. package/src/ui/input.tsx +18 -0
  32. package/src/ui/label.tsx +20 -0
  33. package/src/view-payload.ts +82 -0
  34. package/styles/app.css +1098 -0
  35. package/styles/themes.css +6198 -0
  36. package/themes/generate.d.mts +11 -0
  37. package/themes/generate.mjs +618 -0
  38. package/themes/model.d.mts +24 -0
  39. package/themes/model.mjs +213 -0
  40. package/themes/preview.html +145 -0
  41. package/themes/seeds.d.mts +16 -0
  42. package/themes/seeds.mjs +3694 -0
  43. package/tsconfig.json +15 -0
  44. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,119 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ BACKGROUND_AREAS,
4
+ BACKGROUND_AREA_IDS,
5
+ BACKGROUND_OFF,
6
+ DEFAULT_AREA_BACKGROUNDS,
7
+ decodeBackgrounds,
8
+ encodeBackgrounds,
9
+ isBackgroundChoice,
10
+ resolveAreaBackground,
11
+ resolveBackgrounds,
12
+ } from './backgrounds';
13
+ import { AVATAR_PICKER_STYLES, AVATAR_STYLES, BACKGROUND_STYLES } from './avatar';
14
+
15
+ /**
16
+ * The value here travels DB → HTML attribute → provider → four surfaces, so
17
+ * these pin the two properties that chain depends on: a default is the absence
18
+ * of a pair (or a default change never reaches anyone), and `off` survives the
19
+ * whole round trip (or "I turned the header off" is indistinguishable from "I
20
+ * never set the header").
21
+ */
22
+
23
+ describe('the catalogue split', () => {
24
+ it('puts every style in exactly one of the two galleries', () => {
25
+ expect(AVATAR_PICKER_STYLES.length + BACKGROUND_STYLES.length).toBe(AVATAR_STYLES.length);
26
+ const bg = new Set(BACKGROUND_STYLES.map((s) => s.id));
27
+ for (const s of AVATAR_PICKER_STYLES) expect(bg, s.id).not.toContain(s.id);
28
+ });
29
+
30
+ // These encode an identity, which is an avatar's job and meaningless spread
31
+ // across a sidebar, the reason they moved out of the old "minimalist" shelf.
32
+ it('keeps the identity styles on the avatar side', () => {
33
+ const ids = AVATAR_PICKER_STYLES.map((s) => s.id);
34
+ for (const id of ['initials', 'initial-face', 'glyphs', 'icons']) expect(ids).toContain(id);
35
+ });
36
+
37
+ it('keeps the scene styles on the background side', () => {
38
+ const ids = BACKGROUND_STYLES.map((s) => s.id);
39
+ for (const id of ['constellation', 'landscape', 'planets', 'waves']) expect(ids).toContain(id);
40
+ });
41
+ });
42
+
43
+ describe('choices', () => {
44
+ it('accepts off and any background style', () => {
45
+ expect(isBackgroundChoice(BACKGROUND_OFF)).toBe(true);
46
+ for (const s of BACKGROUND_STYLES) expect(isBackgroundChoice(s.id), s.id).toBe(true);
47
+ });
48
+
49
+ it('rejects avatar-only styles, unknowns and empties', () => {
50
+ for (const s of AVATAR_PICKER_STYLES) expect(isBackgroundChoice(s.id), s.id).toBe(false);
51
+ for (const v of ['', ' ', 'nope', null, undefined]) expect(isBackgroundChoice(v)).toBe(false);
52
+ });
53
+
54
+ it('gives every area a default that is itself a valid choice', () => {
55
+ for (const a of BACKGROUND_AREAS) {
56
+ expect(isBackgroundChoice(DEFAULT_AREA_BACKGROUNDS[a.id]), a.id).toBe(true);
57
+ }
58
+ });
59
+
60
+ it('falls back to the area default for anything invalid', () => {
61
+ expect(resolveAreaBackground('menu', 'thumbs')).toBe(DEFAULT_AREA_BACKGROUNDS.menu);
62
+ expect(resolveAreaBackground('header', 'nope')).toBe(DEFAULT_AREA_BACKGROUNDS.header);
63
+ expect(resolveAreaBackground('chat', null)).toBe(DEFAULT_AREA_BACKGROUNDS.chat);
64
+ expect(resolveAreaBackground('menu', 'waves')).toBe('waves');
65
+ });
66
+
67
+ it('fills in every area so consumers never see unset', () => {
68
+ const all = resolveBackgrounds({ menu: 'stripes' });
69
+ expect(Object.keys(all).sort()).toEqual([...BACKGROUND_AREA_IDS].sort());
70
+ expect(all.menu).toBe('stripes');
71
+ });
72
+ });
73
+
74
+ describe('the wire format', () => {
75
+ it('omits areas sitting on their default', () => {
76
+ expect(encodeBackgrounds(DEFAULT_AREA_BACKGROUNDS)).toBe('');
77
+ expect(encodeBackgrounds({ menu: DEFAULT_AREA_BACKGROUNDS.menu })).toBe('');
78
+ });
79
+
80
+ // The point of omitting defaults: turning an area OFF is a real, stored
81
+ // choice, so it must survive even when "off" is what most areas already are.
82
+ it('carries a non-default off', () => {
83
+ expect(encodeBackgrounds({ ...DEFAULT_AREA_BACKGROUNDS, menu: BACKGROUND_OFF })).toBe(
84
+ 'menu=off',
85
+ );
86
+ expect(decodeBackgrounds('menu=off').menu).toBe(BACKGROUND_OFF);
87
+ });
88
+
89
+ it('round-trips a full set', () => {
90
+ const chosen = {
91
+ menu: 'stripes',
92
+ header: 'glass',
93
+ chat: BACKGROUND_OFF,
94
+ activity: 'landscape',
95
+ } as const;
96
+ expect(decodeBackgrounds(encodeBackgrounds(chosen))).toEqual(chosen);
97
+ });
98
+
99
+ it('emits areas in a stable order, so the stored string does not churn', () => {
100
+ const a = encodeBackgrounds({ activity: 'waves', menu: 'glass' });
101
+ const b = encodeBackgrounds({ menu: 'glass', activity: 'waves' });
102
+ expect(a).toBe(b);
103
+ });
104
+
105
+ it('drops unknown areas and unknown styles on the way in', () => {
106
+ const out = decodeBackgrounds('menu=waves,bogus=waves,header=thumbs,chat=nope');
107
+ expect(out.menu).toBe('waves');
108
+ expect(out.header).toBe(DEFAULT_AREA_BACKGROUNDS.header);
109
+ expect(out.chat).toBe(DEFAULT_AREA_BACKGROUNDS.chat);
110
+ expect(out).not.toHaveProperty('bogus');
111
+ });
112
+
113
+ it('survives garbage without throwing', () => {
114
+ for (const raw of ['', ' ', ',,,', 'menu', 'menu=', '=waves', null, undefined]) {
115
+ expect(() => decodeBackgrounds(raw)).not.toThrow();
116
+ expect(decodeBackgrounds(raw).menu).toBe(DEFAULT_AREA_BACKGROUNDS.menu);
117
+ }
118
+ });
119
+ });
@@ -0,0 +1,118 @@
1
+ /**
2
+ * WHERE a generated background may be applied, and which one each area uses.
3
+ *
4
+ * The style catalogue lives in avatar.ts (category `backgrounds`) and the
5
+ * renderer in backdrop.ts; this module owns only the mapping from AREA to
6
+ * style, plus the wire format that carries it from the DB to the document.
7
+ *
8
+ * A brain-level choice, like the colour theme and the avatar style: it is the
9
+ * look of the product, not one login's taste.
10
+ *
11
+ * OFF IS A REAL CHOICE, not the absence of one. Every area can be bare, and a
12
+ * bare area has to be storable, otherwise "I turned the header off" is
13
+ * indistinguishable from "I never set the header", and the next default change
14
+ * would silently switch it back on.
15
+ */
16
+
17
+ import { BACKGROUND_STYLES } from './avatar';
18
+
19
+ /** The sentinel for "no background here". Not a style id, and deliberately not
20
+ * a name any DiceBear style could ever take. */
21
+ export const BACKGROUND_OFF = 'off';
22
+
23
+ export type BackgroundAreaId = 'menu' | 'header' | 'chat' | 'activity';
24
+
25
+ export type BackgroundArea = {
26
+ id: BackgroundAreaId;
27
+ label: string;
28
+ /** What the user is actually about to decorate. */
29
+ hint: string;
30
+ };
31
+
32
+ export const BACKGROUND_AREAS: BackgroundArea[] = [
33
+ { id: 'menu', label: 'Menu', hint: 'The left navigation column' },
34
+ { id: 'header', label: 'Brand', hint: 'Behind the logo and peer name' },
35
+ { id: 'chat', label: 'Chat', hint: 'Behind the conversation' },
36
+ { id: 'activity', label: 'Activity', hint: 'The right-hand live column' },
37
+ ];
38
+
39
+ export const BACKGROUND_AREA_IDS: readonly BackgroundAreaId[] = BACKGROUND_AREAS.map((a) => a.id);
40
+
41
+ /**
42
+ * What each area shows when the brain has never chosen.
43
+ *
44
+ * Only the menu is decorated out of the box. Turning on all four at once is a
45
+ * decorated app rather than a decorated panel, and the point of a background is
46
+ * that it is background, so the rest are opt-in.
47
+ */
48
+ export const DEFAULT_AREA_BACKGROUNDS: Record<BackgroundAreaId, string> = {
49
+ menu: 'waves',
50
+ header: BACKGROUND_OFF,
51
+ chat: BACKGROUND_OFF,
52
+ activity: BACKGROUND_OFF,
53
+ };
54
+
55
+ export type AreaBackgrounds = Record<BackgroundAreaId, string>;
56
+
57
+ const BACKGROUND_STYLE_IDS = new Set(BACKGROUND_STYLES.map((s) => s.id));
58
+
59
+ function isArea(id: string): id is BackgroundAreaId {
60
+ return (BACKGROUND_AREA_IDS as readonly string[]).includes(id);
61
+ }
62
+
63
+ /** Whether a value may be stored as an area's background. `off` counts; a style
64
+ * that is not in the `backgrounds` category does not, a portrait stretched
65
+ * across the sidebar is a giant face, not a background. */
66
+ export function isBackgroundChoice(v: string | null | undefined): boolean {
67
+ return !!v && (v === BACKGROUND_OFF || BACKGROUND_STYLE_IDS.has(v));
68
+ }
69
+
70
+ /** Resolve one area's stored value. Unknown, empty and avatar-only ids fall
71
+ * back to the area's default rather than throwing or rendering nothing. */
72
+ export function resolveAreaBackground(
73
+ area: BackgroundAreaId,
74
+ v: string | null | undefined,
75
+ ): string {
76
+ return isBackgroundChoice(v) ? v! : DEFAULT_AREA_BACKGROUNDS[area];
77
+ }
78
+
79
+ /** Fill in every area, so consumers never branch on "unset". */
80
+ export function resolveBackgrounds(
81
+ stored: Partial<Record<string, string>> | null | undefined,
82
+ ): AreaBackgrounds {
83
+ const out = {} as AreaBackgrounds;
84
+ for (const a of BACKGROUND_AREAS) out[a.id] = resolveAreaBackground(a.id, stored?.[a.id]);
85
+ return out;
86
+ }
87
+
88
+ /**
89
+ * Wire format: `menu=waves,header=off`.
90
+ *
91
+ * Chosen over JSON because it rides in an HTML attribute; this needs no
92
+ * quoting, survives a glance in devtools, and cannot smuggle anything: both
93
+ * halves of every pair are validated on the way in AND on the way out.
94
+ *
95
+ * Areas already on their default are OMITTED, matching how every other
96
+ * appearance attribute works: a default is the ABSENCE of the value, so
97
+ * changing a default later actually reaches the brains that never chose.
98
+ */
99
+ export function encodeBackgrounds(v: Partial<Record<string, string>> | null | undefined): string {
100
+ if (!v) return '';
101
+ const parts: string[] = [];
102
+ for (const a of BACKGROUND_AREAS) {
103
+ const choice = v[a.id];
104
+ if (!isBackgroundChoice(choice)) continue;
105
+ if (choice === DEFAULT_AREA_BACKGROUNDS[a.id]) continue;
106
+ parts.push(`${a.id}=${choice}`);
107
+ }
108
+ return parts.join(',');
109
+ }
110
+
111
+ export function decodeBackgrounds(raw: string | null | undefined): AreaBackgrounds {
112
+ const stored: Record<string, string> = {};
113
+ for (const pair of (raw ?? '').split(',')) {
114
+ const [area, choice] = pair.split('=');
115
+ if (area && choice && isArea(area) && isBackgroundChoice(choice)) stored[area] = choice;
116
+ }
117
+ return resolveBackgrounds(stored);
118
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Public draw render — the committed SVG snapshot, static, no JS.
3
+ *
4
+ * The snapshot is referenced as an IMAGE (`<img src>`), never injected into the
5
+ * page as markup. An SVG loaded as an image is a separate, script-disabled
6
+ * document: handlers don't fire, an inline `<style>` can't restyle the host
7
+ * page, and external references don't load. acceptSceneSvg still validates at
8
+ * commit, but this surface no longer depends on it being exhaustive.
9
+ *
10
+ * The white mat is deliberate — the snapshot is exported light-mode with its
11
+ * own background, so it reads as a framed artwork on either page theme rather
12
+ * than adapting (and mangling) its colours.
13
+ */
14
+ export function DrawPresenter({
15
+ view,
16
+ src,
17
+ }: {
18
+ view: { title: string; hasSvg: boolean };
19
+ /** URL of the snapshot image (`/s/<token>/draw`). */
20
+ src: string;
21
+ }) {
22
+ return (
23
+ <article className="mx-auto max-w-5xl px-6 py-12 md:py-16">
24
+ <h1 className="mb-8 text-3xl font-bold tracking-tight text-balance">{view.title}</h1>
25
+ {view.hasSvg ? (
26
+ <div className="overflow-hidden rounded-lg border border-border bg-white p-3">
27
+ {/* eslint-disable-next-line @next/next/no-img-element -- a drawing
28
+ snapshot is not a next/image candidate: it's an owner-generated
29
+ SVG of unknown dimensions served from our own share route. */}
30
+ <img src={src} alt={view.title} className="h-auto w-full" />
31
+ </div>
32
+ ) : (
33
+ <p className="rounded-lg border border-dashed border-border p-8 text-center text-sm text-muted-foreground">
34
+ Nothing committed here yet.
35
+ </p>
36
+ )}
37
+ </article>
38
+ );
39
+ }
@@ -0,0 +1,62 @@
1
+ import ReactMarkdown from 'react-markdown';
2
+ import remarkGfm from 'remark-gfm';
3
+ import { CalendarDays, Clock, Download, MapPin } from 'lucide-react';
4
+ import { formatDateTime } from '@mantle/client-types/lib/format-datetime';
5
+ import { buildIcsHref } from '@mantle/client-types/lib/event-time';
6
+
7
+ type EventView = {
8
+ title: string;
9
+ body: string;
10
+ startsAt: string | null;
11
+ endsAt: string | null;
12
+ location: string | null;
13
+ };
14
+
15
+ /** Public event render — a clean card with time, location, and an .ics link. */
16
+ export function EventPresenter({ view }: { view: EventView }) {
17
+ const ics = buildIcsHref(view);
18
+ return (
19
+ <div className="mx-auto max-w-2xl px-6 py-12 md:py-16">
20
+ <div className="rounded-xl border border-border bg-card p-6">
21
+ <div className="flex items-start gap-3">
22
+ <CalendarDays className="mt-1 size-6 shrink-0 text-primary-ink" aria-hidden />
23
+ <h1 className="text-2xl font-bold tracking-tight text-balance">{view.title}</h1>
24
+ </div>
25
+
26
+ <dl className="mt-5 space-y-2 text-sm">
27
+ {view.startsAt && (
28
+ <div className="flex items-center gap-2 text-muted-foreground">
29
+ <Clock className="size-4 shrink-0" aria-hidden />
30
+ <span>
31
+ {formatDateTime(view.startsAt)}
32
+ {view.endsAt ? ` – ${formatDateTime(view.endsAt)}` : ''}
33
+ </span>
34
+ </div>
35
+ )}
36
+ {view.location && (
37
+ <div className="flex items-center gap-2 text-muted-foreground">
38
+ <MapPin className="size-4 shrink-0" aria-hidden />
39
+ <span>{view.location}</span>
40
+ </div>
41
+ )}
42
+ </dl>
43
+
44
+ {view.body && (
45
+ <div className="prose prose-sm dark:prose-invert mt-5 max-w-none border-t border-border pt-5">
46
+ <ReactMarkdown remarkPlugins={[remarkGfm]}>{view.body}</ReactMarkdown>
47
+ </div>
48
+ )}
49
+
50
+ {ics && (
51
+ <a
52
+ href={ics}
53
+ download={`${view.title || 'event'}.ics`}
54
+ className="mt-6 inline-flex items-center gap-2 rounded-md border border-border bg-background px-3 py-2 text-sm font-medium hover:bg-accent/40"
55
+ >
56
+ <Download className="size-4" aria-hidden /> Add to calendar
57
+ </a>
58
+ )}
59
+ </div>
60
+ </div>
61
+ );
62
+ }
@@ -0,0 +1,76 @@
1
+ import { Download, FileText } from 'lucide-react';
2
+ import { formatBytes } from '@mantle/client-types/lib/format-bytes';
3
+
4
+ /**
5
+ * Public file render with a media-appropriate viewer: images, PDFs, video, and
6
+ * audio play inline (served from the scoped asset route); everything else gets
7
+ * a clean download card. Centered.
8
+ */
9
+ export function FilePresenter({
10
+ view,
11
+ assetUrl,
12
+ }: {
13
+ view: { fileId: string; filename: string; mimeType: string; size: number };
14
+ assetUrl: (fileId: string) => string;
15
+ }) {
16
+ const src = assetUrl(view.fileId);
17
+ const mime = view.mimeType || '';
18
+ const isImage = mime.startsWith('image/');
19
+ const isPdf = mime === 'application/pdf';
20
+ const isVideo = mime.startsWith('video/');
21
+ const isAudio = mime.startsWith('audio/');
22
+
23
+ return (
24
+ <div className="mx-auto max-w-5xl px-6 py-12">
25
+ <header className="mb-6 text-center">
26
+ <h1 className="text-xl font-semibold tracking-tight">{view.filename}</h1>
27
+ <p className="mt-1 text-xs text-muted-foreground">
28
+ {mime || 'file'} · {formatBytes(view.size)}
29
+ </p>
30
+ </header>
31
+
32
+ {isImage ? (
33
+ <a href={src} target="_blank" rel="noreferrer" className="block">
34
+ {/* A plain <img> on purpose: share assets are token-scoped bytes,
35
+ not next/image-optimizable public files. */}
36
+ <img
37
+ src={src}
38
+ alt={view.filename}
39
+ className="mx-auto max-h-[80vh] w-auto rounded-lg border border-border"
40
+ />
41
+ </a>
42
+ ) : isPdf ? (
43
+ <iframe
44
+ src={src}
45
+ title={view.filename}
46
+ className="h-[82vh] w-full rounded-lg border border-border"
47
+ />
48
+ ) : isVideo ? (
49
+ <video
50
+ src={src}
51
+ controls
52
+ className="mx-auto max-h-[80vh] w-full rounded-lg border border-border bg-black"
53
+ />
54
+ ) : isAudio ? (
55
+ <div className="mx-auto max-w-xl rounded-lg border border-border bg-card p-4">
56
+ <audio src={src} controls className="w-full" />
57
+ </div>
58
+ ) : (
59
+ <div className="mx-auto flex max-w-md items-center gap-3 rounded-xl border border-border bg-card p-4">
60
+ <FileText className="size-8 shrink-0 text-muted-foreground" aria-hidden />
61
+ <div className="min-w-0 flex-1">
62
+ <p className="truncate text-sm font-medium">{view.filename}</p>
63
+ <p className="text-xs text-muted-foreground">{formatBytes(view.size)}</p>
64
+ </div>
65
+ <a
66
+ href={src}
67
+ download={view.filename}
68
+ className="inline-flex items-center gap-2 rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
69
+ >
70
+ <Download className="size-4" aria-hidden /> Download
71
+ </a>
72
+ </div>
73
+ )}
74
+ </div>
75
+ );
76
+ }
@@ -0,0 +1,209 @@
1
+ 'use client';
2
+
3
+ import { useMemo, useState } from 'react';
4
+ import { parseInputText } from '@mantle/content-core/formula-eval';
5
+ import type { FormulaValue } from '@mantle/content-core/formula-spec';
6
+ import type { TargetSignature } from '@mantle/content-core/formula-signature';
7
+ import type { TraceStep } from '@mantle/content-core/formula-eval';
8
+
9
+ /**
10
+ * The live calculator on a shared formula — a client island, because it is the
11
+ * one part of the page that has to talk back to the server.
12
+ *
13
+ * Its fields come from the Phase 1 signature, embedded in the page payload
14
+ * rather than re-derived here: which symbols a target needs, which are
15
+ * optional, and what the legal values of a rating are. A visitor on a public
16
+ * link has no way to read the resolution ladder, so anything this got wrong
17
+ * would surface as a bare "missing required input" they could not act on.
18
+ *
19
+ * It renders the DERIVATION, not just the number. A shared engineering result
20
+ * that cannot be checked is worth very little, and the trace is the difference
21
+ * between showing your work and asserting an answer.
22
+ */
23
+
24
+ type EvalResponse =
25
+ | { ok: true; value: FormulaValue; trace: TraceStep[] }
26
+ | { ok: false; error: string; trace?: TraceStep[] };
27
+
28
+ export function FormulaCalculator({
29
+ token,
30
+ signature,
31
+ }: {
32
+ token: string;
33
+ signature: TargetSignature[];
34
+ }) {
35
+ const [target, setTarget] = useState(signature[0]?.id ?? '');
36
+ const [inputs, setInputs] = useState<Record<string, string>>({});
37
+ const [result, setResult] = useState<EvalResponse | null>(null);
38
+ const [running, setRunning] = useState(false);
39
+
40
+ const active = useMemo(() => signature.find((s) => s.id === target), [signature, target]);
41
+ const fields = active?.inputs ?? [];
42
+
43
+ async function run() {
44
+ if (!target) return;
45
+ setRunning(true);
46
+ try {
47
+ const supplied: Record<string, FormulaValue> = {};
48
+ for (const [k, v] of Object.entries(inputs)) {
49
+ const parsed = parseInputText(v);
50
+ if (parsed !== undefined) supplied[k] = parsed;
51
+ }
52
+ const res = await fetch(`/s/${encodeURIComponent(token)}/evaluate`, {
53
+ method: 'POST',
54
+ headers: { 'content-type': 'application/json' },
55
+ body: JSON.stringify({ target, inputs: supplied }),
56
+ });
57
+ const body = (await res.json().catch(() => ({}))) as Partial<EvalResponse> & {
58
+ error?: string;
59
+ };
60
+ if (!res.ok) {
61
+ setResult({ ok: false, error: body.error ?? `${res.status} ${res.statusText}` });
62
+ } else {
63
+ setResult(body as EvalResponse);
64
+ }
65
+ } catch (err) {
66
+ setResult({ ok: false, error: err instanceof Error ? err.message : 'evaluation failed' });
67
+ } finally {
68
+ setRunning(false);
69
+ }
70
+ }
71
+
72
+ if (signature.length === 0) return null;
73
+
74
+ return (
75
+ <div className="rounded-lg border border-border bg-card p-5">
76
+ <h2 className="text-lg font-semibold text-foreground">Calculate</h2>
77
+
78
+ <label className="mt-4 block text-xs font-medium text-muted-foreground" htmlFor="calc-target">
79
+ What to compute
80
+ </label>
81
+ <select
82
+ id="calc-target"
83
+ value={target}
84
+ onChange={(e) => {
85
+ setTarget(e.target.value);
86
+ setResult(null);
87
+ }}
88
+ className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground"
89
+ >
90
+ {signature.map((s) => (
91
+ <option key={s.id} value={s.id}>
92
+ {s.id}
93
+ {s.produces ? ` → ${s.produces}${s.unit ? ` [${s.unit}]` : ''}` : ''}
94
+ </option>
95
+ ))}
96
+ </select>
97
+
98
+ {active && active.unverified.length > 0 ? (
99
+ <p className="mt-3 rounded-md border border-destructive/40 bg-destructive/5 p-3 text-xs text-muted-foreground">
100
+ This result depends on {active.unverified.length} equation
101
+ {active.unverified.length === 1 ? '' : 's'} that{' '}
102
+ {active.unverified.length === 1 ? 'was' : 'were'} not read from the source.
103
+ </p>
104
+ ) : null}
105
+
106
+ <div className="mt-4 grid gap-3 sm:grid-cols-2">
107
+ {fields.map((f) => (
108
+ <div key={f.symbol}>
109
+ <label
110
+ className="block text-xs font-medium text-muted-foreground"
111
+ htmlFor={`calc-${f.symbol}`}
112
+ >
113
+ <span className="font-mono text-foreground">{f.symbol}</span>
114
+ {f.unit ? ` (${f.unit})` : ''}
115
+ {f.required ? '' : ' — optional'}
116
+ </label>
117
+ {f.name ? <p className="text-[11px] text-muted-foreground">{f.name}</p> : null}
118
+ {f.kind === 'enum' && f.domain?.length ? (
119
+ <select
120
+ id={`calc-${f.symbol}`}
121
+ value={inputs[f.symbol] ?? ''}
122
+ onChange={(e) => setInputs((p) => ({ ...p, [f.symbol]: e.target.value }))}
123
+ className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground"
124
+ >
125
+ <option value="">Choose…</option>
126
+ {f.domain.map((v) => (
127
+ <option key={String(v)} value={String(v)}>
128
+ {String(v)}
129
+ {f.criteria?.[String(v)] ? ` — ${f.criteria[String(v)]}` : ''}
130
+ </option>
131
+ ))}
132
+ </select>
133
+ ) : (
134
+ <input
135
+ id={`calc-${f.symbol}`}
136
+ value={inputs[f.symbol] ?? ''}
137
+ placeholder={f.default !== undefined ? String(f.default) : ''}
138
+ onChange={(e) => setInputs((p) => ({ ...p, [f.symbol]: e.target.value }))}
139
+ className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground"
140
+ />
141
+ )}
142
+ </div>
143
+ ))}
144
+ </div>
145
+
146
+ <button
147
+ onClick={run}
148
+ disabled={running || !target}
149
+ className="mt-4 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground disabled:opacity-60"
150
+ >
151
+ {running ? 'Calculating…' : 'Calculate'}
152
+ </button>
153
+
154
+ {result ? (
155
+ <div
156
+ className={`mt-4 rounded-md border p-4 ${
157
+ result.ok ? 'border-border bg-muted/40' : 'border-destructive/40 bg-destructive/5'
158
+ }`}
159
+ >
160
+ {result.ok ? (
161
+ <p className="font-mono text-2xl text-foreground">
162
+ {String(result.value)}
163
+ {active?.unit ? (
164
+ <span className="ml-2 text-sm text-muted-foreground">{active.unit}</span>
165
+ ) : null}
166
+ </p>
167
+ ) : (
168
+ <p className="text-sm text-destructive-ink">{result.error}</p>
169
+ )}
170
+ {result.trace && result.trace.length > 0 ? (
171
+ <details className="mt-3">
172
+ <summary className="cursor-pointer text-xs text-muted-foreground">
173
+ Derivation ({result.trace.length} steps)
174
+ </summary>
175
+ <ol className="mt-2 space-y-1 font-mono text-xs text-muted-foreground">
176
+ {result.trace.map((step, i) => (
177
+ <li key={i}>
178
+ {step.kind === 'symbol' ? (
179
+ <>
180
+ {step.symbol} = {String(step.value)}{' '}
181
+ <span className="opacity-60">({step.from})</span>
182
+ </>
183
+ ) : step.kind === 'expression' ? (
184
+ <>
185
+ {step.id} → {String(step.value)}
186
+ </>
187
+ ) : step.kind === 'branch' ? (
188
+ <>
189
+ {step.id}: {step.label ?? step.chose} — {step.when}
190
+ </>
191
+ ) : (
192
+ <>
193
+ {step.id}[
194
+ {Object.entries(step.key)
195
+ .map(([k, v]) => `${k}=${String(v)}`)
196
+ .join(', ')}
197
+ ] → {String(step.value)}
198
+ </>
199
+ )}
200
+ </li>
201
+ ))}
202
+ </ol>
203
+ </details>
204
+ ) : null}
205
+ </div>
206
+ ) : null}
207
+ </div>
208
+ );
209
+ }