feat(brief): letter composition + two-person approval (teaching slice)
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>
This commit is contained in:
71
src/docs/atomic-design.mdx
Normal file
71
src/docs/atomic-design.mdx
Normal file
@@ -0,0 +1,71 @@
|
||||
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.
|
||||
Reference in New Issue
Block a user