The look the Lautstark products share, and the thing that generates it.
Three products — mitreden, bildhaft, vorlaut — are one tool with three outputs. You type a sentence; one gives it a voice, one gives it symbols, one puts it on a key you can press. They should look like siblings, and this is where that is decided.
One more takes the same look without being that tool: wochenwerk, a family calendar and a child's symbol board. Which of the conventions reach it and which do not is settled in the conventions.
The gallery → · The rule set → · The conventions →
- docs/design.md — the agreement. Token names, the three button tiers, fields, chips, menus, dialogs, empty states, and a vocabulary glossary so the same thing has the same name in every product.
- docs/conventions.md — how the products are built, where design.md is how they look. What a Sammlung is, where a preference is kept, which library talks to IndexedDB, how a dialog resolves — and the list of differences that are correct and must not be tidied up. It exists so that moving between the three repositories never means working out how it is done here.
- docs/index.html — the gallery. Every component drawn with live tokens, a light/dark switch, and an accent picker.
@lautstark/design/menu— the overflow menu's behaviour, beside the CSS that draws it:menuOn(trigger, build),closeMenus(), and the item options whose field names stopped two copies of this function meaning opposite things by the same third argument. Importing it attaches two document listeners, so that a press outside or Escape closes whatever is open.@lautstark/design/dialog— the modal sheet's behaviour:openDialog,confirmDialog, and the press-outside dismissal the platform does not give. Every word comes from the caller, including "Cancel" and the name of the ✕, because two of the three products are bilingual and the third is German by policy.@lautstark/design/rename— the work head's name field:renameField, debounced while typing, written on blur and on Enter, never written when the value has not moved. Itsrefreshis the only way to assign the field, which is the point — all three products had a repaint that assigned it directly and so could put the stored name back over what somebody was typing.@lautstark/design/toast— the line that says what just happened:announcer(node, {rest, onRest, onWake, busyClass})→say/rests/busy/clear. It wraps a live region the product has already mounted and never adds or removes it, which is the whole of it — all three products announced their acknowledgements to nobody, because a region that arrives carrying its message is a region no reader was watching. What happens after a message stays the product's: bildhaft empties the line, vorlaut dims it, mitreden leaves it and has a busy state.sayandrestsare two verbs rather than one with a flag, because vorlaut needs both on the same element - a failed write stays lit while "saved" is allowed to fade.@lautstark/design/language— the control that changes which language the page is in:languagePicker({ languages, current, choose, label })→ the.segmentedrow, witharia-pressedwhere it belongs and arefreshfor the two products that switch without reloading. It is the one shared module here that ships words, and the exception is argued rather than convenient: a language's name is not a translation. „Deutsch" is Deutsch on an English page, because this is the control somebody reaches for when they cannot read the interface around it. It returns the row and nothing around it —.opt,.smalland.faintare bildhaft's vocabulary, not this package's, and conventions.md §4.12 says a module ships the rules for what it emits.@lautstark/design/collections— the sidebar's Sammlung rows:drawCollections(container, {rows, open, onPick}), with.collectionsin components.css beside it. Carries the two things a row was getting wrong separately —aria-currenton the open one, and which modifier means "and also this one" (conventions.md §4.2). The sidebar around the rows is not here, because all three are genuinely different objects.- docs/components.css — the components layer. The
button tiers, fields, chips, the focus policy, the overflow menu, the sheet
skeleton, the Sammlung rows and the message furniture, written once against
the token names. It also draws what the shared panels in the other packages
emit —
.where-paneland.backup-panelout of@lautstark/sicherung,.metacom-panelout of@lautstark/bildquelle— because conventions.md §4.12 says a module that emits class names ships the rules for them, and those two modules could not move here: each is built out of calls on its own package. The gallery imports it, and a product imports it beside its token file. - tests/ — the five behaviour modules above, under vitest with
happy-dom.
npm test. happy-dom rather than jsdom because jsdom has noHTMLDialogElement.showModal, which would have left the one module whose subject is the native<dialog>unrunnable. The generator's own CI job still installs nothing; this is a second job. - docs/lib/ — the generator. Colour maths, the derivation, and the emitter. No dependencies.
- products/ — one small JSON file per product.
- build.js — writes each product's token file.
A product declares one thing about itself: its accent.
{ "product": "vorlaut", "accent": "#9B7BFF", "schemes": "dark", "state": false }Everything else follows. The planes, the hairline, the three weights of text, the five accent tokens, the danger family — derived, and every value that has to clear a contrast ratio is solved for it rather than picked by eye.
That last part is the point. Between them these products shipped white-on-salmon at
2.48:1 on the button that deletes everything, a --text-faint at 2.89:1, and a
replacement for it — ported from a sibling — that still read 4.04:1 on the ground it
actually sat on. Nobody was careless. Judging a contrast ratio by eye is not a thing
people can do, and judging it in whichever scheme your laptop happens to be set to
guarantees the other scheme goes unchecked.
docs/index.html imports the same modules build.js does and
applies their output straight to the page. It is not a picture of the design system;
it is the design system with a hue picker on it. Pick an accent — including one no
product uses — and every component, every token and every contrast ratio re-derives
live.
Try it on a hue you are considering for a fourth product. If it looks wrong there, it will look wrong shipped.
Node, no install, no build step.
node build.js --checkAudits every product and exits non-zero on a failure, writing nothing. This is what CI runs.
node build.jsWrites each product's token file, provided that product is checked out beside this repository. A product that fails the audit is never written.
python3 -m http.server 8899 --directory docsServes the gallery at localhost:8899. Any static server will do; the page is three files and imports nothing from the network.
By npm, for anything with a build step — as a github: dependency, which is how
this family already shares code (@lautstark/bildquelle, @lautstark/stimmquelle):
"@lautstark/design": "github:Lautstark/design#v1.0.0"@import '@lautstark/design/tokens/bildhaft.css';
@import '@lautstark/design/components.css';Vite resolves the bare specifier, so there is no plugin and no copy step, and the pin is a real pin.
Nothing runs on a consumer's machine at install time. There is deliberately no
prepare script: this family allowlists install scripts, and a token set that is
static CSS has no business asking for an exemption. What ships is what was
committed, and CI checks the committed files are current instead, by regenerating
them and diffing.
That check is why the header in every token file names its inputs — the
repository, the product, the accent — and names no version, sha or date. A stamp
would make the regenerated copy differ from the committed one for a reason that
has nothing to do with the content, so the check would fail forever, and a check
that always fails is one people learn to scroll past. tests/generated.test.js
holds the header to it, and the head of build.js has the account.
(Two corrections have lived in this paragraph. It used to say prepare
regenerated tokens/ on install; it never has, and build.js has said so in its
header the whole time. It then said the header names a version, which is the
opposite of what makes the check work — read against the emitter 2026-08-27.)
An exact tag means an install can never move the build on its own. It also means nothing notices when a pin stops being current — which is how vorlaut came to sit on 1.5.0 while its two siblings were still on 1.4.3.
node node_modules/@lautstark/design/pins.js
Reads the calling repository's own package.json, resolves the latest release of
every github:Lautstark/* package it pins, and says which are behind. It also
flags a pin that is a range or a branch rather than a tag, since that is the rule
it is checking.
It warns and does not fail. Being a patch behind is not a reason to block a deploy
that fixes something else, and a check that can stop an urgent release for a
cosmetic reason is a check people learn to route around. --strict exits non-zero
for anybody who wants the opposite.
Not by a CDN. All three products run offline, and a stylesheet fetched from a
remote host at page load would cost them that. npm is a build-time fetch that
leaves a local file; a <link> to another origin is a runtime dependency. Those
are different things.
Every product pins a version, and every product imports. That was not
always true: vorlaut served plain ES modules with static/tokens.css
committed, and mitreden inlined the tokens into a hand-built ui.html. Both
pages are gone, so the --sync flag that copied files into them is gone too,
along with the out and inline fields it read. It had been addressing paths
that no longer existed, and nothing caught that, because no check ever ran it.
Nothing here reaches into another repository, and there is no secret anywhere.
An earlier version pushed outward, which needed a personal access token with
write access to two other repositories, stored here and readable by every
workflow in this repo — a long-lived cross-repository credential for a file of
colour values. The one after that had each product clone this repo on a weekly
schedule, which removed the credential but spent about fifty CI runs a year to
find nothing: these files change roughly twice. Both are gone. A version pin in
a package.json was the whole of what anybody wanted.
| product | how | where |
|---|---|---|
| bildhaft | import |
src/main.ts |
| mitreden | import |
src/main.ts |
| vorlaut | import |
src/main.ts |
The accent hue, by design — it is what tells three otherwise identical-looking programs apart. Whether a product follows the OS or commits to one ground. Its navigation shell. Its density — list rows stay per product, because a 200-row archive and a dozen worked-on cards want different furniture.
"And no code" used to end this list. It stopped being true the day the copying
became measurable: vorlaut and mitreden carried the identical button.primary
rule, bildhaft carried the same values under its own class names, and the
one-line focus policy travelled between repositories by hand. Those components
now cross deliberately, as components.css, by the same road the tokens take.
What travels between the repositories is the document, the generated file and
that one stylesheet — always by version pin, never by hand.
MIT.