@oharness/server 0.10.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.
Files changed (74) hide show
  1. package/dist/analytics.d.ts +32 -29
  2. package/dist/analytics.js +225 -278
  3. package/dist/analytics.js.map +1 -1
  4. package/dist/i18n.js +242 -12
  5. package/dist/i18n.js.map +1 -1
  6. package/dist/icons.d.ts +3 -0
  7. package/dist/icons.js +69 -3
  8. package/dist/icons.js.map +1 -1
  9. package/dist/model-order.js +1 -0
  10. package/dist/model-order.js.map +1 -1
  11. package/dist/page/client/account.js +140 -4
  12. package/dist/page/client/account.js.map +1 -1
  13. package/dist/page/client/base.js +42 -16
  14. package/dist/page/client/base.js.map +1 -1
  15. package/dist/page/client/browser.js +1 -1
  16. package/dist/page/client/composer.js +641 -47
  17. package/dist/page/client/composer.js.map +1 -1
  18. package/dist/page/client/dialogs.js +75 -0
  19. package/dist/page/client/dialogs.js.map +1 -1
  20. package/dist/page/client/events.js +25 -2
  21. package/dist/page/client/events.js.map +1 -1
  22. package/dist/page/client/index.js +4 -0
  23. package/dist/page/client/index.js.map +1 -1
  24. package/dist/page/client/panels.js +4 -4
  25. package/dist/page/client/ripple.d.ts +13 -0
  26. package/dist/page/client/ripple.js +247 -0
  27. package/dist/page/client/ripple.js.map +1 -0
  28. package/dist/page/client/routines.js +173 -41
  29. package/dist/page/client/routines.js.map +1 -1
  30. package/dist/page/client/shell.js +200 -54
  31. package/dist/page/client/shell.js.map +1 -1
  32. package/dist/page/client/state.js +7 -2
  33. package/dist/page/client/state.js.map +1 -1
  34. package/dist/page/client/terminal.js +34 -7
  35. package/dist/page/client/terminal.js.map +1 -1
  36. package/dist/page/client/theme.d.ts +17 -0
  37. package/dist/page/client/theme.js +365 -0
  38. package/dist/page/client/theme.js.map +1 -0
  39. package/dist/page/client/transcript.js +63 -7
  40. package/dist/page/client/transcript.js.map +1 -1
  41. package/dist/page/style.js +273 -22
  42. package/dist/page/style.js.map +1 -1
  43. package/dist/page.js +5 -5
  44. package/dist/page.js.map +1 -1
  45. package/dist/prefs.d.ts +17 -0
  46. package/dist/prefs.js +5 -0
  47. package/dist/prefs.js.map +1 -1
  48. package/dist/server.js +710 -45
  49. package/dist/server.js.map +1 -1
  50. package/dist/suggest.d.ts +42 -0
  51. package/dist/suggest.js +215 -0
  52. package/dist/suggest.js.map +1 -0
  53. package/dist/terminal.d.ts +35 -2
  54. package/dist/terminal.js +60 -4
  55. package/dist/terminal.js.map +1 -1
  56. package/dist/theme-assets.d.ts +39 -0
  57. package/dist/theme-assets.js +109 -0
  58. package/dist/theme-assets.js.map +1 -0
  59. package/dist/theme-store.d.ts +50 -0
  60. package/dist/theme-store.js +124 -0
  61. package/dist/theme-store.js.map +1 -0
  62. package/dist/theme-tools.d.ts +25 -0
  63. package/dist/theme-tools.js +243 -0
  64. package/dist/theme-tools.js.map +1 -0
  65. package/dist/tool-icons.d.ts +12 -0
  66. package/dist/tool-icons.js +16 -0
  67. package/dist/tool-icons.js.map +1 -0
  68. package/dist/turn-shots.d.ts +18 -2
  69. package/dist/turn-shots.js +10 -1
  70. package/dist/turn-shots.js.map +1 -1
  71. package/dist/user-theme.d.ts +143 -0
  72. package/dist/user-theme.js +510 -0
  73. package/dist/user-theme.js.map +1 -0
  74. package/package.json +2 -2
@@ -0,0 +1,143 @@
1
+ /**
2
+ * A theme the agent proposes, and what the page does with it.
3
+ *
4
+ * The agent never writes CSS. It hands over a few colours — the seeds — and
5
+ * optionally a value for any other colour token, and everything here is what
6
+ * turns that into a stylesheet that cannot break the page: the rest of the
7
+ * palette is derived from the seeds the way the built-in palettes relate to
8
+ * theirs, every pair that carries text is held to a contrast ratio, and the
9
+ * printed CSS only ever sets the custom properties `theme.ts` already defines.
10
+ * There is no selector, property or URL in a theme for anything to go wrong
11
+ * with.
12
+ *
13
+ * Images go in slots the page defines — the conversation background, the art
14
+ * on an empty session, the assistant's avatar — each with a few enumerated or
15
+ * clamped options. A theme names a stored asset, never a URL; where the image
16
+ * lands, what sits above it and how readable the text over it stays are the
17
+ * page's decisions, not the theme's.
18
+ *
19
+ * See docs/plans/ai-theming.md. This covers global colour tokens and image
20
+ * slots. Shape tokens, part-scoped variables and layout variants come later.
21
+ */
22
+ export type ThemeMode = "both" | "light" | "dark";
23
+ /** A stored image: the sha-256 of its bytes and its type. See theme-assets.ts. */
24
+ export declare const ASSET_ID: RegExp;
25
+ export declare const FITS: readonly ["cover", "contain", "tile"];
26
+ export declare const ANCHORS: readonly ["top-left", "top", "top-right", "left", "center", "right", "bottom-left", "bottom", "bottom-right"];
27
+ export declare const BLENDS: readonly ["normal", "multiply", "screen", "luminosity", "soft-light"];
28
+ /** Behind the conversation. Text sits over it, so it always has a scrim. */
29
+ export interface WallpaperSlot {
30
+ asset: string;
31
+ fit: (typeof FITS)[number];
32
+ anchor: (typeof ANCHORS)[number];
33
+ /** How much of the page colour covers the image, 0.5–0.95. The page raises it further if text would be hard to read. */
34
+ scrim: number;
35
+ blend: (typeof BLENDS)[number];
36
+ /** The dark side's own image or scrim; the rest is shared. */
37
+ dark?: {
38
+ asset?: string;
39
+ scrim?: number;
40
+ };
41
+ }
42
+ /** Above the title of a new, empty session. Nothing is drawn over it. */
43
+ export interface EmptyArtSlot {
44
+ asset: string;
45
+ size: "small" | "medium" | "large";
46
+ align: "left" | "center";
47
+ }
48
+ /** Beside the "assistant" label of every reply. */
49
+ export interface AvatarSlot {
50
+ asset: string;
51
+ shape: "circle" | "rounded" | "square";
52
+ }
53
+ export interface ThemeImages {
54
+ conversation?: WallpaperSlot;
55
+ empty?: EmptyArtSlot;
56
+ avatar?: AvatarSlot;
57
+ }
58
+ /** What `set_theme` accepts, after validation. */
59
+ export interface ThemeInput {
60
+ version: 1;
61
+ name: string;
62
+ mode: ThemeMode;
63
+ light?: Record<string, string>;
64
+ dark?: Record<string, string>;
65
+ images?: ThemeImages;
66
+ }
67
+ /** The slots, as the model is told about them in `get_theme_schema`. */
68
+ export declare const IMAGE_SLOTS: Record<keyof ThemeImages, string>;
69
+ export interface ThemeReport {
70
+ /** Keys or values thrown away, and why. */
71
+ dropped: string[];
72
+ /** Colours moved to pass a contrast check. */
73
+ fixed: string[];
74
+ /** Things worth knowing that did not change anything. */
75
+ notes: string[];
76
+ }
77
+ export interface ResolvedTheme {
78
+ input: ThemeInput;
79
+ light?: Record<string, string>;
80
+ dark?: Record<string, string>;
81
+ report: ThemeReport;
82
+ }
83
+ /** The four colours the rest of a palette is derived from. */
84
+ export declare const SEEDS: readonly ["bg", "fg", "accent", "brand"];
85
+ /**
86
+ * Every token a theme may set, with what it is for. The meanings are what the
87
+ * model reads in `get_theme_schema`, so they say where the colour shows rather
88
+ * than restating its name.
89
+ */
90
+ export declare const TOKENS: Record<string, string>;
91
+ interface Rgba {
92
+ r: number;
93
+ g: number;
94
+ b: number;
95
+ a: number;
96
+ }
97
+ /** `#rgb`, `#rgba`, `#rrggbb` or `#rrggbbaa`; anything else is not a colour here. */
98
+ export declare function parseHex(value: string): Rgba | undefined;
99
+ export declare function toHex(c: Rgba): string;
100
+ /** WCAG contrast ratio, with a translucent colour composited first. */
101
+ export declare function contrast(top: Rgba, under: Rgba): number;
102
+ /**
103
+ * Check what the model sent and turn it into a theme.
104
+ *
105
+ * Lenient where leniency cannot hurt: an unknown key or a bad value is dropped
106
+ * and reported, so one typo does not cost the whole proposal. Strict where it
107
+ * can: nothing that is not a known token with a hex colour reaches the CSS.
108
+ */
109
+ export declare function readThemeInput(raw: unknown): {
110
+ ok: true;
111
+ input: ThemeInput;
112
+ dropped: string[];
113
+ } | {
114
+ ok: false;
115
+ error: string;
116
+ };
117
+ /** Validate, derive and check: everything between the model's JSON and the CSS. */
118
+ export declare function resolveTheme(raw: unknown): {
119
+ ok: true;
120
+ theme: ResolvedTheme;
121
+ } | {
122
+ ok: false;
123
+ error: string;
124
+ };
125
+ /**
126
+ * The stylesheet, printed after the built-in one with the same selectors so it
127
+ * wins on order alone. A one-sided theme pins `color-scheme` and prints its
128
+ * palette under every selector, so the system setting and the light/dark
129
+ * switch cannot bring the other side back.
130
+ */
131
+ export declare function themeCss(theme: ResolvedTheme): string;
132
+ /** Where a stored image is served; see `GET /theme-asset/` in server.ts. */
133
+ export declare const assetUrl: (asset: string) => string;
134
+ /** Every asset a theme refers to, for keeping and for cleaning up. */
135
+ export declare function themeAssets(input: ThemeInput): string[];
136
+ /** A few colours per side for a card to show before anything is applied. */
137
+ export declare function swatches(theme: ResolvedTheme): {
138
+ light?: string[];
139
+ dark?: string[];
140
+ };
141
+ /** What the model reads back after `set_theme`. */
142
+ export declare function describeReport(report: ThemeReport): string;
143
+ export {};
@@ -0,0 +1,510 @@
1
+ import { DARK, LIGHT } from "./theme.js";
2
+ /** A stored image: the sha-256 of its bytes and its type. See theme-assets.ts. */
3
+ export const ASSET_ID = /^[0-9a-f]{64}\.(png|jpg|webp)$/;
4
+ export const FITS = ["cover", "contain", "tile"];
5
+ export const ANCHORS = [
6
+ "top-left", "top", "top-right", "left", "center", "right", "bottom-left", "bottom", "bottom-right",
7
+ ];
8
+ export const BLENDS = ["normal", "multiply", "screen", "luminosity", "soft-light"];
9
+ /** The slots, as the model is told about them in `get_theme_schema`. */
10
+ export const IMAGE_SLOTS = {
11
+ conversation: "Background of the conversation area, behind the messages. Options: fit (cover | contain | tile, default cover), anchor (top-left … center … bottom-right, default center), scrim (0.5–0.95, default 0.8: how much of the page colour covers the image; raised automatically if text would be hard to read), blend (normal | multiply | screen | luminosity | soft-light), dark ({ image?, scrim? } for the dark side). Best: a calm, low-detail landscape image around 2560×1600.",
12
+ empty: "Art above the title of a new, empty session. Options: size (small | medium | large), align (left | center). Best: a transparent PNG, square or a little wider than tall.",
13
+ avatar: "The assistant's avatar beside each reply, shown at 20px. Options: shape (circle | rounded | square). Best: a square image with a simple, bold subject.",
14
+ };
15
+ /** The four colours the rest of a palette is derived from. */
16
+ export const SEEDS = ["bg", "fg", "accent", "brand"];
17
+ /**
18
+ * Every token a theme may set, with what it is for. The meanings are what the
19
+ * model reads in `get_theme_schema`, so they say where the colour shows rather
20
+ * than restating its name.
21
+ */
22
+ export const TOKENS = {
23
+ bg: "Page background behind the conversation. Seed.",
24
+ fg: "Body text. Seed.",
25
+ accent: "Primary buttons and committing actions; their text is drawn in bg. Seed.",
26
+ brand: "The harness being alive: thinking sweep, a call in flight, the caret, a switch that is on. Marks, not words. Seed.",
27
+ panel: "Raised surfaces: cards, menus, code blocks.",
28
+ frame: "The window frame: top bar and rail, set back behind both page and panel.",
29
+ dim: "Secondary text: labels, timestamps, hints.",
30
+ line: "Hairline borders. Usually fg at low alpha.",
31
+ "brand-tint": "Soft fill behind brand-coloured marks. Usually brand at low alpha.",
32
+ "brand-ink": "Brand-coloured words, such as the thinking label.",
33
+ "brand-shine": "The band that sweeps across the thinking label.",
34
+ ok: "Success text and marks.",
35
+ warn: "Warning text and marks.",
36
+ err: "Error text and marks.",
37
+ add: "Added lines in a diff.",
38
+ "add-bg": "Background of added lines.",
39
+ del: "Removed lines in a diff.",
40
+ "del-bg": "Background of removed lines.",
41
+ hover: "Row and button hover wash. Usually fg at very low alpha.",
42
+ sel: "Selected row wash. Usually fg at low alpha.",
43
+ focus: "Focus outline.",
44
+ ring: "Soft focus ring around fields.",
45
+ field: "Input field fill.",
46
+ "field-focus": "Input field fill while focused.",
47
+ track: "Scrollbar and slider tracks.",
48
+ kw: "Syntax: keywords.",
49
+ str: "Syntax: strings.",
50
+ com: "Syntax: comments.",
51
+ num: "Syntax: numbers.",
52
+ };
53
+ /**
54
+ * Pairs that carry text or marks, and the ratio each needs. The first token is
55
+ * the one moved when a pair fails: the colour drawn on top, never the surface
56
+ * under it, because a surface is shared by many pairs and text is not.
57
+ */
58
+ const PAIRS = [
59
+ ["fg", "bg", 4.5],
60
+ ["fg", "panel", 4.5],
61
+ ["fg", "frame", 4.5],
62
+ ["dim", "bg", 4.5],
63
+ ["dim", "panel", 4.5],
64
+ ["dim", "frame", 4.5],
65
+ ["accent", "bg", 4.5],
66
+ ["brand-ink", "bg", 4.5],
67
+ ["brand", "bg", 3],
68
+ ["focus", "bg", 3],
69
+ ["ok", "bg", 4.5],
70
+ ["warn", "bg", 4.5],
71
+ ["err", "bg", 4.5],
72
+ ["add", "add-bg", 4.5],
73
+ ["del", "del-bg", 4.5],
74
+ ["kw", "panel", 4.5],
75
+ ["str", "panel", 4.5],
76
+ ["num", "panel", 4.5],
77
+ ["com", "panel", 3],
78
+ ];
79
+ const MAX_NAME = 60;
80
+ /** `#rgb`, `#rgba`, `#rrggbb` or `#rrggbbaa`; anything else is not a colour here. */
81
+ export function parseHex(value) {
82
+ const match = /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.exec(value.trim());
83
+ if (!match)
84
+ return undefined;
85
+ let hex = match[1];
86
+ if (hex.length <= 4)
87
+ hex = [...hex].map((c) => c + c).join("");
88
+ const n = (i) => parseInt(hex.slice(i, i + 2), 16);
89
+ return { r: n(0), g: n(2), b: n(4), a: hex.length === 8 ? n(6) / 255 : 1 };
90
+ }
91
+ const byte = (v) => Math.round(Math.min(255, Math.max(0, v))).toString(16).padStart(2, "0");
92
+ export function toHex(c) {
93
+ const base = `#${byte(c.r)}${byte(c.g)}${byte(c.b)}`;
94
+ return c.a >= 1 ? base : base + byte(c.a * 255);
95
+ }
96
+ /** A colour drawn over an opaque one, as the eye sees it. */
97
+ function over(top, under) {
98
+ const a = top.a;
99
+ return { r: top.r * a + under.r * (1 - a), g: top.g * a + under.g * (1 - a), b: top.b * a + under.b * (1 - a), a: 1 };
100
+ }
101
+ const linear = (v) => {
102
+ const s = v / 255;
103
+ return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
104
+ };
105
+ const gamma = (v) => 255 * (v <= 0.0031308 ? v * 12.92 : 1.055 * v ** (1 / 2.4) - 0.055);
106
+ function luminance(c) {
107
+ return 0.2126 * linear(c.r) + 0.7152 * linear(c.g) + 0.0722 * linear(c.b);
108
+ }
109
+ /** WCAG contrast ratio, with a translucent colour composited first. */
110
+ export function contrast(top, under) {
111
+ const a = luminance(over(top, under));
112
+ const b = luminance(under);
113
+ return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
114
+ }
115
+ function toOklch(c) {
116
+ const r = linear(c.r), g = linear(c.g), b = linear(c.b);
117
+ const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
118
+ const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
119
+ const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
120
+ const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
121
+ const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
122
+ const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
123
+ return { l: L, c: Math.hypot(A, B), h: Math.atan2(B, A) };
124
+ }
125
+ function fromOklch(o, alpha = 1) {
126
+ const A = o.c * Math.cos(o.h), B = o.c * Math.sin(o.h);
127
+ const l = (o.l + 0.3963377774 * A + 0.2158037573 * B) ** 3;
128
+ const m = (o.l - 0.1055613458 * A - 0.0638541728 * B) ** 3;
129
+ const s = (o.l - 0.0894841775 * A - 1.291485548 * B) ** 3;
130
+ return {
131
+ r: gamma(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
132
+ g: gamma(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
133
+ b: gamma(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s),
134
+ a: alpha,
135
+ };
136
+ }
137
+ function mix(a, b, t) {
138
+ return { r: a.r + (b.r - a.r) * t, g: a.g + (b.g - a.g) * t, b: a.b + (b.b - a.b) * t, a: 1 };
139
+ }
140
+ function withAlpha(c, alpha) {
141
+ return { ...c, a: alpha };
142
+ }
143
+ function shiftLightness(c, delta) {
144
+ const o = toOklch(c);
145
+ return fromOklch({ ...o, l: Math.min(1, Math.max(0, o.l + delta)) }, c.a);
146
+ }
147
+ const isDark = (c) => luminance(c) < 0.18;
148
+ /**
149
+ * The smallest move in OKLCH lightness that lifts `top` to `min` against
150
+ * `under`, keeping its hue and chroma. Lighter over a dark surface, darker over
151
+ * a light one. Returns undefined when even black or white would not do.
152
+ */
153
+ function nudge(top, under, ratio) {
154
+ // Aimed a little past the ratio: the result is rounded to whole bytes when
155
+ // printed as hex, and that rounding alone can land it just short.
156
+ const min = ratio + 0.05;
157
+ const start = toOklch(over(top, under));
158
+ const up = isDark(under);
159
+ const at = (l) => fromOklch({ ...start, l });
160
+ const end = up ? 1 : 0;
161
+ if (contrast(at(end), under) < min)
162
+ return undefined;
163
+ let near = start.l, far = end;
164
+ for (let i = 0; i < 24; i++) {
165
+ const mid = (near + far) / 2;
166
+ if (contrast(at(mid), under) >= min)
167
+ far = mid;
168
+ else
169
+ near = mid;
170
+ }
171
+ return at(far);
172
+ }
173
+ // --- validation ------------------------------------------------------------
174
+ /**
175
+ * Check what the model sent and turn it into a theme.
176
+ *
177
+ * Lenient where leniency cannot hurt: an unknown key or a bad value is dropped
178
+ * and reported, so one typo does not cost the whole proposal. Strict where it
179
+ * can: nothing that is not a known token with a hex colour reaches the CSS.
180
+ */
181
+ export function readThemeInput(raw) {
182
+ if (typeof raw !== "object" || raw === null)
183
+ return { ok: false, error: "the theme must be an object" };
184
+ const body = raw;
185
+ const dropped = [];
186
+ const name = typeof body["name"] === "string" ? body["name"].trim().slice(0, MAX_NAME) : "";
187
+ const mode = body["mode"] === "light" || body["mode"] === "dark" ? body["mode"] : "both";
188
+ const readPalette = (key) => {
189
+ const value = body[key];
190
+ if (value === undefined || value === null)
191
+ return undefined;
192
+ if (typeof value !== "object" || Array.isArray(value)) {
193
+ dropped.push(`${key}: not an object of token → colour`);
194
+ return undefined;
195
+ }
196
+ const out = {};
197
+ for (const [token, colour] of Object.entries(value)) {
198
+ if (!(token in TOKENS)) {
199
+ dropped.push(`${key}.${token}: not a theme token`);
200
+ continue;
201
+ }
202
+ const parsed = typeof colour === "string" ? parseHex(colour) : undefined;
203
+ if (!parsed) {
204
+ dropped.push(`${key}.${token}: ${JSON.stringify(colour)} is not a hex colour`);
205
+ continue;
206
+ }
207
+ if (SEEDS.includes(token) && parsed.a < 1) {
208
+ dropped.push(`${key}.${token}: seeds must be opaque`);
209
+ continue;
210
+ }
211
+ out[token] = toHex(parsed);
212
+ }
213
+ return Object.keys(out).length ? out : undefined;
214
+ };
215
+ const light = mode === "dark" ? undefined : readPalette("light");
216
+ const dark = mode === "light" ? undefined : readPalette("dark");
217
+ if (mode === "dark" && body["light"])
218
+ dropped.push("light: ignored, the theme is dark-only");
219
+ if (mode === "light" && body["dark"])
220
+ dropped.push("dark: ignored, the theme is light-only");
221
+ const images = readImages(body["images"], dropped);
222
+ if (mode === "light" && !light)
223
+ return { ok: false, error: "a light-only theme needs a light palette" };
224
+ if (mode === "dark" && !dark)
225
+ return { ok: false, error: "a dark-only theme needs a dark palette" };
226
+ if (!light && !dark && !images) {
227
+ return { ok: false, error: "the theme sets nothing: give at least bg and fg in light or dark, or an image" };
228
+ }
229
+ return {
230
+ ok: true,
231
+ input: {
232
+ version: 1, name: name || "Custom", mode,
233
+ ...(light ? { light } : {}), ...(dark ? { dark } : {}), ...(images ? { images } : {}),
234
+ },
235
+ dropped,
236
+ };
237
+ }
238
+ const oneOf = (options, value, fallback) => typeof value === "string" && options.includes(value) ? value : fallback;
239
+ const clampNumber = (value, min, max, fallback) => typeof value === "number" && Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback;
240
+ /**
241
+ * Image slots, by asset id only. Turning a file the model names into an asset
242
+ * is the tool's job (theme-tools.ts), done before this runs, so a stored theme
243
+ * can be re-validated later without the file it came from.
244
+ */
245
+ function readImages(raw, dropped) {
246
+ if (raw === undefined || raw === null)
247
+ return undefined;
248
+ if (typeof raw !== "object" || Array.isArray(raw)) {
249
+ dropped.push("images: not an object of slot → options");
250
+ return undefined;
251
+ }
252
+ const body = raw;
253
+ const out = {};
254
+ const asset = (slot, value) => {
255
+ if (typeof value === "string" && ASSET_ID.test(value))
256
+ return value;
257
+ dropped.push(`images.${slot}: no usable image`);
258
+ return undefined;
259
+ };
260
+ for (const key of Object.keys(body)) {
261
+ if (!(key in IMAGE_SLOTS))
262
+ dropped.push(`images.${key}: not an image slot`);
263
+ }
264
+ const wall = body["conversation"];
265
+ if (wall && typeof wall === "object") {
266
+ const w = wall;
267
+ const id = asset("conversation", w["asset"]);
268
+ if (id) {
269
+ const slot = {
270
+ asset: id,
271
+ fit: oneOf(FITS, w["fit"], "cover"),
272
+ anchor: oneOf(ANCHORS, w["anchor"], "center"),
273
+ scrim: clampNumber(w["scrim"], 0.5, 0.95, 0.8),
274
+ blend: oneOf(BLENDS, w["blend"], "normal"),
275
+ };
276
+ const d = w["dark"];
277
+ if (d && typeof d === "object") {
278
+ const dd = d;
279
+ const darkAsset = dd["asset"] === undefined ? undefined : asset("conversation.dark", dd["asset"]);
280
+ const darkScrim = dd["scrim"] === undefined ? undefined : clampNumber(dd["scrim"], 0.5, 0.95, slot.scrim);
281
+ if (darkAsset || darkScrim !== undefined) {
282
+ slot.dark = { ...(darkAsset ? { asset: darkAsset } : {}), ...(darkScrim !== undefined ? { scrim: darkScrim } : {}) };
283
+ }
284
+ }
285
+ out.conversation = slot;
286
+ }
287
+ }
288
+ const empty = body["empty"];
289
+ if (empty && typeof empty === "object") {
290
+ const e = empty;
291
+ const id = asset("empty", e["asset"]);
292
+ if (id) {
293
+ out.empty = {
294
+ asset: id,
295
+ size: oneOf(["small", "medium", "large"], e["size"], "medium"),
296
+ align: oneOf(["left", "center"], e["align"], "left"),
297
+ };
298
+ }
299
+ }
300
+ const avatar = body["avatar"];
301
+ if (avatar && typeof avatar === "object") {
302
+ const a = avatar;
303
+ const id = asset("avatar", a["asset"]);
304
+ if (id)
305
+ out.avatar = { asset: id, shape: oneOf(["circle", "rounded", "square"], a["shape"], "circle") };
306
+ }
307
+ return Object.keys(out).length ? out : undefined;
308
+ }
309
+ /**
310
+ * A whole palette from what the theme set. Seeds the theme left out come from
311
+ * the built-in palette of the same lightness, so a theme that only gives bg
312
+ * and fg still has a sensible accent.
313
+ */
314
+ function derive(given, side, report) {
315
+ const builtin = side === "light" ? LIGHT : DARK;
316
+ const pick = (token) => parseHex(given[token] ?? builtin[token]);
317
+ const bg = pick("bg");
318
+ // The base for the tokens nobody derives: whichever built-in palette this
319
+ // background resembles, not the side it was filed under.
320
+ const base = isDark(bg) ? DARK : LIGHT;
321
+ const dark = isDark(bg);
322
+ if (dark !== (side === "dark")) {
323
+ report.notes.push(`${side}.bg is a ${dark ? "dark" : "light"} colour; the palette was derived as ${dark ? "dark" : "light"}`);
324
+ }
325
+ let fg = pick("fg");
326
+ const fgFixed = nudge(fg, bg, 7);
327
+ if (contrast(fg, bg) < 4.5 && fgFixed) {
328
+ report.fixed.push(`${side}.fg ${toHex(fg)} → ${toHex(fgFixed)} (was ${contrast(fg, bg).toFixed(1)}:1 on bg)`);
329
+ fg = fgFixed;
330
+ }
331
+ const accent = given["accent"] ? pick("accent") : parseHex(base["accent"]);
332
+ const brand = given["brand"] ? pick("brand") : parseHex(base["brand"]);
333
+ const fgA = (alpha) => toHex(withAlpha(fg, alpha / 255));
334
+ const dim = mix(fg, bg, 0.42);
335
+ const palette = {
336
+ ...base,
337
+ bg: toHex(bg),
338
+ fg: toHex(fg),
339
+ accent: toHex(accent),
340
+ brand: toHex(brand),
341
+ panel: toHex(mix(bg, fg, dark ? 0.045 : 0.03)),
342
+ frame: toHex(shiftLightness(bg, dark ? -0.035 : -0.055)),
343
+ dim: toHex(dim),
344
+ line: fgA(dark ? 0x14 : 0x10),
345
+ hover: fgA(dark ? 0x0a : 0x08),
346
+ sel: fgA(dark ? 0x1a : 0x12),
347
+ field: fgA(dark ? 0x08 : 0x06),
348
+ "field-focus": fgA(dark ? 0x12 : 0x0d),
349
+ track: fgA(dark ? 0x2e : 0x1f),
350
+ focus: toHex(dark ? dim : accent),
351
+ ring: toHex(withAlpha(dark ? fg : accent, dark ? 0x14 / 255 : 0x1a / 255)),
352
+ "brand-tint": toHex(withAlpha(brand, (dark ? 0x33 : 0x24) / 255)),
353
+ "brand-ink": toHex(brand),
354
+ };
355
+ // Explicit values win over derivation; seeds were already taken above.
356
+ for (const [token, value] of Object.entries(given)) {
357
+ if (!SEEDS.includes(token))
358
+ palette[token] = value;
359
+ }
360
+ return palette;
361
+ }
362
+ /** Hold every text pair to its ratio, moving the colour on top when it fails. */
363
+ function enforce(palette, side, given, report) {
364
+ // One line per colour the model picked, however many pairs moved it: from
365
+ // what it asked for to where it ended up, and the pair that decided it.
366
+ const moves = new Map();
367
+ for (const [top, under, min] of PAIRS) {
368
+ const surface = parseHex(palette[under]);
369
+ // A translucent surface is judged as it is seen: over the page.
370
+ const seen = surface.a < 1 ? over(surface, parseHex(palette["bg"])) : surface;
371
+ const colour = parseHex(palette[top]);
372
+ const ratio = contrast(colour, seen);
373
+ if (ratio >= min)
374
+ continue;
375
+ const moved = nudge(colour, seen, min);
376
+ if (!moved) {
377
+ report.notes.push(`${side}.${top} cannot reach ${min}:1 on ${under}; left at ${ratio.toFixed(1)}:1`);
378
+ continue;
379
+ }
380
+ palette[top] = toHex(moved);
381
+ // Derived tokens are adjusted quietly unless the theme chose them; the
382
+ // report is for the colours the model picked.
383
+ if (top in given) {
384
+ const first = moves.get(top);
385
+ moves.set(top, { from: first?.from ?? toHex(colour), why: `${ratio.toFixed(1)}:1 on ${under}, needs ${min}:1` });
386
+ }
387
+ }
388
+ for (const [token, move] of moves) {
389
+ report.fixed.push(`${side}.${token} ${move.from} → ${palette[token]} (${move.why})`);
390
+ }
391
+ const bg = toOklch(parseHex(palette["bg"])), panel = toOklch(parseHex(palette["panel"]));
392
+ if (Math.abs(bg.l - panel.l) < 0.012)
393
+ report.notes.push(`${side}.panel is barely distinguishable from bg`);
394
+ }
395
+ /** Validate, derive and check: everything between the model's JSON and the CSS. */
396
+ export function resolveTheme(raw) {
397
+ const read = readThemeInput(raw);
398
+ if (!read.ok)
399
+ return read;
400
+ const report = { dropped: read.dropped, fixed: [], notes: [] };
401
+ const { input } = read;
402
+ const theme = { input, report };
403
+ for (const side of ["light", "dark"]) {
404
+ const given = input[side];
405
+ if (!given)
406
+ continue;
407
+ if (!given["bg"] || !given["fg"])
408
+ report.notes.push(`${side}: no ${!given["bg"] ? "bg" : "fg"} given, the built-in one was used`);
409
+ const palette = derive(given, side, report);
410
+ enforce(palette, side, given, report);
411
+ theme[side] = palette;
412
+ }
413
+ if (input.mode === "both" && (theme.light ? !theme.dark : theme.dark)) {
414
+ report.notes.push(`only the ${theme.light ? "light" : "dark"} side was set; the other keeps the built-in palette`);
415
+ }
416
+ return { ok: true, theme };
417
+ }
418
+ // --- css -------------------------------------------------------------------
419
+ const vars = (palette) => Object.keys(TOKENS)
420
+ .filter((token) => palette[token] !== undefined)
421
+ .map((token) => `--${token}:${palette[token]};`)
422
+ .join(" ");
423
+ /**
424
+ * The stylesheet, printed after the built-in one with the same selectors so it
425
+ * wins on order alone. A one-sided theme pins `color-scheme` and prints its
426
+ * palette under every selector, so the system setting and the light/dark
427
+ * switch cannot bring the other side back.
428
+ */
429
+ export function themeCss(theme) {
430
+ const light = theme.light, dark = theme.dark, images = theme.input.images;
431
+ if (theme.input.mode !== "both") {
432
+ const side = theme.input.mode;
433
+ const palette = (side === "light" ? light : dark);
434
+ const body = `color-scheme:${side}; ${vars(palette)} ${imageVars(images, side)}`;
435
+ return (`:root{${body}}\n` +
436
+ `@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){${body}}}\n` +
437
+ `:root[data-theme="dark"],:root[data-theme="light"]{${body}}\n`);
438
+ }
439
+ const lightBody = `${light ? vars(light) : ""} ${imageVars(images, "light")}`.trim();
440
+ const darkBody = `${dark ? vars(dark) : ""} ${imageVars(images, "dark")}`.trim();
441
+ let css = "";
442
+ if (lightBody)
443
+ css += `:root{${lightBody}}\n:root[data-theme="light"]{${lightBody}}\n`;
444
+ if (darkBody) {
445
+ css += `@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){${darkBody}}}\n`;
446
+ css += `:root[data-theme="dark"]{${darkBody}}\n`;
447
+ }
448
+ return css;
449
+ }
450
+ /** Where a stored image is served; see `GET /theme-asset/` in server.ts. */
451
+ export const assetUrl = (asset) => `/theme-asset/${asset}`;
452
+ const POSITIONS = {
453
+ "top-left": "left top", top: "center top", "top-right": "right top",
454
+ left: "left center", center: "center center", right: "right center",
455
+ "bottom-left": "left bottom", bottom: "center bottom", "bottom-right": "right bottom",
456
+ };
457
+ /**
458
+ * The image slots as custom properties, read by the rules in style.ts that
459
+ * own them. Every value is printed from an enumerated option, a clamped
460
+ * number or an asset id that matched ASSET_ID — nothing here is passed through
461
+ * from the model.
462
+ */
463
+ function imageVars(images, side) {
464
+ if (!images)
465
+ return "";
466
+ const out = [];
467
+ const wall = images.conversation;
468
+ if (wall) {
469
+ const asset = side === "dark" && wall.dark?.asset ? wall.dark.asset : wall.asset;
470
+ const scrim = side === "dark" && wall.dark?.scrim !== undefined ? wall.dark.scrim : wall.scrim;
471
+ out.push(`--wall-image:url("${assetUrl(asset)}");`,
472
+ // The colour the blend mixes the image with: the page's own.
473
+ `--wall-base:var(--bg);`, `--wall-size:${wall.fit === "tile" ? "auto" : wall.fit};`, `--wall-repeat:${wall.fit === "tile" ? "repeat" : "no-repeat"};`, `--wall-position:${POSITIONS[wall.anchor]};`, `--wall-scrim:${Math.round(scrim * 100)}%;`, `--wall-blend:normal, ${wall.blend};`);
474
+ }
475
+ const empty = images.empty;
476
+ if (empty) {
477
+ out.push(`--empty-art:url("${assetUrl(empty.asset)}");`, `--empty-art-display:block;`, `--empty-art-height:${{ small: 120, medium: 180, large: 240 }[empty.size]}px;`, `--empty-art-position:${empty.align === "center" ? "center" : "left"} center;`);
478
+ }
479
+ const avatar = images.avatar;
480
+ if (avatar) {
481
+ out.push(`--avatar:url("${assetUrl(avatar.asset)}");`, `--avatar-display:inline-block;`, `--avatar-radius:${{ circle: "999px", rounded: "6px", square: "2px" }[avatar.shape]};`);
482
+ }
483
+ return out.join(" ");
484
+ }
485
+ /** Every asset a theme refers to, for keeping and for cleaning up. */
486
+ export function themeAssets(input) {
487
+ const images = input.images;
488
+ if (!images)
489
+ return [];
490
+ const ids = [images.conversation?.asset, images.conversation?.dark?.asset, images.empty?.asset, images.avatar?.asset];
491
+ return [...new Set(ids.filter((id) => !!id))];
492
+ }
493
+ /** A few colours per side for a card to show before anything is applied. */
494
+ export function swatches(theme) {
495
+ const pick = (p) => (p ? ["bg", "panel", "fg", "dim", "accent", "brand"].map((t) => p[t]) : undefined);
496
+ const light = pick(theme.light), dark = pick(theme.dark);
497
+ return { ...(light ? { light } : {}), ...(dark ? { dark } : {}) };
498
+ }
499
+ /** What the model reads back after `set_theme`. */
500
+ export function describeReport(report) {
501
+ const lines = [];
502
+ if (report.fixed.length)
503
+ lines.push("Adjusted for contrast:", ...report.fixed.map((l) => `- ${l}`));
504
+ if (report.dropped.length)
505
+ lines.push("Dropped:", ...report.dropped.map((l) => `- ${l}`));
506
+ if (report.notes.length)
507
+ lines.push("Notes:", ...report.notes.map((l) => `- ${l}`));
508
+ return lines.length ? lines.join("\n") : "Every value passed as given.";
509
+ }
510
+ //# sourceMappingURL=user-theme.js.map