@arcforgelabs/flow 2026.10.43 → 2026.10.44

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,278 @@
1
+ // The shared pieces every Flow page builds with: elements (h), the page
2
+ // header, notes, toasts, dialogs, setting rows, the ⋯ row menu, the button
3
+ // that waits between presses, cards that open a page, and the crumbs trail. app.js hands them to each view module in ui(),
4
+ // and the design system's cards (scripts/design-system/) render these same
5
+ // functions, so a card is the real thing. Reuse one or give it an option;
6
+ // never copy it into a page (AGENTS.md, "UI: reuse first").
7
+ import { lucide } from '/assets/ui/custom-fields.js';
8
+ import { named } from '/assets/ui/tip.js';
9
+
10
+ // h('p', { class: 'x', onclick }, 'text', child) — children are text or nodes.
11
+ export function h(tag, attrs = {}, ...children) {
12
+ const node = document.createElement(tag);
13
+ for (const [key, value] of Object.entries(attrs ?? {})) {
14
+ if (value == null || value === false) continue;
15
+ if (key.startsWith('on')) node.addEventListener(key.slice(2), value);
16
+ else if (key === 'style' && typeof value === 'object') Object.assign(node.style, value);
17
+ else node.setAttribute(key, value === true ? '' : String(value));
18
+ }
19
+ for (const child of children.flat()) {
20
+ if (child == null || child === false) continue;
21
+ node.append(child instanceof Node ? child : document.createTextNode(String(child)));
22
+ }
23
+ return node;
24
+ }
25
+
26
+ // Keeps a button off for `ms`, with the time left on it, then puts it back.
27
+ export function holdButton(button, ms) {
28
+ if (!button || !(ms > 0)) return;
29
+ clearInterval(button.fxHold);
30
+ const label = button.dataset.label ?? button.textContent;
31
+ button.dataset.label = label;
32
+ const until = Date.now() + ms;
33
+ const draw = () => {
34
+ const left = Math.ceil((until - Date.now()) / 1000);
35
+ if (left <= 0) {
36
+ clearInterval(button.fxHold);
37
+ button.textContent = label;
38
+ delete button.dataset.label;
39
+ button.removeAttribute('title');
40
+ button.disabled = false;
41
+ return;
42
+ }
43
+ const time = left < 60 ? `${left}s` : `${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')}`;
44
+ button.disabled = true;
45
+ button.textContent = `${label} · ${time}`;
46
+ button.title = `Flow waits before it asks again, to protect the connected system's limits. Ready in ${time}.`;
47
+ };
48
+ draw();
49
+ button.fxHold = setInterval(draw, 1000);
50
+ }
51
+
52
+ // The page's name and nothing under it: the page shows what it is for.
53
+ // `count` is a live number beside the name (2 to check); `help` is a docs
54
+ // link, an icon with its name as a tip.
55
+ export function pageHeader(title, { count = null, help = null, actions = [] } = {}) {
56
+ return h('header', { class: 'fx-ph' },
57
+ h('div', { class: 'fx-ph__text' },
58
+ h('h1', { class: 'fx-title' }, title),
59
+ count != null ? h('span', { class: 'fx-ph__count' }, count) : null,
60
+ help ? helpLink(help) : null),
61
+ actions.length ? h('div', { class: 'fx-ph__actions' }, actions) : null);
62
+ }
63
+
64
+ // The top of a list page (Jobs, Invoices, Reconcile), laid out the same on
65
+ // every list and in Board and List alike, so nothing moves when a person
66
+ // switches: the title row, a row for saved view tabs (`views`: the page's
67
+ // savedViews element, else an empty slot that stays hidden), then one toolbar with `start` at the left (tabs, Group,
68
+ // Sort, Filter) and `end` at the right (search, Board/List), then the page's
69
+ // `actions` (New invoice) last, as Claude's own lists do. `line` draws the
70
+ // rule that tabs sit on; `sticky` keeps the toolbar at the top while the rows
71
+ // scroll up under it, with a shadow once they do. Append `nodes` to the page.
72
+ export function listHead(title, { count = null, help = null, actions = [], start = [], end = [], line = false, sticky = false, views: tabs = null } = {}) {
73
+ const views = tabs ?? h('div', { class: 'fx-views', role: 'group', 'aria-label': 'Views', hidden: true });
74
+ const bar = h('div', { class: 'fx-listhead', 'data-sticky': sticky },
75
+ h('div', { class: `fx-toolbar${line ? '' : ' fx-toolbar--plain'}` }, start, h('div', { class: 'fx-toolbar__end' }, end, actions)));
76
+ const edge = sticky ? h('div', { class: 'fx-listhead__edge', 'aria-hidden': 'true' }) : null;
77
+ if (edge && typeof IntersectionObserver === 'function') {
78
+ const top = window.matchMedia?.('(max-width: 820px)').matches ? 61 : 1;
79
+ new IntersectionObserver(([entry]) => bar.toggleAttribute('data-stuck', !entry.isIntersecting), { rootMargin: `-${top}px 0px 0px 0px` }).observe(edge);
80
+ }
81
+ return { nodes: [pageHeader(title, { count, help }), views, edge, bar].filter(Boolean), views, bar };
82
+ }
83
+
84
+ // The window a list or board sits in under listHead, as HubSpot's: on a
85
+ // desktop it fills the page to the bottom and scrolls inside itself both
86
+ // ways, so nothing runs off the page, with a foot bar for the count and its
87
+ // tools. It makes `main` the window's height (fx-main--frame). Rows fade
88
+ // under an edge they scroll past (edgeFade); the sticky table head is the
89
+ // top edge's own break.
90
+ export function listFrame(main, content) {
91
+ main.classList.add('fx-main--frame');
92
+ const count = h('span', { class: 'fx-frame__count', role: 'status' });
93
+ const tools = h('div', { class: 'fx-frame__tools' });
94
+ const body = h('div', { class: 'fx-frame__body' }, content);
95
+ const view = h('div', { class: 'fx-frame__view' }, body);
96
+ const element = h('section', { class: 'fx-frame' }, view, h('footer', { class: 'fx-frame__foot' }, count, tools));
97
+ edgeFade(body, { target: view, edges: ['bottom', 'left', 'right'] });
98
+ return { element, body, view, tools, count: (text) => { count.textContent = text ?? ''; } };
99
+ }
100
+
101
+ // A soft fade at each edge of `target` that `scroller` has more content
102
+ // past, so rows and cards fade under it instead of vanishing. No fade on
103
+ // an edge a scrollbar already marks (the bottom under a sideways bar, the
104
+ // right beside a down bar). `target` holds the fades and must not scroll.
105
+ const fading = typeof ResizeObserver === 'function'
106
+ ? new ResizeObserver((entries) => entries.forEach(({ target }) => (target.isConnected ? target.fxFade?.() : fading.unobserve(target))))
107
+ : null;
108
+ export function edgeFade(scroller, { target = scroller.parentElement, edges = ['top', 'bottom', 'left', 'right'] } = {}) {
109
+ target.classList.add('fx-fades');
110
+ for (const edge of edges) {
111
+ if (!target.querySelector(`:scope > .fx-fade[data-edge="${edge}"]`)) target.append(h('span', { class: 'fx-fade', 'data-edge': edge, 'aria-hidden': 'true' }));
112
+ }
113
+ const update = () => {
114
+ const { scrollTop, scrollLeft, scrollHeight, scrollWidth, clientHeight, clientWidth, offsetHeight, offsetWidth } = scroller;
115
+ const more = {
116
+ top: scrollTop > 1,
117
+ bottom: scrollTop + clientHeight < scrollHeight - 1 && offsetHeight - clientHeight === 0,
118
+ left: scrollLeft > 1,
119
+ right: scrollLeft + clientWidth < scrollWidth - 1 && offsetWidth - clientWidth === 0,
120
+ };
121
+ // Several scrollers can share a target (the board in the frame): it
122
+ // fades each edge any of them has more past.
123
+ target.fxFades ??= new Map();
124
+ target.fxFades.set(scroller, edges.filter((edge) => more[edge]));
125
+ for (const other of target.fxFades.keys()) if (!other.isConnected) target.fxFades.delete(other);
126
+ target.dataset.fade = [...new Set([...target.fxFades.values()].flat())].join(' ');
127
+ };
128
+ scroller.fxFade = update;
129
+ scroller.addEventListener('scroll', update, { passive: true });
130
+ fading?.observe(scroller);
131
+ requestAnimationFrame(update);
132
+ return update;
133
+ }
134
+
135
+ // A docs link as a small "?" icon whose tip names it (How sending works).
136
+ export function helpLink({ href, label }) {
137
+ return named(h('a', { class: 'fx-help', href, target: '_blank', rel: 'noopener' }, lucide(h, 'circle-help')), label);
138
+ }
139
+
140
+ // A settings section's heading, with its docs link beside it.
141
+ export function sectionTitle(text, help = null) {
142
+ return h('h2', { class: 'fx-set__title' }, text, help ? helpLink(help) : null);
143
+ }
144
+
145
+ export function notice(text, tone = 'info', title = null) {
146
+ return h('div', { class: 'fx-notice', 'data-tone': tone, role: tone === 'danger' ? 'alert' : 'status' },
147
+ title ? h('strong', {}, title) : null,
148
+ h('span', {}, text));
149
+ }
150
+
151
+ // A field that fails a check glows red, like a required field, until the
152
+ // person types in it; the next check marks it again if it still fails.
153
+ // `on` false clears it. Answers the field.
154
+ export function fieldError(field, on = true) {
155
+ if (!on) { field.removeAttribute('aria-invalid'); return field; }
156
+ field.setAttribute('aria-invalid', 'true');
157
+ field.addEventListener('input', () => field.removeAttribute('aria-invalid'), { once: true });
158
+ return field;
159
+ }
160
+
161
+ export function toast(text, tone = 'info') {
162
+ const region = document.getElementById('toasts');
163
+ const item = h('div', { class: 'fx-toast', 'data-tone': tone, role: 'status' }, text);
164
+ region.append(item);
165
+ setTimeout(() => item.remove(), 4200);
166
+ }
167
+
168
+ // A modal built on <dialog>: resolves with the button's value, or null.
169
+ export function dialog({ title, body, confirm, tone = 'primary' }) {
170
+ return new Promise((resolve) => {
171
+ const form = h('form', { method: 'dialog', class: 'fx-dialog__form' },
172
+ h('h2', { class: 'fx-dialog__title' }, title),
173
+ body,
174
+ h('div', { class: 'fx-dialog__actions' },
175
+ h('button', { class: 'fx-btn fx-btn--ghost', value: 'cancel', type: 'submit', formnovalidate: true }, 'Cancel'),
176
+ h('button', { class: `fx-btn fx-btn--${tone}`, value: 'ok', type: 'submit' }, confirm)));
177
+ const element = h('dialog', { class: 'fx-dialog' }, form);
178
+ element.addEventListener('close', () => {
179
+ const ok = element.returnValue === 'ok';
180
+ const data = ok ? new FormData(form) : null;
181
+ element.remove();
182
+ resolve(ok ? data : null);
183
+ });
184
+ document.body.append(element);
185
+ element.showModal();
186
+ element.querySelector('input, select, button[value="ok"]')?.focus();
187
+ });
188
+ }
189
+
190
+ // One setting, laid out the same on every settings screen: a short name, one
191
+ // line of smaller text under it, and the control on the right. Longer help
192
+ // lives behind a link, not here. A wide control (a grid of choices) sits on
193
+ // its own line under the name. `tone: 'danger'` marks a row that needs a person.
194
+ export function settingRow({ label, hint, control, wide = false, tone = null }) {
195
+ const target = control?.matches?.('input, select, textarea') ? control : control?.querySelector?.('input:not([type=color]), select, textarea');
196
+ const id = target ? (target.id ||= `fx-set-${++settingRow.next}`) : null;
197
+ return h('div', { class: `fx-setting${wide ? ' fx-setting--wide' : ''}`, 'data-tone': tone },
198
+ h('div', { class: 'fx-setting__text' },
199
+ id ? h('label', { class: 'fx-setting__name', for: id }, label) : h('span', { class: 'fx-setting__name' }, label),
200
+ hint ? h('p', { class: 'fx-setting__hint' }, hint) : null),
201
+ h('div', { class: 'fx-setting__control' }, control));
202
+ }
203
+ settingRow.next = 0;
204
+
205
+ // The steps of a Connect, each ticked, running or crossed, with the time taken.
206
+
207
+ let rowMenus = 0;
208
+
209
+ // A row's ⋯ menu, as on the Custom fields page. The menu lines up with the
210
+ // button's right edge; `align: 'start'` lines it up with its left edge.
211
+ export function rowMenu(label, items, { align = 'end' } = {}) {
212
+ const id = `fx-row-menu-${++rowMenus}`;
213
+ const button = h('button', { class: 'fx-cfm__more', type: 'button', 'aria-label': label, popovertarget: id, 'aria-haspopup': 'menu', 'aria-expanded': 'false' }, lucide(h, 'ellipsis'));
214
+ const panel = h('div', { class: 'fx-pop', id, popover: 'auto', role: 'menu' });
215
+ panel.addEventListener('toggle', (event) => {
216
+ button.setAttribute('aria-expanded', String(event.newState === 'open'));
217
+ if (event.newState !== 'open') return;
218
+ const close = () => panel.hidePopover();
219
+ panel.replaceChildren(...items.map((entry) => (entry === '-'
220
+ ? h('div', { class: 'fx-pop__sep' })
221
+ : h('button', { type: 'button', class: 'fx-pop__item', role: 'menuitem', 'data-tone': entry.tone, onclick: () => { close(); entry.run(); } },
222
+ h('span', { class: 'fx-pop__tick' }, lucide(h, entry.icon)), h('span', {}, entry.text)))));
223
+ const at = button.getBoundingClientRect();
224
+ panel.style.top = `${Math.round(at.bottom + 6)}px`;
225
+ const left = align === 'start' ? at.left : at.right - panel.offsetWidth;
226
+ panel.style.left = `${Math.round(Math.max(8, Math.min(left, innerWidth - panel.offsetWidth - 8)))}px`;
227
+ });
228
+ return [button, panel];
229
+ }
230
+
231
+ // Where a link in a shared piece opens: app.js passes its own `go`, so a click
232
+ // opens the page in place (a new tab still works). Without it, as in the
233
+ // design system, a link is a plain link.
234
+ let openPath = null;
235
+ export function linksOpenWith(go) { openPath = go; }
236
+ function inPlace(event) {
237
+ if (!openPath || event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) return;
238
+ event.preventDefault();
239
+ openPath(event.currentTarget.getAttribute('href'));
240
+ }
241
+
242
+ // A card that opens a page. Large (Integrations): its mark (a logo node, or a
243
+ // Lucide icon name), the name, a status pill, two lines at most (only when
244
+ // they help), and the action. `row` is the one-line card of a tileSection,
245
+ // like Claude's Customize categories: the mark at the left, the name, and a
246
+ // short dim `meta` value or the status pill at the right.
247
+ export function tileCard({ href, mark, title, status, blurb, meta, action = 'Manage', row = false }) {
248
+ const logo = typeof mark === 'string' ? h('span', { class: 'fx-logo fx-logo--icon', 'aria-hidden': 'true' }, lucide(h, mark)) : mark;
249
+ const pill = status ? h('span', { class: 'fx-status', 'data-tone': status.tone ?? 'mute' }, status.label) : null;
250
+ if (row) {
251
+ return h('a', { class: 'fx-tile', href, onclick: inPlace },
252
+ logo,
253
+ h('h3', {}, title),
254
+ pill ?? (meta ? h('span', { class: 'fx-tile__meta' }, meta) : null));
255
+ }
256
+ return h('a', { class: `fx-int-card${blurb ? '' : ' fx-int-card--bare'}`, href, onclick: inPlace },
257
+ h('header', { class: 'fx-int-card__head' }, logo, h('h3', {}, title), pill),
258
+ blurb ? h('p', { class: 'fx-int-card__blurb' }, blurb) : null,
259
+ h('span', { class: 'fx-int-card__btn' }, action));
260
+ }
261
+
262
+ // A titled group of one-line cards, three columns where they fit, with "Show all" at the right
263
+ // when `href` is given (Claude's Customize page).
264
+ export function tileSection(title, cards, { href, more = 'Show all' } = {}) {
265
+ return h('section', { class: 'fx-tiles' },
266
+ h('header', { class: 'fx-tiles__head' },
267
+ h('h2', {}, title),
268
+ href ? h('a', { class: 'fx-tiles__more', href, onclick: inPlace }, more, lucide(h, 'chevron-right')) : null),
269
+ h('div', { class: 'fx-tiles__grid' }, cards));
270
+ }
271
+
272
+ // The trail above a page one level down: crumbs(['/integrations', 'Integrations'], 'Xero').
273
+ export function crumbs(...trail) {
274
+ const here = trail.pop();
275
+ return h('nav', { class: 'fx-crumbs', 'aria-label': 'Breadcrumb' },
276
+ ...trail.flatMap(([href, label]) => [h('a', { href, onclick: inPlace }, label), h('span', { 'aria-hidden': 'true' }, '›')]),
277
+ h('span', {}, here));
278
+ }
@@ -0,0 +1,73 @@
1
+ // A person's saved views of a list page (Jobs, Invoices, Reconcile), as
2
+ // ClickUp's view tabs: each has a name and the page's view (Group, Sort,
3
+ // Filter, Columns), and one can be the person's default, which opens when
4
+ // they go to the page. No DOM: the server checks a saved list with the same
5
+ // rules the page uses (server/auth/people.js).
6
+ import { cleanFilterView, normaliseJobsView } from './job-view.js';
7
+
8
+ // The Invoices filter's fields (invoicing.js). The tabs are the status, so
9
+ // Status is not here.
10
+ export const INVOICE_FILTER_FIELDS = {
11
+ client: { label: 'Client', kind: 'choice', icon: 'building-2' },
12
+ number: { label: 'Number', kind: 'text', icon: 'hash' },
13
+ reference: { label: 'Reference', kind: 'text', icon: 'text' },
14
+ date: { label: 'Date', kind: 'date', icon: 'calendar' },
15
+ total: { label: 'Total', kind: 'number', icon: 'dollar-sign' },
16
+ problem: { label: 'Problem', kind: 'check', icon: 'triangle-alert' },
17
+ };
18
+
19
+ // The Reconcile filter's fields (reconcile.js). Reconcile lists jobs and
20
+ // invoices, so it has both lists' fields; a job's own field (Practitioner,
21
+ // a custom field) skips invoice rows, so it never hides them.
22
+ export const RECONCILE_FILTER_FIELDS = {
23
+ type: { label: 'Type', kind: 'choice', icon: 'layers' },
24
+ status: { label: 'Status', kind: 'choice', icon: 'circle-dot' },
25
+ client: { label: 'Client', kind: 'choice', icon: 'building-2' },
26
+ practitioner: { label: 'Practitioner', kind: 'choice', icon: 'stethoscope', job: true },
27
+ date: { label: 'Date', kind: 'date', icon: 'calendar' },
28
+ total: { label: 'Total', kind: 'number', icon: 'dollar-sign' },
29
+ match: { label: 'Match', kind: 'check', icon: 'link' },
30
+ };
31
+
32
+ const CUSTOM_FIELD = /^field:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
33
+ const kindIn = (fields, custom) => (field) => (Object.hasOwn(fields, field) ? fields[field].kind : custom && CUSTOM_FIELD.test(field) ? '*' : null);
34
+
35
+ // The Invoices and Reconcile views: their filter.
36
+ export const normaliseInvoicesView = (value) => cleanFilterView(value, kindIn(INVOICE_FILTER_FIELDS, false));
37
+ export const normaliseReconcileView = (value) => cleanFilterView(value, kindIn(RECONCILE_FILTER_FIELDS, true));
38
+
39
+ // The pages with saved views. Each is named in a switch, never looked up
40
+ // by the name a request sends, so a name such as "constructor" is refused.
41
+ export const SAVED_VIEW_PAGES = Object.freeze(['jobs', 'invoices', 'reconcile']);
42
+ export function pageViewCheck(page) {
43
+ switch (page) {
44
+ case 'jobs': return normaliseJobsView;
45
+ case 'invoices': return normaliseInvoicesView;
46
+ case 'reconcile': return normaliseReconcileView;
47
+ default: return null;
48
+ }
49
+ }
50
+
51
+ export const MAX_SAVED_VIEWS = 20;
52
+ const MAX_NAME = 60;
53
+ const VIEW_ID = /^[a-z0-9-]{1,40}$/i;
54
+
55
+ // A page's saved views as Flow keeps them: { list: [{ id, name, view }],
56
+ // default }. Bad entries are dropped; a default that names no view is none.
57
+ export function normaliseSavedViews(page, input) {
58
+ const check = pageViewCheck(page);
59
+ const value = input && typeof input === 'object' ? input : {};
60
+ const seen = new Set();
61
+ const list = check && Array.isArray(value.list) ? value.list : [];
62
+ const kept = list.filter((v) => v && typeof v === 'object' && typeof v.id === 'string' && VIEW_ID.test(v.id) && !seen.has(v.id) && seen.add(v.id))
63
+ .map((v) => ({ id: v.id, name: String(v.name ?? '').trim().slice(0, MAX_NAME), view: check(v.view) }))
64
+ .filter((v) => v.name.length > 0)
65
+ .slice(0, MAX_SAVED_VIEWS);
66
+ return { list: kept, default: kept.some((v) => v.id === value.default) ? value.default : null };
67
+ }
68
+
69
+ // Do two views of a page show the same thing?
70
+ export function sameView(page, a, b) {
71
+ const check = pageViewCheck(page);
72
+ return Boolean(check) && JSON.stringify(check(a)) === JSON.stringify(check(b));
73
+ }
@@ -0,0 +1,29 @@
1
+ // How Flow names a person, in one place: a person field's choices, its
2
+ // filter values and lanes, the Assignee badge, and the People page. The
3
+ // label is the person's full name; with no name, their whole email. No DOM,
4
+ // so the server builds the same label (server/core/custom-fields.js
5
+ // choosablePeople) and tests can read it (test/person.test.js).
6
+
7
+ // The longest label a native menu shows. A longer one ends in "…" and the
8
+ // full label is the option's hover name. Pages with their own lists clip
9
+ // with CSS (`text-overflow: ellipsis`) instead.
10
+ export const LABEL_MAX = 32;
11
+
12
+ // { name, email } → "Jane Citizen", or "jane@example.test" with no name.
13
+ export function personLabel(person) {
14
+ return String(person?.name ?? '').trim() || String(person?.email ?? '').trim();
15
+ }
16
+
17
+ // A badge's letters: the first letters of the first two words of the label
18
+ // ("JC"), or of the email's part before @ ("jane.citizen@…" → "JC").
19
+ export function initialsOf(person) {
20
+ const source = (typeof person === 'string' ? person : personLabel(person)).replace(/@.*/, '');
21
+ const parts = source.split(/[\s._-]+/).filter(Boolean);
22
+ return ((parts[0]?.[0] ?? '?') + (parts[1]?.[0] ?? '')).toUpperCase();
23
+ }
24
+
25
+ // A label cut to `max` characters with "…" at the end, on one line.
26
+ export function clipLabel(text, max = LABEL_MAX) {
27
+ const value = String(text ?? '');
28
+ return value.length > max ? `${value.slice(0, max - 1).trimEnd()}…` : value;
29
+ }