@fasl-work/caos-app-shell 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,59 @@
1
+ # @fasl-work/caos-app-shell
2
+
3
+ The shared **web-app shell + content primitives + design system** for the CAOS / Faena public apps
4
+ (implements [ADR-0016](https://github.com/fsantibanezleal)). Define the header, footer, theme, language
5
+ toggle and content primitives **once** here; every app consumes them so the chrome is identical and a
6
+ fix lands in one place.
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ npm i @fasl-work/caos-app-shell
12
+ # peer deps (the app provides them):
13
+ npm i react react-dom react-router-dom lucide-react katex zustand
14
+ ```
15
+
16
+ ## Use
17
+
18
+ ```tsx
19
+ // main.tsx
20
+ import { applyTheme, readTheme } from "@fasl-work/caos-app-shell";
21
+ import "@fasl-work/caos-app-shell/styles.css";
22
+ applyTheme(readTheme()); // (an inline script in index.html should also set data-theme pre-paint)
23
+
24
+ // router / app
25
+ import { BrowserRouter, Routes, Route } from "react-router-dom";
26
+ import { AppShell, type ShellConfig } from "@fasl-work/caos-app-shell";
27
+
28
+ const config: ShellConfig = {
29
+ product: { name: "RotorVitals" },
30
+ routes: [
31
+ { path: "/", en: "App", es: "App" },
32
+ { path: "/introduction", en: "Introduction", es: "Introducción" },
33
+ { path: "/methodology", en: "Methodology", es: "Metodología" },
34
+ { path: "/implementation", en: "Implementation", es: "Implementación" },
35
+ { path: "/experiments", en: "Experiments", es: "Experimentos" },
36
+ ],
37
+ links: { github: "https://github.com/fsantibanezleal/CAOS_RotorVitals" }, // personal/portfolio default in
38
+ version: "0.01.000",
39
+ };
40
+
41
+ <BrowserRouter>
42
+ <AppShell config={config}>
43
+ <Routes>{/* / = the tool (landing); the rest are deep pages */}</Routes>
44
+ </AppShell>
45
+ </BrowserRouter>
46
+ ```
47
+
48
+ - **Land on the tool:** make `/` your interactive tool; Introduction/Methodology/etc. are separate routes.
49
+ - **Hub case (Faena):** pass `routes: []` (or one) → the nav is hidden, header/footer identical.
50
+ - **Deep pages:** compose with `Tabs`, `SubTabs`, `Equation`/`InlineMath`, `Callout`, `Figure`, and
51
+ `CitationsProvider` + `Cite`/`Refs`/`ReferenceList`. Read the current language with `useShellLang()`.
52
+
53
+ ## Exports
54
+
55
+ `AppShell`, `ThemeToggle`, `LanguageToggle`, `useThemeStore`, `applyTheme`, `readTheme`, `useLangStore`,
56
+ `useShellLang`, `Tabs`, `SubTabs`, `Callout`, `Equation`, `InlineMath`, `Figure`, `CitationsProvider`,
57
+ `Cite`, `Refs`, `ReferenceList`, plus the `@fasl-work/caos-app-shell/styles.css` design system.
58
+
59
+ MIT · part of the Faena mining-analytics hub.
@@ -0,0 +1,139 @@
1
+ import * as react from 'react';
2
+ import { ReactNode } from 'react';
3
+ import * as zustand from 'zustand';
4
+
5
+ interface ShellRoute {
6
+ path: string;
7
+ en: string;
8
+ es: string;
9
+ }
10
+ interface ShellConfig {
11
+ /** Brand: product name + optional lucide icon element (defaults to a generic mark). */
12
+ product: {
13
+ name: string;
14
+ mark?: ReactNode;
15
+ };
16
+ /** Top-level routes for the header nav. 0–1 entries → nav hidden (e.g. the Faena hub). */
17
+ routes?: ShellRoute[];
18
+ /** External links. personal/portfolio default to Felipe's canonical URLs (kept identical app-to-app). */
19
+ links: {
20
+ github: string;
21
+ personal?: string;
22
+ portfolio?: string;
23
+ };
24
+ /** Human version string (X.XX.XXX) shown in the footer. */
25
+ version: string;
26
+ }
27
+ /** The shared CAOS/Faena app shell: sticky header (brand + nav + icon-links + lang/theme) + footer.
28
+ * Wrap your <Routes> (or single landing) in it, inside a Router. */
29
+ declare function AppShell({ config, children }: {
30
+ config: ShellConfig;
31
+ children: ReactNode;
32
+ }): react.JSX.Element;
33
+
34
+ /** Light/dark icon toggle. Shows the icon of the theme you would switch TO. */
35
+ declare function ThemeToggle(): react.JSX.Element;
36
+
37
+ /** Language toggle. Shows the current 2-letter code; pressing it swaps and persists. */
38
+ declare function LanguageToggle(): react.JSX.Element;
39
+
40
+ type Theme = 'light' | 'dark';
41
+ declare function readTheme(): Theme;
42
+ declare function applyTheme(t: Theme): void;
43
+ interface ThemeState {
44
+ theme: Theme;
45
+ toggleTheme: () => void;
46
+ setTheme: (t: Theme) => void;
47
+ }
48
+ declare const useThemeStore: zustand.UseBoundStore<zustand.StoreApi<ThemeState>>;
49
+
50
+ type Lang = 'en' | 'es';
51
+ interface LangState {
52
+ lang: Lang;
53
+ setLang: (l: Lang) => void;
54
+ toggle: () => void;
55
+ }
56
+ declare const useLangStore: zustand.UseBoundStore<zustand.StoreApi<LangState>>;
57
+ /** Current language, for app pages that render language-branched long-form content. */
58
+ declare const useShellLang: () => Lang;
59
+
60
+ interface TabDef {
61
+ id: string;
62
+ label: ReactNode;
63
+ content: ReactNode;
64
+ }
65
+ /** Accessible roving-tabindex tab strip (arrow-key nav, aria wiring). Accent-soft pill for active. */
66
+ declare function Tabs({ tabs, initial, ariaLabel }: {
67
+ tabs: TabDef[];
68
+ initial?: string;
69
+ ariaLabel?: string;
70
+ }): react.JSX.Element;
71
+
72
+ interface SubTabDef {
73
+ id: string;
74
+ label: ReactNode;
75
+ content: ReactNode;
76
+ }
77
+ /** Second-level tabs nested inside a Tabs panel. Lighter chips; optional vertical left rail. */
78
+ declare function SubTabs({ tabs, initial, ariaLabel, orientation, }: {
79
+ tabs: SubTabDef[];
80
+ initial?: string;
81
+ ariaLabel?: string;
82
+ orientation?: 'horizontal' | 'vertical';
83
+ }): react.JSX.Element;
84
+
85
+ /** A left-bordered note box. `honest` = amber (caveats/limits), `strong` = accent, `note` = neutral. */
86
+ declare function Callout({ variant, title, children, }: {
87
+ variant?: 'note' | 'honest' | 'strong';
88
+ title?: ReactNode;
89
+ children: ReactNode;
90
+ }): react.JSX.Element;
91
+
92
+ /** A display equation rendered with KaTeX, with an optional caption. */
93
+ declare function Equation({ tex, caption }: {
94
+ tex: string;
95
+ caption?: ReactNode;
96
+ }): react.JSX.Element;
97
+ /** Inline math for use within a sentence. */
98
+ declare function InlineMath({ tex }: {
99
+ tex: string;
100
+ }): react.JSX.Element;
101
+
102
+ /** Wraps an inline SVG schematic (or any figure) with a centered caption. Theme-aware figures use
103
+ * the `dg-*` / `fig-svg` classes from styles.css and CSS palette variables (follow light/dark). */
104
+ declare function Figure({ caption, children }: {
105
+ caption?: ReactNode;
106
+ children: ReactNode;
107
+ }): react.JSX.Element;
108
+
109
+ interface Citation {
110
+ id: string;
111
+ /** Short inline label, e.g. "Little 1961". */
112
+ label: string;
113
+ /** Full bibliographic string for the reference list. */
114
+ citation: string;
115
+ doi?: string;
116
+ url?: string;
117
+ }
118
+ /** Provide the app's citation list once (e.g. at the page root) so <Cite>/<ReferenceList> resolve ids. */
119
+ declare function CitationsProvider({ items, children }: {
120
+ items: Citation[];
121
+ children: ReactNode;
122
+ }): react.JSX.Element;
123
+ /** Inline reference linked to its DOI/URL, e.g. "(Little 1961)". */
124
+ declare function Cite({ id, paren }: {
125
+ id: string;
126
+ paren?: boolean;
127
+ }): react.JSX.Element;
128
+ /** A short inline "Refs: a · b · c" row (e.g. under a deep sub-tab). */
129
+ declare function Refs({ ids, label }: {
130
+ ids: string[];
131
+ label: string;
132
+ }): react.JSX.Element;
133
+ /** Full ordered bibliography. Omit `ids` to render every provided citation. */
134
+ declare function ReferenceList({ ids, heading }: {
135
+ ids?: string[];
136
+ heading?: string;
137
+ }): react.JSX.Element;
138
+
139
+ export { AppShell, Callout, type Citation, CitationsProvider, Cite, Equation, Figure, InlineMath, type Lang, LanguageToggle, ReferenceList, Refs, type ShellConfig, type ShellRoute, type SubTabDef, SubTabs, type TabDef, Tabs, type Theme, ThemeToggle, applyTheme, readTheme, useLangStore, useShellLang, useThemeStore };
package/dist/index.js ADDED
@@ -0,0 +1,371 @@
1
+ import { NavLink } from 'react-router-dom';
2
+ import { Sun, Moon, Languages, Boxes, Github, Globe, Briefcase } from 'lucide-react';
3
+ import { create } from 'zustand';
4
+ import { jsxs, jsx } from 'react/jsx-runtime';
5
+ import { createContext, useId, useState, useContext } from 'react';
6
+ import katex from 'katex';
7
+
8
+ // src/shell/AppShell.tsx
9
+ var KEY = "caos.lang";
10
+ function readLang() {
11
+ try {
12
+ const s = localStorage.getItem(KEY);
13
+ if (s === "en" || s === "es") return s;
14
+ } catch {
15
+ }
16
+ return "en";
17
+ }
18
+ var useLangStore = create((set, get) => ({
19
+ lang: readLang(),
20
+ setLang: (l) => {
21
+ try {
22
+ localStorage.setItem(KEY, l);
23
+ } catch {
24
+ }
25
+ set({ lang: l });
26
+ },
27
+ toggle: () => {
28
+ const next = get().lang === "en" ? "es" : "en";
29
+ try {
30
+ localStorage.setItem(KEY, next);
31
+ } catch {
32
+ }
33
+ set({ lang: next });
34
+ }
35
+ }));
36
+ var useShellLang = () => useLangStore((s) => s.lang);
37
+
38
+ // src/lib/chrome.ts
39
+ var CHROME = {
40
+ en: {
41
+ github: "Source on GitHub",
42
+ personal: "Personal site",
43
+ portfolio: "Portfolio",
44
+ toggleTheme: "Toggle light / dark",
45
+ toggleLanguage: "Switch language",
46
+ light: "Light",
47
+ dark: "Dark",
48
+ attribution: "Built by Felipe Santib\xE1\xF1ez-Leal",
49
+ complement: "A CAOS research project",
50
+ license: "MIT licensed \xB7 open source",
51
+ version: "v"
52
+ },
53
+ es: {
54
+ github: "C\xF3digo en GitHub",
55
+ personal: "Sitio personal",
56
+ portfolio: "Portafolio",
57
+ toggleTheme: "Cambiar claro / oscuro",
58
+ toggleLanguage: "Cambiar idioma",
59
+ light: "Claro",
60
+ dark: "Oscuro",
61
+ attribution: "Hecho por Felipe Santib\xE1\xF1ez-Leal",
62
+ complement: "Un proyecto de investigaci\xF3n CAOS",
63
+ license: "Licencia MIT \xB7 c\xF3digo abierto",
64
+ version: "v"
65
+ }
66
+ };
67
+ var chrome = (lang) => CHROME[lang];
68
+ var KEY2 = "caos.theme";
69
+ function readTheme() {
70
+ try {
71
+ const s = localStorage.getItem(KEY2);
72
+ if (s === "light" || s === "dark") return s;
73
+ return window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
74
+ } catch {
75
+ return "dark";
76
+ }
77
+ }
78
+ function applyTheme(t) {
79
+ try {
80
+ document.documentElement.dataset.theme = t;
81
+ } catch {
82
+ }
83
+ }
84
+ var useThemeStore = create((set, get) => ({
85
+ theme: readTheme(),
86
+ toggleTheme: () => {
87
+ const next = get().theme === "dark" ? "light" : "dark";
88
+ applyTheme(next);
89
+ try {
90
+ localStorage.setItem(KEY2, next);
91
+ } catch {
92
+ }
93
+ set({ theme: next });
94
+ },
95
+ setTheme: (t) => {
96
+ applyTheme(t);
97
+ try {
98
+ localStorage.setItem(KEY2, t);
99
+ } catch {
100
+ }
101
+ set({ theme: t });
102
+ }
103
+ }));
104
+ function ThemeToggle() {
105
+ const theme = useThemeStore((s) => s.theme);
106
+ const toggleTheme = useThemeStore((s) => s.toggleTheme);
107
+ const c = chrome(useShellLang());
108
+ return /* @__PURE__ */ jsxs("button", { type: "button", className: "icon-btn", onClick: toggleTheme, "aria-label": c.toggleTheme, title: c.toggleTheme, children: [
109
+ theme === "dark" ? /* @__PURE__ */ jsx(Sun, { size: 18, "aria-hidden": "true" }) : /* @__PURE__ */ jsx(Moon, { size: 18, "aria-hidden": "true" }),
110
+ /* @__PURE__ */ jsx("span", { className: "sr-only", children: theme === "dark" ? c.light : c.dark })
111
+ ] });
112
+ }
113
+ function LanguageToggle() {
114
+ const lang = useLangStore((s) => s.lang);
115
+ const toggle = useLangStore((s) => s.toggle);
116
+ const c = chrome(lang);
117
+ return /* @__PURE__ */ jsxs("button", { type: "button", className: "icon-btn", onClick: toggle, "aria-label": c.toggleLanguage, title: c.toggleLanguage, children: [
118
+ /* @__PURE__ */ jsx(Languages, { size: 18, "aria-hidden": "true" }),
119
+ /* @__PURE__ */ jsx("span", { className: "lang-code", children: lang })
120
+ ] });
121
+ }
122
+ var PERSONAL = "https://fsantibanezleal.github.io";
123
+ var PORTFOLIO = "https://fasl-work.com";
124
+ function AppShell({ config, children }) {
125
+ const lang = useShellLang();
126
+ const c = chrome(lang);
127
+ const routes = config.routes ?? [];
128
+ const personal = config.links.personal ?? PERSONAL;
129
+ const portfolio = config.links.portfolio ?? PORTFOLIO;
130
+ return /* @__PURE__ */ jsxs("div", { className: "app-shell", children: [
131
+ /* @__PURE__ */ jsx("header", { className: "site-header", children: /* @__PURE__ */ jsxs("div", { className: "header-inner", children: [
132
+ /* @__PURE__ */ jsxs(NavLink, { to: "/", className: "brand", "aria-label": config.product.name, children: [
133
+ /* @__PURE__ */ jsx("span", { className: "brand-mark", children: config.product.mark ?? /* @__PURE__ */ jsx(Boxes, { size: 18, "aria-hidden": "true" }) }),
134
+ /* @__PURE__ */ jsx("span", { children: config.product.name })
135
+ ] }),
136
+ routes.length > 1 && /* @__PURE__ */ jsx("nav", { className: "main-nav", "aria-label": config.product.name, children: routes.map((r) => /* @__PURE__ */ jsx(
137
+ NavLink,
138
+ {
139
+ to: r.path,
140
+ end: r.path === "/",
141
+ className: ({ isActive }) => isActive ? "nav-link active" : "nav-link",
142
+ children: lang === "es" ? r.es : r.en
143
+ },
144
+ r.path
145
+ )) }),
146
+ /* @__PURE__ */ jsxs("div", { className: "header-actions", children: [
147
+ /* @__PURE__ */ jsx("a", { className: "icon-btn", href: config.links.github, target: "_blank", rel: "noreferrer noopener", "aria-label": c.github, title: c.github, children: /* @__PURE__ */ jsx(Github, { size: 18, "aria-hidden": "true" }) }),
148
+ /* @__PURE__ */ jsx("a", { className: "icon-btn", href: personal, target: "_blank", rel: "noreferrer noopener", "aria-label": c.personal, title: c.personal, children: /* @__PURE__ */ jsx(Globe, { size: 18, "aria-hidden": "true" }) }),
149
+ /* @__PURE__ */ jsx("a", { className: "icon-btn", href: portfolio, target: "_blank", rel: "noreferrer noopener", "aria-label": c.portfolio, title: c.portfolio, children: /* @__PURE__ */ jsx(Briefcase, { size: 18, "aria-hidden": "true" }) }),
150
+ /* @__PURE__ */ jsx("span", { className: "header-sep", "aria-hidden": "true" }),
151
+ /* @__PURE__ */ jsx(LanguageToggle, {}),
152
+ /* @__PURE__ */ jsx(ThemeToggle, {})
153
+ ] })
154
+ ] }) }),
155
+ /* @__PURE__ */ jsx("main", { className: "page", children }),
156
+ /* @__PURE__ */ jsx("footer", { className: "site-footer", children: /* @__PURE__ */ jsxs("div", { className: "footer-inner", children: [
157
+ /* @__PURE__ */ jsxs("div", { className: "footer-meta", children: [
158
+ /* @__PURE__ */ jsx("span", { children: c.attribution }),
159
+ /* @__PURE__ */ jsx("span", { "aria-hidden": "true", children: "\xB7" }),
160
+ /* @__PURE__ */ jsx("span", { children: c.complement })
161
+ ] }),
162
+ /* @__PURE__ */ jsxs("div", { className: "footer-meta", children: [
163
+ /* @__PURE__ */ jsx("a", { href: config.links.github, target: "_blank", rel: "noreferrer noopener", children: c.github }),
164
+ /* @__PURE__ */ jsx("span", { "aria-hidden": "true", children: "\xB7" }),
165
+ /* @__PURE__ */ jsx("a", { href: personal, target: "_blank", rel: "noreferrer noopener", children: c.personal }),
166
+ /* @__PURE__ */ jsx("span", { "aria-hidden": "true", children: "\xB7" }),
167
+ /* @__PURE__ */ jsx("a", { href: portfolio, target: "_blank", rel: "noreferrer noopener", children: c.portfolio }),
168
+ /* @__PURE__ */ jsx("span", { "aria-hidden": "true", children: "\xB7" }),
169
+ /* @__PURE__ */ jsx("span", { className: "faint", children: c.license }),
170
+ /* @__PURE__ */ jsx("span", { className: "footer-build", children: /* @__PURE__ */ jsxs("span", { children: [
171
+ c.version,
172
+ config.version
173
+ ] }) })
174
+ ] })
175
+ ] }) })
176
+ ] });
177
+ }
178
+ function Tabs({ tabs, initial, ariaLabel }) {
179
+ const baseId = useId();
180
+ const first = tabs[0]?.id ?? "";
181
+ const [active, setActive] = useState(initial ?? first);
182
+ function onKeyDown(e, idx) {
183
+ if (e.key !== "ArrowRight" && e.key !== "ArrowLeft" && e.key !== "Home" && e.key !== "End") return;
184
+ e.preventDefault();
185
+ let next = idx;
186
+ if (e.key === "ArrowRight") next = (idx + 1) % tabs.length;
187
+ else if (e.key === "ArrowLeft") next = (idx - 1 + tabs.length) % tabs.length;
188
+ else if (e.key === "Home") next = 0;
189
+ else if (e.key === "End") next = tabs.length - 1;
190
+ const target = tabs[next];
191
+ if (target) {
192
+ setActive(target.id);
193
+ document.getElementById(`${baseId}-tab-${target.id}`)?.focus();
194
+ }
195
+ }
196
+ return /* @__PURE__ */ jsxs("div", { className: "tabs", children: [
197
+ /* @__PURE__ */ jsx("div", { className: "tablist", role: "tablist", "aria-label": ariaLabel, children: tabs.map((tab, idx) => {
198
+ const selected = tab.id === active;
199
+ return /* @__PURE__ */ jsx(
200
+ "button",
201
+ {
202
+ id: `${baseId}-tab-${tab.id}`,
203
+ role: "tab",
204
+ type: "button",
205
+ "aria-selected": selected,
206
+ "aria-controls": `${baseId}-panel-${tab.id}`,
207
+ tabIndex: selected ? 0 : -1,
208
+ className: selected ? "tab active" : "tab",
209
+ onClick: () => setActive(tab.id),
210
+ onKeyDown: (e) => onKeyDown(e, idx),
211
+ children: tab.label
212
+ },
213
+ tab.id
214
+ );
215
+ }) }),
216
+ tabs.map((tab) => /* @__PURE__ */ jsx(
217
+ "div",
218
+ {
219
+ id: `${baseId}-panel-${tab.id}`,
220
+ role: "tabpanel",
221
+ "aria-labelledby": `${baseId}-tab-${tab.id}`,
222
+ hidden: tab.id !== active,
223
+ tabIndex: 0,
224
+ className: "tabpanel",
225
+ children: tab.id === active ? tab.content : null
226
+ },
227
+ tab.id
228
+ ))
229
+ ] });
230
+ }
231
+ function SubTabs({
232
+ tabs,
233
+ initial,
234
+ ariaLabel,
235
+ orientation = "horizontal"
236
+ }) {
237
+ const baseId = useId();
238
+ const first = tabs[0]?.id ?? "";
239
+ const [active, setActive] = useState(initial ?? first);
240
+ const vertical = orientation === "vertical";
241
+ function onKeyDown(e, idx) {
242
+ const fwd = vertical ? "ArrowDown" : "ArrowRight";
243
+ const back = vertical ? "ArrowUp" : "ArrowLeft";
244
+ if (e.key !== fwd && e.key !== back && e.key !== "Home" && e.key !== "End") return;
245
+ e.preventDefault();
246
+ let next = idx;
247
+ if (e.key === fwd) next = (idx + 1) % tabs.length;
248
+ else if (e.key === back) next = (idx - 1 + tabs.length) % tabs.length;
249
+ else if (e.key === "Home") next = 0;
250
+ else if (e.key === "End") next = tabs.length - 1;
251
+ const target = tabs[next];
252
+ if (target) {
253
+ setActive(target.id);
254
+ document.getElementById(`${baseId}-subtab-${target.id}`)?.focus();
255
+ }
256
+ }
257
+ return /* @__PURE__ */ jsxs("div", { className: vertical ? "subtabs subtabs-vertical" : "subtabs", children: [
258
+ /* @__PURE__ */ jsx("div", { className: "subtablist", role: "tablist", "aria-label": ariaLabel, "aria-orientation": vertical ? "vertical" : "horizontal", children: tabs.map((tab, idx) => {
259
+ const selected = tab.id === active;
260
+ return /* @__PURE__ */ jsx(
261
+ "button",
262
+ {
263
+ id: `${baseId}-subtab-${tab.id}`,
264
+ role: "tab",
265
+ type: "button",
266
+ "aria-selected": selected,
267
+ "aria-controls": `${baseId}-subpanel-${tab.id}`,
268
+ tabIndex: selected ? 0 : -1,
269
+ className: selected ? "subtab active" : "subtab",
270
+ onClick: () => setActive(tab.id),
271
+ onKeyDown: (e) => onKeyDown(e, idx),
272
+ children: tab.label
273
+ },
274
+ tab.id
275
+ );
276
+ }) }),
277
+ /* @__PURE__ */ jsx("div", { className: "subtabpanels", children: tabs.map((tab) => /* @__PURE__ */ jsx(
278
+ "div",
279
+ {
280
+ id: `${baseId}-subpanel-${tab.id}`,
281
+ role: "tabpanel",
282
+ "aria-labelledby": `${baseId}-subtab-${tab.id}`,
283
+ hidden: tab.id !== active,
284
+ tabIndex: 0,
285
+ className: "subtabpanel",
286
+ children: tab.id === active ? tab.content : null
287
+ },
288
+ tab.id
289
+ )) })
290
+ ] });
291
+ }
292
+ function Callout({
293
+ variant = "note",
294
+ title,
295
+ children
296
+ }) {
297
+ const cls = variant === "honest" ? "callout callout-honest" : variant === "strong" ? "callout callout-strong" : "callout callout-note";
298
+ return /* @__PURE__ */ jsxs("div", { className: cls, children: [
299
+ title && /* @__PURE__ */ jsx("p", { className: "callout-title", children: title }),
300
+ /* @__PURE__ */ jsx("div", { className: "callout-body", children })
301
+ ] });
302
+ }
303
+ function Equation({ tex, caption }) {
304
+ const html = katex.renderToString(tex, { displayMode: true, throwOnError: false });
305
+ return /* @__PURE__ */ jsxs("div", { className: "equation", children: [
306
+ /* @__PURE__ */ jsx("div", { dangerouslySetInnerHTML: { __html: html } }),
307
+ caption && /* @__PURE__ */ jsx("div", { className: "equation-caption", children: caption })
308
+ ] });
309
+ }
310
+ function InlineMath({ tex }) {
311
+ const html = katex.renderToString(tex, { displayMode: false, throwOnError: false });
312
+ return /* @__PURE__ */ jsx("span", { dangerouslySetInnerHTML: { __html: html } });
313
+ }
314
+ function Figure({ caption, children }) {
315
+ return /* @__PURE__ */ jsxs("figure", { className: "figure", children: [
316
+ children,
317
+ caption && /* @__PURE__ */ jsx("figcaption", { className: "figure-caption", children: caption })
318
+ ] });
319
+ }
320
+ var CitationsContext = createContext({ list: [], byId: {} });
321
+ function CitationsProvider({ items, children }) {
322
+ const byId = {};
323
+ for (const c of items) byId[c.id] = c;
324
+ return /* @__PURE__ */ jsx(CitationsContext.Provider, { value: { list: items, byId }, children });
325
+ }
326
+ function href(c) {
327
+ return c.doi ? `https://doi.org/${c.doi}` : c.url;
328
+ }
329
+ function Cite({ id, paren = true }) {
330
+ const { byId } = useContext(CitationsContext);
331
+ const c = byId[id];
332
+ if (!c) return /* @__PURE__ */ jsxs("cite", { className: "cite-inline", children: [
333
+ "[",
334
+ id,
335
+ "]"
336
+ ] });
337
+ const h = href(c);
338
+ const label = h ? /* @__PURE__ */ jsx("a", { href: h, target: "_blank", rel: "noreferrer noopener", children: c.label }) : /* @__PURE__ */ jsx("span", { children: c.label });
339
+ return /* @__PURE__ */ jsxs("cite", { className: "cite-inline", children: [
340
+ paren ? "(" : null,
341
+ label,
342
+ paren ? ")" : null
343
+ ] });
344
+ }
345
+ function Refs({ ids, label }) {
346
+ return /* @__PURE__ */ jsxs("p", { className: "th-refs", children: [
347
+ /* @__PURE__ */ jsx("span", { className: "th-refs-label", children: label }),
348
+ " ",
349
+ ids.map((id, i) => /* @__PURE__ */ jsxs("span", { children: [
350
+ i > 0 ? " \xB7 " : null,
351
+ /* @__PURE__ */ jsx(Cite, { id, paren: false })
352
+ ] }, id))
353
+ ] });
354
+ }
355
+ function ReferenceList({ ids, heading }) {
356
+ const { list, byId } = useContext(CitationsContext);
357
+ const items = ids ? ids.map((k) => byId[k]).filter(Boolean) : list;
358
+ return /* @__PURE__ */ jsxs("section", { className: "references", "aria-label": heading ?? "References", children: [
359
+ heading ? /* @__PURE__ */ jsx("h2", { children: heading }) : null,
360
+ /* @__PURE__ */ jsx("ol", { className: "reference-list", children: items.map((c) => {
361
+ const h = href(c);
362
+ return /* @__PURE__ */ jsxs("li", { children: [
363
+ /* @__PURE__ */ jsx("span", { children: c.citation }),
364
+ " ",
365
+ h ? /* @__PURE__ */ jsx("a", { href: h, target: "_blank", rel: "noreferrer noopener", className: "faint", children: c.doi ? `doi:${c.doi}` : "link" }) : null
366
+ ] }, c.id);
367
+ }) })
368
+ ] });
369
+ }
370
+
371
+ export { AppShell, Callout, CitationsProvider, Cite, Equation, Figure, InlineMath, LanguageToggle, ReferenceList, Refs, SubTabs, Tabs, ThemeToggle, applyTheme, readTheme, useLangStore, useShellLang, useThemeStore };
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@fasl-work/caos-app-shell",
3
+ "version": "0.1.0",
4
+ "description": "Shared web-app shell + content primitives + design system for the CAOS / Faena public apps (ADR-0016).",
5
+ "license": "MIT",
6
+ "author": "Felipe Santibáñez-Leal",
7
+ "repository": "github:fsantibanezleal/CAOS_APP_SHELL",
8
+ "type": "module",
9
+ "publishConfig": { "access": "public" },
10
+ "sideEffects": ["*.css"],
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "import": "./dist/index.js"
15
+ },
16
+ "./styles.css": "./styles.css"
17
+ },
18
+ "files": ["dist", "styles.css"],
19
+ "scripts": {
20
+ "build": "tsup",
21
+ "prepublishOnly": "npm run build"
22
+ },
23
+ "peerDependencies": {
24
+ "katex": "^0.16.0",
25
+ "lucide-react": "^0.400.0 || ^0.500.0",
26
+ "react": "^18 || ^19",
27
+ "react-dom": "^18 || ^19",
28
+ "react-router-dom": "^6 || ^7",
29
+ "zustand": "^4 || ^5"
30
+ },
31
+ "devDependencies": {
32
+ "@types/katex": "^0.16.7",
33
+ "@types/react": "^19.0.0",
34
+ "@types/react-dom": "^19.0.0",
35
+ "katex": "^0.16.11",
36
+ "lucide-react": "^0.469.0",
37
+ "react": "^19.0.0",
38
+ "react-dom": "^19.0.0",
39
+ "react-router-dom": "^7.1.1",
40
+ "tsup": "^8.3.5",
41
+ "typescript": "^5.7.3",
42
+ "zustand": "^5.0.2"
43
+ }
44
+ }
package/styles.css ADDED
@@ -0,0 +1,221 @@
1
+ @import "katex/dist/katex.min.css";
2
+
3
+ /* @fasl-work/caos-app-shell — shared design system (ADR-0016 + visual-standards palette).
4
+ * Theming via CSS custom properties keyed on data-theme on <html> (set pre-paint by an inline
5
+ * script in index.html, kept in sync by the shell theme store). Import once per app:
6
+ * import "@fasl-work/caos-app-shell/styles.css"; */
7
+
8
+ :root,
9
+ [data-theme="dark"] {
10
+ --color-bg: #0d1117;
11
+ --color-surface: #161b22;
12
+ --color-surface-2: #1c2230;
13
+ --color-border: #30363d;
14
+ --color-fg: #c9d1d9;
15
+ --color-fg-subtle: #9aa6b2;
16
+ --color-fg-faint: #6c7785;
17
+ --color-accent: #58a6ff;
18
+ --color-accent-fg: #0d1117;
19
+ --color-accent-soft: #132036;
20
+ --color-accent-2: #3fb1c8;
21
+ --color-magenta: #f778ba;
22
+ --color-good: #3fb950;
23
+ --color-warn: #d29922;
24
+ --color-bad: #f85149;
25
+ --color-shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 6px 16px rgba(0, 0, 0, 0.45);
26
+ --font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
27
+ --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
28
+ --maxw: 1200px;
29
+ }
30
+
31
+ [data-theme="light"] {
32
+ --color-bg: #f6f8fa;
33
+ --color-surface: #ffffff;
34
+ --color-surface-2: #f0f3f6;
35
+ --color-border: #d0d7de;
36
+ --color-fg: #1f2328;
37
+ --color-fg-subtle: #57606a;
38
+ --color-fg-faint: #6e7781;
39
+ --color-accent: #0969da;
40
+ --color-accent-fg: #ffffff;
41
+ --color-accent-soft: #ddf4ff;
42
+ --color-accent-2: #0a7c8a;
43
+ --color-magenta: #bf3989;
44
+ --color-good: #1a7f37;
45
+ --color-warn: #9a6700;
46
+ --color-bad: #cf222e;
47
+ --color-shadow: 0 1px 2px rgba(31, 35, 40, 0.06), 0 4px 10px rgba(31, 35, 40, 0.04);
48
+ }
49
+
50
+ * { box-sizing: border-box; }
51
+ html, body, #root { height: 100%; }
52
+ body {
53
+ margin: 0;
54
+ font-family: var(--font-sans);
55
+ background-color: var(--color-bg);
56
+ color: var(--color-fg);
57
+ -webkit-font-smoothing: antialiased;
58
+ -moz-osx-font-smoothing: grayscale;
59
+ line-height: 1.55;
60
+ }
61
+ a { color: var(--color-accent); text-decoration: none; }
62
+ a:hover { text-decoration: underline; }
63
+ button { font: inherit; cursor: pointer; color: inherit; }
64
+ h1, h2, h3, h4 { line-height: 1.2; font-weight: 650; letter-spacing: -0.01em; }
65
+ code, kbd { font-family: var(--font-mono); }
66
+
67
+ /* ── Layout primitives ── */
68
+ .app-shell { min-height: 100vh; display: flex; flex-direction: column; background: var(--color-bg); color: var(--color-fg); }
69
+ .container { width: 100%; max-width: var(--maxw); margin-inline: auto; padding-inline: 1.5rem; }
70
+ .page { flex: 1 1 auto; width: 100%; }
71
+ .page-body { width: 100%; max-width: var(--maxw); margin-inline: auto; padding: 2.25rem 1.5rem 3rem; }
72
+ .muted { color: var(--color-fg-subtle); }
73
+ .faint { color: var(--color-fg-faint); }
74
+ .mono { font-family: var(--font-mono); font-size: 0.9em; }
75
+ .small { font-size: 0.84rem; }
76
+ .card { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 10px; padding: 1.25rem 1.5rem; box-shadow: var(--color-shadow); }
77
+ .badge { display: inline-flex; align-items: center; gap: 0.35rem; font-size: 0.78rem; padding: 0.15rem 0.55rem; border-radius: 999px; border: 1px solid var(--color-border); background: var(--color-surface-2); color: var(--color-fg-subtle); }
78
+ .badge.live { color: var(--color-good); border-color: color-mix(in oklab, var(--color-good) 45%, transparent); }
79
+ .badge.precomputed, .badge.accent { color: var(--color-accent); border-color: color-mix(in oklab, var(--color-accent) 45%, transparent); }
80
+ .badge.warn { color: var(--color-warn); border-color: color-mix(in oklab, var(--color-warn) 45%, transparent); }
81
+
82
+ /* ── Header ── */
83
+ .site-header { position: sticky; top: 0; z-index: 50; border-bottom: 1px solid var(--color-border); background: color-mix(in oklab, var(--color-bg) 82%, transparent); backdrop-filter: blur(8px); }
84
+ .header-inner { max-width: var(--maxw); margin-inline: auto; height: 56px; padding-inline: 1.5rem; display: flex; align-items: center; gap: 1rem; }
85
+ .brand { display: inline-flex; align-items: center; gap: 0.5rem; font-weight: 700; letter-spacing: -0.02em; color: var(--color-fg); }
86
+ .brand:hover { text-decoration: none; }
87
+ .brand .brand-mark { color: var(--color-accent); display: inline-flex; flex: none; }
88
+ .main-nav { display: flex; align-items: center; gap: 0.15rem; }
89
+ .nav-link { padding: 0.4rem 0.7rem; border-radius: 6px; font-size: 0.9rem; color: var(--color-fg-subtle); transition: color 0.15s, background-color 0.15s; }
90
+ .nav-link:hover { text-decoration: none; color: var(--color-fg); }
91
+ .nav-link.active { color: var(--color-accent); background: var(--color-accent-soft); font-weight: 600; }
92
+ .header-actions { margin-left: auto; display: inline-flex; align-items: center; gap: 0.15rem; }
93
+ .icon-btn { display: inline-flex; align-items: center; justify-content: center; gap: 0.3rem; padding: 0.4rem 0.5rem; border-radius: 6px; background: transparent; border: none; color: var(--color-fg-subtle); opacity: 0.85; transition: opacity 0.15s, color 0.15s; }
94
+ .icon-btn:hover { opacity: 1; color: var(--color-fg); text-decoration: none; }
95
+ .icon-btn svg { display: block; flex: none; }
96
+ .lang-code { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.06em; font-weight: 600; }
97
+ .header-sep { width: 1px; height: 20px; margin-inline: 0.4rem; background: var(--color-border); }
98
+
99
+ /* ── Footer ── */
100
+ .site-footer { border-top: 1px solid var(--color-border); margin-top: 3rem; background: var(--color-surface); }
101
+ .footer-inner { max-width: var(--maxw); margin-inline: auto; padding: 1.5rem; display: flex; flex-direction: column; gap: 0.9rem; font-size: 0.85rem; color: var(--color-fg-faint); }
102
+ .footer-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem 0.75rem; }
103
+ .footer-build { margin-left: auto; font-family: var(--font-mono); font-size: 0.72rem; color: var(--color-fg-faint); display: inline-flex; gap: 0.6rem; flex-wrap: wrap; }
104
+
105
+ /* ── Misc ── */
106
+ .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
107
+ :where(a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])):focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; border-radius: 4px; }
108
+ @media (max-width: 760px) { .main-nav { display: none; } }
109
+ @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } }
110
+
111
+ /* ── Prose / content pages ── (fill the page-body width with grids/figures/sub-tab rails, not a narrow column) */
112
+ .prose { font-size: 0.98rem; }
113
+ .prose section { margin-block: 1.75rem; }
114
+ .measure { max-width: 70ch; }
115
+ .fig-row { display: grid; grid-template-columns: 1.1fr 1fr; gap: 1.5rem; align-items: center; margin-block: 1.5rem; }
116
+ .fig-row.rev { grid-template-columns: 1fr 1.1fr; }
117
+ @media (max-width: 820px) { .fig-row, .fig-row.rev { grid-template-columns: 1fr; } }
118
+ .def-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 1rem; margin-block: 1.25rem; }
119
+ .def { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 10px; padding: 0.9rem 1.1rem; }
120
+ .def h4 { margin: 0 0 0.3rem; color: var(--color-accent); font-size: 0.95rem; }
121
+ .def p { margin: 0; font-size: 0.9rem; color: var(--color-fg-subtle); }
122
+ .assume { margin-block: 1rem; padding: 0.85rem 1rem; background: var(--color-surface-2); border: 1px solid var(--color-border); border-radius: 8px; }
123
+ .assume-title { margin: 0 0 0.4rem; font-size: 0.76rem; font-weight: 650; text-transform: uppercase; letter-spacing: 0.04em; color: var(--color-fg-faint); }
124
+ .assume ul { margin: 0; padding-left: 1.2rem; color: var(--color-fg-subtle); }
125
+ .assume li { margin-block: 0.3rem; font-size: 0.92rem; }
126
+ .references { margin-top: 2.5rem; border-top: 1px solid var(--color-border); padding-top: 1rem; }
127
+ .reference-list { font-size: 0.85rem; color: var(--color-fg-subtle); line-height: 1.5; padding-left: 1.4rem; }
128
+ .reference-list li { margin-block: 0.5rem; }
129
+ .cite-inline { font-style: normal; font-size: 0.92em; color: var(--color-fg-subtle); }
130
+ .th-refs { margin-top: 1.25rem; padding-top: 0.6rem; border-top: 1px dashed var(--color-border); font-size: 0.85rem; color: var(--color-fg-subtle); }
131
+ .th-refs-label { font-weight: 650; color: var(--color-fg-faint); }
132
+ .prose h2 { font-size: 1.3rem; margin-block: 1.4rem 0.6rem; }
133
+ .prose h3 { font-size: 1.06rem; margin-block: 1.1rem 0.4rem; }
134
+ .prose p { margin-block: 0.6rem; }
135
+ .prose ul, .prose ol { margin-block: 0.5rem; padding-left: 1.3rem; }
136
+ .prose li { margin-block: 0.35rem; }
137
+ .page-head { border-bottom: 1px solid var(--color-border); padding-bottom: 1rem; margin-bottom: 1.25rem; }
138
+ .page-head h1 { font-size: 1.7rem; margin: 0 0 0.4rem; }
139
+ .lede { color: var(--color-fg-subtle); font-size: 1.08rem; margin: 0; max-width: 70ch; }
140
+ .two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 1.25rem; margin-block: 1.5rem; }
141
+ @media (max-width: 760px) { .two-col { grid-template-columns: 1fr; } }
142
+ .tick-list, .cross-list { list-style: none; padding-left: 0; margin: 0.4rem 0 0; }
143
+ .tick-list li, .cross-list li { position: relative; padding-left: 1.5rem; margin-block: 0.5rem; }
144
+ .tick-list li::before { content: "✓"; position: absolute; left: 0; color: var(--color-good); font-weight: 700; }
145
+ .cross-list li::before { content: "✕"; position: absolute; left: 0; color: var(--color-fg-faint); font-weight: 700; }
146
+
147
+ /* ── Callouts ── */
148
+ .callout { margin-block: 1rem; padding: 0.8rem 1.1rem; border-left: 3px solid var(--color-border); background: var(--color-surface-2); border-radius: 0 8px 8px 0; }
149
+ .callout-honest { border-left-color: var(--color-warn); }
150
+ .callout-strong { border-left-color: var(--color-accent); background: var(--color-accent-soft); }
151
+ .callout-note { border-left-color: var(--color-fg-faint); }
152
+ .callout-title { font-weight: 650; margin: 0 0 0.4rem; color: var(--color-fg); }
153
+ .callout-body { color: var(--color-fg-subtle); }
154
+ .callout-body p:first-child { margin-top: 0; }
155
+ .callout-body p:last-child { margin-bottom: 0; }
156
+
157
+ /* ── Tabs ── */
158
+ .tablist { display: flex; gap: 0.35rem; padding-bottom: 0.6rem; margin-bottom: 1.5rem; border-bottom: 1px solid var(--color-border); flex-wrap: wrap; }
159
+ .tab { padding: 0.5rem 1rem; border: 1px solid transparent; border-radius: 8px; background: transparent; color: var(--color-fg-subtle); font-size: 0.92rem; font-weight: 550; transition: color 0.15s, background-color 0.15s, border-color 0.15s; }
160
+ .tab:hover { color: var(--color-fg); background: var(--color-surface-2); }
161
+ .tab.active { color: var(--color-accent); background: var(--color-accent-soft); border-color: color-mix(in oklab, var(--color-accent) 35%, transparent); font-weight: 650; }
162
+ .tabpanel:focus, .subtabpanel:focus { outline: none; }
163
+ .subtabs { margin-block: 1rem; }
164
+ .subtablist { display: flex; gap: 0.3rem; flex-wrap: wrap; margin-bottom: 1.1rem; }
165
+ .subtab { padding: 0.32rem 0.8rem; border: 1px solid var(--color-border); border-radius: 999px; background: var(--color-surface); color: var(--color-fg-subtle); font-size: 0.84rem; font-weight: 500; transition: color 0.15s, background-color 0.15s, border-color 0.15s; }
166
+ .subtab:hover { color: var(--color-fg); border-color: var(--color-fg-faint); }
167
+ .subtab.active { color: var(--color-accent); background: var(--color-accent-soft); border-color: color-mix(in oklab, var(--color-accent) 40%, transparent); font-weight: 600; }
168
+ .subtabs-vertical { display: grid; grid-template-columns: minmax(180px, 240px) 1fr; gap: 1.5rem; align-items: start; }
169
+ .subtabs-vertical .subtablist { flex-direction: column; margin-bottom: 0; }
170
+ .subtabs-vertical .subtab { text-align: left; border-radius: 8px; }
171
+ @media (max-width: 760px) { .subtabs-vertical { grid-template-columns: 1fr; } .subtabs-vertical .subtablist { flex-direction: row; } }
172
+
173
+ /* ── Equations / figures ── */
174
+ .equation { margin: 1rem 0; padding: 0.9rem 1rem; background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 8px; overflow-x: auto; }
175
+ .equation-caption { margin-top: 0.5rem; font-size: 0.82rem; color: var(--color-fg-faint); text-align: center; }
176
+ .katex { color: var(--color-fg); }
177
+ .figure { margin: 1.5rem 0; }
178
+ .figure-caption, .fig-cap { text-align: center; font-size: 0.84rem; color: var(--color-fg-faint); margin-top: 0.6rem; font-style: italic; }
179
+
180
+ /* ── Theme-aware inline SVG figures (teaching diagrams) ── */
181
+ .fig-svg { width: 100%; max-width: 560px; height: auto; display: block; margin-inline: auto; }
182
+ .fig-svg.wide { max-width: 760px; }
183
+ .dg-axis { stroke: var(--color-fg-faint); stroke-width: 1.3; }
184
+ .dg-axis-label { fill: var(--color-fg-subtle); font: 12px var(--font-sans); }
185
+ .dg-tick { fill: var(--color-fg-faint); font: 11px var(--font-mono); }
186
+ .dg-grid { stroke: var(--color-border); stroke-width: 0.5; opacity: 0.6; }
187
+ .dg-curve { fill: none; stroke: var(--color-accent); stroke-width: 2.2; stroke-linejoin: round; }
188
+ .dg-curve-2 { fill: none; stroke: var(--color-magenta); stroke-width: 2; }
189
+ .dg-curve-faint { fill: none; stroke: var(--color-fg-faint); stroke-width: 1.4; stroke-dasharray: 4 3; }
190
+ .dg-asymptote { stroke: var(--color-bad); stroke-width: 1.4; stroke-dasharray: 4 4; }
191
+ .dg-marker { stroke: var(--color-warn); stroke-width: 1.2; stroke-dasharray: 3 3; }
192
+ .dg-marker-label { fill: var(--color-warn); font: 600 11px var(--font-mono); }
193
+ .dg-note { fill: var(--color-fg-faint); font: italic 11px var(--font-sans); }
194
+ .dg-fill-accent { fill: color-mix(in oklab, var(--color-accent) 16%, transparent); stroke: var(--color-accent); stroke-width: 1; }
195
+ .dg-fill-warn { fill: color-mix(in oklab, var(--color-warn) 16%, transparent); stroke: var(--color-warn); stroke-width: 1; }
196
+ .dg-box { fill: var(--color-surface); stroke: var(--color-border); stroke-width: 1.3; }
197
+ .dg-box.accent { stroke: var(--color-accent); fill: var(--color-accent-soft); }
198
+ .dg-box.good { stroke: var(--color-good); }
199
+ .dg-box-title { fill: var(--color-fg); font: 650 12px var(--font-sans); }
200
+ .dg-box-title.accent { fill: var(--color-accent); }
201
+ .dg-box-sub { fill: var(--color-fg-subtle); font: 10.5px var(--font-mono); }
202
+ .dg-node { fill: var(--color-surface); stroke: var(--color-accent); stroke-width: 1.4; }
203
+ .dg-node-label { fill: var(--color-fg); font: 600 12px var(--font-mono); }
204
+ .dg-edge { stroke: var(--color-fg-faint); stroke-width: 1.4; fill: none; }
205
+ .dg-edge-label { fill: var(--color-fg-subtle); font: 11px var(--font-mono); }
206
+ .dg-arrowhead { fill: var(--color-fg-faint); }
207
+ .dg-bar { fill: var(--color-accent); }
208
+ .dg-bar-2 { fill: var(--color-magenta); }
209
+ .dg-bar-label { fill: var(--color-fg-subtle); font: 11px var(--font-sans); }
210
+
211
+ /* ── Generic controls ── */
212
+ .btn { border: 1px solid var(--color-border); background: var(--color-surface-2); color: var(--color-fg); padding: 0.4rem 0.9rem; border-radius: 8px; font-weight: 600; font-size: 0.88rem; display: inline-flex; align-items: center; gap: 0.35rem; }
213
+ .btn:hover { border-color: var(--color-fg-faint); }
214
+ .btn.primary { background: var(--color-accent); color: var(--color-accent-fg); border-color: var(--color-accent); }
215
+ .btn:disabled { opacity: 0.5; cursor: default; }
216
+ .select { background: var(--color-surface-2); color: var(--color-fg); border: 1px solid var(--color-border); border-radius: 8px; padding: 0.35rem 0.5rem; font-size: 0.85rem; }
217
+ .hint { font-size: 0.8rem; color: var(--color-fg-faint); margin: 0; }
218
+ .banner { padding: 0.6rem 0.9rem; border-radius: 8px; font-size: 0.85rem; margin-block: 0.6rem; border-left: 3px solid var(--color-border); background: var(--color-surface-2); }
219
+ .banner.warn { border-left-color: var(--color-warn); color: var(--color-warn); }
220
+ .banner.error { border-left-color: var(--color-bad); color: var(--color-bad); }
221
+ .range { width: 100%; accent-color: var(--color-accent); }