diff --git a/package-lock.json b/package-lock.json
index 4ef8d301..2b8abdf9 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1119,6 +1119,106 @@
"linux"
]
},
+ "node_modules/@shikijs/core": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.4.3.tgz",
+ "integrity": "sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg==",
+ "license": "MIT",
+ "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"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@shikijs/engine-javascript": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.4.3.tgz",
+ "integrity": "sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@shikijs/types": "4.4.3",
+ "@shikijs/vscode-textmate": "^10.0.2",
+ "oniguruma-to-es": "^4.3.6"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@shikijs/engine-oniguruma": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.4.3.tgz",
+ "integrity": "sha512-EcOQkxdxGQrc1Row/cC2c96/v1dbZqGnEVu1qTuT/MJmp6+cXCvQussowVmCv5Tqr3KuY3c7IbM6HTW3LJ1k9w==",
+ "license": "MIT",
+ "dependencies": {
+ "@shikijs/types": "4.4.3",
+ "@shikijs/vscode-textmate": "^10.0.2"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@shikijs/langs": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.4.3.tgz",
+ "integrity": "sha512-ePic0yfAJGOF83D5wBHK/00EjK65oahBYxFk5epgq33WRv7X9UuxLEV8PtR0szC0z8dl7INIpIodB99JRFlR+A==",
+ "license": "MIT",
+ "dependencies": {
+ "@shikijs/types": "4.4.3"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@shikijs/primitive": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.4.3.tgz",
+ "integrity": "sha512-m0wBeLDQDeIxRdUmrCPdQqfuUamDwRL5isCfYbguKD6NiaKpVbsv+3J81DyIKgNW5h4WAIIr8T4EkgQrBBxvaQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@shikijs/types": "4.4.3",
+ "@shikijs/vscode-textmate": "^10.0.2",
+ "@types/hast": "^3.0.5"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@shikijs/themes": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.4.3.tgz",
+ "integrity": "sha512-w8UHjeUnIR965KMWJHUPXOc2mNJUnK3vpVLYLvw5IYU2mnTTJ89E24OrJDBNiJDQ0qzb0tc4l7mrIXx5cFeIyw==",
+ "license": "MIT",
+ "dependencies": {
+ "@shikijs/types": "4.4.3"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@shikijs/types": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.4.3.tgz",
+ "integrity": "sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g==",
+ "license": "MIT",
+ "dependencies": {
+ "@shikijs/vscode-textmate": "^10.0.2",
+ "@types/hast": "^3.0.5"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@shikijs/vscode-textmate": {
+ "version": "10.0.2",
+ "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz",
+ "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==",
+ "license": "MIT"
+ },
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"dev": true,
@@ -1524,7 +1624,9 @@
}
},
"node_modules/@types/hast": {
- "version": "3.0.4",
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz",
+ "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==",
"license": "MIT",
"dependencies": {
"@types/unist": "*"
@@ -2849,6 +2951,29 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/hast-util-to-html": {
+ "version": "9.0.5",
+ "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz",
+ "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==",
+ "license": "MIT",
+ "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"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/hast-util-to-jsx-runtime": {
"version": "2.3.6",
"license": "MIT",
@@ -2915,6 +3040,16 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/html-void-elements": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz",
+ "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/inline-style-parser": {
"version": "0.2.7",
"license": "MIT"
@@ -4023,6 +4158,23 @@
],
"license": "MIT"
},
+ "node_modules/oniguruma-parser": {
+ "version": "0.12.2",
+ "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz",
+ "integrity": "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==",
+ "license": "MIT"
+ },
+ "node_modules/oniguruma-to-es": {
+ "version": "4.3.6",
+ "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.6.tgz",
+ "integrity": "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==",
+ "license": "MIT",
+ "dependencies": {
+ "oniguruma-parser": "^0.12.2",
+ "regex": "^6.1.0",
+ "regex-recursion": "^6.0.2"
+ }
+ },
"node_modules/parse-entities": {
"version": "4.0.2",
"license": "MIT",
@@ -4453,6 +4605,30 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/regex": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz",
+ "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==",
+ "license": "MIT",
+ "dependencies": {
+ "regex-utilities": "^2.3.0"
+ }
+ },
+ "node_modules/regex-recursion": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz",
+ "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==",
+ "license": "MIT",
+ "dependencies": {
+ "regex-utilities": "^2.3.0"
+ }
+ },
+ "node_modules/regex-utilities": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz",
+ "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==",
+ "license": "MIT"
+ },
"node_modules/rehype-slug": {
"version": "6.0.0",
"license": "MIT",
@@ -4671,6 +4847,25 @@
"seroval": "^1.0"
}
},
+ "node_modules/shiki": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.4.3.tgz",
+ "integrity": "sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g==",
+ "license": "MIT",
+ "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"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/siginfo": {
"version": "2.0.0",
"dev": true,
@@ -5695,6 +5890,7 @@
"react-markdown": "^10.1.0",
"rehype-slug": "^6.0.0",
"remark-gfm": "^4.0.1",
+ "shiki": "^4.4.3",
"zod": "^4.0.0"
},
"devDependencies": {
diff --git a/site/package.json b/site/package.json
index aaed0a4d..4deec67e 100644
--- a/site/package.json
+++ b/site/package.json
@@ -32,6 +32,7 @@
"react-markdown": "^10.1.0",
"rehype-slug": "^6.0.0",
"remark-gfm": "^4.0.1",
+ "shiki": "^4.4.3",
"zod": "^4.0.0"
},
"devDependencies": {
diff --git a/site/src/app/routes/_page.tsx b/site/src/app/routes/_page.tsx
index e5939d0d..c21f09aa 100644
--- a/site/src/app/routes/_page.tsx
+++ b/site/src/app/routes/_page.tsx
@@ -1,4 +1,6 @@
import { Outlet, createFileRoute } from "@tanstack/react-router";
+import { DemoSourcesProvider } from "../../shared/demo-workbench/DemoSurface";
+import { demoSources } from "../../shared/demo-workbench/demo-sources";
import { PageFrame, PageLeadProvider } from "../../shared/ui/primitives";
import { SiteBreadcrumb } from "../breadcrumb";
import { findSiteRoute, siteRoutes, usePathname } from "../router";
@@ -9,10 +11,20 @@ export const Route = createFileRoute("/_page")({
function InteriorPage() {
const route = findSiteRoute(usePathname());
+ const sources = demoSources(route.path);
+ const content = ;
+ if (sources !== undefined) {
+ return (
+
+
+ {content}
+
+ );
+ }
return (
}>
-
+ {content}
);
diff --git a/site/src/routes/canvas-demo/CanvasDemoRoute.tsx b/site/src/routes/canvas-demo/CanvasDemoRoute.tsx
index d0193592..a2f52c4e 100644
--- a/site/src/routes/canvas-demo/CanvasDemoRoute.tsx
+++ b/site/src/routes/canvas-demo/CanvasDemoRoute.tsx
@@ -1,4 +1,5 @@
import { useState, type PointerEvent } from "react";
+import { DemoSurface } from "../../shared/demo-workbench/DemoSurface";
import {
createObjectEditor,
type ObjectDocument,
@@ -76,6 +77,7 @@ export function CanvasDemoRoute() {
Pick a box, drag it, then fill the selection. The board is the editor.
+
+
);
}
diff --git a/site/src/routes/database-demo/DatabaseDemoRoute.tsx b/site/src/routes/database-demo/DatabaseDemoRoute.tsx
index 6c813649..3ad552a1 100644
--- a/site/src/routes/database-demo/DatabaseDemoRoute.tsx
+++ b/site/src/routes/database-demo/DatabaseDemoRoute.tsx
@@ -1,3 +1,4 @@
+import { DemoSurface } from "../../shared/demo-workbench/DemoSurface";
import { PageFrame, PageHeader } from "../../shared/ui/primitives";
import { DatabaseTableDemo } from "./DatabaseTableDemo";
@@ -7,7 +8,9 @@ export function DatabaseDemoRoute() {
One canonical database, typed property editors, persistent view configuration, structural selection, and native text leases.
-
+
+
+
);
}
diff --git a/site/src/routes/document-demo/DocumentDemoRoute.tsx b/site/src/routes/document-demo/DocumentDemoRoute.tsx
index df44d146..c6fe436f 100644
--- a/site/src/routes/document-demo/DocumentDemoRoute.tsx
+++ b/site/src/routes/document-demo/DocumentDemoRoute.tsx
@@ -1,4 +1,5 @@
import { useRef, useState, type ClipboardEvent } from "react";
+import { DemoSurface } from "../../shared/demo-workbench/DemoSurface";
import {
type BlockDocument,
type DocumentClipboard,
@@ -169,6 +170,7 @@ export function DocumentDemoRoute() {
)}
>A deliberately small interface for selection, clipboard, history, keyboard input, and canonical JSON publication.
+
Shift-click selects a range. Mod-click adds or removes a block. Arrow keys move the selection when focus is on the surface.
+
);
}
diff --git a/site/src/routes/editing-demos/ClipboardDemoRoute.tsx b/site/src/routes/editing-demos/ClipboardDemoRoute.tsx
index f0759b84..da14f3ca 100644
--- a/site/src/routes/editing-demos/ClipboardDemoRoute.tsx
+++ b/site/src/routes/editing-demos/ClipboardDemoRoute.tsx
@@ -1,4 +1,5 @@
import { useState } from "react";
+import { DemoSurface } from "../../shared/demo-workbench/DemoSurface";
import { type BlockDocument, type DocumentClipboard } from "@interactive-os/json-document-editing";
import { useDocumentEditor, useEditing } from "@interactive-os/json-document-react";
import { Inspector } from "../../shared/ui/inspector";
@@ -56,6 +57,7 @@ export function ClipboardDemoRoute() {
Selection에서 시작해 copy 또는 cut으로 구조화된 payload를 만들고 paste에 넘깁니다.
+
1 · Selection
@@ -98,6 +100,7 @@ export function ClipboardDemoRoute() {
]} />
+
);
}
diff --git a/site/src/routes/editing-demos/HistoryDemoRoute.tsx b/site/src/routes/editing-demos/HistoryDemoRoute.tsx
index 14e41914..fe23ffcd 100644
--- a/site/src/routes/editing-demos/HistoryDemoRoute.tsx
+++ b/site/src/routes/editing-demos/HistoryDemoRoute.tsx
@@ -1,4 +1,5 @@
import { useState } from "react";
+import { DemoSurface } from "../../shared/demo-workbench/DemoSurface";
import { type BlockDocument } from "@interactive-os/json-document-editing";
import { useDocumentEditor, useEditing } from "@interactive-os/json-document-react";
import { Inspector } from "../../shared/ui/inspector";
@@ -50,6 +51,7 @@ export function HistoryDemoRoute() {
편집을 한 번 commit하고, 만들어진 History 항목으로 document.value와 Selection을 함께 복원합니다.
+
1 · 편집
@@ -104,6 +106,7 @@ export function HistoryDemoRoute() {
]} />
+
);
}
diff --git a/site/src/routes/editing-demos/SelectionDemoRoute.tsx b/site/src/routes/editing-demos/SelectionDemoRoute.tsx
index 832a8333..2b7dd5b9 100644
--- a/site/src/routes/editing-demos/SelectionDemoRoute.tsx
+++ b/site/src/routes/editing-demos/SelectionDemoRoute.tsx
@@ -1,4 +1,5 @@
import { useState } from "react";
+import { DemoSurface } from "../../shared/demo-workbench/DemoSurface";
import { type BlockDocument, type DocumentIntent } from "@interactive-os/json-document-editing";
import { useDocumentEditor, useEditing } from "@interactive-os/json-document-react";
import { Inspector } from "../../shared/ui/inspector";
@@ -39,6 +40,7 @@ export function SelectionDemoRoute() {
Selection 입력을 dispatch한 뒤 바뀐 Selection을 그대로인 document.value와 History 옆에서 비교합니다.
+
1 · 입력
@@ -95,6 +97,7 @@ export function SelectionDemoRoute() {
]} />
+
);
}
diff --git a/site/src/routes/editing-demos/TopologyDemoRoute.tsx b/site/src/routes/editing-demos/TopologyDemoRoute.tsx
index 2ae63ba8..b1df5179 100644
--- a/site/src/routes/editing-demos/TopologyDemoRoute.tsx
+++ b/site/src/routes/editing-demos/TopologyDemoRoute.tsx
@@ -1,4 +1,5 @@
import { useMemo, useState } from "react";
+import { DemoSurface } from "../../shared/demo-workbench/DemoSurface";
import { lineInterval, lineTopology } from "@interactive-os/json-document-editing";
import { useEditing } from "@interactive-os/json-document-react";
import { Inspector } from "../../shared/ui/inspector";
@@ -44,6 +45,7 @@ export function TopologyDemoRoute() {
anchor와 focus를 유지한 채 화면 순서를 바꾸고, 그 순서에서 계산된 범위를 확인합니다.
+
1 · 입력
@@ -105,6 +107,7 @@ export function TopologyDemoRoute() {
+
);
}
diff --git a/site/src/routes/kanban-demo/KanbanDemoRoute.tsx b/site/src/routes/kanban-demo/KanbanDemoRoute.tsx
index d7bc6460..1a4d9e3c 100644
--- a/site/src/routes/kanban-demo/KanbanDemoRoute.tsx
+++ b/site/src/routes/kanban-demo/KanbanDemoRoute.tsx
@@ -1,4 +1,5 @@
import { useState, type DragEvent } from "react";
+import { DemoSurface } from "../../shared/demo-workbench/DemoSurface";
import {
createKanbanEditor,
type KanbanDocument,
@@ -49,6 +50,7 @@ export function KanbanDemoRoute() {
Drag a card into another column. One JSON document keeps the board.
+
+
);
}
diff --git a/site/src/routes/object-demo/ObjectDemoRoute.tsx b/site/src/routes/object-demo/ObjectDemoRoute.tsx
index eed3a70a..9134ba0b 100644
--- a/site/src/routes/object-demo/ObjectDemoRoute.tsx
+++ b/site/src/routes/object-demo/ObjectDemoRoute.tsx
@@ -1,4 +1,5 @@
import { useState } from "react";
+import { DemoSurface } from "../../shared/demo-workbench/DemoSurface";
import {
createObjectEditor,
type ObjectClipboard,
@@ -78,6 +79,7 @@ export function ObjectDemoRoute() {
A key-family board. The host hit-tests boxes and sends only stable IDs. Fill changes color without moving geometry.
+
Click a box. Mod-click toggles. Fill uses the selected IDs only.
+
);
}
diff --git a/site/src/routes/order-demo/OrderDemoRoute.tsx b/site/src/routes/order-demo/OrderDemoRoute.tsx
index 36a6980d..6e58e8a8 100644
--- a/site/src/routes/order-demo/OrderDemoRoute.tsx
+++ b/site/src/routes/order-demo/OrderDemoRoute.tsx
@@ -1,4 +1,5 @@
import { useState } from "react";
+import { DemoSurface } from "../../shared/demo-workbench/DemoSurface";
import {
createOrderEditor,
type OrderClipboard,
@@ -73,6 +74,7 @@ export function OrderDemoRoute() {
A one-line list with range selection, structured clipboard, delete, and local history.
+
Shift-click selects a range. Mod-click adds or removes an item.
+
);
}
diff --git a/site/src/routes/rich-text-demo/RichTextDemoRoute.tsx b/site/src/routes/rich-text-demo/RichTextDemoRoute.tsx
index ea0df7a5..6fe3b815 100644
--- a/site/src/routes/rich-text-demo/RichTextDemoRoute.tsx
+++ b/site/src/routes/rich-text-demo/RichTextDemoRoute.tsx
@@ -1,4 +1,5 @@
import { useCallback, useState } from "react";
+import { DemoSurface } from "../../shared/demo-workbench/DemoSurface";
import { createJSONDocument, type JSONPatchOperation } from "@interactive-os/json-document";
import { useEditing } from "@interactive-os/json-document-react";
import {
@@ -201,6 +202,7 @@ export function RichTextDemoRoute() {
DOM은 입력 경계일 뿐입니다. 아래 편집은 Rich Text intent, Selection mapping, EditingSession과 atomic JSON Patch를 차례로 통과합니다.
+
Apply sample intent
runHistory("undo")} disabled={!snapshot.canUndo}>Undo
@@ -283,6 +285,7 @@ export function RichTextDemoRoute() {
+
);
}
diff --git a/site/src/routes/sheet-demo/SheetDemo.tsx b/site/src/routes/sheet-demo/SheetDemo.tsx
index 78cee813..3913920f 100644
--- a/site/src/routes/sheet-demo/SheetDemo.tsx
+++ b/site/src/routes/sheet-demo/SheetDemo.tsx
@@ -1,4 +1,5 @@
import { useRef, useState, type ClipboardEvent } from "react";
+import { DemoSurface } from "../../shared/demo-workbench/DemoSurface";
import {
createSheetEditor,
type EditingResult,
@@ -180,6 +181,7 @@ export function SheetDemo() {
)}
>A small editable grid for rectangular selection, TSV clipboard, history, and canonical JSON publication.
+
Click replaces selection. Shift-click extends the primary rectangle. Mod-click or Mod+Space toggles a cell. Arrows move by the visible grid; Shift+arrows extend it. Delete clears selected cells. Fill selected changes every selected cell in one transaction.
+
);
}
diff --git a/site/src/routes/tree-demo/TreeDemoRoute.tsx b/site/src/routes/tree-demo/TreeDemoRoute.tsx
index 52684f1f..17631aa7 100644
--- a/site/src/routes/tree-demo/TreeDemoRoute.tsx
+++ b/site/src/routes/tree-demo/TreeDemoRoute.tsx
@@ -1,4 +1,5 @@
import { useMemo, useState } from "react";
+import { DemoSurface } from "../../shared/demo-workbench/DemoSurface";
import {
createTreeEditor,
type TreeClipboard,
@@ -91,6 +92,7 @@ export function TreeDemoRoute() {
A folded tree. The host owns expand state and sends only the visible ID line to the editor.
+
Fold a branch to take it out of the visible line. Selection and clipboard read that line only.
+
);
}
diff --git a/site/src/routes/widgets/WidgetDemoFrame.tsx b/site/src/routes/widgets/WidgetDemoFrame.tsx
index f9bd6102..0bf2f79c 100644
--- a/site/src/routes/widgets/WidgetDemoFrame.tsx
+++ b/site/src/routes/widgets/WidgetDemoFrame.tsx
@@ -1,4 +1,5 @@
import type { ReactNode } from "react";
+import { DemoSurface } from "../../shared/demo-workbench/DemoSurface";
import { type InspectorItem } from "../../shared/ui/inspector";
import { JsonInspector } from "../../shared/ui/json-inspector";
import { PageFrame, PageHeader, type PetiteCatIllustration } from "../../shared/ui/primitives";
@@ -20,6 +21,7 @@ export function WidgetDemoFrame(props: {
{props.description}
+
Widget
@@ -42,6 +44,7 @@ export function WidgetDemoFrame(props: {
+
);
}
diff --git a/site/src/shared/demo-workbench/DemoSurface.tsx b/site/src/shared/demo-workbench/DemoSurface.tsx
new file mode 100644
index 00000000..b40db3fc
--- /dev/null
+++ b/site/src/shared/demo-workbench/DemoSurface.tsx
@@ -0,0 +1,22 @@
+import { createContext, useContext, type ReactNode } from "react";
+import { DemoWorkbench } from "./DemoWorkbench";
+import type { DemoSourceFile } from "./demo-sources";
+
+const DemoSourcesContext = createContext | undefined>(undefined);
+
+export function DemoSourcesProvider(props: {
+ readonly sources: ReadonlyArray | undefined;
+ readonly children: ReactNode;
+}) {
+ return {props.children} ;
+}
+
+export function DemoSurface(props: { readonly children: ReactNode }) {
+ const sources = useContext(DemoSourcesContext);
+
+ if (sources === undefined) {
+ return props.children;
+ }
+
+ return {props.children} ;
+}
diff --git a/site/src/shared/demo-workbench/DemoWorkbench.tsx b/site/src/shared/demo-workbench/DemoWorkbench.tsx
new file mode 100644
index 00000000..b7203d2c
--- /dev/null
+++ b/site/src/shared/demo-workbench/DemoWorkbench.tsx
@@ -0,0 +1,99 @@
+import { useEffect, useId, useState, type KeyboardEvent, type ReactNode } from "react";
+import { classes, ui } from "../ui/styles";
+import { ShikiSourceCodeBlock } from "./ShikiSourceCodeBlock";
+import type { DemoSourceFile } from "./demo-sources";
+
+type WorkbenchTab = "demo" | number;
+
+export function DemoWorkbench(props: {
+ readonly children: ReactNode;
+ readonly sources: ReadonlyArray;
+}) {
+ const [activeTab, setActiveTab] = useState("demo");
+ const id = useId();
+ const tabs: ReadonlyArray<{ readonly key: WorkbenchTab; readonly label: string }> = [
+ { key: "demo", label: "Demo" },
+ ...props.sources.map((source, index) => ({ key: index, label: source.path.split("/").at(-1) ?? source.path })),
+ ];
+ const activeSourceIndex = typeof activeTab === "number" ? activeTab : undefined;
+ const activeSource = activeSourceIndex === undefined ? undefined : props.sources[activeSourceIndex];
+ const [sourceText, setSourceText] = useState>>({});
+ const activeSourceText = activeSource === undefined ? undefined : sourceText[activeSource.path];
+
+ useEffect(() => {
+ if (activeSource === undefined || activeSourceText !== undefined) return;
+ let current = true;
+ void activeSource.load().then((source) => {
+ if (current) setSourceText((loaded) => ({ ...loaded, [activeSource.path]: source }));
+ });
+ return () => { current = false; };
+ }, [activeSource?.path, activeSourceText]);
+
+ function selectNeighbor(event: KeyboardEvent, index: number) {
+ const direction = event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0;
+ if (direction === 0) return;
+ event.preventDefault();
+ const nextIndex = (index + direction + tabs.length) % tabs.length;
+ setActiveTab(tabs[nextIndex]!.key);
+ event.currentTarget.parentElement?.querySelectorAll("[role=tab]")[nextIndex]?.focus();
+ }
+
+ return (
+
+
+
+ {tabs.map((tab, index) => {
+ const selected = tab.key === activeTab;
+ return (
+ setActiveTab(tab.key)}
+ onKeyDown={(event) => selectNeighbor(event, index)}
+ >
+ {tab.label}
+
+ );
+ })}
+
+
+
+
+ {props.children}
+
+ {activeSourceIndex === undefined || activeSource === undefined ? null : (
+
+
+
{activeSource.path}
+ {activeSourceText === undefined ? (
+
Loading source…
+ ) : (
+
+ )}
+
+
+ )}
+
+ );
+}
diff --git a/site/src/shared/demo-workbench/ShikiSourceCodeBlock.tsx b/site/src/shared/demo-workbench/ShikiSourceCodeBlock.tsx
new file mode 100644
index 00000000..11862a32
--- /dev/null
+++ b/site/src/shared/demo-workbench/ShikiSourceCodeBlock.tsx
@@ -0,0 +1,33 @@
+import { useEffect, useState } from "react";
+import { CodeBlock, type HighlightedCodeToken } from "../ui/code-block";
+import type { CodeLanguage } from "../ui/code-tokens";
+
+export function ShikiSourceCodeBlock(props: {
+ readonly language: CodeLanguage;
+ readonly source: string;
+}) {
+ const [highlightedLines, setHighlightedLines] = useState>>();
+
+ useEffect(() => {
+ let current = true;
+ setHighlightedLines(undefined);
+ void import("./shiki-highlighter")
+ .then(({ highlightSource }) => highlightSource(props.source, props.language))
+ .then((lines) => {
+ if (current) setHighlightedLines(lines);
+ })
+ .catch(() => undefined);
+ return () => { current = false; };
+ }, [props.language, props.source]);
+
+ return (
+
+
+
+ );
+}
diff --git a/site/src/shared/demo-workbench/demo-sources.ts b/site/src/shared/demo-workbench/demo-sources.ts
new file mode 100644
index 00000000..cc5a04d8
--- /dev/null
+++ b/site/src/shared/demo-workbench/demo-sources.ts
@@ -0,0 +1,98 @@
+import type { CodeLanguage } from "../ui/code-tokens";
+
+export type DemoSourceFile = {
+ readonly path: string;
+ readonly language: CodeLanguage;
+ readonly load: () => Promise;
+};
+
+const sourceModules = import.meta.glob(
+ [
+ "/src/routes/**/*.{ts,tsx}",
+ "/src/shared/**/*.{ts,tsx}",
+ "!/src/shared/ui/**",
+ "!/src/shared/demo-workbench/**",
+ ],
+ { import: "default", query: "?raw" },
+);
+
+const demoSourcePaths: Readonly>> = {
+ "/demo": ["routes/document-demo/DocumentDemoRoute.tsx"],
+ "/demo/order": ["routes/order-demo/OrderDemoRoute.tsx"],
+ "/demo/object": ["routes/object-demo/ObjectDemoRoute.tsx"],
+ "/demo/canvas": ["routes/canvas-demo/CanvasDemoRoute.tsx"],
+ "/demo/sheet": ["routes/sheet-demo/SheetDemoRoute.tsx", "routes/sheet-demo/SheetDemo.tsx"],
+ "/demo/database": [
+ "routes/database-demo/DatabaseDemoRoute.tsx",
+ "routes/database-demo/DatabaseTableDemo.tsx",
+ "routes/database-demo/initial-database.ts",
+ ],
+ "/demo/tree": ["routes/tree-demo/TreeDemoRoute.tsx"],
+ "/demo/kanban": ["routes/kanban-demo/KanbanDemoRoute.tsx"],
+ "/demo/topology": ["routes/editing-demos/TopologyDemoRoute.tsx"],
+ "/demo/selection": ["routes/editing-demos/SelectionDemoRoute.tsx"],
+ "/demo/clipboard": ["routes/editing-demos/ClipboardDemoRoute.tsx"],
+ "/demo/history": ["routes/editing-demos/HistoryDemoRoute.tsx"],
+ "/editing/rich-text": [
+ "routes/rich-text-demo/RichTextDemoRoute.tsx",
+ "routes/rich-text-demo/rich-text-styles.ts",
+ ],
+ "/widgets/listbox": widgetSources("ListboxWidgetRoute.tsx"),
+ "/widgets/grid": widgetSources("GridWidgetRoute.tsx"),
+ "/widgets/toolbar": widgetSources("ToolbarWidgetRoute.tsx"),
+ "/adapters/clipboard": adapterSources("clipboard", "Clipboard"),
+ "/adapters/contenteditable": adapterSources("contenteditable", "ContentEditable"),
+ "/adapters/keyboard": adapterSources("keyboard", "Keyboard"),
+ "/connectors/react": connectorSources("react", "ReactConnector"),
+ "/connectors/react-hook-form": connectorSources("react-hook-form", "ReactHookFormConnector"),
+ "/connectors/tanstack-table": connectorSources("tanstack-table", "TanStackTableConnector"),
+ "/connectors/ajv": connectorSources("ajv", "AjvConnector"),
+ "/connectors/zod": [
+ "routes/connectors/zod/ZodConnectorDemoRoute.tsx",
+ "routes/connectors/zod/ZodAdminLab.tsx",
+ ],
+ "/connectors/zod/validate": [
+ "routes/connectors/zod/ZodValidateDemoRoute.tsx",
+ "routes/connectors/zod/ZodConnectorLab.tsx",
+ ],
+};
+
+export function demoSources(pathname: string): ReadonlyArray | undefined {
+ return demoSourcePaths[pathname]?.map((path) => ({
+ path,
+ language: path.endsWith(".tsx") ? "tsx" : "typescript",
+ load: sourceLoader(path),
+ }));
+}
+
+function widgetSources(route: string): ReadonlyArray {
+ return [
+ `routes/widgets/${route}`,
+ "routes/widgets/binding/index.ts",
+ "routes/widgets/binding/order.ts",
+ "routes/widgets/binding/sheet.ts",
+ "routes/widgets/binding/keyboard.ts",
+ "routes/widgets/binding/history.ts",
+ "routes/widgets/binding/option.ts",
+ ];
+}
+
+function adapterSources(folder: string, name: string): ReadonlyArray {
+ return [
+ `routes/adapters/${folder}/${name}AdapterDemoRoute.tsx`,
+ `routes/adapters/${folder}/${name}AdapterLab.tsx`,
+ ];
+}
+
+function connectorSources(folder: string, name: string): ReadonlyArray {
+ return [
+ `routes/connectors/${folder}/${name}DemoRoute.tsx`,
+ `routes/connectors/${folder}/${name}Lab.tsx`,
+ ];
+}
+
+function sourceLoader(path: string): () => Promise {
+ const load = sourceModules[`/src/${path}`];
+ if (load === undefined) throw new Error(`Unknown demo source: ${path}`);
+ return load;
+}
diff --git a/site/src/shared/demo-workbench/shiki-highlighter.ts b/site/src/shared/demo-workbench/shiki-highlighter.ts
new file mode 100644
index 00000000..5a3b8fd9
--- /dev/null
+++ b/site/src/shared/demo-workbench/shiki-highlighter.ts
@@ -0,0 +1,25 @@
+import typescript from "@shikijs/langs/typescript";
+import tsx from "@shikijs/langs/tsx";
+import githubLightDefault from "@shikijs/themes/github-light-default";
+import { createHighlighterCore } from "shiki/core";
+import { createJavaScriptRegexEngine } from "shiki/engine/javascript";
+import type { HighlightedCodeToken } from "../ui/code-block";
+import type { CodeLanguage } from "../ui/code-tokens";
+
+const highlighter = createHighlighterCore({
+ themes: [githubLightDefault],
+ langs: [typescript, tsx],
+ engine: createJavaScriptRegexEngine(),
+});
+
+export async function highlightSource(
+ source: string,
+ language: CodeLanguage,
+): Promise>> {
+ if (language !== "typescript" && language !== "tsx") return [];
+ const instance = await highlighter;
+ return instance.codeToTokensBase(source, {
+ lang: language,
+ theme: "github-light-default",
+ });
+}
diff --git a/site/src/shared/ui/catalog-demo-page.tsx b/site/src/shared/ui/catalog-demo-page.tsx
index b63d6527..c7731d12 100644
--- a/site/src/shared/ui/catalog-demo-page.tsx
+++ b/site/src/shared/ui/catalog-demo-page.tsx
@@ -1,4 +1,5 @@
import type { ReactNode } from "react";
+import { DemoSurface } from "../demo-workbench/DemoSurface";
import { CodeBlock, InlineCode } from "./code-block";
import type { CodeLanguage } from "./code-tokens";
import { PageFrame, PageHeader, type PetiteCatIllustration } from "./primitives";
@@ -55,10 +56,10 @@ export function CatalogDemoPage(props: {
) : null}
-
-
Live demo
- {props.children}
-
+ Live demo
+
+ {props.children}
+
);
}
diff --git a/site/src/shared/ui/code-block.tsx b/site/src/shared/ui/code-block.tsx
index b42fb8e5..f863c482 100644
--- a/site/src/shared/ui/code-block.tsx
+++ b/site/src/shared/ui/code-block.tsx
@@ -5,6 +5,11 @@ import { classes, ui } from "./styles";
type CodeBlockSize = "compact" | "content" | "standard" | "tall";
+export type HighlightedCodeToken = {
+ readonly content: string;
+ readonly color?: string;
+};
+
export function CodeBlock(props: {
readonly source: string;
readonly language: CodeLanguage;
@@ -14,6 +19,7 @@ export function CodeBlock(props: {
readonly size?: CodeBlockSize;
readonly className?: string;
readonly testId?: string;
+ readonly highlightedLines?: ReadonlyArray>;
}) {
const source = withoutTerminalLineBreak(props.source.replace(/\r\n/g, "\n"));
const label = props.label ?? codeLanguageLabel(props.language);
@@ -61,7 +67,11 @@ export function CodeBlock(props: {
data-code-line
{...(props.language === "json" ? { "data-json-line": true } : {})}
>
- {tokenizeCodeLine(line, props.language).map((token, tokenIndex) => token.kind ? (
+ {props.highlightedLines?.[lineIndex]?.map((token, tokenIndex) => (
+
+ {token.content}
+
+ )) ?? tokenizeCodeLine(line, props.language).map((token, tokenIndex) => token.kind ? (
{
+ await page.goto("/demo");
+
+ const workbench = page.getByRole("region", { name: "Demo workbench" });
+ const tabs = workbench.getByRole("tab");
+ await expect(tabs).toHaveCount(2);
+ await expect(tabs.nth(0)).toHaveText("Demo");
+ await expect(tabs.nth(1)).toHaveText("DocumentDemoRoute.tsx");
+
+ await page.getByRole("button", { name: "Select block 1" }).click();
+ await expect(page.locator('article[data-block-id="welcome"]')).toHaveAttribute("data-selected", "true");
+
+ await tabs.nth(1).click();
+ await expect(workbench.getByText("routes/document-demo/DocumentDemoRoute.tsx")).toBeVisible();
+ const source = workbench.getByRole("tabpanel").locator("pre");
+ await expect(source).toContainText("export function DocumentDemoRoute()");
+ await expect(source).toContainText('from "@interactive-os/json-document-react"');
+ await expect(workbench.locator('[data-source-highlighter="shiki"]')).toBeVisible();
+ await expect(source.locator('span[style*="color"]').first()).toBeVisible();
+
+ await tabs.nth(0).click();
+ await expect(page.locator('article[data-block-id="welcome"]')).toHaveAttribute("data-selected", "true");
+});
+
+test("shows every demo-owned database file as a source tab", async ({ page }) => {
+ await page.goto("/demo/database");
+
+ const tablist = page.getByRole("tablist", { name: "Demo and source files" });
+ await expect(tablist.getByRole("tab")).toHaveText([
+ "Demo",
+ "DatabaseDemoRoute.tsx",
+ "DatabaseTableDemo.tsx",
+ "initial-database.ts",
+ ]);
+});
+
+test("keeps documentation above the sticky product workbench", async ({ page }) => {
+ await page.goto("/demo");
+
+ const breadcrumb = page.getByRole("navigation", { name: "Breadcrumb" });
+ const pageHeader = page.locator("[data-page-header]");
+ const workbench = page.getByRole("region", { name: "Demo workbench" });
+ const tablist = page.getByRole("tablist", { name: "Demo and source files" });
+ const initialBreadcrumb = await breadcrumb.boundingBox();
+ const initialHeader = await pageHeader.boundingBox();
+ const initialTabs = await tablist.boundingBox();
+ expect(initialBreadcrumb).not.toBeNull();
+ expect(initialHeader).not.toBeNull();
+ expect(initialTabs).not.toBeNull();
+ expect(initialBreadcrumb!.y + initialBreadcrumb!.height).toBeLessThanOrEqual(initialHeader!.y);
+ expect(initialHeader!.y + initialHeader!.height).toBeLessThanOrEqual(initialTabs!.y);
+ await expect(page.locator("[data-page-header] >> nav[aria-label='Breadcrumb']")).toHaveCount(0);
+ await expect(workbench.getByRole("heading", { name: "Document" })).toHaveCount(0);
+ await expect(workbench.locator("[data-page-header]")).toHaveCount(0);
+
+ await tablist.getByRole("tab", { name: "DocumentDemoRoute.tsx" }).click();
+ await expect(page.getByText("routes/document-demo/DocumentDemoRoute.tsx")).toBeVisible();
+ await page.evaluate(() => window.scrollTo(0, 900));
+ const stickyTabs = await tablist.boundingBox();
+ expect(stickyTabs).not.toBeNull();
+ expect(stickyTabs!.y).toBeLessThanOrEqual(1);
+});
diff --git a/site/tests/unit/app-shell.test.tsx b/site/tests/unit/app-shell.test.tsx
index 7e961e04..c1c3b1b9 100644
--- a/site/tests/unit/app-shell.test.tsx
+++ b/site/tests/unit/app-shell.test.tsx
@@ -119,7 +119,8 @@ describe("official site shell", () => {
await user.click(within(nav.getByRole("group", { name: "Connector" })).getByRole("link", { name: "Zod", exact: true }));
const adminHeader = document.querySelector("[data-page-header]");
- expect(adminHeader?.querySelector('[aria-label="Breadcrumb"]')).toBeTruthy();
+ expect(adminHeader?.querySelector('[aria-label="Breadcrumb"]')).toBeNull();
+ expect(await screen.findByRole("region", { name: "Demo workbench" })).toBeTruthy();
const adminCrumb = within(await screen.findByRole("navigation", { name: "Breadcrumb" }));
expect(adminCrumb.getByRole("link", { name: "Overview" }).getAttribute("href")).toBe("/");
expect(adminCrumb.getByRole("link", { name: "Connector" }).getAttribute("href")).toBe("/connectors");
diff --git a/site/tests/unit/demo-workbench.test.tsx b/site/tests/unit/demo-workbench.test.tsx
new file mode 100644
index 00000000..e8259d7a
--- /dev/null
+++ b/site/tests/unit/demo-workbench.test.tsx
@@ -0,0 +1,87 @@
+import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
+import { afterEach, describe, expect, test } from "vitest";
+import { DemoWorkbench } from "../../src/shared/demo-workbench/DemoWorkbench";
+import { demoSources } from "../../src/shared/demo-workbench/demo-sources";
+
+afterEach(cleanup);
+
+describe("DemoWorkbench", () => {
+ const sources = [
+ {
+ path: "routes/example/ExampleDemoRoute.tsx",
+ language: "tsx" as const,
+ load: async () => "export function ExampleDemoRoute() {\n return ;\n}",
+ },
+ {
+ path: "routes/example/fixture.ts",
+ language: "typescript" as const,
+ load: async () => "export const fixture = { ready: true };",
+ },
+ ];
+
+ test("keeps Demo first and switches between the live demo and full source files", async () => {
+ render(Run demo );
+
+ const tablist = screen.getByRole("tablist", { name: "Demo and source files" });
+ const tabs = within(tablist).getAllByRole("tab");
+ expect(tabs.map((tab) => tab.textContent)).toEqual(["Demo", "ExampleDemoRoute.tsx", "fixture.ts"]);
+ expect(tabs[0]!.getAttribute("aria-selected")).toBe("true");
+ expect(screen.getByRole("button", { name: "Run demo" })).toBeTruthy();
+
+ fireEvent.click(tabs[1]!);
+
+ expect(screen.queryByRole("button", { name: "Run demo" })).toBeNull();
+ expect(screen.getByText("routes/example/ExampleDemoRoute.tsx")).toBeTruthy();
+ expect(await screen.findByText("export", { selector: '[data-code-token="keyword"]' })).toBeTruthy();
+ expect(screen.getByRole("tabpanel").textContent).toContain(await sources[0]!.load());
+ });
+
+ test("moves across tabs with editor-style arrow navigation", () => {
+ render(Demo
);
+ const tabs = screen.getAllByRole("tab");
+
+ tabs[0]!.focus();
+ fireEvent.keyDown(tabs[0]!, { key: "ArrowRight" });
+ expect(tabs[1]!.getAttribute("aria-selected")).toBe("true");
+ expect(document.activeElement).toBe(tabs[1]);
+
+ fireEvent.keyDown(tabs[1]!, { key: "ArrowLeft" });
+ expect(tabs[0]!.getAttribute("aria-selected")).toBe("true");
+ expect(document.activeElement).toBe(tabs[0]);
+ });
+});
+
+describe("demoSources", () => {
+ test("loads actual full demo files without following package or site chrome imports", async () => {
+ const document = demoSources("/demo")!;
+ expect(document.map((file) => file.path)).toEqual(["routes/document-demo/DocumentDemoRoute.tsx"]);
+ const source = await document[0]!.load();
+ expect(source).toContain("export function DocumentDemoRoute()");
+ expect(source).toContain('from "@interactive-os/json-document-react"');
+ expect(document.some((file) => file.path.includes("packages/"))).toBe(false);
+ expect(document.some((file) => file.path.includes("shared/ui"))).toBe(false);
+ });
+
+ test("includes demo-owned helpers and shared behavioral glue", () => {
+ expect(demoSources("/demo/database")!.map((file) => file.path)).toEqual([
+ "routes/database-demo/DatabaseDemoRoute.tsx",
+ "routes/database-demo/DatabaseTableDemo.tsx",
+ "routes/database-demo/initial-database.ts",
+ ]);
+ expect(demoSources("/widgets/listbox")!.map((file) => file.path)).toContain(
+ "routes/widgets/binding/order.ts",
+ );
+ });
+
+ test("covers every public interactive demo route", () => {
+ const paths = [
+ "/demo", "/demo/order", "/demo/object", "/demo/canvas", "/demo/sheet", "/demo/database",
+ "/demo/tree", "/demo/kanban", "/demo/topology", "/demo/selection", "/demo/clipboard", "/demo/history",
+ "/editing/rich-text", "/widgets/listbox", "/widgets/grid", "/widgets/toolbar",
+ "/adapters/clipboard", "/adapters/contenteditable", "/adapters/keyboard",
+ "/connectors/react", "/connectors/react-hook-form", "/connectors/tanstack-table", "/connectors/ajv",
+ "/connectors/zod", "/connectors/zod/validate",
+ ];
+ for (const path of paths) expect(demoSources(path), path).not.toBeUndefined();
+ });
+});
diff --git a/site/tests/unit/docs-route.test.tsx b/site/tests/unit/docs-route.test.tsx
index e643081f..9fcef67d 100644
--- a/site/tests/unit/docs-route.test.tsx
+++ b/site/tests/unit/docs-route.test.tsx
@@ -41,17 +41,17 @@ describe("documentation routes", () => {
await user.click(nav.getByRole("button", { name: "Connector" }));
await user.click(within(nav.getByRole("group", { name: "Connector" })).getByRole("link", { name: "React", exact: true }));
await waitFor(() => expect(document.title).toBe("React Connector Live Demo - json-document"));
- expect(await screen.findByRole("heading", { level: 1, name: "React Connector" })).toBeTruthy();
+ expect(await screen.findByRole("heading", { level: 1, name: "React Connector" }, { timeout: 10000 })).toBeTruthy();
await user.click(nav.getByRole("button", { name: "Editing" }));
await user.click(within(nav.getByRole("group", { name: "Editing" })).getByRole("link", { name: "Topology" }));
await waitFor(() => expect(document.title).toBe("Topology - json-document"));
- expect(await screen.findByRole("heading", { level: 1, name: "Topology" })).toBeTruthy();
+ expect(await screen.findByRole("heading", { level: 1, name: "Topology" }, { timeout: 10000 })).toBeTruthy();
await user.click(within(nav.getByRole("group", { name: "JSON Document" })).getByRole("link", { name: "API Reference" }));
await waitFor(() => expect(document.title).toBe("json-document API - json-document"));
expect(document.head.querySelector('meta[name="description"]')?.getAttribute("content")).toBe("여섯 가지 JSON Document 진입점과 JSON Patch, Pointer, JSONPath 공개 API를 정리합니다.");
- expect(await screen.findByRole("heading", { level: 1, name: "json-document API" })).toBeTruthy();
+ expect(await screen.findByRole("heading", { level: 1, name: "json-document API" }, { timeout: 10000 })).toBeTruthy();
expect(within(nav.getByRole("group", { name: "JSON Document" })).getByRole("link", { name: "Why" }).getAttribute("aria-current")).toBeNull();
expect(within(nav.getByRole("group", { name: "JSON Document" })).getByRole("link", { name: "API Reference" }).getAttribute("aria-current")).toBe("page");
const mobileSections = within(screen.getByRole("navigation", { name: "Documentation sections" }));