fix(web-ui): pixel loading language, tool-row sweep, composer polish

- StateDot ongoing: gradient spin ring replaced by an 8-cell pixel chase
  (2px matrix cells, stepped trail, no tweening)
- Chat: streaming pulse block replaced by a turn-level 4-pixel chase at the
  flow tail — rides the whole running turn (first-token wait, tools,
  streaming) instead of flickering with partial presence
- Tool rows (ToolRow/BashRow): running no longer swaps the icon for a dot;
  an animated mask band sweeps the row content, gliding off on exit via
  mask-position transition
- Composer: send/stop unified on the blue fill (bigger stop glyph, static
  white arrow), textarea box-sizing overflow fix, settling phase hides the
  composer while replay decides hero vs docked, workspace-placeholder
  fallback disables the bar
- Hero: glow moved behind (z-index) with lower opacity; tool-row hover icon
  crossfade at 100ms
This commit is contained in:
Yif
2026-07-28 07:59:08 +08:00
parent ae76ed2874
commit 484e0f70cc
21 changed files with 257 additions and 124 deletions

View File

@@ -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; }
}

View File

@@ -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 (
<svg
className={clsx(css.ring, className)}
className={clsx(css.matrix, className)}
data-state="ongoing"
width={size}
height={size}
viewBox="0 0 10 10"
shapeRendering="crispEdges"
aria-hidden="true"
>
<defs>
{/* Gradient handles from the figma node: (0.1,0) -> (0.85,1). */}
<linearGradient id={gradientId} x1="1" y1="0" x2="8.5" y2="10" gradientUnits="userSpaceOnUse">
<stop className={css.stopFrom} offset="0" />
<stop className={css.stopTo} offset="1" />
</linearGradient>
</defs>
<circle cx="5" cy="5" r="4.5" fill="none" strokeWidth="1" stroke={`url(#${gradientId})`} />
{MATRIX_CELLS.map(([x, y], index) => (
<rect
key={`${x}-${y}`}
className={css.cell}
x={x}
y={y}
width="2"
height="2"
/* Negative delay phases the chase so every cell animates from mount. */
style={{ animationDelay: `${(index - MATRIX_CELLS.length) * 125}ms` }}
/>
))}
</svg>
)
}

View File

@@ -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(<StateDot state="done" />)
expect(container.firstElementChild?.tagName).toBe('SPAN')
rerender(<StateDot state="ongoing" />)
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', () => {