From 310f8ee390820cd02d33b94ebf24c6933b8e37f3 Mon Sep 17 00:00:00 2001 From: "cyrus@tinyhumans.ai" Date: Fri, 7 Aug 2026 18:18:58 +0530 Subject: [PATCH 01/17] feat(shell): rebuild the app shell as chrome + an inset content card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The root shell painted two opaque panes flush against each other: the sidebar carried `bg-surface` — the same token as the content — and the two were told apart only by a 1px seam plus six hairline dividers inside the sidebar. The themed `AppBackground` (mesh gradient / solid / image) was rendered underneath the whole time and then completely covered, so the Theme Studio backdrop controls had no visible effect. Split the shell into the two layers it was already paying for: - Chrome — the shell root carries one legibility scrim across both the sidebar column and the frame around the card, so they read as a single continuous surface and the themed backdrop shows through. Scrimming per-pane would tint them differently and reintroduce the seam. - Card — routed content sits on a single inset, rounded ContentSurface, the only opaque sheet left in the shell. The two now separate by fill contrast, so the sidebar needs no border and the resize divider no fill. ContentSurface takes an `unframed` prop, and App.tsx sets it whenever a provider account is active. This is load-bearing, not cosmetic: WebviewHost hands the Rust side a plain {x,y,width,height} rectangle and CEF composites that child view above the entire HTML layer, so a rounded card underneath would show four square corners punching through the radius, with no CSS able to mask them. WindowDragBar becomes an in-flow band above the card instead of an absolute overlay painted on top of the routed view. The overlay existed because the content pane was edge-to-edge and a reserved inset would have pushed full-bleed surfaces down; the card is inset by design now, so the band is both simpler and no longer steals pointer events from the top 28px of page content. It keeps its macOS+Tauri gate — reserving the band where a native title bar already owns it would just waste 28px. Chrome-level active states drop the primary accent for a neutral surface lift plus weight: the chrome carries the theme's hue, so tinting a nav pill on top of it stacks two colours. Semantic colour is untouched — the coral unread badge, the companion dot, ConnectionIndicator, and Billing's highlight row all keep theirs. Adds --surface-chrome / --line-chrome and a zero-blur `content-edge` hairline shadow. Theme.colors is a partial override map, so the 19 presets need no edits and fall through to the tokens.css defaults. --- app/src/App.tsx | 11 +++- .../components/layout/shell/AppSidebar.tsx | 24 ++++--- .../layout/shell/CollapsedNavRail.tsx | 12 ++-- .../layout/shell/ContentSurface.test.tsx | 51 +++++++++++++++ .../layout/shell/ContentSurface.tsx | 51 +++++++++++++++ .../layout/shell/RootShellLayout.test.tsx | 60 ++++++++++++++++++ .../layout/shell/RootShellLayout.tsx | 63 +++++++++++++------ .../layout/shell/SidebarNav.test.tsx | 13 ++-- .../components/layout/shell/SidebarNav.tsx | 13 ++-- .../components/layout/shell/WindowDragBar.tsx | 38 +++++------ .../settings/layout/SettingsSidebar.tsx | 20 +++--- .../settings/modal/SettingsModalFrame.tsx | 5 ++ app/src/styles/tokens.css | 12 ++++ app/tailwind.config.js | 7 +++ 14 files changed, 310 insertions(+), 70 deletions(-) create mode 100644 app/src/components/layout/shell/ContentSurface.test.tsx create mode 100644 app/src/components/layout/shell/ContentSurface.tsx create mode 100644 app/src/components/layout/shell/RootShellLayout.test.tsx diff --git a/app/src/App.tsx b/app/src/App.tsx index 3abeb88b3e..cb0cb99829 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -327,7 +327,16 @@ export function AppShellDesktop() { {chromeless ? ( content ) : ( - }>{content} + // A live provider webview forces the content surface edge-to-edge: + // WebviewHost hands the Rust side a plain rectangle and CEF + // composites that child view above the entire HTML layer, so a + // rounded card underneath would show four square corners punching + // through the radius. Nothing in CSS can mask it. + } + unframed={Boolean(activeProviderAccount && !accountsOverlayOpen)}> + {content} + )} {/* Desktop Settings modal — mounted over whatever page is rendered diff --git a/app/src/components/layout/shell/AppSidebar.tsx b/app/src/components/layout/shell/AppSidebar.tsx index 8a4964c635..c98a9db93d 100644 --- a/app/src/components/layout/shell/AppSidebar.tsx +++ b/app/src/components/layout/shell/AppSidebar.tsx @@ -47,10 +47,10 @@ function FooterNavButton({ onClick={onClick} title={label} aria-current={active ? 'page' : undefined} - className={`group flex flex-shrink-0 items-center justify-center gap-2 border-t border-line/70 px-3 py-1 text-[11px] transition-colors cursor-pointer dark:border-line/70 ${ + className={`group flex flex-shrink-0 items-center justify-center gap-2 px-3 py-1 text-[11px] transition-colors cursor-pointer ${ active - ? 'bg-surface text-content font-medium' - : 'text-content-muted hover:bg-surface-strong/70 hover:text-content-secondary dark:hover:bg-surface-muted/60' + ? 'bg-surface/70 text-content font-medium' + : 'text-content-muted hover:bg-surface/40 hover:text-content-secondary' }`}> {label} @@ -126,8 +126,16 @@ export default function AppSidebar() { }; return ( -
-
+ // Sits directly on the window chrome with no fill of its own, so the + // sidebar and the frame around the content card are one continuous surface. + // The legibility scrim lives on the shell root ({@link RootShellLayout}) and + // deliberately NOT here — scrimming only this column would tint it + // differently from the chrome beside the card, which is the seam the + // two-layer look exists to remove. Regions below are separated by spacing + // alone; the hairline seams the old opaque panel needed would draw lines + // across the chrome. +
+
@@ -136,10 +144,10 @@ export default function AppSidebar() { {/* Persistent app switcher — sticks across routes so the agent + connected apps are always one click away. Selecting one routes to /chat where the provider webview / agent chat actually render. */} -
+
-
+
{/* Flex column so routes that project more than one region (e.g. Chat's app rail above its thread list) can order them via Tailwind `order-*`. */} @@ -164,7 +172,7 @@ export default function AppSidebar() { /> {/* App-wide footer: connectivity status + build/version, pinned to the bottom of the sidebar. */} -
+
· diff --git a/app/src/components/layout/shell/CollapsedNavRail.tsx b/app/src/components/layout/shell/CollapsedNavRail.tsx index 2f045f8ee3..fc86d9ab85 100644 --- a/app/src/components/layout/shell/CollapsedNavRail.tsx +++ b/app/src/components/layout/shell/CollapsedNavRail.tsx @@ -65,8 +65,8 @@ export default function CollapsedNavRail() { aria-current={homeActive ? 'page' : undefined} className={`${RAIL_BTN} ${ homeActive - ? 'bg-surface text-content shadow-sm' - : 'text-content-muted hover:bg-surface-hover hover:text-content-secondary' + ? 'bg-surface/70 text-content' + : 'text-content-muted hover:bg-surface/40 hover:text-content-secondary' }`}> @@ -99,8 +99,8 @@ export default function CollapsedNavRail() { aria-current={active ? 'page' : undefined} className={`${RAIL_BTN} ${ active - ? 'bg-surface text-content shadow-sm' - : 'text-content-muted hover:bg-surface-hover hover:text-content-secondary' + ? 'bg-surface/70 text-content' + : 'text-content-muted hover:bg-surface/40 hover:text-content-secondary' }`}> {showBadge && ( @@ -124,8 +124,8 @@ export default function CollapsedNavRail() { data-analytics-id="collapsed-rail-settings" className={`${RAIL_BTN} ${ settingsActive - ? 'bg-surface text-content shadow-sm' - : 'text-content-muted hover:bg-surface-hover hover:text-content-secondary' + ? 'bg-surface/70 text-content' + : 'text-content-muted hover:bg-surface/40 hover:text-content-secondary' }`}> diff --git a/app/src/components/layout/shell/ContentSurface.test.tsx b/app/src/components/layout/shell/ContentSurface.test.tsx new file mode 100644 index 0000000000..27ec1ee2e0 --- /dev/null +++ b/app/src/components/layout/shell/ContentSurface.test.tsx @@ -0,0 +1,51 @@ +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; + +import ContentSurface from './ContentSurface'; + +describe('ContentSurface', () => { + afterEach(cleanup); + + it('renders children', () => { + render( + +

routed page

+
+ ); + expect(screen.getByText('routed page')).toBeTruthy(); + }); + + it('frames the surface as an inset rounded card by default', () => { + render(body); + const surface = screen.getByTestId('app-content-surface'); + expect(surface.className).toContain('rounded-2xl'); + expect(surface.className).toContain('shadow-content-edge'); + // Asymmetric insets: hairline against the sidebar/top, 8px on free edges. + expect(surface.className).toContain('mt-px'); + expect(surface.className).toContain('ml-px'); + expect(surface.className).toContain('mr-2'); + expect(surface.className).toContain('mb-2'); + expect(surface.dataset.unframed).toBeUndefined(); + }); + + it('drops the radius, insets and seam when unframed', () => { + render(body); + const surface = screen.getByTestId('app-content-surface'); + // A native CEF webview composites above HTML as a plain rectangle, so any + // radius here would leave four square corners poking through the card. + expect(surface.className).not.toContain('rounded-2xl'); + expect(surface.className).not.toContain('shadow-content-edge'); + expect(surface.className).not.toContain('mr-2'); + expect(surface.dataset.unframed).toBe('true'); + }); + + it('keeps the bounded flex column in both modes so the page owns the scroll', () => { + const { rerender } = render(body); + expect(screen.getByTestId('app-content-surface').className).toContain('min-h-0'); + rerender(body); + const surface = screen.getByTestId('app-content-surface'); + expect(surface.className).toContain('min-h-0'); + expect(surface.className).toContain('flex-1'); + expect(surface.className).toContain('bg-surface'); + }); +}); diff --git a/app/src/components/layout/shell/ContentSurface.tsx b/app/src/components/layout/shell/ContentSurface.tsx new file mode 100644 index 0000000000..e7ea565939 --- /dev/null +++ b/app/src/components/layout/shell/ContentSurface.tsx @@ -0,0 +1,51 @@ +import debugFactory from 'debug'; +import type { ReactNode } from 'react'; + +const log = debugFactory('shell:content-surface'); + +/** Shared flex/overflow behaviour — identical in both framed and unframed modes. */ +const BASE = 'relative z-10 flex min-h-0 flex-1 flex-col overflow-hidden bg-surface'; + +/** + * Inset, rounded card floating on the window chrome. Asymmetric margins on + * purpose: 1px against the sidebar and the top drag strip (where the chrome is + * only a seam) and 8px on the free right/bottom edges (where the chrome reads + * as a frame). + */ +const FRAMED = `${BASE} mt-px ml-px mr-2 mb-2 rounded-2xl shadow-content-edge`; + +interface ContentSurfaceProps { + children: ReactNode; + /** + * Render edge-to-edge with square corners and no card seam. + * + * **Required whenever a native CEF provider webview is mounted inside.** + * `WebviewHost` hands the Rust side a plain `{x, y, width, height}` rectangle + * and CEF composites that child view *above* the whole HTML layer — so the + * corners cannot be masked by `overflow-hidden`, a CSS radius, or any HTML + * overlay painted on top. A framed card under a live webview shows four + * square corners punching out through the radius. + */ + unframed?: boolean; +} + +/** + * The app's single content surface: the opaque sheet that routed pages render + * onto, floating on the themed chrome layer ({@link AppBackground}) that the + * sidebar also sits on. + * + * This is the "card" half of the two-layer shell. The chrome carries the + * theme's hue; this surface stays neutral so page content reads against a + * consistent background across every theme. + */ +export default function ContentSurface({ children, unframed = false }: ContentSurfaceProps) { + log('render: unframed=%s', unframed); + return ( +
+ {children} +
+ ); +} diff --git a/app/src/components/layout/shell/RootShellLayout.test.tsx b/app/src/components/layout/shell/RootShellLayout.test.tsx new file mode 100644 index 0000000000..74a6074def --- /dev/null +++ b/app/src/components/layout/shell/RootShellLayout.test.tsx @@ -0,0 +1,60 @@ +import { screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { renderWithProviders } from '../../../test/test-utils'; +import RootShellLayout from './RootShellLayout'; + +// Render i18n keys verbatim so assertions don't depend on locale copy. +vi.mock('../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) })); +// The collapsed rail pulls in routing + nav config; the shell's own geometry is +// the unit under test. +vi.mock('./CollapsedNavRail', () => ({ default: () => null })); +// macOS/Tauri-gated, and covered by its own spec. +vi.mock('./WindowDragBar', () => ({ default: () => null })); + +function renderShell(props: { unframed?: boolean } = {}) { + return renderWithProviders( + sidebar body} {...props}> +
routed page
+
+ ); +} + +describe('RootShellLayout', () => { + it('renders the sidebar and the routed content', () => { + renderShell(); + expect(screen.getByText('sidebar body')).toBeTruthy(); + expect(screen.getByText('routed page')).toBeTruthy(); + }); + + it('mounts the routed content inside the content surface', () => { + renderShell(); + const surface = screen.getByTestId('app-content-surface'); + expect(surface.contains(screen.getByText('routed page'))).toBe(true); + }); + + it('frames the content surface as a card by default', () => { + renderShell(); + expect(screen.getByTestId('app-content-surface').dataset.unframed).toBeUndefined(); + }); + + it('forwards unframed so a live CEF webview gets a square, edge-to-edge pane', () => { + renderShell({ unframed: true }); + expect(screen.getByTestId('app-content-surface').dataset.unframed).toBe('true'); + }); + + it('leaves the resize divider unfilled so the chrome reads as one surface', () => { + renderShell(); + const divider = screen.getByTestId('root-shell-divider'); + expect(divider.className).toContain('bg-transparent'); + expect(divider.className).not.toContain('bg-surface-strong'); + }); + + it('exposes the divider as a keyboard-operable separator', () => { + renderShell(); + const divider = screen.getByTestId('root-shell-divider'); + expect(divider.getAttribute('role')).toBe('separator'); + expect(divider.getAttribute('aria-orientation')).toBe('vertical'); + expect(divider.tabIndex).toBe(0); + }); +}); diff --git a/app/src/components/layout/shell/RootShellLayout.tsx b/app/src/components/layout/shell/RootShellLayout.tsx index 978720f748..8e935304b5 100644 --- a/app/src/components/layout/shell/RootShellLayout.tsx +++ b/app/src/components/layout/shell/RootShellLayout.tsx @@ -11,6 +11,7 @@ import { } from '../../../store/layoutSlice'; import { Tooltip } from '../../ui'; import CollapsedNavRail from './CollapsedNavRail'; +import ContentSurface from './ContentSurface'; import WindowDragBar from './WindowDragBar'; // `app-shell` (not the older `root-shell`) so the persisted geometry seeds @@ -55,17 +56,29 @@ interface RootShellLayoutProps { sidebar: ReactNode; /** Dynamic main content (the routed page area). */ children: ReactNode; + /** + * Render the content edge-to-edge instead of as an inset card. Forwarded to + * {@link ContentSurface} — see its docs for why a live CEF provider webview + * *must* set this. + */ + unframed?: boolean; } /** - * Full-bleed, viewport-filling two-pane shell for the app root: a resizable - * sidebar on the left and the main content on the right, separated by a flush - * hairline seam. Unlike the in-page {@link TwoPanelLayout}, this fills its - * container edge-to-edge (no card, no rounded corners) because it *is* the - * window chrome. The dragged width persists per user via the `layout` slice - * (id `root-shell`); the sidebar is always shown. + * Viewport-filling two-pane shell for the app root, built as two layers rather + * than two opaque panes: + * + * - **Chrome** — this component paints nothing of its own. The themed + * {@link AppBackground} behind it shows through here and behind the + * sidebar, so the frame carries the theme's hue as one continuous surface. + * - **Card** — the routed content sits on a single inset, rounded + * {@link ContentSurface}, the only opaque sheet in the shell. + * + * The two separate by fill contrast, which is why the sidebar needs no border + * and the panes need no divider fill. The dragged sidebar width persists per + * user via the `layout` slice (id `app-shell`). */ -export default function RootShellLayout({ sidebar, children }: RootShellLayoutProps) { +export default function RootShellLayout({ sidebar, children, unframed }: RootShellLayoutProps) { const { t } = useT(); const dispatch = useAppDispatch(); const layout = useAppSelector(selectPanelLayout(LAYOUT_ID, LAYOUT_DEFAULTS)); @@ -146,7 +159,14 @@ export default function RootShellLayout({ sidebar, children }: RootShellLayoutPr ); return ( -
+ // The chrome layer. One legibility scrim across the WHOLE shell — the + // sidebar column and the frame around the content card — so the two read as + // a single continuous surface. Scrimming per-pane would tint them + // differently and reintroduce the very seam this layout removes. It is + // partial-alpha on purpose: the themed AppBackground behind it (animated + // mesh gradient, flat canvas, or an arbitrary user image) still carries the + // theme's hue, while chrome-level text stays readable over all three. +
{isOpen && ( <>
+ // Transparent at rest: the sidebar and the content card separate by + // fill contrast, so a filled seam would draw a line across the + // chrome that the two-layer look is trying to remove. It still + // lights up on hover/focus to advertise the drag affordance. + className="group relative w-px flex-shrink-0 cursor-col-resize select-none self-stretch bg-transparent focus:outline-none"> - +
)} @@ -181,7 +205,7 @@ export default function RootShellLayout({ sidebar, children }: RootShellLayoutPr native CEF webview glued to the content's bounds, which composites above the HTML layer — starts to its right and never covers it. */} {!isOpen && ( -
+
{/* macOS overlay title bar (titleBarStyle: Overlay) floats the traffic lights over the top-left. The expanded SidebarHeader dodges them by right-aligning, but this narrow rail can't — so reserve a draggable @@ -207,20 +231,21 @@ export default function RootShellLayout({ sidebar, children }: RootShellLayoutPr {/* Keep the primary nav reachable while collapsed: an icon-only rail. */} -
+
)} -
- {children} - {/* macOS overlay-title-bar drag strip — a transparent overlay pinned on - TOP of the routed view (last child) so full-bleed surfaces (Tiny - Place world, Chat backdrop) stay edge-to-edge while the top of the - window still drags. The sidebar is excluded — its header already - drags in place. No-op off macOS / outside Tauri. */} +
+ {/* macOS overlay-title-bar band, in flow ABOVE the content card so the + traffic lights land on bare chrome instead of on the card. No-op off + macOS / outside Tauri, where the native title bar already owns that + band and reserving one would just waste 28px. */} + {children}
); diff --git a/app/src/components/layout/shell/SidebarNav.test.tsx b/app/src/components/layout/shell/SidebarNav.test.tsx index 790e8289a3..b2e37a78d0 100644 --- a/app/src/components/layout/shell/SidebarNav.test.tsx +++ b/app/src/components/layout/shell/SidebarNav.test.tsx @@ -53,17 +53,20 @@ describe('SidebarNav active matching', () => { expect(tabButton('Workflows')).not.toHaveAttribute('aria-current'); }); - it('gives the active tab a visible brand-accent fill (not the white sidebar background)', () => { + it('gives the active tab a neutral fill that lifts off the chrome, not an accent tint', () => { renderWithProviders(, { initialEntries: ['/chat'] }); const active = tabButton('Chat'); - // Active state uses a themeable primary-accent tint that contrasts against - // any sidebar surface (light, dark, or custom themes). - expect(active.className).toContain('bg-primary-500/12'); + // The sidebar sits on the themed chrome layer, which already carries the + // theme's hue — so selection is a neutral surface lift plus weight. Tinting + // the pill on top of a tinted chrome stacks two colours and reads as noise. + expect(active.className).toContain('bg-surface/70'); + expect(active.className).toContain('font-semibold'); + expect(active.className).not.toContain('bg-primary'); expect(active.className).not.toContain('bg-white'); // Inactive tabs carry no active fill. - expect(tabButton('Human').className).not.toContain('bg-primary-500/12'); + expect(tabButton('Human').className).not.toContain('bg-surface/70'); }); it('clears an active provider selection when clicking the already-active nav item', () => { diff --git a/app/src/components/layout/shell/SidebarNav.tsx b/app/src/components/layout/shell/SidebarNav.tsx index 89d8ec26bd..6a1fccf2bf 100644 --- a/app/src/components/layout/shell/SidebarNav.tsx +++ b/app/src/components/layout/shell/SidebarNav.tsx @@ -76,14 +76,15 @@ export default function SidebarNav() { onClick={() => handleClick(tab, active)} title={tab.label} aria-current={active ? 'page' : undefined} - // Active state uses the primary accent as a translucent tint + ring - // so it reads against any themed sidebar surface (light, dark, or a - // custom theme like Midnight) — accent tokens are themeable, so this - // no longer needs a hardcoded `dark:` neutral fill. + // Active state is a neutral fill lifted off the chrome, not an + // accent tint: the chrome carries the theme's hue, so tinting a nav + // pill on top of it stacks two colours and reads as noise. Weight + // and contrast carry the selection instead. Fills are alpha-based so + // they lift against whatever backdrop the theme paints behind them. className={`group flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-[13px] transition-colors cursor-pointer ${ active - ? 'bg-primary-500/12 text-primary-600 ring-1 ring-primary-500/25 dark:text-primary-300 font-semibold shadow-sm' - : 'text-content-muted hover:bg-surface-hover hover:text-content-secondary' + ? 'bg-surface/70 text-content font-semibold' + : 'text-content-muted hover:bg-surface/40 hover:text-content-secondary' }`}> diff --git a/app/src/components/layout/shell/WindowDragBar.tsx b/app/src/components/layout/shell/WindowDragBar.tsx index 808412b99f..951090b4af 100644 --- a/app/src/components/layout/shell/WindowDragBar.tsx +++ b/app/src/components/layout/shell/WindowDragBar.tsx @@ -8,33 +8,35 @@ import { isTauri } from '../../../utils/tauriCommands/common'; export const WINDOW_DRAG_BAR_HEIGHT = 28; /** - * Transparent macOS window-drag strip for the overlay title bar. + * Transparent macOS window-drag band for the overlay title bar. * * The main window runs with `titleBarStyle: "Overlay"` + `hiddenTitle` (see * `app/src-tauri/tauri.conf.json`), so macOS draws transparent traffic lights * over the web content but does NOT make the top draggable on its own — the * webview captures the pointer events. We opt back in with a `data-tauri-drag- - * region` strip. + * region` band. * - * Rendered as an absolutely-positioned overlay pinned to the top of the main - * content pane ({@link RootShellLayout}), as the LAST child so it paints ON TOP - * of the routed view. That keeps full-bleed HTML surfaces (the Tiny Place world - * canvas, the Chat backdrop) edge-to-edge — the strip floats over them and - * drags the window — instead of a reserved inset that would push them down and - * reveal the app background above them. It occupies no layout space and is - * transparent. + * Rendered **in flow** at the top of the content column ({@link + * RootShellLayout}), above the inset {@link ContentSurface}. It reserves the + * band the traffic lights occupy so they sit on bare window chrome rather than + * on the content card. + * + * It used to be an absolutely-positioned overlay painted on top of the routed + * view, because the content pane was edge-to-edge and any reserved inset would + * have pushed full-bleed surfaces (the Tiny Place world canvas, the Chat + * backdrop) down and revealed the app background above them. The two-layer + * shell makes that moot: the card is inset by design, so an in-flow band is now + * both simpler and correct — and it no longer steals pointer events from the + * top ~28px of page content. * - * A drag region must be the top-most element under the pointer, so the band - * does sit over the top ~28px of the pane: page chrome with controls in that - * band keeps the bulk of each control clickable below the strip, and the - * traffic lights (native, composited above the webview) are always clickable. - * The sidebar is intentionally excluded — its header already drags in place. * Native CEF provider webviews composite above all HTML and so can't be dragged - * through; that's a platform limit, not this strip. + * through; that's a platform limit, not this band. The sidebar is intentionally + * excluded — its header already drags in place. * * macOS-only: Windows/Linux keep their native decorated title bar (the - * `Overlay` style is a no-op there). Outside the Tauri runtime (browser/iOS) - * there is no window to drag, so it renders nothing. + * `Overlay` style is a no-op there), so reserving a band would only waste + * vertical space. Outside the Tauri runtime (browser/iOS) there is no window to + * drag, so it renders nothing. */ export default function WindowDragBar() { if (!isTauri() || !isMac()) return null; @@ -42,7 +44,7 @@ export default function WindowDragBar() { diff --git a/app/src/styles/tokens.css b/app/src/styles/tokens.css index c59b6357e7..228f6543df 100644 --- a/app/src/styles/tokens.css +++ b/app/src/styles/tokens.css @@ -29,6 +29,11 @@ --surface-hover: 240 240 240; /* hover fills — distinct from white surface so hover reads on elevated cards, not just canvas */ --surface-overlay: 0 0 0; /* modal scrim (used with /40../70) */ + --surface-chrome: 245 245 245; /* window chrome — the tinted frame the sidebar + sits on, behind the inset content card. Used + at partial alpha as a legibility scrim over + the themed AppBackground, so it must stay a + near-canvas neutral, not an accent. */ /* ---- Text ---- */ --content: 23 23 23; /* primary text (stone-900) */ @@ -41,6 +46,10 @@ --line: 229 229 229; /* default hairline (stone-200) */ --line-strong: 212 212 212; /* stronger divider (stone-300) */ --line-subtle: 245 245 245; /* faintest divider (stone-100) */ + --line-chrome: 212 212 212; /* the hairline where the content card meets the + chrome (stone-300). Drawn as a zero-blur inset + box-shadow, not a border — see the + `content-edge` shadow in tailwind.config.js. */ /* ---- Accent palette: primary (Complementary Blue) ---- */ --primary-50: 239 246 255; @@ -111,6 +120,8 @@ --surface-strong: 38 38 38; /* neutral-800 */ --surface-hover: 38 38 38; /* neutral-800 */ --surface-overlay: 0 0 0; + --surface-chrome: 10 10 10; /* neutral-950 — darker than --surface (23) so the + content card reads as raised against the frame */ /* ---- Text ---- */ --content: 245 245 245; /* neutral-100 */ @@ -123,6 +134,7 @@ --line: 38 38 38; /* neutral-800 */ --line-strong: 64 64 64; /* neutral-700 */ --line-subtle: 38 38 38; /* neutral-800 */ + --line-chrome: 64 64 64; /* neutral-700 */ /* * Accent palettes intentionally inherit their `:root` values under dark mode: diff --git a/app/tailwind.config.js b/app/tailwind.config.js index d2b06c9d21..647386e4ef 100644 --- a/app/tailwind.config.js +++ b/app/tailwind.config.js @@ -46,6 +46,7 @@ module.exports = { strong: 'rgb(var(--surface-strong) / )', hover: 'rgb(var(--surface-hover) / )', overlay: 'rgb(var(--surface-overlay) / )', + chrome: 'rgb(var(--surface-chrome) / )', }, content: { DEFAULT: 'rgb(var(--content) / )', @@ -58,6 +59,7 @@ module.exports = { DEFAULT: 'rgb(var(--line) / )', strong: 'rgb(var(--line-strong) / )', subtle: 'rgb(var(--line-subtle) / )', + chrome: 'rgb(var(--line-chrome) / )', }, // Neutral - Light theme grayscale (from Figma design tokens) @@ -257,6 +259,11 @@ module.exports = { 'float': '0 12px 32px -8px rgba(0, 0, 0, 0.12), 0 24px 48px -12px rgba(0, 0, 0, 0.12)', 'crisp': '0 0 0 1px rgba(0, 0, 0, 0.05), 0 2px 4px rgba(0, 0, 0, 0.08)', 'cmd-palette': 'var(--cmd-shadow-palette)', + // Zero-blur, zero-spread hairline offset up-left — the seam where the + // inset content card meets the window chrome. Deliberately NOT a drop + // shadow: the card separates from the chrome by fill contrast, and this + // only sharpens the top/left edge where the two surfaces are closest. + 'content-edge': '-1px -1px 0 0 rgb(var(--line-chrome) / 0.45)', }, // Premium animations for polished interactions From 57644a268e065e006f1ca3c45f230ded2247f07e Mon Sep 17 00:00:00 2001 From: "cyrus@tinyhumans.ai" Date: Fri, 7 Aug 2026 18:20:43 +0530 Subject: [PATCH 02/17] style(shell): give the content card an even inset on all four sides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The card sat 1px off the sidebar and 1px below the drag band with 8px on the right and bottom, which read as flush on the left and top-heavy on the right. An even 12px margin frames it deliberately instead. The top gap still lands largest — the window-drag band sits above the card inside the same column, so it totals ~40px there. --- .../components/layout/shell/ContentSurface.test.tsx | 9 +++------ app/src/components/layout/shell/ContentSurface.tsx | 10 +++++----- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/app/src/components/layout/shell/ContentSurface.test.tsx b/app/src/components/layout/shell/ContentSurface.test.tsx index 27ec1ee2e0..a1256d1dec 100644 --- a/app/src/components/layout/shell/ContentSurface.test.tsx +++ b/app/src/components/layout/shell/ContentSurface.test.tsx @@ -20,11 +20,8 @@ describe('ContentSurface', () => { const surface = screen.getByTestId('app-content-surface'); expect(surface.className).toContain('rounded-2xl'); expect(surface.className).toContain('shadow-content-edge'); - // Asymmetric insets: hairline against the sidebar/top, 8px on free edges. - expect(surface.className).toContain('mt-px'); - expect(surface.className).toContain('ml-px'); - expect(surface.className).toContain('mr-2'); - expect(surface.className).toContain('mb-2'); + // Even inset on all four sides so the chrome frames the card. + expect(surface.className).toContain('m-3'); expect(surface.dataset.unframed).toBeUndefined(); }); @@ -35,7 +32,7 @@ describe('ContentSurface', () => { // radius here would leave four square corners poking through the card. expect(surface.className).not.toContain('rounded-2xl'); expect(surface.className).not.toContain('shadow-content-edge'); - expect(surface.className).not.toContain('mr-2'); + expect(surface.className).not.toContain('m-3'); expect(surface.dataset.unframed).toBe('true'); }); diff --git a/app/src/components/layout/shell/ContentSurface.tsx b/app/src/components/layout/shell/ContentSurface.tsx index e7ea565939..b3ae6f8520 100644 --- a/app/src/components/layout/shell/ContentSurface.tsx +++ b/app/src/components/layout/shell/ContentSurface.tsx @@ -7,12 +7,12 @@ const log = debugFactory('shell:content-surface'); const BASE = 'relative z-10 flex min-h-0 flex-1 flex-col overflow-hidden bg-surface'; /** - * Inset, rounded card floating on the window chrome. Asymmetric margins on - * purpose: 1px against the sidebar and the top drag strip (where the chrome is - * only a seam) and 8px on the free right/bottom edges (where the chrome reads - * as a frame). + * Inset, rounded card floating on the window chrome. Even 12px margin on all + * four sides so the chrome reads as a deliberate frame rather than a seam. The + * top gap lands larger than 12px in practice because the window-drag band + * ({@link WindowDragBar}) sits above the card inside the same column. */ -const FRAMED = `${BASE} mt-px ml-px mr-2 mb-2 rounded-2xl shadow-content-edge`; +const FRAMED = `${BASE} m-3 rounded-2xl shadow-content-edge`; interface ContentSurfaceProps { children: ReactNode; From 459a435310a67107bde216c158d5fe043f750bd5 Mon Sep 17 00:00:00 2001 From: "cyrus@tinyhumans.ai" Date: Fri, 7 Aug 2026 18:27:13 +0530 Subject: [PATCH 03/17] fix(shell): let the animated backdrop show through the chrome again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chrome scrim was `bg-surface-chrome/70` plus `backdrop-blur-xl`, which flattened the themed AppBackground into paint. Combined with the opaque content card that landed in 310f8ee3, that hid the animated WebGL mesh gradient everywhere it used to be visible: routed pages paint no background of their own (PanelScaffold), so the content pane had been a transparent window onto it. The shader kept rendering and burning GPU for a backdrop nobody could see. Drop to /30 and remove the blur, so the mesh animates in the sidebar and the frame around the card. The blur was also smearing the 18px dotted canvas. The card stays opaque on purpose — a neutral content sheet is what makes hue-in-the-chrome work, and a native CEF webview cannot be translucent anyway. /30 is the legibility knob; it most likely needs raising under a `backdrop: image` theme, where an arbitrary photo is far harsher than the mesh. --- .../components/layout/shell/RootShellLayout.tsx | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/app/src/components/layout/shell/RootShellLayout.tsx b/app/src/components/layout/shell/RootShellLayout.tsx index 8e935304b5..c361ff669f 100644 --- a/app/src/components/layout/shell/RootShellLayout.tsx +++ b/app/src/components/layout/shell/RootShellLayout.tsx @@ -162,11 +162,16 @@ export default function RootShellLayout({ sidebar, children, unframed }: RootShe // The chrome layer. One legibility scrim across the WHOLE shell — the // sidebar column and the frame around the content card — so the two read as // a single continuous surface. Scrimming per-pane would tint them - // differently and reintroduce the very seam this layout removes. It is - // partial-alpha on purpose: the themed AppBackground behind it (animated - // mesh gradient, flat canvas, or an arbitrary user image) still carries the - // theme's hue, while chrome-level text stays readable over all three. -
+ // differently and reintroduce the very seam this layout removes. + // + // The alpha is deliberately light: the themed AppBackground behind it is an + // *animated* WebGL mesh gradient, and the content card above is opaque, so + // the chrome is the only place that motion is visible at all. A heavier + // scrim (or a backdrop blur, which also smears the 18px dotted canvas) + // flattens it back into paint and leaves the shader burning GPU for nothing. + // /30 is the legibility knob — raise it if sidebar labels wash out, which is + // most likely under a `backdrop: image` theme rather than the mesh. +
{isOpen && ( <>
Date: Fri, 7 Aug 2026 18:38:33 +0530 Subject: [PATCH 04/17] fix(chat): match the composer fade to the surface it actually sits on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fade behind the floating composer left a visible band once the content card landed. Three coupled values were all assuming a background that no longer existed: - The fade hardcoded `from-white dark:from-black`. That matched only while the page was a transparent window onto the app canvas (pure black in dark). It was never right for custom themes. - The page root painted `bg-surface/70 dark:bg-black/40` — a translucent tint. Over the canvas it composed to exactly black, which is why the hardcoded fade lined up. Over the opaque card it composes to an un-tokened ~#0e0e0e that nothing else in the app can name, so no fade colour could ever match it. - The hero card used `bg-surface/80`, which only read as a card because that darker tint sat beneath it. Drop the page tint so the page simply is the card's surface, fade from the `surface` token, and give the hero the `surface-muted` lift the message bubbles already use. One named colour, and the fade matches by construction in every theme rather than by coincidence in two. Same fade bug, same class string, fixed identically in AgentChatPanel. --- app/src/components/chat/ChatNewWindowHero.tsx | 7 +++++- .../orchestration/AgentChatPanel.tsx | 6 +++-- .../features/conversations/Conversations.tsx | 22 +++++++++++++++---- 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/app/src/components/chat/ChatNewWindowHero.tsx b/app/src/components/chat/ChatNewWindowHero.tsx index c18409ff68..fd281fe559 100644 --- a/app/src/components/chat/ChatNewWindowHero.tsx +++ b/app/src/components/chat/ChatNewWindowHero.tsx @@ -147,7 +147,12 @@ export default function ChatNewWindowHero() { the app background. */}
+ // `surface-muted`, not `surface/80`: the translucent fill only read as a + // card while the chat page painted a darker tint beneath it. The page is + // the card surface now, so surface/80 over surface would flatten to the + // same colour and leave only the border. This is the same lift token the + // message bubbles use. + className="animate-fade-up rounded-2xl border border-line/80 bg-surface-muted p-6 shadow-soft dark:border-line/80"> {/* Animated greeting */}

{typedWelcome} diff --git a/app/src/components/orchestration/AgentChatPanel.tsx b/app/src/components/orchestration/AgentChatPanel.tsx index ada61ec31f..b1b08c4666 100644 --- a/app/src/components/orchestration/AgentChatPanel.tsx +++ b/app/src/components/orchestration/AgentChatPanel.tsx @@ -125,11 +125,13 @@ function ChatPageScaffold({ {children}

- {/* Fade so messages dissolve into the background behind the composer. */} + {/* Fade so messages dissolve into the page behind the composer. Fades to + the `surface` token the content card paints, not a hardcoded + white/black pair — see the matching fade in Conversations.tsx. */} {footer ? (