Pulliv0.1
8px
05 — Tokens

Three layers, one direction.

Palette, primitives, semantics. Each layer may reference only the layer above it, and product code may reference only the last one. That single directional rule is what lets the pitch, the palette and the theme change underneath a product without touching it.

Three layers

The dependency direction is the contract. Break it and the system silently becomes a stylesheet.

1 · Palette

Raw, theme-independent values named for the material they come from — --maa-100, --kavi-600. Never referenced by a component or a product. Changing one changes both themes at once.

2 · Primitives

Derived geometry and motion — --idai-3, --valai-2, --nagarthal-2. Every value is a function of --p. Internal to the system.

3 · Semantics

Role names — --space-inset-lg, --color-text-secondary, --motion-reveal. The only layer product code may use.

/* the direction of reference, and the only legal one */

  palette      --kavi-600: #A34524;
      ↓
  primitive    --idai-3: calc(var(--p) * 3);
      ↓
  semantic     --space-inset-md: var(--idai-3);
               --color-accent:   var(--kavi-600);
      ↓
  component    padding: var(--space-inset-md);
               background: var(--color-accent);

/* illegal: skips a layer, and will break on theme change */
  .thing { background: var(--kavi-600); }
Why the ban on skipping. --kavi-600 is the accent in the light theme and not in the dark one, where the accent is --kavi-400. A component that reaches past the semantic layer looks correct until someone switches theme, then fails invisibly. The rule is enforceable by lint.

Naming grammar

Two grammars, one per layer, both parseable by a human at a glance.

Primitives

--{concept}-{step}

--idai-3        spacing, step 3
--valai-2       radius, step 2
--kambi-1       stroke, step 1
--nagarthal-2   duration, step 2

The Tamil term carries the concept; the step carries the magnitude. Steps are ordinal, not pixel values — --idai-3 stays --idai-3 at every pitch.

Semantics

--{category}-{role}-{variant}

--space-inset-lg
--color-text-secondary
--color-accent-hover
--radius-control
--motion-reveal

Category first so tokens sort usefully in autocomplete. No component names — --button-padding is a fourth layer nobody agreed to maintain.

ConceptTamilGoverns
idaiஇடை · gapSpacing steps along the lattice
valaiவளை · curveCorner radii, multiples of the loop radius
kambiகம்பி · wire, lineStroke and border weights
alavuஅளவு · measureControl heights
ezhuthuஎழுத்து · letterType sizes on the √2 scale
niramநிறம் · colourPalette families
nagarthalநகர்தல் · movementDurations and easing curves
nilaiநிலை · levelElevation

Terms are used with glosses everywhere they appear, and the source practice is credited in the README. Naming a system in the language of the tradition it derives from is only respectful if the naming is explained rather than exotic.

Palette

Five materials: rice flour, swept earth, red ochre, and the coloured powders used on festival days. Nothing was invented.

maa · rice flour

--maa-0#FFFFFF
--maa-50#FDFBF7
--maa-100#F5EFE4
--maa-200#E6DCC9
--maa-350#BFB299

kavi · red ochre

--kavi-100#F6E4DA
--kavi-300#F3AE8B
--kavi-400#E7936A
--kavi-600#A34524
--kavi-700#7E331A
--kavi-900#33221A

mann · swept earth

--mann-300#998A75
--mann-350#94836F
--mann-400#7C6B5C
--mann-500#786249
--mann-550#5C4E42
--mann-700#3A2E23
--mann-850#211A14
--mann-900#17120E

festival powders

--ilai-*leaf · success
--manjal-*turmeric · warning
--neelam-*indigo · info
--kumkumam-*kumkum · danger
Dark is the native theme. Kolam is rice flour on a swept, water-darkened floor — light marks on a dark ground. The dark theme is therefore the faithful one and light is the inversion, not the other way round. Both are complete; neither is an afterthought.

Semantic roles

The public API. Everything a product is permitted to reference is on this page.

Colour

TokenLightDarkUse forSample
--color-bgmaa-50mann-900Page ground
--color-surfacemaa-0mann-850Cards, panels, raised areas
--color-surface-sunkenmaa-100mann-800Wells, table headers, inputs in cards
--color-textmann-900maa-100Primary text
--color-text-secondarymann-550maa-350Body copy, descriptions
--color-text-tertiarymann-500mann-300Captions, metadata, hints
--color-bordermaa-200mann-700Dividers, decorative edges
--color-border-strongmann-350mann-350Control boundaries — meets 3:1 on bg, surface and sunken
--color-accentkavi-600kavi-400Primary action, links, kolam line
--color-accent-subtlekavi-100kavi-900Accent backgrounds, focus glow
--color-on-accentmaa-50mann-900Text on accent fill
--color-successilai-600ilai-400Confirmation, positive state
--color-warningmanjal-600manjal-400Caution, needs attention
--color-infoneelam-600neelam-400Neutral information
--color-dangerkumkumam-600kumkumam-400Errors, destructive actions

Measured contrast for every pair is published in Accessibility → Contrast matrix and recomputed in CI.

Space, radius, border

space

  • inset-xs → xl1–6 P
  • stack-tight1 P
  • stack2 P
  • stack-loose4 P
  • section12 P
  • inline2 P
  • gutter3 P
  • page-margin4 P

radius

  • radius-control2 r
  • radius-surface2 r
  • radius-panel4 r
  • radius-pillfull

Control and surface share a radius deliberately — a button sitting on a card should read as the same material.

border

  • border-hairline1px
  • border-edge2px

Hairline is a literal pixel, not a derived value — see where derivation breaks.

Type

The lattice diagonal is P√2. That ratio — 1.414, the same one behind ISO paper sizes — sets the scale.

TokenStepSizeLine heightFaceSpecimen
--ezhuthu-caption16 ÷ √2²11px1.6uiPulli kolam
--ezhuthu-smallhalf-step13px1.6uiPulli kolam
--ezhuthu-bodybase16px1.6uiPulli kolam
--ezhuthu-lead16 × √223px1.35displayPulli kolam
--ezhuthu-h216 × √2²32px1.15displayPulli
--ezhuthu-h116 × √2³45px1.15displayPulli
--ezhuthu-display16 × √2⁴64px1.15displayPulli

Faces

  • --face-displaySpectral
  • --face-uiInter
  • --face-monoJetBrains Mono

A serif for display and a grotesque for interface: the drawn line and the printed instruction are different registers in the tradition too. Every face has a full system fallback stack.

Rules

  • One step per level. Never skip two steps between a heading and its body — the √2 ratio is already large.
  • Display face above lead only. Below 23px, Spectral loses its distinction and costs legibility.
  • No type in the pattern layer. Text never sits on a kolam without a solid ground behind it.
  • Type is pitch-independent. Density changes space, not reading size.

Motion

Duration is distance travelled — 30ms per dot the line passes. Easing curves are the three moves a kolam line can make. This is the token reference; the rationale, choreography rules and the animatable-property restriction are in 08 · Motion.

TokenDerivationValueUse for
--nagarthal-14 dots × 30ms120msHover, press, colour change
--nagarthal-28 dots × 30ms240msReveal, expand, toggle travel
--nagarthal-312 dots × 30ms360msPage and route transitions
--nagarthal-420 dots × 30ms600msKolam draw-on — decorative only
--nelithe turncubic-bezier(0.65, 0, 0.35, 1)Symmetric — in and out of a state
--chuzhithe spiral incubic-bezier(0.33, 0, 0.15, 1)Entering and settling
--viduthe releasecubic-bezier(0.5, 0, 0.9, 0.4)Leaving — accelerates away

--motion-state

120ms neli — anything the user is directly manipulating.

--motion-reveal

240ms chuzhi — content arriving. Decelerates into place.

--motion-exit

120ms vidu — content leaving. Faster out than in, always.

Consuming tokens

CSS is the source of truth. Everything else is generated from it.

CSS

.invoice-card {
  padding:       var(--space-inset-lg);
  gap:           var(--space-stack);
  border-radius: var(--radius-panel);
  border:        var(--border-hairline) solid var(--color-border);
  background:    var(--color-surface);
  color:         var(--color-text);
  transition:    border-color var(--motion-state);
}

JSON export

assets/tokens.json carries the same tree in a format that maps cleanly onto Figma variables and Style Dictionary. Values are expressed as formulas, not resolved pixels, so a consumer can re-derive at any pitch.

{
  "seed": { "p": { "value": 8, "unit": "px" }, "r": { "value": "{seed.p} / 2" } },
  "primitive": {
    "idai": { "3": { "value": "{seed.p} * 3", "type": "dimension" } },
    "valai": { "2": { "value": "{seed.r} * 1", "type": "dimension" } }
  },
  "semantic": {
    "space":  { "inset-md": { "value": "{primitive.idai.3}" } },
    "color":  { "accent":   { "light": "{palette.kavi.600}", "dark": "{palette.kavi.400}" } }
  }
}

Figma

  • Number variables for the seed: p and r. Every spacing and radius variable is a mode-independent multiple of these.
  • Three modes on the colour collection: light, dark, and high-contrast. Semantic names only — never publish palette variables to the team library, or designers will reach past the semantic layer exactly as developers do.
  • Density as a mode on the number collection: compact / default / comfortable maps to p = 6 / 8 / 10.

Theming

A fork replaces the palette and reassigns the semantic layer. It never touches primitives.

/* a brand fork: new palette, same geometry, same contract */
:root {
  --brand-500: #1F5F4E;
  --brand-300: #7FC4AE;
}
:root { --color-accent: var(--brand-500); }
:root[data-theme="dark"] { --color-accent: var(--brand-300); }

What a fork may change

  • Any palette value
  • Any semantic → palette assignment
  • The seed pitch and density mapping
  • Type faces (the √2 ratio stays)

What a fork may not change

  • r = P/2. Break it and the pattern engine and the radius scale decouple.
  • The layer direction. Semantics may not reference components.
  • Accessibility floors. 44px targets, 3:1 non-text contrast, 4.5:1 text contrast.
  • The √2 type ratio. Sizes may shift; the ratio is structural.

Anti-patterns

Every one of these has been done by a real team, usually for a good reason, usually on a deadline.

Do

padding: var(--space-inset-lg) — a role, resolved by the system.

Don't

padding: var(--idai-4) — a primitive. Works today, breaks the contract, and gives the system no way to change inset spacing without touching every call site.

Do

color: var(--color-text-secondary)

Don't

color: var(--mann-550) — correct in light, invisible in dark, and it will ship because nobody tested the other theme.

Do

Add a semantic role when a genuinely new need appears, and document what it is for.

Don't

Add --color-text-secondary-dark-alt-2. A fourth qualifier means the role model is wrong, not that it needs another entry.

Do

Put an unavoidable exception in tokens.css with a comment naming the constraint that forced it.

Don't

Put margin-top: -3px in a component to fix optical alignment. It will be copied into four other components before anyone asks why.