feat(ui-tui): weather reference app — the async-data contract, themed ASCII art

/weather [location]: wttr.in current conditions behind a Dialog, art bucket
table-driven off WWO weather codes, every tint a theme family tone (sun =
primary, rain = shell blue, thunder = warn). Proves the async story the
demos don't: init returns a loading phase and fires the fetch; results land
through the new host.updateWidget, which patches state ONLY while the app
is still active — a late resolution can never resurrect a closed app or
clobber a different one. `r` refetches; Esc/q/Enter close.

Four async-contract tests (loading→ready via updateWidget, late-resolution
guard, error phase, keymap). 1253 TS tests green.
This commit is contained in:
Brooklyn Nicholson 2026-07-20 20:43:28 -05:00
parent d8fcab4736
commit fc3be32a9a
7 changed files with 294 additions and 4 deletions

View file

@ -0,0 +1,91 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getOverlayState, resetOverlayState } from '../app/overlayStore.js'
import { weatherApp, type WeatherState } from '../sdk/apps/index.js'
import { launchWidget } from '../sdk/host.js'
import type { WidgetInput } from '../sdk/types.js'
const key = (overrides: Partial<WidgetInput['key']> = {}, ch = ''): WidgetInput =>
({ ch, key: { ctrl: false, escape: false, return: false, ...overrides } }) as WidgetInput
const wttrReply = (weatherCode: string) => ({
current_condition: [
{
FeelsLikeC: '20',
humidity: '40',
temp_C: '22',
weatherCode,
weatherDesc: [{ value: 'Sunny' }],
windspeedKmph: '7'
}
],
nearest_area: [{ areaName: [{ value: 'Austin' }], country: [{ value: 'USA' }] }]
})
const activeState = () => getOverlayState().widget?.state as undefined | WeatherState
beforeEach(() => resetOverlayState())
afterEach(() => vi.unstubAllGlobals())
describe('weather reference app (async contract)', () => {
it('launches into loading, lands the fetch via updateWidget', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => ({ json: async () => wttrReply('113'), ok: true }))
)
expect(launchWidget('weather', 'Austin')).toBeNull()
expect(activeState()?.phase.kind).toBe('loading')
await vi.waitFor(() => expect(activeState()?.phase.kind).toBe('ready'))
const phase = activeState()!.phase
expect(phase).toMatchObject({ kind: 'ready', report: { area: 'Austin, USA', tempC: '22', weatherCode: 113 } })
})
it('a late resolution cannot resurrect a closed app', async () => {
let resolve!: (value: unknown) => void
vi.stubGlobal(
'fetch',
vi.fn(() => new Promise(r => (resolve = r)))
)
launchWidget('weather', '')
expect(activeState()?.phase.kind).toBe('loading')
// Close while in flight, then resolve.
expect(weatherApp.reduce(activeState()!, key({ escape: true }))).toBeNull()
resetOverlayState()
resolve({ json: async () => wttrReply('113'), ok: true })
await new Promise(r => setTimeout(r, 0))
expect(getOverlayState().widget).toBeNull()
})
it('fetch failure lands as an error phase', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => ({ json: async () => ({}), ok: false, status: 503 }))
)
launchWidget('weather', 'nowhere')
await vi.waitFor(() => expect(activeState()?.phase.kind).toBe('error'))
expect(activeState()?.phase).toMatchObject({ message: expect.stringContaining('503') })
})
it('r refreshes; Esc/q/Enter close', () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => ({ json: async () => wttrReply('113'), ok: true }))
)
const state: WeatherState = { location: 'x', phase: { kind: 'error', message: 'boom' } }
expect(weatherApp.reduce(state, key({}, 'r'))).toMatchObject({ phase: { kind: 'loading' } })
expect(weatherApp.reduce(state, key({ escape: true }))).toBeNull()
expect(weatherApp.reduce(state, key({}, 'q'))).toBeNull()
expect(weatherApp.reduce(state, key({ return: true }))).toBeNull()
})
})

View file

@ -26,6 +26,7 @@ const widgetCommand = (name: string, help: string): SlashCommand => ({
export const debugCommands: SlashCommand[] = [
widgetCommand('grid-test', 'open an interactive widget-grid demo overlay'),
widgetCommand('dialog-test', 'open a sample dialog overlay with a faked backdrop'),
widgetCommand('weather', 'current conditions with themed ASCII art (wttr.in)'),
{
help: 'write a V8 heap snapshot + memory diagnostics (see HERMES_HEAPDUMP_DIR)',

View file

@ -51,9 +51,7 @@ export const dialogTestApp = defineWidgetApp<DialogTestState>({
return key.escape || key.return || ch === 'q' || isCtrl(key, ch, 'c') ? null : state
},
render({ cols, state, t }) {
void t
render({ cols, state }) {
return (
<Overlay backdrop zone={state.zone}>
<Dialog hint={state.hint ?? 'Esc/q close'} title={state.title} width={Math.min(60, cols - 8)}>

View file

@ -3,3 +3,4 @@
export { dialogTestApp } from './dialogTest.js'
export { gridTestApp } from './gridTest.js'
export { GRID_STREAM_COUNT, type GridTestState } from './gridTestState.js'
export { weatherApp, type WeatherState } from './weather.js'

View file

@ -0,0 +1,185 @@
import { Box, Text } from '@hermes/ink'
import { Dialog, Overlay } from '../../components/overlay.js'
import type { Theme } from '../../theme.js'
import { updateWidget } from '../host.js'
import { defineWidgetApp } from '../registry.js'
import { isCtrl } from '../types.js'
/**
* Weather the data-backed reference app. Demonstrates the async contract:
* `init` returns a loading state and fires the fetch; the resolution lands
* through `updateWidget`, which no-ops if the app was closed meanwhile.
* Everything visual derives from the theme (art tinted by family tones).
*/
const USAGE = 'usage: /weather [location] (blank = geolocate by IP)'
type Phase = { kind: 'error'; message: string } | { kind: 'loading' } | { kind: 'ready'; report: Report }
export interface WeatherState {
location: string
phase: Phase
}
interface Report {
area: string
condition: string
feelsC: string
humidity: string
tempC: string
weatherCode: number
windKmph: string
}
// WWO weather codes → art bucket. Table-driven; unknown codes read as cloud.
type Art = 'cloud' | 'fog' | 'rain' | 'snow' | 'sun' | 'thunder'
const ART_BY_CODE: readonly [codes: readonly number[], art: Art][] = [
[[113], 'sun'],
[[116, 119, 122], 'cloud'],
[[143, 248, 260], 'fog'],
[[176, 263, 266, 293, 296, 299, 302, 305, 308, 353, 356, 359], 'rain'],
[[179, 182, 185, 227, 230, 320, 323, 326, 329, 332, 335, 338, 350, 368, 371, 374, 377], 'snow'],
[[200, 386, 389, 392, 395], 'thunder']
]
const artFor = (code: number): Art => ART_BY_CODE.find(([codes]) => codes.includes(code))?.[1] ?? 'cloud'
const ART: Record<Art, readonly string[]> = {
sun: [' \\ / ', ' .-. ', ' ― ( ) ― ', " `-' ", ' / \\ '],
cloud: [' ', ' .--. ', ' .-( ). ', ' (___.__)__) ', ' '],
fog: [' ', ' _ - _ - _ - ', ' _ - _ - _ ', ' _ - _ - _ - ', ' '],
rain: [' .-. ', ' ( ). ', ' (___(__) ', ' ʻʻʻʻ ', ' ʻʻʻʻ '],
snow: [' .-. ', ' ( ). ', ' (___(__) ', ' * * * * ', ' * * * * '],
thunder: [' .-. ', ' ( ). ', ' (___(__) ', ' ⚡‚ʻ⚡‚ʻ ', ' ‚ʻ⚡‚ʻ⚡ ']
}
/** Art tint rides the theme family: sun in primary gold, rain in the shell
* blue, fog in muted never hardcoded hexes. */
const artColor = (art: Art, t: Theme): string =>
({
cloud: t.color.muted,
fog: t.color.muted,
rain: t.color.shellDollar,
snow: t.color.text,
sun: t.color.primary,
thunder: t.color.warn
})[art]
async function fetchReport(location: string): Promise<Report> {
const res = await fetch(`https://wttr.in/${encodeURIComponent(location)}?format=j1`, {
headers: { 'User-Agent': 'hermes-tui-weather' },
signal: AbortSignal.timeout(10_000)
})
if (!res.ok) {
throw new Error(`wttr.in answered ${res.status}`)
}
const data = (await res.json()) as {
current_condition?: {
FeelsLikeC?: string
humidity?: string
temp_C?: string
weatherCode?: string
weatherDesc?: { value?: string }[]
windspeedKmph?: string
}[]
nearest_area?: { areaName?: { value?: string }[]; country?: { value?: string }[] }[]
}
const now = data.current_condition?.[0]
const area = data.nearest_area?.[0]
if (!now) {
throw new Error('no current conditions in reply')
}
return {
area: [area?.areaName?.[0]?.value, area?.country?.[0]?.value].filter(Boolean).join(', ') || location || 'here',
condition: now.weatherDesc?.[0]?.value ?? 'unknown',
feelsC: now.FeelsLikeC ?? '?',
humidity: now.humidity ?? '?',
tempC: now.temp_C ?? '?',
weatherCode: Number(now.weatherCode ?? 116),
windKmph: now.windspeedKmph ?? '?'
}
}
function load(location: string): void {
fetchReport(location).then(
report => updateWidget(weatherApp, state => ({ ...state, phase: { kind: 'ready', report } as Phase })),
(error: unknown) =>
updateWidget(weatherApp, state => ({
...state,
phase: { kind: 'error', message: error instanceof Error ? error.message : String(error) } as Phase
}))
)
}
export const weatherApp = defineWidgetApp<WeatherState>({
id: 'weather',
usage: USAGE,
init(arg) {
const location = arg.trim()
load(location)
return { location, phase: { kind: 'loading' } }
},
reduce(state, { ch, key }) {
if (key.escape || key.return || ch === 'q' || isCtrl(key, ch, 'c')) {
return null
}
if (ch === 'r') {
load(state.location)
return { ...state, phase: { kind: 'loading' } }
}
return state
},
render({ cols, state, t }) {
const { phase } = state
const title = phase.kind === 'ready' ? phase.report.area : 'Weather'
return (
<Overlay backdrop zone="center">
<Dialog hint="r refresh · Esc/q close" title={title} width={Math.min(46, cols - 8)}>
{phase.kind === 'loading' && <Text color={t.color.muted}>fetching wttr.in</Text>}
{phase.kind === 'error' && <Text color={t.color.error}>{phase.message}</Text>}
{phase.kind === 'ready' && <ReadyBody report={phase.report} t={t} />}
</Dialog>
</Overlay>
)
}
})
function ReadyBody({ report, t }: { report: Report; t: Theme }) {
const art = artFor(report.weatherCode)
return (
<Box flexDirection="row" gap={2}>
<Box flexDirection="column" flexShrink={0}>
{ART[art].map((line, i) => (
<Text color={artColor(art, t)} key={i}>
{line}
</Text>
))}
</Box>
<Box flexDirection="column">
<Text color={t.color.label}>{report.condition}</Text>
<Text color={t.color.text}>
{report.tempC}°C <Text color={t.color.muted}>(feels {report.feelsC}°C)</Text>
</Text>
<Text color={t.color.muted}>wind {report.windKmph} km/h</Text>
<Text color={t.color.muted}>humidity {report.humidity}%</Text>
</Box>
</Box>
)
}

View file

@ -43,6 +43,20 @@ export function openWidget<S>(app: WidgetApp<S>, state: S): void {
patchOverlayState({ widget: { appId: app.id, state } })
}
/** Async state delivery: patch the app's state ONLY while it is still the
* active widget a late fetch resolution can never resurrect a closed app
* or clobber a different one. This is how data-backed apps land results
* outside the input pipeline (see the weather reference app). */
export function updateWidget<S>(app: WidgetApp<S>, fn: (state: S) => S): void {
const active = $overlayState.get().widget
if (active?.appId !== app.id) {
return
}
patchOverlayState({ widget: { appId: app.id, state: fn(active.state as S) } })
}
/** Feed one keypress to the active app. Returns true when a widget is active
* (apps swallow every key while open the overlay is modal). */
export function dispatchWidgetInput(input: WidgetInput): boolean {

View file

@ -43,6 +43,6 @@ export {
export type { Theme, ThemeColors } from '../theme.js'
// App contract + host
export { ActiveWidgetSlot, closeWidget, dispatchWidgetInput, launchWidget } from './host.js'
export { ActiveWidgetSlot, closeWidget, dispatchWidgetInput, launchWidget, openWidget, updateWidget } from './host.js'
export { defineWidgetApp, getWidgetApp, listWidgetApps } from './registry.js'
export { type ActiveWidget, isCtrl, type WidgetApp, type WidgetInput, type WidgetRenderCtx } from './types.js'