yaml-flow 5.2.6 → 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 (84) hide show
  1. package/README.md +6 -6
  2. package/board-livecards-server-runtime.js +260 -35
  3. package/browser/board-livegraph-engine.js +61 -33
  4. package/browser/board-livegraph-engine.js.map +1 -1
  5. package/browser/card-compute.js +18 -18
  6. package/browser/live-cards.js +317 -156
  7. package/browser/live-cards.schema.json +15 -10
  8. package/dist/board-livegraph-runtime/index.cjs +61 -33
  9. package/dist/board-livegraph-runtime/index.cjs.map +1 -1
  10. package/dist/board-livegraph-runtime/index.d.cts +1 -1
  11. package/dist/board-livegraph-runtime/index.d.ts +1 -1
  12. package/dist/board-livegraph-runtime/index.js +61 -33
  13. package/dist/board-livegraph-runtime/index.js.map +1 -1
  14. package/dist/card-compute/index.cjs +101 -39
  15. package/dist/card-compute/index.cjs.map +1 -1
  16. package/dist/card-compute/index.d.cts +13 -8
  17. package/dist/card-compute/index.d.ts +13 -8
  18. package/dist/card-compute/index.js +101 -39
  19. package/dist/card-compute/index.js.map +1 -1
  20. package/dist/cli/board-live-cards-cli.cjs +7205 -202
  21. package/dist/cli/board-live-cards-cli.cjs.map +1 -1
  22. package/dist/cli/board-live-cards-cli.d.cts +6 -6
  23. package/dist/cli/board-live-cards-cli.d.ts +6 -6
  24. package/dist/cli/board-live-cards-cli.js +7204 -202
  25. package/dist/cli/board-live-cards-cli.js.map +1 -1
  26. package/dist/continuous-event-graph/index.cjs +59 -31
  27. package/dist/continuous-event-graph/index.cjs.map +1 -1
  28. package/dist/continuous-event-graph/index.d.cts +2 -2
  29. package/dist/continuous-event-graph/index.d.ts +2 -2
  30. package/dist/continuous-event-graph/index.js +59 -31
  31. package/dist/continuous-event-graph/index.js.map +1 -1
  32. package/dist/index.cjs +126 -54
  33. package/dist/index.cjs.map +1 -1
  34. package/dist/index.d.cts +1 -1
  35. package/dist/index.d.ts +1 -1
  36. package/dist/index.js +126 -54
  37. package/dist/index.js.map +1 -1
  38. package/dist/{live-cards-bridge-CeNxiVcm.d.ts → live-cards-bridge-EQjytzI_.d.ts} +10 -5
  39. package/dist/{live-cards-bridge-z_rJCSbi.d.cts → live-cards-bridge-x5XREkXm.d.cts} +10 -5
  40. package/examples/browser/boards/portfolio-tracker/cards/holdings-table.json +1 -1
  41. package/examples/browser/boards/portfolio-tracker/cards/portfolio-form.json +1 -1
  42. package/examples/browser/boards/portfolio-tracker/cards/portfolio-risk-assessment.json +1 -1
  43. package/examples/browser/boards/portfolio-tracker/cards/portfolio-value.json +1 -1
  44. package/examples/browser/boards/portfolio-tracker/cards/price-fetch.json +2 -2
  45. package/examples/browser/boards/portfolio-tracker/cards/rebalancing-strategy.json +1 -1
  46. package/examples/browser/boards/portfolio-tracker/portfolio-tracker.js +10 -10
  47. package/examples/cli/step-machine-cli/portfolio-tracker/cards/holdings-table.json +1 -1
  48. package/examples/cli/step-machine-cli/portfolio-tracker/cards/portfolio-form.json +1 -1
  49. package/examples/cli/step-machine-cli/portfolio-tracker/cards/portfolio-value.json +1 -1
  50. package/examples/cli/step-machine-cli/portfolio-tracker/cards/price-fetch.json +2 -2
  51. package/examples/cli/step-machine-cli/portfolio-tracker/handlers/add-cards-cli.js +1 -1
  52. package/examples/cli/step-machine-cli/portfolio-tracker/handlers/update-holdings-cli.js +1 -1
  53. package/examples/cli/step-machine-cli/portfolio-tracker/portfolio-tracker.flow.yaml +1 -1
  54. package/examples/example-board/agent-instructions-cardlayout.md +29 -1
  55. package/examples/example-board/agent-instructions.md +271 -45
  56. package/examples/example-board/cards/card-concentration.json +8 -5
  57. package/examples/example-board/cards/card-market-prices.json +14 -9
  58. package/examples/example-board/cards/card-my-identity.json +28 -0
  59. package/examples/example-board/cards/card-portfolio-value.json +1 -1
  60. package/examples/example-board/cards/card-portfolio.json +1 -1
  61. package/examples/example-board/cards/card-rebalance-impact.json +65 -0
  62. package/examples/example-board/cards/card-rebalance-sim.json +67 -0
  63. package/examples/example-board/demo-chat-handler.js +2 -1
  64. package/examples/example-board/demo-server-config.json +6 -1
  65. package/examples/example-board/demo-server.js +91 -8
  66. package/examples/example-board/demo-shell-browser.html +6 -6
  67. package/examples/example-board/demo-shell-with-server.html +4 -4
  68. package/examples/example-board/demo-task-executor.js +457 -246
  69. package/examples/example-board/scripts/copilot_wrapper.bat +16 -0
  70. package/examples/example-board/scripts/copilot_wrapper_helper.ps1 +19 -10
  71. package/examples/example-board/scripts/workiq_wrapper.mjs +66 -0
  72. package/examples/npm-libs/continuous-event-graph/live-cards-board.ts +5 -5
  73. package/examples/npm-libs/continuous-event-graph/soc-incident-board.ts +3 -3
  74. package/examples/npm-libs/event-graph/research-pipeline.ts +5 -5
  75. package/examples/npm-libs/graph-of-graphs/multi-stage-etl.ts +9 -9
  76. package/examples/step-machine-cli/portfolio-tracker/cards/holdings-table.json +1 -1
  77. package/examples/step-machine-cli/portfolio-tracker/cards/portfolio-form.json +1 -1
  78. package/examples/step-machine-cli/portfolio-tracker/cards/portfolio-value.json +1 -1
  79. package/examples/step-machine-cli/portfolio-tracker/cards/price-fetch.json +3 -3
  80. package/examples/step-machine-cli/portfolio-tracker/handlers/add-cards-cli.js +1 -1
  81. package/examples/step-machine-cli/portfolio-tracker/handlers/update-holdings-cli.js +1 -1
  82. package/examples/step-machine-cli/portfolio-tracker/portfolio-tracker.flow.yaml +1 -1
  83. package/package.json +2 -2
  84. package/schema/live-cards.schema.json +15 -10
@@ -1,10 +1,10 @@
1
1
  // live-cards.js — LiveCards v3: Node-based Board/Canvas engine
2
2
  //
3
3
  // Schema: Each node has { id } required; all else optional.
4
- // id, meta, card_data, requires, provides, sources, compute, view
5
- // Nodes with view render as cards; nodes with sources but no view render as source pills in canvas.
4
+ // id, meta, card_data, requires, provides, source_defs, compute, view
5
+ // Nodes with view render as cards; nodes with source_defs but no view render as source pills in canvas.
6
6
  // compute[] — ordered array of { bindTo, expr } JSONata steps → writes to node.computed_values (ephemeral)
7
- // sources[] — open objects: only bindTo + outputFile matter to the engine; all other fields are
7
+ // source_defs[] — open objects: only bindTo + outputFile matter to the engine; all other fields are
8
8
  // passed verbatim to the board's task-executor (--in JSON). Users define their own
9
9
  // shape (kind, url, mailbox, channel, model, ...) per executor.
10
10
  // requires[] — upstream node IDs; engine subscribes automatically
@@ -89,6 +89,15 @@ var LiveCard = (function () {
89
89
  .lc-files-modal-backdrop .modal-dialog { max-height:90vh; }
90
90
  .lc-files-modal-backdrop .modal-content { display:flex; flex-direction:column; max-height:90vh; }
91
91
  .lc-files-modal-backdrop .modal-body { overflow-y:auto; flex:1; min-height:200px; padding:1rem; }
92
+ .lc-simulation-card { background:#fdf6ec; border-color:#e0c97f !important; }
93
+ .lc-simulation-card .card-header { background:#faecc8; border-bottom-color:#e0c97f; }
94
+ .lc-gandalf-card { background:#eef4ff; border-color:#6ea4e0 !important; }
95
+ .lc-gandalf-card .card-header { background:#d7e8fa; border-bottom-color:#6ea4e0; cursor:pointer; user-select:none; }
96
+ .lc-gandalf-card .card-header:hover { background:#c8dcf5; }
97
+ .lc-gandalf-caret { transition:transform .2s; display:inline-flex; align-items:center; margin-left:auto; opacity:.6; flex-shrink:0; cursor:pointer; padding:2px; }
98
+ .lc-gandalf-caret:hover { opacity:1; }
99
+ .lc-gandalf-card.lc-collapsed .lc-gandalf-caret { transform:rotate(-90deg); }
100
+ .lc-gandalf-card.lc-collapsed .card-body { display:none !important; }
92
101
  @media (max-width:576px) {
93
102
  .lc-metric-value { font-size:1.5rem; }
94
103
  .lc-chart-wrap { min-height:150px; }
@@ -203,9 +212,9 @@ var LiveCard = (function () {
203
212
 
204
213
  const _cleanup = {}; // nodeId → { ac, timers, charts, unsubs }
205
214
  const _subs = {}; // nodeId → Set<callback>
206
- const _etState = {}; // stateKey → { currentState, pending } for editable-table dirty tracking
207
- const _formState = {}; // stateKey → { currentState, pending } for form dirty tracking
208
- 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 }
209
218
  const _todoState = {}; // stateKey → { currentState, pending } for todo dirty tracking
210
219
  const _renderers = {}; // kind → fn
211
220
  const _nodeEls = {}; // nodeId → { container, resultEl, uid }
@@ -999,45 +1008,50 @@ var LiveCard = (function () {
999
1008
  const props = schema.properties || {};
1000
1009
  const required = schema.required || [];
1001
1010
 
1002
- // --- Journal-style dirty tracking (mirrors editable-table) ---
1003
- // currentState = what the server last sent (updated each SSE re-render)
1004
- // pending = user's local field values accumulated on top of currentState
1005
- // dirty = JSON.stringify(currentState) !== JSON.stringify(pending)
1006
- //
1007
- // On SSE re-render:
1008
- // - currentState updated to new server values
1009
- // - if NOT dirty: pending follows currentState (no unsaved edits)
1010
- // - if dirty: pending untouched, form is rebuilt from pending (edits survive)
1011
- const stateKey = node.id + ':' + (writeTo || '');
1012
- const incomingValues = writeTo ? (_resolveBind(node, writeTo) || {}) : (data && typeof data === 'object' ? data : {});
1013
- 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) : {};
1014
1013
 
1015
1014
  if (!_formState[stateKey]) {
1016
- _formState[stateKey] = { currentState: incomingCopy, pending: Object.assign({}, incomingCopy) };
1015
+ _formState[stateKey] = { baseValues, journal: {} };
1017
1016
  } else {
1018
- const s = _formState[stateKey];
1019
- const wasDirty = JSON.stringify(s.currentState) !== JSON.stringify(s.pending);
1020
- s.currentState = incomingCopy;
1021
- if (!wasDirty) {
1022
- s.pending = Object.assign({}, incomingCopy);
1023
- }
1024
- // 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
+ });
1025
1023
  }
1026
1024
 
1027
1025
  const st = _formState[stateKey];
1028
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
+
1029
1041
  function isDirty() {
1030
- return JSON.stringify(st.currentState) !== JSON.stringify(st.pending);
1042
+ return Object.keys(st.journal).length > 0;
1031
1043
  }
1032
1044
 
1033
- // Snapshot current input values into pending (called on each input event)
1034
- function capturePending(form) {
1045
+ // Capture user edits into a journal overlay (only changed keys).
1046
+ function captureJournal(form) {
1035
1047
  form.querySelectorAll('[data-key]').forEach(inp => {
1036
- const k = inp.dataset.key, p = props[k];
1048
+ const k = inp.dataset.key;
1049
+ const p = props[k];
1037
1050
  if (!p) return;
1038
- if (p.type === 'boolean') st.pending[k] = inp.checked;
1039
- else if (p.type === 'number' || p.type === 'integer') st.pending[k] = inp.value !== '' ? parseFloat(inp.value) : 0;
1040
- 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;
1041
1055
  });
1042
1056
  }
1043
1057
 
@@ -1089,8 +1103,8 @@ var LiveCard = (function () {
1089
1103
 
1090
1104
  input.dataset.key = key;
1091
1105
  if (isReq) input.required = true;
1092
- // Populate from pending (not from server values directly)
1093
- const v = st.pending[key];
1106
+ // Populate from effective values (base bind overlaid by local journal).
1107
+ const v = getEffectiveValues()[key];
1094
1108
  if (v != null) {
1095
1109
  if (prop.type === 'boolean') input.checked = !!v;
1096
1110
  else if (prop.format === 'date') input.value = String(v).slice(0, 10);
@@ -1101,34 +1115,51 @@ var LiveCard = (function () {
1101
1115
 
1102
1116
  const btnCol = document.createElement('div');
1103
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';
1104
1122
  const btn = document.createElement('button');
1105
1123
  btn.type = 'submit';
1106
1124
  btn.className = 'btn btn-sm btn-primary' + (isDirty() ? '' : ' d-none');
1107
- btn.textContent = 'Submit';
1125
+ btn.textContent = 'Save';
1126
+ btnCol.appendChild(discardBtn);
1108
1127
  btnCol.appendChild(btn);
1109
1128
  form.appendChild(btnCol);
1110
1129
 
1111
1130
  el.innerHTML = '';
1112
1131
  el.appendChild(form);
1113
1132
 
1114
- // Real-time input → update pending + show Submit if now dirty
1133
+ // Real-time input → update journal + toggle Save/Discard buttons
1115
1134
  form.addEventListener('input', () => {
1116
- capturePending(form);
1117
- 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);
1118
1139
  }, { signal });
1119
1140
 
1120
1141
  form.addEventListener('submit', e => {
1121
1142
  e.preventDefault();
1122
1143
  if (!form.checkValidity()) { form.classList.add('was-validated'); return; }
1123
- capturePending(form);
1124
- if (writeTo) _deepSet(node, writeTo, st.pending);
1125
- cfg.onPatchState(node.id, { fieldValues: st.pending });
1126
- notify(node.id, st.pending);
1127
- // After save, currentState = pending (no longer dirty)
1128
- 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');
1129
1162
  btn.classList.add('d-none');
1130
- btn.textContent = '✓ Saved';
1131
- setTimeout(() => { btn.textContent = 'Submit'; }, 1500);
1132
1163
  }, { signal });
1133
1164
  }
1134
1165
 
@@ -1141,54 +1172,63 @@ var LiveCard = (function () {
1141
1172
  const writeTo = ed.writeTo;
1142
1173
  const incomingContent = typeof data === 'string' ? data : '';
1143
1174
 
1144
- // --- Journal-style dirty tracking ---
1145
- // currentState = last server value; pending = textarea content
1146
- // On SSE re-render: if dirty, leave textarea alone; if clean, sync from server
1147
- 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) || '');
1148
1178
  if (!_notesState[stateKey]) {
1149
- _notesState[stateKey] = { currentState: incomingContent, pending: incomingContent };
1179
+ _notesState[stateKey] = { baseContent: incomingContent, journal: null };
1150
1180
  } else {
1151
- const s = _notesState[stateKey];
1152
- const wasDirty = s.currentState !== s.pending;
1153
- s.currentState = incomingContent;
1154
- if (!wasDirty) s.pending = incomingContent;
1155
- // 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
+ }
1156
1185
  }
1157
1186
  const st = _notesState[stateKey];
1158
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
+
1159
1200
  el.innerHTML = `
1160
- <div class="btn-group btn-group-sm mb-2" role="group">
1161
- <button class="btn btn-outline-secondary active lc-n-edit" type="button">Edit</button>
1162
- <button class="btn btn-outline-secondary lc-n-preview" type="button">Preview</button>
1163
- </div>
1164
- <textarea class="form-control form-control-sm lc-notes-textarea" rows="8" placeholder="Write markdown...">${_esc(st.pending)}</textarea>
1165
- <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>`;
1166
1206
 
1167
1207
  const textarea = el.querySelector('.lc-notes-textarea');
1168
- const preview = el.querySelector('.lc-notes-preview');
1169
- const editBtn = el.querySelector('.lc-n-edit');
1170
- const previewBtn = el.querySelector('.lc-n-preview');
1208
+ const discardBtn = el.querySelector('.lc-n-discard');
1209
+ const saveBtn = el.querySelector('.lc-n-save');
1171
1210
 
1172
- editBtn.addEventListener('click', () => {
1173
- textarea.classList.remove('d-none'); preview.classList.add('d-none');
1174
- editBtn.classList.add('active'); previewBtn.classList.remove('active');
1211
+ function syncDirtyButtons() {
1212
+ const dirty = isDirty();
1213
+ saveBtn.classList.toggle('d-none', !dirty);
1214
+ discardBtn.classList.toggle('d-none', !dirty);
1215
+ }
1216
+
1217
+ textarea.addEventListener('input', () => {
1218
+ setJournal(textarea.value);
1219
+ syncDirtyButtons();
1175
1220
  }, { signal });
1176
- previewBtn.addEventListener('click', () => {
1177
- preview.innerHTML = _renderMd(textarea.value);
1178
- textarea.classList.add('d-none'); preview.classList.remove('d-none');
1179
- 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...';
1180
1226
  }, { signal });
1181
1227
 
1182
- let timer;
1183
- textarea.addEventListener('input', () => {
1184
- st.pending = textarea.value; // track in journal
1185
- clearTimeout(timer);
1186
- timer = setTimeout(() => {
1187
- if (writeTo) _deepSet(node, writeTo, textarea.value);
1188
- cfg.onPatchState(node.id, { notes: textarea.value });
1189
- st.currentState = textarea.value; // saved — no longer dirty
1190
- }, 800);
1191
- cleanup.timers.push(timer);
1228
+ discardBtn.addEventListener('click', () => {
1229
+ st.journal = null;
1230
+ textarea.value = st.baseContent || '';
1231
+ syncDirtyButtons();
1192
1232
  }, { signal });
1193
1233
  }
1194
1234
 
@@ -1206,7 +1246,11 @@ var LiveCard = (function () {
1206
1246
  const cleanup = _getCleanup(node.id);
1207
1247
  const signal = cleanup.ac.signal;
1208
1248
  const ed = elemDef.data || {};
1209
- 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);
1210
1254
  const schemaProps = (ed.schema && ed.schema.properties) || {};
1211
1255
  const canAdd = ed.addRow !== false;
1212
1256
  const canDelete = ed.deleteRow !== false;
@@ -1219,57 +1263,59 @@ var LiveCard = (function () {
1219
1263
  return [...s];
1220
1264
  }
1221
1265
 
1222
- // --- Journal-style dirty tracking ---
1223
- // currentState = what the server last sent (updated each SSE re-render)
1224
- // pending = user's local accumulation of edits on top of currentState
1225
- // dirty = JSON.stringify(currentState) !== JSON.stringify(pending)
1226
- //
1227
- // When SSE fires and re-renders this element:
1228
- // - currentState is updated to the new server value
1229
- // - if NOT dirty: pending follows currentState (no unsaved edits)
1230
- // - if dirty: pending is left untouched (user's edits survive the SSE tick)
1231
- const stateKey = node.id + ':' + (ed.bind || ed.writeTo || '');
1232
- const incomingRows = Array.isArray(writeTo ? _resolveBind(node, writeTo) : data)
1233
- ? (writeTo ? _resolveBind(node, writeTo) : data)
1234
- : [];
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 : [];
1235
1271
  const incomingCopy = incomingRows.map(r => Object.assign({}, r));
1236
1272
 
1237
1273
  if (!_etState[stateKey]) {
1238
- // First render initialise both sides from server data
1239
- _etState[stateKey] = { currentState: incomingCopy, pending: incomingCopy.map(r => Object.assign({}, r)) };
1274
+ _etState[stateKey] = { baseRows: incomingCopy, journalRows: null };
1240
1275
  } else {
1241
- const s = _etState[stateKey];
1242
- const wasDirty = JSON.stringify(s.currentState) !== JSON.stringify(s.pending);
1243
- s.currentState = incomingCopy;
1244
- if (!wasDirty) {
1245
- // No unsaved edits — sync pending to new server state
1246
- 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;
1247
1279
  }
1248
- // If dirty, pending stays as-is so user's edits survive the SSE tick
1249
1280
  }
1250
1281
 
1251
1282
  const st = _etState[stateKey];
1252
1283
 
1253
1284
  function isDirty() {
1254
- 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));
1255
1296
  }
1256
1297
 
1257
1298
  function markDirty() {
1258
1299
  const saveBtn = el.querySelector('.lc-et-save');
1300
+ const discardBtn = el.querySelector('.lc-et-discard');
1259
1301
  if (saveBtn) saveBtn.classList.remove('d-none');
1302
+ if (discardBtn) discardBtn.classList.remove('d-none');
1260
1303
  }
1261
1304
 
1262
1305
  function commitSave() {
1263
- if (writeTo) _deepSet(node, writeTo, st.pending);
1264
- cfg.onPatchState(node.id, { fieldValues: st.pending });
1265
- notify(node.id, st.pending);
1266
- // After save, currentState = pending (no longer dirty)
1267
- 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;
1268
1314
  build();
1269
1315
  }
1270
1316
 
1271
1317
  function build() {
1272
- const rows = st.pending;
1318
+ const rows = getEffectiveRows();
1273
1319
  const cols = getCols(rows);
1274
1320
 
1275
1321
  if (!cols.length && !canAdd) {
@@ -1312,44 +1358,63 @@ var LiveCard = (function () {
1312
1358
  h += '</tbody></table></div>';
1313
1359
  let footer = '';
1314
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>`;
1315
1362
  footer += `<button class="btn btn-sm btn-primary mt-1 lc-et-save${isDirty() ? '' : ' d-none'}">Save</button>`;
1316
1363
  el.innerHTML = h + footer;
1317
1364
 
1318
- // Cell edit → update pending on blur, show Save if now dirty
1365
+ // Cell edit → update journal overlay and toggle Save/Discard.
1319
1366
  el.querySelectorAll('.lc-et-cell').forEach(inp => {
1320
1367
  inp.addEventListener('change', () => {
1321
1368
  const rowIdx = parseInt(inp.dataset.row);
1322
1369
  const colName = inp.dataset.col;
1323
1370
  const prop = schemaProps[colName] || {};
1324
1371
  const isNum = prop.type === 'number' || prop.type === 'integer' || inp.type === 'number';
1325
- if (!st.pending[rowIdx]) return;
1326
- st.pending[rowIdx] = Object.assign({}, st.pending[rowIdx]);
1327
- 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);
1328
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
+ }
1329
1384
  }, { signal });
1330
1385
  });
1331
1386
 
1332
- // Delete row — updates pending, rebuilds (Save button shown if dirty)
1387
+ // Delete row — updates journal and rebuilds.
1333
1388
  el.querySelectorAll('.lc-et-del').forEach(btn => {
1334
1389
  btn.addEventListener('click', () => {
1335
1390
  const rowIdx = parseInt(btn.dataset.row);
1336
- st.pending = st.pending.filter((_, i) => i !== rowIdx);
1391
+ const nextRows = getEffectiveRows().filter((_, i) => i !== rowIdx);
1392
+ updateJournal(nextRows);
1337
1393
  build();
1338
1394
  }, { signal });
1339
1395
  });
1340
1396
 
1341
- // Add row — appends blank row to pending, rebuilds (Save button shown if dirty)
1397
+ // Add row — appends blank row to journal and rebuilds.
1342
1398
  const addBtn = el.querySelector('.lc-et-add');
1343
1399
  if (addBtn) {
1344
1400
  addBtn.addEventListener('click', () => {
1345
1401
  const newRow = {};
1346
- getCols(st.pending).forEach(c => { newRow[c] = ''; });
1347
- st.pending = [...st.pending, newRow];
1402
+ const nextRows = getEffectiveRows();
1403
+ getCols(nextRows).forEach(c => { newRow[c] = ''; });
1404
+ nextRows.push(newRow);
1405
+ updateJournal(nextRows);
1348
1406
  build();
1349
1407
  }, { signal });
1350
1408
  }
1351
1409
 
1352
- // 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
+
1353
1418
  const saveBtn = el.querySelector('.lc-et-save');
1354
1419
  if (saveBtn) {
1355
1420
  saveBtn.addEventListener('click', () => {
@@ -1847,6 +1912,68 @@ var LiveCard = (function () {
1847
1912
  };
1848
1913
  }
1849
1914
 
1915
+ // ---- ref ----
1916
+ // Indirection element: resolves a bind path to get the view definition,
1917
+ // then dispatches to the real renderer. The resolved value may be:
1918
+ // - a string → treated directly as the element kind ("table", "chart", etc.)
1919
+ // - an object → { kind, label, data: { columns, chartType, chartOptions, writeTo } }
1920
+ // merged with static elemDef (static fields win for protection)
1921
+ // - null/undefined → falls back to elemDef.data.fallbackKind or shape-inferred kind
1922
+ //
1923
+ // Allowed kinds from resolved value (whitelist, unknown → "table"):
1924
+ // table, editable-table, chart, metric, list, badge, text, narrative, markdown
1925
+ //
1926
+ // Usage:
1927
+ // { "kind": "ref",
1928
+ // "data": { "bind": "fetched_sources.rebalance.proposed_trades",
1929
+ // "viewBind": "card_data.display_mode",
1930
+ // "fallbackKind": "table" } }
1931
+ //
1932
+ // viewBind can point to any namespace: card_data, fetched_sources, requires, computed_values.
1933
+ // If the resolved view object contains a "bind" sub-path, that overrides data.bind.
1934
+ const _REF_KIND_WHITELIST = new Set([
1935
+ 'table','editable-table','chart','metric','list','badge',
1936
+ 'text','narrative','markdown','form','filter','todo','alert',
1937
+ ]);
1938
+ function _renderRef(data, el, elemDef, node) {
1939
+ const ed = elemDef.data || {};
1940
+
1941
+ // Resolve the view hint
1942
+ const viewRaw = ed.viewBind ? _resolveBind(node, ed.viewBind) : undefined;
1943
+
1944
+ let resolvedKind, resolvedExtra;
1945
+ if (typeof viewRaw === 'string' && viewRaw) {
1946
+ resolvedKind = viewRaw;
1947
+ resolvedExtra = {};
1948
+ } else if (viewRaw && typeof viewRaw === 'object' && !Array.isArray(viewRaw)) {
1949
+ resolvedKind = typeof viewRaw.kind === 'string' ? viewRaw.kind : undefined;
1950
+ resolvedExtra = viewRaw.data && typeof viewRaw.data === 'object' ? viewRaw.data : {};
1951
+ }
1952
+
1953
+ // Validate kind against whitelist; fall back to shape inference
1954
+ if (!resolvedKind || !_REF_KIND_WHITELIST.has(resolvedKind)) {
1955
+ resolvedKind = ed.fallbackKind && _REF_KIND_WHITELIST.has(ed.fallbackKind)
1956
+ ? ed.fallbackKind
1957
+ : (Array.isArray(data) ? 'table' : typeof data === 'string' ? 'text' : 'narrative');
1958
+ }
1959
+
1960
+ // Build effective elemDef: resolved hints first, static elemDef fields override (card author wins)
1961
+ const mergedData = Object.assign({}, resolvedExtra, ed);
1962
+ delete mergedData.viewBind;
1963
+ delete mergedData.fallbackKind;
1964
+
1965
+ // If the resolved hint provided its own bind path, honour it (but static ed.bind still wins)
1966
+ if (!mergedData.bind && resolvedExtra.bind) mergedData.bind = resolvedExtra.bind;
1967
+
1968
+ const effectiveElemDef = Object.assign({}, elemDef, { kind: resolvedKind }, { data: mergedData });
1969
+
1970
+ // Re-resolve data using effective bind (may have changed)
1971
+ const effectiveData = mergedData.bind ? _resolveBind(node, mergedData.bind) : data;
1972
+
1973
+ const renderer = _renderers[resolvedKind] || _renderers.table;
1974
+ renderer(effectiveData, el, effectiveElemDef, node);
1975
+ }
1976
+
1850
1977
  // ---- Register built-in renderers ----
1851
1978
 
1852
1979
  _renderers.table = _renderTable;
@@ -1867,6 +1994,7 @@ var LiveCard = (function () {
1867
1994
  _renderers['file-upload'] = _renderFileUpload;
1868
1995
  _renderers['chat'] = _renderChatEl;
1869
1996
  _renderers.actions = _renderActions;
1997
+ _renderers.ref = _renderRef;
1870
1998
 
1871
1999
  // ===========================================================================
1872
2000
  // _renderElements — render all view.elements for a card node
@@ -2006,12 +2134,6 @@ var LiveCard = (function () {
2006
2134
  // Elements area
2007
2135
  h += `<div class="lc-result" id="${uid}-result"></div>`;
2008
2136
 
2009
- // Notes section (feature toggle)
2010
- if (features.notes && opts.showNotes !== false) {
2011
- h += `<details class="mt-2"><summary class="small fw-medium">Notes</summary>`;
2012
- 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>`;
2013
- }
2014
-
2015
2137
  h += '</div>';
2016
2138
  containerEl.innerHTML = h;
2017
2139
 
@@ -2051,21 +2173,6 @@ var LiveCard = (function () {
2051
2173
  }, { signal });
2052
2174
  }
2053
2175
 
2054
- // ---- Wire notes ----
2055
- const notesEl = document.getElementById(uid + '-notes');
2056
- if (notesEl) {
2057
- let nTimer;
2058
- notesEl.addEventListener('input', () => {
2059
- clearTimeout(nTimer);
2060
- nTimer = setTimeout(() => {
2061
- if (!node.card_data) node.card_data = {};
2062
- node.card_data._notes = notesEl.value;
2063
- cfg.onPatch(node.id, { _notes: notesEl.value });
2064
- }, 800);
2065
- cleanup.timers.push(nTimer);
2066
- }, { signal });
2067
- }
2068
-
2069
2176
  _autoSubscribe(node);
2070
2177
  }
2071
2178
 
@@ -2221,7 +2328,6 @@ var LiveCard = (function () {
2221
2328
  const nodeList = [];
2222
2329
  const nodeMap = {}; // id → { node, colEl, bodyEl }
2223
2330
  const _positions = {}; // id → { x, y, w, h } for canvas mode
2224
- const showNotes = opts.showNotes !== false;
2225
2331
  const showChat = opts.showChat || false;
2226
2332
  const defaultCol = opts.defaultCol || 6;
2227
2333
 
@@ -2453,26 +2559,75 @@ var LiveCard = (function () {
2453
2559
 
2454
2560
  function _buildCardWrapper(node) {
2455
2561
  const wrap = document.createElement('div');
2456
- wrap.className = 'card shadow-sm h-100';
2562
+ const card = node && node.card ? node.card : {};
2563
+ const isSimulation = card.meta && card.meta.simulation === true;
2564
+ const isGandalfCard = card.meta && card.meta._gandalfCard === true;
2565
+ const extraClass = isSimulation ? ' lc-simulation-card' : (isGandalfCard ? ' lc-gandalf-card' : '');
2566
+ wrap.className = 'card shadow-sm h-100' + extraClass;
2457
2567
  const header = document.createElement('div');
2458
2568
  header.className = 'card-header d-flex align-items-center gap-2 py-2';
2459
- const card = node && node.card ? node.card : {};
2460
2569
  const title = (card.meta && card.meta.title) || node.id;
2461
2570
  const tags = (card.meta && card.meta.tags) || [];
2462
2571
  let badgeHtml = '';
2463
- if ((card.sources && card.sources.length) && !card.view) {
2464
- var src = card.sources[0] || {};
2572
+ if ((card.source_defs && card.source_defs.length) && !card.view) {
2573
+ var src = card.source_defs[0] || {};
2465
2574
  badgeHtml = '<span class="badge bg-info text-dark ms-auto">' + _esc(src.kind || 'source') + '</span>';
2466
2575
  } else if (tags.length) {
2467
2576
  badgeHtml = tags.map(t => '<span class="badge bg-secondary ms-1">' + _esc(t) + '</span>').join('');
2468
2577
  }
2469
2578
  header.innerHTML = '<strong class="small">' + _esc(title) + '</strong>' + badgeHtml;
2470
-
2579
+
2580
+ // Gandalf cards: collapsible via caret — caret gets its own click listener,
2581
+ // header is left alone for dragging in canvas mode.
2582
+ if (isGandalfCard) {
2583
+ const caret = document.createElement('span');
2584
+ caret.className = 'lc-gandalf-caret';
2585
+ caret.title = 'Collapse / expand';
2586
+ caret.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>';
2587
+ header.appendChild(caret);
2588
+
2589
+ const storageKey = 'lc-gandalf-collapsed:' + (node.id || title);
2590
+ if (sessionStorage.getItem(storageKey) === '1') {
2591
+ wrap.classList.add('lc-collapsed');
2592
+ header.dataset.gandalfCollapsed = '1';
2593
+ }
2594
+
2595
+ caret.addEventListener('click', function(e) {
2596
+ e.stopPropagation();
2597
+ const cardEl = caret.closest('.lc-gandalf-card') || wrap;
2598
+ cardEl.classList.toggle('lc-collapsed');
2599
+ sessionStorage.setItem(storageKey, cardEl.classList.contains('lc-collapsed') ? '1' : '0');
2600
+ });
2601
+ caret.addEventListener('pointerdown', e => e.stopPropagation()); // prevent drag start
2602
+ }
2603
+ if (isSimulation) {
2604
+ const simBtns = document.createElement('span');
2605
+ simBtns.className = 'd-inline-flex align-items-center gap-1 ms-auto';
2606
+
2607
+ const pinBtn = document.createElement('button');
2608
+ pinBtn.className = 'btn btn-sm btn-outline-success lc-sim-pin';
2609
+ pinBtn.style.cssText = 'padding: 2px 6px;';
2610
+ pinBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17v5"/><path d="M9 2h6l-1 7h-4L9 2z"/><path d="M6 17h12l-2-4H8L6 17z"/></svg>';
2611
+ pinBtn.title = 'Pin this simulation card';
2612
+ pinBtn.dataset.nodeId = node.id;
2613
+
2614
+ const discardBtn = document.createElement('button');
2615
+ discardBtn.className = 'btn btn-sm btn-outline-danger lc-sim-discard';
2616
+ discardBtn.style.cssText = 'padding: 2px 6px;';
2617
+ discardBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
2618
+ discardBtn.title = 'Discard this simulation card';
2619
+ discardBtn.dataset.nodeId = node.id;
2620
+
2621
+ simBtns.appendChild(pinBtn);
2622
+ simBtns.appendChild(discardBtn);
2623
+ header.appendChild(simBtns);
2624
+ }
2625
+
2471
2626
  // Add dev mode code icon button if devMode is enabled
2472
2627
  if (devMode.current) {
2473
2628
  const codeBtn = document.createElement('button');
2474
2629
  codeBtn.className = 'btn btn-sm btn-outline-secondary';
2475
- codeBtn.style.cssText = 'padding: 2px 6px; margin-left: auto;';
2630
+ codeBtn.style.cssText = 'padding: 2px 6px;' + (isSimulation ? '' : ' margin-left: auto;');
2476
2631
  codeBtn.innerHTML = '&lt;/&gt;';
2477
2632
  codeBtn.title = 'Inspect card data';
2478
2633
  codeBtn.addEventListener('click', function(e) {
@@ -2495,7 +2650,7 @@ var LiveCard = (function () {
2495
2650
  const status = (node.card_data && node.card_data.status) || 'fresh';
2496
2651
  const card = node && node.card ? node.card : {};
2497
2652
  const title = (card.meta && card.meta.title) || node.id;
2498
- const kind = (card.sources && card.sources[0] && card.sources[0].kind) || 'source';
2653
+ const kind = (card.source_defs && card.source_defs[0] && card.source_defs[0].kind) || 'source';
2499
2654
  el.innerHTML = `<div class="lc-source-pill shadow-sm">
2500
2655
  ${_statusDot(status)}
2501
2656
  <span class="fw-medium">${_esc(title)}</span>
@@ -2527,7 +2682,7 @@ var LiveCard = (function () {
2527
2682
  col.appendChild(wrap);
2528
2683
  gridEl.appendChild(col);
2529
2684
  nodeMap[node.id] = { node, colEl: col, bodyEl: body };
2530
- engine.render(node, body, { showNotes, showChat });
2685
+ engine.render(node, body, { showChat });
2531
2686
  });
2532
2687
  }
2533
2688
 
@@ -2615,7 +2770,7 @@ var LiveCard = (function () {
2615
2770
  nodeList.forEach(node => {
2616
2771
  const pos = _positions[node.id] || { x: 0, y: 0 };
2617
2772
 
2618
- if ((!node.card || !node.card.view) && (node.card && node.card.sources && node.card.sources.length)) {
2773
+ if ((!node.card || !node.card.view) && (node.card && node.card.source_defs && node.card.source_defs.length)) {
2619
2774
  const el = _buildSourcePill(node);
2620
2775
  el.dataset.nodeId = node.id;
2621
2776
  el.style.left = pos.x + 'px';
@@ -2625,7 +2780,10 @@ var LiveCard = (function () {
2625
2780
  _makeDraggable(el, node);
2626
2781
  } else {
2627
2782
  const el = document.createElement('div');
2628
- el.className = 'lc-canvas-card card shadow-sm';
2783
+ const isSimCanvas = node.card && node.card.meta && node.card.meta.simulation === true;
2784
+ const isGandalfCanvas = node.card && node.card.meta && node.card.meta._gandalfCard === true;
2785
+ const canvasExtra = isSimCanvas ? ' lc-simulation-card' : (isGandalfCanvas ? ' lc-gandalf-card' : '');
2786
+ el.className = 'lc-canvas-card card shadow-sm' + canvasExtra;
2629
2787
  el.dataset.nodeId = node.id;
2630
2788
  el.style.left = pos.x + 'px';
2631
2789
  el.style.top = pos.y + 'px';
@@ -2633,9 +2791,12 @@ var LiveCard = (function () {
2633
2791
 
2634
2792
  const { wrap, body } = _buildCardWrapper(node);
2635
2793
  while (wrap.firstChild) el.appendChild(wrap.firstChild);
2794
+ // Re-apply collapsed state: in canvas mode el is the card container, not wrap
2795
+ const movedHeader = el.querySelector('.card-header');
2796
+ if (movedHeader && movedHeader.dataset.gandalfCollapsed === '1') el.classList.add('lc-collapsed');
2636
2797
  canvasInner.appendChild(el);
2637
2798
  nodeMap[node.id] = { node, colEl: el, bodyEl: body };
2638
- engine.render(node, body, { showNotes: false, showChat: false });
2799
+ engine.render(node, body, { showChat: false });
2639
2800
  _makeDraggable(el, node);
2640
2801
  }
2641
2802
  });