tablewalk 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +553 -0
  3. package/dist/adapters/adapter.js +372 -0
  4. package/dist/adapters/connect.js +33 -0
  5. package/dist/adapters/mysql.js +951 -0
  6. package/dist/adapters/postgres.js +1000 -0
  7. package/dist/adapters/sqlite.js +781 -0
  8. package/dist/client/agent.js +262 -0
  9. package/dist/client/app.js +973 -0
  10. package/dist/client/arrange.js +254 -0
  11. package/dist/client/ask.js +133 -0
  12. package/dist/client/breakdown.js +317 -0
  13. package/dist/client/clauses.js +390 -0
  14. package/dist/client/columns.js +98 -0
  15. package/dist/client/complete.js +437 -0
  16. package/dist/client/compose.js +166 -0
  17. package/dist/client/composer.css +495 -0
  18. package/dist/client/composer.js +1972 -0
  19. package/dist/client/connections.js +234 -0
  20. package/dist/client/connmanager.js +962 -0
  21. package/dist/client/connurl.js +188 -0
  22. package/dist/client/core.js +893 -0
  23. package/dist/client/deeplink.js +270 -0
  24. package/dist/client/delete.js +144 -0
  25. package/dist/client/diagram.js +885 -0
  26. package/dist/client/dropdown.js +279 -0
  27. package/dist/client/export.js +456 -0
  28. package/dist/client/features.css +524 -0
  29. package/dist/client/findvalue.js +169 -0
  30. package/dist/client/grid.js +205 -0
  31. package/dist/client/handoff.js +153 -0
  32. package/dist/client/help.css +145 -0
  33. package/dist/client/help.js +881 -0
  34. package/dist/client/history.js +222 -0
  35. package/dist/client/index.html +116 -0
  36. package/dist/client/insert.js +151 -0
  37. package/dist/client/menu.js +160 -0
  38. package/dist/client/nested.js +255 -0
  39. package/dist/client/page.css +713 -0
  40. package/dist/client/page.js +1345 -0
  41. package/dist/client/pagebuilder.js +1222 -0
  42. package/dist/client/pagemarks.js +95 -0
  43. package/dist/client/palette.js +374 -0
  44. package/dist/client/peek.js +254 -0
  45. package/dist/client/picker.js +139 -0
  46. package/dist/client/pins.js +140 -0
  47. package/dist/client/prompt.js +129 -0
  48. package/dist/client/record.js +707 -0
  49. package/dist/client/schemaexport.js +242 -0
  50. package/dist/client/schematext.js +125 -0
  51. package/dist/client/shape.js +178 -0
  52. package/dist/client/shapecheck.js +129 -0
  53. package/dist/client/skeleton.js +139 -0
  54. package/dist/client/sql.css +126 -0
  55. package/dist/client/sql.js +398 -0
  56. package/dist/client/sqlcomplete.js +163 -0
  57. package/dist/client/sqlsaved.js +107 -0
  58. package/dist/client/style.css +2711 -0
  59. package/dist/client/summary.js +259 -0
  60. package/dist/client/table.js +1035 -0
  61. package/dist/client/template.js +539 -0
  62. package/dist/client/theme.js +74 -0
  63. package/dist/client/tour.js +324 -0
  64. package/dist/client/undo.js +105 -0
  65. package/dist/client/url.js +166 -0
  66. package/dist/client/value.js +223 -0
  67. package/dist/client/views.js +215 -0
  68. package/dist/client/virtual.js +176 -0
  69. package/dist/client/welcome.js +170 -0
  70. package/dist/client/write.js +414 -0
  71. package/dist/server/changeimpact.js +195 -0
  72. package/dist/server/connections.js +615 -0
  73. package/dist/server/constraints.js +62 -0
  74. package/dist/server/credentials.js +230 -0
  75. package/dist/server/fixture.js +199 -0
  76. package/dist/server/graph.js +194 -0
  77. package/dist/server/impact.js +48 -0
  78. package/dist/server/index.js +2204 -0
  79. package/dist/server/journal.js +173 -0
  80. package/dist/server/layouts.js +128 -0
  81. package/dist/server/mcp.js +2840 -0
  82. package/dist/server/shapeonly.js +91 -0
  83. package/dist/shared/breakdown.js +231 -0
  84. package/dist/shared/breakdowntext.js +257 -0
  85. package/dist/shared/diff.js +130 -0
  86. package/dist/shared/like.js +29 -0
  87. package/dist/shared/lint.js +149 -0
  88. package/dist/shared/order.js +133 -0
  89. package/dist/shared/page.js +932 -0
  90. package/dist/shared/query.js +831 -0
  91. package/dist/shared/recordview.js +343 -0
  92. package/dist/shared/schema.js +377 -0
  93. package/dist/shared/sqlsaved.js +67 -0
  94. package/dist/shared/view.js +981 -0
  95. package/dist/shared/viewtext.js +273 -0
  96. package/dist/shared/vocabulary.js +164 -0
  97. package/package.json +57 -0
@@ -0,0 +1,539 @@
1
+ /**
2
+ * Record templates on the client: storage, and the builder.
3
+ *
4
+ * A template says how a record of one table is laid out — which of its own
5
+ * fields show and in what blocks, which references get pulled in beside them,
6
+ * and which child tables appear underneath. It is bound to the *table*, so
7
+ * configuring `work_order` once lays out every work order.
8
+ *
9
+ * The default is derived rather than blank. A blank record page is a worse
10
+ * starting point than the layout the record view already produces, and the
11
+ * fastest way to explain what a template is is to show someone one made of
12
+ * their own data — the builder opens on a working layout they can subtract
13
+ * from, rather than an empty canvas they have to fill.
14
+ *
15
+ * The shared model lives in src/shared/recordview.ts and is TypeScript, which
16
+ * the browser cannot load, so the derivation is mirrored here. It is a mirror
17
+ * with a test on the other side rather than a second source of truth: the
18
+ * grouping rules and thresholds are asserted there.
19
+ */
20
+ import {
21
+ api, el, findTable, labelColumn, loadJson, looksLikeDateColumn, primaryKey,
22
+ refsFrom, refsTo, render, saveJson, state, tableAtPrefix, toast,
23
+ } from './core.js';
24
+ import { promptFor } from './prompt.js';
25
+ import { fieldArranger } from './arrange.js';
26
+
27
+ const STORE = 'tablewalk.templates.v1';
28
+
29
+ const connectionKey = () => state.activeConnection ?? 'default';
30
+
31
+ /* ---------- storage ----------
32
+
33
+ Layouts live on the server, in a file the user can read and copy between
34
+ machines, and are mirrored into localStorage on the way past.
35
+
36
+ The mirror is not a cache for speed — it is what makes rendering
37
+ synchronous. A record is drawn from its layout, and a layout that had to be
38
+ awaited would make every record either flash the derived layout first or
39
+ wait on a round trip to show fields the browser already had. So the server
40
+ copy is loaded once per connection and the mirror answers every read after
41
+ that.
42
+
43
+ It is also the fallback. If the server cannot write the file — a read-only
44
+ home directory, a permissions problem — the layout is still kept here and
45
+ the toast says which of the two happened, rather than reporting a success
46
+ that only half occurred. */
47
+
48
+ function allTemplates() {
49
+ return loadJson(STORE, {})[connectionKey()] ?? {};
50
+ }
51
+
52
+ function mirror(templates) {
53
+ const all = loadJson(STORE, {});
54
+ all[connectionKey()] = templates;
55
+ saveJson(STORE, all);
56
+ }
57
+
58
+ /**
59
+ * Pull this connection's layouts from the server into the mirror.
60
+ *
61
+ * Called when a connection is opened. A failure is silent: the mirror already
62
+ * holds whatever this browser saved, and a tool that refuses to draw a record
63
+ * because a preferences request failed would be worse than one that draws it
64
+ * from the copy it has.
65
+ */
66
+ export async function loadTemplates() {
67
+ try {
68
+ const { layouts } = await api('/api/layouts');
69
+ if (layouts && typeof layouts === 'object') mirror(layouts);
70
+ } catch {
71
+ /* Server-side layouts unavailable; the mirror stands. */
72
+ }
73
+ }
74
+
75
+ function writeTemplate(tableId, template) {
76
+ const all = loadJson(STORE, {});
77
+ all[connectionKey()] = { ...(all[connectionKey()] ?? {}) };
78
+ if (template) all[connectionKey()][tableId] = template;
79
+ else delete all[connectionKey()][tableId];
80
+ const local = saveJson(STORE, all);
81
+
82
+ /* Sent after the local write, not before. The browser copy is what the next
83
+ render reads, so it has to be in place before the page redraws; the file
84
+ is what makes the layout outlive this browser. */
85
+ void api('/api/layouts', { table: tableId, layout: template ?? null })
86
+ .catch((err) => toast(err.message, 'error'));
87
+
88
+ if (!local) {
89
+ toast('Could not save the layout — this browser is not allowing local storage.', 'error');
90
+ return false;
91
+ }
92
+ return true;
93
+ }
94
+
95
+ /** True when this table's layout has been configured rather than derived. */
96
+ export const hasSavedTemplate = (tableId) => Boolean(allTemplates()[tableId]);
97
+
98
+ /**
99
+ * The template to render a record with: the saved one, or a derived one.
100
+ *
101
+ * Never null. Every table has a layout; the only question is whether anyone
102
+ * has changed it.
103
+ */
104
+ export function templateFor(tableId) {
105
+ return allTemplates()[tableId] ?? deriveTemplate(tableId);
106
+ }
107
+
108
+ /* ---------- derivation (mirrors shared/recordview.ts) ---------- */
109
+
110
+ const GROUP_THRESHOLD = 12;
111
+ const AUDIT_NAME = /^(sys_|created|updated|modified|deleted|inserted)|_(at|by)$|^(version|etag|revision)$/i;
112
+ /* Mirrors DEFAULT_LIST_LIMIT in shared/recordview.ts, which is the source of
113
+ truth and says ten. This copy said eight — the two drifted, and the drift
114
+ was invisible because each side looked right on its own. */
115
+ const DEFAULT_LIST_LIMIT = 10;
116
+
117
+ /* The date test comes from core.js rather than being written out again here.
118
+ The copy that used to live at this spot had a narrower name pattern than
119
+ shared/schema.ts — no `datetime` or `timestamp` — so `event_timestamp TEXT`
120
+ landed in Details on a wide record while the server's grouping put it under
121
+ Dates, and the same record was laid out two different ways depending on
122
+ which side derived it. */
123
+ export function groupOwnColumns(table) {
124
+ const columns = table.columns;
125
+ if (columns.length < GROUP_THRESHOLD) return [[null, columns]];
126
+
127
+ const label = labelColumn(table);
128
+ const groups = new Map([
129
+ ['Identity', []], ['Bookkeeping', []], ['References', []], ['Details', []], ['Dates', []],
130
+ ]);
131
+ for (const col of columns) {
132
+ if (col.primaryKey || col.name === label) groups.get('Identity').push(col);
133
+ else if (col.references) groups.get('References').push(col);
134
+ else if (AUDIT_NAME.test(col.name)) groups.get('Bookkeeping').push(col);
135
+ else if (looksLikeDateColumn(col)) groups.get('Dates').push(col);
136
+ else groups.get('Details').push(col);
137
+ }
138
+ return [...groups].filter(([, cols]) => cols.length);
139
+ }
140
+
141
+ /** A few columns that say what a row is, skipping keys and the join column. */
142
+ /** Mirrors listColumns in shared/recordview.ts. */
143
+ export function listColumns(table, exclude = []) {
144
+ const skip = new Set(exclude);
145
+ const chosen = table.columns.filter((c) => !skip.has(c.name)).map((c) => c.name);
146
+ return chosen.length ? chosen : primaryKey(table).slice(0, 1);
147
+ }
148
+
149
+ export function summaryColumns(table, exclude = [], max = 5) {
150
+ const skip = new Set(exclude);
151
+ const chosen = [];
152
+ for (const column of table.columns) {
153
+ if (skip.has(column.name)) continue;
154
+ if (column.primaryKey && table.columns.some((c) => !c.primaryKey && !skip.has(c.name))) continue;
155
+ chosen.push(column.name);
156
+ if (chosen.length >= max) break;
157
+ }
158
+ return chosen.length ? chosen : primaryKey(table).slice(0, 1);
159
+ }
160
+
161
+ export function deriveTemplate(tableId) {
162
+ const table = findTable(tableId);
163
+ if (!table) return { id: `derived:${tableId}`, name: tableId, base: tableId, sections: [] };
164
+
165
+ const sections = groupOwnColumns(table).map(([title, columns]) => ({
166
+ kind: 'fields',
167
+ title: title ?? undefined,
168
+ paths: columns.map((c) => c.name),
169
+ }));
170
+
171
+ /* Reference blocks are derived but start switched off. Pulling every
172
+ related row's columns into every record by default would double the
173
+ length of a page nobody asked to change — the point is that they are one
174
+ click away and visibly available, not that they are all already there. */
175
+ for (const fk of refsFrom(tableId)) {
176
+ if (fk.to.table === tableId) continue;
177
+ const target = findTable(fk.to.table);
178
+ if (!target) continue;
179
+ const via = fk.from.columns.join('+');
180
+ sections.push({
181
+ kind: 'fields',
182
+ title: `${target.name} · via ${fk.from.columns.join(', ')}`,
183
+ paths: summaryColumns(target).map((c) => `${via}.${c}`),
184
+ off: true,
185
+ });
186
+ }
187
+
188
+ for (const fk of refsTo(tableId)) {
189
+ const child = findTable(fk.from.table);
190
+ if (!child) continue;
191
+ sections.push({
192
+ kind: 'list',
193
+ title: child.name,
194
+ via: fk.name,
195
+ // Every column except the ones being joined on. See listColumns in
196
+ // shared/recordview.ts for why a child table is not summarised.
197
+ columns: listColumns(child, fk.from.columns),
198
+ limit: DEFAULT_LIST_LIMIT,
199
+ });
200
+ }
201
+
202
+ return { id: `derived:${tableId}`, name: table.name, base: tableId, sections };
203
+ }
204
+
205
+ /** Paths the base row must supply, across every enabled field section. */
206
+ export function fieldPathsOf(template) {
207
+ const seen = new Set();
208
+ for (const section of template.sections) {
209
+ if (section.kind !== 'fields' || section.off) continue;
210
+ for (const path of section.paths) seen.add(path);
211
+ }
212
+ return [...seen];
213
+ }
214
+
215
+ /** Enabled list sections, in order. */
216
+ export const listSectionsOf = (template) =>
217
+ template.sections.filter((s) => s.kind === 'list' && !s.off);
218
+
219
+ /* ---------- the builder ---------- */
220
+
221
+ let editing = null;
222
+ let editingTable = null;
223
+
224
+ /**
225
+ * The layout being edited, if the builder is open for this table.
226
+ *
227
+ * The record renders from this rather than from storage while the builder is
228
+ * open, which is what makes the preview live: a toggle changes the draft, the
229
+ * record re-renders, and you see the result before deciding to keep it. A
230
+ * builder that only shows its effect after saving asks you to commit to a
231
+ * change you have not seen.
232
+ */
233
+ export const activeDraft = (tableId) => (editingTable === tableId ? editing : null);
234
+
235
+ export function openTemplateBuilder(tableId) {
236
+ const table = findTable(tableId);
237
+ if (!table) return;
238
+ // Edited on a copy, so Cancel is a real cancel rather than an undo the user
239
+ // has to perform themselves.
240
+ editing = structuredClone(templateFor(tableId));
241
+ editingTable = tableId;
242
+ document.body.classList.add('editing-layout');
243
+ // Escape closes it, the way every other dismissable surface here does. The
244
+ // listener is added on open and removed on close so a session that opens
245
+ // the builder twenty times does not accumulate twenty handlers.
246
+ document.addEventListener('keydown', onBuilderKey, true);
247
+ paintBuilder(tableId, table);
248
+ render();
249
+ }
250
+
251
+ function onBuilderKey(e) {
252
+ if (e.key !== 'Escape') return;
253
+ /* Not while a text field is being edited — Escape there means "undo this
254
+ entry", and closing the whole builder would throw away the rest of the
255
+ work to cancel one word. */
256
+ const tag = document.activeElement?.tagName;
257
+ if (tag === 'INPUT' || tag === 'TEXTAREA') {
258
+ document.activeElement.blur();
259
+ return;
260
+ }
261
+ e.preventDefault();
262
+ close();
263
+ }
264
+
265
+ /** Redraw the builder and the record it is previewing, in that order. */
266
+ function applied(tableId, table) {
267
+ paintBuilder(tableId, table);
268
+ render();
269
+ }
270
+
271
+ /**
272
+ * The preview alone, at most once a frame's worth of gesture.
273
+ *
274
+ * The arranger commits on every step of a drag — that is what makes the list
275
+ * follow the pointer — and a record render re-runs the query behind it. Left
276
+ * undebounced, dragging one field down a block of twenty fired twenty
277
+ * fetches, and the preview lagged the thing it was previewing. */
278
+ let previewing = null;
279
+ function preview() {
280
+ clearTimeout(previewing);
281
+ previewing = setTimeout(() => { previewing = null; render(); }, 120);
282
+ }
283
+
284
+ function paintBuilder(tableId, table) {
285
+ document.querySelector('.tpl')?.remove();
286
+
287
+ const body = el('div', { class: 'tpl-body' },
288
+ editing.sections.map((section, index) => sectionCard(section, index, tableId, table)));
289
+
290
+ /* The layout has a name, and the name is editable here.
291
+
292
+ It was always called after the table, which was fine while a table had
293
+ exactly one layout and useless the moment you want two — "the one with
294
+ the finance fields" and "the one for the service desk" are different
295
+ things and calling them both `work_order` says so of neither. Naming it
296
+ is also the smallest step towards a table having more than one, which is
297
+ where this is going. */
298
+ const name = el('input', {
299
+ type: 'text',
300
+ class: 'tpl-name',
301
+ /* A layout nobody has named is the default layout, and says so. It was
302
+ seeded with the table's name, which named nothing — the eyebrow and
303
+ the "for …" beside it already say the table twice. */
304
+ value: editing.name ?? 'Default layout',
305
+ 'aria-label': 'Layout name',
306
+ placeholder: 'Default layout',
307
+ spellcheck: 'false',
308
+ });
309
+ // Written straight onto the draft: a name typed and then not saved should
310
+ // be discarded with everything else Cancel discards.
311
+ name.addEventListener('input', () => { editing.name = name.value; });
312
+
313
+ const panel = el('div', { class: 'tpl' }, [
314
+ el('div', { class: 'tpl-head' }, [
315
+ el('div', { class: 'tpl-title' }, [
316
+ el('span', { class: 'tpl-eyebrow', text: 'Record layout' }),
317
+ name,
318
+ el('span', { class: 'tpl-for', text: `for ${table.name}` }),
319
+ ]),
320
+ el('p', {
321
+ class: 'tpl-lede',
322
+ text: 'Applies to every record of this table. The record beside this updates as you change it.',
323
+ }),
324
+ el('div', { class: 'tpl-head-actions' }, [
325
+ el('button', {
326
+ type: 'button', class: 'ghost', text: 'Reset to default',
327
+ onclick: () => {
328
+ editing = deriveTemplate(tableId);
329
+ applied(tableId, table);
330
+ },
331
+ }),
332
+ el('button', { type: 'button', class: 'ghost', text: 'Cancel', onclick: close }),
333
+ el('button', {
334
+ type: 'button', text: 'Save layout',
335
+ onclick: () => {
336
+ // A blank name is the default layout, not an empty heading.
337
+ editing.name = (editing.name ?? '').trim() || 'Default layout';
338
+ if (writeTemplate(tableId, editing)) {
339
+ toast(`Saved “${editing.name}” for ${table.name}.`, 'ok');
340
+ close();
341
+ render();
342
+ }
343
+ },
344
+ }),
345
+ ]),
346
+ ]),
347
+ body,
348
+ ]);
349
+
350
+ document.body.append(panel);
351
+ }
352
+
353
+ function close() {
354
+ clearTimeout(previewing);
355
+ previewing = null;
356
+ document.querySelector('.tpl')?.remove();
357
+ editing = null;
358
+ editingTable = null;
359
+ document.body.classList.remove('editing-layout');
360
+ document.removeEventListener('keydown', onBuilderKey, true);
361
+ render();
362
+ }
363
+
364
+ function move(index, delta, tableId, table) {
365
+ const next = index + delta;
366
+ if (next < 0 || next >= editing.sections.length) return;
367
+ const [section] = editing.sections.splice(index, 1);
368
+ editing.sections.splice(next, 0, section);
369
+ applied(tableId, table);
370
+ }
371
+
372
+ function sectionCard(section, index, tableId, table) {
373
+ const isList = section.kind === 'list';
374
+ const child = isList ? childOf(section) : null;
375
+
376
+ return el('section', { class: `tpl-section${section.off ? ' off' : ''}` }, [
377
+ el('div', { class: 'tpl-section-head' }, [
378
+ el('label', { class: 'tpl-toggle' }, [
379
+ el('input', {
380
+ type: 'checkbox',
381
+ checked: !section.off,
382
+ onchange: (e) => {
383
+ section.off = !e.currentTarget.checked;
384
+ applied(tableId, table);
385
+ },
386
+ }),
387
+ el('span', { class: 'tpl-section-name', text: section.title ?? 'Fields' }),
388
+ ]),
389
+ el('span', {
390
+ class: 'tpl-section-kind',
391
+ text: isList ? `table · ${child ? child.name : section.via}` : 'fields',
392
+ }),
393
+ el('div', { class: 'tpl-section-actions' }, [
394
+ el('button', {
395
+ type: 'button', class: 'tpl-move', title: 'Move up', 'aria-label': 'Move up',
396
+ text: '↑', disabled: index === 0, onclick: () => move(index, -1, tableId, table),
397
+ }),
398
+ el('button', {
399
+ type: 'button', class: 'tpl-move', title: 'Move down', 'aria-label': 'Move down',
400
+ text: '↓', disabled: index === editing.sections.length - 1,
401
+ onclick: () => move(index, 1, tableId, table),
402
+ }),
403
+ el('button', {
404
+ type: 'button', class: 'tpl-move', title: 'Rename', 'aria-label': 'Rename section',
405
+ text: '✎',
406
+ onclick: async () => {
407
+ const name = await promptFor({
408
+ title: 'Rename section',
409
+ label: 'Heading',
410
+ value: section.title ?? '',
411
+ confirmLabel: 'Rename',
412
+ });
413
+ if (name === null) return;
414
+ section.title = name.trim() || undefined;
415
+ applied(tableId, table);
416
+ },
417
+ }),
418
+ ]),
419
+ ]),
420
+ section.off ? null : isList
421
+ ? listChoices(section, child, tableId, table)
422
+ : fieldChoices(section, tableId, table),
423
+ ].filter(Boolean));
424
+ }
425
+
426
+ function childOf(section) {
427
+ const fk = state.schema.foreignKeys.find((f) => f.name === section.via);
428
+ return fk ? findTable(fk.from.table) : null;
429
+ }
430
+
431
+ /**
432
+ * Which of the section's fields are shown, and in what order.
433
+ *
434
+ * The arranger is `arrange.js`, shared with the page builder. It used to be a
435
+ * grid of checkboxes here, which could say which fields but not where they
436
+ * go — and a record draws its fields in the order the layout lists them, so
437
+ * the order was a decision the builder had no way to make. Everything below
438
+ * is the translation between a record section and what the arranger asks for.
439
+ */
440
+ function fieldChoices(section, tableId, table) {
441
+ /* The candidates are the columns of whichever table the section's paths
442
+ address — the base for a plain block, the referenced table for a pulled-in
443
+ one — so a reference block offers every column of the customer rather than
444
+ only the few the derivation picked. */
445
+ const prefix = section.paths[0]?.includes('.')
446
+ ? section.paths[0].slice(0, section.paths[0].lastIndexOf('.'))
447
+ : '';
448
+ const source = prefix ? tableAtPrefix(tableId, prefix) : table;
449
+ if (!source) return el('p', { class: 'note', text: 'That reference is no longer in the schema.' });
450
+
451
+ const qualify = (name) => (prefix ? `${prefix}.${name}` : name);
452
+ const all = source.columns.map((c) => qualify(c.name));
453
+ const types = new Map(source.columns.map((c) => [qualify(c.name), c.type.toLowerCase()]));
454
+
455
+ return fieldArranger({
456
+ all,
457
+ chosen: section.paths,
458
+ /* No label of its own: the card this sits in is headed with the block's
459
+ name, and "Fields fields · Fields 7 of 7" is one word three times. */
460
+ heading: null,
461
+ label: (path) => (prefix && path.startsWith(`${prefix}.`) ? path.slice(prefix.length + 1) : path),
462
+ typeOf: (path) => types.get(path) ?? '',
463
+ owner: section,
464
+ /* The record beside the builder, and nothing else.
465
+
466
+ `applied` repaints the builder too, which would replace the row being
467
+ dragged with a new one halfway through the gesture and end the drag.
468
+ The arranger repaints itself; what it cannot do is repaint the preview,
469
+ which is the whole point of editing a layout with the record open. */
470
+ onChange: (next) => { section.paths = next; preview(); },
471
+ others: otherFieldSections(section).map(({ s, i }) => ({
472
+ title: s.title || `block ${i + 1}`,
473
+ receive: (name) => { if (!s.paths.includes(name)) s.paths = [...s.paths, name]; },
474
+ })),
475
+ onSend: () => applied(tableId, table),
476
+ });
477
+ }
478
+
479
+ /** Every other block of fields in the draft, for sending a field to one. */
480
+ function otherFieldSections(section) {
481
+ return editing.sections
482
+ .map((s, i) => ({ s, i }))
483
+ .filter(({ s }) => s.kind === 'fields' && s !== section && !s.off);
484
+ }
485
+
486
+ function listChoices(section, child, tableId, table) {
487
+ if (!child) return el('p', { class: 'note', text: 'That relationship is no longer in the schema.' });
488
+ const types = new Map(child.columns.map((c) => [c.name, c.type.toLowerCase()]));
489
+
490
+ return el('div', {}, [
491
+ /* The same arranger as a field block, because a list's columns are the
492
+ same kind of decision: which ones, and in what order they read left to
493
+ right. Nowhere to send a column, though — the other blocks on this
494
+ layout are about a different table. */
495
+ fieldArranger({
496
+ heading: 'Columns',
497
+ all: child.columns.map((c) => c.name),
498
+ chosen: section.columns,
499
+ typeOf: (name) => types.get(name) ?? '',
500
+ owner: section,
501
+ onChange: (next) => { section.columns = next; preview(); },
502
+ }),
503
+ el('label', { class: 'tpl-rows' }, [
504
+ el('span', { text: 'Rows on the page' }),
505
+ el('input', {
506
+ type: 'number', min: '1', max: '50',
507
+ value: String(section.limit ?? DEFAULT_LIST_LIMIT),
508
+ onchange: (e) => {
509
+ section.limit = Math.max(1, Math.min(50, Number(e.currentTarget.value) || 8));
510
+ applied(tableId, table);
511
+ },
512
+ }),
513
+ ]),
514
+ ]);
515
+ }
516
+
517
+ /** The control that opens the builder, for the record page to place. */
518
+ export function editLayoutButton(tableId) {
519
+ const saved = allTemplates()[tableId];
520
+ const table = findTable(tableId);
521
+ /* The name shows on the button when it says something the table name does
522
+ not. A layout still called `work_order` adds nothing to a page already
523
+ headed `work_order`; one called "Service desk" is the answer to "why does
524
+ this record look like this". */
525
+ const named = saved?.name && saved.name !== table?.name ? saved.name : null;
526
+
527
+ return el('button', {
528
+ type: 'button',
529
+ class: `ghost record-layout${saved ? ' is-custom' : ''}`,
530
+ title: named
531
+ ? `Editing “${named}” — the layout for every ${table?.name ?? tableId} record`
532
+ : 'Choose what this record shows',
533
+ onclick: () => openTemplateBuilder(tableId),
534
+ }, [
535
+ el('span', { text: 'Layout' }),
536
+ named ? el('span', { class: 'record-layout-name', text: named }) : null,
537
+ !named && saved ? el('span', { 'aria-hidden': 'true', text: '\u2713' }) : null,
538
+ ].filter(Boolean));
539
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Light, dark, or whatever the machine is set to.
3
+ *
4
+ * The palette for all three has been in style.css from early on: a full token
5
+ * override under `@media (prefers-color-scheme: dark)`, guarded as
6
+ * `:root:not([data-theme="light"])`, and a second copy under
7
+ * `:root[data-theme="dark"]`. That guard only means anything if something can
8
+ * stamp `data-theme`, and nothing ever did — so the dark palette was reachable
9
+ * by changing your operating system and by no other route, and the light one
10
+ * was unreachable from a dark machine at all.
11
+ *
12
+ * This is that missing piece and nothing more. It writes one attribute.
13
+ *
14
+ * Three states rather than two, because "system" is a real answer and not the
15
+ * absence of one: a reader who has told their machine to go dark at sunset
16
+ * means it here too, and a toggle that only remembers light-or-dark quietly
17
+ * opts them out of that. Absent attribute *is* the system state, which is why
18
+ * the CSS was written to key off its absence.
19
+ */
20
+ const KEY = 'tablewalk.theme';
21
+ const ORDER = ['system', 'light', 'dark'];
22
+
23
+ const LABEL = {
24
+ system: 'match the system',
25
+ light: 'always light',
26
+ dark: 'always dark',
27
+ };
28
+
29
+ let current = 'system';
30
+
31
+ function stored() {
32
+ try {
33
+ const raw = window.localStorage?.getItem(KEY);
34
+ /* Validated, not trusted: this comes back from storage a stale build or a
35
+ hand edit may have written, and an unknown value would stamp
36
+ `data-theme="banana"` — which matches no rule, so the page would render
37
+ in the light palette while claiming to be in another one. */
38
+ return ORDER.includes(raw) ? raw : 'system';
39
+ } catch {
40
+ return 'system';
41
+ }
42
+ }
43
+
44
+ function apply(name) {
45
+ current = ORDER.includes(name) ? name : 'system';
46
+ const root = document.documentElement;
47
+ if (current === 'system') root.removeAttribute('data-theme');
48
+ else root.setAttribute('data-theme', current);
49
+ /* Tells the browser which way to paint the things the page does not: form
50
+ controls, scrollbars, the canvas behind an overscroll. Without it a dark
51
+ page keeps light scrollbars. */
52
+ root.style.colorScheme = current === 'system' ? '' : current;
53
+ }
54
+
55
+ /** Read the stored choice and put it on the page. Called once, at boot. */
56
+ export function initTheme() {
57
+ apply(stored());
58
+ }
59
+
60
+ /** What is in force, for a menu item that has to say so. */
61
+ export const themeChoice = () => current;
62
+ export const themeLabel = () => LABEL[current];
63
+
64
+ /** system → light → dark → system. */
65
+ export function cycleTheme() {
66
+ const next = ORDER[(ORDER.indexOf(current) + 1) % ORDER.length];
67
+ apply(next);
68
+ try {
69
+ window.localStorage?.setItem(KEY, next);
70
+ } catch {
71
+ /* A theme that cannot be remembered is still a theme for this tab. */
72
+ }
73
+ return next;
74
+ }