Skip to content
LautstarkPublic

About

The look the Lautstark AAC tools share: one accent hue per product, every other token derived from it and contrast-checked before it ships.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

design

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 →

What is here

  • 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 menu menuOn drew 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. Its refresh is 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. say and rests are 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 .segmented row, with aria-pressed where it belongs and a refresh for 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, .small and .faint are 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 .collections in components.css beside it. Carries the two things a row was getting wrong separately — aria-current on the open one, and which modifier means "and also this one" (conventions.md §4.2). after is one product's optional seam for putting something of its own under a row, and it takes a Node the 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 is svelte/Sidebar below; only the rows were ever this module's.
  • @lautstark/design/crop — cutting somebody's own picture down to a square: the model's constants (FRAME 0.84, CLOSEST 4, the side * 0.04 arrow step), loadSquare() with the two-per-cent tolerance that decides there is nothing to ask, and cutSquare(). 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) and colorSpace. 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 name components.css has a rule for, and emittedClasses(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. emittedClasses skips Svelte's scoping hash, which is on the element beside the real names and is never a class anybody draws. The KNOWN_MISSING maps 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 .svelte sources with no build step, compiled by the consumer's own plugin and exported behind a svelte condition 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 — the display: contents wrapper that lets a node built outside Svelte, one of the family's shared panels, stand among components.
    • svelte/Panel — the folded panel, over the .panel rules components.css has drawn since v1.7.0. open is 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's open attribute directly and Svelte never sees it — a one-way prop reopens the sheet with everything folded.
    • svelte/Sheet and svelte/sheet — the one dialog idiom, both openings: the component where the caller is markup, openSheet where it is a controller module. Six hand-written dialogs in mitreden and vorlaut go through it. title takes a thunk because one product renames its dialog on every keystroke and five of its e2e cases find it by its current name; closeLabel is required and falls back to nothing; openSheet returns a handle and never a promise.
    • svelte/TileGrid and svelte/Tile — the grid of labelled picture buttons, over the .picker__grid / .picker__item rules that moved into components.css with it. aria-pressed is a prop and not a default: wochenwerk's tile toggles and should say so, bildhaft's closes the dialog and must not.
    • svelte/Overflow and svelte/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's Row already 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 arguing role="group" over radiogroup — 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/rename unchanged. It keeps the oninput echo 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/TopBar and svelte/sidebar — the column of Sammlungen and the three things around it, for the three products that have one (wochenwerk has none). components.css draws 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 / --collections carry ten e2e selectors between them. The collapse control stays the product's, inside its brand row, and is handed the aria-expanded/aria-controls wiring; the component owns the drawer's ✕, and draws it only below 820px, which is a live matchMedia subscription rather than a number read once. The scrim is a focusable <button> whose scoped style resets border — 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() and focus() 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, and stopPropagation after the arrow keys, which the other lacked so the picker saw a keystroke meant for the picture.
    • svelte/Footer and svelte/Legal — the foot of the page and the three pages it opens, over the .footer and .linklike rules 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. Legal is one dialog with every page in it and the ones not showing hidden — 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-panel and .backup-panel out of @lautstark/sicherung, .metacom-panel out 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, and npm run typecheck for svelte-check over svelte/. happy-dom rather than jsdom because jsdom has no HTMLDialogElement.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 needs resolve: { conditions: ['browser'] } or mount() throws lifecycle_function_unavailable in 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.

One input

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.

The gallery is the generator

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.

Running it

Node, no install, no build step.

node build.js --check

Audits every product and exits non-zero on a failure, writing nothing. This is what CI runs.

node build.js

Writes 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 docs

Serves the gallery at localhost:8899. Any static server will do; the page is three files and imports nothing from the network.

How a change reaches the products

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.)

Keeping the pin current

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

What is not shared

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.

Licence

MIT.

About

The look the Lautstark AAC tools share: one accent hue per product, every other token derived from it and contrast-checked before it ships.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages