The kiosk/ directory contains a standalone page for Samsung SmartTVs that rotates between different content screens. It runs on older Tizen browsers (2018+) in landscape mode.
kiosk/index.html – HTML structure (Jekyll front matter layout: null)kiosk/KIOSK.md – this documentkiosk/rocket.svg, kiosk/unicorn.svg, kiosk/moon.svg – special-screen artwork (square viewBox; rocket faces right, unicorn faces left)kiosk/style.css – Styles using display: table / table-cell layout for the event screenkiosk/kiosk.js – Screen rotation system, event display, sine scroller animationAll screens are sibling <div>s inside <body>. Only one is visible at a time (toggled via display: block / display: none). A central rotation loop cycles through a playlist.
body
├── #screen-events (two-pane event display)
├── #screen-special (full-bleed takeover for major events)
└── #screen-welcome (logo + sine-scrolling text)
With no special events, the playlist is:
Welcome → Event 0 → Event 1 → … → Event N → Welcome → Event 0 → …
When one or more events are flagged special: true, those events are pulled out
of the normal rotation and the takeover takes every other slot – so it is on
screen half the time:
Welcome → Special → Event 0 → Special → Event 1 → Special → … → Event N → Special → (wraps)
The cycle starts on a non-special and ends on a special, so the alternation holds across the wrap back to the welcome screen too.
Which act a given special slot shows is not fixed in the playlist – it is taken
from a running cursor (specialActCursor) at display time. That keeps the three
acts evenly distributed however many slots a cycle happens to contain; baking the
act into the playlist starves one whenever the count is not a multiple of three.
Each screen shows for 15 seconds (SCREEN_DURATION_MS). The welcome screen appears once per full cycle.
<div id="screen-foo" class="screen"> in index.htmlstyle.cssscreens object in kiosk.js with activate / deactivate functionsbuildPlaylist()Welcome screen (#screen-welcome): Displays the StartupOulu logo with a
demoscene-style sine-wave text scroller. The text scrolls right-to-left with each
character’s Y-coordinate animated via Math.sin(). A few 🦄 emoji drift across
behind it. Animation starts on activate, cleans up on deactivate.
These unicorns are genuine emoji and render correctly on the TV — this screen
inherits the plain body font stack. Do not “fix” them into SVGs by analogy
with the special screen; see the emoji note under Technical Constraints.
Events screen (#screen-events): Two-pane layout showing event details (title, date, location, description, countdown) on the left and the event cover image with a QR code on the right.
Special takeover (#screen-special): Full-bleed screen for major events, used
for any event with special: true in its front matter. Designed to be readable
by someone walking past, so it leans on colour, scale and motion.
Persistent across every act:
updateFlaps), swapping the
digit at the midpoint while the tile is edge-on — a true 3D card flip would
need rotateX / transform-style: preserve-3d, which is not safe here.
There is deliberately no seconds tile: seconds would flip every tick and
turn the board back into ambient motion. Once there is nothing left to count
down the board gives way to a single pill: green NYT MENOSSA · LIVE NOW
while running, pink with the date once finishedrideCount % 2,
RIDES), flies left-to-right or right-to-left at random, climbs as it
goes, and takes a slightly different path each time so repeat crossings don’t
trace the same line.
Orientation is worked out from the combination of ride and direction:
rocket.svg points right and unicorn.svg points left, so exactly one of (is-unicorn, going-left)
needs mirroring — flightShipEl.className = (isUnicorn !== flightGoingLeft) ?
'is-flipped' : ''. The exhaust reverses to match (backSign), and the tail is
read off the trailing side of the ride’s box (tailFrac). It trails a plume of rainbow
particles in the welcome screen’s RAINBOW palette. A crescent moon
(moon.svg) shows in the corner during crossings on the CTA act only.
Particles come from a fixed recycled pool of 46 divs (ensureFumePool) —
creating and destroying elements every frame is the expensive part on this
hardware, so nothing is added to the DOM after the first crossing. When the
pool is exhausted the extra particle is skipped rather than the pool growing.
The plume keeps burning after the ship exits so it does not blink out mid-air.
The flyby runs off specialBgTimer, not its own intervalradial-gradient orbs and walk a
full-height gradient light band across the screen; recompositing eight
oversized semi-transparent gradient layers 30x a second made the TV crawl,
so both were removed. Keep the backdrop static — it is the one part of this
screen that reliably costs framesOne DOM, three acts swapped by JS, each opening with a short entry animation:
| Act | Content |
|---|---|
| 0 | Hype – giant uppercase title over a gradient rule. The headline is split into one span per character and twinkles: ~7% of letters are lit in a brand colour at any moment, the rest stay Canvas white. Characters are grouped into .sp-word nowrap wrappers so lines still break between words, not mid-word |
| 1 | Countdown – days / hours / min cards with a solid orange ticking seconds chip, or NYT MENOSSA · LIVE NOW while running |
| 2 | CTA – large scannable QR built from the event’s own cta_link, with cta_title as the headline |
Timers used: specialCountdownTimer (act 1), specialRailTimer (rail pill),
specialTickerTimer, specialBgTimer (flyby, exhaust, flaps), specialTwinkleTimer
(headline), specialEntryTimer. All are cleared in deactivateSpecial, which runs
on every screen transition — which also force-hides the ride, moon and exhaust, so a
transition mid-flyby can never leave them stranded on screen.
The screen is deliberately calm. Earlier revisions had five continuous effects
competing, and everything that repainted a large area every frame — the drifting
orbs, the gradient light sweep, the full-screen act wipe — has since been removed
because the TV could not keep up. If it needs rebalancing, these are the knobs
(all in kiosk.js):
| Constant | Now | Effect |
|---|---|---|
TWINKLE_PER_TICK / TWINKLE_LIFE / TWINKLE_FPS |
1 / 4 / 240 | How many headline letters are lit at once (~7%) |
FLIGHT_GAP_MIN_MS / FLIGHT_GAP_MAX_MS |
3000 / 5000 | Gap between flybys |
FLAP_HALF |
4 | Frames per half-flip on the countdown board (~265 ms total) |
FLIGHT_FRAMES_MIN / FLIGHT_FRAMES_VAR |
56 / 26 | Crossing duration (~1.8–2.7 s) |
FUME_PER_FRAME / FUME_POOL |
3 / 46 | Exhaust density and pool ceiling |
The ride flies behind the act content: #sp-rocket and #sp-fumes sit at
z-index: 1, below the stage at 2, so a crossing never obscures the headline.
Raising them above 2 would put the ride in front.
The ride is 30vh. Its size is declared twice — width/height on
#sp-ride-img in the stylesheet and SHIP_VH in kiosk.js, which is where the
exhaust spawn point is derived from. Change both together, or the plume
detaches from the tail. The flight path is also tuned to that height: a 30vh
ride on a steeper path would dip into the ticker band along the bottom.
Both dimensions are always stated explicitly. The artwork is square and viewBox-only, and a legacy engine that cannot infer an SVG’s intrinsic ratio will size it from its container instead — which is how a stale-CSS deploy once put a screen-filling logo over everything.
Prefer giving the flyby more presence over adding ambient motion — ambient motion habituates, onset does not. Whatever is added, keep it off large gradient-filled layers: those are what made this screen unusable on the TV.
This screen uses the brand palette (Knowledge #070540, Passion #FF3296,
Fame #FF4600, Canvas #EDF2F5) and Hanken Grotesk. The webfont is loaded
globally but applied only under #screen-special, so the other screens are
unaffected.
A finished event loses its special treatment. hasEnded() gates the flag, so
once end_time has passed the event is partitioned as an ordinary event and
shows the normal two-pane slide — a finished event should not still be taking
over the screen with a hype countdown. The playlist is only rebuilt every
EVENTS_REFRESH_MS, so activateSpecial re-checks and rebuilds on the spot if
an event finished while it still held special slots.
To make an event use this screen, add to its front matter:
special: true
events.json.liquid must emit the flag (it does) or the kiosk cannot see it.
const/let, arrow functions, template literals, fetch, or Promisesgap, no min(), no object-fitdisplay: table / table-cell with position: absolute for bottom-pinned elementsvh/vw units for resolution independence (720p, 1080p, 4K)setInterval + element.style updates. CSS @keyframes are used only for the small state pulses (live-pulse, sp-live-pulse, sp-sec-pulse, sp-kicker-pulse). The special
screen also uses transform: rotate() (prefixed) for its angled blocks; if a
display ignores it they render as flat bars, which still reads fineindex.html appends ?v=<build revision> to both
style.css and kiosk.js. The TV caches aggressively and does not reload on its
own; if only one of the two is versioned, a deploy lands new JS against old CSS
and screens render unstyled<img> tags carry explicit width/height attributes. The logo SVG is
viewBox-only, so without them a legacy engine sizes it from its containerbody
font stack applies (the welcome screen’s flying unicorns), but #screen-special
leads its stack with the “Hanken Grotesk” webfont, and the TV does not fall back
to a system emoji font from there: the glyph renders as nothing. Special-screen
artwork therefore ships as SVG files (rocket.svg, unicorn.svg, moon.svg)
drawn with an <img>data-auto-track="false" to avoid inflating pageview statskiosk-heartbeat event on each page load. index.html carries
<meta http-equiv="Refresh" content="600">, so that is once every 10 minutes/kiosk/?s=lobby?s= is not set, no analytics are tracked (useful for local testing)bundle exec jekyll serve → open /kiosk/The full rotation is long. To check one screen, pin it with ?only= rather than
waiting the cycle out.
Waiting out the rotation to check one screen is painful, so ?only= pins one:
/kiosk/?only=welcome
/kiosk/?only=special&act=0 # act 0, 1 or 2
/kiosk/?only=events&i=2 # event at index 2
Without ?only= the kiosk rotates normally.