fertig 1.0.2 → 1.1.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/fertig.css CHANGED
@@ -1,4 +1,4 @@
1
- /*! fertig v1.0.2 — a classless CSS stylesheet. MIT */
1
+ /*! fertig v1.1.0 — a classless CSS stylesheet. MIT */
2
2
 
3
3
  /* Opinions, so you don't have to have them:
4
4
  system type · one accent · one measure · a window on a desktop.
@@ -7,8 +7,10 @@
7
7
  Override the tokens marked "yours"; the rest is the point of view. */
8
8
 
9
9
  /* typed so they interpolate; untyped custom properties cannot animate */
10
- @property --a1 { syntax: "<number>"; inherits: true; initial-value: .08 }
11
- @property --a2 { syntax: "<number>"; inherits: true; initial-value: .06 }
10
+ /* Registrations are global: they cannot be layered, scoped or overridden.
11
+ Hence the prefix — a bare --a1 would silently retype a property of yours. */
12
+ @property --fertig-a1 { syntax: "<number>"; inherits: true; initial-value: .08 }
13
+ @property --fertig-a2 { syntax: "<number>"; inherits: true; initial-value: .06 }
12
14
 
13
15
  /* Two layers, in this order. Your own unlayered CSS wins over both regardless
14
16
  of specificity, so overriding never needs !important — and neither does this
@@ -30,8 +32,9 @@
30
32
  --fm: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
31
33
  "Liberation Mono", monospace; /* code */
32
34
  --w: 38rem; /* ~70 characters — yours */
33
- --r: 6px; /* control radius */
34
- --rw: 10px; /* window radius */
35
+ --r: 9px; /* control radius */
36
+ --rs: 5px; /* small radius: inline chips and code */
37
+ --rw: 14px; /* window radius */
35
38
 
36
39
  /* ---- palette ------------------------------------------------
37
40
  Low chroma on purpose. Vivid palettes shout; these are meant to sit
@@ -65,32 +68,77 @@
65
68
  --el: light-dark(oklch(99.4% .002 75), oklch(21% .006 75)); /* paper */
66
69
  --face:light-dark(oklch(97.2% .005 75), oklch(25% .007 75)); /* chrome */
67
70
  --fg: light-dark(oklch(22% .008 75), oklch(95% .004 75));
68
- --mut: light-dark(oklch(50% .009 75), oklch(70% .008 75));
71
+ /* lifted from 50%/70%: the weakest pairing (muted on the desktop ground in
72
+ light, muted on chrome in dark) was 4.96:1 and 5.99:1, a hair over AA.
73
+ 47%/73% takes them to 5.65:1 and 6.70:1 while keeping a clear step down
74
+ from --fg — AAA would need 41.5%, which is close enough to --fg that
75
+ "muted" stops reading as secondary at all. */
76
+ --mut: light-dark(oklch(47% .009 75), oklch(73% .008 75));
69
77
  --bd: light-dark(oklch(22% .008 75 / .13), oklch(95% .004 75 / .13));
70
78
  --tb: light-dark(oklch(97.2% .005 75 / .85), oklch(25% .007 75 / .85));
71
- --ac: light-dark(var(--sky-700), oklch(78% .085 245)); /* yours */
79
+ /* indigo, not the azure every other classless sheet lands on. Measured:
80
+ 6.28:1 as link text on paper and 6.39:1 for a white label on the fill in
81
+ light; 8.72:1 and 10.26:1 in dark. Both sit inside sRGB. */
82
+ --ac: light-dark(oklch(50% .19 275), oklch(78% .11 275)); /* yours */
72
83
  --on-ac: light-dark(#fff, #06121f); /* text on the accent, both themes */
73
84
 
74
85
  /* depth: hairline + soft shadow, macOS weight.
75
- --sh is an ordinary colour — override it with any notation you like;
76
- the alphas come off it through relative colour syntax. */
86
+ --sh is an ordinary colour — override it with any notation you like, and
87
+ every shadow in the sheet retints. The alphas are taken with color-mix
88
+ against transparent rather than relative colour: same result, and it is
89
+ three years older, which keeps Safari 17.5 inside the floor. */
77
90
  --sh: oklch(23.1% .007 286);
78
- --a1: .08; --a2: .06;
79
- --sh1: oklch(from var(--sh) l c h / var(--a1));
80
- --sh2: oklch(from var(--sh) l c h / var(--a2));
91
+ /* The prefixed names are the API; the bare ones are read as a fallback so a
92
+ sheet that set --a1 before 1.1.0 keeps working. --a1 itself stays
93
+ unregistered, which is the point of the rename — an @property
94
+ registration is global and cannot be scoped, so a two-character name was
95
+ silently retyping a consumer's own property. */
96
+ --fertig-a1: var(--a1, .08); --fertig-a2: var(--a2, .06);
97
+ --sh1: color-mix(in srgb, var(--sh) calc(var(--fertig-a1) * 100%), transparent);
98
+ --sh2: color-mix(in srgb, var(--sh) calc(var(--fertig-a2) * 100%), transparent);
81
99
  --up: 0 1px 1px var(--sh1);
82
100
  --up2: 0 1px 2px var(--sh1), 0 6px 16px -6px var(--sh2);
83
- --dn: inset 0 1px 2px oklch(from var(--sh) l c h / .08);
101
+ --dn: inset 0 1px 2px color-mix(in srgb, var(--sh) 8%, transparent);
84
102
  --caps: .74em/1.4 var(--f);
103
+
104
+ /* motion. One decelerating curve for things arriving, one accelerating for
105
+ things leaving, and a symmetric one for state that toggles in place. */
106
+ --ease-out: cubic-bezier(.22, 1, .36, 1);
107
+ --ease-in: cubic-bezier(.55, 0, 1, .45);
108
+ --ease: cubic-bezier(.4, 0, .2, 1);
109
+ --dur-1: .12s; /* a control reacting under the pointer */
110
+ --dur-2: .2s; /* something appearing or leaving */
85
111
  }
86
112
  @media (prefers-color-scheme: dark) {
87
- :root:not([data-theme=light]) { --a1: .5; --a2: .45 }
113
+ :root:not([data-theme=light]) { --fertig-a1: var(--a1, .5); --fertig-a2: var(--a2, .45) }
114
+ }
115
+ /* --on-ac has to survive any --ac override, or a custom accent ships an
116
+ unreadable label. contrast-color() does that properly but has almost no
117
+ support; relative colour reaches the same black-or-white decision from the
118
+ accent's own lightness, and has been cross-engine since 2024. Both are
119
+ additive — the measured light-dark() pair above stands if neither exists. */
120
+ @supports (color: oklch(from red l c h)) {
121
+ :root { --on-ac: oklch(from var(--ac) clamp(0, (l - .62) * -100, 1) 0 h) }
88
122
  }
89
123
  @supports (color: contrast-color(red)) {
90
- :root { --on-ac: contrast-color(var(--ac)) } /* survives any --ac override */
124
+ :root { --on-ac: contrast-color(var(--ac)) }
91
125
  }
92
- [data-theme=light] { color-scheme: light; --a1: .08; --a2: .06 }
93
- [data-theme=dark] { color-scheme: dark; --a1: .5; --a2: .45 }
126
+ [data-theme=light] { color-scheme: light; --fertig-a1: var(--a1, .08); --fertig-a2: var(--a2, .06) }
127
+ [data-theme=dark] { color-scheme: dark; --fertig-a1: var(--a1, .5); --fertig-a2: var(--a2, .45) }
128
+
129
+ /* Named accents. The ramps already exist, so this is a mapping table: put
130
+ data-accent on any element and everything inside it — links, focus rings,
131
+ filled buttons, the caret, the selection — follows. Scoped rather than
132
+ global, so one section of a page can differ from the rest.
133
+ Each pair is measured: the light tone clears AA as link text on paper, the
134
+ dark tone clears it on the dark ground. */
135
+ [data-accent=indigo] { --ac: light-dark(oklch(50% .19 275), oklch(78% .11 275)) }
136
+ [data-accent=sky] { --ac: light-dark(var(--sky-700), oklch(78% .085 245)) }
137
+ [data-accent=sage] { --ac: light-dark(var(--sage-700), oklch(80% .075 155)) }
138
+ [data-accent=clay] { --ac: light-dark(var(--clay-700), oklch(80% .085 45)) }
139
+ [data-accent=plum] { --ac: light-dark(var(--plum-700), oklch(80% .065 320)) }
140
+ [data-accent=gold] { --ac: light-dark(oklch(55% .085 80), oklch(84% .09 85)) }
141
+ [data-accent=slate] { --ac: light-dark(oklch(35% .01 250), oklch(86% .006 250)) }
94
142
 
95
143
  /* ---- base --------------------------------------------------- */
96
144
  *, ::before, ::after { box-sizing: border-box }
@@ -105,30 +153,50 @@ body {
105
153
  font-variant-numeric: slashed-zero tabular-nums;
106
154
  }
107
155
  ::selection { background: color-mix(in srgb, var(--ac) 28%, transparent) }
108
- :focus-visible { /* the macOS ring */
156
+ /* The macOS ring. No border-radius here: an outline already follows the
157
+ element's own corners, and setting one would reshape whatever takes focus —
158
+ a focused dialog would snap from --rw to --r for as long as it is focused. */
159
+ :focus-visible {
109
160
  outline: 3px solid color-mix(in srgb, var(--ac) 45%, transparent);
110
- outline-offset: 1px; border-radius: var(--r);
161
+ outline-offset: 1px;
111
162
  }
112
- img, svg, video, iframe { display: block; max-width: 100%; height: auto }
163
+ img, video, iframe { display: block; max-width: 100%; height: auto }
164
+ /* svg is left inline: it is as often an icon inside a sentence or a button as
165
+ it is a standalone figure, and display:block drops the icon onto its own row */
166
+ svg { max-width: 100%; vertical-align: middle }
113
167
  img, video { border-radius: var(--r); box-shadow: var(--up2) }
114
168
 
115
169
  /* ---- layout: a window on a desktop --------------------------- */
116
- body > * { max-width: var(--w); margin-inline: auto; padding-inline: 1.9rem }
117
- body > header, body > main, body > footer {
170
+ /* The shell is body's own children — or the children of the single wrapper a
171
+ framework insists on (#root, #app, #__next, or your own [data-fertig]).
172
+ Without this a React page silently loses the measure, the paper and the
173
+ gutters, and nothing on screen says why. The wrapper list sits inside
174
+ :where() so every selector keeps the specificity it had as `body > *`. */
175
+ body > *,
176
+ body > :where(#root, #app, #__next, [data-fertig]) > * {
177
+ max-width: var(--w); margin-inline: auto; padding-inline: 1.9rem;
178
+ }
179
+ /* the wrapper is not itself a shell element; it just gets out of the way */
180
+ body > :where(#root, #app, #__next, [data-fertig]) { max-width: none; padding-inline: 0 }
181
+ :is(body, body > :where(#root, #app, #__next, [data-fertig])) > :is(header, main, footer) {
118
182
  background: var(--el); border-inline: 1px solid var(--bd);
119
183
  }
120
- body > header { padding-block: 2.4rem .8rem }
121
- body > main { padding-bottom: 2.8rem }
122
- body > footer {
184
+ :is(body, body > :where(#root, #app, #__next, [data-fertig])) > header { padding-block: 2.4rem .8rem }
185
+ :is(body, body > :where(#root, #app, #__next, [data-fertig])) > main { padding-bottom: 2.8rem }
186
+ :is(body, body > :where(#root, #app, #__next, [data-fertig])) > footer {
123
187
  padding-block: 1.2rem 2rem; color: var(--mut); font-size: .85em;
124
188
  border-top: 1px solid var(--bd); border-bottom: 1px solid var(--bd);
125
189
  border-radius: 0 0 var(--rw) var(--rw); margin-bottom: 2.5rem;
126
- box-shadow: 0 18px 30px -22px oklch(from var(--sh) l c h / .5);
190
+ box-shadow: 0 18px 30px -22px color-mix(in srgb, var(--sh) 50%, transparent);
127
191
  }
128
192
  /* nav = unified toolbar: full-bleed, vibrant, contents on the measure */
129
- body > nav {
193
+ :is(body, body > :where(#root, #app, #__next, [data-fertig])) > nav {
130
194
  position: sticky; top: 0; z-index: 9; max-width: none;
131
- padding-inline: max(1.9rem, calc(50% - var(--w) / 2 + 1.9rem),
195
+ /* the toolbar is full-bleed but its contents line up with the page column.
196
+ --nw is that column: it follows --w, so overriding the measure moves the
197
+ toolbar with it, and .wide re-points it at the wide column instead. */
198
+ --nw: var(--w);
199
+ padding-inline: max(1.9rem, calc(50% - var(--nw) / 2 + 1.9rem),
132
200
  env(safe-area-inset-left), env(safe-area-inset-right));
133
201
  display: flex; flex-wrap: wrap; gap: 1.1rem; align-items: center;
134
202
  padding-block: calc(.6rem + env(safe-area-inset-top, 0px)) .6rem;
@@ -136,10 +204,10 @@ body > nav {
136
204
  backdrop-filter: saturate(180%) blur(20px);
137
205
  border-bottom: 1px solid var(--bd); font-size: .92em;
138
206
  }
139
- body > nav a { color: var(--fg); text-decoration: none; opacity: .75 }
140
- body > nav a:hover { opacity: 1 }
141
- body > nav :is(strong, b) a { opacity: 1 } /* the wordmark is not a link among links */
142
- body > nav ul {
207
+ :is(body, body > :where(#root, #app, #__next, [data-fertig])) > nav a { color: var(--fg); text-decoration: none; opacity: .75 }
208
+ :is(body, body > :where(#root, #app, #__next, [data-fertig])) > nav a:hover { opacity: 1 }
209
+ :is(body, body > :where(#root, #app, #__next, [data-fertig])) > nav :is(strong, b) a { opacity: 1 } /* the wordmark is not a link among links */
210
+ :is(body, body > :where(#root, #app, #__next, [data-fertig])) > nav ul {
143
211
  display: flex; flex-wrap: wrap; gap: 1.1rem; list-style: none; padding: 0; margin: 0;
144
212
  flex: 1; /* takes the slack, so a trailing control sits at the end */
145
213
  }
@@ -172,7 +240,9 @@ blockquote {
172
240
  }
173
241
  ul, ol { padding-inline-start: 1.5rem }
174
242
  li::marker { color: var(--mut) }
175
- dl { display: grid; grid-template-columns: max-content 1fr; gap: .35rem 1rem }
243
+ /* minmax(0, 1fr), not 1fr: a bare 1fr track has min-width:auto, so a long
244
+ value cannot shrink and pushes the page sideways on a narrow screen. */
245
+ dl { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: .35rem 1rem }
176
246
  dt { color: var(--mut) }
177
247
  dd { margin: 0 }
178
248
  figcaption { font-size: .85em; color: var(--mut); text-align: center }
@@ -181,11 +251,11 @@ figcaption { font-size: .85em; color: var(--mut); text-align: center }
181
251
  code, kbd, samp, pre { font-family: var(--fm); font-size: .9em }
182
252
  :not(pre) > code, samp {
183
253
  background: var(--face); border: 1px solid var(--bd);
184
- border-radius: var(--r); padding: .08em .35em;
254
+ border-radius: var(--rs); padding: .08em .35em;
185
255
  }
186
256
  kbd {
187
257
  background: var(--face); border: 1px solid var(--bd); border-bottom-width: 2px;
188
- border-radius: var(--r); padding: .08em .4em; box-shadow: var(--up);
258
+ border-radius: var(--rs); padding: .08em .4em; box-shadow: var(--up);
189
259
  }
190
260
  pre {
191
261
  background: var(--face); border: 1px solid var(--bd); border-radius: var(--r);
@@ -268,9 +338,9 @@ textarea { width: 100%; min-height: 5rem; resize: vertical }
268
338
  @supports (field-sizing: content) {
269
339
  textarea { field-sizing: content; max-height: 24rem }
270
340
  }
271
- input:not([type=checkbox],[type=radio],[type=range],[type=color],[type=file],[type=submit],[type=button],[type=reset]),
341
+ input:not([type=checkbox],[type=radio],[type=range],[type=color],[type=file],[type=image],[type=submit],[type=button],[type=reset]),
272
342
  select, textarea {
273
- display: block; width: 100%; padding: .4em .6em; background: var(--el);
343
+ display: block; width: 100%; padding: .52em .7em; background: var(--el);
274
344
  border: 1px solid var(--bd); border-radius: var(--r); box-shadow: var(--dn);
275
345
  }
276
346
  select {
@@ -294,14 +364,14 @@ select[multiple] { background-image: none; padding-inline-end: .6em }
294
364
  content: ""; inline-size: .45em; block-size: .45em; flex: none;
295
365
  border: solid currentColor; border-width: 0 1px 1px 0;
296
366
  rotate: 45deg; translate: 0 -.15em; opacity: .7;
297
- transition: rotate .15s ease, translate .15s ease;
367
+ transition: rotate var(--dur-1) var(--ease), translate var(--dur-1) var(--ease);
298
368
  }
299
369
  select:open::picker-icon { rotate: 225deg; translate: 0 .1em }
300
370
  ::picker(select) {
301
371
  min-width: anchor-size(width); /* never narrower than the field */
302
372
  border: 1px solid var(--bd); border-radius: var(--r); padding: .3rem;
303
373
  background: var(--el); margin-block-start: .35rem;
304
- box-shadow: var(--up2), 0 20px 40px -14px oklch(from var(--sh) l c h / .4);
374
+ box-shadow: var(--up2), 0 20px 40px -14px color-mix(in srgb, var(--sh) 40%, transparent);
305
375
  }
306
376
  option {
307
377
  display: flex; align-items: center; gap: .5em; padding: .4em .6em;
@@ -352,7 +422,7 @@ legend + * { clear: left }
352
422
  button, [type=submit], [type=button], [type=reset],
353
423
  :where(a.primary, a[role=button]), ::file-selector-button {
354
424
  display: inline-block; text-decoration: none; text-align: center;
355
- padding: .38em 1.1em; background: var(--el); color: var(--fg); cursor: pointer;
425
+ padding: .48em 1.1em; background: var(--el); color: var(--fg); cursor: pointer;
356
426
  border: 1px solid var(--bd); border-radius: var(--r); box-shadow: var(--up);
357
427
  font: inherit; min-width: 5rem;
358
428
  }
@@ -361,22 +431,34 @@ button, [type=submit], [type=button], [type=reset],
361
431
  box-shadow: var(--dn); translate: 0 .5px;
362
432
  }
363
433
  [type=submit], .primary { background: var(--ac); border-color: transparent; color: var(--on-ac) }
434
+ /* The filled button lightens on hover. color-mix does that in oklab, which is
435
+ close enough and is Baseline; relative colour does it by adding to L
436
+ directly, which holds the hue exactly when --ac is a wide-gamut override.
437
+ Both derive from --ac, so a custom accent never needs a second declaration. */
364
438
  :is([type=submit], .primary, a.primary):hover:not(:disabled) {
365
- background: oklch(from var(--ac) calc(l + .05) c h);
439
+ background: color-mix(in oklab, var(--ac), #fff 9%);
440
+ }
441
+ @supports (color: oklch(from red l c h)) {
442
+ :is([type=submit], .primary, a.primary):hover:not(:disabled) {
443
+ background: oklch(from var(--ac) calc(l + .05) c h);
444
+ }
366
445
  }
367
446
 
368
447
  /* button detail: sizes, variants, toggles, icon and block forms */
369
- [data-size=sm] { padding: .2em .7em; font-size: .85em; min-width: 0 }
370
- [data-size=lg] { padding: .55em 1.5em; font-size: 1.05em }
448
+ /* Scoped to controls. data-size, data-block and data-variant are names other
449
+ component libraries and framework wrappers use too, and a bare attribute
450
+ selector would restyle a stranger's <span data-size="sm"> on contact. */
451
+ :is(button, a, input, select, label)[data-size=sm] { padding: .2em .7em; font-size: .85em; min-width: 0 }
452
+ :is(button, a, input, select, label)[data-size=lg] { padding: .55em 1.5em; font-size: 1.05em }
371
453
  button[data-icon] { min-width: 0; padding: .38em .62em; line-height: 1 }
372
- [data-block] { display: block; width: 100%; min-width: 0 }
373
- [data-variant=ghost] { background: none; border-color: transparent; box-shadow: none }
374
- [data-variant=ghost]:hover:not(:disabled) { background: var(--face); border-color: var(--bd) }
375
- [data-variant=link] {
454
+ :is(button, a, input, select, label)[data-block] { display: block; width: 100%; min-width: 0 }
455
+ :is(button, a, input)[data-variant=ghost] { background: none; border-color: transparent; box-shadow: none }
456
+ :is(button, a, input)[data-variant=ghost]:hover:not(:disabled) { background: var(--face); border-color: var(--bd) }
457
+ :is(button, a, input)[data-variant=link] {
376
458
  background: none; border: 0; box-shadow: none; min-width: 0; padding-inline: .2em;
377
459
  color: var(--ac); text-decoration: underline; text-underline-offset: 3px;
378
460
  }
379
- [data-variant=link]:hover:not(:disabled) { background: none; text-decoration-thickness: 2px }
461
+ :is(button, a, input)[data-variant=link]:hover:not(:disabled) { background: none; text-decoration-thickness: 2px }
380
462
  button[aria-pressed=true] { background: var(--face); color: var(--ac); font-weight: 700 }
381
463
  button[aria-pressed=true]:hover:not(:disabled) {
382
464
  background: color-mix(in srgb, var(--fg) 7%, var(--face));
@@ -386,7 +468,13 @@ button[aria-pressed=true]:hover:not(:disabled) {
386
468
  :is([type=submit], .primary):active:not(:disabled) { box-shadow: inset 0 1px 3px #0006 }
387
469
 
388
470
  @supports (corner-shape: squircle) {
389
- * { corner-shape: squircle } /* inert wherever the radius is 0 */
471
+ /* the surfaces this sheet rounds, not `*` — a universal rule would reshape
472
+ your components too, and this one is a taste, not a repair */
473
+ :is(button, [type=submit], [type=button], [type=reset], input, select, textarea,
474
+ fieldset, details, dialog, [popover], pre, kbd, code, samp, img, video,
475
+ menu, aside, [data-avatar], [data-skeleton], .card, .badge) {
476
+ corner-shape: squircle;
477
+ }
390
478
  }
391
479
 
392
480
  /* ---- disclosure / misc -------------------------------------- */
@@ -401,11 +489,11 @@ details[open] > summary { margin-bottom: .6rem }
401
489
  dialog {
402
490
  background: var(--el); color: var(--fg); border: 1px solid var(--bd);
403
491
  border-radius: var(--rw); padding: 1.4rem; max-width: min(30rem, 92dvw);
404
- box-shadow: 0 32px 64px -16px oklch(from var(--sh) l c h / .45);
492
+ box-shadow: 0 32px 64px -16px color-mix(in srgb, var(--sh) 45%, transparent);
405
493
  }
406
494
  dialog > :last-child:not(footer) { margin-bottom: 0 }
407
495
  dialog, [popover] { overscroll-behavior: contain }
408
- dialog::backdrop { background: oklch(from var(--sh) l c h / .35); backdrop-filter: blur(3px) }
496
+ dialog::backdrop { background: color-mix(in srgb, var(--sh) 35%, transparent); backdrop-filter: blur(3px) }
409
497
 
410
498
  /* dialog anatomy: an optional header and footer, pinned while the body scrolls.
411
499
  Sticky rather than a grid, because a classless sheet has no wrapper around
@@ -434,7 +522,8 @@ dialog > footer > :is(p, span):first-child { margin: 0; margin-inline-end: auto;
434
522
  /* sheets come up, they do not appear */
435
523
  @media (prefers-reduced-motion: no-preference) and (update: fast) {
436
524
  dialog, dialog::backdrop {
437
- transition: opacity .2s, translate .2s, display .2s allow-discrete, overlay .2s allow-discrete;
525
+ transition: opacity var(--dur-2) var(--ease-out), translate var(--dur-2) var(--ease-out),
526
+ display var(--dur-2) allow-discrete, overlay var(--dur-2) allow-discrete;
438
527
  }
439
528
  dialog { opacity: 0; translate: 0 10px }
440
529
  dialog[open] { opacity: 1; translate: 0 }
@@ -444,17 +533,19 @@ dialog > footer > :is(p, span):first-child { margin: 0; margin-inline-end: auto;
444
533
  @starting-style { dialog[open]::backdrop { opacity: 0 } }
445
534
  [popover] {
446
535
  opacity: 0; translate: 0 -4px;
447
- transition: opacity .13s, translate .13s, display .13s allow-discrete, overlay .13s allow-discrete;
536
+ transition: opacity .13s var(--ease-out), translate .13s var(--ease-out),
537
+ display .13s allow-discrete, overlay .13s allow-discrete;
448
538
  }
449
539
  [popover]:popover-open { opacity: 1; translate: 0 }
450
540
  @starting-style { [popover]:popover-open { opacity: 0; translate: 0 -4px } }
451
541
  }
452
- search { display: block }
542
+ search { display: block; margin-block: 1.1rem } /* a landmark needs its own rhythm */
453
543
  ::target-text { background: color-mix(in srgb, var(--ac) 28%, transparent) }
454
544
  aside {
455
545
  border: 1px solid var(--bd); border-inline-start: 3px solid var(--ac);
456
546
  border-radius: var(--r); border-start-start-radius: 0; border-end-start-radius: 0;
457
547
  background: var(--el); padding: .7rem 1.1rem;
548
+ margin-block: 1.1rem; /* or the next block sits flush against it */
458
549
  }
459
550
  progress { width: 100%; height: .5rem; border: 0; border-radius: calc(infinity * 1px); background: var(--face) }
460
551
  progress::-webkit-progress-bar { background: var(--face); border-radius: calc(infinity * 1px) }
@@ -464,10 +555,13 @@ progress::-moz-progress-bar { background: var(--ac); border-radius: calc(infinit
464
555
  /* details can only animate to auto height once keywords interpolate */
465
556
  @supports selector(::details-content) {
466
557
  @media (prefers-reduced-motion: no-preference) and (update: fast) {
467
- :root { interpolate-size: allow-keywords }
558
+ /* on details, not :root: allow-keywords is inherited, and setting it
559
+ document-wide would quietly change how your own height:auto
560
+ transitions behave */
561
+ details { interpolate-size: allow-keywords }
468
562
  details::details-content {
469
563
  block-size: 0; overflow: clip;
470
- transition: block-size .28s ease, content-visibility .28s allow-discrete;
564
+ transition: block-size .28s var(--ease), content-visibility .28s allow-discrete;
471
565
  }
472
566
  details[open]::details-content { block-size: auto }
473
567
  }
@@ -477,7 +571,11 @@ progress::-moz-progress-bar { background: var(--ac); border-radius: calc(infinit
477
571
  /* ---- components, addressed by ARIA rather than by class ----- */
478
572
 
479
573
  /* segmented control: <div role=group> of buttons */
480
- [role=group] { display: inline-flex; vertical-align: middle }
574
+ /* max-inline-size + scroll, or a group of four or more buttons pushes the
575
+ whole page sideways on a narrow screen. It scrolls rather than wraps:
576
+ wrapping would break the joined-pill run of radii into separate rows. */
577
+ [role=group] { display: inline-flex; vertical-align: middle;
578
+ max-inline-size: 100%; overflow-x: auto; overscroll-behavior-x: contain }
481
579
  [role=group] > * { border-radius: 0; margin-inline-start: -1px; box-shadow: none; min-width: 0 }
482
580
  [role=group] > :first-child {
483
581
  margin-inline-start: 0;
@@ -507,7 +605,7 @@ input[type=checkbox][role=switch] {
507
605
  input[switch]::after, input[role=switch]::after {
508
606
  content: ""; position: absolute; inset-block-start: .14em; inset-inline-start: .14em;
509
607
  width: 1.02em; height: 1.02em; border-radius: 50%; background: #fff; box-shadow: var(--up);
510
- transition: translate .16s ease;
608
+ transition: translate .16s var(--ease-out);
511
609
  }
512
610
  input[switch]:checked, input[role=switch]:checked { background: var(--ac) }
513
611
  /* translate is physical, so the knob is told which way to travel */
@@ -519,7 +617,7 @@ input[switch]:checked::after, input[role=switch]:checked::after { translate: .9e
519
617
  background: var(--el); color: var(--fg); border: 1px solid var(--bd);
520
618
  border-radius: var(--r); padding: .3rem; min-width: 12rem;
521
619
  max-block-size: 60dvh; overflow: auto; font-size: .95em;
522
- box-shadow: var(--up2), 0 20px 40px -14px oklch(from var(--sh) l c h / .4);
620
+ box-shadow: var(--up2), 0 20px 40px -14px color-mix(in srgb, var(--sh) 40%, transparent);
523
621
  }
524
622
  /* anchor to the invoking button; without support it centres, which is fine.
525
623
  Deliberately NOT scoped to :popover-open — the close is transitioned, so a
@@ -553,7 +651,7 @@ input[switch]:checked::after, input[role=switch]:checked::after { translate: .9e
553
651
 
554
652
  /* breadcrumbs: <nav aria-label="Breadcrumb"><ol> */
555
653
  nav[aria-label] ol { display: flex; flex-wrap: wrap; gap: .5em; list-style: none; padding: 0; font-size: .9em }
556
- nav[aria-label] li + li::before { content: "/"; margin-inline-end: .5em; color: var(--mut) }
654
+ nav[aria-label] ol li + li::before { content: "/"; margin-inline-end: .5em; color: var(--mut) }
557
655
 
558
656
  /* busy: [aria-busy=true] */
559
657
  [aria-busy=true] { cursor: progress }
@@ -567,14 +665,21 @@ nav[aria-label] li + li::before { content: "/"; margin-inline-end: .5em; color:
567
665
  }
568
666
  @keyframes fertig-spin { to { rotate: 360deg } }
569
667
 
570
- /* tooltip: [data-tooltip] */
668
+ /* tooltip: [data-tooltip]. Decorative only, and deliberately so: generated
669
+ content is not reliably exposed to assistive tech and there is no way to
670
+ dismiss it without script (WCAG 1.4.13), so put the same words in aria-label
671
+ or aria-describedby and never say anything here that is said nowhere else.
672
+ A scroll container clips it — [role=group] is one. */
571
673
  [data-tooltip] { position: relative }
572
- [data-tooltip]:is(:hover, :focus-visible)::after {
573
- content: attr(data-tooltip); position: absolute; bottom: 100%; left: 50%;
674
+ [data-tooltip]:is(:hover, :focus-visible, :active)::after {
675
+ content: attr(data-tooltip); position: absolute;
676
+ inset-block-end: 100%; inset-inline-start: 50%;
574
677
  translate: -50% -.45em; z-index: 10; white-space: nowrap; pointer-events: none;
575
678
  background: light-dark(#1d1d1f, #3a3a40); color: #fff; font-size: .8em;
576
679
  padding: .25em .55em; border-radius: var(--r); box-shadow: var(--up2);
577
680
  }
681
+ /* translate is physical; the offset has to be told which way to go */
682
+ [data-tooltip]:dir(rtl)::after { translate: 50% -.45em }
578
683
 
579
684
  /* tones: retint any component by redefining its accent */
580
685
  [data-tone=ok] { --ac: light-dark(var(--sage-700), oklch(76% .085 155)) }
@@ -646,6 +751,27 @@ dialog[data-side=bottom] {
646
751
  position-area: none; inset: auto 1rem 1rem auto; min-width: 16rem;
647
752
  }
648
753
  }
754
+ /* Two open toasts land on the same corner coordinates and sit on top of each
755
+ other, and an open popover is in the top layer so no ordinary wrapper can
756
+ stack them. Make the region the popover instead and the toasts plain
757
+ children of it: <div popover=manual data-toast-region> with a
758
+ <div data-toast> per message. */
759
+ [popover][data-toast-region] {
760
+ position: fixed; inset: auto 1rem 1rem auto; margin: 0;
761
+ display: flex; flex-direction: column; align-items: flex-end; gap: .6rem;
762
+ background: none; border: 0; box-shadow: none; padding: 0;
763
+ max-block-size: none; overflow: visible; pointer-events: none;
764
+ }
765
+ [popover][data-toast-region] > [data-toast] {
766
+ pointer-events: auto; position: static; inset: auto;
767
+ min-width: 16rem; max-width: min(24rem, 92dvw); padding: .8rem 1rem;
768
+ background: var(--el); color: var(--fg); border: 1px solid var(--bd);
769
+ border-radius: var(--r); font-size: .95em;
770
+ box-shadow: var(--up2), 0 20px 40px -14px color-mix(in srgb, var(--sh) 40%, transparent);
771
+ }
772
+ @supports (position-area: block-end span-inline-end) {
773
+ [popover][data-toast-region] { position-area: none; inset: auto 1rem 1rem auto }
774
+ }
649
775
 
650
776
  /* list group: a plain <menu> outside a popover is a bordered list */
651
777
  menu {
@@ -658,6 +784,26 @@ menu > li + li { border-top: 1px solid var(--bd) }
658
784
  [popover] menu > li { padding: 0 }
659
785
  [popover] menu > li + li { border-top: 0 }
660
786
 
787
+ /* navigation lists: a <menu> inside a <nav> is navigation, not a list group.
788
+ No rules between items, no bullets, and the link fills the row so the whole
789
+ band is the hit target. This is what [data-layout=sidebar] below is for —
790
+ the layout shipped without it, which left the obvious markup looking boxed. */
791
+ nav menu {
792
+ border: 0; background: none; padding: 0; margin-block: 0 1.2rem;
793
+ }
794
+ nav menu > li { padding: 0 }
795
+ nav menu > li + li { border-top: 0 }
796
+ nav menu :is(a, button) {
797
+ display: block; inline-size: 100%; text-align: start;
798
+ padding: .38rem .6rem; border: 0; border-radius: var(--r);
799
+ background: none; box-shadow: none; font: inherit;
800
+ color: var(--fg); text-decoration: none; opacity: .72;
801
+ }
802
+ nav menu :is(a, button):hover { background: var(--face); opacity: 1 }
803
+ nav menu :is(a, button)[aria-current] {
804
+ background: var(--face); color: var(--ac); font-weight: 600; opacity: 1;
805
+ }
806
+
661
807
  /* app layout: a sticky sidebar beside the content */
662
808
  [data-layout=sidebar] { display: grid; gap: 1.5rem; align-items: start }
663
809
  @media (width >= 56rem) {
@@ -679,6 +825,10 @@ menu > li + li { border-top: 1px solid var(--bd) }
679
825
  .muted { color: var(--mut) }
680
826
  .center { text-align: center }
681
827
  .wide { max-width: 66rem }
828
+ /* on the toolbar .wide cannot mean "be 66rem wide" — the bar stays full-bleed
829
+ and its contents move out to the wide column, so an app screen's wordmark
830
+ lines up with the app's own content rather than floating mid-screen. */
831
+ :is(body, body > :where(#root, #app, #__next, [data-fertig])) > nav.wide { max-width: none; --nw: 66rem }
682
832
  .row { display: flex; flex-wrap: wrap; gap: .7rem; align-items: center }
683
833
  /* Form controls are full-width blocks by default, which is right in a form and
684
834
  wrong in a toolbar. Inside a .row they size to the row instead. */
@@ -689,9 +839,14 @@ menu > li + li { border-top: 1px solid var(--bd) }
689
839
  .row :is(progress, meter) { width: auto; flex: 1 1 6rem }
690
840
  .row > label { flex: none; margin-top: 0 }
691
841
  .grid { display: grid; gap: .9rem; grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)) }
842
+ /* a .grid is a block-level container that nothing else gives vertical rhythm
843
+ to, so whatever follows one collides with it. Spacing the next sibling
844
+ rather than the grid itself: a margin on .grid would collapse out through
845
+ the top of a padding-less container and open a seam above it. */
846
+ .grid + * { margin-top: 1.1rem }
692
847
  .card {
693
848
  container-type: inline-size; /* query it: @container (min-width: …) */
694
- border: 1px solid var(--bd); border-radius: var(--r); padding: .9rem;
849
+ border: 1px solid var(--bd); border-radius: var(--r); padding: 1.1rem;
695
850
  background: var(--el); box-shadow: var(--up2);
696
851
  display: flex; flex-direction: column; /* so footers line up across a row */
697
852
  gap: .85rem; /* spacing is the container's job */
@@ -758,27 +913,33 @@ menu > li + li { border-top: 1px solid var(--bd) }
758
913
  }
759
914
 
760
915
  /* card anatomy: an optional header, footer, and full-bleed media */
761
- .card > :is(header, footer) {
762
- margin-inline: -.9rem; padding: .55rem .9rem; background: var(--face);
763
- }
916
+ /* No fill on the header and footer. A tinted strip across the top of a card is
917
+ the old panel-heading pattern and it is what dates a sheet on sight; a
918
+ hairline does the same separating job and lets the card read as one surface. */
919
+ .card > :is(header, footer) { margin-inline: -1.1rem; padding-inline: 1.1rem }
764
920
  .card > header {
765
- margin-top: -.9rem; font-weight: 700;
766
- border-bottom: 1px solid var(--bd); border-radius: var(--r) var(--r) 0 0;
921
+ margin-top: -.25rem; padding-bottom: .7rem; font-weight: 600;
922
+ border-bottom: 1px solid var(--bd);
767
923
  }
768
924
  .card > footer {
769
- margin-top: auto; margin-bottom: -.9rem; /* pinned to the bottom edge */
925
+ margin-top: auto; margin-bottom: -.25rem; padding-top: .7rem;
770
926
  color: var(--mut); font-size: .85em;
771
- border-top: 1px solid var(--bd); border-radius: 0 0 var(--r) var(--r);
927
+ border-top: 1px solid var(--bd);
772
928
  }
773
929
  .card > :is(img, video):first-child {
774
- align-self: stretch; width: auto; max-width: none; margin: -.9rem -.9rem 0;
930
+ align-self: stretch; width: auto; max-width: none; margin: -1.1rem -1.1rem 0;
775
931
  aspect-ratio: 16 / 9; object-fit: cover;
776
932
  border: 0; border-radius: var(--r) var(--r) 0 0; box-shadow: none;
777
933
  }
778
- /* inset, not a border, so a tone never shifts the card geometry */
779
- .card[data-tone] { box-shadow: var(--up2), inset 0 2px 0 var(--ac) }
780
- /* a header paints over the card inset, so it carries the rule itself */
781
- .card[data-tone] > header:first-child { box-shadow: inset 0 2px 0 var(--ac) }
934
+ /* A background layer rather than a border or an inset shadow: a border would
935
+ shift the card's geometry, and an inset shadow does not follow the corner
936
+ radius cleanly at this size — it reads as a detached bar with gaps at the
937
+ top corners. A gradient is clipped by the radius, so the tone hugs the curve. */
938
+ .card[data-tone] {
939
+ background: linear-gradient(var(--ac), var(--ac)) top / 100% 3px no-repeat,
940
+ var(--el);
941
+ }
942
+
782
943
  /* a card that is itself a link or button reacts like one */
783
944
  :is(a, button).card {
784
945
  display: flex; width: 100%; /* keep the column from .card */
@@ -787,7 +948,7 @@ menu > li + li { border-top: 1px solid var(--bd) }
787
948
  }
788
949
  :is(a, button).card:hover:not(:disabled) {
789
950
  border-color: color-mix(in srgb, var(--ac) 45%, var(--bd));
790
- box-shadow: var(--up2), 0 12px 26px -14px oklch(from var(--sh) l c h / .4);
951
+ box-shadow: var(--up2), 0 12px 26px -14px color-mix(in srgb, var(--sh) 40%, transparent);
791
952
  translate: 0 -2px;
792
953
  }
793
954
  :is(a, button).card:active:not(:disabled) { translate: 0 0 }
@@ -799,7 +960,7 @@ menu > li + li { border-top: 1px solid var(--bd) }
799
960
  @media (prefers-reduced-motion: no-preference) and (update: fast) { html { scroll-behavior: smooth } }
800
961
  :target { scroll-margin-top: 4rem } /* clears the toolbar */
801
962
  :is(h1,h2,h3,h4):target { background: color-mix(in srgb, var(--ac) 12%, transparent) }
802
- a[target=_blank]::after { content: " ↗"; font-size: .85em; opacity: .6 }
963
+ a[target=_blank]:not(:has(> img, > svg))::after { content: " ↗"; font-size: .85em; opacity: .6 }
803
964
  a:has(> img), a:has(> code) { text-decoration: none }
804
965
  sup, sub { line-height: 0; font-size: .75em } /* stop them stretching lines */
805
966
  q { quotes: "“" "”" "‘" "’" }
@@ -818,13 +979,28 @@ input:user-invalid, textarea:user-invalid { border-color: light-dark(#c0392b, #f
818
979
  input:user-invalid + small { color: light-dark(#c0392b, #ff6b6b) }
819
980
  summary:hover { color: var(--ac) }
820
981
  /* thin scrollbars, macOS weight */
821
- * { scrollbar-width: thin; scrollbar-color: color-mix(in srgb, var(--fg) 25%, transparent) transparent }
982
+ /* scrollbar-width is Safari 18.2, scrollbar-color Safari 26.2 — both younger
983
+ than the floor, and both left ungated on purpose: there is no fallback to
984
+ write. An engine without them paints its own scrollbar, which is exactly
985
+ what a scrollbar should look like there anyway. Same reasoning for
986
+ accent-color at the top of the sheet. */
987
+ /* Not `*`: the page's own scrollbar keeps the width the OS gave it. Thinning
988
+ every scroller on the page narrows hit targets the sheet never made, which
989
+ is a target-size problem for anyone using a pointer imprecisely. */
990
+ :is(pre, table, textarea, dialog, [popover], menu, fieldset,
991
+ [role=group], [data-carousel], [data-layout=sidebar] > *) {
992
+ scrollbar-width: thin;
993
+ scrollbar-color: color-mix(in srgb, var(--fg) 25%, transparent) transparent;
994
+ }
822
995
 
823
996
  @page { margin: 2cm }
824
997
  @media print {
825
998
  :root { --bg: #fff; --el: #fff; --fg: #000; --bd: #999 }
826
- body > nav, body > footer { display: none }
827
- body > * { border: 0; max-width: none }
999
+ :is(body, body > :where(#root, #app, #__next, [data-fertig])) > nav { display: none }
1000
+ /* the footer stays: the legal line, the contact and the citations live there,
1001
+ and a printout without them is missing the part people keep */
1002
+ :is(body, body > :where(#root, #app, #__next, [data-fertig])) > footer { box-shadow: none; margin-bottom: 0 }
1003
+ body > *, body > :where(#root, #app, #__next, [data-fertig]) > * { border: 0; max-width: none }
828
1004
  p, blockquote, li { orphans: 3; widows: 3 } /* no stranded single lines */
829
1005
  a[href^="http"]::after { content: " (" attr(href) ")"; font-size: .8em; color: #555 }
830
1006
  pre, table, figure, details, tr, img { break-inside: avoid }
@@ -839,6 +1015,9 @@ summary:hover { color: var(--ac) }
839
1015
  min-height: 44px;
840
1016
  }
841
1017
  input[type=checkbox], input[type=radio] { width: 1.4em; height: 1.4em }
1018
+ /* 44px is the AAA target (2.5.5). The small variant opts down to the 24px
1019
+ AA floor (2.5.8) rather than stopping being small on a phone. */
1020
+ :is(button, a, input, select, label)[data-size=sm] { min-height: 32px }
842
1021
  }
843
1022
 
844
1023
  /* the OS is already inverting; stop images being inverted twice */
@@ -871,8 +1050,45 @@ summary:hover { color: var(--ac) }
871
1050
 
872
1051
  @media (prefers-reduced-motion: no-preference) and (update: fast) {
873
1052
  a, button, input, select, textarea, summary, tr,
874
- [type=submit], [type=button], [type=reset] {
875
- transition: box-shadow .15s, background-color .15s, border-color .15s, translate .08s;
1053
+ [type=submit], [type=button], [type=reset], .card, .badge,
1054
+ [role=tab], [role=group] > *, [popover] :is(a, button) {
1055
+ transition: box-shadow var(--dur-1) var(--ease),
1056
+ background-color var(--dur-1) var(--ease),
1057
+ border-color var(--dur-1) var(--ease),
1058
+ color var(--dur-1) var(--ease),
1059
+ translate .08s var(--ease-out);
1060
+ }
1061
+ /* the ring must never be animated — it has to be there the instant focus
1062
+ lands, not fade in after it */
1063
+ :focus-visible { transition: none }
1064
+ }
1065
+
1066
+
1067
+ /* ---- below the floor ---------------------------------------
1068
+ Everything above assumes light-dark(). An engine without it does not
1069
+ degrade to "unstyled but readable" — every colour token becomes invalid at
1070
+ computed-value time, so backgrounds go transparent and colour is inherited,
1071
+ and the page can land unreadable rather than plain. These are the same
1072
+ colours flattened to sRGB. Last in the layer, so it wins on order over the
1073
+ token block at the top. Nothing else needs a twin: an invalid --ac on a
1074
+ [data-tone] or [data-accent] element just inherits the one from :root. */
1075
+ @supports not (color: light-dark(#000, #fff)) {
1076
+ :root {
1077
+ --bg: #ece9e5; --el: #fefdfc; --face: #f8f5f2; --fg: #1d1a17;
1078
+ --mut: #5e5a55; --bd: #1d1a1721; --tb: #f8f5f2d9;
1079
+ --ac: #494fcc; --on-ac: #fff;
1080
+ }
1081
+ @media (prefers-color-scheme: dark) {
1082
+ :root:not([data-theme=light]) {
1083
+ --bg: #0f0d0a; --el: #1a1815; --face: #24211e; --fg: #f0eeec;
1084
+ --mut: #aba7a2; --bd: #f0eeec21; --tb: #24211ed9;
1085
+ --ac: #a3b2fe; --on-ac: #06121f;
1086
+ }
1087
+ }
1088
+ [data-theme=dark] {
1089
+ --bg: #0f0d0a; --el: #1a1815; --face: #24211e; --fg: #f0eeec;
1090
+ --mut: #aba7a2; --bd: #f0eeec21; --tb: #24211ed9;
1091
+ --ac: #a3b2fe; --on-ac: #06121f;
876
1092
  }
877
1093
  }
878
1094
 
@@ -887,6 +1103,9 @@ summary:hover { color: var(--ac) }
887
1103
  .card, .badge, details, dialog, [popover], fieldset, table {
888
1104
  border: 1px solid CanvasText;
889
1105
  }
890
- [role=tab][aria-selected=true], button[aria-pressed=true] { border-color: Highlight }
1106
+ /* [role=tab] sets border:0, so there is nothing for border-color to land
1107
+ on, and the rule above just removed the box-shadow that marked it */
1108
+ [role=tab][aria-selected=true] { border-bottom: 2px solid Highlight }
1109
+ button[aria-pressed=true] { border-color: Highlight }
891
1110
  }
892
1111
  }