Pulliv0.1
8px
07 — Components

Nothing here has a hardcoded number.

Thirteen components. Each is documented with its anatomy in lattice terms, its full state set, and the accessibility contract it must satisfy. A component is valid only when its entire box model can be expressed in idai, valai and kambi.

The contract

What every component in this library guarantees, and what it requires of you.

Every component guarantees

  • Full derivation. Every dimension is a semantic token. Change the pitch and it re-derives correctly.
  • Both themes. No component references a palette value directly, so both themes work without per-component overrides.
  • A visible focus state at 2px with 2px offset, never removed, never replaced by a colour change alone.
  • Target size. Interactive elements meet 44px, including at compact density.
  • Reduced motion. All transitions collapse to instant under the media query.

Every component requires

  • A programmatic name. Icon-only controls need aria-label; the library will not invent one.
  • State in the markup. aria-selected, aria-expanded, aria-pressed and hidden are your responsibility; the CSS styles them, it does not set them.
  • Text alongside colour. Status colour is reinforcement, never the sole carrier of meaning.
  • One emphasis per view. See principle 03.

Button

Four variants, three sizes. The variant carries priority; the size carries context.

height max(5.5P, 44px) · pad-x 3P · radius 2r · gap 1P
sm 4P · md max(5.5P, 44px) · lg max(7P, 48px)

Anatomy

  • Height (md)max(5.5 P, 44px)
  • Horizontal padding--space-inset-md
  • Icon gap--idai-1
  • Radius--radius-control
  • Border--border-hairline
  • Label size--ezhuthu-small
  • Focus ring--border-edge
  • Press offset--border-hairline

States

StateTreatment
Rest--color-accent
Hover--color-accent-hover
ActivetranslateY(1px), 120ms vidu
Focus2px ring, 2px offset
Disabledopacity .45, pointer-events none
Loadingaria-busy, label retained, spinner replaces icon
Do

One primary per view. Secondary and ghost carry everything else. If two actions feel equally primary, the view is asking two questions and should be split.

Don't

Use btn-danger for anything reversible. Danger is reserved for actions with no return path — which principle 02 already says should be rare.

Do

Keep the label on a loading button and set aria-busy="true". The user needs to know what is loading.

Don't

Replace the label with a spinner. The button changes width, the layout jumps, and screen reader users lose the action name.

Field

Text, select and textarea share one anatomy so a form reads as one rhythm.

Odd numbers only — a kolam needs a centre dot.
Seed must be a whole number between 1 and 9999.
height max(5.5P, 44px) · pad 1P/2P · radius 2r · label gap 1P · field gap 3P

Anatomy

  • Label to input--space-stack-tight
  • Input to help--space-stack-tight
  • Field to field--idai-3
  • Border--color-border-strong
  • Focus glow2px --color-accent-subtle
  • Textarea min height14 P

Label-to-input is one step; field-to-field is three. The proximity rule from Lattice → spacing requires at least one full step of separation between the two.

Requirements

  • Always a visible label. Placeholder text is not a label — it disappears exactly when the user needs it.
  • Errors are text. Red border plus aria-invalid plus a message that says how to fix it, wired with aria-describedby.
  • Required is marked in text or in the label, not by colour alone. The asterisk is aria-hidden; required carries the semantics.
  • Border is --color-border-strong, which clears 3:1 against the ground. A field boundary is a non-text UI component and is held to that.
Do

Validate on blur and again on submit. Tell the user what to do, not what they did wrong.

Don't

Validate on every keystroke. The field turns red while the user is still typing the value that would have been valid.

Choice

Checkbox, radio, switch. Different questions, not different styles of the same control.

box 2.5P · row min 44px · switch 6P × 3P · local pitch 3P · loop r 1.5P · travel 3P · 240ms neli

The switch is a kolam

The control is a two-dot lattice at a local pitch of 3P, with the loop radius at 1.5P — exactly half that local pitch. The meeting condition r = P/2 therefore holds inside the component as well as across the system.

Off is the line looping the left dot; on is the same loop having travelled to wrap the right one. The travel is a neli — the turn a kolam line makes around a dot — which is why it uses the symmetric curve rather than a decelerating one. The loop is hollow, so the dot it wraps stays visible inside it: that is the difference between a line curving around a dot and a blob covering one.

State is carried by loop position first and colour second, so it survives greyscale and colour-blindness without a second indicator. Full rationale in Motion → the toggle.

  • Box6 P × 3 P
  • Local pitch3 P
  • Loop diameter3 P
  • Loop radius1.5 P = local P/2
  • Loop stroke--border-edge
  • Pulli diameter0.5 P
  • Kambi length3 P, dot to dot
  • Travel3 P
  • Easing--motion-reveal
  • Row target--alavu-md

Both are the line, using different moves

Radio and checkbox stay distinguishable — round versus square, exclusive versus independent — but the marks inside them are now the system's own vocabulary rather than a borrowed dot and tick.

Radio · the loop closes

Unselected is a pulli inside a dashed ring — the line is there but has not closed. Selected is the same ring solid, in accent. Continuity itself carries the state, so it reads in greyscale without depending on a fill.

  • Control2.5 P
  • Pulli0.5 P
  • Open loopkambi-1 dashed
  • Closed loopkambi-2 solid

Checkbox · the line crosses

Unchecked is a pulli in a square enclosure. Checked is tile C — the crossing, which is an X relative to the dot lattice because the kolam line runs diagonally between dots. Indeterminate is a single stroke: the line passing through without crossing.

  • Enclosure--radius-control
  • Crossing arm1.5 P × kambi-2
  • Indeterminateone arm, level
  • Row target--alavu-md
One risk, named. An X can read as remove rather than selected in software, though it is the older convention on paper forms. It is mitigated here by the square enclosure, by accent rather than danger colour, and by the label — but if your product uses X for dismissal nearby, keep the tick and say so in your fork.

Checkbox

Zero or more from a set. Takes effect on submit. Supports an indeterminate state for parent rows.

Radio

Exactly one from a set of two to five. Above five, use a select. Always pre-select a default.

Switch

A single setting that takes effect immediately. If it needs a save button, it is a checkbox. Rendered as a travelling kolam loop — see below.

Do

Wrap the input in its <label> so the text is part of the target. That is what gets the row to 44px.

Don't

Use a switch inside a form with a submit button. The user toggles it, presses Cancel, and reasonably expects it not to have applied.

Badge

Status at a glance. Colour reinforces the label; it never replaces it.

Continuous Open ends Symmetric Broken line Draft
height 3P · pad-y 0.5P · pad-x 2P · radius pill · dot r · caption weight 600
The dot is not the status. It is a repetition of the text colour at a size that survives being scanned quickly. Remove the label and the badge conveys nothing to a colour-blind user, a screen reader, or a greyscale printout — which is why .plain exists for badges that carry no status at all.
Do

Use one badge per row. Two competing badges is the emphasis-inflation problem in miniature.

Don't

Make a badge interactive. If it can be clicked it is a filter chip or a button, and needs a 44px target.

Alert

Persistent, in-page messages. The accent bar is --kambi-3 — one loop radius, the drawn line.

Determinism

This kolam was generated from seed 1729 and will be identical on every machine.

Line closed

All 84 path segments join. Zero open endpoints.

Density above limit

Pattern coverage is 14% of the viewport. The ceiling is 10%.

pad 3P · accent bar r (kambi-3) · radius 0 2r 2r 0 · title/body gap 0.5P

Semantics

  • Errors that interrupt get role="alert".
  • Status that updates gets role="status" — polite, does not interrupt.
  • Informational content gets neither. An alert role on a passive message makes screen reader use exhausting.

Placement

  • Form errors go next to the field, not in a summary at the top — or in both, with the summary linking down.
  • Page-level alerts sit above the content they concern, inside the same container.
  • Never stack more than two. Three alerts is a page that needs restructuring.

Card

A container with a generated media area. The pattern comes from the engine, so no two cards need the same asset.

Margazhi set

Thirty kolams, one for each morning of the month. Generated, never repeated.

Boundary studies

Corner loops and half-loops at every grid size from three to fifteen.

Crossing weaves

What happens above a crossing probability of 0.5, and why it stops being a kolam.

  • Radius--radius-panel
  • Body padding--space-inset-md
  • Media height12 P
  • Grid gutter--space-gutter
  • Borderhairline, --color-border
Do

Give each card a stable seed derived from its content id. The same item then always carries the same kolam, which makes it recognisable.

Don't

Randomise on every render. The card changes appearance on refresh and stops being an identity.

Tabs

The selection indicator is --kambi-2 — the same weight as a drawn edge, never thicker.

Dots sit on a square lattice at pitch P. Cells between four dots are the unit of composition.
dot 0.5P on the kambi · loop 2.5P · gap 3P · loop scale 0→1 over 240ms neli
The indicator is a loop, not an underline. The tab rail is the kambi; each tab has a pulli sitting on it; the current tab's dot is wrapped. This is the same construction as the toggle and the stepper — one primitive, three components. The rule and its limits are in 04 · The Line.
Keyboard behaviour ships with the library. Left and right arrows move between tabs and select on arrival; tabindex is managed so the tab list is a single stop in the page tab order. You supply role, aria-controls and aria-labelledby in the markup.

Stepper

The dot rail laid out as progress. Completed and current steps are wrapped; the rest are dots the line has not reached.

  1. Lay the pulli
  2. Choose symmetry
  3. Draw the line
  4. Close the boundary
dot 0.5P · loop 3P · kambi hairline · label caption · current in accent, done in secondary
  • Set --steps to the number of items so the kambi spans dot to dot rather than edge to edge.
  • aria-current="step" on the current item; data-done on completed ones. The CSS reacts to state, it does not set it.
  • Completed and current are distinguished by colour, not by shape — both are wrapped, because both have been reached. If that distinction carries real meaning in your flow, add a label, not a second loop style.
  • Not for more than about six steps. Beyond that the dots crowd and the labels truncate; use a progress bar and a count.

Table

Hairline rules only. A table with heavy borders is a grid competing with its own contents.

Pattern Grid Seed Segments Status
Margazhi 029 × 92024144Closed
Margazhi 017 × 7172984Closed
Boundary study3 × 31112Draft
header kambi + pulli per sortable column · sorted dot wrapped · selected row wraps its leading dot

A table is already a lattice — rows crossing columns — so two things follow without inventing anything.

The header rule is a kambi, and each sortable column is a pulli on it. The sorted column's dot is wrapped. That is the same primitive as tabs, and correctly so: choosing a sort column is choosing among options along a line. Direction stays an arrow in text — the loop says which column, the arrow says which way, and one mark should not carry both.

A selected row wraps the dot in its leading cell rather than relying on a fill alone. One loop per selection is not line work at frequency, so the density objection does not apply — a loop on every row would be, which is why hover stays a tint.

  • Numbers are monospaced and right-aligned where they are compared down a column.
  • Row actions use --alavu-sm visually but must carry a 44px hit area on touch.
  • Wide tables scroll inside their own container. The page body never scrolls horizontally.
  • aria-sort drives the loop — none shows a bare pulli, ascending or descending wraps it. The CSS reacts to the attribute; it does not set it.
  • aria-selected on the row drives the selection loop.

Progress

Height is one loop radius, so a progress bar is a kambi laid flat.

62% — 18 of 30 kolams generated

24% — exporting

kambi hairline · pulli 0.5P spaced · drawn line kambi-2 · head loop 2P · set --value 0–100

Progress is how far the line has travelled. The track is a kambi with pulli along it; the traversed portion is drawn at full weight in accent; and the head carries a loop — the turn the line is making right now.

This is the stepper generalised to a continuous scale, which is why they look related: a stepper is progress with named stops, and progress is a stepper with unnamed ones.

  • Track--border-hairline
  • Pulli0.5 P, spaced
  • Drawn line--border-edge
  • Head loop2 P
  • Value--value: 0–100
  • Transition--motion-reveal
Determinate or nothing. If the duration is unknown, use the kolam draw-on loader instead — an indeterminate bar that sweeps forever tells the user less than a sentence does. Always pair either with a text status, and always set aria-valuenow alongside --value; the CSS variable draws, the ARIA announces.

Empty state

The one place the pattern layer gets to be the main event.

No kolams yet

Patterns you generate will collect here. Each one is stored as its seed, so the set stays small however many you keep.

art 16P · pad 8P/4P · stack 2P · illustration aria-hidden
Do

Say what will appear here, and give the action that makes it appear. An empty state is an instruction with an illustration attached.

Don't

Leave the kolam without aria-hidden. It is decoration; announcing it adds nothing and delays the sentence that matters.

Toast

The return path that makes principle 02 affordable. Destructive actions are cheap when undo is one click away.

Kolam swept.
pad 2P/3P · radius 2r · elevation overlay · max width 48P
  • Undo is the point. A toast without an action is a notification, and belongs somewhere the user can find it again.
  • Minimum eight seconds when it carries an action, and the timer pauses on hover and on focus.
  • role="status", not alert. A toast is polite by definition; if it must interrupt, it should not have been a toast.
  • One at a time. Queue them. Stacked toasts cover content and expire before they are read.