Three layers

A head comes about in three steps, and each one belongs to a different layer. The rule between them is one sentence long – and that is why there is a program behind it that checks it.

core/ is style-agnostic: randomness, vocabulary, catalogue, DNA, the 3D geometry of the skull, colour as OKLCH. None of it knows that a line exists. styles/ holds the contract (types.ts) and below it one folder per style – klar/ is the style of this edition. app/ wires things up: grid, gaze computation, pointer, keyboard – and two pages, the sheet (app/main.ts) and the workshop (app/werkstatt.ts).

Calls only go downwards. app may use everything, styles only core, and core knows nobody above it. Anyone reading the core never has to ask which style is currently meant.

Who may know whom

A rule without a guard is a request

Three scripts run as part of npm run check, each for a rule that would otherwise only be a comment. They are all built to the same pattern: they read text, they know their own gaps, and every exception stands in them as a list – whoever extends it does so with open eyes.

GuardWhat it watches
check-boundaries.mjs The layers: who may import whom (the five rules below).
check-rollen.mjs The colours: that nobody names a colour value instead of a role – and the second border between page and drawing.
check-vokabular.mjs The language: identifiers are German, except where the English word is the technical term. And the other way round: that nobody translates a CSS or SVG word.

The first one reads every from '…' line under src/ and holds five rules:

RuleWhat it prevents
core never imports from styles/ or app/ A core that knows a style is the style.
styles never imports from app/ A style that fetches the gaze computation hangs off the grid. The next style would have to bring the same app along.
No style imports from another style folder Two styles that borrow shapes from each other are one style with a switch.
core only via @core, styles only via @styles A deep import freezes an internal: the file can no longer move without breaking something.
The docs may reach everywhere – but nobody into the docs These pages deliberately reach into internals, that is their job. The other way round the sheet would hang off its own description.

The fifth rule arrived with these docs. It is the only one that forbids a direction outright: src/docs/ is a dead end. What stands here may go wrong without dragging the sheet along.

What the guard does not see

Symptom: The guard reports "ok", and a style still reaches past @core. Cause: It reads text patterns, not a dependency graph. A dynamic import with a computed path (await import(basis + name)) stands in no from line, and it does not see anything outside src/ at all. Fix: Neither occurs here. Should one occur, the guard has to grow with it – a guard whose gaps you know is useful; one you believe to be complete is dangerous.

The second border

The page works with CSS roles: --flaeche, --vordergrund, --linie, --marke. The drawing works with roles too – tinte, haut, akzent, stoff – but they live elsewhere, namely in styles/klar/palette.ts. The same idea, two worlds: the browser resolves one, a function resolves the other, because canvas knows no variables.

For a while this chapter was called "the second border no guard sees", and the title turned out to be a prediction. There is one now: scripts/check-rollen.mjs. It holds four rules.

RuleWhy
Colour values live only in --ton-* A hex value in the middle of a rule is a colour that belongs to no role – and at the next rebuild it stays behind.
--ton-* is read only by the role level Taking the primitive directly bypasses the meaning. That is exactly what stood in three places in these docs, until the guard arrived.
No drawing step names a colour value The same rule for the other world: outside palette.ts there are no colours in styles/, only roles. Black with opacity is exempt – that is shadow, not colour.
The bridge is a list What the app fetches from the stylesheet in order to paint it onto the canvas is named in the guard. A third one is added only when somebody enters it there.
The role that exists twice

Symptom: A colour is renamed, the page looks right, the canvas keeps drawing in the old tone – or stops drawing at all. Cause: In the canvas no browser resolves a CSS variable. ctx.fillStyle = 'var(--marke)' is not an error but silently ineffective, and getPropertyValue on a name that does not exist returns an empty string instead of complaining. Fix: The two sets are separate and stay that way. Two roles cross the bridge: --marke-canvas for the names under the heads, --flaeche for the veil over the grid. Both are read out, both are entered in the guard, and both must be declared in the stylesheet.

Until recently this said "the only bridge is --marke-canvas" – in the stylesheet, in this chapter and in the comment above it. The app had long been reading two. That is how an agreement that is only written down ages: not with a bang, but silently.

The third border: language

This project speaks German – in the comments, in these docs, in the commit messages. For a while the identifiers spoke two languages: the core English (makeDna, headPoint, FaceDna), style and app German (zeichneMund, Kopfdaten, Regung), and mixed forms in between like makeFrame next to macheSchale. That is not a question of taste but friction: whoever looks for a function first guesses which language it was named in, and whoever writes one decides it anew every time.

The rule is: identifiers are German. Exempt is wherever the English word is the technical term – yaw, pitch and roll, the seed, dpr, oklch, mulberry32. And exempt is whatever is not ours at all: canvas.width is called width because that is what it is called.

This border has two directions, and the second is the more expensive one. An English identifier is unlovely; a translated CSS or SVG word is broken and says nothing. Both directions are checked by check-vokabular.mjs. It cannot recognise English – it knows a list of stems and a list of exceptions, and its gaps stand in its own header. See the pitfalls no picture shows.

Identity, motion, style

Three things that are easily thrown into one pot, and that are deliberately kept apart here:

What it saysWhere it lives
DNAwho the head iscore/dna.ts
Wurf (throw)what was rolled by hand on itcore/wurf.ts
Motionwhat it is doingapp/blick.ts
Bogen (sheet)what it stands on: format, placement, signatureapp/bogen.ts
Stylehow both lookstyles/klar/

The motion arrives in the style ready-made – rotation, gaze direction, blink, attention, mouth opening, clock. The style does not compute it. If it did, every further style would have to invent the same springs again, and two styles would look in different directions for the same pointer.

The only time the style needs itself is zeit – not for the movement, but for the beat of its line. A drawn line does not stand still, even when the head stands still.

The contracts, one by one

Stilbeschreibung – the style description

Five fields, and each answers a question the app would otherwise have to guess: what the style is called (id, label), how much rotation it tolerates (pose), how much room a particular head needs in it (raum), and how you start (macheBlatt). raum is optional because most styles can take RAUM_NORMAL; macheBlatt is not, because without it there is no style.

Blattseite – the sheet

A sheet is a whole sheet, not a single head: on creation the style lays down its ground (for klar the paper including grain and edge darkening) and composites at the end in fertig(). The clear style does nothing there – with it everything lies directly on the sheet.

fertig() stays mandatory nonetheless. A style with layers needs the call, and the caller should not have to know which style is which. A method that is sometimes necessary belongs in the contract – otherwise it eventually ends up inside an if.

Kopfplatz – the head's place

Three numbers, and the style gets no more about the location. No rectangle, no cell, no row and column: a style that knows it sits in a tile starts to arrange itself accordingly.

Raumbedarf and RAUM_NORMAL

The value is not measured but chosen: 1.3 is exactly large enough to work out in every cell of the grid. Raising it as a precaution looks like nothing and in truth shrinks every head – the cell measures against this value, not against the drawn head. More on that in the chapter The sheet.

The chain as a whole

A new style = one folder and one line

The registry is the only place that knows all the styles. It stands above the folders and not inside them; a style that registers itself forces everyone looking for the list to open every folder.

The barrel

@core is a file that does nothing but pass things on. The gain stands in its first line: internals behind it may change freely. core/geometry/wolke.ts can be rebuilt, split or renamed as long as umriss and wolke keep falling out of the barrel. Twenty deep imports in the styles would be twenty reasons to leave it alone.

The price is a file you must not forget when adding something. The guard does not catch that – it only sees the violation, not the omission. What is missing is reported by the typecheck instead, and immediately.