@arcforgelabs/flow 2026.10.19 → 2026.10.21

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,194 @@
1
+ /**
2
+ * Flow's design systems: the theme families people choose in Appearance, each
3
+ * with a light and a dark palette, plus accent colour overrides.
4
+ *
5
+ * This module is the one source of the --t-* tokens, on the server and in the
6
+ * browser (server/ui/routes.js serves this folder as browser modules). Harbour,
7
+ * Flow's own family, is hand-authored here; the other families are ported from
8
+ * OpenClaw's Control UI by scripts/port-openclaw-themes.mjs into
9
+ * ./openclaw-palettes.js (docs/DESIGN.md). The engine is Arc Forge Labs'
10
+ * Field's, so a family looks the same in both apps.
11
+ */
12
+ import { boundaryOn, HEX, inkOn, readableOn } from './color-contrast.js';
13
+ import { OPENCLAW_FAMILIES } from './openclaw-palettes.js';
14
+
15
+ export { boundaryOn, contrast, inkOn, readableOn } from './color-contrast.js';
16
+
17
+ // Harbour: one deep teal for ink, filled buttons and labels, a mint highlight,
18
+ // warm cream pages and cool white cards. Nunito Sans for reading, Playfair
19
+ // Display for page titles, tracked uppercase labels and soft corners. Dark
20
+ // mode turns the teal into the page and the mint into the accent.
21
+ const HARBOUR_SHAPE = {
22
+ '--t-body': "'Nunito Sans Variable', 'Nunito Sans', system-ui, -apple-system, 'Segoe UI', sans-serif",
23
+ '--t-display': "'Playfair Display Variable', 'Playfair Display', Georgia, 'Times New Roman', serif",
24
+ '--t-data': "'JetBrains Mono Variable', 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace",
25
+ '--t-radius': '6px',
26
+ '--t-radius-lg': '10px',
27
+ '--t-stroke': '1px',
28
+ '--t-label-size': '12px',
29
+ '--t-label-weight': '600',
30
+ '--t-label-spacing': '.09em',
31
+ '--t-label-case': 'uppercase',
32
+ '--t-display-weight': '700',
33
+ };
34
+
35
+ const HARBOUR = {
36
+ label: 'Harbour',
37
+ description: 'Teal, mint & cream',
38
+ source: 'flow',
39
+ light: {
40
+ '--t-bg': '#fdfaf8', '--t-s1': '#f1f6f6', '--t-s2': '#e4eeee', '--t-paper': '#fefffe',
41
+ '--t-brd': '#dee5e6', '--t-brd-hi': '#8fb7be', '--t-stroke-color': '#ced9d8',
42
+ '--t-text': '#0d4e5f', '--t-strong': '#0a3c49', '--t-dim': '#4e6365', '--t-mute': '#5d7374',
43
+ '--t-accent': '#0d4e5f', '--t-accent-bg': '#e4ffff', '--t-accent-brd': '#4a7c88',
44
+ '--t-brand': '#0d4e5f', '--t-brand-ink': '#fefffe',
45
+ '--t-primary': '#0d4e5f', '--t-on-primary': '#fefffe', '--t-ring': '#4a7c88',
46
+ '--t-warn': '#8a5a00', '--t-warn-bg': 'rgba(138, 90, 0, 0.08)', '--t-warn-brd': 'rgba(138, 90, 0, 0.32)',
47
+ '--t-danger': '#b42318', '--t-danger-bg': 'rgba(180, 35, 24, 0.07)', '--t-danger-brd': 'rgba(180, 35, 24, 0.40)',
48
+ '--t-success': '#22663f', '--t-success-bg': 'rgba(34, 102, 63, 0.08)',
49
+ ...HARBOUR_SHAPE,
50
+ '--t-shadow': '0 1px 2px rgba(13, 78, 95, 0.05), 0 2px 6px rgba(13, 78, 95, 0.06)',
51
+ 'color-scheme': 'light',
52
+ },
53
+ dark: {
54
+ '--t-bg': '#071f26', '--t-s1': '#0b2a33', '--t-s2': '#11363f', '--t-paper': '#0c2830',
55
+ '--t-brd': '#1b414b', '--t-brd-hi': '#4a7c88', '--t-stroke-color': '#2a5560',
56
+ '--t-text': '#d9e8ea', '--t-strong': '#fefffe', '--t-dim': '#a5bfc3', '--t-mute': '#8fb0b5',
57
+ '--t-accent': '#c5feff', '--t-accent-bg': 'rgba(197, 254, 255, 0.10)', '--t-accent-brd': 'rgba(197, 254, 255, 0.45)',
58
+ '--t-brand': '#c5feff', '--t-brand-ink': '#0d4e5f',
59
+ '--t-primary': '#c5feff', '--t-on-primary': '#0d4e5f', '--t-ring': '#8fb7be',
60
+ '--t-warn': '#e7b75c', '--t-warn-bg': 'rgba(231, 183, 92, 0.12)', '--t-warn-brd': 'rgba(231, 183, 92, 0.32)',
61
+ '--t-danger': '#f2877b', '--t-danger-bg': 'rgba(242, 135, 123, 0.12)', '--t-danger-brd': 'rgba(242, 135, 123, 0.45)',
62
+ '--t-success': '#86d3a1', '--t-success-bg': 'rgba(134, 211, 161, 0.12)',
63
+ ...HARBOUR_SHAPE,
64
+ '--t-shadow': '0 1px 2px rgba(0, 0, 0, 0.30), 0 4px 12px rgba(0, 0, 0, 0.20)',
65
+ 'color-scheme': 'dark',
66
+ },
67
+ };
68
+
69
+ // Picker order: Harbour (Flow's own), then the design directions Field leads
70
+ // with (Beacon, Tide, Claw, Knot, Dash), then the other OpenClaw families.
71
+ const { beacon, tide, claw, knot, dash, ...otherOpenclaw } = OPENCLAW_FAMILIES;
72
+ const FAMILIES = { harbour: HARBOUR, beacon, tide, claw, knot, dash, ...otherOpenclaw };
73
+
74
+ // --t-control: the edge of an input or an outlined button on --t-paper, the
75
+ // text colour faded toward the paper only as far as it still reads at 3:1.
76
+ for (const family of Object.values(FAMILIES)) {
77
+ for (const mode of ['light', 'dark']) {
78
+ const tokens = family[mode];
79
+ tokens['--t-control'] = boundaryOn(tokens['--t-paper'], tokens['--t-text']);
80
+ }
81
+ }
82
+
83
+ /**
84
+ * The design system a browser starts on until someone picks another in
85
+ * Appearance. An installation can choose another with FLOW_THEME
86
+ * (resolveDefaultFamily).
87
+ */
88
+ export const DEFAULT_FAMILY = 'harbour';
89
+
90
+ /** The families in picker order: id, label, description and a preview swatch per mode. */
91
+ export const THEME_FAMILIES = Object.entries(FAMILIES).map(([id, family]) => ({
92
+ id,
93
+ label: family.label,
94
+ description: family.description,
95
+ source: family.source,
96
+ swatches: {
97
+ light: swatchesOf(family.light),
98
+ dark: swatchesOf(family.dark),
99
+ },
100
+ }));
101
+
102
+ function swatchesOf(tokens) {
103
+ return { bg: tokens['--t-bg'], surface: tokens['--t-s1'], text: tokens['--t-text'], accent: tokens['--t-brand'], primary: tokens['--t-primary'] };
104
+ }
105
+
106
+ export function isThemeFamily(id) {
107
+ return typeof id === 'string' && Object.hasOwn(FAMILIES, id);
108
+ }
109
+
110
+ export function normalizeFamily(id, fallback = DEFAULT_FAMILY) {
111
+ return isThemeFamily(id) ? id : fallback;
112
+ }
113
+
114
+ /**
115
+ * FLOW_THEME: the family this installation starts every browser on. Unset
116
+ * means Harbour; an unknown name stops the server, so a typo is not silently
117
+ * ignored.
118
+ */
119
+ export function resolveDefaultFamily(env = process.env) {
120
+ const raw = String(env.FLOW_THEME ?? '').trim().toLowerCase();
121
+ if (!raw) return DEFAULT_FAMILY;
122
+ if (!isThemeFamily(raw)) {
123
+ throw new Error(`FLOW_THEME must be one of ${Object.keys(FAMILIES).join(', ')} (got "${raw}")`);
124
+ }
125
+ return raw;
126
+ }
127
+
128
+ /** The --t-* tokens (and color-scheme) for a family in a resolved mode. */
129
+ export function tokensFor(familyId, mode) {
130
+ const family = FAMILIES[normalizeFamily(familyId)];
131
+ return family[mode === 'dark' ? 'dark' : 'light'];
132
+ }
133
+
134
+ function declarations(tokens, indent) {
135
+ return Object.entries(tokens).map(([k, v]) => `${indent}${k}: ${v};`).join('\n');
136
+ }
137
+
138
+ /** Every family and mode as `[data-theme][data-mode]` rules, served as /assets/themes.css. */
139
+ export function themeStylesheet() {
140
+ return Object.keys(FAMILIES).flatMap((id) => ['light', 'dark'].map((mode) =>
141
+ `[data-theme="${id}"][data-mode="${mode}"] {\n${declarations(FAMILIES[id][mode], ' ')}\n}`,
142
+ )).join('\n');
143
+ }
144
+
145
+ // ── Accent colours ──────────────────────────────────────────────────────────
146
+ // Upstream's accent presets, with Harbour's teal first. "theme" keeps the
147
+ // family's accent. A custom colour is any #rrggbb.
148
+ export const ACCENT_PRESETS = [
149
+ { id: 'theme', label: 'Theme default', hex: null },
150
+ { id: 'harbour', label: 'Harbour teal', hex: '#0d4e5f' },
151
+ { id: 'claw', label: 'Claw red', hex: '#ff5c5c' },
152
+ { id: 'coral', label: 'Coral', hex: '#ff8066' },
153
+ { id: 'amber', label: 'Amber', hex: '#f5b942' },
154
+ { id: 'mint', label: 'Mint', hex: '#52c99a' },
155
+ { id: 'teal', label: 'Teal', hex: '#35b9b0' },
156
+ { id: 'blue', label: 'Blue', hex: '#5b9cf6' },
157
+ { id: 'violet', label: 'Violet', hex: '#a78bfa' },
158
+ { id: 'pink', label: 'Pink', hex: '#f472b6' },
159
+ { id: 'slate', label: 'Slate', hex: '#8795a8' },
160
+ ];
161
+
162
+ export function normalizeAccent(value) {
163
+ if (typeof value !== 'string') return null;
164
+ const preset = ACCENT_PRESETS.find((p) => p.id === value);
165
+ if (preset) return preset.hex;
166
+ return HEX.test(value) ? value.toLowerCase() : null;
167
+ }
168
+
169
+ /**
170
+ * Token overrides that put an accent colour on a family's palette. The colour
171
+ * fills brand surfaces and focus rings as chosen; --t-accent, which is text,
172
+ * is shifted until it reads at 4.5:1 on the background.
173
+ */
174
+ export function accentOverrides(familyId, mode, accent) {
175
+ const hex = normalizeAccent(accent);
176
+ if (!hex) return {};
177
+ const base = tokensFor(familyId, mode);
178
+ const tint = (pct) => `color-mix(in srgb, ${hex} ${pct}%, transparent)`;
179
+ const out = {
180
+ '--t-brand': hex,
181
+ '--t-brand-ink': inkOn(hex),
182
+ '--t-ring': hex,
183
+ '--t-accent': readableOn(hex, base['--t-bg']),
184
+ '--t-accent-bg': tint(mode === 'dark' ? 16 : 14),
185
+ '--t-accent-brd': hex,
186
+ };
187
+ if (base['--t-primary'] === base['--t-brand']) {
188
+ out['--t-primary'] = hex;
189
+ out['--t-on-primary'] = inkOn(hex);
190
+ } else if (base['--t-on-primary'] === base['--t-brand']) {
191
+ out['--t-on-primary'] = readableOn(hex, base['--t-primary']);
192
+ }
193
+ return out;
194
+ }
@@ -0,0 +1,66 @@
1
+ import { FLOW_MARK, renderPage } from './page.js';
2
+
3
+ // The page at /sign-in and at each view (APP_PATHS): the sign-in screen and,
4
+ // once signed in, Flow's app
5
+ // (server/ui/app.js). <body data-state> says which part shows (flow.css):
6
+ // loading, off (sign-in not configured), error, signed-out, no-access or
7
+ // signed-in.
8
+ const BODY = `<div class="fx-app">
9
+ <p class="fx-state-loading fx-loading" role="status"><span class="fx-spinner" aria-hidden="true"></span>Opening Flow…</p>
10
+
11
+ <section class="fx-state-signed-out fx-auth" aria-labelledby="auth-title">
12
+ <div class="fx-auth__brand">
13
+ <span class="fx-brand fx-brand--large">${FLOW_MARK}<span>Flow</span></span>
14
+ <h1 class="fx-auth__title" id="auth-title">Every job billed, every invoice paid.</h1>
15
+ <ul class="fx-auth__points">
16
+ <li>Jobs come in from the lab system.</li>
17
+ <li>Invoices go out to Xero.</li>
18
+ <li>Payments come back and close the job.</li>
19
+ </ul>
20
+ </div>
21
+ <div class="fx-auth__form">
22
+ <p class="fx-auth__note" id="sign-in-note"></p>
23
+ <div id="sign-in" class="fx-auth__widget"></div>
24
+ <button type="button" class="fx-btn fx-btn--ghost fx-btn--sm fx-mode-toggle" data-mode-toggle>Light or dark</button>
25
+ </div>
26
+ </section>
27
+
28
+ <section class="fx-state-message fx-message">
29
+ <span class="fx-brand">${FLOW_MARK}<span>Flow</span></span>
30
+ <div class="fx-card fx-message__card fx-state-off">
31
+ <h1 class="fx-title">Sign-in is not set up</h1>
32
+ <p>The operator turns it on with FLOW_AUTH=clerk and the Clerk keys (docs/SELF_HOSTING.md).</p>
33
+ </div>
34
+ <div class="fx-card fx-message__card fx-state-error" role="alert">
35
+ <h1 class="fx-title">Sign-in could not load</h1>
36
+ <p>Check the connection, then try again.</p>
37
+ <button type="button" class="fx-btn" onclick="location.reload()">Try again</button>
38
+ </div>
39
+ <div class="fx-card fx-message__card fx-state-no-access" id="no-access"></div>
40
+ </section>
41
+
42
+ <div class="fx-state-signed-in fx-shell">
43
+ <header class="fx-top">
44
+ <a class="fx-brand" href="/">${FLOW_MARK}<span>Flow</span></a>
45
+ <nav class="fx-nav" id="nav" aria-label="Flow"></nav>
46
+ <div class="fx-top__end">
47
+ <button type="button" class="fx-btn fx-btn--ghost fx-btn--icon fx-mode-toggle" data-mode-toggle>Light or dark</button>
48
+ <div id="user-button" class="fx-top__user"></div>
49
+ </div>
50
+ </header>
51
+ <main id="main" class="fx-main" tabindex="-1"></main>
52
+ </div>
53
+ <div id="toasts" class="fx-toasts" aria-live="polite"></div>
54
+ </div>`;
55
+
56
+ export const APP_PATHS = ['/sign-in', '/people', '/account', '/appearance'];
57
+
58
+ export function appPage({ defaultFamily }) {
59
+ return renderPage({
60
+ title: 'Flow',
61
+ defaultFamily,
62
+ state: 'loading',
63
+ scripts: ['/assets/ui/app.js'],
64
+ body: BODY,
65
+ });
66
+ }