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.0reads as "anything below 2027.0.0" — a year of changes — soapps/tabletpins 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 effectiveblackout. 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 rendersblackout. The reference tablet’s one-word change is intablet.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_REASONSintablet.tsis 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 therecoveryFailedmessage 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 withreason: "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 PromptBlueprint nodes. They would have wrappedSet Tablet State Stringand named one key. The general host-driven route iswhenHostAsks('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
timeoutMsper 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
sessionActivechange. -
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
promptfrom 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
staleafter 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 — soreadyStatestayed OPEN and every send was buffered into a connection that would never answer. Nothing leftstaleexcept a frame arriving, andretryNow()— 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 thestalephase 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
errcloses 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. -
hellohonoursnullas a deletion, like a patch always did. A host whose serialiser writes unset optionals asnull— 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!nullfor a key the host never had. Both handlers now share onemerge. -
A
revgap 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 stayedreadythroughout — 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-sliderbinds.valuelast. 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.
pendingclaimed 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 apointerIdany 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.
textContentwas written unguarded, and every write to arole="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.
titlewas set alongsidearia-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-haspopupis gone;aria-disabledarrived. In ARIA 1.1haspopup="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-expandedstands alone on a button. Separately, apendingbutton saidaria-busybut nothing said it refuses input, so pressing it was silence. -
pk-slider’s caption is a `<span>. It was a<label>that labelled nothing:forcannot reach a sibling input, so the accessible name always came from the input’s ownaria-labeland click-to-focus never worked.--pk-label-displayis unaffected. -
helloemits once. It calledsetPhase('ready')and thenemit(), 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-activateandpk-changeare typed, so reading a slider value no longer needs(event as CustomEvent<number>).detail.KioskSnapshot.error.codekeeps the protocol’s union while still admitting a code a foreign host invented. -
A socket that cannot be constructed says so. An
http://URL, orws:from an https page, throws synchronously every time; the retry loop swallowed it in silence forever. -
The volume slider is a whole spec.
el,cmdandstatemoved intoVOLUMEintablet.ts, and the language picker’s lock intoLOCALE_TOGGLEthere — 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
helloarrived 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. AndMaxClientsstopped bounding anything, because every release freed a slot while leaving a live session behind. Relatedly, that grace-period close now sends anerrfirst — with no close call available, theerris 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. -
localeno longer restarts a conversation that was not going to change.SetLocaleearly-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 inAvailableLocalesis 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 byStartAllListeners— 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. -
hellois 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
vis 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
errframe 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
WebRootfinds the packaged page.deploy_extras.batcopies it besideStreamingDemo.exe, which in a packaged build is one level aboveFPaths::ProjectDir()— so the only path the resolver tried was the one nothing had written to. -
A saved key lands inside its section.
VolumePercentwas 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
boolreturns are gone. Every one promised "did it change" and nothing ever read it, including the four typed wrappers readingSet’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.
-
pendingnow actually swallows the press. It suppressed the ripple and dispatchedpk-activateanyway — 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 inactivate(), 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
AvailableLocalesin the editor that has no flag inICONS, and the corner used to go blank. Sized by--pk-round-label-size. -
pk-buttonreflectsactive, sopk-button[active]is a working selector. The olddata-activewas stringified, which leftdata-active="false"permanently in the DOM and made the obvious[data-active]match a button that was not active. -
pk-buttongainedexpanded, which putsaria-haspopup/aria-expandedon 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 throughforEachinside the sametrythat 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.tsis what the tablet is — buttons, artwork, every visible string;main.tsis how it is wired;gestures.tsholds 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. -
sideEffectsnow listsdist/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-paddingor 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-gapand relied on it padding the slider row needs--pk-control-paddingnow. -
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
helloorstateescaping as an anonymouswindow.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-gapused to mean both the space between controls and the padding inside one. Inner padding is now--pk-control-padding. -
errsurvives for connection-level failures only (bad protocol version, bad token, malformed frame, rate limit, server full).KioskClientlogs 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.
-
busytakes a list. A control can have more than one reason to be unavailable, and the protocol has no "locked unless" — sobusy: ['agentUnready', 'sessionBusy']. A single string still works. -
errreaches the screen.KioskSnapshotgainederror?: {code, message}, and the reference tablet renders it in thenoticeline — 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.rateLimitedis deliberately excluded: it clears itself within the second, whileerroris only cleared by the nexthello, 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 firespointercanceland sends neitherpointerupnorclick, 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 onpointerdown; 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>gainedtoggle. It is what emitsaria-pressed; a one-shot action leaves it false rather than announcing itself as an un-pressed toggle. -
<pk-button>gainedpending, and the tablet apendingstate key. An action waiting on something slow draws a spinner where its icon was and swallows presses without a ripple — deliberately notdisabled, 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 andackon: a published key, which unlike an ack every tablet in the room can see. -
KioskClient.on()and theeventframe are gone — see the wire protocol. Nothing sent one. -
makeThrottle()swappeddispose()forflush(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 onfocus,visibilitychangeandonline, 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.