Skip to content

feat!: Note Arrays — multi-board (W×H) support [Epic #1167]#1185

Open
jeffredodd wants to merge 29 commits into
mainfrom
next
Open

feat!: Note Arrays — multi-board (W×H) support [Epic #1167]#1185
jeffredodd wants to merge 29 commits into
mainfrom
next

Conversation

@jeffredodd

@jeffredodd jeffredodd commented Jun 18, 2026

Copy link
Copy Markdown
Member

Note Arrays — multi-board (W×H) support · Epic #1167

Adds first-class support for Vestaboard Note Arrays: grids of multiple Note boards driven as one logical display (2 side-by-side, 4 stacked, a 2×2 grid, or a custom layout up to 8×8 Notes). Previously FiestaBoard only knew two fixed sizes — Flagship (22×6) and Note (15×3). The whole stack — model, Cloud API transport, rendering, settings, preview, auto-detect, plugins, tests, docs, and a local dev mock — is now dimension-driven.

This branch also reconciles with main, including @DarthXoc's Device Size Support (#1232) (plugins read a board's width/height), and extends it with note-array awareness.

Issues closed on merge

Closes #1167 (epic) and every sub-issue:


What's included

Model & geometry

Cloud API transport

Web UI

Auto-detect

Plugin device-size support (reconciled with #1232)

  • Note arrays feed their true dimensions into the plugin BoardContext, so size-aware plugins (e.g. Date & Time) adapt to a note array's geometry; per-board plugin caching is keyed by size so different-sized arrays don't collide.

Local dev tooling

  • The note-array Cloud API mock is a real dev tool: the app auto-points at it (VESTABOARD_CLOUD_API_URL), and it serves a live web board UI at http://localhost:19200/ui — split-flap tiles, message history, and runtime size reconfiguration — so you can register a note-array board and watch sends land without hardware (Note arrays: Cloud API mock for integration tests #1181).

Testing & docs


UAT — comprehensive manual test guide (high level)

Prereqs: /start this branch's dev stack. No hardware needed — note-array boards are auto-pointed at the bundled mock Cloud board (its live UI is http://localhost:19200/ui). A real note-array Vestaboard + its X-Vestaboard-Token lets you also test against real hardware. Use placeholder tokens only — never paste a real token into an issue/PR.

1. Configure a note array (Settings → Hardware)

  • Open the Board type dropdown — confirm the grouped list: Flagship, Note, and a "Note arrays" group (2 side-by-side, 4 side-by-side, 2 stacked, 4 stacked, 2×2 grid, Custom…).
  • Pick each preset → the board card's size indicator updates (e.g. "4 side-by-side" → "60 × 3"). Pick Custom… → enter W×H (1–8 each); out-of-range values are rejected inline.
  • Enter the Cloud API Token; reload → selection + token persist (token masked).

2. Auto-detect

  • Click Auto-detect from board → type + size populate from the board's live layout (preset auto-selected, or Custom). With the mock, use its /ui to set a size first, then auto-detect should match it. Try an unreachable/blank board → a clear inline error.

3. Preview & rendering

  • Create/open a page for the note-array device → the editor preview renders at real size with seams. Toggle Fit ↔ Actual size: Actual shows full-size tiles + a horizontal scrollbar for wide arrays (3×60); tall arrays (12×15) render fully (no vertical clipping).

4. Send → watch it land (mock or real)

  • Send a page → on the mock, open http://localhost:19200/ui and watch the grid appear at the right size. Confirm no flip/transition animation, and that rapid re-sends are throttled (≥15s). Use the mock UI's size selector to flip presets and re-test.

5. Plugins adapt to size

  • Add a size-aware plugin (e.g. Date & Time) to a note-array page → content reflects the array's width; two differently-sized arrays each get appropriately-sized output.

6. Regression — Flagship & Note unchanged

  • Repeat the existing Flagship/Note flows (settings shows "22 × 6" / "15 × 3", previews/sends look exactly as before). The single-board experience must be unchanged.

Verification

  • Full reconciled tree verified green: Python 3910 passed (tests/ + integration-tests/ together — the cross-issue check), web vitest 1158 passed, ruff/eslint/prettier clean, i18n parity across all 14 locales.
  • Each sub-issue landed via its own CI-gated PR with an automated review pass.

Notes for reviewers

  • main is an ancestor of this branch, so the merge is clean.
  • Built on top of the epic: note-array plugin BoardContext support, the editor real-size preview, and the mock dev-board front-end — see linked PRs.

🤖 Generated with Claude Code

…1184)

* feat(devices): add note-array dimension model (generalized W×H)

Implements #1183 (part of epic #1167). Adds note_array device type,
NOTE_ROWS/NOTE_COLS/MAX_NOTES_PER_AXIS, NOTE_ARRAY_PRESETS,
note_array_dimensions/is_note_array/is_valid_note_array_grid/resolve_dimensions,
and notes_wide/notes_tall on BoardInstance. No pages.json migration needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style(devices): apply ruff format to note-array model

Matches CI ruff format --check (dev container lacks ruff). No logic change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(devices): reject bool for notes_wide/notes_tall

bool is a subclass of int, so True/False slipped through the int guard in
BoardInstance.__post_init__. Reject bools explicitly and default to 1.
Adds regression tests. (Task review finding on #1183.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(devices): clarify get_dimensions error for note_array callers

get_dimensions() only resolves static DEVICE_DIMENSIONS entries;
after DEVICE_TYPES was expanded to include 'note_array', the old
error message ('Must be one of {DEVICE_TYPES}') became misleading
because it implied note_array was a valid argument.

The message now names get_dimensions() supported types explicitly
and points callers to resolve_dimensions(). A new test covers the
note_array ValueError path and anchors on the hint text.

Co-authored-by: Jeffrey D Johnson <jeffredodd@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
* feat(devices): note-array credentials & board config [#1170]

Add `note_array_token: str = ""` to `BoardInstance` for the
X-Vestaboard-Token credential used by Note Array hardware.

Changes:
- `src/devices.py`: new `note_array_token` field; `is_connection_configured`
  now short-circuits on `is_note_array(device_type)` and requires the token
  (plus notes_wide/notes_tall ≥ 1); `from_dict` extracts the field with ""
  default (backward-compat); `to_dict` via `asdict` serialises it for free.
- `src/settings/service.py`: add `note_array_token` to
  `BOARD_SENSITIVE_FIELDS` so it is masked in API responses and preserved
  when the UI echoes back "***".
- `src/config_manager.py`: add `note_array_token` to `SENSITIVE_FIELDS`
  (masked in `get_all_masked`) and `DEFAULT_CONFIG["board"]` (allows
  `set_board` guard to accept the field); add env-var override
  `BOARD_NOTE_ARRAY_TOKEN` in `_apply_env_overrides`.
- `env.example`: document `BOARD_NOTE_ARRAY_TOKEN` with a placeholder.
- `tests/test_devices.py`: new `TestNoteArrayToken` class (5 tests);
  extended `TestBoardInstanceConnectionConfigured` (6 new tests);
  `test_valid_api_modes_unchanged` in `TestDeviceConstants`.
- `tests/test_config_manager.py`: 4 new round-trip/masking/configured tests
  via `SettingsService`.

api_mode decision: `VALID_API_MODES` stays `("local", "cloud")`. A
note-array board uses `api_mode="cloud"` + the new `note_array_token`
field. Routing in #1168 branches on `is_note_array(device_type)`, not
`api_mode`, to pick the correct endpoint and credential.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(devices): address review findings on note-array credentials

- Drop dead W×H tautology in is_connection_configured (notes_wide/tall are
  always >=1 after __post_init__ clamping); rely on token presence.
- Strip whitespace on note_array_token in from_dict (consistent with other
  credential fields); a whitespace-only token now reads as unconfigured.
- Add tests for the BOARD_NOTE_ARRAY_TOKEN env-override path (apply-when-empty
  and do-not-clobber-existing) and for token whitespace stripping.
- Dedupe redundant VALID_API_MODES test.

Addresses task-review + @claude review findings on #1170.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added minor and removed minor labels Jun 18, 2026
* feat(api): de-hardcode board geometry for note arrays [#1171]

Replace hardcoded 6×22 literals in api_server.py (/debug/blank, /debug/fill,
/debug/info comment, _build_welcome_template, send_welcome_message) and
MessageFormatter (MAX_ROWS/MAX_COLS class constants used at 26 sites) with
resolved dimensions from resolve_dimensions(). Adds _get_first_board_dims()
helper and per-instance rows/cols to MessageFormatter. Flagship/note output
is byte-identical; note_array boards now produce correctly-sized grids.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(api): address review findings on de-hardcoded geometry

- /debug/info: size text_to_board_array to the active board's dims (closes the
  brief AC for /info); add tests asserting 3x30 for a note array and 6x22 for
  flagship.
- Remove dead DisplayService.formatter attribute (+ now-unused import); it was
  never read and would have produced flagship-sized output if wired up.
- Simplify the no-op truncation loop in MessageFormatter (byte-identical) and
  fix stale "22 characters" comments/docstring to reference self._cols.
- Make test_blank_board_success / test_fill_board_success environment-
  independent by mocking get_settings_service to flagship.
- Clarify the /info slice-cap comment (caps are flagship-oriented; final grid
  is board-sized).

Addresses task-review + @claude review findings on #1171.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added minor and removed minor labels Jun 18, 2026
…1188)

* feat(pages): render correctly at any note-array dimensions [#1173]

Add notes_wide/notes_tall fields to Page/PageCreate/PageUpdate models and
wire them through validate_config, _render_composite, and _render_template
so all centering, wrapping, truncation, and vertical-fill rendering uses the
correct dimensions for any valid note-array size (up to 12 rows × 60 cols).

Key changes:
- src/pages/storage.py: v3→v4 migration adds notes_wide=1/notes_tall=1 to
  existing pages (required by CLAUDE.md schema-versioning rules); bumps
  CURRENT_SCHEMA_VERSION from 3 to 4.
- src/pages/models.py: add notes_wide/notes_tall fields (default 1, 1–8);
  validate_config now calls resolve_dimensions(device_type, notes_wide,
  notes_tall) instead of get_dimensions() so note_array row-index bounds
  are computed correctly (e.g., 12-row array allows rows 0–11).
- src/pages/service.py: _render_composite and _render_template forward
  notes_wide/notes_tall to resolve_dimensions / render_lines.
- src/templates/engine.py: render_lines accepts notes_wide/notes_tall and
  calls resolve_dimensions instead of DEVICE_DIMENSIONS.get() (which
  silently fell back to flagship for note_array); validate_template and
  _calculate_max_line_length accept optional cols param (forward-compat).
- Tests: 54 new tests covering preset shapes (3×30, 3×60, 6×15, 12×15,
  6×30), centering/wrapping at wide cols, 12-row vertical fill, row-index
  bounds for note_array pages, v3→v4 migration, and backward-compat proofs
  that flagship/note rendering is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(pages): address review findings on note-array rendering

- Use MAX_NOTES_PER_AXIS for the Page/PageCreate/PageUpdate W×H Field bounds
  instead of hardcoded le=8 (single source of truth with devices.py).
- Add migration test for a mixed page list (some pages have the fields, some
  don't) — only the missing ones are migrated.

Deferred (with rationale, see PR comment): TS Page-type fields → #1174 (its
scope); /templates/validate cols forwarding → web-wiring follow-up (pre-existing
limitation, would conflict with in-flight #1171's api_server.py changes).

Addresses task-review + @claude review findings on #1173.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(pages): repair note-array fallout in AI generator + create path

Full-suite run (not the narrow subset) surfaced regressions from adding
notes_wide/notes_tall to Page:

- AI generator: Page(**PageCreate(...).model_dump()) passed notes_wide=None
  (PageCreate leaves W×H optional), failing Page's int validation. Bridge now
  uses exclude_none so int defaults apply, and the returned draft carries
  concrete W×H ints. Fixes ~19 tests/ai failures.
- PageService.create_page now threads notes_wide/notes_tall (defaulting to 1)
  so created note-array pages persist their size; adds tests.
- test_demo_pages: assert CURRENT_SCHEMA_VERSION == 4 (was 3) after the v3->v4
  migration bump.

Verified: tests/ all green except async tests/ai (need pytest-asyncio, absent
in dev container); with pytest-asyncio installed, tests/ai is 189 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added minor and removed minor labels Jun 18, 2026
] (#1189)

* feat(web): note-array TypeScript types + shared dimension helpers [#1174]

Mirror the Python dimension model from src/devices.py into the web app.
Single source of truth for board geometry; removes duplicated DEVICE_DIMS
literals from board-display.tsx, static-board-display.tsx, page-builder.tsx,
and src/board_html_renderer.py. Closes the DeviceType drift flagged on #1173.

- Add web/src/lib/board-dimensions.ts: NOTE_ROWS/NOTE_COLS/MAX_NOTES_PER_AXIS
  constants, 5 NOTE_ARRAY_PRESETS (exact ids/labels/values matching Python),
  noteArrayDimensions(), isNoteArray(), resolveDimensions()
- Extend DeviceType in api.ts to include "note_array"; add notes_wide/notes_tall
  to BoardInstance, Page, PageCreate, PageUpdate interfaces
- Refactor board-display.tsx and static-board-display.tsx to import
  resolveDimensions instead of maintaining local DEVICE_DIMS maps; add
  notesWide/notesTall props for future note_array rendering (#1176)
- Refactor page-builder.tsx to use resolveDimensions instead of tiptap
  constants DEVICE_DIMENSIONS (which lacked note_array)
- Update page-grid-selector.tsx deviceTypeFilter prop to accept full DeviceType
- Update src/board_html_renderer.py to use resolve_dimensions() from
  src/devices.py; render_board_html() now accepts notes_wide/notes_tall;
  render_page_preview_html() reads them from the page object
- Add 28-test vitest suite covering all 5 presets, custom W×H,
  resolveDimensions for all device types, isNoteArray

Part of #1167

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): address review findings on note-array TS helpers

- BoardDisplay memo comparator now includes notesWide/notesTall, so the board
  re-renders when a note_array's dimensions change (was silently swallowed).
- Fix tests/test_board_html_renderer.py: import was renamed away (DEVICE_DIMS);
  use resolve_dimensions instead. (Full `pytest tests/` caught this — the web-only
  check had missed it.)
- Remove dead _STATIC_DEVICE_DIMS dict in board_html_renderer.py; inline the
  unknown-device flagship fallback.
- page-grid-selector: `as DeviceType` instead of stale `as "flagship" | "note"`.
- board-dimensions.ts JSDoc: clarify the unknown-type fallback divergence.
- page-builder: comment the note_array W×H gap (threaded in #1178).

Verified: full pytest tests/ = 3899 passed; web vitest = 1093 passed; ruff +
prettier clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…1216)

* feat(board): note-array Cloud API send/read in BoardClient [#1168]

Add send/read paths for the Vestaboard note-array Cloud API
(https://cloud.vestaboard.com/) in BoardClient. Routing is determined
by device type via is_note_array(), not api_mode. Widen
_is_valid_character_grid to accept variable note-array grid sizes
(rows × cols, multiples of 3 × 15). Add board_client_from_board_dict
factory support for note_array device_type. Flagship and note local/RW
cloud paths are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(board): address review findings on note-array Cloud API

- send_text now guards note-array boards: Cloud API is characters-only, so it
  fails clearly (False, False) instead of POSTing to the wrong RW URL/auth.
  Adds a test asserting it never POSTs.
- Extract _note_array_headers property (was duplicated in send_characters and
  read_current_message).
- Clarify __init__ comments: note arrays override base_url/headers in send/read;
  notes_wide/tall carried for future grid-size enforcement.
- Update read_current_message docstring to mention note-array variable sizes.
- Comment the intentional size-mismatch acceptance in the 6x30 test.

Addresses task-review + @claude review findings on #1168.
Full pytest tests/ = 3930 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added minor and removed minor labels Jun 18, 2026
…1217)

* feat(web): variable-size note-array board preview with seams [#1176]

- Import isNoteArray, NOTE_COLS, NOTE_ROWS from @/lib/board-dimensions in
  both board-display.tsx and static-board-display.tsx
- Remove stale default params (rows=6, cols=22) from messageToGrid signature
- Replace hardcoded-column comment block with a generic width comment
- Add showSeams / seamGap computation in BoardDisplay and StaticBoardDisplay
- Apply data-note-row on every row wrapper; data-note-row-seam on NOTE_ROWS
  boundaries when showSeams is true
- Apply data-note-tile on every tile; data-note-col-seam on NOTE_COLS
  boundaries when showSeams is true; marginLeft/marginTop seam gap inline
- Pass showSeams / isRowSeam / seamGap through StaticGridRow and GridRow
- Add new test file board-display-variable-size.test.tsx (12 test cases)
  covering 3×30, 3×60, 6×15, 12×15, 6×30, 6×45 sizes; seam presence for
  note_array; and seam absence for flagship/note

Flagship/note rendering is visually unchanged: isNoteArray returns false for
those device types so no extra margins or seam DOM attributes are applied.

Part of #1167 · Implements #1176

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(web): cover animated-path seams; document tile-wrapper rationale

Addresses @claude review on #1176:
- Important 2 (test gap): add seam tests for the animated BoardDisplay path
  (isStatic=false) — note_array col/row seams present, flagship none.
- Important 1 (extra wrapper): keep the per-tile wrapper — it is structurally
  required because CharTile returns a React fragment (flap-animation layers)
  that needs a single containing flex item; it also hosts the data-note-tile
  hook + seam margin. Added comments explaining this. StaticGridRow mirrors it
  for DOM consistency (tests 11/12 rely on data-note-tile on flagship tiles).

vitest: 1108 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added minor and removed minor labels Jun 18, 2026
@jeffredodd jeffredodd changed the title feat(devices): note support [WIP] feat(devices): note-carry support [WIP] Jun 19, 2026
@jeffredodd jeffredodd changed the title feat(devices): note-carry support [WIP] feat(devices): note-array support [WIP] Jun 19, 2026
@github-actions github-actions Bot added minor and removed minor labels Jun 19, 2026
* feat(web): board-size indicator for note arrays [#1175]

Add reusable BoardSizeIndicator component showing rows×cols for flagship/note
boards and rows×cols·preset-label (or "Custom") for note_array layouts. Place
it adjacent to the preview label in page-builder and inside board settings in
display-settings, replacing the hardcoded "22×6"/"15×3" strings.

- New component: web/src/components/board-size-indicator.tsx
- New tests: web/src/__tests__/board-size-indicator.test.tsx (15 cases covering
  flagship, note, all 5 presets, custom note_array, i18n aria-labels, className)
- Add boardSizeIndicator i18n namespace (custom / ariaLabel / ariaLabelWithLayout)
  to en.json and all 13 other locale files
- Wire BoardSizeIndicator into display-settings.tsx and page-builder.tsx
- Add board-size-indicator.tsx to vitest coverage include list

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): board-size indicator uses width×height; fix lint + E2E [#1175]

Align BoardSizeIndicator with the rest of the app, which displays board
dimensions as width × height (cols × rows): the setup wizard shows
"22 × 6 characters" and board cards historically showed "22×6". The
indicator previously rendered rows × cols ("6 × 22"), which contradicted
the wizard for the same board on adjacent screens.

- Render {cols} × {rows} ("22 × 6", "15 × 3", "60 × 3"); aria-label keeps
  explicit "{rows} rows by {cols} columns" wording (order-independent, no
  locale churn) and now documents the visual-order rationale.
- Lock visual order with toHaveTextContent assertions in the unit test.
- Fix Lint UI failure: sort the BoardSizeIndicator import in
  display-settings.tsx ahead of @/components/ui/* (simple-import-sort).
- Update E2E board-card assertions "22×6"/"15×3" → "22 × 6"/"15 × 3"
  (multi-board.spec.ts, settings-hardware-network.spec.ts). Wizard
  "… characters" assertions are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot removed the minor label Jun 19, 2026
@github-actions github-actions Bot added major and removed major labels Jun 21, 2026

@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.

small a11y fixes — 3 issues, 2 worth fixing before merge


1. <label> disconnected from input — Cloud API Token field (WCAG 4.1.2 / 1.3.1)

web/src/components/settings/display-settings.tsx (new note-array token block, around line 303)

<label className="text-xs font-medium">{t("noteArrayTokenLabel")}</label>
<div className="flex gap-1.5">
  <input
    type={showSecrets.note_array_token ? "text" : "password"}
    ...
  />

The <label> is not associated with the <input> — it neither wraps it nor uses htmlFor. Screen readers won't announce "Cloud API Token" when the field is focused.

The simplest fix is to make the label the wrapper (matching the pattern used by the custom dimension inputs added further down in the same PR):

<label className="text-xs font-medium">
  {t("noteArrayTokenLabel")}
  <div className="flex gap-1.5 mt-1">
    <input
      type={showSecrets.note_array_token ? "text" : "password"}

Alternatively, add matching id/htmlFor attributes. Note: the existing local_api_key, enablement_token, and cloud_key fields in the same component have the same pre-existing issue, but since this PR adds a fourth instance it's a good time to establish the right pattern.


2. Icon-only Eye/EyeOff button has no accessible name (WCAG 4.1.2)

web/src/components/settings/display-settings.tsx (same block, the show/hide toggle)

<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>

The button contains only an icon; a keyboard or screen-reader user sees an unnamed button. Add aria-label (ideally from the message catalog — something like "showToken" / "hideToken", or a single toggle key):

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

Same pre-existing gap exists on the other secret fields; worth fixing there too, but that's outside this PR's scope.


3. boardSizeIndicator aria-labels are untranslated in all 13 non-English locales (WCAG 4.1.2 / 1.3.1)

web/messages/de.json, es.json, fr.json, it.json, ja.json, ko.json, nl.json, pl.json, pt.json, ru.json, sv.json, tr.json, zh.json — all contain identical English-language values:

"boardSizeIndicator": {
  "custom": "Custom",
  "ariaLabel": "{rows} rows by {cols} columns",
  "ariaLabelWithLayout": "{rows} rows by {cols} columns, {layout}"
}

BoardSizeIndicator renders <span role="img" aria-label={ariaLabel}>, so these strings are the sole thing read aloud by screen readers. A Japanese or German user with a screen reader will hear English — "3 rows by 15 columns" — rather than a localized equivalent.

The custom label also appears in the visible chip text via the ariaLabelWithLayout interpolation, so it affects sighted users too.

These entries should be translated the same way the other new keys in each locale were. If machine-translation is the workflow for the initial pass, that should be applied here too. Until translations land, using the English fallback is acceptable (next-intl falls back to en.json when a key is missing), but adding stub English-only keys in every locale file opts out of that fallback mechanism — so the current state actively bypasses it.


Low priority / informational

  • Dynamic detect-error not in a live region (display-settings.tsx, the detectError div after the Auto-detect button): the error appears synchronously after a user gesture, so many screen readers will announce it as part of the focus shift, but adding role="status" would make it robust. WCAG 4.1.3, low impact.
  • The new ScaledBoardDisplay Fit/Actual toggle is well-formed: role="group" with a translated aria-label, both buttons have i18n text labels and correct aria-pressed. ✓
  • BoardSizeIndicator's use of role="img" + aria-label is appropriate; the aria-label template is well-composed. ✓
  • Board selector in the sidebar (SelectTrigger aria-label={t("boardSelector")}, collapsed tooltip) is clean. ✓

@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.

Docs review

Verdict: 5 small fixes — all inline suggestions below.

Two categories of error:

  1. Wrong settings tab name (3 places). The new docs say "Settings → Display" but the Settings page has no Display tab. The board-type and size UI lives under the Hardware tab (openSettingsTab(page, "Hardware") in every relevant test; the i18n key is settings.sectionHardware). Affected: README.md, docs/setup/NOTE_ARRAYS.md, and env.example.

  2. matched_preset label has wrong spacing (2 places in docs/reference/NOTE_ARRAYS.md). The actual label in NOTE_ARRAY_PRESETS and the i18n key displaySettings.presets.2x2_grid is "2×2 grid" (no spaces around ×). The reference doc’s presets table and JSON example both show "2 × 2 grid" (with spaces), which does not match the real API response.

All five are addressed with the inline suggestions below.

🤖 Auto-review by claude-docs-review.yml.

Comment thread README.md
| 4 stacked | 1 × 4 | 15 × 12 |
| 2 × 2 grid | 2 × 2 | 30 × 6 |

Note arrays connect through the **Vestaboard Cloud API** using an `X-Vestaboard-Token` (from your Vestaboard Cloud API subscription) — not the Read/Write key. Configure the board type, size, and token in **Settings → Display**, or let FiestaBoard read the array and pick the size for you with **Auto-detect from board**.

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.

The Settings page has no "Display" tab. Board type and size are configured under the Hardware tab (confirmed by openSettingsTab(page, "Hardware") in every note-array integration test and settings.sectionHardware in i18n).

Suggested change
Note arrays connect through the **Vestaboard Cloud API** using an `X-Vestaboard-Token` (from your Vestaboard Cloud API subscription) — not the Read/Write key. Configure the board type, size, and token in **Settings → Display**, or let FiestaBoard read the array and pick the size for you with **Auto-detect from board**.
Note arrays connect through the **Vestaboard Cloud API** using an `X-Vestaboard-Token` (from your Vestaboard Cloud API subscription) — not the Read/Write key. Configure the board type, size, and token in **Settings → Hardware**, or let FiestaBoard read the array and pick the size for you with **Auto-detect from board**.

Comment thread docs/setup/NOTE_ARRAYS.md

### 1. Add the board

In the FiestaBoard web UI, open **Settings → Display** and add a board (or edit

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.

Same wrong tab name — should be Hardware, not Display.

Suggested change
In the FiestaBoard web UI, open **Settings → Display** and add a board (or edit
In the FiestaBoard web UI, open **Settings → Hardware** and add a board (or edit

Comment thread env.example
# - Obtain the token from your Vestaboard Cloud API subscription.
# - Constraints: no transition animations, and at least 15 seconds between sends.
# - This is normally set per-board in the UI (Settings -> Display -> Board type =
# a Note-array preset or "Custom…", then paste the Cloud API Token). The value

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.

Same wrong tab name in the config comment.

Suggested change
# a Note-array preset or "Custom…", then paste the Cloud API Token). The value
# - This is normally set per-board in the UI (Settings -> Hardware -> Board type =

| `4_wide` | 4 side-by-side | 4 × 1 | 60 × 3 |
| `2_tall` | 2 stacked | 1 × 2 | 15 × 6 |
| `4_tall` | 4 stacked | 1 × 4 | 15 × 12 |
| `2x2_grid` | 2 × 2 grid | 2 × 2 | 30 × 6 |

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.

The actual label field in NOTE_ARRAY_PRESETS is "2×2 grid" (no spaces around ×), matching i18n key displaySettings.presets.2x2_grid. A reference doc’s Label column should use the exact string.

Suggested change
| `2x2_grid` | 2 × 2 grid | 2 × 2 | 30 × 6 |
| `2x2_grid` | 2×2 grid | 2 × 2 | 30 × 6 |

"cols": 30,
"notes_wide": 2,
"notes_tall": 2,
"matched_preset": "2 × 2 grid"

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.

classify_dimensions returns matched_preset = preset["label"], which for the 2×2 preset is "2×2 grid" (no spaces). The JSON example should match the real API response exactly.

Suggested change
"matched_preset": "2 × 2 grid"
"matched_preset": "2×2 grid"

* feat(web): display board dimensions as rows × cols app-wide

Per maintainer decision, flip the board-dimension DISPLAY convention from
width×height (cols × rows) to rows × cols everywhere, matching the #1175 brief
("3 × 60 · 4 Notes side-by-side") and the epic's rows×cols convention:

- BoardSizeIndicator renders {rows} × {cols} (board cards + preview).
- Wizard strings flagshipDimensions/noteDimensions → "6 × 22"/"3 × 15
  characters" across all 14 locales.
- Update UI display assertions in board-size-indicator.test.tsx,
  multi-board.spec.ts, and regression/note-arrays.spec.ts to rows × cols.

Backend grid descriptions (e.g. "3×15 array") and notes W×H (e.g. "4×1",
"2×2 grid" preset names) are unchanged — only the on-screen dimension display
order flips. aria-labels already name rows/columns explicitly (unaffected).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(web): flip page-builder note-array dim assertions to rows × cols

Full vitest suite caught page-builder.test.tsx still asserting the old
width×height display ("60 × 3", "30 × 6", "22 × 6"); flip the toHaveTextContent
assertions + comments to rows × cols. aria-label name matchers ("3 rows by 60
columns") were already order-correct and unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(web): flip note-board dim assertion in settings-hardware regression

E2E caught settings-hardware-network.spec.ts still locating the Note board card
by "15 × 3" (old width×height). Flip to "3 × 15" (rows × cols) to match the
new BoardSizeIndicator display.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added major and removed major labels Jun 21, 2026

@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: a11y fixes needed — two WCAG 4.1.2 gaps in the new Cloud API Token field, two unannounced dynamic-error regions, and untranslated aria-labels in all non-English locales.


Blockers

1. Cloud API Token label not programmatically associated with its input — WCAG 4.1.2 / 1.3.1

web/src/components/settings/display-settings.tsx (new block around the isNoteArray guard, inside BoardConnectionForm)

<label className="text-xs font-medium">{t("noteArrayTokenLabel")}</label>
<div className="flex gap-1.5">
  <input
    type={showSecrets.note_array_token ? "text" : "password"}
    ...
  />

The <label> is a sibling of the <div> that wraps the <input>, so it is not implicitly associated. Without htmlFor + matching id, a screen reader user tabbing into the input hears nothing to identify it as "Cloud API Token". (The existing cloudKeyLabel, enablementTokenLabel, and localApiKeyLabel blocks carry the same debt — this PR adds a fourth instance.)

<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"}
    ...
  />

2. Icon-only eye-toggle button on the token field has no accessible name — WCAG 4.1.2

Same block, the <Button> that renders <Eye> / <EyeOff>:

<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>

A button with only an SVG icon and no aria-label is announced as "button" by screen readers. The existing hideToken / showToken keys in en.json are unused here. (Same pre-existing gap exists on the other three eye-toggle buttons, but this PR adds a new one.)

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

Minor fixes

3. Dynamic error messages are not announced — WCAG 4.1.3

Both new error regions appear after user actions but carry no live-region role, so screen readers never surface them.

Dimension validation error (dimError):

{dimError[board.id] && <p className="text-[10px] text-destructive">{dimError[board.id]}</p>}

→ add role="alert" to the <p>.

Auto-detect error (detectError):

{detectError[board.id] && (
  <div className="flex items-center gap-1.5 text-destructive text-[10px]">
    <AlertCircle className="h-3 w-3 flex-shrink-0" />
    <span>{detectError[board.id]}</span>
  </div>
)}

→ add role="alert" to the outer <div>, and aria-hidden="true" to <AlertCircle> (decorative icon).


A11y-relevant i18n gap

4. boardSizeIndicator.ariaLabel and ariaLabelWithLayout left in English across all non-English locales

web/messages/de.json, fr.json, ja.json (and all others) include:

"boardSizeIndicator": {
  "custom": "Custom",
  "ariaLabel": "{rows} rows by {cols} columns",
  "ariaLabelWithLayout": "{rows} rows by {cols} columns, {layout}"
}

These strings are the aria-label on BoardSizeIndicator's role="img" span — the only text a screen reader user receives for board dimensions. Leaving them in English means a German/French/Japanese screen reader user hears dimension labels in English. The custom label is also untranslated but that's visible text (general i18n scope). The ariaLabel* keys need locale-appropriate strings since they are screen-reader-only content.


Clean areas

  • BoardSizeIndicator correctly uses role="img" + aria-label to expose dimensions; the visible × symbol is handled by the label.
  • The new <Select> for board type carries aria-label={t("deviceTypeAriaLabel")} — correct.
  • BoardSelector in NavigationSidebar has aria-label={t("boardSelector")} on its trigger — good.
  • Color-swatch buttons keep aria-label + aria-pressed — unchanged and correct.
  • The fit/actual-size toggle uses role="group" + aria-label and per-button aria-pressed + visible text labels — solid.
  • Custom dimension inputs use wrapping <label> (implicit association) — correct.
  • All new user-facing strings route through next-intl keys.

@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.

Docs review

Verdict: two substantive fixes + one minor factual nit.


Settings → Display should be Settings → Hardware

Both the README blurb (line 261) and the setup guide (line 39) direct users to Settings → Display. The tab users actually click is labelled Hardware — confirmed by settings.sectionHardware = ‘Hardware’ in web/messages/en.json and by openSettingsTab(page, ‘Hardware’) in every Playwright test in this PR. DisplaySettings is the component name; the section is Hardware. Two inline suggestions fix both occurrences.


2x2_grid preset label has inconsistent spacing

The code in src/devices.py defines the label as "2×2 grid" (no spaces around ×). classify_dimensions sets matched_preset = preset["label"] verbatim, so the JSON example response and the tables in both new docs are off. Three inline suggestions correct the label in the reference table, the setup guide table, and the JSON example.


🤖 Auto-review by claude-docs-review.yml.

Comment thread README.md
| 4 stacked | 1 × 4 | 15 × 12 |
| 2 × 2 grid | 2 × 2 | 30 × 6 |

Note arrays connect through the **Vestaboard Cloud API** using an `X-Vestaboard-Token` (from your Vestaboard Cloud API subscription) — not the Read/Write key. Configure the board type, size, and token in **Settings → Display**, or let FiestaBoard read the array and pick the size for you with **Auto-detect from board**.

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.

Settings → Display — the board-config tab is labelled Hardware in the UI (see web/messages/en.json settings.sectionHardware).

Suggested change
Note arrays connect through the **Vestaboard Cloud API** using an `X-Vestaboard-Token` (from your Vestaboard Cloud API subscription) — not the Read/Write key. Configure the board type, size, and token in **Settings → Display**, or let FiestaBoard read the array and pick the size for you with **Auto-detect from board**.
Note arrays connect through the **Vestaboard Cloud API** using an `X-Vestaboard-Token` (from your Vestaboard Cloud API subscription) — not the Read/Write key. Configure the board type, size, and token in **Settings → Hardware**, or let FiestaBoard read the array and pick the size for you with **Auto-detect from board**.

Comment thread docs/setup/NOTE_ARRAYS.md

### 1. Add the board

In the FiestaBoard web UI, open **Settings → Display** and add a board (or edit

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.

Same issue: the Settings tab is Hardware, not Display.

Suggested change
In the FiestaBoard web UI, open **Settings → Display** and add a board (or edit
In the FiestaBoard web UI, open **Settings → Hardware** and add a board (or edit

| `4_wide` | 4 side-by-side | 4 × 1 | 60 × 3 |
| `2_tall` | 2 stacked | 1 × 2 | 15 × 6 |
| `4_tall` | 4 stacked | 1 × 4 | 15 × 12 |
| `2x2_grid` | 2 × 2 grid | 2 × 2 | 30 × 6 |

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.

src/devices.py label for 2x2_grid is "2×2 grid" (no spaces around ×). The Label column should match the source.

Suggested change
| `2x2_grid` | 2 × 2 grid | 2 × 2 | 30 × 6 |
| `2x2_grid` | 2×2 grid | 2 × 2 | 30 × 6 |

"cols": 30,
"notes_wide": 2,
"notes_tall": 2,
"matched_preset": "2 × 2 grid"

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.

classify_dimensions returns preset["label"] verbatim. Actual label is "2×2 grid" (no spaces), so this example response is wrong and would mislead client authors matching on the string.

Suggested change
"matched_preset": "2 × 2 grid"
"matched_preset": "2×2 grid"

Comment thread docs/setup/NOTE_ARRAYS.md
| 4 side-by-side | 4 × 1 | 60 × 3 |
| 2 stacked | 1 × 2 | 15 × 6 |
| 4 stacked | 1 × 4 | 15 × 12 |
| 2 × 2 grid | 2 × 2 | 30 × 6 |

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.

UI dropdown label is 2×2 grid (no spaces — from en.json displaySettings.presets["2x2_grid"]). Should match what users see in the dropdown.

Suggested change
| 2 × 2 grid | 2 × 2 | 30 × 6 |
| 2×2 grid | 2 × 2 | 30 × 6 |

Replace the Schedule page's local, non-persisted board selector with the
shared useCurrentBoard() hook (#1246) so Schedule and Dashboard stay in
sync and the selection persists across reloads via the sidebar selector.

- Drop the local selectedBoardId state + defaulting useEffect; source the
  active board from useCurrentBoard(). effectiveBoardId stays undefined for
  single-board installs so request shapes are unchanged.
- Remove the redundant in-page board <Select> (the sidebar selector
  replaces it); show a compact read-only active-board indicator for
  multi-board installs instead.
- Update multi-board-schedule E2E to drive board switching through the
  shared sidebar selector and assert the new read-only indicator.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added major and removed major labels Jun 21, 2026

@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: 2 a11y blockers + 3 low-severity notes in the new Note-array Settings UI


Blockers

1. Cloud API Token field — label not associated with input (WCAG 4.1.2 / 1.3.1)

web/src/components/settings/display-settings.tsx (new block below {isNoteArray(board.device_type) && ...})

The <label> element is a sibling of the <div> that contains the <input> — there is no htmlFor/id linkage and the input is not a descendant of the label. Screen reader users who navigate by form controls will hear no associated label on this field.

          <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"}

(The board.id suffix keeps IDs unique when multiple boards are rendered.)


2. Show/hide token button — no accessible name (WCAG 4.1.2)

Same block, the Eye/EyeOff toggle button renders only an icon with no aria-label:

<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>

Screen readers announce only "button". Add an aria-label (and a matching i18n key):

              aria-label={showSecrets.note_array_token ? t("hideToken") : t("showToken")}

With keys "hideToken": "Hide token" / "showToken": "Show token" in en.json (and translations elsewhere). Alternatively a single "toggleTokenVisibility": "Toggle token visibility" key works if a static label is preferred.


Low severity

3. Dynamic error regions — no aria-live (WCAG 4.1.3 Status Messages)

Two error messages appear after async/user action with no live region:

Detect error (after handleAutoDetect fails):

<div className="flex items-center gap-1.5 text-destructive text-[10px]">
  <AlertCircle className="h-3 w-3 flex-shrink-0" />
  <span>{detectError[board.id]}</span>
</div>

Dimension validation error (on invalid custom Notes wide/tall input):

<p className="text-[10px] text-destructive">{dimError[board.id]}</p>

Both are injected dynamically but neither has role="alert" or aria-live="polite", so screen reader users won't be notified. Adding role="alert" to the container div/<p> is the minimal fix. While the AlertCircle icon in the detect error is also missing aria-hidden="true" (WCAG 1.1.1), the missing live region is the higher concern.


4. boardSizeIndicator.ariaLabel left in English in all non-English locales (WCAG 3.1.2 — informational)

web/messages/de.json (and by inspection, every other locale file in this PR) ships the new screen-reader–facing strings untranslated:

"boardSizeIndicator": {
  "custom": "Custom",
  "ariaLabel": "{rows} rows by {cols} columns",
  "ariaLabelWithLayout": "{rows} rows by {cols} columns, {layout}"
}

German (and Japanese, Korean, etc.) screen reader users would hear the dimension label in English. Not a blocker since the visual text is still correct, but the aria-label is the primary accessible name for that role="img" span, so it's worth translating before the feature ships.


Clean

  • BoardSizeIndicatorrole="img" + aria-label pattern is correct; inner text is suppressed in favour of the label for AT, which is the intended reading.
  • ScaledBoardDisplay fit/actual toggle — role="group" + aria-label + aria-pressed on each button is well done.
  • BoardSelector (SelectTrigger) — has explicit aria-label={t("boardSelector")} covering the icon-only collapsed state; correct.
  • BoardConnectionForm custom Notes-wide/tall inputs — inputs are properly wrapped inside their <label> elements; no association issue there.
  • All new user-facing strings come from next-intl keys; no hardcoded English literals in TSX.

@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.

Docs review

Verdict: one label mismatch to fix — otherwise clean.

The new docs/setup/NOTE_ARRAYS.md, docs/reference/NOTE_ARRAYS.md, and README section are well-structured, accurately branded (Vestaboard = hardware, FiestaBoard = software), and contain no PII. Internal links resolve. Constants (NOTE_COLS, NOTE_ROWS, MAX_NOTES_PER_AXIS), rate-limit value, Cloud API URL, auth header, and mock-cloud port are all consistent with the PR-head source.

One factual issue: preset label spacing

The 2x2 preset label appears as 2 × 2 grid (spaces around ×) in all four doc tables. The code defines it without spaces (2×2 grid):

# src/devices.py
{"id": "2x2_grid", "label": "2×2 grid", "notes_wide": 2, "notes_tall": 2},

Tests confirm the exact string (diff lines 3104 and 4557). The most critical instance is the JSON response example in docs/reference/NOTE_ARRAYS.md — a developer matching on matched_preset would copy the wrong value. Inline suggestions cover all four occurrences.

🤖 Auto-review by claude-docs-review.yml.

"cols": 30,
"notes_wide": 2,
"notes_tall": 2,
"matched_preset": "2 × 2 grid"

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.

The matched_preset value is preset["label"] verbatim from src/devices.py. The label is "2×2 grid" (no spaces around ×). Tests assert the exact string.

Suggested change
"matched_preset": "2 × 2 grid"
"matched_preset": "2×2 grid"

| `4_wide` | 4 side-by-side | 4 × 1 | 60 × 3 |
| `2_tall` | 2 stacked | 1 × 2 | 15 × 6 |
| `4_tall` | 4 stacked | 1 × 4 | 15 × 12 |
| `2x2_grid` | 2 × 2 grid | 2 × 2 | 30 × 6 |

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.

Label column should match the actual label field in NOTE_ARRAY_PRESETS: "2×2 grid" (no spaces).

Suggested change
| `2x2_grid` | 2 × 2 grid | 2 × 2 | 30 × 6 |
| `2x2_grid` | 2×2 grid | 2 × 2 | 30 × 6 |

Comment thread docs/setup/NOTE_ARRAYS.md
| 4 side-by-side | 4 × 1 | 60 × 3 |
| 2 stacked | 1 × 2 | 15 × 6 |
| 4 stacked | 1 × 4 | 15 × 12 |
| 2 × 2 grid | 2 × 2 | 30 × 6 |

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.

The Board type dropdown shows 2×2 grid (no spaces). Setup guide should match.

Suggested change
| 2 × 2 grid | 2 × 2 | 30 × 6 |
| 2×2 grid | 2 × 2 | 30 × 6 |

Comment thread README.md
| 4 side-by-side | 4 × 1 | 60 × 3 |
| 2 stacked | 1 × 2 | 15 × 6 |
| 4 stacked | 1 × 4 | 15 × 12 |
| 2 × 2 grid | 2 × 2 | 30 × 6 |

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.

Matches the UI label and the other three tables.

Suggested change
| 2 × 2 grid | 2 × 2 | 30 × 6 |
| 2×2 grid | 2 × 2 | 30 × 6 |

…#1242] (#1252)

* feat(settings): per-board active page + versioned settings migrations [#1242]

Backend data-model foundation for per-board support (issue #1242). No
display-loop or endpoint behavior change beyond the new signatures and the
one-time migration.

- Per-board active page: ActivePageSettings gains by_board (board_id->page_id);
  active_page.page_id is kept as a back-compat mirror of the primary board's
  value. New signatures get_active_page_id(board_id=None) /
  set_active_page_id(page_id, board_id=None) (None -> primary, with mirror
  fallback when by_board is empty).
- Versioned settings.json: real schema_version + module-level MIGRATIONS list +
  _run_migrations(raw dict) + one-time backup to settings.json.v{N}_backup,
  ported from src/schedules/storage.py. Migrations run in __init__ before the
  _load_* calls and operate on the raw dict before parsing.
- Migration v0->v1 (idempotent, logs counts): (a) folds the old
  carousel:->collection: rewrite; (b) moves global active_page.page_id into
  by_board[primary_id] (skips if present, defers when no boards); (c) stamps
  schedule_enabled=True on the primary board when the legacy global
  schedule.enabled is True and the primary lacks the key.
- Per-board schedule_enabled authority: is_schedule_enabled(board_id) reads
  boards[i].schedule_enabled (None -> primary); ScheduleSettings.enabled is a
  deprecated primary mirror; set_schedule_enabled(None) writes the primary.
- Primary-board helper: SettingsService.get_primary_board_id() + module-level
  primary_board_id_from_raw(data); routed the duplicated boards[0] id reads in
  main.py (_get_first_board_id) and schedules/service.py through it.

Single-board installs migrate with zero behavior change: the global active page
moves to the primary board and reads with no board_id return the same value.

Tests: tests/test_settings_per_board.py covers migration idempotency + backup +
single-board no-op, per-board active-page get/set across two boards, the primary
helpers, and per-board schedule_enabled (including its migration).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style: apply ruff format to settings service + per-board tests

Wrap the over-length by_board coercion in ActivePageSettings.from_dict and
normalize the per-board test fixtures to a single-line write helper so
ruff format --check passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: align stale per-board tests with get_primary_board_id SSOT [#1242]

Platform Tests failed on 4 stale tests that predate the get_primary_board_id()
single-source-of-truth refactor (which replaced ad-hoc boards[0] lookups in
main.py and schedules/service.py):

- test_schedule_board_id_bug.py (x2): mock get_primary_board_id so the polling
  loop resolves the active board id the test asserts is threaded through.
- test_schedules_service legacy-default: mock get_primary_board_id so the
  first-board legacy '' schedule mapping resolves.
- test_settings_service ActivePageSettings.to_dict: include the new by_board
  per-board map field.

Implementation is unchanged and correct; only the tests were stale.
Full suite: 3923 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added major and removed major labels Jun 22, 2026

@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.

Small a11y fixes — three findings, none blocking.


Finding 1 — boardSizeIndicator aria-labels not translated (13 locales) · WCAG 3.1.2

web/messages/{de,es,fr,it,ja,ko,nl,pl,pt,ru,sv,tr,zh}.json — all 13 non-English locale files received the boardSizeIndicator block as raw English:

```json
"boardSizeIndicator": {
"custom": "Custom",
"ariaLabel": "{rows} rows by {cols} columns",
"ariaLabelWithLayout": "{rows} rows by {cols} columns, {layout}"
}
```

ariaLabel and ariaLabelWithLayout are the values passed to aria-label on <span role="img"> in board-size-indicator.tsx. A screen-reader user with, say, a German OS will hear "6 rows by 22 columns" rather than "6 Reihen mal 22 Spalten". The other a11y-relevant navigation keys added in the same PR (boardSelector, selectBoard, unnamedBoard) were translated — so this looks like an oversight on the boardSizeIndicator section.


Finding 2 — Cloud API Token input has no programmatic label · WCAG 4.1.2

web/src/components/settings/display-settings.tsx ~line 313:

```tsx
{t("noteArrayTokenLabel")}

\`\`\`

The <label> and <input> are siblings with no htmlFor/id pairing and no aria-label on the input. Screen readers will not announce "Cloud API Token" when the field is focused. (The same pattern exists for local_api_key and cloud_key fields added earlier — this PR perpetuates it rather than introducing it, but each new instance remains a violation.)

Suggested fix:

```suggestion
<label htmlFor={note-array-token-${board.id}} className="text-xs font-medium">{t("noteArrayTokenLabel")}

\`\`\`

Finding 3 — Eye/reveal button for note_array_token has no accessible name · WCAG 4.1.2

web/src/components/settings/display-settings.tsx ~line 323:

```tsx
<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 ? : }

```

Icon-only button with no aria-label. Screen readers announce it as an unlabeled button. Same pre-existing gap on the local_api_key / cloud_key buttons — PR perpetuates the pattern. Suggest adding:

```suggestion
<Button
type="button"
variant="outline"
size="sm"
aria-label={showSecrets.note_array_token ? t("hideToken") : t("showToken")}
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 ? : }

```

(Requires adding hideToken/showToken keys to en.json and all locale files — or reuse existing keys if already present for the sibling fields.)


Low-priority note — dynamic error messages not live-announced · WCAG 4.1.3

display-settings.tsx — the dimError and detectError paragraphs appear dynamically but carry no role="alert" or aria-live attribute, so they won't be announced to screen-reader users unless focus happens to land on them. Consider role="alert" on both. Not a blocker given how niche these flows are, but worth a follow-up.


Everything else looks good

  • BoardSizeIndicator uses <span role="img" aria-label={ariaLabel}> — correct pattern for a numeric indicator.
  • The fit/actual-size toggle in ScaledBoardDisplay uses <div role="group" aria-label={t("previewSizeLabel")}> with aria-pressed on each button and visible text labels — well done.
  • BoardSelector in the sidebar gives SelectTrigger an aria-label from i18n (t("boardSelector")); collapsed state wraps it in a <Tooltip> without removing the label.
  • Board-type Select in display-settings.tsx has aria-label={t("deviceTypeAriaLabel")} on SelectTrigger — covered.
  • Custom dimension inputs use the implicit-label pattern (<label>…<input /></label>) — valid association.
  • All new user-facing strings (including aria-labels) in en.json come from next-intl keys; no hardcoded English literals in TSX.

…gate for #1185] (#1296)

* fix(note-arrays): size all send paths to real dimensions + harden tests

Note-array boards crashed with HTTP 500 on every send path that resolved
board geometry via get_dimensions(), which only knows flagship/note and
raises ValueError on "note_array". Reproduced at runtime: POST
/templates/render/live to a note-array board 500s with
"Unknown device type: note_array. ... use resolve_dimensions()".

Fix all send sites to use resolve_dimensions(device_type, notes_wide,
notes_tall):
- /templates/render/live   (live editor output)
- /pages/{id}/send         (send a page)
- /settings/active-page    (set active page + send)
- /displays/{type}/send    (send a plugin display)
- /send-message            (also de-hardcodes the flagship 22x6 grid)

Test hardening (the existing send tests mocked get_dimensions away, so
they could never have caught this):
- Add a real regression test that sends a live template to a note-array
  board WITHOUT mocking the resolver, asserting no 500 and a true 3x60 grid.
- Repoint the 7 send-endpoint tests that patched src.api_server.get_dimensions
  to patch resolve_dimensions (get_dimensions is no longer used there).
- Make the two note-array Cloud URL tests hermetic: assert against the
  client's configured URL so they pass in the documented `docker exec ...
  pytest` dev flow (VESTABOARD_CLOUD_API_URL -> mock), not only in CI.

Also:
- devices.py: guard BoardInstance.from_dict against a JSON-null
  note_array_token ((... or "").strip() crashed on None.strip()).
- ci.yml: run the integration-tests/ mock-cloud contract suite, which was
  never collected (pyproject testpaths excludes integration-tests/).

Verified in the dev container on origin/next: full Python suite 4102
passed / 11 skipped (was 4099 + 2 failing), integration 25 passed, web
vitest 1166 passed, and a live send to a note-array board now returns 200
and lands a 3x60 grid on the bundled mock Cloud board.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(note-arrays): localize size-indicator preset labels + complete BoardInstance TS type

- board-size-indicator.tsx rendered note-array preset names from the raw
  English NOTE_ARRAY_PRESETS[].label, leaking English into both the visible
  indicator and its screen-reader aria-label for all 13 non-English locales.
  Resolve the label via the displaySettings namespace (`presets.${id}`) --
  the same localized keys the Settings board-type selector already uses, so
  the English output is identical and existing unit tests / E2E are unchanged.

- api.ts: add `schedule_enabled` and `port` to interface BoardInstance so the
  TS contract matches what GET /settings/board actually emits (src/devices.py).
  Not a data-loss bug -- the Settings UI spreads the received board object on
  edit, preserving the runtime fields -- but the contract was incomplete.

Verified: i18n key parity clean across all 14 locales; full web vitest 1166
passed; prettier + eslint clean on changed files.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot removed the major label Jun 28, 2026

@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 - 4 findings, 2 medium and 2 low.

@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 - 4 findings, 2 medium and 2 low.


1. Dynamic error messages not announced to AT (medium) - WCAG 4.1.3

Two new inline error regions appear without role="alert" or aria-live, so screen readers will not announce them when they pop in. Both are new in this PR.

Auto-detect error - web/src/components/settings/display-settings.tsx (auto-detect section):
The detectError <div> should have role="alert" added so the error is announced without focus movement.

Custom dimension validation error - same file (custom W x H section):
The dimError <p> should have role="alert" added.

WCAG 4.1.3 requires dynamic status messages to be programmatically determinable so AT can present them without the element receiving focus.


2. Icon-only show/hide button for Cloud API Token has no accessible name (medium) - WCAG 4.1.2

web/src/components/settings/display-settings.tsx, note_array_token section: the Eye/EyeOff toggle <Button> has no aria-label. A keyboard/screen reader user cannot tell what the button does. The same gap exists on the pre-existing local_api_key and cloud_key eye buttons (not in scope here), but this PR introduces a new instance.

Fix: add an aria-label to the <Button> that toggles between e.g. "Show token" / "Hide token", backed by new i18n keys added to en.json and the 13 other locale files.


3. label for Cloud API Token input not associated with its input (low) - WCAG 1.3.1 / 4.1.2

Same file, note_array_token section. The <label> is a sibling of the <div> containing the <input>, not wrapping it, and there is no htmlFor/id binding - the association is not programmatic. Screen readers may not announce the label text when the input receives focus.

The custom W x H inputs added in this same PR do this correctly by wrapping the input inside the label element. The disconnected-label pattern also exists on the pre-existing local_api_key / cloud_key fields (not in scope here). Use the wrapping pattern consistently.


4. boardSizeIndicator aria-label strings left as English in 13 non-English locales (low) - WCAG 3.1.2

All 13 non-English locale files (de, es, fr, it, ja, ko, nl, pl, pt, ru, sv, tr, zh) carry the new boardSizeIndicator block as raw English: "custom": "Custom", "ariaLabel": "{rows} rows by {cols} columns", "ariaLabelWithLayout": "{rows} rows by {cols} columns, {layout}".

ariaLabel and ariaLabelWithLayout are used as aria-label on <span role="img"> in board-size-indicator.tsx, so AT users in any non-English locale hear board dimensions announced in English. custom also surfaces as visible text. Low severity since the values are still intelligible, but they should be translated alongside the rest of the locale additions in this PR.


What is clean: The Fit/Actual size toggle (scaled-board-display.tsx) is well-formed - role="group" with aria-label on the wrapper, aria-pressed on each button, all strings via i18n, focus rings present. The BoardSelector in the sidebar has a proper aria-label on the <SelectTrigger> for the collapsed icon-only state. BoardSizeIndicator uses role="img" with a descriptive aria-label (for en). The board-type <Select> carries aria-label from i18n and Radix SelectGroup/SelectLabel for grouped options. Color-picker buttons (blackAriaLabel, whiteAriaLabel, aria-pressed) are unchanged and correct.

@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.

Docs review

Verdict: three label-string fixes needed, plus a notation ambiguity to address.

matched_preset JSON example has the wrong string (line 110, reference doc)

The code in src/devices.py (line 39) defines the 2×2 grid preset label as 2×2 grid (no spaces around the multiplication sign). The reference doc example response shows 2 × 2 grid (with spaces), which does not match the literal string the API returns. Inline suggestion attached.

Presets table Label column shows wrong spacing (line 52 reference doc; line 57 setup guide)

Both tables show 2 × 2 grid (spaces) in the label/board-type column. Both should say 2×2 grid to match the actual code label. Inline suggestions attached.

Notation ambiguity in resolve_dimensions annotation (lines 30-31, reference doc)

The wording note at lines 38-40 declares W × H throughout this reference, but lines 30-31 give flagship (6 × 22) and note (3 × 15) in (rows, cols) = H × W order (the function return-tuple ordering). A reader who applies W × H reads Flagship as 6 wide, 22 tall, which is wrong (Flagship is 22 wide, 6 tall). Consider flagship (22 × 6) / note (15 × 3) to match the stated W × H convention, or use explicit (rows=6, cols=22) naming.

The classify_dimensions list at lines 127-128 has the same ordering but is safer there: the preamble at line 124 explicitly says Given a raw (rows, cols), so the order is unambiguous.


Branding and FiestaBoard/Vestaboard usage correct throughout. Privacy clean. Internal links resolve. All factual claims against the code check out except the preset label string above.

Auto-review by claude-docs-review.yml.

"cols": 30,
"notes_wide": 2,
"notes_tall": 2,
"matched_preset": "2 × 2 grid"

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.

The actual preset label in src/devices.py (line 39) is 2×2 grid (no spaces around ×). This JSON example should match the literal string the API returns.

Suggested change
"matched_preset": "2 × 2 grid"
"matched_preset": "2×2 grid"

| `4_wide` | 4 side-by-side | 4 × 1 | 60 × 3 |
| `2_tall` | 2 stacked | 1 × 2 | 15 × 6 |
| `4_tall` | 4 stacked | 1 × 4 | 15 × 12 |
| `2x2_grid` | 2 × 2 grid | 2 × 2 | 30 × 6 |

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.

The Label column should match the code preset label 2×2 grid (no spaces), consistent with the corrected JSON example on line 110.

Suggested change
| `2x2_grid` | 2 × 2 grid | 2 × 2 | 30 × 6 |
| `2x2_grid` | 2×2 grid | 2 × 2 | 30 × 6 |

Comment thread docs/setup/NOTE_ARRAYS.md
| 4 side-by-side | 4 × 1 | 60 × 3 |
| 2 stacked | 1 × 2 | 15 × 6 |
| 4 stacked | 1 × 4 | 15 × 12 |
| 2 × 2 grid | 2 × 2 | 30 × 6 |

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.

The UI dropdown label is 2×2 grid (no spaces, per src/devices.py line 39). The setup guide should show what users see in the dropdown.

Suggested change
| 2 × 2 grid | 2 × 2 | 30 × 6 |
| 2×2 grid | 2 × 2 | 30 × 6 |

Brings the merged-to-main storage fixes onto the multi-board (Note Arrays)
branch so PR #1185 carries them:
- #1308 default page device_type after last-delete
- #1309 atomic tmp+os.replace writes in Page/Schedule storage
- #1310 PageStorage.update can clear nullable fields (demo_plugin_id excluded)
- #1311 preserve unparseable entries across migration save

Conflicts resolved: storage.py _save() combines #1311's failed-entry
preservation + datetime coercion with #1309's atomic write; tests/test_pages.py
keeps both next's TestMigrateV3ToV4 (multi-board v3->v4 migration) and main's
TestAtomicSave. Verified: 158 storage/migration tests pass, ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@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 — all minor / non-blocking. The web changes are accessible overall; new interactive UI is well-labeled and every new user-facing string (incl. aria-labels, the size toggle, the size indicator) routes through next-intl keys that exist in en.json. A few small items below.

Findings

1. Icon-only show/hide button has no accessible name — web/src/components/settings/display-settings.tsx:317 (WCAG 4.1.2)
The new Note-array token reveal button renders only an Eye/EyeOff icon, so screen readers announce just "button". This matches the existing pattern for the local-API / enablement / cloud-key toggles (:206, :242, :294), which are equally unlabeled — so it is not a regression, but it adds a fourth instance and would be a good moment to fix the pattern. A dynamic aria-label is ideal since the action flips:

              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("hideToken") : t("showToken")}

(would need showToken/hideToken keys added to displaySettings in en.json + the other locales).

2. Token label not programmatically associated with its input — display-settings.tsx:303 (WCAG 1.3.1 / 4.1.2)
The token <label> has no htmlFor, and the <input> has no id, so the label is not exposed as the field accessible name (it falls back to the placeholder). Same caveat as item 1 — the existing secret fields in this file share the bare-<label> pattern, so this is pre-existing convention, not new breakage. Worth wiring up htmlFor/id (or nesting the input inside the label, as the custom W×H inputs already do at :540).

3. Validation / error text is not announced — display-settings.tsx:705 (dimError) and :728 (detectError) (WCAG 4.1.3, Status Messages, AA)
The custom-dimension range error and the auto-detect failure render into static <p>/<div> nodes. A screen-reader user who submits an out-of-range value or hits a failed detect gets no notification. Adding role="alert" (or an aria-live="polite" region) to those containers would announce them. Low priority, but this is newly-introduced async/validation UI — exactly what status messages target.

4. boardSizeIndicator aria-label left in English across non-English locales — e.g. web/messages/de.json (and es/fr/it/… in this PR) ship "ariaLabel": "{rows} rows by {cols} columns", "ariaLabelWithLayout", and "custom" untranslated. Because this string is the aria-label of the role="img" size indicator, German/Spanish/etc. screen-reader users hear English. The sibling a11y labels in displaySettings (deviceTypeAriaLabel, presets.*) are translated, so this namespace stands out. May be intentional pending a translation pass — flagging so it is not lost.

Looks good (no action)

  • BoardSizeIndicator uses role="img" + a translated aria-label that names rows/columns explicitly, with the visual ·/preset text correctly hidden behind the single accessible name. (board-size-indicator.tsx)
  • Sidebar BoardSelector: trigger has aria-label={t("boardSelector")}; the collapsed icon-only variant keeps the label and adds a tooltip with the current board name. (navigation-sidebar.tsx)
  • Preview Fit/Actual toggle: wrapped in role="group" + aria-label, buttons expose aria-pressed and visible text labels, with focus-visible:ring-2. (scaled-board-display.tsx)
  • Color swatches retain aria-label + aria-pressed; device-type SelectTrigger has aria-label={t("deviceTypeAriaLabel")}. No hardcoded English literals found in the changed TSX.

Color-contrast is excluded from the axe gate and not assessed here. Review only — sign-off stays human.

@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.

Docs review

Verdict: small fixes — the new Note-array docs are accurate, well-voiced, and the technical claims (presets, dimensions, Cloud API transport, the >=15s rate limit, the 8x8 cap, the BOARD_NOTE_ARRAY_TOKEN env var, the mock at localhost:19200/ui, and every internal link) all check out against the code at head. Branding is correct throughout (Vestaboard = hardware, FiestaBoard = software). Two real fixes plus a label-spacing nit, all inline below.

Substantive: Both the README and the setup guide tell users to open Settings -> Display, but there is no Display section. Board type/size/token live under Settings -> Hardware — confirmed by web/messages/en.json (sectionHardware = Hardware, "Physical board configuration and connection"; no Display section exists), by this PR's own UAT steps ("Settings -> Hardware"), and by the regression spec name web/tests/regression/settings-hardware-network.spec.ts.

Minor: The 2x2 preset's label is the literal string 2×2 grid (no spaces around the ×) in both src/devices.py NOTE_ARRAY_PRESETS and web/messages/en.json. The docs render it 2 × 2 grid. Most consequential in the reference doc's auto-detect JSON example, where matched_preset is shown as an exact API return value — classify_dimensions actually returns 2×2 grid. The same spacing appears in the three preset tables; suggestions below.

Inline suggestions below — click Apply suggestion to commit any you agree with.

🤖 Auto-review by claude-docs-review.yml.

Comment thread docs/setup/NOTE_ARRAYS.md

### 1. Add the board

In the FiestaBoard web UI, open **Settings → Display** and add a board (or edit

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.

There is no Display settings section — board type, size, and the Cloud API token live under Settings -> Hardware (sectionHardware in en.json; the PR's UAT and settings-hardware-network.spec.ts agree).

Suggested change
In the FiestaBoard web UI, open **Settings → Display** and add a board (or edit
In the FiestaBoard web UI, open **Settings → Hardware** and add a board (or edit

Comment thread README.md
| 4 stacked | 1 × 4 | 15 × 12 |
| 2 × 2 grid | 2 × 2 | 30 × 6 |

Note arrays connect through the **Vestaboard Cloud API** using an `X-Vestaboard-Token` (from your Vestaboard Cloud API subscription) — not the Read/Write key. Configure the board type, size, and token in **Settings → Display**, or let FiestaBoard read the array and pick the size for you with **Auto-detect from board**.

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.

Same nav fix: the board type/size/token controls are under Settings -> Hardware, not Display (no Display section exists).

Suggested change
Note arrays connect through the **Vestaboard Cloud API** using an `X-Vestaboard-Token` (from your Vestaboard Cloud API subscription) — not the Read/Write key. Configure the board type, size, and token in **Settings → Display**, or let FiestaBoard read the array and pick the size for you with **Auto-detect from board**.
Note arrays connect through the **Vestaboard Cloud API** using an `X-Vestaboard-Token` (from your Vestaboard Cloud API subscription) — not the Read/Write key. Configure the board type, size, and token in **Settings → Hardware**, or let FiestaBoard read the array and pick the size for you with **Auto-detect from board**.

"cols": 30,
"notes_wide": 2,
"notes_tall": 2,
"matched_preset": "2 × 2 grid"

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.

classify_dimensions returns preset["label"], which for this preset is the literal 2×2 grid (no spaces around ×). The example shows the spaced form, so it does not match the actual API response.

Suggested change
"matched_preset": "2 × 2 grid"
"matched_preset": "2×2 grid"

| `4_wide` | 4 side-by-side | 4 × 1 | 60 × 3 |
| `2_tall` | 2 stacked | 1 × 2 | 15 × 6 |
| `4_tall` | 4 stacked | 1 × 4 | 15 × 12 |
| `2x2_grid` | 2 × 2 grid | 2 × 2 | 30 × 6 |

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.

The Label column should mirror the actual NOTE_ARRAY_PRESETS label, which is 2×2 grid (no spaces around ×).

Suggested change
| `2x2_grid` | 2 × 2 grid | 2 × 2 | 30 × 6 |
| `2x2_grid` | 2×2 grid | 2 × 2 | 30 × 6 |

Comment thread README.md
| 4 side-by-side | 4 × 1 | 60 × 3 |
| 2 stacked | 1 × 2 | 15 × 6 |
| 4 stacked | 1 × 4 | 15 × 12 |
| 2 × 2 grid | 2 × 2 | 30 × 6 |

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.

Preset name is 2×2 grid (no spaces around ×) to match the UI label.

Suggested change
| 2 × 2 grid | 2 × 2 | 30 × 6 |
| 2×2 grid | 2 × 2 | 30 × 6 |

Comment thread docs/setup/NOTE_ARRAYS.md
| 4 side-by-side | 4 × 1 | 60 × 3 |
| 2 stacked | 1 × 2 | 15 × 6 |
| 4 stacked | 1 × 4 | 15 × 12 |
| 2 × 2 grid | 2 × 2 | 30 × 6 |

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.

Preset name is 2×2 grid (no spaces around ×) to match the UI label.

Suggested change
| 2 × 2 grid | 2 × 2 | 30 × 6 |
| 2×2 grid | 2 × 2 | 30 × 6 |

…hardware output (#1386)

* test(e2e): verify multi-board output isolation at the board-hardware layer

New multi-board-output.spec.ts drives two boards with real mock Local-API
connections (ports 7000/7001) and asserts what each physical board actually
received — a layer no existing spec covered:

- manual send and scheduled send land on board 1's hardware only
- a board-2-scoped schedule never leaks onto board 1 (content + history)
- per-board pause blocks output without disturbing the other board
- per-board schedule lists stay disjoint/complete; default page and
  enabled flag are independent
- deleting a board leaves no schedules that activate on the survivor
- UI: schedule board selector names the selected board, fully swaps the
  schedule view, and the per-board enabled toggle follows the selection

Two verified product gaps are pinned as test.fixme so they activate the
moment the behavior lands:
- board 2 never receives its scheduled content (display engine drives
  boards[0] only — epic #1241)
- /settings/board mutations never reinitialize the board client, so after
  deleting board 1 the promoted board's content is still delivered to the
  REMOVED board's hardware until restart

Support changes:
- helpers.ts: worker-aware MOCK_BOARD_URL_2, getMockBoardState2(),
  gridToText() decoder, ensureTwoBoardsWithConnections(); fix
  getMockBoardState(port) to stop sending ?port= (the mock keys state by
  internal listening port, so host-mapped ports 404'd — why the port-2
  constants were dead imports)
- ci.yml / integration-tests.yml: mock-board containers now run with
  PORTS=7000,7001 so the second board exists in CI

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(e2e): make per-board default-page assertions pollution-proof

The schedule-lists test assumed board 1's default_page_id was null on a
container shared with earlier tests, which intermittently left a default
page behind (failed in two CI runs). Set both boards' defaults explicitly
and assert independence instead, and verify clearing one board leaves the
other untouched. Also give the board-selector visibility check the same
15s budget as the page heading.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(e2e): compare theme-toggle round-trip as class sets, not raw attribute

The theme toggle test asserted the html element's raw class attribute
round-trips exactly. But "no classes" is null before the first toggle
(attribute absent) and "" after toggling dark on and off (the theme lib
removes its class but leaves the empty attribute), so the assertion
failed deterministically on every CI run while the toggle itself works
correctly. Compare normalized class sets instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(e2e): align theme-toggle fix with main's PR #1380 wording

Adopt the exact null-vs-empty-class normalization that already landed on
main (PR #1380), making navigation.spec.ts byte-identical to main so the
next main<->next merge auto-resolves instead of conflicting on two
equivalent fixes for the same brittle assertion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

@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.

a11y verdict: mostly clean — a few low-priority nits, no blockers.

Scope: reviewed only the accessibility of the changed web/src/**/*.tsx and web/messages/**. The note-array UI is generally strong on a11y, and every new user-facing string (including aria-labels, placeholders, and error text) resolves to a real key in the PR's web/messages/en.json — I confirmed each of boardSizeIndicator.*, navigation.{boardSelector,selectBoard,unnamedBoard}, boardDisplay.{previewSizeLabel,fitMode,actualMode}, and the new displaySettings.* keys (token/detect/dimension labels + presets.*). No hardcoded English literals in the changed TSX.

Nice a11y touches worth calling out:

  • board-size-indicator.tsxrole="img" + composed aria-label ("N rows by M columns, {layout}") so the visual N × M · label reads unambiguously and the localized preset name matches the Settings selector.
  • scaled-board-display.tsx — Fit/Actual toggle wrapped in role="group" + aria-label, buttons use aria-pressed and focus-visible:ring.
  • navigation-sidebar.tsx BoardSelectorSelectTrigger has an aria-label, and the collapsed icon-only variant is backed by a tooltip showing the current board name.
  • display-settings.tsx — device-type Select has aria-label + grouped SelectLabels; color swatches have aria-label + aria-pressed; custom W×H <input>s are wrapped inside their <label> (implicit association).

Low-priority findings (none gate merge):

  1. web/src/components/settings/display-settings.tsx:728 — auto-detect result isn't announced (WCAG 4.1.3). handleAutoDetect updates size on success silently and renders detectError into a plain <div>. A screen-reader user who activates "Auto-detect" gets no feedback on success or failure. Consider wrapping the error node (or a status node) in role="status" / aria-live="polite". This is the most worthwhile of the four.

  2. display-settings.tsx:705 — custom-dimension validation error is not programmatically linked (WCAG 3.3.1 / 4.1.3). dimError[board.id] renders in a <p> that isn't referenced by the number inputs (aria-describedby) and isn't a live region, and the inputs don't set aria-invalid. On an out-of-range value the error is visual-only.

  3. display-settings.tsx:304 — new "Cloud API Token" <label> isn't associated with its <input> (WCAG 1.3.1 / 4.1.2). The label is a sibling with no htmlFor, and the input has no id/aria-label. Note this exactly mirrors the pre-existing cloudKeyLabel / localApiKey inputs in the same component, so it's a consistency issue rather than a regression — but the new field is a good spot to add htmlFor/id (or wrap the input in the label) if you want to start closing it out.

  4. display-settings.tsx:322 — the token show/hide <button> is icon-only (Eye/EyeOff) with no accessible name (WCAG 4.1.2). Same caveat: it mirrors the existing secret-field toggles, but the new one could take a localized aria-label (e.g. a show/hide-token string).

Color-contrast is out of scope for the axe gate and I didn't assess it.

@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.

Docs review

Verdict: small fixes - one accuracy nit, otherwise clean.

Reviewed the docs diff only (README.md, docs/reference/NOTE_ARRAYS.md, docs/setup/NOTE_ARRAYS.md, env.example). Verified against PR-head source:

  • Dimensions model, presets, and MAX_NOTES_PER_AXIS=8 match src/devices.py.
  • Cloud API transport (X-Vestaboard-Token, https://cloud.vestaboard.com/, POST characters-grid, read currentMessage.layout) and NOTE_ARRAY_MIN_SEND_INTERVAL = 15.0 match src/board_client.py.
  • VESTABOARD_CLOUD_API_URL and BOARD_NOTE_ARRAY_TOKEN are both really read (board_client.py:155, config_manager.py:807).
  • The detect-size POST endpoint and its 404/400/422 error codes match src/api_server.py.
  • Branding is correct throughout (FiestaBoard = software, Vestaboard = hardware); internal relative links resolve.

One accuracy nit (inline): the preset's actual label in NOTE_ARRAY_PRESETS is 2×2 grid (no spaces around ×). The reference doc renders it as 2 × 2 grid - a mismatch that matters most in the JSON example, since that block claims a literal API return value. The other four preset labels in the same table are quoted verbatim from source, so only this one row drifts.

Minor/optional: the README and setup-guide display tables also show 2 × 2 grid as a user-facing name. Those aren't quoting a code literal so it's stylistic, but if you want the spelling to match the UI label exactly you may want to align them too.

🤖 Auto-review by claude-docs-review.yml.

"cols": 30,
"notes_wide": 2,
"notes_tall": 2,
"matched_preset": "2 × 2 grid"

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.

This block documents a literal detect-size response, and matched_preset returns the preset's label verbatim. The actual label in NOTE_ARRAY_PRESETS is 2×2 grid (no spaces around ×), so the real response value differs from what's shown here.

Suggested change
"matched_preset": "2 × 2 grid"
"matched_preset": "2×2 grid"

| `4_wide` | 4 side-by-side | 4 × 1 | 60 × 3 |
| `2_tall` | 2 stacked | 1 × 2 | 15 × 6 |
| `4_tall` | 4 stacked | 1 × 4 | 15 × 12 |
| `2x2_grid` | 2 × 2 grid | 2 × 2 | 30 × 6 |

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.

The Label column quotes each preset's label field verbatim (the other four rows match source exactly). The 2x2_grid label is 2×2 grid in src/devices.py, without spaces around the ×.

Suggested change
| `2x2_grid` | 2 × 2 grid | 2 × 2 | 30 × 6 |
| `2x2_grid` | 2×2 grid | 2 × 2 | 30 × 6 |

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment