Skip to content

feat(web): note-array settings UI — presets, custom W×H, token, auto-detect [#1178]#1229

Merged
jeffredodd merged 1 commit into
nextfrom
feat-1178-settings-ui
Jun 19, 2026
Merged

feat(web): note-array settings UI — presets, custom W×H, token, auto-detect [#1178]#1229
jeffredodd merged 1 commit into
nextfrom
feat-1178-settings-ui

Conversation

@jeffredodd

Copy link
Copy Markdown
Member

Closes #1178. Part of the Note Arrays epic #1167. Targets next. Blocks #1180, #1182.

What

The board-settings UI to configure a note array: pick a config, enter the Cloud API token, and auto-detect from the board.

  • Device/config selector — replaces the per-board flagship/note pills with a grouped Radix <Select> (Devices: Flagship/Note · Note arrays: the 5 presets + "Custom…"). A synthetic value (flagship/note/preset:<id>/custom) maps to updateBoard({ device_type, notes_wide?, notes_tall? }). Presets are matched by (notes_wide, notes_tall) — never the matched_preset label (which is localized text).
  • Custom W×H — two number inputs (validated 1..MAX_NOTES_PER_AXIS); invalid values are blocked with an inline error and never persisted.
  • Token field — masked note_array_token input (clones the cloud_key show/hide + "(set)" + masked-round-trip pattern), shown only for note-array configs.
  • Auto-detect from board — button → POST /settings/board/{id}/detect-size (Note arrays: auto-detect board type & size (flagship/note/array) #1172); on success sets device type + dims (preset or Custom); 404/400/422 detail shown inline. Works for any board type.
  • note_array_token added to the TS BoardInstance; new DetectBoardSizeResponse + api.detectBoardSize() wrapper. 15 new displaySettings i18n keys across all 14 locales (parity test passes).

Tests

  • web/src/__tests__/display-settings.test.tsx (new, 14): grouped options, preset save, flagship-from-array, Custom validation/blocking, token visibility + masked round-trip, auto-detect (array/flagship/custom success + 422/404/400 inline errors).
  • web/tests/multi-board.spec.ts: migrated the existing "change device type via type pills" E2E to drive the new Select; added CI-only preset+token-persist-across-reload and route-mocked auto-detect→"2×2 grid". (Add-Board picker tests unchanged.)

Verification (throwaway node:26-alpine, npm ci --legacy-peer-deps)

  • targeted vitest: 14/14 · full vitest: 1144 passed, 13 skipped, 0 failed (incl. i18n parity)
  • eslint: 0 errors · prettier: clean (incl. all 14 message files)

No backend changes (token field, detect endpoint, masking, presets all already merged).

🤖 Generated with Claude Code

…detect [#1178]

Replace the per-board flagship/note pills with a grouped Radix Select
(Devices: Flagship/Note; Note arrays: 5 presets + Custom…). Add Custom
W×H inputs (validated 1..MAX_NOTES_PER_AXIS), a masked note_array_token
field gated by isNoteArray, and an "Auto-detect from board" button that
calls the detect-size endpoint and populates type/dims (matching presets
by dimensions, not the matched_preset label). Adds note_array_token to
BoardInstance, DetectBoardSizeResponse, and api.detectBoardSize(). New
i18n keys mirrored across all 14 locales. New vitest suite + MSW
detect-size handler; migrated the multi-board E2E to drive the Select.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jeffredodd jeffredodd requested a review from roblesi as a code owner June 19, 2026 02:46
@jeffredodd jeffredodd enabled auto-merge (squash) June 19, 2026 02:46
@jeffredodd

Copy link
Copy Markdown
Member Author

@claude please review for correctness, UX, and a11y. Focus: (1) the synthetic Select value model (flagship/note/preset:<id>/custom) ↔ updateBoard mapping, and matching presets by (notes_wide, notes_tall) rather than the localized matched_preset label; (2) Custom W×H validation actually blocks invalid values from persisting (1..MAX_NOTES_PER_AXIS); (3) the masked note_array_token round-trip ("***" sentinel) matches the cloud_key contract; (4) auto-detect populates type+dims and surfaces 404/400/422 detail inline; (5) Select a11y (labelled trigger, keyboard) and that the migrated pill E2E + Add-Board picker still make sense. Please post findings as review comments only — do not push commits; I'll apply revisions as the maintainer.

@claude

claude Bot commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Claude finished @jeffredodd's task in 4m 35s —— View job


Code Review — Note Array Settings UI

Tasks

  • Read display-settings.tsx and api.ts
  • Review synthetic Select value model ↔ updateBoard mapping + preset matching
  • Review Custom W×H validation (1..MAX_NOTES_PER_AXIS)
  • Review masked note_array_token round-trip ("***" sentinel)
  • Review auto-detect: type+dims population + inline error surfacing
  • Review Select a11y (labelled trigger, keyboard)
  • Review migrated pill E2E + Add-Board picker tests
  • Post findings

1. Select value model ↔ updateBoard mapping ✅

The synthetic value model is clean and correct.

currentConfigValue (display-settings.tsx:414–421) correctly matches presets by (notes_wide, notes_tall) comparison, never by the localized matched_preset label string — the comment in the code even calls this out explicitly. The "preset:<id>"NOTE_ARRAY_PRESETS.find(p => \preset:${p.id}` === value)lookup inhandleConfigChange` is a tight, safe reverse-map. I didn't find any path that keys off the label.

Minor: When switching from note_array to "flagship" or "note", handleConfigChange sends { device_type: value } without clearing notes_wide/notes_tall. Those stale fields are passed to the backend. If the backend stores/round-trips them on a non-array board, currentConfigValue would still return "flagship" (correct, because of the board.device_type !== "note_array" short-circuit), but the stored dims are ghost data. If the Python API ignores unknown fields for non-array types this is harmless — worth a comment or confirming the API contract.


2. Custom W×H validation — correctness issue ⚠️

Finding (Medium — blocks real-world typing): The W×H inputs use value={board.notes_wide ?? 1} (fully controlled by server state) combined with an onChange that returns early on invalid input without updating any local state. This means users cannot type intermediate values:

// display-settings.tsx:687–690
value={board.notes_wide ?? 1}
onChange={(e) => handleCustomDim(board, "notes_wide", e.target.value)}

Trace for changing 112:

  1. User clears "1" → onChange("")parseInt("", 10) = NaN → validation blocks → no state change
  2. React re-renders with value={1} → the field snaps back to "1"
  3. User is stuck.

The unit tests pass because fireEvent.change sets the DOM value directly without going through browser-native intermediate states, so the snap-back is never exercised. In a real browser, this input is frustrating to use.

Fix: Match the token field's pattern — use defaultValue + onBlur instead of controlled value + onChange. The token field already does this correctly. Alternatively, hold an intermediate local Record<boardId, { w: string; h: string }> state for the raw string while editing, and only call handleUpdateBoard on blur after validation passes.

Fix this →


3. Masked note_array_token round-trip ✅

The round-trip is correct and consistent with cloud_key:

  • defaultValue: maps "***""" (empty input, placeholder "(set)") and undefined""
  • onBlur guard: val && val !== "***" && val !== board.note_array_token — all three conditions required to fire a PUT ✓
  • Eye toggle: correctly disabled when board.note_array_token === "***"

Minor: The <label> at line 304 is a standalone element, not wrapping the input and lacking htmlFor. Screen readers won't announce "Cloud API Token" when focus lands on the input. This is a pre-existing pattern across cloud_key (line 270) and local_api_key — the new field faithfully clones it — but it's a real a11y gap. Since the field also lacks an id, the <label> is purely presentational.


4. Auto-detect: type+dims + inline errors ✅

handleAutoDetect is solid:

  • Clears detectError[board.id] at start of each call ✓
  • Preset matching in the success path uses NOTE_ARRAY_PRESETS.some(p => p.notes_wide === w && p.notes_tall === h) — dimensions, not the label ✓
  • setCustomOpen is correctly set to !isPreset after a note_array detect ✓
  • For flagship/note detect: customOpen is cleared and device_type only is saved ✓
  • fetchApi already extracts body.detail and throws new Error(detail), so err instanceof Error ? err.message : t("detectFailed") correctly surfaces 422/404/400 detail strings ✓

Minor — stale dimError after switching away from Custom: If a user: (a) picks "Custom…", (b) types an out-of-range value (error shows), (c) selects a preset, (d) picks "Custom…" again — the stale error message reappears even though the board's current dims are now valid. handleConfigChange never clears dimError[board.id]. One-liner fix: add setDimError(prev => ({ ...prev, [board.id]: undefined })) at the start of handleConfigChange.

Fix this →


5. Select a11y ✅ (with minor notes)

  • SelectTrigger carries aria-label={t("deviceTypeAriaLabel")} = "Board type and size" — correctly compensates for the lack of an associated <label>
  • SelectGroup + SelectLabel provide semantic grouping ("Devices" / "Note arrays") ✓
  • Radix Select handles keyboard navigation (arrow keys, Enter, Escape, Home, End) natively ✓
  • Tests correctly query with getByLabelText("Board type and size") — this resolves to the trigger via aria-label

Minor — <Loader2> icon not aria-hidden: The spinner in the detecting state:

// display-settings.tsx:720-722
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
{t("detecting")}

Lucide SVGs don't set aria-hidden="true" by default. Screen readers may announce the SVG in addition to "Detecting…". Add aria-hidden="true" to the icon.

Minor — decorative × separator not aria-hidden: <span>×</span> between the W and H inputs is purely visual. Add aria-hidden="true" so readers don't announce "times" between the two labelled fields.


6. Migrated pill E2E + Add-Board picker ✅

The migration from pill-buttons to combobox role is clean:

// multi-board.spec.ts:183-186 — correct combobox selection
const typeSelect = page.getByRole("combobox", { name: "Board type and size" }).first();
await typeSelect.click();
await page.getByRole("option", { name: "Note", exact: true }).click();

The exact: true on "Note" is important — prevents a partial match against "Note arrays" group label. ✓

The auto-detect route-mock test is a well-structured pattern: intercept **/settings/board/*/detect-size via page.route, return a fixture, and verify the dimension display + API state. ✓

The E2E token locator page.getByText("Cloud API Token").locator("..").locator("input") works correctly — .. resolves to <div class="space-y-1"> which contains the sibling <input>, not just the <label>. ✓

Add-Board picker tests are unchanged as stated. ✓


Summary

# Area Severity Status
1 Stale notes_wide/notes_tall sent on flagship/note switch Low Advisory
2 Controlled number inputs snap back on invalid keystroke Medium Fix needed
3 Token <label> not associated with <input> (pre-existing) Low Pre-existing, consistent with cloud_key
4 Stale dimError re-shows after Custom→Preset→Custom cycle Low Fix recommended
5 <Loader2> missing aria-hidden Low Fix recommended
6 Decorative × missing aria-hidden Low Fix recommended

The most impactful fix is #2 (controlled inputs). The value model, preset matching, masked round-trip, auto-detect error surfacing, and Select a11y structure are all well-implemented.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: small a11y fixes needed — two WCAG issues in the new note_array_token block; both are minor to fix and follow pre-existing patterns already in this file.


Finding 1 — Icon-only eye toggle has no accessible name (WCAG 4.1.2)

web/src/components/settings/display-settings.tsx:326

The new show/hide button for the Cloud API Token renders only <Eye> or <EyeOff> with no label text. Screen readers will announce it as an unnamed button.

<Button
  type="button"
  variant="outline"
  size="sm"
  onClick={() => setShowSecrets((prev) => ({ ...prev, note_array_token: !prev.note_array_token }))}
  className="h-8 w-8 p-0"
  disabled={board.note_array_token === "***"}
>
  {showSecrets.note_array_token ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
</Button>

Suggested fix — add a sr-only span or an aria-label that tracks the toggle state:

<Button
  type="button"
  variant="outline"
  size="sm"
  onClick={() => setShowSecrets((prev) => ({ ...prev, note_array_token: !prev.note_array_token }))}
  className="h-8 w-8 p-0"
  disabled={board.note_array_token === "***"}
  aria-label={showSecrets.note_array_token ? t("hideTokenAriaLabel") : t("showTokenAriaLabel")}
>
  {showSecrets.note_array_token ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
</Button>

(Requires adding hideTokenAriaLabel / showTokenAriaLabel keys to all locale files, or you could reuse the sr-only span approach if those keys are already present for the sibling local/cloud-key buttons.)

Note: The pre-existing eye buttons for local_api_key (line 206) and cloud_key (line 294) have the same gap. This PR doesn't introduce the pattern, but it does copy it into new code.


Finding 2 — <label> not programmatically associated with the token <input> (WCAG 1.3.1 / 4.1.2)

web/src/components/settings/display-settings.tsx:304

The <label> at line 304 is a sibling of the <input> (inside a different <div>), with no htmlFor / id link. AT won't announce the label text when the user focuses the field, and clicking the label won't focus the input.

<label className="text-xs font-medium">{t("noteArrayTokenLabel")}</label>
<div className="flex gap-1.5">
  <input ... />   {/* ← no id; label has no htmlFor */}

Simplest fix — add a stable id to the input and a matching htmlFor on the label (or wrap the input inside the label, as is done for the custom W×H number inputs at lines 681/693):

<label htmlFor={`note-array-token-${board.id}`} className="text-xs font-medium">
  {t("noteArrayTokenLabel")}
</label>
<div className="flex gap-1.5">
  <input
    id={`note-array-token-${board.id}`}
    type={showSecrets.note_array_token ? "text" : "password"}
    ...
  />

Note: The same unassociated-label pattern exists for boardHostLabel (line 136), localApiKeyLabel (line 182), enablementTokenLabel (line 226), and cloudKeyLabel (line 270) — also pre-existing.


Finding 3 — Dynamic error messages not in a live region (WCAG 4.1.3, minor)

web/src/components/settings/display-settings.tsxdimError and detectError blocks

Both inline validation messages appear and disappear without being in an aria-live region. Screen readers won't announce them automatically.

  • dimError[board.id] renders into a plain <p> (~line 705 in the PR head).
  • detectError[board.id] renders into a plain <div> (~line 719).

Adding role="alert" (or aria-live="polite") to these containers is sufficient.

For detectError, the <AlertCircle> icon inside the error block is decorative (the adjacent text carries the message) and should also get aria-hidden="true" to avoid a redundant announcement.


No issues found

  • Select widget (device-type picker): SelectTrigger has a localized aria-label={t("deviceTypeAriaLabel")}
  • Color swatches: aria-label + aria-pressed on both buttons ✓
  • Custom W×H number inputs: wrapping <label> pattern used correctly ✓
  • Auto-detect button: visible text "Auto-detect from board" / "Detecting…" provides the accessible name ✓
  • i18n coverage: all new user-facing strings (including deviceTypeAriaLabel) are in every locale file ✓

@jeffredodd jeffredodd merged commit fbe10fd into next Jun 19, 2026
23 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant