From 10b58a4682d3371aa51996e7b093ac1aa9358801 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 03:32:12 +0000 Subject: [PATCH 1/6] Announce waiting updates on the ticker; fade in from black The agent now holds board software until the night's install window and lists it under `updates` in /api/local/status. While something waits, the ticker adds "Update available: board app 2.1.0 will be installed at 11:00 PM tonight" (utils/updates.js, read in the board's zone), and the agent's new `updates` event re-reads the status so it appears at once. index.html gets a black veil that fades out on load, finishing the dip to black the agent starts when it leaves the update screen. Version 2.1.0. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VUxfdFFUkL7SmAEUvUzspy --- index.html | 18 ++++++++++ package-lock.json | 4 +-- package.json | 2 +- src/App.jsx | 10 ++++-- src/api/localEvents.js | 8 +++-- src/api/localStatus.js | 7 +++- src/utils/updates.js | 70 +++++++++++++++++++++++++++++++++++++++ src/utils/updates.test.js | 48 +++++++++++++++++++++++++++ 8 files changed, 159 insertions(+), 8 deletions(-) create mode 100644 src/utils/updates.js create mode 100644 src/utils/updates.test.js diff --git a/index.html b/index.html index 6331d55..bf9e790 100644 --- a/index.html +++ b/index.html @@ -6,8 +6,26 @@ MusallahBoard + +
diff --git a/package-lock.json b/package-lock.json index 1be83ec..197514d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "musallah-board", - "version": "2.0.0", + "version": "2.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "musallah-board", - "version": "2.0.0", + "version": "2.1.0", "dependencies": { "@fontsource/amiri": "^5.3.0", "@fontsource/cinzel": "^5.3.0", diff --git a/package.json b/package.json index bb35275..46692dc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "musallah-board", - "version": "2.0.0", + "version": "2.1.0", "type": "module", "scripts": { "dev": "vite --host", diff --git a/src/App.jsx b/src/App.jsx index c8b3099..7f3fe82 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -16,6 +16,7 @@ import { buildSlideshow } from './frames/registry.js'; import { resolveTheme, DEFAULT_THEME } from './themes/registry.js'; import './frames/builders.jsx'; // registers default builders import { applyCursorPreference } from './utils/cookies.js'; +import { updateNotice } from './utils/updates.js'; import { APP_VERSION } from './version.js'; import SetupModal from './components/SetupModal.jsx'; import DebugMenu from './components/DebugMenu.jsx'; @@ -401,6 +402,7 @@ export default function App() { return connectLocalEvents({ onContent: () => refreshRef.current(), onApp: () => window.location.reload(), + onUpdates: () => getLocalStatus().then((s) => applyLocalStatusRef.current(s)), }); }, []); @@ -463,7 +465,11 @@ export default function App() { const boardTheme = resolveTheme(payload.deviceConfig.theme, autoTheme); const theme = resolveTheme(debug.theme, boardTheme); - const showTicker = data.scrollingMessages.length > 0; + // Software waiting for the night's install window is announced on the + // ticker, after the board's own messages. + const notice = updateNotice(localStatus?.updates, now, tz); + const tickerMessages = notice ? [...data.scrollingMessages, notice] : data.scrollingMessages; + const showTicker = tickerMessages.length > 0; // Show the Jummah card Wed–Fri (matches prior board behaviour). Read in the // board's zone: near midnight the browser's day can be the wrong one. const dow = zonedClock(now, tz).weekday; @@ -506,7 +512,7 @@ export default function App() { {showTicker ? ( - + ) : (
)} diff --git a/src/api/localEvents.js b/src/api/localEvents.js index 1c6ab2b..3567c5d 100644 --- a/src/api/localEvents.js +++ b/src/api/localEvents.js @@ -12,6 +12,9 @@ * event: app data: {"version": "..."} * a new board app release is installed: reload, so * the new build is what runs. + * event: updates data: the status's `updates` object + * software started or stopped waiting for the night's + * install window: re-read the status for the ticker. * : ping every 25 s, keeps the stream alive. * * EventSource reconnects on its own after a dropped @@ -24,10 +27,10 @@ */ /** - * @param {{ onContent: () => unknown, onApp: () => unknown }} handlers + * @param {{ onContent: () => unknown, onApp: () => unknown, onUpdates?: () => unknown }} handlers * @returns {() => void} disposer: closes the stream. Safe to call twice. */ -export function connectLocalEvents({ onContent, onApp }) { +export function connectLocalEvents({ onContent, onApp, onUpdates }) { if (typeof EventSource === 'undefined') return () => {}; const source = new EventSource('/api/local/events'); @@ -50,6 +53,7 @@ export function connectLocalEvents({ onContent, onApp }) { }); source.addEventListener('content', () => run(onContent, 'content refresh')); source.addEventListener('app', () => run(onApp, 'app reload')); + if (onUpdates) source.addEventListener('updates', () => run(onUpdates, 'updates refresh')); return () => source.close(); } diff --git a/src/api/localStatus.js b/src/api/localStatus.js index 4a62f1d..cef45d0 100644 --- a/src/api/localStatus.js +++ b/src/api/localStatus.js @@ -16,7 +16,10 @@ * today, servingDay, daysRemaining, staleDays, * sync: { enabled, lastSuccessAt, lastAttemptAt, * lastError }, - * update: { active } } + * update: { active }, + * updates: { available: [{ type, version, + * description }], installTime, + * installAt, installing } } * ===================================================== */ @@ -46,6 +49,8 @@ const TIMEOUT_MS = 5000; * @property {number|null} [staleDays] * @property {{enabled:boolean,lastSuccessAt:string|null,lastAttemptAt:string|null,lastError:string|null}} [sync] * @property {{active:boolean}} [update] + * @property {{available:{type:string,version:string,description:string}[],installTime:string,installAt:string|null,installing:boolean}} [updates] + * software waiting for the install window (utils/updates.js) * @property {string} [error] installed content could not be read */ diff --git a/src/utils/updates.js b/src/utils/updates.js new file mode 100644 index 0000000..25ccd0a --- /dev/null +++ b/src/utils/updates.js @@ -0,0 +1,70 @@ +/** + * ===================================================== + * Update notice + * ===================================================== + * The agent holds board software from the release + * channels until the board's quiet window (23:00 by + * default) and lists it under `updates` in + * /api/local/status (agent/docs/architecture.md, + * section 9.4): + * + * { available: [{ type, version, description }], + * installTime: "23:00", + * installAt: "2026-09-30T23:00:00-04:00" | null, + * installing: false } + * + * While something waits, the ticker says so. + * ===================================================== + */ + +import { isoDateKey, zonedClock } from '../models/index.js'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** "11:00 PM" in the board's zone. */ +function clockTime(instant, timezone) { + try { + return new Intl.DateTimeFormat('en-US', { + hour: 'numeric', minute: '2-digit', timeZone: timezone || undefined, + }).format(instant); + } catch { + return instant.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }); + } +} + +/** "tonight", "tomorrow", "on Friday": when `at` falls, seen from `now`. */ +function dayWords(at, now, timezone) { + const evening = zonedClock(at, timezone).hours >= 17; + const day = isoDateKey(at, timezone); + if (day === isoDateKey(now, timezone)) return evening ? 'tonight' : 'today'; + if (day === isoDateKey(new Date(now.getTime() + DAY_MS), timezone)) { + return evening ? 'tomorrow night' : 'tomorrow'; + } + try { + const weekday = new Intl.DateTimeFormat('en-US', { + weekday: 'long', timeZone: timezone || undefined, + }).format(at); + return `on ${weekday}`; + } catch { + return `on ${day}`; + } +} + +/** + * The ticker line for waiting updates, or null when nothing waits. + * @param {{available?: {description:string}[], installAt?: string|null, installing?: boolean}|null|undefined} updates + * @param {Date} now + * @param {string} [timezone] the board's IANA zone + * @returns {string|null} + */ +export function updateNotice(updates, now, timezone) { + const available = updates?.available ?? []; + if (!available.length) return null; + const what = available.map((u) => u.description).join(' and '); + if (updates.installing) return `Installing update: ${what}`; + const at = updates.installAt ? new Date(updates.installAt) : null; + if (!at || Number.isNaN(at.getTime()) || at.getTime() - now.getTime() < 60 * 1000) { + return `Update available: ${what} will be installed shortly`; + } + return `Update available: ${what} will be installed at ${clockTime(at, timezone)} ${dayWords(at, now, timezone)}`; +} diff --git a/src/utils/updates.test.js b/src/utils/updates.test.js new file mode 100644 index 0000000..f25f513 --- /dev/null +++ b/src/utils/updates.test.js @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { updateNotice } from './updates.js'; + +const TZ = 'America/Toronto'; +const app = { type: 'app', version: '2.1.0', description: 'board app 2.1.0' }; +const agent = { type: 'agent', version: '0.3.0', description: 'agent 0.3.0' }; + +describe('updateNotice', () => { + const now = new Date('2026-09-25T14:00:00-04:00'); + + it('is null when nothing waits', () => { + expect(updateNotice(null, now, TZ)).toBeNull(); + expect(updateNotice({ available: [], installAt: null }, now, TZ)).toBeNull(); + }); + + it('says tonight for this evening', () => { + expect(updateNotice({ available: [app], installAt: '2026-09-25T23:00:00-04:00' }, now, TZ)) + .toBe('Update available: board app 2.1.0 will be installed at 11:00 PM tonight'); + }); + + it('names every waiting update', () => { + expect(updateNotice({ available: [agent, app], installAt: '2026-09-25T23:00:00-04:00' }, now, TZ)) + .toBe('Update available: agent 0.3.0 and board app 2.1.0 will be installed at 11:00 PM tonight'); + }); + + it('says tomorrow night after the window has passed', () => { + const morning = new Date('2026-09-26T08:00:00-04:00'); + expect(updateNotice({ available: [app], installAt: '2026-09-26T23:00:00-04:00' }, morning, TZ)) + .toContain('at 11:00 PM tonight'); + expect(updateNotice({ available: [app], installAt: '2026-09-26T23:00:00-04:00' }, now, TZ)) + .toContain('at 11:00 PM tomorrow night'); + }); + + it('reads the time in the board zone, not the runtime zone', () => { + expect(updateNotice({ available: [app], installAt: '2026-09-26T03:00:00Z' }, now, TZ)) + .toContain('at 11:00 PM tonight'); + }); + + it('says shortly when the window is open', () => { + expect(updateNotice({ available: [app], installAt: now.toISOString() }, now, TZ)) + .toBe('Update available: board app 2.1.0 will be installed shortly'); + }); + + it('says installing while it installs', () => { + expect(updateNotice({ available: [app], installAt: now.toISOString(), installing: true }, now, TZ)) + .toBe('Installing update: board app 2.1.0'); + }); +}); From c5b921265dbbab64eafec6e6e8fea0d0dcb09b53 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 15:29:00 +0000 Subject: [PATCH 2/6] Show the agent's update notices as a banner over the board The agent now sends a `notice` event when a person brings an update and nothing is installed (already up to date, a USB stick it cannot read, an update that is not signed for this board), and while it reads a stick. NoticeBanner shows it above the ticker, centred on the board's main area (or the whole screen on the waiting screens), in the update screen's navy with a mark per tone, for as long as the agent says. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VUxfdFFUkL7SmAEUvUzspy --- src/App.jsx | 13 +++- src/api/localEvents.js | 19 +++++- src/components/NoticeBanner.jsx | 105 ++++++++++++++++++++++++++++++++ src/styles.css | 85 ++++++++++++++++++++++++++ 4 files changed, 218 insertions(+), 4 deletions(-) create mode 100644 src/components/NoticeBanner.jsx diff --git a/src/App.jsx b/src/App.jsx index 7f3fe82..4c985f3 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -23,6 +23,7 @@ import DebugMenu from './components/DebugMenu.jsx'; import TopBar from './components/TopBar.jsx'; import PrayerRail from './components/PrayerRail.jsx'; import Ticker from './components/Ticker.jsx'; +import NoticeBanner from './components/NoticeBanner.jsx'; const PAYLOAD_REFRESH_MS = 10 * 60 * 1000; const LOCAL_STATUS_REFRESH_MS = 60 * 1000; @@ -164,6 +165,11 @@ export default function App() { const [setupOpen, setSetupOpen] = useState(false); const [debugOpen, setDebugOpen] = useState(false); const [debug, setDebug] = useState(NO_DEBUG); + // The agent's latest update notice (components/NoticeBanner.jsx). Each + // gets an id so a banner timing out cannot clear the one that replaced it. + const [notice, setNotice] = useState(null); + const noticeSeq = useRef(0); + const clearNotice = useCallback((id) => setNotice((n) => (n && n.id === id ? null : n)), []); const advanceTimer = useRef(null); // Everything downstream reads `now`, so time travel is a single shift here. @@ -403,6 +409,7 @@ export default function App() { onContent: () => refreshRef.current(), onApp: () => window.location.reload(), onUpdates: () => getLocalStatus().then((s) => applyLocalStatusRef.current(s)), + onNotice: (n) => setNotice({ ...n, id: ++noticeSeq.current }), }); }, []); @@ -441,6 +448,7 @@ export default function App() { /> )} {renderDebug()} + {setupOpen && ( 0; // Show the Jummah card Wed–Fri (matches prior board behaviour). Read in the // board's zone: near midnight the browser's day can be the wrong one. @@ -520,6 +528,7 @@ export default function App() {
{renderDebug(boardTheme, autoJummah)} + {setupOpen && ( unknown, onApp: () => unknown, onUpdates?: () => unknown }} handlers + * @param {{ onContent: () => unknown, onApp: () => unknown, onUpdates?: () => unknown, + * onNotice?: (notice: object) => unknown }} handlers * @returns {() => void} disposer: closes the stream. Safe to call twice. */ -export function connectLocalEvents({ onContent, onApp, onUpdates }) { +export function connectLocalEvents({ onContent, onApp, onUpdates, onNotice }) { if (typeof EventSource === 'undefined') return () => {}; const source = new EventSource('/api/local/events'); @@ -54,6 +58,17 @@ export function connectLocalEvents({ onContent, onApp, onUpdates }) { source.addEventListener('content', () => run(onContent, 'content refresh')); source.addEventListener('app', () => run(onApp, 'app reload')); if (onUpdates) source.addEventListener('updates', () => run(onUpdates, 'updates refresh')); + if (onNotice) { + source.addEventListener('notice', (e) => { + let notice; + try { + notice = JSON.parse(e.data); + } catch { + return; + } + if (notice && typeof notice.headline === 'string') run(() => onNotice(notice), 'notice'); + }); + } return () => source.close(); } diff --git a/src/components/NoticeBanner.jsx b/src/components/NoticeBanner.jsx new file mode 100644 index 0000000..8eedd72 --- /dev/null +++ b/src/components/NoticeBanner.jsx @@ -0,0 +1,105 @@ +import { useEffect, useState } from 'react'; + +/** + * ===================================================== + * Notice banner + * ===================================================== + * What the agent tells the people in front of the board + * about an update when there is nothing to install, so + * the full update screen would be too much: "Reading USB + * stick", "Already up to date", "Can't read this USB + * stick" (agent/docs/architecture.md, section 8). It + * arrives as the `notice` event on /api/local/events: + * + * { tone: "progress" | "ok" | "neutral" | "problem", + * headline, lines: [..], seconds } + * + * `seconds` 0 means it stays until the next notice + * replaces it (a progress banner is always followed by + * its outcome). Sized in vmin, above the ticker, so it + * reads from across a room on any screen. + * ===================================================== + */ + +/** A progress banner nobody replaces still goes, eventually. */ +const PROGRESS_MAX_MS = 3 * 60 * 1000; +const EXIT_MS = 400; + +function Mark({ tone }) { + if (tone === 'progress') { + return ( + + ); + } + if (tone === 'ok') { + return ( + + ); + } + if (tone === 'problem') { + return ( + + ); + } + return ( + + ); +} + +/** + * @param {{ notice: null | { id:number, tone:string, headline:string, lines?:string[], seconds?:number }, + * onDone: (id:number) => void, onBoard?: boolean }} props + * onBoard centres the banner on the board's main area, right of the + * prayer rail, instead of on the whole screen. + */ +export default function NoticeBanner({ notice, onDone, onBoard = false }) { + // Kept after `notice` clears, so the banner can fade out with its text. + const [shown, setShown] = useState(null); + const [leaving, setLeaving] = useState(false); + + useEffect(() => { + if (notice) { + setShown(notice); + setLeaving(false); + const ms = notice.seconds > 0 ? notice.seconds * 1000 : PROGRESS_MAX_MS; + const id = setTimeout(() => onDone(notice.id), ms); + return () => clearTimeout(id); + } + setLeaving(true); + const id = setTimeout(() => setShown(null), EXIT_MS); + return () => clearTimeout(id); + }, [notice, onDone]); + + if (!shown) return null; + const lines = shown.lines ?? []; + return ( +
+ +
+
{shown.headline}
+ {lines.map((line, i) => ( +
{line}
+ ))} +
+
+ ); +} diff --git a/src/styles.css b/src/styles.css index 0b8a1df..3ecfabe 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1792,3 +1792,88 @@ body.cursor-hidden, body.cursor-hidden * { cursor: none !important; } max-width: 340px; text-wrap: balance; } + +/* ===================================================== + Notice banner (components/NoticeBanner.jsx) + ===================================================== + An update outcome over the running board. Fixed to the + viewport rather than the scaled stage, so it also shows + over the waiting screens, and sized in vmin so it is + the same share of any screen. The card is the update + screen's navy whatever the theme: it belongs to the + agent, not to the board, and should look it. */ +.notice-banner { + position: fixed; + left: 50%; + bottom: calc(64 / 1080 * 100vh + 3vmin); + z-index: 50; + display: flex; + align-items: flex-start; + gap: 2vmin; + /* Placed from its left edge, a fixed box only gets the width to the right + of that edge; max-content lets max-width decide instead. */ + width: max-content; + max-width: min(78vw, 110vmin); + padding: 2vmin 3.2vmin 2.2vmin 2.4vmin; + font-family: var(--f-sans); + color: #fff; + background: color-mix(in oklch, #082D5D 92%, transparent); + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 2vmin; + box-shadow: 0 1.6vmin 5vmin rgba(0, 0, 0, 0.35); + backdrop-filter: blur(8px); + transform: translateX(-50%); + pointer-events: none; + animation: nb-in 420ms cubic-bezier(0.2, 0.7, 0.2, 1) both; +} +/* On the board, centred on the main area: the stage is 1920 wide with a + 520px prayer rail, so the area's centre is at 1220 / 1920. */ +.notice-banner.nb-on-board { + left: calc(1220 / 1920 * 100vw); + max-width: min(66vw, 100vmin); +} +.notice-banner.is-leaving { animation: nb-out 400ms ease both; } +@keyframes nb-in { + from { opacity: 0; transform: translate(-50%, 2vmin); } + to { opacity: 1; transform: translate(-50%, 0); } +} +@keyframes nb-out { + from { opacity: 1; transform: translate(-50%, 0); } + to { opacity: 0; transform: translate(-50%, 1vmin); } +} + +.nb-mark { + flex: none; + width: 4.6vmin; + height: 4.6vmin; + margin-top: 0.2vmin; + fill: none; + stroke: var(--gold); + stroke-width: 3; + stroke-linecap: round; + stroke-linejoin: round; +} +.nb-mark .nb-dot { fill: var(--gold); stroke: none; } +.nb-neutral .nb-mark { stroke: rgba(255, 255, 255, 0.75); } +.nb-neutral .nb-mark .nb-dot { fill: rgba(255, 255, 255, 0.75); } +.nb-spin .nb-track { stroke: rgba(255, 255, 255, 0.18); } +.nb-spin .nb-arc { + stroke-dasharray: 30 83; + transform-origin: 24px 24px; + animation: nb-spin 1.1s linear infinite; +} +@keyframes nb-spin { to { transform: rotate(360deg); } } + +.nb-text { min-width: 0; } +.nb-headline { + font-size: 3vmin; + font-weight: 500; + line-height: 1.25; +} +.nb-line { + margin-top: 0.6vmin; + font-size: 2.1vmin; + line-height: 1.4; + color: rgba(255, 255, 255, 0.78); + text-wrap: pretty; +} From e6e2d94c8e6e4aaa812f020c6734ae537ec4d6ef Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 15:31:47 +0000 Subject: [PATCH 3/6] Note on the board when its clock may be wrong The agent now reports `clock.trusted`. When it is false (no NTP, no RTC, no laptop's time since boot), a note above the stale-content note says the clock may be wrong and how to fix it: every prayer time on screen follows that clock. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VUxfdFFUkL7SmAEUvUzspy --- src/App.jsx | 20 +++++++++++++++++++- src/api/localStatus.js | 3 +++ src/styles.css | 43 ++++++++++++++++++++++++++++++++++++++---- 3 files changed, 61 insertions(+), 5 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index 4c985f3..3ee7c1f 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -136,6 +136,21 @@ function StaleNote({ status }) { return
Content last updated {when}
; } +/** + * The agent could not confirm the board's clock since it booted: no NTP, no + * hardware clock, no laptop's time (agent/docs/architecture.md, section 10). + * After a power cut an offline Pi without an RTC comes up at whatever time it + * last saved, and every prayer time on screen follows it. + */ +function ClockNote({ status }) { + if (status?.clock?.trusted !== false) return null; + return ( +
+ The clock may be wrong: connect the board to the internet, or send an update from a laptop +
+ ); +} + function gregorian(now, timezone) { const f = new Intl.DateTimeFormat('en-US', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric', @@ -516,7 +531,10 @@ export default function App() { ); })} - +
+ + +
{showTicker ? ( diff --git a/src/api/localStatus.js b/src/api/localStatus.js index cef45d0..f8ce22e 100644 --- a/src/api/localStatus.js +++ b/src/api/localStatus.js @@ -49,6 +49,9 @@ const TIMEOUT_MS = 5000; * @property {number|null} [staleDays] * @property {{enabled:boolean,lastSuccessAt:string|null,lastAttemptAt:string|null,lastError:string|null}} [sync] * @property {{active:boolean}} [update] + * @property {{source:string,trusted:boolean}} [clock] + * where the clock's time comes from (ntp, rtc, uploader, starting, + * unverified); trusted false means the board should say it may be wrong * @property {{available:{type:string,version:string,description:string}[],installTime:string,installAt:string|null,installing:boolean}} [updates] * software waiting for the install window (utils/updates.js) * @property {string} [error] installed content could not be read diff --git a/src/styles.css b/src/styles.css index 3ecfabe..634bae9 100644 --- a/src/styles.css +++ b/src/styles.css @@ -594,13 +594,23 @@ img { display: block; max-width: 100%; } opacity: 0.45; } -/* Offline board whose content bundle has run out (bottom-left of stage-main). - Deliberately quiet — --fg-3 on the recessed ground, the eyebrow type — so it - informs whoever maintains the board without competing with the slide. */ -.stale-note { +/* Notes for whoever maintains the board (bottom-left of stage-main), stacked: + the clock note above the stale-content note. */ +.board-notes { position: absolute; bottom: 14px; left: var(--pad-stage, 48px); z-index: 5; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + pointer-events: none; +} + +/* Offline board whose content bundle has run out. Deliberately quiet: --fg-3 + on the recessed ground, the eyebrow type, so it informs whoever maintains + the board without competing with the slide. */ +.stale-note { padding: 5px 14px; font-family: var(--f-mono); font-size: 11px; @@ -614,6 +624,31 @@ img { display: block; max-width: 100%; } pointer-events: none; } +/* The board's clock is not confirmed since boot, so the prayer times on + screen may be wrong. Louder than the stale note, since what it doubts is + what the congregation reads the board for, but still a note, not an alarm: + sentence case at a readable size, with the accent's diamond. */ +.clock-note { + display: flex; + align-items: center; + gap: 10px; + padding: 7px 16px 7px 14px; + font-family: var(--f-sans); + font-size: 15px; + color: var(--fg-2); + background: color-mix(in oklch, var(--bg-soft) 92%, transparent); + border: 1px solid var(--line); + border-radius: var(--r-pill); +} +.clock-note::before { + content: ""; + flex: none; + width: 8px; height: 8px; + transform: rotate(45deg); + background: var(--accent); + border-radius: 1px; +} + /* Slide indicators (bottom-right of stage-main) */ .slide-indicators { position: absolute; From e3c3fcaa673d9140e20d0f5dff1b460a667c110a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 18:32:42 +0000 Subject: [PATCH 4/6] Say when content is about to run out, not only once it has In the last two days of installed content the board shows "Content ends Saturday, September 26" (or "Content ends today") where the stale note goes, in the same quiet style, so whoever maintains an offline board has warning to send more before it goes stale. Online boards sync a week ahead, so they only see it when their sync is failing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VUxfdFFUkL7SmAEUvUzspy --- src/App.jsx | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/App.jsx b/src/App.jsx index 3ee7c1f..f269224 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -120,6 +120,9 @@ function WaitingForContent({ status }) { * content. Dated by when the package was made, in the content's zone. */ function StaleNote({ status }) { + if (status?.staleDays === 0 && status.daysRemaining != null && status.daysRemaining <= CONTENT_LOW_DAYS) { + return ; + } if (!(status?.staleDays > 0)) return null; const b = status?.content; let when = b?.lastDay ?? ''; @@ -136,6 +139,27 @@ function StaleNote({ status }) { return
Content last updated {when}
; } +/** Content ending this many days after today, or sooner, gets a note. */ +const CONTENT_LOW_DAYS = 2; + +/** + * "Content ends Wednesday, October 1": the last days before an offline board's + * content runs out, so whoever maintains it has warning to send more before + * the stale note appears. An online board never gets here: it syncs a week + * ahead every half hour, unless its sync is failing, which is worth knowing. + */ +function ContentEndsNote({ status }) { + const lastDay = status?.content?.lastDay; + let when = lastDay ?? ''; + const at = lastDay ? new Date(`${lastDay}T12:00:00Z`) : null; + if (at && !Number.isNaN(at.getTime())) { + when = new Intl.DateTimeFormat('en-US', { + weekday: 'long', month: 'long', day: 'numeric', timeZone: 'UTC', + }).format(at); + } + return
{status.daysRemaining === 0 ? 'Content ends today' : `Content ends ${when}`}
; +} + /** * The agent could not confirm the board's clock since it booted: no NTP, no * hardware clock, no laptop's time (agent/docs/architecture.md, section 10). From 80dd0e1d09df9cc90b4952aca30dc44b2dc3b04d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 18:56:05 +0000 Subject: [PATCH 5/6] Offer only the offline routes a board accepts; show a notice's footer The waiting-for-content screen named the ethernet upload page on every board, but the service port is off by default. It now reads the agent's servicePort and usbImport and suggests only what works, or the internet when neither does. Banners show a notice's footer ("You can remove the USB stick") in the accent under its lines. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VUxfdFFUkL7SmAEUvUzspy --- src/App.jsx | 13 ++++++++++--- src/api/localStatus.js | 2 ++ src/components/NoticeBanner.jsx | 6 +++++- src/styles.css | 6 ++++++ 4 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index f269224..e36de0a 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -80,9 +80,16 @@ const SERVICE_PORT_URL = 'http://10.77.0.1/'; */ function WaitingForContent({ status }) { const sync = status?.sync; - const offline = - `Plug in a USB stick with a MusallahBoard update, or connect a laptop or phone ` + - `to the board's ethernet port and open ${SERVICE_PORT_URL}`; + // Only the routes this board accepts: the service port is off by default, + // and a board that does not read USB sticks should not ask for one. + const routes = []; + if (status?.usbImport) routes.push('plug in a USB stick with this board\'s offline bundle'); + if (status?.servicePort) { + routes.push(`connect a laptop or phone to the board's ethernet port and open ${SERVICE_PORT_URL}`); + } + const offline = routes.length + ? `Without internet: ${routes.join(', or ')}.` + : 'Connect the board to the internet so it can download its content.'; if (sync?.enabled && !sync.lastError) { return ( (
{line}
))} + {shown.footer &&
{shown.footer}
} ); diff --git a/src/styles.css b/src/styles.css index 634bae9..c0ffcd6 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1905,6 +1905,12 @@ body.cursor-hidden, body.cursor-hidden * { cursor: none !important; } font-weight: 500; line-height: 1.25; } +.nb-footer { + margin-top: 1vmin; + font-size: 2.1vmin; + font-weight: 500; + color: var(--gold); +} .nb-line { margin-top: 0.6vmin; font-size: 2.1vmin; From 496e1e7c62d15f4c7e8a592506e986380cc32f44 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 20:06:45 +0000 Subject: [PATCH 6/6] Larger notes and banners; the loading screen shows the real version - The stale-content note (15px, from 11px) and the clock note (22px, from 15px) can be read from the back of the room; banner text is 2.6vmin. - The loading screen said "Version 2027"; it shows the app's version. - "Reconnecting to the board service" uses the large layout and says what to do if it does not clear. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VUxfdFFUkL7SmAEUvUzspy --- src/App.jsx | 4 +++- src/styles.css | 14 +++++++------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index e36de0a..1466d65 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -488,9 +488,11 @@ export default function App() { ) : ( )} {renderDebug()} diff --git a/src/styles.css b/src/styles.css index c0ffcd6..0b8ab01 100644 --- a/src/styles.css +++ b/src/styles.css @@ -611,9 +611,9 @@ img { display: block; max-width: 100%; } on the recessed ground, the eyebrow type, so it informs whoever maintains the board without competing with the slide. */ .stale-note { - padding: 5px 14px; + padding: 6px 16px; font-family: var(--f-mono); - font-size: 11px; + font-size: 15px; letter-spacing: 0.18em; text-transform: uppercase; white-space: nowrap; @@ -632,9 +632,9 @@ img { display: block; max-width: 100%; } display: flex; align-items: center; gap: 10px; - padding: 7px 16px 7px 14px; + padding: 9px 20px 9px 16px; font-family: var(--f-sans); - font-size: 15px; + font-size: 22px; color: var(--fg-2); background: color-mix(in oklch, var(--bg-soft) 92%, transparent); border: 1px solid var(--line); @@ -1901,19 +1901,19 @@ body.cursor-hidden, body.cursor-hidden * { cursor: none !important; } .nb-text { min-width: 0; } .nb-headline { - font-size: 3vmin; + font-size: 3.4vmin; font-weight: 500; line-height: 1.25; } .nb-footer { margin-top: 1vmin; - font-size: 2.1vmin; + font-size: 2.6vmin; font-weight: 500; color: var(--gold); } .nb-line { margin-top: 0.6vmin; - font-size: 2.1vmin; + font-size: 2.6vmin; line-height: 1.4; color: rgba(255, 255, 255, 0.78); text-wrap: pretty;