Solutions architecture · In use · v1.6.0 (beta) · Open source (AGPL-3.0)
OpenSign
Local-first digital signage for small congregations and nonprofits. One always-on box keeps every screen in a building in lockstep, anyone on the network configures it from a browser, and nothing is uploaded: images play from wherever they already sit on disk.
Status
In use on real displays. Beta: no installer yet (a Windows .exe is planned) and no automated tests.
Version
1.6.0 (23 Sep 2026), the seventh changelogged release; the first, 1.0.0, shipped 25 Jun 2026
Licence
AGPL-3.0-or-later. Built-in verse text: the Berean Standard Bible (public domain), selected with OpenBible.info’s list (CC BY 4.0). Licensed translations are fetched per install with its own API.Bible key and cached for at most 30 days.
Runs on
One always-on computer on the building’s network (Python 3.10+, Node 20+); every screen is a browser pointed at it
One Reload button, or a server restart, refreshes every display, and a late joiner lands on the current slide.
At a glance
server clock every screen computes its slide from; nothing is pushed
1
verse entries generated from source; 40 references excluded by hand
710
AI-recalled first-cut verses were wrong; the text is now generated from source
9 of 30
of gzipped JavaScript for the whole app, every verse included
108 KB
How these were measured
1 clock: read from the source. frontend/src/Kiosk.tsx polls the config every 5,000 ms and re-syncs the clock every 5 × 60 × 1,000 ms, and frontend/src/modes/ImagesMode.tsx derives the slide from serverNow().
710 and 40: grep -c '{ osis:' frontend/src/data/verses.ts returns 710; the exclusion table in scripts/gen_verses.py, parsed with Python’s ast module, holds 40 references.
9 of 30: a word-level diff of the first 30-verse file against the official-text version that replaced it ten minutes later, with curly quotes and whitespace normalised (5 with changed words, 4 with punctuation only). Re-run from the project’s original local history, which predates the public repository’s recreated history.
108 KB: gzip -9c on the production JavaScript bundle in frontend/dist: 339,212 bytes raw, 108,329 gzipped, all 710 verse entries inside it, and no source file newer than the build.
Fig. 1The production bundle in headless Chromium with a stubbed API: countdown, clock and the verse of the day on frosted glass. Rendered 12 Oct 2026; the verse is the real pick for that date.
The short version
Lockstep across screens is computed from time, not pushed as messages. Every display derives the current slide from one server clock, so screens flip together and a late joiner lands in step, with no WebSockets and no push service.
A minifier bug was fixed in the build, not only in the stylesheet. A build plugin fails the release whenever the emitted CSS loses the standard backdrop-filter that Chromium needs for frosted glass.
Verse text is extracted and generated, never recited by a model. An AI-recalled first cut was wrong in 9 of 30; a generator now pulls 710 entries from the official text, trims only psalm headings and musical notes by exact match, and stops the run on any reference, heading or source it can’t match.
Case 1 of 5
Many screens, one clock, and no push channel
Every display computes the current slide from the server’s clock, so the screens stay in step without the server ever pushing a message.
Each screen is a browser on a cheap box, and each box keeps its own time; those clocks wander seconds apart. At first every display ran its own slideshow counter from whenever it loaded, so screens showing the same folder fell out of step, and a display that reconnected started again at image one.
I wanted lockstep without WebSockets or a push service. The fix landed in two steps, twelve minutes apart. The slide index became a pure function of time, floor(now / period) mod N, and then “now” became the server’s clock. Each display estimates its offset with a round-trip-corrected sample, re-measures every five minutes, and re-arms its timer for the next period boundary. Screens flip together, to within the accuracy of that sample.
The same idea runs through the rest. The clocks can show the server’s wall time by shifting a date by its UTC offset, with no timezone database, and the verse of the day is the day number mod 710, so every screen agrees without coordinating. Changes arrive through a 5-second config poll, and a reload token in that response lets one Reload displays button, or a server restart after an update, refresh every screen.
pulled by each screen: config every 5 s, clock every 5 min
computed locally from server time
Fig. 2Nothing is pushed. A screen that joins late does the same arithmetic and lands on the same slide.How we know
Read from the source on 12 Oct 2026: the slideshow tick in frontend/src/modes/ImagesMode.tsx (floor(serverNow() / period) % files.length, re-armed at the next period boundary); syncClock() and serverNow() in frontend/src/api.ts; the /api/time route and the reload token in backend/app/main.py; the 5 s poll and 5 min resync in frontend/src/Kiosk.tsx; verseIndexForDate() in frontend/src/data/verses.ts. The two steps of the fix are twelve minutes apart in the project’s history, and the changelog lists them under release 1.2.0; the Reload displays button arrived in 1.3.0.
Case 2 of 5
The bug was in the minifier, so the fix went into the build
The frosted glass lost its blur in Chromium because the CSS minifier rewrote it, so the build now refuses to ship that output.
The frosted-glass widgets blurred on Safari-based displays and showed no blur at all in Chrome, Edge and Opera. A test page never reproduced it, because files in public/ are copied unminified. The cause showed up only in the CSS the browser actually received. Vite 8’s minifier, Lightning CSS, collapses the prefixed and standard backdrop-filter declarations into one, keeping whichever is written last, and Chromium ignores the -webkit- spelling. It is a known upstream issue.
Putting the standard property last fixed the symptom. Then I closed the class of bug: a small Vite plugin inspects every emitted stylesheet and fails the build if it holds fewer standard backdrop-filter declarations than prefixed ones. It checks the output, not the source, because the output is what the screen gets.
const prefixed = css.match(/-webkit-backdrop-filter\s*:/g)?.length ?? 0
const standard = css.match(/(?<![\w-])backdrop-filter\s*:/g)?.length ?? 0
// fewer standard than prefixed: Chromium would get no blur
if (standard < prefixed) {
this.error(…)
}
Fig. 3The core of the build guard in frontend/vite.config.ts, run on every production build (excerpt; the comment is added here).How we know
Read on 12 Oct 2026: the requireStandardBackdropFilter() plugin in frontend/vite.config.ts (apply: 'build', enforce: 'post', writeBundle, an error when standard < prefixed) and the .glass rule in frontend/src/styles/kiosk.css, which writes the standard property last. Release 1.3.0 records the fix. The upstream reports are vitejs/vite #21954 and #22649, checked through the GitHub API the same day.
Case 3 of 5
Never let a model recite what it can extract
An AI-recalled first cut got 9 of 30 verses wrong, so the text is now generated from the official source and never typed.
The verse-of-the-day widget has to show exact text: the public-domain Berean Standard Bible asks only that text carrying its name match the official wording. The first cut of 30 verses was recalled in an AI-assisted session rather than extracted, and flagged at the time as unchecked. Ten minutes later the check came back: 9 of 30 were wrong, 5 with changed words (one missing the verse’s closing clause) and 4 in punctuation only.
Since then a script does the work. It pulls a ranked reference list from OpenBible.info and the verbatim BSB text, and asserts that the download carries the public-domain dedication. It strips psalm headings only by exact match: 21 are listed, 7 psalms are declared to have none, and any other psalm opening fails the run. A trailing “Selah” and one closing musical note are trimmed the same narrow way. Forty references are excluded by hand because they don’t stand alone without context.
Licensing lives in code too. Other translations use each install’s own API.Bible key, kept out of the config every display reads and never returned by the status route (the file endpoint is another matter; see the honest notes). Fetched verses are cached so screens share one call, purged at 30 days, and each view is reported to API.Bible, as its terms require. One trap surfaced on the way: red-letter text arrived without verse ids, so filtering by id silently deleted it. The parser filters by paragraph style instead.
The AI-recalled first cut of 30 verses, diffed word by word against the official BSB text
Matched the official text
21
Words changed
5
Punctuation only
4
How we know
Read on 12 Oct 2026: scripts/gen_verses.py (the exclusion, superscription and no-superscription tables at 40, 21 and 7 entries, the dedication assertion, and the exit on an unlisted psalm); the generated-file header in frontend/src/data/verses.ts; backend/app/bible.py (the separate key file, a 30-day cache with a purge on every fetch, filtering by paragraph style); reportView() in frontend/src/api.ts. The 9-of-30 figure comes from a word-level diff of the first verse file against its replacement in the project’s original local history (not in the public repository), with curly quotes and whitespace normalised, re-run the same day.
Case 4 of 5
Layout rules a volunteer can’t break
No free positioning: a 15-point grid, four fixed sizes, a verse card that measures its own text, and Even spacing that measures the screen.
The same layout has to read on a narrow strip display, a portrait 27-inch monitor and a 55-inch TV, and the people arranging it are not designers, so there is no free positioning. Five widgets (clock, calendar, countdown, giving QR code, verse of the day) sit over any of three modes (Text, Images, Stand By) and drop onto an orientation-aware 15-point grid: 5×3 or 3×5, odd counts so there is a true centre. A move onto an occupied point is refused. Each of four fixed sizes grows away from its anchor, so it never spills off its edge, and widget internals are sized in vmin, so a card keeps its proportions on any screen.
The verse card measures itself against a live 120-character sample. Longer text shrinks in 2% steps to a 70% floor; anything over 243 characters is skipped for the next verse, and a licensed translation falls back to the built-in text after 10 fetches without a fit.
Even spacing measures the widgets that share a column (portrait) or a row (landscape), holds the first and last against the edges, shares the leftover space equally, and re-measures whenever a size changes.
Fig. 4The admin’s Widgets tab, built from the current release with a sample config: the spacing and orientation toggles, the 15-point portrait grid, and the S/M/L/XL sizes. With Even spacing on, this preview still shows grid positions.Fig. 5Clock, countdown and verse (all size L) in a 1000×1600 portrait render of the production bundle, with the clock frozen so only the spacing differs: 428 px and 322 px apart on the plain grid, 375 px and 375 px with Even spacing.How we know
Read on 12 Oct 2026: frontend/src/modes/index.ts (three modes); frontend/src/WidgetLocator.tsx (five widgets, the 5×3 / 3×5 snap points and the occupied-point check) and frontend/src/Widgets.tsx (the four size scales, the 120-character sample, MAX_CHARS = 243, FIT_STEP = 0.02, FIT_MIN = 0.7, MAX_SKIPS = 10, and the Even-spacing hook). Fig. 5 was measured the same day in headless Chromium over the built bundle, with requests intercepted and the API stubbed (no server), reading each widget’s box with getBoundingClientRect: grid 40–264, 692–908 and 1230–1560 px; Even spacing 40–264, 639–855 and 1230–1560 px.
Case 5 of 5
Deployment is the product
The fixes that keep signs running are operational: one process on one port in dev and production, a required nightly reboot, and OS sleep turned off.
Signage usually fails for operational reasons, not code: a box loses the server after days of uptime, a screen needs a person after every update, or the dev setup differs from what ships. One FastAPI process on one port serves both the built frontend and the API. A split into separate dev and prod servers was tried in July and removed the same day in favour of parity.
In the field, one display stopped reaching the server after several days up, even though a speed test on that box passed, and a reboot cleared it every time. The cause was never pinned down, so the fix went into operations: a scheduled nightly reboot became a Required item in the display-box checklist, beside auto-starting the browser at the kiosk URL and turning off OS sleep; the server itself gets a stable address (a static IP or reserved lease) because every display points at it. The browser’s wake lock needs a secure context, so over plain LAN HTTP it does nothing; the OS setting is what keeps those screens on.
Images are served in place by path, so nothing is uploaded or copied. Picking a file opens the real OS dialog on the server machine: through osascript on macOS, because tkinter crashes off the main thread there and Homebrew’s Python ships without Tk, and through tkinter on Windows and Linux.
How we know
Read on 12 Oct 2026: backend/app/main.py (one app serving the build and the API, Uvicorn on 6100, open CORS); frontend/vite.config.ts (why not 6000, and the optional hot-reload server on 6101); DEPLOYMENT.md (server setup and the display-box checklist, with the nightly reboot marked Required); the wake-lock code in frontend/src/Kiosk.tsx; backend/app/dialogs.py. The dev/prod split and its removal are both dated 12 Jul 2026 in the project’s history, and the changelog records the removal under release 1.2.0.