@arcforgelabs/flow 2026.10.35 → 2026.10.37

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.
@@ -1,10 +1,13 @@
1
1
  // Settings, Custom fields (/custom-fields; server/core/custom-fields.js,
2
- // docs/CUSTOM_FIELDS.md). The fields a lab makes for its jobs, like ClickUp's
3
- // custom fields: each row is one field, with its type and, for a dropdown,
4
- // its options in lane order. Create field and Edit open a dialog. The work
5
- // systems' own fields (Seazona's stage) show below, read only: Flow reads
6
- // them and never changes them here. Everyone can read the page; an owner or
7
- // admin changes it. The caller passes in app.js's shared pieces as `ui`.
2
+ // docs/CUSTOM_FIELDS.md). The fields a lab makes for its jobs, laid out like
3
+ // ClickUp's Custom Field Manager: one table, with a group per type that folds
4
+ // open and closed, and one row per field with its type icon, who made it,
5
+ // when, and where it applies. Search, a type filter and Filter (archived
6
+ // fields) sit above it. A row's Edit opens the field's dialog (name and
7
+ // options); its ⋯ menu has Rename, Duplicate, Copy field ID and Archive. The
8
+ // work systems' own fields (Seazona's stage) sit in their own group, read
9
+ // only. Everyone can read the page; an owner or admin changes it. The caller
10
+ // passes in app.js's shared pieces as `ui`.
8
11
 
9
12
  const DOCS = 'https://github.com/arcforgelabs/flow/blob/main/docs/CUSTOM_FIELDS.md';
10
13
  const TYPE_LABELS = {
@@ -18,13 +21,44 @@ const TYPE_HINTS = {
18
21
  number: 'A number, for example units.',
19
22
  text: 'A short note.',
20
23
  };
24
+ // Each type's icon (Lucide, served at /assets/icons/) and group colour.
25
+ const TYPE_ICONS = {
26
+ dropdown: 'square-chevron-down', person: 'user-round', checkbox: 'square-check', date: 'calendar', number: 'hash', text: 'type',
27
+ };
28
+ const TYPE_COLORS = { dropdown: 'green', person: 'blue', checkbox: 'purple', date: 'teal', number: 'amber', text: 'gray' };
29
+ const FOLDED_KEY = 'flow_custom_fields_folded';
30
+
31
+ // A Lucide icon as a mask, so it takes the text colour.
32
+ export function lucide(h, name, label = null) {
33
+ return h('span', { class: 'fx-lucide', style: `--icon: url(/assets/icons/${name}.svg)`, ...(label ? { role: 'img', 'aria-label': label, title: label } : { 'aria-hidden': 'true' }) });
34
+ }
35
+
36
+ // "Today", "Yesterday", else d/m/yy, like ClickUp's Date created.
37
+ export function createdDay(iso, now = new Date()) {
38
+ if (!iso) return '';
39
+ const day = new Date(iso);
40
+ const start = (d) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
41
+ const days = Math.round((start(now) - start(day)) / 86_400_000);
42
+ if (days === 0) return 'Today';
43
+ if (days === 1) return 'Yesterday';
44
+ return `${day.getDate()}/${day.getMonth() + 1}/${String(day.getFullYear()).slice(2)}`;
45
+ }
46
+
47
+ const initials = (name) => (name ?? '').split(/[\s@._-]+/).filter(Boolean).slice(0, 2).map((w) => w[0].toUpperCase()).join('') || '?';
48
+
49
+ function readFolded() {
50
+ try { return new Set(JSON.parse(localStorage.getItem(FOLDED_KEY) ?? '[]')); } catch { return new Set(); }
51
+ }
52
+ function saveFolded(set) {
53
+ try { localStorage.setItem(FOLDED_KEY, JSON.stringify([...set])); } catch { /* this browser keeps no state */ }
54
+ }
21
55
 
22
56
  export async function renderCustomFields(main, ui) {
23
57
  const { h, flow, toast, notice, dialog, pageHeader, me } = ui;
24
58
  const disabled = !['owner', 'admin'].includes(me?.person.role);
25
- const host = h('div', { class: 'fx-card', style: { padding: '8px 20px 16px' } }, h('div', { class: 'fx-skeleton' }));
26
- const addButton = h('button', { class: 'fx-btn fx-btn--primary', type: 'button', disabled }, 'Create field');
27
- main.append(pageHeader('Custom fields', { subtitle: 'Fields for your jobs. They stay in Flow and never change Seazona or Xero.', actions: [addButton] }), host);
59
+ const host = h('div', { class: 'fx-card fx-cfm__card' }, h('div', { class: 'fx-skeleton' }));
60
+ const addButton = h('button', { class: 'fx-btn fx-btn--primary fx-btn--sm', type: 'button', disabled }, 'Create field');
61
+ main.append(pageHeader('Custom fields', { subtitle: 'Fields for your jobs. They stay in Flow and never change Seazona or Xero.' }), host);
28
62
 
29
63
  let data = null;
30
64
 
@@ -39,11 +73,6 @@ export async function renderCustomFields(main, ui) {
39
73
  }
40
74
  }
41
75
 
42
- const row = (name, hint, control) => h('div', { class: 'fx-set__row' },
43
- h('div', { class: 'fx-set__text' }, h('span', { class: 'fx-set__name' }, name), hint),
44
- h('div', { class: 'fx-set__control fx-rule__controls' }, control));
45
- const optionChip = (o) => h('span', { class: 'fx-cf__opt', 'data-color': o.color ?? 'gray' }, o.name);
46
- const typeChip = (type) => h('span', { class: 'fx-chip fx-chip--static' }, TYPE_LABELS[type] ?? type);
47
76
 
48
77
  // The option list editor for a dropdown: one line per option (name,
49
78
  // colour, move up, remove), in lane order.
@@ -119,60 +148,198 @@ export async function renderCustomFields(main, ui) {
119
148
  return load();
120
149
  }
121
150
 
122
- async function move(index, by) {
123
- const ids = data.fields.map((f) => f.id);
124
- [ids[index], ids[index + by]] = [ids[index + by], ids[index]];
125
- if (await send('/api/custom-fields/order', 'PUT', { ids }, null)) await load();
151
+ // ---- The manager: toolbar, then one table of groups ----
152
+ const view = { q: '', types: new Set(), archived: false, desc: false, folded: readFolded(), renaming: null };
153
+ let menuCount = 0;
154
+
155
+ // A popover menu under a button (the Jobs board's toolbar menus work the same way).
156
+ function popMenu(button, build) {
157
+ const id = `fx-cf-menu-${++menuCount}`;
158
+ const panel = h('div', { class: 'fx-pop', id, popover: 'auto', role: 'menu' });
159
+ button.setAttribute('popovertarget', id);
160
+ button.setAttribute('aria-haspopup', 'menu');
161
+ button.setAttribute('aria-expanded', 'false');
162
+ panel.addEventListener('toggle', (event) => {
163
+ button.setAttribute('aria-expanded', String(event.newState === 'open'));
164
+ if (event.newState !== 'open') return;
165
+ panel.replaceChildren(...build(() => panel.hidePopover()).filter(Boolean));
166
+ const at = button.getBoundingClientRect();
167
+ panel.style.top = `${Math.round(at.bottom + 6)}px`;
168
+ panel.style.left = `${Math.round(Math.max(8, Math.min(at.right - panel.offsetWidth, innerWidth - panel.offsetWidth - 8)))}px`;
169
+ });
170
+ return panel;
171
+ }
172
+ const item = (iconName, text, onclick, { disabled: off = false, title = null, role = 'menuitem', checked = null } = {}) => h('button', {
173
+ type: 'button', class: 'fx-pop__item', role, disabled: off, title, onclick, ...(checked === null ? {} : { 'aria-checked': String(checked) }),
174
+ }, h('span', { class: 'fx-pop__tick' }, iconName ? lucide(h, iconName) : null), h('span', {}, text));
175
+
176
+ const search = h('input', { class: 'fx-input fx-cfm__search', type: 'search', placeholder: 'Search fields…', 'aria-label': 'Search fields' });
177
+ search.addEventListener('input', () => { view.q = search.value.trim().toLowerCase(); draw(); });
178
+ const typeButton = h('button', { class: 'fx-chip fx-cfm__tool', type: 'button' });
179
+ const typeMenu = popMenu(typeButton, (close) => [
180
+ ...data.types.map((t) => item(view.types.has(t) ? 'check' : null, TYPE_LABELS[t], () => {
181
+ if (view.types.has(t)) view.types.delete(t); else view.types.add(t);
182
+ close(); draw();
183
+ }, { role: 'menuitemcheckbox', checked: view.types.has(t) })),
184
+ view.types.size ? h('div', { class: 'fx-pop__sep' }) : null,
185
+ view.types.size ? item(null, 'All types', () => { view.types.clear(); close(); draw(); }) : null,
186
+ ]);
187
+ const filterButton = h('button', { class: 'fx-chip fx-cfm__tool fx-cfm__tool--dashed', type: 'button' });
188
+ const filterMenu = popMenu(filterButton, (close) => [
189
+ item(view.archived ? 'check' : null, 'Show archived fields', () => { view.archived = !view.archived; close(); draw(); }, { role: 'menuitemcheckbox', checked: view.archived }),
190
+ ]);
191
+ const toolbar = h('div', { class: 'fx-cfm__bar' }, search, typeButton, typeMenu, filterButton, filterMenu, h('span', { class: 'fx-cfm__gap' }), addButton);
192
+ const table = h('div', { class: 'fx-cfm', role: 'table', 'aria-label': 'Custom fields' });
193
+ host.replaceChildren(toolbar, table);
194
+
195
+ const personName = (id) => data.people?.find((p) => p.id === id)?.name ?? null;
196
+ const matches = (f) => !view.q || f.name.toLowerCase().includes(view.q)
197
+ || (f.options ?? []).some((o) => !o.archived && o.name.toLowerCase().includes(view.q));
198
+ const byName = (a, b) => a.name.localeCompare(b.name) * (view.desc ? -1 : 1);
199
+
200
+ async function rename(field, name) {
201
+ view.renaming = null;
202
+ const clean = name.trim();
203
+ if (clean && clean !== field.name && await send(`/api/custom-fields/${field.id}`, 'PUT', { name: clean }, 'Renamed.')) return load();
204
+ return draw();
205
+ }
206
+
207
+ async function duplicate(field) {
208
+ const taken = new Set(data.fields.map((f) => f.name.toLowerCase()));
209
+ let name = `${field.name} copy`.slice(0, 60);
210
+ for (let n = 2; taken.has(name.toLowerCase()); n += 1) name = `${field.name} copy ${n}`.slice(0, 60);
211
+ const payload = { name, type: field.type };
212
+ if (field.type === 'dropdown') payload.options = field.options.filter((o) => !o.archived).map(({ name: n, color }) => ({ name: n, color }));
213
+ if (await send('/api/custom-fields', 'POST', payload, `Made ${name}.`)) await load();
126
214
  }
127
215
 
128
- function fieldRow(field, index) {
129
- const hint = h('div', { class: 'fx-price__hint' }, typeChip(field.type),
130
- ...(field.type === 'dropdown' ? field.options.filter((o) => !o.archived).map(optionChip) : [h('span', { class: 'fx-set__hint' }, TYPE_HINTS[field.type])]));
131
- const controls = [];
132
- if (index !== null) {
133
- const up = h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button', disabled: disabled || index === 0, 'aria-label': `Move ${field.name} up` }, '↑');
134
- up.addEventListener('click', () => move(index, -1));
135
- const down = h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button', disabled: disabled || index === data.fields.length - 1, 'aria-label': `Move ${field.name} down` }, '↓');
136
- down.addEventListener('click', () => move(index, 1));
137
- const editButton = h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button', disabled }, 'Edit');
138
- editButton.addEventListener('click', () => edit(field));
139
- controls.push(up, down, editButton);
140
- } else {
141
- const back = h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button', disabled }, 'Bring back');
142
- back.addEventListener('click', async () => {
143
- if (await send(`/api/custom-fields/${field.id}/archive`, 'POST', { archived: false }, 'Brought back.')) await load();
216
+ async function copyId(field) {
217
+ try {
218
+ await navigator.clipboard.writeText(field.id);
219
+ toast('Field ID copied.', 'success');
220
+ } catch {
221
+ toast(`Field ID: ${field.id}`, 'info');
222
+ }
223
+ }
224
+
225
+ async function setArchived(field, archived) {
226
+ if (await send(`/api/custom-fields/${field.id}/archive`, 'POST', { archived }, archived ? 'Archived. Its values are kept.' : 'Brought back.')) await load();
227
+ }
228
+
229
+ function rowMenu(field) {
230
+ const button = h('button', { class: 'fx-cfm__more', type: 'button', 'aria-label': `More for ${field.name}` }, lucide(h, 'ellipsis'));
231
+ const panel = popMenu(button, (close) => (field.archived
232
+ ? [item('archive-restore', 'Bring back', () => { close(); setArchived(field, false); }, { disabled })]
233
+ : [
234
+ item('square-pen', 'Edit', () => { close(); edit(field); }, { disabled }),
235
+ item('pencil', 'Rename', () => { close(); view.renaming = field.id; draw(); }, { disabled }),
236
+ item('copy', 'Duplicate', () => { close(); duplicate(field); }, { disabled }),
237
+ item('repeat', 'Convert', null, { disabled: true, title: 'Changing a field\'s type is not in Flow yet.' }),
238
+ item('clipboard-copy', 'Copy field ID', () => { close(); copyId(field); }),
239
+ h('div', { class: 'fx-pop__sep' }),
240
+ item('archive', 'Archive', () => { close(); setArchived(field, true); }, { disabled }),
241
+ ]));
242
+ return [button, panel];
243
+ }
244
+
245
+ function nameCell(field) {
246
+ if (view.renaming === field.id) {
247
+ const input = h('input', { class: 'fx-input fx-cfm__rename', value: field.name, maxlength: '60', 'aria-label': 'Field name' });
248
+ input.addEventListener('keydown', (event) => {
249
+ if (event.key === 'Enter') input.blur();
250
+ if (event.key === 'Escape') { view.renaming = null; draw(); }
144
251
  });
145
- controls.push(back);
252
+ input.addEventListener('blur', () => { if (view.renaming === field.id) rename(field, input.value); });
253
+ queueMicrotask(() => { input.focus(); input.select(); });
254
+ return h('div', { class: 'fx-cfm__name', role: 'cell' }, input);
146
255
  }
147
- return row(field.name, hint, h('div', { class: 'fx-row' }, ...controls));
256
+ const editButton = field.archived || disabled || field.source ? null
257
+ : h('button', { class: 'fx-cfm__edit', type: 'button', onclick: () => edit(field) }, 'Edit', lucide(h, 'panel-right'));
258
+ return h('div', { class: 'fx-cfm__name', role: 'cell' },
259
+ field.source ? lucide(h, 'lock', 'Read only') : null,
260
+ h('span', { class: 'fx-cfm__label', title: field.name }, field.name), editButton);
148
261
  }
149
262
 
150
- function systemRow(field) {
151
- const options = field.options.slice(0, 12).map((o) => h('span', { class: 'fx-cf__opt', 'data-color': o.final ? 'green' : 'gray' }, o.name));
152
- if (field.options.length > 12) options.push(h('span', { class: 'fx-set__hint' }, `${field.options.length - 12} more`));
153
- return row(`${field.sourceLabel} ${field.name.toLowerCase()}`, h('div', { class: 'fx-price__hint' }, typeChip(field.type), ...options),
154
- h('span', { class: 'fx-status', 'data-tone': 'mute' }, `Read only · from ${field.sourceLabel}`));
263
+ function fieldRow(field) {
264
+ const by = field.source ? field.sourceLabel : personName(field.createdBy);
265
+ return h('div', { class: 'fx-cfm__row', role: 'row', 'data-archived': field.archived ? 'true' : null },
266
+ nameCell(field),
267
+ h('div', { class: 'fx-cfm__type', role: 'cell', 'data-color': TYPE_COLORS[field.type] }, lucide(h, TYPE_ICONS[field.type], TYPE_LABELS[field.type])),
268
+ h('div', { class: 'fx-cfm__by', role: 'cell' }, by ? h('span', { class: 'fx-cfm__avatar', title: by }, field.source ? lucide(h, 'plug') : initials(by)) : h('span', { class: 'fx-dim' }, '—')),
269
+ h('div', { class: 'fx-cfm__date', role: 'cell' }, field.source ? h('span', { class: 'fx-dim' }, '—') : createdDay(field.createdAt)),
270
+ h('div', { class: 'fx-cfm__where', role: 'cell' }, field.source ? `${field.sourceLabel} jobs` : 'Jobs'),
271
+ h('div', { class: 'fx-cfm__end', role: 'cell' }, field.source ? null : rowMenu(field)));
272
+ }
273
+
274
+ // One group: a header that folds it, its rows, and "Create … field". A
275
+ // search opens every group that has a match.
276
+ function group(key, pill, rows, { create = null } = {}) {
277
+ const open = Boolean(view.q) || !view.folded.has(key);
278
+ const toggle = h('button', { class: 'fx-cfm__fold', type: 'button', 'aria-expanded': String(open), 'aria-label': `${open ? 'Fold' : 'Open'} ${pill.text}` }, lucide(h, 'chevron-down'));
279
+ toggle.addEventListener('click', () => {
280
+ if (open) view.folded.add(key); else view.folded.delete(key);
281
+ saveFolded(view.folded);
282
+ draw();
283
+ });
284
+ const head = h('div', { class: 'fx-cfm__group', role: 'row' }, toggle,
285
+ h('span', { class: 'fx-cfm__pill', 'data-color': pill.color }, pill.icon ? lucide(h, pill.icon) : null, pill.text),
286
+ h('span', { class: 'fx-cfm__count' }, String(rows.length)));
287
+ if (!open) return [head];
288
+ const out = [head, ...rows.map(fieldRow)];
289
+ if (create) out.push(create);
290
+ return out;
155
291
  }
156
292
 
157
293
  function draw() {
158
- const archived = data.archived.filter((f) => f.archived);
159
- const sections = [
160
- disabled ? notice('Only an owner or admin can change these. Everyone can set a job\'s values.', 'info') : null,
161
- h('section', { class: 'fx-set' }, h('h2', { class: 'fx-set__title' }, `Your fields · ${data.fields.length}`),
162
- ...(data.fields.length ? data.fields.map(fieldRow)
163
- : [h('p', { class: 'fx-dim' }, 'No fields yet. Create one, for example Priority or Technician, to group the Jobs board by it.')])),
164
- ];
165
- if (data.systemFields.length) {
166
- sections.push(h('section', { class: 'fx-set' }, h('h2', { class: 'fx-set__title' }, 'From integrations'),
167
- ...data.systemFields.map(systemRow),
168
- h('p', { class: 'fx-dim', style: { margin: '8px 0 0' } }, 'Flow reads these. Change them in their own system.')));
294
+ if (!data) return;
295
+ typeButton.replaceChildren(lucide(h, 'layers'), h('span', {}, view.types.size === 1 ? TYPE_LABELS[[...view.types][0]] : view.types.size ? `${view.types.size} types` : 'Field type'), lucide(h, 'chevron-down'));
296
+ typeButton.classList.toggle('fx-chip--on', view.types.size > 0);
297
+ filterButton.replaceChildren(lucide(h, 'list-filter'), h('span', {}, view.archived ? 'Archived shown' : 'Filter'), lucide(h, 'chevron-down'));
298
+ filterButton.classList.toggle('fx-chip--on', view.archived);
299
+
300
+ const shown = (type) => !view.types.size || view.types.has(type);
301
+ const sortHead = h('button', { class: 'fx-cfm__sort', type: 'button', 'aria-label': `Sort by name, ${view.desc ? 'Z to A' : 'A to Z'}` }, 'Name', lucide(h, view.desc ? 'arrow-up-z-a' : 'arrow-down-a-z'));
302
+ sortHead.addEventListener('click', () => { view.desc = !view.desc; draw(); });
303
+ const parts = [h('div', { class: 'fx-cfm__head', role: 'row' },
304
+ h('div', { role: 'columnheader' }, sortHead), h('div', { role: 'columnheader' }, 'Type'), h('div', { role: 'columnheader', class: 'fx-cfm__by' }, 'Created by'),
305
+ h('div', { role: 'columnheader', class: 'fx-cfm__date' }, 'Date created'), h('div', { role: 'columnheader', class: 'fx-cfm__where' }, 'Location'), h('div', { role: 'columnheader' }))];
306
+ if (disabled) parts.unshift(notice('Only an owner or admin can change these. Everyone can set a job\'s values.', 'info'));
307
+
308
+ // Type groups in ClickUp's order: by type name.
309
+ const types = [...data.types].sort((a, b) => TYPE_LABELS[a].localeCompare(TYPE_LABELS[b]));
310
+ let count = 0;
311
+ for (const type of types) {
312
+ if (!shown(type)) continue;
313
+ const fields = data.fields.filter((f) => f.type === type && matches(f)).sort(byName);
314
+ if (!fields.length && (view.q || view.types.size === 0)) continue;
315
+ count += fields.length;
316
+ const create = view.q || disabled ? null : h('button', { class: 'fx-cfm__create', type: 'button', onclick: () => edit(null, { type, name: '', options: undefined }) },
317
+ lucide(h, 'plus'), `Create ${TYPE_LABELS[type].toLowerCase()} field`);
318
+ parts.push(...group(type, { text: TYPE_LABELS[type], icon: TYPE_ICONS[type], color: TYPE_COLORS[type] }, fields, { create }));
319
+ }
320
+ // The work systems' own fields: one read-only group per system.
321
+ for (const source of [...new Set(data.systemFields.map((f) => f.source))]) {
322
+ const fields = data.systemFields.filter((f) => f.source === source && shown(f.type))
323
+ .map((f) => ({ ...f, id: `${f.source}:${f.name}`, name: `${f.sourceLabel} ${f.name.toLowerCase()}` })).filter(matches);
324
+ if (!fields.length) continue;
325
+ count += fields.length;
326
+ parts.push(...group(`source:${source}`, { text: `From ${fields[0].sourceLabel}`, icon: 'lock', color: 'gray' }, fields));
327
+ }
328
+ if (view.archived) {
329
+ const fields = data.archived.filter((f) => f.archived && shown(f.type) && matches(f)).sort(byName);
330
+ count += fields.length;
331
+ parts.push(...group('archived', { text: 'Archived', icon: 'archive', color: 'gray' }, fields));
169
332
  }
170
- if (archived.length) {
171
- sections.push(h('section', { class: 'fx-set' }, h('h2', { class: 'fx-set__title' }, `Archived · ${archived.length}`), ...archived.map((f) => fieldRow(f, null))));
333
+ if (!count && (view.q || view.types.size)) parts.push(h('p', { class: 'fx-cfm__empty' }, 'No field matches.'));
334
+ if (!data.fields.length && !view.q && !view.types.size) {
335
+ parts.push(h('p', { class: 'fx-cfm__empty' }, 'No fields yet. Create one, for example Priority or Technician, to group the Jobs board by it.'));
172
336
  }
173
- sections.push(h('p', { class: 'fx-dim', style: { margin: '8px 0 0' } }, h('a', { href: DOCS, target: '_blank', rel: 'noopener' }, 'How custom fields work')));
174
- host.replaceChildren(...sections.filter(Boolean));
337
+ table.replaceChildren(...parts);
338
+ foot.replaceChildren(h('span', {}, `${data.fields.length} ${data.fields.length === 1 ? 'field' : 'fields'}`),
339
+ h('a', { href: DOCS, target: '_blank', rel: 'noopener' }, 'How custom fields work'));
175
340
  }
341
+ const foot = h('div', { class: 'fx-cfm__foot' });
342
+ host.append(foot);
176
343
 
177
344
  async function load() {
178
345
  try {
@@ -155,7 +155,8 @@ body:is([data-state="off"], [data-state="error"], [data-state="no-access"]) .fx-
155
155
  /* Folded (wide screens only): the sidebar keeps its icons, the menu button
156
156
  and the account photo; each page's name is still read out. */
157
157
  @media (min-width: 821px) {
158
- .fx-shell[data-folded] .fx-side { width: 64px; padding-inline: 10px; }
158
+ .fx-shell[data-folded] .fx-side { width: 64px; padding-inline: 10px; cursor: e-resize; }
159
+ .fx-shell[data-folded] .fx-side :is(a, button, #user-button) { cursor: pointer; }
159
160
  .fx-shell[data-folded] .fx-side__head { flex-direction: column; gap: 10px; padding: 0 0 14px; }
160
161
  .fx-shell[data-folded] .fx-side__head .fx-brand { display: none; }
161
162
  .fx-shell[data-folded] .fx-nav__group { height: 1px; margin: 10px 6px; padding: 0; overflow: hidden; background: var(--fx-line); color: transparent; }
@@ -223,6 +224,23 @@ body:is([data-state="off"], [data-state="error"], [data-state="no-access"]) .fx-
223
224
  .fx-status[data-tone="warn"] { color: var(--t-warn); background: var(--t-warn-bg); }
224
225
  .fx-status[data-tone="mute"] { color: var(--t-dim); background: var(--fx-hover); }
225
226
  .fx-status[data-tone="danger"] { color: var(--t-danger); background: var(--t-danger-bg); }
227
+ .fx-links { white-space: nowrap; }
228
+ .fx-links > * + * { margin-left: 6px; }
229
+ .fx-link { position: relative; display: inline-grid; place-items: center; width: 26px; height: 26px; border: 1px solid var(--fx-line); border-radius: 8px; vertical-align: middle; }
230
+ .fx-link img { width: 15px; height: 15px; }
231
+ a.fx-link:hover, a.fx-link:focus-visible { border-color: var(--t-strong); }
232
+ .fx-link--none { border-style: dashed; }
233
+ .fx-link--none img { filter: grayscale(1); opacity: .35; }
234
+ .fx-link__dot { position: absolute; right: -3px; bottom: -3px; width: 10px; height: 10px; border-radius: 50%; border: 2px solid var(--t-paper); background: var(--t-dim); }
235
+ .fx-link__dot[data-tone="success"] { background: var(--t-success); }
236
+ .fx-link__dot[data-tone="warn"] { background: var(--t-warn); }
237
+ .fx-link__dot[data-tone="danger"] { background: var(--t-danger); }
238
+ .fx-linked { display: grid; }
239
+ .fx-linked__row { display: flex; align-items: center; gap: 8px; white-space: nowrap; min-height: 44px; padding: 6px 0; border-top: 1px solid var(--fx-line-soft); }
240
+ .fx-linked__row:first-child { border-top: 0; }
241
+ .fx-linked__ref { font-family: var(--t-data); font-variant-numeric: tabular-nums; color: var(--t-strong); }
242
+ .fx-linked__open { margin-left: auto; font-size: 12.5px; color: var(--t-dim); white-space: nowrap; }
243
+ .fx-linked__open:hover { color: var(--t-strong); }
226
244
  .fx-tag {
227
245
  margin-left: 8px; padding: 1px 7px; border: 1px solid var(--fx-line); border-radius: 999px;
228
246
  color: var(--t-dim); font-size: 11.5px; font-weight: 600;
@@ -258,9 +276,6 @@ body:is([data-state="off"], [data-state="error"], [data-state="no-access"]) .fx-
258
276
  font-size: 12.5px; font-weight: 700; letter-spacing: .02em;
259
277
  }
260
278
  .fx-empty { padding: 28px 16px; color: var(--t-dim); text-align: center; }
261
- .fx-roles { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; margin: 0; font-size: 13.5px; color: var(--t-dim); }
262
- .fx-roles dt { color: var(--t-strong); font-weight: 600; }
263
- .fx-roles dd { margin: 0; }
264
279
 
265
280
  /* ── Integrations ───────────────────────────────────────────────────────── */
266
281
  .fx-chip { border: 1px solid var(--fx-line); border-radius: 999px; background: var(--t-paper); color: var(--t-text); }
@@ -498,12 +513,13 @@ a.fx-strong:hover { text-decoration: underline; }
498
513
  .fx-table__group-row { display: flex; align-items: center; gap: 12px; }
499
514
  .fx-table__group-row .fx-board__sum { margin-left: 0; }
500
515
  .fx-stage[data-rank="plain"] { --stage: var(--t-dim); }
501
- /* The board never scrolls sideways: its columns (one per work system status)
502
- are about a phone wide, wrap onto more rows when the page is narrow, and
503
- stack on a phone. On a wide, tall screen the board fills the page down to
504
- its bottom edge, and each column scrolls inside itself (jobs.js sets
505
- fx-main--board while the board shows). */
506
- .fx-board { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 256px), 1fr)); gap: 12px; align-items: start; }
516
+ /* The board is one row of lanes (one per group), each about a phone wide.
517
+ Lanes share the width when they fit; more lanes scroll sideways inside
518
+ the board, never as a second row. On a wide, tall screen the board fills
519
+ the page down to its bottom edge, the page itself does not scroll, and
520
+ each lane scrolls inside itself (jobs.js sets fx-main--board while the
521
+ board shows). */
522
+ .fx-board { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(min(85vw, 272px), 1fr); gap: 12px; align-items: start; overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: thin; padding-bottom: 4px; }
507
523
  .fx-board__col { display: flex; flex-direction: column; gap: 8px; min-width: 0; min-height: 0; padding: 10px 6px 6px 8px; border-radius: var(--t-radius-lg); background: var(--t-s1); }
508
524
  .fx-board__head { display: flex; flex: none; align-items: center; gap: 8px; min-width: 0; padding: 2px 4px 4px 2px; }
509
525
  .fx-board__sum { flex: none; margin-left: auto; color: var(--t-dim); font-size: 12px; font-variant-numeric: tabular-nums; }
@@ -511,7 +527,7 @@ a.fx-strong:hover { text-decoration: underline; }
511
527
  @media (min-width: 821px) and (min-height: 560px) {
512
528
  .fx-main--board { height: 100vh; height: 100dvh; padding: 24px 24px 16px; grid-template-rows: auto auto minmax(0, 1fr); align-content: stretch; }
513
529
  .fx-main--board > .fx-jobs { min-height: 0; }
514
- .fx-main--board .fx-board { height: 100%; overflow-y: auto; grid-auto-rows: 100%; align-items: stretch; }
530
+ .fx-main--board .fx-board { height: 100%; overflow-y: hidden; align-items: stretch; }
515
531
  .fx-main--board .fx-board__cards { flex: 1; max-height: none; }
516
532
  }
517
533
  .fx-board__none { padding: 8px 4px; color: var(--t-dim); font-size: 13px; }
@@ -812,6 +828,16 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
812
828
  .fx-table__actions { text-align: left; }
813
829
  .fx-table__actions .fx-row--end { justify-content: flex-start; }
814
830
  .fx-ph__actions, .fx-ph__actions .fx-btn { width: 100%; }
831
+ /* People: the name with its actions (⋯, Approve) on one line, then the
832
+ role select full width, then the status pill when there is one. */
833
+ .fx-people tr { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 8px 10px; }
834
+ .fx-people td, .fx-people td:nth-child(2), .fx-people td:nth-child(3) { display: block; width: auto; margin: 0; padding: 0; }
835
+ .fx-people td:nth-child(1) { grid-area: 1 / 1; }
836
+ .fx-people td:nth-child(4) { grid-area: 1 / 2; }
837
+ .fx-people td:nth-child(2) { grid-area: 2 / 1 / 3 / -1; }
838
+ .fx-people td:nth-child(2) .fx-select { width: 100%; }
839
+ .fx-people td:nth-child(3) { grid-area: 3 / 1 / 4 / -1; }
840
+ .fx-people td:nth-child(3):empty { display: none; }
815
841
  }
816
842
 
817
843
  @keyframes fx-rise { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
@@ -856,6 +882,70 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
856
882
  .fx-cf__line .fx-input, .fx-cf__line .fx-select { width: 100%; min-width: 0; }
857
883
  .fx-cf__color { border-left: 4px solid var(--cf); }
858
884
  @media (max-width: 560px) { .fx-cf__line { grid-template-columns: minmax(0, 1fr) 96px; } }
885
+ /* Custom fields manager (custom-fields.js), like ClickUp's Custom Field
886
+ Manager: a toolbar, then one table of type groups that fold. */
887
+ .fx-lucide { display: inline-block; flex: none; width: 16px; height: 16px; background: currentColor; -webkit-mask: var(--icon) center / contain no-repeat; mask: var(--icon) center / contain no-repeat; }
888
+ .fx-cfm__card { padding: 0; overflow: hidden; }
889
+ .fx-cfm__bar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 12px 16px; border-bottom: 1px solid var(--fx-line-soft); }
890
+ .fx-cfm__search { width: 220px; min-width: 0; height: 32px; }
891
+ .fx-cfm__tool { min-height: 32px; }
892
+ .fx-cfm__tool--dashed { border-style: dashed; }
893
+ .fx-cfm__gap { flex: 1; }
894
+ .fx-cfm { display: grid; grid-template-columns: minmax(160px, 1fr) 64px 96px 120px minmax(90px, .6fr) 44px; }
895
+ .fx-cfm > [role="row"], .fx-cfm > .fx-notice, .fx-cfm > .fx-cfm__create, .fx-cfm > .fx-cfm__empty { grid-column: 1 / -1; }
896
+ .fx-cfm__head, .fx-cfm__row { display: grid; grid-template-columns: subgrid; align-items: center; }
897
+ .fx-cfm__head { height: 36px; padding: 0 8px 0 36px; color: var(--t-dim); font-size: 12.5px; border-bottom: 1px solid var(--fx-line-soft); }
898
+ .fx-cfm__sort { all: unset; cursor: pointer; display: inline-flex; gap: 4px; align-items: center; }
899
+ .fx-cfm__sort:focus-visible { box-shadow: var(--fx-focus); border-radius: 4px; }
900
+ .fx-cfm__sort .fx-lucide { width: 13px; height: 13px; }
901
+ .fx-cfm__group { display: flex; align-items: center; gap: 8px; height: 44px; margin-top: 12px; padding: 0 16px 0 8px; border-bottom: 1px solid var(--fx-line-soft); }
902
+ .fx-cfm__group:first-of-type { margin-top: 4px; }
903
+ .fx-cfm__fold { all: unset; cursor: pointer; display: inline-grid; place-items: center; width: 24px; height: 24px; border-radius: 6px; color: var(--t-dim); }
904
+ .fx-cfm__fold:hover { background: var(--fx-hover); color: var(--t-strong); }
905
+ .fx-cfm__fold:focus-visible { box-shadow: var(--fx-focus); }
906
+ .fx-cfm__fold .fx-lucide { width: 14px; height: 14px; transition: transform .15s var(--fx-ease); }
907
+ .fx-cfm__fold[aria-expanded="false"] .fx-lucide { transform: rotate(-90deg); }
908
+ .fx-cfm__pill { display: inline-flex; align-items: center; gap: 6px; padding: 3px 9px; border-radius: 6px; font-size: 12.5px; font-weight: 600; color: var(--cf); background: color-mix(in srgb, var(--cf) 14%, transparent); }
909
+ .fx-cfm__pill .fx-lucide { width: 14px; height: 14px; }
910
+ .fx-cfm__count { color: var(--t-dim); font-size: 12.5px; font-weight: 600; }
911
+ .fx-cfm__row { min-height: 36px; padding: 0 8px 0 36px; border-bottom: 1px solid var(--fx-line-soft); font-size: 14px; }
912
+ .fx-cfm__row:hover { background: var(--fx-hover); }
913
+ .fx-cfm__row[data-archived] .fx-cfm__label { color: var(--t-dim); }
914
+ .fx-cfm__name { display: flex; align-items: center; gap: 8px; min-width: 0; padding-right: 8px; color: var(--t-strong); }
915
+ .fx-cfm__name > .fx-lucide { width: 13px; height: 13px; color: var(--t-dim); }
916
+ .fx-cfm__label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
917
+ .fx-cfm__edit { display: none; align-items: center; gap: 5px; flex: none; height: 24px; padding: 0 7px; border: 1px solid var(--fx-line); border-radius: 6px; background: var(--t-paper); color: var(--t-text); font: inherit; font-size: 12px; cursor: pointer; }
918
+ .fx-cfm__edit .fx-lucide { width: 13px; height: 13px; }
919
+ .fx-cfm__row:hover .fx-cfm__edit, .fx-cfm__edit:focus-visible { display: inline-flex; }
920
+ .fx-cfm__edit:focus-visible { box-shadow: var(--fx-focus); outline: none; }
921
+ .fx-cfm__rename { height: 28px; width: 100%; max-width: 320px; }
922
+ .fx-cfm__type { color: var(--cf); }
923
+ .fx-cfm__type .fx-lucide { width: 17px; height: 17px; }
924
+ .fx-cfm__avatar { display: inline-grid; place-items: center; width: 24px; height: 24px; border-radius: 50%; background: var(--t-primary); color: var(--t-on-primary); font-size: 10.5px; font-weight: 700; letter-spacing: .02em; }
925
+ .fx-cfm__avatar .fx-lucide { width: 13px; height: 13px; }
926
+ .fx-cfm__date, .fx-cfm__where { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
927
+ .fx-cfm__row .fx-cfm__where { font-weight: 600; }
928
+ .fx-cfm__end { justify-self: end; }
929
+ .fx-cfm__more { all: unset; cursor: pointer; display: inline-grid; place-items: center; width: 30px; height: 28px; border-radius: 6px; color: var(--t-dim); }
930
+ .fx-cfm__more:hover, .fx-cfm__more[aria-expanded="true"] { background: var(--fx-hover); color: var(--t-strong); }
931
+ .fx-cfm__more:focus-visible { box-shadow: var(--fx-focus); }
932
+ .fx-cfm__create { all: unset; cursor: pointer; display: flex; align-items: center; gap: 6px; height: 36px; padding: 0 16px 0 36px; color: var(--t-dim); font-size: 13px; }
933
+ .fx-cfm__create:hover, .fx-cfm__create:focus-visible { color: var(--t-strong); background: var(--fx-hover); }
934
+ .fx-cfm__create .fx-lucide { width: 14px; height: 14px; }
935
+ .fx-cfm__empty { margin: 0; padding: 20px 16px; color: var(--t-dim); }
936
+ .fx-cfm > .fx-notice { margin: 12px 16px 0; }
937
+ .fx-cfm__foot { display: flex; justify-content: space-between; gap: 12px; padding: 12px 16px; border-top: 1px solid var(--fx-line-soft); color: var(--t-dim); font-size: 13px; }
938
+ .fx-pop__item:disabled { opacity: .5; cursor: default; }
939
+ .fx-pop__item[data-tone="danger"], .fx-pop__item[data-tone="danger"] .fx-pop__tick { color: var(--t-danger); }
940
+ .fx-pop__item .fx-lucide { width: 15px; height: 15px; }
941
+ @media (max-width: 720px) {
942
+ .fx-cfm { grid-template-columns: minmax(0, 1fr) 40px 40px; }
943
+ .fx-cfm .fx-cfm__by, .fx-cfm .fx-cfm__date, .fx-cfm .fx-cfm__where { display: none; }
944
+ .fx-cfm__head, .fx-cfm__row { padding-left: 16px; }
945
+ .fx-cfm__create { padding-left: 16px; }
946
+ .fx-cfm__search { flex: 1 1 100%; width: auto; }
947
+ .fx-cfm__edit { display: none !important; }
948
+ }
859
949
  /* Invoice rules (invoice-rules.js): a row's Edit button and switch, and the
860
950
  rule dialog's condition lines. */
861
951
  .fx-rule__controls { align-items: center; gap: 10px; }
@@ -945,3 +1035,60 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
945
1035
  .fx-pick__row[hidden] { display: none; }
946
1036
  .fx-pick__main { display: grid; gap: 2px; min-width: 0; }
947
1037
  .fx-pick > .fx-dim { padding: 14px; }
1038
+
1039
+ /* The Invoices list head (invoicing.js): tabs, search and the send bar stay
1040
+ at the top while rows scroll up under them; a shadow shows once they do.
1041
+ Narrow, the app bar (60px) sits above it. */
1042
+ .fx-listhead { position: sticky; top: 0; z-index: 5; margin: -12px calc(-1 * clamp(16px, 4vw, 48px)) 0; padding: 12px clamp(16px, 4vw, 48px) 0; background: var(--t-bg); transition: box-shadow .15s; }
1043
+ .fx-listhead[data-stuck] { box-shadow: 0 10px 12px -12px rgba(0, 0, 0, .45); }
1044
+ .fx-listhead .fx-sendbar { margin-bottom: 12px; }
1045
+ .fx-listhead__edge { height: 0; margin-bottom: -20px; }
1046
+ @media (max-width: 820px) { .fx-listhead { top: 60px; } }
1047
+ /* On a phone the head would hide too many rows: it scrolls away. */
1048
+ @media (max-width: 560px) { .fx-listhead { position: static; } }
1049
+
1050
+ /* The bulk action bar (bulk-bar.js): floats at the foot while rows are
1051
+ ticked. "N selected ×", then icon actions in groups, then More. It takes
1052
+ the theme's own surface, line, corners and primary button, like a menu. */
1053
+ .fx-bulk {
1054
+ position: sticky; bottom: 16px; z-index: 6; justify-self: center; display: flex; align-items: center; gap: 2px; max-width: 100%;
1055
+ padding: 5px; border: 1px solid var(--fx-line); border-radius: var(--t-radius-lg); background: var(--t-paper); color: var(--t-text);
1056
+ box-shadow: var(--t-shadow), 0 12px 32px rgba(0, 0, 0, .16);
1057
+ font: 500 13.5px/1 var(--t-body); white-space: nowrap;
1058
+ }
1059
+ .fx-bulk[hidden] { display: none; }
1060
+ .fx-bulk[aria-busy="true"] { opacity: .7; pointer-events: none; }
1061
+ .fx-bulk__acts { display: flex; align-items: center; gap: 2px; min-width: 0; }
1062
+ .fx-bulk__count { display: inline-flex; align-items: center; gap: 6px; flex: none; margin-right: 6px; padding: 3px 4px 3px 10px; border: 1px solid var(--t-accent-brd, var(--fx-line)); border-radius: var(--t-radius); background: var(--t-accent-bg); color: var(--t-accent); font-weight: 600; }
1063
+ .fx-bulk__clear, .fx-bulk__act { position: relative; display: inline-flex; align-items: center; gap: 6px; padding: 7px 9px; border: 1px solid transparent; border-radius: var(--t-radius); background: none; color: inherit; font: inherit; cursor: pointer; }
1064
+ .fx-bulk__clear { padding: 3px; color: inherit; }
1065
+ .fx-bulk__act:hover, .fx-bulk__act:focus-visible, .fx-bulk__act[aria-expanded="true"] { background: var(--fx-hover); color: var(--t-strong); outline: none; }
1066
+ .fx-bulk__clear:hover, .fx-bulk__clear:focus-visible { background: color-mix(in srgb, var(--t-accent) 14%, transparent); outline: none; }
1067
+ .fx-bulk__act--primary, .fx-bulk__act--primary:hover, .fx-bulk__act--primary:focus-visible { background: var(--t-primary); border-color: var(--t-primary); color: var(--t-on-primary); font-weight: 600; }
1068
+ .fx-bulk__act--primary:hover { background: color-mix(in srgb, var(--t-primary) 86%, var(--t-text)); }
1069
+ .fx-bulk__act--icon { padding: 7px; }
1070
+ .fx-bulk__act[data-tone="danger"] { color: var(--t-danger); }
1071
+ .fx-bulk__act[data-tone="danger"]:hover { background: var(--t-danger-bg); }
1072
+ .fx-bulk__sep { flex: none; width: 1px; height: 18px; margin: 0 4px; background: var(--fx-line); }
1073
+ /* An icon-only action says its name on hover, in a tip above it. */
1074
+ .fx-bulk [data-tip]:hover::after, .fx-bulk [data-tip]:focus-visible::after {
1075
+ content: attr(data-tip); position: absolute; bottom: calc(100% + 10px); left: 50%; transform: translateX(-50%);
1076
+ padding: 4px 8px; border: 1px solid var(--fx-line); border-radius: var(--t-radius); background: var(--t-paper); color: var(--t-strong);
1077
+ font: 500 12px/1.2 var(--t-body); pointer-events: none; box-shadow: var(--t-shadow), 0 4px 12px rgba(0, 0, 0, .12);
1078
+ }
1079
+ /* More: a compact list, one row per action, like a dropdown. */
1080
+ .fx-pop.fx-bulk__more { width: 248px; min-width: 0; padding: 4px; }
1081
+ .fx-bulk__search { width: 100%; height: 30px; margin-bottom: 2px; padding: 4px 8px; font-size: 13px; }
1082
+ .fx-bulk__more .fx-pop__head { padding: 6px 8px 2px; font-size: 10.5px; }
1083
+ .fx-bulk__more .fx-pop__item { gap: 8px; padding: 4px 8px; font-size: 13px; line-height: 1.25; }
1084
+ .fx-bulk__more .fx-pop__item .fx-lucide { width: 14px; height: 14px; }
1085
+ .fx-bulk__grid { display: grid; }
1086
+ .fx-bulk__grid .fx-pop__item[data-tone="danger"] { color: var(--t-danger); }
1087
+ .fx-bulk__none { margin: 8px 10px; color: var(--t-dim); font-size: 13.5px; }
1088
+ @media (max-width: 560px) {
1089
+ .fx-bulk { bottom: 10px; padding: 4px; }
1090
+ .fx-bulk__count { margin-right: 2px; padding-left: 8px; }
1091
+ .fx-bulk__act { padding: 7px 6px; }
1092
+ .fx-bulk__sep { margin: 0 2px; }
1093
+ .fx-bulk__act:not(.fx-bulk__act--primary) .fx-bulk__word { display: none; }
1094
+ }