diff --git a/DESIGN.md b/DESIGN.md index 2a5a92b..6c8e56b 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -2,18 +2,19 @@ name: Flect description: The interface that takes your shape. colors: - void: "oklch(0.095 0 0)" - canvas: "oklch(0.125 0.004 340)" - surface: "oklch(0.165 0.006 340)" - surface-raised: "oklch(0.205 0.008 340)" - ink: "oklch(0.955 0.006 340)" - muted: "oklch(0.690 0.012 340)" - quiet: "oklch(0.500 0.012 340)" - line: "oklch(0.300 0.010 340)" - primary: "oklch(0.922 0 0)" - primary-hover: "oklch(0.870 0 0)" - ready: "oklch(0.780 0.120 158)" - danger: "oklch(0.660 0.170 27)" + # Dark-mode values as shipped in src/styles.css; light-mode noted inline. + void: "oklch(0.145 0 0)" # light: oklch(1 0 0) + canvas: "oklch(0.145 0 0)" # aliases void; light: oklch(1 0 0) + surface: "oklch(0.205 0 0)" # light: oklch(1 0 0) + surface-raised: "oklch(0.269 0 0)" # light: oklch(0.97 0 0) + ink: "oklch(0.985 0 0)" # light: oklch(0.145 0 0) + muted: "oklch(0.708 0 0)" # light: oklch(0.45 0 0) + quiet: "oklch(0.708 0 0)" # aliases muted; light: oklch(0.45 0 0) + line: "oklch(1 0 0 / 10%)" # light: oklch(0.922 0 0) + primary: "oklch(0.922 0 0)" # light: oklch(0.205 0 0) + primary-hover: "oklch(0.922 0 0)" # light: oklch(0.205 0 0) + ready: "oklch(0.985 0 0)" # aliases ink until a mint accent ships; light: oklch(0.145 0 0) + danger: "oklch(0.704 0.191 22.216)" # light: oklch(0.577 0.245 27.325) typography: headline: fontFamily: "-apple-system, BlinkMacSystemFont, SF Pro Display, Inter, sans-serif" @@ -39,6 +40,8 @@ rounded: prompt: "16px" pill: "999px" spacing: + # hairline is 1px logically; on 2dppx+ displays structural separators render + # at 0.5px via the --hairline token, restored to 1px under prefers-contrast. hairline: "1px" xs: "4px" sm: "8px" @@ -202,24 +205,31 @@ warning, or failure states. ### Secondary -- **Ready Mint** (`oklch(0.780 0.120 158)`): runtime readiness and successful - completion. Always paired with a label or accessible name. -- **Failure Red** (`oklch(0.660 0.170 27)`): actionable failures and destructive +- **Ready** (`oklch(0.985 0 0)` dark, `oklch(0.145 0 0)` light): runtime + readiness and successful completion. Currently an alias of Ink; a mint + accent is a later, deliberate decision. Always paired with a label or + accessible name. +- **Failure Red** (`oklch(0.704 0.191 22.216)` dark, + `oklch(0.577 0.245 27.325)` light): actionable failures and destructive status only. ### Neutral -- **Void** (`oklch(0.095 0 0)`): application background. -- **Canvas** (`oklch(0.125 0.004 340)`): conversation canvas and protected - shell. -- **Surface** (`oklch(0.165 0.006 340)`): the prompt and primary controls. -- **Raised Surface** (`oklch(0.205 0.008 340)`): hover, menus, and temporary - elevation. -- **Ink** (`oklch(0.955 0.006 340)`): primary text and high-emphasis icons. -- **Muted** (`oklch(0.690 0.012 340)`): secondary text with readable contrast. -- **Quiet** (`oklch(0.500 0.012 340)`): disabled detail and decorative marks, - never essential copy. -- **Line** (`oklch(0.300 0.010 340)`): structural separators. +- **Void** (`oklch(0.145 0 0)` dark, `oklch(1 0 0)` light): application + background. +- **Canvas** (same as Void): conversation canvas and protected shell. +- **Surface** (`oklch(0.205 0 0)` dark, `oklch(1 0 0)` light): the prompt and + primary controls. +- **Raised Surface** (`oklch(0.269 0 0)` dark, `oklch(0.97 0 0)` light): + hover, menus, and temporary elevation. +- **Ink** (`oklch(0.985 0 0)` dark, `oklch(0.145 0 0)` light): primary text + and high-emphasis icons. +- **Muted** (`oklch(0.708 0 0)` dark, `oklch(0.45 0 0)` light): secondary text + with readable contrast. +- **Quiet** (same as Muted today): disabled detail and decorative marks, never + essential copy. +- **Line** (`oklch(1 0 0 / 10%)` dark, `oklch(0.922 0 0)` light): structural + separators. ### Named Rules @@ -261,10 +271,14 @@ a tighter shadow because they temporarily sit above the working plane. ### Shadow Vocabulary -- **Prompt Lift** (`0 6px 8px oklch(0.02 0 0 / 0.24)`): focused or active prompt - only, without a simultaneous decorative border. -- **Menu Lift** (`0 8px 8px oklch(0.02 0 0 / 0.32)`): temporary popovers and - menus. +- **Prompt Lift** (`0 1px 0` Ink at 4% + `0 8px 24px` soft shadow at 72%): the + composer surface, without a simultaneous decorative border. +- **Menu Lift** (`0 8px 8px var(--shadow-strong)`, `oklch(0 0 0 / 0.32)` dark + and `oklch(0 0 0 / 0.16)` light): temporary popovers and menus. +- **Active Edge Light** (`inset 0 0.5px 0` Ink at 7%): a top-edge inner + highlight on the focused composer and open popovers or menus only. It marks + the one surface that is temporarily above the working plane and must never + appear on resting cards (the Flat-Until-Active Rule). ### Named Rules @@ -278,8 +292,11 @@ to communicate focus, movement, or temporary hierarchy. - **Shape:** circular for icon-only controls; 8px for labeled controls. - **Primary:** Ink fill with Void content, using the neighboring neutral when active. -- **Hover / Focus:** 180ms tonal transition and a visible 2px primary focus - ring with offset. +- **Hover / Focus:** two motion tiers. Small-control hover and press tints + (color, background, opacity) use the 90ms `--interaction-motion` tier; + layout, elevation, and sheet or rail movement keep the 180ms + `--state-motion` tier. Focus uses a visible 2px primary focus ring with + offset. - **Ghost:** transparent at rest, Raised Surface on hover, never a faint outline box. diff --git a/src/components/role-aware-shell.test.tsx b/src/components/role-aware-shell.test.tsx index ed51954..5874612 100644 --- a/src/components/role-aware-shell.test.tsx +++ b/src/components/role-aware-shell.test.tsx @@ -62,6 +62,31 @@ const documentWithoutPrompt = InterfaceDocument.make({ }, }); +const documentWithAction = InterfaceDocument.make({ + version: 2, + name: "Projects", + root: { + id: "root", + type: "stack", + direction: "column", + gap: "lg", + children: [ + { + id: "headline", + type: "text", + text: "Projects", + style: "headline", + }, + { + id: "archive", + type: "button", + label: "Archive", + action: "shape", + }, + ], + }, +}); + const conversation = (status: AgentSessionStatus = "ready") => ({ status, messages: [], @@ -393,6 +418,47 @@ describe("RoleAwareShell", () => { ).toHaveFocus(); }); + it("restores focus to the composer when the focused canvas control disappears", async () => { + const { rerender } = render( + , + ); + + const canvasButton = await screen.findByRole("button", { + name: "Archive", + }); + canvasButton.focus(); + expect(canvasButton).toHaveFocus(); + + rerender( + , + ); + + await waitFor(() => + expect( + screen.getByRole("textbox", { name: "Message Flect" }), + ).toHaveFocus(), + ); + }); + + it("leaves focus alone when it legitimately moved before a canvas update", async () => { + const { rerender } = render( + , + ); + + const canvasButton = await screen.findByRole("button", { + name: "Archive", + }); + canvasButton.focus(); + const input = screen.getByRole("textbox", { name: "Message Flect" }); + input.focus(); + + rerender( + , + ); + + expect(input).toHaveFocus(); + }); + it("starts an accepted experience in Run and submits only to App Agent", async () => { const user = userEvent.setup(); const appSubmit = vi.fn(() => Promise.resolve()); diff --git a/src/components/role-aware-shell.tsx b/src/components/role-aware-shell.tsx index b0be749..8dac96d 100644 --- a/src/components/role-aware-shell.tsx +++ b/src/components/role-aware-shell.tsx @@ -23,6 +23,7 @@ import type { ShareInstallationRecord } from "../../shared/share-installation"; import type { ShareReview as ShareReviewContract } from "../../shared/share-review"; import type { AgentWorkspaceController } from "../hooks/use-agent-session"; import { isAgentSessionActive } from "../hooks/use-agent-session"; +import { useFocusRestoration } from "../hooks/use-focus-restoration"; import type { ShellPreferencesController } from "../hooks/use-shell-preferences"; import type { CapsulePresentationState } from "../lib/workspace-controller"; import type { WorkspacePhase } from "../lib/workspace-phase"; @@ -427,6 +428,26 @@ export function RoleAwareShell({ }); }, []); + const restoreLostFocus = useCallback(() => { + if (collapsed) { + reopenRef.current?.focus(); + return; + } + const container = railContainerRef.current; + const composerInput = container?.querySelector( + '.composer textarea[name="message"]', + ); + if (composerInput !== undefined && composerInput !== null) { + composerInput.focus({ preventScroll: true }); + return; + } + container + ?.querySelector(".agent-rail__header button:not(:disabled)") + ?.focus(); + }, [collapsed]); + + useFocusRestoration(shellRef, restoreLostFocus); + const focusComposer = useCallback(() => { if (preferences.value.railCollapsed) { shouldFocusRailRef.current = true; diff --git a/src/hooks/use-focus-restoration.ts b/src/hooks/use-focus-restoration.ts new file mode 100644 index 0000000..4e42e75 --- /dev/null +++ b/src/hooks/use-focus-restoration.ts @@ -0,0 +1,48 @@ +import { type RefObject, useEffect, useRef } from "react"; + +/** + * Restores keyboard focus to a stable protected fallback when the focused + * element inside the shell is removed from the DOM (canvas updates, list + * rerenders) and focus would otherwise drop to ``. + * + * The hook only remembers ephemeral interaction state (the last element that + * held focus inside the shell) and never steals focus that legitimately moved + * elsewhere: after a commit it restores only when that remembered element is + * disconnected AND `document.activeElement` is ``. While the user is + * typing the active element is the input itself, and focus inside the capsule + * iframe leaves the iframe element active, so both are left alone. + */ +export const useFocusRestoration = ( + shellRef: RefObject, + restore: () => void, +) => { + const lastFocusedRef = useRef(undefined); + + useEffect(() => { + const remember = (event: FocusEvent) => { + const target = event.target; + if ( + target instanceof HTMLElement && + shellRef.current?.contains(target) === true + ) { + lastFocusedRef.current = target; + } + }; + globalThis.document.addEventListener("focusin", remember); + return () => globalThis.document.removeEventListener("focusin", remember); + }, [shellRef]); + + // No dependency array: focus can be orphaned by any commit that replaces + // canvas or rail content, so the check must run after every update. + useEffect(() => { + const previous = lastFocusedRef.current; + if ( + previous !== undefined && + !previous.isConnected && + globalThis.document.activeElement === globalThis.document.body + ) { + lastFocusedRef.current = undefined; + restore(); + } + }); +}; diff --git a/src/lib/workspace-controller.ts b/src/lib/workspace-controller.ts index 9af009f..4478c4c 100644 --- a/src/lib/workspace-controller.ts +++ b/src/lib/workspace-controller.ts @@ -25,7 +25,6 @@ import { ShareLocalSource, type ShareSource, } from "../../packages/product/src/share"; -import { ProposalBuildRequest } from "../../shared/browser-build"; import { canvasSelectionSummary } from "../../shared/canvas-selection"; import { type DecodedCapsule, @@ -73,7 +72,6 @@ import { defaultInterfaceDocument, type InterfaceDocument, type InterfaceNode, - validateInterfaceDocument, } from "../../shared/interface-document"; import { ProductCapabilityProjection, diff --git a/src/styles-palette-contrast.test.ts b/src/styles-palette-contrast.test.ts new file mode 100644 index 0000000..613b34e --- /dev/null +++ b/src/styles-palette-contrast.test.ts @@ -0,0 +1,243 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +/** + * WCAG 2.x contrast audit of the shipped OKLCH palette in src/styles.css. + * + * The palette is parsed from both the light block (`:root`) and the dark + * block (`@media (prefers-color-scheme: dark)`), semantic aliases such as + * `--canvas: var(--background)` are resolved, and each color is converted + * OKLCH -> OKLab -> linear sRGB -> WCAG relative luminance with no external + * dependencies. + */ + +// --- Pure color math ------------------------------------------------------- + +interface Rgb { + readonly r: number; + readonly g: number; + readonly b: number; +} + +const clamp01 = (value: number) => Math.min(1, Math.max(0, value)); + +/** OKLCH (L in [0,1], C >= 0, H in degrees) to gamma-encoded sRGB in [0,1]. */ +const oklchToSrgb = ( + lightness: number, + chroma: number, + hueDegrees: number, +): Rgb => { + const hue = (hueDegrees * Math.PI) / 180; + const labA = chroma * Math.cos(hue); + const labB = chroma * Math.sin(hue); + + const l = (lightness + 0.396_337_777_4 * labA + 0.215_803_757_3 * labB) ** 3; + const m = (lightness - 0.105_561_345_8 * labA - 0.063_854_172_8 * labB) ** 3; + const s = (lightness - 0.089_484_177_5 * labA - 1.291_485_548 * labB) ** 3; + + const linear = { + r: 4.076_741_662_1 * l - 3.307_711_591_3 * m + 0.230_969_929_2 * s, + g: -1.268_438_004_6 * l + 2.609_757_401_1 * m - 0.341_319_396_5 * s, + b: -0.004_196_086_3 * l - 0.703_418_614_7 * m + 1.707_614_701 * s, + }; + + const encode = (channel: number) => { + const c = clamp01(channel); + return c <= 0.003_130_8 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055; + }; + + return { r: encode(linear.r), g: encode(linear.g), b: encode(linear.b) }; +}; + +/** WCAG 2.x relative luminance of a gamma-encoded sRGB color. */ +const relativeLuminance = ({ r, g, b }: Rgb): number => { + const linearize = (channel: number) => + channel <= 0.040_45 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4; + return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b); +}; + +/** WCAG 2.x contrast ratio between two gamma-encoded sRGB colors. */ +const contrastRatio = (foreground: Rgb, background: Rgb): number => { + const lighter = Math.max( + relativeLuminance(foreground), + relativeLuminance(background), + ); + const darker = Math.min( + relativeLuminance(foreground), + relativeLuminance(background), + ); + return (lighter + 0.05) / (darker + 0.05); +}; + +// --- Palette parsing -------------------------------------------------------- + +type CustomProperties = ReadonlyMap; + +const parseCustomProperties = (block: string): Map => { + const properties = new Map(); + for (const match of block.matchAll(/(--[\w-]+):\s*([^;]+);/g)) { + const name = match[1]; + const value = match[2]; + if (name !== undefined && value !== undefined) { + properties.set(name, value.trim()); + } + } + return properties; +}; + +const blockAt = (source: string, openBraceIndex: number): string => { + let depth = 0; + for (let index = openBraceIndex; index < source.length; index += 1) { + if (source[index] === "{") depth += 1; + if (source[index] === "}") { + depth -= 1; + if (depth === 0) return source.slice(openBraceIndex + 1, index); + } + } + throw new Error("Unbalanced braces in styles.css"); +}; + +const lightBlock = (source: string): string => { + const start = source.search(/^:root \{/m); + if (start < 0) throw new Error("Missing top-level :root block"); + return blockAt(source, source.indexOf("{", start)); +}; + +const darkBlock = (source: string): string => { + const media = source.indexOf("@media (prefers-color-scheme: dark)"); + if (media < 0) throw new Error("Missing dark color-scheme block"); + const root = source.indexOf(":root", media); + return blockAt(source, source.indexOf("{", root)); +}; + +/** Follow `var(--x)` alias chains down to a literal value. */ +const resolveValue = (name: string, properties: CustomProperties): string => { + let value = properties.get(name); + for (let hop = 0; hop < 16 && value !== undefined; hop += 1) { + const reference = /^var\((--[\w-]+)\)$/.exec(value); + if (reference === null) return value; + const next = reference[1]; + if (next === undefined) break; + value = properties.get(next); + } + if (value === undefined) { + throw new Error(`Custom property ${name} did not resolve to a literal`); + } + return value; +}; + +const parseOklch = (value: string): Rgb => { + const match = + /^oklch\(\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)\s*(?:\/\s*[\d.]+%?\s*)?\)$/.exec( + value, + ); + if (match === null) throw new Error(`Not a literal oklch() color: ${value}`); + return oklchToSrgb(Number(match[1]), Number(match[2]), Number(match[3])); +}; + +const resolveColor = (name: string, properties: CustomProperties): Rgb => + parseOklch(resolveValue(name, properties)); + +const stylesSource = readFileSync( + fileURLToPath(new URL("./styles.css", import.meta.url)), + "utf8", +); +const lightProperties = parseCustomProperties(lightBlock(stylesSource)); +const darkProperties = new Map([ + ...lightProperties, + ...parseCustomProperties(darkBlock(stylesSource)), +]); + +// --- Color math unit tests -------------------------------------------------- + +describe("oklch color math", () => { + it("converts achromatic extremes exactly", () => { + const white = oklchToSrgb(1, 0, 0); + expect(white.r).toBeCloseTo(1, 4); + expect(white.g).toBeCloseTo(1, 4); + expect(white.b).toBeCloseTo(1, 4); + const black = oklchToSrgb(0, 0, 0); + expect(black.r).toBeCloseTo(0, 4); + expect(black.g).toBeCloseTo(0, 4); + expect(black.b).toBeCloseTo(0, 4); + }); + + it("converts sRGB primary red within rounding tolerance", () => { + // oklch(0.628 0.2577 29.234) is the OKLCH form of #ff0000. + const red = oklchToSrgb(0.628, 0.2577, 29.234); + expect(red.r).toBeCloseTo(1, 2); + expect(red.g).toBeCloseTo(0, 2); + expect(red.b).toBeCloseTo(0, 2); + }); + + it("matches WCAG reference luminances and ratios", () => { + expect(relativeLuminance({ r: 1, g: 1, b: 1 })).toBeCloseTo(1, 5); + expect(relativeLuminance({ r: 0, g: 0, b: 0 })).toBeCloseTo(0, 5); + expect( + contrastRatio({ r: 1, g: 1, b: 1 }, { r: 0, g: 0, b: 0 }), + ).toBeCloseTo(21, 5); + expect( + contrastRatio({ r: 1, g: 1, b: 1 }, { r: 1, g: 1, b: 1 }), + ).toBeCloseTo(1, 5); + }); +}); + +// --- Palette contrast gates ------------------------------------------------- + +const modes: ReadonlyArray = [ + ["light", lightProperties], + ["dark", darkProperties], +]; + +const expectContrast = ( + properties: CustomProperties, + mode: string, + foreground: string, + background: string, + minimum: number, +) => { + const measured = contrastRatio( + resolveColor(foreground, properties), + resolveColor(background, properties), + ); + expect( + measured, + `${foreground} over ${background} (${mode}) measured ${measured.toFixed(2)}:1, requires ${minimum}:1`, + ).toBeGreaterThanOrEqual(minimum); +}; + +describe.each(modes)("palette contrast (%s)", (mode, properties) => { + it("keeps ink readable on every surface tier", () => { + for (const background of [ + "--void", + "--canvas", + "--surface", + "--surface-raised", + ]) { + expectContrast(properties, mode, "--ink", background, 4.5); + } + }); + + it("keeps muted text readable on working surfaces", () => { + for (const background of ["--void", "--canvas", "--surface"]) { + expectContrast(properties, mode, "--muted", background, 4.5); + } + }); + + it("keeps quiet marks perceivable on void and canvas", () => { + // Quiet is decorative-only per DESIGN.md, so 3:1 is the floor. If this + // fails, surface the measured value instead of weakening the gate. + for (const background of ["--void", "--canvas"]) { + expectContrast(properties, mode, "--quiet", background, 3); + } + }); + + it("keeps semantic state colors perceivable", () => { + for (const foreground of ["--danger", "--ready"]) { + for (const background of ["--void", "--surface"]) { + expectContrast(properties, mode, foreground, background, 3); + } + } + }); +}); diff --git a/src/styles.css b/src/styles.css index bfd162e..4a34af5 100644 --- a/src/styles.css +++ b/src/styles.css @@ -77,10 +77,13 @@ --shadow-float: oklch(0 0 0 / 0.12); --shadow-strong: oklch(0 0 0 / 0.16); --surface-shine: oklch(0 0 0 / 0.03); + --edge-light: color-mix(in oklch, var(--ink) 7%, transparent); --sheet-backdrop: oklch(0 0 0 / 0.4); --placeholder: var(--muted-foreground); --focus-ring: 0 0 0 2px var(--void), 0 0 0 4px var(--primary); --state-motion: 180ms cubic-bezier(0.22, 1, 0.36, 1); + --interaction-motion: 90ms ease; + --hairline: 1px; --font-mono: "SFMono-Regular", "SF Mono", Menlo, Consolas, monospace; --flect-rail-width: 400px; --z-dropdown: 20; @@ -90,6 +93,18 @@ --z-modal: 60; } +@media (min-resolution: 2dppx) { + :root { + --hairline: 0.5px; + } +} + +@media (prefers-contrast: more) { + :root { + --hairline: 1px; + } +} + @media (prefers-color-scheme: dark) { :root { color-scheme: dark; @@ -337,10 +352,6 @@ textarea:focus-visible, box-shadow: inset 0 0 0 1px color-mix(in oklch, var(--line) 55%, transparent); } -.workspace-canvas--app .capsule-surface { - min-height: 100dvh; -} - .role-shell--split:not(.role-shell--collapsed) .workspace-canvas--starter { padding-right: calc(var(--flect-rail-width) + 64px); } @@ -614,7 +625,9 @@ textarea:focus-visible, padding: 0; background: var(--canvas); color: var(--ink); - box-shadow: 0 8px 8px var(--shadow-strong); + box-shadow: + inset 0 0.5px 0 var(--edge-light), + 0 8px 8px var(--shadow-strong); } .share-source-dialog::backdrop { @@ -1006,9 +1019,9 @@ textarea:focus-visible, line-height: 1.35; text-align: left; transition: - color var(--state-motion), - background var(--state-motion), - border-color var(--state-motion); + color var(--interaction-motion), + background var(--interaction-motion), + border-color var(--interaction-motion); } .composer__starter-prompt:hover { @@ -1109,8 +1122,8 @@ textarea:focus-visible, cursor: pointer; font-size: 13px; transition: - color var(--state-motion), - background var(--state-motion); + color var(--interaction-motion), + background var(--interaction-motion); } .canvas-agent-entry:hover, @@ -1228,7 +1241,7 @@ textarea:focus-visible, flex: 0 0 auto; align-items: center; justify-content: space-between; - border-bottom: 1px solid var(--line); + border-bottom: var(--hairline) solid var(--line); padding: 0 16px 0 18px; } @@ -1320,6 +1333,9 @@ textarea:focus-visible, background: transparent; color: var(--muted); cursor: pointer; + transition: + color var(--interaction-motion), + background var(--interaction-motion); } .icon-button:hover { @@ -1421,6 +1437,7 @@ textarea:focus-visible, cursor: pointer; font-size: 0.75rem; text-align: start; + transition: background var(--interaction-motion); } .historical-turn__summary:hover, @@ -1455,7 +1472,7 @@ textarea:focus-visible, min-width: 0; gap: 8px; padding-inline-start: 8px; - border-inline-start: 1px solid var(--line); + border-inline-start: var(--hairline) solid var(--line); } .historical-turn__context[hidden] { @@ -1482,6 +1499,7 @@ textarea:focus-visible, cursor: pointer; font-size: 0.6875rem; text-align: start; + transition: background var(--interaction-motion); } .work-log__summary:hover, @@ -1662,7 +1680,8 @@ textarea:focus-visible, min-width: 0; gap: 10px; border: 0; - border-left: 1px solid color-mix(in oklch, var(--line) 56%, transparent); + border-left: var(--hairline) solid + color-mix(in oklch, var(--line) 56%, transparent); margin: 2px 0 6px 14px; padding: 5px 4px 5px 14px; background: transparent; @@ -1981,8 +2000,8 @@ textarea:focus-visible, cursor: pointer; font: inherit; transition: - background var(--state-motion), - color var(--state-motion); + background var(--interaction-motion), + color var(--interaction-motion); } .markdown-action:hover { @@ -2198,11 +2217,19 @@ textarea:focus-visible, border-color: color-mix(in oklch, var(--line) 84%, transparent); border-radius: var(--radius); background: var(--card); + --active-edge: transparent; + box-shadow: + inset 0 0.5px 0 var(--active-edge), 0 1px 0 color-mix(in oklch, var(--ink) 4%, transparent), 0 8px 24px color-mix(in oklch, var(--shadow-soft) 72%, transparent); } +/* Active Edge Light: focused surfaces only, never resting cards. */ +.composer:focus-within { + --active-edge: var(--edge-light); +} + .composer textarea { min-height: 44px; padding: 10px 12px 4px; @@ -2232,6 +2259,10 @@ textarea:focus-visible, min-height: 420px; } +.workspace-canvas--app .capsule-surface { + min-height: 100dvh; +} + .canvas-selection-outline { position: absolute; z-index: 4; @@ -2341,7 +2372,9 @@ textarea:focus-visible, border-radius: 10px; padding: 4px; background: color-mix(in oklch, var(--surface) 96%, transparent); - box-shadow: 0 10px 28px var(--shadow-float); + box-shadow: + inset 0 0.5px 0 var(--edge-light), + 0 10px 28px var(--shadow-float); color: var(--ink); backdrop-filter: blur(18px); } @@ -2435,7 +2468,7 @@ textarea:focus-visible, align-items: center; justify-content: space-between; gap: 24px; - border-bottom: 1px solid var(--line); + border-bottom: var(--hairline) solid var(--line); padding: 20px clamp(24px, 5vw, 64px); } @@ -2521,7 +2554,7 @@ textarea:focus-visible, max-height: min(42dvh, 360px); gap: 10px; overflow-y: auto; - border-top: 1px solid var(--line); + border-top: var(--hairline) solid var(--line); padding: 10px; } @@ -2596,8 +2629,8 @@ textarea:focus-visible, font-size: 0.6875rem; font-weight: 590; transition: - background var(--state-motion), - color var(--state-motion); + background var(--interaction-motion), + color var(--interaction-motion); } .diagnostics-panel__control button:hover:not(:disabled), @@ -2618,7 +2651,7 @@ textarea:focus-visible, .diagnostics-panel__setup { display: grid; gap: 8px; - border-top: 1px solid var(--line); + border-top: var(--hairline) solid var(--line); padding-top: 10px; } @@ -2860,8 +2893,8 @@ textarea:focus-visible, font-size: 12px; font-weight: 520; transition: - color var(--state-motion), - background var(--state-motion); + color var(--interaction-motion), + background var(--interaction-motion); } .composer-control--icon { @@ -2897,7 +2930,9 @@ textarea:focus-visible, border-radius: 12px; padding: 6px; background: var(--surface-raised); - box-shadow: 0 8px 8px var(--shadow-strong); + box-shadow: + inset 0 0.5px 0 var(--edge-light), + 0 8px 8px var(--shadow-strong); } .composer-popover--actions { @@ -2937,7 +2972,7 @@ textarea:focus-visible, display: grid; gap: 3px; margin: 2px 4px 6px; - border-bottom: 1px solid var(--line); + border-bottom: var(--hairline) solid var(--line); padding: 7px 6px 10px; } @@ -2973,6 +3008,7 @@ textarea:focus-visible, color: var(--ink); cursor: pointer; text-align: left; + transition: background var(--interaction-motion); } .composer-popover__item:hover:not(:disabled), @@ -3007,7 +3043,7 @@ textarea:focus-visible, .composer-popover__section { margin: 6px 4px 2px; - border-top: 1px solid var(--line); + border-top: var(--hairline) solid var(--line); padding: 10px 6px 3px; color: var(--quiet); font-size: 10px; @@ -3049,7 +3085,7 @@ textarea:focus-visible, flex-direction: column; gap: 4px; overflow-y: auto; - border-right: 1px solid var(--line); + border-right: var(--hairline) solid var(--line); padding: 4px; background: var(--canvas); scrollbar-width: none; @@ -3107,7 +3143,7 @@ textarea:focus-visible, min-height: 44px; align-items: center; gap: 8px; - border-bottom: 1px solid var(--line); + border-bottom: var(--hairline) solid var(--line); padding: 0 12px; color: var(--muted); } @@ -3173,9 +3209,23 @@ textarea:focus-visible, } .model-menu__option[aria-checked="true"] { + position: relative; background: var(--accent-soft); } +/* Leading bar reinforces selection beside the background and check mark; + it must never become the only selection signal. A pseudo-element keeps + the global focus-ring box-shadow intact. */ +.model-menu__option[aria-checked="true"]::before { + position: absolute; + inset-block: 6px; + inset-inline-start: 0; + width: 2px; + border-radius: 999px; + background: currentColor; + content: ""; +} + .model-menu__check { flex: 0 0 auto; color: var(--primary); @@ -3212,7 +3262,7 @@ textarea:focus-visible, .model-menu__reasoning { display: grid; gap: 8px; - border-top: 1px solid var(--line); + border-top: var(--hairline) solid var(--line); padding: 10px 12px 12px; } @@ -3413,8 +3463,8 @@ textarea:focus-visible, background: var(--ink); color: var(--void); transition: - color var(--state-motion), - background var(--state-motion), + color var(--interaction-motion), + background var(--interaction-motion), transform var(--state-motion); } @@ -4276,7 +4326,10 @@ textarea:focus-visible, border-radius: 14px; padding: 0; background: var(--surface-raised); - box-shadow: var(--shadow-popover); + /* --shadow-popover was never defined; use the Menu Lift stack directly. */ + box-shadow: + inset 0 0.5px 0 var(--edge-light), + 0 8px 8px var(--shadow-strong); color: var(--ink); } @@ -4667,6 +4720,7 @@ button.decision-button--primary:hover:not(:disabled) { @media (prefers-reduced-motion: reduce) { :root { --state-motion: 0.01ms linear; + --interaction-motion: 0.01ms linear; } .thinking span,