From 312ddc5301ce298f9c81213c808a80776fd71342 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Fri, 21 Aug 2026 19:11:23 -0400 Subject: [PATCH] feat: json column type, structured example cells, syntax highlighting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Example cells went through String(value), so any nested value rendered as "[object Object]" — the frontmatter format accepted structured data that no surface could display. A structured cell now shows a truncated monospace preview with an expander that opens the pretty-printed document. The declared type licenses parsing a *string* as a document; nested values are recognised unconditionally, so an author who omits `json` still gets a readable cell. Entity and process tables share one ExampleCell. Modal keeps a stack and answers ESC only in its top-most instance — without that, a dialog opened from inside another closed both. Code fences were rendering as plain preformatted text; markdown-it was constructed with no highlight option. Two shiki entry points rather than one shared module: grammars are static imports that no bundler can tree-shake, so a single module carrying all six languages would put ~620KB in the browser bundle. Body markdown reaches the client as already-rendered bodyHtml, so the six-language set lives on the parse side and the browser needs json alone — 4.22MB -> 4.36MB. Both run the JavaScript raw engine over precompiled grammars. The default Oniguruma engine loads a WebAssembly blob, which would break the single-file export and its zero-network guarantee. defaultColor:false leaves both theme values on every token so styles.css resolves them off the existing .theme-* class, keeping code blocks on the in-app toggle rather than the OS scheme. --- bun.lock | 95 +++++++++++++ docs/design/markdown-driven-erd.md | 2 +- docs/guides/folder-format.md | 46 ++++++- docs/spec/example-instance-tables.md | 30 +++++ docs/wiki/feature-map.md | 2 + docs/wiki/parser.md | 3 +- docs/wiki/skill.md | 2 +- .../data/transactional/PaymentMethod.md | 25 ++++ package.json | 3 + .../references/conventions.md | 21 ++- .../references/entity-flow.md | 2 +- .../components/entity/ExamplesAccordion.tsx | 23 ++-- .../components/process/ProcessExamples.tsx | 6 +- src/app/components/ui/ExampleCell.tsx | 26 ++++ src/app/components/ui/JsonValue.tsx | 54 ++++++++ src/app/components/ui/Modal.tsx | 41 +++++- src/app/logic/json-highlight.ts | 41 ++++++ src/app/logic/json-value.ts | 73 ++++++++++ src/app/styles.css | 101 ++++++++++++++ src/flows/flow-parse.ts | 3 +- src/model/markdown-highlight.ts | 54 ++++++++ src/model/parse.ts | 3 +- test/checks/test-code-highlight.ts | Bin 0 -> 4177 bytes test/checks/test-json-value.ts | 126 ++++++++++++++++++ test/visual/screenshot-json-values.ts | 123 +++++++++++++++++ 25 files changed, 877 insertions(+), 28 deletions(-) create mode 100644 src/app/components/ui/ExampleCell.tsx create mode 100644 src/app/components/ui/JsonValue.tsx create mode 100644 src/app/logic/json-highlight.ts create mode 100644 src/app/logic/json-value.ts create mode 100644 src/model/markdown-highlight.ts create mode 100644 test/checks/test-code-highlight.ts create mode 100644 test/checks/test-json-value.ts create mode 100644 test/visual/screenshot-json-values.ts diff --git a/bun.lock b/bun.lock index 5069f63..95c26eb 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,8 @@ "name": "bun-react-template", "dependencies": { "@clack/prompts": "^1.5.0", + "@shikijs/langs-precompiled": "^4.4.3", + "@shikijs/themes": "^4.4.3", "citty": "^0.2.2", "cytoscape": "^3.31.0", "cytoscape-elk": "^2.2.0", @@ -15,6 +17,7 @@ "markdown-it": "^14.1.0", "react": "^19", "react-dom": "^19", + "shiki": "^4.4.3", "web-worker": "^1.5.0", "yaml": "^2.8.4", }, @@ -32,20 +35,54 @@ "@clack/prompts": ["@clack/prompts@1.5.0", "", { "dependencies": { "@clack/core": "1.4.0", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-wKh+wTjmrUoUdkZg8KpJO5X+p9PWV+KE9mePseq9UYWkukgTKsGS47RRL2HstwVcvDQH+PenrPJWII8+MfiiyA=="], + "@shikijs/core": ["@shikijs/core@4.4.3", "", { "dependencies": { "@shikijs/primitive": "4.4.3", "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5", "hast-util-to-html": "^9.0.5" } }, "sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg=="], + + "@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ=="], + + "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-EcOQkxdxGQrc1Row/cC2c96/v1dbZqGnEVu1qTuT/MJmp6+cXCvQussowVmCv5Tqr3KuY3c7IbM6HTW3LJ1k9w=="], + + "@shikijs/langs": ["@shikijs/langs@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3" } }, "sha512-ePic0yfAJGOF83D5wBHK/00EjK65oahBYxFk5epgq33WRv7X9UuxLEV8PtR0szC0z8dl7INIpIodB99JRFlR+A=="], + + "@shikijs/langs-precompiled": ["@shikijs/langs-precompiled@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3", "oniguruma-to-es": "^4.3.6" } }, "sha512-i3+91QcqVBji2mlCinQpHQyoZfciaWsHCuv5XZwMXGEmSAMCW0oCdrWp+zMKLf0CwHd9XT+E5RBDJ0RY1USWCw=="], + + "@shikijs/primitive": ["@shikijs/primitive@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-m0wBeLDQDeIxRdUmrCPdQqfuUamDwRL5isCfYbguKD6NiaKpVbsv+3J81DyIKgNW5h4WAIIr8T4EkgQrBBxvaQ=="], + + "@shikijs/themes": ["@shikijs/themes@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3" } }, "sha512-w8UHjeUnIR965KMWJHUPXOc2mNJUnK3vpVLYLvw5IYU2mnTTJ89E24OrJDBNiJDQ0qzb0tc4l7mrIXx5cFeIyw=="], + + "@shikijs/types": ["@shikijs/types@4.4.3", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g=="], + + "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], + "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], + "@types/hast": ["@types/hast@3.0.5", "", { "dependencies": { "@types/unist": "*" } }, "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g=="], + + "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], + "@types/node": ["@types/node@25.6.2", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw=="], "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.3", "", {}, "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg=="], + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], + "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], + + "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], + + "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], + "citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="], + "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], + "cose-base": ["cose-base@2.2.0", "", { "dependencies": { "layout-base": "^2.0.0" } }, "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g=="], "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], @@ -58,6 +95,10 @@ "cytoscape-navigator": ["cytoscape-navigator@2.0.2", "", { "peerDependencies": { "cytoscape": "^2.6.0 || ^3.0.0" } }, "sha512-TZFBLFWEMW858UOt4rzusOjtDj7YT5vNx2uCwpUuicUYbaWCHHcUROBZWO+hiuSPWpVhvGLFlOq3NBcAVYOAgw=="], + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + + "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + "elkjs": ["elkjs@0.11.1", "", {}, "sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg=="], "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], @@ -70,38 +111,92 @@ "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="], + + "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], + + "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], + "layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="], "linkify-it": ["linkify-it@5.0.1", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg=="], "markdown-it": ["markdown-it@14.2.0", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", "linkify-it": "^5.0.1", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ=="], + "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="], + "mdurl": ["mdurl@2.0.0", "", {}, "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="], + "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + + "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="], + + "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="], + + "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], + + "oniguruma-parser": ["oniguruma-parser@0.12.2", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="], + + "oniguruma-to-es": ["oniguruma-to-es@4.3.6", "", { "dependencies": { "oniguruma-parser": "^0.12.2", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA=="], + "playwright": ["playwright@1.60.0", "", { "dependencies": { "playwright-core": "1.60.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA=="], "playwright-core": ["playwright-core@1.60.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA=="], + "property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="], + "punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="], "react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="], "react-dom": ["react-dom@19.2.6", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="], + "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="], + + "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="], + + "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + "shiki": ["shiki@4.4.3", "", { "dependencies": { "@shikijs/core": "4.4.3", "@shikijs/engine-javascript": "4.4.3", "@shikijs/engine-oniguruma": "4.4.3", "@shikijs/langs": "4.4.3", "@shikijs/themes": "4.4.3", "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g=="], + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], + + "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], + + "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], + "uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="], "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], + + "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="], + + "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], + + "unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="], + + "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], + + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], + + "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], + "web-worker": ["web-worker@1.5.0", "", {}, "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw=="], "webview-bun": ["webview-bun@2.4.0", "", {}, "sha512-0+ugnQlcUHmuW+iLeb+Lzb8rGUJh7WEdXvNsuvaVEXT3EagK380XdD7heVJu0Ek/mNxMY3G2JM142YRQ1hDUGQ=="], "yaml": ["yaml@2.8.4", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog=="], + "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + "cytoscape-elk/elkjs": ["elkjs@0.9.3", "", {}, "sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ=="], } } diff --git a/docs/design/markdown-driven-erd.md b/docs/design/markdown-driven-erd.md index 5913393..ed43de8 100644 --- a/docs/design/markdown-driven-erd.md +++ b/docs/design/markdown-driven-erd.md @@ -90,7 +90,7 @@ No attributes table here — that's generated from the frontmatter. | Property | Required | Description | |---|---|---| -| `type` | Yes | Logical type: text, integer, decimal, boolean, date, datetime, binary | +| `type` | Yes | Logical type: text, integer, decimal, boolean, date, datetime, binary, json | | `nullable` | No | Default false. Opt-in only. | | `default` | No | Logical default value (literal or function name like `now`). Shown in its own column in the UI. | | `desc` | No | Brief purpose of this column — what function it serves, why it exists. Not a repeat of the column name. | diff --git a/docs/guides/folder-format.md b/docs/guides/folder-format.md index daee099..6de774e 100644 --- a/docs/guides/folder-format.md +++ b/docs/guides/folder-format.md @@ -91,6 +91,26 @@ A customer settles invoices with a [[PaymentMethod|payment method]] on file. The target must match an entity id exactly (case-sensitive). A link to an entity that does not exist renders as muted, non-clickable text and is reported as a `body.unknown_link` finding, so a typo never passes silently. See [Validation and findings](validation.md). +### Code in the body + + +A tagged code fence is syntax-highlighted, in entity bodies and flow bodies alike: + +~~~markdown +Selecting a party's usable methods: + +```sql +select payment_method_id, type, label +from PaymentMethod +where party_id = @party_id; +``` +~~~ + +Six languages ship with the viewer — `json`, `sql`, `javascript`, `typescript`, `python`, `bash` — along with their usual fence aliases (`js`, `ts`, `py`, `sh`, `shell`, `zsh`). An untagged fence, or one tagged with a language that is not bundled, renders as plain preformatted text rather than failing. + +Highlighting is applied when the model is parsed, not in the browser, so a static `export` carries it with no extra work. Colours follow the viewer's own light/dark toggle. + + ### Columns @@ -98,7 +118,7 @@ Each column takes a logical `type` and three optional fields. | Field | Default | Meaning | |---|---|---| -| `type` | required | One of `text`, `integer`, `decimal`, `boolean`, `date`, `datetime`, `binary` | +| `type` | required | One of `text`, `integer`, `decimal`, `boolean`, `date`, `datetime`, `binary`, `json` | | `nullable` | `false` | Whether the column accepts null | | `default` | none | A default value note | | `desc` | none | A short note on what the column is for | @@ -136,6 +156,30 @@ examples: The rows render as a collapsible table in the entity dialog and the dictionary. Two or three realistic rows are enough; their job is to make the rules concrete — a sample row that violates a constraint you believe in reveals a modeling error no structural check can catch. Every key must be a real column (or PK column); the live server flags unknown keys with an `entity.example_unknown_column` warning. +### Structured values + + +A `json` column carries a document rather than a scalar. Write the value as nested YAML: + +```yaml +columns: + details: + type: json + nullable: true + desc: "Instrument details, whose shape differs per method type." +examples: + - payment_method_id: 1 + details: + network: visa + last4: "4471" + exp_month: 11 +``` + +The cell renders as a truncated monospace preview with an expander beside it; the expander opens the pretty-printed document in a dialog, layered over the entity dialog when you are already in one. A nested value renders this way on any column, so an author who omits the `json` type still gets a readable cell — declaring it is what additionally lets a value written as a quoted JSON string be recognised as a document. + +Reach for `json` only where the shape is genuinely open-ended. A fixed set of known fields is columns, and a repeating group is a child entity; a `json` column with a stable shape is a modeling miss the viewer cannot help you with. + + ## A group file diff --git a/docs/spec/example-instance-tables.md b/docs/spec/example-instance-tables.md index 31d033e..dbdac9e 100644 --- a/docs/spec/example-instance-tables.md +++ b/docs/spec/example-instance-tables.md @@ -94,6 +94,36 @@ Live-only validation: ## Change log +### 2026-08-21 — Structured (`json`) example values + +**What changed:** `json` (and its `jsonb` spelling) joins the column-type vocabulary, and an example cell holding a structured value now renders as a truncated monospace preview with an expander that opens the pretty-printed document in its own dialog. Added criteria: + +- [ ] A cell whose value is a nested object or array renders via `JsonValue` on any declared type; a cell whose value is a *string* is parsed only when the column declares `json`/`jsonb`. +- [ ] The preview is compact single-line JSON clipped on a character budget (48 default), not a CSS ellipsis — these cells live in horizontally scrolling tables where full text sets the column width from the longest document. +- [ ] The expander's dialog is always `stacked`: from the dictionary it is the only dialog on screen, from an entity or process dialog it layers over the opener. +- [ ] `Modal` keeps a module-level stack and answers ESC only in the top-most instance, so a stacked dialog closes itself and leaves its opener open. +- [ ] Entity and process example tables share one `ExampleCell`, so a structured value renders identically on both surfaces. +- [ ] Formatting degrades rather than throwing on a self-referential value — YAML aliases can reach an ancestor node, and an exception inside a table cell takes the view down. + +**Why:** `String(value)` rendered every nested value as `[object Object]`, so the frontmatter format accepted structured example data that no surface could display. The declared type is what licenses parsing a *string* as a document; recognising nested values unconditionally means an author who omits the type still gets a readable cell. + +**Superseded:** the criterion "Missing values render as a muted en-dash" now also covers the empty string on the dict surface, which previously rendered `''` as an empty cell while the modal surface rendered the en-dash. One `ExampleCell` gives both the en-dash. + +### 2026-08-21 — Syntax highlighting via shiki + +**What changed:** the expanded JSON document is syntax-highlighted, and tagged code fences in entity and flow bodies are too. Added criteria: + +- [ ] Highlighting uses shiki with `createHighlighterCoreSync` and `createJavaScriptRawEngine()` over precompiled grammars — never the default Oniguruma engine, whose WebAssembly payload would break both the single-file `export` and the zero-network guarantee. +- [ ] Two entry points, not one: `src/model/markdown-highlight.ts` (json, sql, javascript, typescript, python, bash) is reached only from `parse.ts` / `flow-parse.ts` and never crosses into the browser bundle; `src/app/logic/json-highlight.ts` loads json alone. +- [ ] Both run with `defaultColor: false`, so tokens carry `--shiki-light` and `--shiki-dark` and commit to neither; `styles.css` resolves them off the `.theme-*` root class so blocks follow the in-app toggle. +- [ ] `highlightCodeFence` returns `''` for an untagged fence, an unbundled language, or a grammar error, handing the block back to markdown-it's default escaping. A body must never fail to render over a code fence. +- [ ] Both producers escape HTML in the source; output is injected via `dangerouslySetInnerHTML` and the source is model data. +- [ ] The JSON document is highlighted only once its dialog opens — a dictionary page can hold hundreds of unexpanded cells. + +**Why:** the split exists because grammars are static imports that no bundler can tree-shake. A single shared module would put roughly 620KB of grammar into the browser bundle; keeping the six-language set on the parse side holds the browser cost to about 140KB, since body markdown reaches the client as already-rendered `bodyHtml`. Measured: browser bundle 4.22MB → 4.36MB. + +**Superseded:** the previous entry's criterion that the expanded document renders in a plain `
` — it is now shiki's `
`, and `.json-value-full` is the scroll container around it rather than the styled block itself.
+
 ### 2026-05-31 — Re-number CP-5 step to E7b (was E5b)
 
 **What changed:** the new examples step in the modeling skill is renamed from **E5b** to **E7b** and re-anchored as "between E7 (Columns) and E8 (Reference table)" instead of "between E5 (columns) and E6 (description)".
diff --git a/docs/wiki/feature-map.md b/docs/wiki/feature-map.md
index 4d8d316..2108caa 100644
--- a/docs/wiki/feature-map.md
+++ b/docs/wiki/feature-map.md
@@ -37,6 +37,8 @@ Paths are relative to `docs/design/`, `docs/spec/`, `docs/guides/`, and `skills/
 | The modeling skill itself | ignatius-modeling-skill | ignatius-modeling-skill | modeling-skill | SKILL + all references |
 | Skill `flow` + `discover` modes (DFD authoring; Socratic business→model discovery, five gates, generates entities + flows; reverse-engineering from existing DB/code/schema in the IDEF1X spirit) | noorm-flow-discovery | noorm-flow-discovery | modeling-skill, flows | SKILL (4-mode router), dfd-authoring, flow-templates, discover-flow, reverse-engineering |
 | Example / sample instance tables | example-instance-tables | example-instance-tables | folder-format (example rows) | entity-flow E7b (`examples:` frontmatter) + templates |
+| `json` column type + structured example cells (truncated monospace preview, expander opens the document in a dialog stacked over its opener; shared `ExampleCell` across entity and process tables) | markdown-driven-erd (type list) | example-instance-tables (change log) | folder-format (structured values) | conventions (type list + authoring note), entity-flow E7 |
+| Syntax highlighting (shiki). Body code fences highlighted at parse time via markdown-it's `highlight` option — six grammars, server-side only; the expanded JSON document highlighted in the browser from a json-only module. Split deliberately: grammars are static imports and cannot be tree-shaken, so one shared module would put ~620KB in the bundle instead of ~140KB | — | example-instance-tables (change log) | folder-format (code in the body) | conventions (bundled languages) |
 | SSADM process flows (DFD): parse, 11 `flow.*` rules, in-app Flows view (unified SPA), recursive data-level balancing, client-side drill-down, separate-key persistence, per-node ⓘ dialog + `[[wiki-link]]` routing; `db:` store opens rich entity dialog; process dictionary fused into Dictionary view ◆ | process-flows | process-flows (+ research `ssadm-dfd-rules`) | flows | — |
 | Unified SPA collapse (Graph / Dictionary / Flows in one app; `export` replaces `dict`/`graph`/`flow`; fused searchable Dictionary; `db:` store → rich entity dialog; shared chrome + theme on DFDs) | unified-app | unified-app | commands, building-from-source | — |
 | DFD polish round 2: no text-select on nodes (CP14); store/external coloring by `kind` (theme-aware, `theme.flowKinds` override) (CP15); per-process in/out data example tables (CP16) | — | dfd-polish-round2, process-flows (examples) | themes-and-branding (kind colors) | — |
diff --git a/docs/wiki/parser.md b/docs/wiki/parser.md
index 68b91f9..507c484 100644
--- a/docs/wiki/parser.md
+++ b/docs/wiki/parser.md
@@ -27,7 +27,8 @@ Reads a model root directory and produces a `Model`: entity nodes, edges, groups
 ## Coupling
 
 - `validate` — two-way type coupling: `parse.ts` imports `GlobalError` from [`src/model/validate.ts`](../../src/model/validate.ts), and `validate.ts` imports `Model`/`ModelNode`/`ModelEdge`/`SubtypeCluster` from `parse.ts`. `model-index.ts` documents mirroring `validate.ts`'s `checkAlternateKeys` (AK column union) and `checkEdgeDanglingFkColumn` (FK column derivation from `edge.on` keys) logic — a change to either derivation must be kept in sync in both files.
-- `flows` — [`src/flows/flow-validate.ts`](../../src/flows/flow-validate.ts) imports `Model` from `parse.ts`; [`src/flows/flow-parse.ts`](../../src/flows/flow-parse.ts) imports `wikiLinkPlugin` from `wikilink.ts` directly (its own markdown-it instance, separate from the one in `parse.ts`).
+- `flows` — [`src/flows/flow-validate.ts`](../../src/flows/flow-validate.ts) imports `Model` from `parse.ts`; [`src/flows/flow-parse.ts`](../../src/flows/flow-parse.ts) imports `wikiLinkPlugin` from `wikilink.ts` directly (its own markdown-it instance, separate from the one in `parse.ts`). Both instances pass the same `highlight` callback, so entity and flow bodies highlight identically.
+- [`src/model/markdown-highlight.ts`](../../src/model/markdown-highlight.ts) — shiki with six precompiled grammars (json, sql, javascript, typescript, python, bash) behind `createJavaScriptRawEngine()`, exported as `highlightCodeFence(code, lang)` and wired into both `MarkdownIt` constructors as the `highlight` option. Returns `''` on an untagged fence, an unbundled language, or a grammar throw, which hands the block back to markdown-it's default escaping. Server-side only: app code imports `parse.ts` for types alone, so neither markdown-it nor these grammars reach the browser bundle — the browser's own highlighter, [`src/app/logic/json-highlight.ts`](../../src/app/logic/json-highlight.ts), loads the json grammar by itself for the same reason.
 - `frontend` ([`src/app/`](../../src/app)) — multiple modules under [`src/app/logic/`](../../src/app/logic) and [`src/app/hooks/`](../../src/app/hooks) import `Model`, `ModelNode`, `ModelEdge`, `Predicate`, `ThemeConfig`, or `SubtypeCluster` as types from `parse.ts`; `spotlight.ts` and `spotlight-inherited.ts` import `ModelIndex` from `model-index.ts`. `GroupConfig` is imported from `parse.ts` only outside `logic/`/`hooks/`, by [`src/app/App.tsx`](../../src/app/App.tsx), [`src/app/components/ui/FabMenu.tsx`](../../src/app/components/ui/FabMenu.tsx), and [`src/app/views/graph/styles.ts`](../../src/app/views/graph/styles.ts).
 - `server` ([`src/server/server.ts`](../../src/server/server.ts)) and `cli` ([`src/cli/cli.ts`](../../src/cli/cli.ts)) both call `parseModels()` directly to produce the `Model` they serve or output.
 - `generators` ([`src/generators/app.ts`](../../src/generators/app.ts)) imports the `Model` type from `parse.ts`.
diff --git a/docs/wiki/skill.md b/docs/wiki/skill.md
index ce99d93..cf1036f 100644
--- a/docs/wiki/skill.md
+++ b/docs/wiki/skill.md
@@ -23,7 +23,7 @@ Writes real files to disk and, after every write, runs the verification loop in
 - [`skills/ignatius-modeling/references/flow-templates.md`](../../skills/ignatius-modeling/references/flow-templates.md) — the three DFD node file templates (process, external entity at `externals/.md`, non-`db` store at `stores/.md`) plus worked examples (`Collect-Payment.md`, `Customer.md`, `gateway-log.md`); states its frontmatter keys and endpoint tokens match [`docs/spec/process-flows.md`](../spec/process-flows.md).
 - [`skills/ignatius-modeling/references/discover-flow.md`](../../skills/ignatius-modeling/references/discover-flow.md) — `discover` mode: verbs-first shape (find the verbs, derive the nouns, write nouns then verbs), the five gates (Identify, Decide, Justify, Derive, Ground) as plain-English questions, a `` block banning the gates' underlying formal-logic names (excluded middle, law of identity, non-contradiction, sufficient reason, four causes, three-valued logic, falsifiable, syllogism, a priori, ontology) from ever reaching the user, and "crystallize as you go" incremental file-writing.
 - [`skills/ignatius-modeling/references/reverse-engineering.md`](../../skills/ignatius-modeling/references/reverse-engineering.md) — extracting a model from an existing system (live DB/DDL, ORM models, codebase, stored procedures, API spec, sample data) in five IDEF1X-spirit phases R0–R4; explicitly faithful-first ("never silently 'fix' " an anti-pattern during extraction); feeds candidates back through `discover-flow.md`'s five gates.
-- [`skills/ignatius-modeling/references/conventions.md`](../../skills/ignatius-modeling/references/conventions.md) — column type list (`text`, `integer`, `decimal`, `boolean`, `date`, `datetime`, `binary`), column-property table, and the classification/cardinality derivation tables the parser applies (read-only reference — the skill never asks the user for these).
+- [`skills/ignatius-modeling/references/conventions.md`](../../skills/ignatius-modeling/references/conventions.md) — column type list (`text`, `integer`, `decimal`, `boolean`, `date`, `datetime`, `binary`, `json`), column-property table, and the classification/cardinality derivation tables the parser applies (read-only reference — the skill never asks the user for these).
 - [`skills/ignatius-modeling/references/templates.md`](../../skills/ignatius-modeling/references/templates.md) — the entity `.md`, `groups/.md`, and `ignatius.yml` templates, plus worked key-inherited, orm-oriented, business-context, and subtype-cluster (base + member) examples.
 - [`skills/ignatius-modeling/references/verification.md`](../../skills/ignatius-modeling/references/verification.md) — the `ignatius validate` loop: stderr line format, the full entity/parse/edge/cluster/body rule table and the `flow.*` rule table (each row mapped back to the authoring step that produced the finding), retry policy (max 5 attempts, prefilled re-ask rather than blind rewrite), and the post-clean-validate self-check (business context captured, predicates read as true sentences, example rows checked against `pk ∪ columns` since `entity.example_unknown_column` is live-server-only and `validate` never prints it).
 
diff --git a/models/key-inherited/data/transactional/PaymentMethod.md b/models/key-inherited/data/transactional/PaymentMethod.md
index 77d0cb9..1fdc0ac 100644
--- a/models/key-inherited/data/transactional/PaymentMethod.md
+++ b/models/key-inherited/data/transactional/PaymentMethod.md
@@ -17,15 +17,31 @@ columns:
   label:
     type: text
     desc: "User-facing label (e.g. 'Visa ending 4242')."
+  details:
+    type: json
+    nullable: true
+    desc: "Instrument details, whose shape is set by the gateway and differs per type — no two method types carry the same fields."
 examples:
   - party_id: 2
     payment_method_id: 1
     type: CREDIT_CARD
     label: Visa ending 4471
+    details:
+      network: visa
+      last4: "4471"
+      exp_month: 11
+      exp_year: 2028
+      billing_zip: "33139"
+      wallet: apple_pay
   - party_id: 1
     payment_method_id: 1
     type: BANK_TRANSFER
     label: Silicon Valley Bank ••1847
+    details:
+      account_type: checking
+      routing_last4: "0114"
+      account_last4: "1847"
+      verified_by: micro_deposits
   - party_id: 3
     payment_method_id: 1
     type: CHECK
@@ -46,3 +62,12 @@ relationships:
 A **PaymentMethod** is a means by which a Party can pay — a card, bank account, or check on file. It belongs to the party that holds it and is classified by a `PaymentMethodType`.
 
 It exists as a stored, reusable record so a party can pay repeatedly without re-entering details, and so each `Payment` can point at exactly the instrument that settled it.
+
+Selecting a party's usable methods:
+
+```sql
+select payment_method_id, type, label
+from PaymentMethod
+where party_id = @party_id
+order by payment_method_id;
+```
diff --git a/package.json b/package.json
index 14f0232..e026af1 100644
--- a/package.json
+++ b/package.json
@@ -27,6 +27,8 @@
   },
   "dependencies": {
     "@clack/prompts": "^1.5.0",
+    "@shikijs/langs-precompiled": "^4.4.3",
+    "@shikijs/themes": "^4.4.3",
     "citty": "^0.2.2",
     "cytoscape": "^3.31.0",
     "cytoscape-elk": "^2.2.0",
@@ -36,6 +38,7 @@
     "markdown-it": "^14.1.0",
     "react": "^19",
     "react-dom": "^19",
+    "shiki": "^4.4.3",
     "web-worker": "^1.5.0",
     "yaml": "^2.8.4"
   },
diff --git a/skills/ignatius-modeling/references/conventions.md b/skills/ignatius-modeling/references/conventions.md
index 85e2e46..122733c 100644
--- a/skills/ignatius-modeling/references/conventions.md
+++ b/skills/ignatius-modeling/references/conventions.md
@@ -2,7 +2,26 @@
 
 ### Column types
 
-`text`, `integer`, `decimal`, `boolean`, `date`, `datetime`, `binary`
+`text`, `integer`, `decimal`, `boolean`, `date`, `datetime`, `binary`, `json`
+
+`json` holds a structured document. Reach for it only when the shape is genuinely open-ended — a fixed set of known fields is columns, and a repeating group is a child entity. A `json` column that turns out to have a stable shape is a modeling miss, not a shortcut.
+
+Write `json` example values as nested YAML. A quoted JSON string parses too, but nested YAML is what the rest of the file already reads like:
+
+    examples:
+      - account_id: 1
+        settings:
+          theme: dark
+          digest: weekly
+          muted_tags: [billing, marketing]
+
+### Code in bodies
+
+A tagged code fence in an entity or flow body is syntax-highlighted by the viewer. Bundled languages:
+
+`json`, `sql`, `javascript`, `typescript`, `python`, `bash` — plus the aliases `js`, `ts`, `py`, `sh`, `shell`, `zsh`
+
+Tag every fence you write; an untagged one, or one tagged with a language outside that set, renders as plain preformatted text. Prefer `sql` for the query snippets that most often earn a place in an entity body.
 
 ### Column properties
 
diff --git a/skills/ignatius-modeling/references/entity-flow.md b/skills/ignatius-modeling/references/entity-flow.md
index c0cf4e3..8715fcc 100644
--- a/skills/ignatius-modeling/references/entity-flow.md
+++ b/skills/ignatius-modeling/references/entity-flow.md
@@ -175,7 +175,7 @@ If yes, collect: rule name + columns array. Repeat for each AK.
 
 Ask: "List the non-PK columns. For each: name, type, nullable? (default false), optional default, optional desc."
 
-Valid types: `text`, `integer`, `decimal`, `boolean`, `date`, `datetime`, `binary`
+Valid types: `text`, `integer`, `decimal`, `boolean`, `date`, `datetime`, `binary`, `json`
 
 Note: PK columns must also appear in `columns` with their types.
 
diff --git a/src/app/components/entity/ExamplesAccordion.tsx b/src/app/components/entity/ExamplesAccordion.tsx
index ba87635..8772ac2 100644
--- a/src/app/components/entity/ExamplesAccordion.tsx
+++ b/src/app/components/entity/ExamplesAccordion.tsx
@@ -1,4 +1,5 @@
 import type { ModelNode } from '../../../model/parse';
+import { ExampleCell } from '../ui/ExampleCell';
 
 export function ExamplesAccordion({ node, variant = 'modal' }: { node: ModelNode; variant?: 'modal' | 'dict' }) {
   const examples = node.examples;
@@ -10,6 +11,15 @@ export function ExamplesAccordion({ node, variant = 'modal' }: { node: ModelNode
   const headers = [...node.pk, ...declaredCols];
   const isOpen = examples.length <= 3;
 
+  const cell = (row: Record, h: string, emptyClassName: string) => (
+    
+  );
+
   if (variant === 'dict') {
     return (
       
@@ -23,11 +33,7 @@ export function ExamplesAccordion({ node, variant = 'modal' }: { node: ModelNode {examples.map((row, i) => ( {headers.map(h => ( - - {row[h] !== undefined && row[h] !== null - ? String(row[h]) - : } - + {cell(row, h, 'dict-example-empty')} ))} ))} @@ -52,12 +58,7 @@ export function ExamplesAccordion({ node, variant = 'modal' }: { node: ModelNode {examples.map((row, i) => ( {headers.map(h => ( - - {row[h] !== undefined && row[h] !== null && row[h] !== '' - ? String(row[h]) - : - } - + {cell(row, h, 'example-empty')} ))} ))} diff --git a/src/app/components/process/ProcessExamples.tsx b/src/app/components/process/ProcessExamples.tsx index db4daff..a5e8d83 100644 --- a/src/app/components/process/ProcessExamples.tsx +++ b/src/app/components/process/ProcessExamples.tsx @@ -1,4 +1,5 @@ import type { FlowProcess, FlowExample } from '../../../flows/flow-parse'; +import { ExampleCell } from '../ui/ExampleCell'; /** * Renders the `examples: { in, out }` data from a FlowProcess as a series of @@ -47,10 +48,7 @@ export function ProcessExamples({ examples }: { examples: FlowProcess['examples' {cols.map(c => ( - {row[c] !== undefined && row[c] !== null && row[c] !== '' - ? String(row[c]) - : - } + ))} diff --git a/src/app/components/ui/ExampleCell.tsx b/src/app/components/ui/ExampleCell.tsx new file mode 100644 index 0000000..4e6ae32 --- /dev/null +++ b/src/app/components/ui/ExampleCell.tsx @@ -0,0 +1,26 @@ +import { asJsonValue } from '../../logic/json-value'; +import { JsonValue } from './JsonValue'; + +/** + * One cell of an example row, shared by the entity accordions and the process + * in/out tables so a structured value renders the same wherever it surfaces. + * + * `columnType` is the declared type when the caller has a column definition to + * hand; process example rows are free-form, so they omit it and only nested + * values are recognised. + */ +export function ExampleCell({ value, columnType, label, emptyClassName }: { + value: unknown; + columnType?: string; + label: string; + emptyClassName: string; +}) { + if (value === undefined || value === null || value === '') { + return ; + } + + const json = asJsonValue(value, columnType); + if (json !== null) return ; + + return <>{String(value)}; +} diff --git a/src/app/components/ui/JsonValue.tsx b/src/app/components/ui/JsonValue.tsx new file mode 100644 index 0000000..ab4999a --- /dev/null +++ b/src/app/components/ui/JsonValue.tsx @@ -0,0 +1,54 @@ +import { useMemo, useState } from 'react'; +import { describeJson, formatJsonFull, formatJsonPreview } from '../../logic/json-value'; +import { highlightJson } from '../../logic/json-highlight'; +import { Modal } from './Modal'; + +/** + * A structured value inside a table cell: monospaced one-line preview plus an + * expander that opens the pretty-printed document in its own dialog. + * + * The dialog is always `stacked`, which covers both callers without either of + * them having to know where it sits — from the dictionary page it is the only + * modal on screen, and from an entity or process dialog it layers over the + * opener. It renders inline rather than through a portal so that a click on its + * backdrop is caught by the opener's stop-propagation handler. + */ +export function JsonValue({ value, label }: { value: object; label: string }) { + const [expanded, setExpanded] = useState(false); + + // Only pay for highlighting once the dialog is actually open — a dictionary + // page can carry hundreds of these cells, none of them expanded. + const highlighted = useMemo( + () => (expanded ? highlightJson(formatJsonFull(value)) : ''), + [expanded, value], + ); + + return ( + + {formatJsonPreview(value)} + + {expanded && ( + setExpanded(false)} + className="modal-json" + stacked + headerExtra={{describeJson(value)}} + > +
+ + )} + + ); +} diff --git a/src/app/components/ui/Modal.tsx b/src/app/components/ui/Modal.tsx index 22216b2..4f2ff6a 100644 --- a/src/app/components/ui/Modal.tsx +++ b/src/app/components/ui/Modal.tsx @@ -1,34 +1,65 @@ import { useEffect, useRef } from 'react'; import type { ReactNode } from 'react'; +/** + * Open modals, outermost first. Every instance listens for ESC on `document`, + * so without this only-the-top-entry check a stacked dialog would dismiss the + * modal that opened it along with itself. + * + * Backdrop clicks need no equivalent guard: a stacked backdrop renders inside + * its opener's `.modal`, whose own handler already stops propagation. + */ +const modalStack: object[] = []; + /** * Shared modal primitive. Owns the backdrop, stop-propagation, close button, * header with title, and ONE ESC keydown listener (added/removed on mount). * All four dialog variants render their content as children. * `headerExtra` renders inside `.modal-header` after the `

` (for badges etc.). + * `stacked` marks a dialog opened from within another one — it lifts the + * backdrop above the opener's. */ -export function Modal({ title, onClose, children, className, headerExtra }: { +export function Modal({ title, onClose, children, className, headerExtra, stacked }: { title: string; onClose: () => void; children: ReactNode; className?: string; headerExtra?: ReactNode; + stacked?: boolean; }) { const closeRef = useRef(null); + // Read through a ref so the listener effect can stay mount-only. Keying it on + // `onClose` instead would re-push this modal's stack entry on every render + // that passes a fresh closure, handing ESC back to an opener that a stacked + // dialog is currently covering. + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; + useEffect(() => { // Focus the close button so keyboard users can dismiss immediately. closeRef.current?.focus(); + const entry = {}; + modalStack.push(entry); + function onKeyDown(e: KeyboardEvent) { - if (e.key === 'Escape') onClose(); + if (e.key !== 'Escape') return; + if (modalStack[modalStack.length - 1] !== entry) return; + onCloseRef.current(); } document.addEventListener('keydown', onKeyDown); - return () => document.removeEventListener('keydown', onKeyDown); - }, [onClose]); + return () => { + document.removeEventListener('keydown', onKeyDown); + const i = modalStack.indexOf(entry); + if (i !== -1) modalStack.splice(i, 1); + }; + }, []); + + const backdropClass = stacked ? 'modal-backdrop modal-backdrop-stacked' : 'modal-backdrop'; return ( -
+
e.stopPropagation()}>
diff --git a/src/app/logic/json-highlight.ts b/src/app/logic/json-highlight.ts new file mode 100644 index 0000000..99f028a --- /dev/null +++ b/src/app/logic/json-highlight.ts @@ -0,0 +1,41 @@ +import jsonLang from '@shikijs/langs-precompiled/json'; +import githubDark from '@shikijs/themes/github-dark'; +import githubLight from '@shikijs/themes/github-light'; +import { createHighlighterCoreSync } from 'shiki/core'; +import { createJavaScriptRawEngine } from 'shiki/engine/javascript'; + +/** + * Shiki, assembled by hand rather than through a bundled preset. + * + * Three choices keep this out of the way of how the app ships: + * + * - The *raw* JavaScript engine over a *precompiled* grammar. The default + * engine is Oniguruma, which loads a WebAssembly blob — `export` has to + * produce one self-contained HTML file that makes no network request, and a + * `.wasm` fetch would break both. + * - `createHighlighterCoreSync`, because the render path is synchronous React. + * - `defaultColor: false`, so every token carries `--shiki-light` and + * `--shiki-dark` custom properties and picks neither. `styles.css` resolves + * them off the existing `.theme-*` root class, which is what keeps the + * dialog on the app's own theme switch instead of shiki's. + */ +const highlighter = createHighlighterCoreSync({ + themes: [githubLight, githubDark], + langs: [jsonLang], + engine: createJavaScriptRawEngine(), +}); + +/** + * Highlighted markup for a pretty-printed JSON document. + * + * Shiki escapes the source, so the result is safe to inject — `test-json-highlight` + * pins that, since the input is model data and the output goes through + * `dangerouslySetInnerHTML`. + */ +export function highlightJson(code: string): string { + return highlighter.codeToHtml(code, { + lang: 'json', + themes: { light: 'github-light', dark: 'github-dark' }, + defaultColor: false, + }); +} diff --git a/src/app/logic/json-value.ts b/src/app/logic/json-value.ts new file mode 100644 index 0000000..cb45619 --- /dev/null +++ b/src/app/logic/json-value.ts @@ -0,0 +1,73 @@ +/** + * Recognition and formatting for structured (JSON) example values. + * + * Kept free of React so the detection and truncation rules are testable in a + * plain check without a DOM. + */ + +const JSON_TYPES = new Set(['json', 'jsonb']); + +/** True when a declared column `type` denotes a structured document. */ +export function isJsonType(type: string | undefined): boolean { + if (type === undefined) return false; + return JSON_TYPES.has(type.trim().toLowerCase()); +} + +/** + * Resolves an example cell to the structured value it should render as, or + * `null` when the cell is an ordinary scalar. + * + * Two independent triggers, because authors arrive here two ways: a nested YAML + * map or list parses straight into an object whatever the declared type says, + * while a `json` column written as a quoted string arrives as text that still + * holds a document. A quoted string on a non-`json` column stays text — the + * declared type is what licenses parsing it. + */ +export function asJsonValue(raw: unknown, columnType?: string): object | null { + if (typeof raw === 'object' && raw !== null) return raw; + if (typeof raw !== 'string' || !isJsonType(columnType)) return null; + + const trimmed = raw.trim(); + if (trimmed[0] !== '{' && trimmed[0] !== '[') return null; + try { + const parsed: unknown = JSON.parse(trimmed); + return typeof parsed === 'object' && parsed !== null ? parsed : null; + } catch { + return null; + } +} + +/** Pretty-printed form for the expanded view. */ +export function formatJsonFull(value: object): string { + try { + return JSON.stringify(value, null, 2) ?? ''; + } catch { + return String(value); + } +} + +/** + * Single-line preview for a table cell, clipped on a character budget rather + * than a CSS ellipsis: these cells sit in horizontally scrolling tables, where + * full text would set the column width from the longest document in the table + * even while it renders visually truncated. + */ +export function formatJsonPreview(value: object, maxLength = 48): string { + let compact: string; + try { + compact = JSON.stringify(value) ?? ''; + } catch { + return String(value); + } + if (compact.length <= maxLength) return compact; + return `${compact.slice(0, Math.max(1, maxLength - 1))}…`; +} + +/** Short shape summary, shown beside the column name in the expanded view. */ +export function describeJson(value: object): string { + if (Array.isArray(value)) { + return `${value.length} item${value.length === 1 ? '' : 's'}`; + } + const count = Object.keys(value).length; + return `${count} key${count === 1 ? '' : 's'}`; +} diff --git a/src/app/styles.css b/src/app/styles.css index 664fa41..d621798 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -431,6 +431,12 @@ a { z-index: 100; } +/* A dialog opened from inside another one. It renders within the opener's + `.modal`, so it needs to clear that subtree's stacking order to sit on top. */ +.modal-backdrop-stacked { + z-index: 110; +} + .modal { position: relative; background: var(--color-background); @@ -1889,6 +1895,101 @@ code { font-size: 0.85em; } +/* ── Structured (json) example values ── */ + +.json-value { + display: inline-flex; + align-items: baseline; + gap: 6px; + max-width: 100%; +} + +/* Truncation happens in JS on a character budget, so this only has to keep the + preview on one line. Ligatures are off because `-->`, `=>` and `!=` inside a + JSON string kern into glyphs that misrepresent the underlying text. */ +.json-value-preview { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-variant-ligatures: none; + font-feature-settings: "calt" 0; + font-size: 0.85em; + white-space: nowrap; + color: var(--color-text-muted); +} + +.json-value-expand { + flex: none; + background: none; + border: 1px solid var(--color-border); + border-radius: 4px; + color: var(--color-text-muted); + cursor: pointer; + font-size: 0.7em; + line-height: 1; + padding: 2px 4px; +} + +.json-value-expand:hover { + color: var(--color-text); + border-color: var(--color-text-muted); +} + +.json-value-shape { + color: var(--color-text-muted); + font-size: 12px; +} + +.modal-json { + max-width: 860px; +} + +/* ── Highlighted code blocks (shiki) ── + Two producers, one appearance: body code fences, highlighted at parse time + by src/model/markdown-highlight.ts, and the expanded JSON document, + highlighted in the browser by src/app/logic/json-highlight.ts. */ + +.shiki { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-variant-ligatures: none; + font-feature-settings: "calt" 0; + font-size: 13px; + line-height: 1.5; + margin: 0; + overflow-x: auto; + padding: 16px; + background: var(--color-surface-alt); + border: 1px solid var(--color-border); + border-radius: 8px; + tab-size: 2; +} + +/* The global `code` rule paints a background and padding for inline snippets. + Inside a block it repeats per line box and stripes the document. */ +.shiki code { + background: none; + padding: 0; + border-radius: 0; + font: inherit; +} + +/* Shiki runs with defaultColor:false, so every token carries both theme values + as custom properties and commits to neither. The app's own theme class picks + one, which is what keeps these blocks on the in-app theme toggle rather than + on the OS colour scheme. */ +.theme-light .shiki, +.theme-light .shiki span { + color: var(--shiki-light); +} + +.theme-dark .shiki, +.theme-dark .shiki span { + color: var(--shiki-dark); +} + +.json-value-full .shiki { + max-height: 60vh; + overflow: auto; +} + /* ── CP7: Entity ↔ Process cross-reference ── */ /* DD processes table (appears after examples in DictEntitySection) */ diff --git a/src/flows/flow-parse.ts b/src/flows/flow-parse.ts index 8f04e46..8524db3 100644 --- a/src/flows/flow-parse.ts +++ b/src/flows/flow-parse.ts @@ -19,10 +19,11 @@ import { parse as parseYaml } from 'yaml'; import MarkdownIt from 'markdown-it'; import type { GlobalError } from '../model/validate'; import { wikiLinkPlugin } from '../model/wikilink'; +import { highlightCodeFence } from '../model/markdown-highlight'; import { titlelize } from './titlelize'; import { deriveLevels } from './flow-derive-levels'; -const md = new MarkdownIt(); +const md = new MarkdownIt({ highlight: highlightCodeFence }); // `[[Target]]` links in flow markdown (process / external / store bodies) render // as `a.entity-link[data-entity]` anchors, same as ERD entity bodies. Rendered // optimistically (no knownIds) — every target becomes a navigable anchor and the diff --git a/src/model/markdown-highlight.ts b/src/model/markdown-highlight.ts new file mode 100644 index 0000000..c99b194 --- /dev/null +++ b/src/model/markdown-highlight.ts @@ -0,0 +1,54 @@ +import bash from '@shikijs/langs-precompiled/bash'; +import javascript from '@shikijs/langs-precompiled/javascript'; +import json from '@shikijs/langs-precompiled/json'; +import python from '@shikijs/langs-precompiled/python'; +import sql from '@shikijs/langs-precompiled/sql'; +import typescript from '@shikijs/langs-precompiled/typescript'; +import githubDark from '@shikijs/themes/github-dark'; +import githubLight from '@shikijs/themes/github-light'; +import { createHighlighterCoreSync } from 'shiki/core'; +import { createJavaScriptRawEngine } from 'shiki/engine/javascript'; + +/** + * Code-fence highlighting for entity and process body markdown. + * + * Deliberately separate from the browser's `src/app/logic/json-highlight.ts`, + * which loads the json grammar alone. Grammars are static imports and cannot be + * tree-shaken, so one shared module would drag all six into the browser bundle + * — about 620KB against the ~130KB the popup actually needs. This module runs + * only where `parse.ts` runs: the dev server and the CLI, both of which reach + * the browser as already-rendered `bodyHtml`. App code imports `parse.ts` for + * types alone, so none of this crosses into the bundle. + * + * See `json-highlight.ts` for why the engine, the sync constructor, and + * `defaultColor: false` are what they are — the reasoning is identical. + */ +const highlighter = createHighlighterCoreSync({ + themes: [githubLight, githubDark], + langs: [json, sql, javascript, typescript, python, bash], + engine: createJavaScriptRawEngine(), +}); + +const loaded = new Set(highlighter.getLoadedLanguages()); + +/** + * markdown-it `highlight` callback. Returns a complete `
` block, which
+ * tells markdown-it to emit it verbatim rather than wrapping it again.
+ *
+ * An empty string hands the fence back to markdown-it's default escaping —
+ * the right answer for an unfenced block, an unbundled language, or a grammar
+ * that chokes on the snippet. A body must never fail to render because a code
+ * fence was tagged with something we don't carry.
+ */
+export function highlightCodeFence(code: string, lang: string): string {
+  if (lang === '' || !loaded.has(lang)) return '';
+  try {
+    return highlighter.codeToHtml(code, {
+      lang,
+      themes: { light: 'github-light', dark: 'github-dark' },
+      defaultColor: false,
+    });
+  } catch {
+    return '';
+  }
+}
diff --git a/src/model/parse.ts b/src/model/parse.ts
index 6fcaa81..1e82bee 100644
--- a/src/model/parse.ts
+++ b/src/model/parse.ts
@@ -4,9 +4,10 @@ import MarkdownIt from 'markdown-it';
 import { defaultTheme, mergeTheme, type ThemeConfig } from '../theme/theme-defaults';
 import { defaultBranding, mergeBranding, inlineBrandingLogos, type Branding } from '../theme/branding-defaults';
 import { wikiLinkPlugin, type WikiLinkEnv } from './wikilink';
+import { highlightCodeFence } from './markdown-highlight';
 import type { GlobalError } from './validate';
 
-const md = new MarkdownIt();
+const md = new MarkdownIt({ highlight: highlightCodeFence });
 md.use(wikiLinkPlugin);
 
 function isRecord(v: unknown): v is Record {
diff --git a/test/checks/test-code-highlight.ts b/test/checks/test-code-highlight.ts
new file mode 100644
index 0000000000000000000000000000000000000000..ba0cdb1c094aa003ba3a0a0c7a1ac1b152f095a2
GIT binary patch
literal 4177
zcmbVQ?Q$D66y>*{;x;hdb#d0DUq2jNrW6&D@0CQWSZ>gax)bMIA$4<7XB0R`#9X>JR7x={0l#=npSPk;XY2VF_0W=eXR
z+OQzMP)kM91h=NjDieH${S5JDWvOzusB`H&$!p~USwmIrD(mHlCf?=45=ZD^DcrKK
zD|2wzgmU2=ALP|F1J0c&@unwX3Ualn;Bn4GU1`i8KUOJdKXz)c<4Yi%CeE8Pe
z5AYdtdjvlvp{or&po^ZNjj$3b^Wc2NuS3gio
zA#ZDkjKLJ?b5NzADbkE^Wc0dW5FRP>R_5%>l@dFX3UEL=TYJ6!S%#O!NXNj9mF&$P
zI!cKO5kzam(~Q1hrg%6b@6j%fTy@rjOvJWT|R=?uHr&OY56k12`ZM3Zlxzx*LR
z>yIe;WW$ed5|pCI&RtEIuowOF-c1bC!x$zM(p=7J9c9cS9)LzD4k#H*1Ia?ib22PE
znt~rogfWumXw;IqWtSZ4BLAH_q0Fq#m;5F9UoMVsx%lvyi+9zo0V+dVs0dlUBNC5d
zBm}6BoW%|S9eAUv3gy}dg}UfusGUK9m1Pyy+-z3X`OrfpVF}3<5(6W=JnwP$<6Q0z
z+es!(K1wntD=9+y^l_g)rDSuFa&RKmI5TKM0rKSiTiD=gpx*RaZrqFO*b@`}2+uynr4xt*=des3N3V-o=+juPC`Z~LXjJ8jyAijOgSyIPwRXKmDXjgb
z4vdd_>ZL{nqqB2%j}?Sd_dBEbI~)0XnrwbCL?_~8L}$O48{W~8=7kX@+8?^?WmM=z
z5?deOccxqp=who{US!nEONa{10qQ=G7;rLWa$OhFr^!Lr#+anCy4hsk*H{i>$^<~I_qDrH
zS8(|KsaNFV2%by?@U&qEl4v>wxvH5|5l*Sj=SHr1h$J&oFHUc_z6A#(vO0<-N3
zZ$f~9>Z%parWqDr@x9Gs92=8Z3B1rujp?n)Fy7DhjbKB~n7ZE>UufXcBtG=RrCgsX
zgKLGtJiOn$Qem-GY-q46^^8Q(m3%`gJ0pfX3ZbD`S5NVeizUkq*W1tz&W%<;CA
zTbC578V^N3iFFBEUP;U>hzr} 20`);
+assert(preview.endsWith('…'), `FAIL(7): clipped preview should end in an ellipsis, got ${preview}`);
+assert(!preview.includes('\n'), 'FAIL(7): preview should be single-line');
+
+const small = { a: 1 };
+assert(
+  formatJsonPreview(small, 20) === '{"a":1}',
+  'FAIL(7): a value inside the budget should render whole and unclipped',
+);
+
+// 8 — full form is pretty-printed
+const full = formatJsonFull({ a: { b: 1 } });
+assert(full.includes('\n'), 'FAIL(8): full form should be multi-line');
+assert(full.includes('  "a"'), `FAIL(8): full form should use 2-space indent, got:\n${full}`);
+
+// 9 — shape summary
+assert(describeJson({ a: 1, b: 2 }) === '2 keys', 'FAIL(9): expected "2 keys"');
+assert(describeJson({ a: 1 }) === '1 key', 'FAIL(9): expected singular "1 key"');
+assert(describeJson([1, 2, 3]) === '3 items', 'FAIL(9): expected "3 items"');
+assert(describeJson(['only']) === '1 item', 'FAIL(9): expected singular "1 item"');
+assert(describeJson([]) === '0 items', 'FAIL(9): expected "0 items"');
+
+// 10 — a self-referential value must not throw out of a table cell. YAML
+// anchors can alias back into an ancestor node, so this is reachable from a
+// model file, not just from hand-built objects.
+const circular: Record = { name: 'loop' };
+circular['self'] = circular;
+let threw = false;
+try {
+  formatJsonPreview(circular);
+  formatJsonFull(circular);
+} catch {
+  threw = true;
+}
+assert(!threw, 'FAIL(10): formatting a circular value should degrade, not throw');
+
+console.log('test-json-value: OK');
diff --git a/test/visual/screenshot-json-values.ts b/test/visual/screenshot-json-values.ts
new file mode 100644
index 0000000..d4122ce
--- /dev/null
+++ b/test/visual/screenshot-json-values.ts
@@ -0,0 +1,123 @@
+/**
+ * Visual verification: structured (`json`) example values and highlighted code.
+ *
+ * Serves models/key-inherited, whose PaymentMethod entity carries a `json`
+ * column (`details`) with two populated rows and one null, plus a ```sql fence
+ * in its body. Walks both themes and captures, per theme:
+ *
+ *   - tmp/json--dict-cell.png      dictionary table, truncated previews
+ *   - tmp/json--body-fence.png     highlighted sql fence in the body
+ *   - tmp/json--stacked-modal.png  expanded over the entity modal
+ *
+ * The stacked capture is the one worth looking at: the JSON dialog must sit
+ * above the entity dialog, and the entity dialog must still be there behind it.
+ *
+ * The two highlight paths are deliberately different — body fences are
+ * highlighted at parse time on the server, the JSON document in the browser —
+ * so this script is what proves they land on the same theme colours.
+ *
+ * NOT run by `bun run test` — manual only.
+ */
+
+import { chromium, type Page } from 'playwright';
+import { resolve, join } from 'path';
+import { mkdirSync } from 'fs';
+import { serveCommand } from '../../src/server/server';
+
+const ROOT = resolve(import.meta.dir, '../..');
+const MODEL = join(ROOT, 'models', 'key-inherited');
+const TMP = join(ROOT, 'tmp');
+mkdirSync(TMP, { recursive: true });
+
+const PORT = 3296;
+const BASE = `http://localhost:${PORT}`;
+
+let ok = true;
+const note = (m: string) => console.log(m);
+const fail = (m: string) => { console.error('FAIL:', m); ok = false; };
+
+const handle = serveCommand(MODEL, { port: PORT });
+await Bun.sleep(400);
+
+const browser = await chromium.launch();
+
+/** Resolved token colour of the first highlighted span, to prove the theme took. */
+async function firstTokenColor(page: Page, selector: string): Promise {
+  return page.evaluate((sel) => {
+    const span = document.querySelector(`${sel} span[style*="--shiki"]`);
+    return span === null ? '' : getComputedStyle(span).color;
+  }, selector);
+}
+
+async function walk(theme: 'light' | 'dark') {
+  const page = await browser.newPage({ viewport: { width: 1440, height: 950 } });
+  try {
+    await page.goto(`${BASE}/#view=dict`);
+    await page.evaluate((t) => localStorage.setItem('ignatius-theme', t), theme);
+    await page.reload();
+    await page.waitForSelector('.json-value-preview', { timeout: 20_000 });
+
+    const previews = await page.locator('.json-value-preview').count();
+    if (previews === 0) fail(`${theme}: no .json-value-preview rendered`);
+
+    const preview = await page.locator('.json-value-preview').first().innerText();
+    if (preview.length > 48) fail(`${theme}: preview exceeded the 48-char budget (${preview.length})`);
+
+    await page.locator('.json-value-preview').first().scrollIntoViewIfNeeded();
+    await page.waitForTimeout(300);
+    await page.screenshot({ path: join(TMP, `json-${theme}-dict-cell.png`) });
+    note(`Saved tmp/json-${theme}-dict-cell.png (${previews} structured cells)`);
+
+    // Body code fence — highlighted at parse time, so it is already in bodyHtml.
+    const fence = page.locator('.shiki').first();
+    if (await fence.count() === 0) fail(`${theme}: no highlighted code fence in any body`);
+    else {
+      await fence.scrollIntoViewIfNeeded();
+      await page.waitForTimeout(300);
+      await page.screenshot({ path: join(TMP, `json-${theme}-body-fence.png`) });
+      const color = await firstTokenColor(page, '.shiki');
+      if (color === '' || color === 'rgb(0, 0, 0)') fail(`${theme}: fence tokens have no resolved colour (${color})`);
+      else note(`Saved tmp/json-${theme}-body-fence.png — first token colour ${color}`);
+    }
+
+    // Graph surface: the expander stacks a dialog over the entity dialog.
+    await page.goto(`${BASE}/#view=graph&entity=PaymentMethod`);
+    await page.waitForSelector('.modal-backdrop', { timeout: 20_000 });
+    const examples = page.locator('.modal-backdrop .modal-examples');
+    await examples.first().waitFor({ state: 'visible', timeout: 20_000 });
+
+    const inModal = page.locator('.modal-backdrop .modal-examples .json-value-expand');
+    if (await inModal.count() === 0) { fail(`${theme}: entity modal has no JSON expander`); return; }
+    await inModal.first().click();
+    await page.waitForSelector('.modal-backdrop-stacked', { timeout: 5_000 });
+    await page.waitForTimeout(400);
+    await page.screenshot({ path: join(TMP, `json-${theme}-stacked-modal.png`) });
+
+    const jsonColor = await firstTokenColor(page, '.json-value-full .shiki');
+    if (jsonColor === '') fail(`${theme}: JSON document tokens are not highlighted`);
+    else note(`Saved tmp/json-${theme}-stacked-modal.png — first token colour ${jsonColor}`);
+
+    // The opener must survive, and ESC must peel exactly one layer.
+    if (await page.locator('.modal-backdrop').count() < 2) {
+      fail(`${theme}: entity modal did not stay open behind the JSON dialog`);
+    }
+    await page.keyboard.press('Escape');
+    await page.waitForTimeout(400);
+    if (await page.locator('.modal-backdrop-stacked').count() !== 0) fail(`${theme}: ESC did not close the stacked dialog`);
+    if (await examples.count() === 0) fail(`${theme}: ESC closed the entity modal too`);
+    note(`${theme}: ESC peeled exactly one layer`);
+  } catch (err) {
+    fail(`${theme}: ${String(err)}`);
+  } finally {
+    await page.close();
+  }
+}
+
+await walk('dark');
+await walk('light');
+
+await browser.close();
+handle.stop();
+
+console.log(ok ? 'screenshot-json-values: OK' : 'screenshot-json-values: FAILURES ABOVE');
+process.exit(ok ? 0 : 1);