Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 49 additions & 32 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand All @@ -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.

Expand Down
66 changes: 66 additions & 0 deletions src/components/role-aware-shell.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
Expand Down Expand Up @@ -393,6 +418,47 @@ describe("RoleAwareShell", () => {
).toHaveFocus();
});

it("restores focus to the composer when the focused canvas control disappears", async () => {
const { rerender } = render(
<ShellHarness document={documentWithAction} phase="accepted" />,
);

const canvasButton = await screen.findByRole("button", {
name: "Archive",
});
canvasButton.focus();
expect(canvasButton).toHaveFocus();

rerender(
<ShellHarness document={documentWithoutPrompt} phase="accepted" />,
);

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(
<ShellHarness document={documentWithAction} phase="accepted" />,
);

const canvasButton = await screen.findByRole("button", {
name: "Archive",
});
canvasButton.focus();
const input = screen.getByRole("textbox", { name: "Message Flect" });
input.focus();

rerender(
<ShellHarness document={documentWithoutPrompt} phase="accepted" />,
);

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());
Expand Down
21 changes: 21 additions & 0 deletions src/components/role-aware-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -427,6 +428,26 @@ export function RoleAwareShell({
});
}, []);

const restoreLostFocus = useCallback(() => {
if (collapsed) {
reopenRef.current?.focus();
return;
}
const container = railContainerRef.current;
const composerInput = container?.querySelector<HTMLTextAreaElement>(
'.composer textarea[name="message"]',
);
if (composerInput !== undefined && composerInput !== null) {
composerInput.focus({ preventScroll: true });
return;
}
container
?.querySelector<HTMLElement>(".agent-rail__header button:not(:disabled)")
?.focus();
}, [collapsed]);

useFocusRestoration(shellRef, restoreLostFocus);

const focusComposer = useCallback(() => {
if (preferences.value.railCollapsed) {
shouldFocusRailRef.current = true;
Expand Down
48 changes: 48 additions & 0 deletions src/hooks/use-focus-restoration.ts
Original file line number Diff line number Diff line change
@@ -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 `<body>`.
*
* 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 `<body>`. 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<HTMLElement | null>,
restore: () => void,
) => {
const lastFocusedRef = useRef<HTMLElement | undefined>(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();
}
});
};
2 changes: 0 additions & 2 deletions src/lib/workspace-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -73,7 +72,6 @@ import {
defaultInterfaceDocument,
type InterfaceDocument,
type InterfaceNode,
validateInterfaceDocument,
} from "../../shared/interface-document";
import {
ProductCapabilityProjection,
Expand Down
Loading
Loading