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 ( + + ); + })} +
+
+ + + {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(); + + 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" }));