From ecf8035ec24bc81bd32d56ec7374a3809288669e Mon Sep 17 00:00:00 2001 From: Maya Kid <29959320+Maya-Kid@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:43:28 +1100 Subject: [PATCH 1/4] Declare client boundaries so installed interactive components work in server pages. Signed-off-by: Maya Kid <29959320+Maya-Kid@users.noreply.github.com> --- content/solid/status-glyph.mdx | 2 ++ src/blocks/agent-workspace.tsx | 2 ++ src/components/ui/ask-card.tsx | 2 ++ src/components/ui/composer.tsx | 2 ++ src/components/ui/context-ring.tsx | 2 ++ src/components/ui/status-glyph.tsx | 2 ++ src/components/ui/tool-steps.tsx | 2 ++ src/components/ui/turn-status.tsx | 2 ++ src/site/component-preview.tsx | 5 +---- 9 files changed, 17 insertions(+), 4 deletions(-) diff --git a/content/solid/status-glyph.mdx b/content/solid/status-glyph.mdx index 385cad7..d160a55 100644 --- a/content/solid/status-glyph.mdx +++ b/content/solid/status-glyph.mdx @@ -43,6 +43,8 @@ There are five kinds of work, and a way of moving for each. The points are alway A state that lasts minutes is not minutes of one motion: an activity takes several figures in turn, holding each for a few seconds and then turning into the next. `STATUS_FIGURES` lists every figure on its own, and the `figure` prop plays just one. +In a React Server Components project, the default glyph can be rendered from a server page. A `figure` is a function, so choose from `STATUS_FIGURES` or define a custom figure inside a `"use client"` component, then pass it to `StatusGlyph` there. The same applies to the `figure` prop on `TurnStatus`. + ## Inline diff --git a/src/blocks/agent-workspace.tsx b/src/blocks/agent-workspace.tsx index 00da0d1..b81d4df 100644 --- a/src/blocks/agent-workspace.tsx +++ b/src/blocks/agent-workspace.tsx @@ -1,3 +1,5 @@ +"use client" + import * as React from "react" import { ChevronRightIcon, diff --git a/src/components/ui/ask-card.tsx b/src/components/ui/ask-card.tsx index 9b7c7db..2f63447 100644 --- a/src/components/ui/ask-card.tsx +++ b/src/components/ui/ask-card.tsx @@ -1,3 +1,5 @@ +"use client" + import { cn } from "@/lib/utils" /** diff --git a/src/components/ui/composer.tsx b/src/components/ui/composer.tsx index 697e3ca..e672c74 100644 --- a/src/components/ui/composer.tsx +++ b/src/components/ui/composer.tsx @@ -1,3 +1,5 @@ +"use client" + import * as React from "react" import { ArrowUp, ChevronDown, Square } from "lucide-react" diff --git a/src/components/ui/context-ring.tsx b/src/components/ui/context-ring.tsx index 37f6f09..300c66f 100644 --- a/src/components/ui/context-ring.tsx +++ b/src/components/ui/context-ring.tsx @@ -1,3 +1,5 @@ +"use client" + import * as React from "react" import { ChevronDown } from "lucide-react" diff --git a/src/components/ui/status-glyph.tsx b/src/components/ui/status-glyph.tsx index 6e3687c..954bf3b 100644 --- a/src/components/ui/status-glyph.tsx +++ b/src/components/ui/status-glyph.tsx @@ -1,3 +1,5 @@ +"use client" + import * as React from "react" import { cn } from "@/lib/utils" diff --git a/src/components/ui/tool-steps.tsx b/src/components/ui/tool-steps.tsx index 6d9071e..f55b21d 100644 --- a/src/components/ui/tool-steps.tsx +++ b/src/components/ui/tool-steps.tsx @@ -1,3 +1,5 @@ +"use client" + import * as React from "react" import { ChevronRight } from "lucide-react" diff --git a/src/components/ui/turn-status.tsx b/src/components/ui/turn-status.tsx index 944cffa..53600c4 100644 --- a/src/components/ui/turn-status.tsx +++ b/src/components/ui/turn-status.tsx @@ -1,3 +1,5 @@ +"use client" + import * as React from "react" import { ChevronRight } from "lucide-react" diff --git a/src/site/component-preview.tsx b/src/site/component-preview.tsx index 330df41..a408155 100644 --- a/src/site/component-preview.tsx +++ b/src/site/component-preview.tsx @@ -42,9 +42,6 @@ function useSource(load: (() => Promise) | undefined) { return text } -/** As it would be written in a project: the site's own paths become the ones shadcn installs to. */ -const asInstalled = (code: string) => code.replace(/^"use client"\n\n?/, "") - /** * A component shown live over its code, as on ui.shadcn.com: the preview on top, and under it the * first lines of the source, fading out, with a button that shows the rest. @@ -67,7 +64,7 @@ export function ComponentPreview({ const Example = exampleOf(name) const source = useSource(sources[`/src/examples/${name}.tsx`]) const [open, setOpen] = React.useState(false) - const code = source ? asInstalled(source) : "" + const code = source ?? "" return (
From 6da9d145b9ef60157b4657531184cffc6a2f729e Mon Sep 17 00:00:00 2001 From: Maya Kid <29959320+Maya-Kid@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:10:19 +1100 Subject: [PATCH 2/4] fix: keep server-safe helpers across client boundaries Signed-off-by: Maya Kid <29959320+Maya-Kid@users.noreply.github.com> --- content/solid/context-ring.mdx | 2 + content/solid/turn-status.mdx | 2 + registry.json | 16 +++ scripts/build-registry.mjs | 24 ++-- src/components/ui/context-ring-client.tsx | 123 ++++++++++++++++++++ src/components/ui/context-ring-helpers.ts | 11 ++ src/components/ui/context-ring.tsx | 134 +--------------------- src/components/ui/turn-status-client.tsx | 104 +++++++++++++++++ src/components/ui/turn-status-helpers.ts | 8 ++ src/components/ui/turn-status.tsx | 112 +----------------- src/registry-files.json | 4 + src/site/component-preview.tsx | 16 ++- 12 files changed, 295 insertions(+), 261 deletions(-) create mode 100644 src/components/ui/context-ring-client.tsx create mode 100644 src/components/ui/context-ring-helpers.ts create mode 100644 src/components/ui/turn-status-client.tsx create mode 100644 src/components/ui/turn-status-helpers.ts create mode 100644 src/registry-files.json diff --git a/content/solid/context-ring.mdx b/content/solid/context-ring.mdx index 12e2bdc..9ea5caa 100644 --- a/content/solid/context-ring.mdx +++ b/content/solid/context-ring.mdx @@ -32,6 +32,8 @@ import { ContextRing } from "@/components/ui/context-ring" ``` +The exported `compactTokens(count)` helper can also be called in a server component. Keep all three installed files together: `context-ring-client.tsx` contains the interactive ring, and `context-ring-helpers.ts` contains the pure formatter. + ## Colour Blue while there is room, which is nearly always. Amber from 70%, and the colour of failure from 90%: nearly full is worth a second look. diff --git a/content/solid/turn-status.mdx b/content/solid/turn-status.mdx index 9ab79a2..fc9de51 100644 --- a/content/solid/turn-status.mdx +++ b/content/solid/turn-status.mdx @@ -37,6 +37,8 @@ import { TurnStatus } from "@/components/ui/turn-status" /> ``` +The exported `formatDuration(seconds)` helper can also be called in a server component. Keep all three installed files together: `turn-status-client.tsx` contains the interactive implementation and the `useElapsed` hook, and `turn-status-helpers.ts` contains the pure formatter. + ## With steps Given children, the line is a button and opens to show them: the calls of the step in hand. diff --git a/registry.json b/registry.json index 41828e1..ef6f59d 100644 --- a/registry.json +++ b/registry.json @@ -64,6 +64,14 @@ { "path": "src/components/ui/context-ring.tsx", "type": "registry:ui" + }, + { + "path": "src/components/ui/context-ring-client.tsx", + "type": "registry:ui" + }, + { + "path": "src/components/ui/context-ring-helpers.ts", + "type": "registry:ui" } ] }, @@ -206,6 +214,14 @@ { "path": "src/components/ui/turn-status.tsx", "type": "registry:ui" + }, + { + "path": "src/components/ui/turn-status-client.tsx", + "type": "registry:ui" + }, + { + "path": "src/components/ui/turn-status-helpers.ts", + "type": "registry:ui" } ] }, diff --git a/scripts/build-registry.mjs b/scripts/build-registry.mjs index 189b805..c0576a9 100644 --- a/scripts/build-registry.mjs +++ b/scripts/build-registry.mjs @@ -24,6 +24,8 @@ import { fileURLToPath } from "node:url" const root = join(dirname(fileURLToPath(import.meta.url)), "..") const out = join(root, "public/r") const read = (path) => readFileSync(join(root, path), "utf8") +// The same file groups drive the registry and the documentation's manual-copy source panels. +const componentFiles = JSON.parse(read("src/registry-files.json")) // Where the site is served from, and the repository on GitHub. const origin = (process.env.SOLID_GLASS_ORIGIN ?? "https://deeplethe.github.io/solid-glass").replace(/\/$/, "") const repo = "deeplethe/solid-glass" @@ -33,12 +35,13 @@ const own = readdirSync(join(root, "content/solid")) .sort() /** What a file needs: the packages it imports, and the components, its own by address and shadcn/ui's by name. */ -function needs(content) { +function needs(content, included = []) { const packages = new Set() const others = new Set() for (const [, source] of content.matchAll(/from "([^"]+)"/g)) { if (source === "react" || source === "@/lib/utils") continue const used = source.match(/^@\/components\/ui\/(.+)$/)?.[1] + if (included.includes(used)) continue if (used) others.add(own.includes(used) ? `${repo}/${used}` : used) else packages.add(source.split("/").slice(0, source.startsWith("@") ? 2 : 1).join("/")) } @@ -63,25 +66,18 @@ const blocks = readdirSync(join(root, "src/blocks")) }) const components = own.map((name) => { - const content = read(`src/components/ui/${name}.tsx`) + // A universal entry can keep pure helpers available to servers while its UI has a client boundary. + const names = componentFiles[name] ?? [`${name}.tsx`] + const files = names.map((file) => ({ path: `src/components/ui/${file}`, type: "registry:ui" })) + const content = files.map((file) => read(file.path)).join("\n") const front = read(`content/solid/${name}.mdx`) - const packages = new Set() - const others = new Set() - for (const [, source] of content.matchAll(/from "([^"]+)"/g)) { - if (source === "react" || source === "@/lib/utils") continue - const used = source.match(/^@\/components\/ui\/(.+)$/)?.[1] - // One of Solid Glass's own is named by its GitHub address; one of shadcn/ui's by its name. - if (used) others.add(own.includes(used) ? `${repo}/${used}` : used) - else packages.add(source.split("/").slice(0, source.startsWith("@") ? 2 : 1).join("/")) - } return { name, type: "registry:ui", title: front.match(/^title:\s*(.*)$/m)?.[1], description: front.match(/^description:\s*(.*)$/m)?.[1], - dependencies: [...packages].sort(), - registryDependencies: [...others].sort(), - files: [{ path: `src/components/ui/${name}.tsx`, type: "registry:ui" }], + ...needs(content, names.map((file) => file.replace(/\.tsx?$/, ""))), + files, } }) diff --git a/src/components/ui/context-ring-client.tsx b/src/components/ui/context-ring-client.tsx new file mode 100644 index 0000000..54550ec --- /dev/null +++ b/src/components/ui/context-ring-client.tsx @@ -0,0 +1,123 @@ +"use client" + +import * as React from "react" +import { ChevronDown } from "lucide-react" + +import { cn } from "@/lib/utils" +import { compactTokens, type ContextPart } from "@/components/ui/context-ring-helpers" +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" + +const share = (tokens: number, window: number) => (window > 0 ? `${((tokens / window) * 100).toFixed(1)}%` : "—") + +/** + * How full a model's context window is, as a ring: read at a glance, opened + * for the numbers and for what the context is full of. + * + * Blue while there is room, which is nearly always; amber from 70%, the + * colour of failure from 90% — nearly full is worth a second look. + */ +function ContextRing({ + used, + window, + parts = [], + title = "Context window", + note = "How much of the window this conversation takes up. Earlier turns are compacted as it fills.", + freeLabel = "Free", + className, +}: { + used: number + window: number + parts?: ContextPart[] + title?: string + note?: string + freeLabel?: string + className?: string +}) { + const [detailed, setDetailed] = React.useState(false) + const fraction = window > 0 ? Math.min(used / window, 1) : 0 + const percent = Math.round(fraction * 100) + const r = 6 + const around = 2 * Math.PI * r + const tone = fraction >= 0.9 ? "text-destructive" : fraction >= 0.7 ? "text-amber-500" : "text-blue-500" + const counted = parts.reduce((sum, part) => sum + part.tokens, 0) + const free = Math.max(window - Math.max(used, counted), 0) + + return ( + + + + + + + + {/* One bar, a segment to a part. Without parts it is the plain total. */} +
+ {parts.length ? ( + parts.map((part) => ( +
0 ? (part.tokens / window) * 100 : 0}%` }} + /> + )) + ) : ( +
+ )} +
+ + {detailed && parts.length > 0 && ( +
    + {[...parts, { key: "free", label: freeLabel, tokens: free, tint: "bg-secondary" }].map((part) => ( +
  • +
  • + ))} +
+ )} + +

{note}

+ + + ) +} + +export { ContextRing } diff --git a/src/components/ui/context-ring-helpers.ts b/src/components/ui/context-ring-helpers.ts new file mode 100644 index 0000000..df1c427 --- /dev/null +++ b/src/components/ui/context-ring-helpers.ts @@ -0,0 +1,11 @@ +/** One thing the context is full of, and the colour it is drawn in (a background class). */ +export type ContextPart = { key: string; label: string; tokens: number; tint: string } + +/** 168100 → "168.1k", 1000000 → "1M": the way a window's size is said aloud. */ +function compactTokens(n: number): string { + if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(1)}M` + if (n >= 1_000) return `${+(n / 1_000).toFixed(1)}k` + return String(n) +} + +export { compactTokens } diff --git a/src/components/ui/context-ring.tsx b/src/components/ui/context-ring.tsx index 300c66f..9d627d8 100644 --- a/src/components/ui/context-ring.tsx +++ b/src/components/ui/context-ring.tsx @@ -1,132 +1,2 @@ -"use client" - -import * as React from "react" -import { ChevronDown } from "lucide-react" - -import { cn } from "@/lib/utils" -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" - -/** One thing the context is full of, and the colour it is drawn in (a background class). */ -export type ContextPart = { key: string; label: string; tokens: number; tint: string } - -/** 168100 → "168.1k", 1000000 → "1M": the way a window's size is said aloud. */ -function compactTokens(n: number): string { - if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(1)}M` - if (n >= 1_000) return `${+(n / 1_000).toFixed(1)}k` - return String(n) -} - -const share = (tokens: number, window: number) => (window > 0 ? `${((tokens / window) * 100).toFixed(1)}%` : "—") - -/** - * How full a model's context window is, as a ring: read at a glance, opened - * for the numbers and for what the context is full of. - * - * Blue while there is room, which is nearly always; amber from 70%, the - * colour of failure from 90% — nearly full is worth a second look. - */ -function ContextRing({ - used, - window, - parts = [], - title = "Context window", - note = "How much of the window this conversation takes up. Earlier turns are compacted as it fills.", - freeLabel = "Free", - className, -}: { - used: number - window: number - parts?: ContextPart[] - title?: string - note?: string - freeLabel?: string - className?: string -}) { - const [detailed, setDetailed] = React.useState(false) - const fraction = window > 0 ? Math.min(used / window, 1) : 0 - const percent = Math.round(fraction * 100) - const r = 6 - const around = 2 * Math.PI * r - const tone = fraction >= 0.9 ? "text-destructive" : fraction >= 0.7 ? "text-amber-500" : "text-blue-500" - const counted = parts.reduce((sum, part) => sum + part.tokens, 0) - const free = Math.max(window - Math.max(used, counted), 0) - - return ( - - - - - - - - {/* One bar, a segment to a part. Without parts it is the plain total. */} -
- {parts.length ? ( - parts.map((part) => ( -
0 ? (part.tokens / window) * 100 : 0}%` }} - /> - )) - ) : ( -
- )} -
- - {detailed && parts.length > 0 && ( -
    - {[...parts, { key: "free", label: freeLabel, tokens: free, tint: "bg-secondary" }].map((part) => ( -
  • -
  • - ))} -
- )} - -

{note}

- - - ) -} - -export { ContextRing, compactTokens } +export { ContextRing } from "@/components/ui/context-ring-client" +export { compactTokens, type ContextPart } from "@/components/ui/context-ring-helpers" diff --git a/src/components/ui/turn-status-client.tsx b/src/components/ui/turn-status-client.tsx new file mode 100644 index 0000000..d1f5ede --- /dev/null +++ b/src/components/ui/turn-status-client.tsx @@ -0,0 +1,104 @@ +"use client" + +import * as React from "react" +import { ChevronRight } from "lucide-react" + +import { cn } from "@/lib/utils" +import { formatDuration } from "@/components/ui/turn-status-helpers" +import { RollingNumber, StatusGlyph, type Activity, type Figure } from "@/components/ui/status-glyph" + +/** Whole seconds since a moment, counted for as long as the caller is mounted. */ +function useElapsed(since: number | undefined): number { + const [now, setNow] = React.useState(() => Date.now()) + React.useEffect(() => { + if (since === undefined) return + const tick = window.setInterval(() => setNow(Date.now()), 1000) + return () => window.clearInterval(tick) + }, [since]) + return since === undefined ? 0 : Math.max(0, Math.floor((now - since) / 1000)) +} + +/** + * The line a turn ends on for as long as it is going: that it is going (the + * glyph), what it is doing this moment (the words, with a slow glint across + * them), for how long, and about how much it has written. It is there from + * the first moment to the last, whatever else comes and goes above it. + * + * Given children, the line is a button, and opens to show them — the calls + * of the step in hand. + * + * Nothing is said of the time until there is a second of it: "0s" is a + * figure about nothing. + */ +function TurnStatus({ + activity = "thinking", + figure, + label, + since, + tokens = 0, + trailing, + children, + className, +}: { + activity?: Activity + /** One figure alone, instead of the activity's own; see StatusGlyph. */ + figure?: Figure + /** What is being done, in a few words. Left out, only the glyph and the figures show. */ + label?: string + /** When the turn began, in milliseconds since the epoch. */ + since?: number + /** Tokens written so far; the count climbs to each new value. */ + tokens?: number + /** Said after the line and apart from it: how many background tasks are running, say. */ + trailing?: React.ReactNode + children?: React.ReactNode + className?: string +}) { + const [open, setOpen] = React.useState(false) + const elapsed = useElapsed(since) + const opens = Boolean(children) + + const line = ( + <> + + {label && {label}} + + {elapsed >= 1 && formatDuration(elapsed)} + {tokens > 0 && ( + <> + {elapsed >= 1 && ·} + + tokens + + )} + + + ) + + return ( +
+
+ {opens ? ( + + ) : ( +
+ {line} +
+ )} + {trailing} +
+ {open && children} +
+ ) +} + +export { TurnStatus, useElapsed } diff --git a/src/components/ui/turn-status-helpers.ts b/src/components/ui/turn-status-helpers.ts new file mode 100644 index 0000000..ad60e62 --- /dev/null +++ b/src/components/ui/turn-status-helpers.ts @@ -0,0 +1,8 @@ +/** 8 → "8s", 75 → "1m 15s", 3700 → "1h 1m". */ +function formatDuration(seconds: number): string { + if (seconds < 60) return `${seconds}s` + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s` + return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m` +} + +export { formatDuration } diff --git a/src/components/ui/turn-status.tsx b/src/components/ui/turn-status.tsx index 53600c4..88582f6 100644 --- a/src/components/ui/turn-status.tsx +++ b/src/components/ui/turn-status.tsx @@ -1,110 +1,2 @@ -"use client" - -import * as React from "react" -import { ChevronRight } from "lucide-react" - -import { cn } from "@/lib/utils" -import { RollingNumber, StatusGlyph, type Activity, type Figure } from "@/components/ui/status-glyph" - -/** 8 → "8s", 75 → "1m 15s", 3700 → "1h 1m". */ -function formatDuration(seconds: number): string { - if (seconds < 60) return `${seconds}s` - if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s` - return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m` -} - -/** Whole seconds since a moment, counted for as long as the caller is mounted. */ -function useElapsed(since: number | undefined): number { - const [now, setNow] = React.useState(() => Date.now()) - React.useEffect(() => { - if (since === undefined) return - const tick = window.setInterval(() => setNow(Date.now()), 1000) - return () => window.clearInterval(tick) - }, [since]) - return since === undefined ? 0 : Math.max(0, Math.floor((now - since) / 1000)) -} - -/** - * The line a turn ends on for as long as it is going: that it is going (the - * glyph), what it is doing this moment (the words, with a slow glint across - * them), for how long, and about how much it has written. It is there from - * the first moment to the last, whatever else comes and goes above it. - * - * Given children, the line is a button, and opens to show them — the calls - * of the step in hand. - * - * Nothing is said of the time until there is a second of it: "0s" is a - * figure about nothing. - */ -function TurnStatus({ - activity = "thinking", - figure, - label, - since, - tokens = 0, - trailing, - children, - className, -}: { - activity?: Activity - /** One figure alone, instead of the activity's own; see StatusGlyph. */ - figure?: Figure - /** What is being done, in a few words. Left out, only the glyph and the figures show. */ - label?: string - /** When the turn began, in milliseconds since the epoch. */ - since?: number - /** Tokens written so far; the count climbs to each new value. */ - tokens?: number - /** Said after the line and apart from it: how many background tasks are running, say. */ - trailing?: React.ReactNode - children?: React.ReactNode - className?: string -}) { - const [open, setOpen] = React.useState(false) - const elapsed = useElapsed(since) - const opens = Boolean(children) - - const line = ( - <> - - {label && {label}} - - {elapsed >= 1 && formatDuration(elapsed)} - {tokens > 0 && ( - <> - {elapsed >= 1 && ·} - - tokens - - )} - - - ) - - return ( -
-
- {opens ? ( - - ) : ( -
- {line} -
- )} - {trailing} -
- {open && children} -
- ) -} - -export { TurnStatus, formatDuration, useElapsed } +export { TurnStatus, useElapsed } from "@/components/ui/turn-status-client" +export { formatDuration } from "@/components/ui/turn-status-helpers" diff --git a/src/registry-files.json b/src/registry-files.json new file mode 100644 index 0000000..05adfc1 --- /dev/null +++ b/src/registry-files.json @@ -0,0 +1,4 @@ +{ + "turn-status": ["turn-status.tsx", "turn-status-client.tsx", "turn-status-helpers.ts"], + "context-ring": ["context-ring.tsx", "context-ring-client.tsx", "context-ring-helpers.ts"] +} diff --git a/src/site/component-preview.tsx b/src/site/component-preview.tsx index a408155..a843f13 100644 --- a/src/site/component-preview.tsx +++ b/src/site/component-preview.tsx @@ -4,11 +4,12 @@ import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" import { Spinner } from "@/components/ui/spinner" import { CodeBlock } from "@/site/code" +import componentFiles from "@/registry-files.json" // Every example, loaded when its preview is first shown, and its source as text. const modules = import.meta.glob<{ default?: React.ComponentType } & Record>("/src/examples/*.tsx") const sources = import.meta.glob("/src/examples/*.tsx", { query: "?raw", import: "default" }) -const uiSources = import.meta.glob("/src/components/ui/*.tsx", { query: "?raw", import: "default" }) +const uiSources = import.meta.glob(["/src/components/ui/*.tsx", "/src/components/ui/*.ts"], { query: "?raw", import: "default" }) const lazies = new Map>() function exampleOf(name: string) { @@ -102,9 +103,14 @@ export function ComponentPreview({ ) } -/** A component's own source, as it is installed. */ -export function ComponentSource({ name, title }: { name: string; title?: string }) { - const source = useSource(uiSources[`/src/components/ui/${name}.tsx`]) +function ComponentSourceFile({ file, title }: { file: string; title?: string }) { + const source = useSource(uiSources[`/src/components/ui/${file}`]) if (!source) return null - return + return +} + +/** Every file in the component's registry item, for installation by hand. */ +export function ComponentSource({ name, title }: { name: string; title?: string }) { + const files = (componentFiles as Record)[name] ?? [`${name}.tsx`] + return <>{files.map((file, index) => )} } From 3e0e353c2501bbb1e898fe96cac3f17e8b94387d Mon Sep 17 00:00:00 2001 From: Maya Kid <29959320+Maya-Kid@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:15:42 +1100 Subject: [PATCH 3/4] fix: preserve accessible turn status after client split Signed-off-by: Maya Kid <29959320+Maya-Kid@users.noreply.github.com> --- content/solid/turn-status.mdx | 4 ++++ src/components/ui/turn-status-client.tsx | 10 ++++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/content/solid/turn-status.mdx b/content/solid/turn-status.mdx index fc9de51..695ca80 100644 --- a/content/solid/turn-status.mdx +++ b/content/solid/turn-status.mdx @@ -42,6 +42,10 @@ The exported `formatDuration(seconds)` helper can also be called in a server com ## With steps Given children, the line is a button and opens to show them: the calls of the step in hand. +The activity label is announced by a separate status region, so the button keeps +its native role and updates to the clock and token count do not repeat the announcement. +When `label` is omitted, the activity name supplies the announcement and button +name without adding visible text. Pass `label` to customize or translate it. diff --git a/src/components/ui/turn-status-client.tsx b/src/components/ui/turn-status-client.tsx index d1f5ede..dd40aa4 100644 --- a/src/components/ui/turn-status-client.tsx +++ b/src/components/ui/turn-status-client.tsx @@ -57,6 +57,9 @@ function TurnStatus({ const [open, setOpen] = React.useState(false) const elapsed = useElapsed(since) const opens = Boolean(children) + // The glyph is decorative. Even without visible words, expose the activity + // and keep the disclosure's name independent of the clock and token count. + const activityLabel = label?.trim() || activity const line = ( <> @@ -77,11 +80,14 @@ function TurnStatus({ return (
+ {/* Announce the activity separately: the line can be a button, and its + clock and animated count should not keep repeating the status. */} + {activityLabel}
{opens ? ( ) : ( -
+
{line}
)} From 75682434544f1df13107e92a7e46e299906ef30f Mon Sep 17 00:00:00 2001 From: Maya Kid <29959320+Maya-Kid@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:50:45 +1100 Subject: [PATCH 4/4] fix: narrow client boundaries to single-file entries Signed-off-by: Maya Kid <29959320+Maya-Kid@users.noreply.github.com> --- content/solid/context-ring.mdx | 2 - content/solid/turn-status.mdx | 6 - registry.json | 16 --- scripts/build-registry.mjs | 24 ++-- src/components/ui/context-ring-client.tsx | 123 -------------------- src/components/ui/context-ring-helpers.ts | 11 -- src/components/ui/context-ring.tsx | 134 +++++++++++++++++++++- src/components/ui/turn-status-client.tsx | 110 ------------------ src/components/ui/turn-status-helpers.ts | 8 -- src/components/ui/turn-status.tsx | 112 +++++++++++++++++- src/registry-files.json | 4 - src/site/component-preview.tsx | 16 +-- 12 files changed, 261 insertions(+), 305 deletions(-) delete mode 100644 src/components/ui/context-ring-client.tsx delete mode 100644 src/components/ui/context-ring-helpers.ts delete mode 100644 src/components/ui/turn-status-client.tsx delete mode 100644 src/components/ui/turn-status-helpers.ts delete mode 100644 src/registry-files.json diff --git a/content/solid/context-ring.mdx b/content/solid/context-ring.mdx index 9ea5caa..12e2bdc 100644 --- a/content/solid/context-ring.mdx +++ b/content/solid/context-ring.mdx @@ -32,8 +32,6 @@ import { ContextRing } from "@/components/ui/context-ring" ``` -The exported `compactTokens(count)` helper can also be called in a server component. Keep all three installed files together: `context-ring-client.tsx` contains the interactive ring, and `context-ring-helpers.ts` contains the pure formatter. - ## Colour Blue while there is room, which is nearly always. Amber from 70%, and the colour of failure from 90%: nearly full is worth a second look. diff --git a/content/solid/turn-status.mdx b/content/solid/turn-status.mdx index 695ca80..9ab79a2 100644 --- a/content/solid/turn-status.mdx +++ b/content/solid/turn-status.mdx @@ -37,15 +37,9 @@ import { TurnStatus } from "@/components/ui/turn-status" /> ``` -The exported `formatDuration(seconds)` helper can also be called in a server component. Keep all three installed files together: `turn-status-client.tsx` contains the interactive implementation and the `useElapsed` hook, and `turn-status-helpers.ts` contains the pure formatter. - ## With steps Given children, the line is a button and opens to show them: the calls of the step in hand. -The activity label is announced by a separate status region, so the button keeps -its native role and updates to the clock and token count do not repeat the announcement. -When `label` is omitted, the activity name supplies the announcement and button -name without adding visible text. Pass `label` to customize or translate it. diff --git a/registry.json b/registry.json index 59cf04d..f28095f 100644 --- a/registry.json +++ b/registry.json @@ -64,14 +64,6 @@ { "path": "src/components/ui/context-ring.tsx", "type": "registry:ui" - }, - { - "path": "src/components/ui/context-ring-client.tsx", - "type": "registry:ui" - }, - { - "path": "src/components/ui/context-ring-helpers.ts", - "type": "registry:ui" } ] }, @@ -214,14 +206,6 @@ { "path": "src/components/ui/turn-status.tsx", "type": "registry:ui" - }, - { - "path": "src/components/ui/turn-status-client.tsx", - "type": "registry:ui" - }, - { - "path": "src/components/ui/turn-status-helpers.ts", - "type": "registry:ui" } ] }, diff --git a/scripts/build-registry.mjs b/scripts/build-registry.mjs index 617c78b..fa362b8 100644 --- a/scripts/build-registry.mjs +++ b/scripts/build-registry.mjs @@ -25,8 +25,6 @@ import { blockMetadata } from "./block-metadata.mjs" const root = join(dirname(fileURLToPath(import.meta.url)), "..") const out = join(root, "public/r") const read = (path) => readFileSync(join(root, path), "utf8") -// The same file groups drive the registry and the documentation's manual-copy source panels. -const componentFiles = JSON.parse(read("src/registry-files.json")) // Where the site is served from, and the repository on GitHub. const origin = (process.env.SOLID_GLASS_ORIGIN ?? "https://deeplethe.github.io/solid-glass").replace(/\/$/, "") const repo = "deeplethe/solid-glass" @@ -36,13 +34,12 @@ const own = readdirSync(join(root, "content/solid")) .sort() /** What a file needs: the packages it imports, and the components, its own by address and shadcn/ui's by name. */ -function needs(content, included = []) { +function needs(content) { const packages = new Set() const others = new Set() for (const [, source] of content.matchAll(/from "([^"]+)"/g)) { if (source === "react" || source === "@/lib/utils") continue const used = source.match(/^@\/components\/ui\/(.+)$/)?.[1] - if (included.includes(used)) continue if (used) others.add(own.includes(used) ? `${repo}/${used}` : used) else packages.add(source.split("/").slice(0, source.startsWith("@") ? 2 : 1).join("/")) } @@ -68,18 +65,25 @@ const blocks = readdirSync(join(root, "src/blocks")) }) const components = own.map((name) => { - // A universal entry can keep pure helpers available to servers while its UI has a client boundary. - const names = componentFiles[name] ?? [`${name}.tsx`] - const files = names.map((file) => ({ path: `src/components/ui/${file}`, type: "registry:ui" })) - const content = files.map((file) => read(file.path)).join("\n") + const content = read(`src/components/ui/${name}.tsx`) const front = read(`content/solid/${name}.mdx`) + const packages = new Set() + const others = new Set() + for (const [, source] of content.matchAll(/from "([^"]+)"/g)) { + if (source === "react" || source === "@/lib/utils") continue + const used = source.match(/^@\/components\/ui\/(.+)$/)?.[1] + // One of Solid Glass's own is named by its GitHub address; one of shadcn/ui's by its name. + if (used) others.add(own.includes(used) ? `${repo}/${used}` : used) + else packages.add(source.split("/").slice(0, source.startsWith("@") ? 2 : 1).join("/")) + } return { name, type: "registry:ui", title: front.match(/^title:\s*(.*)$/m)?.[1], description: front.match(/^description:\s*(.*)$/m)?.[1], - ...needs(content, names.map((file) => file.replace(/\.tsx?$/, ""))), - files, + dependencies: [...packages].sort(), + registryDependencies: [...others].sort(), + files: [{ path: `src/components/ui/${name}.tsx`, type: "registry:ui" }], } }) diff --git a/src/components/ui/context-ring-client.tsx b/src/components/ui/context-ring-client.tsx deleted file mode 100644 index 54550ec..0000000 --- a/src/components/ui/context-ring-client.tsx +++ /dev/null @@ -1,123 +0,0 @@ -"use client" - -import * as React from "react" -import { ChevronDown } from "lucide-react" - -import { cn } from "@/lib/utils" -import { compactTokens, type ContextPart } from "@/components/ui/context-ring-helpers" -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" - -const share = (tokens: number, window: number) => (window > 0 ? `${((tokens / window) * 100).toFixed(1)}%` : "—") - -/** - * How full a model's context window is, as a ring: read at a glance, opened - * for the numbers and for what the context is full of. - * - * Blue while there is room, which is nearly always; amber from 70%, the - * colour of failure from 90% — nearly full is worth a second look. - */ -function ContextRing({ - used, - window, - parts = [], - title = "Context window", - note = "How much of the window this conversation takes up. Earlier turns are compacted as it fills.", - freeLabel = "Free", - className, -}: { - used: number - window: number - parts?: ContextPart[] - title?: string - note?: string - freeLabel?: string - className?: string -}) { - const [detailed, setDetailed] = React.useState(false) - const fraction = window > 0 ? Math.min(used / window, 1) : 0 - const percent = Math.round(fraction * 100) - const r = 6 - const around = 2 * Math.PI * r - const tone = fraction >= 0.9 ? "text-destructive" : fraction >= 0.7 ? "text-amber-500" : "text-blue-500" - const counted = parts.reduce((sum, part) => sum + part.tokens, 0) - const free = Math.max(window - Math.max(used, counted), 0) - - return ( - - - - - - - - {/* One bar, a segment to a part. Without parts it is the plain total. */} -
- {parts.length ? ( - parts.map((part) => ( -
0 ? (part.tokens / window) * 100 : 0}%` }} - /> - )) - ) : ( -
- )} -
- - {detailed && parts.length > 0 && ( -
    - {[...parts, { key: "free", label: freeLabel, tokens: free, tint: "bg-secondary" }].map((part) => ( -
  • -
  • - ))} -
- )} - -

{note}

- - - ) -} - -export { ContextRing } diff --git a/src/components/ui/context-ring-helpers.ts b/src/components/ui/context-ring-helpers.ts deleted file mode 100644 index df1c427..0000000 --- a/src/components/ui/context-ring-helpers.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** One thing the context is full of, and the colour it is drawn in (a background class). */ -export type ContextPart = { key: string; label: string; tokens: number; tint: string } - -/** 168100 → "168.1k", 1000000 → "1M": the way a window's size is said aloud. */ -function compactTokens(n: number): string { - if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(1)}M` - if (n >= 1_000) return `${+(n / 1_000).toFixed(1)}k` - return String(n) -} - -export { compactTokens } diff --git a/src/components/ui/context-ring.tsx b/src/components/ui/context-ring.tsx index 9d627d8..300c66f 100644 --- a/src/components/ui/context-ring.tsx +++ b/src/components/ui/context-ring.tsx @@ -1,2 +1,132 @@ -export { ContextRing } from "@/components/ui/context-ring-client" -export { compactTokens, type ContextPart } from "@/components/ui/context-ring-helpers" +"use client" + +import * as React from "react" +import { ChevronDown } from "lucide-react" + +import { cn } from "@/lib/utils" +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" + +/** One thing the context is full of, and the colour it is drawn in (a background class). */ +export type ContextPart = { key: string; label: string; tokens: number; tint: string } + +/** 168100 → "168.1k", 1000000 → "1M": the way a window's size is said aloud. */ +function compactTokens(n: number): string { + if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(1)}M` + if (n >= 1_000) return `${+(n / 1_000).toFixed(1)}k` + return String(n) +} + +const share = (tokens: number, window: number) => (window > 0 ? `${((tokens / window) * 100).toFixed(1)}%` : "—") + +/** + * How full a model's context window is, as a ring: read at a glance, opened + * for the numbers and for what the context is full of. + * + * Blue while there is room, which is nearly always; amber from 70%, the + * colour of failure from 90% — nearly full is worth a second look. + */ +function ContextRing({ + used, + window, + parts = [], + title = "Context window", + note = "How much of the window this conversation takes up. Earlier turns are compacted as it fills.", + freeLabel = "Free", + className, +}: { + used: number + window: number + parts?: ContextPart[] + title?: string + note?: string + freeLabel?: string + className?: string +}) { + const [detailed, setDetailed] = React.useState(false) + const fraction = window > 0 ? Math.min(used / window, 1) : 0 + const percent = Math.round(fraction * 100) + const r = 6 + const around = 2 * Math.PI * r + const tone = fraction >= 0.9 ? "text-destructive" : fraction >= 0.7 ? "text-amber-500" : "text-blue-500" + const counted = parts.reduce((sum, part) => sum + part.tokens, 0) + const free = Math.max(window - Math.max(used, counted), 0) + + return ( + + + + + + + + {/* One bar, a segment to a part. Without parts it is the plain total. */} +
+ {parts.length ? ( + parts.map((part) => ( +
0 ? (part.tokens / window) * 100 : 0}%` }} + /> + )) + ) : ( +
+ )} +
+ + {detailed && parts.length > 0 && ( +
    + {[...parts, { key: "free", label: freeLabel, tokens: free, tint: "bg-secondary" }].map((part) => ( +
  • +
  • + ))} +
+ )} + +

{note}

+ + + ) +} + +export { ContextRing, compactTokens } diff --git a/src/components/ui/turn-status-client.tsx b/src/components/ui/turn-status-client.tsx deleted file mode 100644 index dd40aa4..0000000 --- a/src/components/ui/turn-status-client.tsx +++ /dev/null @@ -1,110 +0,0 @@ -"use client" - -import * as React from "react" -import { ChevronRight } from "lucide-react" - -import { cn } from "@/lib/utils" -import { formatDuration } from "@/components/ui/turn-status-helpers" -import { RollingNumber, StatusGlyph, type Activity, type Figure } from "@/components/ui/status-glyph" - -/** Whole seconds since a moment, counted for as long as the caller is mounted. */ -function useElapsed(since: number | undefined): number { - const [now, setNow] = React.useState(() => Date.now()) - React.useEffect(() => { - if (since === undefined) return - const tick = window.setInterval(() => setNow(Date.now()), 1000) - return () => window.clearInterval(tick) - }, [since]) - return since === undefined ? 0 : Math.max(0, Math.floor((now - since) / 1000)) -} - -/** - * The line a turn ends on for as long as it is going: that it is going (the - * glyph), what it is doing this moment (the words, with a slow glint across - * them), for how long, and about how much it has written. It is there from - * the first moment to the last, whatever else comes and goes above it. - * - * Given children, the line is a button, and opens to show them — the calls - * of the step in hand. - * - * Nothing is said of the time until there is a second of it: "0s" is a - * figure about nothing. - */ -function TurnStatus({ - activity = "thinking", - figure, - label, - since, - tokens = 0, - trailing, - children, - className, -}: { - activity?: Activity - /** One figure alone, instead of the activity's own; see StatusGlyph. */ - figure?: Figure - /** What is being done, in a few words. Left out, only the glyph and the figures show. */ - label?: string - /** When the turn began, in milliseconds since the epoch. */ - since?: number - /** Tokens written so far; the count climbs to each new value. */ - tokens?: number - /** Said after the line and apart from it: how many background tasks are running, say. */ - trailing?: React.ReactNode - children?: React.ReactNode - className?: string -}) { - const [open, setOpen] = React.useState(false) - const elapsed = useElapsed(since) - const opens = Boolean(children) - // The glyph is decorative. Even without visible words, expose the activity - // and keep the disclosure's name independent of the clock and token count. - const activityLabel = label?.trim() || activity - - const line = ( - <> - - {label && {label}} - - {elapsed >= 1 && formatDuration(elapsed)} - {tokens > 0 && ( - <> - {elapsed >= 1 && ·} - - tokens - - )} - - - ) - - return ( -
- {/* Announce the activity separately: the line can be a button, and its - clock and animated count should not keep repeating the status. */} - {activityLabel} -
- {opens ? ( - - ) : ( -
- {line} -
- )} - {trailing} -
- {open && children} -
- ) -} - -export { TurnStatus, useElapsed } diff --git a/src/components/ui/turn-status-helpers.ts b/src/components/ui/turn-status-helpers.ts deleted file mode 100644 index ad60e62..0000000 --- a/src/components/ui/turn-status-helpers.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** 8 → "8s", 75 → "1m 15s", 3700 → "1h 1m". */ -function formatDuration(seconds: number): string { - if (seconds < 60) return `${seconds}s` - if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s` - return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m` -} - -export { formatDuration } diff --git a/src/components/ui/turn-status.tsx b/src/components/ui/turn-status.tsx index 88582f6..53600c4 100644 --- a/src/components/ui/turn-status.tsx +++ b/src/components/ui/turn-status.tsx @@ -1,2 +1,110 @@ -export { TurnStatus, useElapsed } from "@/components/ui/turn-status-client" -export { formatDuration } from "@/components/ui/turn-status-helpers" +"use client" + +import * as React from "react" +import { ChevronRight } from "lucide-react" + +import { cn } from "@/lib/utils" +import { RollingNumber, StatusGlyph, type Activity, type Figure } from "@/components/ui/status-glyph" + +/** 8 → "8s", 75 → "1m 15s", 3700 → "1h 1m". */ +function formatDuration(seconds: number): string { + if (seconds < 60) return `${seconds}s` + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s` + return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m` +} + +/** Whole seconds since a moment, counted for as long as the caller is mounted. */ +function useElapsed(since: number | undefined): number { + const [now, setNow] = React.useState(() => Date.now()) + React.useEffect(() => { + if (since === undefined) return + const tick = window.setInterval(() => setNow(Date.now()), 1000) + return () => window.clearInterval(tick) + }, [since]) + return since === undefined ? 0 : Math.max(0, Math.floor((now - since) / 1000)) +} + +/** + * The line a turn ends on for as long as it is going: that it is going (the + * glyph), what it is doing this moment (the words, with a slow glint across + * them), for how long, and about how much it has written. It is there from + * the first moment to the last, whatever else comes and goes above it. + * + * Given children, the line is a button, and opens to show them — the calls + * of the step in hand. + * + * Nothing is said of the time until there is a second of it: "0s" is a + * figure about nothing. + */ +function TurnStatus({ + activity = "thinking", + figure, + label, + since, + tokens = 0, + trailing, + children, + className, +}: { + activity?: Activity + /** One figure alone, instead of the activity's own; see StatusGlyph. */ + figure?: Figure + /** What is being done, in a few words. Left out, only the glyph and the figures show. */ + label?: string + /** When the turn began, in milliseconds since the epoch. */ + since?: number + /** Tokens written so far; the count climbs to each new value. */ + tokens?: number + /** Said after the line and apart from it: how many background tasks are running, say. */ + trailing?: React.ReactNode + children?: React.ReactNode + className?: string +}) { + const [open, setOpen] = React.useState(false) + const elapsed = useElapsed(since) + const opens = Boolean(children) + + const line = ( + <> + + {label && {label}} + + {elapsed >= 1 && formatDuration(elapsed)} + {tokens > 0 && ( + <> + {elapsed >= 1 && ·} + + tokens + + )} + + + ) + + return ( +
+
+ {opens ? ( + + ) : ( +
+ {line} +
+ )} + {trailing} +
+ {open && children} +
+ ) +} + +export { TurnStatus, formatDuration, useElapsed } diff --git a/src/registry-files.json b/src/registry-files.json deleted file mode 100644 index 05adfc1..0000000 --- a/src/registry-files.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "turn-status": ["turn-status.tsx", "turn-status-client.tsx", "turn-status-helpers.ts"], - "context-ring": ["context-ring.tsx", "context-ring-client.tsx", "context-ring-helpers.ts"] -} diff --git a/src/site/component-preview.tsx b/src/site/component-preview.tsx index a843f13..a408155 100644 --- a/src/site/component-preview.tsx +++ b/src/site/component-preview.tsx @@ -4,12 +4,11 @@ import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" import { Spinner } from "@/components/ui/spinner" import { CodeBlock } from "@/site/code" -import componentFiles from "@/registry-files.json" // Every example, loaded when its preview is first shown, and its source as text. const modules = import.meta.glob<{ default?: React.ComponentType } & Record>("/src/examples/*.tsx") const sources = import.meta.glob("/src/examples/*.tsx", { query: "?raw", import: "default" }) -const uiSources = import.meta.glob(["/src/components/ui/*.tsx", "/src/components/ui/*.ts"], { query: "?raw", import: "default" }) +const uiSources = import.meta.glob("/src/components/ui/*.tsx", { query: "?raw", import: "default" }) const lazies = new Map>() function exampleOf(name: string) { @@ -103,14 +102,9 @@ export function ComponentPreview({ ) } -function ComponentSourceFile({ file, title }: { file: string; title?: string }) { - const source = useSource(uiSources[`/src/components/ui/${file}`]) - if (!source) return null - return -} - -/** Every file in the component's registry item, for installation by hand. */ +/** A component's own source, as it is installed. */ export function ComponentSource({ name, title }: { name: string; title?: string }) { - const files = (componentFiles as Record)[name] ?? [`${name}.tsx`] - return <>{files.map((file, index) => )} + const source = useSource(uiSources[`/src/components/ui/${name}.tsx`]) + if (!source) return null + return }