From a567694ebc8190a4529e86776df4eaf3dfd8f0a7 Mon Sep 17 00:00:00 2001 From: Dan Barua Date: Mon, 12 Oct 2026 01:05:58 +0100 Subject: [PATCH] Every page shares one frame: the Trace Console's top bar, palette and theme The gallery, sessions list, replays, agent page and error pages used their own older header and palette. They now use the frame in src/ui/chrome, with the Trace Console's top bar, the palette's short names and quiet controls. The theme is one shared setting, so the top bar's button and the content agree. The top bar no longer links to the gallery or the sessions list. Co-Authored-By: Claude Sonnet 5.5 --- packages/app-web/docs/architecture.md | 9 + packages/app-web/e2e/app.e2e.ts | 25 ++ packages/app-web/src/routes/__root.tsx | 7 +- packages/app-web/src/styles.css | 189 +---------- packages/app-web/src/ui/AgentSession.tsx | 7 +- packages/app-web/src/ui/Bar.tsx | 36 --- packages/app-web/src/ui/Gallery.tsx | 15 +- packages/app-web/src/ui/LoadError.tsx | 7 +- packages/app-web/src/ui/Replay.tsx | 17 +- packages/app-web/src/ui/SessionList.tsx | 9 +- packages/app-web/src/ui/chrome/Frame.tsx | 77 +++++ packages/app-web/src/ui/chrome/chrome.css | 293 ++++++++++++++++++ packages/app-web/src/ui/saved-theme.ts | 25 +- .../app-web/src/ui/trace/TraceConsole.tsx | 21 +- packages/app-web/src/ui/trace/trace.css | 209 +------------ 15 files changed, 456 insertions(+), 490 deletions(-) delete mode 100644 packages/app-web/src/ui/Bar.tsx create mode 100644 packages/app-web/src/ui/chrome/Frame.tsx create mode 100644 packages/app-web/src/ui/chrome/chrome.css 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} />