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 nothing: while a menu is open, two window listeners close it on a press outside or on Escape, and they go when it does. Closing touches only the menumenuOndrew and the trigger that opened it.@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, after}), 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).afteris one product's optional seam for putting something of its own under a row, and it takes aNodethe helper re-parents with.after()rather than a snippet: appending an existing node moves it, so whatever is mounted inside survives the list being redrawn around it, and a snippet would be the remount that takes the keyboard out of the list somebody is arrowing through. The sidebar's column issvelte/Sidebarbelow; only the rows were ever this module's.@lautstark/design/crop— cutting somebody's own picture down to a square: the model's constants (FRAME0.84,CLOSEST4, theside * 0.04arrow step),loadSquare()with the two-per-cent tolerance that decides there is nothing to ask, andcutSquare(). The output is a policy of four fields —type(a sentinel or a callback, because one product preserves the source type and no MIME string can say that),cap(nullable: one product is uncapped, because print must not upscale or downscale),quality(0.92, JPEG only) andcolorSpace. A symbol out of a source is never cropped — that would be a derivative — so this hangs off the one path that already keeps bytes.@lautstark/design/css— the CSS contract's two helpers:drawnClasses()reads every class namecomponents.csshas a rule for, andemittedClasses(root)collects every class token a rendered module put on the page. The difference between them is conventions.md §4.12 as a test rather than as prose, and sicherung, bildquelle and stimmquelle each carried a character-identical copy saying it belonged here.emittedClassesskips Svelte's scoping hash, which is on the element beside the real names and is never a class anybody draws. TheKNOWN_MISSINGmaps stay per package: they are dated local exceptions with a reason each.- svelte/ — the components layer's second half, since 2026-09-17.
All four products are Svelte, so what they were retyping stopped being only
rules and started being markup: the same vanilla host in four repositories,
the same folded panel, the same sheet frame. They are raw
.sveltesources with no build step, compiled by the consumer's own plugin and exported behind asveltecondition so the plugin compiles rather than pre-bundles them — without it dev mode silently makes a second copy. conventions.md §6 is the specification for all of them, one entry per component.svelte/Vanilla— thedisplay: contentswrapper that lets a node built outside Svelte, one of the family's shared panels, stand among components.svelte/Panel— the folded panel, over the.panelrules components.css has drawn since v1.7.0.openis two-way, and that is the whole of why this is a component:name=is the platform's accordion, so the browser removes another panel'sopenattribute directly and Svelte never sees it — a one-way prop reopens the sheet with everything folded.svelte/Sheetandsvelte/sheet— the one dialog idiom, both openings: the component where the caller is markup,openSheetwhere it is a controller module. Six hand-written dialogs in mitreden and vorlaut go through it.titletakes a thunk because one product renames its dialog on every keystroke and five of its e2e cases find it by its current name;closeLabelis required and falls back to nothing;openSheetreturns a handle and never a promise.svelte/TileGridandsvelte/Tile— the grid of labelled picture buttons, over the.picker__grid/.picker__itemrules that moved into components.css with it.aria-pressedis a prop and not a default: wochenwerk's tile toggles and should say so, bildhaft's closes the dialog and must not.svelte/Overflowandsvelte/Dropdown— the ⋯ and the labelled picker, over@lautstark/design/menu. Eleven call sites and seven class strings across the four products; bildhaft's is the shape. The ARIA is in the markup from the first paint, not added at open; the anchor is a prop, because wochenwerk'sRowalready supplies one and two nested anchors hang the list off the wrong one; and vorlaut's collision handling comes with them, which is the only implementation that flips the list upwards and caps its height so a long one stays inside a sheet body.svelte/ThemePicker— light, dark, or whatever the machine is set to, over@lautstark/design/theme. Four near-identical implementations, three of which carry the same comment arguingrole="group"overradiogroup— the strongest evidence in the audit that a control is ready to be shared.svelte/TitleField— the work head's name field, over@lautstark/design/renameunchanged. It keeps theoninputecho beside the debounced write, because two products repaint on every keystroke and a component with only the write would move those repaints to 400ms after typing stops. The caret request is one prop where the four had three mechanisms.svelte/Sidebar,svelte/Scrim,svelte/Reveal,svelte/TopBarandsvelte/sidebar— the column of Sammlungen and the three things around it, for the three products that have one (wochenwerk has none).components.cssdraws none of these four class names, so the layout is in the components' own scoped styles — only the declarations all three already agree on, because a scoped selector carries the hash and out-specifies a product's plain rule. The sections are one snippet: the<h2>is part of what a search swaps, and bildhaft's.sidebar__section--words/--collectionscarry ten e2e selectors between them. The collapse control stays the product's, inside its brand row, and is handed thearia-expanded/aria-controlswiring; the component owns the drawer's ✕, and draws it only below 820px, which is a livematchMediasubscription rather than a number read once. The scrim is a focusable<button>whose scoped style resetsborder— without it a user agent draws a two-pixel frame around the whole viewport, and no test that clicks a position can see it.svelte/Crop— the square, the slider and the two ways of moving them, over@lautstark/design/crop.cut(),close()andfocus()are instance exports, because the product that had a DOM factory reached them through the object it returned and a component returns nothing. It brings the two fixes that fall out of writing it once:touch-action: none, which one product lacked so the first drag with a finger scrolled the dialog, andstopPropagationafter the arrow keys, which the other lacked so the picker saw a keystroke meant for the picture.svelte/Footerandsvelte/Legal— the foot of the page and the three pages it opens, over the.footerand.linklikerules components.css already draws. The shell is shared and every word in it is the product's: the links arrive as children and are wrapped in nothing, because one product puts its four in a flex row and the other two do not.Legalis one dialog with every page in it and the ones not showinghidden— one product's markup is addressed by forty-one ids, several of them e2e locators, so mounting a page at a time would make a locator resolve or not depending on what happened to be open.
- 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 behaviour modules and the components above, under
vitest with happy-dom.
npm test, andnpm run typecheckforsvelte-checkoversvelte/. happy-dom rather than jsdom because jsdom has noHTMLDialogElement.showModal, which would have left the one module whose subject is the native<dialog>unrunnable — and which is also what lets the Sheet's open and close be asserted rather than inferred. The vitest config needsresolve: { conditions: ['browser'] }ormount()throwslifecycle_function_unavailablein every test at once, with a message that says nothing about configuration. 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 shares code (@lautstark/bildquelle, @lautstark/stimmquelle):
"@lautstark/design": "github:Lautstark/design#v1.31.3"@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.)
Renovate does, since 2026-09-16. Every tag is cut by CI from the commit
subjects (CLAUDE.md §8); Renovate follows the github: tags, opens a branch
per product for each minor or patch, and merges it to main itself once the
product's tests are green. A major waits for a person, with the changelog in
front of them. The family's shared preset in Lautstark/.github is where that
rule is written.
pins.js is what did this while a person moved the pins: it reads the calling
repository's package.json, resolves the latest release of every
github:Lautstark/* package it pins, and warns which are behind — which is how
vorlaut was found on 1.5.0 while its two siblings sat on 1.4.3. It still runs
in the products' workflows and still warns; between two Renovate runs it is
the only thing that says a pin is behind. --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.