{/* Mounted for every real session, hero included: ConversationSession
renders no chrome while blank but owns the draft-persistence mirror
bind — unmounting it in the hero would lose pre-first-send text on
diff --git a/packages/client/ui-conversation/src/client/skeleton/DisabledInputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/DisabledInputBar.tsx
index 118baf5ac9..bfaa0b8d02 100644
--- a/packages/client/ui-conversation/src/client/skeleton/DisabledInputBar.tsx
+++ b/packages/client/ui-conversation/src/client/skeleton/DisabledInputBar.tsx
@@ -29,7 +29,7 @@ export function DisabledInputBar() {
diff --git a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx
index ca64fd7f44..fe3ee28d32 100644
--- a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx
+++ b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx
@@ -59,6 +59,40 @@ export function WorkspaceChip({ buttonRef, label, menuOpen = false, onClick }: {
)
}
+/**
+ * The soft blue backdrop ellipse (figma 313:14109). Rendered by the hero
+ * owner (ConversationRoot), not HeroShell, so it can center on the input
+ * card; the owner's className supplies all positioning.
+ * @param props.className - positioning class from the owner.
+ * @returns the blurred-ellipse svg element.
+ */
+export function HeroGlow({ className }: { className?: string }) {
+ // Stable filter id so multiple hero mounts do not collide in the DOM.
+ const glowFilterId = `empty-glow-${useId().replace(/:/g, '')}`
+ return (
+
+ )
+}
+
/** Hero chrome props. The workspace row rides the InputBar accessory hole, not here. */
export interface HeroShellProps {
/** Overlay content after the stack (modals). */
@@ -66,13 +100,12 @@ export interface HeroShellProps {
}
/**
- * Render the hero chrome (headline + glow; no composer, no workspace row).
+ * Render the hero chrome (headline only; no glow, no composer, no workspace
+ * row — the glow is the owner's {@link HeroGlow}).
* @param props - see {@link HeroShellProps}.
* @returns the centered hero element tree.
*/
export function HeroShell({ children }: HeroShellProps) {
- // Stable filter id so multiple hero mounts do not collide in the DOM.
- const glowFilterId = `empty-glow-${useId().replace(/:/g, '')}`
return (
@@ -82,29 +115,6 @@ export function HeroShell({ children }: HeroShellProps) {
Let's start building
- {/* figma 313:14109: soft ellipse behind workspace + composer; width
- tracks the card (glow asset 1051 vs design card 776) so blur
- scales in userSpace with it. */}
-
{/* The resident composer (rendered by ConversationRoot at its stable
tree position; the workspace row rides its accessory hole) is
CSS-positioned into this gap during the hero phase — see
diff --git a/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css b/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css
index 6bc6af5fea..71b90bfdfa 100644
--- a/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css
+++ b/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css
@@ -41,8 +41,9 @@
color: var(--dsw-alias-state-business-primary);
}
-/* Workspace row sits 12px above the input card (figma y80 → y112). Glow is
- centered on this block so it stays under the picker + InputBar together. */
+/* Workspace row sits 12px above the input card (figma y80 → y112). The blue
+ glow lives with the owner (ConversationRoot .heroGlow) so it can center on
+ the input card. */
.body {
position: relative;
display: flex;
@@ -52,19 +53,7 @@
overflow: visible;
}
-/* Design input 776 → glow SVG 1051×468 (ellipse 851×268 + blur pad). */
-.glow {
- position: absolute;
- left: 50%;
- top: 50%;
- z-index: 0;
- width: calc(100% * 1051 / 776);
- aspect-ratio: 1051 / 468;
- transform: translate(-50%, -50%);
- pointer-events: none;
-}
-
-.body > :not(.glow) {
+.body > * {
position: relative;
z-index: 1;
}
diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css
index 7b2711da39..0cb3920f6e 100644
--- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css
+++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css
@@ -171,6 +171,10 @@
.input,
.mirror,
.backdrop {
+ /* Textareas default to content-box (unlike buttons/inputs): without this the
+ width:100% textarea gains its padding OUTSIDE the card and text runs past
+ the right padding — and wraps 28px later than the mirror/backdrop layers. */
+ box-sizing: border-box;
/* figma .InputText 34:10434: pl 16 / pr 12 / pt 4. Backdrop MUST share these
metrics or the highlight ranges drift off the glyphs. */
padding: 4px 12px 0 16px;
@@ -306,11 +310,14 @@
border: none;
border-radius: 999px;
background: var(--dsw-alias-button-info-fill);
- color: var(--dsw-alias-label-primary-foreground);
+ /* Static white, not the foreground token: the arrow stays white on the blue
+ fill in both themes (design 34:10465). */
+ color: #fff;
cursor: pointer;
+ transition: background-color 100ms ease;
}
-.primary:hover {
+.primary:hover:not(:disabled) {
background: var(--dsw-alias-button-info-hover);
}
@@ -319,14 +326,6 @@
cursor: default;
}
-/* Stop state: same slot, dimmed brand fill — the running-state send-key
- replacement is a design gap filled by us (figma gives no stop form). */
-.stopping,
-.stopping:hover {
- background: var(--dsw-alias-button-primary-dimmed);
- color: var(--dsw-alias-label-primary);
-}
-
.retry {
margin-left: 8px;
padding: 1px 8px;
diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
index f2313d798b..f72103ffc0 100644
--- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
+++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
@@ -374,7 +374,7 @@ export function InputBar({
{machineBusy && }
diff --git a/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css b/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css
index 84224c9979..81123532d5 100644
--- a/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css
+++ b/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css
@@ -1,5 +1,8 @@
/* Bash toolview: same geometry/tokens as ToolRow (figma Bash · description). */
+/* Row sweep mask — same masked in-flight signal and exit-glide contract as
+ ToolRow (mask always mounted, band parked off-screen; running only adds
+ the animation; the transition finishes the sweep on exit). */
.root {
display: flex;
align-items: center;
@@ -7,6 +10,19 @@
min-width: 0;
cursor: pointer;
border-radius: 6px;
+ mask-image: linear-gradient(100deg, #000 30%, rgba(0, 0, 0, 0.35) 50%, #000 70%);
+ mask-size: 200% 100%;
+ mask-position: -50% 0;
+ transition: mask-position 1.6s ease-out;
+}
+
+.root[data-state='running'] {
+ animation: dsh-bash-row-sweep 2.2s linear infinite;
+}
+
+@keyframes dsh-bash-row-sweep {
+ from { mask-position: 150% 0; }
+ to { mask-position: -50% 0; }
}
.leading {
diff --git a/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx b/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx
index dc4dd6b367..fbb1104eca 100644
--- a/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx
+++ b/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx
@@ -12,9 +12,9 @@ import css from './bash-sample.module.css'
function leadingFor(state: ToolRowState) {
switch (state) {
- case 'running': return
case 'error': return
case 'stopped': return
+ // Running keeps the icon — the row sweep carries the in-flight signal.
default: return
}
}
diff --git a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx
index aa9451b413..798f5c52bc 100644
--- a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx
+++ b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx
@@ -251,7 +251,7 @@ describe('run_code sub-calls through the real chat machinery', () => {
const b = await bench(snapshotWith([], dispatches, [runningCode(parent)]))
const view = mountApp(b.slots)
// The nested row derives 'running' from the RunningToolCall shape — the
- // same StateDot ring a native in-flight row wears.
+ // same data-state chrome (row sweep) a native in-flight row wears.
const nested = view.container.querySelector('[data-subcalls] [data-variant][data-state="running"]')
expect(nested).not.toBeNull()
})
diff --git a/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx b/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx
index 13a74548b4..4425e98cb3 100644
--- a/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx
+++ b/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx
@@ -125,12 +125,12 @@ describe('ToolRow', () => {
expect(view.getByText('List files')).toBeTruthy()
})
- it('running and error states replace the icon with a StateDot', () => {
+ it('running keeps the icon (row sweep carries the signal); error swaps in a StateDot', () => {
const runningView = render()
- expect(runningView.queryByTestId('tool-icon')).toBeNull()
+ expect(runningView.queryByTestId('tool-icon')).not.toBeNull()
expect(runningView.container.querySelector('[data-state="running"]')).not.toBeNull()
const errorView = render()
- expect(errorView.queryByTestId('tool-icon')).toBeNull()
+ expect(errorView.container.querySelector('[data-testid="tool-icon"]')).toBeNull()
})
it('non-expandable rows render a passive leading slot', () => {
diff --git a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx
index 136901bd6d..86bb99246d 100644
--- a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx
+++ b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx
@@ -76,7 +76,7 @@ describe('tails', () => {
expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull()
})
- it('BashRow shows StateDot chrome for running/error/stopped (root session arm)', () => {
+ it('BashRow carries data-state for running (row sweep) and StateDots for error/stopped (root session arm)', () => {
const sid = 'root-1' as SessionId
const list = createSnapshotStore({
ids: [sid],
diff --git a/packages/client/ui-primitives/src/StateDot.module.css b/packages/client/ui-primitives/src/StateDot.module.css
index e5a8ebdb50..e9442f46bb 100644
--- a/packages/client/ui-primitives/src/StateDot.module.css
+++ b/packages/client/ui-primitives/src/StateDot.module.css
@@ -1,7 +1,7 @@
/* Ongoing blue has no alias token (state-business-primary is the 500 step,
* not this 450) — component-level var pinned to the static scale instead. */
.dot,
-.ring {
+.matrix {
--dsh-state-ongoing: var(--dsw-static-deepseek-450);
}
@@ -42,24 +42,24 @@
color: var(--dsw-alias-state-error-primary);
}
-.ring {
+/* Pixel chase: each outer cell holds a discrete brightness step (flat keyframe
+ * holds, no tweening — the retro feel), peaking when the chase hits it and
+ * decaying over the next three cells. Phase offsets come from per-rect
+ * animation-delay (index * -125ms) set inline by the component. */
+.matrix {
flex: none;
color: var(--dsh-state-ongoing);
- animation: dsh-state-dot-spin 1s linear infinite;
}
-.stopFrom {
- stop-color: currentColor;
- stop-opacity: 1;
+.cell {
+ fill: currentColor;
+ opacity: 0.15;
+ animation: dsh-state-dot-chase 1s infinite;
}
-.stopTo {
- stop-color: currentColor;
- stop-opacity: 0;
-}
-
-@keyframes dsh-state-dot-spin {
- to {
- transform: rotate(360deg);
- }
+@keyframes dsh-state-dot-chase {
+ 0%, 12.4% { opacity: 1; }
+ 12.5%, 24.9% { opacity: 0.6; }
+ 25%, 37.4% { opacity: 0.35; }
+ 37.5%, 100% { opacity: 0.15; }
}
diff --git a/packages/client/ui-primitives/src/StateDot.tsx b/packages/client/ui-primitives/src/StateDot.tsx
index c4117673ba..643f9283f4 100644
--- a/packages/client/ui-primitives/src/StateDot.tsx
+++ b/packages/client/ui-primitives/src/StateDot.tsx
@@ -1,15 +1,19 @@
// StateDot: session state indicator (figma nodes 14:3303/3305/3312, 122:9182).
// done/warning/error: 10x10 halo (same color, 10% opacity) around a 6x6 solid
-// core. ongoing: 10x10 ring, 1px inside stroke, color fading out along a
-// linear gradient, spinning. Colors resolve through --dsw-* tokens only.
+// core. ongoing: a pixel-art chase — the 8 outer cells of a 3x3 matrix light
+// up clockwise with a stepped trail. Colors resolve through --dsw-* tokens only.
-import { useId } from 'react'
import clsx from 'clsx'
import css from './StateDot.module.css'
/** Four-color session state semantic (green done / amber approval-waiting / blue running ring / red error). */
export type StateDotState = 'done' | 'warning' | 'ongoing' | 'error'
+/** Outer 3x3 matrix cells (2px pixels on a 10px grid), clockwise from top-left. */
+const MATRIX_CELLS: readonly (readonly [number, number])[] = [
+ [0, 0], [4, 0], [8, 0], [8, 4], [8, 8], [4, 8], [0, 8], [0, 4],
+]
+
/**
* Render a state dot.
* @param props.state - which of the four states to show.
@@ -22,25 +26,29 @@ export function StateDot({ state, size = 10, className }: {
size?: number
className?: string
}) {
- const gradientId = useId()
if (state === 'ongoing') {
return (
)
}
diff --git a/packages/client/ui-primitives/tests/state-dot.spec.tsx b/packages/client/ui-primitives/tests/state-dot.spec.tsx
index a3759174ff..86e41c7787 100644
--- a/packages/client/ui-primitives/tests/state-dot.spec.tsx
+++ b/packages/client/ui-primitives/tests/state-dot.spec.tsx
@@ -14,16 +14,17 @@ describe('StateDot', () => {
expect(dot.getAttribute('aria-hidden')).toBe('true')
})
- it('solid states are spans; ongoing is an svg gradient ring', () => {
+ it('solid states are spans; ongoing is an svg pixel matrix', () => {
const { container, rerender } = render()
expect(container.firstElementChild?.tagName).toBe('SPAN')
rerender()
- const ring = container.firstElementChild as SVGSVGElement
- expect(ring.tagName).toBe('svg')
- const circle = ring.querySelector('circle')
- expect(circle?.getAttribute('stroke-width')).toBe('1')
- expect(circle?.getAttribute('stroke')).toMatch(/^url\(#/)
- expect(ring.querySelector('linearGradient')).not.toBeNull()
+ const matrix = container.firstElementChild as SVGSVGElement
+ expect(matrix.tagName).toBe('svg')
+ const cells = matrix.querySelectorAll('rect')
+ expect(cells).toHaveLength(8)
+ // Chase phase: every cell carries its own negative animation delay.
+ const delays = [...cells].map(cell => (cell).style.animationDelay)
+ expect(new Set(delays).size).toBe(8)
})
it('sizes via the size prop in both shapes', () => {