yaml-flow 5.2.8 → 5.3.0

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 (33) hide show
  1. package/browser/board-livegraph-engine.js +4 -1
  2. package/browser/board-livegraph-engine.js.map +1 -1
  3. package/browser/card-compute.js +1 -1
  4. package/browser/live-cards.js +178 -144
  5. package/browser/live-cards.schema.json +1 -1
  6. package/dist/board-livegraph-runtime/index.cjs +4 -1
  7. package/dist/board-livegraph-runtime/index.cjs.map +1 -1
  8. package/dist/board-livegraph-runtime/index.js +4 -1
  9. package/dist/board-livegraph-runtime/index.js.map +1 -1
  10. package/dist/card-compute/index.cjs +5 -1
  11. package/dist/card-compute/index.cjs.map +1 -1
  12. package/dist/card-compute/index.js +5 -1
  13. package/dist/card-compute/index.js.map +1 -1
  14. package/dist/cli/board-live-cards-cli.cjs +5 -1
  15. package/dist/cli/board-live-cards-cli.cjs.map +1 -1
  16. package/dist/cli/board-live-cards-cli.js +5 -1
  17. package/dist/cli/board-live-cards-cli.js.map +1 -1
  18. package/dist/continuous-event-graph/index.cjs +4 -1
  19. package/dist/continuous-event-graph/index.cjs.map +1 -1
  20. package/dist/continuous-event-graph/index.js +4 -1
  21. package/dist/continuous-event-graph/index.js.map +1 -1
  22. package/dist/index.cjs +5 -1
  23. package/dist/index.cjs.map +1 -1
  24. package/dist/index.js +5 -1
  25. package/dist/index.js.map +1 -1
  26. package/examples/example-board/agent-instructions-cardlayout.md +28 -0
  27. package/examples/example-board/cards/card-rebalance-sim.json +13 -3
  28. package/examples/example-board/demo-server.js +20 -8
  29. package/examples/example-board/demo-shell-browser.html +3 -3
  30. package/examples/example-board/demo-shell-with-server.html +4 -4
  31. package/examples/example-board/demo-task-executor.js +21 -0
  32. package/package.json +1 -1
  33. package/schema/live-cards.schema.json +1 -1
@@ -124,7 +124,7 @@
124
124
  // validate — lightweight structural validator (sync)
125
125
  // ===========================================================================
126
126
 
127
- var VALID_ELEMENT_KINDS = ['metric','table','chart','form','filter','list','notes','todo','alert','narrative','badge','text','markdown','custom'];
127
+ var VALID_ELEMENT_KINDS = ['metric','table','editable-table','chart','form','filter','list','notes','todo','alert','narrative','badge','text','markdown','ref','custom','actions'];
128
128
  var VALID_STATUSES = ['fresh','stale','loading','error'];
129
129
  var ALLOWED_KEYS = ['id','meta','requires','provides','view','card_data','compute','source_defs'];
130
130
 
@@ -212,9 +212,9 @@ var LiveCard = (function () {
212
212
 
213
213
  const _cleanup = {}; // nodeId → { ac, timers, charts, unsubs }
214
214
  const _subs = {}; // nodeId → Set<callback>
215
- const _etState = {}; // stateKey → { currentState, pending } for editable-table dirty tracking
216
- const _formState = {}; // stateKey → { currentState, pending } for form dirty tracking
217
- const _notesState = {}; // stateKey → { currentState, pending } for notes dirty tracking
215
+ const _etState = {}; // stateKey → { baseRows, journalRows|null }
216
+ const _formState = {}; // stateKey → { baseValues, journal }
217
+ const _notesState = {}; // stateKey → { baseContent, journal|null }
218
218
  const _todoState = {}; // stateKey → { currentState, pending } for todo dirty tracking
219
219
  const _renderers = {}; // kind → fn
220
220
  const _nodeEls = {}; // nodeId → { container, resultEl, uid }
@@ -1008,45 +1008,50 @@ var LiveCard = (function () {
1008
1008
  const props = schema.properties || {};
1009
1009
  const required = schema.required || [];
1010
1010
 
1011
- // --- Journal-style dirty tracking (mirrors editable-table) ---
1012
- // currentState = what the server last sent (updated each SSE re-render)
1013
- // pending = user's local field values accumulated on top of currentState
1014
- // dirty = JSON.stringify(currentState) !== JSON.stringify(pending)
1015
- //
1016
- // On SSE re-render:
1017
- // - currentState updated to new server values
1018
- // - if NOT dirty: pending follows currentState (no unsaved edits)
1019
- // - if dirty: pending untouched, form is rebuilt from pending (edits survive)
1020
- const stateKey = node.id + ':' + (writeTo || '');
1021
- const incomingValues = writeTo ? (_resolveBind(node, writeTo) || {}) : (data && typeof data === 'object' ? data : {});
1022
- const incomingCopy = Object.assign({}, incomingValues);
1011
+ const stateKey = node.id + ':' + (ed.bind || writeTo || '');
1012
+ const baseValues = (data && typeof data === 'object' && !Array.isArray(data)) ? Object.assign({}, data) : {};
1023
1013
 
1024
1014
  if (!_formState[stateKey]) {
1025
- _formState[stateKey] = { currentState: incomingCopy, pending: Object.assign({}, incomingCopy) };
1015
+ _formState[stateKey] = { baseValues, journal: {} };
1026
1016
  } else {
1027
- const s = _formState[stateKey];
1028
- const wasDirty = JSON.stringify(s.currentState) !== JSON.stringify(s.pending);
1029
- s.currentState = incomingCopy;
1030
- if (!wasDirty) {
1031
- s.pending = Object.assign({}, incomingCopy);
1032
- }
1033
- // if dirty, pending stays so user's in-progress edits survive the SSE tick
1017
+ _formState[stateKey].baseValues = baseValues;
1018
+ Object.keys(_formState[stateKey].journal).forEach(key => {
1019
+ if (_same(_formState[stateKey].journal[key], baseValues[key])) {
1020
+ delete _formState[stateKey].journal[key];
1021
+ }
1022
+ });
1034
1023
  }
1035
1024
 
1036
1025
  const st = _formState[stateKey];
1037
1026
 
1027
+ function _toInputValue(prop, inp) {
1028
+ if (prop.type === 'boolean') return !!inp.checked;
1029
+ if (prop.type === 'number' || prop.type === 'integer') return inp.value !== '' ? parseFloat(inp.value) : 0;
1030
+ return inp.value;
1031
+ }
1032
+
1033
+ function _same(a, b) {
1034
+ return JSON.stringify(a) === JSON.stringify(b);
1035
+ }
1036
+
1037
+ function getEffectiveValues() {
1038
+ return Object.assign({}, st.baseValues, st.journal);
1039
+ }
1040
+
1038
1041
  function isDirty() {
1039
- return JSON.stringify(st.currentState) !== JSON.stringify(st.pending);
1042
+ return Object.keys(st.journal).length > 0;
1040
1043
  }
1041
1044
 
1042
- // Snapshot current input values into pending (called on each input event)
1043
- function capturePending(form) {
1045
+ // Capture user edits into a journal overlay (only changed keys).
1046
+ function captureJournal(form) {
1044
1047
  form.querySelectorAll('[data-key]').forEach(inp => {
1045
- const k = inp.dataset.key, p = props[k];
1048
+ const k = inp.dataset.key;
1049
+ const p = props[k];
1046
1050
  if (!p) return;
1047
- if (p.type === 'boolean') st.pending[k] = inp.checked;
1048
- else if (p.type === 'number' || p.type === 'integer') st.pending[k] = inp.value !== '' ? parseFloat(inp.value) : 0;
1049
- else st.pending[k] = inp.value;
1051
+ const nextVal = _toInputValue(p, inp);
1052
+ const baseVal = st.baseValues[k];
1053
+ if (_same(nextVal, baseVal)) delete st.journal[k];
1054
+ else st.journal[k] = nextVal;
1050
1055
  });
1051
1056
  }
1052
1057
 
@@ -1098,8 +1103,8 @@ var LiveCard = (function () {
1098
1103
 
1099
1104
  input.dataset.key = key;
1100
1105
  if (isReq) input.required = true;
1101
- // Populate from pending (not from server values directly)
1102
- const v = st.pending[key];
1106
+ // Populate from effective values (base bind overlaid by local journal).
1107
+ const v = getEffectiveValues()[key];
1103
1108
  if (v != null) {
1104
1109
  if (prop.type === 'boolean') input.checked = !!v;
1105
1110
  else if (prop.format === 'date') input.value = String(v).slice(0, 10);
@@ -1110,34 +1115,51 @@ var LiveCard = (function () {
1110
1115
 
1111
1116
  const btnCol = document.createElement('div');
1112
1117
  btnCol.className = 'col-12 mt-1';
1118
+ const discardBtn = document.createElement('button');
1119
+ discardBtn.type = 'button';
1120
+ discardBtn.className = 'btn btn-sm btn-outline-secondary me-2' + (isDirty() ? '' : ' d-none');
1121
+ discardBtn.textContent = 'Discard';
1113
1122
  const btn = document.createElement('button');
1114
1123
  btn.type = 'submit';
1115
1124
  btn.className = 'btn btn-sm btn-primary' + (isDirty() ? '' : ' d-none');
1116
- btn.textContent = 'Submit';
1125
+ btn.textContent = 'Save';
1126
+ btnCol.appendChild(discardBtn);
1117
1127
  btnCol.appendChild(btn);
1118
1128
  form.appendChild(btnCol);
1119
1129
 
1120
1130
  el.innerHTML = '';
1121
1131
  el.appendChild(form);
1122
1132
 
1123
- // Real-time input → update pending + show Submit if now dirty
1133
+ // Real-time input → update journal + toggle Save/Discard buttons
1124
1134
  form.addEventListener('input', () => {
1125
- capturePending(form);
1126
- if (isDirty()) btn.classList.remove('d-none');
1135
+ captureJournal(form);
1136
+ const dirty = isDirty();
1137
+ btn.classList.toggle('d-none', !dirty);
1138
+ discardBtn.classList.toggle('d-none', !dirty);
1127
1139
  }, { signal });
1128
1140
 
1129
1141
  form.addEventListener('submit', e => {
1130
1142
  e.preventDefault();
1131
1143
  if (!form.checkValidity()) { form.classList.add('was-validated'); return; }
1132
- capturePending(form);
1133
- if (writeTo) _deepSet(node, writeTo, st.pending);
1134
- cfg.onPatchState(node.id, { fieldValues: st.pending });
1135
- notify(node.id, st.pending);
1136
- // After save, currentState = pending (no longer dirty)
1137
- st.currentState = Object.assign({}, st.pending);
1144
+ captureJournal(form);
1145
+ const nextValues = getEffectiveValues();
1146
+ cfg.onPatchState(node.id, { fieldValues: nextValues });
1147
+ btn.textContent = 'Saving...';
1148
+ }, { signal });
1149
+
1150
+ discardBtn.addEventListener('click', () => {
1151
+ st.journal = {};
1152
+ form.querySelectorAll('[data-key]').forEach(inp => {
1153
+ const k = inp.dataset.key;
1154
+ const p = props[k];
1155
+ if (!p) return;
1156
+ const v = st.baseValues[k];
1157
+ if (p.type === 'boolean') inp.checked = !!v;
1158
+ else if (p.format === 'date') inp.value = v != null ? String(v).slice(0, 10) : '';
1159
+ else inp.value = v != null ? v : '';
1160
+ });
1161
+ discardBtn.classList.add('d-none');
1138
1162
  btn.classList.add('d-none');
1139
- btn.textContent = '✓ Saved';
1140
- setTimeout(() => { btn.textContent = 'Submit'; }, 1500);
1141
1163
  }, { signal });
1142
1164
  }
1143
1165
 
@@ -1150,54 +1172,63 @@ var LiveCard = (function () {
1150
1172
  const writeTo = ed.writeTo;
1151
1173
  const incomingContent = typeof data === 'string' ? data : '';
1152
1174
 
1153
- // --- Journal-style dirty tracking ---
1154
- // currentState = last server value; pending = textarea content
1155
- // On SSE re-render: if dirty, leave textarea alone; if clean, sync from server
1156
- const stateKey = node.id + ':' + (writeTo || '');
1175
+ // Base + journal overlay model:
1176
+ // effective = journal when present, else baseContent from bind.
1177
+ const stateKey = node.id + ':' + ((ed.bind || writeTo) || '');
1157
1178
  if (!_notesState[stateKey]) {
1158
- _notesState[stateKey] = { currentState: incomingContent, pending: incomingContent };
1179
+ _notesState[stateKey] = { baseContent: incomingContent, journal: null };
1159
1180
  } else {
1160
- const s = _notesState[stateKey];
1161
- const wasDirty = s.currentState !== s.pending;
1162
- s.currentState = incomingContent;
1163
- if (!wasDirty) s.pending = incomingContent;
1164
- // if dirty, pending stays so user's typing survives the SSE tick
1181
+ _notesState[stateKey].baseContent = incomingContent;
1182
+ if (_notesState[stateKey].journal === incomingContent) {
1183
+ _notesState[stateKey].journal = null;
1184
+ }
1165
1185
  }
1166
1186
  const st = _notesState[stateKey];
1167
1187
 
1188
+ function isDirty() {
1189
+ return st.journal != null;
1190
+ }
1191
+
1192
+ function getEffectiveContent() {
1193
+ return st.journal != null ? st.journal : st.baseContent;
1194
+ }
1195
+
1196
+ function setJournal(nextValue) {
1197
+ st.journal = nextValue === st.baseContent ? null : nextValue;
1198
+ }
1199
+
1168
1200
  el.innerHTML = `
1169
- <div class="btn-group btn-group-sm mb-2" role="group">
1170
- <button class="btn btn-outline-secondary active lc-n-edit" type="button">Edit</button>
1171
- <button class="btn btn-outline-secondary lc-n-preview" type="button">Preview</button>
1172
- </div>
1173
- <textarea class="form-control form-control-sm lc-notes-textarea" rows="8" placeholder="Write markdown...">${_esc(st.pending)}</textarea>
1174
- <div class="lc-notes-preview d-none border rounded p-2 small"></div>`;
1201
+ <textarea class="form-control form-control-sm lc-notes-textarea" rows="8" placeholder="Write markdown...">${_esc(getEffectiveContent())}</textarea>
1202
+ <div class="mt-2">
1203
+ <button class="btn btn-sm btn-outline-secondary me-2 lc-n-discard${isDirty() ? '' : ' d-none'}" type="button">Discard</button>
1204
+ <button class="btn btn-sm btn-primary lc-n-save${isDirty() ? '' : ' d-none'}" type="button">Save</button>
1205
+ </div>`;
1175
1206
 
1176
1207
  const textarea = el.querySelector('.lc-notes-textarea');
1177
- const preview = el.querySelector('.lc-notes-preview');
1178
- const editBtn = el.querySelector('.lc-n-edit');
1179
- const previewBtn = el.querySelector('.lc-n-preview');
1208
+ const discardBtn = el.querySelector('.lc-n-discard');
1209
+ const saveBtn = el.querySelector('.lc-n-save');
1210
+
1211
+ function syncDirtyButtons() {
1212
+ const dirty = isDirty();
1213
+ saveBtn.classList.toggle('d-none', !dirty);
1214
+ discardBtn.classList.toggle('d-none', !dirty);
1215
+ }
1180
1216
 
1181
- editBtn.addEventListener('click', () => {
1182
- textarea.classList.remove('d-none'); preview.classList.add('d-none');
1183
- editBtn.classList.add('active'); previewBtn.classList.remove('active');
1217
+ textarea.addEventListener('input', () => {
1218
+ setJournal(textarea.value);
1219
+ syncDirtyButtons();
1184
1220
  }, { signal });
1185
- previewBtn.addEventListener('click', () => {
1186
- preview.innerHTML = _renderMd(textarea.value);
1187
- textarea.classList.add('d-none'); preview.classList.remove('d-none');
1188
- previewBtn.classList.add('active'); editBtn.classList.remove('active');
1221
+
1222
+ saveBtn.addEventListener('click', () => {
1223
+ const nextValue = textarea.value;
1224
+ cfg.onPatchState(node.id, { notes: nextValue });
1225
+ saveBtn.textContent = 'Saving...';
1189
1226
  }, { signal });
1190
1227
 
1191
- let timer;
1192
- textarea.addEventListener('input', () => {
1193
- st.pending = textarea.value; // track in journal
1194
- clearTimeout(timer);
1195
- timer = setTimeout(() => {
1196
- if (writeTo) _deepSet(node, writeTo, textarea.value);
1197
- cfg.onPatchState(node.id, { notes: textarea.value });
1198
- st.currentState = textarea.value; // saved — no longer dirty
1199
- }, 800);
1200
- cleanup.timers.push(timer);
1228
+ discardBtn.addEventListener('click', () => {
1229
+ st.journal = null;
1230
+ textarea.value = st.baseContent || '';
1231
+ syncDirtyButtons();
1201
1232
  }, { signal });
1202
1233
  }
1203
1234
 
@@ -1215,7 +1246,11 @@ var LiveCard = (function () {
1215
1246
  const cleanup = _getCleanup(node.id);
1216
1247
  const signal = cleanup.ac.signal;
1217
1248
  const ed = elemDef.data || {};
1218
- const writeTo = ed.writeTo;
1249
+ // Standard convention:
1250
+ // - bind = read source
1251
+ // - writeTo = explicit write target for editable views
1252
+ // If bind already points at card_data, default writeTo to bind.
1253
+ const writeTo = ed.writeTo || ((typeof ed.bind === 'string' && ed.bind.startsWith('card_data.')) ? ed.bind : undefined);
1219
1254
  const schemaProps = (ed.schema && ed.schema.properties) || {};
1220
1255
  const canAdd = ed.addRow !== false;
1221
1256
  const canDelete = ed.deleteRow !== false;
@@ -1228,57 +1263,59 @@ var LiveCard = (function () {
1228
1263
  return [...s];
1229
1264
  }
1230
1265
 
1231
- // --- Journal-style dirty tracking ---
1232
- // currentState = what the server last sent (updated each SSE re-render)
1233
- // pending = user's local accumulation of edits on top of currentState
1234
- // dirty = JSON.stringify(currentState) !== JSON.stringify(pending)
1235
- //
1236
- // When SSE fires and re-renders this element:
1237
- // - currentState is updated to the new server value
1238
- // - if NOT dirty: pending follows currentState (no unsaved edits)
1239
- // - if dirty: pending is left untouched (user's edits survive the SSE tick)
1240
- const stateKey = node.id + ':' + (ed.bind || ed.writeTo || '');
1241
- const incomingRows = Array.isArray(writeTo ? _resolveBind(node, writeTo) : data)
1242
- ? (writeTo ? _resolveBind(node, writeTo) : data)
1243
- : [];
1266
+ // Base + journal overlay model:
1267
+ // effectiveRows = journalRows if present, else baseRows(bind).
1268
+ // Dirty is determined by journal presence (supports Save/Discard UX).
1269
+ const stateKey = node.id + ':' + (ed.bind || writeTo || '');
1270
+ const incomingRows = Array.isArray(data) ? data : [];
1244
1271
  const incomingCopy = incomingRows.map(r => Object.assign({}, r));
1245
1272
 
1246
1273
  if (!_etState[stateKey]) {
1247
- // First render initialise both sides from server data
1248
- _etState[stateKey] = { currentState: incomingCopy, pending: incomingCopy.map(r => Object.assign({}, r)) };
1274
+ _etState[stateKey] = { baseRows: incomingCopy, journalRows: null };
1249
1275
  } else {
1250
- const s = _etState[stateKey];
1251
- const wasDirty = JSON.stringify(s.currentState) !== JSON.stringify(s.pending);
1252
- s.currentState = incomingCopy;
1253
- if (!wasDirty) {
1254
- // No unsaved edits — sync pending to new server state
1255
- s.pending = incomingCopy.map(r => Object.assign({}, r));
1276
+ _etState[stateKey].baseRows = incomingCopy;
1277
+ if (_etState[stateKey].journalRows && JSON.stringify(_etState[stateKey].journalRows) === JSON.stringify(incomingCopy)) {
1278
+ _etState[stateKey].journalRows = null;
1256
1279
  }
1257
- // If dirty, pending stays as-is so user's edits survive the SSE tick
1258
1280
  }
1259
1281
 
1260
1282
  const st = _etState[stateKey];
1261
1283
 
1262
1284
  function isDirty() {
1263
- return JSON.stringify(st.currentState) !== JSON.stringify(st.pending);
1285
+ return Array.isArray(st.journalRows);
1286
+ }
1287
+
1288
+ function getEffectiveRows() {
1289
+ const rows = Array.isArray(st.journalRows) ? st.journalRows : st.baseRows;
1290
+ return rows.map(r => Object.assign({}, r));
1291
+ }
1292
+
1293
+ function updateJournal(nextRows) {
1294
+ if (JSON.stringify(nextRows) === JSON.stringify(st.baseRows)) st.journalRows = null;
1295
+ else st.journalRows = nextRows.map(r => Object.assign({}, r));
1264
1296
  }
1265
1297
 
1266
1298
  function markDirty() {
1267
1299
  const saveBtn = el.querySelector('.lc-et-save');
1300
+ const discardBtn = el.querySelector('.lc-et-discard');
1268
1301
  if (saveBtn) saveBtn.classList.remove('d-none');
1302
+ if (discardBtn) discardBtn.classList.remove('d-none');
1269
1303
  }
1270
1304
 
1271
1305
  function commitSave() {
1272
- if (writeTo) _deepSet(node, writeTo, st.pending);
1273
- cfg.onPatchState(node.id, { fieldValues: st.pending });
1274
- notify(node.id, st.pending);
1275
- // After save, currentState = pending (no longer dirty)
1276
- st.currentState = st.pending.map(r => Object.assign({}, r));
1306
+ const rows = getEffectiveRows();
1307
+ cfg.onPatchState(node.id, { fieldValues: rows });
1308
+ const saveBtn = el.querySelector('.lc-et-save');
1309
+ if (saveBtn) saveBtn.textContent = 'Saving...';
1310
+ }
1311
+
1312
+ function commitDiscard() {
1313
+ st.journalRows = null;
1277
1314
  build();
1278
1315
  }
1279
1316
 
1280
1317
  function build() {
1281
- const rows = st.pending;
1318
+ const rows = getEffectiveRows();
1282
1319
  const cols = getCols(rows);
1283
1320
 
1284
1321
  if (!cols.length && !canAdd) {
@@ -1321,44 +1358,63 @@ var LiveCard = (function () {
1321
1358
  h += '</tbody></table></div>';
1322
1359
  let footer = '';
1323
1360
  if (canAdd) footer += '<button class="btn btn-sm btn-outline-secondary mt-1 me-1 lc-et-add">+ Add row</button>';
1361
+ footer += `<button class="btn btn-sm btn-outline-secondary mt-1 me-1 lc-et-discard${isDirty() ? '' : ' d-none'}">Discard</button>`;
1324
1362
  footer += `<button class="btn btn-sm btn-primary mt-1 lc-et-save${isDirty() ? '' : ' d-none'}">Save</button>`;
1325
1363
  el.innerHTML = h + footer;
1326
1364
 
1327
- // Cell edit → update pending on blur, show Save if now dirty
1365
+ // Cell edit → update journal overlay and toggle Save/Discard.
1328
1366
  el.querySelectorAll('.lc-et-cell').forEach(inp => {
1329
1367
  inp.addEventListener('change', () => {
1330
1368
  const rowIdx = parseInt(inp.dataset.row);
1331
1369
  const colName = inp.dataset.col;
1332
1370
  const prop = schemaProps[colName] || {};
1333
1371
  const isNum = prop.type === 'number' || prop.type === 'integer' || inp.type === 'number';
1334
- if (!st.pending[rowIdx]) return;
1335
- st.pending[rowIdx] = Object.assign({}, st.pending[rowIdx]);
1336
- st.pending[rowIdx][colName] = isNum ? (inp.value !== '' ? parseFloat(inp.value) : 0) : inp.value;
1372
+ const nextRows = getEffectiveRows();
1373
+ if (!nextRows[rowIdx]) return;
1374
+ nextRows[rowIdx] = Object.assign({}, nextRows[rowIdx]);
1375
+ nextRows[rowIdx][colName] = isNum ? (inp.value !== '' ? parseFloat(inp.value) : 0) : inp.value;
1376
+ updateJournal(nextRows);
1337
1377
  if (isDirty()) markDirty();
1378
+ else {
1379
+ const saveBtn = el.querySelector('.lc-et-save');
1380
+ const discardBtn = el.querySelector('.lc-et-discard');
1381
+ if (saveBtn) saveBtn.classList.add('d-none');
1382
+ if (discardBtn) discardBtn.classList.add('d-none');
1383
+ }
1338
1384
  }, { signal });
1339
1385
  });
1340
1386
 
1341
- // Delete row — updates pending, rebuilds (Save button shown if dirty)
1387
+ // Delete row — updates journal and rebuilds.
1342
1388
  el.querySelectorAll('.lc-et-del').forEach(btn => {
1343
1389
  btn.addEventListener('click', () => {
1344
1390
  const rowIdx = parseInt(btn.dataset.row);
1345
- st.pending = st.pending.filter((_, i) => i !== rowIdx);
1391
+ const nextRows = getEffectiveRows().filter((_, i) => i !== rowIdx);
1392
+ updateJournal(nextRows);
1346
1393
  build();
1347
1394
  }, { signal });
1348
1395
  });
1349
1396
 
1350
- // Add row — appends blank row to pending, rebuilds (Save button shown if dirty)
1397
+ // Add row — appends blank row to journal and rebuilds.
1351
1398
  const addBtn = el.querySelector('.lc-et-add');
1352
1399
  if (addBtn) {
1353
1400
  addBtn.addEventListener('click', () => {
1354
1401
  const newRow = {};
1355
- getCols(st.pending).forEach(c => { newRow[c] = ''; });
1356
- st.pending = [...st.pending, newRow];
1402
+ const nextRows = getEffectiveRows();
1403
+ getCols(nextRows).forEach(c => { newRow[c] = ''; });
1404
+ nextRows.push(newRow);
1405
+ updateJournal(nextRows);
1357
1406
  build();
1358
1407
  }, { signal });
1359
1408
  }
1360
1409
 
1361
- // Save button — persist pending to server via fieldValues (same as form submit)
1410
+ // Save/Discard controls.
1411
+ const discardBtn = el.querySelector('.lc-et-discard');
1412
+ if (discardBtn) {
1413
+ discardBtn.addEventListener('click', () => {
1414
+ commitDiscard();
1415
+ }, { signal });
1416
+ }
1417
+
1362
1418
  const saveBtn = el.querySelector('.lc-et-save');
1363
1419
  if (saveBtn) {
1364
1420
  saveBtn.addEventListener('click', () => {
@@ -2078,12 +2134,6 @@ var LiveCard = (function () {
2078
2134
  // Elements area
2079
2135
  h += `<div class="lc-result" id="${uid}-result"></div>`;
2080
2136
 
2081
- // Notes section (feature toggle)
2082
- if (features.notes && opts.showNotes !== false) {
2083
- h += `<details class="mt-2"><summary class="small fw-medium">Notes</summary>`;
2084
- h += `<textarea class="form-control form-control-sm mt-1" id="${uid}-notes" rows="3" placeholder="Add notes...">${_esc((node.card_data && node.card_data._notes) || '')}</textarea></details>`;
2085
- }
2086
-
2087
2137
  h += '</div>';
2088
2138
  containerEl.innerHTML = h;
2089
2139
 
@@ -2123,21 +2173,6 @@ var LiveCard = (function () {
2123
2173
  }, { signal });
2124
2174
  }
2125
2175
 
2126
- // ---- Wire notes ----
2127
- const notesEl = document.getElementById(uid + '-notes');
2128
- if (notesEl) {
2129
- let nTimer;
2130
- notesEl.addEventListener('input', () => {
2131
- clearTimeout(nTimer);
2132
- nTimer = setTimeout(() => {
2133
- if (!node.card_data) node.card_data = {};
2134
- node.card_data._notes = notesEl.value;
2135
- cfg.onPatch(node.id, { _notes: notesEl.value });
2136
- }, 800);
2137
- cleanup.timers.push(nTimer);
2138
- }, { signal });
2139
- }
2140
-
2141
2176
  _autoSubscribe(node);
2142
2177
  }
2143
2178
 
@@ -2293,7 +2328,6 @@ var LiveCard = (function () {
2293
2328
  const nodeList = [];
2294
2329
  const nodeMap = {}; // id → { node, colEl, bodyEl }
2295
2330
  const _positions = {}; // id → { x, y, w, h } for canvas mode
2296
- const showNotes = opts.showNotes !== false;
2297
2331
  const showChat = opts.showChat || false;
2298
2332
  const defaultCol = opts.defaultCol || 6;
2299
2333
 
@@ -2648,7 +2682,7 @@ var LiveCard = (function () {
2648
2682
  col.appendChild(wrap);
2649
2683
  gridEl.appendChild(col);
2650
2684
  nodeMap[node.id] = { node, colEl: col, bodyEl: body };
2651
- engine.render(node, body, { showNotes, showChat });
2685
+ engine.render(node, body, { showChat });
2652
2686
  });
2653
2687
  }
2654
2688
 
@@ -2762,7 +2796,7 @@ var LiveCard = (function () {
2762
2796
  if (movedHeader && movedHeader.dataset.gandalfCollapsed === '1') el.classList.add('lc-collapsed');
2763
2797
  canvasInner.appendChild(el);
2764
2798
  nodeMap[node.id] = { node, colEl: el, bodyEl: body };
2765
- engine.render(node, body, { showNotes: false, showChat: false });
2799
+ engine.render(node, body, { showChat: false });
2766
2800
  _makeDraggable(el, node);
2767
2801
  }
2768
2802
  });
@@ -126,7 +126,7 @@
126
126
  "kind": {
127
127
  "enum": ["metric", "table", "editable-table", "chart", "form", "filter", "list",
128
128
  "notes", "todo", "alert", "narrative", "badge", "text",
129
- "markdown", "custom", "actions"]
129
+ "markdown", "ref", "custom", "actions"]
130
130
  },
131
131
  "label": { "type": "string", "description": "Heading above this element" },
132
132
  "className": { "type": "string", "description": "Bootstrap grid class, e.g. 'col-12 col-md-6'" },
@@ -70,6 +70,7 @@ function resolve(node, path) {
70
70
  var VALID_ELEMENT_KINDS = /* @__PURE__ */ new Set([
71
71
  "metric",
72
72
  "table",
73
+ "editable-table",
73
74
  "chart",
74
75
  "form",
75
76
  "filter",
@@ -81,7 +82,9 @@ var VALID_ELEMENT_KINDS = /* @__PURE__ */ new Set([
81
82
  "badge",
82
83
  "text",
83
84
  "markdown",
84
- "custom"
85
+ "ref",
86
+ "custom",
87
+ "actions"
85
88
  ]);
86
89
  var ALLOWED_KEYS = /* @__PURE__ */ new Set(["id", "meta", "requires", "provides", "view", "card_data", "compute", "source_defs"]);
87
90
  function validateNode(node) {