A meeting room display

RECIPE

A 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-after generously. 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.