From 62b00f3af7f7d3d8ce7b907c843320f6365b36d6 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Thu, 30 Jul 2026 23:56:35 +0800 Subject: [PATCH 1/3] fix(ui,workbench,desktop,webview): swap the thread-title view transition for a header enter animation --- .../src/renderer/src/shell/chrome/chrome.tsx | 21 ++++++--------- apps/desktop/tsconfig.json | 2 +- apps/webview/e2e/browser-smoke.e2e.mts | 8 +++--- .../webview/src/shell/web-workbench-shell.tsx | 27 +++++++------------ apps/webview/tsconfig.json | 3 +-- .../client/workbench/src/surface/shell.tsx | 23 +++++++--------- .../src/surface/use-workbench-sessions.ts | 5 ++-- packages/client/workbench/tsconfig.json | 3 +-- .../ui/src/shell/sidebar/thread-row.tsx | 17 +++--------- packages/presentation/ui/src/styles.css | 26 ++++++++---------- packages/presentation/ui/tsconfig.json | 2 +- 11 files changed, 49 insertions(+), 88 deletions(-) diff --git a/apps/desktop/src/renderer/src/shell/chrome/chrome.tsx b/apps/desktop/src/renderer/src/shell/chrome/chrome.tsx index 043ab869e..751e7ae22 100644 --- a/apps/desktop/src/renderer/src/shell/chrome/chrome.tsx +++ b/apps/desktop/src/renderer/src/shell/chrome/chrome.tsx @@ -10,7 +10,7 @@ import { PanelLeftIcon, PanelRightIcon, } from 'lucide-react'; -import { createContext, use, useCallback, useRef, useState, ViewTransition } from 'react'; +import { createContext, use, useCallback, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { useTranslations } from 'use-intl'; import { useChromeRailInsets } from './use-chrome-rail-insets'; @@ -529,18 +529,13 @@ function MainChromeTitle({ {icon ?? } - {header.sessionId ? ( - - {header.title} - - ) : ( - {header.title} - )} + {/* Keyed so a switch remounts the title and replays its enter animation. */} + + {header.title} + {chip} {menu} diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json index e69fccc21..809598dd2 100644 --- a/apps/desktop/tsconfig.json +++ b/apps/desktop/tsconfig.json @@ -6,7 +6,7 @@ "@desktop/*": ["./src/*"], "@renderer/*": ["./src/renderer/src/*"] }, - "types": ["node", "react/canary"], + "types": ["node"], // The `e2e/*.mts` harnesses run under plain `node`, whose ESM resolver requires the explicit // file extension on relative imports. Safe here because the base config is `noEmit`. "allowImportingTsExtensions": true diff --git a/apps/webview/e2e/browser-smoke.e2e.mts b/apps/webview/e2e/browser-smoke.e2e.mts index 2a8ad4066..3a9b59ac4 100644 --- a/apps/webview/e2e/browser-smoke.e2e.mts +++ b/apps/webview/e2e/browser-smoke.e2e.mts @@ -133,11 +133,9 @@ async function verifyMockEntry(browser: Browser): Promise { const appErrors: string[] = []; const page = await browser.newPage(); monitorApplicationErrors(page, server.origin, appErrors); - // This boundary verifies wire prompt/reload recovery, not animation timing. React's - // `` still runs `document.startViewTransition` under reduce-motion (the - // preference only collapses the snapshot animations to ~0ms via CSS), so selection is no - // longer synchronous — but near-instant finishes keep a throttled headless tab from holding - // transitions (and the assertions below) open across animation frames. + // This boundary verifies wire prompt/reload recovery, not animation timing: the product's + // reduce-motion fallback collapses the title animations so a throttled headless tab cannot + // hold the assertions below open across animation frames. await page.addInitScript(() => { localStorage.setItem( 'linkcode.workbench.appearance:v1', diff --git a/apps/webview/src/shell/web-workbench-shell.tsx b/apps/webview/src/shell/web-workbench-shell.tsx index d9e00bf27..fefadeca9 100644 --- a/apps/webview/src/shell/web-workbench-shell.tsx +++ b/apps/webview/src/shell/web-workbench-shell.tsx @@ -3,7 +3,6 @@ import type { WorkbenchShellProps } from '@linkcode/workbench'; import { WorkspaceServicesMenu } from '@linkcode/workbench'; import { Button } from 'coss-ui/components/button'; import { ChevronLeftIcon, ChevronRightIcon, SettingsIcon } from 'lucide-react'; -import { ViewTransition } from 'react'; import { Link, useNavigate } from 'react-router'; import { useTranslations } from 'use-intl'; @@ -40,23 +39,15 @@ export function WebWorkbenchShell({
- {/* data-conversation-title is the browser-smoke E2E's header selector. */} - {header.sessionId ? ( - -
- {header.title} -
-
- ) : ( -
- {header.title} -
- )} + {/* data-conversation-title is the browser-smoke E2E's header selector. Keyed so a + switch remounts the title and replays its enter animation. */} +
+ {header.title} +
{header.subtitle && (
{header.subtitle}
)} diff --git a/apps/webview/tsconfig.json b/apps/webview/tsconfig.json index ef415cf93..c879fa6a6 100644 --- a/apps/webview/tsconfig.json +++ b/apps/webview/tsconfig.json @@ -4,8 +4,7 @@ "jsx": "react-jsx", "paths": { "@webview/*": ["./src/*"] - }, - "types": ["react/canary"] + } }, "include": ["src"] } diff --git a/packages/client/workbench/src/surface/shell.tsx b/packages/client/workbench/src/surface/shell.tsx index 5076fd8a8..23c548a9a 100644 --- a/packages/client/workbench/src/surface/shell.tsx +++ b/packages/client/workbench/src/surface/shell.tsx @@ -1,13 +1,12 @@ import type { SessionId, TokenUsage } from '@linkcode/schema'; import type { ComposerAttachment, ShellFrameProps } from '@linkcode/ui'; import { ErrorBadge, ShellFrame, TitleStrip } from '@linkcode/ui'; -import { ViewTransition } from 'react'; export interface WorkbenchShellHeader { title: string; subtitle?: string; usage?: TokenUsage | null; - /** The open conversation, when one is; keys the title's matched-geometry boundary. */ + /** The open conversation, when one is; keys the header title so a switch replays its animation. */ sessionId?: SessionId | null; } @@ -71,18 +70,14 @@ function DefaultTitleStrip({ return (
- {header.sessionId ? ( - -
{header.title}
-
- ) : ( -
{header.title}
- )} + {/* Keyed so a switch remounts the title and replays its enter animation; a rename inside + the open thread keeps the key and stays put. */} +
+ {header.title} +
{header.subtitle && (
{header.subtitle}
)} diff --git a/packages/client/workbench/src/surface/use-workbench-sessions.ts b/packages/client/workbench/src/surface/use-workbench-sessions.ts index 0500ae319..0bd2aba39 100644 --- a/packages/client/workbench/src/surface/use-workbench-sessions.ts +++ b/packages/client/workbench/src/surface/use-workbench-sessions.ts @@ -85,9 +85,8 @@ export function useWorkbenchSessions(onError: (err: unknown) => void): Workbench // Session page: every thread stays one click away in the sidebar, so we skip straight to the // new-thread draft instead of auto-opening an arbitrary recent session (an all-automation list // has nothing to open either; an explicit automation selection resolves against the full list). - // Deferred for the render path only: zustand updates ride useSyncExternalStore, which never - // enters a transition lane, so this bridge is what activates the `` boundaries - // (row title → header) on a switch. Mutations keep using the live store values. + // Deferred for the render path only: the outgoing thread stays painted while the incoming tree + // renders, instead of flashing through an empty surface. Mutations use the live store values. const deferredSelectedId = useDeferredValue(selectedId); const draft = explicitDraft ?? (deferredSelectedId === null ? LANDING_DRAFT : null); diff --git a/packages/client/workbench/tsconfig.json b/packages/client/workbench/tsconfig.json index c48f6d945..4c49bc8c1 100644 --- a/packages/client/workbench/tsconfig.json +++ b/packages/client/workbench/tsconfig.json @@ -1,8 +1,7 @@ { "extends": "../../../tsconfig.base.json", "compilerOptions": { - "jsx": "react-jsx", - "types": ["react/canary"] + "jsx": "react-jsx" }, "include": ["src"] } diff --git a/packages/presentation/ui/src/shell/sidebar/thread-row.tsx b/packages/presentation/ui/src/shell/sidebar/thread-row.tsx index 9fb9d2316..e1bead2f2 100644 --- a/packages/presentation/ui/src/shell/sidebar/thread-row.tsx +++ b/packages/presentation/ui/src/shell/sidebar/thread-row.tsx @@ -5,7 +5,6 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from 'coss-ui/ import { PreviewCard, PreviewCardTrigger } from 'coss-ui/components/preview-card'; import { SidebarMenuButton, SidebarMenuItem } from 'coss-ui/components/sidebar'; import { ClockIcon, EllipsisIcon, FolderIcon, GitBranchIcon, PinIcon, XIcon } from 'lucide-react'; -import { ViewTransition } from 'react'; import { useTranslations } from 'use-intl'; import { AGENT_LABELS, AgentIcon } from '../../chat/agent-icon'; import { cn } from '../../lib/cn'; @@ -96,19 +95,9 @@ export function ThreadRow({ /> {/* data-thread-title is the browser-smoke E2E's row selector, not product styling. */} - {active ? ( - // No boundary on the active row: pairing is mount/unmount-based, so this boundary - // unmounting (plain span taking over) is what lets the entering header adopt it. - - {title} - - ) : ( - - - {title} - - - )} + + {title} +
diff --git a/packages/presentation/ui/src/styles.css b/packages/presentation/ui/src/styles.css index aa7b3b8f8..1b96643d3 100644 --- a/packages/presentation/ui/src/styles.css +++ b/packages/presentation/ui/src/styles.css @@ -32,6 +32,17 @@ /* Bottom of the type scale (badges, chrome labels); no ad-hoc pixel sizes below xs. */ --text-2xs: 0.6875rem; --text-2xs--line-height: calc(0.875 / 0.6875); + + /* The incoming header thread title on a switch — deliberately NOT a matched-geometry morph + with the sidebar row: a shared name pairs both directions at once and reads as a swap. */ + --animate-title-enter: title-enter var(--motion-fast) ease-out; + + @keyframes title-enter { + from { + opacity: 0; + transform: translateY(0.25rem); + } + } } :root { @@ -148,21 +159,6 @@ } } -/* View transitions (React ``, CODE-457): pace every group on the motion scale. */ -::view-transition-group(*), -::view-transition-old(root), -::view-transition-new(root) { - animation-duration: var(--motion-normal); -} - -/* The app-level reduce-motion preference must silence snapshot animations too — the global - `.reduce-motion *` reset cannot reach these root-attached pseudo-elements. */ -.reduce-motion::view-transition-group(*), -.reduce-motion::view-transition-old(*), -.reduce-motion::view-transition-new(*) { - animation-duration: 0.01ms !important; -} - @layer components { /* Read-only chat terminals mirror LiveTerminal's auto pair: GitHub Light Default / Dark+. Scope the palette so ANSI class names from other libraries cannot inherit it accidentally. */ diff --git a/packages/presentation/ui/tsconfig.json b/packages/presentation/ui/tsconfig.json index 81d45f6d8..5829e3603 100644 --- a/packages/presentation/ui/tsconfig.json +++ b/packages/presentation/ui/tsconfig.json @@ -3,7 +3,7 @@ "compilerOptions": { "jsx": "react-jsx", "rootDir": "src", - "types": ["react", "react/canary"] + "types": ["react"] }, "include": ["src"] } From 028d34ae30c4575296bf8660576d1ace4ad81359 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 31 Jul 2026 16:04:47 +0800 Subject: [PATCH 2/3] fix(ui,workbench,desktop,webview): gate the thread-title entrance on pointer input --- .claude/rules/frontend.md | 3 +- .../src/renderer/src/shell/chrome/chrome.tsx | 10 ++--- .../webview/src/shell/web-workbench-shell.tsx | 13 +++--- .../client/workbench/src/surface/shell.tsx | 11 ++--- packages/presentation/ui/src/index.ts | 1 + .../presentation/ui/src/input-modality.ts | 43 ++++++++++++++++++ .../ui/src/shell/shell-control.tsx | 22 +++++++++ packages/presentation/ui/src/styles.css | 45 ++++++++++++++----- 8 files changed, 114 insertions(+), 34 deletions(-) create mode 100644 packages/presentation/ui/src/input-modality.ts diff --git a/.claude/rules/frontend.md b/.claude/rules/frontend.md index 3c5e7f2bd..9fa3e1db1 100644 --- a/.claude/rules/frontend.md +++ b/.claude/rules/frontend.md @@ -24,7 +24,8 @@ parts of `packages/presentation/ui` (`chat`/`shell`) and `packages/client/workbe - **Type scale bottoms out at `text-2xs`** (11px — badges, chrome labels); never write ad-hoc pixel sizes like `text-[13px]` — body/secondary/caption are `text-sm`/`text-xs`/`text-2xs`. Below `text-muted-foreground`, dimmer text uses the semantic tiers `text-label-tertiary` (timestamps, weak hints) and `text-label-quaternary` (placeholders, pending edges), never ad-hoc `/NN` opacities (fills like status dots are exempt). Numeric readouts outside `font-mono` take `tabular-nums`. - **Press feedback on custom controls**: compact independent controls (tabs, icon buttons) get `transition-transform duration-(--motion-fast) active:scale-[0.98]` (icon-only micro buttons: `active:scale-90`); full-width rows mirror their hover colour under `active:` instead of scaling. coss-ui primitives already ship `data-pressed`/`active:` treatments — never restyle those. - **Long-list rows read the density vars** (`py-(--density-row-py)`; skeletons track the matching `--density-*` height) — the appearance store's `listDensity` flips them via `data-density` on the root. A new list surface opts in with the var, not a fixed `py-2`. -- **Motion reads the token scale**: `duration-(--motion-fast|normal|emphasis)` (150/250/350ms) for CSS transitions, and the exported `SPRING` from `@linkcode/ui` for Motion springs — a new animation picks a tier, never a bare `duration-NNN` or inline spring params; anything slower than `--motion-emphasis` needs a written reason. +- **Motion reads the token scale**: `duration-(--motion-fast|normal|emphasis)` (150/250/350ms) for CSS transitions, and the exported `SPRING` from `@linkcode/ui` for Motion springs — a new animation picks a tier, never a bare `duration-NNN` or inline spring params; anything slower than `--motion-emphasis` needs a written reason. Entrances/exits take `ease-(--motion-ease-out)`, not the built-in `ease-out` (too weak to read at 150ms) and **not** the shell's `cubic-bezier(0.2, 0, 0, 1)` — that one is an emphasized in-out whose zero initial slope belongs on things that morph in place (the pane grid, the composer frame), never on something arriving. + - **Never animate a keyboard-initiated change.** History chords (`⌘[`/`⌘]`) and the palette drive the same state as a click, so a keyed entrance fires on all three; gate it on `useInputModality() === 'pointer'` (`@linkcode/ui`) the way `ThreadTitle` does. CSS animations also need their own `@media (prefers-reduced-motion: reduce)` arm: the app-level `.reduce-motion` class comes from the appearance store, whose default is hard-coded `false` and never reads the OS preference. - **Routing & layout (webview).** Define routes with `createBrowserRouter` (data router) — no JSX `` trees. Build the shell once at the layout level (sidebar/header/content inset); pages render into the outlet and never rebuild chrome. Each page sets its title via the `usePageTitle` hook (the SPA equivalent of Next `metadata`). There is no breadcrumb portal — the current layout has no slot for one; add it (and the corresponding convention here) if a surface needs breadcrumbs. - **Overlay surfaces (desktop).** A full-page surface layered over the workbench must hide the layer it covers (`invisible` + `inert` on the covered subtree, keeping it mounted): on macOS/Windows both shells are translucent over the native backdrop, so any painted pixels underneath ghost through. - **Settings mounts differently per app** (mirroring the router split): desktop `settings/settings-view.tsx` is a router-free `fixed inset-0 z-50` overlay rendered **above** the connection gate (reachable with the daemon down to fix a bad URL) — category is `useState`, items `onClick`, closed via the desktop settings store + Escape; webview `routes/settings/settings-layout.tsx` uses react-router (`Link` items + `Outlet`). Both render the shared `SettingsSidebarNav`; its search field is live and app-controlled — the app filters its grouped nav items through `filterSettingsNavGroups` + per-tab keywords from `useSettingsSearchKeywords` (`packages/client/workbench/src/settings/search.ts`, reusing the palette matcher). Don't add react-router to desktop for a new tab; extend the overlay. diff --git a/apps/desktop/src/renderer/src/shell/chrome/chrome.tsx b/apps/desktop/src/renderer/src/shell/chrome/chrome.tsx index 751e7ae22..d6e4688b2 100644 --- a/apps/desktop/src/renderer/src/shell/chrome/chrome.tsx +++ b/apps/desktop/src/renderer/src/shell/chrome/chrome.tsx @@ -1,4 +1,4 @@ -import { cn, ShellIconButton } from '@linkcode/ui'; +import { cn, ShellIconButton, ThreadTitle } from '@linkcode/ui'; import type { WorkbenchShellHeader, WorkbenchShellNavigation } from '@linkcode/workbench'; import { nullthrow } from 'foxact/nullthrow'; import { useIsomorphicLayoutEffect } from 'foxact/use-isomorphic-layout-effect'; @@ -529,13 +529,9 @@ function MainChromeTitle({ {icon ?? } - {/* Keyed so a switch remounts the title and replays its enter animation. */} - + {header.title} - + {chip} {menu}
diff --git a/apps/webview/src/shell/web-workbench-shell.tsx b/apps/webview/src/shell/web-workbench-shell.tsx index fefadeca9..9d1dd820c 100644 --- a/apps/webview/src/shell/web-workbench-shell.tsx +++ b/apps/webview/src/shell/web-workbench-shell.tsx @@ -1,4 +1,4 @@ -import { ErrorBadge, ShellFrame, ShellIconButton, TitleStrip } from '@linkcode/ui'; +import { ErrorBadge, ShellFrame, ShellIconButton, ThreadTitle, TitleStrip } from '@linkcode/ui'; import type { WorkbenchShellProps } from '@linkcode/workbench'; import { WorkspaceServicesMenu } from '@linkcode/workbench'; import { Button } from 'coss-ui/components/button'; @@ -39,15 +39,14 @@ export function WebWorkbenchShell({
- {/* data-conversation-title is the browser-smoke E2E's header selector. Keyed so a - switch remounts the title and replays its enter animation. */} -
{header.title} -
+ {header.subtitle && (
{header.subtitle}
)} diff --git a/packages/client/workbench/src/surface/shell.tsx b/packages/client/workbench/src/surface/shell.tsx index 23c548a9a..32d4a1815 100644 --- a/packages/client/workbench/src/surface/shell.tsx +++ b/packages/client/workbench/src/surface/shell.tsx @@ -1,6 +1,6 @@ import type { SessionId, TokenUsage } from '@linkcode/schema'; import type { ComposerAttachment, ShellFrameProps } from '@linkcode/ui'; -import { ErrorBadge, ShellFrame, TitleStrip } from '@linkcode/ui'; +import { ErrorBadge, ShellFrame, ThreadTitle, TitleStrip } from '@linkcode/ui'; export interface WorkbenchShellHeader { title: string; @@ -70,14 +70,9 @@ function DefaultTitleStrip({ return (
- {/* Keyed so a switch remounts the title and replays its enter animation; a rename inside - the open thread keeps the key and stays put. */} -
+ {header.title} -
+ {header.subtitle && (
{header.subtitle}
)} diff --git a/packages/presentation/ui/src/index.ts b/packages/presentation/ui/src/index.ts index 6824bb6ef..eb5919c7b 100644 --- a/packages/presentation/ui/src/index.ts +++ b/packages/presentation/ui/src/index.ts @@ -4,6 +4,7 @@ export * from './brand/animated-mark'; export * from './chat'; export * from './code-themes'; +export * from './input-modality'; export * from './keyboard'; export { cn } from './lib/cn'; export * from './motion'; diff --git a/packages/presentation/ui/src/input-modality.ts b/packages/presentation/ui/src/input-modality.ts new file mode 100644 index 000000000..71df88f17 --- /dev/null +++ b/packages/presentation/ui/src/input-modality.ts @@ -0,0 +1,43 @@ +import { useSyncExternalStore } from 'react'; + +/** Which device drove the most recent interaction — the signal `:focus-visible` keeps to itself, + * exposed so motion can stay off keyboard-driven changes (a chord repeats far too often to animate). */ +export type InputModality = 'pointer' | 'keyboard'; + +let modality: InputModality | null = null; +const subscribers = new Set<() => void>(); + +function record(next: InputModality): void { + if (modality === next) return; + modality = next; + for (const notify of subscribers) notify(); +} + +const onPointerDown = (): void => record('pointer'); +const onKeyDown = (): void => record('keyboard'); + +function subscribe(onStoreChange: () => void): () => void { + if (subscribers.size === 0) { + // Capture: a handler that stops propagation must not hide the modality from us. + document.addEventListener('pointerdown', onPointerDown, { capture: true }); + document.addEventListener('keydown', onKeyDown, { capture: true }); + } + subscribers.add(onStoreChange); + return () => { + subscribers.delete(onStoreChange); + if (subscribers.size === 0) { + document.removeEventListener('pointerdown', onPointerDown, { capture: true }); + document.removeEventListener('keydown', onKeyDown, { capture: true }); + } + }; +} + +function getSnapshot(): InputModality | null { + return modality; +} + +/** `null` until the first interaction — callers must read unknown as "not pointer" so nothing + * animates on the first paint. */ +export function useInputModality(): InputModality | null { + return useSyncExternalStore(subscribe, getSnapshot); +} diff --git a/packages/presentation/ui/src/shell/shell-control.tsx b/packages/presentation/ui/src/shell/shell-control.tsx index 188c3bbd5..fcce22068 100644 --- a/packages/presentation/ui/src/shell/shell-control.tsx +++ b/packages/presentation/ui/src/shell/shell-control.tsx @@ -1,6 +1,8 @@ +import type { SessionId } from '@linkcode/schema'; import { Button } from 'coss-ui/components/button'; import { Kbd } from 'coss-ui/components/kbd'; import { Tooltip, TooltipContent, TooltipTrigger } from 'coss-ui/components/tooltip'; +import { useInputModality } from '../input-modality'; import { cn } from '../lib/cn'; export type ShellIconButtonProps = React.ComponentProps & { @@ -71,6 +73,26 @@ export function PanelControlButton({ ); } +export type ThreadTitleProps = React.ComponentProps<'div'> & { + /** The open thread; keys the element internally so a switch remounts it and replays the + * entrance. A rename inside the same thread keeps the key and stays put. */ + sessionId?: SessionId | null; +}; + +/** The header's thread title. The entrance plays only for pointer-driven switches — the history + * chords and the palette repeat far too often to animate. */ +export function ThreadTitle({ sessionId, className, ...props }: ThreadTitleProps): React.ReactNode { + const pointerDriven = useInputModality() === 'pointer'; + + return ( +
+ ); +} + export function TitleStrip({ className, children, diff --git a/packages/presentation/ui/src/styles.css b/packages/presentation/ui/src/styles.css index 1b96643d3..64c343af2 100644 --- a/packages/presentation/ui/src/styles.css +++ b/packages/presentation/ui/src/styles.css @@ -32,17 +32,6 @@ /* Bottom of the type scale (badges, chrome labels); no ad-hoc pixel sizes below xs. */ --text-2xs: 0.6875rem; --text-2xs--line-height: calc(0.875 / 0.6875); - - /* The incoming header thread title on a switch — deliberately NOT a matched-geometry morph - with the sidebar row: a shared name pairs both directions at once and reads as a swap. */ - --animate-title-enter: title-enter var(--motion-fast) ease-out; - - @keyframes title-enter { - from { - opacity: 0; - transform: translateY(0.25rem); - } - } } :root { @@ -51,6 +40,10 @@ --motion-fast: 150ms; --motion-normal: 250ms; --motion-emphasis: 350ms; + /* Entrances and exits, consumed as `ease-(--motion-ease-out)`. Strong on purpose — the built-in + `ease-out` is too weak to read at 150ms. Do NOT reuse the shell's `cubic-bezier(0.2, 0, 0, 1)` + here: that is an emphasized in-out (zero initial slope) and belongs on things that morph. */ + --motion-ease-out: cubic-bezier(0.23, 1, 0.32, 1); /* List density (data-density on the root, synced from the appearance store): long-list rows read these instead of fixed paddings; skeletons must track the same vars. */ @@ -160,6 +153,36 @@ } @layer components { + /* The incoming header thread title (`ThreadTitle` gates it to pointer-driven switches). + Deliberately not a matched-geometry morph with the sidebar row: one shared name pairs both + directions at once and reads as the two titles swapping. */ + .thread-title-enter { + opacity: 1; + transform: translateY(0); + transition: + opacity var(--motion-fast) var(--motion-ease-out), + transform var(--motion-fast) var(--motion-ease-out); + + /* Not from 0: this replaces a title rather than adding one, and a fully transparent first + frame leaves the header unlabeled exactly as the user arrives. */ + @starting-style { + opacity: 0.35; + transform: translateY(0.25rem); + } + } + + /* Keep the fade (it bridges the two titles); drop the movement. Reduced motion means gentler, + not zero — the app-level `.reduce-motion` reset above is the one that silences it outright. */ + @media (prefers-reduced-motion: reduce) { + .thread-title-enter { + transition-property: opacity; + + @starting-style { + transform: translateY(0); + } + } + } + /* Read-only chat terminals mirror LiveTerminal's auto pair: GitHub Light Default / Dark+. Scope the palette so ANSI class names from other libraries cannot inherit it accidentally. */ .chat-terminal-output { From 956d497fc0d4cea67621b043db036bf512263b81 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 31 Jul 2026 16:21:52 +0800 Subject: [PATCH 3/3] test(ui): cover the thread-title entrance gate --- .../src/shell/__tests__/thread-title.test.tsx | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 packages/presentation/ui/src/shell/__tests__/thread-title.test.tsx diff --git a/packages/presentation/ui/src/shell/__tests__/thread-title.test.tsx b/packages/presentation/ui/src/shell/__tests__/thread-title.test.tsx new file mode 100644 index 000000000..0f24db2f0 --- /dev/null +++ b/packages/presentation/ui/src/shell/__tests__/thread-title.test.tsx @@ -0,0 +1,65 @@ +// @vitest-environment jsdom + +import type { SessionId } from '@linkcode/schema'; +import { cleanup, fireEvent, render } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; +import { ThreadTitle } from '../shell-control'; + +const ENTER_CLASS = 'thread-title-enter'; + +function renderTitle(sessionId: string, title: string) { + return render( + + {title} + , + ); +} + +function titleOf(container: HTMLElement): HTMLElement { + const element = container.querySelector('[data-testid="title"]'); + if (!(element instanceof HTMLElement)) throw new Error('title not rendered'); + return element; +} + +afterEach(cleanup); + +describe('ThreadTitle', () => { + it('remounts the element on a switch so the entrance can replay', () => { + const { container, rerender } = renderTitle('a', 'first'); + fireEvent.pointerDown(document); + const first = titleOf(container); + + rerender( + + second + , + ); + + expect(titleOf(container)).not.toBe(first); + expect(titleOf(container).textContent).toBe('second'); + }); + + it('animates a pointer-driven switch', () => { + const { container } = renderTitle('a', 'first'); + fireEvent.pointerDown(document); + + expect(titleOf(container).classList.contains(ENTER_CLASS)).toBe(true); + }); + + it('stays static for a keyboard-driven switch — a history chord repeats too often to animate', () => { + const { container } = renderTitle('a', 'first'); + fireEvent.pointerDown(document); + fireEvent.keyDown(document, { key: '[', metaKey: true }); + + expect(titleOf(container).classList.contains(ENTER_CLASS)).toBe(false); + }); + + it('re-arms on the next pointer interaction', () => { + const { container } = renderTitle('a', 'first'); + fireEvent.keyDown(document, { key: '[', metaKey: true }); + expect(titleOf(container).classList.contains(ENTER_CLASS)).toBe(false); + + fireEvent.pointerDown(document); + expect(titleOf(container).classList.contains(ENTER_CLASS)).toBe(true); + }); +});