@juwel-development/design-system 3.0.0 → 3.2.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.
- package/README.md +31 -0
- package/dist/design-system.js +219 -128
- package/dist/index.css +1 -1
- package/dist/types/Arrangement/Cluster/Cluster.d.ts +50 -0
- package/dist/types/Arrangement/Stack/Stack.d.ts +51 -0
- package/dist/types/Display/Figure/Figure.d.ts +21 -4
- package/dist/types/Display/Rail/Rail.d.ts +6 -0
- package/dist/types/Display/Typography/H1/H1.d.ts +7 -1
- package/dist/types/Display/Typography/Note/Note.d.ts +35 -0
- package/dist/types/Interaction/Link/Link.d.ts +4 -4
- package/dist/types/Layout/Cover/Cover.d.ts +48 -0
- package/dist/types/Layout/Form/Form.d.ts +8 -4
- package/dist/types/Layout/Header/Header.d.ts +20 -1
- package/dist/types/Layout/Hero/Hero.d.ts +9 -7
- package/dist/types/Layout/Section/Section.d.ts +7 -0
- package/dist/types/Theme/Palette.d.ts +55 -6
- package/dist/types/index.d.ts +4 -0
- package/package.json +1 -1
- package/src/Arrangement/Cluster/Cluster.tsx +80 -0
- package/src/Arrangement/Stack/Stack.tsx +81 -0
- package/src/Display/Figure/Figure.tsx +48 -7
- package/src/Display/Rail/Rail.tsx +6 -0
- package/src/Display/Typography/H1/H1.tsx +17 -7
- package/src/Display/Typography/Note/Note.tsx +56 -0
- package/src/Interaction/Button/Button.tsx +4 -1
- package/src/Interaction/Input/Input.tsx +17 -6
- package/src/Interaction/Link/Link.tsx +9 -5
- package/src/Interaction/TextArea/TextArea.tsx +17 -6
- package/src/Layout/Cover/Cover.tsx +85 -0
- package/src/Layout/Form/Form.tsx +24 -8
- package/src/Layout/Header/Header.tsx +37 -4
- package/src/Layout/Hero/Hero.tsx +10 -6
- package/src/Layout/PageHead/PageHead.tsx +2 -0
- package/src/Layout/Section/Section.tsx +11 -0
- package/src/Theme/Palette.ts +63 -14
- package/src/Theme/renderTokens.ts +37 -7
- package/src/index.ts +4 -0
- package/src/tokens.css +29 -10
- package/src/tokens.dark.css +25 -6
- package/src/tokens.light.css +25 -6
|
@@ -8,9 +8,11 @@ import type { Subject } from 'rxjs';
|
|
|
8
8
|
// drawn in `controlBorder` (>=3:1 against surface) and turned `error` on both `:user-invalid` and
|
|
9
9
|
// `aria-invalid` so a server-rendered and a browser-validated invalid state paint identically. Focus
|
|
10
10
|
// adds only the shared ring - the border never changes on focus (docs/adr/0002). Height is the
|
|
11
|
-
// recipe's, not a `rows` prop.
|
|
11
|
+
// recipe's, not a `rows` prop. The control's face, and the placeholder that follows it, are
|
|
12
|
+
// docs/adr/0004's (#90); so is its size (#92) - `body` is the role clearing the 16px below which
|
|
13
|
+
// iOS Safari zooms a focused control.
|
|
12
14
|
const textArea = cva(
|
|
13
|
-
'block min-h-24 w-full rounded-[var(--radius-control)] border border-solid border-control-border bg-transparent px-3 py-2 text-foreground transition-colors duration-[var(--motion-duration-color)] outline-focus-ring outline-offset-[var(--focus-ring-offset)] focus-visible:outline focus-visible:outline-[length:var(--focus-ring-width)] [&:user-invalid]:border-error aria-[invalid=true]:border-error disabled:cursor-not-allowed disabled:border-disabled disabled:text-muted',
|
|
15
|
+
'block min-h-24 w-full rounded-[var(--radius-control)] border border-solid border-control-border bg-transparent px-3 py-2 font-primary text-body text-foreground transition-colors duration-[var(--motion-duration-color)] outline-focus-ring outline-offset-[var(--focus-ring-offset)] focus-visible:outline focus-visible:outline-[length:var(--focus-ring-width)] [&:user-invalid]:border-error aria-[invalid=true]:border-error disabled:cursor-not-allowed disabled:border-disabled disabled:text-muted',
|
|
14
16
|
);
|
|
15
17
|
|
|
16
18
|
interface ITextAreaProps {
|
|
@@ -82,7 +84,14 @@ export const TextArea: FunctionComponent<ITextAreaProps> = ({
|
|
|
82
84
|
|
|
83
85
|
return (
|
|
84
86
|
<div className={'flex flex-col gap-[var(--space-stack)]'}>
|
|
85
|
-
|
|
87
|
+
{/* Each of these declares `font-secondary` on itself, never on the wrapper above - the
|
|
88
|
+
wrapper would hand the labelling face to the control too (docs/adr/0004, #90). The
|
|
89
|
+
label's size is declared here for the same reason, and it is `body`, the control's own
|
|
90
|
+
role, rather than `label` (docs/adr/0004, #92). */}
|
|
91
|
+
<label
|
|
92
|
+
htmlFor={controlId}
|
|
93
|
+
className={'font-secondary font-medium text-body text-foreground'}
|
|
94
|
+
>
|
|
86
95
|
{label}
|
|
87
96
|
</label>
|
|
88
97
|
<textarea
|
|
@@ -101,15 +110,17 @@ export const TextArea: FunctionComponent<ITextAreaProps> = ({
|
|
|
101
110
|
onInput={(event) => onInput$?.next(event.currentTarget.value)}
|
|
102
111
|
/>
|
|
103
112
|
{!required && optionalLabel && (
|
|
104
|
-
<span className={'text-muted text-
|
|
113
|
+
<span className={'font-secondary text-muted text-small'}>
|
|
114
|
+
{optionalLabel}
|
|
115
|
+
</span>
|
|
105
116
|
)}
|
|
106
117
|
{hint && (
|
|
107
|
-
<p id={hintId} className={'text-muted text-
|
|
118
|
+
<p id={hintId} className={'font-secondary text-muted text-small'}>
|
|
108
119
|
{hint}
|
|
109
120
|
</p>
|
|
110
121
|
)}
|
|
111
122
|
{invalid && (
|
|
112
|
-
<p id={errorId} className={'text-error text-
|
|
123
|
+
<p id={errorId} className={'font-secondary text-error text-small'}>
|
|
113
124
|
{errorMessage}
|
|
114
125
|
</p>
|
|
115
126
|
)}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { cva } from 'class-variance-authority';
|
|
2
|
+
import type { FunctionComponent, ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
// One recipe, no variants: both-axes centring is the component's whole job (#96), so there is nothing
|
|
5
|
+
// to choose - a distribution axis waits for evidence under ADR 0008's test. items-center centres the
|
|
6
|
+
// inline axis, the slot's auto margins (below) the block axis; alone among the composables it carries
|
|
7
|
+
// its own inset - the deliberate exception to "Section owns the gutter" (CONTEXT.md: Cover).
|
|
8
|
+
const cover = cva(
|
|
9
|
+
[
|
|
10
|
+
'flex flex-col items-center',
|
|
11
|
+
'min-h-[var(--cover-height)]',
|
|
12
|
+
'px-[var(--gutter)] py-[var(--space-region)]',
|
|
13
|
+
].join(' '),
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
// Not a second recipe - the slot has nothing to vary, and the standard allows one cva()
|
|
17
|
+
// (design-system-components.md §4), the frame's own above. Block-axis auto margins split the leftover
|
|
18
|
+
// space equally, so a foot after the slot still lands on the bottom edge - justify-center on the
|
|
19
|
+
// frame would centre slot and foot as one group and lift the foot off that edge.
|
|
20
|
+
const slot = 'my-auto';
|
|
21
|
+
|
|
22
|
+
export interface ICoverProps {
|
|
23
|
+
/** The screen's one opaque slot, centred on both axes. Rendered unmodified: a menu composes a title,
|
|
24
|
+
* a tagline and a stack of actions here; a sign-in composes a form. `Cover` imposes no anatomy. */
|
|
25
|
+
children: ReactNode;
|
|
26
|
+
/** The line on the screen's bottom edge - a version line, a legal line - centred on the inline axis.
|
|
27
|
+
* Omitted - or given nothing: `null`, a flag's `false` - nothing renders: no empty container
|
|
28
|
+
* holds its place. */
|
|
29
|
+
foot?: ReactNode;
|
|
30
|
+
testId?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A whole screen's frame: a plain container at least the cover height tall that centres one column in
|
|
35
|
+
* the leftover space, on both axes, with an optional foot pinned to the bottom edge. It is the fold's
|
|
36
|
+
* counterpart (CONTEXT.md): a menu, a sign-in, a splash is the entire app for a moment, and nothing
|
|
37
|
+
* follows it - so where `Hero` deliberately stops short of the viewport to say the page continues, a
|
|
38
|
+
* cover reaches it, because stopping short would signal a continuation that does not exist. It renders
|
|
39
|
+
* no heading and no landmark: whatever the screen says is composed in the slot.
|
|
40
|
+
*
|
|
41
|
+
* @Guarantees — enforced on every render
|
|
42
|
+
* - It is at least `--cover-height` tall: a `min-height` floor, not a fixed height, so content longer
|
|
43
|
+
* than the viewport grows the frame rather than overflowing it.
|
|
44
|
+
* - `children` sit in the middle of the leftover space, centred on both axes; the centring is fixed,
|
|
45
|
+
* with no distribution to choose.
|
|
46
|
+
* - `foot` renders on the frame's bottom edge, centred on the inline axis, and renders nothing - not
|
|
47
|
+
* even an empty container - when not given or given nothing to render (`null`, a flag's `false`).
|
|
48
|
+
* - `children` and `foot` render unmodified; the component adds nothing to and strips nothing from
|
|
49
|
+
* them, and sets no colour, no heading and no landmark of its own.
|
|
50
|
+
* - It owns its own inset - `--gutter` on the inline axis, `--space-region` on the block axis - the
|
|
51
|
+
* deliberate exception to "`Section` owns the gutter": the frame equals the viewport, so it cannot
|
|
52
|
+
* sit inside a `Section` band without overflowing it, and there is no band around it to carry one.
|
|
53
|
+
* - It is never sticky or fixed and needs no JavaScript, so it renders identically server-side.
|
|
54
|
+
*
|
|
55
|
+
* @CallerMustEnsure — the component cannot see these and does not check them
|
|
56
|
+
* - The cover stands on its own, never inside a `Section`: the band's vertical air would push the
|
|
57
|
+
* frame past the viewport it exists to equal. A page that continues past its first screen wants
|
|
58
|
+
* `Section` and `Hero` instead - the fold, not the screen.
|
|
59
|
+
* - Anything the page must announce - a landmark, a heading - is composed in the slot; the frame
|
|
60
|
+
* declares nothing over it.
|
|
61
|
+
*
|
|
62
|
+
* @UXGuidelines
|
|
63
|
+
* - A cover is for a page that is the whole app for a moment - a menu, a sign-in, a splash. The
|
|
64
|
+
* moment content follows on the same page, the screen has become a fold and stopping short of the
|
|
65
|
+
* viewport is the honest signal: reach for `Hero` inside a `Section` instead.
|
|
66
|
+
* - The foot is a quiet line, not a footer: a version, a legal notice. Content a viewer must reach
|
|
67
|
+
* belongs in the slot, where it sits in the column the screen is actually about.
|
|
68
|
+
*/
|
|
69
|
+
export const Cover: FunctionComponent<ICoverProps> = ({
|
|
70
|
+
children,
|
|
71
|
+
foot,
|
|
72
|
+
testId,
|
|
73
|
+
}) => {
|
|
74
|
+
// Absence, not falsiness, after Form's note guard: `foot={showLegal && <p/>}` hands over `false`,
|
|
75
|
+
// and an empty <div> would still be a flex item sitting on the bottom edge.
|
|
76
|
+
const hasFoot =
|
|
77
|
+
foot !== undefined && foot !== null && typeof foot !== 'boolean';
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<div className={cover()} data-testid={testId}>
|
|
81
|
+
<div className={slot}>{children}</div>
|
|
82
|
+
{hasFoot && <div>{foot}</div>}
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
85
|
+
};
|
package/src/Layout/Form/Form.tsx
CHANGED
|
@@ -8,8 +8,9 @@ export type FormState = 'idle' | 'sending' | 'sent' | 'failed';
|
|
|
8
8
|
// The one recipe, and it paints the note only (issue #6): `state` selects the note's tone - muted
|
|
9
9
|
// while the form stands, success on the outcome, error on the failure. Region selection is the
|
|
10
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
|
-
|
|
11
|
+
// tokens re-pointed by `.dark`, so no variant carries a `dark:` class. The face joins the size in
|
|
12
|
+
// the base, so no state can disagree with it (docs/adr/0004, #90).
|
|
13
|
+
const form = cva('font-secondary text-small', {
|
|
13
14
|
variants: {
|
|
14
15
|
state: {
|
|
15
16
|
idle: 'text-muted',
|
|
@@ -37,8 +38,12 @@ interface IFormProps {
|
|
|
37
38
|
children?: ReactNode;
|
|
38
39
|
/** The actions row - the consumer supplies its own submit Button. */
|
|
39
40
|
actions?: ReactNode;
|
|
40
|
-
/** Standing note in `idle`/`sending`; the outcome message in `sent`/`failed`.
|
|
41
|
-
note
|
|
41
|
+
/** Standing note in `idle`/`sending`; the outcome message in `sent`/`failed`. Inline content: a
|
|
42
|
+
* sentence, with a Link or emphasis inside it. Form renders the paragraph the note sits in and
|
|
43
|
+
* owns its ARIA role, so a node that brings its own block element - a second `<p>` - is unnested
|
|
44
|
+
* by the parser and the text leaves the region carrying that role. A node that renders nothing
|
|
45
|
+
* (`null`, `false`, an omitted prop) is no note: the paragraph is not rendered at all. */
|
|
46
|
+
note?: ReactNode;
|
|
42
47
|
testId?: string;
|
|
43
48
|
}
|
|
44
49
|
|
|
@@ -51,8 +56,8 @@ interface IFormProps {
|
|
|
51
56
|
* `sent` drops the fields and actions so a completed submission cannot be resubmitted, while the
|
|
52
57
|
* `<form>` itself is retained in every state so the DOM shape is stable across a runtime change.
|
|
53
58
|
*
|
|
54
|
-
* The note's
|
|
55
|
-
* the outcome message, so a `sent` with no `note` is a programmer error and throws.
|
|
59
|
+
* The note's content is always the consumer's; Form chooses only its element and ARIA role. `sent`
|
|
60
|
+
* is the outcome message, so a `sent` with no `note` is a programmer error and throws.
|
|
56
61
|
*/
|
|
57
62
|
export const Form: FunctionComponent<IFormProps> = ({
|
|
58
63
|
action,
|
|
@@ -63,7 +68,16 @@ export const Form: FunctionComponent<IFormProps> = ({
|
|
|
63
68
|
note,
|
|
64
69
|
testId,
|
|
65
70
|
}) => {
|
|
66
|
-
|
|
71
|
+
// Absence, not falsiness: a ReactNode may be falsy and still render (`0` renders as `"0"`), so a
|
|
72
|
+
// truthiness test would both refuse a legitimate note in `sent` and drop a paragraph React had
|
|
73
|
+
// written a value into. Absence is React's own set of nothing-to-render nodes rather than
|
|
74
|
+
// `undefined` alone, because `note={showPrivacy && <>...</>}` hands over `false` when the line is
|
|
75
|
+
// off - and an empty `<p>` is still a flex item, so it would cost a region gap and, in `sent`, a
|
|
76
|
+
// status region announcing nothing.
|
|
77
|
+
const hasNote =
|
|
78
|
+
note !== undefined && note !== null && typeof note !== 'boolean';
|
|
79
|
+
|
|
80
|
+
if (state === 'sent' && !hasNote) {
|
|
67
81
|
throw new Error(
|
|
68
82
|
'Form in the `sent` state must be given a `note` - it is the outcome message.',
|
|
69
83
|
);
|
|
@@ -87,12 +101,14 @@ export const Form: FunctionComponent<IFormProps> = ({
|
|
|
87
101
|
{children}
|
|
88
102
|
</div>
|
|
89
103
|
)}
|
|
104
|
+
{/* Not pinned against Stack (#75) as the two columns above are: the actions row runs along the
|
|
105
|
+
other axis, which is Cluster's arrangement (#76) and not a stack with a different gap. */}
|
|
90
106
|
{showFields && actions && (
|
|
91
107
|
<div className={'flex flex-row gap-[var(--space-stack)]'}>
|
|
92
108
|
{actions}
|
|
93
109
|
</div>
|
|
94
110
|
)}
|
|
95
|
-
{
|
|
111
|
+
{hasNote && (
|
|
96
112
|
<p role={noteRole} className={form({ state })}>
|
|
97
113
|
{note}
|
|
98
114
|
</p>
|
|
@@ -2,7 +2,7 @@ import type { VariantProps } from 'class-variance-authority';
|
|
|
2
2
|
import { cva } from 'class-variance-authority';
|
|
3
3
|
import type { FunctionComponent, ReactNode } from 'react';
|
|
4
4
|
|
|
5
|
-
//
|
|
5
|
+
// The recipe on the <header>. It sets the label type role - the "small grotesk, letter-spaced, muted"
|
|
6
6
|
// the issue described, whose "never grows past 1rem" was a role wearing a number, so no size literal
|
|
7
7
|
// appears here (#14). The shell's air is one value in every direction: --space-region above, below and
|
|
8
8
|
// (on the nav) between, --gutter across, so the bar aligns with every inset Section. The current-page
|
|
@@ -15,7 +15,7 @@ import type { FunctionComponent, ReactNode } from 'react';
|
|
|
15
15
|
const header = cva(
|
|
16
16
|
[
|
|
17
17
|
'flex items-baseline justify-between',
|
|
18
|
-
'font-secondary text-label tracking-label text-muted',
|
|
18
|
+
'font-secondary text-label leading-label tracking-label text-muted',
|
|
19
19
|
'py-[var(--space-region)] px-[var(--gutter)]',
|
|
20
20
|
'[&_[aria-current=page]]:text-foreground',
|
|
21
21
|
].join(' '),
|
|
@@ -30,6 +30,20 @@ const header = cva(
|
|
|
30
30
|
},
|
|
31
31
|
);
|
|
32
32
|
|
|
33
|
+
// Not a second recipe - the standing slot has nothing to vary, and the standard allows a component
|
|
34
|
+
// one cva() (design-system-components.md §4), which is the bar's own above. A named constant beside
|
|
35
|
+
// the nav's inline class string, so the comment has something to sit on.
|
|
36
|
+
//
|
|
37
|
+
// The height floor is the nav's own line box, written from the two tokens the recipe above sets the
|
|
38
|
+
// header from, so the declared floor and the rendered line cannot drift (#81). `shrink-0` because an
|
|
39
|
+
// explicit min-width replaces a flex item's automatic minimum - without it the bar squeezes the slot
|
|
40
|
+
// below its content and wraps the place name the floor exists to keep on one line.
|
|
41
|
+
const standingSlot = [
|
|
42
|
+
'inline-flex shrink-0 items-center',
|
|
43
|
+
'min-h-[calc(var(--text-label)*var(--leading-label))]',
|
|
44
|
+
'min-w-[var(--standing-min-width)]',
|
|
45
|
+
].join(' ');
|
|
46
|
+
|
|
33
47
|
export interface IHeaderProps extends VariantProps<typeof header> {
|
|
34
48
|
/** The standing link: a place name, a mark, or a home link. The consumer supplies the whole anchor -
|
|
35
49
|
* Header renders no link of its own. A place name uses `<Link treatment="quiet" href="/">…</Link>`; a
|
|
@@ -49,7 +63,14 @@ export interface IHeaderProps extends VariantProps<typeof header> {
|
|
|
49
63
|
*
|
|
50
64
|
* @Guarantees — enforced on every render
|
|
51
65
|
* - 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.
|
|
66
|
+
* leading-label tracking-label text-muted`, so it never grows past that role whatever the page font size.
|
|
67
|
+
* - The nav's line box is the library's own rather than the consuming document's: the header leads
|
|
68
|
+
* itself at the label role, so the height it declares for its slot is the height it renders.
|
|
69
|
+
* - The standing slot is floored, never fixed: its minimum height is that same line box - the label
|
|
70
|
+
* role's size times its leading - its minimum width is `--standing-min-width`, and its contents are
|
|
71
|
+
* centred. Standing content inside both floors leaves the bar the same height from route to route,
|
|
72
|
+
* so a place name on one page and a mark on another do not move the shell; content past either floor
|
|
73
|
+
* grows the slot, on one line, and is never clamped or wrapped.
|
|
53
74
|
* - A nav item marked `aria-current="page"` renders at `foreground` whatever supplied it - the treatment
|
|
54
75
|
* keys on the attribute, not on `Link`, so it holds against a bare anchor or any component.
|
|
55
76
|
* - The shell's air is one value above, below and between: `--space-region` vertically and between nav
|
|
@@ -57,6 +78,18 @@ export interface IHeaderProps extends VariantProps<typeof header> {
|
|
|
57
78
|
* - It is never sticky and needs no JavaScript: there is no `sticky` variant and nothing to hydrate.
|
|
58
79
|
* - Omitting `navName` emits no `aria-label` at all, not an empty one.
|
|
59
80
|
*
|
|
81
|
+
* @CallerMustEnsure — the component cannot see these and does not check them
|
|
82
|
+
* - A mark that should *fill* the standing slot is given a **definite width** by whoever placed it -
|
|
83
|
+
* `width: var(--standing-min-width)` on the element wrapping it, so the mark and the floor move
|
|
84
|
+
* together when a theme re-points the token. Measured against a `viewBox`-only SVG, the common
|
|
85
|
+
* shape: it has no intrinsic width to fill from, and `width: 100%` cannot resolve against the slot's
|
|
86
|
+
* indefinite basis - so a mark told to fill that way renders at its intrinsic width instead (300px in
|
|
87
|
+
* a slot floored at 120px, against 120px on the place-name route) and the floor stops governing,
|
|
88
|
+
* which is the jump between routes the floor exists to remove. A definite width holds at any bar
|
|
89
|
+
* width; `flex: 1 1 0; min-width: 0` only collapses the mark when the bar is already out of room, so
|
|
90
|
+
* it is not the rule to reach for. The library cannot apply either rule for you: the same width on a
|
|
91
|
+
* place name would clamp the name and wrap it.
|
|
92
|
+
*
|
|
60
93
|
* @UXGuidelines
|
|
61
94
|
* - Name the nav with `navName` once the page has more than one navigation landmark - a footer nav will
|
|
62
95
|
* be the second, and two unnamed navs are indistinguishable to a screen-reader user.
|
|
@@ -73,7 +106,7 @@ export const Header: FunctionComponent<IHeaderProps> = ({
|
|
|
73
106
|
testId,
|
|
74
107
|
}) => (
|
|
75
108
|
<header className={header({ edge })} data-testid={testId}>
|
|
76
|
-
<div>{standing}</div>
|
|
109
|
+
<div className={standingSlot}>{standing}</div>
|
|
77
110
|
<nav
|
|
78
111
|
aria-label={navName}
|
|
79
112
|
className={'flex flex-wrap items-baseline gap-[var(--space-region)]'}
|
package/src/Layout/Hero/Hero.tsx
CHANGED
|
@@ -2,6 +2,8 @@ import type { VariantProps } from 'class-variance-authority';
|
|
|
2
2
|
import { cva } from 'class-variance-authority';
|
|
3
3
|
import type { FunctionComponent, ReactNode } from 'react';
|
|
4
4
|
|
|
5
|
+
// Not pinned against Stack (#75): the fold height is this component's own, so the base legitimately
|
|
6
|
+
// differs and an equality spec would only forbid a difference that is the point of the component.
|
|
5
7
|
// One recipe on a plain <div>. It holds the fold - a minimum height taller than its content, so leftover
|
|
6
8
|
// space is guaranteed and how the slot sits in it is a decision, not a default (#17). With no className
|
|
7
9
|
// escape hatch, whatever place picks is final, so it is a variant: center is the conventional hero and the
|
|
@@ -52,12 +54,14 @@ export interface IHeroProps extends VariantProps<typeof hero> {
|
|
|
52
54
|
* bleed, the band, the gutter and the region. `Hero` imports no component and adds none of these.
|
|
53
55
|
*
|
|
54
56
|
* @UXGuidelines
|
|
55
|
-
* -
|
|
56
|
-
*
|
|
57
|
-
* 24ch a one-sentence lead set five lines and
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
57
|
+
* - The lead is capped at `--measure-display`, and `H1` is what carries the cap - place one in the slot
|
|
58
|
+
* and the bound comes with it (#87). The slot itself stays uncapped, because capping it would cap the
|
|
59
|
+
* mark too. Measured on the lockup that prompted this: at 24ch a one-sentence lead set five lines and
|
|
60
|
+
* became a second block competing with the mark; at 36ch, where the token sits, it sets three and reads
|
|
61
|
+
* as the mark's caption. This is the one place where copy length is a layout parameter, and the token
|
|
62
|
+
* is one value for the whole product - so a hero whose lead runs longer than that measurement assumed
|
|
63
|
+
* is a reason to re-measure `--measure-display` against the real sentence, which moves every `H1` with
|
|
64
|
+
* it, and never a reason to cap that one hero by hand.
|
|
61
65
|
* - A hero built on a wide lockup is a desktop bet by construction. A 15-character lockup can only be as
|
|
62
66
|
* wide as the phone, so at 390px the scale contrast between mark and lead is capped by the viewport
|
|
63
67
|
* rather than chosen - roughly 2:1, against roughly 6:1 at 1440.
|
|
@@ -10,6 +10,8 @@ import type { FunctionComponent, ReactNode } from 'react';
|
|
|
10
10
|
// literal appears. That tracking is the title role's, not this component's (#57): H2 binds to the same
|
|
11
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
12
|
// tokens re-pointed by `.dark`, so no `dark:` class.
|
|
13
|
+
// Not pinned against Stack (#75): the band and the gutter are the head's own page job, so the base
|
|
14
|
+
// legitimately differs and there is no equality to hold - a Stack carries neither.
|
|
13
15
|
const pageHead = cva(
|
|
14
16
|
'flex flex-col gap-[var(--space-stack)] py-[var(--space-band)] px-[var(--gutter)]',
|
|
15
17
|
);
|
|
@@ -16,6 +16,10 @@ import type { FunctionComponent, ReactNode } from 'react';
|
|
|
16
16
|
// `.dark`, so no `dark:` class.
|
|
17
17
|
const section = cva(
|
|
18
18
|
[
|
|
19
|
+
// Anchor, not placement: a consumer hangs its own decoration off the join, and cannot get an anchor
|
|
20
|
+
// by wrapping the section without costing two joins. No offset and no z-index, so the layout is
|
|
21
|
+
// unchanged and no stacking context forms - decoration at z-index:-1 must still escape to an outer one.
|
|
22
|
+
'relative',
|
|
19
23
|
'py-[var(--space-band)]',
|
|
20
24
|
'[[data-section]+&]:border-t [[data-section]+&]:border-solid [[data-section]+&]:border-rule',
|
|
21
25
|
].join(' '),
|
|
@@ -51,10 +55,17 @@ export interface ISectionProps extends VariantProps<typeof section> {
|
|
|
51
55
|
* - A `bleed="full"` section drops the gutter and runs edge to edge; the vertical band is kept on both
|
|
52
56
|
* bleed variants, and the join spans the full width of either.
|
|
53
57
|
* - An unnamed section is not a landmark: it emits no `aria-label` and is inert to assistive technology.
|
|
58
|
+
* - The section is a positioning context, under either bleed, so a consumer may place absolutely-positioned
|
|
59
|
+
* decoration against its edges - including the join above it, the one edge only a section knows. It sets
|
|
60
|
+
* no offset and no `z-index`, so it is not a stacking context and decoration at a negative `z-index`
|
|
61
|
+
* still resolves against an outer one.
|
|
54
62
|
*
|
|
55
63
|
* @CallerMustEnsure
|
|
56
64
|
* - Where `name` is given it matches the section's visible heading. The component labels the region with
|
|
57
65
|
* that string because it cannot reach the heading's id to reference it with `aria-labelledby` instead.
|
|
66
|
+
* - A descendant meant to position against an ancestor *outside* the section is given its own positioned
|
|
67
|
+
* wrapper, since the section is now the nearer positioned ancestor and takes the anchor. The
|
|
68
|
+
* re-anchoring is silent - no error and no warning, only a descendant that lands somewhere else.
|
|
58
69
|
*
|
|
59
70
|
* @UXGuidelines
|
|
60
71
|
* - On a page with no borrowed proof - no logos, no testimonials, no credits - a gap between blocks reads
|
package/src/Theme/Palette.ts
CHANGED
|
@@ -39,15 +39,36 @@ export type PaletteTokens = {
|
|
|
39
39
|
* keeps it near `surface` rather than a mid grey. */
|
|
40
40
|
backing: string;
|
|
41
41
|
|
|
42
|
-
/** The main call-to-action fill.
|
|
42
|
+
/** The main call-to-action fill. A filled control draws no border, so the fill is the only thing
|
|
43
|
+
* separating the control from the surface - what `controlBorder` is for an unfilled one.
|
|
44
|
+
* Constraint (WCAG 2.2 SC 1.4.11): at least 3:1 against `surface` in the same theme,
|
|
45
|
+
* `primaryHover` included, since a hovered control has to stay identifiable too. A second
|
|
46
|
+
* constraint governs this value and `primaryHover` from the other side, stated on
|
|
47
|
+
* `primaryForeground` - the ink they carry - rather than restated here. */
|
|
43
48
|
primary: string;
|
|
44
49
|
primaryHover: string;
|
|
45
|
-
/** Text and icons drawn on top of `primary`.
|
|
50
|
+
/** Text and icons drawn on top of `primary`. Constraint (WCAG 2.2 SC 1.4.3): at least 4.5:1
|
|
51
|
+
* against both `primary` and `primaryHover` in the same theme, hover included, since a hovered
|
|
52
|
+
* control has to stay readable too. Button text is normal-weight at the `body` role, so it takes
|
|
53
|
+
* the text threshold rather than the 3:1 large-text allowance. This is what makes the ink follow
|
|
54
|
+
* the theme where the fills' own roles do not: a fill light enough to clear 3:1 against a dark
|
|
55
|
+
* surface is too light to carry near-white text, so the ink sits at the *surface's* end of the
|
|
56
|
+
* neutral range in each theme. Not required against `disabled` or `disabledHover` - a disabled
|
|
57
|
+
* control is an inactive user interface component, which SC 1.4.3 exempts. */
|
|
46
58
|
primaryForeground: string;
|
|
47
59
|
|
|
48
|
-
/** The alternative action fill, for choices that sit beside a primary one.
|
|
60
|
+
/** The alternative action fill, for choices that sit beside a primary one. Filled like `primary`
|
|
61
|
+
* and so identified the same way. Constraint (WCAG 2.2 SC 1.4.11): at least 3:1 against
|
|
62
|
+
* `surface` in the same theme, `secondaryHover` included. A second constraint governs this value
|
|
63
|
+
* and `secondaryHover` from the other side, stated on `secondaryForeground`. */
|
|
49
64
|
secondary: string;
|
|
50
65
|
secondaryHover: string;
|
|
66
|
+
/** Text and icons drawn on top of `secondary`. Constraint (WCAG 2.2 SC 1.4.3): at least 4.5:1
|
|
67
|
+
* against both `secondary` and `secondaryHover` in the same theme, hover included, since a
|
|
68
|
+
* hovered control has to stay readable too. Not required against `disabled` or `disabledHover` -
|
|
69
|
+
* a disabled control is an inactive user interface component, which SC 1.4.3 exempts. Why the
|
|
70
|
+
* threshold is 4.5 and not 3, and why this makes the ink follow the theme: see
|
|
71
|
+
* `primaryForeground`, which carries the same rule for the other ramp. */
|
|
51
72
|
secondaryForeground: string;
|
|
52
73
|
|
|
53
74
|
/** The tone a control takes when it cannot be interacted with - its fill, or its border when
|
|
@@ -75,6 +96,25 @@ export type PaletteTokens = {
|
|
|
75
96
|
info: string;
|
|
76
97
|
};
|
|
77
98
|
|
|
99
|
+
/**
|
|
100
|
+
* The ink follows the theme, and that is what set the eight fill and ink values across both sets
|
|
101
|
+
* (issue #93). Solve the window a fill has to sit in - at least 3:1 against its surface (#78) and
|
|
102
|
+
* at least 4.5:1 against the ink drawn on it - and it comes out lopsided: a near-white ink is
|
|
103
|
+
* unbounded above in light and leaves a window just 1.26x wide in dark, and a near-black ink is the
|
|
104
|
+
* exact inverse. The reason is structural - in a dark theme a fill has to be light enough to
|
|
105
|
+
* separate from a near-black surface, and a light fill wants dark text - so pinning the ink
|
|
106
|
+
* near-white in both themes asks a dark fill to be both at once. That 1.26x has to hold two values,
|
|
107
|
+
* rest and hover, where what shipped before it stepped 1.35x and 1.23x here and 1.35x and 1.48x in
|
|
108
|
+
* dark. Letting the ink invert instead puts every value on a stock ramp step, with hover steps of
|
|
109
|
+
* 1.25x and 1.27x here and 1.56x and 1.48x in dark.
|
|
110
|
+
*
|
|
111
|
+
* Two routes were rejected, and they are what a reader arriving here is most likely to re-propose:
|
|
112
|
+
* - Keep one near-white ink in both themes and move the fills. It clears 4.5:1, but caps the dark
|
|
113
|
+
* hover at that same 1.26x - gutting the state #78 constrained hover in order to keep.
|
|
114
|
+
* - Give each ramp its own ink, the same in both themes. It clears 4.5:1 only against pure black:
|
|
115
|
+
* against `#0f172a`, the dark set's own `surface`, sky-600 lands at 4.36 and fails. It also buys
|
|
116
|
+
* a light theme with white text on one button and black on the one beside it.
|
|
117
|
+
*/
|
|
78
118
|
export const light: PaletteTokens = {
|
|
79
119
|
surface: '#ffffff',
|
|
80
120
|
foreground: '#0f172a',
|
|
@@ -84,12 +124,12 @@ export const light: PaletteTokens = {
|
|
|
84
124
|
rule: '#808fa3',
|
|
85
125
|
backing: '#f1f5f9',
|
|
86
126
|
|
|
87
|
-
primary: '#
|
|
88
|
-
primaryHover: '#
|
|
127
|
+
primary: '#7c3aed',
|
|
128
|
+
primaryHover: '#6d28d9',
|
|
89
129
|
primaryForeground: '#f8fafc',
|
|
90
130
|
|
|
91
|
-
secondary: '#
|
|
92
|
-
secondaryHover: '#
|
|
131
|
+
secondary: '#0369a1',
|
|
132
|
+
secondaryHover: '#075985',
|
|
93
133
|
secondaryForeground: '#f8fafc',
|
|
94
134
|
|
|
95
135
|
disabled: '#94a3b8',
|
|
@@ -106,10 +146,19 @@ export const light: PaletteTokens = {
|
|
|
106
146
|
};
|
|
107
147
|
|
|
108
148
|
/**
|
|
109
|
-
* Dark reverses the
|
|
110
|
-
*
|
|
149
|
+
* Dark reverses the direction the fills step: it sits higher up the ramp than light does and hovers
|
|
150
|
+
* *up* into lighter still, where light sits lower and hovers down. That is what the components' old
|
|
111
151
|
* `dark:bg-primary-600 dark:hover:bg-primary-500` pair encoded, moved here so it is stated once
|
|
112
|
-
* for the whole system instead of repeated per component.
|
|
152
|
+
* for the whole system instead of repeated per component. The two sets no longer share a step, since
|
|
153
|
+
* each theme's pair has to clear its own ink as well as its own surface (issue #93).
|
|
154
|
+
*
|
|
155
|
+
* The ink inverts with the ramp: slate-950 under these lighter fills, where light takes slate-50
|
|
156
|
+
* under its darker ones. The pair is the two ends of the one neutral ramp the rest of the palette is
|
|
157
|
+
* already built from - `#f8fafc` is slate-50, `surface` slate-900, `muted` slate-500 - rather than a
|
|
158
|
+
* new colour arriving for a single job. `#020617` is also the lightest slate step that still admits
|
|
159
|
+
* violet-500 and sky-600, which is what leaves this set's `secondary` pair unmoved: slate-900 draws
|
|
160
|
+
* 4.22 and 4.36 against them and fails. Why the ink follows the theme at all, and the two routes
|
|
161
|
+
* rejected in getting here: see the light set.
|
|
113
162
|
*/
|
|
114
163
|
export const dark: PaletteTokens = {
|
|
115
164
|
surface: '#0f172a',
|
|
@@ -120,13 +169,13 @@ export const dark: PaletteTokens = {
|
|
|
120
169
|
rule: '#5b6a80',
|
|
121
170
|
backing: '#1e293b',
|
|
122
171
|
|
|
123
|
-
primary: '#
|
|
124
|
-
primaryHover: '#
|
|
125
|
-
primaryForeground: '#
|
|
172
|
+
primary: '#8b5cf6',
|
|
173
|
+
primaryHover: '#a78bfa',
|
|
174
|
+
primaryForeground: '#020617',
|
|
126
175
|
|
|
127
176
|
secondary: '#0284c7',
|
|
128
177
|
secondaryHover: '#0ea5e9',
|
|
129
|
-
secondaryForeground: '#
|
|
178
|
+
secondaryForeground: '#020617',
|
|
130
179
|
|
|
131
180
|
disabled: '#475569',
|
|
132
181
|
disabledHover: '#334155',
|
|
@@ -61,6 +61,14 @@ const CONTROL_MIN_WIDTH = `:root {
|
|
|
61
61
|
--control-min-width: 10.5rem;
|
|
62
62
|
}`;
|
|
63
63
|
|
|
64
|
+
/* The shell's standing slot claims a width, and that is what this names: how much of the bar the
|
|
65
|
+
slot takes, never how wide any drawing is - a slot role, so Brandmark keeps setting no dimension of
|
|
66
|
+
its own (.out-of-scope/brandmark-size-vocabulary.md). Not a colour and no Tailwind namespace, so it
|
|
67
|
+
sits in :root beside the control minimum width. 7.5rem is the one attested value, measured in #81. */
|
|
68
|
+
const STANDING_MIN_WIDTH = `:root {
|
|
69
|
+
--standing-min-width: 7.5rem;
|
|
70
|
+
}`;
|
|
71
|
+
|
|
64
72
|
/* The library's underline is not a colour: like the focus-ring dimensions it lives in :root only,
|
|
65
73
|
never @theme inline. Not prose's alone - every Link treatment that draws a line draws it from these.
|
|
66
74
|
Constraint: --underline-thickness-hover > --underline-thickness (the thicken on hover is the only
|
|
@@ -102,11 +110,17 @@ const TYPOGRAPHY = `@theme {
|
|
|
102
110
|
subtitle was rejected in ADR 0004 - it welds the lede's size to H3's and its leading to a head's. */
|
|
103
111
|
--text-lede: 1.25rem; /* 20px */
|
|
104
112
|
--leading-lede: 1.4;
|
|
105
|
-
--text-body: 1.0625rem; /* 17px */
|
|
113
|
+
--text-body: 1.0625rem; /* 17px, floored at 16px: below it iOS Safari zooms the viewport when a control set at this role takes focus (#92) */
|
|
106
114
|
--leading-body: 1.6;
|
|
107
115
|
--text-small: 0.9375rem; /* 15px floor: below it tabular figures stop comparing column to column */
|
|
108
116
|
--text-label: 0.8125rem; /* 13px floor: below it the tracking reads as damage, not a device */
|
|
109
117
|
|
|
118
|
+
/* The label role's leading, declared standalone and deliberately not paired into text-label's
|
|
119
|
+
utility: pairing would move every existing label site's line box at once, silently. A component
|
|
120
|
+
whose own height is measured against its labels reads leading-label explicitly (Header #81). 1.5
|
|
121
|
+
is the line height SC 1.4.12 expects text to survive, so a user stylesheet applying it moves nothing. */
|
|
122
|
+
--leading-label: 1.5;
|
|
123
|
+
|
|
110
124
|
/* Two quantities on one property that must not collapse: --tracking-label is a fixed letter-spaced
|
|
111
125
|
style, --tracking-optical a correction that varies with size. The names say which is which, so the
|
|
112
126
|
distinction survives without the ADR in hand. Scope: the title role and above - H1, H2 and the page
|
|
@@ -115,15 +129,15 @@ const TYPOGRAPHY = `@theme {
|
|
|
115
129
|
--tracking-optical: -0.02em;
|
|
116
130
|
}`;
|
|
117
131
|
|
|
118
|
-
/* The reading
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
having no Tailwind namespace, in :root beside radius, read as max-w-[var(--measure)]. */
|
|
132
|
+
/* The reading measures are in ch so the character count holds when the body size moves under them:
|
|
133
|
+
--measure-display narrower because bigger type wants fewer characters per line, --measure-wide a
|
|
134
|
+
little past the reading column for the page-head standfirst (PageHead #18). --measure-action is
|
|
135
|
+
the action column, in rem (docs/adr/0008, Amendments, #97); 20rem clears --control-min-width. */
|
|
123
136
|
const MEASURE = `:root {
|
|
124
137
|
--measure: 66ch;
|
|
125
138
|
--measure-display: 36ch;
|
|
126
139
|
--measure-wide: 72ch;
|
|
140
|
+
--measure-action: 20rem;
|
|
127
141
|
}`;
|
|
128
142
|
|
|
129
143
|
/* Three spacing roles, not a ladder: --space-stack is the sibling gap in a stack, --space-region the air
|
|
@@ -159,6 +173,14 @@ const FOLD = `:root {
|
|
|
159
173
|
--fold-height: min(70vh, 40rem);
|
|
160
174
|
}`;
|
|
161
175
|
|
|
176
|
+
/* The least height a whole screen takes (CONTEXT.md: Cover) - measured against the screen like the
|
|
177
|
+
fold, in :root beside it with no Tailwind namespace, read as min-h-[var(--cover-height)]. In svh so
|
|
178
|
+
a mobile browser's collapsing chrome never hides a foot pinned to the bottom edge. A floor and
|
|
179
|
+
never a ceiling, so overflowing content grows the frame; enforced per ADR 0004. */
|
|
180
|
+
const COVER = `:root {
|
|
181
|
+
--cover-height: 100svh;
|
|
182
|
+
}`;
|
|
183
|
+
|
|
162
184
|
/* Four aspect roles in a @theme block like typography: --aspect-* is a Tailwind 4 namespace, so it
|
|
163
185
|
generates the aspect-portrait utilities Figure reaches rather than a :root value. Named shapes, not
|
|
164
186
|
a ladder - ADR 0003/0004 rejected a radius and spacing scale (see SPACING) because a scale lets a
|
|
@@ -225,6 +247,9 @@ ${RADIUS}
|
|
|
225
247
|
/* The control minimum width is not a colour either, and sits in :root beside radius. */
|
|
226
248
|
${CONTROL_MIN_WIDTH}
|
|
227
249
|
|
|
250
|
+
/* The standing slot's minimum width is not a colour either, and sits in :root beside the control one. */
|
|
251
|
+
${STANDING_MIN_WIDTH}
|
|
252
|
+
|
|
228
253
|
/* The library's underline dimensions are not colours either, and sit in :root beside radius. They are
|
|
229
254
|
not prose's alone: every Link treatment that draws a line draws it from these (docs/adr/0006). */
|
|
230
255
|
${UNDERLINE}
|
|
@@ -237,7 +262,9 @@ ${TYPOGRAPHY}
|
|
|
237
262
|
after it and are not carried in @theme inline with the palette. */
|
|
238
263
|
${ASPECT}
|
|
239
264
|
|
|
240
|
-
/* The
|
|
265
|
+
/* The measures have no Tailwind namespace, so they sit in :root beside radius. The reading measures
|
|
266
|
+
are in ch against the type; the action column is in rem against the root, since it bounds a stack
|
|
267
|
+
of controls rather than a reading line (docs/adr/0008, Amendments). */
|
|
241
268
|
${MEASURE}
|
|
242
269
|
|
|
243
270
|
/* Spacing has no Tailwind namespace either - --spacing is a single base multiplier ADR 0004 forbids
|
|
@@ -250,6 +277,9 @@ ${GUTTER}
|
|
|
250
277
|
/* The fold height has no Tailwind namespace either, so it sits in :root beside the gutter. */
|
|
251
278
|
${FOLD}
|
|
252
279
|
|
|
280
|
+
/* The cover height has no Tailwind namespace either, so it sits in :root beside the fold. */
|
|
281
|
+
${COVER}
|
|
282
|
+
|
|
253
283
|
/* The checklist tick's dimensions are not colours either, and sit in :root beside the underline block. */
|
|
254
284
|
${TICK}
|
|
255
285
|
`;
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import './styles.css';
|
|
2
2
|
|
|
3
|
+
export { Cluster } from 'Arrangement/Cluster/Cluster';
|
|
4
|
+
export { Stack } from 'Arrangement/Stack/Stack';
|
|
3
5
|
export { Brandmark } from 'Display/Brandmark/Brandmark';
|
|
4
6
|
export { Checklist } from 'Display/Checklist/Checklist';
|
|
5
7
|
export { DefinitionList } from 'Display/DefinitionList/DefinitionList';
|
|
@@ -13,12 +15,14 @@ export { H3 } from 'Display/Typography/H3/H3';
|
|
|
13
15
|
export { H4 } from 'Display/Typography/H4/H4';
|
|
14
16
|
export { H5 } from 'Display/Typography/H5/H5';
|
|
15
17
|
export { H6 } from 'Display/Typography/H6/H6';
|
|
18
|
+
export { Note } from 'Display/Typography/Note/Note';
|
|
16
19
|
export { P } from 'Display/Typography/P/P';
|
|
17
20
|
export { Prose } from 'Display/Typography/Prose/Prose';
|
|
18
21
|
export { Button } from 'Interaction/Button/Button';
|
|
19
22
|
export { Input } from 'Interaction/Input/Input';
|
|
20
23
|
export { Link } from 'Interaction/Link/Link';
|
|
21
24
|
export { TextArea } from 'Interaction/TextArea/TextArea';
|
|
25
|
+
export { Cover } from 'Layout/Cover/Cover';
|
|
22
26
|
export { Footer } from 'Layout/Footer/Footer';
|
|
23
27
|
export type { FormState } from 'Layout/Form/Form';
|
|
24
28
|
export { Form } from 'Layout/Form/Form';
|