@harmonics/lattice-ui
v0.19.0
Published
The Harmonics tree's look as Custom Elements: a lattice face, the hexagon sky, raised lattice shapes, a shears cut gesture, four ways around a point, marks and a board of pieces to build on, with its tokens and reading face to take as well. Any page, any
Maintainers
Readme
@harmonics/lattice-ui
The Harmonics tree's look as Custom Elements: a face drawn in the lattice's own triangles, the hexagon sky, raised shapes of cells and triangles, a shears cut gesture, four ways around a held point, marks, and a board of pieces to build on. They work on any page and in any framework (React, Vue, Svelte, Solid, plain HTML) with no build step. Each draws in its own shadow root and carries the tree's colours with it, so it needs nothing else from the page. You bring the state; the elements draw it.
npm install @harmonics/lattice-uiIn five minutes
<script type="module">
import "@harmonics/lattice-ui/define"; // registers every element
</script>
<lattice-face mood="distressed"></lattice-face>
<lattice-shape shape="flower" tone="sage"></lattice-shape>
<hex-sky style="height: 200px"></hex-sky>
<button><lattice-icon name="shears"></lattice-icon> Prune</button>@harmonics/lattice-ui itself defines nothing when imported: call defineLatticeElements() (or
one element's defineLatticeFace(), …) in a browser. Both are safe to import in a server render;
there they do nothing. Every input is both an attribute and a property; a property set does not
change the attribute, so style by class rather than by [mood=…]. A value an input does not know
falls back to its default.
A tag another library already holds is left to it, with a warning. Give the element a tag of your
own instead: defineHexSky("my-sky"), and the same for every defineX.
An element moved on the page (a list reordered) keeps what it was doing; one taken off the page lets go a moment later.
Elements
<lattice-face>
A pale hexagon plate raised off the page with a face on it, in 1,536 of the lattice's triangles.
It idles (breathes, blinks), laughs with relief when pulse goes up, keeping its own brows and
tears and lending itself the laugh's eyes and mouth, and sinks the old face in steps as a new one
rises. Under prefers-reduced-motion (followed as it changes), or still, it holds still and
only changes. Off screen, or in a hidden tab, it does not idle. Each face on a page blinks and
breathes at its own times, so a row of them does not move as one; an idle is a few dozen poses,
each drawn once and kept. Under 72 pixels it is drawn on the coarser lattice (each side cut in 8,
not 16), under 64 flat with bolder lines, and under 128 the plate's own lattice is left out, so a
small face stays a face.
| Input | Type | Default | |
|---|---|---|---|
| mood | happy content asleep sad distressed puzzled relieved (MOODS) | happy | How something feels; each mood wears a face. Use it to say a feeling |
| expression | a face's name from FACES, in any case (happy, content, laughing, sad, surprised, puzzled, angry, worried, sleepy, wink, smitten) | | One face by name, whatever the mood; wins over mood |
| character | elder sprout scamp, in any case | | Who wears it. Needs the characters entry; an unknown one, or none, is the tree's own face |
| pulse | number | 0 | Raise it by one for a laugh of relief |
| still | boolean | false | No idle, no laugh, no fade |
| size | number (pixels) | 96 | Its width; the height follows. Under 72 it draws on the coarser lattice, under 64 flat, under 128 without the plate's |
| label | string | "A happy face" | What a screen reader says |
| decorative | boolean | false | Hidden from screen readers |
const face = document.querySelector("lattice-face");
face.pulse += 1;Characters
import "@harmonics/lattice-ui/characters" adds three characters and five expressions (proud,
bored, determined, shy, skeptical), and is a page's to load: index never does. Until it
arrives, a character or expression it adds falls back to the tree's face and the mood's expression;
a face already drawn then takes it up, so the import can come in any order, or lazily. Every
character wears all sixteen.
| character | Who | Moves |
|---|---|---|
| elder | Dry; has seen it all | Slowly: long holds, a brow lift, a sigh |
| sprout | New here; on alert | Quickly: darting looks, a double blink, a shiver |
| scamp | Up to something | A little ahead of the beat: a twitch of the mouth, a sideways look |
A character idles in small takes, one at a time and different from loop to loop, and acts a change
of expression (a blink, a double take, a slump, a perk, a flinch) in a few steps instead of sinking
the old face. A pulse, still, reduced motion or going off screen stops an act; the tree's own
face keeps its loops and its sink.
<script type="module">
import "@harmonics/lattice-ui/define";
import "@harmonics/lattice-ui/characters";
</script>
<lattice-face character="scamp" expression="proud"></lattice-face><lattice-shape>
A shape made of the sky lattice's own cells and triangles, raised off the page, each part in one of the tree's woods.
| Input | Type | Default | |
|---|---|---|---|
| shape | cell pair line flower triangle diamond trapezoid big-triangle hexagon (SHAPE_NAMES) | cell | |
| parts | Part[], or the same as JSON | | Any shape on the lattice; wins over shape. Set a new array: the same one changed in place is not redrawn |
| tone | a name of TONE_NAMES, or 0–7 | 0 | brown ochre slate plum sage (0–4, the tree's woods), good-fruit bitter-fruit untasted-fruit (5–7), in any case; a part's own tone may be a name too |
| color | any CSS colour | | Draws every part in a colour of your own, a token of your page's among them: its top the colour itself, its sides deeper. Wins over tone |
| lift | number | 8 | How high it stands, in lattice units |
| scale | number | 1 | 1 is the tree's own size: a cell 76 pixels across |
| label | string | | Without one it is hidden from screen readers; when pressable, its name |
| pressable | boolean | false | A button shaped like itself (below) |
| pressed | boolean | unset | Makes a pressable shape a toggle (aria-pressed); the page sets it |
| disabled | boolean | false | A pressable shape that cannot be pressed, out of the tab order |
A part is a cell { kind: "cell", at: { q, r } } on the axial hex grid, or a triangle
{ kind: "tri", at: { i, j, k } } (corner i, j of the triangle grid, k 0 pointing right, 1
left), each with an optional tone.
shape.parts = [
{ kind: "cell", at: { q: 0, r: 0 }, tone: 1 },
{ kind: "tri", at: { i: 0, j: 3, k: 0 }, tone: 5 },
];With pressable it is a button shaped like itself, for a palette of woods, a picker of shapes or
a button that is a shape. It is in the tab order with role="button" and its label as its name
(one without a label warns in the console). A click on its faces, Enter, or Space (on release)
fires press, { shape, parts }. Only its faces take the pointer: a press in the gap round a
hexagon's corner goes to whatever is under it. It stands a little higher under the pointer and
sinks to half its lift while pressed, or while pressed; the keyboard's ring runs round its own
outline, and a pressed one keeps a quiet ring and draws its top deeper in its wood, so a palette
shows which is picked by more than its depth. The shape never presses itself: keep one pressed in a palette from press.
for (const swatch of swatches)
swatch.addEventListener("press", () => {
for (const s of swatches) s.pressed = s === swatch;
});<hex-sky>
The tree's sky, its hexagon lattice drawn faint over it, filling the element. Size it as any block. Decorative.
| Input | Type | Default | |
|---|---|---|---|
| kind | sky earth | sky | Earth: cells broken into triangles, on the earth's colour |
| orient | pointy flat | pointy | Flat: the lattice turned a quarter turn clockwise about its origin, so its cells have flat tops and bottoms |
| scale | number | 1 | The lattice's size |
| x, y | number (pixels) | 0 | Where the lattice's origin sits from the element's top-left, so a sky showing part of a larger one keeps its cells in place |
<lattice-ways>
Four actions around a point, on a pointy-top hex's bearings: up, down, and the two lower vertices. A page shows it while a pointer is down. What a bearing does is the page's.
Place the host on the point (a zero-size element there, or any box, whose centre is the point) and
set pointer to the pointer that is already down. The element captures it. With no pointer set, a
press on the element starts one. Cells flip inward along their bearing when they would leave the
viewport. A release inside the dead zone, or a pointer that is cancelled, selects nothing. There
is no travel, so reduced motion changes nothing. The drag is not the only way: the page keeps a
control for each action (WCAG 2.5.7).
| Input | Type | Default | |
|---|---|---|---|
| up-label, down-label, down-left-label, down-right-label | string | Up, Down, Down-left, Down-right | The name on the outer side of that cell |
| up-icon, down-icon, down-left-icon, down-right-icon | an icon name | count, highlight, next, search | The mark in that cell |
| pointer | number | -1 | The pointer already down. -1 waits for a press on the element |
| Event | detail | |
|---|---|---|
| way | { bearing } | The pointer went up on a bearing: up, down, down-left, down-right |
| wayend | { bearing? } | Every release. bearing is omitted when nothing was chosen |
ways.pointer = event.pointerId;
ways.addEventListener("way", (e) => act(e.detail.bearing));
ways.addEventListener("wayend", () => close());LatticeWaysEventMap types these on the element's own addEventListener.
<shears-pad>
The cut gesture. A drag across it draws the blade's fading trail and reports the stroke; what it cuts is yours to decide. Put it over what can be cut and size it as any block.
| Event | detail | |
|---|---|---|
| stroke | { points, point, fresh } | At every move; point is the newest, fresh on a stroke's first |
| strokeend | { points, cancelled } | Once per stroke: lifted, or cancelled when cut short (the pointer cancelled or lost, disabled set, the pad taken off the page) |
Points are in the element's own pixels from its top-left. disabled stops it taking strokes.
The keyboard cuts too, so a drag is not the only way (WCAG 2.5.7): the pad is in the tab order,
the arrows move a blade marker (Shift for small steps), Space or Enter starts a cut there and ends
it, and Escape cuts it short. The same stroke and strokeend come as from a pointer. It names
itself to a screen reader by those keys (role="application"). A page's own tabindex, role
and aria-label are kept: tabindex="-1" takes it out of the tab order, for a page that gives the
cut a button of its own.
pad.addEventListener("stroke", (e) => cutAlong(e.detail.points));
pad.addEventListener("strokeend", (e) => (e.detail.cancelled ? undo() : commit()));ShearsPadEventMap types these on the pad's own addEventListener. To find what a stroke cut,
see Cutting.
<lattice-icon>
One of the tree's marks (ICON_NAMES), in the text's colour, a step square
(--lattice-icon-size to change it). Hidden from screen readers unless it has a label. Every
mark is drawn from the lattice's pieces: hexagons, triangles and diamonds, every line upright or
at 30°. Under 24 pixels (the default step is 16) shears, bin and name draw a small drawing
with less detail, and every mark draws in heavier lines; the element picks by its own size.
| name | | Parts |
|---|---|---|
| shears (default) | two hexagon handles, two diamond blades crossing | shears-handle, shears-blade |
| done | a check in a hexagon | done-cell, done-check |
| note | a hexagon with two lines of writing | note-cell, note-line |
| turn | a hexagon open at one side, a triangle pointing on round (R) | turn-path, turn-arrow |
| glue | two cells sharing a heavy side (G) | glue-cell, glue-seam |
| apart | two cells a wide gap apart (Shift+G) | apart-cell |
| bin | a cup under a diamond lid (Delete) | bin-can, bin-rib (not under 24 pixels), bin-lid |
| name | a pencil rising at 30°, a triangle for its point | name-body, name-point, name-band (not under 24 pixels) |
| split | a triangle with its middle turned over, the Sierpinski step | split-whole, split-hole |
| search | a hexagon lens, a strip for its handle | search-lens, search-handle |
| previous | a lattice triangle pointing left | previous-arrow |
| next | a lattice triangle pointing right | next-arrow |
| close | two strokes crossing at 30° | close-stroke |
| highlight | a marker, its nib a triangle, over the stroke it lays down | highlight-body, highlight-nib, highlight-stroke |
| count | four tally strokes struck through by a fifth | count-tally, count-strike |
| bet | ב, a hexagon open on its left, its base running on past the upright | bet-letter |
| harmonics | a hexagon, its right-hand triangle broken down into a hole and three corners | harmonics-cell, harmonics-spoke, harmonics-hole |
turn, glue, apart and bin are <lattice-board>'s keyboard actions, for a page that gives
each a button; name, split and bin are the Harmonics grid's own controls; previous, next,
close, highlight and count are a reader's (the Harmonics Tanakh reader's bars and word sheet);
bet, the Torah's first letter, is the Tanakh reader's own mark, on its icon and its picker;
harmonics is the family's, on every other Harmonics page's icon.
Board
<lattice-board> is a surface of the lattice's pieces a person builds with, the way the Harmonics
tree's Build mode does. It is an entry of its own, so a page without one loads none of it.
import { defineLatticeBoard } from "@harmonics/lattice-ui/board";
defineLatticeBoard();
const board = document.querySelector("lattice-board");
board.pieces = [
{ id: "a", parts: [{ kind: "cell", at: { q: 1, r: 1 }, tone: 0 }, { kind: "cell", at: { q: 2, r: 1 }, tone: 0 }] },
{ id: "b", parts: [{ kind: "tri", at: { i: -6, j: 16, k: 0 }, tone: 5 }] }, // a triangle of cell (7, 1)
];
board.addEventListener("change", (e) => save(e.detail.pieces));<lattice-board tray style="height: 400px"></lattice-board>Drag a piece and the lattice pulls it in: near a slot it leans in and the slot lights, nearer it is caught and held, and let go it clicks into the slot, or goes to the nearest place it fits. A piece with the focus (Tab) steps with the arrows; R and Shift+R (or a double-click) turn it a sixth; G glues it to every piece it shares a side with (the sides glow first); Shift+G breaks it into its units; Alt-drag pulls one part off; Delete throws it away. Under reduced motion a carried piece follows the pointer exactly and nothing animates.
| Input | Type | Default | |
|---|---|---|---|
| pieces | { id?, parts, pivot? }[], or the same as JSON | | What the board starts from; read back, its own pieces now (each { id, parts, pivot }). Set it again, as a new array, to replace them; hand back what was read and each piece keeps its pivot, so it turns as it did. An id made for a piece without one never matches one the page gave |
| fixed | { id?, parts }[] (property) | | Drawn flat and in the way, never handled, glued to or handed back. Setting it again replaces only the fixed pieces: the others stay where the person put them. A fixed piece stays where the page put it, even off a board too narrow for it: place it for the smallest board |
| tray | boolean | false | Two cells at the bottom left: drag out a new cell or triangle (or Enter on one), drop a piece there to throw it away: the tray turns the alert colour and shows the bin while one is over it |
| scale | number | 1 | 1 is the tree's own size: a cell 76 pixels across |
| label | string | "A board of lattice pieces" | What a screen reader calls it |
| Event | detail | |
|---|---|---|
| change | { pieces, action, id } | After each thing done: action is move, turn, glue, break, pull, add or remove; id the piece acted on ("" when a resize moved pieces back onto the board). Cancelable: preventDefault() undoes it, except the resize's, which is not. It bubbles out of the board as a native change does: a form's own change listener round a board hears it too |
| catch | { id } | A slot caught the carried piece: a page's own sound goes here |
The board fills the element; size it as any block. Its lattice starts at its top-left, so a
<hex-sky> of the same size and scale under it shows the slots. LatticeBoardEventMap types the
events on the board's own addEventListener. Pieces are the Pieces of
Geometry: a page can lay them out with those functions before handing them over.
Styling
Every element carries the tree's colour tokens, light and dark (it follows
prefers-color-scheme). Set any of them on the element itself to change it:
lattice-face, lattice-shape {
--color-tree-ink: #222;
--color-tree-raised: #fffdf6;
}Set them on the elements, not on body: an element's own tokens sit nearer than an ancestor's.
To force a scheme whatever the system's, give an element data-theme="light" or "dark"; with
the package's tokens.css (below), data-theme on the page's root does the same for the page.
The ones the elements use:
| Token | Used for |
|---|---|
| --color-tree-sky | the sky, and what woods and plates are mixed with |
| --color-tree-earth | the earth sky |
| --color-tree-ink | the face's features |
| --color-tree-raised | the face's plate, a glint, untasted fruit |
| --color-tree-wood-0 … --color-tree-wood-4 | the five woods; wood-0 is also the plate's edge |
| --color-tree-olive | fruit |
| --color-tree-alert | blush, the tongue, the blade's trail |
| --color-tree-lattice-sky, --color-tree-lattice-earth | the lattice lines |
| --tree-rule-width, --tree-rule-thick-width | edges, seams, the blade |
| --space-tree-2, --space-tree-4 | how far a face sinks; an icon's size |
Each element also exposes its drawing as a part: ::part(face), ::part(shape), ::part(sky),
::part(trail), ::part(icon), ::part(board), and a shape's focus ring ::part(ring). A
mark's own paths are parts too (see <lattice-icon>), for a hover that recolours them:
lattice-icon::part(done-cell).
Tokens and fonts
The elements need nothing from the page: each carries the tree's tokens inside it, and draws no text, so it needs no font. For a page that wants to set its own words and boxes in the tree's values, the package also ships the tokens and the reading face, each loaded only when imported:
import "@harmonics/lattice-ui/tokens.css"; // every token of the tree, on :root, light and dark
import "@harmonics/lattice-ui/fonts.css"; // Atkinson Hyperlegible Next, upright and italicbody { font: var(--font-body); background: var(--color-tree-sky); color: var(--color-tree-ink); }tokens.css is the tree's DESIGN.md as custom properties: the colours (--color-tree-*, with
their dark values under prefers-color-scheme: dark), the type (--font-body and its sizes), and
the spacing and widths (--space-tree-*, --tree-*-width). The --font-tree-* tokens name the
tree's monospace faces, Roboto Mono and Victor Mono, which the package does not ship: without
them a page gets its system monospace there.
fonts.css declares Atkinson Hyperlegible Next (the Braille Institute's face, made so that
easily confused letters stay distinct) as variable @font-faces with unicode-range, so a
browser fetches only the files its letters need (the Latin file is about 34 KB). To take the
upright alone, import its own sheet:
import "@harmonics/lattice-ui/fonts/atkinson-hyperlegible-next/index.css";The face is under the SIL Open Font License 1.1; its LICENSE ships beside its files in
fonts/atkinson-hyperlegible-next/. The files are the ones @fontsource-variable publishes.
Frameworks
- React 19: use the tags directly (define the board before it renders, as the others);
add
import type {} from "@harmonics/lattice-ui/react"for their props. React 19 sets inputs as properties, soparts={[…]}works, and listens withonstroke/onstrokeend. Define the elements before React first renders them: until then React cannot see the property and setspartsas the attribute"[object Object]", which draws nothing (the shape warns once in the console when that happens). - Solid:
import type {} from "@harmonics/lattice-ui/solid"; set objects withprop:parts; listen withon:stroke. - Vue: tell the compiler the tags are custom elements
(
compilerOptions.isCustomElement: (tag) => /^(lattice|hex|shears)-/.test(tag)); bind objects with.prop(:parts.prop="parts"); listen with@stroke. - Svelte, Angular, plain HTML: as any custom element.
TypeScript knows the tags from HTMLElementTagNameMap once the package is imported.
Geometry
@harmonics/lattice-ui/hexes is the lattice's own geometry, for a page that places pieces on the
sky itself (a board, a puzzle, an editor). Pure functions, no DOM, safe in a server render.
import { pieceOf, moved, snapDelta, fits, nearestFit, screenArea } from "@harmonics/lattice-ui/hexes";
const piece = pieceOf("a", [{ kind: "cell", at: { q: 2, r: 1 }, tone: 1 }]);
const carried = moved(piece, snapDelta(piece, dx, dy)); // follow a pointer, snapped to whole cells
const placed = fits(carried, others, screenArea(width, height))
? carried
: nearestFit(carried, others, screenArea(width, height));The sky is pointy-top hexagons 44 on a side: a cell is 76.21 across, rows are 66 apart, and each
row starts half a cell further right. A cell is { q, r } (axial); its box's top-left is
boxOf(hex). Split into triangles, the sky is also a grid of corners { i, j } (cornerOf), each
the top-left of two triangles { i, j, k }: k 0 points right, 1 left. A piece is
{ id, parts, pivot }; make one with pieceOf. A piece with a cell moves a whole cell at a time.
To lay a <lattice-shape> exactly on a <hex-sky> of the same scale (s), give the shape the
piece's parts and place it at s × (minX − 2, minY − lift − 2), the top-left of every corner it
covers (covered, cornersOf, cornerOf), less the shape's padding and lift.
Glue
Pieces that share a side (not only a corner) can be glued into one; each part keeps its tone, so
a glued piece still shows its woods, and it moves and turns as one. The functions that split a
piece take id, your own generator of new piece ids.
import { apart, glued, pulledOff, seamsOf, sharedSides, turned, unitAt } from "@harmonics/lattice-ui/hexes";
const sides = others.flatMap((o) => sharedSides(piece, o)); // where G would glue: draw them lit
const { piece: one, absorbed } = glued(piece, others); // G: drop the absorbed ids from your list
const seams = seamsOf(one); // the sides between its parts, to draw as hairlines
const units = apart(one, newId); // Shift+G: every part a piece of its own
const [part, ...rest] = pulledOff(one, index, newId); // one part off; the rest stay joined
const turnedOnce = turned(one, 1); // a sixth clockwise about its pivot (pivotPoint(one) on screen)
const fresh = unitAt("tri", x, y, tone, newId()); // a new cell or triangle under a pointSides and seams come as pairs of screen points, in the sky's own pixels. Glued pieces are drawn
with <lattice-shape> like any other: give it the piece's parts.
Magnet
A carried piece is pulled into the sky's lattice. Near a slot it leans in. Nearer, the slot
catches it and it is drawn there exactly. It stays held until it is tugged past a release
distance, which is further out than the catch, so it never flickers at the edge. MAGNET holds
the four distances, as parts of the piece's unit: reach, capture, release and lean.
import { magnetOf, moved, snapFrames, stepOnScreen } from "@harmonics/lattice-ui/hexes";
let held; // the slot it is held in, handed back on every move
onPointerMove((dx, dy) => {
const m = magnetOf(piece, dx, dy, held);
held = m.held;
draw(piece, m.x, m.y); // where it is drawn, as a move from where it was lifted
glow(m.step, m.pull); // the slot it would land in, and how hard it pulls (0 to 1)
});
onPointerUp((dx, dy) => {
const m = magnetOf(piece, dx, dy, held);
const slot = stepOnScreen(m.step); // the slot's move on the screen
piece = moved(piece, m.step);
// The click: from where it was let go, past the slot once, to rest in it.
element.animate(snapFrames({ x: m.x - slot.x, y: m.y - slot.y }), { duration: 260 });
});Under reduced motion, draw the piece in its slot and leave out the animation.
Cutting
What a <shears-pad> stroke went through. A stroke goes through an outline when it goes in at one
side and out at another; one that only grazes a corner, or stops inside, does not.
import { crossesCell, crossesLine, crossesRing } from "@harmonics/lattice-ui/hexes";
pad.addEventListener("stroke", (e) => {
const stroke = e.detail.points; // in the sky's pixels when the pad covers the sky at scale 1
const isCut = (part) => part.kind === "cell" && crossesCell(stroke, part.at);
const cut = pieces.filter((p) => p.parts.some(isCut));
const severed = crossesLine(stroke, branch); // through a line of points
const through = crossesRing(stroke, outline); // through any closed outline
});crossesCell(stroke, hex, lift) takes the cell as drawn lift above its place.
Server rendering
Importing either entry on a server does nothing and throws nothing. Render the tags as markup; they draw when the page defines them in the browser.
Licence
MIT. The package bundles Solid (MIT).
