New `brief` context — a letter-composition feature with a drafter/approver approval workflow, built as a teaching vertical slice on the repo's existing FP + Elm + atomic-design patterns (see plan in ~/.claude/plans). Domain (pure): - Rich text as a serialisable value tree (placeholders are first-class nodes), moved to @shared/kernel/rich-text.ts so the shared editor can use it. - lintPlaceholders: a pure, total content -> Diagnostic[] linter, derived never stored. - brief.machine.ts: status sum-type with guarded transitions; frozen-snapshot = deep value copy; derived diagnostics/editability. Full specs. Backend (.NET stub): - BriefStore + seed, GET/PUT /brief and submit/approve/reject/send endpoints, role via X-Role header (mirrors X-Admin), transition + approver!=drafter guards, audit logging. Regenerated typed client via gen:api. +6 backend tests. Seam: - brief.adapter.ts maps flat wire unions <-> domain discriminated unions at the parse boundary (+ spec). UI (atomic): - shared atoms: checkbox, placeholder-chip; molecule: rich-text-editor (no-dep contenteditable, DOM<->RichTextBlock round-trip tested). - brief/ui: letter-block, passage-picker, diagnostics-panel, rejection-comments, letter-section, letter-composer, letter-preview, brief.page + /brief route. - Dev-only ?role=drafter|approver toggle + roleInterceptor; dashboard nav link. Enforcement: @brief/* alias + eslint layer boundary (brief depends only on shared). Also included (same session): - Value-object specs (postcode/uren/big-nummer) — closes the "domain must have a spec" gap. - src/docs/ Storybook MDX foundation pages (atomic design, tokens, FP-in-UI). - .storybook/tsconfig.json: add @angular/localize to types (Storybook was fully broken — $localize unresolved — dev + build). Verified: 168 FE tests, 68 backend tests, lint/build/check:tokens green, Storybook boots, end-to-end HTTP smoke (self-approve 403, approver 200, full flow). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
72 lines
3.2 KiB
Plaintext
72 lines
3.2 KiB
Plaintext
import { Meta, Canvas } from '@storybook/addon-docs/blocks';
|
|
import * as ButtonStories from '../app/shared/ui/button/button.stories';
|
|
import * as FormFieldStories from '../app/shared/ui/form-field/form-field.stories';
|
|
import * as PageShellStories from '../app/shared/layout/page-shell/page-shell.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 Utrecht/RHC 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 RHC 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, status banner,
|
|
progress bar, chips) into a section that owns real upload behaviour. It has no story yet
|
|
(Track A backlog); read it at `src/app/shared/ui/upload/document-upload/`.
|
|
|
|
### 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.
|