API reference · layer 3

@latticekit/ui

DOM overlay primitives — a declarative element builder, panels, toasts, number rolls, and thumbnails rendered from the draw kit. Deliberately not a framework.

exports48 symbols in 10 modules
depends on@latticekit/core, @latticekit/draw
environmentbrowser
gzipped8.72 kB against a 12 kB budget
sourcepackages/ui · README · index.d.ts

@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 linethe decision
createOverlaythe root is pointer-events: none inline, and there is no stylesheet — so a tap that is not on a node you named reaches the world
rollthe number animates on paint and is correct on update: if render never runs, the text is still right
ui.everythe state cadence is the loop's update. This package starts no timer and no rAF loop
drivethe pairing it is fatal to cross is a function body, not a comment
formatformatting comes from @latticekit/core. This package has no fmt and never will

The two cadences, which is the whole design

ui.every / tick()ui.paint / repaint()
driven bythe loop's update — wall timethe loop's render — rAF
in a hidden tabruns0 Hz
put hereanything whose absence makes the HUD wronganything whose absence makes it plainer

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.

classon
lattice-uithe overlay root
lattice-layer, lattice-layer-floats / -panels / -modal / -toaststhe four layer containers
lattice-panel, lattice-panel-modala panel wrapper
lattice-ack, lattice-ack-title, lattice-ack-body, lattice-ack-confirmthe four nodes acknowledge builds
lattice-scrimthe modal scrim
lattice-toast, lattice-toast-plain / -good / -bad, lattice-toast-bara toast and its life bar
lattice-rolla roll's default node
lattice-float, lattice-float-gain / -loss / -plaina floating number

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".

What it promises

  • No virtual DOM. The whole overlay is a few dozen nodes that change a few times a second.
  • The package ships no stylesheet at all. The root is inline pointer-events:none and interactivity is granted per node, so the specificity fight that swallows taps on the world has no rule to lose to.
  • State updates on the interval cadence, never inside the render callback. If render never runs, every number on screen is still right.
  • Anything that is not painting must survive a hidden tab. The loop's update callback IS the interval — a second setInterval here recreates the poll-races-settle data-loss bug.

Verbatim from .lattice/kit.json, which npm run lint keeps in step with the code.

overlay9 symbols

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.

Dispose type ↳ src/overlay.ts:43

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.

LayerName type ↳ src/overlay.ts:52

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.

OverlayOptions interface ↳ src/overlay.ts:63

interface OverlayOptions {

How an overlay is created.

5 members
readonly now: () => number

Time, 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?: HTMLElement

Where 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?: number

Only with driver: 'standalone'. Default 1000. @throws RangeError if set in 'driven' mode, which would be a cadence nobody reads.

readonly zIndex?: number

Stacking against your canvas. Default 1, which is right when the canvas has none.

MountOptions interface ↳ src/overlay.ts:95

interface MountOptions {

How one node joins the overlay.

2 members
readonly layer?: LayerName

Default 'panels'.

readonly interactive?: boolean

Opt 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.

Overlay interface ↳ src/overlay.ts:160

interface Overlay {

The overlay: a root, four layers, two cadences and a teardown.

9 members
readonly root: HTMLElement

The overlay root. Pointer-transparent, position: fixed; inset: 0, and never transformed.

readonly modalOpen: boolean

True while any modal panel is open. Hosts use it to park world-space controls.

layer(name: LayerName): HTMLElement

The container for a layer, if you need to style or measure it. Do not reparent it.

mount<T extends HTMLElement>(node: T, opts?: MountOptions): T

Put 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.

Throws

Error 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): Disposer

Register 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): Disposer

Register 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): void

Advance 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.

Throws

Error, naming the mistake, if the overlay was created with driver: 'standalone'.

Throws

RangeError if nowMs is given and is not finite.

repaint(nowMs?: number): void

Advance the paint cadence. Call this from your loop's render. Bound, like tick.

destroy(): void

Remove the root, cancel anything the standalone driver started, drop every listener this package added, and destroy every widget bound to this overlay. Idempotent.

createOverlay function ↳ src/overlay.ts:264

function createOverlay(opts: OverlayOptions): Overlay

Build an overlay: a pointer-transparent root, four layers, and two cadences that nothing advances until you do.

Throws

TypeError 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.

Throws

RangeError if driver is not one of the two names, if standaloneMs is set outside standalone mode, or if standaloneMs / zIndex is not finite.

Driven interface ↳ src/overlay.ts:501

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): Disposer

Subscribe to the state cadence. Must return a disposer that unsubscribes.

onRender(fn: () => void): Disposer

Subscribe to the paint cadence. Must return a disposer that unsubscribes.

drive function ↳ src/overlay.ts:528

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.

Throws

TypeError 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.

Throws

Error 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.

auditOverlay function ↳ src/overlay.ts:604

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":

  1. 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.
  2. 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.

cadence1 symbol

The two cadences, with the DOM taken out.

Pure: two subscriber lists and a dispatcher. It is its own module because the thing worth proving about this package — state advances on update and never on render — is a property of these two lists, and proving it should not require a browser.

listfed byruns whenmay hold
everythe loop's updatewall time, hidden tab includedanything whose absence makes the HUD wrong
paintthe loop's renderrAF: 0 Hz hidden, throttled, skipped under loadanything whose absence makes the HUD plainer

There is no third list and no way to register into paint by accident, because the failure being designed out is not a crash: it is a HUD that looks alive in a background tab — the canvas still showing its last painted frame — while its prices, its affordability marks and its build timers froze twenty minutes ago.

CadenceFn type ↳ src/cadence.ts:24

type CadenceFn = (nowMs: number) => void

Work registered on a cadence. The argument is wall-clock milliseconds from the overlay's injected clock — never a delta, and never requestAnimationFrame's own timestamp.

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.

Attrs type ↳ src/el.ts:26

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.

Child type ↳ src/el.ts:32

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.

el function ↳ src/el.ts:55

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.

Throws

TypeError 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.

clear function ↳ src/el.ts:112

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.

setText function ↳ src/el.ts:131

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.

show function ↳ src/el.ts:153

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.

hide function ↳ src/el.ts:164

function hide(node: HTMLElement): void

Hide, inline. show(node, false), named for the call site that reads better.

pulse function ↳ src/el.ts:183

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.

interactive function ↳ src/el.ts:201

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.

passthrough function ↳ src/el.ts:213

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.

panel5 symbols

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.

PanelOptions interface ↳ src/panel.ts:26

interface PanelOptions {

How a panel behaves. Nothing here describes how it looks.

4 members
readonly modal?: boolean

A modal gets a scrim, traps focus, sets role="dialog" and blocks the world. Default false.

readonly dismissible?: boolean

Scrim click and Escape close it. Default true. A confirmation that must be answered sets false.

readonly layer?: LayerName

Default 'panels', or 'modal' when modal is true.

readonly onClose?: () => void

Called 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.

Panel interface ↳ src/panel.ts:41

interface Panel {

An open-and-closable region of the overlay.

6 members
readonly node: HTMLElement

Your 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: boolean

Whether it is currently on screen.

open(): void

Open 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(): boolean

Open 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(): void

Close it, restoring focus to whatever had it when the panel opened. Idempotent.

destroy(): void

Close it and remove its nodes. Idempotent, and safe to call after ui.destroy().

panel function ↳ src/panel.ts:116

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.

Throws

Error if the overlay has already been destroyed.

AcknowledgeOptions interface ↳ src/panel.ts:235

interface AcknowledgeOptions {

What an acknowledgement says. Three fields, because a fourth is a dialog system.

3 members
readonly title: string

Short. It is the line the player reads before deciding whether this matters.

readonly body: string | Node

The 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?: string

The 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.

acknowledge function ↳ src/panel.ts:287

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.

Throws

TypeError if title is not a string or body is neither a string nor a node.

Throws

Error if the overlay has already been destroyed.

toast4 symbols

The game talking to the player, briefly.

Four decisions here are not cosmetic, and each of them is a bug the source game shipped:

decisionwhat it prevents
duration scales with length3.2 s is fine for "+40 MW" and theft for a sentence, and the toasts carrying real information are exactly the long ones
it holds while hoveredsomebody reading a toast is the one person who must not lose it
a tap dismisses earlythe alternative to reading it is waiting it out, which nobody does
expiry runs on the state cadencea tab hidden for a minute comes back with the backlog already gone, not with forty toasts to dismiss

Toasts live in the topmost layer, above the scrim: a message that lands under a modal has not been shown, it has been lost, and holding it in a queue instead means the queue has to be drained by somebody.

ToastKind type ↳ src/toast.ts:25

type ToastKind = 'plain' | 'good' | 'bad'

How a toast reads. Three, because a fourth needs a color convention and this package holds no opinion about what red means.

ToastOptions interface ↳ src/toast.ts:28

interface ToastOptions {

How a toast host behaves.

3 members
readonly max?: number

Never more than this many on screen; the oldest is dropped. Default 3 — a wall of toasts hides the game they are about.

readonly minMs?: number

Floor for how long one lives. Default 7000.

The source game shipped 3200 and it was wrong: long enough for "+40 MW" and nowhere near enough for a sentence.

readonly msPerChar?: number

Added per character on top of minMs, at roughly a slow reading pace. Default 55, which is about 220 words per minute at five characters a word — deliberately slower than a reader who is looking at the toast, because the player is looking at the game.

ToastHost interface ↳ src/toast.ts:47

interface ToastHost {

A place toasts appear. One per overlay is normal; more than one is a game that has decided two regions of the screen mean different things.

4 members
show(text: string, kind?: ToastKind): void

Show one.

Throws

TypeError if text is not a string.

once(key: string, text: string, kind?: ToastKind): boolean

Show one at most once per key for this session, and say whether this call was the one that showed it.

The case that named it, from @latticekit/persist: storage may be non-persistent — private browsing, a quota-constrained device, a user who has blocked site data — and the autosave rediscovers this every thirty seconds for the rest of the session. Shown every time, "your browser will not keep this save" becomes furniture: the player learns the shape of a toast and dismisses it unread, and the next one, which mattered, goes with it. A notice that repeats is worse than no notice, because it trains the dismissal.

key must name the condition, not the message: 'storage-not-persistent', never the rendered text. persist exposes store.status as a bare union member for exactly this. A text carrying a detail — a timestamp, a byte count, an attempt number — changes on every discovery and defeats a latch keyed on it, which is a deduplication that silently stops deduplicating in exactly the case it was written for.

The scope is this session and this host, in memory. "Once ever, across reloads" is a boolean in your saved state, and @latticekit/persist owns saved state: if (!save.warnedAboutStorage) save.warnedAboutStorage = toasts.once('storage-not-persistent', …).

Throws

TypeError if key is not a non-empty string.

clear(): void

Remove every toast on screen now. Does not reset the once latches: those name conditions the player has already been told about, and clearing the screen is not the player forgetting.

destroy(): void

Remove everything and unsubscribe. Idempotent.

toasts function ↳ src/toast.ts:118

function toasts(ui: Overlay, opts?: ToastOptions): ToastHost

A toast host bound to an overlay.

Expiry is registered on the overlay's state cadence, never on paint. Web Animations do not run in a hidden tab, so an onfinish-driven expiry never fires there and a player returning after a minute finds the whole backlog waiting; the animation here is the life bar only, and the clock that removes a toast is ui.every.

Throws

RangeError if max is below 1, or if minMs / msPerChar is negative or not finite.

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.

RollOptions interface ↳ src/roll.ts:20

interface RollOptions {

How a roll behaves.

4 members
readonly node?: HTMLElement

Where 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) => string

Default 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?: number

Roll 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?: string

Pulsed on every settled change. Default 'bump'. Pass '' to disable, which also skips the forced layout pulse needs.

Roll interface ↳ src/roll.ts:43

interface Roll {

A number that eases to its target.

5 members
readonly node: HTMLElement

The element the text is written into.

readonly value: number

The 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): void

Set 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.

Throws

RangeError 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(): void

Land 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(): void

Unsubscribe from both cadences. The node is left where you put it. Idempotent.

roll function ↳ src/roll.ts:76

function roll(ui: Overlay, opts?: RollOptions): Roll

A number that eases to its target on the paint cadence.

Throws

RangeError if ms is negative or not finite.

ScreenPoint interface ↳ src/roll.ts:202

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: number

CSS pixels from the left of the viewport.

y: number

CSS pixels from the top of the viewport.

FloatOptions interface ↳ src/roll.ts:210

interface FloatOptions {

How a float host behaves.

3 members
readonly capacity?: number

How 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?: number

Lifetime in ms. Default 900.

readonly project?: (anchorX: number, anchorY: number, out: ScreenPoint) => void

Re-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.

FloatHost interface ↳ src/roll.ts:234

interface FloatHost {

A pool of floating numbers.

2 members
spawn(anchorX: number, anchorY: number, text: string, kind?: FloatKind): void

Spawn 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.

Throws

RangeError 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(): void

Remove the pool. Idempotent.

floats function ↳ src/roll.ts:293

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.

Throws

RangeError if capacity is below 1 or ms is not positive.

thumb3 symbols

The one real bridge from @latticekit/draw to the DOM.

It draws nothing itself. It hands you a Surface — the same interface the world is painted through — and turns what you painted into a data: URL for an <img src>, cached by a key you choose. One code path for the building in the world and the building on the shop card is what stops the two from ever drifting apart.

Two failures are designed out rather than documented:

  1. The key does not name the brand hue, and setBrand invalidates. The source game keyed on ${id}|${brand}|${w}x${h} into an unbounded Map of data: URL strings, which never went stale and also grew without limit for a player who enjoyed the color picker. Keying on the brand is the fix for staleness and the cause of the leak; inverting it fixes both.
  2. The device pixel ratio is clamped to 2. A 3× phone painting a 240×140 card allocates a 720×420 canvas and nine times the fill per shop item; twelve cards is a visible stall on the frame the shop opens, on exactly the hardware least able to absorb it.

ThumbSpec interface ↳ src/thumb.ts:26

interface ThumbSpec {

What to paint, and how big.

5 members
readonly width: number

CSS pixels. The backing canvas is this times the effective dpr.

readonly height: number

CSS pixels.

readonly dpr?: number

Device pixel ratio, clamped to [1, 2]. Default: the window's, clamped. Pin it to 1 in a test and the bytes are identical across machines.

readonly background?: string

Painted before paint runs. A #rgb, #rrggbb or #rrggbbaa string — @latticekit/draw's color model parses those and nothing else, and a second parser here would be this package holding a second opinion about what a color is. Default: transparent.

readonly paint: (surface: Surface, width: number, height: number) => void

Draw the thumbnail. The same Surface the world is drawn with, already scaled for dpr, so width and height are CSS pixels.

This must be deterministic: same key, same pixels. If your sprite jitters, seed it from an Rng you construct here from the key, or hard-code the jitter — a card whose building leans a different way on every reload is a card that makes the shop look broken.

ThumbCache interface ↳ src/thumb.ts:52

interface ThumbCache {

A bounded, keyed set of painted thumbnails.

4 members
url(key: string, spec: ThumbSpec): string

A data: URL for <img src>, painted once per key.

The key must name everything that changes the pixels — the building id, its level, and the size. It must not name the brand hue: setBrand invalidates every cache on the overlay for you.

Throws

TypeError if key is empty.

Throws

RangeError if width, height or dpr is not finite and positive.

Throws

Error, from @latticekit/draw, if the host cannot give the canvas a 2D context.

invalidate(): void

Drop everything. Called for you by setBrand.

readonly size: number

How many keys are held. Never above capacity.

destroy(): void

Drop everything and unregister from the overlay. Idempotent.

thumbnails function ↳ src/thumb.ts:86

function thumbnails(ui: Overlay, capacity?: number): ThumbCache

A bounded thumbnail cache bound to an overlay.

Least-recently-used eviction at capacity, default 64. Bound to the overlay so that setBrand can invalidate it and ui.destroy() can drop it: a cache that outlives its overlay is a megabyte of strings pinned by nothing anybody can name.

Throws

RangeError if capacity is below 1.

theme6 symbols

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.

BrandOptions interface ↳ src/theme.ts:21

interface BrandOptions {

How a brand hue is turned into a color.

2 members
readonly saturation?: number

HSL saturation for the derived color, 0..1. Default 0.72.

readonly lightness?: number

HSL lightness for the derived color, 0..1. Default 0.62.

Palette type ↳ src/theme.ts:68

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.

PaletteOptions interface ↳ src/theme.ts:71

interface PaletteOptions {

How a palette is namespaced on the root.

1 member
readonly prefix?: string

Custom-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.

setBrand function ↳ src/theme.ts:119

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.

Throws

RangeError 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.

setTokens function ↳ src/theme.ts:145

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.

Throws

RangeError 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.

applyPalette function ↳ src/theme.ts:207

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:

  1. 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.
  2. 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.
  3. 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.
Throws

RangeError 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.

index1 symbol

dispose1 symbolre-exported from @latticekit/core

One teardown vocabulary for the whole kit.

Before this module, five packages had each invented their own: input returned disposers from a scope, ui from interactive, loop from subscriptions, persist from store handles, audio from buses. A game tearing down a scene had to remember all five, and the one it forgot was a listener that stayed live — invisible for an hour, then the tab is using two gigabytes. Everything in Lattice that binds something now hands back a Disposer, and anything with a lifetime owns a Scope.

There is exactly one ordering rule — reverse registration order — because child() registers the child's dispose into the parent's own list. "Children before parent" is not a second rule to remember; it falls out of the first.

Tier A: no clock, no platform, no allocation beyond the disposer list itself.

Disposer type ↳ src/dispose.ts:30

type Disposer = () => void

Undo one thing.

Idempotent by contract. Calling a disposer twice must be safe and must not undo something else. The failure this rule exists to prevent: a handle is released, its slot is reused by somebody else, and the second call to the stale disposer releases their handle — a bug that presents as a completely unrelated subsystem losing its subscription. Every disposer the kit returns satisfies it, and every disposer a game writes is expected to.

A Scope calls each disposer exactly once, so idempotence is not for the scope's benefit: it is for the caller that also holds the disposer directly and disposes early.