feat(boards): drive every configured board from its own schedule (#1243 first slice)#1395
feat(boards): drive every configured board from its own schedule (#1243 first slice)#1395jeffredodd wants to merge 7 commits into
Conversation
… first slice)
The display engine now builds one client per configured board and, on each
update cycle, resolves and delivers every enabled, unpaused,
schedule-enabled board's own active page via its own client — where
previously only boards[0] was ever driven.
- _build_board_clients: one client per board with credentials (board_clients
map); vb_client remains the primary board's client for the single-board
code paths. Reinit no longer blocks on board I/O inside request handlers.
- _update_secondary_boards: schedule + per-board default page, per-board
pause/schedule_enabled/enabled gates, collections, per-page transitions,
per-board content cache, failure isolation (one board's error never
blocks another). Silence mode freezes secondaries; triggers, temporary
overrides and the manual active page remain primary-only for now.
- /settings/board PUT/add/DELETE now rebuild the board clients, fixing the
stale-client bug where output kept targeting a removed board's
connection until restart.
- /force-refresh clears every board client's cache, not just the primary.
- Page-driven sends use resolve_dimensions() so note_array pages no longer
raise in the send path.
- /pages/{id}/send returns a structured error body when the board is
unreachable (nginx swallows 502/503/504 on /api/).
- E2E: the two test.fixme gap pins flip to active tests, plus new
mixed-mode coverage: both boards showing their own content from one
refresh, and schedule mode off for one board silencing only that board.
Remaining #1243 scope (BoardRuntime refactor, per-board read polling,
per-board triggers/overrides) is unchanged and tracked on the issue.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…enario With schedule mode off, the primary board falls back to its manual active page (and defaults to the first page rather than going dark), so "board 1 receives nothing" was never the contract. Assert what users actually get: board 1 shows its manual page and ignores its own schedule while board 2 delivers scheduled content, with no cross-board bleed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The board selector is the app-wide context switcher — it scopes every page below it — so it now leads the menu instead of sitting below the primary navigation: - Desktop: directly under the logo in its own zone, restyled as a context chip on the sidebar surface (translucent accent background, sidebar foreground text) instead of a bare form control. Collapsed mode keeps the icon-only trigger with the current-board tooltip. - Mobile: promoted out of the drawer into the always-visible header bar, so the current board is always on screen and switching takes one tap without opening the menu (it also stays reachable while the drawer is open). The drawer no longer duplicates it. Renders only for multi-board installs, as before. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Verdict: no a11y concerns.
The only web change is web/src/components/navigation-sidebar.tsx, which relocates the multi-board BoardSelector (top of the desktop sidebar + a new mobileHeader variant in the always-visible header) and adds a variant prop. No new interactive elements, strings, headings, or landmarks are introduced — it re-parametrizes styling on the existing Radix SelectTrigger.
A11y checks against FiestaBoard conventions / WCAG 2.2 AA:
- Accessible name (4.1.2/1.3.1): ✅ The
SelectTriggerkeepsaria-label={t("boardSelector")}(navigation-sidebar.tsx:708), so the control is named in both variants even when the visible label is truncated (max-w-[170px]) or hidden in the collapsed sidebar (max-w-0 opacity-0). - Strings via next-intl (i18n): ✅ No hardcoded literals added.
boardSelectorandselectBoardalready exist under thenavigationnamespace inweb/messages/en.json(lines 81–82); noweb/messages/**changes needed. - Semantics (2.1.1/4.1.2): ✅ Still a Radix Select trigger — no new
div role=button/ unlabeledonClick. - No duplicate control: ✅ The old bottom-of-nav and mobile-menu instances are removed; mobile now has exactly one selector (header), desktop one (top of sidebar), each in its own responsive branch.
- Headings/landmarks (1.3.1): ✅ Unchanged; existing
<nav aria-label>landmarks preserved.
No blockers, no suggested fixes.
(Note, low priority / out of scope: the decorative Monitor icon has no aria-hidden, but this is pre-existing and harmless here since the aria-label sets the trigger's accessible name.)
The multi-board specs all ran flagship+flagship; this closes the mixed- device gap: one refresh delivers correctly-sized content to each device (6×22 grid to the flagship, 3×15 to the note board, dimension + token asserts, no cross-device leakage in either direction), and the board selector switches cleanly between a flagship and a note board on /schedule. Uses the previously-unused board2DeviceType helper support. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A new page's device type was locked to the Pages tab the user came from (/pages/new?device=…) — starting on the wrong tab meant recreating the page. The editor now shows a board-size switcher (Flagship/Note) next to the size indicator for NEW pages; the grid resizes live and the chosen type is what gets saved. Existing pages stay locked: converting saved 6×22 content to 3×15 is lossy, so that remains out of scope. TDD: unit specs (switcher resizes to 3×15, absent for existing pages) and an e2e (flagship-entry page saved as a note page) were written and verified failing before the implementation. The aria-label is translated in all 14 locales. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
"removing Note board hides Note tab" failed deterministically whenever an earlier suite's cleanup left a note-typed page behind: deleting the last page auto-creates a Welcome typed to the deleted page's device, and pages keep their device tab alive by design (#943). Harden the test to clear lingering note pages (anchored so the store never empties), add a deletePagesByDevice helper, and make the suites that create note pages clean them up note-first so the regenerated Welcome is flagship. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Verdict: no a11y concerns. The web UI changes are accessible and i18n-clean.
Reviewed the two TSX files (web/src/components/page-builder.tsx, web/src/components/navigation-sidebar.tsx) and all 14 web/messages/*.json changes on the a11y dimension only.
What I checked
- New board-size switcher (
web/src/components/page-builder.tsx:1481) — implemented as a shadcnSelectwith a proper accessible name viaaria-label={t("deviceTypeSwitcherAriaLabel")}on theSelectTrigger. Native semantics, keyboard-operable, no icon-only-button gap. (WCAG 4.1.2 / 2.1.1) - Strings via next-intl — the new
aria-labelresolves through thepageBuildernamespace, anddeviceTypeSwitcherAriaLabelis present inen.json(underpageBuilder) and translated in all 13 other locales — none left as raw English. The option labels reuse existingdisplaySettings.flagshipLabel/noteLabelkeys. No hardcoded literals. (WCAG 1.3.1 / 3.1.2) - BoardSelector
mobileHeadervariant (web/src/components/navigation-sidebar.tsx) — the refactor only changes placement/styling; the trigger keeps itsaria-label={t("boardSelector")}, so the accessible name is preserved across both variants. Responsive duplication (sidebar vs. header) is standard and each instance is viewport-scoped. (WCAG 4.1.2) - Headings / landmarks / images — no changes; the
headerandnav aria-labellandmarks and heading structure are untouched.
No blockers, no fixes needed. Sign-off stays human.
There was a problem hiding this comment.
Docs review
Verdict: one small accuracy fix - otherwise clean.
The two new coverage rows (multi-board-mixed.spec.ts, page-device-switch.spec.ts) are accurate: both specs exist at head and the concrete claims check out (Flagship 6x22, Note 3x15, the /schedule device-type selector, and device_type=note persistence). Examples-first voice, no marketing fluff, no branding/privacy/link issues.
One staleness finding is inline below - click Apply suggestion to commit it.
🤖 Auto-review by claude-docs-review.yml.
| @@ -36,6 +36,8 @@ Playwright browser → Next.js UI → FastAPI backend → Mock Vestaboard API | |||
| | `multi-board.spec.ts` | Multi-board | Board card display, board CRUD (add/rename/type/color/toggle/delete), wizard board type/color picker, cross-feature config | | |||
| | `multi-board-schedule.spec.ts` | Multi-board scheduling | Single-board schedule page, two-board board selector, schedule mode toggle, per-board CRUD, filtering by board_id | | |||
| | `multi-board-output.spec.ts` | Multi-board output | Two boards with real mock connections (ports 7000/7001): manual send + scheduled send land on the right board's hardware, cross-board leak checks, per-board pause isolation, per-board schedule list consistency, board-deletion consistency, board-selector UI clarity (name shown, view fully swaps, per-board toggle). Known gaps as fixme: board 2 driving (epic #1241), client reinit on board mutations | | |||
There was a problem hiding this comment.
This row still lists board 2 driving (epic #1241) and client reinit on board mutations as fixme gaps - but the headline change of this PR is that those two test.fixme pins are now active tests. The Known gaps as fixme clause is now stale; fold those items into the covered list instead:
| | `multi-board-output.spec.ts` | Multi-board output | Two boards with real mock connections (ports 7000/7001): manual send + scheduled send land on the right board's hardware, cross-board leak checks, per-board pause isolation, per-board schedule list consistency, board-deletion consistency, board-selector UI clarity (name shown, view fully swaps, per-board toggle). Known gaps as fixme: board 2 driving (epic #1241), client reinit on board mutations | | |
| | `multi-board-output.spec.ts` | Multi-board output | Two boards with real mock connections (ports 7000/7001): manual send + scheduled send land on the correct board hardware, cross-board leak checks, per-board pause isolation, per-board schedule list consistency, board-deletion consistency, board-selector UI clarity (name shown, view fully swaps, per-board toggle), board 2 driving, and client reinit on board mutations | |
The client-map build pre-filtered boards on local_api_key/cloud_key, so note-array boards (which authenticate with note_array_token) were silently dropped: a solo array install fell back to the legacy Config client and secondary arrays were never driven at all (issue #1243 item 3). Drop the pre-filter — board_client_from_board_dict already returns None for any board without a usable connection. TDD: a unit test (note-array board with only a token gets a client) and a 14-test e2e suite were written and verified failing first. The new note-array-output.spec.ts proves, against the size-strict Cloud API mock: - solo array installs deliver scheduled AND manual content at the array's exact geometry - every geometry class: all five named presets, 1×1, the 8-wide / 8-tall / 8×8 maximums, and a non-preset size (3×15 up to 24×120) - a flagship + array fleet is driven independently in one refresh with no cross-device leakage, and pausing the array blocks only the array - per-test unique tokens respect the module-level ≥15s note-array send throttle CI: the e2e job now runs a mock-cloud container per worker and passes VESTABOARD_CLOUD_API_URL to the app containers (integration-tests.yml started the mock but never told the backend where it was); helpers gain worker-aware MOCK_CLOUD_URL + state/configure accessors. Also restores prettier formatting of COVERAGE.md (lint-web failure on the previous push). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Verdict: no a11y concerns. The web UI changes are accessible as written — nothing to fix.
Reviewed only the accessibility dimension of the two changed TSX files (web/src/components/navigation-sidebar.tsx, web/src/components/page-builder.tsx) plus the web/messages/** additions.
What I checked and confirmed:
page-builder.tsx:1252— new device-typeSelect. The icon-free trigger carries an accessible name viaaria-label={t("deviceTypeSwitcherAriaLabel")}(WCAG 4.1.2), and theSelectItems usedisplaySettings.flagshipLabel/displaySettings.noteLabelrather than hardcoded English. Semantics/keyboard come from RadixSelect. ✅- next-intl coverage.
pageBuilder.deviceTypeSwitcherAriaLabelis added to all 14 locale files with real translations (not left as raw English in the non-English files — e.g. de "Boardgröße ändern", ja "ボードサイズを変更"), andflagshipLabel/noteLabelalready exist indisplaySettings. No hardcoded user-facing strings introduced. ✅ navigation-sidebar.tsx— newmobileHeadervariant + relocated sidebarBoardSelector. Both instances keep the existingaria-label={t("boardSelector")}on theSelectTrigger, so the control stays named in every layout including the collapsed icon-only desktop state. TheMonitoricon sits inside a trigger whosearia-labelsupplies the name, so it isn't separately announced. Only Tailwind/layout classes and placement changed. ✅- No heading/landmark changes, no new images, no focus-management or live-region surfaces added.
Nothing here trips the axe 2.2 AA gate in web/tests/a11y.spec.ts. Sign-off left to a human.
There was a problem hiding this comment.
Docs review
Verdict: one stale-accuracy fix; otherwise clean.
Only web/tests/COVERAGE.md is a doc change. The three new rows (multi-board-mixed, note-array-output, page-device-switch) all check out against the actual specs — geometry list, dimension asserts, device-switch behavior, and the /schedule selector test all match. Voice, branding (FiestaBoard vs. Vestaboard), and privacy are fine.
One substantive finding inline: this PR's own code changes make the existing multi-board-output.spec.ts row inaccurate — it still lists the two test.fixme gaps (board 2 driving, client reinit) that this PR converts into passing tests. Suggestion below updates that row.
🤖 Auto-review by claude-docs-review.yml.
| @@ -36,6 +36,9 @@ Playwright browser → Next.js UI → FastAPI backend → Mock Vestaboard API | |||
| | `multi-board.spec.ts` | Multi-board | Board card display, board CRUD (add/rename/type/color/toggle/delete), wizard board type/color picker, cross-feature config | | |||
| | `multi-board-schedule.spec.ts` | Multi-board scheduling | Single-board schedule page, two-board board selector, schedule mode toggle, per-board CRUD, filtering by board_id | | |||
| | `multi-board-output.spec.ts` | Multi-board output | Two boards with real mock connections (ports 7000/7001): manual send + scheduled send land on the right board's hardware, cross-board leak checks, per-board pause isolation, per-board schedule list consistency, board-deletion consistency, board-selector UI clarity (name shown, view fully swaps, per-board toggle). Known gaps as fixme: board 2 driving (epic #1241), client reinit on board mutations | | |||
There was a problem hiding this comment.
This row is now stale: the same PR removes both test.fixme blocks in multi-board-output.spec.ts and turns them into passing tests (board 2 per-board driving via #1243, and the primary-board-removal redirect), plus adds simultaneous-schedule, schedule-mode-off, and mixed manual/scheduled cases. "Known gaps as fixme: board 2 driving (epic #1241)…" no longer describes the file.
| | `multi-board-output.spec.ts` | Multi-board output | Two boards with real mock connections (ports 7000/7001): manual send + scheduled send land on the right board's hardware, cross-board leak checks, per-board pause isolation, per-board schedule list consistency, board-deletion consistency, board-selector UI clarity (name shown, view fully swaps, per-board toggle). Known gaps as fixme: board 2 driving (epic #1241), client reinit on board mutations | | |
| | `multi-board-output.spec.ts` | Multi-board output | Two boards with real mock connections (ports 7000/7001): manual send + scheduled send land on the right board's hardware, cross-board leak checks, per-board pause isolation, per-board schedule list consistency, board-deletion consistency, board-selector UI clarity (name shown, view fully swaps, per-board toggle). Per-board driving (#1243): both boards in schedule mode update simultaneously, board 2 receives its own scheduled content, per-board schedule-mode gating (mixed manual/scheduled), removing the primary board redirects output to the promoted board | |
Summary
Closes the two product gaps pinned as
test.fixmein #1386: the display engine now drives every configured board, not justboards[0], and/settings/boardmutations rebuild the board clients so output never targets a removed board's connection._build_board_clientsbuilds one client per board with credentials (board_clientsmap);vb_clientstays the primary's client for all single-board code paths._update_secondary_boards: each enabled, unpaused, schedule-enabled secondary board resolves its own active page (schedules → per-board default, collections included) and receives it via its own client, with per-board content caching, per-page transitions, and failure isolation (one board erroring never blocks the others). Global silence mode freezes secondaries./settings/boardPUT/add/DELETE now reinitialize the clients (the former stale-client bug: after deleting board 1, content kept flowing to the removed board's hardware until restart)./force-refreshclears every board client's cache.resolve_dimensions()sonote_arraypages no longer raise in the send path (issue Per-board #2: per-board display engine (DisplayService → runtimes) #1243 item 9)./pages/{id}/sendreturns a structured error body when the board is unreachable (nginx swallows 502/503/504 on/api/).Scope note: this is the first slice of #1243, not the full
BoardRuntimerefactor — triggers, temporary overrides, the manual active page, and board-read polling remain primary-only; the issue stays open for that work.Tests
tests/test_multi_board_driving.py— 13 unit tests: client-per-board building, reinit pruning, per-board routing, pause/schedule/enabled gates, silence, unchanged-content skip, send-failure no-cache.test.fixmepins inweb/tests/multi-board-output.spec.tsare now active tests, plus three new mixed-mode scenarios: both boards showing their own scheduled content from one refresh; board 2's schedule mode off silencing only board 2; board 1 in manual mode showing its manual page (its own schedule ignored) while board 2 delivers scheduled content.multi-board,multi-board-schedule,mock-board,note-multiboard-extended,note-pages) 59/59 passed, full platform pytest 4162 passed in Docker.🤖 Generated with Claude Code