Files
ottyl/webapp/src/components/Docs.tsx
T
2026-09-06 10:36:26 +01:00

586 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef, useState, type ReactNode } from 'react'
import type { DocsTarget } from '../lib/docsNav'
/// One accordion section: title plus body as plain JSX, avoiding a markdown renderer for these static pages.
interface DocSection {
id: string
title: string
body: ReactNode
}
function Code({ children }: { children: string }) {
return <code className="rounded bg-surface-2 px-1 py-0.5 font-mono text-[11px] text-text">{children}</code>
}
const SECTIONS: DocSection[] = [
{
id: 'general',
title: 'General idea',
body: (
<div className="flex flex-col gap-2">
<p>ottyl turns a CLI program's stdout into a dashboard. Pipe a program's output into it:</p>
<p>
<Code>{'cli_command --verbose | ottyl'}</Code>
</p>
<p>
It indexes each line in memory as it arrives and serves the dashboard from the same process. Everything —
log lines, params, metrics, and derived buckets — lives in that in-memory index for the process's
lifetime. Nothing touches disk unless you save a <em>profile</em> or change a <em>setting</em>.
</p>
<p className="font-semibold text-text">CLI arguments</p>
<ul className="list-disc pl-5">
<li>
<Code>--port</Code> — port to serve on. Example: <Code>ottyl --port 9000</Code>. Default: an OS-assigned
free port.
</li>
<li>
<Code>--settings-path</Code> — path to the settings file. Example:{' '}
<Code>ottyl --settings-path ./ottyl-settings.json</Code>. Default: a per-OS config location, shown by{' '}
<Code>ottyl --help</Code>.
</li>
<li>
<Code>--file</Code> — tail a file instead of reading stdin, like <Code>tail -f</Code>. Example:{' '}
<Code>ottyl --file /var/log/app.log</Code>.
</li>
<li>
<Code>--metrics-url</Code> — poll a Prometheus/OpenMetrics endpoint for metrics, alongside log ingestion.
Example: <Code>ottyl --metrics-url http://localhost:9100/metrics</Code>. Scraped samples are queryable by
metric name in PromQL and dashboards immediately, with no declaration step.
</li>
</ul>
<p className="font-semibold text-text">Settings</p>
<p>
One <em>settings</em> file per machine holds the active style (a built-in colour scheme or an
imported/linked stylesheet — see below), the ntfy sink, and which profile is active. Settings only tracks
profile pointers — the profiles themselves (params, metrics, dashboards) are stored separately, next to the
settings file by default.
</p>
<p className="font-semibold text-text">Profiles</p>
<p>
A profile bundles the parsing/indexing rules for one CLI app — its params and metrics, plus any saved
dashboards — so restarting ottyl against the same program keeps your setup. The <Code>Default</Code>{' '}
profile always exists and can't be deleted. The profile dropdown's "+" button creates a new profile as a
copy of the active one, and its "×" button deletes the active profile. Switching profiles hot-swaps the
running engine's declared params/metrics immediately, with no restart.
</p>
<p className="font-semibold text-text">Worked example</p>
<p>
Piping a webserver's access log — <Code>{'tail -f access.log | ottyl'}</Code> — into a profile named{' '}
<Code>webserver</Code>: create the profile from the settings menu, declare params/metrics against it (see
the <em>Parameters</em>, <em>Metrics</em>, and <em>Dashboards</em> sections below), then next time you run
the same command, switch to <Code>webserver</Code> and everything you declared is already there —
no re-declaring params or rebuilding dashboards.
</p>
</div>
),
},
{
id: 'filtering',
title: 'Filtering',
body: (
<div className="flex flex-col gap-2">
<p>
The filter bar combines regex search over raw log text with comparisons against declared metrics, joined
by <Code>and</Code>/<Code>or</Code>. Examples:
</p>
<ul className="list-disc pl-5">
<li>
<Code>{'regex("error")'}</Code> — lines containing "error".
</li>
<li>
<Code>{'regex("error") and ottyl_seq<5'}</Code> — lines containing "error" with sequence under 5.
</li>
<li>
<Code>{'requests[duration]>100'}</Code> — the <Code>requests</Code> metric's captured{' '}
<Code>duration</Code> param over 100.
</li>
<li>
<Code>{'ottyl_ts>-5m'}</Code> — lines from the last 5 minutes.
</li>
<li>
<Code>{'ottyl_fmt==json'}</Code> — lines auto-detected as JSON.
</li>
</ul>
<p>
<Code>ottyl_seq</Code>, <Code>ottyl_ts</Code>, and <Code>ottyl_fmt</Code> are reserved metric names present
on every log line, so they're always filterable without declaring anything. <Code>ottyl_fmt</Code> holds the
auto-detected format of the line's text — <Code>log_stream</Code> (macOS <Code>log stream</Code> output),{' '}
<Code>json</Code>, <Code>logfmt</Code>, or <Code>none</Code>. A <Code>log_stream</Code> line also gets{' '}
<Code>log_type</Code>, <Code>process</Code>, and <Code>pid</Code> params derived automatically from its
fixed-column header. All three reserved names also appear (flagged <Code>reserved</Code>) in{' '}
<Code>GET /api/params</Code> for discoverability, though there's no UI to edit or remove them — see also{' '}
<em>Dashboards</em> below for using <Code>ottyl_seq</Code>/<Code>ottyl_ts</Code> in a panel query.
</p>
<p className="font-semibold text-text">Date/time formats</p>
<p>Timestamp comparisons accept three forms:</p>
<ul className="list-disc pl-5">
<li>
relative offsets from now — <Code>5h</Code> (5 hours from now), <Code>-3m</Code> (3 minutes ago)
</li>
<li>
absolute clock times — <Code>12:00</Code>, <Code>09:30:00</Code>
</li>
<li>
full dates — <Code>2026-01-15</Code>, <Code>2026-01-15T09:30:00Z</Code>
</li>
</ul>
<p>
The filter string is evaluated on the server. A parse error shows as inline text under the filter bar.
</p>
<p className="font-semibold text-text">Worked examples</p>
<ul className="list-disc pl-5">
<li>
Only lines mentioning "timeout" in the last 15 minutes:{' '}
<Code>{'regex("timeout") and ottyl_ts>-15m'}</Code>
</li>
<li>
Requests slower than 200ms, once a <Code>requests</Code> metric has a <Code>value</Code>-role{' '}
<Code>duration</Code> param declared: <Code>{'requests[duration]>200'}</Code>
</li>
<li>
5xx responses on one path, given a <Code>requests</Code> metric with a <Code>path</Code> label param —
note that <Code>[param]</Code> comparisons only work against a <em>declared metric's</em> labels, not a
bare param declared on its own, and values are unquoted (no surrounding <Code>"..."</Code>):{' '}
<Code>{'requests[path]==/checkout and regex("5[0-9]{2}")'}</Code>
</li>
<li>
Everything before noon on a specific date: <Code>{'ottyl_ts<2024-06-15T12:00:00Z'}</Code>
</li>
</ul>
</div>
),
},
{
id: 'auto-formats',
title: 'Auto-detected formats',
body: (
<div className="flex flex-col gap-2">
<p>
Every ingested line is classified into one of four formats, exposed as the reserved{' '}
<Code>ottyl_fmt</Code> param (see <em>Filtering</em> above). The format also decides what — if anything —
gets auto-declared as a param without you doing anything:
</p>
<ul className="list-disc pl-5">
<li>
<span className="font-semibold text-text">
<Code>log_stream</Code>
</span>{' '}
— macOS <Code>{'log stream'}</Code> output's fixed-column shape (timestamp, thread, type, activity id,
pid, ttl, then <Code>process: message</Code>). The first such line seen auto-declares{' '}
<Code>log_type</Code>, <Code>process</Code>, and <Code>pid</Code> as params, parsed straight out of those
columns.
</li>
<li>
<span className="font-semibold text-text">
<Code>json</Code>
</span>{' '}
— the line parses as a JSON object (a bare array or scalar doesn't count). Every leaf scalar field
(string/number/bool) auto-declares a param named after its dot-separated path, extracted by JSON field
path rather than regex — a nested field like <Code>{'{"request":{"path":"/x"}}'}</Code> becomes a param
named <Code>request.path</Code>. Nested objects are walked into automatically; arrays and <Code>null</Code>{' '}
fields are skipped since there's no single scalar value to declare a param against.
</li>
<li>
<span className="font-semibold text-text">
<Code>logfmt</Code>
</span>{' '}
— the line contains at least one <Code>key=value</Code>/<Code>{'key="value"'}</Code> pair anywhere in its
text (it doesn't need to be logfmt end-to-end — a free-text prefix or suffix around the pairs is fine).
Every distinct key auto-declares a param of the same name, type-inferred as <Code>number</Code> if its
first-seen value parses as one, else <Code>string</Code>.
</li>
<li>
<span className="font-semibold text-text">
<Code>none</Code>
</span>{' '}
— none of the above; plain prose with no structure ottyl can key off of. Nothing is auto-declared, though
the line is still fully searchable via <Code>regex(...)</Code> in the filter bar.
</li>
</ul>
<p>
Auto-declared params are flagged <Code>auto_derived</Code> in <Code>GET /api/params</Code> and show up as
log-table columns immediately, same as a manually declared param — but they can't be edited or removed
individually, and they're never persisted to a profile (so they're rediscovered fresh each run rather than
traveling with a profile like manual declarations do). A manual param declaration for a name always takes
precedence and is never overwritten by auto-detection. To keep one unusual line from flooding the params
list, auto-detection is capped at 64 auto-derived params total and 8 new ones per line — once either cap is
hit, further would-be auto-declarations from new keys/fields are simply skipped.
</p>
</div>
),
},
{
id: 'parameters',
title: 'Parameters',
body: (
<div className="flex flex-col gap-2">
<p>
A parameter declaration derives a named value from a log line: a regex the message must match, a capture
group, and a type (<Code>string</Code>, <Code>number</Code>, or <Code>timestamp</Code>). Example:
</p>
<ul className="list-disc pl-5">
<li>
Name: <Code>duration</Code>
</li>
<li>
Regex: <Code>{'duration=(?P<duration>\\d+)ms'}</Code>
</li>
<li>
Capture group: <Code>duration</Code>
</li>
<li>
Type: <Code>number</Code>
</li>
</ul>
<p>
Declaring a param indexes every already-ingested line immediately, then checks every new line as it
arrives. It shows up right away as a column in the log table. To filter on it (e.g.{' '}
<Code>requests[duration]&gt;100</Code>), declare it as a param on a metric instead — see the Metrics
section below.
</p>
<p className="font-semibold text-text">Worked example</p>
<p>
Given lines like <Code>{'request path=/checkout method=GET duration=182'}</Code>, declaring a param named{' '}
<Code>duration</Code> with regex <Code>{String.raw`duration=(?P<duration>\d+)`}</Code>, capture group{' '}
<Code>duration</Code>, and type <Code>number</Code> makes <Code>duration</Code> show up as a column in the
log table for every line that matched, and comparable in the filter bar once it's also declared as part of
a metric (see <em>Filtering</em> above — bare params aren't queryable by name on their own, only metric
labels/values are).
</p>
<p>
Given the same kind of lines, declaring a param named <Code>IsError</Code> with regex{' '}
<Code>{String.raw`(?i)error`}</Code> and type <Code>boolean</Code> — no capture group needed — shows{' '}
<Code>IsError</Code> as <Code>true</Code> or <Code>false</Code> on every line in the log table, based on
whether the regex matches that line's text case-insensitively. Unlike <Code>string</Code>/<Code>number</Code>
/<Code>timestamp</Code>/<Code>duration</Code> params, a <Code>boolean</Code> param is never omitted for a
non-matching line.
</p>
</div>
),
},
{
id: 'timeline',
title: 'Timeline',
body: (
<div className="flex flex-col gap-2">
<p>
The timeline above the log table renders log volume per bucket over the active window. Drag across it to
select a fixed range, narrowing the log table, metrics, and dashboards below to that window.
</p>
<p>
The preset buttons — <Code>All</Code>, <Code>1m</Code>, <Code>2m</Code>, <Code>5m</Code>, <Code>15m</Code>,{' '}
<Code>1h</Code>, <Code>24h</Code> — are quick-range shortcuts. <Code>All</Code> clears the range. Every
other preset is <em>rolling</em>: its window keeps advancing to stay "last N minutes from now" instead of
freezing at selection time. Dragging while a preset is active switches to a fixed range.
</p>
</div>
),
},
{
id: 'log-preview',
title: 'Log preview',
body: (
<div className="flex flex-col gap-2">
<p>
The log table lists ingested lines newest-last, each with its sequence number, timestamp, and raw text. The
column picker (top-right) adds a column per declared param, showing that line's derived value (blank if it
didn't match).
</p>
<p>
Scrolling stays anchored to the row you're looking at as new lines arrive or old ones age out of a rolling
window — the view doesn't jump when content is pruned from the top.
</p>
</div>
),
},
{
id: 'metrics',
title: 'Metrics',
body: (
<div className="flex flex-col gap-2">
<p>
Every metric is a <em>counter</em>: a message-match regex paired with one or more named param matchers
(each with its own capture group and type). Every log line matching the regex is one counter increment — a
timestamped occurrence, tagged with the labels captured from that line. Example:
</p>
<ul className="list-disc pl-5">
<li>
Name: <Code>requests</Code>
</li>
<li>
Regex: <Code>{'request path=(?P<path>\\S+) duration=(?P<duration>\\d+)ms'}</Code>
</li>
<li>
Params: <Code>path</Code> (capture group <Code>path</Code>, type <Code>string</Code>, role{' '}
<Code>label</Code>), <Code>duration</Code> (capture group <Code>duration</Code>, type <Code>number</Code>
, role <Code>value</Code>)
</li>
</ul>
<p>
Each param is either a <strong>label</strong> (the default) or the metric's single <strong>value</strong>{' '}
param. Labels identify which bucket an occurrence belongs to. A value param's captured number becomes the
occurrence's stored value instead of a label — in the example above, <Code>duration</Code> lets you query
the distribution or latest reading, not just how often the line occurred. A metric with only label params
defaults every occurrence's value to a fixed <Code>1</Code> (a pure occurrence count).
</p>
<p>
Declaring a metric backfills it against everything already ingested, then keeps indexing new lines as they
arrive — no need to restart ingestion or re-declare after the fact.
</p>
<p>
The Metrics panel lets you declare a metric, pick one from the declared list, and see its bucket values
grouped by label combination over the active time range — occurrence count per group, plus a small trend
sparkline. Dashboard panels query that same data via PromQL (see below), including two forms{' '}
<em>derived</em> from a counter's value param rather than stored separately:
</p>
<ul className="list-disc pl-5">
<li>
<span className="font-semibold text-text">
<Code>histogram(metric_name[width])</Code>
</span>{' '}
— buckets each matching event's value into fixed-width buckets of size <Code>width</Code>, e.g.{' '}
<Code>histogram(durations[10])</Code>.
</li>
<li>
<span className="font-semibold text-text">
<Code>gauge(metric_name)</Code>
</span>{' '}
— the most-recent value per label combination in the active range, i.e. the counter collapsed to its
latest reading.
</li>
</ul>
<p className="font-semibold text-text">Worked example</p>
<p>
To count requests per path and track their latency from lines like{' '}
<Code>{'request path=/checkout method=GET duration=182'}</Code>, declare a metric named{' '}
<Code>requests</Code> with message-match regex{' '}
<Code>{String.raw`request path=(?P<path>\S+) method=(?P<method>\S+) duration=(?P<duration>\d+)`}</Code>{' '}
and two params: <Code>path</Code> (capture group <Code>path</Code>, kind <Code>string</Code>, role{' '}
<Code>label</Code>) and <Code>duration</Code> (capture group <Code>duration</Code>, kind{' '}
<Code>number</Code>, role <Code>value</Code> — at most one param can be <Code>value</Code>). Selecting{' '}
<Code>requests</Code> in the panel then shows one row per distinct <Code>path</Code> with its occurrence
count and a trend sparkline. The same declaration also unlocks{' '}
<Code>{'histogram(requests[50])'}</Code> (duration distribution in 50ms-wide buckets) and{' '}
<Code>gauge(requests)</Code> (each path's most recent duration) as dashboard panel queries.
</p>
<p>
A param of kind <Code>duration</Code> marked <Code>value</Code> is a special case: rather than parsing the
capture group as a number, its value is derived automatically as the elapsed time since the previous
matching event with the same labels — useful for measuring the gap between repeated events (e.g. time
between retries) without the log line itself reporting a duration.
</p>
</div>
),
},
{
id: 'dashboards',
title: 'Dashboards',
body: (
<div className="flex flex-col gap-2">
<p>
Dashboards work like Grafana: each is a named set of panels, and each panel is a PromQL query paired with a
chart type — bar, line, or pie. Panels preview live as you edit the query, and a malformed query shows an
inline error instead of breaking the page.
</p>
<ul className="list-disc pl-5">
<li>
<span className="font-semibold text-text">Line</span> — one series per label combination, time on the
x-axis.
</li>
<li>
<span className="font-semibold text-text">Bar</span> — the latest value per series (a snapshot of "now").
</li>
<li>
<span className="font-semibold text-text">Pie</span> — each series' share of the summed total across the
active range.
</li>
</ul>
<p>
Dashboards are saved per profile, so they travel with it when you switch or copy it — an active profile is
required to create or save one; pick or create one from the settings menu first.
</p>
<p>
The reserved <Code>ottyl_seq</Code>/<Code>ottyl_ts</Code> names (see <em>Filtering</em> above) work in panel
queries the same as any declared metric — e.g. <Code>count(ottyl_seq)</Code> for log volume, or{' '}
<Code>rate(ottyl_ts[1m])</Code> for ingest rate — since every log line carries them without a metric
declaration. <Code>ottyl_fmt</Code> is categorical (<Code>json</Code>/<Code>logfmt</Code>/<Code>none</Code>
), not a number, so it's rejected in a panel query with an explicit error rather than silently charting
nothing — filter on it in the filter bar instead.
</p>
</div>
),
},
{
id: 'alerts',
title: 'Alerts',
body: (
<div className="flex flex-col gap-2">
<p>
An alert rule fires a notification when its condition matches a newly-ingested log line, evaluated per line
as it arrives. There are four condition types:
</p>
<ul className="list-disc pl-5">
<li>
<span className="font-semibold text-text">Immediate</span> — fires once per matching line. Example query:{' '}
<Code>{'regex("panic")'}</Code>.
</li>
<li>
<span className="font-semibold text-text">Threshold</span> — fires when a query's match count within a
rolling window crosses a numeric threshold. Example: query <Code>{'regex("error")'}</Code>, op{' '}
<Code>&gt;=</Code>, threshold <Code>10</Code>, window <Code>1m</Code>.
</li>
<li>
<span className="font-semibold text-text">NewValue</span> — fires the first time a field regex captures a
value not seen before by this rule. Example: field regex{' '}
<Code>{'user_id=(?P<id>\\d+)'}</Code>, capture group <Code>id</Code>. Seen values live only in memory, so
a restart forgets them.
</li>
<li>
<span className="font-semibold text-text">Ratio</span> — fires when the ratio of two queries' match
counts within a rolling window crosses a numeric ratio. Example: query A{' '}
<Code>{'regex("error")'}</Code>, query B <Code>{'regex("request")'}</Code>, op <Code>&gt;=</Code>, ratio{' '}
<Code>0.1</Code>, window <Code>5m</Code>.
</li>
</ul>
<p>
Query-based conditions (Immediate/Threshold/Ratio) reuse the filter bar's syntax, restricted to{' '}
<Code>regex(...)</Code>, <Code>ottyl_seq</Code>, <Code>ottyl_ts</Code>, <Code>ottyl_fmt</Code>, and{' '}
<Code>and</Code>/<Code>or</Code>. A <Code>metric_name[param]</Code> comparison isn't supported in an alert
query.
</p>
<p>
A window accepts a plain number of milliseconds or a humantime-style duration: <Code>30s</Code>,{' '}
<Code>5m</Code>, <Code>1h</Code>.
</p>
<p>
Every rule delivers to the single ntfy sink configured in Settings: a topic and an optional server override
(default <Code>https://ntfy.sh</Code>). Configure the sink and send a test notification from the Settings
panel — the Alerts panel only defines rule conditions.
</p>
<p className="font-semibold text-text">Worked example: build a "requests" dashboard</p>
<p>
Assuming a <Code>requests</Code> metric declared as in the <em>Metrics</em> example above (a <Code>path</Code>{' '}
label and a <Code>duration</Code> value param), a useful dashboard is three panels:
</p>
<ul className="list-disc pl-5">
<li>
<span className="font-semibold text-text">Line</span>, query <Code>requests</Code> — one line per path,
showing every request occurrence over time.
</li>
<li>
<span className="font-semibold text-text">Bar</span>, query <Code>gauge(requests)</Code> — each path's
most recent request duration side by side, a quick "who's slow right now" snapshot.
</li>
<li>
<span className="font-semibold text-text">Pie</span>, query{' '}
<Code>{'requests{path="/checkout"}'}</Code>{' '}
— label matchers filter a query down to one series; here, just the checkout path's share of its own
occurrences over the range (mostly useful once more matchers are added to compare paths against each
other).
</li>
</ul>
<p>
Other query shapes worth knowing: <Code>{'sum(requests)'}</Code> collapses every path into one combined
count-over-time series, and <Code>{'rate(requests[5m])'}</Code> gives each path's occurrences-per-second
over a trailing 5-minute window — both work with any of the three chart types, since chart type is just how
the returned series get drawn, not part of the query itself. Add a panel, type a query, and it previews
live; a malformed query (e.g. mismatched braces, or <Code>histogram()</Code> missing its{' '}
<Code>[width]</Code>) shows its error inline in the panel instead of breaking the dashboard — a syntactically
valid query for a metric that doesn't exist just renders "No data in range."
</p>
</div>
),
},
{
id: 'settings',
title: 'Settings',
body: (
<div className="flex flex-col gap-2">
<p>
The Settings panel controls process-wide state: the active style — one unified list of built-in colour
schemes and any imported/linked stylesheets, any of which can be a simple palette or a full layout
redesign — the ntfy sink alert rules deliver to, and the max number of logs kept in memory (blank means
unlimited). Changes apply immediately and persist to the settings file on disk. There is no save button.
</p>
<p className="font-semibold text-text">Worked example</p>
<p>
Ingesting a very chatty process and only care about the last hour of activity: set{' '}
<em>max logs in memory</em> to something like <Code>50000</Code> so older lines are dropped as new ones
arrive rather than growing memory use unbounded for the life of the process. Leave it blank to keep
everything ingested — the default, and fine for shorter-lived commands.
</p>
</div>
),
},
{
id: 'presets',
title: 'Presets',
body: (
<div className="flex flex-col gap-2">
<p>
The profile dropdown in the sidebar manages profiles. Selecting one switches to it and activates its
params/metrics in the running engine immediately, with no restart. The <Code>+</Code> button prompts for a
name and creates a new profile as a copy of the active one, then switches to it. The <Code>×</Code> button
deletes the active profile; it's disabled for <Code>Default</Code>, which can't be deleted. See{' '}
<em>General idea</em> above for what a profile stores.
</p>
</div>
),
},
]
const FLASH_DURATION_MS = 1200
export function DocsPanel({ target }: { target?: DocsTarget | null }) {
const [openId, setOpenId] = useState<string | null>(SECTIONS[0].id)
const [flashId, setFlashId] = useState<string | null>(null)
const sectionRefs = useRef<Record<string, HTMLDivElement | null>>({})
// Keyed on `target?.nonce`, not `target?.sectionId`, so clicking the same "?" icon twice re-triggers the flash instead of no-op'ing when already open.
useEffect(() => {
if (!target) return
setOpenId(target.sectionId)
const raf = requestAnimationFrame(() => {
sectionRefs.current[target.sectionId]?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
})
setFlashId(target.sectionId)
const timeout = setTimeout(() => setFlashId(null), FLASH_DURATION_MS)
return () => {
cancelAnimationFrame(raf)
clearTimeout(timeout)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [target?.nonce])
return (
<div className="flex flex-col gap-1 text-sm text-text-secondary">
{SECTIONS.map((section) => {
const isOpen = openId === section.id
const isFlashing = flashId === section.id
return (
<div
key={section.id}
ref={(el) => {
sectionRefs.current[section.id] = el
}}
className={`rounded border transition-colors duration-500 ${
isFlashing ? 'border-accent bg-accent-soft/25' : 'border-border'
}`}
>
<button
type="button"
onClick={() => setOpenId(isOpen ? null : section.id)}
className="flex w-full items-center justify-between px-3 py-2 text-left text-sm font-semibold text-text hover:bg-surface-2"
aria-expanded={isOpen}
>
{section.title}
<span className="text-text-muted">{isOpen ? '−' : '+'}</span>
</button>
{isOpen && <div className="border-t border-border px-3 py-3">{section.body}</div>}
</div>
)
})}
</div>
)
}