@nastechai/agent 0.16.0 → 0.17.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 (98) hide show
  1. package/eslint.config.js +23 -0
  2. package/index.html +24 -0
  3. package/package.json +54 -26
  4. package/package.json.bak +89 -0
  5. package/package.json.pub +88 -0
  6. package/src/App.tsx +1173 -0
  7. package/src/components/AuthWidget.tsx +150 -0
  8. package/src/components/AutoField.tsx +206 -0
  9. package/src/components/Backdrop.tsx +93 -0
  10. package/src/components/ChatSidebar.tsx +394 -0
  11. package/src/components/DeleteConfirmDialog.tsx +40 -0
  12. package/src/components/LanguageSwitcher.tsx +186 -0
  13. package/src/components/Markdown.tsx +383 -0
  14. package/src/components/ModelInfoCard.tsx +112 -0
  15. package/src/components/ModelPickerDialog.tsx +470 -0
  16. package/src/components/OAuthLoginModal.tsx +374 -0
  17. package/src/components/OAuthProvidersCard.tsx +287 -0
  18. package/src/components/PlatformsCard.tsx +97 -0
  19. package/src/components/ScheduleBuilder.tsx +273 -0
  20. package/src/components/SidebarFooter.tsx +42 -0
  21. package/src/components/SidebarStatusStrip.tsx +72 -0
  22. package/src/components/SlashPopover.tsx +171 -0
  23. package/src/components/ThemeSwitcher.tsx +243 -0
  24. package/src/components/ToolCall.tsx +228 -0
  25. package/src/components/ToolsetConfigDrawer.tsx +448 -0
  26. package/src/contexts/PageHeaderProvider.tsx +139 -0
  27. package/src/contexts/SystemActions.tsx +120 -0
  28. package/src/contexts/page-header-context.ts +12 -0
  29. package/src/contexts/system-actions-context.ts +18 -0
  30. package/src/contexts/usePageHeader.ts +10 -0
  31. package/src/contexts/useSystemActions.ts +15 -0
  32. package/src/hooks/useModalBehavior.ts +44 -0
  33. package/src/hooks/useSidebarStatus.ts +27 -0
  34. package/src/i18n/af.ts +702 -0
  35. package/src/i18n/context.tsx +123 -0
  36. package/src/i18n/de.ts +701 -0
  37. package/src/i18n/en.ts +708 -0
  38. package/src/i18n/es.ts +701 -0
  39. package/src/i18n/fr.ts +701 -0
  40. package/src/i18n/ga.ts +702 -0
  41. package/src/i18n/hu.ts +702 -0
  42. package/src/i18n/index.ts +2 -0
  43. package/src/i18n/it.ts +701 -0
  44. package/src/i18n/ja.ts +702 -0
  45. package/src/i18n/ko.ts +702 -0
  46. package/src/i18n/pt.ts +702 -0
  47. package/src/i18n/ru.ts +702 -0
  48. package/src/i18n/tr.ts +702 -0
  49. package/src/i18n/types.ts +710 -0
  50. package/src/i18n/uk.ts +702 -0
  51. package/src/i18n/zh-hant.ts +702 -0
  52. package/src/i18n/zh.ts +698 -0
  53. package/src/index.css +274 -0
  54. package/src/lib/api.ts +1585 -0
  55. package/src/lib/dashboard-flags.ts +15 -0
  56. package/src/lib/format.ts +9 -0
  57. package/src/lib/fuzzy.ts +192 -0
  58. package/src/lib/gatewayClient.ts +253 -0
  59. package/src/lib/nested.ts +23 -0
  60. package/src/lib/resolve-page-title.ts +41 -0
  61. package/src/lib/schedule.ts +382 -0
  62. package/src/lib/slashExec.ts +163 -0
  63. package/src/lib/utils.ts +35 -0
  64. package/src/main.tsx +25 -0
  65. package/src/pages/AnalyticsPage.tsx +601 -0
  66. package/src/pages/ChannelsPage.tsx +772 -0
  67. package/src/pages/ChatPage.tsx +889 -0
  68. package/src/pages/ConfigPage.tsx +660 -0
  69. package/src/pages/CronPage.tsx +524 -0
  70. package/src/pages/DocsPage.tsx +69 -0
  71. package/src/pages/EnvPage.tsx +918 -0
  72. package/src/pages/LogsPage.tsx +246 -0
  73. package/src/pages/McpPage.tsx +757 -0
  74. package/src/pages/ModelsPage.tsx +994 -0
  75. package/src/pages/PairingPage.tsx +276 -0
  76. package/src/pages/PluginsPage.tsx +580 -0
  77. package/src/pages/ProfilesPage.tsx +559 -0
  78. package/src/pages/SessionsPage.tsx +936 -0
  79. package/src/pages/SkillsPage.tsx +557 -0
  80. package/src/pages/SystemPage.tsx +1259 -0
  81. package/src/pages/WebhooksPage.tsx +483 -0
  82. package/src/plugins/PluginPage.tsx +64 -0
  83. package/src/plugins/index.ts +6 -0
  84. package/src/plugins/registry.ts +151 -0
  85. package/src/plugins/sdk.d.ts +160 -0
  86. package/src/plugins/slots.ts +199 -0
  87. package/src/plugins/types.ts +37 -0
  88. package/src/plugins/usePlugins.ts +133 -0
  89. package/src/themes/context.tsx +443 -0
  90. package/src/themes/fonts.ts +160 -0
  91. package/src/themes/index.ts +3 -0
  92. package/src/themes/presets.ts +477 -0
  93. package/src/themes/types.ts +187 -0
  94. package/tsconfig.app.json +34 -0
  95. package/tsconfig.json +7 -0
  96. package/tsconfig.node.json +26 -0
  97. package/vite.config.ts +124 -0
  98. package/vite.config.ts.timestamp-1780999102396-af6b77b30ebd8.mjs +105 -0
@@ -0,0 +1,443 @@
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useEffect,
6
+ useMemo,
7
+ useState,
8
+ type ReactNode,
9
+ } from "react";
10
+ import { BUILTIN_THEMES, defaultTheme } from "./presets";
11
+ import type {
12
+ DashboardTheme,
13
+ ThemeAssets,
14
+ ThemeColorOverrides,
15
+ ThemeComponentStyles,
16
+ ThemeDensity,
17
+ ThemeLayer,
18
+ ThemeLayout,
19
+ ThemeLayoutVariant,
20
+ ThemeListEntry,
21
+ ThemePalette,
22
+ ThemeTypography,
23
+ } from "./types";
24
+ import { api } from "@/lib/api";
25
+
26
+ /** LocalStorage key — pre-applied before the React tree mounts to avoid
27
+ * a visible flash of the default palette on theme-overridden installs. */
28
+ const STORAGE_KEY = "nastech-dashboard-theme";
29
+
30
+ /** Tracks fontUrls we've already injected so multiple theme switches don't
31
+ * pile up <link> tags. Keyed by URL. */
32
+ const INJECTED_FONT_URLS = new Set<string>();
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // CSS variable builders
36
+ // ---------------------------------------------------------------------------
37
+
38
+ /** Turn a ThemeLayer into the two CSS expressions the DS consumes:
39
+ * `--<name>` (color-mix'd with alpha) and `--<name>-base` (opaque hex). */
40
+ function layerVars(
41
+ name: "background" | "midground" | "foreground",
42
+ layer: ThemeLayer,
43
+ ): Record<string, string> {
44
+ const pct = Math.round(layer.alpha * 100);
45
+ return {
46
+ [`--${name}`]: `color-mix(in srgb, ${layer.hex} ${pct}%, transparent)`,
47
+ [`--${name}-base`]: layer.hex,
48
+ [`--${name}-alpha`]: String(layer.alpha),
49
+ };
50
+ }
51
+
52
+ function paletteVars(palette: ThemePalette): Record<string, string> {
53
+ return {
54
+ ...layerVars("background", palette.background),
55
+ ...layerVars("midground", palette.midground),
56
+ ...layerVars("foreground", palette.foreground),
57
+ "--warm-glow": palette.warmGlow,
58
+ "--noise-opacity-mul": String(palette.noiseOpacity),
59
+ };
60
+ }
61
+
62
+ const DENSITY_MULTIPLIERS: Record<ThemeDensity, string> = {
63
+ compact: "0.85",
64
+ comfortable: "1",
65
+ spacious: "1.2",
66
+ };
67
+
68
+ function typographyVars(typo: ThemeTypography): Record<string, string> {
69
+ return {
70
+ "--theme-font-sans": typo.fontSans,
71
+ "--theme-font-mono": typo.fontMono,
72
+ "--theme-font-display": typo.fontDisplay ?? typo.fontSans,
73
+ "--theme-base-size": typo.baseSize,
74
+ "--theme-line-height": typo.lineHeight,
75
+ "--theme-letter-spacing": typo.letterSpacing,
76
+ };
77
+ }
78
+
79
+ function layoutVars(layout: ThemeLayout): Record<string, string> {
80
+ return {
81
+ "--radius": layout.radius,
82
+ "--theme-radius": layout.radius,
83
+ "--theme-spacing-mul": DENSITY_MULTIPLIERS[layout.density] ?? "1",
84
+ "--theme-density": layout.density,
85
+ };
86
+ }
87
+
88
+ /** Map a color-overrides key (camelCase) to its `--color-*` CSS var. */
89
+ const OVERRIDE_KEY_TO_VAR: Record<keyof ThemeColorOverrides, string> = {
90
+ card: "--color-card",
91
+ cardForeground: "--color-card-foreground",
92
+ popover: "--color-popover",
93
+ popoverForeground: "--color-popover-foreground",
94
+ primary: "--color-primary",
95
+ primaryForeground: "--color-primary-foreground",
96
+ secondary: "--color-secondary",
97
+ secondaryForeground: "--color-secondary-foreground",
98
+ muted: "--color-muted",
99
+ mutedForeground: "--color-muted-foreground",
100
+ accent: "--color-accent",
101
+ accentForeground: "--color-accent-foreground",
102
+ destructive: "--color-destructive",
103
+ destructiveForeground: "--color-destructive-foreground",
104
+ success: "--color-success",
105
+ warning: "--color-warning",
106
+ border: "--color-border",
107
+ input: "--color-input",
108
+ ring: "--color-ring",
109
+ };
110
+
111
+ /** Keys we might have written on a previous theme — needed to know which
112
+ * properties to clear when a theme with fewer overrides replaces one
113
+ * with more. */
114
+ const ALL_OVERRIDE_VARS = Object.values(OVERRIDE_KEY_TO_VAR);
115
+
116
+ function overrideVars(
117
+ overrides: ThemeColorOverrides | undefined,
118
+ ): Record<string, string> {
119
+ if (!overrides) return {};
120
+ const out: Record<string, string> = {};
121
+ for (const [key, value] of Object.entries(overrides)) {
122
+ if (!value) continue;
123
+ const cssVar = OVERRIDE_KEY_TO_VAR[key as keyof ThemeColorOverrides];
124
+ if (cssVar) out[cssVar] = value;
125
+ }
126
+ return out;
127
+ }
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // Asset + component-style + layout variant vars
131
+ // ---------------------------------------------------------------------------
132
+
133
+ /** Well-known named asset slots a theme may populate. Kept in sync with
134
+ * `_THEME_NAMED_ASSET_KEYS` in `nastech_cli/web_server.py`. */
135
+ const NAMED_ASSET_KEYS = ["bg", "hero", "logo", "crest", "sidebar", "header"] as const;
136
+
137
+ /** Component buckets mirrored from the backend's `_THEME_COMPONENT_BUCKETS`.
138
+ * Each bucket emits `--component-<bucket>-<kebab-prop>` CSS vars. */
139
+ const COMPONENT_BUCKETS = [
140
+ "card", "header", "footer", "sidebar", "tab",
141
+ "progress", "badge", "backdrop", "page",
142
+ ] as const;
143
+
144
+ /** Camel → kebab (`clipPath` → `clip-path`). */
145
+ function toKebab(s: string): string {
146
+ return s.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
147
+ }
148
+
149
+ /** Build `--theme-asset-*` CSS vars from the assets block. Values are wrapped
150
+ * in `url(...)` when they look like a bare path/URL; raw CSS expressions
151
+ * (`linear-gradient(...)`, pre-wrapped `url(...)`, `none`) pass through. */
152
+ function assetVars(assets: ThemeAssets | undefined): Record<string, string> {
153
+ if (!assets) return {};
154
+ const out: Record<string, string> = {};
155
+ const wrap = (v: string): string => {
156
+ const trimmed = v.trim();
157
+ if (!trimmed) return "";
158
+ // Already a CSS image/gradient/url/none — don't re-wrap.
159
+ if (/^(url\(|linear-gradient|radial-gradient|conic-gradient|none$)/i.test(trimmed)) {
160
+ return trimmed;
161
+ }
162
+ // Bare path / http(s) URL / data: URL → wrap in url().
163
+ return `url("${trimmed.replace(/"/g, '\\"')}")`;
164
+ };
165
+ for (const key of NAMED_ASSET_KEYS) {
166
+ const val = assets[key];
167
+ if (typeof val === "string" && val.trim()) {
168
+ out[`--theme-asset-${key}`] = wrap(val);
169
+ out[`--theme-asset-${key}-raw`] = val;
170
+ }
171
+ }
172
+ if (assets.custom) {
173
+ for (const [key, val] of Object.entries(assets.custom)) {
174
+ if (typeof val !== "string" || !val.trim()) continue;
175
+ if (!/^[a-zA-Z0-9_-]+$/.test(key)) continue;
176
+ out[`--theme-asset-custom-${key}`] = wrap(val);
177
+ out[`--theme-asset-custom-${key}-raw`] = val;
178
+ }
179
+ }
180
+ return out;
181
+ }
182
+
183
+ /** Build `--component-<bucket>-<prop>` CSS vars from the componentStyles
184
+ * block. Values pass through untouched so themes can use any CSS expression. */
185
+ function componentStyleVars(
186
+ styles: ThemeComponentStyles | undefined,
187
+ ): Record<string, string> {
188
+ if (!styles) return {};
189
+ const out: Record<string, string> = {};
190
+ for (const bucket of COMPONENT_BUCKETS) {
191
+ const props = (styles as Record<string, Record<string, string> | undefined>)[bucket];
192
+ if (!props) continue;
193
+ for (const [prop, value] of Object.entries(props)) {
194
+ if (typeof value !== "string" || !value.trim()) continue;
195
+ // Same guardrail as backend — camelCase or kebab-case alnum only.
196
+ if (!/^[a-zA-Z0-9_-]+$/.test(prop)) continue;
197
+ out[`--component-${bucket}-${toKebab(prop)}`] = value;
198
+ }
199
+ }
200
+ return out;
201
+ }
202
+
203
+ // Tracks keys we set on the previous theme so we can clear them when the
204
+ // next theme has fewer assets / component vars. Without this, switching
205
+ // from a richly-decorated theme to a plain one would leave stale vars.
206
+ let _PREV_DYNAMIC_VAR_KEYS: Set<string> = new Set();
207
+
208
+ /** ID for the injected <style> tag that carries a theme's customCSS.
209
+ * A single tag is reused + replaced on every theme switch. */
210
+ const CUSTOM_CSS_STYLE_ID = "nastech-theme-custom-css";
211
+
212
+ function applyCustomCSS(css: string | undefined) {
213
+ if (typeof document === "undefined") return;
214
+ let el = document.getElementById(CUSTOM_CSS_STYLE_ID) as HTMLStyleElement | null;
215
+ if (!css || !css.trim()) {
216
+ if (el) el.remove();
217
+ return;
218
+ }
219
+ if (!el) {
220
+ el = document.createElement("style");
221
+ el.id = CUSTOM_CSS_STYLE_ID;
222
+ el.setAttribute("data-nastech-theme-css", "true");
223
+ document.head.appendChild(el);
224
+ }
225
+ el.textContent = css;
226
+ }
227
+
228
+ function applyLayoutVariant(variant: ThemeLayoutVariant | undefined) {
229
+ if (typeof document === "undefined") return;
230
+ const root = document.documentElement;
231
+ const final: ThemeLayoutVariant = variant ?? "standard";
232
+ root.dataset.layoutVariant = final;
233
+ root.style.setProperty("--theme-layout-variant", final);
234
+ }
235
+
236
+ // ---------------------------------------------------------------------------
237
+ // Font stylesheet injection
238
+ // ---------------------------------------------------------------------------
239
+
240
+ function injectFontStylesheet(url: string | undefined) {
241
+ if (!url || typeof document === "undefined") return;
242
+ if (INJECTED_FONT_URLS.has(url)) return;
243
+ // Also skip if the page already has this href (e.g. SSR'd or persisted).
244
+ const existing = document.querySelector<HTMLLinkElement>(
245
+ `link[rel="stylesheet"][href="${CSS.escape(url)}"]`,
246
+ );
247
+ if (existing) {
248
+ INJECTED_FONT_URLS.add(url);
249
+ return;
250
+ }
251
+ const link = document.createElement("link");
252
+ link.rel = "stylesheet";
253
+ link.href = url;
254
+ link.setAttribute("data-nastech-theme-font", "true");
255
+ document.head.appendChild(link);
256
+ INJECTED_FONT_URLS.add(url);
257
+ }
258
+
259
+ // ---------------------------------------------------------------------------
260
+ // Apply a full theme to :root
261
+ // ---------------------------------------------------------------------------
262
+
263
+ function applyTheme(theme: DashboardTheme) {
264
+ if (typeof document === "undefined") return;
265
+ const root = document.documentElement;
266
+
267
+ // Clear any overrides from a previous theme before applying the new set.
268
+ for (const cssVar of ALL_OVERRIDE_VARS) {
269
+ root.style.removeProperty(cssVar);
270
+ }
271
+ // Clear dynamic (asset/component) vars from the previous theme so the
272
+ // new one starts clean — otherwise stale notched clip-paths, hero URLs,
273
+ // etc. would bleed across theme switches.
274
+ for (const prevKey of _PREV_DYNAMIC_VAR_KEYS) {
275
+ root.style.removeProperty(prevKey);
276
+ }
277
+
278
+ const assetMap = assetVars(theme.assets);
279
+ const componentMap = componentStyleVars(theme.componentStyles);
280
+ _PREV_DYNAMIC_VAR_KEYS = new Set([
281
+ ...Object.keys(assetMap),
282
+ ...Object.keys(componentMap),
283
+ ]);
284
+
285
+ const vars = {
286
+ ...paletteVars(theme.palette),
287
+ ...typographyVars(theme.typography),
288
+ ...layoutVars(theme.layout),
289
+ ...overrideVars(theme.colorOverrides),
290
+ ...assetMap,
291
+ ...componentMap,
292
+ };
293
+ for (const [k, v] of Object.entries(vars)) {
294
+ root.style.setProperty(k, v);
295
+ }
296
+
297
+ injectFontStylesheet(theme.typography.fontUrl);
298
+ applyCustomCSS(theme.customCSS);
299
+ applyLayoutVariant(theme.layoutVariant);
300
+ }
301
+
302
+ // ---------------------------------------------------------------------------
303
+ // Provider
304
+ // ---------------------------------------------------------------------------
305
+
306
+ export function ThemeProvider({ children }: { children: ReactNode }) {
307
+ /** Name of the currently active theme (built-in id or user YAML name). */
308
+ const [themeName, setThemeName] = useState<string>(() => {
309
+ if (typeof window === "undefined") return "default";
310
+ return window.localStorage.getItem(STORAGE_KEY) ?? "amoled";
311
+ });
312
+
313
+ /** All selectable themes (shown in the picker). Starts with just the
314
+ * built-ins; the API call below merges in user themes. */
315
+ const [availableThemes, setAvailableThemes] = useState<ThemeListEntry[]>(() =>
316
+ Object.values(BUILTIN_THEMES).map((t) => ({
317
+ name: t.name,
318
+ label: t.label,
319
+ description: t.description,
320
+ })),
321
+ );
322
+
323
+ /** Full definitions for user themes keyed by name — the API provides
324
+ * these so custom YAMLs apply without a client-side stub. */
325
+ const [userThemeDefs, setUserThemeDefs] = useState<
326
+ Record<string, DashboardTheme>
327
+ >({});
328
+
329
+ // Resolve a theme name to a full DashboardTheme, falling back to default
330
+ // only when neither a built-in nor a user theme is found.
331
+ const resolveTheme = useCallback(
332
+ (name: string): DashboardTheme => {
333
+ return (
334
+ BUILTIN_THEMES[name] ??
335
+ userThemeDefs[name] ??
336
+ defaultTheme
337
+ );
338
+ },
339
+ [userThemeDefs],
340
+ );
341
+
342
+ // Re-apply on every themeName change, or when user themes arrive from
343
+ // the API (since the active theme might be a user theme whose definition
344
+ // hadn't loaded yet on first render).
345
+ useEffect(() => {
346
+ applyTheme(resolveTheme(themeName));
347
+ }, [themeName, resolveTheme]);
348
+
349
+ // Load server-side themes (built-ins + user YAMLs) once on mount.
350
+ useEffect(() => {
351
+ let cancelled = false;
352
+ api
353
+ .getThemes()
354
+ .then((resp) => {
355
+ if (cancelled) return;
356
+ if (resp.themes?.length) {
357
+ setAvailableThemes(
358
+ resp.themes.map((t) => ({
359
+ name: t.name,
360
+ label: t.label,
361
+ description: t.description,
362
+ definition: t.definition,
363
+ })),
364
+ );
365
+ // Index any definitions the server shipped (user themes).
366
+ const defs: Record<string, DashboardTheme> = {};
367
+ for (const entry of resp.themes) {
368
+ if (entry.definition) {
369
+ defs[entry.name] = entry.definition;
370
+ }
371
+ }
372
+ if (Object.keys(defs).length > 0) setUserThemeDefs(defs);
373
+ }
374
+ // localStorage wins — the user's explicit choice always beats the
375
+ // server's configured default. Only apply server active for brand-new
376
+ // sessions where the user has never set a preference.
377
+ const storedPref = typeof window !== "undefined"
378
+ ? window.localStorage.getItem(STORAGE_KEY)
379
+ : null;
380
+ if (resp.active && !storedPref && resp.active !== themeName) {
381
+ setThemeName(resp.active);
382
+ window.localStorage.setItem(STORAGE_KEY, resp.active);
383
+ }
384
+ })
385
+ .catch(() => {});
386
+ return () => {
387
+ cancelled = true;
388
+ };
389
+ // eslint-disable-next-line react-hooks/exhaustive-deps
390
+ }, []);
391
+
392
+ const setTheme = useCallback(
393
+ (name: string) => {
394
+ // Accept any name the server told us exists OR any built-in.
395
+ const knownNames = new Set<string>([
396
+ ...Object.keys(BUILTIN_THEMES),
397
+ ...availableThemes.map((t) => t.name),
398
+ ...Object.keys(userThemeDefs),
399
+ ]);
400
+ const next = knownNames.has(name) ? name : "default";
401
+ setThemeName(next);
402
+ if (typeof window !== "undefined") {
403
+ window.localStorage.setItem(STORAGE_KEY, next);
404
+ }
405
+ api.setTheme(next).catch(() => {});
406
+ },
407
+ [availableThemes, userThemeDefs],
408
+ );
409
+
410
+ const value = useMemo<ThemeContextValue>(
411
+ () => ({
412
+ theme: resolveTheme(themeName),
413
+ themeName,
414
+ availableThemes,
415
+ setTheme,
416
+ }),
417
+ [themeName, availableThemes, setTheme, resolveTheme],
418
+ );
419
+
420
+ return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
421
+ }
422
+
423
+ export function useTheme(): ThemeContextValue {
424
+ return useContext(ThemeContext);
425
+ }
426
+
427
+ const ThemeContext = createContext<ThemeContextValue>({
428
+ theme: defaultTheme,
429
+ themeName: "amoled",
430
+ availableThemes: Object.values(BUILTIN_THEMES).map((t) => ({
431
+ name: t.name,
432
+ label: t.label,
433
+ description: t.description,
434
+ })),
435
+ setTheme: () => {},
436
+ });
437
+
438
+ interface ThemeContextValue {
439
+ availableThemes: ThemeListEntry[];
440
+ setTheme: (name: string) => void;
441
+ theme: DashboardTheme;
442
+ themeName: string;
443
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Curated UI-font catalog for the dashboard font override.
3
+ *
4
+ * The font override is an independent layer that sits ON TOP of the active
5
+ * theme: a theme still ships its own `typography.fontSans` default, but a
6
+ * user can pick any font here and it persists across theme switches. Picking
7
+ * "Theme default" clears the override and returns to whatever the active
8
+ * theme specifies.
9
+ *
10
+ * Why a curated catalog instead of a free-text font name + URL box: the
11
+ * `fontUrl` is injected into the page as a `<link rel="stylesheet">`, so
12
+ * accepting an arbitrary user-supplied URL would be a self-XSS / SSRF-ish
13
+ * footgun in the dashboard. A vetted catalog keeps the injected origins
14
+ * fixed (system stacks + Google Fonts) while still giving real choice. The
15
+ * matching allow-list on the backend (`_FONT_CHOICES` in web_server.py)
16
+ * rejects any id not defined here.
17
+ *
18
+ * Keep `FONT_CHOICES` in sync with `_FONT_CHOICES` in
19
+ * `nastech_cli/web_server.py` — the ids must match exactly.
20
+ */
21
+
22
+ /** System stacks reused from presets so "System" choices need no webfont. */
23
+ const SYSTEM_SANS =
24
+ 'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif';
25
+ const SYSTEM_MONO =
26
+ 'ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, monospace';
27
+ const SYSTEM_SERIF =
28
+ 'Georgia, Cambria, "Times New Roman", Times, serif';
29
+
30
+ export type FontCategory = "sans" | "serif" | "mono";
31
+
32
+ export interface FontChoice {
33
+ /** Stable id persisted in config / localStorage. */
34
+ id: string;
35
+ /** Human-readable label shown in the picker. */
36
+ label: string;
37
+ /** Rough grouping for the picker. */
38
+ category: FontCategory;
39
+ /** CSS font-family stack applied to `--theme-font-sans` (+ display). */
40
+ stack: string;
41
+ /** Optional Google-Fonts (or other vetted) stylesheet URL. */
42
+ fontUrl?: string;
43
+ }
44
+
45
+ /** Sentinel id meaning "no override — use the active theme's font". */
46
+ export const THEME_DEFAULT_FONT_ID = "theme";
47
+
48
+ const GF = (family: string): string =>
49
+ `https://fonts.googleapis.com/css2?family=${family}&display=swap`;
50
+
51
+ /**
52
+ * The curated set. Order is the display order in the picker (grouped by
53
+ * category in the UI). `stack` always ends in a system fallback so a font
54
+ * that fails to load still renders something sane.
55
+ */
56
+ export const FONT_CHOICES: FontChoice[] = [
57
+ // ── System (no webfont fetch) ──────────────────────────────────────────
58
+ { id: "system-sans", label: "System Sans", category: "sans", stack: SYSTEM_SANS },
59
+ { id: "system-serif", label: "System Serif", category: "serif", stack: SYSTEM_SERIF },
60
+ { id: "system-mono", label: "System Mono", category: "mono", stack: SYSTEM_MONO },
61
+
62
+ // ── Sans ────────────────────────────────────────────────────────────────
63
+ {
64
+ id: "inter",
65
+ label: "Inter",
66
+ category: "sans",
67
+ stack: `"Inter", ${SYSTEM_SANS}`,
68
+ fontUrl: GF("Inter:wght@400;500;600;700"),
69
+ },
70
+ {
71
+ id: "ibm-plex-sans",
72
+ label: "IBM Plex Sans",
73
+ category: "sans",
74
+ stack: `"IBM Plex Sans", ${SYSTEM_SANS}`,
75
+ fontUrl: GF("IBM+Plex+Sans:wght@400;500;600;700"),
76
+ },
77
+ {
78
+ id: "work-sans",
79
+ label: "Work Sans",
80
+ category: "sans",
81
+ stack: `"Work Sans", ${SYSTEM_SANS}`,
82
+ fontUrl: GF("Work+Sans:wght@400;500;600;700"),
83
+ },
84
+ {
85
+ id: "atkinson-hyperlegible",
86
+ label: "Atkinson Hyperlegible",
87
+ category: "sans",
88
+ stack: `"Atkinson Hyperlegible", ${SYSTEM_SANS}`,
89
+ fontUrl: GF("Atkinson+Hyperlegible:wght@400;700"),
90
+ },
91
+ {
92
+ id: "dm-sans",
93
+ label: "DM Sans",
94
+ category: "sans",
95
+ stack: `"DM Sans", ${SYSTEM_SANS}`,
96
+ fontUrl: GF("DM+Sans:opsz,wght@9..40,400;9..40,500;9..40,600;9..40,700"),
97
+ },
98
+
99
+ // ── Serif ─────────────────────────────────────────────────────────────
100
+ {
101
+ id: "spectral",
102
+ label: "Spectral",
103
+ category: "serif",
104
+ stack: `"Spectral", ${SYSTEM_SERIF}`,
105
+ fontUrl: GF("Spectral:wght@400;500;600;700"),
106
+ },
107
+ {
108
+ id: "fraunces",
109
+ label: "Fraunces",
110
+ category: "serif",
111
+ stack: `"Fraunces", ${SYSTEM_SERIF}`,
112
+ fontUrl: GF("Fraunces:opsz,wght@9..144,400;9..144,500;9..144,600"),
113
+ },
114
+ {
115
+ id: "source-serif",
116
+ label: "Source Serif 4",
117
+ category: "serif",
118
+ stack: `"Source Serif 4", ${SYSTEM_SERIF}`,
119
+ fontUrl: GF("Source+Serif+4:opsz,wght@8..60,400;8..60,500;8..60,600;8..60,700"),
120
+ },
121
+
122
+ // ── Mono ──────────────────────────────────────────────────────────────
123
+ {
124
+ id: "jetbrains-mono",
125
+ label: "JetBrains Mono",
126
+ category: "mono",
127
+ stack: `"JetBrains Mono", ${SYSTEM_MONO}`,
128
+ fontUrl: GF("JetBrains+Mono:wght@400;500;700"),
129
+ },
130
+ {
131
+ id: "ibm-plex-mono",
132
+ label: "IBM Plex Mono",
133
+ category: "mono",
134
+ stack: `"IBM Plex Mono", ${SYSTEM_MONO}`,
135
+ fontUrl: GF("IBM+Plex+Mono:wght@400;500;700"),
136
+ },
137
+ {
138
+ id: "space-mono",
139
+ label: "Space Mono",
140
+ category: "mono",
141
+ stack: `"Space Mono", ${SYSTEM_MONO}`,
142
+ fontUrl: GF("Space+Mono:wght@400;700"),
143
+ },
144
+ ];
145
+
146
+ const FONT_BY_ID: Record<string, FontChoice> = Object.fromEntries(
147
+ FONT_CHOICES.map((f) => [f.id, f]),
148
+ );
149
+
150
+ /** Look up a font choice by id. Returns undefined for the theme-default
151
+ * sentinel and for any unknown id. */
152
+ export function getFontChoice(id: string | null | undefined): FontChoice | undefined {
153
+ if (!id || id === THEME_DEFAULT_FONT_ID) return undefined;
154
+ return FONT_BY_ID[id];
155
+ }
156
+
157
+ /** Whether an id refers to a real catalog font (vs. theme-default/unknown). */
158
+ export function isOverrideFont(id: string | null | undefined): boolean {
159
+ return getFontChoice(id) !== undefined;
160
+ }
@@ -0,0 +1,3 @@
1
+ export { ThemeProvider, useTheme } from "./context";
2
+ export { BUILTIN_THEMES, defaultTheme } from "./presets";
3
+ export type { DashboardTheme, ThemeLayer, ThemeListEntry, ThemeListResponse, ThemePalette } from "./types";