@arcforgelabs/flow 2026.10.19 → 2026.10.20-beta.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.
@@ -0,0 +1,129 @@
1
+ // Appearance in the browser: which design system (theme family), light or
2
+ // dark, and an optional accent colour. The families and their tokens live in
3
+ // server/theme/themes.js. The choice is per browser and is a convenience, so
4
+ // it lives in localStorage. server/ui/page.js runs the same family and mode
5
+ // resolution inline before first paint so there is no flash; keep the two in
6
+ // step. Ported from Arc Forge Labs' Field (client/src/lib/color-mode.ts).
7
+ import { accentOverrides, normalizeAccent, normalizeFamily, tokensFor } from '/assets/theme/themes.js';
8
+ import { fontStylesheets } from '/assets/theme/font-families.js';
9
+
10
+ const KEY = 'flow_appearance';
11
+ const root = document.documentElement;
12
+
13
+ /** The installation's default family (FLOW_THEME), as the page was served. */
14
+ export const defaultFamily = normalizeFamily(root.getAttribute('data-default-theme'));
15
+
16
+ function readSettings() {
17
+ try {
18
+ const settings = JSON.parse(localStorage.getItem(KEY) || '{}');
19
+ return settings && typeof settings === 'object' ? settings : {};
20
+ } catch {
21
+ return {};
22
+ }
23
+ }
24
+
25
+ function writeSettings(update) {
26
+ try {
27
+ const settings = readSettings();
28
+ update(settings);
29
+ localStorage.setItem(KEY, JSON.stringify(settings));
30
+ } catch { /* storage blocked: the choice lasts for this page only */ }
31
+ }
32
+
33
+ function systemPrefersDark() {
34
+ return typeof window.matchMedia === 'function' && window.matchMedia('(prefers-color-scheme: dark)').matches;
35
+ }
36
+
37
+ function readState() {
38
+ const settings = readSettings();
39
+ const colorMode = ['light', 'dark', 'system'].includes(settings.colorMode) ? settings.colorMode : 'system';
40
+ const accent = typeof settings.accent === 'string' && normalizeAccent(settings.accent) ? settings.accent : null;
41
+ return { colorMode, family: normalizeFamily(settings.themeFamily, defaultFamily), accent };
42
+ }
43
+
44
+ let state = readState();
45
+ const listeners = new Set();
46
+
47
+ export function resolveMode(colorMode = state.colorMode) {
48
+ if (colorMode === 'light' || colorMode === 'dark') return colorMode;
49
+ return systemPrefersDark() ? 'dark' : 'light';
50
+ }
51
+
52
+ /** { colorMode, family, accent, mode } — mode is what is showing. */
53
+ export function getAppearance() {
54
+ return { ...state, mode: resolveMode() };
55
+ }
56
+
57
+ const loadedFonts = new Set([...document.querySelectorAll('link[data-font]')].map((link) => link.getAttribute('href')));
58
+
59
+ /** Load a family's faces once. A failed font leaves the stack's system fallback painting the text. */
60
+ export function loadFonts(family) {
61
+ for (const href of fontStylesheets(family)) {
62
+ if (loadedFonts.has(href)) continue;
63
+ loadedFonts.add(href);
64
+ const link = document.createElement('link');
65
+ link.rel = 'stylesheet';
66
+ link.href = href;
67
+ link.setAttribute('data-font', '');
68
+ document.head.append(link);
69
+ }
70
+ }
71
+
72
+ let appliedAccentKeys = [];
73
+
74
+ function apply() {
75
+ const { family, accent } = state;
76
+ const mode = resolveMode();
77
+ loadFonts(family);
78
+ root.setAttribute('data-theme', family);
79
+ root.setAttribute('data-mode', mode);
80
+ for (const key of appliedAccentKeys) root.style.removeProperty(key);
81
+ const overrides = accentOverrides(family, mode, accent);
82
+ for (const [key, value] of Object.entries(overrides)) root.style.setProperty(key, value);
83
+ appliedAccentKeys = Object.keys(overrides);
84
+ if (appliedAccentKeys.length) root.setAttribute('data-accent', String(accent));
85
+ else root.removeAttribute('data-accent');
86
+ let meta = document.querySelector('meta[name="theme-color"]');
87
+ if (!meta) {
88
+ meta = document.createElement('meta');
89
+ meta.name = 'theme-color';
90
+ document.head.append(meta);
91
+ }
92
+ meta.content = tokensFor(family, mode)['--t-bg'];
93
+ for (const listener of listeners) listener(getAppearance());
94
+ }
95
+
96
+ /** Change any of colorMode, family and accent (null keeps the family's own). */
97
+ export function setAppearance(update) {
98
+ state = { ...state, ...update };
99
+ state.family = normalizeFamily(state.family, defaultFamily);
100
+ state.accent = state.accent && normalizeAccent(state.accent) ? state.accent : null;
101
+ writeSettings((settings) => {
102
+ settings.colorMode = state.colorMode;
103
+ if (state.family === defaultFamily) delete settings.themeFamily;
104
+ else settings.themeFamily = state.family;
105
+ if (state.accent) settings.accent = state.accent;
106
+ else delete settings.accent;
107
+ });
108
+ apply();
109
+ }
110
+
111
+ /** Called after every change of appearance, with getAppearance(). Returns an unsubscribe. */
112
+ export function onAppearanceChange(listener) {
113
+ listeners.add(listener);
114
+ return () => listeners.delete(listener);
115
+ }
116
+
117
+ if (typeof window.matchMedia === 'function') {
118
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener?.('change', () => {
119
+ if (state.colorMode === 'system') apply();
120
+ });
121
+ }
122
+ // Another tab changed the appearance.
123
+ window.addEventListener('storage', (event) => {
124
+ if (event.key !== KEY) return;
125
+ state = readState();
126
+ apply();
127
+ });
128
+
129
+ apply();
@@ -0,0 +1,76 @@
1
+ // Clerk's components (sign-in, the account button and the account page) in
2
+ // Flow's active design system: colours, type and corners are read from the
3
+ // --t-* tokens at render time, so Clerk changes with the theme and mode.
4
+ // Ported from Arc Forge Labs' Field (client/src/lib/clerk-appearance.ts).
5
+
6
+ function token(name, fallback) {
7
+ const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
8
+ return value || fallback;
9
+ }
10
+
11
+ /**
12
+ * `embedded` is the account page, where Clerk's profile sits inside Flow's
13
+ * page; otherwise Clerk's card stands on its own (sign-in) or pops over
14
+ * (the account button).
15
+ */
16
+ export function flowClerkAppearance({ embedded = false, publicSignUp = false } = {}) {
17
+ const text = token('--t-text', '#333c45');
18
+ const line = `color-mix(in srgb, ${text} 12%, transparent)`;
19
+ const shadow = token('--t-shadow', '0 1px 2px rgba(16, 24, 40, .06)');
20
+ // Clerk draws an input's and an outlined button's edge as a 1px box-shadow
21
+ // ring, so a box-shadow here replaces it. Draw it in --t-control (3:1 on
22
+ // the paper) and keep the hover and focus states with it. Clerk sets its
23
+ // own ring on `[data-variant]`, which outranks a plain class, so each rule
24
+ // doubles the class (`&&`) to win.
25
+ const edge = (color) => `0 0 0 1px ${color}`;
26
+ const control = token('--t-control', '#949494');
27
+ const focus = `0 0 0 3px color-mix(in srgb, ${token('--t-ring', '#1f6f8f')} 35%, transparent)`;
28
+ const controlEdge = {
29
+ '&&': { boxShadow: edge(control) },
30
+ '&&:hover': { boxShadow: edge(text) },
31
+ '&&:focus-visible, &&:focus-within, &&[data-focus-within="true"]': { boxShadow: `${edge(text)}, ${focus}` },
32
+ };
33
+ const danger = token('--t-danger', '#b8442a');
34
+ const inputEdge = { ...controlEdge, '&&[data-feedback="error"]': { boxShadow: edge(danger) } };
35
+ const display = token('--t-display', 'ui-serif, Georgia, serif');
36
+ return {
37
+ variables: {
38
+ colorPrimary: token('--t-primary', '#1f6f8f'),
39
+ colorPrimaryForeground: token('--t-on-primary', '#ffffff'),
40
+ colorBackground: token('--t-paper', '#ffffff'),
41
+ colorForeground: text,
42
+ colorMutedForeground: token('--t-dim', '#5b6773'),
43
+ colorMuted: token('--t-s1', '#f7f9fb'),
44
+ colorInput: token('--t-paper', '#ffffff'),
45
+ colorInputForeground: text,
46
+ colorDanger: danger,
47
+ colorSuccess: token('--t-success', '#3f6b2a'),
48
+ colorWarning: token('--t-warn', '#8a5a00'),
49
+ colorNeutral: text,
50
+ colorBorder: line,
51
+ colorRing: token('--t-ring', '#1f6f8f'),
52
+ fontFamily: token('--t-body', 'ui-sans-serif, system-ui, sans-serif'),
53
+ fontSize: '14px',
54
+ borderRadius: token('--t-radius', '6px'),
55
+ },
56
+ elements: {
57
+ // With public sign-up off there is no sign-up link: an installation is
58
+ // for one business's people, and an owner invites them.
59
+ footerAction: embedded || publicSignUp ? undefined : { display: 'none' },
60
+ headerTitle: { fontFamily: display, fontWeight: token('--t-display-weight', '700'), letterSpacing: '-0.01em' },
61
+ card: { boxShadow: embedded ? 'none' : shadow, border: `1px solid ${line}`, borderRadius: token('--t-radius-lg', '10px') },
62
+ cardBox: embedded
63
+ ? { width: '100%', maxWidth: 'none', boxShadow: 'none', border: `1px solid ${line}`, borderRadius: token('--t-radius-lg', '10px') }
64
+ : { boxShadow: shadow, borderRadius: token('--t-radius-lg', '10px') },
65
+ rootBox: embedded ? { width: '100%' } : undefined,
66
+ navbar: embedded ? { background: token('--t-s1', '#f7f9fb'), borderRight: `1px solid ${line}` } : undefined,
67
+ scrollBox: embedded ? { borderRadius: 0 } : undefined,
68
+ formFieldInput: inputEdge,
69
+ otpCodeFieldInput: inputEdge,
70
+ formButtonPrimary: { boxShadow: 'none', textTransform: 'none', fontWeight: 650 },
71
+ socialButtonsBlockButton: controlEdge,
72
+ userButtonPopoverCard: { border: `1px solid ${line}`, boxShadow: shadow },
73
+ footer: embedded ? { display: 'none' } : undefined,
74
+ },
75
+ };
76
+ }
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="8" fill="#0d4e5f"/><path d="M7 13.5c3-3 6-3 9 0s6 3 9 0M7 20.5c3-3 6-3 9 0s6 3 9 0" fill="none" stroke="#fefffe" stroke-width="2.4" stroke-linecap="round"/></svg>
@@ -0,0 +1,314 @@
1
+ /* Flow's components, prefixed fx-. Every colour, face, corner and stroke is a
2
+ --t-* token from the active design system (/assets/themes.css, made by
3
+ server/theme/themes.js), so every family reaches every screen. Never
4
+ hard-code a colour here (docs/DESIGN.md). */
5
+
6
+ *, *::before, *::after { box-sizing: border-box; }
7
+
8
+ :root {
9
+ --fx-line: color-mix(in srgb, var(--t-text) 12%, transparent);
10
+ --fx-line-soft: color-mix(in srgb, var(--t-text) 7%, transparent);
11
+ --fx-hover: color-mix(in srgb, var(--t-text) 5%, transparent);
12
+ --fx-focus: 0 0 0 3px color-mix(in srgb, var(--t-ring) 40%, transparent);
13
+ --fx-ease: cubic-bezier(.2, .6, .3, 1);
14
+ }
15
+
16
+ html { background: var(--t-bg); color-scheme: inherit; }
17
+ body {
18
+ margin: 0;
19
+ min-height: 100vh;
20
+ background: var(--t-bg);
21
+ color: var(--t-text);
22
+ font: 15px/1.5 var(--t-body);
23
+ font-variant-numeric: tabular-nums;
24
+ -webkit-font-smoothing: antialiased;
25
+ }
26
+ a { color: var(--t-accent); }
27
+ h1, h2, h3, p { margin: 0; }
28
+ [hidden] { display: none !important; }
29
+ :focus-visible { outline: none; box-shadow: var(--fx-focus); }
30
+ .fx-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
31
+ .fx-dim { color: var(--t-dim); font-size: 13px; }
32
+
33
+ /* Which part of the page shows: <body data-state> (server/ui/app-page.js). */
34
+ [class*="fx-state-"] { display: none; }
35
+ body[data-state="loading"] .fx-state-loading,
36
+ body[data-state="signed-out"] .fx-state-signed-out,
37
+ body[data-state="signed-in"] .fx-state-signed-in,
38
+ body[data-state="off"] .fx-state-message, body[data-state="off"] .fx-state-off,
39
+ body[data-state="error"] .fx-state-message, body[data-state="error"] .fx-state-error,
40
+ body[data-state="no-access"] .fx-state-message, body[data-state="no-access"] .fx-state-no-access { display: revert; }
41
+ body[data-state="signed-out"] .fx-state-signed-out { display: grid; }
42
+ body[data-state="signed-in"] .fx-state-signed-in { display: flex; }
43
+ body:is([data-state="off"], [data-state="error"], [data-state="no-access"]) .fx-state-message { display: grid; }
44
+
45
+ /* ── Brand ──────────────────────────────────────────────────────────────── */
46
+ .fx-brand {
47
+ display: inline-flex; align-items: center; gap: 10px;
48
+ color: var(--t-strong); text-decoration: none;
49
+ font: var(--t-display-weight) 20px/1 var(--t-display); letter-spacing: -0.01em;
50
+ }
51
+ .fx-mark { width: 28px; height: 28px; flex: none; }
52
+ .fx-brand--large { font-size: 30px; }
53
+ .fx-brand--large .fx-mark { width: 40px; height: 40px; }
54
+
55
+ /* ── Buttons ────────────────────────────────────────────────────────────── */
56
+ .fx-btn {
57
+ display: inline-flex; align-items: center; justify-content: center; gap: 6px;
58
+ min-height: 36px; padding: 0 14px;
59
+ border: 1px solid var(--t-control); border-radius: var(--t-radius);
60
+ background: var(--t-paper); color: var(--t-strong);
61
+ font: 600 14px/1 var(--t-body); text-decoration: none; white-space: nowrap;
62
+ cursor: pointer; box-shadow: 0 1px 1px var(--fx-line-soft);
63
+ transition: background-color 120ms var(--fx-ease), border-color 120ms var(--fx-ease), color 120ms var(--fx-ease);
64
+ }
65
+ .fx-btn:hover { background: var(--t-s1); border-color: var(--t-text); }
66
+ .fx-btn:active { transform: translateY(1px); }
67
+ .fx-btn:disabled { opacity: .45; cursor: not-allowed; transform: none; }
68
+ .fx-btn--primary { background: var(--t-primary); border-color: var(--t-primary); color: var(--t-on-primary); }
69
+ .fx-btn--primary:hover { background: color-mix(in srgb, var(--t-primary) 86%, var(--t-text)); border-color: transparent; }
70
+ .fx-btn--ghost { background: transparent; border-color: transparent; box-shadow: none; color: var(--t-text); }
71
+ .fx-btn--ghost:hover { background: var(--fx-hover); border-color: transparent; }
72
+ .fx-btn--danger { color: var(--t-danger); }
73
+ .fx-btn--danger:not(.fx-btn--ghost) { background: var(--t-danger); border-color: var(--t-danger); color: var(--t-paper); }
74
+ .fx-btn--danger.fx-btn--ghost:hover { background: var(--t-danger-bg); }
75
+ .fx-btn--sm { min-height: 30px; padding: 0 10px; font-size: 13px; }
76
+ .fx-btn--icon { width: 36px; padding: 0; }
77
+
78
+ /* ── Fields ─────────────────────────────────────────────────────────────── */
79
+ .fx-fields { display: grid; gap: 14px; }
80
+ .fx-field { display: grid; gap: 6px; }
81
+ .fx-label {
82
+ color: var(--t-dim);
83
+ font: var(--t-label-weight) var(--t-label-size)/1.3 var(--t-body);
84
+ letter-spacing: var(--t-label-spacing); text-transform: var(--t-label-case);
85
+ }
86
+ .fx-input, .fx-select {
87
+ min-height: 36px; width: 100%; padding: 0 10px;
88
+ border: 1px solid var(--t-control); border-radius: var(--t-radius);
89
+ background: var(--t-paper); color: var(--t-text); font: 14px var(--t-body);
90
+ }
91
+ .fx-input:hover, .fx-select:hover { border-color: var(--t-text); }
92
+ .fx-input:focus, .fx-select:focus { outline: none; border-color: var(--t-text); box-shadow: var(--fx-focus); }
93
+ .fx-select {
94
+ width: auto; min-width: 112px; padding-right: 30px; appearance: none; cursor: pointer;
95
+ background-image: linear-gradient(45deg, transparent 50%, var(--t-dim) 50%), linear-gradient(135deg, var(--t-dim) 50%, transparent 50%);
96
+ background-position: calc(100% - 15px) 52%, calc(100% - 10px) 52%;
97
+ background-size: 5px 5px; background-repeat: no-repeat;
98
+ }
99
+
100
+ /* ── Layout ─────────────────────────────────────────────────────────────── */
101
+ .fx-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
102
+ .fx-row--end { justify-content: flex-end; }
103
+ .fx-card, .fx-panel {
104
+ background: var(--t-paper); border: 1px solid var(--fx-line);
105
+ border-radius: var(--t-radius-lg); box-shadow: var(--t-shadow);
106
+ }
107
+ .fx-panel { overflow: hidden; }
108
+
109
+ .fx-shell { flex-direction: column; min-height: 100vh; }
110
+ .fx-top {
111
+ position: sticky; top: 0; z-index: 10;
112
+ display: flex; align-items: center; gap: 24px;
113
+ height: 60px; padding: 0 max(16px, calc((100vw - 1120px) / 2));
114
+ background: color-mix(in srgb, var(--t-bg) 92%, transparent);
115
+ border-bottom: 1px solid var(--fx-line);
116
+ backdrop-filter: saturate(1.2) blur(8px);
117
+ }
118
+ .fx-nav { display: flex; gap: 4px; flex: 1; overflow-x: auto; }
119
+ .fx-nav a {
120
+ padding: 7px 12px; border-radius: var(--t-radius);
121
+ color: var(--t-dim); font-weight: 600; font-size: 14px; text-decoration: none; white-space: nowrap;
122
+ }
123
+ .fx-nav a:hover { color: var(--t-strong); background: var(--fx-hover); }
124
+ .fx-nav a[aria-current="page"] { color: var(--t-strong); background: var(--t-accent-bg); box-shadow: inset 0 0 0 1px var(--t-accent-brd); }
125
+ .fx-top__end { display: flex; align-items: center; gap: 6px; }
126
+ .fx-top__user { display: flex; min-width: 32px; min-height: 32px; align-items: center; }
127
+ .fx-main { width: 100%; max-width: 1120px; margin: 0 auto; padding: 32px 16px 64px; display: grid; gap: 20px; align-content: start; }
128
+ .fx-main:focus { outline: none; box-shadow: none; }
129
+
130
+ /* Page header: title and one line, then the actions, primary last. */
131
+ .fx-ph { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 16px; }
132
+ .fx-ph__text { display: grid; gap: 6px; max-width: 64ch; }
133
+ .fx-title {
134
+ color: var(--t-strong);
135
+ font: var(--t-display-weight) 28px/1.15 var(--t-display); letter-spacing: -0.015em;
136
+ }
137
+ .fx-ph__sub { color: var(--t-dim); }
138
+ .fx-ph__actions { display: flex; gap: 8px; }
139
+
140
+ /* ── Notices, pills, tags, toasts ──────────────────────────────────────── */
141
+ .fx-notice {
142
+ display: flex; flex-wrap: wrap; gap: 6px; padding: 12px 14px;
143
+ border: 1px solid var(--t-accent-brd); border-radius: var(--t-radius);
144
+ background: var(--t-accent-bg); color: var(--t-text); font-size: 14px;
145
+ }
146
+ .fx-notice[data-tone="warn"] { border-color: var(--t-warn-brd); background: var(--t-warn-bg); }
147
+ .fx-notice[data-tone="danger"] { border-color: var(--t-danger-brd); background: var(--t-danger-bg); color: var(--t-danger); }
148
+ .fx-panel > .fx-notice { margin: 12px; }
149
+
150
+ .fx-status {
151
+ display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px;
152
+ border-radius: 999px; font-size: 12.5px; font-weight: 600; white-space: nowrap;
153
+ color: var(--t-accent); background: var(--t-accent-bg);
154
+ }
155
+ .fx-status::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
156
+ .fx-status[data-tone="success"] { color: var(--t-success); background: var(--t-success-bg); }
157
+ .fx-status[data-tone="warn"] { color: var(--t-warn); background: var(--t-warn-bg); }
158
+ .fx-status[data-tone="mute"] { color: var(--t-dim); background: var(--fx-hover); }
159
+ .fx-tag {
160
+ margin-left: 8px; padding: 1px 7px; border: 1px solid var(--fx-line); border-radius: 999px;
161
+ color: var(--t-dim); font-size: 11.5px; font-weight: 600;
162
+ }
163
+
164
+ .fx-toasts { position: fixed; z-index: 30; right: 16px; bottom: 16px; display: grid; gap: 8px; max-width: min(420px, calc(100vw - 32px)); }
165
+ .fx-toast {
166
+ padding: 12px 16px; border-radius: var(--t-radius);
167
+ background: var(--t-strong); color: var(--t-bg); font-size: 14px; font-weight: 600;
168
+ box-shadow: 0 8px 24px rgba(0, 0, 0, .18); animation: fx-rise 160ms var(--fx-ease);
169
+ }
170
+ .fx-toast[data-tone="danger"] { background: var(--t-danger); color: var(--t-paper); }
171
+
172
+ /* ── Table and people ───────────────────────────────────────────────────── */
173
+ .fx-table { width: 100%; border-collapse: collapse; font-size: 14px; }
174
+ .fx-table th {
175
+ padding: 10px 16px; text-align: left; background: var(--t-s1); border-bottom: 1px solid var(--fx-line);
176
+ color: var(--t-dim); font: var(--t-label-weight) var(--t-label-size)/1.3 var(--t-body);
177
+ letter-spacing: var(--t-label-spacing); text-transform: var(--t-label-case);
178
+ }
179
+ .fx-table td { padding: 12px 16px; border-bottom: 1px solid var(--fx-line-soft); vertical-align: middle; }
180
+ .fx-table tbody tr:last-child td { border-bottom: 0; }
181
+ .fx-table tbody tr:hover td { background: var(--fx-hover); }
182
+ .fx-table__actions { text-align: right; width: 1%; white-space: nowrap; }
183
+ .fx-table__actions .fx-row { flex-wrap: nowrap; }
184
+ .fx-person { display: flex; align-items: center; gap: 12px; min-width: 0; }
185
+ .fx-person__text { display: grid; min-width: 0; }
186
+ .fx-person__name { color: var(--t-strong); font-weight: 600; overflow-wrap: anywhere; }
187
+ .fx-person__email { color: var(--t-dim); font-size: 13px; overflow-wrap: anywhere; }
188
+ .fx-avatar {
189
+ display: grid; place-items: center; flex: none; width: 34px; height: 34px; border-radius: 50%;
190
+ background: var(--t-accent-bg); color: var(--t-accent); box-shadow: inset 0 0 0 1px var(--t-accent-brd);
191
+ font-size: 12.5px; font-weight: 700; letter-spacing: .02em;
192
+ }
193
+ .fx-empty { padding: 28px 16px; color: var(--t-dim); text-align: center; }
194
+ .fx-roles { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; margin: 0; font-size: 13.5px; color: var(--t-dim); }
195
+ .fx-roles dt { color: var(--t-strong); font-weight: 600; }
196
+ .fx-roles dd { margin: 0; }
197
+
198
+ /* ── Dialog ─────────────────────────────────────────────────────────────── */
199
+ .fx-dialog {
200
+ width: min(440px, calc(100vw - 32px)); padding: 0;
201
+ border: 1px solid var(--fx-line); border-radius: var(--t-radius-lg);
202
+ background: var(--t-paper); color: var(--t-text);
203
+ box-shadow: 0 24px 64px rgba(0, 0, 0, .24); animation: fx-rise 140ms var(--fx-ease);
204
+ }
205
+ .fx-dialog::backdrop { background: color-mix(in srgb, var(--t-strong) 35%, transparent); }
206
+ .fx-dialog__form { display: grid; gap: 16px; padding: 22px; }
207
+ .fx-dialog__title { color: var(--t-strong); font: var(--t-display-weight) 21px/1.2 var(--t-display); }
208
+ .fx-dialog__actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; }
209
+
210
+ /* ── Sign-in ────────────────────────────────────────────────────────────── */
211
+ .fx-auth { min-height: 100vh; grid-template-columns: minmax(0, 5fr) minmax(0, 6fr); }
212
+ .fx-auth__brand {
213
+ display: flex; flex-direction: column; justify-content: center; gap: 28px;
214
+ padding: 48px clamp(24px, 6vw, 88px);
215
+ background: var(--t-primary); color: var(--t-on-primary);
216
+ }
217
+ .fx-auth__brand .fx-brand { color: inherit; }
218
+ /* In dark mode a filled panel in the primary (often a light tint) glares, so
219
+ the panel is a raised surface with the brand in the accent instead. */
220
+ [data-mode="dark"] .fx-auth__brand { background: var(--t-s1); color: var(--t-strong); border-right: 1px solid var(--fx-line); }
221
+ [data-mode="dark"] .fx-auth__brand .fx-auth__title { color: var(--t-accent); }
222
+ [data-mode="light"] .fx-auth__brand .fx-mark rect { fill: var(--t-on-primary); }
223
+ [data-mode="light"] .fx-auth__brand .fx-mark path { stroke: var(--t-primary); }
224
+ .fx-auth__title { max-width: 16ch; font: var(--t-display-weight) clamp(30px, 3.6vw, 46px)/1.08 var(--t-display); letter-spacing: -0.02em; }
225
+ .fx-auth__points { display: grid; gap: 12px; margin: 0; padding: 0; list-style: none; font-size: 16px; opacity: .88; }
226
+ .fx-auth__points li { display: flex; gap: 12px; align-items: baseline; }
227
+ .fx-auth__points li::before { content: ""; flex: none; width: 18px; height: 2px; border-radius: 2px; background: currentColor; transform: translateY(-4px); opacity: .7; }
228
+ .fx-auth__form { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 16px; padding: 40px 16px; }
229
+ .fx-auth__note { color: var(--t-dim); text-align: center; }
230
+ .fx-auth__widget { display: flex; justify-content: center; min-height: 360px; width: 100%; }
231
+ @media (max-width: 820px) {
232
+ .fx-auth { grid-template-columns: 1fr; }
233
+ .fx-auth__brand { gap: 16px; padding: 28px 16px; }
234
+ .fx-auth__points { display: none; }
235
+ .fx-auth__title { font-size: 26px; max-width: none; }
236
+ }
237
+
238
+ /* A one-message page (sign-in off, an error, no access, Xero's return). */
239
+ .fx-message, .fx-centre .fx-message { min-height: 100vh; place-content: center; justify-items: center; gap: 20px; padding: 32px 16px; }
240
+ .fx-centre .fx-message { display: grid; }
241
+ .fx-message__card { display: grid; gap: 12px; width: min(460px, 100%); padding: 28px; }
242
+ .fx-message__card[data-tone="success"] { border-top: 3px solid var(--t-success); }
243
+ .fx-message__card[data-tone="danger"] { border-top: 3px solid var(--t-danger); }
244
+ .fx-message__card .fx-btn { justify-self: start; }
245
+
246
+ .fx-loading { display: none; min-height: 100vh; place-content: center; gap: 12px; color: var(--t-dim); }
247
+ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
248
+ .fx-spinner { width: 22px; height: 22px; border: 2px solid var(--fx-line); border-top-color: var(--t-brand); border-radius: 50%; animation: fx-spin 800ms linear infinite; }
249
+ .fx-skeleton { height: 160px; background: linear-gradient(90deg, var(--fx-hover), var(--fx-line-soft), var(--fx-hover)); background-size: 200% 100%; animation: fx-shimmer 1.2s linear infinite; }
250
+
251
+ /* Clerk's account page sits in Flow's column. */
252
+ .fx-clerk-profile { display: flex; }
253
+ .fx-clerk-profile > * { flex: 1; }
254
+
255
+ /* ── Appearance ─────────────────────────────────────────────────────────── */
256
+ .fx-appearance { display: grid; gap: 28px; }
257
+ .fx-group { display: grid; gap: 12px; }
258
+ .fx-seg { display: inline-flex; justify-self: start; padding: 3px; gap: 2px; border-radius: calc(var(--t-radius) + 3px); background: var(--t-s2); }
259
+ .fx-seg__item {
260
+ min-height: 32px; padding: 0 16px; border: 0; border-radius: var(--t-radius);
261
+ background: transparent; color: var(--t-dim); font: 600 14px var(--t-body); cursor: pointer;
262
+ }
263
+ .fx-seg__item[aria-checked="true"] { background: var(--t-paper); color: var(--t-strong); box-shadow: 0 1px 2px rgba(0, 0, 0, .12); }
264
+ .fx-theme-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; }
265
+ .fx-theme-card {
266
+ display: grid; gap: 4px; padding: 8px 8px 10px; text-align: left;
267
+ border: 1px solid var(--fx-line); border-radius: var(--t-radius-lg);
268
+ background: var(--t-paper); color: var(--t-text); cursor: pointer;
269
+ transition: border-color 120ms var(--fx-ease), transform 120ms var(--fx-ease);
270
+ }
271
+ .fx-theme-card:hover { border-color: var(--t-brd-hi); transform: translateY(-1px); }
272
+ .fx-theme-card[aria-checked="true"] { border-color: var(--t-brand); box-shadow: 0 0 0 2px var(--t-brand); }
273
+ .fx-theme-card__preview { position: relative; display: block; height: 72px; margin-bottom: 4px; overflow: hidden; border: 1px solid; }
274
+ .fx-theme-card__bar { position: absolute; inset: 0 0 auto; height: 16px; }
275
+ .fx-theme-card__type { position: absolute; left: 10px; bottom: 6px; font-size: 26px; line-height: 1; }
276
+ .fx-theme-card__chips { position: absolute; right: 10px; bottom: 10px; display: flex; gap: 4px; }
277
+ .fx-theme-card__chips span { width: 16px; height: 16px; }
278
+ .fx-theme-card__name { color: var(--t-strong); font-weight: 650; font-size: 14px; padding: 0 2px; }
279
+ .fx-theme-card__desc { color: var(--t-dim); font-size: 12.5px; padding: 0 2px; }
280
+ .fx-swatches { display: flex; flex-wrap: wrap; gap: 8px; }
281
+ .fx-swatch {
282
+ width: 30px; height: 30px; padding: 0; border: 0; border-radius: 50%; cursor: pointer;
283
+ box-shadow: inset 0 0 0 1px rgba(0, 0, 0, .15);
284
+ }
285
+ .fx-swatch[aria-checked="true"] { box-shadow: 0 0 0 2px var(--t-paper), 0 0 0 4px var(--t-text); }
286
+ .fx-swatch--default { overflow: hidden; background: var(--t-paper); }
287
+ .fx-swatch--default span { display: block; width: 100%; height: 100%; }
288
+ .fx-swatch-custom { display: inline-flex; align-items: center; gap: 8px; color: var(--t-dim); font-size: 13px; font-weight: 600; }
289
+ .fx-swatch-custom input { width: 34px; height: 30px; padding: 0; border: 1px solid var(--t-control); border-radius: var(--t-radius); background: var(--t-paper); cursor: pointer; }
290
+
291
+ /* ── Phones ─────────────────────────────────────────────────────────────── */
292
+ @media (max-width: 640px) {
293
+ .fx-top { gap: 12px; }
294
+ .fx-top .fx-brand span { display: none; }
295
+ .fx-nav a { padding: 7px 9px; }
296
+ .fx-main { padding-top: 20px; }
297
+ .fx-title { font-size: 24px; }
298
+ .fx-table thead { display: none; }
299
+ .fx-table, .fx-table tbody, .fx-table tr, .fx-table td { display: block; width: 100%; }
300
+ .fx-table tr { padding: 12px 14px; border-bottom: 1px solid var(--fx-line-soft); }
301
+ .fx-table td { padding: 4px 0; border: 0; }
302
+ .fx-table td:nth-child(2), .fx-table td:nth-child(3) { display: inline-flex; width: auto; margin: 6px 10px 0 0; vertical-align: middle; }
303
+ .fx-table tbody tr:hover td { background: none; }
304
+ .fx-table__actions { text-align: left; }
305
+ .fx-table__actions .fx-row--end { justify-content: flex-start; }
306
+ .fx-ph__actions, .fx-ph__actions .fx-btn { width: 100%; }
307
+ }
308
+
309
+ @keyframes fx-rise { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
310
+ @keyframes fx-spin { to { transform: rotate(360deg); } }
311
+ @keyframes fx-shimmer { to { background-position: -200% 0; } }
312
+ @media (prefers-reduced-motion: reduce) {
313
+ *, *::before, *::after { animation: none !important; transition: none !important; }
314
+ }
@@ -0,0 +1,90 @@
1
+ import { fontStylesheets } from '../theme/font-families.js';
2
+ import { THEME_FAMILIES } from '../theme/themes.js';
3
+
4
+ // Every Flow page starts here: the design system's tokens, Flow's components
5
+ // (flow.css) and a small script that puts the person's design system and
6
+ // light or dark on <html> before first paint, with that family's faces.
7
+ // server/ui/appearance.js reads and writes the same choice afterwards; keep
8
+ // the storage key and resolution in step (docs/DESIGN.md).
9
+
10
+ export const APPEARANCE_KEY = 'flow_appearance';
11
+
12
+ export function escapeHtml(value) {
13
+ return String(value ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
14
+ }
15
+
16
+ // Flow's mark: two waves on a rounded square, in the theme's primary colours.
17
+ export const FLOW_MARK = '<svg class="fx-mark" viewBox="0 0 32 32" aria-hidden="true" focusable="false">'
18
+ + '<rect width="32" height="32" rx="8" fill="var(--t-primary)"/>'
19
+ + '<path d="M7 13.5c3-3 6-3 9 0s6 3 9 0M7 20.5c3-3 6-3 9 0s6 3 9 0" fill="none" stroke="var(--t-on-primary)" stroke-width="2.4" stroke-linecap="round"/>'
20
+ + '</svg>';
21
+
22
+ const FONTS = Object.fromEntries(THEME_FAMILIES.map(({ id }) => [id, fontStylesheets(id)]));
23
+
24
+ function bootScript(defaultFamily) {
25
+ const boot = JSON.stringify({ key: APPEARANCE_KEY, defaultFamily, fonts: FONTS }).replace(/</g, '\\u003c');
26
+ return `(function () {
27
+ var boot = ${boot};
28
+ var mode = 'system';
29
+ var family = boot.defaultFamily;
30
+ try {
31
+ var saved = JSON.parse(localStorage.getItem(boot.key) || '{}');
32
+ mode = saved.colorMode || 'system';
33
+ if (Object.prototype.hasOwnProperty.call(boot.fonts, saved.themeFamily)) family = saved.themeFamily;
34
+ } catch (e) {}
35
+ var dark = mode === 'dark' || (mode !== 'light' && window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches);
36
+ var root = document.documentElement;
37
+ root.setAttribute('data-theme', family);
38
+ root.setAttribute('data-mode', dark ? 'dark' : 'light');
39
+ boot.fonts[family].forEach(function (href) {
40
+ var link = document.createElement('link');
41
+ link.rel = 'stylesheet';
42
+ link.href = href;
43
+ link.setAttribute('data-font', '');
44
+ document.head.appendChild(link);
45
+ });
46
+ })();`;
47
+ }
48
+
49
+ /**
50
+ * A full HTML page in the installation's design system. `body` is trusted
51
+ * markup (escape any text with escapeHtml); `scripts` are module URLs.
52
+ */
53
+ export function renderPage({ title, body, defaultFamily, scripts = [], bodyClass = '', state = '' }) {
54
+ return `<!doctype html>
55
+ <html lang="en" data-theme="${escapeHtml(defaultFamily)}" data-mode="light" data-default-theme="${escapeHtml(defaultFamily)}">
56
+ <head>
57
+ <meta charset="utf-8">
58
+ <meta name="viewport" content="width=device-width, initial-scale=1">
59
+ <meta name="referrer" content="no-referrer">
60
+ <title>${escapeHtml(title)}</title>
61
+ <link rel="icon" href="/assets/ui/flow-mark.svg" type="image/svg+xml">
62
+ <link rel="stylesheet" href="/assets/themes.css">
63
+ <link rel="stylesheet" href="/assets/ui/flow.css">
64
+ <script>${bootScript(defaultFamily)}</script>
65
+ ${scripts.map((src) => `<script type="module" src="${escapeHtml(src)}"></script>`).join('\n')}
66
+ </head>
67
+ <body${bodyClass ? ` class="${escapeHtml(bodyClass)}"` : ''}${state ? ` data-state="${escapeHtml(state)}"` : ''}>
68
+ ${body}
69
+ </body>
70
+ </html>
71
+ `;
72
+ }
73
+
74
+ /** A one-message page, such as the end of connecting Xero. */
75
+ export function messagePage({ title, text, defaultFamily, tone = 'info' }) {
76
+ return renderPage({
77
+ title,
78
+ defaultFamily,
79
+ bodyClass: 'fx-centre',
80
+ scripts: ['/assets/ui/appearance.js'],
81
+ body: `<main class="fx-message">
82
+ <a class="fx-brand" href="/">${FLOW_MARK}<span>Flow</span></a>
83
+ <section class="fx-card fx-message__card" data-tone="${escapeHtml(tone)}">
84
+ <h1 class="fx-title">${escapeHtml(title)}</h1>
85
+ <p>${escapeHtml(text)}</p>
86
+ <a class="fx-btn" href="/">Back to Flow</a>
87
+ </section>
88
+ </main>`,
89
+ });
90
+ }
@@ -0,0 +1,48 @@
1
+ import { dirname, join } from 'node:path';
2
+ import { createRequire } from 'node:module';
3
+ import { fileURLToPath } from 'node:url';
4
+ import express from 'express';
5
+ import { rateLimit } from '../rate-limit.js';
6
+ import { FONT_PACKAGES } from '../theme/font-families.js';
7
+ import { themeStylesheet } from '../theme/themes.js';
8
+
9
+ // The files every page loads (server/ui/page.js), under /assets:
10
+ // /assets/themes.css every design system as [data-theme][data-mode] rules
11
+ // /assets/theme/<file>.js the theme engine itself, as browser modules
12
+ // /assets/ui/<file> Flow's components (flow.css) and page scripts
13
+ // /assets/fonts/<pkg>/… the families' faces (server/theme/font-families.js)
14
+ // Only the files named here are served. They change only with a release, so
15
+ // browsers may keep them for an hour.
16
+
17
+ const require = createRequire(import.meta.url);
18
+ const here = dirname(fileURLToPath(import.meta.url));
19
+ const themeDir = join(here, '..', 'theme');
20
+ const THEME_FILES = new Set(['themes.js', 'color-contrast.js', 'openclaw-palettes.js', 'font-families.js']);
21
+ const UI_FILES = new Set(['flow.css', 'appearance.js', 'clerk-appearance.js', 'app.js', 'flow-mark.svg']);
22
+ const MAX_AGE = '1h';
23
+
24
+ export function createUiAssetsRouter({ rateLimitPerMinute = 600 } = {}) {
25
+ const router = express.Router();
26
+ const css = themeStylesheet();
27
+ router.use('/assets', rateLimit({ limit: rateLimitPerMinute, windowMs: 60_000 }));
28
+
29
+ router.get('/assets/themes.css', (_req, res) => {
30
+ res.set('Cache-Control', 'public, max-age=3600');
31
+ res.type('css').send(css);
32
+ });
33
+
34
+ const sendFrom = (dir, allowed) => (req, res, next) => {
35
+ if (!allowed.has(req.params.file)) return next();
36
+ res.sendFile(join(dir, req.params.file), { maxAge: MAX_AGE });
37
+ };
38
+ router.get('/assets/theme/:file', sendFrom(themeDir, THEME_FILES));
39
+ router.get('/assets/ui/:file', sendFrom(here, UI_FILES));
40
+
41
+ for (const name of FONT_PACKAGES) {
42
+ const dir = dirname(require.resolve(`@fontsource-variable/${name}/LICENSE`));
43
+ router.get(`/assets/fonts/${name}/index.css`, (_req, res) => res.sendFile(join(dir, 'index.css'), { maxAge: MAX_AGE }));
44
+ router.use(`/assets/fonts/${name}/files`, express.static(join(dir, 'files'), { maxAge: MAX_AGE, index: false, fallthrough: false }));
45
+ }
46
+
47
+ return router;
48
+ }