@syntax-syllogism/flow-delta 0.6.1 → 0.6.2

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 (2) hide show
  1. package/dist/flexipage-cli.js +205 -50
  2. package/package.json +1 -1
@@ -126,11 +126,11 @@ function diffPage(oldModel, newModel) {
126
126
  };
127
127
  }
128
128
  function diffRegion(name, oldRegion, newRegion) {
129
- if (!oldRegion) return { name, type: newRegion.type, mode: newRegion.mode, status: "added", items: newRegion.items.map((item, index) => classifyItem(void 0, item, `${name}:after:${index}`)) };
130
- if (!newRegion) return { name, type: oldRegion.type, mode: oldRegion.mode, status: "deleted", items: oldRegion.items.map((item, index) => classifyItem(item, void 0, `${name}:before:${index}`)) };
129
+ if (!oldRegion) return { name, path: name, type: newRegion.type, mode: newRegion.mode, status: "added", items: newRegion.items.map((item, index) => classifyItem(void 0, item, `${name}:after:${index}`, name)) };
130
+ if (!newRegion) return { name, path: name, type: oldRegion.type, mode: oldRegion.mode, status: "deleted", items: oldRegion.items.map((item, index) => classifyItem(item, void 0, `${name}:before:${index}`, name)) };
131
131
  const items = diffItems(oldRegion.items, newRegion.items, name);
132
132
  const regionChanges = deepDiff({ type: oldRegion.type, mode: oldRegion.mode }, { type: newRegion.type, mode: newRegion.mode });
133
- return { name, type: newRegion.type, mode: newRegion.mode, status: regionChanges.length || items.some((item) => item.status !== "unchanged") ? "modified" : "unchanged", ...regionChanges.length ? { changes: regionChanges } : {}, items };
133
+ return { name, path: name, type: newRegion.type, mode: newRegion.mode, status: regionChanges.length || items.some((item) => item.status !== "unchanged") ? "modified" : "unchanged", ...regionChanges.length ? { changes: regionChanges } : {}, items };
134
134
  }
135
135
  function diffItems(oldItems, newItems, regionName) {
136
136
  const pairs = lcs(oldItems, newItems);
@@ -138,22 +138,24 @@ function diffItems(oldItems, newItems, regionName) {
138
138
  let oldIndex = 0;
139
139
  let newIndex = 0;
140
140
  for (const [matchOld, matchNew] of pairs) {
141
- while (oldIndex < matchOld) output.push(classifyItem(oldItems[oldIndex], void 0, `${regionName}:before:${oldIndex++}`));
142
- while (newIndex < matchNew) output.push(classifyItem(void 0, newItems[newIndex], `${regionName}:after:${newIndex++}`));
143
- output.push(classifyItem(oldItems[matchOld], newItems[matchNew], `${regionName}:${matchNew}`));
141
+ while (oldIndex < matchOld) output.push(classifyItem(oldItems[oldIndex], void 0, `${regionName}:before:${oldIndex++}`, regionName));
142
+ while (newIndex < matchNew) output.push(classifyItem(void 0, newItems[newIndex], `${regionName}:after:${newIndex++}`, regionName));
143
+ output.push(classifyItem(oldItems[matchOld], newItems[matchNew], `${regionName}:${matchNew}`, regionName));
144
144
  oldIndex = matchOld + 1;
145
145
  newIndex = matchNew + 1;
146
146
  }
147
- while (oldIndex < oldItems.length) output.push(classifyItem(oldItems[oldIndex], void 0, `${regionName}:before:${oldIndex++}`));
148
- while (newIndex < newItems.length) output.push(classifyItem(void 0, newItems[newIndex], `${regionName}:after:${newIndex++}`));
147
+ while (oldIndex < oldItems.length) output.push(classifyItem(oldItems[oldIndex], void 0, `${regionName}:before:${oldIndex++}`, regionName));
148
+ while (newIndex < newItems.length) output.push(classifyItem(void 0, newItems[newIndex], `${regionName}:after:${newIndex++}`, regionName));
149
149
  return output;
150
150
  }
151
- function classifyItem(before, after, id) {
151
+ function classifyItem(before, after, id, regionPath) {
152
152
  const item = after ?? before;
153
- const base = item.kind === "component" ? { id, kind: item.kind, componentName: item.componentName } : { id, kind: item.kind, fieldItem: item.fieldItem };
154
- if (!before) return { ...base, status: "added", after };
155
- if (!after) return { ...base, status: "deleted", before };
156
- const changes = before.kind === "component" && after.kind === "component" ? deepDiff(before.properties, after.properties) : deepDiff(before, after);
153
+ const identifier = item.identifier;
154
+ const itemPath = `${regionPath} \u203A ${itemLabel(item)}`;
155
+ const base = item.kind === "component" ? { id, path: itemPath, kind: item.kind, componentName: item.componentName, ...identifier ? { identifier } : {} } : { id, path: itemPath, kind: item.kind, fieldItem: item.fieldItem, ...identifier ? { identifier } : {} };
156
+ if (!before) return { ...base, status: "added", ...visibilityNote(item), after };
157
+ if (!after) return { ...base, status: "deleted", ...visibilityNote(item), before };
158
+ const changes = before.kind === "component" && after.kind === "component" ? deepDiff(before.properties, after.properties) : before.kind === "field" && after.kind === "field" ? deepDiff(before.attributes, after.attributes) : deepDiff(before, after);
157
159
  return changes.length ? { ...base, status: "modified", changes, before, after } : { ...base, status: "unchanged", after };
158
160
  }
159
161
  function lcs(oldItems, newItems) {
@@ -178,7 +180,13 @@ function lcs(oldItems, newItems) {
178
180
  return matches;
179
181
  }
180
182
  function itemKey(item) {
181
- return item.kind === "component" ? `component:${item.componentName}` : `field:${item.fieldItem}`;
183
+ return item.kind === "component" ? `component:${item.componentName}#${item.identifier ?? ""}` : `field:${item.fieldItem}#${item.identifier ?? ""}`;
184
+ }
185
+ function itemLabel(item) {
186
+ return item.kind === "component" ? `${item.componentName}${item.identifier ? `#${item.identifier}` : ""}` : `${item.fieldItem}${item.identifier ? `#${item.identifier}` : ""}`;
187
+ }
188
+ function visibilityNote(item) {
189
+ return item.kind === "field" && item.attributes.visibilityRule !== void 0 ? { notes: ["has visibility rule"] } : {};
182
190
  }
183
191
  function orderOf(path) {
184
192
  const index = HEADER_ORDER.indexOf(path);
@@ -193,6 +201,7 @@ import { Parser } from "xml2js";
193
201
  import { createHash } from "node:crypto";
194
202
  var FACET_GUID = /^Facet-[0-9a-f-]{36}$/i;
195
203
  var HEADER_KEYS = ["masterLabel", "type", "sobjectType", "parentFlexiPage", "description"];
204
+ var ALWAYS_LIST_KEYS = /* @__PURE__ */ new Set(["criteria"]);
196
205
  async function parseFlexiPage(xml) {
197
206
  const parsed = await new Parser({ explicitArray: true, trim: true, normalize: false }).parseStringPromise(xml);
198
207
  const page = parsed.FlexiPage;
@@ -233,6 +242,7 @@ function parseItemInstance(value) {
233
242
  return [{
234
243
  kind: "component",
235
244
  componentName: scalar(component.componentName) ?? "(unnamed component)",
245
+ ...scalar(component.identifier) !== void 0 ? { identifier: scalar(component.identifier) } : {},
236
246
  properties: Object.fromEntries(properties),
237
247
  facetRefs: []
238
248
  }];
@@ -240,11 +250,13 @@ function parseItemInstance(value) {
240
250
  const field = objectAt(instance.fieldInstance);
241
251
  if (field) {
242
252
  const attributes = Object.fromEntries(
243
- Object.entries(field).filter(([key]) => key !== "fieldItem").map(([key, raw]) => [key, valueText(raw)]).filter((entry) => entry[1] !== void 0)
253
+ Object.entries(field).filter(([key]) => key !== "fieldItem" && key !== "identifier").map(([key, raw]) => [key, valueText(raw)]).filter((entry) => entry[1] !== void 0)
244
254
  );
255
+ const identifier = scalar(field.identifier);
245
256
  return [{
246
257
  kind: "field",
247
258
  fieldItem: scalar(field.fieldItem) ?? "(unnamed field)",
259
+ ...identifier !== void 0 ? { identifier } : {},
248
260
  attributes
249
261
  }];
250
262
  }
@@ -253,48 +265,88 @@ function parseItemInstance(value) {
253
265
  function canonicalize(model) {
254
266
  const rawRegions = model.regions;
255
267
  const facets = new Map(rawRegions.filter((region) => region.type === "Facet").map((region) => [region.name, region]));
256
- const references = /* @__PURE__ */ new Map();
257
- for (const region of rawRegions) {
258
- region.items.forEach((item) => {
259
- if (item.kind !== "component") return;
260
- for (const [property, value] of Object.entries(item.properties)) {
261
- if (!facets.has(value)) continue;
262
- const context = `${region.name}:${item.componentName}:${property}`;
263
- references.set(value, [...references.get(value) ?? [], context]);
264
- }
265
- });
266
- }
267
268
  const canonicalNames = /* @__PURE__ */ new Map();
268
- for (const [name, contexts] of references) {
269
- if (!FACET_GUID.test(name)) continue;
270
- const context = [...contexts].sort()[0];
271
- canonicalNames.set(name, `Facet:${context}`);
272
- }
273
- for (const facet of rawRegions.filter((region) => region.type === "Facet" && FACET_GUID.test(region.name))) {
274
- if (!canonicalNames.has(facet.name)) {
275
- canonicalNames.set(facet.name, `Facet:orphan:${facetHash(facet)}`);
276
- }
269
+ const usedNames = new Set(rawRegions.filter((region) => region.type === "Region").map((region) => region.name));
270
+ for (const region of rawRegions.filter((candidate) => candidate.type === "Region")) {
271
+ resolveFacetChildren(region, region.name);
272
+ }
273
+ const orphanCounts = /* @__PURE__ */ new Map();
274
+ for (const facet of rawRegions.filter((candidate) => candidate.type === "Facet" && FACET_GUID.test(candidate.name))) {
275
+ if (canonicalNames.has(facet.name)) continue;
276
+ const base = `Facet:orphan:${facetHash(facet)}`;
277
+ const count = orphanCounts.get(base) ?? 0;
278
+ const name = count === 0 ? base : `${base}#${count + 1}`;
279
+ orphanCounts.set(base, count + 1);
280
+ canonicalNames.set(facet.name, name);
281
+ usedNames.add(name);
277
282
  }
283
+ const canonicalFacetNames = new Set(canonicalNames.values());
278
284
  const regions = rawRegions.map((region) => ({
279
285
  ...region,
280
286
  name: canonicalNames.get(region.name) ?? region.name,
281
287
  items: region.items.map((item) => {
282
288
  if (item.kind !== "component") return item;
283
- const properties = Object.fromEntries(Object.entries(item.properties).map(([key, value]) => [key, canonicalNames.get(value) ?? value]));
289
+ const properties = Object.fromEntries(Object.entries(item.properties).map(([key, value]) => [key, mapFacetValues(value)]));
284
290
  return {
285
291
  ...item,
286
292
  properties,
287
- facetRefs: Object.values(properties).filter((value) => rawRegions.some((candidate) => candidate.type === "Facet" && (canonicalNames.get(candidate.name) ?? candidate.name) === value))
293
+ facetRefs: [...new Set(Object.values(properties).flatMap((value) => collectFacetRefs(value)))]
288
294
  };
289
295
  })
290
296
  }));
297
+ const names = regions.map((region) => region.name);
298
+ if (names.length !== new Set(names).size) {
299
+ throw new Error("FlexiPage canonicalization produced duplicate region names");
300
+ }
291
301
  return { ...model, regions };
302
+ function resolveFacetChildren(parent, parentPath) {
303
+ const siblingCounts = /* @__PURE__ */ new Map();
304
+ for (const item of parent.items) {
305
+ if (item.kind !== "component") continue;
306
+ for (const [property, value] of Object.entries(item.properties)) {
307
+ for (const facetName of collectRawFacetRefs(value)) {
308
+ const child = facets.get(facetName);
309
+ if (!child || !FACET_GUID.test(facetName)) continue;
310
+ let childPath = canonicalNames.get(facetName);
311
+ if (!childPath) {
312
+ const base = `${parentPath} \u203A ${componentLabel(item)} \u203A ${property}`;
313
+ const count = siblingCounts.get(base) ?? 0;
314
+ siblingCounts.set(base, count + 1);
315
+ childPath = count === 0 ? base : `${base}#${count + 1}`;
316
+ canonicalNames.set(facetName, childPath);
317
+ usedNames.add(childPath);
318
+ resolveFacetChildren(child, childPath);
319
+ }
320
+ }
321
+ }
322
+ }
323
+ }
324
+ function mapFacetValues(value) {
325
+ if (typeof value === "string") return canonicalNames.get(value) ?? value;
326
+ if (Array.isArray(value)) return value.map(mapFacetValues);
327
+ return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, mapFacetValues(child)]));
328
+ }
329
+ function collectRawFacetRefs(value) {
330
+ if (typeof value === "string") return facets.has(value) ? [value] : [];
331
+ if (Array.isArray(value)) return value.flatMap(collectRawFacetRefs);
332
+ return Object.values(value).flatMap(collectRawFacetRefs);
333
+ }
334
+ function collectFacetRefs(value) {
335
+ if (typeof value === "string") {
336
+ const canonical = canonicalNames.get(value);
337
+ if (canonical) return [canonical];
338
+ if (facets.has(value)) return [value];
339
+ return canonicalFacetNames.has(value) ? [value] : [];
340
+ }
341
+ if (Array.isArray(value)) return value.flatMap(collectFacetRefs);
342
+ return Object.values(value).flatMap(collectFacetRefs);
343
+ }
292
344
  }
293
345
  function facetHash(region) {
294
346
  const stable = {
295
347
  type: region.type,
296
348
  mode: region.mode,
297
- items: region.items.map((item) => item.kind === "component" ? { kind: item.kind, componentName: item.componentName, properties: item.properties } : { kind: item.kind, fieldItem: item.fieldItem, attributes: item.attributes })
349
+ items: region.items.map((item) => item.kind === "component" ? { kind: item.kind, componentName: item.componentName, identifier: item.identifier, properties: item.properties } : { kind: item.kind, fieldItem: item.fieldItem, identifier: item.identifier, attributes: item.attributes })
298
350
  };
299
351
  return createHash("sha1").update(JSON.stringify(stable)).digest("hex").slice(0, 12);
300
352
  }
@@ -313,13 +365,38 @@ function scalar(value) {
313
365
  if (value === void 0 || value === null || typeof value === "object") return void 0;
314
366
  return String(value);
315
367
  }
316
- function valueText(value) {
368
+ function valueText(value, preserveList = false) {
317
369
  const direct = scalar(value);
318
370
  if (direct !== void 0) return direct;
371
+ if (Array.isArray(value)) {
372
+ const values = value.map((entry) => valueText(entry)).filter((entry) => entry !== void 0);
373
+ if (values.length === 0) return void 0;
374
+ if (preserveList) return values;
375
+ return mergeNamedValues(values) ?? (values.length === 1 ? values[0] : values);
376
+ }
319
377
  const object = objectAt(value);
320
378
  if (!object) return void 0;
321
379
  const entries = Object.entries(object).sort(([left], [right]) => left.localeCompare(right));
322
- return entries.map(([key, child]) => `${key}=${valueText(child) ?? ""}`).join(";");
380
+ const name = scalar(object.name);
381
+ if (name !== void 0 && Object.prototype.hasOwnProperty.call(object, "value")) {
382
+ const nested = valueText(object.value);
383
+ return { [name]: nested ?? "" };
384
+ }
385
+ return Object.fromEntries(entries.map(([key, child]) => [key, valueText(child, ALWAYS_LIST_KEYS.has(key))]).filter((entry) => entry[1] !== void 0));
386
+ }
387
+ function mergeNamedValues(values) {
388
+ if (values.length === 0 || !values.every(isSingleEntryObject)) return void 0;
389
+ const entries = values.flatMap((value) => Object.entries(value));
390
+ if (new Set(entries.map(([key]) => key)).size !== entries.length) return void 0;
391
+ return Object.fromEntries(entries);
392
+ }
393
+ function isSingleEntryObject(value) {
394
+ return !Array.isArray(value) && typeof value === "object" && Object.keys(value).length === 1;
395
+ }
396
+ function componentLabel(item) {
397
+ const identity = item.identifier ? `${item.componentName}#${item.identifier}` : item.componentName;
398
+ const label = ["title", "label", "name"].map((key) => item.properties[key]).find((value) => typeof value === "string" && value.length > 0);
399
+ return label && label !== item.componentName ? `${identity} (${label})` : identity;
323
400
  }
324
401
 
325
402
  // src/render/shell.ts
@@ -355,6 +432,8 @@ function renderShell(options) {
355
432
  :root[data-theme="dark"] { color-scheme:dark; --bg:#0f172a; --surface:#111827; --surface-muted:#1f2937; --surface-selected:#1e3a5f; --border:#334155; --focus:#60a5fa; --text:#e5e7eb; --muted:#a5b4c7; --faint:#718096; --added:#4ade80; --added-fill:#123f2a; --deleted:#f87171; --deleted-fill:#4a1d23; --modified:#fbbf24; --modified-fill:#46330d; --unchanged:#64748b; --unchanged-fill:#263241; }
356
433
  @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { color-scheme:dark; --bg:#0f172a; --surface:#111827; --surface-muted:#1f2937; --surface-selected:#1e3a5f; --border:#334155; --focus:#60a5fa; --text:#e5e7eb; --muted:#a5b4c7; --faint:#718096; --added:#4ade80; --added-fill:#123f2a; --deleted:#f87171; --deleted-fill:#4a1d23; --modified:#fbbf24; --modified-fill:#46330d; --unchanged:#64748b; --unchanged-fill:#263241; } }
357
434
  * { box-sizing:border-box; } body { margin:0; height:100vh; display:grid; grid-template-rows:auto 1fr; background:var(--bg); color:var(--text); font-size:14px; line-height:1.45; } header { display:flex; justify-content:space-between; gap:16px; align-items:center; padding:12px 20px; border-bottom:1px solid var(--border); background:var(--surface); } h1 { margin:0 0 2px; font-size:15px; font-weight:650; } .meta { color:var(--muted); font-size:12.5px; } .header-actions { display:flex; flex-direction:column; align-items:flex-end; gap:9px; } .filters { display:flex; gap:7px; flex-wrap:wrap; justify-content:flex-end; } .display-controls { display:flex; align-items:center; justify-content:flex-end; gap:8px; } button { font:inherit; cursor:pointer; } .filters button, .theme-toggle button, .panel-toggle, .panel-reopen { border:1px solid var(--border); background:var(--surface-muted); color:var(--muted); border-radius:999px; padding:5px 10px; font-size:12px; font-weight:600; } .filters button.active, .theme-toggle button[aria-pressed="true"] { background:var(--surface-selected); border-color:var(--focus); color:var(--text); } .theme-toggle { display:inline-flex; gap:2px; padding:2px; border:1px solid var(--border); border-radius:999px; background:var(--surface-muted); } .theme-toggle button { border:0; padding:3px 8px; } main { position:relative; display:grid; grid-template-columns:minmax(0,1fr) clamp(300px,var(--panel-width),70vw); min-height:0; } body.panel-collapsed main { grid-template-columns:minmax(0,1fr) 0; } .canvas { overflow:auto; padding:24px; position:relative; } .outline { max-width:1100px; margin:0 auto; } .outline-region { margin:0 0 13px; border:1px solid var(--border); border-left-width:4px; border-radius:9px; background:var(--surface); overflow:hidden; } .outline-region.added { border-left-color:var(--added); } .outline-region.deleted { border-left-color:var(--deleted); } .outline-region.modified { border-left-color:var(--modified); } .outline-region.unchanged { border-left-color:var(--unchanged); } .region-head { display:flex; align-items:center; gap:8px; padding:10px 13px; background:var(--surface-muted); font-weight:700; } .region-items { padding:7px 10px 10px 25px; } .outline-row { display:flex; align-items:center; gap:8px; margin:4px 0; padding:8px 10px; border:1px solid var(--border); border-radius:7px; background:var(--surface); cursor:pointer; } .outline-row:hover, .outline-row:focus-visible { border-color:var(--focus); outline:none; } .outline-row.added { background:var(--added-fill); border-color:color-mix(in srgb,var(--added) 45%,var(--border)); } .outline-row.deleted { background:var(--deleted-fill); border-color:color-mix(in srgb,var(--deleted) 45%,var(--border)); } .outline-row.modified { background:var(--modified-fill); border-color:color-mix(in srgb,var(--modified) 45%,var(--border)); } .facet-region { margin:8px 0 3px 22px; } .facet-region .region-head { font-size:12px; padding:7px 10px; } .facet-region .region-items { padding-left:14px; } .status-badge { min-width:68px; color:var(--muted); font-size:10px; font-weight:750; text-transform:uppercase; letter-spacing:.04em; } .added .status-badge { color:var(--added); } .deleted .status-badge { color:var(--deleted); } .modified .status-badge { color:var(--modified); } .summary-stat { margin-right:10px; } .summary-stat.added { color:var(--added); } .summary-stat.deleted { color:var(--deleted); } .summary-stat.modified { color:var(--modified); } .banner { margin:0 auto 16px; max-width:1100px; padding:11px 14px; border:1px solid var(--modified); border-radius:8px; background:var(--modified-fill); color:var(--modified); font-weight:750; } .panel { position:relative; min-width:0; border-left:1px solid var(--border); background:var(--surface); padding:20px; overflow:auto; } body.panel-collapsed .panel { padding:0; border-left:0; overflow:hidden; } body.panel-collapsed .panel > *:not(.panel-resizer) { display:none; } .panel-resizer { position:absolute; left:0; top:0; bottom:0; width:9px; transform:translateX(-50%); cursor:col-resize; z-index:2; } .panel-resizer:hover { background:var(--focus); opacity:.5; } .panel-head { display:flex; justify-content:space-between; align-items:flex-start; gap:8px; } .panel h2 { margin:0 0 8px; font-size:15px; } .panel-toggle { border-radius:7px; padding:6px 9px; } .panel-reopen { display:none; position:absolute; top:14px; right:14px; z-index:3; border-radius:7px; } body.panel-collapsed .panel-reopen { display:block; } .panel-badge { display:inline-block; margin:0 0 15px; padding:3px 9px; border-radius:999px; background:var(--unchanged-fill); color:var(--muted); font-size:11px; font-weight:650; } .panel-badge.added { background:var(--added-fill); color:var(--added); } .panel-badge.deleted { background:var(--deleted-fill); color:var(--deleted); } .panel-badge.modified { background:var(--modified-fill); color:var(--modified); } .empty { color:var(--muted); font-size:13px; } .changes { list-style:none; margin:0; padding:0; } .changes li { margin:0 0 10px; } .change-path { display:block; margin-bottom:3px; color:var(--muted); font-size:11px; font-weight:700; } .val { padding:2px 5px; border-radius:4px; font:12px ui-monospace,Consolas,monospace; overflow-wrap:anywhere; } .val.before { color:var(--deleted); background:var(--deleted-fill); text-decoration:line-through; } .val.after { color:var(--added); background:var(--added-fill); } .arrow { color:var(--faint); margin:0 3px; } [hidden] { display:none !important; }
435
+ .item-note { margin-left:auto; padding:2px 6px; border:1px solid var(--border); border-radius:999px; background:var(--surface-muted); color:var(--muted); font-size:11px; white-space:nowrap; }
436
+ .item-notes { margin:0 0 10px; padding-left:18px; color:var(--muted); font-size:12px; }
358
437
  .view-toggle { display:inline-flex; gap:2px; padding:2px; border:1px solid var(--border); border-radius:999px; background:var(--surface-muted); }
359
438
  .view-toggle button { border:1px solid var(--border); background:var(--surface-muted); color:var(--muted); border-radius:999px; padding:3px 8px; font-size:12px; font-weight:600; }
360
439
  .view-toggle button.active { background:var(--surface-selected); border-color:var(--focus); color:var(--text); }
@@ -374,6 +453,8 @@ function renderShell(options) {
374
453
  .wireframe-cell.modified { border-color:var(--modified); }
375
454
  .wireframe-cell.unchanged { border-color:var(--unchanged); }
376
455
  .wireframe-slot-head { display:flex; align-items:center; gap:8px; padding:9px 11px; background:var(--surface-muted); font-weight:700; }
456
+ .wireframe-rollup { margin-left:auto; border:1px solid var(--modified); border-radius:999px; padding:3px 8px; background:var(--modified-fill); color:var(--modified); font-size:11px; font-weight:700; white-space:nowrap; }
457
+ .wireframe-rollup:hover, .wireframe-rollup:focus-visible { border-color:var(--text); outline:2px solid var(--focus); outline-offset:1px; }
377
458
  .wireframe-items { min-height:56px; padding:7px 10px 10px; }
378
459
  .wireframe-empty-label { display:block; padding:8px 2px; color:var(--faint); font-style:italic; }
379
460
  .wireframe-stack-cell { padding:0; }
@@ -381,11 +462,26 @@ function renderShell(options) {
381
462
  .wireframe-removed { margin-top:18px; padding:14px; border:1px dashed var(--deleted); border-radius:9px; background:var(--surface-muted); }
382
463
  .wireframe-removed h2 { margin:0 0 10px; font-size:14px; color:var(--deleted); }
383
464
  .wireframe-removed-region { margin-top:8px; border:1px solid var(--border); border-radius:7px; overflow:hidden; }
465
+ .panel-back { display:block; margin:0 0 7px; border:0; padding:0; background:transparent; color:var(--muted); font-size:12px; font-weight:650; text-align:left; }
466
+ .panel-back:hover, .panel-back:focus-visible { color:var(--text); text-decoration:underline; outline:none; }
467
+ .digest { display:grid; gap:14px; }
468
+ .digest-group { margin:0; }
469
+ .digest-group-head { display:flex; justify-content:space-between; gap:8px; margin:0 0 6px; color:var(--text); font-size:13px; font-weight:750; }
470
+ .digest-group-count { color:var(--muted); font-weight:650; }
471
+ .digest-entry { display:flex; align-items:center; gap:8px; width:100%; margin:4px 0; border:1px solid var(--border); border-radius:7px; padding:7px 9px; background:var(--surface); color:var(--text); text-align:left; }
472
+ .digest-entry.added { background:var(--added-fill); border-color:color-mix(in srgb,var(--added) 45%,var(--border)); }
473
+ .digest-entry.deleted { background:var(--deleted-fill); border-color:color-mix(in srgb,var(--deleted) 45%,var(--border)); }
474
+ .digest-entry.modified { background:var(--modified-fill); border-color:color-mix(in srgb,var(--modified) 45%,var(--border)); }
475
+ .digest-entry.added .status-badge { color:var(--added); }
476
+ .digest-entry.deleted .status-badge { color:var(--deleted); }
477
+ .digest-entry.modified .status-badge { color:var(--modified); }
478
+ .digest-entry:hover, .digest-entry:focus-visible { border-color:var(--focus); outline:none; }
479
+ .digest-entry .status-badge { min-width:58px; }
384
480
  </style>
385
481
  </head>
386
482
  <body>
387
483
  <header><div><h1>${productName} \u2014 ${title}</h1><div class="meta">${options.metaHtml}</div></div><div class="header-actions"><div class="filters" aria-label="View filters"><button class="filter-button active" data-view-mode="all">All</button><button class="filter-button" data-view-mode="after">After</button><button class="filter-button" data-view-mode="before">Before</button><button class="filter-button" data-view-mode="changes">Changes only</button></div><div class="display-controls">${options.wireframeHtml === void 0 ? "" : `<div class="view-toggle" aria-label="Canvas view"><button class="view-button" data-view-mode="outline">Outline</button><button class="view-button" data-view-mode="wireframe">Wireframe</button></div>`}<div class="theme-toggle" aria-label="Theme"><button class="theme-button" data-theme-choice="system">System</button><button class="theme-button" data-theme-choice="light">Light</button><button class="theme-button" data-theme-choice="dark">Dark</button></div></div></div></header>
388
- <main><section class="canvas">${options.bannerHtml ?? ""}<div class="view-canvas" data-view-canvas="outline">${options.contentHtml}</div>${options.wireframeHtml === void 0 ? "" : `<div class="view-canvas" data-view-canvas="wireframe">${options.wireframeHtml}</div>`}</section><aside class="panel" id="detail-panel"><div class="panel-resizer" id="panel-resizer"></div><div class="panel-head"><h2 id="panel-title">Select a component or field</h2><button class="panel-toggle" id="panel-toggle" type="button" aria-expanded="true" aria-label="Collapse details" title="Collapse details">\u203A</button></div><div class="panel-badge" id="panel-badge" hidden></div><div id="panel-body" class="empty">Click a component or field row to inspect its property deltas.</div></aside><button class="panel-reopen" id="panel-reopen" type="button">Show details</button></main>
484
+ <main><section class="canvas">${options.bannerHtml ?? ""}<div class="view-canvas" data-view-canvas="outline">${options.contentHtml}</div>${options.wireframeHtml === void 0 ? "" : `<div class="view-canvas" data-view-canvas="wireframe">${options.wireframeHtml}</div>`}</section><aside class="panel" id="detail-panel"><div class="panel-resizer" id="panel-resizer"></div><div class="panel-head"><div><button class="panel-back" id="panel-back" type="button" hidden>Back to changes</button><h2 id="panel-title">Select a component or field</h2></div><button class="panel-toggle" id="panel-toggle" type="button" aria-expanded="true" aria-label="Collapse details" title="Collapse details">\u203A</button></div><div class="panel-badge" id="panel-badge" hidden></div><div id="panel-body" class="empty">Click a component or field row to inspect its property deltas.</div></aside><button class="panel-reopen" id="panel-reopen" type="button">Show details</button></main>
389
485
  <script>(() => { const viewKey = ${JSON.stringify(VIEW_STORAGE_KEY)}; const viewButtons = [...document.querySelectorAll('.view-button')]; const hasWireframe = viewButtons.length > 0; function applyViewMode(mode) { const selected = hasWireframe && mode === "wireframe" ? "wireframe" : "outline"; document.documentElement.dataset.view = selected; viewButtons.forEach((button) => button.classList.toggle('active', button.dataset.viewMode === selected)); } viewButtons.forEach((button) => button.addEventListener('click', () => { const mode = button.dataset.viewMode; applyViewMode(mode); try { localStorage.setItem(viewKey, mode); } catch {} })); applyViewMode(document.documentElement.dataset.view ?? "outline"); })(); (() => { ${options.clientScript} })();</script>
390
486
  </body>
391
487
  </html>`;
@@ -397,7 +493,8 @@ function escapeHtml(value) {
397
493
  // src/flexipage/render-helpers.ts
398
494
  function renderItem(item) {
399
495
  const label = item.componentName ?? item.fieldItem ?? "Item";
400
- return `<div class="outline-row ${item.status}" data-status="${item.status}" data-item-id="${escapeHtml2(item.id)}" tabindex="0" role="button"><span class="status-badge">${item.status}</span><span>${escapeHtml2(label)}</span></div>`;
496
+ const notes = item.notes?.map((note) => `<span class="item-note">${escapeHtml2(note)}</span>`).join("") ?? "";
497
+ return `<div class="outline-row ${item.status}" data-status="${item.status}" data-item-id="${escapeHtml2(item.id)}" data-item-path="${escapeHtml2(item.path)}" tabindex="0" role="button"><span class="status-badge">${item.status}</span><span>${escapeHtml2(label)}</span>${notes}</div>`;
401
498
  }
402
499
  function itemFacetRefs(item) {
403
500
  const value = item.after ?? item.before;
@@ -481,7 +578,38 @@ function deepFreeze(value) {
481
578
  }
482
579
 
483
580
  // src/flexipage/render-wireframe.ts
484
- function renderWireframe(diff, geometry) {
581
+ function getWireframeRollups(diff) {
582
+ const rollups = /* @__PURE__ */ new Map();
583
+ const addEntry = (root, group, id) => {
584
+ const rollup = rollups.get(root) ?? { itemIds: [], groups: /* @__PURE__ */ new Map() };
585
+ rollup.itemIds.push(id);
586
+ const groupItems = rollup.groups.get(group) ?? [];
587
+ groupItems.push(id);
588
+ rollup.groups.set(group, groupItems);
589
+ rollups.set(root, rollup);
590
+ };
591
+ for (const region of diff.regions) {
592
+ if (!region.changes?.length) continue;
593
+ const segments = region.path.split(" \u203A ");
594
+ const root = segments[0];
595
+ addEntry(root, digestGroupLabel([...segments, "__region__"], root), regionChangeId(region));
596
+ }
597
+ for (const item of diff.regions.flatMap((region) => region.items)) {
598
+ if (item.status === "unchanged") continue;
599
+ const segments = item.path.split(" \u203A ");
600
+ const root = segments[0];
601
+ addEntry(root, digestGroupLabel(segments, root), item.id);
602
+ }
603
+ return Object.fromEntries([...rollups.entries()].map(([region, value]) => [region, {
604
+ count: value.itemIds.length,
605
+ itemIds: value.itemIds,
606
+ groups: [...value.groups.entries()].map(([label, itemIds]) => ({ label, itemIds }))
607
+ }]));
608
+ }
609
+ function regionChangeId(region) {
610
+ return `region:${region.name}`;
611
+ }
612
+ function renderWireframe(diff, geometry, rollups = getWireframeRollups(diff)) {
485
613
  if (!geometry) return void 0;
486
614
  validateTemplateGeometry(geometry);
487
615
  const slots = geometry.rows.flatMap((row) => row.flatMap(slotsInCell));
@@ -504,7 +632,9 @@ function renderWireframe(diff, geometry) {
504
632
  const renderSlot = (slot) => {
505
633
  const region = regions.get(slot);
506
634
  const status = region?.status ?? "unchanged";
507
- return `<div class="wireframe-cell${region ? ` ${status}` : " wireframe-empty"}" data-region-status="${status}" data-slot="${escapeHtml2(slot)}"><div class="wireframe-slot-head"><span class="status-badge">${status}</span><span>${escapeHtml2(slot)}</span></div><div class="wireframe-items">${region ? renderRegionItems(region) : '<span class="wireframe-empty-label">Empty slot</span>'}</div></div>`;
635
+ const rollup = rollups[slot];
636
+ const rollupMarkup = rollup ? `<button class="wireframe-rollup" type="button" data-rollup-region="${escapeHtml2(slot)}" aria-label="${rollup.count} changes in ${escapeHtml2(slot)}">${rollup.count} ${rollup.count === 1 ? "change" : "changes"}</button>` : "";
637
+ return `<div class="wireframe-cell${region ? ` ${status}` : " wireframe-empty"}" data-region-status="${status}" data-slot="${escapeHtml2(slot)}"><div class="wireframe-slot-head"><span class="status-badge">${status}</span><span>${escapeHtml2(slot)}</span>${rollupMarkup}</div><div class="wireframe-items">${region ? renderRegionItems(region) : '<span class="wireframe-empty-label">Empty slot</span>'}</div></div>`;
508
638
  };
509
639
  const renderCell = (cell) => {
510
640
  if (isStack2(cell)) return `<div class="wireframe-cell wireframe-stack-cell"><div class="wireframe-stack">${cell.stack.map((child) => Array.isArray(child) ? renderNestedRow(child) : renderSlot(child.slot)).join("")}</div></div>`;
@@ -522,6 +652,10 @@ function renderWireframe(diff, geometry) {
522
652
  return `<div class="wireframe-removed-region" data-region-status="${region.status}"><div class="wireframe-slot-head"><span class="status-badge">${region.status}</span><span>${escapeHtml2(region.name)}</span></div><div class="wireframe-items">${renderRegionItems(region)}</div></div>`;
523
653
  }
524
654
  }
655
+ function digestGroupLabel(segments, root) {
656
+ const ancestor = [...segments.slice(1, -1)].reverse().find((segment) => /\([^()]+\)$/.test(segment));
657
+ return ancestor?.match(/\(([^()]+)\)$/)?.[1] ?? segments.at(-2) ?? root;
658
+ }
525
659
  function slotsInCell(cell) {
526
660
  if (!isStack2(cell)) return [cell.slot];
527
661
  return cell.stack.flatMap((child) => Array.isArray(child) ? child.flatMap(slotsInCell) : [child.slot]);
@@ -567,24 +701,45 @@ function renderOutline(diff, options = {}) {
567
701
  const metaHtml = `<span class="summary-stat added">+${summary.addedComponents} components</span><span class="summary-stat deleted">\u2212${summary.removedComponents} components</span><span class="summary-stat modified">~${summary.modifiedComponents} components</span><span class="summary-stat">+${summary.addedRegions}/\u2212${summary.removedRegions} regions</span><span class="summary-stat">${summary.changedPageAttributes} page attributes</span>`;
568
702
  const templateChange = diff.pageChanges?.find((change) => change.path === "template");
569
703
  const bannerHtml = templateChange ? `<div class="banner">Template changed: ${renderInlineValue(templateChange.before)} \u2192 ${renderInlineValue(templateChange.after)}</div>` : "";
570
- const data = Object.fromEntries(diff.regions.flatMap((region) => region.items.map((item) => [item.id, item])));
571
- const json = JSON.stringify({ items: data }).replace(/</g, "\\u003c");
704
+ const data = Object.fromEntries([
705
+ ...diff.regions.flatMap((region) => region.items.map((item) => [item.id, item])),
706
+ ...diff.regions.flatMap((region) => region.changes?.length ? [[regionChangeId(region), {
707
+ id: regionChangeId(region),
708
+ path: region.path,
709
+ kind: "region",
710
+ status: region.status,
711
+ componentName: region.name.split(" \u203A ").at(-1)?.match(/\(([^()]+)\)$/)?.[1] ?? region.name.split(" \u203A ").at(-1) ?? region.name,
712
+ changes: region.changes
713
+ }]] : [])
714
+ ]);
715
+ const rollups = getWireframeRollups(diff);
716
+ const json = JSON.stringify({ items: data, rollups }).replace(/</g, "\\u003c");
572
717
  const clientScript = `const DATA = ${json};
573
718
  const rows = [...document.querySelectorAll('.outline-row')];
574
719
  const regions = [...document.querySelectorAll('[data-region-status]')];
575
720
  const filters = [...document.querySelectorAll('.filter-button')];
576
- function changed(el) { return el.dataset.regionStatus !== 'unchanged' || el.querySelector('.outline-row.added,.outline-row.deleted,.outline-row.modified') !== null; }
721
+ const panelTitle = document.getElementById('panel-title');
722
+ const panelBadge = document.getElementById('panel-badge');
723
+ const panelBody = document.getElementById('panel-body');
724
+ const panelBack = document.getElementById('panel-back');
725
+ let activeDigestRegion;
726
+ function changed(el) { return el.dataset.regionStatus !== 'unchanged' || el.querySelector('.outline-row:not(.unchanged)') !== null; }
577
727
  function applyView(mode) {
578
728
  filters.forEach((button) => button.classList.toggle('active', button.dataset.viewMode === mode));
579
729
  rows.forEach((row) => { const status = row.dataset.status; row.hidden = mode === 'after' ? status === 'deleted' : mode === 'before' ? status === 'added' : mode === 'changes' ? status === 'unchanged' : false; });
580
730
  regions.forEach((region) => { const wireframeCell = region.classList.contains('wireframe-cell'); region.hidden = mode === 'after' ? region.dataset.regionStatus === 'deleted' : mode === 'before' ? region.dataset.regionStatus === 'added' : mode === 'changes' ? !wireframeCell && !changed(region) : false; });
581
731
  }
582
732
  filters.forEach((button) => button.addEventListener('click', () => applyView(button.dataset.viewMode)));
583
- function text(value) { return value === undefined ? '(missing)' : String(value); }
733
+ function text(value) { return value === undefined ? '(missing)' : typeof value === 'object' ? JSON.stringify(value) : String(value); }
584
734
  function escape(value) { return text(value).replaceAll('&','&amp;').replaceAll('<','&lt;').replaceAll('>','&gt;').replaceAll('"','&quot;'); }
585
735
  function renderValue(before, after) { if (before === undefined) return '<span class="val after">' + escape(after) + '</span>'; if (after === undefined) return '<span class="val before">' + escape(before) + '</span>'; return '<span class="val before">' + escape(before) + '</span><span class="arrow">\u2192</span><span class="val after">' + escape(after) + '</span>'; }
586
- function selectRow(row) { const item = DATA.items[row.dataset.itemId]; if (!item) return; const title = item.componentName || item.fieldItem || 'Item'; const badge = document.getElementById('panel-badge'); document.getElementById('panel-title').textContent = title; badge.hidden = false; badge.textContent = item.status; badge.className = 'panel-badge ' + item.status; const body = document.getElementById('panel-body'); if (!item.changes || item.changes.length === 0) { body.className = 'empty'; body.textContent = item.status === 'unchanged' ? 'No property changes.' : 'No property details available.'; return; } body.className = ''; body.innerHTML = '<ul class="changes">' + item.changes.map((change) => '<li><span class="change-path">' + escape(change.path) + '</span>' + renderValue(change.before, change.after) + '</li>').join('') + '</ul>'; }
736
+ function selectItem(id, fromDigest = false) { const item = DATA.items[id]; if (!item) return; if (!fromDigest) { activeDigestRegion = undefined; panelBack.hidden = true; } else { panelBack.hidden = false; panelBack.textContent = 'Back to ' + activeDigestRegion + ' changes'; } const title = item.componentName || item.fieldItem || 'Item'; panelTitle.textContent = title; panelBadge.hidden = false; panelBadge.textContent = item.status; panelBadge.className = 'panel-badge ' + item.status; const location = item.path ? '<div class="item-path">' + escape(item.path) + '</div>' : ''; const notes = item.notes?.length ? '<ul class="item-notes">' + item.notes.map((note) => '<li>' + escape(note) + '</li>').join('') + '</ul>' : ''; if (!item.changes || item.changes.length === 0) { if (item.status === 'unchanged' && !notes) { panelBody.className = 'empty'; panelBody.textContent = 'No property changes.'; } else { panelBody.className = 'empty'; panelBody.innerHTML = location + notes + '<div>' + (item.status === 'unchanged' ? 'No property changes.' : 'No property details available.') + '</div>'; } return; } panelBody.className = ''; panelBody.innerHTML = location + notes + '<ul class="changes">' + item.changes.map((change) => '<li><span class="change-path">' + escape(change.path) + '</span>' + renderValue(change.before, change.after) + '</li>').join('') + '</ul>'; }
737
+ function selectRow(row) { selectItem(row.dataset.itemId); }
738
+ function digestEntry(id) { const item = DATA.items[id]; if (!item) return ''; const label = item.componentName || item.fieldItem || 'Item'; return '<button class="digest-entry ' + item.status + '" type="button" data-digest-item-id="' + escape(id) + '"><span class="status-badge ' + item.status + '">' + escape(item.status) + '</span><span>' + escape(label) + '</span></button>'; }
739
+ function showDigest(regionName) { const rollup = DATA.rollups[regionName]; if (!rollup) return; document.body.classList.remove('panel-collapsed'); document.getElementById('panel-toggle')?.setAttribute('aria-expanded','true'); activeDigestRegion = regionName; panelTitle.textContent = regionName + ' changes'; panelBadge.hidden = true; panelBack.hidden = true; panelBody.className = ''; panelBody.innerHTML = '<div class="digest">' + rollup.groups.map((group) => '<section class="digest-group"><div class="digest-group-head"><span>' + escape(group.label) + '</span><span class="digest-group-count">' + group.itemIds.length + ' ' + (group.itemIds.length === 1 ? 'change' : 'changes') + '</span></div>' + group.itemIds.map(digestEntry).join('') + '</section>').join('') + '</div>'; panelBody.querySelectorAll('[data-digest-item-id]').forEach((entry) => entry.addEventListener('click', () => selectItem(entry.dataset.digestItemId, true))); }
587
740
  rows.forEach((row) => { row.addEventListener('click', () => selectRow(row)); row.addEventListener('keydown', (event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); selectRow(row); } }); });
741
+ document.querySelectorAll('.wireframe-rollup').forEach((button) => button.addEventListener('click', (event) => { event.stopPropagation(); showDigest(button.dataset.rollupRegion); }));
742
+ panelBack.addEventListener('click', () => { if (activeDigestRegion) showDigest(activeDigestRegion); });
588
743
  const themeButtons = [...document.querySelectorAll('.theme-button')];
589
744
  function applyTheme(theme) { document.documentElement.dataset.theme = theme; themeButtons.forEach((button) => button.setAttribute('aria-pressed', button.dataset.themeChoice === theme ? 'true' : 'false')); }
590
745
  function storedTheme() { try { const value = localStorage.getItem(${JSON.stringify("flow-delta-theme")}); return ['system','light','dark'].includes(value) ? value : 'system'; } catch { return 'system'; } }
@@ -592,7 +747,7 @@ function renderOutline(diff, options = {}) {
592
747
  applyTheme(storedTheme()); applyView('all');
593
748
  const panelToggle = document.getElementById('panel-toggle'); const panelReopen = document.getElementById('panel-reopen'); panelToggle.addEventListener('click', () => { document.body.classList.add('panel-collapsed'); panelToggle.setAttribute('aria-expanded','false'); }); panelReopen.addEventListener('click', () => { document.body.classList.remove('panel-collapsed'); panelToggle.setAttribute('aria-expanded','true'); });
594
749
  const resizer = document.getElementById('panel-resizer'); let resizeStart; resizer.addEventListener('pointerdown', (event) => { resizeStart = { x:event.clientX, width:document.getElementById('detail-panel').getBoundingClientRect().width }; resizer.setPointerCapture(event.pointerId); }); resizer.addEventListener('pointermove', (event) => { if (!resizeStart) return; document.documentElement.style.setProperty('--panel-width', Math.max(300, resizeStart.width - (event.clientX - resizeStart.x)) + 'px'); }); resizer.addEventListener('pointerup', () => { resizeStart = undefined; });`;
595
- const wireframe = renderWireframe(diff, getTemplateGeometry(options.template));
750
+ const wireframe = renderWireframe(diff, getTemplateGeometry(options.template), rollups);
596
751
  return renderShell({ title: diff.pageName, productName: "FlexiPageDelta", metaHtml, bannerHtml, contentHtml: content, wireframeHtml: wireframe ?? void 0, defaultView: wireframe ? "wireframe" : "outline", clientScript });
597
752
  }
598
753
  function renderInlineValue(value) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syntax-syllogism/flow-delta",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
4
4
  "type": "module",
5
5
  "description": "Semantic visual diff for Salesforce Flows",
6
6
  "license": "MIT",