@ashley-shrok/viewmodel-shell 6.4.0 → 6.5.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.
package/dist/browser.d.ts CHANGED
@@ -250,4 +250,17 @@ export declare class BrowserAdapter implements Adapter {
250
250
  * with an `action` becomes a role="button" tabstop with Enter/Space activation
251
251
  * (Space suppresses page scroll), mirroring TableRow.action / SectionNode.action. */
252
252
  private tracker;
253
+ /** DiffNode — aligned before/after primitive. Row-kind (add/remove/context)
254
+ * is derived client-side from the shape of DiffRow: old-only = remove,
255
+ * new-only = add, matching text = context, differing text = modified pair
256
+ * (side-by-side shows both cells; unified splits into remove-then-add).
257
+ * Line-number cells carry the same kind class as their content cell so the
258
+ * whole row-side reads as one connected colored band. Left-stripe lives only
259
+ * on the leftmost cell of each colored row-side (linenum cell). In unified
260
+ * mode the two linenum columns visually collapse into a single left margin
261
+ * for context rows; for add/remove rows the second linenum keeps its tint so
262
+ * the color band runs continuously, minus the stripe. Design of record:
263
+ * `.planning/design/diff-node.md`. Spike-validated 2026-07-19. */
264
+ private diff;
265
+ private _diffCell;
253
266
  }
package/dist/browser.js CHANGED
@@ -509,6 +509,7 @@ export class BrowserAdapter {
509
509
  case "breadcrumb": return this.breadcrumb(n, parent, on);
510
510
  case "steps": return this.steps(n, parent);
511
511
  case "tracker": return this.tracker(n, parent, on);
512
+ case "diff": return this.diff(n, parent);
512
513
  default: {
513
514
  // Fail loud, not silent (AGENTS.md: "Nothing important fails quietly").
514
515
  // Runtime trees are server-controlled JSON, so an unknown/forward-version
@@ -3608,4 +3609,96 @@ export class BrowserAdapter {
3608
3609
  }
3609
3610
  parent.appendChild(strip);
3610
3611
  }
3612
+ /** DiffNode — aligned before/after primitive. Row-kind (add/remove/context)
3613
+ * is derived client-side from the shape of DiffRow: old-only = remove,
3614
+ * new-only = add, matching text = context, differing text = modified pair
3615
+ * (side-by-side shows both cells; unified splits into remove-then-add).
3616
+ * Line-number cells carry the same kind class as their content cell so the
3617
+ * whole row-side reads as one connected colored band. Left-stripe lives only
3618
+ * on the leftmost cell of each colored row-side (linenum cell). In unified
3619
+ * mode the two linenum columns visually collapse into a single left margin
3620
+ * for context rows; for add/remove rows the second linenum keeps its tint so
3621
+ * the color band runs continuously, minus the stripe. Design of record:
3622
+ * `.planning/design/diff-node.md`. Spike-validated 2026-07-19. */
3623
+ diff(n, parent) {
3624
+ const mode = n.mode ?? "side-by-side";
3625
+ const root = document.createElement("div");
3626
+ root.className = `vms-diff vms-diff--${mode}`;
3627
+ if (n.id != null)
3628
+ root.id = n.id;
3629
+ root.setAttribute("role", "group");
3630
+ root.setAttribute("aria-label", "Diff");
3631
+ // Optional header row(s) — file paths for old / new.
3632
+ if (n.header) {
3633
+ if (mode === "side-by-side") {
3634
+ const hOld = document.createElement("div");
3635
+ hOld.className = "vms-diff__header vms-diff__header--old";
3636
+ hOld.textContent = n.header.old;
3637
+ root.appendChild(hOld);
3638
+ const hNew = document.createElement("div");
3639
+ hNew.className = "vms-diff__header vms-diff__header--new";
3640
+ hNew.textContent = n.header.new;
3641
+ root.appendChild(hNew);
3642
+ }
3643
+ else {
3644
+ const h = document.createElement("div");
3645
+ h.className = "vms-diff__header";
3646
+ h.textContent = `${n.header.old} → ${n.header.new}`;
3647
+ root.appendChild(h);
3648
+ }
3649
+ }
3650
+ for (const row of n.rows) {
3651
+ const oldCell = row.old ?? null;
3652
+ const newCell = row.new ?? null;
3653
+ const kindOld = oldCell == null ? "empty"
3654
+ : newCell == null ? "remove"
3655
+ : oldCell.text === newCell.text ? "context"
3656
+ : "remove";
3657
+ const kindNew = newCell == null ? "empty"
3658
+ : oldCell == null ? "add"
3659
+ : newCell.text === oldCell.text ? "context"
3660
+ : "add";
3661
+ if (mode === "side-by-side") {
3662
+ this._diffCell(root, oldCell?.lineNumber, kindOld, true, "old");
3663
+ this._diffCell(root, oldCell?.text, kindOld, false, "old");
3664
+ this._diffCell(root, newCell?.lineNumber, kindNew, true, "new");
3665
+ this._diffCell(root, newCell?.text, kindNew, false, "new");
3666
+ }
3667
+ else {
3668
+ // Unified: context = one visual row; change = a remove row then an add row.
3669
+ if (kindOld === "context") {
3670
+ this._diffCell(root, oldCell?.lineNumber, "context", true, "old");
3671
+ this._diffCell(root, newCell?.lineNumber, "context", true, "new");
3672
+ this._diffCell(root, oldCell?.text, "context", false, "old");
3673
+ }
3674
+ else {
3675
+ if (oldCell !== null) {
3676
+ this._diffCell(root, oldCell?.lineNumber, "remove", true, "old");
3677
+ this._diffCell(root, "", "remove", true, "new");
3678
+ this._diffCell(root, oldCell?.text, "remove", false, "old");
3679
+ }
3680
+ if (newCell !== null) {
3681
+ this._diffCell(root, "", "add", true, "old");
3682
+ this._diffCell(root, newCell?.lineNumber, "add", true, "new");
3683
+ this._diffCell(root, newCell?.text, "add", false, "new");
3684
+ }
3685
+ }
3686
+ }
3687
+ }
3688
+ parent.appendChild(root);
3689
+ }
3690
+ _diffCell(parent, content, kind, isLinenum, side) {
3691
+ const el = document.createElement("div");
3692
+ const parts = ["vms-diff__cell", `vms-diff__cell--${kind}`];
3693
+ if (isLinenum) {
3694
+ parts.push("vms-diff__cell--linenum");
3695
+ parts.push(side === "old" ? "vms-diff__cell--old-linenum" : "vms-diff__cell--new-linenum");
3696
+ // Line numbers are visual gutter — hide from SR (the actual content
3697
+ // announces on the content cell). Also mark unselectable via CSS.
3698
+ el.setAttribute("aria-hidden", "true");
3699
+ }
3700
+ el.className = parts.join(" ");
3701
+ el.textContent = content == null ? "" : String(content);
3702
+ parent.appendChild(el);
3703
+ }
3611
3704
  }
package/dist/index.d.ts CHANGED
@@ -103,7 +103,7 @@ export interface Adapter {
103
103
  * concept). */
104
104
  reload?(): void;
105
105
  }
106
- export type ViewNode = PageNode | SectionNode | ListNode | ListItemNode | FormNode | FieldNode | CheckboxNode | ButtonNode | TextNode | LinkNode | ImageNode | StatBarNode | TabsNode | ProgressNode | ModalNode | TableNode | CopyButtonNode | DividerNode | FitsNode | EmptyStateNode | BadgeNode | ChartNode | BreadcrumbNode | StepsNode | TrackerNode;
106
+ export type ViewNode = PageNode | SectionNode | ListNode | ListItemNode | FormNode | FieldNode | CheckboxNode | ButtonNode | TextNode | LinkNode | ImageNode | StatBarNode | TabsNode | ProgressNode | ModalNode | TableNode | CopyButtonNode | DividerNode | FitsNode | EmptyStateNode | BadgeNode | ChartNode | BreadcrumbNode | StepsNode | TrackerNode | DiffNode;
107
107
  export interface PageNode {
108
108
  type: "page";
109
109
  title?: string;
@@ -1155,6 +1155,76 @@ export interface TrackerNode {
1155
1155
  /** Ordered buckets, oldest → newest (rendered left → right). */
1156
1156
  cells: TrackerCell[];
1157
1157
  }
1158
+ /** One cell of a diff row (either the "old" side or the "new" side). Carries the
1159
+ * cell's text plus an optional line number for the gutter. `text` may be an
1160
+ * empty string. The wire distinction "cell present but empty" vs "cell absent"
1161
+ * (the `null` case on the parent DiffRow) carries meaning — see DiffRow. */
1162
+ export interface DiffCell {
1163
+ text: string;
1164
+ lineNumber?: number;
1165
+ }
1166
+ /** One row of a DiffNode. Row-KIND (add / remove / context) is derived
1167
+ * client-side from the shape of the row itself — no separate `kind` wire field:
1168
+ *
1169
+ * old != null && new == null → REMOVE (left-side content, right side empty)
1170
+ * old == null && new != null → ADD (right-side content, left side empty)
1171
+ * old.text === new.text → CONTEXT (unchanged; both sides identical)
1172
+ * old.text !== new.text → a modified pair — both cells shown side-by-side
1173
+ * and tinted in side-by-side mode; splits into
1174
+ * a REMOVE then ADD row-pair in unified mode
1175
+ *
1176
+ * This "shape carries the meaning" pattern makes it impossible for a `kind`
1177
+ * label to disagree with the content, and keeps the wire compact. Line numbers
1178
+ * are optional so diff sources that don't track them (e.g. two prose versions)
1179
+ * degrade to a content-only column. */
1180
+ export interface DiffRow {
1181
+ /** Old (pre-change) cell. OMIT to signal a pure addition (no left-side content).
1182
+ * Wire contract (gotcha #8): omit the key rather than emit `"old": null` — an
1183
+ * unset optional is ABSENT, never `"field": null`. The renderer treats a
1184
+ * missing key as "no old side" (pure add). */
1185
+ old?: DiffCell;
1186
+ /** New (post-change) cell. OMIT to signal a pure removal (no right-side content).
1187
+ * Same wire contract as `old` — omit the key rather than emit `null`. */
1188
+ new?: DiffCell;
1189
+ }
1190
+ /** DiffNode — aligned before/after primitive for review, audit, and
1191
+ * change-comparison apps. Consumers compute the diff server-side (LibGit2Sharp
1192
+ * on .NET, `diff` lib on TS, `git diff --json`, whatever they have) and hand
1193
+ * VMS the structured rows — same server-computes / framework-renders doctrine
1194
+ * as the markdown → tree pattern. The framework owns ALL appearance and a11y:
1195
+ * the CSS-Grid alignment (4 tracks in side-by-side, 3 in unified), the tint +
1196
+ * left-stripe row coloring, the long-line horizontal-scroll-per-cell that
1197
+ * preserves row alignment, the empty-cell styling, the tinted line numbers,
1198
+ * and the unified-mode collapse of the two linenum columns into a single left
1199
+ * margin. This is the CHARTS/TRACKER precedent applied to before/after
1200
+ * content — aligned side-by-side is a genuinely uncomposable capability that
1201
+ * no combination of existing nodes produces (two `pre` blocks in `layout:"split"`
1202
+ * give you two independent walls of text with no row alignment). Explicitly
1203
+ * out of scope for v1: syntax highlighting on cells (a separate CodeBlockNode
1204
+ * question), word-level intra-line diff highlighting (would need inline rich
1205
+ * text, an open architectural question), in-line review comments, and
1206
+ * collapse/expand of hunks (consumers who want that compute a smaller `rows`
1207
+ * array server-side). Design of record: [design/diff-node.md](../../.planning/design/diff-node.md). */
1208
+ export interface DiffNode {
1209
+ type: "diff";
1210
+ id?: string;
1211
+ /** Ordered rows, top → bottom (rendered in order). */
1212
+ rows: DiffRow[];
1213
+ /** Layout mode. Omitted = "side-by-side" (the default — the whole reason this
1214
+ * primitive exists over composition of two `pre` blocks). "unified" = a
1215
+ * single-column form with dual line-number columns, useful when horizontal
1216
+ * space is constrained or the app prefers the git-log-style presentation.
1217
+ * Closed union (DIFF-01). */
1218
+ mode?: "unified" | "side-by-side";
1219
+ /** Optional header row showing file paths (or any labels) for old/new. In
1220
+ * side-by-side mode: spans the two column-groups (2 tracks each). In unified
1221
+ * mode: spans all 3 tracks and joins the two labels with " → ". Omitted =
1222
+ * no header. */
1223
+ header?: {
1224
+ old: string;
1225
+ new: string;
1226
+ };
1227
+ }
1158
1228
  export interface ShellOptions {
1159
1229
  endpoint: string;
1160
1230
  actionEndpoint: string;
package/dist/server.js CHANGED
@@ -239,11 +239,11 @@ function collectActions(node, enclosingForm, out) {
239
239
  return;
240
240
  }
241
241
  // Nodes with no dispatch-bearing actions of their own:
242
- // text, link, image, stat-bar, progress, copy-button, badge, chart, steps
242
+ // text, link, image, stat-bar, progress, copy-button, badge, chart, steps, diff
243
243
  // (breadcrumb crumb actions ARE recorded above via the "breadcrumb" arm)
244
- // ChartNode (CHART-05) and StepsNode (NAV-02) are DELIBERATE
245
- // childless/action-free leaves — they carry only data, so they fall through
246
- // here with no recursion (no fits-style blind spot).
244
+ // ChartNode (CHART-05), StepsNode (NAV-02), and DiffNode (DIFF-01) are
245
+ // DELIBERATE childless/action-free leaves — they carry only data, so they
246
+ // fall through here with no recursion (no fits-style blind spot).
247
247
  default:
248
248
  return;
249
249
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ashley-shrok/viewmodel-shell",
3
- "version": "6.4.0",
4
- "description": "A server-driven UI framework where the wire format is structured enough that agents can build full-stack apps without ever opening a browser and all UI tests are pure unit tests with no browser runtime. Server returns a JSON tree of typed nodes; a thin TypeScript adapter renders it to DOM. Backend-agnostic a .NET reference backend ships with the repo, but any language can produce the JSON contract.",
3
+ "version": "6.5.0",
4
+ "description": "A server-driven UI framework where the wire format is structured enough that agents can build full-stack apps without ever opening a browser and all UI tests are pure unit tests with no browser runtime. Server returns a JSON tree of typed nodes; a thin TypeScript adapter renders it to DOM. Backend-agnostic \u2014 a .NET reference backend ships with the repo, but any language can produce the JSON contract.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "ashley-shrok",
@@ -1783,3 +1783,109 @@ button.vms-breadcrumb__link {
1783
1783
  position: relative; /* lift the ring above neighbors */
1784
1784
  z-index: 1;
1785
1785
  }
1786
+
1787
+ /* ── DiffNode ────────────────────────────────────────────────────────────────
1788
+ Aligned before/after primitive. CSS Grid handles vertical alignment across
1789
+ two columns naturally (each row's cells share a grid row). Long lines
1790
+ overflow HORIZONTALLY within their cell (per-cell overflow-x: auto) — never
1791
+ wrap, because wrapping would break the row-alignment guarantee. Row-kind
1792
+ coloring is DUAL SIGNAL: background tint (22% alpha) + a 3px inset left
1793
+ stripe on the leftmost cell of each colored row-side. Spike-validated
1794
+ 2026-07-19; design of record: .planning/design/diff-node.md. */
1795
+ .vms-diff {
1796
+ display: grid;
1797
+ font-family: var(--vms-font-mono, ui-monospace, SFMono-Regular, monospace);
1798
+ font-size: var(--vms-text-sm);
1799
+ line-height: 1.5;
1800
+ border: 1px solid var(--vms-border);
1801
+ border-radius: var(--vms-radius-md, 6px);
1802
+ overflow: hidden;
1803
+ background: var(--vms-surface);
1804
+ color: var(--vms-text);
1805
+ }
1806
+ /* Side-by-side: 4 tracks (old-line#, old-content, new-line#, new-content). */
1807
+ .vms-diff--side-by-side {
1808
+ grid-template-columns:
1809
+ [old-line] max-content
1810
+ [old-content] minmax(0, 1fr)
1811
+ [new-line] max-content
1812
+ [new-content] minmax(0, 1fr);
1813
+ }
1814
+ /* Unified: 3 tracks (dual line#s + one content column). */
1815
+ .vms-diff--unified {
1816
+ grid-template-columns:
1817
+ [line-old] max-content
1818
+ [line-new] max-content
1819
+ [content] minmax(0, 1fr);
1820
+ }
1821
+ .vms-diff__cell {
1822
+ padding: 0.15em 0.6em;
1823
+ white-space: pre;
1824
+ overflow-x: auto;
1825
+ min-width: 0; /* required inside minmax(0,1fr) so overflow doesn't blow the track */
1826
+ }
1827
+ /* Line-number cells (base — neutral tint; add/remove/empty override below). */
1828
+ .vms-diff__cell--linenum {
1829
+ color: var(--vms-text-muted);
1830
+ text-align: right;
1831
+ user-select: none;
1832
+ padding: 0.15em 0.5em;
1833
+ border-right: 1px solid var(--vms-border);
1834
+ background: color-mix(in srgb, var(--vms-text-muted) 4%, transparent);
1835
+ }
1836
+ /* Row-kind coloring — applied to BOTH linenum and content cells so each row-side
1837
+ reads as one connected colored band. Stripe on the leftmost cell only. */
1838
+ .vms-diff__cell--add {
1839
+ background: color-mix(in srgb, var(--vms-success) 22%, transparent);
1840
+ }
1841
+ .vms-diff__cell--remove {
1842
+ background: color-mix(in srgb, var(--vms-error) 22%, transparent);
1843
+ }
1844
+ .vms-diff__cell--linenum.vms-diff__cell--add {
1845
+ box-shadow: inset 3px 0 0 0 var(--vms-success);
1846
+ color: var(--vms-success);
1847
+ }
1848
+ .vms-diff__cell--linenum.vms-diff__cell--remove {
1849
+ box-shadow: inset 3px 0 0 0 var(--vms-error);
1850
+ color: var(--vms-error);
1851
+ }
1852
+ .vms-diff__cell--context {
1853
+ /* neutral — no fill */
1854
+ }
1855
+ .vms-diff__cell--empty {
1856
+ background: color-mix(in srgb, var(--vms-text-muted) 6%, transparent);
1857
+ }
1858
+ /* Unified mode — collapse the two linenum columns visually into one left margin.
1859
+ Context rows: only leftmost linenum keeps muted base + border-right (one clean
1860
+ line-# margin). Add/remove rows: the second linenum keeps its row-tint so the
1861
+ color band runs continuously across [line#][line#][content], but drops the
1862
+ stripe (which belongs only on the leftmost cell of the row). */
1863
+ .vms-diff--unified .vms-diff__cell--new-linenum {
1864
+ background: transparent;
1865
+ border-right: none;
1866
+ }
1867
+ .vms-diff--unified .vms-diff__cell--new-linenum.vms-diff__cell--add {
1868
+ background: color-mix(in srgb, var(--vms-success) 22%, transparent);
1869
+ box-shadow: none;
1870
+ }
1871
+ .vms-diff--unified .vms-diff__cell--new-linenum.vms-diff__cell--remove {
1872
+ background: color-mix(in srgb, var(--vms-error) 22%, transparent);
1873
+ box-shadow: none;
1874
+ }
1875
+ /* Header (optional file-path row). */
1876
+ .vms-diff__header {
1877
+ padding: 0.4em 0.8em;
1878
+ font-weight: 600;
1879
+ background: color-mix(in srgb, var(--vms-text-muted) 8%, transparent);
1880
+ border-bottom: 1px solid var(--vms-border);
1881
+ }
1882
+ .vms-diff--side-by-side .vms-diff__header--old { grid-column: old-line / span 2; }
1883
+ .vms-diff--side-by-side .vms-diff__header--new {
1884
+ grid-column: new-line / span 2;
1885
+ border-left: 1px solid var(--vms-border);
1886
+ }
1887
+ .vms-diff--unified .vms-diff__header { grid-column: 1 / -1; }
1888
+ /* Vertical divider between old and new in side-by-side mode. */
1889
+ .vms-diff--side-by-side .vms-diff__cell--new-linenum {
1890
+ border-left: 1px solid var(--vms-border);
1891
+ }