@arcforgelabs/flow 2026.10.43 → 2026.10.45

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.
@@ -17,7 +17,11 @@
17
17
  // /jobs/:id/no-invoice (docs/JOB_INVOICE_LINKS.md).
18
18
  import { bulkBar } from '/assets/ui/bulk-bar.js';
19
19
  import { lucide } from '/assets/ui/custom-fields.js';
20
- import { filterJobs, MATCHES } from '/assets/ui/job-view.js';
20
+ import { columnTable, rowCells, viewSaver } from '/assets/ui/columns.js';
21
+ import { statusRank } from '/assets/ui/job-board.js';
22
+ import { filterJobs, KIND_OF_TYPE, normaliseListView, RECONCILE_COLUMNS } from '/assets/ui/job-view.js';
23
+ import { normaliseReconcileView, RECONCILE_FILTER_FIELDS } from '/assets/ui/list-views.js';
24
+ import { savedViews } from '/assets/ui/saved-views.js';
21
25
  import { findBox, today, viewBar } from '/assets/ui/view-bar.js';
22
26
  import { formatCents } from '/assets/ui/invoicing.js';
23
27
 
@@ -97,15 +101,10 @@ const CHEVRONS = { prev: 'M14.5 6l-6 6 6 6', next: 'M9.5 6l6 6-6 6' };
97
101
  const FILTER_KEY = 'flow_reconcile_filters';
98
102
  const NAV_KEY = 'flow_reconcile_nav';
99
103
  // The fields a filter row can use. One word each; Type's choices have icons.
100
- const FILTER_FIELDS = {
101
- type: { label: 'Type', kind: 'choice' },
102
- client: { label: 'Client', kind: 'choice' },
103
- practitioner: { label: 'Practitioner', kind: 'choice' },
104
- date: { label: 'Date', kind: 'date' },
105
- total: { label: 'Total', kind: 'number' },
106
- match: { label: 'Match', kind: 'check' },
107
- };
108
- const MATCH_OPS = { yes: 'has one', no: 'has none' };
104
+ // The filter's fields: list-views.js (the server checks saved views with them).
105
+ const FILTER_FIELDS = RECONCILE_FILTER_FIELDS;
106
+ const MATCH_OPS = { yes: 'Has one', no: 'Has none' };
107
+ const SET_OPS = { is: 'Is', not: 'Is not' };
109
108
  const TYPES = [{ value: 'job', label: 'Job', icon: 'briefcase' }, { value: 'invoice', label: 'Invoice', icon: 'file-text' }];
110
109
 
111
110
  function readStored(store, key, fallback) {
@@ -118,35 +117,47 @@ function writeStored(store, key, value) {
118
117
  // One row for each gap, jobs and invoices together, oldest first.
119
118
  export function reconcileRows({ jobs = [], invoices = [] }) {
120
119
  return [
121
- ...jobs.map((j) => ({ kind: 'job', id: j.id, client: j.clientName, who: j.practitioner, date: j.jobDate, totalCents: j.totalCents, possible: j.possible, unclear: j.unclear })),
122
- ...invoices.map((i) => ({ kind: 'invoice', id: i.id, client: i.clientName, who: i.number ?? 'Draft', status: i.status, date: i.issueDate, totalCents: i.totalCents, possible: i.possible })),
120
+ ...jobs.map((j) => ({
121
+ kind: 'job', id: j.id, client: j.clientName, who: j.practitioner, stage: j.status ?? null, date: j.jobDate, totalCents: j.totalCents, possible: j.possible, unclear: j.unclear,
122
+ key: j.sourceSystem && j.sourceJobRef ? `${j.sourceSystem}\u0000${j.sourceJobRef}` : null,
123
+ })),
124
+ ...invoices.map((i) => ({ kind: 'invoice', id: i.id, client: i.clientName, who: i.number ?? 'Draft', status: i.status, stage: statusWords(i.status), date: i.issueDate, totalCents: i.totalCents, possible: i.possible })),
123
125
  ].sort((a, b) => String(a.date ?? '9999').localeCompare(String(b.date ?? '9999')));
124
126
  }
125
127
 
126
- // A row's value for a filter field, as the filter compares it.
127
- export function rowValue(row, field) {
128
+ const isCustom = (field) => /^field:/.test(field);
129
+
130
+ // A row's value for a filter field, as the filter compares it. `valueOf`
131
+ // reads a job's custom field value: (row, fieldId) => value.
132
+ export function rowValue(row, field, valueOf = () => null) {
128
133
  switch (field) {
129
134
  case 'type': return row.kind;
135
+ case 'status': return row.stage ?? null;
130
136
  case 'client': return row.client ?? null;
131
137
  case 'practitioner': return row.kind === 'job' ? row.who ?? null : null;
132
138
  case 'date': return row.date ?? null;
133
139
  case 'total': return row.totalCents == null ? null : row.totalCents / 100;
134
140
  case 'match': return row.possible > 0;
135
- default: return null;
141
+ default: return isCustom(field) && row.kind === 'job' ? valueOf(row, field.slice(6)) : null;
136
142
  }
137
143
  }
138
144
 
139
- // The rows a view keeps: its filter rows, then the search words.
140
- export function keptRows(rows, view, q = '') {
145
+ // A view without the rows a record does not have: an invoice has no
146
+ // practitioner and no custom fields.
147
+ const forInvoices = (view) => ({ ...view, groups: view.groups.map((g) => ({ ...g, filters: g.filters.filter((f) => !FILTER_FIELDS[f.field]?.job && !isCustom(f.field)) })) });
148
+
149
+ // The rows a view keeps: its filter rows, then the search words. `kindOf`
150
+ // gives a field's kind (custom fields too); `valueOf` a custom value.
151
+ export function keptRows(rows, view, q = '', { kindOf = (field) => FILTER_FIELDS[field]?.kind ?? null, valueOf = () => null, now = today() } = {}) {
141
152
  const words = q.trim().toLowerCase();
142
- return filterJobs(rows, view, { valueOf: rowValue, kindOf: (field) => FILTER_FIELDS[field]?.kind ?? null, today: today() })
153
+ const keep = (list, v) => filterJobs(list, v, { valueOf: (row, field) => rowValue(row, field, valueOf), kindOf, today: now });
154
+ const kept = new Set([...keep(rows.filter((r) => r.kind === 'job'), view), ...keep(rows.filter((r) => r.kind !== 'job'), forInvoices(view))]);
155
+ return rows.filter((r) => kept.has(r))
143
156
  .filter((r) => !words || [r.client, r.who, money(r.totalCents)].some((v) => String(v ?? '').toLowerCase().includes(words)));
144
157
  }
145
158
 
146
159
  function readView() {
147
- const saved = readStored(localStorage, FILTER_KEY, {});
148
- const filters = Array.isArray(saved.filters) ? saved.filters.filter((f) => f && Object.hasOwn(FILTER_FIELDS, f.field)) : [];
149
- return { match: MATCHES.includes(saved.match) ? saved.match : 'all', filters };
160
+ return normaliseReconcileView(readStored(localStorage, FILTER_KEY, {}));
150
161
  }
151
162
 
152
163
  export async function renderReconcile(main, ui, id = null) {
@@ -158,43 +169,84 @@ const pathOf = (row) => `/reconcile/${row.id}${row.kind === 'invoice' ? '?kind=i
158
169
  const KIND_ICON = { job: ['briefcase', 'Job'], invoice: ['file-text', 'Invoice'] };
159
170
 
160
171
  async function renderList(main, ui) {
161
- const { h, flow, toast, pageHeader } = ui;
172
+ const { h, flow, toast, listHead, listFrame } = ui;
162
173
  const icon = (name, label) => lucide(h, name, label);
163
174
  const canAct = ['owner', 'admin'].includes(ui.me?.person?.role);
164
175
  const back = readStored(sessionStorage, NAV_KEY, null);
165
- const state = { view: readView(), q: back?.q ?? '', ticked: new Set() };
176
+ const state = { view: readView(), q: back?.q ?? '', ticked: new Set(), columns: normaliseListView(ui.me?.views?.reconcile, RECONCILE_COLUMNS) };
177
+ // The list's columns: resize, move, hide, kept with the account.
178
+ const saveColumns = viewSaver(ui, 'reconcile');
179
+ const heads = columnTable(h, {
180
+ order: () => state.columns.columns,
181
+ widths: () => state.columns.widths,
182
+ change: (patch) => {
183
+ state.columns = normaliseListView({ ...state.columns, ...patch }, RECONCILE_COLUMNS);
184
+ saveColumns(state.columns);
185
+ draw();
186
+ },
187
+ hidden: () => Object.keys(RECONCILE_COLUMNS).filter((id) => !state.columns.columns.includes(id)).map((id) => ({ id, label: RECONCILE_COLUMNS[id] })),
188
+ });
189
+ let rows;
190
+ // A change to the filter: kept in this browser, and drawn.
191
+ const setView = (patch) => {
192
+ state.view = normaliseReconcileView({ ...state.view, ...patch });
193
+ writeStored(localStorage, FILTER_KEY, state.view);
194
+ views.draw();
195
+ if (rows) draw();
196
+ };
197
+ // The person's named views (saved-views.js): their default opens on a
198
+ // new visit.
199
+ const views = savedViews(ui, { page: 'reconcile', current: () => state.view, apply: setView });
200
+ const opening = views.opening();
201
+ if (opening) state.view = normaliseReconcileView(opening);
166
202
  const find = findBox(h, { label: 'Find a client, practitioner or amount', value: state.q, oninput: (words) => { state.q = words; draw(); } });
167
203
  const controls = h('div', { class: 'fx-viewbar', role: 'group', 'aria-label': 'Filter' });
168
204
  const host = h('div', { class: 'fx-rec' }, h('div', { class: 'fx-skeleton' }));
169
205
  const bulk = bulkBar(ui, { label: 'Ticked jobs' });
170
- main.append(
171
- pageHeader('Reconcile'),
172
- h('div', { class: 'fx-toolbar fx-toolbar--plain' }, controls, h('div', { class: 'fx-toolbar__end' }, find.element)),
173
- host, bulk.element);
174
-
175
- // The Filter chip: the same rows as on Jobs (view-bar.js).
206
+ const frame = listFrame(main, host);
207
+ main.append(...listHead('Reconcile', { views: views.element, start: controls, end: find.element }).nodes, frame.element, bulk.element);
208
+
209
+ // The Filter chip: the same panel as on Jobs (view-bar.js). Job rows also
210
+ // have the lab's custom fields.
211
+ const custom = { fields: [], people: [], values: new Map() };
212
+ const fieldById = (id) => custom.fields.find((f) => f.id === id) ?? null;
213
+ const kindOf = (field) => FILTER_FIELDS[field]?.kind ?? (isCustom(field) ? KIND_OF_TYPE[fieldById(field.slice(6))?.type] ?? null : null);
214
+ const valueOf = (row, fieldId) => (row.key ? custom.values.get(`${row.key}\u0000${fieldId}`) ?? null : null);
176
215
  const labels = (list) => [...new Set(list.filter(Boolean))].sort((a, b) => a.localeCompare(b)).map((v) => ({ value: v, label: v }));
177
216
  const filterMenu = viewBar(h).filterMenu({
178
- fields: () => Object.entries(FILTER_FIELDS).map(([value, f]) => ({ value, label: f.label })),
179
- kindOf: (field) => FILTER_FIELDS[field]?.kind ?? null,
180
- opsOf: (field) => (field === 'match' ? MATCH_OPS : null),
181
- choicesOf: (field) => (field === 'type' ? TYPES
182
- : field === 'client' ? labels(rows.map((r) => r.client))
183
- : field === 'practitioner' ? labels(rows.filter((r) => r.kind === 'job').map((r) => r.who)) : []),
184
- view: () => state.view,
185
- change: (patch) => {
186
- state.view = { ...state.view, ...patch };
187
- writeStored(localStorage, FILTER_KEY, state.view);
188
- draw();
217
+ fields: () => [
218
+ ...Object.entries(FILTER_FIELDS).map(([value, f]) => ({ value, label: f.label, icon: f.icon })),
219
+ ...custom.fields.filter((f) => KIND_OF_TYPE[f.type]).map((f) => ({ value: `field:${f.id}`, label: f.name })),
220
+ ],
221
+ kindOf,
222
+ opsOf: (field) => (field === 'match' ? MATCH_OPS : ['type', 'status'].includes(field) ? SET_OPS : null),
223
+ choicesOf: (field) => {
224
+ if (field === 'type') return TYPES;
225
+ if (field === 'status') return labels(rows.filter((r) => r.kind === 'job').map((r) => r.stage)).map((c) => ({ ...c, rank: statusRank(c.value) }))
226
+ .concat(labels(rows.filter((r) => r.kind !== 'job').map((r) => r.stage)).filter((c) => !rows.some((r) => r.kind === 'job' && r.stage === c.value)));
227
+ if (field === 'client') return labels(rows.map((r) => r.client));
228
+ if (field === 'practitioner') return labels(rows.filter((r) => r.kind === 'job').map((r) => r.who));
229
+ const f = isCustom(field) ? fieldById(field.slice(6)) : null;
230
+ if (f?.type === 'person') return [{ value: 'me', label: 'Me' }, ...custom.people.map((p) => ({ value: p.id, label: p.name }))];
231
+ return (f?.options ?? []).map((o) => ({ value: o.id, label: o.archived ? `${o.name} (archived)` : o.name, color: o.color }));
189
232
  },
233
+ view: () => state.view,
234
+ change: setView,
190
235
  first: 'type',
191
236
  empty: 'No filters. Every job and invoice shows.',
192
237
  });
193
238
  controls.append(...filterMenu.node);
194
239
 
195
- let rows;
240
+ views.draw();
196
241
  try {
197
- rows = reconcileRows(await flow('/api/invoicing/reconcile'));
242
+ // Custom fields are extra: the list still shows without them.
243
+ const [lists, fields, values] = await Promise.all([
244
+ flow('/api/invoicing/reconcile'), flow('/api/custom-fields').catch(() => null), flow('/api/custom-fields/values').catch(() => null),
245
+ ]);
246
+ rows = reconcileRows(lists);
247
+ custom.fields = fields?.fields ?? [];
248
+ custom.people = fields?.people ?? [];
249
+ custom.values = new Map((values?.values ?? []).map((v) => [`${v.sourceSystem}\u0000${v.sourceJobRef}\u0000${v.fieldId}`, v.value]));
198
250
  } catch (error) {
199
251
  host.replaceChildren(h('p', { class: 'fx-empty' }, error.message));
200
252
  return;
@@ -202,8 +254,9 @@ async function renderList(main, ui) {
202
254
 
203
255
  function draw() {
204
256
  filterMenu.draw();
205
- const shown = keptRows(rows, state.view, state.q);
257
+ const shown = keptRows(rows, state.view, state.q, { kindOf, valueOf });
206
258
  const seen = new Set(shown.map((r) => r.id));
259
+ frame.count(`${shown.length} to check`);
207
260
  for (const key of state.ticked) if (!seen.has(key)) state.ticked.delete(key);
208
261
  const remember = () => writeStored(sessionStorage, NAV_KEY, { q: state.q, rows: shown.map(({ kind, id }) => ({ kind, id })) });
209
262
  if (!rows.length) {
@@ -252,31 +305,31 @@ async function renderList(main, ui) {
252
305
  const possible = (r) => (r.possible
253
306
  ? h('span', { class: 'fx-rec__possible', 'data-state': 'some' }, icon('link-2', 'Possible match'), String(r.possible))
254
307
  : h('span', { class: 'fx-rec__possible' }, icon('circle-dashed', 'No possible match')));
255
- host.replaceChildren(h('div', { class: 'fx-panel' }, h('table', { class: 'fx-table fx-table--rows fx-rec-list' },
256
- h('thead', {}, h('tr', {},
257
- canAct ? h('th', { class: 'fx-tick-cell' }, all) : null,
258
- h('th', { class: 'fx-rec-list__kind' }, h('span', { class: 'fx-sr' }, 'Type')),
259
- h('th', {}, 'Client'),
260
- h('th', {}, 'Detail'),
261
- h('th', {}, 'Date'),
262
- h('th', { class: 'fx-num' }, 'Total'),
263
- h('th', { class: 'fx-rec-list__mid' }, 'Possible'))),
264
- h('tbody', {}, shown.map((r) => {
308
+ const ALL = {
309
+ kind: { kind: 'icon', icon: 'layers', className: 'fx-rec-list__kind', cell: (r) => icon(...KIND_ICON[r.kind]) },
310
+ client: { kind: 'client', className: 'fx-strong', cell: (r) => r.client ?? h('span', { class: 'fx-dim' }, 'No client') },
311
+ detail: { kind: 'text', cell: (r) => h('span', { class: r.kind === 'invoice' ? 'fx-num' : 'fx-dim' }, r.who ?? '—', r.kind === 'invoice' ? h('span', { class: 'fx-dim' }, ` · ${statusWords(r.status)}`) : null) },
312
+ date: { kind: 'date', className: 'fx-dim', cell: (r) => shortDate(r.date) },
313
+ total: { kind: 'total', num: true, cell: (r) => money(r.totalCents) },
314
+ possible: { kind: 'status', className: 'fx-rec-list__mid', cell: possible },
315
+ };
316
+ const columns = state.columns.columns.map((id) => ({ id, label: RECONCILE_COLUMNS[id], ...ALL[id] }));
317
+ host.replaceChildren(h('div', { class: 'fx-panel' }, heads.table({
318
+ className: 'fx-table--rows fx-rec-list',
319
+ columns,
320
+ lead: canAct ? [h('th', { class: 'fx-tick-cell' }, all)] : [],
321
+ rows: h('tbody', {}, shown.map((r) => {
265
322
  const box = r.kind === 'job' ? h('input', { type: 'checkbox', class: 'fx-tick', 'aria-label': `Tick ${r.client ?? 'job'}`, checked: state.ticked.has(r.id), onclick: (event) => event.stopPropagation(), onchange: () => {
266
323
  if (box.checked) state.ticked.add(r.id); else state.ticked.delete(r.id);
267
324
  drawBar();
268
325
  } }) : null;
269
326
  if (box) boxes.set(r.id, box);
270
- const [glyph, label] = KIND_ICON[r.kind];
271
327
  return h('tr', { 'data-kind': r.kind, onclick: () => { remember(); ui.go(pathOf(r)); } },
272
328
  canAct ? h('td', { class: 'fx-tick-cell', onclick: (event) => event.stopPropagation() }, box) : null,
273
- h('td', { class: 'fx-rec-list__kind' }, icon(glyph, label)),
274
- h('td', { class: 'fx-strong' }, r.client ?? h('span', { class: 'fx-dim' }, 'No client')),
275
- h('td', { class: r.kind === 'invoice' ? 'fx-num' : 'fx-dim' }, r.who ?? '—', r.kind === 'invoice' ? h('span', { class: 'fx-dim' }, ` · ${statusWords(r.status)}`) : null),
276
- h('td', { class: 'fx-dim' }, shortDate(r.date)),
277
- h('td', { class: 'fx-num' }, money(r.totalCents)),
278
- h('td', { class: 'fx-rec-list__mid' }, possible(r)));
279
- })))));
329
+ rowCells(h, columns, r),
330
+ h('td', { class: 'fx-c-end' }));
331
+ })),
332
+ })));
280
333
  drawBar();
281
334
  }
282
335
  draw();
@@ -21,7 +21,7 @@ const themeDir = join(here, '..', 'theme');
21
21
  const logoDir = join(here, 'logos');
22
22
  const LOGO_FILES = new Set(['xero.svg', 'seazona.svg', 'sentry.svg']);
23
23
  const THEME_FILES = new Set(['themes.js', 'color-contrast.js', 'openclaw-palettes.js', 'font-families.js']);
24
- const UI_FILES = new Set(['flow.css', 'appearance.js', 'clerk-appearance.js', 'app.js', 'invoicing.js', 'invoice-notes.js', 'job-change.js', 'sidecar.js', 'invoicing-settings.js', 'automations.js', 'invoice-rules.js', 'line-mappings.js', 'price-list.js', 'price-match.js', 'price-reconcile.js', 'custom-fields.js', 'jobs.js', 'job-board.js', 'reconcile.js', 'xero-import.js', 'job-view.js', 'monitoring.js', 'search.js', 'alerts.js', 'bulk-bar.js', 'view-bar.js', 'tip.js', 'flow-mark.svg']);
24
+ export const UI_FILES = new Set(['flow.css', 'appearance.js', 'clerk-appearance.js', 'app.js', 'invoicing.js', 'invoice-notes.js', 'job-change.js', 'sidecar.js', 'invoicing-settings.js', 'automations.js', 'invoice-rules.js', 'line-mappings.js', 'price-list.js', 'price-match.js', 'price-reconcile.js', 'custom-fields.js', 'jobs.js', 'job-board.js', 'reconcile.js', 'xero-import.js', 'job-view.js', 'list-views.js', 'saved-views.js', 'monitoring.js', 'search.js', 'alerts.js', 'bulk-bar.js', 'view-bar.js', 'tip.js', 'kit.js', 'columns.js', 'person.js', 'pager.js', 'flow-mark.svg']);
25
25
  const iconDir = join(dirname(require.resolve('lucide-static/package.json')), 'icons');
26
26
  const ICON_FILE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.svg$/;
27
27
  const MAX_AGE = '1h';
@@ -0,0 +1,141 @@
1
+ // Saved views: the row of named view tabs over a list (Jobs, Invoices,
2
+ // Reconcile), as ClickUp's. A view keeps the page's Group, Sort, Filter and
3
+ // Columns under a name. A tab shows it; "Save" and "Revert" show when the
4
+ // page no longer matches the chosen tab; "+ View" saves the page as a new
5
+ // view. Each tab's ⋯ menu has "Set as my default" (the view that opens
6
+ // when the person comes to the page), Rename and Delete. The views follow
7
+ // the person's account (PUT /api/me/saved-views/:page, list-views.js).
8
+ //
9
+ // const views = savedViews(ui, { page, current, apply });
10
+ // views.element the tab row, for the page to place
11
+ // views.opening() the view to open on, or null: the default view on the
12
+ // first visit in this browser tab, else the page's own
13
+ // views.draw() call after any change to the page's view
14
+ import { lucide } from '/assets/ui/custom-fields.js';
15
+ import { rowMenu } from '/assets/ui/kit.js';
16
+ import { MAX_SAVED_VIEWS, normaliseSavedViews, sameView } from '/assets/ui/list-views.js';
17
+
18
+ const ACTIVE_KEY = (page) => `flow_view_tab_${page}`;
19
+
20
+ function readActive(page) {
21
+ try { return sessionStorage.getItem(ACTIVE_KEY(page)); } catch { return null; }
22
+ }
23
+ function writeActive(page, id) {
24
+ try {
25
+ if (id) sessionStorage.setItem(ACTIVE_KEY(page), id);
26
+ else sessionStorage.removeItem(ACTIVE_KEY(page));
27
+ } catch { /* the tab is forgotten on the next visit */ }
28
+ }
29
+
30
+ const newId = () => (globalThis.crypto?.randomUUID?.() ?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`);
31
+
32
+ export function savedViews(ui, { page, current, apply }) {
33
+ const { h, flow, dialog, toast } = ui;
34
+ let saved = normaliseSavedViews(page, ui.me?.views?.saved?.[page]);
35
+ let active = null;
36
+ const element = h('div', { class: 'fx-views', role: 'tablist', 'aria-label': 'Views' });
37
+ const chosen = () => saved.list.find((v) => v.id === active) ?? null;
38
+
39
+ async function store(next) {
40
+ const before = saved;
41
+ saved = normaliseSavedViews(page, next);
42
+ if (ui.me) ui.me.views = { ...ui.me.views, saved: { ...ui.me.views?.saved, [page]: saved } };
43
+ draw();
44
+ try {
45
+ await flow(`/api/me/saved-views/${page}`, { method: 'PUT', body: JSON.stringify(saved) });
46
+ } catch {
47
+ saved = before;
48
+ draw();
49
+ toast?.('Flow could not save your views. Try again.', 'danger');
50
+ }
51
+ }
52
+
53
+ function choose(id) {
54
+ active = id;
55
+ writeActive(page, id);
56
+ const view = chosen();
57
+ if (view) apply(view.view);
58
+ draw();
59
+ }
60
+
61
+ // A name for a view: a dialog with one box.
62
+ async function askName(title, value, confirm) {
63
+ const data = await dialog({
64
+ title,
65
+ body: h('div', { class: 'fx-fields' }, h('label', { class: 'fx-field' },
66
+ h('span', { class: 'fx-label' }, 'Name'),
67
+ h('input', { class: 'fx-input', name: 'name', required: true, maxlength: '60', autocomplete: 'off', value }))),
68
+ confirm,
69
+ });
70
+ const name = data?.get('name')?.toString().trim();
71
+ return name || null;
72
+ }
73
+
74
+ async function addView() {
75
+ const name = await askName('Save view', `View ${saved.list.length + 1}`, 'Save');
76
+ if (!name) return;
77
+ const id = newId();
78
+ await store({ ...saved, list: [...saved.list, { id, name, view: current() }] });
79
+ active = id;
80
+ writeActive(page, id);
81
+ draw();
82
+ }
83
+
84
+ async function rename(view) {
85
+ const name = await askName('Rename view', view.name, 'Rename');
86
+ if (name) await store({ ...saved, list: saved.list.map((v) => (v.id === view.id ? { ...v, name } : v)) });
87
+ }
88
+
89
+ async function remove(view) {
90
+ const ok = await dialog({ title: `Delete "${view.name}"?`, body: h('p', {}, 'The list stays as it is now.'), confirm: 'Delete', tone: 'danger' });
91
+ if (!ok) return;
92
+ if (active === view.id) { active = null; writeActive(page, null); }
93
+ await store({ list: saved.list.filter((v) => v.id !== view.id), default: saved.default === view.id ? null : saved.default });
94
+ }
95
+
96
+ const setDefault = (view) => store({ ...saved, default: saved.default === view.id ? null : view.id });
97
+
98
+ function draw() {
99
+ const view = chosen();
100
+ const changed = Boolean(view) && !sameView(page, view.view, current());
101
+ const tabs = saved.list.map((v) => {
102
+ const on = v.id === active;
103
+ const isDefault = saved.default === v.id;
104
+ const [more, menu] = rowMenu(`More for ${v.name}`, [
105
+ { icon: isDefault ? 'house' : 'house-plus', text: isDefault ? 'Not my default' : 'Set as my default', run: () => setDefault(v) },
106
+ { icon: 'pencil', text: 'Rename', run: () => rename(v) },
107
+ '-',
108
+ { icon: 'trash-2', text: 'Delete', tone: 'danger', run: () => remove(v) },
109
+ ], { align: 'start' });
110
+ return h('span', { class: 'fx-views__item', 'data-on': String(on) },
111
+ h('button', {
112
+ type: 'button', class: 'fx-views__tab', role: 'tab', 'aria-selected': String(on),
113
+ title: isDefault ? `${v.name} (your default)` : v.name,
114
+ onclick: () => choose(v.id),
115
+ }, isDefault ? lucide(h, 'house', 'Your default') : null, h('span', { class: 'fx-views__name' }, v.name), on && changed ? h('span', { class: 'fx-views__dot', 'aria-label': 'Changed', title: 'Changed' }) : null),
116
+ more, menu);
117
+ });
118
+ element.replaceChildren(...[
119
+ ...tabs,
120
+ saved.list.length < MAX_SAVED_VIEWS ? h('button', { type: 'button', class: 'fx-views__add', onclick: addView }, lucide(h, 'plus'), 'View') : null,
121
+ changed ? h('span', { class: 'fx-views__end' },
122
+ h('button', { type: 'button', class: 'fx-btn fx-btn--ghost fx-btn--sm', onclick: () => { apply(view.view); draw(); } }, 'Revert'),
123
+ h('button', { type: 'button', class: 'fx-btn fx-btn--primary fx-btn--sm', onclick: () => store({ ...saved, list: saved.list.map((v) => (v.id === view.id ? { ...v, view: current() } : v)) }) }, 'Save')) : null,
124
+ ].filter(Boolean));
125
+ }
126
+
127
+ // The view the page opens on. In the same browser tab, the tab chosen
128
+ // last stays chosen and the page keeps its own view; on a new visit the
129
+ // person's default view opens.
130
+ function opening() {
131
+ const kept = readActive(page);
132
+ if (kept && saved.list.some((v) => v.id === kept)) { active = kept; return null; }
133
+ const first = saved.list.find((v) => v.id === saved.default);
134
+ if (!first) return null;
135
+ active = first.id;
136
+ writeActive(page, first.id);
137
+ return first.view;
138
+ }
139
+
140
+ return { element, opening, draw };
141
+ }
@@ -8,6 +8,14 @@ const LIMIT = 6;
8
8
 
9
9
  const lower = (value) => String(value ?? '').toLowerCase();
10
10
 
11
+ /**
12
+ * True for Ctrl+K or ⌘K. Some keydown events carry no `key` (Chrome sends
13
+ * them when it fills a form from saved details), so a missing key is no match.
14
+ */
15
+ export function isSearchKey(event) {
16
+ return lower(event.key) === 'k' && Boolean(event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey;
17
+ }
18
+
11
19
  /**
12
20
  * `views` is [{ name, label, icon }] for the pages this person may open;
13
21
  * `icon(name)` draws a sidebar icon; `go(path)` opens a path in place.