DEV Community

Cover image for Designing a UI That Gets Projected in Front of a Room
Christian • ancer
Christian • ancer

Posted on

Designing a UI That Gets Projected in Front of a Room

Almost every interface decision I make assumes a private screen. One person, their own monitor, their own data in front of them. It's such a safe assumption that it never gets written down anywhere.

For the last few months I've been building a Windows desktop app called Rueda de Actos, for the Filà Ligeros — one of the groups that take part in the Moros y Cristianos festival in Alcoy, Spain. Its job is to hand out participation in five festival events, eleven slots each, following a rotating turn that has to stay fair from one year to the next.

It breaks that assumption completely.

The app runs once a year, in a meeting room, projected on a wall, with the whole board watching. One person drives it. There are ninety-six names in the table on screen, and a good number of them belong to people sitting in that room.

Every interesting design decision in this app came out of that one sentence.

The room is the spec

Put the scenario next to the usual mental model and the assumptions fall over one by one.

The usual assumption The actual session
One user looking at the screen One user driving, a room reading over their shoulder
The data is about other people The data is about the audience
A mistake costs a few seconds A mistake happens in front of everyone it affects
You can use the app again tomorrow The session happens once a year

None of that is exotic. It's a projector and a meeting, which describes a fair amount of internal software. The difference here is that the rows are the audience — this is a group of neighbours and friends deciding who marches in which squad, and the tool is standing in the middle of it.

So the brief stopped being "make this efficient to operate" and became something closer to "make this safe to project".

The block is public. The reason is private.

Two things can make a person ineligible: unpaid dues and a standing penalty. Both are recorded per person. Neither one appears anywhere in the table.

They live in the individual record, behind the pencil icon on the row, as two toggles next to the license status. That record opens for one person at a time, and in practice it gets opened when nobody has a reason to look.

What the table shows instead is that the cell can't be clicked. That's it.

The reason this works is that a blocked cell is deliberately ambiguous. Three different rules produce it, and they all render identically:

// Behind on dues, or carrying a penalty.
const blockedByStatus = (person.unpaidDues || person.penalty)
  && event === activeColumn && !assignedThisYear

// Already taking part in another event this year.
const blockedByAnotherEvent = !state.allowMultipleEvents
  && hasAnyEventThisYear(person) && !assignedThisYear && person[event] == null

// Already did this same event in an earlier edition.
const blockedByRepeat = !state.allowRepeatEvent
  && event === activeColumn && !assignedThisYear && person[event] != null

// One class. Three reasons. The cell never says which.
classes['cell-blocked'] = blockedByStatus || blockedByAnotherEvent || blockedByRepeat
Enter fullscreen mode Exit fullscreen mode

And that class is a single flat pattern, with nothing attached to it:

td.cell-blocked {
  background-image: repeating-linear-gradient(
    -45deg, transparent 0 5px, rgba(100, 116, 139, .13) 5px 6px
  );
  cursor: not-allowed;
  pointer-events: none;
}
Enter fullscreen mode Exit fullscreen mode

The room sees that the rules were applied. The room does not learn who owes money.

The temptation I had to talk myself out of was the tooltip. On any other desktop app I'd have added title="Blocked: unpaid dues" without thinking — it's helpful, it's cheap, it's what a good interface does. Projected, it's the worst feature in the product: the same private fact, on a half-second delay, rendered wherever the mouse happens to be resting, at wall size.

Worth being precise about where the line sits, because it isn't "hide every status". The avantcarga license — the paperwork some members need for certain events — is right there in the open, as a small coloured card next to each name and as a running count in the header: 32 valid, 13 expired, 51 none. Nobody minds. It's an administrative fact about a document.

Money and penalties are a fact about a person, in a room full of people who know them. That's the line, and it isn't a technical one. It came from asking what would be uncomfortable to have on a wall, not from a data classification exercise.

Everyone has to stay oriented, not just the operator

In a normal app, only the user needs to know where they are. Here, thirty people are following along without touching anything, and every question they can't answer for themselves becomes an interruption: wait, which event are we on? how many are left? is that one full?

So the state of the session is permanently on screen, not tucked behind the current interaction:

  • Five cards across the top, one per event, each showing 8/11 and a men/women split (H 8 M 0), with a fill bar. The event in progress is the blue one.
  • The active column is tinted down the entire height of the table, so a row halfway down the list still reads as belonging to the current event.
  • A status line along the bottom: how many people are in the rota, which event is in progress, and whether the squad has locked to a gender.

One small detail I like more than it probably deserves. When an event closes, its per-gender counters are reset internally, so leaving them on the card would show a confident H 0 M 0 for an event that had just filled eleven slots. The card swaps them for a state instead — Completed, Skipped, Incomplete, Pending verification — and only shows numbers while they still mean something.

Nobody would catch that in a code review. Projected, a wrong number on a wall gets a question from the third row, and the session stops while somebody explains that the zero doesn't mean what it says.

If you cover something, let them move it

When the eleven slots fill, a verification modal opens listing the eleven people, with a checkbox for squad leaders and an × to remove anyone who shouldn't be there. It's the moment the room is paying most attention.

It also covers about eleven rows of the table underneath — which is exactly when somebody asks whether so-and-so already went out last year.

On a private screen the answer is trivial: close the dialog, look, open it again. In front of a room, that means throwing away a selection you just made while eleven people wait for you to rebuild it.

So every modal in the app can be dragged out of the way:

const INTERACTIVE = 'button, a, input, select, textarea, label, [contenteditable]'

function onPointerDown(e) {
  if (e.button !== 0) return
  const target = e.target
  if (target.closest(INTERACTIVE)) return

  // The handle is the header. Small dialogs have no header, so anything
  // non-interactive works there.
  const header = contentEl.value?.querySelector('.modal-header')
  if (header && !header.contains(target)) return

  measureBase()
  pointerStart = { x: e.clientX, y: e.clientY }
  offsetStart = { ...offset.value }
  dragging = true
  contentEl.value?.setPointerCapture?.(e.pointerId)
  e.preventDefault()   // without this, dragging selects the dialog's text
}
Enter fullscreen mode Exit fullscreen mode

Two things in there exist only because of the room.

The first is that check against INTERACTIVE. Dragging from a button would make the checkboxes in that verification list less than perfectly reliable to click, and a mis-click during verification is the one mistake you cannot make quietly.

The second doesn't appear in that snippet: every offset is clamped against window.innerWidth and innerHeight before it's applied, so the dialog can't be dragged past the edge of the screen. This is a kiosk-shaped desktop app — no browser chrome, no page scroll, nothing to rescue a window that left the viewport. Get that wrong and the operator is closing a modal they can no longer see, blind, in front of everybody.

The position also resets to centre on every open, rather than remembering where the last dialog was left. Where you dragged the previous dialog says nothing about what the next one is covering.

Interrupt for changes, not for confirmations

The app has one dialog system, and the rule that decides how a message appears turned out to be a single line:

// A notice is a dialog that closes itself and expects no answer.
const isNotice = computed(() =>
  dialog.timer > 0 && !dialog.showConfirmButton && !dialog.showCancelButton
)
Enter fullscreen mode Exit fullscreen mode

If there's nothing to answer, it isn't allowed to take the screen. Saved, 12 records imported, Exported to XLSX, Event reopened — corner of the screen, no backdrop, a thin bar draining to show how long it stays, gone in two seconds. The app underneath keeps working the whole time.

Dimming an entire projected wall to announce "Saved" is absurd once you've watched it happen. It was also covering the modal that was in the middle of closing behind it.

The interesting case is the one that still blocks.

The squad rules say an event locks to a gender once six of the eleven go the same way. When that happens the app doesn't just note it — it un-assigns everyone of the other gender from the event, which rewrites rows the room is actively reading.

That started life as a corner notice. It was wrong. The toast slid away after two seconds, the table simply changed, and people were left wondering what the operator had done.

It's now the one message in the app allowed to take the whole screen: centred, no backdrop click, no Escape, and a single Understood button somebody has to press before the session moves on. It names the rule that fired — Men's event — and says plainly that the women have been un-assigned.

The rule I ended up with: interrupt when the screen changed underneath the room, never to confirm something the operator did on purpose.

At three metres, colour is the interface

Nobody in the fourth row is reading a table cell. They're reading colour and position, and only leaning in when something looks off.

So colour carries real information, and it's the only part of the visual design with a strict budget:

  • Green — assigned in the current edition.
  • Hatched grey — unavailable, reason unstated.
  • One tint per past year2023 amber, 2024 yellow, 2025 blue, and so on. You can see how long ago someone last did an event without reading a single number.
  • Bold year — that person was a squad leader that year.
  • A small coloured card beside the name — license valid, expired, or missing.

Everything else is grey. There is no colour in this UI that doesn't mean something, which is less a matter of taste than of bandwidth: the moment you use a tint for decoration, the room starts trying to decode it.

The honest caveat: those license cards are colour-only, three images that differ in nothing but hue, with a title carrying the exact status. That would not pass an accessibility review, and it's on the list. The rest of the palette is redundant — every tinted cell also contains the year as text, and blocked cells carry a hatch pattern as well as a fill — but the cards genuinely aren't, and writing this up is what made me notice.

None of this was in the requirements

Here's the part that bothers me a little.

Every decision above is small and cheap. Not painting two fields. One CSS class covering three cases. A pointerdown handler with an early return. A boolean choosing corner over centre.

And not one of them was in anything I was given. The brief was the rules of the rota — slots, rotation, squads, veterans, licenses, penalties — and every one of those rules is in the app, correctly. You could implement the entire specification, pass every test, and still ship something that quietly puts a member's unpaid dues on a wall in front of them.

They came from watching the previous version being used. Not from a demo and not from a call: somebody had been running this session for years, and the shape of that evening — the projector, the questions from the room, the window that had to be closed to check something — was the real requirements document. It just wasn't written like one.

I don't have a repeatable process for extracting that, and I'm suspicious of anyone who claims one. The closest thing I have is a question I now ask early, before any of the interesting technical questions: where will this be running, and who else can see the screen?

Context of use is a functional requirement

We're fairly disciplined about non-functional requirements when they have familiar names. Performance, accessibility, security — those get budgeted for.

Where the screen is doesn't have a familiar name, so it gets skipped. And when it's skipped, what you ship is an interface that is entirely correct and still fails on the one night it's used, because you built for a desk and it ended up standing in front of a room.

The rota is fair either way. The maths doesn't care where it runs. But the whole point of building this was that the group could watch the reasoning happen and stop having the argument — and that only works if the screen shows exactly what the room is entitled to see, and not one field more.

Top comments (0)