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'; # 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.
{[ [ '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) => (
{name} — {why}
{where}
))}
## 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. ### 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. ### 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. ### 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. ## 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 `
  • ` (`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** (`