Files
atomic-design-poc/libs/shared/docs/atomic-design.mdx
T
ehoandClaude Sonnet 5 43dc3210cd refactor: move libs/shared/src/ui/ into atoms/molecules/organisms (RD-27)
The folder now equals the layer, as CLAUDE.md decision 2 requires. 33
directories move by git mv (25 flat, plus upload/'s 8 subfolders split
across all three layers). 28 distinct @shared/ui/* specifiers rewrite
across 73 files, longest-first. Five relative imports inside upload/
become @shared/ui aliases because their sibling now lives in a
different layer; two stay relative because both ends stay in the same
layer. Four .mdx docs get their seven broken story imports fixed;
atomic-design.mdx's page-shell import is untouched, because layout/
does not move.

No component, template, story title, or layer-tag comment changes.
That is RD-28's job.

Verified against the ticket's acceptance commands: the 26 flat
directories become exactly 3 layer folders with the counts the ticket
names, only three @shared/ui/* prefixes remain (atoms, molecules,
organisms), the .mdx import count holds at 7, and the relative-import
count inside ui/ drops from 7 to 2 as decision 4 requires. The
@shared/ui/ occurrence count moves from 200 to 205: decision 4
mandates turning 5 of those 7 relative imports into @shared/ui/*
aliases, which decision 3's "200 before, 200 after" check does not
account for. The 5-occurrence gap is exactly the 5 conversions decision
4 names, not a lost or duplicated specifier.

npm run ci --full passes: lint, typecheck, dep:check, format, tokens,
seam, both apps' + both libraries' tests, both apps' localized build,
audit, backend tests, all three generated-artifact drift checks, and
both Storybook instances' build + axe-core a11y suite (67+45 suites,
198+112 tests, all green).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 08:14:10 +02:00

126 lines
7.9 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 — `eslint.config.mjs` fails the build if `domain/` imports
Angular, or if a context imports "upward". See [the FP-in-the-UI primer](?path=/docs/foundations-fp-in-the-ui--docs)
for how the same discipline shapes state and effects.
## 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, but bind **different vendored patterns** — CIBG _Keuzelijst_ (`.keuzelijst__link`, `.stretched-link`) vs _Aanvragen_ (`.dashboard-block.applications li a`) — with different list/host semantics (`app-choice-link` renders an inner `<li>`; `application-link` **is** the `<li>`). 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).