Surface firing error causes and label the charts for screen readers (#164, #170) - #235
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 97c4dc40d6
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| } | ||
| }, [inError, queryClient]); | ||
| const { data: systemInfo } = useSystemInfo(); | ||
| const errorCode = inError ? systemInfo?.lastErrorCode : undefined; |
There was a problem hiding this comment.
Clear the cached cause before showing a new failure
When a second firing enters error after a previous failure, React Query retains the earlier systemInfo value while invalidateQueries refetches, so this immediately displays the previous firing's error cause and guidance. For example, an earlier thermocouple fault followed by a not-rising fault will initially report the thermocouple fault, and it remains wrong indefinitely if the refetch fails; use the generic description while this error transition's request is pending or otherwise associate the fetched code with the current transition.
Useful? React with 👍 / 👎.
| describeFiringChart({ | ||
| points: currentTempData, | ||
| hasPlanned: profilePath.length > 0, |
There was a problem hiding this comment.
Do not announce the placeholder as a real reading
When the controller is offline or the initial REST and WebSocket requests have not produced data, currentTempData still contains the store's synthetic 20°C seed, so this label announces “1 reading, currently 20°C” even though no temperature has been received. That can leave a screen-reader user with a fabricated kiln reading indefinitely during an outage; pass an empty series until either the status request or a WebSocket frame establishes that the data is real.
Useful? React with 👍 / 👎.
| return ( | ||
| `Temperature trace for ${args.profileName}. ${args.points.length} readings over ` + | ||
| `${durationMin} minute${durationMin === 1 ? "" : "s"}, peak ${formatTemp(peak, args.unit)}.` |
There was a problem hiding this comment.
Include the trace endpoint in the accessible summary
For a history trace that cools after reaching its peak, this accessible replacement reports only duration and peak, so a firing ending near room temperature and one ending at peak temperature announce identically despite conveying very different curves visually. Include at least the final temperature in the summary so screen-reader users can determine whether and how far the firing cooled.
Useful? React with 👍 / 👎.
| hints at how to get out of it (#164). */} | ||
| {systemInfo?.emergencyStop && ( | ||
| <p className="text-xs text-muted-foreground mt-1"> | ||
| {firingErrorGuidance(FIRING_ERROR_CODES.EMERGENCY_STOP)} |
There was a problem hiding this comment.
Use the actual trip code for emergency-stop guidance
When emergencyStop is active because of a thermocouple fault, over-temperature event, not-rising condition, or runaway, this always renders the code-5 guidance telling the operator that starting a new firing clears a stale stop. All of those safety trips set the same emergency flag, while lastErrorCode preserves their actual cause, so this can show unsafe and contradictory advice such as “Over temperature” followed by an instruction to start another firing; select guidance from systemInfo.lastErrorCode and omit it for causes that deliberately have no safe remote remedy.
Useful? React with 👍 / 👎.
| return `Temperature trace for ${args.profileName}. No trace data recorded.`; | ||
| } | ||
| const temps = args.points.map((p) => p.temp_c); | ||
| const peak = Math.max(...temps); |
There was a problem hiding this comment.
Avoid spreading unbounded traces into Math.max
For a sufficiently long firing trace, spreading every temperature as function arguments throws RangeError: Maximum call stack size exceeded during render and blanks the history detail. The firmware records one point per minute, supports an indefinite hold, and has enough SPIFFS space for more than the roughly 125,000 arguments at which this fails in V8, so multi-month or otherwise very long traces can reach this path; compute the peak with an iterative reduction instead.
Useful? React with 👍 / 👎.
|
All five findings verified against the firmware and fixed in f0a3ed2. Details, in severity order. P1 — emergency-stop guidance used a hardcoded codeConfirmed, and it was the worst kind of wrong: the copy was actively unsafe.
New Verified end-to-end in the browser with the mock reporting P2 — cached cause shown for a new failureConfirmed. My PR text claimed the banner "renders the generic line until the code arrives"; that was only true on first load. On a second failure React Query keeps serving the first failure's value while refetching, and keeps it forever if the refetch fails. Fixed by recording Worth noting why it landed in the store: my first two attempts were a The first version of this test was vacuous. Also fixed P2 —
|
| Check | Result |
|---|---|
npx vitest run |
213 passed, 1 skipped (22 files) — was 201 |
npx tsc --noEmit |
0 |
npx eslint . --max-warnings 0 |
0 |
npx prettier --check . |
0 |
The one thing I did not fix: the synthetic seed is still drawn on the chart itself, so a sighted user still sees a phantom 20°C point during an outage. That predates this PR and belongs with #239 rather than here.
f0a3ed2 to
1e4c157
Compare
|
Rebased onto The synthetic-20°C finding is now fixed upstream, not by me. #247 deleted the seed outright —
One conflict-resolution catch worth recording. The chart conflict was mine-vs-theirs on the whole
|
Closes #164. Closes #170.
#164 — the error cause was never shown
The firmware has always known why a firing failed and has always sent it. The web UI dropped it at every one of the three places it could have been shown:
E3Kiln not heating (E3)errorrole="alert"Errorbadge, nothing elsedescribeFiringError()mirrorserror_code_description()incomponents/display/dashboard.cword for word — someone standing at the kiln comparing the LCD to their phone should get one story, not two.firingErrorGuidance()returnsnullrather than filler when there is nothing honest to add: over-temp and runaway have no safe instruction you can act on from a phone, so they get a description and no false confidence.The emergency-stop row said
ACTIVEand stopped there. My first draft told the operator to "clear the emergency stop in Settings" — there is no such control anywhere in the UI. Starting a firing callssafety_clear_emergency()(firing_engine.c:809) andsafety_taskre-trips within 500 ms if the fault is real. The copy now says that.One wrinkle worth flagging
The live status payload carries no error code — only
/api/v1/systemdoes.useSystemInfo()never refetches on its own, so reading it directly would have shown whatever was cached when Settings last loaded, which is almost always "none". The dashboard now invalidates that query on the edge into error, and renders the generic line until the code arrives so the banner is never empty.Adding
errorCodeto the status payload would be cleaner, but that means touchingfiring_progress_t, the REST serializer, the WS broadcast, the mock and the fixtures. Left for a follow-up rather than smuggled in here.#170 — the charts were silent
Recharts emits a bare
<svg>of<path>elements with no accessible name, so a screen reader announced nothing where a sighted user sees the whole firing. Both charts are nowrole="img"with a generated summary;role="img"also makes the descendants presentational, so axis ticks stop leaking out as a stream of loose numbers. The status badge is a polite live region — status changes on its own as the firing advances, with no user action to anchor them.Tests
chartAria.test.ts(7) andfiringError.test.ts(5). Two of the a11y assertions were checked by breaking the implementation and confirming they fail:1 failed(Math.max()of[]is-Infinity, so an idle dashboard would announce "peak -Infinity°C")expected 'Temperature trace for Bisque. 3 readi…' to contain '60 minutes'There is a real double-conversion trap here:
buildChartDataalready converts to the display unit, sodescribeFiringCharttakes the raw Celsius series instead of the assembledChartPoint[]. A test pins that (572°F, not1062).npx vitest runnpx tsc --noEmitnpx eslint . --max-warnings 0Verified in the browser
Not just unit-tested — driven against real API responses on the dev server, with
/api/v1/systemstubbed to reportlastErrorCode: 1andemergencyStop: true:Last ErrorrendersThermocouple disconnected or shorted (E1)ACTIVEaria-label→Temperature chart. 1 reading, currently 20°C, peak 20°C.role="alert",aria-live="assertive", cause + remedyKnown gap
The mock server cannot produce an error firing at all — every history record is hardcoded
errorCode: 0and the simulator never entersstatus: "error". So none of the #164 UI is reachable in the published demo or in local mock-server development, which is why the verification above needed stubbing. Same mock/firmware divergence class as #131. Filing separately.🤖 Generated with Claude Code