@latticekit/ui — the handful of DOM primitives a game HUD cannot avoid needing, and
deliberately not a framework.
import { fmtCompact } from '@latticekit/core';
import { createOverlay, drive, el, roll, toasts } from '@latticekit/ui';
const ui = createOverlay({ now: () => performance.now() });
const gold = roll(ui, { format: fmtCompact });
ui.mount(el('div', { class: 'hud' }, 'Gold ', gold.node), { interactive: true });
ui.every((nowMs) => { gold.set(wallet.goldAt(nowMs)); });
drive(ui, loop); // `update` drives ui.tick, `render` drives ui.repaint. Never the other way.
Five lines, and five of this package's decisions are already made for the caller.
The two cadences, which is the whole design
There is no third registration point, and no way to put a state update inside render. That
is not tidiness: a HUD updated in the render callback freezes in a background tab while the
canvas keeps showing its last painted frame, so the game looks alive with prices, timers and
affordability marks that stopped twenty minutes ago.
And the fix for that is not a setInterval of this package's own. update already is the
interval. A second clock beside the loop's is a HUD polling while the simulation settles,
which is how a one-shot dialog reopens blank after a confirm and the obvious recovery
overwrites what the player typed. driver: 'driven' is the default for this reason, and
driver: 'standalone' makes tick() throw so the two can never both be running.
The class names are public API
The package ships no CSS, so the only thing a game's stylesheet can hold on to is the node
structure and these names. Renaming one is a breaking change.
Two custom-property namespaces are written on the root and are equally public:
--lattice-brand, --lattice-brand-hi, --lattice-brand-lo from setBrand, and
--lattice-<key> for every key of a palette pushed through applyPalette. Your sheet reads
them; nothing in this package ever reads them back.
The complete list of CSS properties this package ever writes to an element's inline style is
position, inset, left, top, z-index, pointer-events and display. Nothing
decorative — no color, no font, no radius, no shadow. That list is a test, and it is the
boundary between "primitives" and "a look you have to fight".
The root, the pointer contract, and the two cadences — the three things that make the rest of
this package small.
The pointer contract, stated once
The overlay root is pointer-events: none, set inline. Interactivity is granted to
nodes, never by selector. This package ships no stylesheet at all, and in particular
no rule of the form #ui > *, so there is nothing for a game's
.spacer { pointer-events: none } to lose a specificity fight against. If a tap should
reach the world, do nothing. If it should not, name the node.
That is a rule rather than a warning because the failing configuration no longer exists.
There is no descendant rule to out-specify, and an inline auto on a full-width wrapper is
something a person had to type.
The overlay owns no clock
driver: 'driven' is the default and the default is the point: no timer, no
requestAnimationFrame loop, nothing advances until tick() or repaint() is called. In a
game that means @latticekit/loop's update and render, which is what drive wires.
The failure this designs out is not a crash. It is a HUD that appears to work: one cadence
from the loop and one of its own, drifting apart, until a poll lands between the player's
confirm and the settle that clears the condition — at which point a one-shot dialog reopens
blank and the obvious recovery overwrites what they typed. A second clock is how a game
acquires a poll it did not know it had written.
type Dispose = Disposer
Undo a mount, a subscription or a widget.
The kit's teardown vocabulary is Disposer from @latticekit/core and this is that type, not a
second one: Scope.add has to accept what ui.every returns without a cast, and two
identical aliases would be two things to keep in step. The name Dispose is kept because the
RFC spells it that way and consumers were written against it; prefer Disposer in new code.
type LayerName = 'floats' | 'panels' | 'modal' | 'toasts'
The four layers, bottom to top.
Fixed, named and small on purpose. A z-index a game can pick is a z-index two games will pick
differently, and then a toast lands under a scrim — which is not a message that was shown
badly, it is a message that was not shown.
interface OverlayOptions {
How an overlay is created.
5 members
readonly now: () => numberTime, injected — and it must be the same clock @latticekit/loop was given.
The kit bans Date.now() inside every src/, and a widget that reads a clock it was not
handed is a widget no test can fast-forward. Most of this package's time arrives as the
argument to tick(); now covers the moments that originate outside a tick — a toast
spawned in a click handler, the visibilitychange resync, the standalone driver — and two
clocks in one HUD is the same class of bug as two cadences.
readonly parent?: HTMLElementWhere the root is appended. Defaults to document.body.
readonly driver?: 'driven' | 'standalone'Who advances the state cadence. Default 'driven'.
'driven' — the overlay starts no timer and no requestAnimationFrame loop. It advances only when something calls tick() / repaint().'standalone' — the overlay runs its own interval at standaloneMs and its own frame loop. For a HUD with no game behind it: a menu, a settings screen, a component page. In this mode tick() throws, because a host calling it as well is precisely the two-clocks bug this option exists to keep out of games.
readonly standaloneMs?: numberOnly with driver: 'standalone'. Default 1000.
@throws RangeError if set in 'driven' mode, which would be a cadence nobody reads.
readonly zIndex?: numberStacking against your canvas. Default 1, which is right when the canvas has none.
interface MountOptions {
How one node joins the overlay.
2 members
readonly layer?: LayerNameDefault 'panels'.
readonly interactive?: booleanOpt this subtree into pointer events. Default false, and that default is the package's
most important one: a tap that is not on a node you named reaches the world.
interface Overlay {
The overlay: a root, four layers, two cadences and a teardown.
9 members
readonly root: HTMLElementThe overlay root. Pointer-transparent, position: fixed; inset: 0, and never transformed.
readonly modalOpen: booleanTrue while any modal panel is open. Hosts use it to park world-space controls.
layer(name: LayerName): HTMLElementThe container for a layer, if you need to style or measure it. Do not reparent it.
mount<T extends HTMLElement>(node: T, opts?: MountOptions): TPut a node in a layer. Returns the node, so it composes inside an el() call.
Writes pointer-events inline on the node either way — auto when interactive, none
otherwise. The none is not redundant with the layer's: a game stylesheet containing
.lattice-layer > * { pointer-events: auto } targets this node, not the layer, and
without an inline declaration of its own the node would win that rule and swallow every tap
on the world behind it. Inline beats any author rule that is not !important, so the
guarantee holds against a sheet this package never sees.
ThrowsError if the overlay has been destroyed — a node mounted into a detached root is a
widget that runs, ticks and is never seen.
every(fn: CadenceFn): DisposerRegister work on the state cadence — everything tick() runs, and therefore
@latticekit/loop's update, which advances on wall time whether or not anything paints.
Anything whose absence would make the HUD wrong goes here: prices, affordability,
disabled buttons, build timers, toast expiry, the day/night palette.
Note what this is not: a setInterval of this package's own. update already is the
interval, and a HUD polling beside the simulation instead of with it is a poll racing a
settle — which silently replaced a player's typed company name in the game this kit came
from.
paint(fn: CadenceFn): DisposerRegister work on the paint cadence — everything repaint() runs, and therefore
@latticekit/loop's render: rAF, 0 Hz in a hidden tab, throttled on a low-power device,
skipped entirely under load.
Anything registered here must be cosmetic: if it never runs once, every number on
screen must still be right. That is the whole rule.
tick(nowMs?: number): voidAdvance the state cadence. Call this from your loop's update and nowhere else.
nowMs defaults to the overlay's own clock, which is what drive uses. Pass it
explicitly only when you are the clock's owner.
Do not write loop.onUpdate(ui.tick) against @latticekit/loop. Its update callback is
(dt, tick) in seconds, so the overlay would be told the time is 0.016 ms, forever. It is
bound, so the reference is safe to pass around; it is the argument that is wrong. Use
drive(ui, loop).
A no-op after destroy(), so a loop still holding the reference during a hot reload does
not throw sixty times a second on the way out.
ThrowsError, naming the mistake, if the overlay was created with driver: 'standalone'.
ThrowsRangeError if nowMs is given and is not finite.
repaint(nowMs?: number): voidAdvance the paint cadence. Call this from your loop's render. Bound, like tick.
destroy(): voidRemove the root, cancel anything the standalone driver started, drop every listener this
package added, and destroy every widget bound to this overlay. Idempotent.
function createOverlay(opts: OverlayOptions): Overlay
Build an overlay: a pointer-transparent root, four layers, and two cadences that nothing
advances until you do.
ThrowsTypeError if now is not a function — the one option with no sensible default,
because a clock this package chose for itself would be the second clock in the game.
ThrowsRangeError if driver is not one of the two names, if standaloneMs is set outside
standalone mode, or if standaloneMs / zIndex is not finite.
interface Driven {
The shape of a game loop, as this package needs it.
Declared structurally rather than imported: ui is layer 3 and depends on core and draw
only, so it cannot name @latticekit/loop — but it can describe it, and the real Loop
satisfies this without knowing that ui exists.
Both callbacks are declared as taking no arguments, and that is deliberate.
@latticekit/loop hands update a delta in seconds and render an interpolation alpha;
neither is the wall-clock reading this overlay wants, and a Driven that promised one would
be a promise the real loop does not keep. The overlay reads its own injected clock instead —
the same clock the loop was given.
2 members
onUpdate(fn: () => void): DisposerSubscribe to the state cadence. Must return a disposer that unsubscribes.
onRender(fn: () => void): DisposerSubscribe to the paint cadence. Must return a disposer that unsubscribes.
function drive(ui: Overlay, loop: Driven): Disposer
Wire an overlay to a loop: update drives tick, render drives repaint.
One export for two lines a caller could write, and it earns its place because those two lines
are the ones it is fatal to cross. render-drives-tick is a HUD that freezes with stale
prices and stale disabled states the moment the tab goes behind another — the bug the source
game shipped and then fixed with a comment. Here it is not a comment; it is a function whose
whole body is the correct pairing.
Returns a disposer that unwires both. Disposing it is not the same as destroying the overlay —
a paused game may want the HUD detached from the loop and still on screen — but the reverse
does hold: ui.destroy() unwires the loop, so a torn-down overlay cannot be left subscribed to
a running one.
ThrowsTypeError if loop has no onUpdate/onRender — which is what a loop that takes its
callbacks at construction looks like from here, and the fix is two lines of hand-wiring rather
than a shim.
ThrowsError if the overlay is driver: 'standalone', at wiring time rather than on the
first update, so the two-clocks mistake fails at the line that made it.
function auditOverlay(ui: Overlay): readonly string[]
Dev-time audit. One English sentence per problem found, empty when clean.
It catches the two failures that are invisible until a player reports "I can't tap the ground
here":
- A node whose computed
pointer-events is auto that this package never granted it. That can only have come from a stylesheet, which means a game has written the descendant rule this package refuses to ship, and a full-width wrapper is now swallowing every tap on the world behind it. - A
transform, filter or will-change on the root or a layer, which silently re-parents every position: fixed descendant to that element — a modal scrim that no longer covers the viewport, a toast column anchored to the wrong thing.
Call it from a test, or from the console when a tap goes missing. It reads computed styles,
so it costs a layout: it is a diagnostic, not something to run per frame.
Returns an empty array where the host cannot compute styles at all — a report of "no problems"
from a host that cannot see any would be worse than no report, so the sentences say which
check produced them.
el10 symbols
The element builder and the four write helpers — the half of this package that deletes the
most code from a game.
The measure is a shipped game's HUD file: 3,102 lines, 342 hand-written
document.createElement sequences, 59 classList pokes and 37 private lastX = ''
fields whose entire job was "do not write the DOM if the string did not change". Those 37
fields are one function here, and the function returns whether it wrote, which is the part
that makes them deletable rather than merely shorter.
Nothing in this file reads a global except through host.ts, and nothing in it writes a
decorative style. The complete set of CSS properties this whole package ever assigns is
position, inset, left, top, z-index, pointer-events and display, plus custom
properties; three of those are written here.
type Attrs = Readonly<Record<string, string | number | boolean | EventListener | undefined>>
Attributes for el.
class and text are special-cased, a key starting with on whose value is a function
binds a listener, undefined and false are skipped so a conditional attribute reads
inline, and true sets a bare attribute.
type Child = Node | string | false | null | undefined
A child of el. The falsy members exist so that cond && el(…) composes without a
filter — a list built from four optional rows should read as four lines, not as a reduce.
function el<K extends keyof HTMLElementTagNameMap>(tag: K, attrs?: Attrs, ...children: Child[]): HTMLElementTagNameMap[K]
Build an element.
const row = el('div', { class: 'pill', onclick: buy }, 'Gold ', gold.node, unlocked && badge);
There is deliberately no html key, and passing one throws. The source game had one, and
the first string a game wants to interpolate is the player's own typed company name. An
element builder that makes innerHTML the short path is a cross-site-scripting hole with
good ergonomics, and a silently-ignored html key would be the same hole plus a mystery.
ThrowsTypeError naming the key if html is passed, or if a function is passed under a key
that does not start with on — the second is always a typo (click for onclick), and
stringifying a function into an attribute is how it goes unnoticed for a week.
function clear(node: Element): void
Empty a node.
innerHTML = '' is the one-liner everybody reaches for and it leaks listeners on some
engines — the removed subtree is discarded wholesale rather than detached node by node, and
a listener bound to a node that no longer has a parent is a listener nothing will collect.
Removing children one at a time costs a loop and leaks nothing.
function setText(node: Node, text: string): boolean
Write text only if it changed, and say whether it did.
This one function replaces the 37 private lastX = '' fields in the source game's HUD, and
the return value is why: if (setText(node, s)) pulse(node) flashes a change without
flashing every tick. A HUD that pulses on every update has taught the player to ignore the
pulse, which costs exactly the moment the number did move.
Compares against textContent, so it is correct for a node whose text was written by anyone
— including the game's own code before this package saw it.
function show(node: HTMLElement, on?: boolean): void
Show or hide, inline and !important, so it wins.
The user-agent rule behind the hidden attribute is [hidden] { display: none } at
specificity zero, and a game's own .dock { display: flex } beats it. The source game hit
this and ended up restating [hidden] { display: none !important } beside every flex element
it could hide, which is a rule that has to be remembered once per element for the life of the
project. An inline display: none !important cannot lose to an author rule at all.
The hidden attribute is set too — not for the layout, which the inline style already owns,
but for assistive technology and for a game's own :not([hidden]) selectors.
Showing removes the inline value rather than writing display: block, so an element goes
back to whatever the game's stylesheet says it is. Writing a display value here would be this
package holding an opinion about layout, and it holds none.
function hide(node: HTMLElement): void
Hide, inline. show(node, false), named for the call site that reads better.
function pulse(node: HTMLElement, className?: string): void
Restart a CSS animation on a node — the "+1" bump on a resource pill.
classList.remove('bump') then classList.add('bump') in the same task does nothing at
all: the browser never observes the intermediate state, so there is no transition between
two identical computed styles. Reading offsetWidth between them forces a synchronous layout,
which is what makes the removal observable.
That read is load-bearing. It is the exact line a tidying pass deletes as a no-op with no
assignment, after which the pill bumps the first time and never again, and nobody attributes
it to a commit three weeks earlier. el.test.ts counts the reflow so that deleting the line
fails a test rather than a player's second collect.
Pass '' to disable — a caller that has no bump animation should not pay a forced layout.
function interactive<T extends HTMLElement>(node: T): T
Grant pointer events to this node and its subtree, inline. The only way in.
The overlay root is pointer-events: none and this package ships no stylesheet, so there is
no #ui > * rule for a game's own .spacer { pointer-events: none } to lose a specificity
fight against. That fight is trap 1 and it cost the source game real time: a full-width flex
spacer inherited auto from a descendant rule, and every tap on the ground behind it died on
an invisible div. Nothing on screen changed; the game simply stopped responding in the middle.
If a tap should reach the world, do nothing. If it should not, name the node.
function passthrough<T extends HTMLElement>(node: T): T
Take pointer events away from a subtree of an interactive panel.
For the decorative child that overlaps something tappable — a full-width header glow, a
gradient scrim inside a sheet, an absolutely-positioned badge. Without it the only remedy is a
stylesheet rule, and a stylesheet rule is how trap 1 starts.
Sheets, modals, and the things that must be answered.
Three behaviors earn this module its place, and none of them is appearance: a focus trap,
a stack whose Escape key and scrim pop the top only, and a latch that makes a
one-shot dialog safe to drive from a poll. A game styles everything it can see.
Why openOnce exists
The source game polled a derived condition every 900 ms to decide whether to show its company
namer, while that condition only cleared on a 1000 ms settle. The namer therefore reopened —
blank — after the player pressed CONFIRM, and the obvious recovery, pressing CONFIRM again,
overwrote the name they had just typed with a random roll. The recovery the bug invited was
the bug's payload. It is not a modal that blinks; it is data loss.
ui.every(() => { if (questIsNaming) namer.openOnce(); }) is correct at any poll rate,
including one faster than the state that drives it.
interface PanelOptions {
How a panel behaves. Nothing here describes how it looks.
4 members
readonly modal?: booleanA modal gets a scrim, traps focus, sets role="dialog" and blocks the world. Default
false.
readonly dismissible?: booleanScrim click and Escape close it. Default true. A confirmation that must be answered
sets false.
readonly layer?: LayerNameDefault 'panels', or 'modal' when modal is true.
readonly onClose?: () => voidCalled after close, whatever closed it — a button, the scrim, Escape, destroy(), or the
overlay being torn down. Called at most once per close, never twice for one.
interface Panel {
An open-and-closable region of the overlay.
6 members
readonly node: HTMLElementYour content goes in here.
The package owns this element's structural styles — it is mounted, hidden, shown and given
pointer events by panel — and you own every child of it. It carries lattice-panel, and
lattice-panel-modal when modal, and no other styling whatsoever.
readonly isOpen: booleanWhether it is currently on screen.
open(): voidOpen it. Idempotent: opening an open panel does not push a second modal entry, which
would take two Escapes to close and leave modalOpen true after the first.
openOnce(): booleanOpen at most once, ever, for the life of this panel — and return whether this call was
the one that opened it.
This exists because of a data-loss bug, not for tidiness; see the module header. The latch
never resets: a panel that has been opened once and closed will not reopen through this
door, including from a poll running faster than the state that drives it.
close(): voidClose it, restoring focus to whatever had it when the panel opened. Idempotent.
destroy(): voidClose it and remove its nodes. Idempotent, and safe to call after ui.destroy().
function panel(ui: Overlay, opts?: PanelOptions): Panel
A panel bound to an overlay.
Modals are a stack: opening a second over the first pushes, Escape and the scrim pop the
top only, and ui.modalOpen is true while the stack is non-empty. Focus moves into the top
panel on open and is restored to the previously focused element on close — including when the
close came from ui.destroy(), because a game that tears down a screen while a dialog is open
should not leave focus on a node that no longer exists.
ThrowsError if the overlay has already been destroyed.
interface AcknowledgeOptions {
What an acknowledgement says. Three fields, because a fourth is a dialog system.
3 members
readonly title: stringShort. It is the line the player reads before deciding whether this matters.
readonly body: string | NodeThe explanation, in the player's terms: what has happened, and what it means for them. A
Node if you need structure — this package will not parse an HTML string for you.
readonly confirmText?: stringThe button. Default 'OK', and you should nearly always replace it: a label that names the
acknowledgement ("I understand") is read, and "OK" is pressed without being read.
function acknowledge(ui: Overlay, opts: AcknowledgeOptions): Promise<void>
Tell the player something that must not be missed, and wait until they say they have seen it.
A modal panel with dismissible: false, one button, and a promise that resolves when it is
pressed. Escape does nothing, the scrim does nothing, there is no close cross — the only way
out is the acknowledgement, which is the entire point.
This exists for a specific class of message: the session has silently stopped working and
the player cannot tell. @latticekit/persist's 'refusing-newer' is the case that named it —
a save written by a newer deploy, which persist correctly refuses to overwrite, so the
player's progress is safe and their current session is not being recorded. A toast is
exactly wrong there: it is dismissible, it expires whether or not it was read, and it competes
with the toast that said "Refinery online" three seconds earlier. Severity is a property of
what the player loses by missing a message, not of how alarming it sounds.
Guarantees worth relying on:
- It works before the loop is running. Panels are event-driven, not tick-driven, so a dialog raised at boot — before
drive(ui, loop), or when the loop will never start because whatever it would have run is the thing that failed — is fully functional. A message about a broken session must not depend on the session. - The confirm button takes focus on open, so Enter answers it and a keyboard-only player is never trapped in a dialog that ignores Escape by design.
- It stacks. Raised over an open modal it goes on top, and the one underneath is still there when it closes.
- If the overlay is destroyed first, the promise never settles. Deliberate, and the one sharp edge here: a continuation written after
await acknowledge(…) is written for a player who agreed, and running it because the page is being torn down would be a lie. If you need to know about teardown instead, build it from panel directly.
One action only. Two buttons is a choice, not an acknowledgement, and a choice has an
outcome the caller must handle — that is a different function with a different return type,
and it is the first step into a dialog system this package refuses to become.
ThrowsTypeError if title is not a string or body is neither a string nor a node.
ThrowsError if the overlay has already been destroyed.
roll8 symbols
Numbers that move, and numbers that fly.
One module and two exports, because a +120 rising off a building and a wallet ticking up to
1,240 are the same feature seen twice: a number in screen space, animated, that must be
correct without the animation.
That contract is the whole design. set() records the target and value reports it
immediately; the easing happens on the paint cadence, and if no frame has painted recently
enough for an animation to be seen — a hidden tab, a low-power device, a test, the very first
value at boot — set() writes the target text there and then. A HUD is never wrong because a
frame did not happen; it is only less pretty.
interface RollOptions {
4 members
readonly node?: HTMLElementWhere it lives. Created as <span class="lattice-roll"> if you do not pass one, and never
mounted for you — a number belongs inside your own markup, not in a layer of its own.
readonly format?: (value: number) => stringDefault String. Pass fmtCompact from @latticekit/core for compact magnitudes. This
package has no formatter and never will: formatting is a pure function of a number and
belongs where pure functions live.
readonly ms?: numberRoll duration in ms. Default 400. Past about 600 the number is unreadable while it moves,
which makes the animation cost the thing it was decorating.
It is also the staleness threshold for the paint cadence: a set more than ms after the
last painted frame writes its value straight out, because an animation nothing will draw is
not an animation.
readonly bumpClass?: stringPulsed on every settled change. Default 'bump'. Pass '' to disable, which also skips
the forced layout pulse needs.
interface Roll {
A number that eases to its target.
5 members
readonly node: HTMLElementThe element the text is written into.
readonly value: numberThe target — always the truth, even mid-roll. Read this in a test, never
node.textContent, which is a frame of an animation and is allowed to be behind.
set(value: number): voidSet the target.
Cheap and idempotent: setting the value it already has does nothing at all, so calling it
from every() at 60 Hz costs one comparison. When no frame has painted recently enough for
an animation to be seen — a hidden tab, a test, the first value at boot — it writes the text
immediately rather than starting a roll nobody will watch.
ThrowsRangeError if value is not finite — a NaN here reaches the screen as the word
"NaN" in a currency display, which is the sort of bug players screenshot.
snap(): voidLand on the target immediately. Called for you on visibilitychange, and by set itself
whenever no frame has painted recently enough for an animation to be seen.
destroy(): voidUnsubscribe from both cadences. The node is left where you put it. Idempotent.
function roll(ui: Overlay, opts?: RollOptions): Roll
A number that eases to its target on the paint cadence.
ThrowsRangeError if ms is negative or not finite.
type FloatKind = 'gain' | 'loss' | 'plain'
How a floating number reads.
interface ScreenPoint {
A mutable point, used only as an output parameter. Structurally a Vec2 from
@latticekit/core, declared here so ui compiles with no import for three fields.
2 members
x: numberCSS pixels from the left of the viewport.
y: numberCSS pixels from the top of the viewport.
interface FloatOptions {
How a float host behaves.
3 members
readonly capacity?: numberHow many can be alive at once. Default 24. The nodes are created up front and recycled;
spawn() creates no element, because a big collect spawns a dozen of these in one tap and
a garbage collection during the feedback for a tap is the tap feeling bad.
readonly ms?: numberLifetime in ms. Default 900.
readonly project?: (anchorX: number, anchorY: number, out: ScreenPoint) => voidRe-project each live float's anchor, every paint.
Omit it and spawn() takes screen pixels, which is right for a static camera. Supply it and
spawn() takes whatever coordinates you like — world units, grid units — and this converts
them, so a +120 stays glued to the building it came from while the player is still
dragging the camera. @latticekit/ui does not know what a camera is and must not; three lines
of worldToScreen from @latticekit/iso live on the game's side of this hook.
Called with the same out object every time. Write into it; do not keep it.
interface FloatHost {
A pool of floating numbers.
2 members
spawn(anchorX: number, anchorY: number, text: string, kind?: FloatKind): voidSpawn one.
Four primitives, no object: this is the hot path in a collect-and-spend game. Over capacity
the oldest float is recycled — the newest feedback is the one the player is looking for.
ThrowsRangeError if either anchor is not finite; a NaN becomes left: NaNpx, which the
browser ignores, so the float appears in the top-left corner of the screen for everybody.
destroy(): voidRemove the pool. Idempotent.
function floats(ui: Overlay, opts?: FloatOptions): FloatHost
Floating "+120" feedback, in the overlay's bottom layer.
It is DOM rather than canvas because it is screen-space type: it wants the game's font, its
text shadow and its color tokens, and painting it through @latticekit/draw's text kit would
mean a second typographic system that drifts from the first. It is in the bottom layer
because feedback must never intercept the next tap, and that layer is pointer-events: none
with no way to turn it on.
Motion is a Web Animations keyframe set by this package, not a CSS class you have to supply —
the kit ships zero assets and that includes stylesheets, so a float must move on its own or
the primitive is half a primitive. The node is positioned with its horizontal center and top
edge on the anchor; style everything else with .lattice-float.
Expiry is driven from the state cadence, with the animation's own completion as an
optimization and never as the mechanism. Web Animations do not run in a hidden tab, so
onfinish never fires there and a recycler that waited for it would hand back no nodes at all
— the pool would fill, and the first tap after the player returns would show nothing.
ThrowsRangeError if capacity is below 1 or ms is not positive.
One hue, one palette, and no design system.
This package ships zero CSS, so everything here writes custom properties on the overlay
root and stops. Your stylesheet consumes them; nothing in this package ever reads them back.
That is the entire opinion @latticekit/ui holds about how anything looks, and the reason it can
be dropped into a game whose art direction was decided before the kit existed.
On the root, not on document.documentElement, for two reasons: a global custom property is
a global variable, and two overlays on one page — a game and its own settings preview — must
be able to disagree.
Nothing here touches a global. Every function takes the overlay whose root it writes.
interface BrandOptions {
How a brand hue is turned into a color.
2 members
readonly saturation?: numberHSL saturation for the derived color, 0..1. Default 0.72.
readonly lightness?: numberHSL lightness for the derived color, 0..1. Default 0.62.
type Palette = Readonly<Record<string, string>>
A set of named colors — whatever @latticekit/draw produces from interpolating two palettes by
a 0..1 parameter. Names to CSS color strings, and nothing else.
Structurally identical to draw's Vars, and declared here rather than imported so the seam
between the two packages is one shape rather than one package's opinions. @latticekit/ui
neither defines the names nor knows what they mean.
interface PaletteOptions {
How a palette is namespaced on the root.
1 member
readonly prefix?: stringCustom-property namespace. Default 'lattice', so a key sky becomes --lattice-sky.
An empty string writes --sky, for a game that already owns its token names.
function setBrand(ui: Overlay, hue: number, opts?: BrandOptions): void
Recolour the overlay from a single hue in degrees.
Writes exactly three custom properties on the overlay root — --lattice-brand,
--lattice-brand-hi, --lattice-brand-lo — derived through @latticekit/draw's color model,
so the HUD accent and the buildings in the world are the same hue by construction rather
than by two people picking hex codes that drift apart at the next art pass.
It also invalidates every ThumbCache on this overlay, because a thumbnail painted in
the old brand is now a lie. That inversion is the fix for a real bug: the source game keyed
its thumbnail cache on ${id}|${brand}|${w}x${h}, which never went stale and also grew
without bound as a player played with the color picker. Here the key does not name the brand
and the recolour drops the cache, so neither mistake is available to a caller.
Persist the hue, never these strings. The derivation is presentation-tier; the hue is the
durable value and it is one number.
ThrowsRangeError if hue, saturation or lightness is not finite. hue wraps, so 380
and 20 are the same color and a hue driven by an accumulating slider needs no modulo.
function setTokens(ui: Overlay, tokens: Readonly<Record<string, string>>): void
Set arbitrary custom properties on the overlay root.
The escape hatch that stops this package growing a design system: a game that wants a
--panel-radius, a --danger or a --dock-height sets it here and styles with it.
@latticekit/ui defines no scale, no ramp and no palette beyond the brand triplet above.
Change-guarded per key, exactly like applyPalette, so a token written from every()
costs a string comparison rather than a style invalidation of the whole overlay.
ThrowsRangeError naming the offending key if any key does not start with --. A key
written without the dashes sets an ordinary style property that nothing in this package
permits and no selector will find, and the symptom is a token that is simply never applied.
function applyPalette(ui: Overlay, palette: Palette, opts?: PaletteOptions): boolean
Push a palette onto the overlay as custom properties, and say whether anything moved.
This is setBrand's mechanism driven by a different input. A brand hue is chosen once
at incorporation; a day/night palette is a fresh set of strings as dusk falls, and the overlay
has to darken with the world — a HUD glowing in its daytime colors over a night scene is the
most obvious way an overlay reveals itself as a layer bolted on top.
Write it from update, never from render, and reach it through one of @latticekit/draw's
two bridges — never through draw's Palette itself:
// a game that draws already holds a live palette: `paletteVars` is the bridge
ui.every(() => applyPalette(ui, paletteVars(palette)));
// an overlay with no canvas behind it can blend two stop sets directly
ui.every(() => applyPalette(ui, lerpPalette(DAY, NIGHT, world.dayT)));
paletteVars is not ceremony. draw's Palette is live state with a rev and a get;
the Palette this function takes is a flat bag of name → CSS string. Passing the live
object straight in is a type error, and it is one on purpose: they are two different things
that share a word, and nothing here would catch it at runtime. The keys of a live palette are
rev, get, set, …, so the overlay would receive a --lattice-rev, six stringified
functions, and not one color.
If a canvas is behind the overlay, both must be asked the same question. draw's
palette.lerp(from, to, t) and its lerpPalette(from, to, t) are built to round t the same
way, so they cannot land on different colors for one t — which is no help whatever if the
world is given one t and the HUD another, and dusk is the one moment where a mismatch is
both unmissable and impossible to name.
Three properties make that correct rather than merely cheap:
- It is change-guarded per key. An identical palette writes nothing and returns
false, so pushing on every update is wasteful rather than wrong. Quantise t on your side — 1/64 is beyond what anyone can see over a dusk — and the guard turns most pushes into no-ops for free. - Smoothing is a CSS transition, not a JavaScript tween. One-second steps look like steps;
transition: background-color 1.2s linear in your stylesheet turns them into a continuous fade that runs on the compositor, costs no main-thread work, needs no frame callback, and degrades to an instant jump in a hidden tab — which is correct, because nobody is looking. - It does not invalidate thumbnails, unlike
setBrand. A shop card is a portrait of the building, not a photograph of it at this hour, and a cache rebuilt once a second is a memory leak with a pleasant API.
ThrowsRangeError naming the offending key if any key — or the prefix — is empty or contains
a character that is not valid in a custom-property name.