3057 lines
118 KiB
CSS
3057 lines
118 KiB
CSS
@import 'tailwindcss';
|
||
@plugin '@tailwindcss/typography';
|
||
@import 'tw-shimmer';
|
||
@import 'katex/dist/katex.min.css';
|
||
@import '@vscode/codicons/dist/codicon.css';
|
||
|
||
/* Titlebar clusters: 24×24 hit targets, 13.9px glyphs. codicon.css is
|
||
unlayered (`font: 16px/1`); Tailwind utilities in @layer lose that fight. */
|
||
.titlebar-icon-button .codicon[class*='codicon-'] {
|
||
font-size: var(--titlebar-icon-size);
|
||
}
|
||
|
||
@custom-variant dark (&:is(.dark *));
|
||
|
||
/* Blanket reduced-motion override: kill ALL CSS animations and transitions
|
||
when the user (or the E2E test harness via prefers-reduced-motion) requests
|
||
it. Per-component @media rules below handle specific cases; this catches
|
||
everything else so overlays and loading bars resolve instantly instead of
|
||
being caught mid-fade by a screenshot. */
|
||
@media (prefers-reduced-motion: reduce) {
|
||
*,
|
||
*::before,
|
||
*::after {
|
||
animation-duration: 0.01ms !important;
|
||
animation-iteration-count: 1 !important;
|
||
transition-duration: 0.01ms !important;
|
||
scroll-behavior: auto !important;
|
||
}
|
||
}
|
||
|
||
/* Sidebar sections: tall viewports give each its own scroller; compact ones
|
||
(this variant) flatten everything into one shared scroll. See ChatSidebar. */
|
||
@custom-variant compact (@media (max-height: 768px));
|
||
|
||
@font-face {
|
||
font-family: 'Collapse';
|
||
font-style: normal;
|
||
font-weight: 700;
|
||
font-display: swap;
|
||
src: url('../../../node_modules/@nous-research/ui/dist/fonts/Collapse-Bold.woff2') format('woff2');
|
||
}
|
||
|
||
/* JetBrains Mono — bundled terminal font (Apache-2.0) so bold/italic share the
|
||
regular face's metrics instead of squeezing against a system fallback. */
|
||
@font-face {
|
||
font-family: 'JetBrains Mono';
|
||
font-style: normal;
|
||
font-weight: 400;
|
||
font-display: swap;
|
||
src: url('./fonts/JetBrainsMono-Regular.woff2') format('woff2');
|
||
}
|
||
@font-face {
|
||
font-family: 'JetBrains Mono';
|
||
font-style: normal;
|
||
font-weight: 700;
|
||
font-display: swap;
|
||
src: url('./fonts/JetBrainsMono-Bold.woff2') format('woff2');
|
||
}
|
||
@font-face {
|
||
font-family: 'JetBrains Mono';
|
||
font-style: italic;
|
||
font-weight: 400;
|
||
font-display: swap;
|
||
src: url('./fonts/JetBrainsMono-Italic.woff2') format('woff2');
|
||
}
|
||
|
||
@theme inline {
|
||
--color-background: var(--dt-background);
|
||
--color-foreground: var(--dt-foreground);
|
||
--color-card: var(--dt-card);
|
||
--color-card-foreground: var(--dt-card-foreground);
|
||
--color-muted: var(--dt-muted);
|
||
--color-muted-foreground: var(--dt-muted-foreground);
|
||
--color-popover: var(--dt-popover);
|
||
--color-popover-foreground: var(--dt-popover-foreground);
|
||
--color-primary: var(--dt-primary);
|
||
--color-primary-foreground: var(--dt-primary-foreground);
|
||
--color-secondary: var(--dt-secondary);
|
||
--color-secondary-foreground: var(--dt-secondary-foreground);
|
||
--color-accent: var(--dt-accent);
|
||
--color-accent-foreground: var(--dt-accent-foreground);
|
||
--color-border: var(--dt-border);
|
||
--color-input: var(--dt-input);
|
||
--color-ring: var(--dt-ring);
|
||
--color-destructive: var(--dt-destructive);
|
||
--color-destructive-foreground: var(--dt-destructive-foreground);
|
||
|
||
--color-midground: var(--dt-midground);
|
||
--color-midground-foreground: var(--dt-midground-foreground);
|
||
|
||
--font-sans: var(--dt-font-sans);
|
||
--font-mono: var(--dt-font-mono);
|
||
|
||
--spacing-mul: var(--dt-spacing-mul, 1);
|
||
|
||
--radius-xs: calc(var(--radius-scalar) * 0.125rem);
|
||
--radius-sm: calc(var(--radius-scalar) * 0.5rem);
|
||
--radius-md: calc(var(--radius-scalar) * 0.625rem);
|
||
--radius-lg: calc(var(--radius-scalar) * 0.75rem);
|
||
--radius-xl: calc(var(--radius-scalar) * 1rem);
|
||
--radius-2xl: calc(var(--radius-scalar) * 1.5rem);
|
||
--radius-3xl: calc(var(--radius-scalar) * 2rem);
|
||
--radius-4xl: calc(var(--radius-scalar) * 2.5rem);
|
||
|
||
--color-sidebar-ring: var(--sidebar-ring);
|
||
--color-sidebar-border: var(--sidebar-border);
|
||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||
--color-sidebar-accent: var(--sidebar-accent);
|
||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||
--color-sidebar-primary: var(--sidebar-primary);
|
||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||
--color-sidebar: var(--sidebar);
|
||
|
||
--shadow-xs: 0 0.0625rem 0.125rem color-mix(in srgb, #000 5%, transparent);
|
||
--shadow-sm:
|
||
0 0 0 0.0625rem color-mix(in srgb, var(--dt-foreground) 6%, transparent),
|
||
0 0.125rem 0.5rem color-mix(in srgb, #000 4%, transparent);
|
||
--shadow-md:
|
||
0 0 0 0.0625rem color-mix(in srgb, var(--dt-foreground) 8%, transparent),
|
||
0 0.25rem 1rem color-mix(in srgb, #000 8%, transparent),
|
||
0 1rem 2rem -1.5rem color-mix(in srgb, #000 18%, transparent);
|
||
/* Soft floating shadow for borderless modals/overlays. Single top light
|
||
source: every layer is centered (x=0) and cast downward, with negative
|
||
spread that grows with the blur so each layer is pulled horizontally inward
|
||
— the shadow pools below the panel instead of bleeding out every side.
|
||
Layered (contact → ambient) for a smooth, natural falloff. */
|
||
--shadow-nous:
|
||
0 0.125rem 0.25rem -0.125rem color-mix(in srgb, #000 7%, transparent),
|
||
0 0.5rem 0.75rem -0.375rem color-mix(in srgb, #000 6%, transparent),
|
||
0 1.25rem 1.75rem -0.875rem color-mix(in srgb, #000 6%, transparent),
|
||
0 2.25rem 3rem -1.75rem color-mix(in srgb, #000 0%, transparent);
|
||
/* Hairline border paired with --shadow-nous on borderless overlays.
|
||
currentColor resolves per-element, so it adapts to text color/theme. */
|
||
--stroke-nous: color-mix(in srgb, currentColor 3%, transparent);
|
||
--shadow-lg:
|
||
inset 0 0.0625rem 0 color-mix(in srgb, #fff 28%, transparent),
|
||
0 0 0 0.0625rem color-mix(in srgb, var(--dt-foreground) 8%, transparent),
|
||
0 0.75rem 2rem color-mix(in srgb, #000 12%, transparent);
|
||
--shadow-composer: 0 0.0625rem 0.125rem color-mix(in srgb, #000 5%, transparent);
|
||
}
|
||
|
||
@layer base {
|
||
:root {
|
||
color-scheme: light;
|
||
|
||
--theme-foreground: #17171a;
|
||
--theme-primary: #0053fd;
|
||
--theme-secondary: color-mix(in srgb, #0053fd 7%, #ffffff);
|
||
--theme-accent-soft: color-mix(in srgb, #0053fd 10%, #ffffff);
|
||
--theme-midground: #0053fd;
|
||
--theme-warm: #cf806d;
|
||
--theme-background-seed: #f8faff;
|
||
--theme-sidebar-seed: #f3f7ff;
|
||
--theme-card-seed: #ffffff;
|
||
--theme-elevated-seed: #ffffff;
|
||
--theme-bubble-seed: color-mix(in srgb, #0053fd 6%, #ffffff);
|
||
--theme-neutral-chrome: #f3f3f3;
|
||
--theme-neutral-sidebar: #f3f3f3;
|
||
--theme-neutral-card: #fcfcfc;
|
||
--theme-mix-chrome: 92%;
|
||
--theme-mix-sidebar: 100%;
|
||
--theme-mix-card: 22%;
|
||
--theme-mix-elevated: 28%;
|
||
--theme-mix-bubble: 0%;
|
||
--theme-fill-primary-accent-mix: 16%;
|
||
--theme-fill-secondary-accent-mix: 11%;
|
||
--theme-fill-tertiary-accent-mix: 8%;
|
||
--theme-fill-quaternary-accent-mix: 5%;
|
||
--theme-fill-quinary-accent-mix: 3%;
|
||
--theme-stroke-primary-accent-mix: 24%;
|
||
--theme-stroke-secondary-accent-mix: 16%;
|
||
--theme-stroke-tertiary-accent-mix: 10%;
|
||
--theme-stroke-quaternary-accent-mix: 6%;
|
||
--theme-row-hover-accent-mix: 4%;
|
||
--theme-row-active-accent-mix: 8%;
|
||
--theme-control-hover-accent-mix: 6%;
|
||
--theme-control-active-accent-mix: 8%;
|
||
|
||
--ui-base: var(--theme-foreground);
|
||
--ui-accent: var(--theme-midground);
|
||
--ui-accent-secondary: var(--theme-primary);
|
||
--ui-warm: var(--theme-warm);
|
||
--ui-red: #cf2d56;
|
||
--ui-orange: #db704b;
|
||
--ui-yellow: #c08532;
|
||
--ui-green: #1f8a65;
|
||
--ui-cyan: #4c7f8c;
|
||
--ui-blue: #0053fd;
|
||
--ui-purple: #9e94d5;
|
||
--context-usage-system: color-mix(in srgb, var(--ui-base) 55%, transparent);
|
||
--context-usage-tools: var(--ui-purple);
|
||
--context-usage-rules: var(--ui-green);
|
||
--context-usage-skills: var(--ui-yellow);
|
||
--context-usage-mcp: color-mix(in srgb, var(--ui-red) 72%, var(--ui-purple));
|
||
--context-usage-subagents: color-mix(in srgb, var(--ui-blue) 70%, var(--ui-cyan));
|
||
--context-usage-memory: color-mix(in srgb, var(--ui-orange) 80%, var(--ui-yellow));
|
||
--context-usage-conversation: var(--ui-cyan);
|
||
/* Landed memory-write tool row — gold → purple, never amber warning. */
|
||
--tool-memory-legendary-from: color-mix(in srgb, var(--ui-yellow) 72%, #f5d08a);
|
||
--tool-memory-legendary-mid: color-mix(in srgb, var(--ui-orange) 55%, var(--ui-purple));
|
||
--tool-memory-legendary-to: color-mix(in srgb, var(--ui-purple) 82%, #c4b5fd);
|
||
--tool-memory-legendary-icon: color-mix(in srgb, var(--ui-yellow) 55%, var(--ui-purple));
|
||
--tool-memory-legendary-meta: color-mix(in srgb, var(--ui-purple) 58%, var(--ui-text-tertiary));
|
||
--tool-memory-legendary-glow: color-mix(in srgb, var(--ui-purple) 38%, transparent);
|
||
/* Diff add/remove, derived from the semantic palette so every diff surface
|
||
(tool cards, preview, review pane) tracks the theme's green/red. Only the
|
||
foregrounds need a dark override — they mix toward the page instead of
|
||
away from it. */
|
||
--ui-diff-add-border: var(--ui-green);
|
||
--ui-diff-add-background: color-mix(in srgb, var(--ui-green) 12%, transparent);
|
||
--ui-diff-add-foreground: color-mix(in srgb, var(--ui-green) 70%, #000);
|
||
--ui-diff-remove-border: var(--ui-red);
|
||
--ui-diff-remove-background: color-mix(in srgb, var(--ui-red) 12%, transparent);
|
||
--ui-diff-remove-foreground: color-mix(in srgb, var(--ui-red) 70%, #000);
|
||
|
||
/* Overlay ladder. DESIGN.md: app-wide surfaces must not compete through
|
||
ad-hoc z-index literals — pick the rung that describes the surface.
|
||
Values are deliberately sparse so a one-off can slot between two rungs
|
||
without a renumber. Local stacking inside a component (a sticky header
|
||
over its own scroll area) stays on plain `z-10`/`z-20`; these rungs are
|
||
only for surfaces that float over the app. */
|
||
--z-modal-backdrop: 120;
|
||
--z-modal: 130;
|
||
/* A select/dropdown/popover opened from inside a modal, portaled to body. */
|
||
--z-modal-popover: 140;
|
||
/* Must clear any open modal: toasts, tooltips, command-surface backdrops. */
|
||
--z-over-modal: 200;
|
||
--z-over-modal-content: 210;
|
||
--z-switcher-backdrop: 219;
|
||
--z-switcher: 220;
|
||
/* Boot and blocking states, in the order they can stack. */
|
||
--z-connecting: 1200;
|
||
--z-onboarding: 1300;
|
||
--z-onboarding-popover: 1310;
|
||
--z-setup: 1400;
|
||
--z-crash: 1500;
|
||
--ui-bg-chrome: color-mix(
|
||
in srgb,
|
||
var(--theme-background-seed) var(--theme-mix-chrome),
|
||
var(--theme-neutral-chrome)
|
||
);
|
||
--ui-bg-sidebar: color-mix(
|
||
in srgb,
|
||
var(--theme-sidebar-seed) var(--theme-mix-sidebar),
|
||
var(--theme-neutral-sidebar)
|
||
);
|
||
--ui-bg-editor: color-mix(in srgb, var(--theme-card-seed) var(--theme-mix-card), var(--theme-neutral-card));
|
||
--ui-bg-elevated: color-mix(
|
||
in srgb,
|
||
var(--theme-elevated-seed) var(--theme-mix-elevated),
|
||
var(--theme-neutral-card)
|
||
);
|
||
--ui-bg-card: color-mix(in srgb, var(--ui-accent) 4%, color-mix(in srgb, var(--ui-base) 4%, transparent));
|
||
--ui-bg-input: #fcfcfc;
|
||
--ui-bg-primary: color-mix(
|
||
in srgb,
|
||
var(--ui-accent) var(--theme-fill-primary-accent-mix),
|
||
color-mix(in srgb, var(--ui-base) 10%, transparent)
|
||
);
|
||
--ui-bg-secondary: color-mix(
|
||
in srgb,
|
||
var(--ui-accent) var(--theme-fill-secondary-accent-mix),
|
||
color-mix(in srgb, var(--ui-base) 7%, transparent)
|
||
);
|
||
--ui-bg-tertiary: color-mix(
|
||
in srgb,
|
||
var(--ui-accent) var(--theme-fill-tertiary-accent-mix),
|
||
color-mix(in srgb, var(--ui-base) 5%, transparent)
|
||
);
|
||
--ui-bg-quaternary: color-mix(
|
||
in srgb,
|
||
var(--ui-accent) var(--theme-fill-quaternary-accent-mix),
|
||
color-mix(in srgb, var(--ui-base) 4%, transparent)
|
||
);
|
||
--ui-bg-quinary: color-mix(
|
||
in srgb,
|
||
var(--ui-accent) var(--theme-fill-quinary-accent-mix),
|
||
color-mix(in srgb, var(--ui-base) 3%, transparent)
|
||
);
|
||
--ui-row-hover-background: color-mix(
|
||
in srgb,
|
||
var(--ui-accent) var(--theme-row-hover-accent-mix),
|
||
color-mix(in srgb, var(--ui-base) 3%, transparent)
|
||
);
|
||
--ui-row-active-background: color-mix(
|
||
in srgb,
|
||
var(--ui-accent) var(--theme-row-active-accent-mix),
|
||
color-mix(in srgb, var(--ui-base) 5%, transparent)
|
||
);
|
||
--ui-control-hover-background: color-mix(
|
||
in srgb,
|
||
var(--ui-accent) var(--theme-control-hover-accent-mix),
|
||
color-mix(in srgb, var(--ui-base) 4%, transparent)
|
||
);
|
||
--ui-control-active-background: color-mix(
|
||
in srgb,
|
||
var(--ui-accent) var(--theme-control-active-accent-mix),
|
||
color-mix(in srgb, var(--ui-base) 5%, transparent)
|
||
);
|
||
--ui-text-primary: color-mix(in srgb, var(--ui-base) 94%, transparent);
|
||
--ui-text-secondary: color-mix(in srgb, var(--ui-base) 74%, transparent);
|
||
--ui-text-tertiary: color-mix(in srgb, var(--ui-base) 54%, transparent);
|
||
--ui-text-quaternary: color-mix(in srgb, var(--ui-base) 36%, transparent);
|
||
/* Transcript scaffolding — thinking headers, settled tool runs, the live
|
||
activity ticker. One colour for all of them (see `ScaffoldRow`), pitched
|
||
between the secondary and tertiary greys those lines used to pick
|
||
individually, and dimmed once more by the fade rule below so the prose
|
||
reading column stays primary. */
|
||
--conversation-scaffold-text: color-mix(in srgb, var(--ui-base) 64%, transparent);
|
||
--conversation-scaffold-meta: color-mix(in srgb, var(--ui-base) 44%, transparent);
|
||
--ui-stroke-primary: color-mix(
|
||
in srgb,
|
||
var(--ui-accent) var(--theme-stroke-primary-accent-mix),
|
||
color-mix(in srgb, var(--ui-base) 10%, transparent)
|
||
);
|
||
--ui-stroke-secondary: color-mix(
|
||
in srgb,
|
||
var(--ui-accent) var(--theme-stroke-secondary-accent-mix),
|
||
color-mix(in srgb, var(--ui-base) 7%, transparent)
|
||
);
|
||
--ui-stroke-tertiary: color-mix(
|
||
in srgb,
|
||
var(--ui-accent) var(--theme-stroke-tertiary-accent-mix),
|
||
color-mix(in srgb, var(--ui-base) 5%, transparent)
|
||
);
|
||
--ui-stroke-quaternary: color-mix(
|
||
in srgb,
|
||
var(--ui-accent) var(--theme-stroke-quaternary-accent-mix),
|
||
color-mix(in srgb, var(--ui-base) 3%, transparent)
|
||
);
|
||
--ui-sash-hover-border: color-mix(in srgb, var(--ui-accent) 18%, var(--ui-stroke-tertiary));
|
||
--ui-sash-hover-background: color-mix(in srgb, var(--ui-accent) 6%, transparent);
|
||
--ui-surface-background: var(--ui-bg-editor);
|
||
--ui-sidebar-surface-background: var(--ui-bg-sidebar);
|
||
--ui-chat-surface-background: var(--ui-bg-chrome);
|
||
--ui-editor-surface-background: var(--ui-bg-chrome);
|
||
/* The integrated terminal wears the transcript's background, not the
|
||
editor's — it reads as part of the conversation surface. Its own token so
|
||
the two can diverge without dragging the terminal along, and so a skin
|
||
gets one knob for it. xterm's canvas can't use a var, so the probe in
|
||
terminal/selection.ts resolves this same name to a concrete rgb. */
|
||
--ui-terminal-surface-background: var(--ui-chat-surface-background);
|
||
/* Inline chat widgets (clarify, artifact card) — the card fill in light
|
||
mode, nudged down in dark so a widget settles into the transcript
|
||
instead of glowing above it (see the `.dark` override). */
|
||
--ui-widget-surface-background: var(--ui-bg-editor);
|
||
--ui-chat-bubble-background: color-mix(
|
||
in srgb,
|
||
var(--theme-bubble-seed) var(--theme-mix-bubble),
|
||
var(--theme-neutral-card)
|
||
);
|
||
--ui-chat-bubble-opaque-background: var(--ui-bg-editor);
|
||
--ui-inline-code-background: color-mix(in srgb, #141414 5%, transparent);
|
||
--ui-inline-code-foreground: color-mix(in srgb, #141414 88%, transparent);
|
||
--ui-selection-background: color-mix(in srgb, #ffd24a 55%, transparent);
|
||
|
||
--dt-background: var(--ui-bg-chrome);
|
||
--dt-foreground: var(--ui-text-primary);
|
||
--dt-card: var(--ui-bg-editor);
|
||
--dt-card-foreground: var(--ui-text-primary);
|
||
--dt-muted: var(--ui-bg-tertiary);
|
||
--dt-muted-foreground: var(--ui-text-tertiary);
|
||
--dt-popover: color-mix(in srgb, var(--ui-bg-elevated) 96%, transparent);
|
||
--dt-popover-foreground: var(--ui-text-primary);
|
||
--dt-primary: var(--theme-primary);
|
||
--dt-primary-foreground: #fcfcfc;
|
||
--dt-secondary: var(--theme-secondary);
|
||
--dt-secondary-foreground: var(--ui-text-secondary);
|
||
--dt-accent: var(--theme-accent-soft);
|
||
--dt-accent-foreground: var(--ui-text-primary);
|
||
--dt-border: var(--ui-stroke-secondary);
|
||
--dt-input: var(--ui-stroke-primary);
|
||
/* THE single knob for input-field borders: the resting alpha (% of the ring
|
||
color). Hover doubles it; focus/open go full. 0% = invisible at rest. */
|
||
--dt-input-border: 7%;
|
||
/* Knob for input-field background fill: alpha (% of --dt-card) across all
|
||
states. 100% = fully opaque, lower = translucent over the blurred chrome. */
|
||
--dt-input-bg: 0%;
|
||
/* Classic recessed "inset" — a crisp 1px inner shadow at the TOP edge.
|
||
:root.dark bumps the alpha since a dark card swallows shadow. Removed on
|
||
focus (the focus border carries the state). */
|
||
--dt-input-inset: inset 0 1px 1px color-mix(in srgb, #000 10%, transparent);
|
||
/* Hovered inactive tab: deepen it AWAY from the lighter active surface, so
|
||
hover can never be mistaken for selected. Dark mode needs more — black
|
||
over a dark gutter shows less. */
|
||
--ui-tab-hover-darken: 2.5%;
|
||
--dt-ring: var(--ui-stroke-primary);
|
||
--dt-midground: var(--theme-midground);
|
||
--dt-composer-ring: var(--ui-base);
|
||
--dt-destructive: #cf2d56;
|
||
--dt-destructive-foreground: #ffffff;
|
||
--dt-sidebar-bg: var(--ui-bg-sidebar);
|
||
--dt-sidebar-border: var(--ui-stroke-secondary);
|
||
--dt-user-bubble: var(--ui-chat-bubble-background);
|
||
--dt-user-bubble-border: var(--ui-stroke-tertiary);
|
||
|
||
--dt-font-sans:
|
||
'Segoe WPC', 'Segoe UI', -apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif,
|
||
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji', emoji;
|
||
/* Key caps always use the native UI face — never theme typography overrides. */
|
||
--dt-font-kbd: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif;
|
||
/* Menlo/Monaco first — Apple's native monospace faces — so code/diff read
|
||
in the system mono on macOS, with SF Mono and bundled Courier Prime as
|
||
fallbacks. */
|
||
--dt-font-mono:
|
||
Menlo, Monaco, 'SF Mono', 'Courier Prime', monospace, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||
'Noto Color Emoji', emoji;
|
||
--dt-base-size: 1rem;
|
||
--dt-line-height: 1.5;
|
||
--dt-letter-spacing: 0;
|
||
--dt-spacing-mul: 1;
|
||
|
||
--radius: 0.75rem;
|
||
/* Effective value everywhere: the chat backdrop used to force 0.2 onto the
|
||
root element at runtime (an ungated leva slider default), so this 0.6
|
||
only ever applied to the windows that don't mount the backdrop. Pinned
|
||
to the value the app actually renders at. */
|
||
--radius-scalar: 0.2;
|
||
|
||
/* Space under last message vs overlay composer — driven by the measured composer DOCK height (see
|
||
composer/index.tsx), which covers the micro-action strip, the status stack, the composer, and the
|
||
underside strip in one number.
|
||
This is the default; each chat surface re-declares the calc against its own measurement (see below). */
|
||
--thread-last-message-clearance: calc(var(--composer-measured-height) + 2rem);
|
||
|
||
--composer-shell-pad-block-end: 0.625rem;
|
||
--message-text-indent: 0.75rem;
|
||
--conversation-text-font-size: 0.8125rem;
|
||
--conversation-tool-font-size: 0.6875rem;
|
||
--conversation-caption-font-size: 0.75rem;
|
||
--conversation-line-height: 1.125rem;
|
||
--conversation-caption-line-height: 1rem;
|
||
--conversation-turn-gap: 0.375rem;
|
||
/* Gap between top-level turn blocks (prose ↔ tools ↔ thinking) — enough air
|
||
that scaffolding reads as separate from the reply, not crammed into it. */
|
||
--turn-block-gap: 0.75rem;
|
||
/* Between adjacent scaffolding, though: a back-to-back run of thinking
|
||
headers and tool rows is one sequence, not blocks to hold apart. Derived
|
||
so retuning the block gap carries it along. */
|
||
--scaffold-block-gap: calc(var(--turn-block-gap) / 3);
|
||
/* Tight gap between tool rows inside a single action group, so a back-to-back
|
||
run still reads as one cohesive sequence. */
|
||
--tool-row-gap: 0.375rem;
|
||
/* Resting opacity of a disclosure caret — invisible until hover, except
|
||
where a surface opts into a faint hint of the affordance. */
|
||
--disclosure-caret-rest: 0;
|
||
/* Paragraph spacing — vertical gap between prose paragraphs, both inside a
|
||
markdown block and between consecutive prose parts. Single knob; tweak
|
||
freely. */
|
||
--paragraph-gap: 0.7rem;
|
||
--sticky-human-top: 0.23rem;
|
||
--file-tree-row-height: 1.375rem;
|
||
|
||
--composer-width: 48.75rem;
|
||
--composer-control-size: 1.5rem;
|
||
--composer-control-primary-size: 1.625rem;
|
||
--composer-control-gap: 0.25rem;
|
||
--composer-row-gap: 0.25rem;
|
||
--composer-ring-strength: 1;
|
||
--composer-surface-pad-x: 0.5rem;
|
||
--composer-surface-pad-y: 0.3125rem;
|
||
--composer-input-min-height: 1.625rem;
|
||
--composer-input-max-height: 9.375rem;
|
||
--composer-input-inline-min-width: 8rem;
|
||
--composer-fallback-height: 2.75rem;
|
||
--composer-measured-height: calc(0.5rem + var(--composer-shell-pad-block-end) + var(--composer-fallback-height));
|
||
--composer-surface-measured-height: var(--composer-fallback-height);
|
||
--thread-viewport-height: max(
|
||
0rem,
|
||
calc(100% - var(--composer-measured-height) + var(--composer-surface-measured-height))
|
||
);
|
||
--vsq: min(0.5vh, 0.5vw);
|
||
--image-preview-max-width: 34rem;
|
||
--image-preview-height: clamp(16.25rem, calc(var(--vsq) * 100), 26.25rem);
|
||
|
||
--sidebar-width: 14.8125rem;
|
||
--chat-min-width: 28rem;
|
||
--titlebar-control-size: 24px;
|
||
--titlebar-control-height: 24px;
|
||
--titlebar-icon-size: 13.9px;
|
||
--sidebar-content-inline-padding: 1rem;
|
||
|
||
--sidebar: var(--dt-sidebar-bg);
|
||
--sidebar-foreground: var(--dt-foreground);
|
||
--sidebar-primary: var(--dt-primary);
|
||
--sidebar-primary-foreground: var(--dt-primary-foreground);
|
||
--sidebar-accent: var(--ui-control-active-background);
|
||
--sidebar-accent-foreground: var(--dt-accent-foreground);
|
||
--sidebar-border: var(--dt-sidebar-border);
|
||
--sidebar-ring: var(--dt-ring);
|
||
--sidebar-edge-border: color-mix(in srgb, var(--ui-base) 7.5%, transparent);
|
||
--chrome-action-hover: var(--ui-control-hover-background);
|
||
|
||
--midground: var(--dt-midground);
|
||
--background: var(--dt-background);
|
||
--foreground: var(--dt-foreground);
|
||
|
||
--warm-glow: color-mix(in srgb, var(--ui-warm) 32%, color-mix(in srgb, var(--ui-accent) 6%, transparent));
|
||
/* `--noise-opacity-mul` is set per-mode by `applyTheme()`. */
|
||
--noise-opacity-mul: 1;
|
||
--backdrop-invert-mul: 1;
|
||
}
|
||
|
||
:root.dark {
|
||
/* Per-mode mix knobs — overridden inline by `applyTheme()` per skin. */
|
||
--theme-mix-chrome: 74%;
|
||
--theme-mix-card: 38%;
|
||
--theme-mix-elevated: 46%;
|
||
--theme-mix-bubble: 46%;
|
||
--theme-neutral-chrome: #0d0d0e;
|
||
--theme-neutral-sidebar: #0a0a0b;
|
||
--theme-neutral-card: #161618;
|
||
|
||
/* Dark-only accent palette overrides. */
|
||
--ui-red: #e75e78;
|
||
--ui-green: #55a583;
|
||
--ui-cyan: #6f9ba6;
|
||
--ui-diff-add-foreground: color-mix(in srgb, var(--ui-green) 62%, #fff);
|
||
--ui-diff-remove-foreground: color-mix(in srgb, var(--ui-red) 62%, #fff);
|
||
|
||
--sidebar-edge-border: color-mix(in srgb, var(--ui-base) 12%, transparent);
|
||
--composer-ring-strength: 1.3;
|
||
--backdrop-invert-mul: 0;
|
||
/* Dark mode: a dark card needs a stronger black inset to show the recess. */
|
||
--dt-input-inset: inset 0 1px 1px color-mix(in srgb, #000 38%, transparent);
|
||
--ui-tab-hover-darken: 6%;
|
||
/* Dark needs a lighter resting border than light mode. */
|
||
--dt-input-border: 4%;
|
||
|
||
--ui-inline-code-background: color-mix(in srgb, #ffffff 7%, transparent);
|
||
--ui-inline-code-foreground: color-mix(in srgb, #ffffff 88%, transparent);
|
||
--ui-selection-background: color-mix(in srgb, #ffd24a 38%, transparent);
|
||
/* A dark card sits ABOVE the chrome (#161618 over #0d0d0e), so an inline
|
||
widget wearing the raw card fill reads as a lit panel. Take it down a
|
||
touch — still clearly a surface, no longer the brightest thing in the
|
||
transcript. Derived from the card so every skin follows its own seed. */
|
||
--ui-widget-surface-background: color-mix(in srgb, var(--ui-bg-editor) 88%, #000);
|
||
}
|
||
|
||
* {
|
||
box-sizing: border-box;
|
||
border-color: var(--dt-border);
|
||
}
|
||
|
||
html,
|
||
body,
|
||
#root {
|
||
height: 100%;
|
||
/* App shell, not a document: the window itself never scrolls on either axis
|
||
(panes own their own scroll). Belt to the auto-scroll axis-lock in the
|
||
sidebar reorder DnD — nothing can drag the whole shell sideways. */
|
||
overflow: hidden;
|
||
}
|
||
|
||
html {
|
||
font-size: var(--dt-base-size, 0.875rem);
|
||
}
|
||
|
||
body {
|
||
margin: 0;
|
||
background: var(--ui-chat-surface-background);
|
||
color: var(--dt-foreground);
|
||
font-family: var(--dt-font-sans);
|
||
font-size: 0.8125rem;
|
||
line-height: var(--dt-line-height, 1.55);
|
||
letter-spacing: var(--dt-letter-spacing, 0);
|
||
-webkit-user-select: none;
|
||
user-select: none;
|
||
-webkit-font-smoothing: antialiased;
|
||
}
|
||
|
||
button,
|
||
textarea {
|
||
font: inherit;
|
||
}
|
||
|
||
:where(
|
||
a,
|
||
.underline,
|
||
[class~='hover:underline'],
|
||
[class~='focus:underline'],
|
||
[class~='focus-visible:underline'],
|
||
[class~='group-hover:underline'],
|
||
[class~='peer-hover:underline']
|
||
) {
|
||
text-decoration-color: color-mix(in srgb, currentColor 20%, transparent);
|
||
text-underline-offset: 0.25rem;
|
||
}
|
||
|
||
*::selection {
|
||
background: var(--ui-selection-background);
|
||
color: inherit;
|
||
}
|
||
}
|
||
|
||
.dither {
|
||
background: repeating-conic-gradient(currentColor 0% 25%, transparent 0% 50%) 0 0 / 0.125rem 0.125rem;
|
||
}
|
||
|
||
/* ─────────────────────────────────────────────────────────────────────────
|
||
INLINE REFERENCES — the one system for anything that points at something
|
||
from inside a run of text.
|
||
|
||
A pasted link, an `@file:` the user picked, a `/skill`, a `@session:` the
|
||
agent wrote: all the same species. They render the same way in the composer
|
||
and in a sent message, because they're the same thing before and after send.
|
||
|
||
A reference is TEXT, not a badge — colour and an optional icon, no fill, no
|
||
padding, no border. A pill turns every mention into a widget the eye has to
|
||
step over, and mid-sentence that's most of the sentence.
|
||
|
||
Usage: `class="ref"` plus `data-ref="<kind>"` for the accent. Kinds live in
|
||
`components/assistant-ui/reference-kinds.ts` (icon + label); their colour
|
||
lives here, so a theme restyles every reference at once and TS never ships a
|
||
hex. No `data-ref` = an ordinary link, which keeps the primary link colour.
|
||
───────────────────────────────────────────────────────────────────────── */
|
||
/* Kind → accent. Keyed on the attribute ALONE so anything can adopt a
|
||
reference's colour without also taking its inline-text layout (a completion
|
||
row's icon column wants the hue, not the margin). Grouped by what a
|
||
reference DOES, so the palette reads as meaning rather than decoration:
|
||
· things you point at (paths) → neutral, they're the common case
|
||
· things you fetch (links, media) → secondary
|
||
· things that act (commands, tools) → accent
|
||
· things that change code (skills, git)→ warm */
|
||
[data-ref] {
|
||
--ref-color: var(--dt-primary);
|
||
}
|
||
|
||
[data-ref='file'],
|
||
[data-ref='folder'],
|
||
[data-ref='line'],
|
||
[data-ref='terminal'] {
|
||
--ref-color: var(--ui-text-secondary);
|
||
}
|
||
|
||
[data-ref='url'],
|
||
[data-ref='image'],
|
||
[data-ref='session'],
|
||
[data-ref='theme'] {
|
||
--ref-color: color-mix(in srgb, var(--ui-accent-secondary) 82%, var(--foreground));
|
||
}
|
||
|
||
[data-ref='command'],
|
||
[data-ref='tool'] {
|
||
--ref-color: color-mix(in srgb, var(--ui-accent) 82%, var(--foreground));
|
||
}
|
||
|
||
[data-ref='skill'],
|
||
[data-ref='git'],
|
||
[data-ref='diff'],
|
||
[data-ref='staged'] {
|
||
--ref-color: color-mix(in srgb, var(--ui-warm) 82%, var(--foreground));
|
||
}
|
||
|
||
.ref {
|
||
--ref-color: var(--dt-primary);
|
||
|
||
/* Prose links inherit the surrounding text's weight. `@tailwindcss/typography`
|
||
sets `prose a { font-weight: 500 }`, which outranks a utility class on the
|
||
anchor — so the override belongs here, on the shared class. */
|
||
font-weight: inherit;
|
||
color: var(--ref-color);
|
||
/* Explicit: the base layer underlines every `a` (see the :where(a, …) reset). */
|
||
text-decoration: none;
|
||
/* A wrapped reference breaks per line fragment, not as one ragged box. */
|
||
-webkit-box-decoration-break: clone;
|
||
box-decoration-break: clone;
|
||
}
|
||
|
||
/* Affordance without chrome: only the ones you can actually activate respond,
|
||
and they do it with an underline rather than a background. */
|
||
:where(a, button).ref:hover {
|
||
text-decoration: underline;
|
||
text-underline-offset: 0.15em;
|
||
}
|
||
|
||
/* The leading glyph. Sized in `em` so it tracks the text at any scale, and
|
||
spaced with `margin` — not flex `gap` — so `.ref` stays an inline box whose
|
||
label can wrap mid-word (a long URL has to break across lines like the prose
|
||
around it, which a flex container would prevent).
|
||
|
||
The margin is unconditional. A `:not(:only-child)` guard looks right and is
|
||
silently wrong: CSS counts ELEMENT siblings, so an icon followed by a bare
|
||
text-node label still matches `:only-child` — which is exactly how every
|
||
chip lost its spacer. Icon-only users take the hue via `[data-ref]` instead
|
||
of this class. */
|
||
.ref > :where(svg, i.codicon) {
|
||
margin-inline-end: 0.25em;
|
||
opacity: 0.8;
|
||
}
|
||
|
||
.ref > svg {
|
||
display: inline-block;
|
||
width: 0.875em;
|
||
height: 0.875em;
|
||
vertical-align: -0.1em;
|
||
}
|
||
|
||
.ref > i.codicon {
|
||
font-size: 0.875em;
|
||
}
|
||
|
||
/* Hover-reveal suppression — the shared, declarative escape hatch.
|
||
A collapsed pane slides in when the pointer dwells on its thin edge trigger.
|
||
Controls that sit over that edge gutter would drag the panel in by accident.
|
||
Mark any such region and, while it's hovered, the matching edge trigger(s) go
|
||
pointer-transparent. Auto-resets on mouse-out, no JS.
|
||
|
||
- data-suppress-pane-reveal → kills BOTH edges (use for small regions
|
||
like the thread timeline).
|
||
- data-suppress-pane-reveal-side → kills only the hovered region's OWN side
|
||
(side read from its [data-pane-side]
|
||
pane ancestor), so the opposite sidebar
|
||
stays summonable while you work a pane. */
|
||
[data-pane-shell]:has([data-suppress-pane-reveal]:hover) [data-pane-reveal-trigger] {
|
||
pointer-events: none;
|
||
}
|
||
|
||
[data-pane-shell]:has([data-pane-side='left'] [data-suppress-pane-reveal-side]:hover)
|
||
[data-pane-side='left']
|
||
[data-pane-reveal-trigger],
|
||
[data-pane-shell]:has([data-pane-side='right'] [data-suppress-pane-reveal-side]:hover)
|
||
[data-pane-side='right']
|
||
[data-pane-reveal-trigger] {
|
||
pointer-events: none;
|
||
}
|
||
|
||
/* A pane's self-naming label ("REVIEW" atop the review pane) is redundant
|
||
when its zone header is showing a tab with the same name — hide it there.
|
||
Headerless zones (and the pane used outside the tree) keep the label. */
|
||
[data-tree-group][data-zone-header] [data-pane-self-label] {
|
||
display: none;
|
||
}
|
||
|
||
:root:not([style*='--theme-asset-bg:']) .theme-default-filler {
|
||
display: block;
|
||
}
|
||
|
||
:root[style*='--theme-asset-bg:'] .theme-default-filler {
|
||
display: none;
|
||
}
|
||
|
||
/* ─────────────────────────────────────────────────────────────────────────
|
||
Composer placeholder.
|
||
|
||
The prompt is a PAINTED HINT, never content. Rendering `::before` inline —
|
||
the obvious way — puts a box in the contenteditable's text flow, and the
|
||
caret sits after it: click an empty composer and the insertion point lands
|
||
at the right edge of the hint rather than the left edge of the field, and a
|
||
hint that wraps makes the empty composer two lines tall. Taking it out of
|
||
flow is what makes it a hint: the caret keeps the field's real origin, the
|
||
box keeps its real height, and no pointer or selection can reach the text.
|
||
|
||
`data-empty` carries emptiness because `:empty` can't: a cleared editor
|
||
keeps a scaffolding <br>. See markEditorEmptiness in rich-editor.ts.
|
||
───────────────────────────────────────────────────────────────────────── */
|
||
[data-slot='composer-rich-input'] {
|
||
position: relative;
|
||
}
|
||
|
||
[data-slot='composer-rich-input']:is(:empty, [data-empty])::before {
|
||
content: attr(data-placeholder);
|
||
position: absolute;
|
||
inset: 0;
|
||
padding: inherit;
|
||
overflow: hidden;
|
||
color: color-mix(in srgb, var(--muted-foreground) 60%, transparent);
|
||
/* One line, clipped — the hint states the field's purpose; it never reflows
|
||
the box it sits in. A narrow composer or a long locale string would
|
||
otherwise wrap and double the empty composer's height. */
|
||
white-space: nowrap;
|
||
text-overflow: ellipsis;
|
||
pointer-events: none;
|
||
user-select: none;
|
||
}
|
||
|
||
/* Primitive-level pointer cursor for every interactive control (buttons,
|
||
selects, menu items, switches, tabs, summaries). Keeps individual
|
||
components from having to hardcode `cursor-pointer`; explicit cursor
|
||
utilities (cursor-grab, cursor-default, disabled:cursor-*) still win since
|
||
they live in the utilities layer. */
|
||
@layer base {
|
||
button:not(:disabled):not([aria-disabled='true']),
|
||
summary,
|
||
[role='button']:not([aria-disabled='true']),
|
||
[role='menuitem']:not([aria-disabled='true']),
|
||
[role='menuitemradio']:not([aria-disabled='true']),
|
||
[role='menuitemcheckbox']:not([aria-disabled='true']),
|
||
[role='option']:not([aria-disabled='true']),
|
||
[role='switch']:not([aria-disabled='true']),
|
||
[role='tab']:not([aria-disabled='true']) {
|
||
cursor: pointer;
|
||
}
|
||
}
|
||
|
||
@layer utilities {
|
||
[class*='rounded-full'],
|
||
[class*=':rounded-full'] {
|
||
border-radius: calc(var(--radius-scalar) * 9999rem);
|
||
}
|
||
}
|
||
|
||
/* Interactive list-row hover — the sessions-list interaction, shared by every
|
||
row list (sessions, capabilities, messaging, file trees, timeline): the
|
||
highlight lands instantly on hover-in and fades out over 100ms on leave. */
|
||
@utility row-hover {
|
||
cursor: pointer;
|
||
transition:
|
||
color 100ms ease-out,
|
||
background-color 100ms ease-out;
|
||
|
||
&:hover {
|
||
background-color: var(--ui-row-hover-background);
|
||
transition: none;
|
||
}
|
||
}
|
||
|
||
@keyframes arc-border {
|
||
0% {
|
||
background-position: 15% 15%;
|
||
}
|
||
100% {
|
||
background-position: 75% 75%;
|
||
}
|
||
}
|
||
|
||
.arc-border {
|
||
--arc-c0: color-mix(in srgb, var(--dt-foreground) 0%, transparent);
|
||
--arc-c1: var(--dt-midground);
|
||
--arc-c2: var(--dt-background);
|
||
--arc-angle: 160deg;
|
||
--arc-width: 0.078125rem;
|
||
/* How far the ring stands off its host, per side. */
|
||
--arc-standoff: 0.125rem;
|
||
/* An outset ring is only concentric when its radius grows by the standoff
|
||
(r = r_host + gap); `border-radius: inherit` leaves the corner pinched.
|
||
Defaults to the shared `rounded-md`; a host on another radius sets it. */
|
||
--arc-radius: var(--radius-md);
|
||
--arc-duration: 2.23s;
|
||
|
||
pointer-events: none;
|
||
position: absolute;
|
||
overflow: hidden;
|
||
border-radius: calc(var(--arc-radius) + var(--arc-standoff));
|
||
inset: calc(var(--arc-standoff) * -1);
|
||
padding: var(--arc-width);
|
||
mask:
|
||
linear-gradient(#000 0 0) content-box,
|
||
linear-gradient(#000 0 0);
|
||
-webkit-mask-composite: xor;
|
||
mask-composite: exclude;
|
||
}
|
||
|
||
:root.dark .arc-border {
|
||
--arc-c1: var(--dt-foreground);
|
||
}
|
||
|
||
/* Landed `memory` tool row — gold → purple on the brain glyph + title.
|
||
Pair with `text-transparent` so the scaffold label color can't win. */
|
||
.tool-memory-legendary-title {
|
||
background-image: linear-gradient(
|
||
105deg,
|
||
var(--tool-memory-legendary-from) 0%,
|
||
var(--tool-memory-legendary-mid) 48%,
|
||
var(--tool-memory-legendary-to) 100%
|
||
);
|
||
-webkit-background-clip: text;
|
||
background-clip: text;
|
||
color: transparent !important;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.tool-memory-legendary-glyph {
|
||
filter: drop-shadow(0 0 0.28rem var(--tool-memory-legendary-glow));
|
||
}
|
||
|
||
.tool-memory-legendary-meta {
|
||
color: var(--tool-memory-legendary-meta);
|
||
}
|
||
|
||
/* Command-palette deep-link: briefly flash the targeted settings row. */
|
||
@keyframes setting-field-flash {
|
||
0% {
|
||
background-color: color-mix(in srgb, var(--dt-primary, #f59e0b) 22%, transparent);
|
||
}
|
||
100% {
|
||
background-color: transparent;
|
||
}
|
||
}
|
||
|
||
.setting-field-highlight {
|
||
animation: setting-field-flash 1.6s ease-out;
|
||
}
|
||
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.setting-field-highlight {
|
||
animation: none;
|
||
}
|
||
}
|
||
|
||
.arc-border::before {
|
||
content: '';
|
||
position: absolute;
|
||
inset: 0;
|
||
border-radius: inherit;
|
||
background: linear-gradient(
|
||
var(--arc-angle),
|
||
transparent 0%,
|
||
var(--arc-c0) 15%,
|
||
var(--arc-c1) 20%,
|
||
var(--arc-c2) 25%,
|
||
transparent 35%,
|
||
transparent 40%,
|
||
var(--arc-c0) 55%,
|
||
var(--arc-c1) 60%,
|
||
var(--arc-c2) 65%,
|
||
transparent 75%,
|
||
transparent 80%,
|
||
var(--arc-c0) 95%,
|
||
var(--arc-c1) 100%
|
||
);
|
||
background-size: 300% 300%;
|
||
animation: arc-border var(--arc-duration) linear infinite;
|
||
}
|
||
|
||
/* Flip the arc's travel direction (e.g. the Nous Portal hero row). */
|
||
.arc-border.arc-reverse::before {
|
||
animation-direction: reverse;
|
||
}
|
||
|
||
/* Nous Portal hero: slower, blue → orange arc. */
|
||
.arc-border.arc-nous,
|
||
:root.dark .arc-border.arc-nous {
|
||
--arc-c1: #4f8cff;
|
||
--arc-c2: #ff8c42;
|
||
--arc-duration: 3.27s;
|
||
}
|
||
|
||
/* Sidebar session row — a thin, clipped, single-line host, so it differs twice
|
||
from the default ring:
|
||
|
||
1. Flush, not outset. The sidebar scroller is `overflow-x-hidden` and rows sit
|
||
`gap-px` apart, so an outset ring loses its left/right runs to the clip.
|
||
|
||
2. A visible tail. `--arc-c2` defaults to `--dt-background` — invisible
|
||
against the sidebar. With c0 a transparent sentinel and c2 invisible, only
|
||
the bright c1 stop survives each pass, and the gradient has three of them:
|
||
three moving dots instead of an arc. Tying c2 to c1 gives each spike a
|
||
falloff. (`arc-nous` already overrides c2, so the hero is unaffected.) */
|
||
.arc-border.arc-row,
|
||
:root.dark .arc-border.arc-row {
|
||
--arc-standoff: 0rem;
|
||
--arc-c2: color-mix(in srgb, var(--arc-c1) 45%, transparent);
|
||
}
|
||
|
||
/* HUD composer — same travelling arc as a working sidebar row, 2px and pill-
|
||
rounded to match the bar. */
|
||
.arc-border.arc-composer,
|
||
:root.dark .arc-border.arc-composer {
|
||
--arc-width: 2px;
|
||
--arc-standoff: 0rem;
|
||
--arc-radius: 0.75rem;
|
||
--arc-c2: color-mix(in srgb, var(--arc-c1) 45%, transparent);
|
||
z-index: 6;
|
||
}
|
||
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.arc-border::before {
|
||
animation: none;
|
||
}
|
||
}
|
||
|
||
/* No focus rings, anywhere. Kills the native outline plus Tailwind's
|
||
`focus-visible:ring-*` (a box-shadow driven by --tw-ring-*). Unlayered so it
|
||
beats the utilities layer without !important on the outline. The composer /
|
||
.desktop-input-chrome focus glow is untouched — those set `box-shadow`
|
||
directly rather than through the ring vars. */
|
||
*:focus,
|
||
*:focus-visible {
|
||
outline: none;
|
||
}
|
||
|
||
*:focus-visible {
|
||
--tw-ring-shadow: 0 0 #0000 !important;
|
||
--tw-ring-offset-shadow: 0 0 #0000 !important;
|
||
}
|
||
|
||
button {
|
||
-webkit-app-region: no-drag;
|
||
}
|
||
|
||
/* Button variant styling lives entirely in the cva in components/ui/button.tsx
|
||
(the single source of truth). Don't re-add [data-slot='button'] rules here —
|
||
attribute selectors out-specify the Tailwind utilities and silently override
|
||
the variants. */
|
||
|
||
[data-slot='dropdown-menu-content'],
|
||
[data-slot='select-content'],
|
||
[data-slot='dialog-content'],
|
||
[data-slot='thread-timeline-popover'] {
|
||
border-color: var(--ui-stroke-secondary);
|
||
background: color-mix(in srgb, var(--ui-bg-elevated) 96%, transparent);
|
||
box-shadow: var(--shadow-md);
|
||
backdrop-filter: blur(0.75rem) saturate(1.08);
|
||
-webkit-backdrop-filter: blur(0.75rem) saturate(1.08);
|
||
}
|
||
|
||
[data-slot='dropdown-menu-item']:focus,
|
||
[data-slot='dropdown-menu-checkbox-item']:focus,
|
||
[data-slot='dropdown-menu-radio-item']:focus {
|
||
background: var(--ui-bg-tertiary);
|
||
color: var(--ui-text-primary);
|
||
}
|
||
|
||
input,
|
||
textarea,
|
||
[contenteditable]:not([contenteditable='false']),
|
||
[data-slot='aui_user-message-root'],
|
||
[data-slot='aui_assistant-message-content'],
|
||
[data-slot='aui_system-message-root'],
|
||
[data-selectable-text='true'],
|
||
[data-selectable-text='true'] * {
|
||
-webkit-user-select: text;
|
||
user-select: text;
|
||
}
|
||
|
||
button,
|
||
[role='button'] {
|
||
-webkit-user-select: none;
|
||
user-select: none;
|
||
}
|
||
|
||
/* User bubbles are <button>s (click → edit), so the rule above would kill
|
||
drag-to-select and leave ⌘C with nothing to grab. Message text stays
|
||
selectable; chrome controls outside the bubble keep user-select: none. */
|
||
[data-slot='aui_user-message-root'] button,
|
||
[data-slot='aui_user-message-root'] [role='button'] {
|
||
-webkit-user-select: text;
|
||
user-select: text;
|
||
}
|
||
|
||
img,
|
||
picture,
|
||
video,
|
||
canvas,
|
||
svg {
|
||
-webkit-user-select: none;
|
||
user-select: none;
|
||
}
|
||
|
||
img,
|
||
video,
|
||
canvas {
|
||
-webkit-user-drag: none;
|
||
}
|
||
|
||
/* Tabs render 2-wide on every HTML code surface — the source preview, inline
|
||
diffs, markdown code blocks, tool output — so indentation matches the editor
|
||
and the terminal instead of the browser default (8). */
|
||
pre,
|
||
code {
|
||
tab-size: 2;
|
||
-moz-tab-size: 2;
|
||
}
|
||
|
||
/* Arc-style multicolor action surface (static, not animated). Reusable on any
|
||
Button via className. Unlayered so it beats Tailwind's bg- and text- variant
|
||
utilities. */
|
||
.btn-arc {
|
||
background-image: linear-gradient(110deg, #5b6cff 0%, #8b5cf6 28%, #d946ef 58%, #fb7185 82%, #fb923c 100%);
|
||
color: #fff;
|
||
border-color: transparent;
|
||
}
|
||
|
||
.btn-arc:hover:not(:disabled) {
|
||
filter: brightness(1.08) saturate(1.06);
|
||
}
|
||
|
||
/* Shared input chrome — mirrors composer hover/focus FX. Unlayered to beat Tailwind utilities. */
|
||
/* Border strength is driven by the single --dt-input-border alpha knob (× the
|
||
theme's tuned ring color); hover doubles it and focus triples it. Only the
|
||
border-color animates — animating the translucent background over the window's
|
||
backdrop-blur flickers (badly on textareas), so the bg fill snaps instead.
|
||
(:focus is declared after :hover so a focused+hovered field shows focus.) */
|
||
.desktop-input-chrome {
|
||
background: color-mix(in srgb, var(--dt-card) var(--dt-input-bg), transparent);
|
||
border-color: color-mix(in srgb, var(--dt-composer-ring) var(--dt-input-border), transparent);
|
||
box-shadow: var(--dt-input-inset);
|
||
transition: border-color 200ms ease-out;
|
||
}
|
||
|
||
.desktop-input-chrome:hover {
|
||
border-color: color-mix(in srgb, var(--dt-composer-ring) calc(var(--dt-input-border) * 2), transparent);
|
||
}
|
||
|
||
/* `[data-state='open']` keeps the trigger looking focused while its dropdown is
|
||
open — Radix moves focus into the list, so the trigger itself loses :focus. */
|
||
.desktop-input-chrome:focus,
|
||
.desktop-input-chrome:focus-within,
|
||
.desktop-input-chrome[data-state='open'] {
|
||
border-color: var(--dt-composer-ring);
|
||
box-shadow: none;
|
||
outline: none;
|
||
}
|
||
|
||
.desktop-input-chrome[aria-invalid='true'] {
|
||
border-color: var(--dt-destructive);
|
||
}
|
||
|
||
/* Drag in progress (sash resize, console resize, tab drag): webview/iframe
|
||
guests hit-test in their own process and swallow the gesture the moment the
|
||
pointer crosses them — see lib/guest-pointer-guard.ts. */
|
||
body.guest-pointer-lock :is(webview, iframe) {
|
||
pointer-events: none;
|
||
}
|
||
|
||
@layer components {
|
||
/* Chromium 121+ (Electron) prefers standard scrollbar-* over
|
||
::-webkit-scrollbar and ignores the latter when both are set — platform
|
||
thin on mac still looks chunky. Use webkit for real thin thumbs; gate the
|
||
standard props so only non-webkit engines (Firefox) take that path.
|
||
https://developer.chrome.com/docs/css-ui/scrollbar-styling
|
||
https://syntackle.com/blog/changes-to-scrollbar-styling-in-chrome-121/
|
||
|
||
Any author rule on ::-webkit-scrollbar switches that scroller from
|
||
platform overlay (fade in on scroll, zero gutter) to classic always-on
|
||
gutters. `.scrollbar-overlay` opts a surface out of the themed rules so
|
||
Electron keeps native overlay — used by conversation code blocks where a
|
||
permanent 4px track eats the card and always draws a bar. */
|
||
|
||
.scrollbar-dt::-webkit-scrollbar,
|
||
.scrollbar-dt *:not(.scrollbar-overlay)::-webkit-scrollbar {
|
||
width: 0.25rem;
|
||
height: 0.25rem;
|
||
}
|
||
|
||
.scrollbar-dt::-webkit-scrollbar-track,
|
||
.scrollbar-dt::-webkit-scrollbar-corner,
|
||
.scrollbar-dt *:not(.scrollbar-overlay)::-webkit-scrollbar-track,
|
||
.scrollbar-dt *:not(.scrollbar-overlay)::-webkit-scrollbar-corner {
|
||
background: transparent;
|
||
}
|
||
|
||
.scrollbar-dt::-webkit-scrollbar-thumb,
|
||
.scrollbar-dt *:not(.scrollbar-overlay)::-webkit-scrollbar-thumb {
|
||
background: color-mix(in srgb, var(--dt-midground) 18%, transparent);
|
||
border-radius: 9999rem;
|
||
background-clip: padding-box;
|
||
}
|
||
|
||
.scrollbar-dt::-webkit-scrollbar-thumb:hover,
|
||
.scrollbar-dt *:not(.scrollbar-overlay)::-webkit-scrollbar-thumb:hover {
|
||
background: color-mix(in srgb, var(--dt-midground) 40%, transparent);
|
||
background-clip: padding-box;
|
||
}
|
||
|
||
.scrollbar-dt::-webkit-scrollbar-button,
|
||
.scrollbar-dt *:not(.scrollbar-overlay)::-webkit-scrollbar-button {
|
||
display: none;
|
||
}
|
||
|
||
/* Hover-only bars. A thumb sitting on a list you aren't touching is chrome,
|
||
not information — the gutter is reserved either way, so fading it in and
|
||
out costs no reflow. Opt-in per surface: the sidebar wants it, a document
|
||
you're reading down wants its bar where you can see it. */
|
||
.scrollbar-fade::-webkit-scrollbar-thumb,
|
||
.scrollbar-dt .scrollbar-fade::-webkit-scrollbar-thumb {
|
||
background-color: transparent;
|
||
transition: background-color 150ms ease-out;
|
||
}
|
||
|
||
.scrollbar-fade:hover::-webkit-scrollbar-thumb,
|
||
.scrollbar-dt .scrollbar-fade:hover::-webkit-scrollbar-thumb {
|
||
background-color: color-mix(in srgb, var(--dt-midground) 18%, transparent);
|
||
}
|
||
|
||
.scrollbar-fade:hover::-webkit-scrollbar-thumb:hover,
|
||
.scrollbar-dt .scrollbar-fade:hover::-webkit-scrollbar-thumb:hover {
|
||
background-color: color-mix(in srgb, var(--dt-midground) 40%, transparent);
|
||
}
|
||
|
||
/* Hover marquee: a truncated line that scrolls its overflow into view while
|
||
hovered. Opt-in via .hover-marquee + an inner .hover-marquee-inner; the
|
||
JS side measures the overflow on pointerenter and sets --marquee-d (and
|
||
data-marquee) only when the text actually overflows, so short titles
|
||
keep their plain ellipsis and never wiggle. Alternate direction reads
|
||
back to the start; the 300ms delay keeps casual pointer passes still. */
|
||
.hover-marquee[data-marquee='true'] {
|
||
text-overflow: clip;
|
||
}
|
||
|
||
.hover-marquee[data-marquee='true'] > .hover-marquee-inner {
|
||
display: inline-block;
|
||
animation: hover-marquee var(--marquee-t, 3s) linear infinite;
|
||
}
|
||
|
||
/* One direction, Spotify-style: scroll the tail in, hold, snap back, hold.
|
||
The percentages pin the two holds; the travel occupies the middle. */
|
||
@keyframes hover-marquee {
|
||
0%,
|
||
10% {
|
||
transform: translateX(0);
|
||
}
|
||
|
||
75%,
|
||
100% {
|
||
transform: translateX(calc(-1 * var(--marquee-d, 0px)));
|
||
}
|
||
}
|
||
|
||
/* Variant for portaled overlays (Radix DropdownMenu, Popover, etc.) that
|
||
render under document.body, outside the `.scrollbar-dt` scope on
|
||
#root. Same visual treatment, applied directly to the overlay
|
||
container so its (and only its) internal scrollbar is themed. */
|
||
.dt-portal-scrollbar::-webkit-scrollbar {
|
||
width: 0.25rem;
|
||
height: 0.25rem;
|
||
}
|
||
|
||
.dt-portal-scrollbar::-webkit-scrollbar-track,
|
||
.dt-portal-scrollbar::-webkit-scrollbar-corner {
|
||
background: transparent;
|
||
}
|
||
|
||
.dt-portal-scrollbar::-webkit-scrollbar-thumb {
|
||
background: color-mix(in srgb, var(--dt-midground) 28%, transparent);
|
||
border-radius: 9999rem;
|
||
background-clip: padding-box;
|
||
}
|
||
|
||
.dt-portal-scrollbar::-webkit-scrollbar-thumb:hover {
|
||
background: color-mix(in srgb, var(--dt-midground) 50%, transparent);
|
||
background-clip: padding-box;
|
||
}
|
||
|
||
.dt-portal-scrollbar::-webkit-scrollbar-button {
|
||
display: none;
|
||
}
|
||
|
||
@supports not selector(::-webkit-scrollbar) {
|
||
.scrollbar-dt,
|
||
.scrollbar-dt *:not(.scrollbar-overlay) {
|
||
scrollbar-width: thin;
|
||
scrollbar-color: color-mix(in srgb, var(--dt-midground) 18%, transparent) transparent;
|
||
}
|
||
|
||
.dt-portal-scrollbar {
|
||
scrollbar-width: thin;
|
||
scrollbar-color: color-mix(in srgb, var(--dt-midground) 28%, transparent) transparent;
|
||
}
|
||
|
||
/* Firefox has no real overlay mode; thin + transparent track is the closest
|
||
match to the Chromium overlay opt-out above. */
|
||
.scrollbar-overlay {
|
||
scrollbar-width: thin;
|
||
scrollbar-color: color-mix(in srgb, var(--dt-midground) 28%, transparent) transparent;
|
||
}
|
||
|
||
/* No transition — Firefox doesn't animate scrollbar-color. */
|
||
.scrollbar-fade {
|
||
scrollbar-color: transparent transparent;
|
||
}
|
||
|
||
.scrollbar-fade:hover {
|
||
scrollbar-color: color-mix(in srgb, var(--dt-midground) 18%, transparent) transparent;
|
||
}
|
||
}
|
||
}
|
||
|
||
/* Bottom clearance lives on [data-slot='aui_composer-clearance'] —
|
||
virtualized items unmount, so :nth-last-child can't fire reliably. */
|
||
|
||
[data-slot='aui_assistant-message-content'] {
|
||
padding-left: var(--message-text-indent);
|
||
font-size: var(--conversation-text-font-size);
|
||
line-height: 1.5;
|
||
}
|
||
|
||
[data-slot='aui_assistant-message-root'] {
|
||
width: 100%;
|
||
}
|
||
|
||
[data-slot='aui_assistant-message-content'] .aui-md,
|
||
[data-slot='aui_assistant-message-content'] .aui-md :where(p, li, blockquote, table, pre) {
|
||
font-size: inherit;
|
||
}
|
||
|
||
/* Tailwind Typography sets `.prose :where(p) { margin: 1.25em }` (~16px). That
|
||
selector ties our `my-*` utility on specificity and wins on source order, so
|
||
paragraph spacing must be reclaimed here at higher specificity. One tight
|
||
top-margin (bottom zeroed to avoid doubling), first child reset to flush. */
|
||
[data-slot='aui_assistant-message-content'] .aui-md :where(p) {
|
||
margin-block: var(--paragraph-gap) 0;
|
||
}
|
||
|
||
/* Headings are section breaks, so they own a larger top gap than prose — a
|
||
leading `# title` (common in re-entered/delegated turns) no longer butts
|
||
against the block above. Top-owned + bottom snug; the first-child reset
|
||
below still flushes a leading heading. */
|
||
[data-slot='aui_assistant-message-content'] .aui-md :where(h1, h2, h3, h4) {
|
||
margin-block: 1rem 0.25rem;
|
||
}
|
||
|
||
/* First rendered element of a prose block is flush — the block-level gap above
|
||
(tool / paragraph) already provides the separation. Reach one level deep too:
|
||
Streamdown wraps blocks in a `div.space-y-*`, so the real first line is the
|
||
first child's first child. */
|
||
[data-slot='aui_assistant-message-content'] .aui-md > :first-child,
|
||
[data-slot='aui_assistant-message-content'] .aui-md > :first-child > :first-child {
|
||
margin-top: 0;
|
||
}
|
||
|
||
/* Prose, tools, todos, and thinking all share one left edge (the message
|
||
content's --message-text-indent). No extra prose indent — a single gutter
|
||
reads cleaner than a ragged tool-vs-reply column. */
|
||
|
||
/* RTL/bidi chat text (#44150): each block resolves its own base direction from
|
||
its first strong char (UAX#9 plaintext). text-align:start makes that resolved
|
||
direction drive alignment too — load-bearing, since the user bubble pins
|
||
text-left. direction is never set, so chrome/layout/list-indent stay LTR (the
|
||
issue asks not to flip the whole UI). Covers assistant prose, user lines, and
|
||
both composers (main + edit share composer-rich-input). */
|
||
[data-slot='aui_assistant-message-content'] .aui-md :where(p, h1, h2, h3, h4, h5, h6, li, blockquote),
|
||
[data-slot='aui_user-inline-text'],
|
||
[data-slot='composer-rich-input'] {
|
||
unicode-bidi: plaintext;
|
||
text-align: start;
|
||
}
|
||
|
||
/* Inline code/KaTeX don't vote on direction and keep their own order: isolate
|
||
makes bidi treat each as one neutral, so a block that *starts* with `./run.sh`
|
||
then Arabic still resolves RTL, and the command's neutrals (dots/slashes)
|
||
aren't reordered by the surrounding RTL run. */
|
||
[data-slot='aui_assistant-message-content'] .aui-md :where(:not(pre) > code),
|
||
[data-slot='aui_user-inline-code'],
|
||
[data-slot='aui_assistant-message-content'] .aui-md .katex {
|
||
direction: ltr;
|
||
unicode-bidi: isolate;
|
||
}
|
||
|
||
/* Fenced code stays LTR even inside an RTL list item/blockquote — never mirrors. */
|
||
[data-slot='aui_assistant-message-content'] .aui-md [data-slot='code-card'],
|
||
[data-slot='aui_user-fence'] {
|
||
direction: ltr;
|
||
text-align: left;
|
||
}
|
||
|
||
[data-slot='aui_user-message-root'] {
|
||
top: var(--sticky-human-top);
|
||
}
|
||
|
||
[data-slot='aui_user-message-root'],
|
||
[data-slot='aui_edit-composer-root'] {
|
||
--human-msg-line-height: 1.3;
|
||
font-size: var(--conversation-text-font-size);
|
||
}
|
||
|
||
[data-slot='aui_user-inline-text'] {
|
||
line-height: var(--human-msg-line-height);
|
||
}
|
||
|
||
[data-slot='aui_edit-composer-root'] [data-slot='composer-rich-input'] {
|
||
line-height: var(--human-msg-line-height);
|
||
}
|
||
|
||
/* Sticky human bubbles clamp to ~2 lines with a soft bottom fade so a long
|
||
prompt doesn't dominate the viewport. The clamp lifts only in the edit
|
||
composer; expanding on read-only :focus-within ran on mousedown (before the
|
||
swap) and fought stick-to-bottom when parked at the bottom. */
|
||
.sticky-human-clamp {
|
||
cursor: pointer;
|
||
max-height: calc(4 * var(--human-msg-line-height) * var(--conversation-text-font-size) + 0.15rem);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.sticky-human-clamp[data-clamped='true'] {
|
||
-webkit-mask-image: linear-gradient(to bottom, #000 55%, transparent);
|
||
mask-image: linear-gradient(to bottom, #000 55%, transparent);
|
||
}
|
||
|
||
/* Stick-to-bottom owns scrollTop while following. Once escaped, native anchoring
|
||
is safe and keeps sticky human edits from shoving the viewport; data-editing
|
||
enables that path before React swaps in the inline editor. */
|
||
[data-slot='aui_thread-viewport'] {
|
||
overflow-anchor: none;
|
||
}
|
||
|
||
[data-slot='aui_thread-viewport'][data-following='false'],
|
||
[data-slot='aui_thread-viewport'][data-editing='true'] {
|
||
overflow-anchor: auto;
|
||
}
|
||
|
||
[data-slot='aui_thread-content'] {
|
||
max-width: var(--composer-width);
|
||
padding-inline: 1.5rem;
|
||
}
|
||
|
||
[data-slot='aui_intro'] {
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding-bottom: var(--composer-measured-height);
|
||
text-align: center;
|
||
}
|
||
|
||
[data-slot='aui_intro'] > div {
|
||
max-width: min(var(--composer-width), 82vw);
|
||
}
|
||
|
||
[data-slot='aui_intro'] p:last-child {
|
||
max-width: 34rem;
|
||
margin-inline: auto;
|
||
color: var(--ui-text-tertiary);
|
||
font-size: 0.875rem;
|
||
line-height: 1.45;
|
||
}
|
||
|
||
.fit-text {
|
||
--fit-captured-length: initial;
|
||
--fit-support-sentinel: var(--fit-captured-length, 9999px);
|
||
|
||
display: flex;
|
||
container-type: inline-size;
|
||
}
|
||
|
||
.fit-text > [aria-hidden] {
|
||
visibility: hidden;
|
||
}
|
||
|
||
.fit-text > :not([aria-hidden]) {
|
||
flex-grow: 1;
|
||
container-type: inline-size;
|
||
|
||
--fit-captured-length: 100cqi;
|
||
--fit-available-space: var(--fit-captured-length);
|
||
}
|
||
|
||
.fit-text > :not([aria-hidden]) > * {
|
||
--fit-support-sentinel: inherit;
|
||
--fit-captured-length: 100cqi;
|
||
--fit-ratio: tan(atan2(var(--fit-available-space), var(--fit-available-space) - var(--fit-captured-length)));
|
||
|
||
display: block;
|
||
inline-size: var(--fit-available-space);
|
||
font-size: clamp(
|
||
var(--fit-min, 1em),
|
||
1em * var(--fit-ratio),
|
||
var(--fit-max, infinity * 1px) - var(--fit-support-sentinel)
|
||
);
|
||
}
|
||
|
||
@container (inline-size > 0) {
|
||
.fit-text > :not([aria-hidden]) > * {
|
||
white-space: nowrap;
|
||
}
|
||
}
|
||
|
||
@property --fit-captured-length {
|
||
syntax: '<length>';
|
||
initial-value: 0px;
|
||
inherits: true;
|
||
}
|
||
|
||
/* The dock column owns the composer's width; the composer keeps the 5px
|
||
transparent grab margin that the peel-out drag needs. +10px width compensates
|
||
that padding so the visible surface keeps its exact width/position. */
|
||
[data-slot='composer-dock'] {
|
||
width: calc(min(var(--composer-width), calc(100% - 2rem)) + 10px);
|
||
padding-bottom: var(--composer-shell-pad-block-end);
|
||
}
|
||
|
||
[data-slot='composer-root'] {
|
||
width: 100%;
|
||
padding-inline: 5px;
|
||
}
|
||
|
||
/* Popped-out (floating) composer: compact width + an even 5px transparent grab
|
||
platform. The higher-specificity selector resets the base rule's padding-bottom
|
||
so the inset is equal on all four sides (not 5px sides / shell-pad bottom). */
|
||
[data-slot='composer-dock'][data-popped-out] {
|
||
width: var(--composer-popout-width, 24rem);
|
||
max-width: calc(100vw - 1.5rem);
|
||
padding-bottom: 0;
|
||
}
|
||
|
||
[data-slot='composer-root'][data-popped-out] {
|
||
padding: 5px;
|
||
}
|
||
|
||
/* Dock glow intensity scale — dimmer in light mode (the primary glow reads
|
||
much stronger over a light backdrop), full strength in dark mode. */
|
||
:root {
|
||
--dock-glow-scale: 0.55;
|
||
}
|
||
|
||
.dark {
|
||
--dock-glow-scale: 1;
|
||
}
|
||
|
||
/* Drag-region hatch — a diagonal ///// pattern (Photoshop-style) that fades into
|
||
the transparent grab margin on hover (and stays while dragging) to signal the
|
||
composer is draggable. Inherits the root radius so it clips to the corners. */
|
||
[data-slot='composer-drag-region'] {
|
||
/* Hatch frame radius (tuned by hand). */
|
||
border-radius: 0.4rem;
|
||
opacity: 0;
|
||
transition: opacity 150ms ease;
|
||
background-image: repeating-linear-gradient(
|
||
-45deg,
|
||
color-mix(in srgb, var(--ui-text-tertiary) 38%, transparent) 0,
|
||
color-mix(in srgb, var(--ui-text-tertiary) 38%, transparent) 1px,
|
||
transparent 1px,
|
||
transparent 3.5px
|
||
);
|
||
}
|
||
|
||
[data-slot='composer-drag-region']:hover,
|
||
[data-slot='composer-drag-region'][data-dragging] {
|
||
opacity: 0.33;
|
||
}
|
||
|
||
[data-slot='composer-root'] > .pointer-events-none {
|
||
background: linear-gradient(
|
||
to bottom,
|
||
transparent,
|
||
color-mix(in srgb, var(--ui-chat-surface-background) 88%, transparent)
|
||
) !important;
|
||
}
|
||
|
||
[data-slot='composer-surface'] {
|
||
border-color: var(--ui-stroke-secondary) !important;
|
||
}
|
||
|
||
[data-slot='composer-fade'] {
|
||
min-height: 2.375rem;
|
||
}
|
||
|
||
[data-slot='composer-rich-input'] {
|
||
color: var(--ui-text-primary);
|
||
font-size: 0.8125rem;
|
||
}
|
||
|
||
[data-slot='composer-rich-input']:is(:empty, [data-empty])::before {
|
||
color: var(--ui-text-tertiary) !important;
|
||
}
|
||
|
||
/* ── Composer fill — ONE var painted by the surface AND anything docked to it
|
||
(slash·@ popover, `?` help). State ladder sets the var; consumers just paint
|
||
`background: var(--composer-fill)`, so every state matches by construction.
|
||
The :has() rule is last on purpose: while a completion drawer is open it
|
||
beats focus/scroll and forces an OPAQUE fill (both mix endpoints solid) —
|
||
translucent glass can never match across the two layers because they sample
|
||
different backdrops. */
|
||
:root {
|
||
/* Fallback for drawers outside the main composer (e.g. edit-message). */
|
||
--composer-fill: color-mix(in srgb, var(--dt-card) 90%, var(--dt-background));
|
||
}
|
||
|
||
[data-slot='composer-root'] {
|
||
--composer-fill: color-mix(in srgb, var(--dt-card) 72%, transparent);
|
||
}
|
||
|
||
[data-slot='composer-root'][data-thread-scrolled-up] {
|
||
--composer-fill: color-mix(in srgb, var(--dt-card) 48%, transparent);
|
||
}
|
||
|
||
/* Tool/thinking blocks now live at message-text alignment (no leading
|
||
chevron column to escape into), so their headers and bodies share a
|
||
common left edge with the model's text. */
|
||
[data-slot='aui_assistant-message-content'] > [data-slot='tool-block'],
|
||
[data-slot='aui_assistant-message-content'] > [data-slot='aui_thinking-disclosure'] {
|
||
width: 100%;
|
||
max-width: 100%;
|
||
}
|
||
|
||
/* A delegation is a list of short rows — goal, model, timer — not prose, so it
|
||
reads better narrow than stretched across the full reading column. Scoped
|
||
here rather than as a utility on the element: the rule above sets width on
|
||
every tool block and would win over it. */
|
||
[data-slot='aui_assistant-message-content'] [data-slot='tool-block'][data-delegate-card] {
|
||
max-width: 75%;
|
||
}
|
||
|
||
[data-slot='aui_assistant-message-content'] .aui-md [data-streamdown='code-block'] code {
|
||
max-width: none;
|
||
font-family: inherit;
|
||
font-size: inherit;
|
||
padding: 0;
|
||
border-radius: 0;
|
||
background: transparent;
|
||
color: inherit;
|
||
overflow-x: visible;
|
||
overflow-wrap: inherit;
|
||
vertical-align: baseline;
|
||
word-break: inherit;
|
||
white-space: inherit;
|
||
}
|
||
|
||
/* Streamdown's adapter wraps code fences in a `data-streamdown="code-block"`
|
||
container with its own card chrome. We render our own <CodeCard>, so this
|
||
strips the upstream chrome down to a layout-only passthrough. */
|
||
[data-slot='aui_assistant-message-content'] .aui-md [data-streamdown='code-block'] {
|
||
contain: none;
|
||
overflow: visible;
|
||
margin-block: var(--paragraph-gap) 0 !important;
|
||
padding: 0 !important;
|
||
gap: 0 !important;
|
||
border: 0 !important;
|
||
border-radius: 0 !important;
|
||
background: transparent !important;
|
||
color: inherit;
|
||
}
|
||
|
||
[data-slot='aui_assistant-message-content'] .aui-md [data-streamdown='code-block']:has(.aui-prose-fence) {
|
||
margin-block: 0 !important;
|
||
}
|
||
|
||
[data-slot='aui_assistant-message-content'] .aui-md [data-slot='code-card'] {
|
||
/* Streamdown nests blocks, so the container's child-combinator rhythm can't
|
||
reach the card. Carry the paragraph gap on the card itself (top-owned);
|
||
collapses cleanly with the wrapper's margin when one is present, and the
|
||
first-child reset still flushes a leading code block. */
|
||
margin-block: var(--paragraph-gap) 0;
|
||
position: relative;
|
||
transition:
|
||
box-shadow 180ms ease-out,
|
||
background-color 180ms ease-out;
|
||
}
|
||
|
||
[data-slot='aui_assistant-message-content'] .aui-md [data-slot='code-card'][data-streaming='true'] {
|
||
animation:
|
||
code-card-stream-enter 180ms cubic-bezier(0.16, 1, 0.3, 1) both,
|
||
code-card-stream-glow 1.8s ease-in-out 180ms infinite alternate;
|
||
box-shadow:
|
||
0 0 0 0.0625rem color-mix(in srgb, var(--dt-ring) 10%, transparent),
|
||
0 0.625rem 1.75rem color-mix(in srgb, var(--dt-ring) 8%, transparent);
|
||
}
|
||
|
||
[data-slot='aui_assistant-message-content']
|
||
.aui-md
|
||
[data-slot='code-card'][data-streaming='true']
|
||
[data-slot='code-card-body'] {
|
||
-webkit-mask-image: linear-gradient(to bottom, black 0%, black calc(100% - 1.5rem), rgb(0 0 0 / 64%) 100%);
|
||
mask-image: linear-gradient(to bottom, black 0%, black calc(100% - 1.5rem), rgb(0 0 0 / 64%) 100%);
|
||
}
|
||
|
||
[data-slot='aui_assistant-message-content'] .aui-md :not(pre) > code {
|
||
background: var(--ui-inline-code-background);
|
||
color: var(--ui-inline-code-foreground);
|
||
}
|
||
|
||
[data-slot='aui_assistant-message-content'] .aui-md :where(.aui-shiki, .aui-shiki > pre) {
|
||
margin: 0 !important;
|
||
}
|
||
|
||
/* react-shiki nests its own `pre.shiki` inside our `.aui-shiki` Pre, so the
|
||
code card's `[&_pre]` padding lands on both and doubles the inset. The outer
|
||
Pre already carries the card's padding — zero the inner one. */
|
||
[data-slot='aui_assistant-message-content'] .aui-md .aui-shiki .shiki {
|
||
padding: 0 !important;
|
||
}
|
||
|
||
[data-slot='aui_assistant-message-content'] .aui-md .aui-md-table {
|
||
border-spacing: 0;
|
||
}
|
||
|
||
[data-slot='aui_assistant-message-content'] .aui-md .aui-md-table > table,
|
||
[data-slot='aui_assistant-message-content'] .aui-md .aui-md-table thead,
|
||
[data-slot='aui_assistant-message-content'] .aui-md .aui-md-table tbody,
|
||
[data-slot='aui_assistant-message-content'] .aui-md .aui-md-table tr,
|
||
[data-slot='aui_assistant-message-content'] .aui-md .aui-md-table th,
|
||
[data-slot='aui_assistant-message-content'] .aui-md .aui-md-table td {
|
||
margin: 0 !important;
|
||
margin-block-start: 0 !important;
|
||
margin-block-end: 0 !important;
|
||
}
|
||
|
||
[data-slot='aui_assistant-message-content'] .aui-md .aui-md-table thead {
|
||
border-bottom-color: var(--ui-stroke-tertiary) !important;
|
||
}
|
||
|
||
/* Tool / thinking blocks are scaffolding around the model's reply, so we
|
||
keep them transparent and fade them slightly. The reading column (prose)
|
||
stays at full strength; scaffolding lifts back to full opacity on
|
||
hover/focus so it stays legible when the user actually wants to read it. */
|
||
[data-slot='tool-block'],
|
||
[data-slot='aui_thinking-disclosure'] {
|
||
background: transparent !important;
|
||
}
|
||
|
||
/* Fade scaffolding so the prose reading column stays primary. Each surface
|
||
opts in with `data-conversation-scaffold` — thinking header, tool row, run
|
||
summary, live status line — instead of being named by its own selector here.
|
||
Spelling them out individually is how the status line came to sit a shade
|
||
brighter than the rows either side of it.
|
||
|
||
Marked per surface and never on a container: opacity opens a stacking
|
||
context, so nothing inside a faded parent can be more opaque than it, and
|
||
one row (an open diff) could not stay lit while its siblings dimmed. Hence
|
||
the tool group wrapper carries no mark and each row inside it carries its
|
||
own — no two marked elements nest, so the fade never compounds. */
|
||
[data-slot='aui_assistant-message-content'] [data-conversation-scaffold] {
|
||
opacity: 0.67;
|
||
transition: opacity 120ms ease-out;
|
||
}
|
||
|
||
/* Lift on hover or *keyboard* focus only. `:focus-within` also matches the
|
||
focus a mouse click leaves on the disclosure toggle, which kept a row lit
|
||
after you clicked to collapse it; `:has(:focus-visible)` excludes that. */
|
||
[data-slot='aui_assistant-message-content'] [data-conversation-scaffold]:is(:hover, :has(:focus-visible)) {
|
||
opacity: 1;
|
||
}
|
||
|
||
/* Shiki surfaces in the inline file diff + source preview: strip the theme's
|
||
own background/margins, and lay each `.line` on its own grid row so the
|
||
inter-line `\n` text nodes can't double-space full-width rows. Empty lines
|
||
carry a non-breaking space so blank rows keep their height. */
|
||
[data-slot='file-diff-panel'] .shiki,
|
||
[data-slot='file-diff-panel'] .shiki code,
|
||
.preview-source-code .shiki,
|
||
.preview-source-code .shiki code {
|
||
margin: 0;
|
||
background: transparent !important;
|
||
}
|
||
|
||
[data-slot='file-diff-panel'] .shiki code,
|
||
.preview-source-code .shiki code {
|
||
display: grid;
|
||
}
|
||
|
||
[data-slot='file-diff-panel'] .shiki code .line:empty::before,
|
||
.preview-source-code .shiki code .line:empty::before {
|
||
content: '\00a0';
|
||
}
|
||
|
||
/* Inline diff rows keep their utility-class padding; just floor the height. */
|
||
[data-slot='file-diff-panel'] .shiki code .line {
|
||
min-height: 1.25rem;
|
||
white-space: pre;
|
||
}
|
||
|
||
/* Source rows are a fixed editor-height box so source⇄diff toggling never
|
||
shifts and the gutter stays aligned. */
|
||
.preview-source-code .shiki code {
|
||
min-width: max-content;
|
||
}
|
||
|
||
.preview-source-code .shiki code .line {
|
||
display: block;
|
||
height: 1.25rem;
|
||
padding: 0 0.625rem;
|
||
line-height: 1.25rem;
|
||
white-space: pre;
|
||
}
|
||
|
||
/* The github-dark token palette reads candy-bright at our small code size.
|
||
`github-dark-dimmed` only dims the *background* (which we strip), so soften
|
||
the token *foregrounds* directly — a small saturation + brightness pullback,
|
||
hues preserved — for both code blocks and inline diffs. Dark mode only. */
|
||
.dark .shiki {
|
||
filter: saturate(0.82) brightness(0.92);
|
||
}
|
||
|
||
/* File edits (write_file / edit_file / patch) are the deliverable, not
|
||
scaffolding — the diff is what the user reviews, like a PR. An *expanded*
|
||
edit stays at full strength; collapsed it fades like any other row. The
|
||
`data-file-edit` marker sits on the same row element and is only present
|
||
while the row is open. */
|
||
[data-slot='aui_assistant-message-content'] [data-slot='tool-block'][data-tool-row][data-file-edit] {
|
||
opacity: 1;
|
||
}
|
||
|
||
/* Conversation block rhythm. assistant-ui renders each range as a direct child
|
||
of the message content with no per-part wrapper, so adjacency rules cover
|
||
every pairing — first block needs no reset, nested tool rows are untouched.
|
||
|
||
One gap between top-level blocks, whatever they are. Spelling out which
|
||
*pairs* qualified meant the live status line (not tool, thinking or prose)
|
||
fell through every branch and carried its own half-size margin instead. */
|
||
[data-slot='aui_assistant-message-content']
|
||
> :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure'], [data-slot='aui_stream-stall'], .aui-md)
|
||
+ :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure'], [data-slot='aui_stream-stall'], .aui-md) {
|
||
margin-top: var(--turn-block-gap);
|
||
}
|
||
|
||
/* Except prose ↔ prose: one reading column that happens to arrive as several
|
||
parts, so it keeps the in-block paragraph rhythm. Same specificity as the
|
||
rule above and deliberately after it. */
|
||
[data-slot='aui_assistant-message-content'] > .aui-md + .aui-md {
|
||
margin-top: var(--paragraph-gap);
|
||
}
|
||
|
||
/* And except scaffolding ↔ scaffolding: the full gap is the space between the
|
||
reply and the work around it, so a back-to-back run of thinking headers,
|
||
tool rows and status lines spent it between every line and read as stacked
|
||
cards. Same list as the rule above minus prose — `data-conversation-scaffold`
|
||
is the wrong hook here: it's absent on a multi-call tool GROUP, which is most
|
||
of a real run, and present on rows nested inside one. */
|
||
[data-slot='aui_assistant-message-content']
|
||
> :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure'], [data-slot='aui_stream-stall'])
|
||
+ :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure'], [data-slot='aui_stream-stall']) {
|
||
margin-top: var(--scaffold-block-gap);
|
||
}
|
||
|
||
/* Except a file edit, which is the deliverable rather than another line in the
|
||
run — a diff you read like a PR, so it keeps the full block gap on both
|
||
sides while the scaffolding around it stays tight. `data-file-edit` is only
|
||
on the row while it's open, so a collapsed edit rejoins the run. Margins on
|
||
the block itself, not adjacency pairs: it needs the clearance whatever sits
|
||
next to it, and adjacent margins collapse rather than stacking. */
|
||
[data-slot='aui_assistant-message-content'] > [data-tool-row][data-file-edit] {
|
||
margin-block: var(--turn-block-gap);
|
||
}
|
||
|
||
/* Thinking headers carry a faint resting caret. A run of them is the one place
|
||
the affordance isn't otherwise discoverable — every other disclosure sits in
|
||
a row you're already reaching for. The scaffold fade multiplies this, so the
|
||
painted value lands well under the number here. */
|
||
[data-slot='aui_thinking-disclosure'] {
|
||
--disclosure-caret-rest: 0.4;
|
||
}
|
||
|
||
/* A streaming turn is sealed into several assistant bubbles as it goes
|
||
(`message.interim`) and rehydrates into fewer, so the same two blocks are
|
||
sometimes siblings inside one bubble and sometimes split across two. The
|
||
flex gap between bubbles is the turn gap — half the block gap — which made
|
||
the rhythm visibly tighten and then relax as a turn settled. Top it up so
|
||
the column ticks at one interval either way. */
|
||
[data-slot='aui_turn-pair'] > [data-slot='aui_assistant-message-root'] + [data-slot='aui_assistant-message-root'] {
|
||
margin-top: calc(var(--turn-block-gap) - var(--conversation-turn-gap));
|
||
}
|
||
|
||
/* Unless the blocks either side of that seal are both scaffolding, in which
|
||
case the scaffold gap is the target and the flex gap already overshoots it.
|
||
Topping up to the full block gap here is what made a live run relax between
|
||
rows and then tighten once it rehydrated into one bubble. A bubble carrying
|
||
an open diff is excluded — it keeps the full gap, same as in-flow. */
|
||
[data-slot='aui_turn-pair']
|
||
> [data-slot='aui_assistant-message-root']:has(
|
||
> [data-slot='aui_assistant-message-content']
|
||
> :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure'], [data-slot='aui_stream-stall']):last-child
|
||
):not(:has([data-file-edit]))
|
||
+ [data-slot='aui_assistant-message-root']:has(
|
||
> [data-slot='aui_assistant-message-content']
|
||
> :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure'], [data-slot='aui_stream-stall']):first-child
|
||
):not(:has([data-file-edit])) {
|
||
margin-top: calc(var(--scaffold-block-gap) - var(--conversation-turn-gap));
|
||
}
|
||
|
||
/* Message action bars — flat icon hits with default dim; only the hovered/focused control is full-strength.
|
||
The reaction slot lives OUTSIDE the bar (a landed emoji must not ride the
|
||
bar's hover fade) but is still one of these controls visually. */
|
||
[data-slot='aui_msg-actions'] button,
|
||
button[data-slot='aui_msg-reactions'] {
|
||
border: 0;
|
||
border-radius: 0;
|
||
background: transparent;
|
||
box-shadow: none;
|
||
padding: 0;
|
||
gap: 0;
|
||
height: auto;
|
||
width: auto;
|
||
min-height: 0;
|
||
min-width: 0;
|
||
flex-shrink: 0;
|
||
cursor: pointer;
|
||
color: var(--color-muted-foreground);
|
||
opacity: 0.5;
|
||
}
|
||
|
||
/* A landed reaction is content, not a dimmed affordance — emoji render at
|
||
full strength everywhere, always (color dim would gray them anyway; they're
|
||
glyphs, not icons). */
|
||
button[data-slot='aui_msg-reactions'][data-reacted] {
|
||
opacity: 1;
|
||
color: inherit;
|
||
}
|
||
|
||
/* The EMPTY slot is an affordance and follows its action-bar neighbors
|
||
exactly: hidden until the message is hovered, then the same 0.5 dim, full
|
||
strength under the pointer. Stylesheet-owned because the base rule above
|
||
outweighs Tailwind's opacity utilities (0,1,1 vs 0,1,0). */
|
||
button[data-slot='aui_msg-reactions']:not([data-reacted]) {
|
||
opacity: 0;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.group:hover button[data-slot='aui_msg-reactions']:not([data-reacted]),
|
||
button[data-slot='aui_msg-reactions']:not([data-reacted]):focus-visible,
|
||
button[data-slot='aui_msg-reactions']:not([data-reacted])[data-state='open'] {
|
||
opacity: 0.5;
|
||
pointer-events: auto;
|
||
}
|
||
|
||
.group:hover button[data-slot='aui_msg-reactions']:not([data-reacted]):hover,
|
||
button[data-slot='aui_msg-reactions']:not([data-reacted])[data-state='open'] {
|
||
opacity: 1;
|
||
}
|
||
|
||
/* Emoji in ANY reaction surface (badge under a user bubble, footer slot,
|
||
picker rows) never inherit a translucent treatment from their container. */
|
||
[data-slot='aui_msg-reactions'] .reaction-pop,
|
||
span[data-slot='aui_msg-reactions'] {
|
||
opacity: 1;
|
||
}
|
||
|
||
[data-slot='aui_msg-actions'] button:disabled {
|
||
cursor: default;
|
||
}
|
||
|
||
[data-slot='aui_msg-actions'] button:hover,
|
||
button[data-slot='aui_msg-reactions']:hover {
|
||
background: transparent;
|
||
color: var(--color-foreground);
|
||
opacity: 1;
|
||
}
|
||
|
||
[data-slot='aui_msg-actions'] button:active,
|
||
button[data-slot='aui_msg-reactions']:active {
|
||
background: transparent;
|
||
}
|
||
|
||
[data-slot='aui_msg-actions'] button:focus-visible,
|
||
button[data-slot='aui_msg-reactions']:focus-visible {
|
||
opacity: 1;
|
||
}
|
||
|
||
[data-slot='aui_msg-actions'] button svg,
|
||
button[data-slot='aui_msg-reactions'] svg {
|
||
width: 0.875rem;
|
||
height: 0.875rem;
|
||
}
|
||
|
||
/* Re-declare the clearance calc on every chat surface. `:root` computes it
|
||
once against the ROOT measurement, so scoping only the input would leave
|
||
every thread reading the same substituted value. Redeclaring here makes each
|
||
surface resolve the calc against its own dock height, falling back to the
|
||
root default until this surface publishes its first measurement. See
|
||
surface-vars.ts. */
|
||
[data-chat-surface] {
|
||
--thread-last-message-clearance: calc(var(--composer-measured-height) + 2rem);
|
||
}
|
||
|
||
/* A live tool run, shown as one line. `ToolRunTicker` stacks the run's rows
|
||
into a reel and offsets it by the newest row's index, so each new action
|
||
slides the one before it up and out of a single-line window — the run reads
|
||
as one line ticking over in place rather than a list growing down the page.
|
||
Rows are clipped to a uniform line box so the offset stays exact whatever a
|
||
row contains. */
|
||
.tool-ticker {
|
||
height: var(--conversation-line-height);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.tool-ticker__reel {
|
||
transform: translateY(calc(var(--tool-ticker-index, 0) * var(--conversation-line-height) * -1));
|
||
transition: transform 240ms cubic-bezier(0.22, 1, 0.36, 1);
|
||
}
|
||
|
||
.tool-ticker__row {
|
||
display: flex;
|
||
height: var(--conversation-line-height);
|
||
align-items: center;
|
||
overflow: hidden;
|
||
}
|
||
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.tool-ticker__reel {
|
||
transition: none;
|
||
}
|
||
}
|
||
|
||
@keyframes code-card-stream-enter {
|
||
from {
|
||
opacity: 0.74;
|
||
transform: translateY(0.375rem);
|
||
}
|
||
|
||
to {
|
||
opacity: 1;
|
||
transform: translateY(0);
|
||
}
|
||
}
|
||
|
||
/* Floating "jump to bottom" control (see scroll-to-bottom-button.tsx).
|
||
Directional scale: it contracts toward 1 as it arrives (from 1.1) and keeps
|
||
contracting to 0.9 as it leaves — always shrinking in the direction of
|
||
travel, so the motion reads as a soft settle / recede rather than a pop. The
|
||
X half-offset stays baked into every transform so `left-1/2` centering holds
|
||
through the animation. */
|
||
.thread-jump-button {
|
||
opacity: 0;
|
||
transform: translateX(-50%) translateY(0.3rem) scale(0.9);
|
||
}
|
||
|
||
.thread-jump-button[data-state='in'] {
|
||
animation: thread-jump-in 200ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||
}
|
||
|
||
.thread-jump-button[data-state='out'] {
|
||
animation: thread-jump-out 180ms ease-in forwards;
|
||
}
|
||
|
||
@keyframes thread-jump-in {
|
||
from {
|
||
opacity: 0;
|
||
transform: translateX(-50%) translateY(0.3rem) scale(1.1);
|
||
}
|
||
|
||
to {
|
||
opacity: 1;
|
||
transform: translateX(-50%) translateY(0) scale(1);
|
||
}
|
||
}
|
||
|
||
@keyframes thread-jump-out {
|
||
from {
|
||
opacity: 1;
|
||
transform: translateX(-50%) translateY(0) scale(1);
|
||
}
|
||
|
||
to {
|
||
opacity: 0;
|
||
transform: translateX(-50%) translateY(0.3rem) scale(0.9);
|
||
}
|
||
}
|
||
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.thread-jump-button[data-state='in'],
|
||
.thread-jump-button[data-state='out'] {
|
||
animation: none;
|
||
transition: opacity 120ms linear;
|
||
}
|
||
|
||
.thread-jump-button[data-state='in'] {
|
||
opacity: 1;
|
||
transform: translateX(-50%) translateY(0) scale(1);
|
||
}
|
||
}
|
||
|
||
@keyframes code-card-stream-glow {
|
||
from {
|
||
box-shadow:
|
||
0 0 0 0.0625rem color-mix(in srgb, var(--dt-ring) 6%, transparent),
|
||
0 0.5rem 1.5rem color-mix(in srgb, var(--dt-ring) 5%, transparent);
|
||
}
|
||
|
||
to {
|
||
box-shadow:
|
||
0 0 0 0.0625rem color-mix(in srgb, var(--dt-ring) 12%, transparent),
|
||
0 0.75rem 2rem color-mix(in srgb, var(--dt-ring) 10%, transparent);
|
||
}
|
||
}
|
||
|
||
@media (prefers-reduced-motion: reduce) {
|
||
[data-slot='aui_assistant-message-content'] .aui-md [data-slot='code-card'][data-streaming='true'] {
|
||
animation: none;
|
||
}
|
||
}
|
||
|
||
/* -------------------------------------------------------------------------- */
|
||
/* Pet egg hatch (Cmd-K → Pets → Generate) */
|
||
/* The incubation wobble + reveal flash/pop give the draft→pet step a */
|
||
/* Pokémon-style "egg is hatching" beat instead of a bare spinner. */
|
||
/* -------------------------------------------------------------------------- */
|
||
|
||
.pet-egg {
|
||
position: relative;
|
||
width: 5.5rem;
|
||
height: 7rem;
|
||
border-radius: 50% 50% 50% 50% / 62% 62% 38% 38%;
|
||
background: radial-gradient(
|
||
120% 90% at 32% 26%,
|
||
color-mix(in srgb, var(--ui-accent) 14%, #fff) 0%,
|
||
#f4ecd8 46%,
|
||
#e4d3ad 100%
|
||
);
|
||
box-shadow:
|
||
inset -0.45rem -0.6rem 1.1rem color-mix(in srgb, #000 16%, transparent),
|
||
inset 0.35rem 0.4rem 0.7rem color-mix(in srgb, #fff 70%, transparent),
|
||
0 0.4rem 0.9rem color-mix(in srgb, #000 22%, transparent);
|
||
transform-origin: 50% 88%;
|
||
animation: pet-egg-wobble 2.4s ease-in-out infinite;
|
||
}
|
||
|
||
/* Compact egg (empty-state hero). Children are %-based so they track the size;
|
||
only the rem box-shadow needs scaling down to stay crisp. */
|
||
.pet-egg--sm {
|
||
width: 3.25rem;
|
||
height: 4.1rem;
|
||
box-shadow:
|
||
inset -0.28rem -0.38rem 0.7rem color-mix(in srgb, #000 16%, transparent),
|
||
inset 0.22rem 0.26rem 0.45rem color-mix(in srgb, #fff 70%, transparent),
|
||
0 0.25rem 0.55rem color-mix(in srgb, #000 22%, transparent);
|
||
}
|
||
|
||
.pet-egg__shine {
|
||
position: absolute;
|
||
top: 14%;
|
||
left: 22%;
|
||
width: 28%;
|
||
height: 22%;
|
||
border-radius: 50%;
|
||
background: color-mix(in srgb, #fff 85%, transparent);
|
||
filter: blur(2px);
|
||
opacity: 0.85;
|
||
}
|
||
|
||
.pet-egg__spot {
|
||
position: absolute;
|
||
border-radius: 50%;
|
||
background: color-mix(in srgb, var(--ui-accent) 70%, #b89b63);
|
||
opacity: 0.55;
|
||
}
|
||
|
||
.pet-egg__glow {
|
||
position: absolute;
|
||
inset: -35%;
|
||
border-radius: 50%;
|
||
background: radial-gradient(circle, color-mix(in srgb, var(--ui-accent) 55%, transparent) 0%, transparent 62%);
|
||
animation: pet-egg-glow 2.4s ease-in-out infinite;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.pet-egg-shadow {
|
||
width: 4.5rem;
|
||
height: 0.8rem;
|
||
border-radius: 50%;
|
||
/* Lighter on light backgrounds (~20% less ink); dark mode keeps it grounded. */
|
||
background: radial-gradient(
|
||
circle,
|
||
color-mix(in srgb, #000 var(--pet-egg-shadow-ink, 26%), transparent) 0%,
|
||
transparent 72%
|
||
);
|
||
animation: pet-egg-shadow 2.4s ease-in-out infinite;
|
||
}
|
||
|
||
.dark .pet-egg-shadow {
|
||
--pet-egg-shadow-ink: 32%;
|
||
}
|
||
|
||
/* Contact shadow sized for the compact incubator egg (roughly its footprint). */
|
||
.pet-egg-shadow--sm {
|
||
width: 3rem;
|
||
height: 0.6rem;
|
||
}
|
||
|
||
/* Contact shadow under the revealed pet — mirrors the floating mascot's in-app
|
||
shadow: an ellipse at the feet, ~55% of the sprite width, sitting behind it. */
|
||
.pet-contact-shadow {
|
||
position: absolute;
|
||
bottom: -0.15rem;
|
||
left: 50%;
|
||
width: 55%;
|
||
aspect-ratio: 100 / 28;
|
||
transform: translateX(-50%);
|
||
background: radial-gradient(ellipse at center, color-mix(in srgb, #000 42%, transparent) 0%, transparent 70%);
|
||
pointer-events: none;
|
||
z-index: 0;
|
||
}
|
||
|
||
/* Hatch wiggle for the pixel egg (rocks around its base). */
|
||
.pet-wobble {
|
||
transform-origin: 50% 85%;
|
||
animation: pet-egg-wobble 2.4s ease-in-out infinite;
|
||
}
|
||
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.pet-wobble {
|
||
animation: none;
|
||
}
|
||
}
|
||
|
||
@keyframes pet-egg-wobble {
|
||
0%,
|
||
62%,
|
||
100% {
|
||
transform: rotate(0deg);
|
||
}
|
||
8% {
|
||
transform: rotate(-7deg);
|
||
}
|
||
16% {
|
||
transform: rotate(6deg);
|
||
}
|
||
24% {
|
||
transform: rotate(-5deg);
|
||
}
|
||
32% {
|
||
transform: rotate(4deg);
|
||
}
|
||
40% {
|
||
transform: rotate(0deg);
|
||
}
|
||
/* the "almost out" burst */
|
||
70% {
|
||
transform: rotate(-12deg);
|
||
}
|
||
76% {
|
||
transform: rotate(12deg);
|
||
}
|
||
82% {
|
||
transform: rotate(-9deg);
|
||
}
|
||
88% {
|
||
transform: rotate(7deg);
|
||
}
|
||
94% {
|
||
transform: rotate(-3deg);
|
||
}
|
||
}
|
||
|
||
@keyframes pet-egg-glow {
|
||
0%,
|
||
100% {
|
||
opacity: 0.35;
|
||
transform: scale(0.92);
|
||
}
|
||
70% {
|
||
opacity: 0.4;
|
||
}
|
||
84% {
|
||
opacity: 0.85;
|
||
transform: scale(1.08);
|
||
}
|
||
}
|
||
|
||
@keyframes pet-egg-shadow {
|
||
0%,
|
||
62%,
|
||
100% {
|
||
transform: scaleX(1);
|
||
opacity: 0.6;
|
||
}
|
||
76% {
|
||
transform: scaleX(0.8);
|
||
opacity: 0.45;
|
||
}
|
||
}
|
||
|
||
.pet-reveal {
|
||
animation: pet-reveal-pop 620ms cubic-bezier(0.22, 1.4, 0.4, 1) both;
|
||
}
|
||
|
||
@keyframes pet-reveal-pop {
|
||
0% {
|
||
opacity: 0;
|
||
transform: scale(0.35) translateY(0.4rem);
|
||
}
|
||
60% {
|
||
opacity: 1;
|
||
transform: scale(1.12) translateY(0);
|
||
}
|
||
100% {
|
||
transform: scale(1) translateY(0);
|
||
}
|
||
}
|
||
|
||
@keyframes reaction-pop {
|
||
0% {
|
||
opacity: 0;
|
||
transform: scale(0.4);
|
||
}
|
||
60% {
|
||
opacity: 1;
|
||
transform: scale(1.18);
|
||
}
|
||
100% {
|
||
transform: scale(1);
|
||
}
|
||
}
|
||
|
||
/* Landing a reaction should feel like something — same pop shape as
|
||
pet-reveal-pop, scaled down for an inline glyph. */
|
||
.reaction-pop {
|
||
animation: reaction-pop 260ms cubic-bezier(0.34, 1.56, 0.64, 1) both;
|
||
}
|
||
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.pet-egg,
|
||
.pet-egg__glow,
|
||
.pet-egg-shadow,
|
||
.pet-reveal {
|
||
animation: none;
|
||
}
|
||
.reaction-pop {
|
||
animation: none;
|
||
}
|
||
.pet-reveal {
|
||
opacity: 1;
|
||
transform: none;
|
||
}
|
||
}
|
||
|
||
/* Pet generation progress bar — determinate (hatch rows: done/total) or */
|
||
/* indeterminate (drafts, which return together so a % would just snap). */
|
||
/* Sliding indeterminate block for the `accent` Progress variant (pet hatch
|
||
flow). Color/positioning come from the primitive; this owns the animation. */
|
||
.progress-slide {
|
||
width: 40%;
|
||
animation: progress-slide 1.15s ease-in-out infinite;
|
||
}
|
||
|
||
@keyframes progress-slide {
|
||
0% {
|
||
left: -42%;
|
||
}
|
||
100% {
|
||
left: 100%;
|
||
}
|
||
}
|
||
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.progress-slide {
|
||
animation: none;
|
||
left: 0;
|
||
width: 100%;
|
||
opacity: 0.4;
|
||
}
|
||
}
|
||
|
||
/* ── HUD mode ────────────────────────────────────────────────────────────────
|
||
The chrome-free floating chat (`?win=hud`). Same renderer, same components,
|
||
same transcript as any other window — this block only removes the frame and
|
||
makes the window see-through. It deliberately does NOT restyle how messages
|
||
render: the transcript is the app's transcript, and a HUD that reinvents
|
||
bubbles is a second design to keep in sync. Layout and surface only. */
|
||
|
||
/* The HOST LAYERS are made transparent at mount, not here: index.html's
|
||
pre-paint script sets an opaque themed background on <html> as an INLINE
|
||
style (the anti-white-flash trick), and an inline style beats a stylesheet
|
||
rule. Against that opaque wall every translucent panel below is just glass
|
||
over white — which is exactly how this shipped: a plain opaque slab. The
|
||
pet overlay and quick entry inject a style tag at mount for the same
|
||
reason; HudShell does it too. See `hud-shell.tsx`. */
|
||
|
||
[data-hud-shell] {
|
||
background: transparent;
|
||
/* Arrive decisively and settle; leave gently and get out of the way. Linear
|
||
both directions is what made this feel mechanical. */
|
||
--hud-ease-enter: cubic-bezier(0.16, 1, 0.3, 1);
|
||
--hud-ease-exit: cubic-bezier(0.4, 0, 0.7, 0.2);
|
||
/* The band is narrower than the bar, centred under it, so the bar's corner
|
||
controls sit clear of the sheet's edge instead of on top of it. */
|
||
--hud-band-inset: 0.5rem;
|
||
/* Clear space above the composer for the exit chip. */
|
||
--hud-chip-strip: 1.625rem;
|
||
/* How long the exit chip stays after you stop pointing at the HUD. Long
|
||
enough to cross the gap between the bar and the chip without it
|
||
evaporating, short enough that it isn't furniture. */
|
||
--hud-exit-hold: 600ms;
|
||
}
|
||
|
||
/* Chat surface carries nothing in HUD mode — the visual is the BAND below. */
|
||
[data-hud-shell] [data-chat-surface] {
|
||
background: transparent !important;
|
||
overflow: hidden !important;
|
||
}
|
||
|
||
/* The scrollback — the chat BAND, straight from the nous-viz2d chat (which is
|
||
the reference for this whole surface): a fixed-height smoked strip hugging
|
||
the input, tinted from the theme, with the text simply sitting in it.
|
||
|
||
- viz2d: `bg-black/20` at rest → `bg-black/60` on hover / focus-within /
|
||
pending. Same mechanism here, theme-aware: the tint is the theme's own
|
||
background colour, so it reads correctly on dark and light themes alike
|
||
and text keeps its normal contrast against it.
|
||
- VISIBILITY keeps the WoW fade: shown while a turn is recent/streaming
|
||
(`data-hud-recent`) or the composer is focused, melted away otherwise.
|
||
viz2d's band never hides — ours does, because idle HUD mode is just the
|
||
Spotlight bar.
|
||
- Focus steps the tint to fully solid: typing means reading conditions.
|
||
|
||
Reveal is fast, fade-out is slow — the reveal is an answer to the user, the
|
||
fade is furniture leaving the room. */
|
||
[data-hud-shell] [data-slot='composer-bounds'] {
|
||
position: absolute !important;
|
||
/* THE BAND IS THIS BOX. It sits against the bar and is exactly as tall as the
|
||
transcript is allowed to be — same geometry as the sheet behind it, same
|
||
clock — so the text is clipped away as the panel rolls rather than fading
|
||
in place, and the box that scrolls is the box you can see.
|
||
|
||
It ran the full window for a long time, clipped down to the band for paint
|
||
and hit-testing. That works right up until the band is CAPPED: the scroll
|
||
container was still window-tall, so content shorter than the window never
|
||
overflowed it and never scrolled, while the clip hid everything past the
|
||
cap. A capped band has to be a real box or half the transcript is
|
||
unreachable.
|
||
|
||
No fill of its own — the sheet is its own layer ([data-hud-glass]) so it
|
||
can animate independently of the text. */
|
||
inset: auto var(--hud-band-inset) var(--hud-bar-height, var(--composer-fallback-height))
|
||
var(--hud-band-inset) !important;
|
||
height: var(--hud-band-height, 0px) !important;
|
||
width: auto !important;
|
||
max-width: none !important;
|
||
flex: none !important;
|
||
border: 0 !important;
|
||
/* Rounded away from the bar, square where it meets it. */
|
||
border-radius: 0.75rem 0.75rem 0 0;
|
||
background: transparent !important;
|
||
/* Three opacity states: gone at rest, half strength when a turn is recent or
|
||
you've just let go of the composer (there to glance at, not to demand
|
||
attention), full while you're actually reading it. */
|
||
opacity: 0;
|
||
translate: 0 var(--hud-exit-shift, 2.5rem);
|
||
transition:
|
||
opacity var(--hud-fade) var(--hud-ease-exit),
|
||
translate var(--hud-collapse) var(--hud-ease-exit),
|
||
scale var(--hud-dim) var(--hud-ease-enter),
|
||
filter var(--hud-dim) var(--hud-ease-enter);
|
||
transform-origin: bottom center;
|
||
}
|
||
|
||
/* Shown: recent turn / streaming, or a focused composer.
|
||
|
||
POINTER EVENTS ARE THE GATE: unfocused, the band is a ghost you can't touch
|
||
— clicks in that region fall through to the shell's drag region (so the
|
||
whole upper window is a drag handle at rest) and the band can't eat a
|
||
scroll or a stray click while you're aiming at the app behind it. Focus the
|
||
composer and it becomes a real scrollable surface. This also means hover
|
||
alone can't reveal the band anymore — hover is not engagement. */
|
||
/* The band's sheet, behind the transcript: a tint wearing the same gradient
|
||
mask as the text, so the whole surface ramps out together and the band has no
|
||
top edge at all.
|
||
|
||
No desktop blur under it, and that is a limit rather than an omission. CSS
|
||
backdrop-filter reaches nothing here — a transparent window's backdrop root
|
||
is the document, and the desktop was never in it (verified on the real
|
||
window, not assumed). macOS vibrancy does see the desktop, but WindowServer
|
||
composites it below the web contents after this process has finished drawing,
|
||
so no mask, clip or stacking order can shape it; left on under a fading tint
|
||
it stays a flat slab and the top of the band goes pale exactly where it
|
||
should be disappearing. The way through is NSVisualEffectView.maskImage
|
||
behind a small native addon, which is its own change. */
|
||
[data-hud-shell] [data-hud-glass] {
|
||
position: absolute;
|
||
right: var(--hud-band-inset);
|
||
bottom: var(--hud-bar-height, var(--composer-fallback-height));
|
||
left: var(--hud-band-inset);
|
||
z-index: 0;
|
||
pointer-events: none;
|
||
border-radius: 0.75rem 0.75rem 0 0;
|
||
/* Heavier than the text in front of it, deliberately: with no blur to
|
||
separate the band from what it lies over, the sheet is the only thing
|
||
keeping half-opacity text off someone else's UI. Fade the words, keep the
|
||
paper. */
|
||
background: color-mix(in srgb, var(--dt-card) 80%, transparent);
|
||
/* Slides down behind the bar as it fades. A TRANSFORM, not height: it is
|
||
composited, so it stays smooth where animating height re-lays-out the panel
|
||
every frame and looked coarse — and it leaves the box alone, which matters
|
||
because the one time the transcript's own container was collapsed the
|
||
messages spilled out of a zero-height box. Translate rather than scale: the
|
||
panel keeps its proportions on the way out instead of being squashed.
|
||
|
||
The height below still tracks the transcript; only the offset animates on
|
||
exit, and it finishes ahead of the fade so the panel has left while the last
|
||
of the text is still going. */
|
||
height: var(--hud-band-height, 0px);
|
||
translate: 0 var(--hud-exit-shift, 2.5rem);
|
||
opacity: 0;
|
||
transform-origin: bottom center;
|
||
transition:
|
||
opacity var(--hud-fade) var(--hud-ease-exit),
|
||
translate var(--hud-collapse) var(--hud-ease-exit),
|
||
scale var(--hud-dim) var(--hud-ease-enter),
|
||
filter var(--hud-dim) var(--hud-ease-enter),
|
||
height var(--hud-reveal) var(--hud-ease-enter),
|
||
background-color var(--hud-dim) var(--hud-ease-enter);
|
||
}
|
||
|
||
/* Engaged: as tall as the transcript needs, capped at the window. An empty
|
||
session measures 0, so it stays rolled up. */
|
||
[data-hud-shell][data-hud-recent] [data-hud-glass],
|
||
[data-hud-shell]:has([data-slot='composer-rich-input']:focus) [data-hud-glass] {
|
||
translate: none;
|
||
opacity: 1;
|
||
transition-duration: var(--hud-reveal);
|
||
}
|
||
|
||
/* Engaged means the caret is in the composer, not merely that the window holds
|
||
focus somewhere. Activating a window restores focus to whatever had it last,
|
||
so `:focus-within` counted grabbing the bar to DRAG the HUD as sitting down
|
||
to use it, and the transcript flew open every time it was moved. Hit-testing
|
||
below stays on :focus-within — being permissive about what can be clicked is
|
||
harmless, being permissive about what lights up is not. */
|
||
[data-hud-shell]:has([data-slot='composer-rich-input']:focus) [data-hud-glass] {
|
||
background: color-mix(in srgb, var(--dt-card) 92%, transparent);
|
||
}
|
||
|
||
/* Flipped, the sheet hangs from the bar instead of standing on it. */
|
||
[data-hud-shell][data-hud-edge='top'] [data-hud-glass] {
|
||
top: var(--hud-bar-height, var(--composer-fallback-height));
|
||
bottom: auto;
|
||
--hud-exit-shift: -2.5rem;
|
||
border-radius: 0 0 0.75rem 0.75rem;
|
||
}
|
||
|
||
/* WHAT ANSWERS THE CURSOR — and, because `useHudClickThrough` hands the window
|
||
to the desktop wherever the document reports nothing under the mouse, what
|
||
the HUD occupies as far as the rest of the screen is concerned.
|
||
|
||
Default to none and let surfaces opt in. The HUD is mostly scaffolding — the
|
||
shell, the chat surface, the routes wrapper — all full-window, all invisible,
|
||
and all hit-testable, so the window came back "something is here" for every
|
||
point in its rectangle and ate clicks meant for the app behind it. Written
|
||
this way round because that scaffolding is not a list anyone maintains: one
|
||
more wrapper between the shell and the bar would quietly restore the dead
|
||
rectangle, whereas a control that forgets to opt in is visibly dead. */
|
||
[data-hud-shell] {
|
||
pointer-events: none;
|
||
}
|
||
|
||
/* Clickable whenever it's actually on screen, not only while the caret is in
|
||
the composer — a link in the transcript is worth clicking the moment you can
|
||
see it. Faded out it goes back to `none`, which is what lets clicks over the
|
||
HUD reach whatever you're really working in. */
|
||
[data-hud-shell][data-hud-recent] [data-slot='composer-bounds'],
|
||
[data-hud-shell]:focus-within [data-slot='composer-bounds'] {
|
||
pointer-events: auto;
|
||
}
|
||
|
||
/* Three states, not two. A turn landing brings the transcript up, but only part
|
||
way — it is there to be glanced at over whatever you are actually doing, and
|
||
full-strength text over another app reads as the HUD demanding attention it
|
||
hasn't earned. Focus is what promotes it to properly readable; the hold and
|
||
fade then take it away from wherever it was. */
|
||
[data-hud-shell][data-hud-recent] [data-slot='composer-bounds'] {
|
||
opacity: 0.5;
|
||
translate: none;
|
||
/* Same duration AND curve as the sheet's tint step, or the two visibly ease
|
||
apart on the way down. */
|
||
transition-duration: var(--hud-dim);
|
||
transition-timing-function: var(--hud-ease-enter);
|
||
}
|
||
|
||
[data-hud-shell]:has([data-slot='composer-rich-input']:focus) [data-slot='composer-bounds'] {
|
||
opacity: 1;
|
||
translate: none;
|
||
transition-duration: var(--hud-reveal);
|
||
transition-delay: 0s;
|
||
}
|
||
|
||
/* A completion list is the whole interface for as long as it is open, and it
|
||
hangs over the band. Two half-lit surfaces stacked on a third thing — the
|
||
app underneath — is unreadable however the numbers are tuned, so the list
|
||
goes fully opaque and the band falls back behind it: dimmer, fractionally
|
||
smaller, slightly out of focus. Scaled from the bar's edge, so it reads as
|
||
depth rather than as the panel shrinking. */
|
||
[data-hud-shell]:has([data-slot='composer-completion-drawer'])
|
||
:is([data-slot='composer-bounds'], [data-hud-glass]) {
|
||
opacity: 0.25 !important;
|
||
scale: 0.97;
|
||
filter: blur(1.5px);
|
||
}
|
||
|
||
[data-hud-shell] [data-slot='composer-completion-drawer'] {
|
||
background: var(--dt-card) !important;
|
||
}
|
||
|
||
/* User messages ride the band like every other line. In the app each sticky
|
||
user row paints an OPAQUE chat-surface slab behind it (so a stuck bubble can
|
||
slide over scrolled text) and the bubble is solid --dt-user-bubble — inside
|
||
the smoked band both read as fully opaque cards that ignore every fade. The
|
||
slab goes unconditionally; the bubble smokes at rest and returns solid when
|
||
engaged. ONE variable carries the fill so there is no specificity fight
|
||
between rest/engaged rules — the states just move the var. */
|
||
[data-hud-shell] {
|
||
--hud-bubble-fill: color-mix(in srgb, var(--dt-user-bubble) 65%, transparent);
|
||
}
|
||
|
||
/* ONE opacity law for the whole band: solid on focus, material otherwise.
|
||
Bubbles going solid on `recent` too made them opaque cards floating on the
|
||
62% band mid-stream — the band only steps solid on focus, so nothing inside
|
||
it may step earlier. */
|
||
[data-hud-shell]:has([data-slot='composer-rich-input']:focus) {
|
||
--hud-bubble-fill: var(--dt-user-bubble);
|
||
}
|
||
|
||
[data-hud-shell] [data-slot='aui_user-message-root'] {
|
||
background: transparent !important;
|
||
/* Not sticky in HUD mode: the band is a short log, and a pinned bubble
|
||
spends a third of it on a line you already read. Static rows scroll away
|
||
like everything else (the app keeps its sticky behavior — this is the
|
||
HUD's call, not a global one). */
|
||
position: static !important;
|
||
}
|
||
|
||
/* User bubbles: the ONE thing in the band that paints its own opaque fill
|
||
(bg-(--dt-user-bubble) on USER_BUBBLE_BASE_CLASS), so it survives every
|
||
fade the band applies to its own background and reads as a solid card
|
||
floating on glass. Two things are needed, and only doing one of them is
|
||
why this kept coming back:
|
||
- the FILL follows the band's state (--hud-bubble-fill), and
|
||
- the BORDER does too; an opaque hairline outlines the card even when
|
||
the fill is right.
|
||
Fill and border both ride the same variable, so they cannot desync. */
|
||
[data-hud-shell] .composer-human-message {
|
||
background: var(--hud-bubble-fill) !important;
|
||
border-color: color-mix(in srgb, var(--ui-stroke-secondary) 45%, transparent) !important;
|
||
backdrop-filter: none !important;
|
||
-webkit-backdrop-filter: none !important;
|
||
transition:
|
||
background-color var(--hud-reveal) var(--hud-ease-enter),
|
||
border-color 350ms ease;
|
||
}
|
||
|
||
/* The bubble's own container also paints the chat surface behind it. */
|
||
[data-hud-shell] .composer-human-message-container {
|
||
background: transparent !important;
|
||
}
|
||
|
||
/* ONE glass element in HUD mode: [data-hud-glass]. Everything else drops its
|
||
backdrop-filter — the composer tree ships several Tailwind backdrop-blur
|
||
utilities (surface glass, fallback surface, attachment spinners, voice
|
||
pills) that are invisible inside the opaque app but paint as smeared halos
|
||
around the pill's rounded corners on a transparent window.
|
||
|
||
Keep this exemption in step with whatever carries the glass: pointing it at
|
||
the wrong element silently deletes the blur, which looks exactly like
|
||
backdrop-filter not working at all. */
|
||
[data-hud-shell] *:not([data-hud-glass], [data-hud-glass] *) {
|
||
backdrop-filter: none !important;
|
||
-webkit-backdrop-filter: none !important;
|
||
}
|
||
|
||
/* No session header, no jump-to-bottom pill, no timeline rail. */
|
||
[data-hud-shell] header,
|
||
[data-hud-shell] [data-slot='thread-timeline'],
|
||
[data-hud-shell] .thread-jump-button {
|
||
display: none !important;
|
||
}
|
||
|
||
/* The band is three lines of conversation over someone else's window, not a
|
||
transcript. Tool rows, file diffs, and background notices ("Self-improvement
|
||
review: patched …") are the docked app's job — here they push the actual
|
||
answer out of view and read as junk pinned over the app below. */
|
||
[data-hud-shell] [data-slot='tool-block'],
|
||
[data-hud-shell] [data-slot='file-diff-panel'],
|
||
[data-hud-shell] [data-slot='aui_system-message-root'] {
|
||
display: none !important;
|
||
}
|
||
|
||
/* ── Edge flip ───────────────────────────────────────────────────────────────
|
||
Parked in the top half of the screen (data-hud-edge='top', broadcast by
|
||
main on move/resize), the whole HUD mirrors vertically: composer hugs the
|
||
window's top edge, the band hangs BELOW it, and text melts at the bottom.
|
||
Same surfaces, same variables — only the anchors swap.
|
||
|
||
The bar would sit flush against the window's top edge, leaving no "above the
|
||
composer" to put anything in — which is where the exit chip belongs. Reserve
|
||
the strip as dock padding rather than moving the bar: --hud-bar-height is
|
||
measured from the dock's box, so the band's offset picks the gap up on its
|
||
own instead of needing a second variable kept in sync. */
|
||
[data-hud-shell][data-hud-edge='top'] [data-slot='composer-dock'] {
|
||
top: 0 !important;
|
||
bottom: auto !important;
|
||
padding-top: var(--hud-chip-strip) !important;
|
||
}
|
||
|
||
/* Flipped, the band hangs from the bar instead of standing on it. */
|
||
[data-hud-shell][data-hud-edge='top'] [data-slot='composer-bounds'] {
|
||
top: var(--hud-bar-height, var(--composer-fallback-height)) !important;
|
||
bottom: auto !important;
|
||
border-radius: 0 0 0.75rem 0.75rem;
|
||
}
|
||
|
||
/* …so the edge it falls back from is the bar's, which is now above it. */
|
||
[data-hud-shell][data-hud-edge='top'] :is([data-slot='composer-bounds'], [data-hud-glass]) {
|
||
transform-origin: top center;
|
||
}
|
||
|
||
/* The completion list — `/`, `@`, `:`, and the help hint — hangs off the bar's
|
||
outer edge, the same side the band is on. It grows UP by default, which is
|
||
right everywhere the composer has a window above it; parked at the top edge
|
||
there is nothing up there, so the list rendered off screen and `/` looked
|
||
dead. Capped to the room below the bar, since the app's own cap assumes a
|
||
full window. */
|
||
[data-hud-shell][data-hud-edge='top'] [data-slot='composer-completion-drawer'] {
|
||
top: 100%;
|
||
bottom: auto;
|
||
margin-top: 0.25rem;
|
||
margin-bottom: 0;
|
||
max-height: calc(100dvh - var(--hud-bar-height, var(--composer-fallback-height)) - 0.75rem);
|
||
}
|
||
|
||
/* Tighten the thread on every side. The docked chat's gutters (px-6 py-8 plus
|
||
a 1.5rem inline pad) are sized for a full window column; in a bar a few
|
||
lines tall they're most of the surface. Bottom pad drops to a hairline —
|
||
the band already ends at the bar, so any block-end padding here reads as a
|
||
gap between the last message and the composer. */
|
||
[data-hud-shell] [data-slot='aui_thread-content'] {
|
||
padding-inline: 0.6rem !important;
|
||
padding-block: 0.35rem 0.25rem !important;
|
||
/* A chat frame fills from the bottom. The docked thread hangs from the top
|
||
because it is always taller than its content is short; the band is a few
|
||
lines tall, so a two-message conversation left a dead gap between the last
|
||
reply and the composer. Filling the viewport and packing to the end puts
|
||
that slack ABOVE the text, where it's just more transparent window. */
|
||
min-height: 100%;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
/* Parked at the top the band hangs below the bar, so it fills downward. */
|
||
[data-hud-shell][data-hud-edge='top'] [data-slot='aui_thread-content'] {
|
||
justify-content: flex-start;
|
||
}
|
||
|
||
/* The band's box already ends at the bar, so the app's dock clearance — sized
|
||
to hold text out from under a composer it overlaps — would only open a hole
|
||
the height of the bar between the last message and the composer. */
|
||
[data-hud-shell] [data-chat-surface] {
|
||
--thread-last-message-clearance: 0.25rem;
|
||
}
|
||
|
||
/* Same for the dock's own bottom pad — this var also feeds
|
||
--composer-measured-height, so the thread and the composer stay in
|
||
agreement from one value. */
|
||
[data-hud-shell] {
|
||
/* Conversation rhythm, tightened for a bar a few lines tall. The docked
|
||
thread's spacing is sized for a full-height column; in the HUD that air is
|
||
most of the band, and the ramp eats the older turns before you can read
|
||
them. Same knobs the app uses, just smaller here. */
|
||
--conversation-turn-gap: 0.1875rem;
|
||
--turn-block-gap: 0.375rem;
|
||
--paragraph-gap: 0.4rem;
|
||
--composer-shell-pad-block-end: 0px;
|
||
/* The viewport fills the band, period. The app's calc shortens the viewport
|
||
to leave the dock's strips visible below it; the HUD hides those strips, so
|
||
applying it here just strands a dead zone at the end of the thread that
|
||
grows as you type. Room for the bar comes from the clearance instead. */
|
||
--thread-viewport-height: 100%;
|
||
}
|
||
|
||
/* NOTHING in HUD mode declares `-webkit-app-region: drag`. The window manager
|
||
takes a draggable region's mouse input whole, so the page never sees the
|
||
cursor arrive there — and `useHudClickThrough`, which decides whether the
|
||
window is solid from exactly those moves, has already handed the window away
|
||
by the time you press. Every version of the app-region handle was therefore
|
||
both unusable (the press fell through to the app behind) and the reason the
|
||
HUD stayed solid over dead space once you left it. The two mechanisms cannot
|
||
share a window; click-through wins, and dragging is `useHudComposerDrag` —
|
||
press and hold the bar, then move. */
|
||
[data-hud-shell] [data-slot='composer-dock'] {
|
||
/* The one surface that is always there, so the one that always takes the
|
||
mouse — everything else in the shell earns it by being on screen. */
|
||
pointer-events: auto;
|
||
}
|
||
|
||
/* HUD mode is the input and the log — nothing else. The dock stacks
|
||
[micro-action pills] · [status stack] · [composer] · [underside slot], and
|
||
only the composer belongs here: the status stack (todos, subagents,
|
||
background tasks, queue, the coding/cwd/git-branch row) and the strips
|
||
around it are ambient chrome for a full window, and in a bar a few lines
|
||
tall they'd shove the input off screen.
|
||
|
||
Written as "keep the composer, hide its siblings" rather than a list of
|
||
selectors, because those siblings carry no data-slot of their own — a list
|
||
would silently miss whatever gets added to the dock next. */
|
||
[data-hud-shell] [data-slot='composer-dock'] > *:not([data-slot='composer-root']) {
|
||
display: none !important;
|
||
}
|
||
|
||
/* The coding/cwd/git-branch strip is NOT a dock sibling — it renders inside
|
||
`composer-surface` (it's meant to inherit the composer's width and top
|
||
radius), so the rule above never reaches it. Same intent, own selector. */
|
||
[data-hud-shell] .coding-status-bar {
|
||
display: none !important;
|
||
}
|
||
|
||
/* The composer is the Spotlight bar: centered, capped width, the ONE
|
||
permanent object on screen. When the thread is faded out, this is all HUD
|
||
mode is.
|
||
|
||
The dock is `absolute bottom-0 left-1/2 -translate-x-1/2` against the chat
|
||
surface. Two traps live in that, and both silently ate earlier attempts:
|
||
- `margin-bottom` does NOTHING to an absolutely-positioned box anchored to
|
||
an edge; the lift has to come from `bottom`.
|
||
- Tailwind centers it with `left-1/2` + a translate, so BOTH `transform`
|
||
and `translate` (the standalone property) must be cancelled — absolute
|
||
centering here is left/right 0 + capped width + margin-inline auto.
|
||
|
||
Do NOT restyle `position`: the controls row (model pill, mic, send) resolves
|
||
against the nearest positioned ancestor, so making the dock static re-anchors
|
||
that row and the composer tears in half — input in flow at the top, controls
|
||
stranded at the bottom. */
|
||
[data-hud-shell] [data-slot='composer-dock'],
|
||
[data-hud-shell] [data-slot='composer-dock'][data-popped-out] {
|
||
left: 0 !important;
|
||
right: 0 !important;
|
||
bottom: 0 !important;
|
||
top: auto !important;
|
||
width: 100% !important;
|
||
max-width: none !important;
|
||
transform: none !important;
|
||
/* Keep this value indirect. Lightning CSS folds literal identity values into
|
||
`transform: translate(0)`, which does not override Tailwind's standalone
|
||
`translate: -50%`; the dock then starts half a HUD width off-screen. */
|
||
--hud-composer-translate: 0;
|
||
translate: var(--hud-composer-translate) !important;
|
||
padding: 0 !important;
|
||
}
|
||
|
||
/* The bar is never "away". In the docked app the composer dims to 30% while
|
||
you read back through the thread, and comes back on hover or focus — sensible
|
||
when it's a panel at the bottom of a big window you're scrolling past. Here
|
||
it IS the interface: the one permanent, fully-present object, whatever the
|
||
transcript above it is doing. */
|
||
[data-hud-shell] [data-slot='composer-fade'] {
|
||
opacity: 1 !important;
|
||
}
|
||
|
||
[data-hud-shell] [data-slot='composer-root'] {
|
||
/* Opaque, so anything docked to the composer (status stack, queue panel)
|
||
paints the same solid fill the bar does — they share this var. */
|
||
--composer-fill: var(--dt-card);
|
||
/* The base rule adds 5px of transparent grab margin for the peel-out drag,
|
||
and the dock compensates with +10px of width. Neither applies here (the
|
||
HUD composer doesn't peel out), and left in they inset the card unevenly
|
||
inside its own float. */
|
||
padding: 0 !important;
|
||
width: 100% !important;
|
||
}
|
||
|
||
/* The composer surface paints TWO layers: the surface itself, plus an
|
||
`absolute inset-0 -z-10` fill+glass div behind it (composerFill /
|
||
composerSurfaceGlass). Two stacked fills read as a box inside a box, so the
|
||
backing layer goes and the surface carries the single fill. */
|
||
[data-hud-shell] [data-slot='composer-surface'] > .pointer-events-none.absolute.inset-0 {
|
||
display: none !important;
|
||
}
|
||
|
||
/* Opaque, pill-rounded — the Spotlight bar. Same surface tokens the composer
|
||
already uses; only the radius steps up so a lone floating bar reads as an
|
||
object rather than a snippet of app chrome.
|
||
|
||
ALWAYS fully opaque. It's the input — the one thing that must never be
|
||
compromised by whatever's behind the window, in any state. The idle/engaged
|
||
breathing belongs to the thread and the chat surface; the bar just sits
|
||
there, solid, like Spotlight does. */
|
||
[data-hud-shell] [data-slot='composer-surface'] {
|
||
border: 1px solid var(--ui-stroke-secondary) !important;
|
||
/* Same radius as the band's top and the window's own corners, so the HUD
|
||
reads as one object however the band is behaving. Its top corners let a
|
||
sliver of band through, which is the point — the glass wraps the bar's
|
||
shoulders instead of stopping in a straight line above it. */
|
||
border-radius: 0.75rem !important;
|
||
background: var(--dt-card) !important;
|
||
backdrop-filter: none !important;
|
||
-webkit-backdrop-filter: none !important;
|
||
/* Bottom edge only. Enough to lift the bar off whatever it is lying over —
|
||
without it the HUD reads as pasted onto the other app — and tight enough
|
||
that it never becomes a glow around a floating card. */
|
||
box-shadow: 0 2px 2px -1px rgb(0 0 0 / 0.12) !important;
|
||
transition: border-color var(--hud-reveal) var(--hud-ease-enter);
|
||
}
|
||
|
||
/* Focus: the border the bar already has, in the accent. Everything with its own
|
||
geometry is out — the bar sits flush against the window, so a ring or a
|
||
shadow is sawn off by the window before any CSS can shape it, and paying for
|
||
the clearance moves the bar. Recolouring what is already drawn costs no
|
||
space, cannot be clipped, and leaves the working arc as the only ring. */
|
||
[data-hud-shell]:has([data-slot='composer-rich-input']:focus) [data-slot='composer-surface'] {
|
||
border-color: var(--dt-primary) !important;
|
||
}
|
||
|
||
/* Long-press drag armed — the whole bar is the handle until release. */
|
||
[data-hud-shell] [data-slot='composer-root'][data-hud-grabbing] [data-slot='composer-dock'] {
|
||
cursor: grabbing;
|
||
}
|
||
|
||
/* The exit button. HUD mode has no titlebar, so this is the only visible way
|
||
back; it rides the bar's outer edge at the right.
|
||
|
||
It wears the BAR'S material, not the desktop's. Every shipped control that
|
||
floats over content the app does not own resolves this the same way — give
|
||
the control its own substrate and let the glyph read against that, never
|
||
against the backdrop:
|
||
|
||
- Apple HIG, Materials: "Materials help visually separate foreground
|
||
elements, such as text and controls, from background elements." Controls
|
||
sit ON a material, never directly on content; even `clear` Liquid Glass
|
||
is specced with a 35%-opacity dimming layer behind it over bright
|
||
content.
|
||
https://developer.apple.com/design/human-interface-guidelines/materials
|
||
- Firefox picture-in-picture, the closest analogue we have (a small
|
||
always-on-top window over arbitrary content): the close/unpip buttons
|
||
are `background-color: rgba(255,255,255,.8)` with a `#000` glyph — an
|
||
opaque chip, not a bare icon.
|
||
toolkit/themes/shared/pictureinpicture/player.css
|
||
- Discord's overlay redesign: "This layer provided contrast against the
|
||
game making the UI easier to see."
|
||
https://discord.com/blog/redesigning-the-discord-overlay
|
||
|
||
Anything that instead tries to derive contrast from the backdrop is a dead
|
||
end here. mix-blend-difference composites against the PAGE behind the
|
||
element, and behind a transparent Electron window that is nothing — the
|
||
desktop is composited by WindowServer after Chromium has finished drawing
|
||
the frame (the same reason backdrop-filter can't frost the HUD). A glyph
|
||
halo has the same flaw from the other side: it guesses one backdrop
|
||
luminance and is grime over everything else.
|
||
|
||
So: the composer bar's exact tokens — same fill, same hairline, same bottom
|
||
shadow. The bar is the HUD's solved case for "our surface, over an unknown
|
||
desktop, in either appearance", and theme plus OS light/dark (mode 'system')
|
||
come free with it, because --dt-card and --ui-text-primary are the pair the
|
||
whole app is built on. The chip inverts with the appearance instead of
|
||
betting on one.
|
||
|
||
Placed from --hud-bar-height rather than CSS anchor(). Lightning CSS drops a
|
||
whole rule containing an `anchor()` on the vertical axis, so the flipped-edge
|
||
override never reached the browser and the chip rendered off screen. */
|
||
[data-hud-shell] [data-hud-exit] {
|
||
left: auto;
|
||
top: auto;
|
||
right: 0.375rem;
|
||
bottom: calc(var(--hud-bar-height, var(--composer-fallback-height)) + 0.375rem);
|
||
/* Square. `size="icon-titlebar"` sizes width and height from two DIFFERENT
|
||
vars (--titlebar-control-size / --titlebar-control-height, 20x22 today),
|
||
which is right in a titlebar row and reads as a dent on a lone floating
|
||
chip — so height comes off the width instead. */
|
||
aspect-ratio: 1 / 1;
|
||
height: auto;
|
||
background: var(--dt-card) !important;
|
||
border: 1px solid var(--ui-stroke-secondary) !important;
|
||
border-radius: 0.5rem;
|
||
box-shadow: 0 2px 2px -1px rgb(0 0 0 / 0.12);
|
||
color: var(--ui-text-primary) !important;
|
||
/* Gone until you reach for the HUD. A permanent chip is a fragment of Hermes
|
||
parked on top of whatever you are really working in; reaching for the bar
|
||
is the motion that means "I want the app", so that is what brings the way
|
||
out with it. Hover, not focus — the gate #81893 warned about made the
|
||
escape hatch depend on the caret landing in the composer, broken exactly
|
||
when you most want out, whereas pointing at the bar needs nothing to be
|
||
working.
|
||
|
||
`visibility`, not opacity alone: an always-on-top window eats clicks
|
||
wherever the page hands the hit test something real, so a 0-opacity chip
|
||
with `pointer-events: auto` would be an invisible button in the corner
|
||
that drops you out of HUD mode when you click the app behind it.
|
||
`visibility: hidden` takes it out of `elementFromPoint` as well as off the
|
||
screen, and it transitions discretely, so it flips in step with the fade
|
||
rather than needing a second mechanism. */
|
||
opacity: 0;
|
||
visibility: hidden;
|
||
/* Leaving holds first. The chip sits 0.375rem clear of the bar, so the
|
||
cursor crosses shell dead space on its way up from the bar to the chip and
|
||
both hover triggers are false for that moment — without the hold it would
|
||
fade out from under a hand that is on its way to press it. */
|
||
transition:
|
||
opacity var(--hud-fade) var(--hud-ease-exit) var(--hud-exit-hold),
|
||
visibility 0s linear calc(var(--hud-exit-hold) + var(--hud-fade)),
|
||
background-color var(--hud-reveal) var(--hud-ease-enter);
|
||
pointer-events: auto;
|
||
}
|
||
|
||
/* Flipped, "above the composer" is the reserved strip at the top of the
|
||
window rather than a gap the band has to leave. */
|
||
[data-hud-shell][data-hud-edge='top'] [data-hud-exit] {
|
||
bottom: auto;
|
||
top: 0;
|
||
}
|
||
|
||
/* What counts as reaching for it: the bar, the transcript band, or the chip
|
||
itself. The band only answers `:hover` while it is on screen (faded out it
|
||
is `pointer-events: none`), so this can't reveal the chip over a HUD that
|
||
isn't there. The chip's own hover is in the set so arriving re-asserts the
|
||
reveal that the hold above was covering for.
|
||
|
||
Hovering the shell at large is deliberately NOT a trigger — most of the
|
||
HUD's rectangle is empty window over someone else's app, and it is
|
||
`pointer-events: none` precisely so the cursor passing through means
|
||
nothing. */
|
||
[data-hud-shell]:has([data-slot='composer-dock']:hover) [data-hud-exit],
|
||
[data-hud-shell]:has([data-slot='composer-bounds']:hover) [data-hud-exit],
|
||
[data-hud-shell] [data-hud-exit]:hover,
|
||
[data-hud-shell] [data-hud-exit]:focus-visible {
|
||
opacity: 1;
|
||
visibility: visible;
|
||
transition-duration: var(--hud-reveal), 0s, var(--hud-reveal);
|
||
transition-delay: 0s;
|
||
}
|
||
|
||
/* Hover and focus-visible: the app's own control-hover tint, so pointing at it
|
||
confirms it is a button with the same feedback every other icon button in
|
||
Hermes gives. Layered OVER the card rather than replacing it —
|
||
--ui-control-hover-background is a translucent color-mix meant to sit on an
|
||
opaque titlebar, and here the button IS the opaque surface, so assigning it
|
||
directly would make the chip see-through on hover. */
|
||
[data-hud-shell] [data-hud-exit]:hover,
|
||
[data-hud-shell] [data-hud-exit]:focus-visible {
|
||
background:
|
||
linear-gradient(var(--ui-control-hover-background), var(--ui-control-hover-background)), var(--dt-card) !important;
|
||
}
|
||
|
||
/* The corner resize handle — a hot corner, not a button. The window is created
|
||
non-resizable (the transparent-frameless Windows drag-growth bug), so this
|
||
is the one sanctioned way to change the HUD's size; it drives
|
||
`hermes:hud:set-bounds`, which flips resizable on for the call.
|
||
|
||
Deliberately invisible chrome: no glyph, no border — the corner of the bar
|
||
reads as the affordance, and painting a handle on a surface that lives over
|
||
other apps would be a stray UI fragment. It opts in to pointer events the
|
||
same way every other HUD control does (the shell defaults to none). */
|
||
[data-hud-shell] [data-hud-resize] {
|
||
width: 1.25rem;
|
||
height: 1.25rem;
|
||
cursor: nwse-resize;
|
||
pointer-events: auto;
|
||
touch-action: none;
|
||
}
|
||
|
||
/* The composer's drop target is a full-window dashed sheet sized for the app's
|
||
chat column. In the HUD it is a white slab hanging under the bar on a fresh
|
||
thread, and there is nowhere to drop anything into a bar anyway. */
|
||
[data-hud-shell] [data-slot='chat-drop-overlay'] {
|
||
display: none !important;
|
||
}
|
||
|
||
/* The dock's fade-to-surface gradient assumes a chat column behind it; over a
|
||
transparent HUD it's a grey smear. */
|
||
[data-hud-shell] [data-slot='composer-root'] > .pointer-events-none {
|
||
display: none !important;
|
||
}
|
||
|
||
/* Popovers in a ~320px-tall window. Two separate problems:
|
||
|
||
1. Radix clamps the popover to --radix-*-available-height, but fixed
|
||
max-heights INSIDE the panels (the model catalog's max-h-[max(150px,
|
||
30dvh)] list) don't shrink with it, so the list keeps its own floor and
|
||
the menu overflows the window.
|
||
2. The window IS the collision boundary, so even a correctly-clamped menu
|
||
has only ~320px to live in.
|
||
|
||
(1) is ours to fix in CSS; inner scrollers yield to the window instead of
|
||
their own floor. (2) is why the menus are compact here rather than roomy —
|
||
a HUD is a small window and a 400px menu cannot fit in it. */
|
||
html:has([data-hud-shell]) [data-slot='dropdown-menu-content'] [class*='max-h-'],
|
||
html:has([data-hud-shell]) [data-slot='popover-content'] [class*='max-h-'] {
|
||
max-height: max(5rem, calc(100dvh - 9rem)) !important;
|
||
}
|
||
|
||
/* The menu panels themselves: never taller than the window minus the bar. */
|
||
html:has([data-hud-shell]) [data-slot='dropdown-menu-content'],
|
||
html:has([data-hud-shell]) [data-slot='popover-content'] {
|
||
max-height: calc(100dvh - 4.5rem) !important;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
/* Hide the scrollbar — a HUD with a visible track stops reading as an overlay. */
|
||
[data-hud-shell] [data-slot='composer-bounds'] * {
|
||
scrollbar-width: none;
|
||
}
|
||
|
||
[data-hud-shell] [data-slot='composer-bounds'] *::-webkit-scrollbar {
|
||
display: none;
|
||
}
|