Extend the record to its final shape, revive the dead panels, honor the index
Three firmware waves of the gap-report program, built in parallel
worktrees against the same tip and integrated with both-sides conflict
resolution (two stitch errors — an unopened Doxygen comment and a lost
closing brace — were caught by the compiler, as intended).
Wave 1 — the record's final shape. CredentialRecord gains flags (bit0 =
auto-submit: username, TAB, password, ENTER as one BLE action — and the
trailing ENTER is now conditional where it used to be unconditional),
three typed custom fields (label/value/hidden — hidden values ride
behind the same plaintext-consent header as passwords), a group id, and
TOTP entries gain an account label plus the algorithm byte everywhere.
Groups are a 100-slot encrypted name table (one littlefs block) managed
over GET/POST/DELETE /api/groups — deleting a group only clears labels,
never credentials. The index row carries group, a derived domain column
and real mtime, with names widened to 32 chars; brand is populated at
save time from the url, ending the per-row decrypt the device paid for
logos. URL capacity rises to 192 (the owner's real data maxed at 190),
and every save/import reports per-field truncation instead of silently
clamping. Backup, restore, the normalized importer and the CSV importer
round-trip all of it; the importer maps 1Password vaults and CSV
folder/tags/vault columns into groups server-side.
Wave 2 — six dead routes live: /api/ota/status (the whole Firmware
panel keyed on it), /api/vault/reindex, /api/time/diag (RTC coin-cell
health), the GPS trio (gated to boards that have one), NTP-sync-on-exit
through the AdminMode teardown choke point, and the staged-SD firmware
apply behind the confirm gate. Both full-replace restores now demand
the unlock secret (X-Unlock-Secret) — a backup passphrase proves
nothing about owning the live vault — and the restore body cap is
computed from the device's own backup bound instead of a 64 KB literal.
Wave 3 — the UI stops lying: the audit export honors Accept: text/csv
with real CSV, the web CSV importer honors the type column (Bitwarden
non-logins skipped and counted), LastPass and Proton Pass get real
header signatures, /api/runtime-stats is gone, AutoLockMode appears on
the six boards that hid a working handler, and the device vault lists
honor favorite-first plus manual order with an allocation-free
comparator.
Integration extras: the index envelope buffers and the rekey crypto
scratch now live on the heap (the rekey frame was already past the
32 KB worker stack at the current 200-slot caps — the latent overflow
the dimensioning study flagged); core2_v13's capacity-scaled view-cache
block moves to PSRAM .ext_ram.bss (keys and passwords stay in internal
DRAM), un-breaking its 48-byte link margin; the groups list
distinguishes a corrupt table from an empty one; custom:null is refused
rather than destructive; and the three amalgam suites learned the new
seams, including rebasing an unreachable handler-413 assertion onto the
gate-logic suite that actually pins the bound.
Builds: sticks3_debug, tdeck, cardputer, m5stickc_plus1_debug,
core2_v13_debug (now with SPIRAM bss). Native: full suite green with
nine new suites registered.
Claude-Session: https://claude.ai/code/session_01Q2J5gQSFMTDLVzPUYog51r
Add the StickS3 UI v2: home pager, detail carousel, TOTP, settings, onboarding
Implements the Claude Design v2 handoff on the StickS3 (135x240), built against
pixel-exact 1:1 mock references extracted from the handoff and verified by
overlay diff against captures from real silicon.
Screens
- Home pager: lock face, timing-arc PIN dial, icon tab strip, vault list.
- Credential detail: 6-stop action carousel (type, username, URL, related TOTP,
notes, delete) over the untouched decrypt/reveal/scrub paths.
- TOTP tab: per-row live codes with countdown rings.
- Settings: navigable tree with Wi-Fi, NTP, timezone and DST screens.
- Onboarding: welcome, button intros, language, five interactive component
tutorials (dial, tabs, lists, values, writing), then PIN creation.
Reusable components (screens compose; components own anatomy)
- CountdownRing, TotpCodeCard, Ipv4Field, TutorialHint, drawPinDialRimDigits and
drawListRowSelectionBand; the inline TOTP ring/code sites now delegate.
Timezone and DST
- Extends the in-house timex engine to 40 zones with POSIX TZ strings and a DST
toggle (default on). The internal epoch stays UTC: timezone and DST convert
display only, so the TOTP epoch (RFC 6238) and the lockout math never shift.
A native test pins that invariance across every zone.
Fixes found on silicon
- popup::confirm/alert blocked the TWDT-subscribed loopTask, so any modal left
open past the 5 s window rebooted the device - reproducible with the
credential delete confirm under no other load. The blocking loops now feed the
watchdog through the platform facade.
- AUDIO ON|OFF never persisted: saveSettings() had no callers repo-wide while
the help text and docs claimed it survived a reboot.
- The onboarding language A-hold confirm could never fire: wasClicked() consumed
the release edge before wasReleaseFor() ran.
- Cancelling the delete confirm exited the whole detail view, via a late release
edge leaking out of the modal.
- Settings entry landed on the second row: multi-tap consumes click edges but
leaves press edges latched, so the first tick read a stale nav.
- The video stream server ran above the vault worker on Core 0 and starved vault
I/O during capture.
QA
- 18 new hardware tests: timezone/DST, TOTP UTC invariance, NTP server
validation, Wi-Fi store, NTP sync, demo seed, audio persistence.
- The video capture test had silently skipped since WEB-05: /stream requires a
token the decoder never sent, so a 401 became a skip. It now sends the token
and fails hard on auth errors.
Native 2258/2258; sticks3, m5stack_gray, m5core_ink and tdeck build -Werror;
clang-tidy reports 0 findings across 269 TUs.
Claude-Session: https://claude.ai/code/session_01P6BNTpbgrvnZXSJnNuj8ZJ
Deliver the hold gauge fleet-wide and cut UI frame time
Recording the footer hold on video showed the gauge never moving, and
chasing that turned up three unrelated defects plus the render costs
behind them.
Hold feedback
Fifteen screens drew the rule that advertises "this action has a hold",
but only two ever filled it: the progress half was hand-rolled in one
controller and the secondary (B) gauge existed nowhere. A hold that is
charging was therefore indistinguishable from one the device missed.
Add HoldGauge + holdPct to the two-button convention (grace period so a
tap never flashes a sliver, 5% quantization so an 800 ms hold costs ~20
repaints instead of one per frame, wrap-safe, one change on release so
the partial fill is wiped exactly once) and wire every screen that
promises a hold, including the B gauge that fills from the right.
Drop the two older hold affordances the rule replaces: the accent fill
that grew across the cell behind its own label and swallowed the text,
and the success-hue recolor that announced a commit from the first few
percent. Mono keeps its whole-cell invert, having no gauge to fill.
Partial repaints
An unbracketed draw takes a seqlock cycle and an SPI address window per
primitive, so a footer repaint streamed its band dozens of times and
churned the sequence hard enough that a reader on the services core
never completed a clean read. Bracket them. Conversely commitFrame
publishes a sequence even for an empty dirty box, so the per-tick hold
border now returns before opening a frame and steps in the gauge's own
increments rather than per perimeter pixel.
Live video stream
With a completely static screen the stream ran its full pipeline at the
target rate: 16 ms read-back, 10 ms compression and 11 ms of transfer
per frame to ship 14.5 KB of identical pixels. An unchanged
display::frameSequence() proves the framebuffer is byte-identical, so
skip all three stages and state the repeat in the header alone
(kVideoFormatRepeat); the host re-emits its previous frame, keeping the
cadence a recording is timed by. 37 ms -> 3 ms per frame, 14.5 KB -> 0 B,
25 -> 30 fps. The profile line now also reports repeat and stale shares.
Frame time
The SPI blit was transaction-bound, not clock-bound: 256-byte chunks
meant 254 DMA round-trips per frame, each costing about what its 26 us
of clock did. Size the framebuffer blit separately from the direct-draw
chunks (which are stack arrays) and raise it. 36.1 ms -> 10.4 ms.
Glyphs were rasterized one pixel at a time, each pixel paying a virtual
read, a virtual 1x1 fill, a clip and a damage record, far more than the
blend itself. Hand each glyph row to the surface as one run.
The settings tree cleared and recomposed the whole screen for every
cursor step; give it region-scoped painting like its sibling list.
Vault-list navigation 14.7 -> 42.9 Hz, settings navigation 18.6 -> 38.2 Hz.
Also: the credential type action is now a fixed-order chooser
(user+password, TOTP when present, user, password) behind an N-row
selectable modal with per-board input, list navigation wraps instead of
clamping, and the PIN dot uses the brand token on every path.
Claude-Session: https://claude.ai/code/session_01ABhkBJsMKTZAxZh4Vh7jsF
Migrate the keyboard settings panel onto the shared option model
Collapse the second of the two divergent on-device settings surfaces (catalog
§7.1) onto the one capability-driven option model. The keyboard panel
(Cardputer / T-Deck) now iterates the shared SettingsPresenter instead of a
hand-coded flat menu: a two-level group -> option tree, capability-gated per
board, keyboard-native, with every option wired to its shared handler.
Shared, de-duplicated handler surface (both renderers now call one copy):
- settings_value_format: the live per-option value/toggle/chevron resolver
(was private in settings_controller).
- settings_stepper: the stepper range/step/unit spec + commit, including the
§9.3 idle-timer ceiling clamp (was private in settings_overlays_controller).
- settings_actions: the three destructive confirms + sleep + the toggle
flip/persist (was private in settings_controller).
- keyboard_panel_dispatch: the headless, host-tested classification of every
option to its keyboard handler, so the panel's routing cannot diverge.
Keyboard-native handlers in the panel: toggles, +/- steppers, language/HID-layout
pickers, read-only info panels, BLE hosts, and TYPED text entry for device name /
Wi-Fi SSID+PSK (masked) / NTP server. The Data & Backup group (SD-gated) and the
admin portal are deferred to the shared vault-view handlers; the modal wizards
(set-clock, change-PIN, timezone, GPS sync) are delegated to the shared
SettingsController; GPS re-detect runs in-panel. Station-mode features
(NTP sync / firmware auto-update) point at the admin portal.
Adds a headless native suite asserting the model-driven behavior (no dead rows on
T-Deck/Cardputer, typed text-entry routing, the delegate/defer/confirm seams).
Claude-Session: https://claude.ai/code/session_01P6BNTpbgrvnZXSJnNuj8ZJ
Add the StickS3 UI v2: home pager, detail carousel, TOTP, settings, onboarding
Implements the Claude Design v2 handoff on the StickS3 (135x240), built against
pixel-exact 1:1 mock references extracted from the handoff and verified by
overlay diff against captures from real silicon.
Screens
- Home pager: lock face, timing-arc PIN dial, icon tab strip, vault list.
- Credential detail: 6-stop action carousel (type, username, URL, related TOTP,
notes, delete) over the untouched decrypt/reveal/scrub paths.
- TOTP tab: per-row live codes with countdown rings.
- Settings: navigable tree with Wi-Fi, NTP, timezone and DST screens.
- Onboarding: welcome, button intros, language, five interactive component
tutorials (dial, tabs, lists, values, writing), then PIN creation.
Reusable components (screens compose; components own anatomy)
- CountdownRing, TotpCodeCard, Ipv4Field, TutorialHint, drawPinDialRimDigits and
drawListRowSelectionBand; the inline TOTP ring/code sites now delegate.
Timezone and DST
- Extends the in-house timex engine to 40 zones with POSIX TZ strings and a DST
toggle (default on). The internal epoch stays UTC: timezone and DST convert
display only, so the TOTP epoch (RFC 6238) and the lockout math never shift.
A native test pins that invariance across every zone.
Fixes found on silicon
- popup::confirm/alert blocked the TWDT-subscribed loopTask, so any modal left
open past the 5 s window rebooted the device - reproducible with the
credential delete confirm under no other load. The blocking loops now feed the
watchdog through the platform facade.
- AUDIO ON|OFF never persisted: saveSettings() had no callers repo-wide while
the help text and docs claimed it survived a reboot.
- The onboarding language A-hold confirm could never fire: wasClicked() consumed
the release edge before wasReleaseFor() ran.
- Cancelling the delete confirm exited the whole detail view, via a late release
edge leaking out of the modal.
- Settings entry landed on the second row: multi-tap consumes click edges but
leaves press edges latched, so the first tick read a stale nav.
- The video stream server ran above the vault worker on Core 0 and starved vault
I/O during capture.
QA
- 18 new hardware tests: timezone/DST, TOTP UTC invariance, NTP server
validation, Wi-Fi store, NTP sync, demo seed, audio persistence.
- The video capture test had silently skipped since WEB-05: /stream requires a
token the decoder never sent, so a 401 became a skip. It now sends the token
and fails hard on auth errors.
Native 2258/2258; sticks3, m5stack_gray, m5core_ink and tdeck build -Werror;
clang-tidy reports 0 findings across 269 TUs.
Claude-Session: https://claude.ai/code/session_01P6BNTpbgrvnZXSJnNuj8ZJ
Add the StickS3 UI v2: home pager, detail carousel, TOTP, settings, onboarding
Implements the Claude Design v2 handoff on the StickS3 (135x240), built against
pixel-exact 1:1 mock references extracted from the handoff and verified by
overlay diff against captures from real silicon.
Screens
- Home pager: lock face, timing-arc PIN dial, icon tab strip, vault list.
- Credential detail: 6-stop action carousel (type, username, URL, related TOTP,
notes, delete) over the untouched decrypt/reveal/scrub paths.
- TOTP tab: per-row live codes with countdown rings.
- Settings: navigable tree with Wi-Fi, NTP, timezone and DST screens.
- Onboarding: welcome, button intros, language, five interactive component
tutorials (dial, tabs, lists, values, writing), then PIN creation.
Reusable components (screens compose; components own anatomy)
- CountdownRing, TotpCodeCard, Ipv4Field, TutorialHint, drawPinDialRimDigits and
drawListRowSelectionBand; the inline TOTP ring/code sites now delegate.
Timezone and DST
- Extends the in-house timex engine to 40 zones with POSIX TZ strings and a DST
toggle (default on). The internal epoch stays UTC: timezone and DST convert
display only, so the TOTP epoch (RFC 6238) and the lockout math never shift.
A native test pins that invariance across every zone.
Fixes found on silicon
- popup::confirm/alert blocked the TWDT-subscribed loopTask, so any modal left
open past the 5 s window rebooted the device - reproducible with the
credential delete confirm under no other load. The blocking loops now feed the
watchdog through the platform facade.
- AUDIO ON|OFF never persisted: saveSettings() had no callers repo-wide while
the help text and docs claimed it survived a reboot.
- The onboarding language A-hold confirm could never fire: wasClicked() consumed
the release edge before wasReleaseFor() ran.
- Cancelling the delete confirm exited the whole detail view, via a late release
edge leaking out of the modal.
- Settings entry landed on the second row: multi-tap consumes click edges but
leaves press edges latched, so the first tick read a stale nav.
- The video stream server ran above the vault worker on Core 0 and starved vault
I/O during capture.
QA
- 18 new hardware tests: timezone/DST, TOTP UTC invariance, NTP server
validation, Wi-Fi store, NTP sync, demo seed, audio persistence.
- The video capture test had silently skipped since WEB-05: /stream requires a
token the decoder never sent, so a 401 became a skip. It now sends the token
and fails hard on auth errors.
Native 2258/2258; sticks3, m5stack_gray, m5core_ink and tdeck build -Werror;
clang-tidy reports 0 findings across 269 TUs.
Claude-Session: https://claude.ai/code/session_01P6BNTpbgrvnZXSJnNuj8ZJ
Add the StickS3 UI v2: home pager, detail carousel, TOTP, settings, onboarding
Implements the Claude Design v2 handoff on the StickS3 (135x240), built against
pixel-exact 1:1 mock references extracted from the handoff and verified by
overlay diff against captures from real silicon.
Screens
- Home pager: lock face, timing-arc PIN dial, icon tab strip, vault list.
- Credential detail: 6-stop action carousel (type, username, URL, related TOTP,
notes, delete) over the untouched decrypt/reveal/scrub paths.
- TOTP tab: per-row live codes with countdown rings.
- Settings: navigable tree with Wi-Fi, NTP, timezone and DST screens.
- Onboarding: welcome, button intros, language, five interactive component
tutorials (dial, tabs, lists, values, writing), then PIN creation.
Reusable components (screens compose; components own anatomy)
- CountdownRing, TotpCodeCard, Ipv4Field, TutorialHint, drawPinDialRimDigits and
drawListRowSelectionBand; the inline TOTP ring/code sites now delegate.
Timezone and DST
- Extends the in-house timex engine to 40 zones with POSIX TZ strings and a DST
toggle (default on). The internal epoch stays UTC: timezone and DST convert
display only, so the TOTP epoch (RFC 6238) and the lockout math never shift.
A native test pins that invariance across every zone.
Fixes found on silicon
- popup::confirm/alert blocked the TWDT-subscribed loopTask, so any modal left
open past the 5 s window rebooted the device - reproducible with the
credential delete confirm under no other load. The blocking loops now feed the
watchdog through the platform facade.
- AUDIO ON|OFF never persisted: saveSettings() had no callers repo-wide while
the help text and docs claimed it survived a reboot.
- The onboarding language A-hold confirm could never fire: wasClicked() consumed
the release edge before wasReleaseFor() ran.
- Cancelling the delete confirm exited the whole detail view, via a late release
edge leaking out of the modal.
- Settings entry landed on the second row: multi-tap consumes click edges but
leaves press edges latched, so the first tick read a stale nav.
- The video stream server ran above the vault worker on Core 0 and starved vault
I/O during capture.
QA
- 18 new hardware tests: timezone/DST, TOTP UTC invariance, NTP server
validation, Wi-Fi store, NTP sync, demo seed, audio persistence.
- The video capture test had silently skipped since WEB-05: /stream requires a
token the decoder never sent, so a 401 became a skip. It now sends the token
and fails hard on auth errors.
Native 2258/2258; sticks3, m5stack_gray, m5core_ink and tdeck build -Werror;
clang-tidy reports 0 findings across 269 TUs.
Claude-Session: https://claude.ai/code/session_01P6BNTpbgrvnZXSJnNuj8ZJ
Add the capability-driven settings option model (phase 1)
Encode the resolved settings catalog (docs/design/settings-catalog.md §4/§9)
as a renderer-agnostic, host-testable data model: nine top-level groups (A-I)
and the flat option list inside each, every option carrying its control kind,
capability gate, responsive label tiers, and backing (NVS key or action id).
New, no live renderer touched (phase 2 wires settings_controller /
settings_nav_logic / device_panel onto this):
- src/states/settings_model.h — SettingsGroup/OptionId/SettingsControl/
SettingsGate/SettingsCapabilities/ValueBacking/LabelTiers, the constexpr
group + option tables, gateAllows/isOffered/offeredOptionCount/groupIsShown,
buildLabelVariants (functor-injected i18n resolve, no pool link), and the
ESP_PLATFORM SettingsCapabilities::fromVariant() factory. Encodes the §9
semantics: three idle timers with the deep-sleep ceiling, three orthogonal
destructive actions, HID layout Common, station-pull OTA, curated Device Info
+ Advanced, fixed brute-force info; GPS/SD/IMU/speaker/backlight/epaper/
OTA/secure rows gated.
- i18n/strings.csv (+ regenerated strings_gen.*) — settings group + option
label strings with _MED/_SHORT responsive tiers per docs/design/
settings-label-fit.md, all five languages, German shortest forms fitting the
Stick budget.
- test/states/test_settings_model — model integrity + the per-device
applicability matrix (catalog §5) across all nine variants.
- test/states/test_settings_label_fit — responsive-label selection for the
tightest German-on-Stick cases (byte-exact fit-doc widths).
- docs/design/settings-model-migration.md — the phase-2 plan wiring both live
renderers onto the model, screen by screen, in a safe order.
Claude-Session: https://claude.ai/code/session_01P6BNTpbgrvnZXSJnNuj8ZJ
Finish the admin portal surface and give destruction one bar everywhere
The six endpoint groups the portal rendered nothing for are now firmware,
one module per file, each behind the auth policy its usage demands
(polled reads are SessionReadOnly so a dashboard cannot hold the radio up):
- admin_system_api: /api/system{,/partitions,/hardware}, selftest, beep,
reboot and sleep through the FSM flag seam. Hardware rows carry PROBED
chip identities published from Core 1 through atomics — a build flag
selects a code path, it does not name a part (the StickS3 PMIC lesson).
- admin_totp_api: live one-time codes gated on clock trust before any
vault I/O, answering the whole validity window (remaining_s) so the
page polls once per period instead of once per second.
- admin_credentials_api: the list gains user/favorite/order/hasTotp, and
every delete — single or batch — is prepare (unlock secret) → commit
(one-shot nonce), executed as ONE meta commit and ONE index rewrite by
VaultStore::deleteCredentialBatch. The bare DELETE route is gone.
- admin_ble_api: device name + per-bond rename (POST, not PATCH — the
HTTP facade deliberately stays GET/POST/DELETE), with a bounded
name-store flush so a rename survives a power cycle.
- SD restore wizard: stage → gated commit (unlock secret + imported PIN +
X-Confirm-Restore) → idempotent abort, with foreign work factors
clamped to [1000, 4x local] and session teardown on any failure past
the point of no return. The ungated one-shot import route is removed.
- admin_telemetry_api: on-demand battery sampler with a columnar chunked
body, ownership-guarded so a portal claim can never stop a
debug-console capture, drained on the UI core with a bounded loop.
Destruction now has one bar everywhere: credentials, TOTP entries (new),
SD keyfile/format, wipe, factory reset and the restore commit all demand
proof of the unlock secret through admin_confirm.h. BLE bonds stay the
deliberate exception — re-pairing is recoverable.
The device side gains the owner-facing half of the pinned admin network:
a "Fixed admin network" toggle (OptionId::AdminNetworkFixed, key
ap_fixed) that arms a pin request; the password is still drawn only in
generateApPassword(), before esp_wifi_start(), where the bootloader
entropy source is sound — the toggle records intent, the draw stays in
its one proven-safe place.
The SPA follows every contract (columnar telemetry, the restore 401-with-
restored:true success screen, per-id delete outcomes), the mock mirrors
them, and portal_checks gains seven second-wave HIL checks including
"the retired routes stay retired" and "secretless destruction is refused
before any KDF".
The batch pays its own DRAM bill: the fleet build caught core2_v13_debug
overflowing dram0 by 344 bytes (classic-ESP32 debug links with a few
hundred bytes of headroom). Three trims inside the new modules return
~405 bytes of permanent .bss: the telemetry channel registry is sized to
what the fleet registers (8 slots, not 24), the staged delete batch is
heap-allocated on first use, and the device-information observations
(power snapshot, chip probes, storage figures) live in one heap block
allocated on first touch — they are session observations, not boot
state. The 32 KB vault state and 12.5 KB app context stay deliberately
static: key material must not reach PSRAM, and their fixed reservation
is the linker's honest canary.
Native: 2705/2705. Builds: full 43-env fleet (the two native test envs
that pio run cannot build alone are the known Unity flake), then
core2_v13_debug, sticks3_debug, m5stickc_plus1_debug and core2_v13
re-verified after the DRAM trims. Guards and pre-commit hooks all green.
Claude-Session: https://claude.ai/code/session_01Q2J5gQSFMTDLVzPUYog51r
Deliver the hold gauge fleet-wide and cut UI frame time
Recording the footer hold on video showed the gauge never moving, and
chasing that turned up three unrelated defects plus the render costs
behind them.
Hold feedback
Fifteen screens drew the rule that advertises "this action has a hold",
but only two ever filled it: the progress half was hand-rolled in one
controller and the secondary (B) gauge existed nowhere. A hold that is
charging was therefore indistinguishable from one the device missed.
Add HoldGauge + holdPct to the two-button convention (grace period so a
tap never flashes a sliver, 5% quantization so an 800 ms hold costs ~20
repaints instead of one per frame, wrap-safe, one change on release so
the partial fill is wiped exactly once) and wire every screen that
promises a hold, including the B gauge that fills from the right.
Drop the two older hold affordances the rule replaces: the accent fill
that grew across the cell behind its own label and swallowed the text,
and the success-hue recolor that announced a commit from the first few
percent. Mono keeps its whole-cell invert, having no gauge to fill.
Partial repaints
An unbracketed draw takes a seqlock cycle and an SPI address window per
primitive, so a footer repaint streamed its band dozens of times and
churned the sequence hard enough that a reader on the services core
never completed a clean read. Bracket them. Conversely commitFrame
publishes a sequence even for an empty dirty box, so the per-tick hold
border now returns before opening a frame and steps in the gauge's own
increments rather than per perimeter pixel.
Live video stream
With a completely static screen the stream ran its full pipeline at the
target rate: 16 ms read-back, 10 ms compression and 11 ms of transfer
per frame to ship 14.5 KB of identical pixels. An unchanged
display::frameSequence() proves the framebuffer is byte-identical, so
skip all three stages and state the repeat in the header alone
(kVideoFormatRepeat); the host re-emits its previous frame, keeping the
cadence a recording is timed by. 37 ms -> 3 ms per frame, 14.5 KB -> 0 B,
25 -> 30 fps. The profile line now also reports repeat and stale shares.
Frame time
The SPI blit was transaction-bound, not clock-bound: 256-byte chunks
meant 254 DMA round-trips per frame, each costing about what its 26 us
of clock did. Size the framebuffer blit separately from the direct-draw
chunks (which are stack arrays) and raise it. 36.1 ms -> 10.4 ms.
Glyphs were rasterized one pixel at a time, each pixel paying a virtual
read, a virtual 1x1 fill, a clip and a damage record, far more than the
blend itself. Hand each glyph row to the surface as one run.
The settings tree cleared and recomposed the whole screen for every
cursor step; give it region-scoped painting like its sibling list.
Vault-list navigation 14.7 -> 42.9 Hz, settings navigation 18.6 -> 38.2 Hz.
Also: the credential type action is now a fixed-order chooser
(user+password, TOTP when present, user, password) behind an N-row
selectable modal with per-board input, list navigation wraps instead of
clamping, and the PIN dot uses the brand token on every path.
Claude-Session: https://claude.ai/code/session_01ABhkBJsMKTZAxZh4Vh7jsF
Add the headless SettingsPresenter seam over the option model
Introduce the phase-2 navigation seam both on-device settings renderers will
iterate. Given a board's SettingsCapabilities, SettingsPresenter resolves the
flat capability-driven option model (settings_model.h) into that board's visible
two-level tree: the ordered top-level groups with at least one offered option,
and per group the ordered options whose capability gate the board satisfies.
Rows a board lacks are simply absent (never greyed), per catalog §5. The visible
tree is precomputed once at construction into fixed, allocation-free tables, so
per-tick navigation and render are O(1) index lookups.
The presenter is pure model logic with no draw/NVS/hardware coupling, so it is
host-testable. The new native suite (20 cases) drives it with synthetic per-board
capability sets and pins the resolved tree against the catalog §5 applicability
matrix and the §9 decisions the button tree must now honor: the three distinct
idle timers (§9.3 — Auto-lock backs auto_lock, Deep sleep backs idle_slp_ms,
Screen off backs disp_off_ms, fixing the old "Auto-Lock writes idle_slp_ms"
bug), the three orthogonal destructive actions (§9.4), the surfaced orphan
settings (§6), and the Common WiFi row that launches the masked-PSK CharWheel
entry.
Claude-Session: https://claude.ai/code/session_01P6BNTpbgrvnZXSJnNuj8ZJ
Wire deferred settings rows: destructive actions, auto-lock relock, orphan editors
Surface a batch of settings-model options that were deferred by
isSettingImplemented, each with a working on-device handler (no dead rows),
and wire the auto-lock timer to the FSM.
Destructive actions (catalog §9.4): add ResetSettings (restore every device
preference to its default, keep the vault) and FactoryReset (wipe vault +
preferences + BLE bonds + onboarding -> first-run). A new settings_reset module
holds the single source of truth for which NVS entries are preferences
(kPreferenceKeys) — deliberately excluding vault/onboarding/security/identity
state so Reset Settings keeps the vault usable. Both reuse the hold-to-confirm
popup and restart so every subsystem re-reads its default.
Auto-lock relock (catalog §9.3): wire the auto_lock idle timer to the FSM. The
HomeStateHandler now relocks the vault (radios off + key zeroize -> PIN_ENTRY)
once idle passes autoLockMs, distinct from and below the deep-sleep ceiling.
decideRelock (pure, host-tested) splits the two §9.3 cases: lock face visible
when the screen is still on, silent relock (relockSilent -> PinStateHandler
keeps the panel dark) when the screen already powered off. Gated off keyboard
boards and capture builds; does not regress deep-sleep-locks-on-idle.
Orphan editors: add the AudioFeedback and ShakeToLock toggles and the
ShakeSensitivity stepper to the button-nav tree, reusing the existing
speaker/IMU backends (audio_fb / shake_lock / shake_thr NVS keys).
Also add a scoped NOLINT for a pre-existing cppcoreguidelines-owning-memory
finding on the QR scratch placement-new singleton (unrelated to this change;
keeps the clang-tidy gate green).
Native tests: decideRelock policy (6 cases), Reset-Settings preference scope
(7 cases), and the newly listed rows in the nav-logic suite.
Claude-Session: https://claude.ai/code/session_01P6BNTpbgrvnZXSJnNuj8ZJ
Close the review-batch coverage gaps with mutation-verified suites
Two new native suites and four extended ones, 59 tests, every one
verified to fail against a deliberately broken source line:
- test_touch_auth_entry_ui (NEW, 29): the touch unlock controller had
zero host coverage while carrying the review's security-critical
behaviors. An amalgam TU (vault-harness precedent) compiles the
device-only controller on the host behind a fake pointer/canvas/board
seam; pins numeric auto-submit, the inert under-length OK key, reveal
auto-hide via the clock hook, secret zeroization on every exit path,
layer transitions, and the zone-button double-fire guard. Adds a
host-side LayoutContext::fromActiveDisplay test seam (device branch
untouched).
- test_pin_policy (NEW, 11): isValidPinLength/clampPinLength (the guard
that keeps PROVISION from creating unlockable-by-nobody vaults) and
clampSecretKind's fail-closed coercion of unknown persisted bytes.
- test_vault_rekey/import/meta/store_facade (+8): rekey carries the
authenticated secretKind forward verbatim, staged-import accessors
fall back to defaults on abort/truncation/no-stage, and the
provisioned kind survives a simulated reboot (invalidate + remount)
for both kinds.
- test_header_component/menu_list/settings_timer_clamp (+11): title-
subtitle leading in headerHeight and baseline placement, value-band
slack boundaries (exact fit / one-glyph overflow / two-line reserve),
and the kTimerNeverMs sentinel vs finite-deadline branches of the
relock policy.
Claude-Session: https://claude.ai/code/session_01XBVa8G5jrkprye4gVAkQSU
Add the StickS3 UI v2: home pager, detail carousel, TOTP, settings, onboarding
Implements the Claude Design v2 handoff on the StickS3 (135x240), built against
pixel-exact 1:1 mock references extracted from the handoff and verified by
overlay diff against captures from real silicon.
Screens
- Home pager: lock face, timing-arc PIN dial, icon tab strip, vault list.
- Credential detail: 6-stop action carousel (type, username, URL, related TOTP,
notes, delete) over the untouched decrypt/reveal/scrub paths.
- TOTP tab: per-row live codes with countdown rings.
- Settings: navigable tree with Wi-Fi, NTP, timezone and DST screens.
- Onboarding: welcome, button intros, language, five interactive component
tutorials (dial, tabs, lists, values, writing), then PIN creation.
Reusable components (screens compose; components own anatomy)
- CountdownRing, TotpCodeCard, Ipv4Field, TutorialHint, drawPinDialRimDigits and
drawListRowSelectionBand; the inline TOTP ring/code sites now delegate.
Timezone and DST
- Extends the in-house timex engine to 40 zones with POSIX TZ strings and a DST
toggle (default on). The internal epoch stays UTC: timezone and DST convert
display only, so the TOTP epoch (RFC 6238) and the lockout math never shift.
A native test pins that invariance across every zone.
Fixes found on silicon
- popup::confirm/alert blocked the TWDT-subscribed loopTask, so any modal left
open past the 5 s window rebooted the device - reproducible with the
credential delete confirm under no other load. The blocking loops now feed the
watchdog through the platform facade.
- AUDIO ON|OFF never persisted: saveSettings() had no callers repo-wide while
the help text and docs claimed it survived a reboot.
- The onboarding language A-hold confirm could never fire: wasClicked() consumed
the release edge before wasReleaseFor() ran.
- Cancelling the delete confirm exited the whole detail view, via a late release
edge leaking out of the modal.
- Settings entry landed on the second row: multi-tap consumes click edges but
leaves press edges latched, so the first tick read a stale nav.
- The video stream server ran above the vault worker on Core 0 and starved vault
I/O during capture.
QA
- 18 new hardware tests: timezone/DST, TOTP UTC invariance, NTP server
validation, Wi-Fi store, NTP sync, demo seed, audio persistence.
- The video capture test had silently skipped since WEB-05: /stream requires a
token the decoder never sent, so a 401 became a skip. It now sends the token
and fails hard on auth errors.
Native 2258/2258; sticks3, m5stack_gray, m5core_ink and tdeck build -Werror;
clang-tidy reports 0 findings across 269 TUs.
Claude-Session: https://claude.ai/code/session_01P6BNTpbgrvnZXSJnNuj8ZJ
Add the StickS3 UI v2: home pager, detail carousel, TOTP, settings, onboarding
Implements the Claude Design v2 handoff on the StickS3 (135x240), built against
pixel-exact 1:1 mock references extracted from the handoff and verified by
overlay diff against captures from real silicon.
Screens
- Home pager: lock face, timing-arc PIN dial, icon tab strip, vault list.
- Credential detail: 6-stop action carousel (type, username, URL, related TOTP,
notes, delete) over the untouched decrypt/reveal/scrub paths.
- TOTP tab: per-row live codes with countdown rings.
- Settings: navigable tree with Wi-Fi, NTP, timezone and DST screens.
- Onboarding: welcome, button intros, language, five interactive component
tutorials (dial, tabs, lists, values, writing), then PIN creation.
Reusable components (screens compose; components own anatomy)
- CountdownRing, TotpCodeCard, Ipv4Field, TutorialHint, drawPinDialRimDigits and
drawListRowSelectionBand; the inline TOTP ring/code sites now delegate.
Timezone and DST
- Extends the in-house timex engine to 40 zones with POSIX TZ strings and a DST
toggle (default on). The internal epoch stays UTC: timezone and DST convert
display only, so the TOTP epoch (RFC 6238) and the lockout math never shift.
A native test pins that invariance across every zone.
Fixes found on silicon
- popup::confirm/alert blocked the TWDT-subscribed loopTask, so any modal left
open past the 5 s window rebooted the device - reproducible with the
credential delete confirm under no other load. The blocking loops now feed the
watchdog through the platform facade.
- AUDIO ON|OFF never persisted: saveSettings() had no callers repo-wide while
the help text and docs claimed it survived a reboot.
- The onboarding language A-hold confirm could never fire: wasClicked() consumed
the release edge before wasReleaseFor() ran.
- Cancelling the delete confirm exited the whole detail view, via a late release
edge leaking out of the modal.
- Settings entry landed on the second row: multi-tap consumes click edges but
leaves press edges latched, so the first tick read a stale nav.
- The video stream server ran above the vault worker on Core 0 and starved vault
I/O during capture.
QA
- 18 new hardware tests: timezone/DST, TOTP UTC invariance, NTP server
validation, Wi-Fi store, NTP sync, demo seed, audio persistence.
- The video capture test had silently skipped since WEB-05: /stream requires a
token the decoder never sent, so a 401 became a skip. It now sends the token
and fails hard on auth errors.
Native 2258/2258; sticks3, m5stack_gray, m5core_ink and tdeck build -Werror;
clang-tidy reports 0 findings across 269 TUs.
Claude-Session: https://claude.ai/code/session_01P6BNTpbgrvnZXSJnNuj8ZJ
Raise the per-variant capacities, and stop shipping unusable vault UI
Every board now pins the capacity its flash and RAM actually support:
tdeck and cores3_se 700, core2_v13 500, sticks3 and cardputer 250,
m5stickc_plus2 180. The two 4 MB boards stay at 100 — their rekey-safe
ceiling is 139, and staging a second vault for a change-PIN on a fuller
one would run the filesystem out of space.
m5stickc_plus2 could not honestly hold 180: it had 3,132 B of internal
DRAM left against the 4,096 B bar. The slack came from a real absurdity.
Every button board compiled BOTH vault-menu layouts and picked one at
runtime from whether a third button existed, though a board's buttons are
fixed at manufacture. Each unused layout carries a row array sized by
capacity, so plus2 was paying 5 KB for a screen it can never draw. Boards
now declare which layout they use and only that one is compiled: plus2
goes to 9,196 B, sticks3 to 143,141, cores3_se to 78,985. core2_v13
recovers only the view object because its row arrays already live in
PSRAM, which is the placement working as intended.
The declaration is deliberate rather than inferred. Button count looked
like the discriminator only because today's fleet correlates: cardputer
and tdeck are wide-screen boards with no third button, and the old rule
would have handed them the narrow layout if their keyboards ever went
away. What actually decides the layout is width — a sliding pill
indicator is what fits 135 px, while a top tab bar plus an action bar
need room, and a past CoreInk header garble came from that view
hardcoding 320x240. Input decides only how you move between tabs. So the
variant states its choice, with no default, and a build-time tie rejects
a board declaring a layout its hardware cannot drive.
The names were lying too. Both layouts draw tabs, so "Tabbed"
distinguished nothing: they are now NarrowVaultView and WideVaultView,
after the constraint that actually separates them. KeyboardVaultView
keeps its name on purpose — cardputer is 240x135 and tdeck is 320x240,
one narrow and one wide, and both use it, so there the discriminator
really is the input device.
Review of the change caught a T-Deck pin that had never been raised while
five other artifacts already advertised 700, a NAV TOTP macro that
regressed on the wide boards so the screenshot suite filed a vault-list
capture as the TOTP screen, a selector whose "no silent default" promise
had a hole (an unknown token preprocesses to zero, which was the one
value exempted, so a typo compiled no view at all), and a variant guard
whose regex rejected correct declarations that carried a trailing
comment.
Verified: native 2861/2861, all three repo guards, and all eight boards
build with their declared layout confirmed present and its siblings
absent in each map.
Claude-Session: https://claude.ai/code/session_01Q2J5gQSFMTDLVzPUYog51r
Widen the slot id to 16 bits so capacity can pass 255
Credential capacity is pinned per variant at up to 700 by the vault
dimensioning study, but the slot id was a u8 across the app, web and
persistence layers: every id above 255 would have aliased onto a live
slot. Widen the id and the counts derived from it to uint16_t through
vault, web, states and ui; TOTP and group ids deliberately stay u8
(their caps stay under 255) and are now pinned by co-located
static_asserts instead of by assumption.
The wire follows: index rows and the row count, and the slot id in the
envelope's authenticated context, become u16 little-endian, growing the
context prefix from 7 to 8 bytes. No version byte moves — the format is
pre-release and every board is reflashed — but the security docs that
quote the prefix byte-for-byte are updated so the audit dossier stays
verifiable against the source.
Adversarial review of the diff found four defects worth naming, all
fixed here:
- promoteAll() held two capacity-sized filename vectors live on the
32 KB vault-worker stack; at the 700-slot pin that is ~78 KB and a
guaranteed overflow on the first SD import or re-key. Both move to
the heap, matching the pattern the rest of the vault already uses.
- The widened staged-filename grammar accepted cred_007.bin as well as
cred_07.bin for the same slot, so an import deleted the canonical
record and wrote a path the repository never opens — silent slot
loss where the previous grammar had failed closed. The grammar is now
canonical-only and lives in one header both readers share.
- DevicePanel kept the last-viewed slot in editCredId_ after BACK, so
the standalone password generator saved into it, blanking that
credential's name, user and url. The id now resets to the invalid-slot
sentinel, and the guard that also made slot 0 unwritable is gone.
- The envelope test asserted the id's new high byte against its own
zero-initialized fixture, so it would have passed against a writer
that never wrote that byte at all.
Verified: native 2843/2843 (including a new wide-capacity suite that
exercises slot 260 end to end), sticks3_debug, cardputer_debug, tdeck
and core2_v13_debug, plus the merge gate — a clean -Werror build at
VAULT_MAX_CREDENTIALS=300, which is what proves no narrowing survives.
Claude-Session: https://claude.ai/code/session_01Q2J5gQSFMTDLVzPUYog51r