From f7cf8f551a00628201aabeaecabdd2a2830e66b9 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Thu, 17 Sep 2026 15:43:36 -0400 Subject: [PATCH 01/32] canvas: put the panels on Geist, dark by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel already carried nineteen steps of Geist's scale under its own `--ds-*` names, but as hex approximations of oklch values, light-only, and with two dozen plain literals around them. This finishes the adoption: the scale at the values Vercel publishes, the four steps Geist does not publish interpolated along its own ramp rather than picked by eye, the two variable families bundled so an offline board measures the same, and Geist's icons in place of eighteen hand-drawn SVGs. Each step is a `light-dark()` pair and `color-scheme: dark` on `:root` picks the half, so one word is the theme. tldraw gets the same preference on mount, because a dark rail against its near-white ground is two apps in one window — and that retires `WelcomeGround`, which blacked out the canvas under the welcome board to solve exactly the light ground it no longer has. Five colours stay fixed literals on purpose: the checkerboards and the photo ground are grounds for the board's own artwork, and white on the accent fill sits on a blue that does not invert. Reasoning in docs/2026-09-17-canvas-geist.md. Co-Authored-By: Claude Opus 5 --- RELEASE-NOTES.md | 7 ++ canvas/bun.lock | 6 + canvas/package.json | 2 + canvas/src/App.tsx | 21 +--- canvas/src/ChatPanel.tsx | 28 ++--- canvas/src/InspectorPanel.tsx | 195 ++++++-------------------------- canvas/src/geistIcons.tsx | 142 +++++++++++++++++++++++ canvas/src/index.css | 179 +++++++++++++++++------------ canvas/src/main.tsx | 5 + docs/2026-09-17-canvas-geist.md | 108 ++++++++++++++++++ 10 files changed, 423 insertions(+), 270 deletions(-) create mode 100644 canvas/src/geistIcons.tsx create mode 100644 docs/2026-09-17-canvas-geist.md diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index b0f5cfc5..90513157 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -96,6 +96,13 @@ A plus in the panel's header starts a new session: the log clears and the next message has nothing above it. Anything still running keeps running and stays in the history list — the button clears the view, it does not stop the agent. +The canvas is dark, and its panels are drawn in Vercel's Geist: its greys and +its accents, its icons, and Geist Sans and Geist Mono bundled so a board looks +the same offline as online. The chat panel, the inspector and the top bar all +read as one app now, and tldraw's own canvas is dark with them. Your boards are +untouched — a mockup is drawn by its own generator and keeps whatever palette it +was measured in. + ## v1.3.0 2026-09-15. The inspector moves onto the canvas: you click the board itself, diff --git a/canvas/bun.lock b/canvas/bun.lock index 8786f3e0..ef27beec 100644 --- a/canvas/bun.lock +++ b/canvas/bun.lock @@ -5,6 +5,8 @@ "": { "name": "prototyping-canvas", "dependencies": { + "@fontsource-variable/geist": "^5.3.0", + "@fontsource-variable/geist-mono": "^5.3.0", "@tldraw/commenting": "^5.4.2", "dompurify": "^3.4.15", "marked": "^18.0.13", @@ -58,6 +60,10 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.12", "", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="], + "@fontsource-variable/geist": ["@fontsource-variable/geist@5.3.0", "", {}, "sha512-j0m+vLQuG5XAYoHtGCVu0spvlGreR3EzpECUVzkFmI1mTVnAO38l/NEPDCFgZ177JxzYJCLSmTQibIiYPilGrA=="], + + "@fontsource-variable/geist-mono": ["@fontsource-variable/geist-mono@5.3.0", "", {}, "sha512-vBbuwDEo9AkrqADMXOrlAR3DFcJi4/JxeuU43FoiQERnNwsfXNnvxvReZG02cQKmyk4DZkZdBZX3oTDvy2zBAw=="], + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.3" }, "os": "darwin", "cpu": "arm64" }, "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA=="], diff --git a/canvas/package.json b/canvas/package.json index d2786a4a..28910e72 100644 --- a/canvas/package.json +++ b/canvas/package.json @@ -11,6 +11,8 @@ "preview": "vite preview" }, "dependencies": { + "@fontsource-variable/geist": "^5.3.0", + "@fontsource-variable/geist-mono": "^5.3.0", "@tldraw/commenting": "^5.4.2", "dompurify": "^3.4.15", "marked": "^18.0.13", diff --git a/canvas/src/App.tsx b/canvas/src/App.tsx index 294ea399..5470198d 100644 --- a/canvas/src/App.tsx +++ b/canvas/src/App.tsx @@ -26,7 +26,6 @@ import { type TLTextShape, useEditor, useLocalStore, - useValue, } from "tldraw"; import "tldraw/tldraw.css"; import "@tldraw/commenting/commenting.css"; @@ -247,22 +246,6 @@ function LockedLinkClicks() { return null; } -/** Blacks out the canvas under the welcome board, whose art runs to its own edges. */ -function WelcomeGround() { - const editor = useEditor(); - const isWelcome = useValue( - "on the welcome page", - () => editor.getCurrentPage().meta.canvasSlug === WELCOME_PAGE_SLUG, - [editor], - ); - useEffect(() => { - const container = editor.getContainer(); - container.classList.toggle("canvas-welcome-ground", isWelcome); - return () => container.classList.remove("canvas-welcome-ground"); - }, [editor, isWelcome]); - return null; -} - /** * A path as a single shell word. A boards directory is chosen by whoever ran `sp-canvas`, so it * can hold a space, and the command below is meant to be copied and run as it stands. @@ -1325,6 +1308,9 @@ export default function App() { function handleMount(editor: Editor) { setEditor(editor); + // tldraw's own dark theme, to the panel's. A dark rail against tldraw's near-white ground is + // two apps in one window, and the ground is the larger half of what that looks like. + editor.user.updateUserPreferences({ colorScheme: "dark" }); initializeCanvas(editor); // After the library, which is what creates the pages the comments are keyed to. const disposeComments = installCanvasComments(editor); @@ -1386,7 +1372,6 @@ export default function App() { inspectingPath={inspecting?.path ?? null} frame={inspectorFrame} /> - diff --git a/canvas/src/ChatPanel.tsx b/canvas/src/ChatPanel.tsx index 94344d81..ed830f01 100644 --- a/canvas/src/ChatPanel.tsx +++ b/canvas/src/ChatPanel.tsx @@ -34,6 +34,7 @@ import { WELCOME_PAGE_SLUG } from "./canvasUrl"; import { applyFrame, followRun, type Turn } from "./chatTransport"; import { ClaudeMark } from "./ClaudeMark"; import { CodexMark } from "./CodexMark"; +import { Check, ClockRewind, Plus, SidebarLeft } from "./geistIcons"; import { renderMarkdown } from "./markdown"; const RUNS_KEY = "sp-chat-runs"; @@ -48,12 +49,6 @@ const effortName = (e: string) => (e === "xhigh" ? "XHigh" : e.charAt(0).toUpper /** 26k, 272k: a token count is read at a glance or not at all. */ const tokens = (n: number) => (n >= 1000 ? `${Math.round(n / 1000)}k` : String(n)); -const Check = () => ( - - - -); - /** Each agent's mark, by the id the server names it with. */ const MARKS = { claude: ClaudeMark, codex: CodexMark }; @@ -303,9 +298,7 @@ export function ChatPanel() { aria-label="New session" title="New session" > - - - + )} @@ -346,7 +332,7 @@ export function ChatPanel() { {a.name} {!a.available && {a.missing}} - {a.id === agent && } + {a.id === agent && } ))} @@ -367,7 +353,7 @@ export function ChatPanel() { {m.name} {!m.id && Whatever {nameOf(agent)} is set to use} - {m.id === model && } + {m.id === model && } ))} @@ -386,7 +372,7 @@ export function ChatPanel() { Default Whatever {nameOf(agent)} is set to use - {!effort && } + {!effort && } setOpen((v) => !v)} > {badge} - - - + {open ? (
@@ -222,17 +229,7 @@ function BoardStatus({ path }: { path: string }) { {BOARD_STATUS_LABEL[option]} {option === status ? ( - - - + ) : null} ))} @@ -249,19 +246,7 @@ function BoardStatus({ path }: { path: string }) { title={`Back to ${BOARD_STATUS_LABEL[undo.back]}`} onClick={revert} > - - - - + Undo ) : null} @@ -577,18 +562,7 @@ export function InspectorPanel({ title="Open this board as a page" aria-label="Open this board as a page" > - - - + ) : null} @@ -800,18 +765,7 @@ export function ImagePanel({ title="Open the original" aria-label="Open the original" > - - - + ) : null} @@ -931,64 +876,13 @@ function CopySvg({ svg }: { svg: string }) { ); } -function LayerIcon({ kind }: { kind: ReturnType }) { - if (kind === "vector") - return ( - - - - - - ); - if (kind === "text") - return ( - - - - ); - if (kind === "image") - return ( - - - - - ); - if (kind === "frame") - return ( - - - - ); - return ( - - - - ); -} +const LAYER_ICON: Record ReactElement> = { + vector: Pen, + text: TextTitle, + image: Image, + frame: Layout, + box: Box, +}; /** Adds an index to a set, or takes it out: what the fold and the eye do to their set. */ function toggled(set: ReadonlySet, i: number) { @@ -1078,7 +972,7 @@ function Layers({ title={layerSelector(n)} onClick={() => onSelect(n.i)} > - + {LAYER_ICON[layerKind(n)]({ size: 12 })} {layerName(n, names.get(n.i))} {n.i === 0 && n.box ? ( @@ -1096,7 +990,7 @@ function Layers({ aria-pressed={hidden.has(n.i)} onClick={() => onHide(n.i)} > - + {hidden.has(n.i) ? : } ) : null}
@@ -1106,27 +1000,8 @@ function Layers({ } function Caret({ open }: { open: boolean }) { - return ( - - - - ); -} - -function Eye({ off }: { off: boolean }) { - return ( - - - - {off ? : null} - - ); + const Glyph = open ? ChevronDownSmall : ChevronRightSmall; + return ; } function Summary({ diff --git a/canvas/src/geistIcons.tsx b/canvas/src/geistIcons.tsx new file mode 100644 index 00000000..15c3febb --- /dev/null +++ b/canvas/src/geistIcons.tsx @@ -0,0 +1,142 @@ +/** + * The glyphs the panel uses, from Vercel's Geist icon set, at its geometry. + * + * Copied in rather than installed: `geist-icons` on npm is a third-party mirror of all 455 of + * them in one 600K module, and sixteen path strings are smaller than the dependency, let alone + * the mirror's trust. Geist draws every icon as a single filled path on a 16x16 grid — no + * strokes — so one wrapper covers all of them and `size` is the only knob. + */ + +const Icon = ({ + d, + size = 16, + className, +}: { + d: string; + size?: number; + className?: string; +}) => ( + + + +); + +type IconProps = { size?: number; className?: string }; + +export const Check = (p: IconProps) => ( + +); + +export const ChevronDownSmall = (p: IconProps) => ( + +); + +export const ChevronRightSmall = (p: IconProps) => ( + +); + +export const Cross = (p: IconProps) => ( + +); + +export const Plus = (p: IconProps) => ( + +); + +export const Eye = (p: IconProps) => ( + +); + +export const EyeOff = (p: IconProps) => ( + +); + +export const Fullscreen = (p: IconProps) => ( + +); + +export const RefreshCounterClockwise = (p: IconProps) => ( + +); + +export const ClockRewind = (p: IconProps) => ( + +); + +export const SidebarLeft = (p: IconProps) => ( + +); + +export const Pen = (p: IconProps) => ( + +); + +export const TextTitle = (p: IconProps) => ( + +); + +export const Image = (p: IconProps) => ( + +); + +export const Layout = (p: IconProps) => ( + +); + +export const Box = (p: IconProps) => ( + +); diff --git a/canvas/src/index.css b/canvas/src/index.css index 14e832b2..5e8c0fe9 100644 --- a/canvas/src/index.css +++ b/canvas/src/index.css @@ -11,7 +11,7 @@ body, } body { - font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-family: var(--sp-sans); } button { @@ -25,12 +25,17 @@ button { } /* - * The welcome board is full-bleed black line work. On tldraw's default near-white ground it - * reads as a slab with a hard edge, so the ground goes black under it and the page's own - * captions go white to match. App.tsx toggles the class per current page. + * The canvas ground, on the same token as the panels beside it. tldraw's own dark ground is + * hsl(240 5% 6.5%), a shade off black and a shade off them, which reads as two apps in one + * window — the thing asking for tldraw's dark theme was meant to stop. Geist has one default + * ground: "in most instances, you should use Background 1", and Background 2 "sparingly when a + * subtle background differentiation is needed". A rail next to a canvas is not that: the + * panel's own 1px border is what separates them. */ -.tl-container.canvas-welcome-ground { - --tl-color-background: #08080a; +/* Two classes, because the value it replaces is `.tl-theme__dark`'s and one class would only + win on import order. */ +.tl-container.tl-theme__dark { + --tl-color-background: var(--ds-background-100); } /* @@ -128,15 +133,15 @@ input.canvas-clone-name:focus { padding: 32px; pointer-events: none; text-align: center; - color: #3c3c43; - font: 400 15px/1.55 Inter, ui-sans-serif, system-ui, -apple-system, sans-serif; + color: var(--ds-gray-900); + font: 400 15px/1.55 var(--sp-sans); } .canvas-empty__title { margin: 0; font-size: 24px; font-weight: 700; - color: #1c1c1e; + color: var(--ds-gray-1000); } .canvas-empty__body { @@ -148,8 +153,8 @@ input.canvas-clone-name:focus { .canvas-empty__cmd { pointer-events: all; user-select: text; - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - background: rgba(0, 0, 0, 0.05); + font-family: var(--sp-mono); + background: var(--ds-gray-alpha-300); border-radius: 8px; } @@ -195,53 +200,82 @@ input.canvas-clone-name:focus { } /* - Geist, at the published values of the steps this panel uses: 100-300 are component - backgrounds, 400 a border, 700 a solid fill or a dot, 900-1000 text. They are spelled out - rather than imported because the canvas ships as a plugin, and there is no design-system - package on the other side of an install. + Geist's scale, at the values Vercel publishes in vercel.com/geist/vercel-brand.css (GeistCN + snapshot 4f5d9c0da9ad400918e15083c83626572f19fd9b). The names are Geist's own `--ds-*`, so a + path lifted out of its icon set reads the same here as it does there. Spelled out rather than + imported because the canvas ships as a plugin and there is no design-system package on the + other side of an install; on `:root` rather than on the panel because the top bar's own + controls are part of the same system. + + Geist publishes each step as a `light-dark()` pair, so both halves are here and + `color-scheme` below picks which one every token resolves to. Dark, unconditionally: it is + what the canvas opens as, and a board is easier to read against a ground that is not competing + with it. One word here is the whole switch. */ -.sp-panel { - --ds-gray-100: #f2f2f2; - --ds-gray-200: #ebebeb; - --ds-gray-300: #e6e6e6; - --ds-gray-400: #ebebeb; - --ds-gray-700: #8f8f8f; - --ds-gray-900: #4d4d4d; - --ds-gray-1000: #171717; - --ds-blue-100: #f0f7ff; - --ds-blue-200: #ebf5ff; - --ds-blue-300: #e0f0ff; - --ds-blue-400: #cce6ff; - --ds-blue-700: #0072f5; - --ds-blue-900: #0068d6; - --ds-amber-100: #fff6e6; - --ds-amber-200: #fff4d6; - --ds-amber-300: #fef0cd; - --ds-amber-400: #ffdd8f; - --ds-amber-700: #ffb224; - --ds-amber-900: #a35200; +:root { + color-scheme: dark; + --ds-background-100: light-dark(oklch(1 0 0), oklch(0 0 0)); + --ds-background-200: light-dark(oklch(0.984 0 0), oklch(0.027 0 0)); + --ds-gray-100: light-dark(oklch(0.961 0 0), oklch(0.218 0 0)); + --ds-gray-200: light-dark(oklch(0.94 0 0), oklch(0.239 0 0)); + --ds-gray-300: light-dark(oklch(0.925 0 0), oklch(0.281 0 0)); + --ds-gray-400: light-dark(oklch(0.937 0 0), oklch(0.301 0 0)); + --ds-gray-700: light-dark(oklch(0.65 0 0), oklch(0.65 0 0)); + --ds-gray-900: light-dark(oklch(0.42 0 0), oklch(0.706 0 0)); + --ds-gray-1000: light-dark(oklch(0.205 0 0), oklch(0.946 0 0)); + --ds-gray-alpha-300: light-dark(oklch(0 0 0 / 0.1), oklch(1 0 0 / 0.13)); + --ds-gray-alpha-400: light-dark(oklch(0 0 0 / 0.08), oklch(1 0 0 / 0.14)); + --ds-blue-100: light-dark(oklch(97.32% 0.0141 251.56), oklch(22.17% 0.069 259.89)); + --ds-blue-400: light-dark(oklch(91.58% 0.0473 245.116), oklch(34.1% 0.121 254.74)); + --ds-blue-700: light-dark(oklch(57.61% 0.2508 258.23), oklch(57.61% 0.2321 258.23)); + --ds-blue-900: light-dark(oklch(53.18% 0.2399 256.99), oklch(71.7% 0.1648 250.794)); + --ds-amber-100: light-dark(oklch(97.48% 0.0331 85.79), oklch(22.46% 0.0538 76.04)); + --ds-amber-400: light-dark(oklch(91.02% 0.1322 88.25), oklch(35.53% 0.0903 66.297)); + --ds-amber-700: light-dark(oklch(81.87% 0.1969 76.46), oklch(81.87% 0.1969 76.46)); + --ds-amber-900: light-dark(oklch(52.79% 0.1496 54.65), oklch(77.21% 0.1991 64.28)); + --ds-red-900: light-dark(oklch(54.99% 0.232 25.29), oklch(69.96% 0.2136 22.03)); + /* Geist publishes 100, 400, 700, 900 and 1000 of a hue and nothing between. The status badge + wants two presses' worth of ground between its 100 and its 400, so those two steps are + interpolated on the scale's own ramp rather than picked by eye. */ + --ds-blue-200: color-mix(in oklab, var(--ds-blue-100), var(--ds-blue-400) 33%); + --ds-blue-300: color-mix(in oklab, var(--ds-blue-100), var(--ds-blue-400) 66%); + --ds-amber-200: color-mix(in oklab, var(--ds-amber-100), var(--ds-amber-400) 33%); + --ds-amber-300: color-mix(in oklab, var(--ds-amber-100), var(--ds-amber-400) 66%); + /* The 1px ring is the token rather than the published `#00000014`: on a dark ground a black + ring is no edge at all, and a menu with no edge is a menu floating in the panel. */ --ds-shadow-menu: - 0 0 0 1px #00000014, 0 1px 1px #00000005, 0 4px 8px -4px #0000000a, - 0 16px 24px -8px #0000000f; - --ds-focus-ring: 0 0 0 2px #ffffff, 0 0 0 4px var(--ds-blue-700); + 0 0 0 1px var(--ds-gray-alpha-400), 0 1px 1px #00000005, + 0 4px 8px -4px #0000000a, 0 16px 24px -8px #0000000f; + --ds-focus-ring: 0 0 0 2px var(--ds-background-100), 0 0 0 4px var(--ds-blue-700); + --sp-sans: "Geist Variable", Geist, ui-sans-serif, system-ui, -apple-system, sans-serif; + --sp-mono: "Geist Mono Variable", "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace; +} +.sp-panel { + --sp-bg: var(--ds-background-100); --sp-line: var(--ds-gray-400); --sp-text: var(--ds-gray-1000); --sp-text-2: var(--ds-gray-700); - --sp-field: #fafafa; + --sp-field: var(--ds-background-200); --sp-hover: var(--ds-gray-100); --sp-sel: var(--ds-blue-100); --sp-accent: var(--ds-blue-700); - --sp-mono: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace; + --sp-warn: var(--ds-amber-900); + --sp-danger: var(--ds-red-900); display: flex; flex: 0 0 auto; height: 100%; border-left: 1px solid var(--sp-line); - background: #ffffff; + background: var(--sp-bg); color: var(--sp-text); - font: 400 12px/16px Geist, Inter, ui-sans-serif, system-ui, -apple-system, sans-serif; + font: 400 12px/16px var(--sp-sans); letter-spacing: -0.01em; + /* After the shorthand, which resets all four. Geist ships with contextual alternates off and + ss11 on; that pair is what gives its `a` and its numerals the shapes Vercel draws. */ + font-feature-settings: "rlig" 1, "calt" 0, "ss11" 1; font-variant-numeric: tabular-nums; + font-kerning: normal; + font-optical-sizing: auto; -webkit-font-smoothing: antialiased; user-select: none; } @@ -275,7 +309,9 @@ input.canvas-clone-name:focus { object-fit: contain; padding: 8px 12px 4px; /* Checkerboard: most of these are cut-outs with alpha, and alpha over a flat ground is a - shape you cannot tell from a white one. */ + shape you cannot tell from a white one. Light in both themes, along with the dark ground + the photo thumbs take: these are grounds for the board's own artwork, and artwork that + inverted with the panel would be artwork nobody could check. */ background-image: linear-gradient(45deg, #eeeeee 25%, transparent 25%, transparent 75%, #eeeeee 75%), linear-gradient(45deg, #eeeeee 25%, transparent 25%, transparent 75%, #eeeeee 75%); @@ -346,8 +382,8 @@ input.canvas-clone-name:focus { margin-left: 3px; border-radius: 8px; /* White against the bar's grey, which is what makes it read as the one thing to press here. */ - background: #ffffff; - box-shadow: inset 0 0 0 1px #e3e3e3; + background: var(--ds-background-100); + box-shadow: inset 0 0 0 1px var(--ds-gray-alpha-400); font-weight: 500; white-space: nowrap; text-decoration: none; @@ -355,7 +391,7 @@ input.canvas-clone-name:focus { .tlui-button.sp-figma:hover, .tlui-button.sp-brand:hover { - background: #f7f7f7; + background: var(--ds-gray-100); } /* On a phone the bar is already tight against the page menu; the mark alone still says Figma. */ @@ -473,12 +509,12 @@ input.canvas-clone-name:focus { padding: 0 10px; border: 1px solid var(--ds-gray-400); border-radius: 9999px; - background: #fff; + background: var(--ds-background-100); color: var(--ds-gray-1000); font: inherit; font-weight: 500; cursor: pointer; - box-shadow: 0 0 0 1px #ffffffa8; + box-shadow: 0 0 0 1px color-mix(in oklab, var(--ds-background-100) 66%, transparent); transition: background 0.12s ease; } @@ -503,7 +539,7 @@ input.canvas-clone-name:focus { color: var(--fg); font-weight: 500; /* Against a board that may be any colour at all, not a drop shadow. */ - box-shadow: 0 0 0 1px #ffffffa8; + box-shadow: 0 0 0 1px color-mix(in oklab, var(--ds-background-100) 66%, transparent); transition: background 0.12s ease; } @@ -580,7 +616,7 @@ button.sp-status[aria-expanded="true"] .sp-status-chev { width: 196px; padding: 6px; border-radius: 12px; - background: #ffffff; + background: var(--ds-background-100); box-shadow: var(--ds-shadow-menu); } @@ -623,10 +659,10 @@ button.sp-status[aria-expanded="true"] .sp-status-chev { right: 0; padding: 4px 8px; border-radius: 6px; - background: #ffffff; + background: var(--ds-background-100); box-shadow: var(--ds-shadow-menu); white-space: nowrap; - color: #cb2a2f; + color: var(--sp-danger); } .sp-head-dim { @@ -934,7 +970,7 @@ button.sp-status[aria-expanded="true"] .sp-status-chev { .sp-link { flex: none; color: var(--sp-accent); - font-family: Inter, ui-sans-serif, system-ui, sans-serif; + font-family: var(--sp-sans); cursor: pointer; } @@ -960,14 +996,14 @@ button.sp-status[aria-expanded="true"] .sp-status-chev { .sp-state { padding: 0 0 4px 96px; line-height: 16px; - color: #b25d00; + color: var(--sp-warn); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .sp-state.overridden { - color: #c0392b; + color: var(--sp-danger); } .sp-decl.overridden .sp-k { @@ -982,7 +1018,7 @@ button.sp-status[aria-expanded="true"] .sp-status-chev { gap: 5px; padding: 0 6px 0 4px; border-radius: 4px; - background: #efefef; + background: var(--ds-gray-100); white-space: nowrap; cursor: pointer; } @@ -997,7 +1033,7 @@ button.sp-status[aria-expanded="true"] .sp-status-chev { } .sp-pill:hover { - background: #e4e4e4; + background: var(--ds-gray-300); } .sp-sw { @@ -1005,7 +1041,7 @@ button.sp-status[aria-expanded="true"] .sp-status-chev { height: 12px; flex: none; border-radius: 2px; - box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.12); + box-shadow: inset 0 0 0 1px var(--ds-gray-alpha-300); } .sp-sw--none { @@ -1081,7 +1117,7 @@ button.sp-status[aria-expanded="true"] .sp-status-chev { line-height: 12px; padding: 0 3px; border-radius: 3px; - background: #efefef; + background: var(--ds-gray-100); color: var(--sp-text-2); } @@ -1162,7 +1198,7 @@ button.sp-status[aria-expanded="true"] .sp-status-chev { max-height: 45%; display: flex; flex-direction: column; - background: #ffffff; + background: var(--sp-bg); border-top: 1px solid var(--sp-line); } @@ -1513,7 +1549,7 @@ button.sp-status[aria-expanded="true"] .sp-status-chev { .sp-chat-tool[data-ok="false"] b, .sp-chat-error { - color: #cb2a2f; + color: var(--sp-danger); } .sp-chat-error { @@ -1596,21 +1632,22 @@ button.sp-status[aria-expanded="true"] .sp-status-chev { overscroll-behavior: contain; border: 1px solid var(--sp-line); border-radius: 8px; - background: #fff; - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); + background: var(--ds-background-100); + box-shadow: var(--ds-shadow-menu); } .sp-chat-slash .sp-menu-row { height: 28px; - font-family: var(--sp-mono, ui-monospace, monospace); + font-family: var(--sp-mono); } -/* The accent, not the hover grey: the hover grey is a 5% step on white, so the arrow keys moved - the selection without anything on screen appearing to move. It also has to outrank :hover, - which paints the same grey on whichever row the pointer happens to be resting over. */ +/* The accent, not the hover grey: the hover grey is one step off the panel's own ground, so the + arrow keys moved the selection without anything on screen appearing to move. It also has to + outrank :hover, which paints the same grey on whichever row the pointer happens to be resting + over. */ .sp-chat-slash .sp-menu-row.sp-chat-slash-on { background: var(--sp-accent); - color: #fff; + color: #ffffff; } /* Collapsed: a rail the width of the mark, which is the button that opens the panel again. The @@ -1644,9 +1681,9 @@ button.sp-status[aria-expanded="true"] .sp-status-chev { padding: 4px; border: 1px solid var(--sp-line); border-radius: 8px; - background: #fff; + background: var(--sp-bg); color: var(--sp-text); - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); + box-shadow: var(--ds-shadow-menu); } .sp-chat-history { @@ -1699,7 +1736,7 @@ button.sp-status[aria-expanded="true"] .sp-status-chev { } .sp-chat-history-row[data-status="failed"] .sp-chat-history-title { - color: #cb2a2f; + color: var(--sp-danger); } /* The agent menu: the inspector's rows, grown to fit a second line under a CLI that was not diff --git a/canvas/src/main.tsx b/canvas/src/main.tsx index bef5202a..49a4e5c3 100644 --- a/canvas/src/main.tsx +++ b/canvas/src/main.tsx @@ -1,5 +1,10 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +// Bundled, not linked from a CDN: the canvas is mostly a local dev server, and a board that +// falls back to the system font offline is a board that measures differently. Fontsource splits +// each family by `unicode-range`, so only the latin subset is ever fetched. +import '@fontsource-variable/geist' +import '@fontsource-variable/geist-mono' import './index.css' import App from './App.tsx' diff --git a/docs/2026-09-17-canvas-geist.md b/docs/2026-09-17-canvas-geist.md new file mode 100644 index 00000000..7c0ad3f8 --- /dev/null +++ b/docs/2026-09-17-canvas-geist.md @@ -0,0 +1,108 @@ +# The canvas panels move to Geist, and go dark + +2026-09-17. The chat panel and the inspector are drawn in Vercel's Geist — its scale, its +fonts, its icons — and the whole canvas opens dark. The adoption was half-built already: the +panel's CSS named `Geist Variable` in a `font` shorthand and carried nineteen steps of the scale +under Geist's own `--ds-*` names. But the fonts were never loaded, the steps were hex +approximations of oklch values, light-only, scoped to the panel — and another two dozen colours +around them were plain hex literals owing nothing to the system. This finishes it, and settles +what a design system means for something that ships as a plugin. + +## The tokens are transcribed, not installed + +There is no design-system package on the other side of an install. The plugin ships skills and a +Vite app; a user's project holds boards. So the scale is spelled out in `index.css`, at the +values Vercel publishes — `vercel.com/geist/vercel-brand.css`, GeistCN snapshot +`4f5d9c0da9ad400918e15083c83626572f19fd9b` — under Geist's own names, because a path lifted out +of its icon set reads the same here as it does there. That file namespaces everything `--vbg-*` +for a report site; the values are Geist's, the names are not, and it is the values that were +wanted. + +Geist publishes 100, 400, 700, 900 and 1000 of a hue and nothing between. The status badge wants +two presses' worth of ground between its 100 and its 400, and the four missing steps were sitting +here as hex picked by eye. They are now `color-mix(in oklab, …)` along the scale's own ramp — 33% +and 66% of the way from the 100 to the 400, in the space the scale is already defined in — so +they follow the published ends instead of being a guess that has to be re-guessed per theme. +Measured in the browser rather than assumed, since `color-mix()` is being handed `light-dark()` +arguments: `--ds-blue-200` computes to `oklab(0.261069 -0.0186247 -0.0840343)`, a third of the way +from blue-100's L 0.2217 to blue-400's L 0.341. + +The tokens live on `:root`, not on `.sp-panel` where they were. The top bar's chips are part of +the same system and sit outside the panel. + +## Dark is one word + +Every step is a `light-dark()` pair and `color-scheme: dark` on `:root` picks which half every +token resolves to. No second stylesheet, no class on ``, no theme provider. A light theme +is the same file with a different word, the day anyone wants one. + +tldraw gets `editor.user.updateUserPreferences({ colorScheme: "dark" })` on mount rather than a +hand re-skin of its chrome. A dark rail against tldraw's near-white ground is two apps in one +window, and the ground is the larger half of what the window looks like; tldraw already has the +dark theme its own components were designed for, and one line asks for it. + +That deleted a component. `WelcomeGround` blacked out the canvas under the welcome board because +the board's full-bleed line work read as a slab on tldraw's near-white ground — an effect, a +class toggle and a `--tl-color-background` override for a problem that was the light ground. +tldraw's dark ground is `hsl(240 5% 6.5%)`, within a shade of the override, so the override is now +the thing it was correcting for. + +## Five colours do not invert + +Two kinds of thing must not follow the theme, and both are visible on screen next to things that +do: + +- **Grounds for the board's own artwork.** The four checkerboard stops behind a thumbnail and the + `#1c1c1e` behind a photo. A checkerboard exists so a cut-out with alpha can be told from a + white rectangle; inverted with the panel it is a dark check behind dark artwork, which defeats + the only reason it is there. Artwork that inverts with the UI is artwork nobody can check. +- **Text on the accent fill.** `--ds-blue-700` is the same blue in both themes, so white on it + stays `#ffffff` rather than `var(--ds-background-100)`, which would have turned black under a + selected row. + +The two badge legibility rings went the other way and became relative: +`color-mix(in oklab, var(--ds-background-100) 66%, transparent)`. They hold a badge off whatever +board is behind it, so they belong to the panel, not to the board. + +The menu shadow's 1px ring is the token rather than the published `#00000014`. On a dark ground a +black ring is no edge at all, and a menu with no edge is a menu floating in the panel; the ring +is `--ds-gray-alpha-400`, whose dark half is white at 14%. + +## Sixteen path strings, not a package + +`geistIcons.tsx` is sixteen `d` strings and one wrapper, replacing eighteen hand-drawn SVGs of +mixed geometry and stroke weight. Geist draws every icon as a single filled `currentColor` path +on a 16×16 grid — no strokes — so one component covers all of them and `size` is the only knob. + +`geist-icons` on npm is a third-party mirror: all 455 icons in one 600K ESM module, one +maintainer. Sixteen path strings are smaller than the dependency, let alone the trust. If the +panel ever wants fifty of them, that is the day to reconsider. + +Two functions went away with the icons rather than being kept next to them. `LayerIcon`'s +five-branch switch is a `Record` of five Geist glyphs, and `Caret` picks between +`ChevronDownSmall` and `ChevronRightSmall` instead of rotating one path. + +## The fonts are bundled + +`@fontsource-variable/geist` and `-geist-mono`, imported in `main.tsx`, not linked from Google +Fonts. The canvas is mostly a local dev server, and a board that silently falls back to the +system font is a board that measures differently — the one thing this repo's boards exist to be +right about. Fontsource splits each family by `unicode-range`, so only the latin subset is ever +fetched. + +One trap, worth writing down: the CSS `font` shorthand resets `font-feature-settings`, +`font-kerning`, `font-variant-numeric` and `font-optical-sizing`. The panel sets all four, and +they have to come after the shorthand or they are silently dropped. + +## Left out + +- A light theme, and anything that switches between the two. `color-scheme` is the switch; there + is nowhere yet for a user to reach it, and a toggle with no second opinion behind it is a + preference nobody asked for. +- `sheet.css` and `brand.css`, which both pin `color-scheme: light` on purpose: a contact sheet + and a brand kit are documents about boards, printed on white. +- The brand marks — Claude, Codex, Figma, the brand-kit palette — which are other people's + identities at their own colours, and the welcome board's own art. +- tldraw's chrome beyond the one preference line. Its dark theme is the vendor's; restyling it to + Geist would be a per-release maintenance cost for a toolbar nobody is looking at while reading + a board. From b73ca2affbf4616a2839c2a41edbbfca24af1b67 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Thu, 17 Sep 2026 16:29:28 -0400 Subject: [PATCH 02/32] canvas: let a board sit on the canvas, not on a white card The dark ground showed what a white one had hidden: every mockup was a white card. Two different things were painting that white. Six generators, and raycast's hand-written boards, declared `background:#fff` in the shared screen `body{}` rule -- drift from `templates/gen.py`, which has never had it. That is data, so it is gone and the boards are regenerated. The `.phone` frame already paints its own opaque ground, so nothing inside a phone moved. Every other board declared nothing and was white anyway: a frame paints an opaque base background under the document it loads, and nothing outside the frame reaches it -- not `background` on the element, not `allowtransparency`, not `color-scheme` on the element, not a transparent `html` injected inside. Measured, all five still paint #FFFFFF. A dark `color-scheme` on the board's own `:root` is the one lever that releases it, so CanvasFileShapeUtil injects that straight after the doctype, with the root's `color` pinned back to the black a light scheme was giving it. It names no colour, so a board shows whatever canvas is behind it and a light theme needs no second rule. Both skills grew the rule, so the next generator does not put the ground back. Reasoning in docs/2026-09-17-canvas-geist.md. Co-Authored-By: Claude Opus 5 --- RELEASE-NOTES.md | 16 +++++-- canvas/src/CanvasFileShapeUtil.tsx | 25 ++++++++++- docs/2026-09-17-canvas-geist.md | 45 +++++++++++++++++++ .../apple-calendar/00-design-tokens.html | 2 +- .../canvases/apple-calendar/00b-evidence.html | 2 +- .../canvases/apple-calendar/00c-evidence.html | 2 +- .../canvases/apple-calendar/00d-evidence.html | 2 +- .../canvases/apple-calendar/00e-evidence.html | 2 +- .../apple-calendar/01-today-events.html | 2 +- .../apple-calendar/02-today-empty.html | 2 +- mockups/canvases/apple-calendar/03-month.html | 2 +- .../canvases/apple-calendar/04-whats-new.html | 2 +- .../05-location-permission.html | 2 +- .../06-notifications-permission.html | 2 +- .../canvases/apple-calendar/07-new-event.html | 2 +- .../apple-calendar/08-event-details.html | 2 +- .../apple-calendar/09-video-call.html | 2 +- .../apple-calendar/d01-today-events.html | 2 +- .../apple-calendar/d02-today-empty.html | 2 +- .../canvases/apple-calendar/d03-month.html | 2 +- .../apple-calendar/d04-whats-new.html | 2 +- .../d05-location-permission.html | 2 +- .../d06-notifications-permission.html | 2 +- .../apple-calendar/d07-new-event.html | 2 +- .../apple-calendar/d08-event-details.html | 2 +- .../apple-calendar/d09-video-call.html | 2 +- mockups/canvases/apple-calendar/gen.py | 2 +- .../apple-photos/00-design-tokens.html | 2 +- .../canvases/apple-photos/01-all-photos.html | 2 +- .../canvases/apple-photos/02-whats-new.html | 2 +- .../apple-photos/03-notifications.html | 2 +- mockups/canvases/apple-photos/gen.py | 2 +- .../apple-settings/00-design-tokens.html | 2 +- .../canvases/apple-settings/00b-evidence.html | 2 +- .../canvases/apple-settings/00c-evidence.html | 2 +- .../canvases/apple-settings/01-settings.html | 2 +- .../canvases/apple-settings/02-developer.html | 2 +- .../apple-settings/03-display-zoom.html | 2 +- .../canvases/apple-settings/d01-settings.html | 2 +- .../apple-settings/d02-developer.html | 2 +- .../apple-settings/d03-display-zoom.html | 2 +- mockups/canvases/apple-settings/gen.py | 2 +- .../apple-wallet/00-design-tokens.html | 2 +- .../canvases/apple-wallet/00b-evidence.html | 2 +- .../canvases/apple-wallet/00c-evidence.html | 2 +- .../apple-wallet/00d-design-tokens.html | 2 +- .../canvases/apple-wallet/00e-evidence.html | 2 +- .../canvases/apple-wallet/00f-evidence.html | 2 +- mockups/canvases/apple-wallet/01-cards.html | 2 +- .../canvases/apple-wallet/02-get-started.html | 2 +- .../apple-wallet/03-boarding-pass.html | 2 +- .../canvases/apple-wallet/04-store-card.html | 2 +- mockups/canvases/apple-wallet/05-key-a.html | 2 +- mockups/canvases/apple-wallet/06-key-b.html | 2 +- mockups/canvases/apple-wallet/07-key-c.html | 2 +- mockups/canvases/apple-wallet/d01-cards.html | 2 +- .../apple-wallet/d02-get-started.html | 2 +- mockups/canvases/apple-wallet/passes.py | 2 +- mockups/canvases/apple-wallet/screens.py | 2 +- .../canvases/notion-ios/00-design-tokens.html | 2 +- mockups/canvases/notion-ios/01-splash.html | 2 +- .../canvases/notion-ios/02-search-ask-ai.html | 2 +- .../notion-ios/03-notion-ai-chat.html | 2 +- .../canvases/notion-ios/04-meeting-page.html | 2 +- .../canvases/notion-ios/05-date-sheet.html | 2 +- .../notion-ios/06-share-settings-sheet.html | 2 +- .../notion-ios/07-manage-data-sources.html | 2 +- .../notion-ios/08-new-data-source.html | 2 +- .../09-manage-data-sources-two.html | 2 +- .../notion-ios/10-to-do-list-table.html | 2 +- .../notion-ios/11-add-an-account.html | 2 +- .../notion-ios/12-add-an-account-email.html | 2 +- .../13-add-an-account-email-filled.html | 2 +- .../notion-ios/14-add-an-account-code.html | 2 +- .../15-add-an-account-code-filled.html | 2 +- .../notion-ios/16-plan-plus-ai-monthly.html | 2 +- .../notion-ios/17-plan-plus-ai-yearly.html | 2 +- .../notion-ios/18-purchase-success.html | 2 +- mockups/canvases/notion-ios/gen.py | 2 +- .../canvases/raycast-ios/01-ask-anything.html | 2 +- .../canvases/raycast-ios/02-prompt-typed.html | 2 +- mockups/canvases/raycast-ios/03-thinking.html | 2 +- .../canvases/raycast-ios/04-streaming.html | 2 +- .../raycast-ios/05-answer-actions.html | 2 +- .../raycast-ios/06-answer-scrolled.html | 2 +- .../canvases/raycast-ios/07-models-sheet.html | 2 +- .../raycast-ios/08-home-composer.html | 2 +- .../raycast-ios/09-presets-opening.html | 2 +- .../canvases/raycast-ios/10-presets-list.html | 2 +- .../raycast-ios/11-home-perplexity.html | 2 +- skills/clone-prototype/SKILL.md | 8 ++++ skills/prototype-canvas/references/layout.md | 6 +++ 92 files changed, 182 insertions(+), 92 deletions(-) diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 90513157..6d1253be 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -98,10 +98,18 @@ the history list — the button clears the view, it does not stop the agent. The canvas is dark, and its panels are drawn in Vercel's Geist: its greys and its accents, its icons, and Geist Sans and Geist Mono bundled so a board looks -the same offline as online. The chat panel, the inspector and the top bar all -read as one app now, and tldraw's own canvas is dark with them. Your boards are -untouched — a mockup is drawn by its own generator and keeps whatever palette it -was measured in. +the same offline as online. The chat panel, the inspector, the top bar and the +canvas they sit around all read as one app now, on one ground, separated by +hairlines rather than by shade. Your boards are untouched — a mockup is drawn by +its own generator and keeps whatever palette it was measured in. + +One thing about a board does change, and it is what the dark ground exposed: a +mockup used to sit on a white card. A frame paints an opaque white background +of its own underneath whatever the board draws — invisible while the canvas was +white, a card around every phone once it is not. The canvas releases it, so the +canvas shows through wherever a board paints nothing and every pixel the board +does paint stays as it was. A board that wants a ground still declares one, a +token sheet or an evidence sheet, and looks exactly as it did. ## v1.3.0 diff --git a/canvas/src/CanvasFileShapeUtil.tsx b/canvas/src/CanvasFileShapeUtil.tsx index bf7d23e7..b0d312a9 100644 --- a/canvas/src/CanvasFileShapeUtil.tsx +++ b/canvas/src/CanvasFileShapeUtil.tsx @@ -34,7 +34,30 @@ export type CanvasFileShape = TLShape; function CanvasFile({ shape }: { shape: CanvasFileShape }) { const isEditing = useIsEditing(shape.id); const { inspectingPath, setInspectorFrame } = useContext(CanvasChromeContext); - const html = useCanvasFileHtml(shape.props.path); + const loaded = useCanvasFileHtml(shape.props.path); + + /** + * A frame paints an opaque white base background of its own, underneath whatever the board + * draws, and nothing out here reaches it: not `background` on the `