Changelog

Newest first.

Changed in 2026.38.0

Versions are now dates. Everything in this repo — the workspace, the app and the library — carries 2026.38.0, and the numbering above stops here. The scheme is the company’s: YYYY.ww.index, meaning the ISO week-year, the ISO week, and the build’s order within that week counting from zero. So this is the first build of week 38 of 2026, and the next one that week is 2026.38.1.

Two things follow from it that are easy to get wrong:

  • The library no longer has a version of its own. Up to 0.6 it deliberately lagged the app, which is what the last three entries above keep explaining. One date covers the whole repo instead, so "which library version does app 0.6 contain" stops being a question anyone has to answer.

  • Depend on it exactly, never with a caret. ^2026.38.0 reads as "anything below 2027.0.0" — a year of changes — so apps/tablet pins the exact version, and so should you.

The git tag carries the same string with no v prefix, and CI refuses to publish if the tag and package.json disagree.

The library also changed, for the first time since 0.4:

protocol.ts gains CommandDescriptor and an optional server.commands on HelloMessage: a host may now declare the commands it accepts, with each one’s value type and the state key it reflects. Additive and optional, so a client written against earlier v2 is unaffected and the wire version stays at 2. See Writing your own host and Finding out what a host accepts.

Nothing in core.ts or ui.ts changed, and the reference app does not read the new field — reading it to decide what to render is the one thing that would turn it back into the v1 manifest.

On the host side (not part of this package): the Unreal plugin publishes the vocabulary, answers a known command carrying a bad value with badRequest instead of dropping it, and exposes Register Tablet Command so a project can declare its own.

Changed in 0.6

The host can now take itself out of service — see Taking itself out of service. On by default; nothing is required to get it.

The wire format grew two keys and no message types, which is the whole of the protocol change:

  • blackoutManual, beside the effective blackout. There are two latches behind that key now — a person’s and the host’s own diagnostics — and a tablet’s out-of-service toggle must render the person’s half or it lights up for faults nobody touched and stops following its own taps. The fog still renders blackout. The reference tablet’s one-word change is in tablet.ts — and it pairs the app to a host that publishes the key: run this app against an older host and the Manual blackout button sits permanently disabled, which is the flip-button rule doing exactly what it says (a key that was never published has no honest value to send).

  • health.failed, the failing check names. Names rather than sentences, because a sentence has a language the host cannot know; HEALTH_REASONS in tablet.ts is where the wording lives, and an unrecognised name falls back to showing the name.

Two fixes came with it, both visible without touching any of the above:

  • A blackout during a streamed session lands the browser on the disconnected overlay instead of a frozen frame of a live-looking avatar. Disconnect() sends nothing to the frontend, and the lockout path never sent the recoveryFailed message its two sibling teardowns both send — so a viewer whose kiosk was taken out of service mid-conversation just watched the picture stop. It now arrives with reason: "outOfService" and wording of its own, since "the connection was lost" invites a retry that is going to be refused.

  • The failures the host already detected are no longer discarded. A recovery give-up, a failed connect, a dropped namespace and a session change that never completed now each ask for a probe. None of them latches anything by itself, deliberately — see the section above for why that distinction is the one that keeps a bad afternoon from becoming a permanent lockout.

@promethist/kiosk-tablet is byte-for-byte the 0.4 library again, so its version stays at 0.4.0 while the app and the workspace go to 0.6.0.

Changed in 0.5

One feature, and it is deliberately confined: the tablet can now ask the visitor something. apps/tablet/src/prompts.ts holds the registry and the shipped five-star rating — see Asking the visitor something.

Nothing else moved. The wire format is untouched, no plugin code changed, no Blueprint node was added, and @promethist/kiosk-tablet is byte-for-byte the 0.4 library — which is why its version stays at 0.4.0 while the app and the workspace go to 0.5.0.

One document arrived with it: the kiosk deployment guide, the front door for somebody building their own tablet. It says out loud what this file only implies — that the sample app carries every feature at once so that we can exercise them, which makes it a poor model for an installation that needs four controls, and that the language picker in the corner is the specific thing not to copy.

Three things were considered and cut, since the shape of what is not here is the useful part:

  • Show Tablet Prompt / Hide Tablet Prompt Blueprint nodes. They would have wrapped Set Tablet State String and named one key. The general host-driven route is whenHostAsks('name') against that existing node.

  • A timeout in C++. It belongs to whoever drew the card: a rating wants 20 s and a 3D viewer wants minutes, so it is timeoutMs per prompt rather than one number compiled in for everyone.

  • "A prompt must not survive into a conversation," in C++. That is a policy, and a partner may want the opposite. The tablet enforces it because the tablet can see sessionActive change.

  • A second Blueprint node to clear the key. It was there to defeat the host store’s value dedupe so the second visitor also got asked. Gone because the rating’s opener ANDs the key with the end of a conversation, which turns prompt from an event into a setting — published once, never undone, and unable to go stale.

The one design point worth carrying into your own prompts: a prompt opens on an edge between two snapshots, never on a condition. A condition is satisfied again by every later snapshot, and since hello carries a complete one, a tablet reloading hours later would ask out of nowhere. An edge cannot be replayed, because the first snapshot after a connect has no predecessor to differ from.

Changed in 0.4

A second review pass over the same surface. The wire format is still untouched — a v2 client works against any v2 host — but enough behaviour changed to need its own version rather than a second helping of 0.3.

The one to know about if you are running a tablet on Wi-Fi:

  • A silent host is now dropped, not merely named. The client marked the link stale after 8 s without a frame and then did nothing about it: a host powered off at the wall, or an access point that vanishes, closes nothing — no FIN, no RST — so readyState stayed OPEN and every send was buffered into a connection that would never answer. Nothing left stale except a frame arriving, and retryNow() — the whole wake mechanism, including the one that fires when an operator picks the tablet up — returned at its first guard while a socket object was still held. The tablet sat fogged and dead until the OS finished its TCP retransmit budget, which is minutes. Staleness now discards the zombie and arms the retry, keeping the stale phase as the name for what happened.

The rest, in rough order of how much they can cost you:

  • The error banner now renders above the fog. It is the only diagnostic surface on a kiosk — a bad token, or a page cached from before a protocol bump — and both of those arrive with the fog already up, because an err closes the socket. Underneath it, the banner was a pale smear behind a 160 px spinner: the operator saw the endless spinner the banner exists to replace.

  • hello honours null as a deletion, like a patch always did. A host whose serialiser writes unset optionals as null — the default in C#, Java and Go — used to leave the key present with a null value, which is exactly what makes a flip button decide it has been published. It went live and sent !null for a key the host never had. Both handlers now share one merge.

  • A rev gap can no longer strand the mirror. The gap handler re-asks on every patch, which recovers only while patches keep coming; an idle kiosk publishes nothing for minutes. The phase stayed ready throughout — no fog, no banner, every control live over state the client had already declared wrong. The heartbeat now re-asks until the snapshot arrives.

  • pk-slider binds .value last. Lit commits parts in template order, and a range input clamps the value it is given to the min and max it holds at that moment — still the UA’s 0 and 100 on the first render. <pk-slider min="200" max="400" value="300"> put the real control at its floor under a fill and thumb drawing 50 %. The reference tablet’s range happens to be 0–100.

  • A held mouse press on a spinning button no longer fires it. pending claimed the double-press latch for touch but not for a mouse, so pressing Call Assistance mid-spinner and holding until the host cleared the key dispatched the command on release, with no ripple.

  • The volume drag survives a second finger. The release listeners are on window, because a drag routinely ends outside the slider, and without a pointerId any unrelated lift — a palm steadying the tablet — cleared the drag flag and let the next echo yank the thumb backwards.

  • And it survives being inside something scrollable. pk-slider’s input now sets `touch-action: none. The shared theme allows panning, which is right for every other control, but inside a scroll container — the admin menu is one on a short screen — a drag with any vertical component was claimed by the scroller, and the thumb stopped following the finger halfway.

  • The banner stopped shouting. textContent was written unguarded, and every write to a role="status" region is an announcement; a refused connection emits two to four snapshots a second, forever. The fog’s status line had the guard already.

  • The flag tiles stopped spelling the code out. title was set alongside aria-label, and a screen reader reads it as the description: "Čeština, c s hyphen C Z". On a captive touch surface the tooltip was reaching nobody anyway.

  • aria-haspopup is gone; aria-disabled arrived. In ARIA 1.1 haspopup="true" means menu, and the reference popover is a column of plain buttons with no menu semantics — a component cannot know what kind of popup it opens. aria-expanded stands alone on a button. Separately, a pending button said aria-busy but nothing said it refuses input, so pressing it was silence.

  • pk-slider’s caption is a `<span>. It was a <label> that labelled nothing: for cannot reach a sibling input, so the accessible name always came from the input’s own aria-label and click-to-focus never worked. --pk-label-display is unaffected.

  • hello emits once. It called setPhase('ready') and then emit(), so on every connect and reconnect each subscriber got the same snapshot twice — harmless for an idempotent one, not for an edge-triggered one, and this tablet contains that shape.

  • retryNow() brings the machinery with it. Called on a client nobody had connected yet — which its own documentation says is safe — it opened a socket with no heartbeat and no wake listeners, permanently.

  • pk-activate and pk-change are typed, so reading a slider value no longer needs (event as CustomEvent<number>).detail. KioskSnapshot.error.code keeps the protocol’s union while still admitting a code a foreign host invented.

  • A socket that cannot be constructed says so. An http:// URL, or ws: from an https page, throws synchronously every time; the retry loop swallowed it in silence forever.

  • The volume slider is a whole spec. el, cmd and state moved into VOLUME in tablet.ts, and the language picker’s lock into LOCALE_TOGGLE there — it was the one control whose rules lived in the wiring, and it had already drifted out of step with Start’s.

On the Unreal side, all of it invisible from the wire:

  • A released client is remembered until its socket actually closes. There is no close call on the engine’s WebSocket, so releasing a client means dropping our pointer while its TCP session stays open — and libwebsockets will not reap it either, because every callback clears the session timeout. Deleting our entry as well made that socket invisible: the receive callback was still bound and still firing, but every lookup behind it failed, so each frame was discarded in total silence. A tablet whose hello arrived just after the auth grace expired sat fogged for the rest of its life, re-asking every eight seconds into a host that had forgotten it. And MaxClients stopped bounding anything, because every release freed a slot while leaving a live session behind. Relatedly, that grace-period close now sends an err first — with no close call available, the err is the only thing that makes a client hang up.

  • Three value guards were not the guards their comments claimed. FString::IsNumeric() steps over a leading sign and then runs an empty loop, so "-", ""` and `"."` are all "numeric" while `Atof` reads them as zero — which `volume` persisted, muting the kiosk across every restart from one malformed frame. And `FString::ToBool()` falls through to `Atoi() != 0`, so _every_ unrecognised non-empty string read as a confident `false`: `{"cmd":"blackout","args":{"value":"x"}}+ lifted the out-of-service latch, which is the one direction a latch must never fail in. Both now parse for real and ignore the command otherwise.

  • locale no longer restarts a conversation that was not going to change. SetLocale early-returned when the code matched, but the restart behind it was unconditional — so an operator tapping the flag they were already on tore a visitor’s conversation down for nothing. A code that is not in AvailableLocales is now refused as well: the choice is stored on the game instance and survives level travel, so an unrecognised one fed every later connect until somebody picked a real language.

  • A failed HTTP bind is finally detected. GetHttpRouter(port, bFailOnBindFailure=true) only attempts the bind when listeners are already enabled, and that flag is set by StartAllListeners — which this code called on the next line. So the check was inert, a valid router came back for a port something else held, and the log said "Serving the tablet page" over a dead port. The two calls are now in the other order.

  • hello is metered. It was handled above the rate check, so a client spamming it had a complete state snapshot built and serialised per frame, at line rate, against no budget at all. Every frame now passes the token bucket before its type is even read.

  • A frame with no v is refused. The field was pre-seeded with the host’s own version and overwritten only if present, so a frame carrying none compared equal and sailed through.

  • A refusal is reported once per rate window, not per frame. A looping client earned a warning line and an err frame every time round — an unbounded write to a kiosk’s disk over a condition the host was already refusing. Rationing it by time rather than by "the first of a burst" is what makes it bounded under a sustained flood, where tokens refill continuously and no burst ever ends. The drop is still logged, with a count.

  • The default WebRoot finds the packaged page. deploy_extras.bat copies it beside StreamingDemo.exe, which in a packaged build is one level above FPaths::ProjectDir() — so the only path the resolver tried was the one nothing had written to.

  • A saved key lands inside its section. VolumePercent was inserted directly above the next [Section] header, taking that section’s blank separator into ours. This file is hand-edited.

  • The state store’s five bool returns are gone. Every one promised "did it change" and nothing ever read it, including the four typed wrappers reading Set’s. `HasPendingPatch() answers that question where it is actually asked.

Changed in 0.3

The wire format is untouched, so a v2 client still works against any v2 host. What changed is the library’s own surface — enough of it to need its own version rather than a second helping of 0.2.

  • pending now actually swallows the press. It suppressed the ripple and dispatched pk-activate anyway — an animation that said "ignored" over a command that went out. The reference tablet hid it by refusing the same press a second time, so it only ever bit a tablet that borrowed the component. The guard now lives in activate(), where both the pointer and the click path pass through it.

  • A round button with no artwork draws its label instead of an empty circle. Reachable without touching code: add a language to AvailableLocales in the editor that has no flag in ICONS, and the corner used to go blank. Sized by --pk-round-label-size.

  • pk-button reflects active, so pk-button[active] is a working selector. The old data-active was stringified, which left data-active="false" permanently in the DOM and made the obvious [data-active] match a button that was not active.

  • pk-button gained expanded, which puts aria-haspopup/aria-expanded on the real button inside the shadow root. Written on the <pk-button> host from outside they reach nothing, because the host has no role and is not the focusable node.

  • A subscriber that throws no longer starves the ones after it. emit() fanned out through forEach inside the same try that guards frame parsing, so one tablet’s render bug abandoned every later subscriber and was reported as a malformed frame from the host.

  • The tablet split in two. tablet.ts is what the tablet is — buttons, artwork, every visible string; main.ts is how it is wired; gestures.ts holds the pointer rules. Adding a button no longer means reading 767 lines to find the table.

  • The admin menu’s Close button and the flag tiles activate on the finger landing, like every other control. They were still on click, which is the dead-press bug the rest of the tablet was rewritten to avoid — and Close was translated once from the config language, making it the only string that did not follow the conversation.

  • sideEffects now lists dist/index.js. A bundler was free to treat the root import as pure and tree-shake away the custom-element registration.

  • BREAKING, quietly: pk-slider’s inner padding no longer falls back to `--pk-gap. It takes --pk-control-padding or the built-in default, finishing the 0.2 migration the changelog below already announced. The reference theme sets both, so nothing here moves; a tablet that set only --pk-gap and relied on it padding the slider row needs --pk-control-padding now.

  • close() disposes the client — it detaches the three wake listeners and drops the subscribers, instead of leaving them attached for the life of the page. A kiosk never calls it; a tablet mounted on an SPA route does.

  • A frame that cannot be handled is logged as such. Narrowing the parse guard left a malformed hello or state escaping as an anonymous window.onerror, which on a kiosk with no reachable console reads as nothing happening at all.

Nothing in the wire format changed after 0.2 — a v2 client works against any v2 host. The host-side API did: on the Unreal end the tablet channel is no longer its own module, and a project binds On Tablet Command on PromethistLink instead of reaching for the subsystem.

Changed in 0.2

  • The command manifest is gone. v1 had host-side registration, per-command descriptors, correlation ids and ack; it amounted to an RPC framework serving eight synchronous buttons. Buttons are now declared in the app, and every lock the acks provided is a state key — which, unlike an ack, every connected tablet can see.

  • <pk-controls>, <pk-options> and <pk-text> are gone with it. <pk-button> and <pk-slider> remain and are unchanged visually.

  • --pk-gap used to mean both the space between controls and the padding inside one. Inner padding is now --pk-control-padding.

  • err survives for connection-level failures only (bad protocol version, bad token, malformed frame, rate limit, server full). KioskClient logs them and — for the four that need a person — carries them on the snapshot; see "`err` reaches the screen" below.

  • Buttons are now named as flip or fire-and-forget rather than described by a schema. It is the same distinction the descriptors encoded, minus the descriptors.

  • busy takes a list. A control can have more than one reason to be unavailable, and the protocol has no "locked unless" — so busy: ['agentUnready', 'sessionBusy']. A single string still works.

  • err reaches the screen. KioskSnapshot gained error?: {code, message}, and the reference tablet renders it in the notice line — for the four codes that need a person, none of which resolve by reconnecting, so a console warning was the same as saying nothing on a kiosk. rateLimited is deliberately excluded: it clears itself within the second, while error is only cleared by the next hello, so one long slider drag would have left a red banner up for the rest of the session.

  • <pk-button> activates on the finger landing, not on the click. Slide a fingertip a couple of millimetres while it is down and the browser reinterprets the gesture as a pan: it fires pointercancel and sends neither pointerup nor click, so the button drew its ripple and did nothing — no command on the wire, and nothing in the host’s log, because from the host’s side the press never happened. Touch and pen now activate on pointerdown; a mouse keeps its click, where dragging off to cancel is what a user expects. The compatibility click after a touch is de-duplicated.

  • <pk-button> gained toggle. It is what emits aria-pressed; a one-shot action leaves it false rather than announcing itself as an un-pressed toggle.

  • <pk-button> gained pending, and the tablet a pending state key. An action waiting on something slow draws a spinner where its icon was and swallows presses without a ripple — deliberately not disabled, because a request in flight is the button working, not the button unavailable, and greying out the one thing moving says the opposite. It is also the protocol’s answer to progress reporting, which v1 spent correlation ids and ack on: a published key, which unlike an ack every tablet in the room can see.

  • KioskClient.on() and the event frame are gone — see the wire protocol. Nothing sent one.

  • makeThrottle() swapped dispose() for flush(id), which is what a continuous control needs when the gesture ends: without it the last movement of a drag can sit in the throttle long enough for a stale echo to pull the thumb backwards.

  • Reconnecting is near-instant. Three changes, and the third is the one that matters most: the backoff ceiling dropped from 5 s to 1 s (exponential backoff protects a shared remote service from a crowd; this is one tablet and one process on a LAN, where a refused connect costs one packet); a connect that neither opens nor fails is now abandoned after 5 s, so a dropped SYN can no longer make the OS’s TCP timeout the retry interval; and retryNow() connects immediately on focus, visibilitychange and online, because a browser is entitled to freeze the retry timer on an idle tablet and no interval can fix a suspended interval. Measured against the built library: a host that comes back is picked up in under a second, and a wake trigger beats the timer outright.