From c5d33da17711098511529962b53cad476ba0d58b Mon Sep 17 00:00:00 2001 From: Ritwik G Date: Thu, 5 Nov 2026 14:42:27 +0530 Subject: [PATCH 01/11] feat(v1): add code and structured data components (UN-4400) CodeBlock, JsonView (tree and raw, virtualised), DiffView (split and unified), Markdown, CopyButton, CodeEditor (lazy Monaco) and InlineCode under components/ui/code, with pure helpers in lib/code. Adds the code-* and diff-* colour tokens in every mode, refractor, diff and @tanstack/react-virtual as direct dependencies, tests including the 5,000-line JSON case, and usage docs. Co-Authored-By: Claude Opus 5.5 --- frontend/bun.lock | 7 + frontend/package.json | 3 + frontend/src/v1/README.md | 12 + .../v1/components/ui/code/CodeBlock.test.tsx | 98 +++ .../src/v1/components/ui/code/CodeBlock.tsx | 107 +++ .../v1/components/ui/code/CodeEditor.test.tsx | 103 +++ .../src/v1/components/ui/code/CodeEditor.tsx | 140 ++++ .../src/v1/components/ui/code/CodeLines.tsx | 87 ++ .../v1/components/ui/code/CopyButton.test.tsx | 94 +++ .../src/v1/components/ui/code/CopyButton.tsx | 122 +++ .../v1/components/ui/code/DiffView.test.tsx | 135 ++++ .../src/v1/components/ui/code/DiffView.tsx | 283 +++++++ .../src/v1/components/ui/code/InlineCode.tsx | 34 + .../v1/components/ui/code/JsonView.test.tsx | 260 ++++++ .../src/v1/components/ui/code/JsonView.tsx | 550 +++++++++++++ .../v1/components/ui/code/Markdown.test.tsx | 99 +++ .../src/v1/components/ui/code/Markdown.tsx | 219 +++++ frontend/src/v1/components/ui/code/README.md | 206 +++++ .../ui/code/__snapshots__/markup.md | 762 ++++++++++++++++++ .../v1/components/ui/code/code.gallery.tsx | 150 ++++ frontend/src/v1/components/ui/code/index.ts | 30 + .../src/v1/components/ui/code/markup.test.tsx | 43 + frontend/src/v1/lib/code/clipboard.test.ts | 23 + frontend/src/v1/lib/code/clipboard.ts | 21 + frontend/src/v1/lib/code/diff.test.ts | 46 ++ frontend/src/v1/lib/code/diff.ts | 126 +++ frontend/src/v1/lib/code/highlight.test.ts | 78 ++ frontend/src/v1/lib/code/highlight.ts | 174 ++++ frontend/src/v1/lib/code/json-lines.test.ts | 124 +++ frontend/src/v1/lib/code/json-lines.ts | 216 +++++ frontend/src/v1/lib/code/tokens.ts | 36 + frontend/src/v1/lib/react-markdown-jsx.d.ts | 16 + .../v1/styles/__snapshots__/token-table.md | 10 + frontend/src/v1/styles/index.css | 11 + frontend/src/v1/styles/root.test.ts | 15 + frontend/src/v1/styles/tokens.css | 30 + frontend/src/v1/styles/tokens.json | 100 +++ frontend/src/v1/styles/tokens.test.ts | 4 +- frontend/src/v1/test/fixtures/json.ts | 47 ++ 39 files changed, 4620 insertions(+), 1 deletion(-) create mode 100644 frontend/src/v1/components/ui/code/CodeBlock.test.tsx create mode 100644 frontend/src/v1/components/ui/code/CodeBlock.tsx create mode 100644 frontend/src/v1/components/ui/code/CodeEditor.test.tsx create mode 100644 frontend/src/v1/components/ui/code/CodeEditor.tsx create mode 100644 frontend/src/v1/components/ui/code/CodeLines.tsx create mode 100644 frontend/src/v1/components/ui/code/CopyButton.test.tsx create mode 100644 frontend/src/v1/components/ui/code/CopyButton.tsx create mode 100644 frontend/src/v1/components/ui/code/DiffView.test.tsx create mode 100644 frontend/src/v1/components/ui/code/DiffView.tsx create mode 100644 frontend/src/v1/components/ui/code/InlineCode.tsx create mode 100644 frontend/src/v1/components/ui/code/JsonView.test.tsx create mode 100644 frontend/src/v1/components/ui/code/JsonView.tsx create mode 100644 frontend/src/v1/components/ui/code/Markdown.test.tsx create mode 100644 frontend/src/v1/components/ui/code/Markdown.tsx create mode 100644 frontend/src/v1/components/ui/code/README.md create mode 100644 frontend/src/v1/components/ui/code/__snapshots__/markup.md create mode 100644 frontend/src/v1/components/ui/code/code.gallery.tsx create mode 100644 frontend/src/v1/components/ui/code/index.ts create mode 100644 frontend/src/v1/components/ui/code/markup.test.tsx create mode 100644 frontend/src/v1/lib/code/clipboard.test.ts create mode 100644 frontend/src/v1/lib/code/clipboard.ts create mode 100644 frontend/src/v1/lib/code/diff.test.ts create mode 100644 frontend/src/v1/lib/code/diff.ts create mode 100644 frontend/src/v1/lib/code/highlight.test.ts create mode 100644 frontend/src/v1/lib/code/highlight.ts create mode 100644 frontend/src/v1/lib/code/json-lines.test.ts create mode 100644 frontend/src/v1/lib/code/json-lines.ts create mode 100644 frontend/src/v1/lib/code/tokens.ts create mode 100644 frontend/src/v1/lib/react-markdown-jsx.d.ts create mode 100644 frontend/src/v1/test/fixtures/json.ts diff --git a/frontend/bun.lock b/frontend/bun.lock index 890f17274b..2e28eba374 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -20,12 +20,14 @@ "@stripe/stripe-js": "^4.2.0", "@tanstack/react-query": "5.104.5", "@tanstack/react-table": "^8.21.3", + "@tanstack/react-virtual": "3.14.21", "axios": "1.16.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cronstrue": "^2.48.0", "date-fns": "^4.1.0", "dayjs": "1.11.19", + "diff": "9.0.0", "emoji-picker-react": "^4.8.0", "emoji-regex": "^10.3.0", "file-saver": "^2.0.5", @@ -58,6 +60,7 @@ "react-router-dom": "^6.11.2", "react-social-login-buttons": "^4.1.1", "recharts": "^3.5.1", + "refractor": "5.0.0", "remark-gfm": "^3.0.1", "socket.io-client": "^4.7.2", "sonner": "^2.0.7", @@ -650,8 +653,12 @@ "@tanstack/react-table": ["@tanstack/react-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="], + "@tanstack/react-virtual": ["@tanstack/react-virtual@3.14.21", "", { "dependencies": { "@tanstack/virtual-core": "3.19.6" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-wWE/ZJDNNDp18JHWy7n5PQooqerHePVkEEu6Op0twKQ2InF9DPhYMQptQY0syDEviMd3/r8D0OUvKJibDGm9HQ=="], + "@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="], + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.19.6", "", {}, "sha512-CsTW892z23gy7IyxYTTorVu1uZWB+0N349rSMgKz4rFLPpLza7F7TpcCKeI5iUAwYQCt8QUlOU67J+KXCxtpGw=="], + "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], "@testing-library/jest-dom": ["@testing-library/jest-dom@6.10.0", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" }, "peerDependencies": { "@testing-library/dom": ">=10 <11" } }, "sha512-HQwu0KaB2zyT0iLzBL+8CLyZDL3KlZlZJ+2iyc9uCUnlJVskJU/UlPuVCyIPhtukjPQdT2QNoR5nCP5FqTmmDQ=="], diff --git a/frontend/package.json b/frontend/package.json index a106bc9e54..78624b54e4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,12 +18,14 @@ "@stripe/stripe-js": "^4.2.0", "@tanstack/react-query": "5.104.5", "@tanstack/react-table": "^8.21.3", + "@tanstack/react-virtual": "3.14.21", "axios": "1.16.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cronstrue": "^2.48.0", "date-fns": "^4.1.0", "dayjs": "1.11.19", + "diff": "9.0.0", "emoji-picker-react": "^4.8.0", "emoji-regex": "^10.3.0", "file-saver": "^2.0.5", @@ -56,6 +58,7 @@ "react-router-dom": "^6.11.2", "react-social-login-buttons": "^4.1.1", "recharts": "^3.5.1", + "refractor": "5.0.0", "remark-gfm": "^3.0.1", "socket.io-client": "^4.7.2", "sonner": "^2.0.7", diff --git a/frontend/src/v1/README.md b/frontend/src/v1/README.md index af0dcb2ddf..2becdb9f87 100644 --- a/frontend/src/v1/README.md +++ b/frontend/src/v1/README.md @@ -85,6 +85,18 @@ is convention. `Spinner` sits only in buttons and running text. Errors on a screen are an `InlineAlert` above the content, with `onRetry`. +## Code and data + +- Code, JSON, diffs, Markdown, copy controls and code editors come from + `components/ui/code/` (`CodeBlock`, `JsonView`, `DiffView`, `Markdown`, + `CopyButton`, `CodeEditor`, `InlineCode`); its README lists every prop. + Do not build another highlighter, JSON tree, diff or Markdown renderer. +- Mono text (IDs, token counts) is ``; the font + comes only from the `font-mono` token. Syntax and diff colours are the + `code-*` and `diff-*` tokens. +- `CodeEditor` uses Monaco from its default CDN, as v0 does. Never call + `loader.config()` in v1: the loader is shared with v0. + ## Folders and what each may import Every folder may import from itself. Test files (`*.test.ts(x)`, diff --git a/frontend/src/v1/components/ui/code/CodeBlock.test.tsx b/frontend/src/v1/components/ui/code/CodeBlock.test.tsx new file mode 100644 index 0000000000..3013a526fd --- /dev/null +++ b/frontend/src/v1/components/ui/code/CodeBlock.test.tsx @@ -0,0 +1,98 @@ +import { screen, waitFor, within } from "@testing-library/react"; +import { renderV1 } from "@v1/test"; +import { expect, it, vi } from "vitest"; +import { CodeBlock } from "./CodeBlock"; +import { InlineCode } from "./InlineCode"; + +it("renders each line, highlighted with the code tokens", () => { + const { container } = renderV1( + , + ); + const lines = container.querySelectorAll("[data-slot=code-line]"); + expect([...lines].map((l) => l.textContent)).toEqual([ + '{"a": 1}', + "", + "null", + ]); + expect(container.querySelector(".ui\\:text-code-key")?.textContent).toBe( + '"a"', + ); + expect(container.querySelector(".ui\\:text-code-number")?.textContent).toBe( + "1", + ); + // Never Prism's own class names, which v0's global Prism theme styles. + expect(container.innerHTML).not.toMatch(/\btoken\b|language-/); +}); + +it("names its scrollable region and makes it focusable", async () => { + const { user, rerender } = renderV1( + , + ); + const region = screen.getByRole("region", { name: "Python code" }); + await user.tab(); + await user.tab(); + expect(document.activeElement).toBe(region); + rerender(); + expect(screen.getByRole("region", { name: "Request" })).toBeTruthy(); + rerender(Req} />); + expect(screen.getByRole("region", { name: "Body" })).toBeTruthy(); +}); + +it("shows line numbers only when asked, outside the copied text", () => { + const { container, rerender } = renderV1(); + expect(container.querySelector("[aria-hidden].ui\\:select-none")).toBeNull(); + rerender(); + const numbers = container.querySelectorAll( + "[data-slot=code-line] > [aria-hidden]", + ); + expect([...numbers].map((n) => n.textContent)).toEqual(["1", "2"]); +}); + +it("copies the code, and drops the header when there is nothing for it", async () => { + const { user, container, rerender } = renderV1( + Run} />, + ); + const writeText = vi.spyOn(navigator.clipboard, "writeText"); + const header = container.querySelector("[data-slot=code-block-header]"); + expect( + within(header as HTMLElement).getByRole("button", { name: "Run" }), + ).toBeTruthy(); + await user.click(screen.getByRole("button", { name: "Copy code" })); + await waitFor(() => expect(writeText).toHaveBeenCalledWith("print(1)")); + rerender(); + expect(container.querySelector("[data-slot=code-block-header]")).toBeNull(); +}); + +it("wraps long lines only when asked", () => { + const { container, rerender } = renderV1(); + const content = () => + container.querySelector("[data-slot=code-line] > span:last-child"); + expect(content()?.className).toContain("ui:whitespace-pre"); + rerender(); + expect(content()?.className).toContain("ui:whitespace-pre-wrap"); +}); + +it("passes axe in every mode", async () => { + for (const theme of ["light", "dark", "unstract"] as const) { + const { container, unmount } = renderV1( + , + { theme }, + ); + await expect(container).toHaveNoA11yViolations(); + unmount(); + } +}); + +it("InlineCode sets mono text as a chip or plain", () => { + renderV1( +

+ a + 2 +

, + ); + const [chip, plain] = document.querySelectorAll("code"); + expect(chip.className).toContain("ui:font-mono"); + expect(chip.className).toContain("ui:bg-muted"); + expect(plain.className).toContain("ui:font-mono"); + expect(plain.className).not.toContain("ui:bg-muted"); +}); diff --git a/frontend/src/v1/components/ui/code/CodeBlock.tsx b/frontend/src/v1/components/ui/code/CodeBlock.tsx new file mode 100644 index 0000000000..76abcbf46e --- /dev/null +++ b/frontend/src/v1/components/ui/code/CodeBlock.tsx @@ -0,0 +1,107 @@ +import { cn } from "@v1/lib/cn"; +import { type CodeLanguage, highlightLines } from "@v1/lib/code/highlight"; +import { type ComponentProps, type JSX, type ReactNode, useMemo } from "react"; +import { gutterWidth, LineRow, MONO, Tokens } from "./CodeLines"; +import { CopyButton } from "./CopyButton"; + +/** Display names, used for the default accessible label ("Python code"). */ +export const LANGUAGE_NAME: Readonly> = { + json: "JSON", + bash: "Shell", + python: "Python", + javascript: "JavaScript", + typescript: "TypeScript", + yaml: "YAML", + markdown: "Markdown", + text: "Plain text", +}; + +export type CodeBlockProps = Omit, "children" | "title"> & + Readonly<{ + code: string; + /** Highlighting grammar; `text` (the default) is not highlighted. */ + language?: CodeLanguage; + /** Shown in the header, left. */ + title?: ReactNode; + /** Extra controls in the header, before the copy button. */ + actions?: ReactNode; + /** Shows a copy button in the header (default on). */ + copyable?: boolean; + showLineNumbers?: boolean; + /** Wraps long lines instead of scrolling sideways. */ + wrap?: boolean; + /** Names the scrollable code region; defaults to the title or the language. */ + "aria-label"?: string; + }>; + +/** + * Read-only, highlighted code. The block grows with its content; constrain it + * with a class (`className="ui:max-h-80"`) and the code area scrolls. + */ +export function CodeBlock({ + code, + language = "text", + title, + actions, + copyable = true, + showLineNumbers = false, + wrap = false, + "aria-label": ariaLabel, + className, + ...props +}: CodeBlockProps): JSX.Element { + const lines = useMemo(() => highlightLines(code, language), [code, language]); + const label = + ariaLabel ?? + (typeof title === "string" ? title : `${LANGUAGE_NAME[language]} code`); + const header = title != null || actions != null || copyable; + return ( +
+ {header ? ( +
+
+ {title} +
+ {actions} + {copyable ? : null} +
+ ) : null} +
+
+          
+            {lines.map((line, i) => (
+              
+                
+              
+            ))}
+          
+        
+
+
+ ); +} diff --git a/frontend/src/v1/components/ui/code/CodeEditor.test.tsx b/frontend/src/v1/components/ui/code/CodeEditor.test.tsx new file mode 100644 index 0000000000..ca74cbe2d5 --- /dev/null +++ b/frontend/src/v1/components/ui/code/CodeEditor.test.tsx @@ -0,0 +1,103 @@ +import { act, screen, waitFor } from "@testing-library/react"; +import { renderV1 } from "@v1/test"; +import type { ReactNode } from "react"; +import { expect, it, vi } from "vitest"; +import { CodeEditor } from "./CodeEditor"; + +/* + * Monaco's loader fetches its runtime from the CDN, which happy-dom cannot + * run (and the harness fails any unmocked request), so the editor is a + * stand-in that records the props the wrapper passes. + */ +type MonacoProps = { + value?: string; + defaultValue?: string; + language?: string; + theme?: string; + height?: number | string; + loading?: ReactNode; + onChange?: (value: string | undefined) => void; + options?: Record; +}; +const seen: MonacoProps[] = []; +vi.mock("@monaco-editor/react", () => ({ + default: (props: MonacoProps) => { + seen.push(props); + return ( +