Reimplements the handoff Shell in React/TS: 208px left rail (nav + connection dot + Evening service theme switch), max-1120 main column, 330px Reginald chat panel. Routing across top-level views with an issue drill-in + back-stack of one; data-theme owned by the shell. - ChatPanel: fixture write-path — echoes a canned reply so layout + interactions are real; model router wiring lands in P4. - states.tsx: EmptyState / OfflineBanner / ModelAwayState + the States specimen gallery, ported from the handoff. - PlaceholderScreen stands in for not-yet-built views (P3-3+), keeping navigation live; it also exposes the issue drill-in for now. - Gallery loses its own theme toggle (shell owns data-theme); reachable via a Primitives rail entry as a living reference. Fixtures mirrored from the handoff's data.js. typecheck + 7 e2e green (nav, theme, offline banner + disabled composer, chat echo, drill-in back-stack); light/dark/states screenshots verified. Closes P3-2. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
233 lines
7.6 KiB
TypeScript
233 lines
7.6 KiB
TypeScript
import React from 'react'
|
||
|
||
import { Badge, Button, Icon } from '../ui/index.js'
|
||
|
||
/**
|
||
* Shared empty/trouble states + the States gallery screen, ported from the
|
||
* handoff. EmptyState/OfflineBanner/ModelAwayState are reused across real
|
||
* screens as they land; StatesScreen is the specimen gallery.
|
||
*/
|
||
|
||
export interface EmptyStateProps {
|
||
icon: string
|
||
title: React.ReactNode
|
||
line: React.ReactNode
|
||
action?: string
|
||
onAction?: () => void
|
||
compact?: boolean
|
||
}
|
||
|
||
export function EmptyState({ icon, title, line, action, onAction, compact }: EmptyStateProps) {
|
||
return (
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
alignItems: 'center',
|
||
textAlign: 'center',
|
||
gap: 10,
|
||
padding: compact ? '28px 20px' : '52px 24px',
|
||
}}
|
||
>
|
||
<span
|
||
style={{
|
||
width: 44,
|
||
height: 44,
|
||
borderRadius: '50%',
|
||
background: 'var(--paper-2)',
|
||
color: 'var(--ink-3)',
|
||
display: 'inline-flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
}}
|
||
>
|
||
<Icon name={icon} size={19} />
|
||
</span>
|
||
<div style={{ font: '400 20px/1.25 var(--font-serif-display)', color: 'var(--ink-1)' }}>{title}</div>
|
||
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: 0, maxWidth: 380 }}>{line}</p>
|
||
{action ? (
|
||
<Button style={{ marginTop: 6 }} onClick={onAction}>
|
||
{action}
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export interface OfflineBannerProps {
|
||
retryIn?: string
|
||
}
|
||
|
||
export function OfflineBanner({ retryIn }: OfflineBannerProps) {
|
||
return (
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 10,
|
||
background: 'var(--warn-tint)',
|
||
border: '1px solid var(--warn)',
|
||
borderRadius: 'var(--radius-2)',
|
||
padding: '9px 14px',
|
||
}}
|
||
>
|
||
<span style={{ color: 'var(--warn)', display: 'inline-flex' }}>
|
||
<Icon name="triangle-alert" size={15} />
|
||
</span>
|
||
<span style={{ font: 'var(--text-small)', color: 'var(--ink-1)', flex: 1 }}>
|
||
Gitea isn’t answering. I’ll keep trying and say nothing more about it.
|
||
</span>
|
||
<span style={{ font: '400 11.5px var(--font-mono)', color: 'var(--ink-2)', whiteSpace: 'nowrap' }}>
|
||
retry in {retryIn || '0:12'} · reads from cache
|
||
</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export function ModelAwayState() {
|
||
return (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, padding: '14px 16px' }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||
<span style={{ color: 'var(--ink-3)', display: 'inline-flex' }}>
|
||
<Icon name="sparkles" size={15} />
|
||
</span>
|
||
<span style={{ font: 'var(--text-body-strong)', color: 'var(--ink-2)' }}>Reginald</span>
|
||
<span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)' }}>model offline</span>
|
||
<span style={{ marginLeft: 'auto' }}>
|
||
<Badge>queued: 1 directive</Badge>
|
||
</span>
|
||
</div>
|
||
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: 0 }}>
|
||
The model is away from its desk. Reads still work; writes will wait their turn.
|
||
</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function Specimen({ label, children }: { label: string; children: React.ReactNode }) {
|
||
return (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||
<span style={{ font: '400 11px var(--font-mono)', color: 'var(--ink-3)' }}>{label}</span>
|
||
<div
|
||
style={{
|
||
border: '1px dashed var(--line-2)',
|
||
borderRadius: 'var(--radius-3)',
|
||
background: 'var(--surface-card)',
|
||
overflow: 'hidden',
|
||
}}
|
||
>
|
||
{children}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export function StatesScreen({ onCapture }: { onCapture?: () => void }) {
|
||
return (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||
<header style={{ borderBottom: 'var(--rule-double)', paddingBottom: 14 }}>
|
||
<h1 style={{ font: 'var(--text-display)', color: 'var(--ink-1)', margin: 0 }}>States</h1>
|
||
<p style={{ font: 'var(--text-data)', color: 'var(--ink-3)', margin: '6px 0 0' }}>
|
||
empty & trouble · specimens as wired in the app
|
||
</p>
|
||
</header>
|
||
|
||
<p
|
||
style={{
|
||
font: 'var(--text-overline)',
|
||
letterSpacing: 'var(--letter-spacing-wide)',
|
||
textTransform: 'uppercase',
|
||
color: 'var(--ink-3)',
|
||
margin: '4px 0 0',
|
||
}}
|
||
>
|
||
Empty
|
||
</p>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14 }}>
|
||
<Specimen label="the pot · no issues">
|
||
<EmptyState
|
||
compact
|
||
icon="inbox"
|
||
title="The pot is empty"
|
||
line="Tell me what you’re planning and I’ll draw up the tickets."
|
||
action="Start a capture"
|
||
onAction={onCapture}
|
||
/>
|
||
</Specimen>
|
||
<Specimen label="morning service · nothing scheduled">
|
||
<EmptyState
|
||
compact
|
||
icon="coffee"
|
||
title="Nothing to pour"
|
||
line="Capture some work, or enjoy the silence — it never lasts."
|
||
/>
|
||
</Specimen>
|
||
<Specimen label="directives · no entries">
|
||
<EmptyState
|
||
compact
|
||
icon="flag"
|
||
title="No directives yet"
|
||
line="When you overrule the scheduler, it goes on the record here — who, when, what, why."
|
||
/>
|
||
</Specimen>
|
||
<Specimen label="board search · no match">
|
||
<EmptyState
|
||
compact
|
||
icon="search"
|
||
title="Nothing by that name"
|
||
line="The pot holds 24 issues; none of them answer to that."
|
||
/>
|
||
</Specimen>
|
||
</div>
|
||
|
||
<p
|
||
style={{
|
||
font: 'var(--text-overline)',
|
||
letterSpacing: 'var(--letter-spacing-wide)',
|
||
textTransform: 'uppercase',
|
||
color: 'var(--ink-3)',
|
||
margin: '8px 0 0',
|
||
}}
|
||
>
|
||
Trouble
|
||
</p>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||
<Specimen label="gitea unreachable · banner above main content (toggle the rail’s connection dot to see it live)">
|
||
<div style={{ padding: 12 }}>
|
||
<OfflineBanner />
|
||
</div>
|
||
</Specimen>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14 }}>
|
||
<Specimen label="reginald’s panel · model offline">
|
||
<ModelAwayState />
|
||
</Specimen>
|
||
<Specimen label="webhooks down · poll fallback">
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '14px 16px' }}>
|
||
<Badge tone="warn" dot>
|
||
webhooks down
|
||
</Badge>
|
||
<span style={{ font: '400 11.5px var(--font-mono)', color: 'var(--ink-2)' }}>
|
||
polling every 2 min · updates may lag
|
||
</span>
|
||
</div>
|
||
</Specimen>
|
||
</div>
|
||
<Specimen label="first reconcile failed · full-screen">
|
||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '8px 0 20px' }}>
|
||
<EmptyState
|
||
compact
|
||
icon="refresh-cw"
|
||
title="The reconcile failed"
|
||
line="Gitea answered, then hung up mid-sentence. Your cache is intact; nothing human is lost."
|
||
/>
|
||
<div style={{ display: 'flex', gap: 8, marginTop: -6 }}>
|
||
<Button>Try again</Button>
|
||
<Button variant="ghost">Work from cache</Button>
|
||
</div>
|
||
</div>
|
||
</Specimen>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|