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.
| Guard | What 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:
| Rule | What 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.
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.
| Rule | Why |
|---|---|
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. |
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 says | Where it lives | |
|---|---|---|
| DNA | who the head is | core/dna.ts |
| Wurf (throw) | what was rolled by hand on it | core/wurf.ts |
| Motion | what it is doing | app/blick.ts |
| Bogen (sheet) | what it stands on: format, placement, signature | app/bogen.ts |
| Style | how both look | styles/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.