CLAUDE.md did not name the overzicht context that RD-03 created, and it did not name the max-lines budget that RD-02 enforces. An agent that follows it writes a long page into the wrong context, and meets a red build with no warning. CLAUDE.md now names the context, the @overzicht/* alias, the overzicht -> registratie arrow, the 250-line budget and its glob, and the step-component contract. layers.mdx gains the same arrow. atomic-design.mdx credited eslint.config.mjs with the layer rules. dependency-cruiser enforces them. Each tool is now named for what it does. The page also gains the layer-folder table and the step contract. Four paths were pre-monorepo: three example paths in the ui-component skill, and two citations of libs/shared/src/ui/async, which RD-27 moved to libs/shared/src/ui/molecules/async. npm run ci --full passes: 67 and 45 storybook suites, 306 axe tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
153 lines
9.2 KiB
Plaintext
153 lines
9.2 KiB
Plaintext
import { Meta, Canvas } from '@storybook/addon-docs/blocks';
|
|
import * as ButtonStories from '../src/ui/atoms/button/button.stories';
|
|
import * as FormFieldStories from '../src/ui/molecules/form-field/form-field.stories';
|
|
import * as PageShellStories from '../src/layout/page-shell/page-shell.stories';
|
|
import * as DocumentUploadStories from '../src/ui/organisms/upload/document-upload/document-upload.stories';
|
|
|
|
<Meta title="Foundations/Atomic Design" />
|
|
|
|
# Atomic design
|
|
|
|
Every screen in this app is built from a small set of layers, each composed **only from
|
|
the layer below it**. Read a screen top-down and you always land on the same handful of
|
|
atoms — that is the whole point: fewer things to understand, nothing bespoke per page.
|
|
|
|
<div style={{ display: 'grid', gap: '0.5rem', maxWidth: '32rem', margin: '1.5rem 0' }}>
|
|
{[
|
|
[
|
|
'Templates',
|
|
'shared/layout',
|
|
'shell, page-shell, wizard-shell — the page skeleton',
|
|
'#1e3a5f',
|
|
],
|
|
[
|
|
'Organisms',
|
|
'shared/ui/upload/document-upload …',
|
|
'self-contained sections that own a bit of behaviour',
|
|
'#2a5a8a',
|
|
],
|
|
['Molecules', 'shared/ui/form-field, async …', 'a label + control + error, grouped', '#3f7cb5'],
|
|
[
|
|
'Atoms',
|
|
'shared/ui/button, text-input …',
|
|
'thin wrappers over CIBG Huisstijl (Bootstrap) CSS classes',
|
|
'#6aa6d8',
|
|
],
|
|
].map(([name, where, why, bg], i) => (
|
|
<div
|
|
key={name}
|
|
style={{
|
|
background: bg,
|
|
color: '#fff',
|
|
padding: '0.75rem 1rem',
|
|
borderRadius: '6px',
|
|
marginLeft: `${i * 1.5}rem`,
|
|
}}
|
|
>
|
|
<strong>{name}</strong> <span style={{ opacity: 0.85 }}>— {why}</span>
|
|
<div
|
|
style={{ fontFamily: 'monospace', fontSize: '0.75rem', opacity: 0.8, marginTop: '0.2rem' }}
|
|
>
|
|
{where}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
## The rule, enforced
|
|
|
|
**Each layer only uses layers below it, and dependencies point inward.** This is not a
|
|
convention you have to remember — `npm run dep:check` (dependency-cruiser) fails the build if
|
|
`domain/` imports Angular, or if a context imports "upward". `eslint.config.mjs` enforces a
|
|
different rule: the `any` ban, and a `max-lines` budget (250 lines, `skipBlankLines`,
|
|
`skipComments`) on every `{apps,libs}/**/*.{page,component,section,step}.ts` file — reachable,
|
|
not a style-guide default, since the dashboard page lands at 42 lines. See
|
|
[the FP-in-the-UI primer](?path=/docs/foundations-fp-in-the-ui--docs) for how the same
|
|
discipline shapes state and effects.
|
|
|
|
## The layer folders
|
|
|
|
| Layer | Where |
|
|
| --------- | --------------------------------------------------------------------------------------------------------------------------- |
|
|
| Atoms | `libs/shared/src/ui/atoms/` |
|
|
| Molecules | `libs/shared/src/ui/molecules/` |
|
|
| Organisms | `libs/shared/src/ui/organisms/` |
|
|
| Templates | `libs/shared/src/layout/` — deliberately holds several layers; its own organisms are chrome that only its own templates use |
|
|
|
|
## The step-component contract
|
|
|
|
A wizard step follows the same rule as `address-fields.component.ts`: values in, events
|
|
out, no internal state.
|
|
|
|
1. **Inputs down.** A step reads its data only from `input()`s the container passes it.
|
|
2. **One narrow output up.** A step emits one specific event, not the container's whole
|
|
`dispatch`.
|
|
3. **`dispatch` is never passed down.** The container owns the Model and decides what a
|
|
step's event means; a step never calls `dispatch` itself.
|
|
|
|
Corollary: a step gets **no** story of its own. The wizard's own story already mounts every
|
|
step, because it seeds the machine.
|
|
|
|
## A composition chain, live
|
|
|
|
Here is one real chain from atom → molecule → template. Each is a published Storybook
|
|
story below; click through to the sidebar entries to explore every variant.
|
|
|
|
### Atom — `button`
|
|
|
|
A thin wrapper: we own a typed `variant` input, the CIBG CSS owns the pixels.
|
|
|
|
<Canvas of={ButtonStories.Primary} />
|
|
|
|
### Molecule — `form-field`
|
|
|
|
Label + control + error text, grouped so the error is announced via `role="alert"`. It
|
|
composes atoms; it adds no new visual primitives of its own.
|
|
|
|
<Canvas of={FormFieldStories.WithError} />
|
|
|
|
### Organism — `document-upload`
|
|
|
|
`shared/ui/upload/document-upload` composes molecules (a file input, alert, progress bar,
|
|
chips) into a section that owns real upload behaviour.
|
|
|
|
<Canvas of={DocumentUploadStories.Default} />
|
|
|
|
### Template — `page-shell`
|
|
|
|
The page skeleton — title, optional back-link, content slot. Pages drop composed
|
|
organisms into it; the template never knows what they are.
|
|
|
|
<Canvas of={PageShellStories.WithBackLink} />
|
|
|
|
## Why bother
|
|
|
|
A new page should be **composition of existing blocks**. Adding a new building block is the
|
|
exception, not the reflex — if you reach for one, that is a signal to check whether an
|
|
existing atom/molecule already covers it. Fewer primitives → less to test, less to learn,
|
|
one place to fix a bug.
|
|
|
|
## Convergence decisions — pairs that look duplicated but stay separate
|
|
|
|
Periodically we audit for near-duplicate blocks. Some collapse into one; a few **look**
|
|
similar but earn their separation. This table records the "don't merge these" verdicts so
|
|
the next person doesn't spend an afternoon re-deciding. (Deliberate CIBG-specific deviations
|
|
live in [CIBG gaps](?path=/docs/foundations-cibg-gap-register--docs); the FE⇄DS "same shape, different
|
|
context" cases in [Domain-driven design](?path=/docs/foundations-domain-driven-design--docs).)
|
|
|
|
| Pair | Why kept separate |
|
|
| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
| `choice-link` vs `application-link` | Share the same `to`/`clickable`/`activate` navigation triad, and both **are** the `<li>` (`selector: 'li[app-…]'`), but bind **different vendored patterns** — CIBG _Keuzelijst_ (`.keuzelijst__link`, `.stretched-link`) vs _Aanvragen_ (`.dashboard-block.applications li a`). Merging would fight the vendored CSS. Extract the shared triad into a mixin only if it grows. |
|
|
| `text-input` / `radio-group` / `checkbox` | Share only the standard Angular **ControlValueAccessor** boilerplate (the `writeValue`/`registerOn*`/`setDisabledState` block). They render genuinely different controls, so they stay three atoms. A base CVA class is the only DRY move — a refactor, not a component merge, and not worth it at three. |
|
|
| `button variant="subtle"` (`.btn-link`) vs `app-link` | A subtle button _looks_ like a link but is an **action** (`<button>`, emits click); `app-link` is **navigation** (`<a routerLink>`). Different semantics and a11y roles → keep both. |
|
|
| `shell` / `page-shell` / `wizard-shell` | Three distinct jobs that **compose**, not overlap: persistent app chrome (mounted once) → routed page body → the wizard form/step frame. |
|
|
| Raw `<h3>` in `application-link` vs the `heading` atom | The vendored `.applications li a h3` chain styles the **bare `<h3>`**; wrapping it in the `app-heading` host element would sit between the anchor and the h3 and can break that selector. This is the one sanctioned raw-heading; everywhere else uses `<app-heading [level]>`. |
|
|
|
|
Single-consumer shared blocks (e.g. `placeholder-chip`, `rich-text-editor`, `checkbox`, the
|
|
`task-list`/`choice-list`/`choice-link` family) currently have one consumer each. They stay in
|
|
`shared` as design-system primitives; relocate one into its consuming context only if it stays
|
|
single-consumer long-term. That is a watch-item, not a merge.
|
|
|
|
The last audit also **removed** a genuinely dead block — a generic white `app-card` with zero
|
|
consumers (superseded by the grey `app-data-block` as the single data surface).
|