riprap-nyc / web /sveltekit /src /lib /components /shell /StatusPill.svelte
seriffic's picture
ux v0.4.5: live status pill + drop stray dev probes
86e2a29
<script lang="ts">
import { briefingState } from '$lib/stores/briefingState.svelte';
/** v0.4.5 — live status pill for the AppHeader.
*
* Tracks pipeline phase + active step + fired-count + reconcile
* attempt so a user staring at a half-rendered briefing knows what's
* actually being crunched. Reads from the briefingState rune store
* which q/[queryId]/+page.svelte writes into from SSE callbacks.
*
* Visible only during a live run (phase != idle && != done). On the
* prerendered /q/sample route the store stays at idle and the pill
* stays hidden.
*/
// Pretty short labels for FSM step names. Lifted from the legacy
// agent.js label set; only the short names land here — the trace
// panel still owns the long form. Anything not mapped just shows
// its raw step name, which is mono-cased and readable.
const SHORT: Record<string, string> = {
geocode: 'geocoding',
nta_resolve: 'resolving NTA',
sandy_inundation: 'Sandy 2012',
dep_stormwater: 'DEP scenarios',
floodnet: 'FloodNet sensors',
nyc311: 'NYC 311 history',
noaa_tides: 'NOAA tides',
nws_alerts: 'NWS alerts',
nws_obs: 'NWS hourly obs',
ttm_forecast: 'TTM r2 surge (zero-shot)',
ttm_311_forecast: 'TTM r2 weekly 311',
ttm_battery_surge: 'TTM Battery (NYC fine-tune)',
floodnet_forecast: 'FloodNet recurrence forecast',
ida_hwm_2021: 'Ida 2021 HWMs',
// Two distinct Prithvi specialists with different compute profiles:
// prithvi_eo_v2 — static spatial join against the baked Ida 2021
// polygons in data/prithvi_ida_2021.geojson. No
// model inference; sub-100ms even on cold cache.
// prithvi_eo_live — live Sentinel-2 fetch + Prithvi-NYC-Pluvial
// forward pass. The actual ML run.
prithvi_eo_v2: 'Ida 2021 polygons (baked lookup)',
prithvi_eo_live: 'Prithvi-NYC-Pluvial v2 segmentation',
microtopo_lidar: 'LiDAR microtopo',
mta_entrance_exposure: 'MTA entrances',
nycha_development_exposure: 'NYCHA developments',
doe_school_exposure: 'DOE schools',
doh_hospital_exposure: 'NYS DOH hospitals',
terramind_synthesis: 'TerraMind v1 synthesis',
terramind_lulc: 'TerraMind LULC',
terramind_buildings: 'TerraMind Buildings',
eo_chip_fetch: 'fetching S2/S1/DEM chip',
rag_granite_embedding: 'RAG retrieval',
gliner_extract: 'GLiNER typed extraction',
};
let visible = $derived(
briefingState.phase !== 'idle' && briefingState.phase !== 'done'
);
let phaseLabel = $derived.by(() => {
switch (briefingState.phase) {
case 'planning': return 'planning intent';
case 'specialists': return 'gathering evidence';
case 'reconciling': return 'reconciling';
case 'streaming': return briefingState.attempt > 1
? `writing (reroll ${briefingState.attempt - 1})`
: 'writing briefing';
case 'error': return 'error';
default: return '';
}
});
let stepLabel = $derived.by(() => {
const s = briefingState.activeStep;
if (!s) return null;
return SHORT[s] ?? s;
});
let progress = $derived.by(() => {
if (briefingState.phase !== 'specialists' && briefingState.phase !== 'reconciling') return null;
const fired = briefingState.firedCount;
const total = briefingState.totalSpecialists;
if (!total) return fired > 0 ? `${fired}` : null;
return `${fired}/${total}`;
});
let kind = $derived.by(() => {
// Drives the dot color — error red, otherwise the accent pulse.
if (briefingState.phase === 'error') return 'err';
return 'live';
});
</script>
{#if visible}
<span class="status" data-kind={kind} aria-live="polite" aria-atomic="true">
<span class="status-dot" aria-hidden="true"></span>
<span class="status-phase">{phaseLabel}</span>
{#if stepLabel}
<span class="status-sep">·</span>
<span class="status-step">{stepLabel}</span>
{/if}
{#if progress}
<span class="status-sep">·</span>
<span class="status-progress">{progress}</span>
{/if}
{#if briefingState.phase === 'error' && briefingState.errorMessage}
<span class="status-sep">·</span>
<span class="status-err">{briefingState.errorMessage}</span>
{/if}
</span>
{/if}
<style>
.status {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 10px;
background: var(--paper-deep);
border: 1px solid var(--rule-soft);
font-family: var(--font-mono);
font-size: 11px;
color: var(--ink-secondary);
letter-spacing: 0.04em;
max-width: min(60ch, 50vw);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.status[data-kind='err'] {
border-color: #B91C1C;
color: #B91C1C;
}
.status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex: none;
background: var(--accent-graphical);
animation: pulse 1.4s ease-in-out infinite;
}
.status[data-kind='err'] .status-dot {
background: #B91C1C;
animation: none;
}
.status-phase {
color: var(--ink);
text-transform: lowercase;
letter-spacing: 0.05em;
font-weight: 600;
}
.status-sep { color: var(--ink-tertiary); opacity: 0.6; }
.status-step {
color: var(--ink-secondary);
overflow: hidden;
text-overflow: ellipsis;
}
.status-progress { color: var(--ink); font-weight: 600; }
.status-err { color: #B91C1C; }
@keyframes pulse {
0%, 100% { opacity: 0.35; transform: scale(0.85); }
50% { opacity: 1; transform: scale(1.1); }
}
@media (prefers-reduced-motion: reduce) {
.status-dot { animation: none; opacity: 0.7; }
}
</style>