@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.
- package/LICENSE.md +135 -0
- package/package.json +68 -0
- package/src/app-bridge-protocol.ts +115 -0
- package/src/app-presenter.tsx +25 -0
- package/src/app-sandbox.tsx +552 -0
- package/src/appearance.ts +192 -0
- package/src/avatar.test.ts +229 -0
- package/src/avatar.ts +731 -0
- package/src/backgrounds.test.ts +119 -0
- package/src/backgrounds.ts +118 -0
- package/src/draw-presenter.tsx +39 -0
- package/src/event-presenter.tsx +62 -0
- package/src/file-presenter.tsx +76 -0
- package/src/formula-calculator.tsx +209 -0
- package/src/formula-presenter.test.ts +128 -0
- package/src/formula-presenter.tsx +301 -0
- package/src/help-topics.ts +104 -0
- package/src/lib/ink-audit.test.ts +314 -0
- package/src/lib/theme-css-blocks.ts +26 -0
- package/src/lib/theme-generator.test.ts +179 -0
- package/src/lib/theme-registry.gen.ts +352 -0
- package/src/lib/themes.test.ts +308 -0
- package/src/lib/themes.ts +75 -0
- package/src/lib/utils.ts +6 -0
- package/src/nav-items.ts +225 -0
- package/src/note-presenter.tsx +14 -0
- package/src/page-outline.tsx +127 -0
- package/src/table-presenter.tsx +226 -0
- package/src/task-presenter.tsx +60 -0
- package/src/ui/button.tsx +50 -0
- package/src/ui/input.tsx +18 -0
- package/src/ui/label.tsx +20 -0
- package/src/view-payload.ts +82 -0
- package/styles/app.css +1098 -0
- package/styles/themes.css +6198 -0
- package/themes/generate.d.mts +11 -0
- package/themes/generate.mjs +618 -0
- package/themes/model.d.mts +24 -0
- package/themes/model.mjs +213 -0
- package/themes/preview.html +145 -0
- package/themes/seeds.d.mts +16 -0
- package/themes/seeds.mjs +3694 -0
- package/tsconfig.json +15 -0
- 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
|
+
}
|