@vitavision/ui 0.6.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/src/theme.ts ADDED
@@ -0,0 +1,113 @@
1
+ /*
2
+ * Which of the two palettes is on screen.
3
+ *
4
+ * Judging an anomaly map, or a measurement overlay drawn over a low-contrast part, against
5
+ * a bright room and against a dark one are different tasks, and a researcher switches
6
+ * between them within a session -- so dark mode is a real, storable choice here rather than
7
+ * only `prefers-color-scheme`.
8
+ *
9
+ * Three states, not two. "system" is a real choice and has to survive a reload as itself,
10
+ * otherwise the first time the OS flips at sunset the app disagrees with everything else on
11
+ * the machine and there is no way back to following it.
12
+ *
13
+ * The class is applied to <html> rather than <body> so the page background painted before
14
+ * React mounts is already correct. `applyTheme` (below, as `paint`) has to be duplicated as
15
+ * an inline script in the consumer's `index.html` for exactly that reason -- see the
16
+ * package README for the exact snippet, and keep it in agreement with this module if either
17
+ * changes.
18
+ */
19
+
20
+ /**
21
+ * A stored theme preference: a palette, or `system` to follow the OS
22
+ * (`prefers-color-scheme`) and keep following it.
23
+ */
24
+ export type ThemeChoice = "light" | "dark" | "system";
25
+
26
+ /**
27
+ * The `localStorage` key a consumer's theme choice is kept under.
28
+ *
29
+ * Every function here takes it as an optional last argument and defaults to this constant,
30
+ * so a single app needs to pass nothing, while two apps sharing a browser profile -- or one
31
+ * app that wants a namespaced key -- can each choose their own without stepping on the
32
+ * other's stored preference.
33
+ */
34
+ export const DEFAULT_THEME_STORAGE_KEY = "vitavision-theme";
35
+
36
+ // Every DOM access here is inside a function, and guarded: this module is imported on the
37
+ // server too.
38
+ const query = () =>
39
+ typeof window !== "undefined" && typeof window.matchMedia === "function"
40
+ ? window.matchMedia("(prefers-color-scheme: dark)")
41
+ : null;
42
+
43
+ /**
44
+ * The stored preference.
45
+ *
46
+ * @param storageKey - The `localStorage` key. Defaults to `DEFAULT_THEME_STORAGE_KEY`.
47
+ * @returns The stored choice, or `system` when nothing valid is stored or storage cannot be
48
+ * read (private browsing, the server).
49
+ */
50
+ export function readThemeChoice(storageKey: string = DEFAULT_THEME_STORAGE_KEY): ThemeChoice {
51
+ try {
52
+ const stored = window.localStorage.getItem(storageKey);
53
+ if (stored === "light" || stored === "dark" || stored === "system") return stored;
54
+ } catch {
55
+ // Private browsing, or a WebView with storage disabled. Following the OS is a fine
56
+ // answer to "I could not read your preference", and better than refusing to render.
57
+ }
58
+ return "system";
59
+ }
60
+
61
+ /**
62
+ * What `choice` actually resolves to right now.
63
+ *
64
+ * @param choice - The preference.
65
+ * @returns `choice` itself for `light`/`dark`; for `system`, the OS palette (`light` where
66
+ * it cannot be queried).
67
+ */
68
+ export function resolveTheme(choice: ThemeChoice): "light" | "dark" {
69
+ if (choice !== "system") return choice;
70
+ return query()?.matches ? "dark" : "light";
71
+ }
72
+
73
+ function paint(choice: ThemeChoice): void {
74
+ if (typeof document === "undefined") return;
75
+ document.documentElement.classList.toggle("dark", resolveTheme(choice) === "dark");
76
+ }
77
+
78
+ /**
79
+ * Store a preference and paint it (the `dark` class on `<html>`).
80
+ *
81
+ * @param choice - The new preference.
82
+ * @param storageKey - The `localStorage` key. Defaults to `DEFAULT_THEME_STORAGE_KEY`.
83
+ */
84
+ export function setThemeChoice(
85
+ choice: ThemeChoice,
86
+ storageKey: string = DEFAULT_THEME_STORAGE_KEY,
87
+ ): void {
88
+ try {
89
+ window.localStorage.setItem(storageKey, choice);
90
+ } catch {
91
+ // Not worth failing the click over; the choice still applies for this session.
92
+ }
93
+ paint(choice);
94
+ }
95
+
96
+ /**
97
+ * Paint the stored choice and keep following the OS while the choice is "system".
98
+ *
99
+ * @param storageKey - The `localStorage` key. Defaults to `DEFAULT_THEME_STORAGE_KEY`.
100
+ * @returns An unsubscribe, so a test can tear the listener down; a long-lived app never does.
101
+ */
102
+ export function initTheme(storageKey: string = DEFAULT_THEME_STORAGE_KEY): () => void {
103
+ paint(readThemeChoice(storageKey));
104
+
105
+ const media = query();
106
+ if (media === null) return () => {};
107
+
108
+ const onSystemChange = () => {
109
+ if (readThemeChoice(storageKey) === "system") paint("system");
110
+ };
111
+ media.addEventListener("change", onSystemChange);
112
+ return () => media.removeEventListener("change", onSystemChange);
113
+ }
package/src/tone.ts ADDED
@@ -0,0 +1,42 @@
1
+ /*
2
+ * The verdict vocabulary, for the components that paint outside of `className`.
3
+ *
4
+ * Every Tailwind-driven primitive under `components/ui` reaches the palette through a
5
+ * class name, which only works where Tailwind's own build sees the class. `MeasureOverlay`
6
+ * and `LineProfile` paint through raw SVG `stroke`/`fill` attributes instead — a caliper
7
+ * box or an edge mark is drawn from data the consumer supplies at runtime, not from a
8
+ * fixed set of class names a bundler can scan for. So these two read the same five tokens
9
+ * `styles.css` defines (`--signal`, `--normal`, `--defect`, `--warn`, `--fg-subtle`)
10
+ * directly as CSS custom properties, which resolves correctly in both themes without
11
+ * needing Tailwind involved in the overlay's own render path at all.
12
+ */
13
+
14
+ /**
15
+ * A paint for SVG overlays and charts: the accent (`signal`), a verdict colour, or `muted`.
16
+ *
17
+ * Named `MeasureTone` rather than `Tone` — `components/ui/Badge.tsx` already owns that name
18
+ * for the verdict-badge vocabulary (`neutral`/`normal`/`defect`/`unlabeled`/`warning`/
19
+ * `info`), and both are re-exported from the package root, where two types of the same name
20
+ * would collide.
21
+ */
22
+ export type MeasureTone = "signal" | "normal" | "defect" | "warn" | "muted";
23
+
24
+ const TONE_VAR: Record<MeasureTone, string> = {
25
+ signal: "var(--signal)",
26
+ normal: "var(--normal)",
27
+ defect: "var(--defect)",
28
+ warn: "var(--warn)",
29
+ muted: "var(--fg-subtle)",
30
+ };
31
+
32
+ /**
33
+ * The CSS paint value for a tone — usable directly as a `stroke`, `fill` or `color`. It is a
34
+ * `var(--token)` reference, so it follows the theme without a re-render.
35
+ *
36
+ * @param tone - The tone; `undefined` falls back.
37
+ * @param fallback - The tone used when `tone` is `undefined`. Defaults to `signal`.
38
+ * @returns A `var(--…)` CSS value.
39
+ */
40
+ export function toneColor(tone: MeasureTone | undefined, fallback: MeasureTone = "signal"): string {
41
+ return TONE_VAR[tone ?? fallback];
42
+ }