A meeting room display
RECIPEA room sign answers one question from ten metres away — is this room free right now? — and a second question from one metre: what else is booked today. Getting the hierarchy between those two right is the whole design.
Two reading distances
The mistake is treating this as one screen. It is two: a status band that must be readable at ten metres, and a schedule that only needs to work at arm's length. Sizing them the same wastes the panel; sizing the status band timidly makes the sign useless for its main purpose.
A useful rule of thumb for signage is roughly 1 cm of cap height per 3 m of viewing distance. At ten metres the status word wants to be genuinely large — on a 7.5" panel that means it dominates the top third.
Layout
<div class="ink-page room">
<e-flex justify="space-between" align="baseline">
<e-title level="1">Faraday</e-title>
<e-text kind="label">Floor 2 · 8 seats</e-text>
</e-flex>
<!-- The ten-metre band. One word, inverted when occupied so the
state reads as a shape before it reads as text. -->
<div class="room__status" id="status" data-state="free">
<span class="room__word">FREE</span>
<span class="room__until">until 14:00</span>
</div>
<e-divider label="Today"></e-divider>
<e-list id="agenda" split>
<e-list-item title="09:30 – 10:00" description="Standup · Platform"></e-list-item>
<e-list-item title="11:00 – 12:00" description="Design review · Ink"></e-list-item>
<e-list-item title="14:00 – 15:30" description="Interview · Reserved"></e-list-item>
</e-list>
<e-flex justify="space-between" align="center">
<e-qrcode value="https://rooms.example.com/faraday" level="M" scale="3"></e-qrcode>
<e-last-updated id="stamp" datetime="2026-08-18T09:14:00Z"
stale-after="600"></e-last-updated>
</e-flex>
</div>
/* Inversion carries the state. No colour involved, and the whole
band is one clean rectangle for the controller to drive. */
.room__status {
border: var(--ink-border-strong);
padding: var(--ink-space-5);
text-align: center;
}
.room__word { display: block; font-size: 76px; font-weight: 700; line-height: 1; }
.room__until { display: block; font-size: 18px; margin-top: 8px; }
.room__status[data-state='busy'] {
background: var(--ink-fg);
color: var(--ink-bg);
}
Scheduling updates against the calendar
A polling timer is the wrong instrument. The room's state changes at known future moments — the start and end of each booking — so schedule the repaint for those moments and poll only to discover new bookings.
let timer;
function render(bookings, now = new Date()) {
const current = bookings.find((b) => b.start <= now && now < b.end);
const next = bookings.find((b) => b.start > now);
const status = document.getElementById('status');
status.dataset.state = current ? 'busy' : 'free';
status.querySelector('.room__word').textContent = current ? 'BUSY' : 'FREE';
status.querySelector('.room__until').textContent = current
? `until ${time(current.end)}`
: next ? `until ${time(next.start)}` : 'all day';
// Wake exactly when the state next changes, not every minute.
const edge = current ? current.end : next?.start;
clearTimeout(timer);
if (edge) {
timer = setTimeout(() => render(bookings), Math.max(1000, edge - now));
}
}
// Poll only for schedule changes — someone booking the room.
async function sync() {
const bookings = await fetch('/api/rooms/faraday/today')
.then((r) => r.json())
.then((rows) => rows.map((b) => ({
...b, start: new Date(b.start), end: new Date(b.end),
})));
render(bookings);
document.getElementById('stamp')
.setAttribute('datetime', new Date().toISOString());
}
sync();
setInterval(sync, 5 * 60_000);
This gives an idle room a completely static panel between bookings — no updates at all for hours — while still flipping to BUSY precisely on the hour. A one-minute polling loop would have produced 60 needless refreshes for the same result.
Pitfalls
- Do not put a live clock on it. A ticking minute display is 1,440 panel updates a day for information every phone already has. Show the boundary time instead — "until 14:00".
- Handle the empty day. A room with no bookings should say so plainly; an empty list reads as a broken feed.
- Make the QR code do the booking. The sign cannot take input, so the code is the entire interaction path.
- Set
stale-aftergenerously. Ten minutes suits a five-minute poll; a tighter threshold will flap on one slow request. - Mind the clock source. A panel with drifting time will flip state at the wrong moment. Trust a server timestamp over the device clock.