diff --git a/browser/ui/src/configuration/index.tsx b/browser/ui/src/configuration/index.tsx index ea3684d16..8dd7d9f95 100644 --- a/browser/ui/src/configuration/index.tsx +++ b/browser/ui/src/configuration/index.tsx @@ -4,7 +4,12 @@ * unsaved-change guard; this component only edits the JSON draft. */ -import { type ConfigFormProps, Input, type JsonValue, StatusPanel } from '@iii-dev/console-ui' +import { + type ConfigFormProps, + Input, + type JsonValue, + StatusPanel, +} from '@iii-dev/console-ui' import { type ReactNode, useEffect, useRef, useState } from 'react' import { ChevronLeftIcon, GlobeIcon, useContainerNarrow } from '../lib/widgets' @@ -49,7 +54,9 @@ const FIELD_SECTION: Record = { } function asObject(value: JsonValue | undefined): JsonObject { - return value && typeof value === 'object' && !Array.isArray(value) ? { ...value } : {} + return value && typeof value === 'object' && !Array.isArray(value) + ? { ...value } + : {} } function stringValue(value: JsonValue | undefined, fallback = ''): string { @@ -60,7 +67,10 @@ function numberValue(value: JsonValue | undefined, fallback: number): number { return typeof value === 'number' ? value : fallback } -function booleanValue(value: JsonValue | undefined, fallback: boolean): boolean { +function booleanValue( + value: JsonValue | undefined, + fallback: boolean, +): boolean { return typeof value === 'boolean' ? value : fallback } @@ -70,7 +80,12 @@ function pointer(field: string) { function fieldError(errors: ConfigFormProps['errors'], field: string) { const base = pointer(field) - return errors?.get(base) ?? [...(errors?.entries() ?? [])].find(([path]) => path.startsWith(`${base}/`))?.[1] + return ( + errors?.get(base) ?? + [...(errors?.entries() ?? [])].find(([path]) => + path.startsWith(`${base}/`), + )?.[1] + ) } function formatCount(value: number) { @@ -131,7 +146,11 @@ function TextField({ }) { const id = `br-cfg-${field}` return ( - {label}} hint={hint} error={error}> + {label}} + hint={hint} + error={error} + > {label}} hint={hint} error={error}> + {label}} + hint={hint} + error={error} + > Allowed URL schemes} + label={ + + } hint="Enter a comma-separated list without ://. Keep this list as narrow as your workflows allow." error={error} > @@ -274,7 +299,13 @@ function SchemesField({ ) } -function SectionHeader({ title, description }: { title: string; description: string }) { +function SectionHeader({ + title, + description, +}: { + title: string + description: string +}) { return (
@@ -298,9 +329,18 @@ function ConfigNav({ const height = numberValue(value.viewport_height, DEFAULTS.viewport_height) const maxSessions = numberValue(value.max_sessions, DEFAULTS.max_sessions) const headless = booleanValue(value.headless, DEFAULTS.headless) - const consoleBuffer = numberValue(value.console_buffer, DEFAULTS.console_buffer) - const networkBuffer = numberValue(value.network_buffer, DEFAULTS.network_buffer) - const timeout = numberValue(value.default_timeout_ms, DEFAULTS.default_timeout_ms) + const consoleBuffer = numberValue( + value.console_buffer, + DEFAULTS.console_buffer, + ) + const networkBuffer = numberValue( + value.network_buffer, + DEFAULTS.network_buffer, + ) + const timeout = numberValue( + value.default_timeout_ms, + DEFAULTS.default_timeout_ms, + ) const idle = numberValue(value.idle_stop_ms, DEFAULTS.idle_stop_ms) const sections: Array<{ @@ -338,7 +378,7 @@ function ConfigNav({ return (
{booleanValue(value.allow_attach, DEFAULTS.allow_attach) ? (
- Attach mode is enabled. Only connect to browser instances you trust. + Attach mode is enabled. Only connect to browser instances you + trust.
) : null} @@ -558,8 +609,12 @@ function ConfigEditor({ }} > - {numberValue(value.viewport_width, DEFAULTS.viewport_width)} ×{' '} - {numberValue(value.viewport_height, DEFAULTS.viewport_height)} + {numberValue(value.viewport_width, DEFAULTS.viewport_width)}{' '} + ×{' '} + {numberValue( + value.viewport_height, + DEFAULTS.viewport_height, + )}

Aspect-ratio preview for newly launched sessions.

@@ -676,11 +731,16 @@ function ConfigEditor({ typeof scheme === 'string') + ? value.allowed_schemes.filter( + (scheme): scheme is string => + typeof scheme === 'string', + ) : [...DEFAULTS.allowed_schemes] } error={fieldError(errors, 'allowed_schemes')} - onChange={(schemes) => onChange({ ...value, allowed_schemes: schemes })} + onChange={(schemes) => + onChange({ ...value, allowed_schemes: schemes }) + } /> @@ -718,7 +778,9 @@ export function BrowserConfigForm(props: ConfigFormProps) { useEffect(() => { if (!focusKey || !domRef.current) return const field = props.focusField?.[0] ?? focusKey - const target = domRef.current.querySelector(`[data-field="${CSS.escape(field)}"]`) + const target = domRef.current.querySelector( + `[data-field="${CSS.escape(field)}"]`, + ) target?.focus() target?.scrollIntoView({ block: 'center' }) }, [focusKey, selection]) @@ -729,7 +791,9 @@ export function BrowserConfigForm(props: ConfigFormProps) { return (
- {showNav ? : null} + {showNav ? ( + + ) : null} {showEditor ? ( {formatTime(entry.timestamp)} - + {entry.level} @@ -304,9 +307,7 @@ export function ConsoleReadView({ export function NetworkEntryRow({ entry }: { entry: BrowserNetworkEntry }) { return (
  • - + {entry.status ?? (entry.failed ? 'err' : '...')} {entry.method} @@ -338,7 +339,7 @@ export function NetworkReadView({ variant={res.entries.length > 0 ? 'accent' : 'default'} /> {req?.failed_only ? ( - failed only + Failed only ) : null} {req?.pattern ? /{req.pattern}/ : null} {res.dropped > 0 ? ( @@ -513,7 +514,9 @@ export function EvaluateView({
    · undefined
    ) : (
    - +
    ) ) : ( diff --git a/browser/ui/src/function-trigger-message/index.tsx b/browser/ui/src/function-trigger-message/index.tsx index a458c528e..9ffbe9896 100644 --- a/browser/ui/src/function-trigger-message/index.tsx +++ b/browser/ui/src/function-trigger-message/index.tsx @@ -165,13 +165,13 @@ function BrowserCallView({ message }: { message: FunctionTriggerMessage }) { )} {sessionId ? ( - + open in browser tab ) : null}
  • {running && message.output == null ? ( -

    running...

    +

    Running...

    ) : body ? ( body ) : fallback != null ? ( @@ -179,7 +179,7 @@ function BrowserCallView({ message }: { message: FunctionTriggerMessage }) {
    ) : ( -

    no result

    +

    No result

    )} ) diff --git a/browser/ui/src/lib/errors.tsx b/browser/ui/src/lib/errors.tsx index e211838fe..71b9c51b4 100644 --- a/browser/ui/src/lib/errors.tsx +++ b/browser/ui/src/lib/errors.tsx @@ -147,7 +147,9 @@ function denialToInvocation( ? 'Denied' : 'Trigger failed' const message = - denial.reason ?? fallbackMessage ?? 'The browser trigger could not complete.' + denial.reason ?? + fallbackMessage ?? + 'The browser trigger could not complete.' return { title, message, @@ -344,7 +346,7 @@ function DispatchDeniedView({ denial }: { denial: InfraDispatchDenial }) { denied - dispatch policy + Dispatch policy {fn ? (
    diff --git a/browser/ui/src/lib/icons.tsx b/browser/ui/src/lib/icons.tsx index b3a3eff66..345191d0e 100644 --- a/browser/ui/src/lib/icons.tsx +++ b/browser/ui/src/lib/icons.tsx @@ -18,7 +18,7 @@ export interface IconProps { } function Svg({ - size = 14, + size = 16, children, className, style, diff --git a/browser/ui/src/page/SessionRail.tsx b/browser/ui/src/page/SessionRail.tsx index 20e399ed9..13cd4bd24 100644 --- a/browser/ui/src/page/SessionRail.tsx +++ b/browser/ui/src/page/SessionRail.tsx @@ -29,7 +29,12 @@ function hostOf(url: string): string { } } -export function SessionRail({ sessions, selectedId, loading, onSelect }: SessionRailProps) { +export function SessionRail({ + sessions, + selectedId, + loading, + onSelect, +}: SessionRailProps) { if (sessions.length === 0) { if (loading) { return ( @@ -46,7 +51,10 @@ export function SessionRail({ sessions, selectedId, loading, onSelect }: Session return (

    No sessions yet.

    -

    Sessions started by agents appear in this list live; new session starts one now.

    +

    + Sessions started by agents appear in this list live; new session + starts one now. +

    ) } @@ -65,18 +73,24 @@ export function SessionRail({ sessions, selectedId, loading, onSelect }: Session className={cn('br-ui-rail-row', selected && 'active')} > - + - {session.title?.trim() || hostOf(session.url) || 'about:blank'} + {session.title?.trim() || + hostOf(session.url) || + 'about:blank'} + + + {session.headless ? 'headless' : 'headful'} - {session.headless ? 'headless' : 'headful'} {session.url} - live + Live · - {formatMtime(Math.floor(session.last_used_ms / 1000))} + + {formatMtime(Math.floor(session.last_used_ms / 1000))} + diff --git a/browser/ui/src/page/SessionView.tsx b/browser/ui/src/page/SessionView.tsx index 7dc495f63..414dd309d 100644 --- a/browser/ui/src/page/SessionView.tsx +++ b/browser/ui/src/page/SessionView.tsx @@ -18,7 +18,7 @@ * here — url draft, pick mode, type buffer, pane choices — is session-local. */ -import { Button, type Host, Input } from '@iii-dev/console-ui' +import { Button, type Host, Input, SegmentedControl } from '@iii-dev/console-ui' import { useCallback, useEffect, useRef, useState } from 'react' import { BROWSER_PICKED_TRIGGER, @@ -122,7 +122,9 @@ export function SessionView({ readStored(dockStoreKey) === 'network' ? 'network' : 'console', ) const dockCollapsedStoreKey = `browser-ui:${tabId || 'page'}:dock-collapsed` - const [dockCollapsed, setDockCollapsedState] = useState(() => readStored(dockCollapsedStoreKey) === 'true') + const [dockCollapsed, setDockCollapsedState] = useState( + () => readStored(dockCollapsedStoreKey) === 'true', + ) const setDockPane = (pane: FeedPane) => { setDockPaneState(pane) writeStored(dockStoreKey, pane) @@ -194,7 +196,8 @@ export function SessionView({ const openCurrentPage = useCallback(() => { let url = urlDraft.trim() || session.url - if (url && !/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(url)) url = `https://${url}` + if (url && !/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(url)) + url = `https://${url}` if (url) window.open(url, '_blank', 'noopener,noreferrer') }, [session.url, urlDraft]) @@ -252,7 +255,9 @@ export function SessionView({ setLastPicked(evt) // No composer slot in injected UI: copy the summary for the user to // paste into chat. - void navigator.clipboard?.writeText(formatPickedElement(evt)).catch(() => {}) + void navigator.clipboard + ?.writeText(formatPickedElement(evt)) + .catch(() => {}) setPicking(false) }, }) @@ -341,22 +346,39 @@ export function SessionView({ }) }, [host, sessionId, runAction, onSessionsRefresh, onStopped]) - const displayName = session.title?.trim() || hostOf(session.url) || 'about:blank' - const feedPane: FeedPane = narrow ? (narrowPane === 'network' ? 'network' : 'console') : dockPane + const displayName = + session.title?.trim() || hostOf(session.url) || 'about:blank' + const feedPane: FeedPane = narrow + ? narrowPane === 'network' + ? 'network' + : 'console' + : dockPane const browserMajor = chromiumVersion?.match(/\d+/)?.[0] const browserLabel = browserMajor ? `Chromium ${browserMajor}` : null return ( -
    +
    - {narrow ? : null} + {narrow ? ( + + ) : null}
    - + {displayName} - {session.headless ? 'headless' : 'headful'} - {!narrow && browserLabel ? {browserLabel} : null} + + {session.headless ? 'headless' : 'headful'} + + {!narrow && browserLabel ? ( + {browserLabel} + ) : null}
    {session.url} @@ -368,7 +390,9 @@ export function SessionView({ {!narrow ? ( <> · - started {formatMtime(Math.floor(session.created_ms / 1000))} + + started {formatMtime(Math.floor(session.created_ms / 1000))} + ) : null} @@ -385,10 +409,15 @@ export function SessionView({ } className={cn('br-ui-pick-btn', picking && 'is-on')} > - + {picking ? 'Inspecting...' : 'Inspect'} -
    @@ -396,27 +425,36 @@ export function SessionView({ {lastPicked ? (
    - picked - + Picked + {lastPicked.element.ref} - {pickedSelector(lastPicked.element)} + + {pickedSelector(lastPicked.element)} + - copied to clipboard + Copied to clipboard
    ) : null} {actionError ? (
    {actionError} -
    @@ -424,20 +462,21 @@ export function SessionView({ {narrow ? (
    - {/* biome-ignore lint/a11y/useSemanticElements: segmented control of buttons; fieldset chrome (min-content sizing) breaks the row */} -
    - {NARROW_PANES.map((pane) => ( - - ))} -
    + + value={narrowPane} + onChange={setNarrowPane} + options={NARROW_PANES.map((pane) => ({ + value: pane, + label: + pane === 'console' + ? 'Console' + : pane === 'network' + ? 'Network' + : 'Viewport', + }))} + className="br-ui-tabs" + aria-label="Session view" + />
    ) : null} @@ -451,7 +490,10 @@ export function SessionView({ submitUrl() }} > -
    +
    - + - @@ -538,26 +584,26 @@ export function SessionView({ {!narrow ? (
    - {/* biome-ignore lint/a11y/useSemanticElements: segmented control of buttons; fieldset chrome (min-content sizing) breaks the row */} -
    - {FEED_PANES.map((pane) => ( - - ))} -
    + + value={dockPane} + onChange={setDockPane} + options={FEED_PANES.map((pane) => ({ + value: pane, + label: pane === 'console' ? 'Console' : 'Network', + }))} + className="br-ui-tabs" + aria-label="Session feeds" + />
    {!dockCollapsed ? (
    {dockPane === 'console' ? ( - + ) : ( - + )}
    ) : null} @@ -590,7 +644,9 @@ export function SessionView({ ) : ( Viewport: — )} - {session.headless ? 'Headless' : 'Headful'} + + {session.headless ? 'Headless' : 'Headful'} + {browserLabel ? {browserLabel} : null} @@ -599,7 +655,9 @@ export function SessionView({ {viewportShown ? ( picking ? ( - pick mode: click an element to copy it — esc cancels + + pick mode: click an element to copy it — esc cancels + ) : ( <> Click to focus diff --git a/browser/ui/src/page/index.tsx b/browser/ui/src/page/index.tsx index 8ea9dc71a..3eb353621 100644 --- a/browser/ui/src/page/index.tsx +++ b/browser/ui/src/page/index.tsx @@ -1,9 +1,10 @@ /** * The browser page (#/ext/browser): the standard page chrome (PageShell/ - * PageHeader from @iii-dev/console-ui) over a session rail and the selected - * session's workspace — a screencast-fed live viewport with the console and - * network feeds — so a user can watch what an agent is doing in a Chromium - * session, drive the page directly, and pick elements into the clipboard. + * PageHeader/PageSidebar from @iii-dev/console-ui) over a session rail and the + * selected session's workspace — a screencast-fed live viewport with the + * console and network feeds — so a user can watch what an agent is doing in a + * Chromium session, drive the page directly, and pick elements into the + * clipboard. * * The host only mounts this page while the browser worker is connected, so * there is no presence gate here (worker disconnect disposes the script and @@ -12,20 +13,36 @@ * * Layout adapts to the width the page HAS (a ResizeObserver on its own body * row, not a viewport media query — the console can host it in panes of any - * size). Wide: the rail (start control + session list) is a fixed navigation - * column beside the session workspace. Under NARROW_BELOW px it becomes a - * drill-in flow: the session list fills the width, and opening a session - * swaps it for the full-width workspace with a ← back button. The screencast - * subscription only runs while the viewport is actually visible (see - * SessionView), so a narrow pane parked on the list streams nothing. + * size). Wide: the rail (start control + session list) is a collapsible + * navigation column beside the session workspace. Under NARROW_BELOW px it + * becomes a drill-in flow: the session list fills the width, and opening a + * session swaps it for the full-width workspace with a ← back button. The + * screencast subscription only runs while the viewport is actually visible + * (see SessionView), so a narrow pane parked on the list streams nothing. */ -import { Button, type Host, PageHeader, type PageRenderProps, PageShell } from '@iii-dev/console-ui' +import { + Button, + type Host, + PageHeader, + type PageRenderProps, + PageShell, + PageSidebar, +} from '@iii-dev/console-ui' import type { ComponentType } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { errorMessage, readBrowserDoctor, startBrowserSession } from '../lib/browser' +import { + errorMessage, + readBrowserDoctor, + startBrowserSession, +} from '../lib/browser' import { Plus } from '../lib/icons' -import { GlobeIcon, LivePill, RefreshButton, useContainerNarrow } from '../lib/widgets' +import { + GlobeIcon, + LivePill, + RefreshButton, + useContainerNarrow, +} from '../lib/widgets' import { SessionRail } from './SessionRail' import { SessionView } from './SessionView' import { useBrowserSessionsLive } from './useBrowserSessionsLive' @@ -47,7 +64,10 @@ export function BrowserPage({ onClose: () => void }> | undefined - const { sessions, loading, error, live, refresh } = useBrowserSessionsLive(host, true) + const { sessions, loading, error, live, refresh } = useBrowserSessionsLive( + host, + true, + ) const [chromiumVersion, setChromiumVersion] = useState(null) useEffect(() => { @@ -91,7 +111,10 @@ export function BrowserPage({ }) }, [loading, sessions]) - const selected = useMemo(() => sessions.find((s) => s.session_id === selectedId) ?? null, [sessions, selectedId]) + const selected = useMemo( + () => sessions.find((s) => s.session_id === selectedId) ?? null, + [sessions, selectedId], + ) // The drilled-into session can die underneath us (stopped from chat or // another tab): drill back out to the list rather than silently showing @@ -138,12 +161,16 @@ export function BrowserPage({ } title="Browser" - description="live Chromium sessions you can watch and drive" + description="Live Chromium sessions you can watch and drive" actions={
    {ConfigurationDialog ? ( - ) : null} @@ -164,32 +191,88 @@ export function BrowserPage({
    ) : null} -
    +
    {railVisible ? ( -
    + } + collapsedActions={ + <> + + + + } + > + {startError ?

    {startError}

    : null}
    - active now + Active now - {loading && sessions.length === 0 ? null : {sessions.length}} - + {loading && sessions.length === 0 ? null : ( + {sessions.length} + )} +
    - +
    - + ) : null} {stageVisible ? ( @@ -217,8 +300,9 @@ export function BrowserPage({

    No browser sessions

    - Sessions started by agents appear here automatically. Start one from the session rail, or ask an agent - to call browser::sessions::start. + Sessions started by agents appear here automatically. Start + one from the session rail, or ask an agent to call{' '} + browser::sessions::start.

    @@ -226,7 +310,10 @@ export function BrowserPage({ ) : null}
    {ConfigurationDialog ? ( - setConfigOpen(false)} /> + setConfigOpen(false)} + /> ) : null} ) diff --git a/browser/ui/styles.css b/browser/ui/styles.css index 8d02bdacc..e94d99308 100644 --- a/browser/ui/styles.css +++ b/browser/ui/styles.css @@ -132,12 +132,6 @@ /* ── navigation rail ────────────────────────────────────────────────── */ [data-iii-ui="browser"] .br-ui-rail { - width: 300px; - flex-shrink: 0; - display: flex; - flex-direction: column; - min-height: 0; - background: var(--color-sidebar); border-right: 1px solid var(--color-edge); } [data-iii-ui="browser"] .br-ui-browser.right .br-ui-rail { @@ -148,23 +142,14 @@ the viewport: one pane at a time — the JSX renders either the rail or the opened session workspace. */ [data-iii-ui="browser"] .br-ui-browser.narrow .br-ui-rail { - width: 100%; border-right: 0; border-left: 0; } -/* start control, pinned above the list in both layout modes */ -[data-iii-ui="browser"] .br-ui-rail-top { - display: flex; - flex-direction: column; - gap: 8px; - padding: 14px; - flex-shrink: 0; - border-bottom: 1px solid var(--color-edge); -} - [data-iii-ui="browser"] .br-ui-rail-intro { display: flex; + flex: 1; + min-width: 0; align-items: flex-start; gap: 10px; } @@ -197,6 +182,8 @@ } [data-iii-ui="browser"] .br-ui-rail-err { margin: 0; + padding: 8px 14px; + border-bottom: 1px solid var(--color-edge); font-size: 12px; color: var(--color-alert); word-break: break-word; @@ -254,8 +241,8 @@ outline-offset: -2px; } [data-iii-ui="browser"] .br-ui-iconbtn-icon { - width: 13px; - height: 13px; + width: 16px; + height: 16px; } [data-iii-ui="browser"] .br-ui-rail-scroll { @@ -291,17 +278,21 @@ font-family: inherit; } [data-iii-ui="browser"] .br-ui-rail-row:hover { - border-color: color-mix(in srgb, var(--color-ink-ghost) 58%, var(--color-edge)); + border-color: color-mix( + in srgb, + var(--color-ink-ghost) 58%, + var(--color-edge) + ); background: var(--color-surface-hover); } [data-iii-ui="browser"] .br-ui-rail-row:focus-visible { outline: 2px solid var(--color-rule-focus); outline-offset: -2px; } -/* Selection = wash + accent indicator + stronger title, not color alone. */ +/* Selection stays neutral: wash + edge + stronger title. */ [data-iii-ui="browser"] .br-ui-rail-row.active { - border-color: var(--color-accent); - background: color-mix(in srgb, var(--color-accent) 10%, var(--color-panel-raised)); + border-color: var(--color-edge); + background: var(--color-surface-selected); } [data-iii-ui="browser"] .br-ui-rail-row.active::before { content: none; @@ -317,7 +308,7 @@ color: var(--color-ink-ghost); } [data-iii-ui="browser"] .br-ui-rail-row.active .br-ui-rail-icon { - color: var(--color-accent); + color: var(--color-ink-faint); } [data-iii-ui="browser"] .br-ui-rail-title { min-width: 0; @@ -527,7 +518,8 @@ flex-shrink: 0; border-radius: 50%; background: var(--color-ok, var(--color-accent)); - box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-ok, var(--color-accent)) 12%, transparent); + box-shadow: 0 0 0 2px + color-mix(in srgb, var(--color-ok, var(--color-accent)) 12%, transparent); } [data-iii-ui="browser"] .br-ui-doc-actions { display: flex; @@ -595,7 +587,8 @@ [data-iii-ui="browser"] .br-ui-stop-btn { min-height: 36px; padding-inline: 13px; - border: 1px solid color-mix(in srgb, var(--color-alert) 80%, var(--color-edge)); + border: 1px solid + color-mix(in srgb, var(--color-alert) 80%, var(--color-edge)); color: var(--color-alert); } @@ -772,55 +765,19 @@ font-variant-numeric: tabular-nums; } -/* segmented control — one control, mutually exclusive options */ -[data-iii-ui="browser"] .br-ui-seg { - display: inline-flex; - align-items: center; - gap: 2px; - padding: 2px; - background: var(--color-surface); - border-radius: 6px; +/* Shared line tabs own typography, icon sizing and active underline. */ +[data-iii-ui="browser"] .br-ui-tabs { flex-shrink: 0; } -[data-iii-ui="browser"] .br-ui-seg.block { - display: flex; -} -[data-iii-ui="browser"] .br-ui-seg-btn { - appearance: none; - border: 0; - background: transparent; - height: 26px; - padding: 0 12px; - border-radius: 4px; - font-family: var(--font-mono, ui-monospace, monospace); - font-size: 11px; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--color-ink-faint); - cursor: pointer; -} -[data-iii-ui="browser"] .br-ui-seg-btn:hover { - color: var(--color-ink); - background: var(--color-surface-hover); -} -[data-iii-ui="browser"] .br-ui-seg-btn.active { - color: var(--color-ink); - background: var(--color-panel-raised); - box-shadow: 0 0 0 1px var(--color-edge); -} -[data-iii-ui="browser"] .br-ui-seg-btn:focus-visible { - outline: 2px solid var(--color-rule-focus); - outline-offset: -2px; -} -[data-iii-ui="browser"] .br-ui-seg.block .br-ui-seg-btn { - flex: 1; -} /* narrow-mode viewport | console | network switcher row */ [data-iii-ui="browser"] .br-ui-view-row { padding: 8px 12px; flex-shrink: 0; border-bottom: 1px solid var(--color-edge); } +[data-iii-ui="browser"] .br-ui-view-row .br-ui-tabs { + width: 100%; +} /* ── viewport — one browser frame, sized from the live screencast ───── */ @@ -973,27 +930,8 @@ border-bottom: 1px solid var(--color-edge); } -[data-iii-ui="browser"] .br-ui-dock .br-ui-seg { +[data-iii-ui="browser"] .br-ui-dock .br-ui-tabs { align-self: stretch; - gap: 0; - padding: 0; - border-radius: 0; - background: transparent; -} -[data-iii-ui="browser"] .br-ui-dock .br-ui-seg-btn { - position: relative; - height: 100%; - padding: 0 16px; - border-radius: 0; - text-transform: none; - letter-spacing: 0; - font-family: var(--font-sans, system-ui, sans-serif); - font-size: 12px; -} -[data-iii-ui="browser"] .br-ui-dock .br-ui-seg-btn.active { - background: var(--color-surface-selected); - color: var(--color-accent); - box-shadow: inset 0 -2px 0 var(--color-accent); } [data-iii-ui="browser"] .br-ui-dock.collapsed .br-ui-dock-head { @@ -1033,7 +971,7 @@ height: 16px; flex-shrink: 0; transform: rotate(-90deg); - transition: transform 120ms ease; + transition: transform var(--motion-duration-fast) var(--motion-ease-standard); } [data-iii-ui="browser"] .br-ui-dock.collapsed .br-ui-dock-toggle-icon { @@ -1544,7 +1482,7 @@ inset: 7px auto 7px -8px; width: 2px; border-radius: 0 2px 2px 0; - background: var(--color-accent); + background: var(--color-edge); content: ""; } diff --git a/canvas/ui/src/page/ExportMenu.tsx b/canvas/ui/src/page/ExportMenu.tsx index a816b96ca..5d80fd155 100644 --- a/canvas/ui/src/page/ExportMenu.tsx +++ b/canvas/ui/src/page/ExportMenu.tsx @@ -57,7 +57,7 @@ export function ExportMenu({ theme, disabled, onExport }: ExportMenuProps) { disabled={disabled} title="download as svg" > - + svg diff --git a/canvas/ui/src/page/MermaidPane.tsx b/canvas/ui/src/page/MermaidPane.tsx index e103ec835..c21c4ff70 100644 --- a/canvas/ui/src/page/MermaidPane.tsx +++ b/canvas/ui/src/page/MermaidPane.tsx @@ -80,7 +80,6 @@ interface View { z: number } - interface MermaidPaneProps { host: Host record: CanvasRecord @@ -217,7 +216,10 @@ export function MermaidPane({ initMermaidOnce(mermaid, theme) await mermaid.parse(source) if (seqRef.current !== seq) return - const { svg } = await mermaid.render(`cv-mmd-${record.id}-${seq}`, source) + const { svg } = await mermaid.render( + `cv-mmd-${record.id}-${seq}`, + source, + ) setPreview((s) => renderSucceeded(s, seq, svg)) } catch (err) { setPreview((s) => renderFailed(s, seq, errorMessage(err))) @@ -254,7 +256,8 @@ export function MermaidPane({ const svg = el.querySelector('svg') if (!svg) return const box = svg.viewBox.baseVal - const w = box && box.width > 0 ? box.width : svg.getBoundingClientRect().width + const w = + box && box.width > 0 ? box.width : svg.getBoundingClientRect().width const h = box && box.height > 0 ? box.height : svg.getBoundingClientRect().height if (w > 0 && h > 0) { @@ -378,7 +381,11 @@ export function MermaidPane({ const d = dragRef.current if (!d) return const v = viewRef.current - viewRef.current = { ...v, x: v.x + (e.clientX - d.px), y: v.y + (e.clientY - d.py) } + viewRef.current = { + ...v, + x: v.x + (e.clientX - d.px), + y: v.y + (e.clientY - d.py), + } dragRef.current = { px: e.clientX, py: e.clientY } schedule() } @@ -465,7 +472,7 @@ export function MermaidPane({ disabled={!contentSize} title="fit the diagram in the frame" > - + fit + + } + collapsedActions={ - - + } + > {sideError ?
    {sideError}
    : null} {listState.phase === 'loading' ? ( @@ -477,7 +430,7 @@ export function CanvasPage({ title="delete" onClick={() => remove(c)} > - + ))} diff --git a/canvas/ui/styles.css b/canvas/ui/styles.css index 9fdecc768..63b4a8ca3 100644 --- a/canvas/ui/styles.css +++ b/canvas/ui/styles.css @@ -46,14 +46,18 @@ [data-iii-ui='canvas'] .cv-side-head { display: flex; + flex: 1; + min-width: 0; align-items: center; justify-content: space-between; gap: 0.5rem; - padding: 0.5rem 0.625rem 0.25rem; - flex: none; } [data-iii-ui='canvas'] .cv-side-count { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; color: var(--color-ink-faint); font-size: 0.75rem; } @@ -237,27 +241,7 @@ grid-template-columns: minmax(200px, var(--cv-split, 44%)) 6px minmax(0, 1fr); } -/* Drag handles — sidebar boundary and the editor|preview divider. */ -[data-iii-ui='canvas'] .cv-sidebar { - position: relative; -} -[data-iii-ui='canvas'] .cv-resize-handle { - position: absolute; - top: 0; - bottom: 0; - width: 5px; - cursor: col-resize; - z-index: 2; - touch-action: none; -} -[data-iii-ui='canvas'] .cv-resize-handle.left { - left: 0; -} -[data-iii-ui='canvas'] .cv-resize-handle.right { - right: 0; -} -[data-iii-ui='canvas'] .cv-resize-handle:hover, -[data-iii-ui='canvas'] .cv-resize-handle:active, +/* Drag handle for the editor|preview divider. */ [data-iii-ui='canvas'] .cv-split-handle:hover, [data-iii-ui='canvas'] .cv-split-handle:active { background: var(--color-panel-raised, var(--color-panel)); diff --git a/computer/ui/src/page/index.tsx b/computer/ui/src/page/index.tsx index 5c3c848e9..4afa96734 100644 --- a/computer/ui/src/page/index.tsx +++ b/computer/ui/src/page/index.tsx @@ -9,7 +9,7 @@ * * Layout adapts to the width the page HAS (a ResizeObserver on its own body * row, not a viewport media query — the console can host it in panes of any - * size). Wide: the rail (start form + session list) is a fixed navigation + * size). Wide: the rail (start form + session list) is a collapsible navigation * column beside the desktop workspace. Under NARROW_BELOW px it becomes a * drill-in flow: the session list fills the width, and opening a session * swaps it for the full-width viewport with a ← back button. The screencast @@ -22,6 +22,7 @@ import { PageHeader, type PageRenderProps, PageShell, + PageSidebar, } from '@iii-dev/console-ui' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { @@ -52,7 +53,9 @@ const NARROW_BELOW = 720 * gives it. Measures synchronously on mount to avoid a wide-mode flash; * zero widths (display:none) are ignored so a hidden page keeps its last * real layout. */ -function useContainerNarrow(threshold: number): [(node: HTMLDivElement | null) => void, boolean] { +function useContainerNarrow( + threshold: number, +): [(node: HTMLDivElement | null) => void, boolean] { const [narrow, setNarrow] = useState(false) const observerRef = useRef(null) const refCb = useCallback( @@ -206,8 +209,8 @@ export function ComputerPage({ } - title="computer" - description="live desktops you can watch and drive" + title="Computer" + description="Live desktops you can watch and drive" actions={} onClose={onRequestClose} /> @@ -233,7 +236,24 @@ export function ComputerPage({ ref={rootRef} > {railVisible ? ( - + ) : null} {stageVisible ? ( @@ -322,7 +335,9 @@ export function ComputerPage({ click to focus — clicks, scroll, typing and shortcuts forward as act - shift+esc leaves the surface + + shift+esc leaves the surface + ) : ( @@ -330,8 +345,8 @@ export function ComputerPage({

    no desktop yet

    - start a session to drive this machine, a sandboxed desktop, - or a remote one. + start a session to drive this machine, a sandboxed desktop, or + a remote one.

    )} diff --git a/computer/ui/styles.css b/computer/ui/styles.css index 8ff28a0aa..c7981a0c5 100644 --- a/computer/ui/styles.css +++ b/computer/ui/styles.css @@ -111,12 +111,6 @@ /* ── navigation rail ────────────────────────────────────────────────── */ [data-iii-ui="computer"] .cp-ui-rail { - width: 280px; - flex-shrink: 0; - display: flex; - flex-direction: column; - min-height: 0; - background: var(--color-sidebar); border-right: 1px solid var(--color-edge); } [data-iii-ui="computer"] .cp-ui-browser.right .cp-ui-rail { @@ -127,7 +121,6 @@ the viewport: one pane at a time — the JSX renders either the rail or the opened desktop. */ [data-iii-ui="computer"] .cp-ui-browser.narrow .cp-ui-rail { - width: 100%; border-right: 0; border-left: 0; } @@ -164,10 +157,8 @@ display: flex; align-items: center; gap: 8px; - min-height: 32px; - padding: 6px 10px 6px 14px; - flex-shrink: 0; - border-bottom: 1px solid var(--color-edge); + width: 100%; + min-width: 0; } [data-iii-ui="computer"] .cp-ui-col-head .label { font-family: var(--font-mono, ui-monospace, monospace); @@ -214,7 +205,7 @@ [data-iii-ui="computer"] .cp-ui-rail-row:hover { background: var(--color-surface-hover); } -/* Selection = wash + accent indicator + stronger id, not color alone. */ +/* Selection stays neutral: wash + edge indicator + stronger id. */ [data-iii-ui="computer"] .cp-ui-rail-row.active { background: var(--color-surface-selected); } @@ -226,7 +217,7 @@ bottom: 7px; width: 2px; border-radius: 1px; - background: var(--color-accent); + background: var(--color-edge); } [data-iii-ui="computer"] .cp-ui-rail-pick { flex: 1; diff --git a/console/DESIGN.md b/console/DESIGN.md index c60b9b3de..10beb74b2 100644 --- a/console/DESIGN.md +++ b/console/DESIGN.md @@ -1,7 +1,7 @@ --- version: beta name: iii Schematic -description: A minimal, engineering-document design system for the iii engine. The system draws no lines — hierarchy comes entirely from layered surfaces and alpha-gray fills; warm cream paper in light, neutral grays from black in dark, with a single rationed accent (burnt orange on cream, electric blue on dark), one 6px corner radius everywhere, and a mono voice for technical data. The only sanctioned strokes are the focus ring and the very subtle `edge` frame around the floating workspace panels. +description: A minimal, engineering-document design system for the iii engine. Hierarchy comes from layered surfaces and alpha-gray fills; warm cream paper in light, neutral grays from black in dark, with a single rationed accent (burnt orange on cream, electric blue on dark), one 6px corner radius everywhere, and a mono voice for technical data. Strokes are limited to focus, the subtle workspace frame, and an optional neutral selection edge. colors: bg: "#f2f0ed" sidebar: "#edeae5" @@ -11,7 +11,7 @@ colors: paper-2: "#ebe8e3" surface: "rgba(20, 16, 8, 0.055)" surface-hover: "rgba(20, 16, 8, 0.085)" - surface-selected: "rgba(184, 66, 15, 0.12)" + surface-selected: "rgba(20, 16, 8, 0.12)" surface-active: "rgba(20, 16, 8, 0.12)" ink: "#0a0a0a" ink-2: "#1a1a1a" @@ -44,7 +44,7 @@ colors: paper-2-dark: "#171717" surface-dark: "rgba(255, 255, 255, 0.055)" surface-hover-dark: "rgba(255, 255, 255, 0.085)" - surface-selected-dark: "rgba(40, 168, 247, 0.14)" + surface-selected-dark: "rgba(255, 255, 255, 0.12)" surface-active-dark: "rgba(255, 255, 255, 0.12)" ink-dark: "#ededed" ink-faint-dark: "#a6a6a6" @@ -373,7 +373,7 @@ The full design-token stylesheet — drop this in as your global CSS entrypoint: --color-paper-2: #ebe8e3; --color-surface: rgba(20, 16, 8, 0.055); --color-surface-hover: rgba(20, 16, 8, 0.085); - --color-surface-selected: rgba(184, 66, 15, 0.12); + --color-surface-selected: rgba(20, 16, 8, 0.12); --color-surface-active: rgba(20, 16, 8, 0.12); /* ── Ink ramp ──────────────────────────────────────────────────────── */ @@ -434,7 +434,14 @@ The full design-token stylesheet — drop this in as your global CSS entrypoint: 0 1px 0 rgba(255, 255, 255, 0.03) inset, 0 12px 32px rgba(0, 0, 0, 0.28); /* ── Motion ─────────────────────────────────────────────────────────── */ - --ease-glide: cubic-bezier(0.2, 0.8, 0.2, 1); + --motion-duration-instant: 0ms; + --motion-duration-fast: 120ms; + --motion-duration-control: 160ms; + --motion-duration-panel: 220ms; + --motion-ease-standard: cubic-bezier(0.2, 0, 0, 1); + --motion-ease-enter: cubic-bezier(0.16, 1, 0.3, 1); + --motion-ease-exit: cubic-bezier(0.4, 0, 1, 1); + --ease-glide: var(--motion-ease-enter); /* spacing scale (carried from the YAML) */ --spacing-gutter: 24px; @@ -447,8 +454,8 @@ The full design-token stylesheet — drop this in as your global CSS entrypoint: [data-theme="dark"] { /* Neutral grays derived from black — no blue cast in the base ramp. The component fills (surface*) are white-alpha so a step reads identically - over any base layer; the ONLY chromatic surface is surface-selected, - the blue selection tint. Borders are gone: rule/rule-2/rule-strong + over any base layer, including selection. Borders are gone: + rule/rule-2/rule-strong resolve to transparent, and hierarchy is carried entirely by fills. */ --color-bg: #0a0a0a; --color-sidebar: #0e0e0e; @@ -457,7 +464,7 @@ The full design-token stylesheet — drop this in as your global CSS entrypoint: --color-paper-2: #171717; --color-surface: rgba(255, 255, 255, 0.055); --color-surface-hover: rgba(255, 255, 255, 0.085); - --color-surface-selected: rgba(40, 168, 247, 0.14); + --color-surface-selected: rgba(255, 255, 255, 0.12); --color-surface-active: rgba(255, 255, 255, 0.12); --color-ink: #ededed; --color-ink-faint: #a6a6a6; @@ -607,18 +614,19 @@ looking boxed. - The page is built from **layered surfaces**: hierarchy comes from a one-step background difference, full stop. The system draws **no lines** — no outlines on controls, no dividers between rows or regions. Exactly - two strokes are sanctioned: the focus indicator (`rule-focus` - border/ring) on a focused control, and the very subtle `edge` frame - around the floating workspace panels. + three strokes are sanctioned: the focus indicator (`rule-focus`), the + very subtle `edge` frame around floating workspace panels, and an optional + neutral `edge` on a selected row/card when fill alone is too subtle. - Dark mode is a **first-class layered system** — neutral grays derived from black (`#0a0a0a → #171717`, no blue cast) with white-alpha component fills, not an inverted paper ramp. - One corner radius: **6px everywhere** (every Tailwind radius step resolves to it); the shapes stay disciplined, not soft or - consumer-playful. Lowercase voice throughout. + consumer-playful. Natural sentence/title case throughout. - Color is rationed: the palette is essentially **ink-on-surface**, broken by a single accent (burnt orange on cream, electric blue on dark) reserved - for selected, focused, active, and live states. + for primary actions, form focus, live/running state, and semantic data. + Selection itself remains neutral in both themes. - The personality is **technical but unintimidating** — the same energy as a well-kept lab notebook or a hand-drawn architecture diagram. It must feel built by engineers, for engineers, and for the agents working alongside @@ -629,8 +637,10 @@ looking boxed. ### Voice -- All UI copy is **lowercase**, including headlines, buttons, and nav items. -- Headlines treat sentence fragments as visual blocks (e.g. *"any task. one +- Author human-facing UI copy in **natural sentence/title case**, including + headlines, buttons, tabs, menus, fields, and navigation. Never force case + with CSS text transforms. +- Headlines treat sentence fragments as visual blocks (e.g. *"Any task. One experience."*). - Numbers and metadata always use **tabular monospace**, never proportional figures. @@ -644,18 +654,17 @@ looking boxed. ## 2. Typography — two families (Geist + Geist Mono) -**Geist** (`--font-sans`) carries the UI: navigation, conversation titles, -buttons, inputs, chat content, empty states, headings, labels. **Geist -Mono** (`--font-mono`) carries everything technical: trace names, worker -names, function names, IDs, timestamps, metrics, span labels, filter -expressions, code-like values. +**Geist** (`--font-sans`) carries all human-facing UI chrome: navigation, +conversation titles, buttons, inputs, tabs, menus, chat content, empty states, +headings, and labels. **Geist Mono** (`--font-mono`) is reserved for +machine-readable values: trace names, worker names, function names, IDs, +timestamps, metrics, span labels, filter expressions, and code-like values. **Rule:** if a human wrote it, it's sans; if the machine produced it (or a machine will parse it), it's mono. Don't add a third family — variety comes -from weight, scale, case, and letter-spacing, not more fonts. Chat and -configuration surfaces remap incidental `.font-mono` chrome back to Geist -(see the `.chat-surface` / `.configuration-surface` rules in `index.css`); -function-trigger cards run Geist Mono throughout. +from weight, scale, case, and letter-spacing, not more fonts. Do not apply +mono to an entire panel or function-trigger card; technical values inside +those surfaces opt into mono individually. Decorative ligatures are explicitly disabled on mono surfaces (`liga 0, clig 0, calt 0, dlig 0`) to preserve the schematic feel. @@ -682,10 +691,9 @@ Decorative ligatures are explicitly disabled on mono surfaces - Any numeric or timestamp cell uses `tabular-nums` so columns align. See the duration column in `Trace` and the version row in `WorkerCard` (§10). -- The **only** uppercase text in the system is the `label-caps-*` set - (uppercase + tracking). Used for: tab strips, status pills, table headers, - code-block chrome, section eyebrows. Never capitalize a sentence to "fix" a - heading — rewrite it instead. +- Human-facing labels keep their authored casing. Do not use CSS `uppercase` + or `lowercase` transforms on tabs, buttons, menus, fields, or navigation. + Preserve acronyms and machine identifiers exactly as provided. --- @@ -699,9 +707,8 @@ raw CSS variable. The base layers (`bg` → `panel-raised`) are solid neutral tones; the component fills (`surface*`) are **alpha grays**, so one step reads -identically over any base layer. `surface-selected` is the only chromatic -surface — the accent-tinted selection fill (blue in dark, burnt orange in -light). +identically over any base layer. `surface-selected` is a stronger neutral +alpha fill in both themes; it never inherits the orange/blue accent. | Token | Use | | ------------------ | ---------------------------------------------------------- | @@ -712,7 +719,7 @@ light). | `paper-2` | Legacy alias for `panel-raised` (kept for existing code) | | `surface` | Inputs, controls, pills, chips, secondary cards | | `surface-hover` | Hover state on rows, items, and ghost controls | -| `surface-selected` | Selected conversation, trace row, or list item (accent tint) | +| `surface-selected` | Selected conversation, trace row, list item, card, tab, or chip (neutral wash) | | `surface-active` | Strong active/pressed state | ### Ink (4-step contrast) @@ -737,16 +744,15 @@ breaking. Never design with them. | `rule-2` | transparent | Legacy subtle divider — draws nothing | | `rule-strong` | transparent | Legacy emphasis border — draws nothing | | `rule-focus` | accent ~60–70% alpha | The interactive stroke: the focus indicator on inputs and controls | -| `edge` | ink ~7–8% alpha | The structural stroke: the VERY subtle frame around the floating workspace panels (the tab columns) — never used inside a panel | +| `edge` | ink ~7–8% alpha | The structural stroke: workspace frames and optional neutral selected edges/rails | ### Accent (single hero — burnt orange on cream, electric blue on dark) `accent`, `accent-fg`, `accent-hover`, `accent-muted` (10–12%-alpha fill), -`accent-border` (35%-alpha, legacy — prefer `accent-muted` fills). Reserved -for: selected navigation and segmented controls (`accent-muted` fill), -focused inputs (`rule-focus`), active filters, live state, the selected -conversation/trace (`surface-selected` fill), the primary technical action, -the `$` prompt. **Never** for body text, large fills, or outlines. +`accent-border` (35%-alpha, legacy). Reserved for focused form controls +(`rule-focus`), live/running state, primary technical actions, semantic chart +data, and the `$` prompt. **Never** use accent for selection labels, rails, +borders, tab underlines, or selected card fills. ### Status @@ -757,7 +763,7 @@ stripe or outline. | Token | Use | | -------- | ----------------------------------------- | -| `accent` | live / running / focused / selected | +| `accent` | live / running / focused / primary action | | `ok` | success, completed calls, diff additions | | `alert` | error states (traces, status panels) | | `warn` | warning states, pending approval | @@ -768,9 +774,9 @@ Override the same tokens inside a `[data-theme="dark"]` block (see §0). Dark is a neutral gray ramp derived from black (`#0a0a0a → #0e0e0e → #111111 → #171717` — no blue cast), white-alpha component fills (`rgba(255,255,255,0.055 → 0.12)`), neutral light ink (`#ededed → #a6a6a6 → -#6f6f6f`), and the accent swapped to electric blue (`#28a8f7`) — which also -tints `surface-selected`. Same structural logic; the blue lives only in -state, never in the grays. +#6f6f6f`), and the accent swapped to electric blue (`#28a8f7`). Selection +remains a white-alpha neutral wash; blue is reserved for meaningful state, +never the neutral gray ramp. To follow the OS, set the attribute on load: @@ -783,9 +789,9 @@ document.documentElement.dataset.theme = isDark ? 'dark' : 'light' ## 4. The "surfaces, not borders" rule (key composition pattern) -> **A one-step surface difference defines a region. The system draws no -> lines — exactly two strokes are sanctioned: the focus ring, and the -> `edge` frame around the floating workspace panels.** +> **A one-step surface difference defines a region. Strokes are limited to +> focus, the workspace `edge` frame, and a neutral selected edge when a wash +> alone is not sufficiently clear.** Structure comes from the layered surface ramp (§3): a new region means a new background step, never an outline and never a divider. Controls are @@ -793,7 +799,7 @@ alpha-gray fills; rows separate by their hover/selected fills; regions separate by base-layer steps; overlays separate by `panel-raised` + `shadow-floating`. -Two strokes are allowed, each with one job: +Three strokes are allowed, each with one job: 1. **Focus** — a focused field swaps its (transparent) border to `rule-focus` and gains a soft 3px accent ring; keyboard focus on @@ -802,8 +808,10 @@ Two strokes are allowed, each with one job: workspace-tab column) float on the canvas as `rounded-sm border border-edge bg-panel` with 6px gutters; the `edge` stroke is a VERY subtle ink-alpha frame that keeps a panel readable - against the canvas. It is never used inside a panel — interior - hierarchy stays fill-only. + against the canvas. +3. **Selection edge** — dense lists/cards may pair `surface-selected` with + an `edge` border or 2px inset rail. The edge stays neutral and the label + stays `ink`; never substitute `accent`. Nothing else in the chrome may draw a line. (Data visualizations are exempt: charts may draw connector and grid lines with explicit alpha-ink @@ -814,8 +822,8 @@ trace timeline.) ```tsx
    -
    - title +
    + Title
    {/* body */}
    @@ -826,9 +834,10 @@ separator. No divider, no outer outline. ### Selection and severity -- Selected row, card, conversation, or trace: `bg-surface-selected` — the - accent-tinted fill. **No left rail, no outline.** -- Active segment / tab / filter: `bg-accent-muted` fill. +- Selected row, card, conversation, or trace: `bg-surface-selected` with + `text-ink`; optionally add a subtle `edge` border/inset rail for dense + navigation. Never recolor the label or rail with accent. +- Active segment / tab / filter: `bg-surface-selected` with `text-ink`. - Row severity: the status's `-muted` tinted fill (e.g. `bg-alert-muted`) plus the status text color and dot — no stripe. - Legacy `divide-y divide-rule-2` / `border-b border-rule-2` classes are @@ -842,6 +851,25 @@ dropdowns, tooltips). `.deal-shadow` remains a transient animation cue on the language-card stack. No heavy glows — the only glow is the live `pulse-dot`. +### Motion vocabulary + +Motion communicates continuity; it never decorates a settled screen or delays +input. Use the shared tokens instead of component-local milliseconds: + +| Token | Duration | Use | +| --- | ---: | --- | +| `motion-duration-instant` | 0ms | Streaming/high-frequency updates and direct manipulation | +| `motion-duration-fast` | 120ms | Tooltip/menu opacity and lightweight feedback | +| `motion-duration-control` | 160ms | Hover, selected, pressed, chevrons, segmented controls | +| `motion-duration-panel` | 220ms | Dialogs, sheets and panel/overlay entry/exit | + +Use `motion-ease-standard` for state changes, `motion-ease-enter` for mounting, +and `motion-ease-exit` for dismissal. Width/position updates driven by token +streaming, logs, traces, terminal output, drag, resize, or pointer movement are +instant: repeated updates must not queue animations. Under +`prefers-reduced-motion`, all three non-zero duration tokens resolve to zero and +non-essential keyframes run once without delay. + **When in doubt:** step the background one level. Never reach for a border. --- @@ -863,8 +891,8 @@ bg application canvas ``` Interactive containers walk the state sub-ramp (`surface → -surface-hover → surface-selected → surface-active`); nothing about their -edges ever changes except the focus ring. +surface-hover → surface-selected → surface-active`). Dense selected rows/cards +may also reveal the neutral `edge`; focus remains the only chromatic stroke. ### Where elevation actually shows up @@ -883,8 +911,8 @@ edges ever changes except the focus ring. system allows. Dark mode keeps the same surface-driven hierarchy — neutral grays stepping -up from black. The only visible edge anywhere is the workspace panels' -`edge` frame (§4). +up from black. Visible edges remain limited to workspace frames and the +optional neutral selected-state edge (§4). --- @@ -1001,9 +1029,10 @@ severity. background plus its text color and dot. Never a stripe, ring, or solid status background. See `Trace` (§10). -**Dot semantics:** green (`ok`) means completed; blue (`accent`) is reserved -for live/running (animated) and selected states; `warn` for pending; `alert` -for failed. For inline "live" emphasis, pair a `StatusDot` with the +**Dot semantics:** green (`ok`) means completed; `accent` is reserved for +live/running activity; `warn` for pending; `alert` for failed. Selection uses +the neutral surface recipe, never a status dot or chromatic state. For inline +"live" emphasis, pair a `StatusDot` with the `pulse-dot` utility — the only sanctioned glow. --- @@ -1070,8 +1099,8 @@ export function Caret({ className }: CaretProps) { ### `StatusDot` 6px circle. Optional `.pulse-dot` glow for "live" emphasis. Tone semantics -per §9: `ok` = completed, `accent` = live/selected, `warn` = pending, -`alert` = failed. +per §9: `ok` = completed, `accent` = live/running, `warn` = pending, +`alert` = failed. Selection is not a status tone. ```tsx import * as React from 'react' @@ -1131,7 +1160,7 @@ import { cva, type VariantProps } from 'class-variance-authority' import { cn } from '@/lib/utils' const buttonVariants = cva( - 'inline-flex items-center justify-center gap-x-2 whitespace-nowrap font-mono lowercase rounded-sm transition-[background-color,color,border-color] duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rule-focus disabled:pointer-events-none disabled:opacity-40 select-none', + 'iii-ui-motion-control inline-flex items-center justify-center gap-x-2 whitespace-nowrap rounded-sm font-sans transition-[background-color,color,border-color] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rule-focus disabled:pointer-events-none disabled:opacity-40 select-none [&>svg]:size-4 [&>svg]:shrink-0', { variants: { variant: { @@ -1260,14 +1289,14 @@ export function StatusPanel({
    {headline}
    {detail ? ( -
    +
    {detail}
    ) : null} @@ -1330,7 +1359,7 @@ export function Terminal({ title, children, className }: TerminalProps) { return (
    {title ? ( -
    +
    {title}
    ) : null} @@ -1411,7 +1440,7 @@ export function Trace({ title, rows, totalMs, className }: TraceProps) { ) return (
    -
    +
    {title}
      @@ -1425,13 +1454,13 @@ export function Trace({ title, rows, totalMs, className }: TraceProps) { className="grid grid-cols-[auto_1fr_auto_auto] items-center gap-x-3 px-3.5 py-2 font-mono text-[12px]" > - {row.op} + {row.op} {row.durationMs}ms @@ -1473,11 +1502,11 @@ export function Cell({ title, children, className }: CellProps) { return (
      {title ? ( -
      +
      {title}
      ) : null} -
      +
      {children}
      @@ -1490,7 +1519,8 @@ export function Cell({ title, children, className }: CellProps) { 400px-wide ticker card with a name + version row, description, a `panel`-tinted command block, and a footer with a kind tag and check icon. Focused state switches the body fill from `surface` to `surface-selected` -(the accent tint) — no rail, no outline. +(a neutral wash) and may add the neutral `edge` when fill alone is too subtle. +It never changes the title to `accent`. ```tsx import * as React from 'react' @@ -1518,27 +1548,27 @@ export function WorkerCard({ return (
      -
      +
      {name}
      -
      +
      v{version}
      -
      +
      {description}
      {command}