@juwel-development/design-system 1.1.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/design-system.js +541 -36
  2. package/dist/index.css +1 -1
  3. package/dist/types/Display/Brandmark/Brandmark.d.ts +50 -0
  4. package/dist/types/Display/Checklist/Checklist.d.ts +26 -0
  5. package/dist/types/Display/DefinitionList/DefinitionList.d.ts +41 -0
  6. package/dist/types/Display/Figure/Figure.d.ts +52 -0
  7. package/dist/types/Display/Rail/Rail.d.ts +54 -0
  8. package/dist/types/Display/Table/Table.d.ts +61 -0
  9. package/dist/types/Display/Typography/Eyebrow/Eyebrow.d.ts +26 -0
  10. package/dist/types/Display/Typography/H1/H1.d.ts +27 -0
  11. package/dist/types/Display/Typography/H2/H2.d.ts +24 -0
  12. package/dist/types/Display/Typography/H3/H3.d.ts +23 -0
  13. package/dist/types/Display/Typography/H4/H4.d.ts +22 -0
  14. package/dist/types/Display/Typography/H5/H5.d.ts +23 -0
  15. package/dist/types/Display/Typography/H6/H6.d.ts +23 -0
  16. package/dist/types/Display/Typography/P/P.d.ts +25 -0
  17. package/dist/types/Display/Typography/Prose/Prose.d.ts +42 -0
  18. package/dist/types/Interaction/Button/Button.d.ts +7 -3
  19. package/dist/types/Interaction/Input/Input.d.ts +29 -0
  20. package/dist/types/Interaction/Link/Link.d.ts +27 -0
  21. package/dist/types/Interaction/TextArea/TextArea.d.ts +25 -0
  22. package/dist/types/Layout/Footer/Footer.d.ts +34 -0
  23. package/dist/types/Layout/Form/Form.d.ts +31 -0
  24. package/dist/types/Layout/Header/Header.d.ts +41 -0
  25. package/dist/types/Layout/Hero/Hero.d.ts +46 -0
  26. package/dist/types/Layout/PageHead/PageHead.d.ts +36 -0
  27. package/dist/types/Layout/Section/Section.d.ts +38 -0
  28. package/dist/types/Theme/Palette.d.ts +25 -6
  29. package/dist/types/index.d.ts +25 -1
  30. package/package.json +1 -1
  31. package/src/Display/.gitkeep +0 -0
  32. package/src/Display/Brandmark/Brandmark.tsx +97 -0
  33. package/src/Display/Checklist/Checklist.tsx +75 -0
  34. package/src/Display/DefinitionList/DefinitionList.tsx +90 -0
  35. package/src/Display/Figure/Figure.tsx +116 -0
  36. package/src/Display/Rail/Rail.tsx +108 -0
  37. package/src/Display/Table/Table.tsx +191 -0
  38. package/src/Display/Typography/Eyebrow/Eyebrow.tsx +46 -0
  39. package/src/Display/Typography/H1/H1.tsx +46 -0
  40. package/src/Display/Typography/H2/H2.tsx +43 -0
  41. package/src/Display/Typography/H3/H3.tsx +41 -0
  42. package/src/Display/Typography/H4/H4.tsx +40 -0
  43. package/src/Display/Typography/H5/H5.tsx +41 -0
  44. package/src/Display/Typography/H6/H6.tsx +41 -0
  45. package/src/Display/Typography/P/P.tsx +38 -0
  46. package/src/Display/Typography/Prose/Prose.tsx +91 -0
  47. package/src/Interaction/Button/Button.tsx +15 -10
  48. package/src/Interaction/Input/Input.tsx +103 -0
  49. package/src/Interaction/Link/Link.tsx +70 -0
  50. package/src/Interaction/TextArea/TextArea.tsx +93 -0
  51. package/src/Layout/.gitkeep +0 -0
  52. package/src/Layout/Footer/Footer.tsx +60 -0
  53. package/src/Layout/Form/Form.tsx +102 -0
  54. package/src/Layout/Header/Header.tsx +84 -0
  55. package/src/Layout/Hero/Hero.tsx +73 -0
  56. package/src/Layout/PageHead/PageHead.tsx +79 -0
  57. package/src/Layout/Section/Section.tsx +79 -0
  58. package/src/Theme/Palette.ts +38 -12
  59. package/src/Theme/renderTokens.ts +179 -2
  60. package/src/index.ts +25 -1
  61. package/src/tokens.css +113 -9
@@ -0,0 +1,60 @@
1
+ import type { VariantProps } from 'class-variance-authority';
2
+ import { cva } from 'class-variance-authority';
3
+ import type { FunctionComponent, ReactNode } from 'react';
4
+
5
+ // One recipe on the <footer>. The type is set here, not borrowed from Header's label role: this slot is
6
+ // opaque and legitimately holds a sentence, and --tracking-label across one is damage, not a device (ADR
7
+ // 0004). `edge` is a variant, not a conditional: a footer is always last, exactly once, so there is no
8
+ // "first footer" for a data-section-style selector to suppress, and its predecessor is unpredictable - a
9
+ // conditional would silently drop the hairline. It draws in `rule`, the page's single hairline weight
10
+ // Section joins and Header's bottom edge share. Colours are semantic tokens re-pointed by `.dark`.
11
+ const footer = cva(
12
+ 'font-secondary text-small text-muted py-[var(--space-region)] px-[var(--gutter)]',
13
+ {
14
+ variants: {
15
+ edge: {
16
+ none: '',
17
+ rule: 'border-t border-solid border-rule',
18
+ },
19
+ },
20
+ defaultVariants: { edge: 'rule' },
21
+ },
22
+ );
23
+
24
+ export interface IFooterProps extends VariantProps<typeof footer> {
25
+ children?: ReactNode;
26
+ testId?: string;
27
+ }
28
+
29
+ /**
30
+ * The shell's bottom edge: a `<footer>` that owns the `contentinfo` landmark, the small type, the shell's
31
+ * vertical air and the gutter, and its own top rule - and nothing about what is inside it. It is a frame,
32
+ * not an arrangement: `children` are placed directly in the footer with no wrapper and no `<nav>`.
33
+ *
34
+ * @Guarantees — enforced on every render
35
+ * - The footer is set at the `small` role and carries no size of its own.
36
+ * - Children are placed unmodified, directly inside the `<footer>`, with no wrapping element.
37
+ * - The top edge is the caller's choice - `edge="rule"` (the default) or `edge="none"` - and when drawn is
38
+ * always the page's single hairline weight, in the `rule` colour Section's join uses.
39
+ * - It is never sticky or fixed and needs no JavaScript.
40
+ *
41
+ * @CallerMustEnsure
42
+ * - The footer is a direct child of the page, not nested inside `main`, `article`, `aside`, `nav` or a
43
+ * `Section`. Nesting silently demotes it out of the `contentinfo` landmark, and the component cannot
44
+ * detect this.
45
+ *
46
+ * @UXGuidelines
47
+ * - Supply your own `<nav aria-label="…">` inside the slot if the footer navigates - a footer's contents
48
+ * are frequently not navigation, so this component declares no nav landmark over them. Where the footer
49
+ * does navigate, name `Header`'s nav with `navName` too: two unnamed navigation landmarks are
50
+ * indistinguishable to a screen-reader user.
51
+ */
52
+ export const Footer: FunctionComponent<IFooterProps> = ({
53
+ edge,
54
+ children,
55
+ testId,
56
+ }) => (
57
+ <footer className={footer({ edge })} data-testid={testId}>
58
+ {children}
59
+ </footer>
60
+ );
@@ -0,0 +1,102 @@
1
+ import { cva } from 'class-variance-authority';
2
+ import type { FunctionComponent, ReactNode } from 'react';
3
+
4
+ /** Which of the four states the page has put the form in. Owned by the page, never by Form: the
5
+ * component renders the state it is given and never decides which one it is in. */
6
+ export type FormState = 'idle' | 'sending' | 'sent' | 'failed';
7
+
8
+ // The one recipe, and it paints the note only (issue #6): `state` selects the note's tone - muted
9
+ // while the form stands, success on the outcome, error on the failure. Region selection is the
10
+ // NOTE_ROLE map below, not classes, so this axis never grows a layout job. Colours are semantic
11
+ // tokens re-pointed by `.dark`, so no variant carries a `dark:` class.
12
+ const form = cva('text-sm', {
13
+ variants: {
14
+ state: {
15
+ idle: 'text-muted',
16
+ sending: 'text-muted',
17
+ sent: 'text-success',
18
+ failed: 'text-error',
19
+ },
20
+ },
21
+ defaultVariants: { state: 'idle' },
22
+ });
23
+
24
+ // The note's ARIA role: a settled outcome is a status region, a failure an alert; while the form
25
+ // still stands the note is plain prose and carries none.
26
+ const NOTE_ROLE: Partial<Record<FormState, 'status' | 'alert'>> = {
27
+ sent: 'status',
28
+ failed: 'alert',
29
+ };
30
+
31
+ interface IFormProps {
32
+ /** Where the submission goes. Passed straight to the form element; Form makes no decision about it. */
33
+ action: string;
34
+ method?: 'get' | 'post';
35
+ state?: FormState;
36
+ /** The fields. */
37
+ children?: ReactNode;
38
+ /** The actions row - the consumer supplies its own submit Button. */
39
+ actions?: ReactNode;
40
+ /** Standing note in `idle`/`sending`; the outcome message in `sent`/`failed`. */
41
+ note?: string;
42
+ testId?: string;
43
+ }
44
+
45
+ /**
46
+ * The container around a set of fields: fields, an actions row, a note. It renders a real `<form>`
47
+ * carrying `action` and `method`, so a native submission works with no JavaScript, and it renders
48
+ * the `state` it is given without ever owning a submission, a transport or validation timing - both
49
+ * a server-rendered driver and a hydrated island express every state through the same props.
50
+ *
51
+ * `sent` drops the fields and actions so a completed submission cannot be resubmitted, while the
52
+ * `<form>` itself is retained in every state so the DOM shape is stable across a runtime change.
53
+ *
54
+ * The note's text is always the consumer's; Form chooses only its element and ARIA role. `sent` is
55
+ * the outcome message, so a `sent` with no `note` is a programmer error and throws.
56
+ */
57
+ export const Form: FunctionComponent<IFormProps> = ({
58
+ action,
59
+ method = 'post',
60
+ state = 'idle',
61
+ children,
62
+ actions,
63
+ note,
64
+ testId,
65
+ }) => {
66
+ if (state === 'sent' && !note) {
67
+ throw new Error(
68
+ 'Form in the `sent` state must be given a `note` - it is the outcome message.',
69
+ );
70
+ }
71
+
72
+ const showFields = state !== 'sent';
73
+ const noteRole = NOTE_ROLE[state];
74
+
75
+ return (
76
+ <form
77
+ action={action}
78
+ method={method}
79
+ aria-busy={state === 'sending' || undefined}
80
+ data-testid={testId}
81
+ className={
82
+ 'flex max-w-[var(--measure)] flex-col gap-[var(--space-region)]'
83
+ }
84
+ >
85
+ {showFields && (
86
+ <div className={'flex flex-col gap-[var(--space-stack)]'}>
87
+ {children}
88
+ </div>
89
+ )}
90
+ {showFields && actions && (
91
+ <div className={'flex flex-row gap-[var(--space-stack)]'}>
92
+ {actions}
93
+ </div>
94
+ )}
95
+ {note && (
96
+ <p role={noteRole} className={form({ state })}>
97
+ {note}
98
+ </p>
99
+ )}
100
+ </form>
101
+ );
102
+ };
@@ -0,0 +1,84 @@
1
+ import type { VariantProps } from 'class-variance-authority';
2
+ import { cva } from 'class-variance-authority';
3
+ import type { FunctionComponent, ReactNode } from 'react';
4
+
5
+ // One recipe on the <header>. It sets the label type role - the "small grotesk, letter-spaced, muted"
6
+ // the issue described, whose "never grows past 1rem" was a role wearing a number, so no size literal
7
+ // appears here (#14). The shell's air is one value in every direction: --space-region above, below and
8
+ // (on the nav) between, --gutter across, so the bar aligns with every inset Section. The current-page
9
+ // treatment keys on the attribute, not on a component: [&_[aria-current=page]] compiles to specificity
10
+ // 0,2,0 and beats Link's quiet text-muted (0,1,0) with no !important and no import, so the current item
11
+ // sits at foreground - the colour every other item reaches only on hover. `edge` draws the bottom
12
+ // hairline in `rule`, the one weight a page's boundaries share with a Section join, and defaults to
13
+ // `rule` - the conventional header - so a ruleless shell opts out. Colours are semantic tokens re-pointed
14
+ // by `.dark`, so no `dark:` class.
15
+ const header = cva(
16
+ [
17
+ 'flex items-baseline justify-between',
18
+ 'font-secondary text-label tracking-label text-muted',
19
+ 'py-[var(--space-region)] px-[var(--gutter)]',
20
+ '[&_[aria-current=page]]:text-foreground',
21
+ ].join(' '),
22
+ {
23
+ variants: {
24
+ edge: {
25
+ none: '',
26
+ rule: 'border-b border-solid border-rule',
27
+ },
28
+ },
29
+ defaultVariants: { edge: 'rule' },
30
+ },
31
+ );
32
+
33
+ export interface IHeaderProps extends VariantProps<typeof header> {
34
+ /** The standing link: a place name, a mark, or a home link. The consumer supplies the whole anchor -
35
+ * Header renders no link of its own. A place name uses `<Link treatment="quiet" href="/">…</Link>`; a
36
+ * mark uses `<Link treatment="graphic" href="/"><Brandmark …/></Link>`, whose `graphic` treatment
37
+ * paints nothing over a `currentColor` mark. */
38
+ standing?: ReactNode;
39
+ /** Names the nav for assistive technology. Omit unless the page has more than one nav. */
40
+ navName?: string;
41
+ /** The nav links. */
42
+ children?: ReactNode;
43
+ testId?: string;
44
+ }
45
+
46
+ /**
47
+ * The shell's top edge: a standing link and a nav, at the label type role. It renders the banner
48
+ * landmark and a single `<nav>`, arranging nothing beyond the two slots, so it works with no hydration.
49
+ *
50
+ * @Guarantees — enforced on every render
51
+ * - The header is set at the label role and carries no size of its own: `font-secondary text-label
52
+ * tracking-label text-muted`, so it never grows past that role whatever the page font size.
53
+ * - A nav item marked `aria-current="page"` renders at `foreground` whatever supplied it - the treatment
54
+ * keys on the attribute, not on `Link`, so it holds against a bare anchor or any component.
55
+ * - The shell's air is one value above, below and between: `--space-region` vertically and between nav
56
+ * items, `--gutter` across, so the bar aligns with every inset `Section`.
57
+ * - It is never sticky and needs no JavaScript: there is no `sticky` variant and nothing to hydrate.
58
+ * - Omitting `navName` emits no `aria-label` at all, not an empty one.
59
+ *
60
+ * @UXGuidelines
61
+ * - Name the nav with `navName` once the page has more than one navigation landmark - a footer nav will
62
+ * be the second, and two unnamed navs are indistinguishable to a screen-reader user.
63
+ * - Nav links use `Link`'s `quiet` treatment. The current-page treatment is applied here from
64
+ * `aria-current`, so set `current` on the `Link` and style nothing yourself.
65
+ * - The nav does not collapse into a menu: a disclosure needs JavaScript, so on a narrow viewport the
66
+ * items wrap. A mobile menu is out of scope, not a follow-up.
67
+ */
68
+ export const Header: FunctionComponent<IHeaderProps> = ({
69
+ edge,
70
+ standing,
71
+ navName,
72
+ children,
73
+ testId,
74
+ }) => (
75
+ <header className={header({ edge })} data-testid={testId}>
76
+ <div>{standing}</div>
77
+ <nav
78
+ aria-label={navName}
79
+ className={'flex flex-wrap items-baseline gap-[var(--space-region)]'}
80
+ >
81
+ {children}
82
+ </nav>
83
+ </header>
84
+ );
@@ -0,0 +1,73 @@
1
+ import type { VariantProps } from 'class-variance-authority';
2
+ import { cva } from 'class-variance-authority';
3
+ import type { FunctionComponent, ReactNode } from 'react';
4
+
5
+ // One recipe on a plain <div>. It holds the fold - a minimum height taller than its content, so leftover
6
+ // space is guaranteed and how the slot sits in it is a decision, not a default (#17). With no className
7
+ // escape hatch, whatever place picks is final, so it is a variant: center is the conventional hero and the
8
+ // default; between drops a poster's foot on the fold's bottom edge; start asks for the minimum height
9
+ // without the centring. `end` is deliberately absent - content pinned to the bottom over empty space is
10
+ // not a hero. It reads --fold-height for the floor and --space-stack for the gap Prose and PageHead use.
11
+ const hero = cva(
12
+ 'flex flex-col min-h-[var(--fold-height)] gap-[var(--space-stack)]',
13
+ {
14
+ variants: {
15
+ place: {
16
+ start: 'justify-start',
17
+ center: 'justify-center',
18
+ between: 'justify-between',
19
+ },
20
+ },
21
+ defaultVariants: { place: 'center' },
22
+ },
23
+ );
24
+
25
+ export interface IHeroProps extends VariantProps<typeof hero> {
26
+ /** The fold's one opaque slot. Rendered unmodified: a poster composes a mark, an `H1` lead and a foot
27
+ * here; another brand composes something else. `Hero` imposes no anatomy. */
28
+ children: ReactNode;
29
+ testId?: string;
30
+ }
31
+
32
+ /**
33
+ * The first screen's frame: a plain container that holds at least the fold height and places one opaque
34
+ * slot within it. It renders no heading and no landmark - a hero's lead is an `<H1>` the consumer places
35
+ * in the slot, and the `Section` around it owns the region, the bleed, the band and the gutter. A hero
36
+ * varies in its structure where a footer varies only in its contents, so there is no shared anatomy to
37
+ * model and the opaque slot is the honest answer.
38
+ *
39
+ * @Guarantees — enforced on every render
40
+ * - It is at least `--fold-height` tall and never taller by construction: a `min-height` floor, not a
41
+ * fixed height, so content longer than the fold grows the frame rather than overflowing it.
42
+ * - `children` render unmodified; the component adds nothing to and strips nothing from them, and sets no
43
+ * margin, max-width, colour or heading of its own.
44
+ * - It owns no bleed, band or gutter and emits no `<section>` and no landmark role - the enclosing
45
+ * `Section` carries those.
46
+ * - `place` positions the slot in the leftover space: `center` (the default), `start` or `between`; the
47
+ * parts are gapped with `--space-stack`.
48
+ * - It is never sticky or fixed and needs no JavaScript, so it renders identically server-side.
49
+ *
50
+ * @CallerMustEnsure — the component cannot see these and does not check them
51
+ * - The hero sits inside a `Section` - `<Section bleed="full"><Hero>…</Hero></Section>` - which owns the
52
+ * bleed, the band, the gutter and the region. `Hero` imports no component and adds none of these.
53
+ *
54
+ * @UXGuidelines
55
+ * - Cap the lead at `--measure-display`, not at a caption's width, and apply it to your own lead - not
56
+ * here, since capping the slot would cap the mark too. Measured on the lockup that prompted this: at
57
+ * 24ch a one-sentence lead set five lines and became a second block competing with the mark; at 36ch it
58
+ * sets three and reads as the mark's caption. This is the one place where copy length is a layout
59
+ * parameter - a hero whose lead sentence can vary in length needs its measure chosen against the real
60
+ * sentence, not a rule of thumb.
61
+ * - A hero built on a wide lockup is a desktop bet by construction. A 15-character lockup can only be as
62
+ * wide as the phone, so at 390px the scale contrast between mark and lead is capped by the viewport
63
+ * rather than chosen - roughly 2:1, against roughly 6:1 at 1440.
64
+ */
65
+ export const Hero: FunctionComponent<IHeroProps> = ({
66
+ place,
67
+ children,
68
+ testId,
69
+ }) => (
70
+ <div className={hero({ place })} data-testid={testId}>
71
+ {children}
72
+ </div>
73
+ );
@@ -0,0 +1,79 @@
1
+ import { cva } from 'class-variance-authority';
2
+ import type { FunctionComponent, ReactNode } from 'react';
3
+
4
+ // One recipe on the <header>, one on each element it paints. The head is full-bleed: it carries the
5
+ // band and the gutter every page unit shares but takes no max-width and joins no grid, so it opts out
6
+ // of the index column a default Section sits in (#9) by spanning the full content width - the text, not
7
+ // the head, owns every measure. The title is the title role, one rung below the hero's display: the cap
8
+ // on out-scaling the homepage hero is the display > title token relationship renderTokens pins (ADR
9
+ // 0004/0005), never a local clamp, so tracking-optical is the head's only optical touch and no size
10
+ // literal appears. That tracking is the title role's, not this component's (#57): H2 binds to the same
11
+ // role and carries the same correction, so the role reads one way wherever it is rendered. The stack gap is --space-stack, the sibling gap Prose uses. Colours are semantic
12
+ // tokens re-pointed by `.dark`, so no `dark:` class.
13
+ const pageHead = cva(
14
+ 'flex flex-col gap-[var(--space-stack)] py-[var(--space-band)] px-[var(--gutter)]',
15
+ );
16
+
17
+ // The scale event: the title role, led and tracked as a large heading, foreground. No measure - the
18
+ // head is full-bleed and the consumer keeps titles short.
19
+ const pageHeadTitle = cva(
20
+ 'font-primary text-title leading-title tracking-optical text-foreground',
21
+ );
22
+
23
+ // The standfirst: the lede role, foreground, run one measure wider than the reading column (#18).
24
+ const pageHeadLede = cva(
25
+ 'font-primary text-lede leading-lede text-foreground max-w-[var(--measure-wide)]',
26
+ );
27
+
28
+ // The small print: the small role, muted, held to the reading measure like Prose's tail.
29
+ const pageHeadIntro = cva(
30
+ 'font-primary text-small text-muted max-w-[var(--measure)]',
31
+ );
32
+
33
+ export interface IPageHeadProps {
34
+ /** The page title - the head's one scale event, rendered as the page's `h1` at the title role. */
35
+ title: ReactNode;
36
+ /** The standfirst under the title: a lede-role paragraph at a measure slightly wider than the
37
+ * reading column. Omit for a bare title. */
38
+ lede?: ReactNode;
39
+ /** The muted small-print paragraph under the lede, held to the reading measure. Omit where the head
40
+ * is title and lede only. */
41
+ intro?: ReactNode;
42
+ testId?: string;
43
+ }
44
+
45
+ /**
46
+ * A subpage's opening: one scale event, then small print. It renders a `<header>` carrying the page's
47
+ * `h1`, an optional lede standfirst and an optional muted intro, full-bleed over the vertical band and
48
+ * the gutter. It arranges the three slots and owns their type roles; the consumer supplies the copy.
49
+ *
50
+ * @Guarantees — enforced on every render
51
+ * - The title is an `h1` at the `title` role - one rung below the hero's `display` - so it can never
52
+ * out-scale the homepage hero: the cap is the `display > title` token relationship (ADR 0004/0005),
53
+ * not two `clamp()`s ordered by luck, and no size literal is set here.
54
+ * - The head is full-bleed: it carries `--space-band` and `--gutter` but takes no `max-width` and joins
55
+ * no grid, so it opts out of the index column and spans the full content width.
56
+ * - The lede is the `lede` role at `--measure-wide`, one measure wider than the reading column; the
57
+ * intro is the `small` role, muted, at `--measure`. Each measure is on the text, never on the head.
58
+ * - Omitting `lede` or `intro` renders that paragraph not at all.
59
+ * - It is never sticky or fixed and needs no JavaScript.
60
+ *
61
+ * @CallerMustEnsure — the component cannot see these and does not check them
62
+ * - The head sits inside the page's main content (a `main`, `article` or `section`), not as a top-level
63
+ * child of `body`, so its `<header>` is the page head and not a second `banner` beside the shell's
64
+ * `Header` (#14).
65
+ * - This head opens a subpage. The homepage hero is `PosterFold` (#17), which renders at the `display`
66
+ * role; reach for that where the page is the poster, not for this.
67
+ */
68
+ export const PageHead: FunctionComponent<IPageHeadProps> = ({
69
+ title,
70
+ lede,
71
+ intro,
72
+ testId,
73
+ }) => (
74
+ <header className={pageHead()} data-testid={testId}>
75
+ <h1 className={pageHeadTitle()}>{title}</h1>
76
+ {lede !== undefined && <p className={pageHeadLede()}>{lede}</p>}
77
+ {intro !== undefined && <p className={pageHeadIntro()}>{intro}</p>}
78
+ </header>
79
+ );
@@ -0,0 +1,79 @@
1
+ import type { VariantProps } from 'class-variance-authority';
2
+ import { cva } from 'class-variance-authority';
3
+ import type { FunctionComponent, ReactNode } from 'react';
4
+
5
+ // One recipe on the <section>. The join is the load-bearing part: a top rule is drawn only when the
6
+ // immediately preceding sibling also carries data-section, so it appears between two sections and never
7
+ // above the first - and never beneath a header, whose own rule is a direction-level choice (#14), not a
8
+ // default here. The [[data-section]+&] selector says exactly "every section after the first", which
9
+ // :not(:first-child) does not - that would rule beneath anything at all. It reads `rule`, not `border`:
10
+ // a section join carries the whole load a gap would, and at page scale a 1.23:1 line may not read as a
11
+ // separation. The rule spans the full element width in both bleed variants, since a border sits outside
12
+ // padding - the join is the page's structure, so it does not inset with the content. `bleed` is the only
13
+ // axis: inset holds content off the viewport edge by the gutter, full runs edge to edge for a hero or
14
+ // page head; both keep the vertical band. No margin and no max-width anywhere - sections abut, the band
15
+ // is air inside the unit, and Prose owns the reading measure. Colours are semantic tokens re-pointed by
16
+ // `.dark`, so no `dark:` class.
17
+ const section = cva(
18
+ [
19
+ 'py-[var(--space-band)]',
20
+ '[[data-section]+&]:border-t [[data-section]+&]:border-solid [[data-section]+&]:border-rule',
21
+ ].join(' '),
22
+ {
23
+ variants: {
24
+ bleed: {
25
+ inset: 'px-[var(--gutter)]',
26
+ full: '',
27
+ },
28
+ },
29
+ defaultVariants: { bleed: 'inset' },
30
+ },
31
+ );
32
+
33
+ export interface ISectionProps extends VariantProps<typeof section> {
34
+ /** Names the section as a region a screen-reader user can jump to. Omit for an ordinary section: an
35
+ * unnamed section is inert to assistive technology, which is the right default. Two or three named
36
+ * regions on a page is navigation; six is landmark noise, which is why this is opt-in. */
37
+ name?: string;
38
+ children?: ReactNode;
39
+ testId?: string;
40
+ }
41
+
42
+ /**
43
+ * The page's structural unit: a band carrying its own vertical air and, unless it bleeds, the gutter.
44
+ * It arranges nothing inside itself - what it owns is the join to the section before it, which is why
45
+ * it is a composable of the page rather than a container of its contents.
46
+ *
47
+ * @Guarantees — enforced on every render
48
+ * - Sections abut and never gap: the component sets no margin, and the join is the separation. A rule
49
+ * appears only between two sections - drawn when the preceding sibling also carries data-section, so
50
+ * never above the first section nor beneath a header before it.
51
+ * - A `bleed="full"` section drops the gutter and runs edge to edge; the vertical band is kept on both
52
+ * bleed variants, and the join spans the full width of either.
53
+ * - An unnamed section is not a landmark: it emits no `aria-label` and is inert to assistive technology.
54
+ *
55
+ * @CallerMustEnsure
56
+ * - Where `name` is given it matches the section's visible heading. The component labels the region with
57
+ * that string because it cannot reach the heading's id to reference it with `aria-labelledby` instead.
58
+ *
59
+ * @UXGuidelines
60
+ * - On a page with no borrowed proof - no logos, no testimonials, no credits - a gap between blocks reads
61
+ * as missing content, while air inside the type reads as care. So the levers on a page's rhythm are the
62
+ * measure, the leading and the band, never a space between sections. This is why Section offers no gap
63
+ * and no margin.
64
+ */
65
+ export const Section: FunctionComponent<ISectionProps> = ({
66
+ bleed,
67
+ name,
68
+ children,
69
+ testId,
70
+ }) => (
71
+ <section
72
+ data-section={''}
73
+ className={section({ bleed })}
74
+ aria-label={name}
75
+ data-testid={testId}
76
+ >
77
+ {children}
78
+ </section>
79
+ );
@@ -23,29 +23,49 @@ export type PaletteTokens = {
23
23
  muted: string;
24
24
  /** Hairlines and dividers. */
25
25
  border: string;
26
+ /** The boundary of a control with no fill of its own, so it is the only thing separating the
27
+ * control from the surface. Constraint (WCAG 2.2 SC 1.4.11): at least 3:1 against `surface`
28
+ * in the same theme. */
29
+ controlBorder: string;
30
+ /** The structural rule that marks where a block's structure is, weightier than `border`'s hairline
31
+ * dividers and lighter than `controlBorder`'s control edge. It serves three consumers: a table's
32
+ * heavier top line above the first row, a checklist's tick, and a page section's join to the
33
+ * section before it. A mid-neutral, so it structures without boxing. Constraint: at least 3:1
34
+ * against `surface` in the same theme. */
35
+ rule: string;
36
+ /** The plate behind content that has not painted - an image still loading, or one that failed and
37
+ * is showing its alt text. Constraint: at least 4.5:1 against `foreground` in the same theme,
38
+ * because a failed image renders its alt text on this plate and that text must stay legible. That
39
+ * keeps it near `surface` rather than a mid grey. */
40
+ backing: string;
26
41
 
27
42
  /** The main call-to-action fill. */
28
43
  primary: string;
29
44
  primaryHover: string;
30
45
  /** Text and icons drawn on top of `primary`. */
31
46
  primaryForeground: string;
32
- /** Focus ring for primary surfaces - lighter than the fill so it reads against it. */
33
- primaryRing: string;
34
47
 
35
48
  /** The alternative action fill, for choices that sit beside a primary one. */
36
49
  secondary: string;
37
50
  secondaryHover: string;
38
51
  secondaryForeground: string;
39
- secondaryRing: string;
40
52
 
41
- /** Fill for controls that cannot be interacted with. */
53
+ /** The tone a control takes when it cannot be interacted with - its fill, or its border when
54
+ * the fill is transparent. */
42
55
  disabled: string;
43
56
  /** Kept distinct from `disabled` so a disabled control still absorbs hover rather than
44
57
  * appearing to respond to it. */
45
58
  disabledHover: string;
46
59
 
47
- /** Focus ring for surfaces that have no fill of their own, such as a ghost button. */
48
- ring: string;
60
+ /** The one focus ring, drawn by every focusable primitive regardless of variant - a focus ring
61
+ * states keyboard position, not the control's importance. Constraint (WCAG 2.2 SC 1.4.11): at
62
+ * least 3:1 against `surface` in the same theme. */
63
+ focusRing: string;
64
+
65
+ /** The colour of a prose link. A link is told apart by its underline, never by hue, so this
66
+ * carries the text threshold, not the 3:1 the ring takes. Constraint (WCAG 2.2 SC 1.4.3): at
67
+ * least 4.5:1 against `surface` in the same theme. */
68
+ link: string;
49
69
 
50
70
  /** Status colours. Not yet consumed by a component - they complete the role set so the
51
71
  * first Alert or Toast has names to reach for instead of inventing them. */
@@ -60,21 +80,24 @@ export const light: PaletteTokens = {
60
80
  foreground: '#0f172a',
61
81
  muted: '#64748b',
62
82
  border: '#e2e8f0',
83
+ controlBorder: '#64748b',
84
+ rule: '#808fa3',
85
+ backing: '#f1f5f9',
63
86
 
64
87
  primary: '#8b5cf6',
65
88
  primaryHover: '#7c3aed',
66
89
  primaryForeground: '#f8fafc',
67
- primaryRing: '#a78bfa',
68
90
 
69
91
  secondary: '#0ea5e9',
70
92
  secondaryHover: '#0284c7',
71
93
  secondaryForeground: '#f8fafc',
72
- secondaryRing: '#38bdf8',
73
94
 
74
95
  disabled: '#94a3b8',
75
96
  disabledHover: '#64748b',
76
97
 
77
- ring: '#94a3b8',
98
+ focusRing: '#475569',
99
+
100
+ link: '#2563eb',
78
101
 
79
102
  success: '#10b981',
80
103
  warning: '#f59e0b',
@@ -93,21 +116,24 @@ export const dark: PaletteTokens = {
93
116
  foreground: '#f8fafc',
94
117
  muted: '#94a3b8',
95
118
  border: '#334155',
119
+ controlBorder: '#94a3b8',
120
+ rule: '#5b6a80',
121
+ backing: '#1e293b',
96
122
 
97
123
  primary: '#7c3aed',
98
124
  primaryHover: '#8b5cf6',
99
125
  primaryForeground: '#f8fafc',
100
- primaryRing: '#a78bfa',
101
126
 
102
127
  secondary: '#0284c7',
103
128
  secondaryHover: '#0ea5e9',
104
129
  secondaryForeground: '#f8fafc',
105
- secondaryRing: '#38bdf8',
106
130
 
107
131
  disabled: '#475569',
108
132
  disabledHover: '#334155',
109
133
 
110
- ring: '#64748b',
134
+ focusRing: '#cbd5e1',
135
+
136
+ link: '#60a5fa',
111
137
 
112
138
  success: '#34d399',
113
139
  warning: '#fbbf24',