# How we build

> One source, many surfaces. How tokens, components and checks fit together, which rules apply in code, and when a view is done.

Source: https://brand.heyalbert.family/en/entwickeln/ · Hey Albert brand and design system

Hey Albert runs in the browser and on iPhone, iPad, Mac and Watch. Every surface shows the same rows from the same database. And every surface carries the same design from the same source: `packages/design-system`.

This chapter is for anyone who writes code: people on the team, outside experts and agents.

## One source

Every value of the brand exists exactly once in the repository. Colours, sizes, fonts and motion live in `packages/design-system/tokens/quelle/*.json`. Everything else refers to them.

| What | Where | Becomes |
|---|---|---|
| Tokens | `tokens/quelle/*.json` (W3C Design Tokens Community Group format) | `tokens/*.css`, `tailwind/theme.css`, `swift/Tokens.swift` |
| Components | `components/<group>/<Name>.jsx` with `.d.ts` and `.prompt.md` | the model for `apps/web/src/bausteine` and for SwiftUI |
| Web primitives | `bauteile/ui/*.tsx` (shadcn on our roles) | forms, dialogs and tables in HQ and on the website |
| Website blocks | `bloecke/*.tsx`, one to one with `packages/content-model` | the pages of hey-albert.app from Payload |
| Rules | `regeln/regeln.json` | this site, brand QA, agents |
| Fonts, icons, logos | `assets/` | every surface, served locally |

> **Why only one source**
>
> Two sources for the same thing are not redundancy but a fork. The first brand site from 30 August was deleted on 4 September because it carried values of its own. This site contains no values either. Everything here is read from the package at build time.

## The order

Every area is built in the same sequence. The foundation stands before anyone sees a screen.

1. **Schema and RLS.** The table with `household_id`, an index and four policies, in one migration.
2. **Tests.** pgTAP for all five roles: `owner`, `adult`, `child`, `guest`, `service`.
3. **Domain logic.** Pure functions without UI, with their own tests.
4. **Interface.** From components of the system, against the seed of the Sonntag family.

Nobody builds against empty screens. The seed household has a real week: Mon 1 to Sun 7 September, and today is Wednesday.

## The stack per surface

### Web app and HQ

`apps/web` is Next.js 16 with the App Router. Tokens come straight from the package as CSS variables, imported in the layout from `tokens/colors.css` and the other token files. Tailwind is used only for layout helpers such as grids, spacing and breakpoints. Colour, type and radius go through roles.

The components in `apps/web/src/bausteine` are the production versions of the components in the system. `Rahmen.tsx` is `LeisteMac` plus `Kopfzeile`, and `Zeichen.tsx` is `Icon`. If a version differs, that is a bug, not a style.

### Website

`apps/site` builds hey-albert.app from Payload. Every block in the content model has exactly one component in `packages/design-system/bloecke`. `RenderBloecke` picks by `blockType`. `pnpm test` in the package checks that no block is left without a component.

### Apple

`apps/apple` will be SwiftUI Multiplatform with Swift 6 and strict concurrency. The roles live in `swift/Tokens.swift`: `AlbertFarbe`, `AlbertRaum`, `AlbertSchrift`, `AlbertForm`, `AlbertBewegung`. Every colour carries light and dark and resolves at runtime.

> **Open**
>
> The Swift app moves into `apps/apple` only when its phase begins. Until then `Tokens.swift` is generated but not part of any Xcode project.

## Tokens as code

The source is JSON in the W3C Design Tokens Community Group format, one set per file: `farben-hell`, `farben-dunkel`, `schrift`, `raum`, `form`, `bewegung`. Style Dictionary 5 generates three outputs from it without converting any value.

```bash
cd packages/design-system
pnpm tokens        # generate and check against the frozen reference
pnpm tokens:diff   # check only
```

`tokens-diff` compares every name and value pair with `tokens/referenz/`. If a value differs, the run fails. New roles are listed with a reason in `tokens/referenz/ergaenzungen.txt`.

Generated files start with the line „Erzeugt aus tokens/quelle“ (generated from tokens/quelle). Nobody edits them by hand. The GitHub workflow `design.yml` regenerates them and checks that nothing differs.

## Rules in code

- No hex value in a component. Colour runs through roles (--grund, --karte, --tinte, --akzent).
- No shadows, anywhere. Depth comes from a veil, a stronger outline and tilt.
- Meanings are never swapped: pink = accent and now, yellow = family and ritual, blue = drive, open-pink only as a dashed outline for „no one responsible“. Pink never means „error“.
- Colour is never the only carrier. An initial or name is always present.
- Disabled is a computed role, not opacity.
- Text and controls at least 4.5:1, surfaces and outlines at least 3:1.
- Target sizes: pointer 28, finger 44, child 56 px — never below.
- Focus always via :focus-visible: a 2 px ring in open-pink with 2 px offset.
- 120 ms states, 200 ms reveals, 300 ms breath. No springs, no spinner. With reduced motion everything stands still.
- Skeleton, not spinner. The skeleton has the shape of the content.
- Every change is optimistic and offers „Undo“.
- Fonts are served locally, never from Google’s servers.
- No emoji, no Unicode characters as icon substitutes.
- If an icon is missing, take it from the set; never draw one.

### Colour only through roles

> **Do**
>
> ```css
> .karte {
>   background: var(--flaeche-karte);
>   color: var(--text-koerper);
>   border: var(--strich-haar) solid var(--linie-haar);
>   border-radius: var(--radius-karte);
>   padding: var(--karte-innen);
> }
> ```

> **Don't**
>
> ```css
> .karte {
>   background: #EEE8DD;
>   color: rgba(0, 0, 0, 0.87);
>   border-radius: 10px;
>   box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
> }
> ```
> A hex value, a free opacity, a free radius, a shadow. Four violations in four lines.

### React with Tailwind

The Tailwind theme knows only our roles. Tailwind’s own palette, shadows and radii are switched off, so `bg-red-500` or `shadow-md` simply do not exist.

> **Do**
>
> ```tsx
> <button className="bg-knopf-primaer-flaeche text-knopf-primaer-text rounded-pille h-ziel-finger px-raum-2
>   hover:bg-knopf-primaer-flaeche-zeiger focus-visible:outline-2 focus-visible:outline-rand-fokus">
>   Ich fahre
> </button>
> ```

> **Don't**
>
> ```tsx
> <button className="bg-black text-white rounded-lg shadow-md h-8 opacity-50 focus:ring" disabled>
>   OK!
> </button>
> ```
> Foreign colour, free radius, shadow, a target below 44, opacity for disabled, `:focus` instead of `:focus-visible`, „OK“ as a label and an exclamation mark.

Disabled is a computed role: `disabled:bg-knopf-inaktiv-flaeche disabled:text-knopf-inaktiv-text`. Opacity multiplies with whatever lies underneath. Then no computed contrast ratio holds any more.

### SwiftUI

> **Do**
>
> ```swift
> Text("Schwimmen mit Lena")
>   .foregroundStyle(AlbertFarbe.textKoerper)
>   .padding(AlbertRaum.karteInnen)
>   .background(AlbertFarbe.flaecheKarte,
>               in: RoundedRectangle(cornerRadius: AlbertForm.radiusKarte))
>   .overlay(RoundedRectangle(cornerRadius: AlbertForm.radiusKarte)
>     .stroke(AlbertFarbe.linieHaar, lineWidth: AlbertForm.strichHaar))
> ```

> **Don't**
>
> ```swift
> Text("Schwimmen mit Lena")
>   .foregroundColor(colorScheme == .dark ? .white : Color(hex: "#1A1A1A"))
>   .background(Color.yellow.opacity(0.4))
>   .cornerRadius(10)
>   .shadow(radius: 4)
> ```
> Dark mode as a branch instead of a role, a hex value, system yellow with opacity, a free radius, a shadow.

### Fonts served locally

The four fonts ship in the package as woff2 for the web and TTF for the app. Nothing loads from Google’s servers. Otherwise every visitor’s IP address would go to the US. The Munich Regional Court ruled that unlawful in 2022.

### Focus

Focus always shows through `:focus-visible`, never `:focus`. The ring is 2 px wide in `--rand-fokus` with a 2 px offset. Keyboard users see it, and nobody else does.

## Checks

| Check | What it finds | Command |
|---|---|---|
| Brand QA | hex values outside the source, shadows, free radii, banned words, exclamation marks, images without `alt` | `pnpm qa` in the package |
| Token diff | values that differ from the frozen reference | `pnpm tokens:diff` |
| Blocks | a block in the content model without a component | `pnpm test` |
| Storybook | tokens, primitives, every block in de, en, tr, light and dark, phone and desktop | `pnpm storybook` (port 3112) |
| Visual regression | every story light and dark, phone and desktop, threshold 0.1 % | `pnpm vr`, new references: `pnpm vr:update` |
| Contrast | required pairings by WCAG in both modes | `python3 tooling/check-kontrast.py` |
| Language | sentence length, register, word lists | `python3 tooling/check-sprache.py` |
| Agent `design-pruefer` | token violations and the definition of done for a view, read-only | in Claude Code before every „done“ |
| Chapters of this site | directives, arguments, language | `node apps/brand/skripte/pruefen.ts` |

> **Open**
>
> `tooling/check-kontrast.py` and `tooling/check-sprache.py` currently fail. Both look for modules under paths from the predecessor project. Until they are moved, this site computes contrast itself (chapter Colour) and `pnpm qa` checks the word list.

The GitHub workflow `design.yml` runs on every change under `packages/design-system`. It generates the tokens, checks the reference, tests the blocks, runs brand QA and types, builds Storybook and compares the images.

## A view is done only with

- [ ] empty state
- [ ] skeleton instead of spinner
- [ ] keyboard and :focus-visible
- [ ] phone and desktop
- [ ] light and dark as a token switch
- [ ] optimistic update with „Undo“
- [ ] correct register
- [ ] no hex value
- [ ] target sizes 28/44/56

On the Mac this includes full keyboard control, drag and drop too. In the app it includes Dynamic Type and VoiceOver. Conflicts are visible, but they never block.

## A new component

No component exists in one app only. Whatever is built in `apps/web`, in the Swift app or on the website goes into the system first, or at the latest in the same commit.

1. `components/<group>/<Name>.jsx` with every state: default, pressed, focused, disabled, empty, light and dark, child size where children use it.
2. `<Name>.d.ts` with German props.
3. `<Name>.prompt.md`: what it is for, which register, an example with the Sonntag family, the limits.
4. A `@dsCard` card in the folder and an entry in `_ds_manifest.json`.
5. The production version in the app, with the same name and the same props.
6. `pnpm qa`, a Storybook story, visual regression.

Changing an existing component means: read `.prompt.md` first, then the JSX, then the version in the app.

## Changing a token

1. Change the value in `tokens/quelle/*.json`, for colours light **and** dark.
2. Run `pnpm tokens`. The diff fails and shows the difference.
3. Record the change with date, context and reason in `docs/entscheidungen.md`.
4. Freeze the reference in `tokens/referenz/` again.
5. Check contrast and visual regression, then commit the generated files too.

A new role needs no decision, but a line with a reason in `ergaenzungen.txt`. An existing role only changes with a decision.

## Git and publishing

Commits come from the account `guido77-EnP`, in German, with the subject as a sentence. Every `git push` is confirmed first. Afterwards `tooling/deploy-pruefen.sh` checks the builds of albert-web, albert-site and the GitHub workflow. A red build is never left standing.

Screens, stories and examples always show the Sonntag family. Real family data never goes into the repository. The seed doubles as the login for Apple’s App Review.
