@baseworks/ui 0.0.0-stage → 0.2.1

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,98 @@
1
+ /**
2
+ * Theme.
3
+ *
4
+ * There is one palette — see tokens.css — in a light and a dark form. The
5
+ * operating system decides which, until someone says otherwise: `data-theme` on
6
+ * <html> forces a form in both directions, and `ThemeToggle` is the control
7
+ * that sets it.
8
+ *
9
+ * The choice is remembered in the realm's theme cookie, `bw_theme` — the one
10
+ * the pages a service draws read and write too (the sign-in page, the
11
+ * organization pages, the account pages; `@baseworks/core/pages`). So a theme
12
+ * chosen on `/account` is the theme the product opens in, and the other way
13
+ * round. It has to be restored before the first paint: `themeInitScript()` in
14
+ * <head>, or the page renders the system's answer and then flips.
15
+ *
16
+ * Until 0.2.1 the choice lived in localStorage under `<prefix>.theme`, where a
17
+ * server-drawn page could never see it. That key is still read when there is
18
+ * no cookie, so nobody's choice is lost, and still written, so an older copy
19
+ * of this package on the same origin keeps agreeing.
20
+ */
21
+
22
+ export type ThemeMode = 'system' | 'light' | 'dark'
23
+
24
+ const themeKey = (prefix: string) => `${prefix}.theme`
25
+
26
+ /** The realm's theme cookie. Its values are `light`, `dark` and `auto`. */
27
+ export const THEME_COOKIE = 'bw_theme'
28
+
29
+ /** What the cookie says, or undefined when it is absent or says something else. */
30
+ function cookieTheme(): ThemeMode | undefined {
31
+ if (typeof document === 'undefined') return undefined
32
+ const m = new RegExp(`(?:^|;\\s*)${THEME_COOKIE}=([^;]*)`).exec(document.cookie)
33
+ const v = m?.[1]
34
+ return v === 'light' || v === 'dark' ? v : v === 'auto' ? 'system' : undefined
35
+ }
36
+
37
+ /** The remembered choice: the cookie, else what an earlier version stored, else the system. */
38
+ export function storedTheme(prefix = 'bw'): ThemeMode {
39
+ const fromCookie = cookieTheme()
40
+ if (fromCookie) return fromCookie
41
+ try {
42
+ const legacy = localStorage.getItem(themeKey(prefix))
43
+ if (legacy === 'light' || legacy === 'dark') return legacy
44
+ } catch { /* no storage: nothing was stored */ }
45
+ return 'system'
46
+ }
47
+
48
+ /** What the document is showing now — not what is stored. */
49
+ export function currentTheme(): ThemeMode {
50
+ if (typeof document === 'undefined') return 'system'
51
+ const t = document.documentElement.dataset['theme']
52
+ return t === 'dark' || t === 'light' ? t : 'system'
53
+ }
54
+
55
+ /**
56
+ * What the viewer is actually seeing: the forced form if there is one, else the
57
+ * system's. This is the honest answer to "which theme is on" — `currentTheme()`
58
+ * says `system`, which is a setting, not a colour.
59
+ */
60
+ export function resolvedTheme(): 'light' | 'dark' {
61
+ const forced = currentTheme()
62
+ if (forced !== 'system') return forced
63
+ if (typeof window === 'undefined') return 'light'
64
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
65
+ }
66
+
67
+ /**
68
+ * Force a form, or hand the decision back to the system. Remembered in the
69
+ * realm's cookie, so the pages a service draws agree; `prefix` only names the
70
+ * older localStorage key.
71
+ */
72
+ export function applyTheme(mode: ThemeMode, prefix = 'bw'): void {
73
+ if (mode === 'system') delete document.documentElement.dataset['theme']
74
+ else document.documentElement.dataset['theme'] = mode
75
+ try {
76
+ const secure = location.protocol === 'https:' ? '; Secure' : ''
77
+ document.cookie = `${THEME_COOKIE}=${mode === 'system' ? 'auto' : mode}; Path=/; Max-Age=31536000; SameSite=Lax${secure}`
78
+ } catch { /* cookies refused: the choice just does not persist */ }
79
+ try { localStorage.setItem(themeKey(prefix), mode) } catch { /* private mode: the same */ }
80
+ }
81
+
82
+ /**
83
+ * The snippet for <head>, for a product that uses `applyTheme`. It is
84
+ * deliberately tiny and wrapped in try/catch: a browser that refuses cookies
85
+ * or localStorage must still render the page.
86
+ *
87
+ * It reads the realm's cookie first and the older localStorage key only when
88
+ * there is no cookie. `auto` in the cookie means "the system decides", which
89
+ * is what the page does with nothing set.
90
+ *
91
+ * A product with no toggle should still include this: its pages then open in
92
+ * the theme the person chose on the account page.
93
+ */
94
+ export function themeInitScript(prefix = 'bw'): string {
95
+ return `try{var m=/(?:^|;\\s*)${THEME_COOKIE}=([^;]*)/.exec(document.cookie),t=m?m[1]:null;`
96
+ + `if(t===null)t=localStorage.getItem("${themeKey(prefix)}");`
97
+ + `if(t==="dark"||t==="light")document.documentElement.dataset.theme=t}catch(e){}`
98
+ }
package/src/tokens.css ADDED
@@ -0,0 +1,143 @@
1
+ /* @baseworks/ui — design tokens.
2
+
3
+ One palette: **warm paper, ultramarine ink**. Light and dark are the same
4
+ design at two luminances, not two designs — every token below exists in both
5
+ and means the same thing in both.
6
+
7
+ There is no brand switcher: a second palette is a second design to keep
8
+ correct in every state. There is a light/dark toggle, because that one is not
9
+ taste — it is the room the screen is in. The theme follows the operating
10
+ system until the viewer says otherwise, and `data-theme="light|dark"` on
11
+ <html> is how they say it (see `ThemeToggle`).
12
+
13
+ Why these colours:
14
+
15
+ - The neutrals are warm (a paper hue at very low saturation), not grey. A
16
+ console that is mostly tables and text is read for hours; a warm ground is
17
+ easier to sit in front of, and it is what distinguishes this from the
18
+ cool-grey default every admin panel ships with.
19
+
20
+ - The accent is ultramarine, at 231°. That is deliberate and functional: the
21
+ interface is full of status — ok, warn, crit — and those own green, amber
22
+ and red. An accent anywhere near them turns "this is a link" into "this is
23
+ a problem". Ultramarine is 76° from ok, 169° from warn and 132° from crit,
24
+ so an accented thing never reads as a state. The previous palette's
25
+ vermilion sat beside its own crit red, which is exactly the trap.
26
+
27
+ - Every pairing the interface actually makes was measured, not eyeballed:
28
+ body text ≥ 14:1, secondary text ≥ 5.7:1, an accent used as a link ≥ 7.4:1,
29
+ a button label on the accent ≥ 7.4:1, and each status pill against its own
30
+ tinted background ≥ 4.7:1. The faint tier (timestamps, placeholders) is
31
+ ≥ 3:1 and is never the only place something is said.
32
+
33
+ The one serif is the page title. Nothing else is set in it — it marks the
34
+ top of a document and then gets out of the way. */
35
+
36
+ :root{
37
+ /* Ground and surfaces. bg is the page, surface is a card, surface2 is a
38
+ recessed area inside one (a toolbar, a code block, a table header). */
39
+ --bg:#FBFAF8; --surface:#FFFFFF; --surface2:#F2F0EB;
40
+
41
+ /* Text, in three tiers. ink says it, muted explains it, faint timestamps it. */
42
+ --ink:#1A1916; --muted:#67635B; --faint:#98938A;
43
+
44
+ /* line separates, line-strong encloses (an input, a control you can click). */
45
+ --line:#E8E4DC; --line-strong:#D6D0C5;
46
+
47
+ /* The accent: one colour, for the one thing on a screen that acts.
48
+ accent-soft is the same colour at 7%, for a pill or a selected row.
49
+ accent-ink is what is legible on top of it. */
50
+ --accent:#2B44CE; --accent-soft:#2B44CE12; --accent-ink:#FFFFFF;
51
+
52
+ /* State. Never the accent, never each other — see the note above. */
53
+ --ok:#16794F; --ok-soft:#16794F16;
54
+ --warn:#8A6112; --warn-soft:#8A611218;
55
+ --crit:#B3362F; --crit-soft:#B3362F16;
56
+
57
+ --chat-bg:#F5F3EF;
58
+ --shadow:0 8px 28px rgba(26,25,22,.10);
59
+
60
+ --sans:'Instrument Sans',system-ui,-apple-system,'Segoe UI',sans-serif;
61
+ --mono:'IBM Plex Mono',ui-monospace,'SF Mono',Menlo,monospace;
62
+ --display:'Instrument Serif',Georgia,'Times New Roman',serif;
63
+ }
64
+
65
+ /* Dark is the same palette rotated down: the paper becomes a warm charcoal and
66
+ the ultramarine comes up to meet it. Not black — a dense table on pure black
67
+ vibrates, and the borders that hold it together disappear. */
68
+ @media (prefers-color-scheme: dark){
69
+ :root:not([data-theme="light"]){
70
+ --bg:#1A1815; --surface:#221F1B; --surface2:#2B2823;
71
+ --ink:#EDE9E1; --muted:#A5A096; --faint:#7B766D;
72
+ --line:#322E28; --line-strong:#453F37;
73
+ --accent:#A9B5FF; --accent-soft:#A9B5FF20; --accent-ink:#151538;
74
+ --ok:#4FC28C; --ok-soft:#4FC28C22;
75
+ --warn:#DFB255; --warn-soft:#DFB25520;
76
+ --crit:#F0736B; --crit-soft:#F0736B1F;
77
+ --chat-bg:#1D1B18;
78
+ --shadow:0 8px 28px rgba(0,0,0,.5);
79
+ }
80
+ }
81
+
82
+ /* The same values again, so an explicit choice wins in both directions: a
83
+ forced dark on a light system, and a forced light on a dark one. */
84
+ :root[data-theme="dark"]{
85
+ --bg:#1A1815; --surface:#221F1B; --surface2:#2B2823;
86
+ --ink:#EDE9E1; --muted:#A5A096; --faint:#7B766D;
87
+ --line:#322E28; --line-strong:#453F37;
88
+ --accent:#A9B5FF; --accent-soft:#A9B5FF20; --accent-ink:#151538;
89
+ --ok:#4FC28C; --ok-soft:#4FC28C22;
90
+ --warn:#DFB255; --warn-soft:#DFB25520;
91
+ --crit:#F0736B; --crit-soft:#F0736B1F;
92
+ --chat-bg:#1D1B18;
93
+ --shadow:0 8px 28px rgba(0,0,0,.5);
94
+ }
95
+
96
+ /* shadcn/ui names for the same colours, so a component copied in from there
97
+ inherits this palette instead of bringing its own. These are raw colour
98
+ values, which is the form shadcn uses with Tailwind v4 (`bg-background`);
99
+ the older `hsl(var(--background))` form expects bare HSL triplets and will
100
+ not read these. */
101
+ :root{
102
+ --background:var(--bg); --foreground:var(--ink);
103
+ --card:var(--surface); --card-foreground:var(--ink);
104
+ --popover:var(--surface); --popover-foreground:var(--ink);
105
+ --primary:var(--accent); --primary-foreground:var(--accent-ink);
106
+ --secondary:var(--surface2); --secondary-foreground:var(--ink);
107
+ --muted-foreground:var(--muted);
108
+ --destructive:var(--crit); --destructive-foreground:var(--accent-ink);
109
+ --border:var(--line); --input:var(--line-strong);
110
+ --ring:var(--accent); --radius:10px;
111
+ }
112
+
113
+ /* Reset and text primitives. */
114
+ *{box-sizing:border-box}
115
+ html,body{margin:0;padding:0}
116
+ body{
117
+ background:var(--bg); color:var(--ink);
118
+ font-family:var(--sans); font-size:14px; line-height:1.5;
119
+ -webkit-font-smoothing:antialiased;
120
+ }
121
+ button{font:inherit;color:inherit;background:none;border:none;cursor:pointer;padding:0}
122
+ a{color:inherit;text-decoration:none}
123
+ button:focus-visible,a:focus-visible,input:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:4px}
124
+ .mono{font-family:var(--mono);font-size:.92em}
125
+ .num{font-variant-numeric:tabular-nums}
126
+
127
+ /* Controls carry the surface, not the browser's white. A bare <select> in a
128
+ table cell is a real case — the wrapper classes in primitives.css size and
129
+ stretch these, but an unwrapped control must already look right, in both
130
+ themes. */
131
+ input,select,textarea{font:inherit;color:var(--ink)}
132
+ select,textarea,
133
+ input:not([type=checkbox]):not([type=radio]):not([type=range]):not([type=file]){
134
+ background:var(--surface);border:1px solid var(--line-strong);border-radius:8px;padding:6px 10px;
135
+ }
136
+ select{padding-right:6px}
137
+ input::placeholder,textarea::placeholder{color:var(--faint)}
138
+ input:disabled,select:disabled,textarea:disabled{opacity:.6;cursor:not-allowed}
139
+
140
+ /* Someone who asked for less motion gets none. */
141
+ @media (prefers-reduced-motion: reduce){
142
+ *{animation:none!important;transition:none!important}
143
+ }
@@ -0,0 +1,70 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * A small fetch helper: loading and error state, optional periodic refresh.
5
+ * A cache/mutation library is worth adding only when a screen needs more.
6
+ *
7
+ * `enabled` exists for a question that should not be asked yet — a count for a
8
+ * service this realm may not have, a list that needs an organization first.
9
+ */
10
+
11
+ import { useCallback, useEffect, useRef, useState } from 'react'
12
+
13
+ export type Resource<T> = {
14
+ data: T | undefined
15
+ error: string | undefined
16
+ loading: boolean
17
+ reload: () => void
18
+ }
19
+
20
+ export type ResourceOptions = {
21
+ refreshMs?: number
22
+ /** Re-runs when this changes — an id the fetcher closes over, usually. */
23
+ key?: string
24
+ enabled?: boolean
25
+ /** Shown when the failure carries no message of its own. */
26
+ fallbackError?: string
27
+ }
28
+
29
+ export function useResource<T>(fetcher: () => Promise<T>, options: ResourceOptions = {}): Resource<T> {
30
+ const [data, setData] = useState<T>()
31
+ const [error, setError] = useState<string>()
32
+ const [loading, setLoading] = useState(true)
33
+ const [nonce, setNonce] = useState(0)
34
+ // Held in a ref so a caller may write the fetcher inline without the effect
35
+ // re-running on every render.
36
+ const fetcherRef = useRef(fetcher)
37
+ fetcherRef.current = fetcher
38
+ const enabled = options.enabled ?? true
39
+ const fallback = options.fallbackError ?? 'That did not answer'
40
+
41
+ const reload = useCallback(() => setNonce((n) => n + 1), [])
42
+
43
+ useEffect(() => {
44
+ if (!enabled) return
45
+ let cancelled = false
46
+
47
+ const run = async () => {
48
+ try {
49
+ const result = await fetcherRef.current()
50
+ if (cancelled) return
51
+ setData(result)
52
+ setError(undefined)
53
+ } catch (err) {
54
+ if (cancelled) return
55
+ setError(err instanceof Error && err.message ? err.message : fallback)
56
+ } finally {
57
+ if (!cancelled) setLoading(false)
58
+ }
59
+ }
60
+
61
+ run()
62
+ const timer = options.refreshMs ? setInterval(run, options.refreshMs) : undefined
63
+ return () => {
64
+ cancelled = true
65
+ if (timer) clearInterval(timer)
66
+ }
67
+ }, [nonce, options.refreshMs, options.key, enabled, fallback])
68
+
69
+ return { data, error, loading, reload }
70
+ }