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.
|
||||
76
src/docs/design-tokens.mdx
Normal file
76
src/docs/design-tokens.mdx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Meta } from '@storybook/addon-docs/blocks';
|
||||
import { useState, useLayoutEffect, useRef } from 'react';
|
||||
|
||||
<Meta title="Foundations/Design Tokens" />
|
||||
|
||||
# Design tokens
|
||||
|
||||
We do not hand-write colours or spacing. The Rijkshuisstijl (RHC) design system ships them
|
||||
as CSS custom properties; `src/styles.scss` imports them and adds a thin `--app-*` layer for
|
||||
the few measures RHC has no token for. `npm run check:tokens` fails the build on any
|
||||
hardcoded hex colour in atoms/molecules/chrome — so these tokens are the *only* source of
|
||||
colour.
|
||||
|
||||
> Resolved values below are read live from the running theme via `getComputedStyle`, so they
|
||||
> can't drift from what ships. All demos are wrapped in `.rhc-theme.lintblauw`, the same scope
|
||||
> the app runs under.
|
||||
|
||||
export const Resolved = ({ token }) => {
|
||||
const ref = useRef(null);
|
||||
const [val, setVal] = useState('');
|
||||
useLayoutEffect(() => {
|
||||
if (ref.current) setVal(getComputedStyle(ref.current).getPropertyValue(token).trim());
|
||||
}, [token]);
|
||||
return <span ref={ref} style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#666' }}>{val || '…'}</span>;
|
||||
};
|
||||
|
||||
## When to use which token
|
||||
|
||||
- **Semantic first** — reach for a role token (`--rhc-color-foreground-default`,
|
||||
`--rhc-color-border-default`, `--rhc-color-core`) before a raw palette step. Roles survive
|
||||
a theme swap; palette steps don't.
|
||||
- **`--rhc-space-max-*`** for all spacing/gaps — never a raw `rem`.
|
||||
- **`--app-*`** (in `src/styles.scss`) only for app measures RHC has no token for
|
||||
(`--app-content-max`, `--app-form-max`). If you're tempted to add one, check RHC first.
|
||||
|
||||
<div className="rhc-theme lintblauw">
|
||||
|
||||
## Spacing scale — `--rhc-space-max-*`
|
||||
|
||||
<div style={{ display: 'grid', gap: '0.4rem', margin: '1rem 0' }}>
|
||||
{['2xs','xs','sm','md','lg','xl','2xl','3xl','4xl','5xl'].map((step) => {
|
||||
const token = `--rhc-space-max-${step}`;
|
||||
return (
|
||||
<div key={step} style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||
<code style={{ width: '12rem', fontSize: '0.78rem' }}>{token}</code>
|
||||
<div style={{ height: '1rem', width: `var(${token})`, background: 'var(--rhc-color-core)', borderRadius: '2px' }} />
|
||||
<Resolved token={token} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
## Semantic colours
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(14rem, 1fr))', gap: '0.75rem', margin: '1rem 0' }}>
|
||||
{[
|
||||
'--rhc-color-foreground-default',
|
||||
'--rhc-color-foreground-subtle',
|
||||
'--rhc-color-foreground-link',
|
||||
'--rhc-color-core',
|
||||
'--rhc-color-core-hover',
|
||||
'--rhc-color-border-default',
|
||||
'--rhc-color-border-strong',
|
||||
'--rhc-color-bg-document',
|
||||
].map((token) => (
|
||||
<div key={token} style={{ border: '1px solid #ddd', borderRadius: '6px', overflow: 'hidden' }}>
|
||||
<div style={{ height: '3rem', background: `var(${token})` }} />
|
||||
<div style={{ padding: '0.4rem 0.5rem' }}>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.72rem', wordBreak: 'break-all' }}>{token}</div>
|
||||
<Resolved token={token} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
63
src/docs/fp-in-ui.mdx
Normal file
63
src/docs/fp-in-ui.mdx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { Meta, Canvas } from '@storybook/addon-docs/blocks';
|
||||
import * as AsyncStories from '../app/shared/ui/async/async.stories';
|
||||
|
||||
<Meta title="Foundations/FP in the UI" />
|
||||
|
||||
# Functional programming in the UI
|
||||
|
||||
The components in this library are the *view*. Behind them, three small functional tools do
|
||||
the heavy lifting — all so that **illegal states can't be represented**. This page is the
|
||||
Storybook front door; the full narrative lives in `docs/fp-tea-atomic-design.md`, and a
|
||||
side-by-side "before/after" runs at the app's **`/concepts`** route.
|
||||
|
||||
## 1. `RemoteData<E,T>` — async has four states, not a boolean soup
|
||||
|
||||
`src/app/shared/application/remote-data.ts`. Instead of juggling `loading`, `error`, and
|
||||
`data` flags (which permit "loading **and** error" nonsense), one tagged union:
|
||||
`Loading | Empty | Failure | Success`. You combine sources with `map`/`map2`/`andThen` and
|
||||
render it through the `async` molecule — exactly one of four templates shows, by
|
||||
construction:
|
||||
|
||||
<Canvas of={AsyncStories.Loading} />
|
||||
<Canvas of={AsyncStories.ErrorState} />
|
||||
|
||||
## 2. The Elm-style store — all state in one Model, changed only by pure `reduce`
|
||||
|
||||
`src/app/shared/application/store.ts` + the `*.machine.ts` files. State is one tagged-union
|
||||
value; the template never mutates it, it `dispatch`es a message and a **pure**
|
||||
`reduce(model, msg)` returns the next state. Side effects live in a *command*, never in the
|
||||
reducer:
|
||||
|
||||
```ts
|
||||
// reducer = "what the new state is" — pure, testable, no I/O
|
||||
function reduce(model: Model, msg: Msg): Model { … }
|
||||
|
||||
// command = "go do it, then say what happened"
|
||||
async function submit(...) {
|
||||
const res = await http(...);
|
||||
dispatch(res.ok ? { tag: 'Submitted' } : { tag: 'Failed', error: res.error });
|
||||
}
|
||||
```
|
||||
|
||||
Because state is one value, the whole thing is inspectable and every transition has a spec.
|
||||
|
||||
## 3. Parse, don't validate — raw input becomes a branded type once
|
||||
|
||||
`src/app/registratie/domain/value-objects/`. A `Postcode` is a distinct type from `string`,
|
||||
mintable only through `parsePostcode`, which returns a `Result`. Once you hold the type, you
|
||||
never re-check it — the type *is* the proof. Compose the parse pipeline with the `Result`
|
||||
combinators in `src/app/shared/kernel/fp.ts` (`map`, `mapErr`, `andThen`, `fold`) rather than
|
||||
hand-branching `r.ok ? … : …` at every step.
|
||||
|
||||
```ts
|
||||
parsePostcode(raw) // Result<string, Postcode>
|
||||
|> mapErr(toLocalizedMessage) // swap raw msg → UI copy
|
||||
|> map(toDomain) // only runs on success
|
||||
```
|
||||
|
||||
## How it connects to atomic design
|
||||
|
||||
Atoms and molecules are pure view functions of their inputs; pages are the TEA runtime (the
|
||||
"shell") that holds the store and wires effects. Same inward-pointing discipline as the
|
||||
[layer rule](?path=/docs/foundations-atomic-design--docs), applied to state and effects
|
||||
instead of imports.
|
||||
Reference in New Issue
Block a user