// --------------------------------------------------------------------------- // customer-grid / JsonViewer.tsx — ⭐ wave-23 C7 (owner item 5 / ruling R5). // // THE BIG VIEWER a json cell opens on click. One component, reached from two // places (the grid cell's click path and the record drawer's value slot), for // the reason `assetUrl` is one resolver: two readers of the same document that // disagree about how it folds is a bug nobody can see in either one alone. // // The document is a STRING at rest (the scalar Row contract — see the `json` // note in types.ts), so everything here is text in and text out. Nothing in // this file ever writes a parsed OBJECT back. // // THREE decisions worth stating, because each has a plausible alternative: // // 1. THE RAW TAB IS THE EDITOR, and there is no field-by-field one. A form // over a document whose shape nobody declared would have to invent that // shape from the current value — so adding a key would mean editing the // form's idea of the schema, and the first machine write with a different // shape would silently drop what it did not recognise. // 2. PARSE ON SAVE, REFUSE WITH A SENTENCE. Never repair, never re-indent on // the way in: `{a:1}` is not JSON and quietly "fixing" it to `{"a":1}` // teaches a person their typo was fine, until the day a value cannot be // guessed. The host applies the same rule at the write wall (C7), so a // refusal here says the same thing the server would. // 3. THE TREE COLLAPSES BY DEPTH, not by hand-kept state per node beyond what // the reader opens. A 200-key payload that arrives fully expanded is a // scroll bar, not a document. // // ⭐ WAVE-27 item 13 (owner ruling R13) AMENDS decision 3: THE TREE IS EDITABLE // NOW, two-way synced with the raw tab, and the raw text WINS on conflict. // // "Wins" is structural rather than a rule anybody has to enforce: `draft` (the // raw text) is the single source of truth, `parsed` is derived from it, and the // tree is a projection of `parsed`. A tree edit writes a new VALUE at a path, // re-serialises the whole document and sets `draft` — so there is never a second // copy of the document to reconcile, and a draft that stops parsing simply has // no tree to show (the raw tab is where it gets read, exactly as before). // // ⛔ WHAT THE TREE EDITS, AND WHAT IT DELIBERATELY DOES NOT. Leaf VALUES, in // place, keeping each leaf's own type. NOT keys, NOT structure, NOT a value's // type — those stay on the raw tab, and that is decision 1 above still standing: // a form that can add a key has to have an opinion about the document's shape, // and nobody declared one. Editing `"12"` into `12` is a shape change, so it is // a raw edit; editing `"Anna"` into `"Anne"` is a value change, so it is here. // --------------------------------------------------------------------------- import { useEffect, useId, useMemo, useRef, useState } from "react"; import type { ReactNode } from "react"; import { BodyPortal, useOverlayLayer } from "./OverlaySurface"; import { dayText, jsonParse, jsonPretty, MAX_JSON_BYTES, postsWindowOf } from "./display"; import type { PostSummary, PostsWindow } from "./display"; /** Bytes, not characters — the host's cap is on the stored UTF-8, and a * document of 20,000 emoji is 80 KB. Counting `length` would let a value * through here that the server then refuses, which is the one refusal a user * cannot act on (nothing on screen said it was too big). */ export function jsonByteLength(text: string): number { return typeof TextEncoder === "undefined" ? text.length : new TextEncoder().encode(text).length; } /** The one-line summary in the modal's header: what this document IS. */ export function jsonSummary(text: string): string { const parsed = jsonParse(text); const bytes = jsonByteLength(text); const size = bytes < 1024 ? `${bytes} bytes` : `${(bytes / 1024).toFixed(1)} KB`; if (String(text ?? "").trim() === "") return "Empty"; if (!parsed.ok) return `Not valid JSON · ${size}`; const v = parsed.value; if (Array.isArray(v)) return `${v.length} item${v.length === 1 ? "" : "s"} · ${size}`; if (v !== null && typeof v === "object") { const n = Object.keys(v as object).length; return `${n} key${n === 1 ? "" : "s"} · ${size}`; } return `${v === null ? "null" : typeof v} · ${size}`; } // --- the tree --------------------------------------------------------------- /** How deep a node is open when the viewer mounts. Two levels shows the shape * of every payload this product actually receives (an object of objects) and * stops before the leaf spam of a scraped array. */ const OPEN_TO_DEPTH = 2; function isBranch(v: unknown): v is Record | unknown[] { return v !== null && typeof v === "object"; } /** The value's own text, in its own ink class. Strings keep their quotes: a * bare `12` and a `"12"` are different documents and the tree is where that * difference has to be visible. */ function Leaf({ v }: { v: unknown }): ReactNode { if (v === null) return null; if (typeof v === "string") return "{v}"; if (typeof v === "number") return {String(v)}; if (typeof v === "boolean") return {String(v)}; return {String(v)}; } /** * ⭐ R13 — the path a tree edit names, and the ONE function that applies it. * * Structural clone down the path only: every container on the way is copied, everything else is * shared. That keeps a 32 KB document's edit cheap AND keeps the old value untouched, which is * what makes the raw tab's Revert honest. * * ⚠ Returns the ORIGINAL root unchanged if the path does not resolve — a stale path (the raw tab * changed the shape under an open tree) must not invent the containers it names. The caller reads * that as "nothing to do" rather than silently growing the document a key it never had. */ function setAtPath(root: unknown, path: (string | number)[], next: unknown): unknown { if (path.length === 0) return next; const [head, ...rest] = path; if (Array.isArray(root)) { const i = typeof head === "number" ? head : Number(head); if (!Number.isInteger(i) || i < 0 || i >= root.length) return root; const copy = root.slice(); copy[i] = setAtPath(root[i], rest, next); return copy; } if (root !== null && typeof root === "object") { const obj = root as Record; const k = String(head); if (!Object.prototype.hasOwnProperty.call(obj, k)) return root; return { ...obj, [k]: setAtPath(obj[k], rest, next) }; } return root; } /** * R13 — the in-place leaf editor. * * TYPE-PRESERVING by construction, which is the whole reason it is not one "edit as JSON" box: * a string edits as text (no quotes to type), a number as a number, a boolean as its two values. * A leaf therefore cannot change TYPE here — that is a shape change and belongs to the raw tab * (see the amendment at the top of the file). * * ⛔ A number that does not parse is REFUSED, not repaired and not coerced to a string. This is * decision 2 of the header applied one level down: quietly turning `12a` into `"12a"` teaches a * person their typo was fine, right up until a consumer of the document expects arithmetic. */ function LeafEditor({ value, onCommit, onCancel, }: { value: string | number | boolean; onCommit: (next: string | number | boolean) => void; onCancel: () => void; }) { const kind = typeof value; const [text, setText] = useState(() => String(value)); const [bad, setBad] = useState(false); const commit = () => { if (kind === "number") { const n = Number(text.trim()); if (text.trim() === "" || !Number.isFinite(n)) { setBad(true); return; } onCommit(n); return; } onCommit(text); }; if (kind === "boolean") return ( ); return ( { setText(e.target.value); if (bad) setBad(false); }} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); commit(); } else if (e.key === "Escape") { e.preventDefault(); onCancel(); } }} // Blur commits rather than discards: a person who clicks away from an edit box in a // document editor means "keep it" far more often than "throw it away", and Escape is the // discard that is actually reachable. onBlur={commit} /> ); } function Node({ name, value, depth, last, path, onValue, editing, onEditing, }: { name: string | null; value: unknown; depth: number; last: boolean; /** ⭐ R13 — where this node lives in the document, for `setAtPath`. */ path: (string | number)[]; /** ⭐ R13 — absent = the tree is READ-ONLY (the same condition `onSave` states one level up: * a machine-owned, Odoo-sourced or unpermitted field). Present = leaves are editable. */ onValue?: (path: (string | number)[], next: unknown) => void; /** The ONE leaf currently open for editing, as a joined path. Hoisted out of the node so * opening a second editor closes the first — per-node state would leave a trail of open * inputs down a document. */ editing?: string; onEditing?: (key: string | undefined) => void; }) { const [open, setOpen] = useState(depth < OPEN_TO_DEPTH); const pathKey = path.join(""); if (!isBranch(value)) { const canEdit = !!onValue && value !== null; return (
{name !== null && {name}} {name !== null && : } {canEdit && editing === pathKey ? ( { onValue(path, next); onEditing?.(undefined); }} onCancel={() => onEditing?.(undefined)} /> ) : canEdit ? ( // A real button, so the keyboard reaches every leaf the mouse does. `null` is // deliberately NOT editable: there is no type to preserve, so any edit would be a // shape change, which belongs to the raw tab. ) : ( )} {!last && ,}
); } const array = Array.isArray(value); const entries: [string, unknown][] = array ? (value as unknown[]).map((v, i) => [String(i), v]) : Object.entries(value as Record); const openMark = array ? "[" : "{"; const closeMark = array ? "]" : "}"; return ( <>
{name !== null && {name}} {name !== null && : } {openMark} {/* Collapsed, the count IS the content — a bare `{…}` tells a reader nothing about whether opening it is worth the click. */} {!open && ( {entries.length} {array ? (entries.length === 1 ? "item" : "items") : entries.length === 1 ? "key" : "keys"} )} {!open && {closeMark}{last ? "" : ","}}
{open && entries.map(([k, v], i) => ( ))} {open && (
{closeMark} {last ? "" : ","}
)} ); } // --- ⭐ wave-26 item 8 (contract C2): the POSTS reading ---------------------- // // The owner's ask was that the `posts` cell "reads as POSTS, not raw JSON". The // tree is a fine JSON reader and a poor post reader: `posts → 3 → caption` is // four clicks to see one line somebody wrote. So a posts document opens on its // OWN tab, and the tree and raw tabs stay exactly where they were — this adds a // reading, it does not replace the document. // // ⛔ AN ABSENT METRIC RENDERS AS NOTHING, NEVER AS A ZERO. `postsWindowOf` keeps // them `undefined` when unbought (C2: metrics are ABSENT KEYS when // `postMetrics` was off), and the row below tests each one before painting it. // A `0 views` on a post that was never measured is a fabricated measurement, // and it is indistinguishable on screen from a real one. /** One metric, or nothing at all. The guard IS the feature — see above. */ function Metric({ label, value }: { label: string; value: number | undefined }) { if (value === undefined) return null; return ( {value.toLocaleString()} {label} ); } function PostRow({ post }: { post: PostSummary }) { const day = post.posted_at ? dayText(post.posted_at) : ""; /* ⚠ A ROW WITH NOTHING READABLE IS POSSIBLE BY CONSTRUCTION. `postsWindowOf` accepts any object inside `posts` — deliberately, because refusing an unrecognised member would hide a post the engine wrote in a shape this file has not learned yet. The engine's own writer fills these, so in practice it takes a hand-edited document to get here; when it happens the card must not paint as an empty rectangle, which reads as a rendering bug rather than as a thin record. The shortcode is the post's identity and is the honest last resort. */ const bare = !day && !post.type && !post.caption && !post.url; return (
{bare && ( {post.shortcode ?? "A post with no details"} )}
{day && {day}} {/* The type is the post's own word (`reel`, `image`) — shown verbatim rather than mapped, because a vocabulary this file invents would drift from the vendor's the first time they add one. */} {post.type && {post.type}} {post.url && ( Open )}
{post.caption &&

{post.caption}

} {/* ⛔ NO `views` ROW HERE (2026-08-08 — instagram-capture.md §4e). The vendor's `views` is delivered at ACCOUNT grain: one value repeated across every reel of a creator. Rendering it beside this post's own likes and comments is exactly the reading that made it look like a post metric in the first place, and this panel would have kept saying so after the column was retired. `plays` is the honest per-reel field; it is omitted rather than shown empty, because a blank metric chip claims we looked and got zero. */}
); } function PostsPanel({ window: w }: { window: PostsWindow }) { if (w.posts.length === 0) return

No posts were captured for this profile.

; return (
{/* ⛔ THE ONE SENTENCE THIS PANEL OWES A READER (DESIGN.md §4 — never over-explain). It is a WINDOW: the append tables keep the whole series (R1/R3), so a reader must not take these for every post the account has ever made. And when metrics were not bought, saying so is the only way an empty metrics row reads as "not measured" rather than "measured as nothing". */}

The {w.posts.length} most recent post{w.posts.length === 1 ? "" : "s"} {w.as_of ? `, as of ${dayText(w.as_of)}` : ""} {w.metrics ? "." : " — engagement was not collected for this run."}

    {w.posts.map((p, i) => (
  • ))}
); } // --- the shared modal shell ------------------------------------------------- // // ⭐ WAVE-27 item 13 (R13) — extracted from `JsonViewer`'s own chrome when the CODE editor // arrived, because "a big value that opens in a modal with a title, a summary line, a copy // button and a footer" is now two things rather than one. One shell, so the two cannot drift // into looking like different products — the reason `assetUrl` is one resolver, applied to // furniture instead of to URLs. // // It owns ONLY the chrome. Tabs, body and footer are children: what a document IS differs // between a JSON payload and a snippet, and a shell that tried to own that would end up with a // `mode` flag threaded through every branch. function ViewerShell({ label, summary, kind, panelRef, titleId, onClose, onCopy, copied, children, }: { label: string; summary: string; /** `data-overlay-kind` — the overlay layer's own identifier, distinct per viewer. */ kind: string; panelRef: React.RefObject; titleId: string; onClose: () => void; onCopy: () => void; copied: boolean; children: ReactNode; }) { return (
{label}
{summary}
{children}
); } // --- ⭐ WAVE-27 item 13 (R13): the CODE editor ------------------------------ // // A snippet is not a document, and the difference decides everything below: // // · IT NEED NOT PARSE. Half-written SQL is the normal state of a snippet, so there is no // validate-on-save and no refusal — the only limit is the byte cap the store enforces // anyway. This is the one rule `json` has that `code` deliberately does not. // · THERE IS NO TREE. A snippet has no structure to project, so there is one panel, not tabs. // · THE HIGHLIGHTER IS A RENDERER, NEVER AN INTERPRETER (R13: "NO execution engine"). It // colours strings, comments, numbers and a per-language keyword list. Nothing here can run, // fetch or evaluate anything, and no `language` value opens a path to something that could. // // The editor is a transparent-text `