Skip to main content

max / goingson

Build row cells from the column description, not by hand The row builders spelled `col-description` beside a stylesheet that generated the same class from the description in build.rs, so a column renamed on one side went unmatched on the other with nothing said. That is the defect tables.columns.json was added to catch by assertion; this removes the second copy instead. build.rs now carries each column's class as column_class writes it, and emits the same description a second time as js/tables.generated.js for the running frontend. table-cells.js builds a row's cells from it: ordered by the columns rather than by the caller, a column with no cell keeping its container, a cell naming no column dropped. Both behaviours are cells_html's. Not a call into cells_html, per its own header. VirtualScroller renders synchronously at 60Hz+, so reaching Rust from a row builder means an IPC round trip inside a drag. Batching per page does not rescue it either: a task row carries its own selection state, which SelectionManager mutates in place without a re-render, so cached markup is stale one click later. The description crosses the boundary instead of the markup, once, at build time. The three described tables render byte-identical markup.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-15 22:04 UTC
Signed with PGP, not checked
Commit: a075ea5d31b0cee0a9e75ddb742160fe83bad31a
Parent: 0b8a7b8
8 files changed, +326 insertions, -46 deletions
M .gitignore +4 -1
@@ -50,8 +50,11 @@
50 50 /src-tauri/frontend/css/layout.css
51 51 /src-tauri/frontend/css/tables.css
52 52
53 - # Generated by src-tauri/build.rs from the column descriptions
53 + # Generated by src-tauri/build.rs from the column descriptions. The JSON is read
54 + # off disk by the node tests, the JS by the running frontend; both are the same
55 + # bytes out of one function.
54 56 /src-tauri/frontend/tables.columns.json
57 + /src-tauri/frontend/js/tables.generated.js
55 58
56 59 # Secrets and credentials. The baseline every repo under ~/Code carries, kept
57 60 # identical so no repo is the one that forgot. Suffix-matched rather than
M Cargo.lock +4 -4
@@ -8373,6 +8373,10 @@
8373 8373 "winnow 1.0.4",
8374 8374 ]
8375 8375
8376 + [[patch.unused]]
8377 + name = "ops-status"
8378 + version = "0.1.0"
8379 +
8376 8380 [[patch.unused]]
8377 8381 name = "quasi-axum"
8378 8382 version = "0.11.0"
@@ -8388,7 +8392,3 @@
8388 8392 [[patch.unused]]
8389 8393 name = "quasi-store"
8390 8394 version = "0.1.0"
8391 -
8392 - [[patch.unused]]
8393 - name = "ops-status"
8394 - version = "0.1.0"
@@ -5,7 +5,7 @@
5 5 use makeover_geometry::SizeClass;
6 6 use makeover_layout::{Column, Priority, Width};
7 7 use makeover_webview::Emit;
8 - use makeover_webview::list::{Sizing, narrowing_css};
8 + use makeover_webview::list::{Sizing, column_class, narrowing_css};
9 9
10 10 /// The tasks table, left to right.
11 11 ///
@@ -181,7 +181,18 @@
181 181 /// said. That is how the events table came to render six cells into five
182 182 /// tracks. This file is the description in a form the frontend can check itself
183 183 /// against.
184 + ///
185 + /// # Why the class is carried rather than spelled
186 + ///
187 + /// `classes` is the column's own class as [`column_class`] writes it, and it is
188 + /// here so that no reader has to derive `col-` + name for itself. The derivation
189 + /// is not the identity it looks like: `push_column_name` reduces a name to
190 + /// identifier characters, so a column named `Due date` is `col-Due-date` and a
191 + /// reader that concatenated would spell `col-Due date`, which the HTML parser
192 + /// reads as two classes and which the narrowing selector matches neither of.
193 + /// One writer, every reader.
184 194 fn table_columns_json() -> String {
195 + let opts = Emit::default();
185 196 let mut json = String::from("{\n");
186 197 for (i, (table, header, _, columns)) in TABLES.iter().enumerate() {
187 198 let names = columns
@@ -198,10 +209,15 @@
198 209 .map(|c| format!("\"{}\"", c.name))
199 210 .collect::<Vec<_>>()
200 211 .join(", ");
212 + let classes = columns
213 + .iter()
214 + .map(|c| format!("\"{}\": \"{}\"", c.name, column_class(c, &opts)))
215 + .collect::<Vec<_>>()
216 + .join(", ");
201 217 let _ = writeln!(
202 218 json,
203 219 " \"{table}\": {{ \"header\": \"{header}\", \"columns\": [{names}], \
204 - \"sortable\": [{sortable}] }}{}",
220 + \"sortable\": [{sortable}], \"classes\": {{{classes}}} }}{}",
205 221 if i + 1 == TABLES.len() { "" } else { "," }
206 222 );
207 223 }
@@ -209,6 +225,36 @@
209 225 json
210 226 }
211 227
228 + /// The same description again, in a form the running frontend can read.
229 + ///
230 + /// [`table_columns_json`] is read by the node test runner off disk. The webview
231 + /// has no filesystem and fetching a JSON file under Tauri's CSP is a worse
232 + /// bargain than a script tag, so the runtime gets the identical bytes assigned
233 + /// to a global instead. One description, one writer, two readers -- rather than
234 + /// the third copy that lived in the row builders as hand-typed `col-` classes.
235 + ///
236 + /// This is data, not markup. The row builders stay in JS and keep emitting
237 + /// their own cells, which is what `makeover_webview::list`'s own header asks a
238 + /// webview app to do: take `narrowing_css` and `column_class`, and do not put an
239 + /// IPC round trip on a scroll path that runs synchronously at 60Hz.
240 + fn table_columns_js() -> String {
241 + format!(
242 + "// Generated by src-tauri/build.rs from the column descriptions. Do not edit.\n\
243 + //\n\
244 + // The runtime half of tables.columns.json, which the node tests read off\n\
245 + // disk. Both come out of one function, so a row cannot be built against a\n\
246 + // column list the stylesheet and the tests disagree with.\n\
247 + (function () {{\n\
248 + 'use strict';\n\
249 + const TABLES = {};\n\
250 + if (window.GoingsOn) {{\n\
251 + \x20 GoingsOn.tableColumns = TABLES;\n\
252 + }}\n\
253 + }})();\n",
254 + table_columns_json().trim_end()
255 + )
256 + }
257 +
212 258 /// Generate the tables' column CSS.
213 259 ///
214 260 /// Both halves of narrowing come out of one call per breakpoint: the track list
@@ -560,6 +606,8 @@
560 606 fs::write(frontend.join("css/tables.css"), table_css()).expect("write tables.css");
561 607 fs::write(frontend.join("tables.columns.json"), table_columns_json())
562 608 .expect("write tables.columns.json");
609 + fs::write(frontend.join("js/tables.generated.js"), table_columns_js())
610 + .expect("write js/tables.generated.js");
563 611
564 612 // The generated files above cannot drift from SizeClass. The hand-written
565 613 // ones can, so they are checked rather than trusted.
@@ -709,6 +709,11 @@
709 709 <script src="js/themes.js"></script>
710 710 <script src="js/query-state.js"></script>
711 711
712 + <!-- Generated by build.rs from the column descriptions. Must load before any
713 + row builder: table-cells.js reads the column order and classes off it. -->
714 + <script src="js/tables.generated.js"></script>
715 + <script src="js/table-cells.js"></script>
716 +
712 717 <!-- Utility Managers -->
713 718 <script src="js/cache.js"></script>
714 719 <script src="js/selection-manager.js"></script>
@@ -536,19 +536,40 @@
536 536 // so users see "Weekly ยท Mon Wed Fri" rather than a single arbitrary start date.
537 537 if (isRecurring) {
538 538 const patternLabel = e.recurrenceDisplay || e.recurrence || 'Recurring';
539 + const cells = GoingsOn.tableCells.cells('recurring', 'event-cell', [
540 + {
541 + column: 'pattern',
542 + class: 'event-cell-date',
543 + html: `<span class="event-recurrence-pattern">${esc(patternLabel)}</span>`,
544 + },
545 + {
546 + column: 'time',
547 + class: 'row-secondary event-cell-time',
548 + html: e.timeFormatted,
549 + },
550 + {
551 + column: 'title',
552 + class: 'event-cell-title',
553 + html: esc(displayTitle),
554 + },
555 + {
556 + column: 'location',
557 + class: 'row-secondary event-cell-location',
558 + html: e.location ? esc(e.location) : '-',
559 + },
560 + {
561 + column: 'actions',
562 + attrs: 'style="text-align: right;" data-act="ui.noop"',
563 + html: `<button class="button--icon row-actions kebab-btn" data-act="contextMenus.showEvent" data-a1="@event" data-a2="${escAttr(e.id)}" title="Actions" aria-label="Event actions">&#x22EE;</button>`,
564 + },
565 + ]);
539 566 return `
540 567 <div class="row event-row-virtual event-recurring"
541 568 data-id="${escAttr(e.id)}"
542 569 data-act="events.open" data-a1="${escAttr(e.id)}"
543 570 data-contextmenu="contextMenus.showEvent" data-a1="@event" data-a2="${escAttr(e.id)}"
544 571 tabindex="0" role="row">
545 - <div class="event-cell col-pattern event-cell-date"><span class="event-recurrence-pattern">${esc(patternLabel)}</span></div>
546 - <div class="event-cell col-time row-secondary event-cell-time">${e.timeFormatted}</div>
547 - <div class="event-cell col-title event-cell-title">${esc(displayTitle)}</div>
548 - <div class="event-cell col-location row-secondary event-cell-location">${e.location ? esc(e.location) : '-'}</div>
549 - <div class="event-cell col-actions" style="text-align: right;" data-act="ui.noop">
550 - <button class="button--icon row-actions kebab-btn" data-act="contextMenus.showEvent" data-a1="@event" data-a2="${escAttr(e.id)}" title="Actions" aria-label="Event actions">&#x22EE;</button>
551 - </div>
572 + ${cells}
552 573 </div>
553 574 `;
554 575 }
@@ -571,6 +592,43 @@
571 592 dateHeader = `<div class="event-date-group-header">${dayName}, ${startDate.getDate()} ${monthName}</div>`;
572 593 }
573 594
595 + const cells = GoingsOn.tableCells.cells('upcoming', 'event-cell', [
596 + {
597 + column: 'select',
598 + html: `<input type="checkbox" class="bulk-checkbox event-select-cb" data-id="${escAttr(e.id)}"
599 + data-act="events.toggleEventSelection" data-a1="${escAttr(e.id)}" data-a2="@event"
600 + aria-label="Select event">`,
601 + },
602 + {
603 + column: 'date',
604 + class: 'event-cell-date',
605 + html: `
606 + <span class="row-primary event-date-num">${startDate.getDate()} ${monthName}</span>
607 + <span class="event-date-badge event-proximity-${e.proximityClass || 'default'}">${e.proximityLabel || ''}</span>
608 + `,
609 + },
610 + {
611 + column: 'time',
612 + class: 'row-secondary event-cell-time',
613 + html: e.timeFormatted,
614 + },
615 + {
616 + column: 'title',
617 + class: 'event-cell-title',
618 + html: esc(displayTitle),
619 + },
620 + {
621 + column: 'location',
622 + class: 'row-secondary event-cell-location',
623 + html: e.location ? esc(e.location) : '-',
624 + },
625 + {
626 + column: 'actions',
627 + attrs: 'style="text-align: right;" data-act="ui.noop"',
628 + html: `<button class="button--icon row-actions kebab-btn" data-act="contextMenus.showEvent" data-a1="@event" data-a2="${escAttr(e.id)}" title="Actions" aria-label="Event actions">&#x22EE;</button>`,
629 + },
630 + ]);
631 +
574 632 return `
575 633 ${dateHeader}
576 634 <div class="row event-row-virtual event-upcoming ${e.isPast || isPast ? 'event-past' : ''}"
@@ -578,21 +636,7 @@
578 636 data-act="events.open" data-a1="${escAttr(e.id)}"
579 637 data-contextmenu="contextMenus.showEvent" data-a1="@event" data-a2="${escAttr(e.id)}"
580 638 tabindex="0" role="row">
581 - <div class="event-cell col-select">
582 - <input type="checkbox" class="bulk-checkbox event-select-cb" data-id="${escAttr(e.id)}"
583 - data-act="events.toggleEventSelection" data-a1="${escAttr(e.id)}" data-a2="@event"
584 - aria-label="Select event">
585 - </div>
586 - <div class="event-cell col-date event-cell-date">
587 - <span class="row-primary event-date-num">${startDate.getDate()} ${monthName}</span>
588 - <span class="event-date-badge event-proximity-${e.proximityClass || 'default'}">${e.proximityLabel || ''}</span>
589 - </div>
590 - <div class="event-cell col-time row-secondary event-cell-time">${e.timeFormatted}</div>
591 - <div class="event-cell col-title event-cell-title">${esc(displayTitle)}</div>
592 - <div class="event-cell col-location row-secondary event-cell-location">${e.location ? esc(e.location) : '-'}</div>
593 - <div class="event-cell col-actions" style="text-align: right;" data-act="ui.noop">
594 - <button class="button--icon row-actions kebab-btn" data-act="contextMenus.showEvent" data-a1="@event" data-a2="${escAttr(e.id)}" title="Actions" aria-label="Event actions">&#x22EE;</button>
595 - </div>
639 + ${cells}
596 640 </div>
597 641 `;
598 642 }
@@ -181,12 +181,16 @@
181 181 const isSelected = GoingsOn.tasks.selection.isSelected(t.id);
182 182 const isStarted = t.status === 'Started';
183 183
184 - return `
185 - <div class="row task-row task-${t.status.toLowerCase()} task-${t.blockedClass || 'ready'} ${t.isSnoozed ? 'task-snoozed' : ''} ${t.isOverdue ? 'task-overdue' : ''} ${isSelected ? 'selected' : ''}"
186 - data-id="${escAttr(t.id)}"
187 - data-contextmenu="contextMenus.showTask" data-a1="@event" data-a2="${escAttr(t.id)}"
188 - tabindex="0" role="row">
189 - <div class="task-cell col-description task-description" data-act="taskOverview.open" data-a1="${escAttr(t.id)}">
184 + // Cells are named, not ordered: the column description in build.rs
185 + // decides what comes where, and the `col-` class each one wears comes
186 + // out of the same description that generated tables.css. Listing them
187 + // in visual order here is a courtesy to the reader and nothing more.
188 + const cells = GoingsOn.tableCells.cells('task', 'task-cell', [
189 + {
190 + column: 'description',
191 + class: 'task-description',
192 + attrs: `data-act="taskOverview.open" data-a1="${escAttr(t.id)}"`,
193 + html: `
190 194 ${isStarted ? '<span class="task-started-icon" title="Started" aria-hidden="true"></span>' : ''}
191 195 <span class="task-description-text">${esc(displayDesc)}</span>
192 196 ${renderTokenDot(t)}
@@ -196,28 +200,60 @@
196 200 ${renderTaskBadges(t)}
197 201 ${t.contactName ? `<span class="contact-badge" title="${escAttrVal(t.contactName)}">${esc(t.contactName)}</span>` : ''}
198 202 ${t.isSnoozed ? `<span class="snooze-badge" title="Snoozed until ${escAttr(t.snoozedUntilFormatted || '')}" aria-label="Snoozed until ${escAttr(t.snoozedUntilFormatted || '')}">Snoozed</span>` : ''}
199 - </div>
200 - <div class="task-cell col-project row-secondary task-project">${esc(t.projectName) || '-'}${GoingsOn.groups.taskSharedBadge(t.projectId)}</div>
201 - <div class="task-cell col-priority priority-${t.priority.toLowerCase()}" aria-label="Priority ${t.priority}">${t.priority.charAt(0)}</div>
202 - <div class="task-cell col-due row-meta task-due">${t.dueFormatted || '-'}</div>
203 - <div class="task-cell col-recurrence row-secondary task-recurrence">${formatRecurrence(t)}</div>
204 - <div class="task-cell col-progress task-progress">
205 - ${t.subtaskCount > 0 ? `
203 + `,
204 + },
205 + {
206 + column: 'project',
207 + class: 'row-secondary task-project',
208 + html: `${esc(t.projectName) || '-'}${GoingsOn.groups.taskSharedBadge(t.projectId)}`,
209 + },
210 + {
211 + column: 'priority',
212 + class: `priority-${t.priority.toLowerCase()}`,
213 + attrs: `aria-label="Priority ${t.priority}"`,
214 + html: t.priority.charAt(0),
215 + },
216 + {
217 + column: 'due',
218 + class: 'row-meta task-due',
219 + html: t.dueFormatted || '-',
220 + },
221 + {
222 + column: 'recurrence',
223 + class: 'row-secondary task-recurrence',
224 + html: formatRecurrence(t),
225 + },
226 + {
227 + column: 'progress',
228 + class: 'task-progress',
229 + html: t.subtaskCount > 0 ? `
206 230 <div class="progress" title="${t.subtaskCompleted}/${t.subtaskCount} subtasks"
207 231 role="progressbar" aria-valuenow="${progress}" aria-valuemin="0" aria-valuemax="100"
208 232 aria-label="${t.subtaskCompleted} of ${t.subtaskCount} subtasks completed">
209 233 <div class="progress-fill" data-tone="success" style="width: ${progress}%"></div>
210 234 </div>
211 - ` : '<span class="row-secondary no-subtasks">-</span>'}
212 - </div>
213 - <div class="task-cell col-actions task-actions-cell">
235 + ` : '<span class="row-secondary no-subtasks">-</span>',
236 + },
237 + {
238 + column: 'actions',
239 + class: 'task-actions-cell',
240 + html: `
214 241 ${renderRowActions(t, isStarted)}
215 242 <input type="checkbox" class="bulk-checkbox" data-id="${escAttr(t.id)}"
216 243 ${isSelected ? 'checked' : ''}
217 244 data-change="tasks.toggleSelection" data-a1="${escAttr(t.id)}" data-a2="@el" data-a3="@event"
218 245 aria-label="Select task">
219 246 <button class="button--icon row-actions kebab-btn" data-act="contextMenus.showTask" data-a1="@event" data-a2="${escAttr(t.id)}" title="Actions" aria-label="Task actions">&#x22EE;</button>
220 - </div>
247 + `,
248 + },
249 + ]);
250 +
251 + return `
252 + <div class="row task-row task-${t.status.toLowerCase()} task-${t.blockedClass || 'ready'} ${t.isSnoozed ? 'task-snoozed' : ''} ${t.isOverdue ? 'task-overdue' : ''} ${isSelected ? 'selected' : ''}"
253 + data-id="${escAttr(t.id)}"
254 + data-contextmenu="contextMenus.showTask" data-a1="@event" data-a2="${escAttr(t.id)}"
255 + tabindex="0" role="row">
256 + ${cells}
221 257 </div>
222 258 `;
223 259 }
@@ -97,6 +97,13 @@
97 97 require('../virtual-scroller'); // GoingsOn.VirtualScroller
98 98 require('../emails-threads'); // GoingsOn.emailsThreads (thread-list mutations)
99 99
100 + // Both row builders order their cells by the column description rather than by
101 + // hand, so the generated half has to be loaded before either of them. Requiring
102 + // it here is also the load-order assertion: a row built before the description
103 + // arrives renders no cells at all.
104 + require('../tables.generated'); // GoingsOn.tableColumns (written by build.rs)
105 + require('../table-cells'); // GoingsOn.tableCells (cells, in column order)
106 +
100 107 // tasks-render builds row markup from two collaborators it does not own. Both
101 108 // are stubbed here so a row render is a pure string call.
102 109 GoingsOn.tasks = GoingsOn.tasks || {};
@@ -1084,6 +1091,54 @@
1084 1091 }
1085 1092 });
1086 1093
1094 + // Test: the cell builder that puts the description in charge of the order
1095 +
1096 + // The row tests above assert the outcome. These assert the mechanism, because
1097 + // the outcome is only as good as what the builder does when a caller and the
1098 + // description disagree -- which is the case the hand-typed `col-` classes used
1099 + // to lose silently. Both behaviours are `cells_html`'s, deliberately: a column
1100 + // with no cell keeps its container so the grid stays aligned, and a cell naming
1101 + // no column is dropped because there is nowhere to put it.
1102 + describe('GoingsOn.tableCells', () => {
1103 + const cells = (list) => GoingsOn.tableCells.cells('recurring', 'event-cell', list);
1104 +
1105 + test('orders cells by the description, not by the caller', () => {
1106 + const html = cells([
1107 + { column: 'actions', html: 'A' },
1108 + { column: 'pattern', html: 'P' },
1109 + { column: 'title', html: 'T' },
1110 + ]);
1111 + assertDeepEqual(
1112 + [...html.matchAll(/class="[^"]*\bcol-([a-z]+)\b/g)].map(m => m[1]),
1113 + GoingsOn.tableColumns.recurring.columns
1114 + );
1115 + assert(html.indexOf('>P<') < html.indexOf('>T<'), 'pattern precedes title');
1116 + });
1117 +
1118 + test('a column with no cell keeps an empty container', () => {
1119 + const html = cells([{ column: 'title', html: 'T' }]);
1120 + assert(html.includes('<div class="event-cell col-location"></div>'),
1121 + 'the unfilled location column still emits its cell');
1122 + });
1123 +
1124 + test('a cell naming no column is dropped', () => {
1125 + const html = cells([
1126 + { column: 'title', html: 'T' },
1127 + { column: 'nonesuch', html: 'GONE' },
1128 + ]);
1129 + assert(!html.includes('GONE'), 'the undescribed cell does not reach the row');
1130 + assert(!html.includes('col-nonesuch'), 'and neither does its class');
1131 + });
1132 +
1133 + test('the column class comes from the description, never from the caller', () => {
1134 + // The caller says which column, never what it is called. That is the
1135 + // whole point: `col-` + name is a derivation the description owns.
1136 + const html = cells([{ column: 'pattern', class: 'event-cell-date', html: 'P' }]);
1137 + assert(html.includes(`class="event-cell ${GoingsOn.tableColumns.recurring.classes.pattern} event-cell-date"`),
1138 + 'base class, described class, then the caller\'s own');
1139 + });
1140 + });
1141 +
1087 1142 // Report
1088 1143
1089 1144 const success = report();
@@ -1,0 +1,89 @@
1 + /**
2 + * GoingsOn - table cells
3 + *
4 + * A row's cells, in the order its column description names, with each cell
5 + * wearing the class the description writes. The counterpart of
6 + * `makeover_webview::list::cells_html`, kept in JS on purpose.
7 + *
8 + * # Why this is not a call into Rust
9 + *
10 + * `cells_html` exists and is the same function, and `list.rs` asks a webview app
11 + * not to reach for it: goingson renders rows through VirtualScroller, whose
12 + * `_render` calls its row builder synchronously while scrolling, at 60Hz+, and
13 + * reaching Rust from there means an IPC round trip and an `await` inside that
14 + * loop during a drag. Batching per page instead of per window does not rescue
15 + * it either -- a task row's markup carries its own selection state, which
16 + * SelectionManager mutates in place without a re-render, so cached markup is
17 + * stale one click later.
18 + *
19 + * So the description crosses the boundary instead of the markup, once, at build
20 + * time. `tables.generated.js` carries the column order and each column's class;
21 + * this builds the cells from them. Nothing is derived here that build.rs did not
22 + * already write, which is the whole point: the row builders used to hand-type
23 + * `col-description` beside a stylesheet that generated it, and a column renamed
24 + * on one side went silently unmatched on the other.
25 + */
26 +
27 + (function() {
28 + 'use strict';
29 +
30 + /**
31 + * A table's column description, as build.rs wrote it.
32 + * @param {string} table - Table name ('task', 'upcoming', 'recurring')
33 + * @returns {{columns: string[], classes: Object<string,string>}|null}
34 + */
35 + function spec(table) {
36 + const spec = GoingsOn.tableColumns?.[table];
37 + if (!spec) {
38 + // Generated file missing or a table nobody described. Warn rather than
39 + // throw: a row that draws slightly wrong is recoverable inside a scroll
40 + // frame and a row that throws takes the list down with it.
41 + console.warn(`table-cells: no column description for table "${table}"`);
42 + return null;
43 + }
44 + return spec;
45 + }
46 +
47 + /**
48 + * Build a row's cells, ordered by the description rather than by the caller.
49 + *
50 + * Ordered by the columns and not by the cells, so a row cannot silently
51 + * disagree with its table about what comes where. A column with no cell gets an
52 + * empty container, which keeps the grid aligned; a cell naming no column is
53 + * dropped, because there is nowhere to put it. Both behaviours are
54 + * `cells_html`'s, deliberately.
55 + *
56 + * @param {string} table - Table name, keying the generated description
57 + * @param {string} baseClass - The app's own cell class ('task-cell', 'event-cell')
58 + * @param {Array<{column: string, class?: string, attrs?: string, html?: string}>} cells
59 + * @returns {string} HTML string of the row's cells, in column order
60 + */
61 + function cells(table, baseClass, cells) {
62 + const described = spec(table);
63 + if (!described) return '';
64 +
65 + for (const cell of cells) {
66 + if (!described.classes[cell.column]) {
67 + console.warn(
68 + `table-cells: cell "${cell.column}" names no column of table "${table}"; dropped`
69 + );
70 + }
71 + }
72 +
73 + return described.columns.map(name => {
74 + const cell = cells.find(c => c.column === name);
75 + const classes = [baseClass, described.classes[name], cell?.class]
76 + .filter(Boolean)
77 + .join(' ');
78 + const attrs = cell?.attrs ? ` ${cell.attrs}` : '';
79 + return `<div class="${classes}"${attrs}>${cell?.html ?? ''}</div>`;
80 + }).join('');
81 + }
82 +
83 + // Populate GoingsOn Namespace
84 +
85 + if (window.GoingsOn) {
86 + GoingsOn.tableCells = { cells };
87 + }
88 +
89 + })();