diff --git a/packages/app-web/docs/architecture.md b/packages/app-web/docs/architecture.md index 9d35c591..4b8a7e94 100644 --- a/packages/app-web/docs/architecture.md +++ b/packages/app-web/docs/architecture.md @@ -89,6 +89,15 @@ link beside it has the rest: | A file | `commit:changes` | the commits that changed it | `history` | | An act | `ran_against:commit` | its `git_hash`, when the workspace's repository holds it | — | +## Pages and their chrome + +Every page of the app, the Trace Console and the development pages (the gallery, the sessions list, +session replays and the agent), shares one frame: `src/ui/chrome/Frame.tsx` and `chrome.css`. The +frame gives a page the palette, the top bar and the resizable border. The theme is one setting +(`useSavedTheme`) that every component reads, so the button in the top bar and the content agree. +The development pages have no link in the Trace Console, except `agent`, and are reached by their +addresses. + ## Trace Console The browser app's page for the graph, at `/app/`: `/app/workspace/overlap_bench/Q_1` opens diff --git a/packages/app-web/e2e/app.e2e.ts b/packages/app-web/e2e/app.e2e.ts index aab24277..7c8e8783 100644 --- a/packages/app-web/e2e/app.e2e.ts +++ b/packages/app-web/e2e/app.e2e.ts @@ -86,6 +86,31 @@ test.describe("getting around", () => { await expect(bar).not.toContainText("live"); }); + test("the top bar links to no gallery or sessions page", async ({ page }) => { + await page.goto("/app/workspace/alpha/Q_1"); + const bar = page.locator(".topbar"); + await expect(bar.getByRole("button", { name: "LabKit" })).toBeVisible(); + await expect(bar.getByRole("link", { name: "gallery" })).toHaveCount(0); + await expect(bar.getByRole("link", { name: "sessions" })).toHaveCount(0); + }); + + test("the sessions page has the same top bar as the Trace Console, with the theme button at its right", async ({ + page, + }, info) => { + test.skip(info.project.name !== "dev", "the sessions page exists only in development"); + await page.goto("/app/gallery/sessions"); + const bar = page.locator(".topbar"); + await expect(bar.getByRole("link", { name: "LabKit" })).toBeVisible(); + const theme = bar.getByRole("button", { name: /^Theme:/ }); + await expect(theme).toBeVisible(); + const barBox = await bar.boundingBox(); + const themeBox = await theme.boundingBox(); + expect( + (barBox?.x ?? 0) + (barBox?.width ?? 0) - ((themeBox?.x ?? 0) + (themeBox?.width ?? 0)), + ).toBeLessThan(24); + expect(await bar.evaluate((e) => getComputedStyle(e).height)).toBe("52px"); + }); + test("the Items filter narrows the listed items", async ({ page }) => { await page.goto(`/app/workspace/alpha/Q_1?${list("/workspace/alpha/question")}`); await expect(item(page, "Q_2")).toBeVisible(); diff --git a/packages/app-web/src/routes/__root.tsx b/packages/app-web/src/routes/__root.tsx index 065a63e1..9edf8d38 100644 --- a/packages/app-web/src/routes/__root.tsx +++ b/packages/app-web/src/routes/__root.tsx @@ -1,11 +1,10 @@ import { createRootRoute, Link, Outlet } from "@tanstack/react-router"; -import { Bar } from "../ui/Bar"; +import { Frame } from "../ui/chrome/Frame"; export const Route = createRootRoute({ component: Outlet, notFoundComponent: () => ( - <> - +

Not found

@@ -14,6 +13,6 @@ export const Route = createRootRoute({

- + ), }); diff --git a/packages/app-web/src/styles.css b/packages/app-web/src/styles.css index c4d7dafc..b8bf86b4 100644 --- a/packages/app-web/src/styles.css +++ b/packages/app-web/src/styles.css @@ -1,15 +1,5 @@ +/* The page's own palette lives in the chrome (`src/ui/chrome/chrome.css`); this is the page. */ :root { - --bg: #0b0e14; - --panel: #12151d; - --panel-border: #232838; - --text: #d7dce4; - --text-dim: #808a9c; - --accent: #5ad1c9; - --accent-dim: #2c4a48; - --amber: #e0b25a; - --danger: #e0687a; - --font-sans: "IBM Plex Sans"; - --font-mono: "IBM Plex Mono", "SF Mono", Menlo, Consolas, monospace; font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; @@ -19,7 +9,7 @@ box-sizing: border-box; /* A scrollbar is a thin thumb and nothing else: no track, no border, no background. */ scrollbar-width: thin; - scrollbar-color: color-mix(in srgb, var(--text-dim) 45%, transparent) transparent; + scrollbar-color: color-mix(in srgb, var(--lk-muted) 45%, transparent) transparent; } html, @@ -28,8 +18,8 @@ body, margin: 0; height: 100%; width: 100%; - background: var(--bg); - color: var(--text); + background: var(--lk-bg); + color: var(--lk-text); overflow: hidden; } @@ -40,86 +30,6 @@ body, height: 100vh; } -#bar { - display: flex; - align-items: center; - gap: 20px; - padding: 10px 16px; - background: var(--panel); - border-bottom: 1px solid var(--panel-border); - flex: 0 0 auto; -} - -#bar h1 { - font-size: 15px; - font-weight: 600; - margin: 0; - letter-spacing: 0.02em; - white-space: nowrap; -} -#bar h1 span { - color: var(--accent); - font-weight: 400; -} - -.field { - display: flex; - align-items: center; - gap: 8px; - font-size: 12px; - color: var(--text-dim); -} -/* biome-ignore lint/style/noDescendingSpecificity: source order is the cascade the explorer was designed against */ -.field span { - text-transform: uppercase; - letter-spacing: 0.05em; -} - -/* The Trace Console (`.trace`) styles its own controls. */ -select:where(:not(.trace *)), -button:where(:not(.trace *)), -input[type="range"]:where(:not(.trace *)) { - font-family: inherit; - font-size: 12px; - color: var(--text); - background: #191d28; - border: 1px solid var(--panel-border); - border-radius: 4px; -} - -select { - padding: 5px 8px; - min-width: 220px; -} - -button:where(:not(.trace *)) { - padding: 5px 10px; - cursor: pointer; -} -button:where(:not(.trace *)):hover { - border-color: var(--accent); - color: var(--accent); -} -button:where(:not(.trace *)):active { - transform: translateY(1px); -} -/* The theme button: an icon alone, as tall as the text buttons beside it. */ -button.theme-toggle { - display: inline-flex; - align-items: center; - padding: 4px 8px; -} - -.view-toggle { - display: flex; - gap: 2px; -} -.view-toggle button.active { - background: var(--accent-dim); - border-color: var(--accent); - color: var(--accent); -} - main { flex: 1 1 auto; display: flex; @@ -137,94 +47,3 @@ main { width: 100%; height: 100%; } - -/* Pages other than the graph: workspaces, collections. */ -.crumb { - color: var(--text-dim); - font-size: 13px; - text-decoration: none; -} -.crumb:hover { - color: var(--accent); -} - -.page { - flex: 1 1 auto; - overflow: auto; - padding: 20px 24px; -} -.page h2 { - margin: 0 0 16px; - font-size: 16px; - font-weight: 600; -} -.page a { - color: var(--accent); - text-decoration: none; -} -.page a:hover { - text-decoration: underline; -} - -.dim { - color: var(--text-dim); -} - -.cards { - display: flex; - flex-wrap: wrap; - gap: 12px; - margin: 0; - padding: 0; - list-style: none; -} -.cards li { - display: flex; - flex-direction: column; - gap: 4px; - min-width: 180px; - padding: 12px 14px; - background: var(--panel); - border: 1px solid var(--panel-border); - border-radius: 6px; -} - -.items { - width: 100%; - border-collapse: collapse; - font-size: 12px; -} -.items td { - padding: 6px 10px 6px 0; - border-bottom: 1px solid var(--panel-border); - vertical-align: top; -} - -.paging { - display: flex; - gap: 16px; - margin-top: 16px; - font-size: 12px; -} - -/* After the page rules: the same element, and this one is the more specific. */ -#bar h1 a { - color: inherit; - text-decoration: none; -} - -/* A page that failed to load: the error the API or the router gave. */ -.load-error { - font-size: 10px; - text-transform: uppercase; - letter-spacing: 0.05em; - padding: 3px 8px; - border-radius: 3px; - color: var(--danger); - border: 1px solid color-mix(in srgb, var(--danger) 35%, transparent); - background: color-mix(in srgb, var(--danger) 12%, transparent); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: 42ch; -} diff --git a/packages/app-web/src/ui/AgentSession.tsx b/packages/app-web/src/ui/AgentSession.tsx index b59ef617..db023125 100644 --- a/packages/app-web/src/ui/AgentSession.tsx +++ b/packages/app-web/src/ui/AgentSession.tsx @@ -12,7 +12,7 @@ import { initialState, reduce } from "@labkit/view-model"; import { useNavigate } from "@tanstack/react-router"; import { useCallback, useEffect, useReducer, useRef, useState } from "react"; import { AGENT_CWD, AGENT_URL } from "./agent-endpoint"; -import { Bar } from "./Bar"; +import { Frame } from "./chrome/Frame"; import { useSavedTheme } from "./saved-theme"; type Status = "connecting" | "ready" | "failed"; @@ -177,8 +177,7 @@ export default function AgentSession({ sessionId }: { sessionId?: string }) { void navigate({ to: "/agent/{-$sessionId}", params: { sessionId: started }, replace: true }); }, [started, navigate]); return ( - <> - +
{real ? ( `Agent (${status})` @@ -220,6 +219,6 @@ export default function AgentSession({ sessionId }: { sessionId?: string }) { resolveLink={resolveLink} />
- + ); } diff --git a/packages/app-web/src/ui/Bar.tsx b/packages/app-web/src/ui/Bar.tsx deleted file mode 100644 index 4f9eebfc..00000000 --- a/packages/app-web/src/ui/Bar.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { Link } from "@tanstack/react-router"; -import type { ReactNode } from "react"; - -/** The header every page shares: where you are, and whatever controls the page adds. */ -export function Bar({ children }: { children?: ReactNode }) { - return ( -
-

- - LabKit Explorer - -

- {/* Pages that exist only in development; a host with no address bar reaches them from here. */} - {import.meta.env.DEV ? ( - <> - - gallery - - - sessions - - {/* A full page load: the agent page keeps the session it has open, and this starts one. */} - - agent - - - ) : null} - {children} -
- ); -} diff --git a/packages/app-web/src/ui/Gallery.tsx b/packages/app-web/src/ui/Gallery.tsx index 67d2a388..fda4622c 100644 --- a/packages/app-web/src/ui/Gallery.tsx +++ b/packages/app-web/src/ui/Gallery.tsx @@ -1,12 +1,12 @@ import { FIXTURES } from "@labkit/acp-scenarios"; -import { Conversation, ThemeToggle } from "@labkit/ui"; +import { Conversation } from "@labkit/ui"; import { RECORD_TYPES } from "./record-types"; import "@labkit/ui/ui.css"; import type { TranscriptState } from "@labkit/view-model"; import { stateOfFixture } from "@labkit/view-model/fixtures"; import { Link } from "@tanstack/react-router"; import { useEffect, useState } from "react"; -import { Bar } from "./Bar"; +import { Frame } from "./chrome/Frame"; import { BuildingBlocks } from "./BuildingBlocks"; import { useSavedTheme } from "./saved-theme"; import { type TranscriptEntry, transcriptList, transcriptState } from "./transcripts-api"; @@ -22,7 +22,7 @@ import { type TranscriptEntry, transcriptList, transcriptState } from "./transcr export default function Gallery() { const [entries, setEntries] = useState(FIXTURES); const [states, setStates] = useState>({}); - const [theme, setTheme] = useSavedTheme(); + const [theme] = useSavedTheme(); const [recorded, setRecorded] = useState>(new Set()); // Why a card, or the list of recorded transcripts, could not be loaded, by id ("" for the list). const [failures, setFailures] = useState>>({}); @@ -56,12 +56,7 @@ export default function Gallery() { }, []); return ( - <> - - - - - +
- + ); } diff --git a/packages/app-web/src/ui/LoadError.tsx b/packages/app-web/src/ui/LoadError.tsx index 9dbdeaf3..32a72a7b 100644 --- a/packages/app-web/src/ui/LoadError.tsx +++ b/packages/app-web/src/ui/LoadError.tsx @@ -1,5 +1,5 @@ import { Link, useRouter } from "@tanstack/react-router"; -import { Bar } from "./Bar"; +import { Frame } from "./chrome/Frame"; /** * A page whose data could not be loaded: the header, what went wrong, and ways on from here, so a @@ -8,8 +8,7 @@ import { Bar } from "./Bar"; export function LoadError({ error }: { error: unknown }) { const router = useRouter(); return ( - <> - +

{error instanceof Error ? error.message : String(error)}

@@ -21,6 +20,6 @@ export function LoadError({ error }: { error: unknown }) {

- + ); } diff --git a/packages/app-web/src/ui/Replay.tsx b/packages/app-web/src/ui/Replay.tsx index 88db6ef3..11ad1fc1 100644 --- a/packages/app-web/src/ui/Replay.tsx +++ b/packages/app-web/src/ui/Replay.tsx @@ -1,9 +1,9 @@ -import { Conversation, ThemeToggle } from "@labkit/ui"; +import { Conversation } from "@labkit/ui"; import { RECORD_TYPES } from "./record-types"; import "@labkit/ui/ui.css"; import type { TranscriptState } from "@labkit/view-model"; import { type ReactNode, useMemo, useState } from "react"; -import { Bar } from "./Bar"; +import { Frame } from "./chrome/Frame"; import { type Recording, usePlayback } from "./playback"; import { useSavedTheme } from "./saved-theme"; @@ -24,15 +24,9 @@ export function Page({ children: ReactNode; }) { return ( - <> - - {crumb} - - {controls} - - + {crumb}} end={controls}> {children} - + ); } @@ -44,7 +38,7 @@ export function Replay({ crumb, recording }: { crumb: string; recording: Recordi // `?from=N` opens the session as it stood after N events, ready to play on from there. const from = Number(new URLSearchParams(window.location.search).get("from") ?? Number.NaN); const playback = usePlayback(recording, Number.isFinite(from) ? from : undefined); - const [theme, setTheme] = useSavedTheme(); + const [theme] = useSavedTheme(); const [picked, setPicked] = useState>(new Map()); const state: TranscriptState = useMemo( () => ({ @@ -82,7 +76,6 @@ export function Replay({ crumb, recording }: { crumb: string; recording: Recordi {playback.applied} / {playback.total} {recording.at === undefined ? " · no times recorded" : ""} - ); diff --git a/packages/app-web/src/ui/SessionList.tsx b/packages/app-web/src/ui/SessionList.tsx index 9b3ecc3a..606a00bf 100644 --- a/packages/app-web/src/ui/SessionList.tsx +++ b/packages/app-web/src/ui/SessionList.tsx @@ -3,7 +3,7 @@ import { listSessions } from "@labkit/acp-client"; import { Link } from "@tanstack/react-router"; import { useEffect, useState } from "react"; import { AGENT_CWD, AGENT_URL } from "./agent-endpoint"; -import { Bar } from "./Bar"; +import { Frame } from "./chrome/Frame"; type Listed = | { kind: "loading" } @@ -52,10 +52,7 @@ export default function SessionList() { }, []); return ( - <> - - sessions - + sessions}>
{listed.kind === "loading" ? null : listed.kind === "not_listed" ? (

This agent does not list its sessions.

@@ -98,6 +95,6 @@ export default function SessionList() { )}
- + ); } diff --git a/packages/app-web/src/ui/chrome/Frame.tsx b/packages/app-web/src/ui/chrome/Frame.tsx new file mode 100644 index 00000000..6f343bfa --- /dev/null +++ b/packages/app-web/src/ui/chrome/Frame.tsx @@ -0,0 +1,77 @@ +import { ThemeToggle } from "@labkit/ui"; +import { Link } from "@tanstack/react-router"; +import type { ReactNode } from "react"; +import markUrl from "@labkit/design/mark.svg"; +import "@labkit/design/tokens.css"; +import { useSavedTheme } from "../saved-theme"; +import "./chrome.css"; + +/** + * The top bar every page shares: the LabKit mark and name, which open the list of workspaces, then + * the page's own controls, then the theme button. `onHome` replaces the link when the page has + * more to do on its way home than navigate. + */ +export function TopBar({ + onHome, + children, + end, +}: { + onHome?: () => void; + children?: ReactNode; + /** Controls at the right, before the theme button. */ + end?: ReactNode; +}) { + const [theme, setTheme] = useSavedTheme(); + const brand = ( + <> + + LabKit + + ); + return ( +
+ {onHome === undefined ? ( + + {brand} + + ) : ( + + )} + {children} + + {end} + +
+ ); +} + +/** + * A page of the app: the palette, the top bar and the page below it. The theme is the one every + * page shares (`useSavedTheme`), so content a page draws in it and the bar's button agree. + */ +export function Frame({ + bar, + end, + className = "", + children, +}: { + /** The page's controls in the top bar, after the name. */ + bar?: ReactNode; + /** The page's controls at the right of the top bar. */ + end?: ReactNode; + className?: string; + children: ReactNode; +}) { + const [theme] = useSavedTheme(); + return ( +
+ {bar} + {children} +
+ ); +} diff --git a/packages/app-web/src/ui/chrome/chrome.css b/packages/app-web/src/ui/chrome/chrome.css new file mode 100644 index 00000000..b6d3ebc8 --- /dev/null +++ b/packages/app-web/src/ui/chrome/chrome.css @@ -0,0 +1,293 @@ +/* + * The chrome the app's pages share: the palette's short names, the top bar, the rail and the + * resizable border. A page's root carries `.lk-root` for the palette and `.lk-frame` for these + * rules; the Trace Console adds `.trace` for its own. + */ +.lk-frame { + --ink: var(--lk-bg); + --panel: var(--lk-panel); + --panel-2: var(--lk-panel-2); + --line: var(--lk-line); + --text: var(--lk-text); + --text-dim: var(--lk-muted); + --text-faint: var(--lk-faint); + --accent: var(--lk-accent); + --accent-dim: var(--lk-accent-dim); + --danger: var(--lk-danger); + --primary: var(--lk-primary); + --primary-foreground: var(--lk-primary-text); + --font-sans: var(--lk-font-sans); + --font-mono: var(--lk-font-mono); + --font-wordmark: var(--lk-font-wordmark); + --panel-border: var(--lk-line); + --bg: var(--lk-bg); + display: flex; + flex-direction: column; + height: 100vh; + background: var(--ink); + color: var(--text); + font-family: var(--font-sans); + -webkit-font-smoothing: antialiased; + font-size: 14px; + line-height: 1.5; +} + +.lk-frame * { + box-sizing: border-box; +} +.lk-frame button, +.lk-frame input { + font-family: inherit; + font-size: inherit; + color: inherit; +} +.lk-frame button { + cursor: pointer; +} +.lk-frame a { + color: inherit; +} +.lk-frame ::selection { + background: var(--accent-dim); +} +.lk-frame :focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: 3px; +} + +.lk-frame .mono { + font-family: var(--font-mono); +} +.lk-frame .dim { + color: var(--text-dim); +} +.lk-frame .faint { + color: var(--text-faint); +} + +/* ---------- topbar ---------- */ +.lk-frame .topbar { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 14px; + padding: 0 16px; + height: 52px; + border-bottom: 1px solid var(--line); + background: var(--panel); +} +/* The brand is the link to the workspaces. */ +.lk-frame .brand { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 6px; + border: 0; + border-radius: 6px; + background: none; + color: var(--text); + font-size: inherit; + white-space: nowrap; +} +.lk-frame .brand:hover { + background: var(--panel-2); +} +.lk-frame .brand-mark { + width: 21px; + height: 23.6px; + flex: 0 0 auto; +} +.lk-frame .brand-name { + font-family: var(--font-wordmark, inherit); + font-weight: 600; + letter-spacing: 0.01em; +} + +.lk-frame .topbar-gap { + flex: 1 1 auto; +} + +.lk-frame .dev-links { + display: flex; + gap: 10px; + font-size: 12px; + white-space: nowrap; +} +.lk-frame .dev-links a { + color: var(--text-dim); + text-decoration: none; +} +.lk-frame .dev-links a:hover { + color: var(--accent); +} + +.lk-frame .theme-btn { + display: inline-flex; + align-items: center; + background: none; + border: 0; + border-radius: 6px; + padding: 6px 8px; + color: var(--text-dim); +} +.lk-frame .theme-btn:hover { + background: var(--panel-2); + color: var(--text); +} +.lk-frame .items-filter { + width: 100%; + margin-bottom: 6px; + background: var(--panel-2); + border: 1px solid var(--line); + border-radius: 6px; + padding: 4px 8px; + color: var(--text); + font-size: 12.5px; +} +.lk-frame .items-filter::placeholder { + color: var(--text-faint); +} + +/* The rail: a narrow column of icons at the left edge that stays when the sidebar is hidden. Its + first button shows or hides the sidebar; the others fold or open one section each. */ +.lk-frame .rail { + flex: 0 0 44px; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 8px 0; + border-right: 1px solid var(--line); + background: var(--panel); +} +.lk-frame .rail-btn { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + border: 0; + border-radius: 6px; + background: none; + color: var(--text-dim); + font-size: 18px; +} +.lk-frame .rail-btn:hover:not(:disabled) { + background: var(--panel-2); + color: var(--text); +} +.lk-frame .rail-btn.on { + background: var(--accent-dim); + color: var(--text); +} +.lk-frame .rail-btn:disabled { + color: var(--text-faint); + cursor: default; +} +/* The border between two open sections, which a drag moves. It lies over the border and takes no + height of its own, and shows the accent while it is hovered, dragged or focused. */ +.lk-frame .splitter { + flex: none; + position: relative; + z-index: 3; + height: 7px; + margin: -4px 0 -3px; + cursor: row-resize; + touch-action: none; +} +.lk-frame .splitter::after { + content: ""; + position: absolute; + left: 0; + right: 0; + top: 2px; + height: 3px; + transition: background-color 120ms ease; +} +.lk-frame .splitter:hover::after, +.lk-frame .splitter.dragging::after, +.lk-frame .splitter:focus-visible::after { + background: var(--accent); +} +.lk-frame .splitter:focus-visible { + outline: none; +} + +/* ---------- pages other than the Trace Console: gallery, sessions, replays, errors ---------- */ +.lk-frame .crumb { + color: var(--text-dim); + font-size: 13px; + text-decoration: none; +} +.lk-frame a.crumb:hover { + color: var(--accent); +} +/* A control in the top bar, such as a replay's play and speed buttons: quiet, like the tabs. */ +.lk-frame .topbar button:not(.brand):not(.theme-btn) { + padding: 5px 10px; + border: 0; + border-radius: 6px; + background: none; + color: var(--text-dim); + font-size: 12.5px; +} +.lk-frame .topbar button:not(.brand):not(.theme-btn):hover:not(:disabled) { + background: var(--panel-2); + color: var(--text); +} +.lk-frame .topbar button:disabled { + color: var(--text-faint); + cursor: default; +} +.lk-frame .page { + flex: 1 1 auto; + overflow: auto; + padding: 20px 24px; +} +.lk-frame .page h2 { + margin: 0 0 16px; + font-size: 16px; + font-weight: 600; +} +.lk-frame .page a { + color: var(--accent); + text-decoration: none; +} +.lk-frame .page a:hover { + text-decoration: underline; +} +.lk-frame .page button { + padding: 5px 10px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--panel-2); +} +.lk-frame .page button:hover { + border-color: var(--accent); +} +.lk-frame .sessions { + border-collapse: collapse; + font-size: 13px; +} +.lk-frame .sessions th { + text-align: left; + font-weight: 500; + color: var(--text-dim); +} +.lk-frame .sessions th, +.lk-frame .sessions td { + padding: 6px 16px 6px 0; + border-bottom: 1px solid var(--line); +} +/* A page that failed to load: the error the API or the router gave. */ +.lk-frame .load-error { + display: inline-block; + max-width: 80ch; + padding: 6px 10px; + border-radius: 6px; + color: var(--danger); + border: 1px solid color-mix(in srgb, var(--danger) 35%, transparent); + background: color-mix(in srgb, var(--danger) 12%, transparent); +} diff --git a/packages/app-web/src/ui/saved-theme.ts b/packages/app-web/src/ui/saved-theme.ts index bb2aa7e3..ffec9a6b 100644 --- a/packages/app-web/src/ui/saved-theme.ts +++ b/packages/app-web/src/ui/saved-theme.ts @@ -1,31 +1,44 @@ import type { Theme } from "@labkit/ui"; -import { useCallback, useState } from "react"; +import { useCallback, useSyncExternalStore } from "react"; const KEY = "labkit.theme"; +// Where storage is closed (a private window) the pick lasts as long as the page. +let current: Theme | undefined; +const listeners = new Set<() => void>(); + /** The theme last picked in this browser; "system" when none was saved or storage is closed. */ function savedTheme(): Theme { + if (current !== undefined) return current; try { const saved = localStorage.getItem(KEY); - return saved === "light" || saved === "dark" ? saved : "system"; + current = saved === "light" || saved === "dark" ? saved : "system"; } catch { - return "system"; + current = "system"; } + return current; +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); } /** * The page's theme, kept in this browser, so every page and the next visit start with the one - * last picked. Where storage is closed (a private window) the pick lasts as long as the page. + * last picked. Every component that calls this shares the one theme, so a button in the top bar + * and the content it themes always agree. */ export function useSavedTheme(): readonly [Theme, (theme: Theme) => void] { - const [theme, setTheme] = useState(savedTheme); + const theme = useSyncExternalStore(subscribe, savedTheme, () => "system" as const); const pick = useCallback((next: Theme) => { - setTheme(next); + current = next; try { localStorage.setItem(KEY, next); } catch { // Not kept; this page still shows it. } + for (const listener of listeners) listener(); }, []); return [theme, pick] as const; } diff --git a/packages/app-web/src/ui/trace/TraceConsole.tsx b/packages/app-web/src/ui/trace/TraceConsole.tsx index 82f46493..4a8fb918 100644 --- a/packages/app-web/src/ui/trace/TraceConsole.tsx +++ b/packages/app-web/src/ui/trace/TraceConsole.tsx @@ -5,7 +5,7 @@ * pasted link opens it again. */ -import { ContentCard, ICONS, JsonView, ThemeToggle } from "@labkit/ui"; +import { ContentCard, ICONS, JsonView } from "@labkit/ui"; import "@labkit/ui/content.css"; import { ArrowClockwiseIcon, @@ -32,8 +32,6 @@ import { WrenchIcon, XCircleIcon, } from "@phosphor-icons/react"; -import markUrl from "@labkit/design/mark.svg"; -import "@labkit/design/tokens.css"; import { Link, useNavigate, useRouterState } from "@tanstack/react-router"; import { type CSSProperties, @@ -48,6 +46,7 @@ import { useState, useSyncExternalStore, } from "react"; +import { TopBar } from "../chrome/Frame"; import { useSavedFlag, useSavedNumber } from "../saved-flag"; import { useSavedTheme } from "../saved-theme"; import { GraphView, type GraphEdgeSeed, type GraphNodeSeed, type Overlay } from "../GraphView"; @@ -157,7 +156,7 @@ export interface TraceConsoleProps { export function TraceConsole({ path, list, tab }: TraceConsoleProps) { const navigate = useNavigate(); - const [theme, setTheme] = useSavedTheme(); + const [theme] = useSavedTheme(); const [drawer, setDrawer] = useState<"items" | undefined>(); // At full width the sidebar is shown or hidden, as this browser last left it; in a narrow // window it is a drawer over the page, closed until opened. @@ -218,16 +217,8 @@ export function TraceConsole({ path, list, tab }: TraceConsoleProps) { return ( -
-
- - - {import.meta.env.DEV ? : null} - -
+
+ : null} />