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); }
--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.
| Concept | Tamil | Governs |
|---|---|---|
| idai | இடை · gap | Spacing steps along the lattice |
| valai | வளை · curve | Corner radii, multiples of the loop radius |
| kambi | கம்பி · wire, line | Stroke and border weights |
| alavu | அளவு · measure | Control heights |
| ezhuthu | எழுத்து · letter | Type sizes on the √2 scale |
| niram | நிறம் · colour | Palette families |
| nagarthal | நகர்தல் · movement | Durations and easing curves |
| nilai | நிலை · level | Elevation |
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 |
Semantic roles
The public API. Everything a product is permitted to reference is on this page.
Colour
| Token | Light | Dark | Use for | Sample |
|---|---|---|---|---|
| --color-bg | maa-50 | mann-900 | Page ground | |
| --color-surface | maa-0 | mann-850 | Cards, panels, raised areas | |
| --color-surface-sunken | maa-100 | mann-800 | Wells, table headers, inputs in cards | |
| --color-text | mann-900 | maa-100 | Primary text | |
| --color-text-secondary | mann-550 | maa-350 | Body copy, descriptions | |
| --color-text-tertiary | mann-500 | mann-300 | Captions, metadata, hints | |
| --color-border | maa-200 | mann-700 | Dividers, decorative edges | |
| --color-border-strong | mann-350 | mann-350 | Control boundaries — meets 3:1 on bg, surface and sunken | |
| --color-accent | kavi-600 | kavi-400 | Primary action, links, kolam line | |
| --color-accent-subtle | kavi-100 | kavi-900 | Accent backgrounds, focus glow | |
| --color-on-accent | maa-50 | mann-900 | Text on accent fill | |
| --color-success | ilai-600 | ilai-400 | Confirmation, positive state | |
| --color-warning | manjal-600 | manjal-400 | Caution, needs attention | |
| --color-info | neelam-600 | neelam-400 | Neutral information | |
| --color-danger | kumkumam-600 | kumkumam-400 | Errors, 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.
| Token | Step | Size | Line height | Face | Specimen |
|---|---|---|---|---|---|
| --ezhuthu-caption | 16 ÷ √2² | 11px | 1.6 | ui | Pulli kolam |
| --ezhuthu-small | half-step | 13px | 1.6 | ui | Pulli kolam |
| --ezhuthu-body | base | 16px | 1.6 | ui | Pulli kolam |
| --ezhuthu-lead | 16 × √2 | 23px | 1.35 | display | Pulli kolam |
| --ezhuthu-h2 | 16 × √2² | 32px | 1.15 | display | Pulli |
| --ezhuthu-h1 | 16 × √2³ | 45px | 1.15 | display | Pulli |
| --ezhuthu-display | 16 × √2⁴ | 64px | 1.15 | display | Pulli |
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.
| Token | Derivation | Value | Use for |
|---|---|---|---|
| --nagarthal-1 | 4 dots × 30ms | 120ms | Hover, press, colour change |
| --nagarthal-2 | 8 dots × 30ms | 240ms | Reveal, expand, toggle travel |
| --nagarthal-3 | 12 dots × 30ms | 360ms | Page and route transitions |
| --nagarthal-4 | 20 dots × 30ms | 600ms | Kolam draw-on — decorative only |
| --neli | the turn | cubic-bezier(0.65, 0, 0.35, 1) | Symmetric — in and out of a state |
| --chuzhi | the spiral in | cubic-bezier(0.33, 0, 0.15, 1) | Entering and settling |
| --vidu | the release | cubic-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:
pandr. 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.
padding: var(--space-inset-lg) — a role, resolved by the system.
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.
color: var(--color-text-secondary)
color: var(--mann-550) — correct in light, invisible in dark, and it will ship because nobody tested the other theme.
Add a semantic role when a genuinely new need appears, and document what it is for.
Add --color-text-secondary-dark-alt-2. A fourth qualifier means the role model is wrong, not that it needs another entry.
Put an unavoidable exception in tokens.css with a comment naming the constraint that forced it.
Put margin-top: -3px in a component to fix optical alignment. It will be copied into four other components before anyone asks why.