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,