fertig 1.0.3 → 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.
package/fertig.css CHANGED
@@ -1,4 +1,4 @@
1
- /*! fertig v1.0.3 — a classless CSS stylesheet. MIT */
1
+ /*! fertig v2.0.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
@@ -73,29 +76,64 @@
73
76
  --mut: light-dark(oklch(47% .009 75), oklch(73% .008 75));
74
77
  --bd: light-dark(oklch(22% .008 75 / .13), oklch(95% .004 75 / .13));
75
78
  --tb: light-dark(oklch(97.2% .005 75 / .85), oklch(25% .007 75 / .85));
76
- --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 */
77
83
  --on-ac: light-dark(#fff, #06121f); /* text on the accent, both themes */
78
84
 
79
85
  /* depth: hairline + soft shadow, macOS weight.
80
- --sh is an ordinary colour — override it with any notation you like;
81
- 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. */
82
90
  --sh: oklch(23.1% .007 286);
83
- --a1: .08; --a2: .06;
84
- --sh1: oklch(from var(--sh) l c h / var(--a1));
85
- --sh2: oklch(from var(--sh) l c h / var(--a2));
91
+ --fertig-a1: .08; --fertig-a2: .06;
92
+ --sh1: color-mix(in srgb, var(--sh) calc(var(--fertig-a1) * 100%), transparent);
93
+ --sh2: color-mix(in srgb, var(--sh) calc(var(--fertig-a2) * 100%), transparent);
86
94
  --up: 0 1px 1px var(--sh1);
87
95
  --up2: 0 1px 2px var(--sh1), 0 6px 16px -6px var(--sh2);
88
- --dn: inset 0 1px 2px oklch(from var(--sh) l c h / .08);
96
+ --dn: inset 0 1px 2px color-mix(in srgb, var(--sh) 8%, transparent);
89
97
  --caps: .74em/1.4 var(--f);
98
+
99
+ /* motion. One decelerating curve for things arriving, one accelerating for
100
+ things leaving, and a symmetric one for state that toggles in place. */
101
+ --ease-out: cubic-bezier(.22, 1, .36, 1);
102
+ --ease-in: cubic-bezier(.55, 0, 1, .45);
103
+ --ease: cubic-bezier(.4, 0, .2, 1);
104
+ --dur-1: .12s; /* a control reacting under the pointer */
105
+ --dur-2: .2s; /* something appearing or leaving */
90
106
  }
91
107
  @media (prefers-color-scheme: dark) {
92
- :root:not([data-theme=light]) { --a1: .5; --a2: .45 }
108
+ :root:not([data-theme=light]) { --fertig-a1: .5; --fertig-a2: .45 }
109
+ }
110
+ /* --on-ac has to survive any --ac override, or a custom accent ships an
111
+ unreadable label. contrast-color() does that properly but has almost no
112
+ support; relative colour reaches the same black-or-white decision from the
113
+ accent's own lightness, and has been cross-engine since 2024. Both are
114
+ additive — the measured light-dark() pair above stands if neither exists. */
115
+ @supports (color: oklch(from red l c h)) {
116
+ :root { --on-ac: oklch(from var(--ac) clamp(0, (l - .62) * -100, 1) 0 h) }
93
117
  }
94
118
  @supports (color: contrast-color(red)) {
95
- :root { --on-ac: contrast-color(var(--ac)) } /* survives any --ac override */
119
+ :root { --on-ac: contrast-color(var(--ac)) }
96
120
  }
97
- [data-theme=light] { color-scheme: light; --a1: .08; --a2: .06 }
98
- [data-theme=dark] { color-scheme: dark; --a1: .5; --a2: .45 }
121
+ [data-theme=light] { color-scheme: light; --fertig-a1: .08; --fertig-a2: .06 }
122
+ [data-theme=dark] { color-scheme: dark; --fertig-a1: .5; --fertig-a2: .45 }
123
+
124
+ /* Named accents. The ramps already exist, so this is a mapping table: put
125
+ data-accent on any element and everything inside it — links, focus rings,
126
+ filled buttons, the caret, the selection — follows. Scoped rather than
127
+ global, so one section of a page can differ from the rest.
128
+ Each pair is measured: the light tone clears AA as link text on paper, the
129
+ dark tone clears it on the dark ground. */
130
+ [data-accent=indigo] { --ac: light-dark(oklch(50% .19 275), oklch(78% .11 275)) }
131
+ [data-accent=sky] { --ac: light-dark(var(--sky-700), oklch(78% .085 245)) }
132
+ [data-accent=sage] { --ac: light-dark(var(--sage-700), oklch(80% .075 155)) }
133
+ [data-accent=clay] { --ac: light-dark(var(--clay-700), oklch(80% .085 45)) }
134
+ [data-accent=plum] { --ac: light-dark(var(--plum-700), oklch(80% .065 320)) }
135
+ [data-accent=gold] { --ac: light-dark(oklch(55% .085 80), oklch(84% .09 85)) }
136
+ [data-accent=slate] { --ac: light-dark(oklch(35% .01 250), oklch(86% .006 250)) }
99
137
 
100
138
  /* ---- base --------------------------------------------------- */
101
139
  *, ::before, ::after { box-sizing: border-box }
@@ -110,28 +148,44 @@ body {
110
148
  font-variant-numeric: slashed-zero tabular-nums;
111
149
  }
112
150
  ::selection { background: color-mix(in srgb, var(--ac) 28%, transparent) }
113
- :focus-visible { /* the macOS ring */
151
+ /* The macOS ring. No border-radius here: an outline already follows the
152
+ element's own corners, and setting one would reshape whatever takes focus —
153
+ a focused dialog would snap from --rw to --r for as long as it is focused. */
154
+ :focus-visible {
114
155
  outline: 3px solid color-mix(in srgb, var(--ac) 45%, transparent);
115
- outline-offset: 1px; border-radius: var(--r);
156
+ outline-offset: 1px;
116
157
  }
117
- img, svg, video, iframe { display: block; max-width: 100%; height: auto }
158
+ img, video, iframe { display: block; max-width: 100%; height: auto }
159
+ /* svg is left inline: it is as often an icon inside a sentence or a button as
160
+ it is a standalone figure, and display:block drops the icon onto its own row */
161
+ svg { max-width: 100%; vertical-align: middle }
118
162
  img, video { border-radius: var(--r); box-shadow: var(--up2) }
119
163
 
120
164
  /* ---- layout: a window on a desktop --------------------------- */
121
- body > * { max-width: var(--w); margin-inline: auto; padding-inline: 1.9rem }
122
- body > header, body > main, body > footer {
165
+ /* The shell is body's own children — or the children of the single wrapper a
166
+ framework insists on (#root, #app, #__next, or your own [data-fertig]).
167
+ Without this a React page silently loses the measure, the paper and the
168
+ gutters, and nothing on screen says why. The wrapper list sits inside
169
+ :where() so every selector keeps the specificity it had as `body > *`. */
170
+ body > *,
171
+ body > :where(#root, #app, #__next, [data-fertig]) > * {
172
+ max-width: var(--w); margin-inline: auto; padding-inline: 1.9rem;
173
+ }
174
+ /* the wrapper is not itself a shell element; it just gets out of the way */
175
+ body > :where(#root, #app, #__next, [data-fertig]) { max-width: none; padding-inline: 0 }
176
+ :is(body, body > :where(#root, #app, #__next, [data-fertig])) > :is(header, main, footer) {
123
177
  background: var(--el); border-inline: 1px solid var(--bd);
124
178
  }
125
- body > header { padding-block: 2.4rem .8rem }
126
- body > main { padding-bottom: 2.8rem }
127
- body > footer {
179
+ :is(body, body > :where(#root, #app, #__next, [data-fertig])) > header { padding-block: 2.4rem .8rem }
180
+ :is(body, body > :where(#root, #app, #__next, [data-fertig])) > main { padding-bottom: 2.8rem }
181
+ :is(body, body > :where(#root, #app, #__next, [data-fertig])) > footer {
128
182
  padding-block: 1.2rem 2rem; color: var(--mut); font-size: .85em;
129
183
  border-top: 1px solid var(--bd); border-bottom: 1px solid var(--bd);
130
184
  border-radius: 0 0 var(--rw) var(--rw); margin-bottom: 2.5rem;
131
- box-shadow: 0 18px 30px -22px oklch(from var(--sh) l c h / .5);
185
+ box-shadow: 0 18px 30px -22px color-mix(in srgb, var(--sh) 50%, transparent);
132
186
  }
133
187
  /* nav = unified toolbar: full-bleed, vibrant, contents on the measure */
134
- body > nav {
188
+ :is(body, body > :where(#root, #app, #__next, [data-fertig])) > nav {
135
189
  position: sticky; top: 0; z-index: 9; max-width: none;
136
190
  /* the toolbar is full-bleed but its contents line up with the page column.
137
191
  --nw is that column: it follows --w, so overriding the measure moves the
@@ -145,10 +199,10 @@ body > nav {
145
199
  backdrop-filter: saturate(180%) blur(20px);
146
200
  border-bottom: 1px solid var(--bd); font-size: .92em;
147
201
  }
148
- body > nav a { color: var(--fg); text-decoration: none; opacity: .75 }
149
- body > nav a:hover { opacity: 1 }
150
- body > nav :is(strong, b) a { opacity: 1 } /* the wordmark is not a link among links */
151
- body > nav ul {
202
+ :is(body, body > :where(#root, #app, #__next, [data-fertig])) > nav a { color: var(--fg); text-decoration: none; opacity: .75 }
203
+ :is(body, body > :where(#root, #app, #__next, [data-fertig])) > nav a:hover { opacity: 1 }
204
+ :is(body, body > :where(#root, #app, #__next, [data-fertig])) > nav :is(strong, b) a { opacity: 1 } /* the wordmark is not a link among links */
205
+ :is(body, body > :where(#root, #app, #__next, [data-fertig])) > nav ul {
152
206
  display: flex; flex-wrap: wrap; gap: 1.1rem; list-style: none; padding: 0; margin: 0;
153
207
  flex: 1; /* takes the slack, so a trailing control sits at the end */
154
208
  }
@@ -192,11 +246,11 @@ figcaption { font-size: .85em; color: var(--mut); text-align: center }
192
246
  code, kbd, samp, pre { font-family: var(--fm); font-size: .9em }
193
247
  :not(pre) > code, samp {
194
248
  background: var(--face); border: 1px solid var(--bd);
195
- border-radius: var(--r); padding: .08em .35em;
249
+ border-radius: var(--rs); padding: .08em .35em;
196
250
  }
197
251
  kbd {
198
252
  background: var(--face); border: 1px solid var(--bd); border-bottom-width: 2px;
199
- border-radius: var(--r); padding: .08em .4em; box-shadow: var(--up);
253
+ border-radius: var(--rs); padding: .08em .4em; box-shadow: var(--up);
200
254
  }
201
255
  pre {
202
256
  background: var(--face); border: 1px solid var(--bd); border-radius: var(--r);
@@ -279,9 +333,9 @@ textarea { width: 100%; min-height: 5rem; resize: vertical }
279
333
  @supports (field-sizing: content) {
280
334
  textarea { field-sizing: content; max-height: 24rem }
281
335
  }
282
- input:not([type=checkbox],[type=radio],[type=range],[type=color],[type=file],[type=submit],[type=button],[type=reset]),
336
+ input:not([type=checkbox],[type=radio],[type=range],[type=color],[type=file],[type=image],[type=submit],[type=button],[type=reset]),
283
337
  select, textarea {
284
- display: block; width: 100%; padding: .4em .6em; background: var(--el);
338
+ display: block; width: 100%; padding: .52em .7em; background: var(--el);
285
339
  border: 1px solid var(--bd); border-radius: var(--r); box-shadow: var(--dn);
286
340
  }
287
341
  select {
@@ -305,14 +359,14 @@ select[multiple] { background-image: none; padding-inline-end: .6em }
305
359
  content: ""; inline-size: .45em; block-size: .45em; flex: none;
306
360
  border: solid currentColor; border-width: 0 1px 1px 0;
307
361
  rotate: 45deg; translate: 0 -.15em; opacity: .7;
308
- transition: rotate .15s ease, translate .15s ease;
362
+ transition: rotate var(--dur-1) var(--ease), translate var(--dur-1) var(--ease);
309
363
  }
310
364
  select:open::picker-icon { rotate: 225deg; translate: 0 .1em }
311
365
  ::picker(select) {
312
366
  min-width: anchor-size(width); /* never narrower than the field */
313
367
  border: 1px solid var(--bd); border-radius: var(--r); padding: .3rem;
314
368
  background: var(--el); margin-block-start: .35rem;
315
- box-shadow: var(--up2), 0 20px 40px -14px oklch(from var(--sh) l c h / .4);
369
+ box-shadow: var(--up2), 0 20px 40px -14px color-mix(in srgb, var(--sh) 40%, transparent);
316
370
  }
317
371
  option {
318
372
  display: flex; align-items: center; gap: .5em; padding: .4em .6em;
@@ -363,7 +417,7 @@ legend + * { clear: left }
363
417
  button, [type=submit], [type=button], [type=reset],
364
418
  :where(a.primary, a[role=button]), ::file-selector-button {
365
419
  display: inline-block; text-decoration: none; text-align: center;
366
- padding: .38em 1.1em; background: var(--el); color: var(--fg); cursor: pointer;
420
+ padding: .48em 1.1em; background: var(--el); color: var(--fg); cursor: pointer;
367
421
  border: 1px solid var(--bd); border-radius: var(--r); box-shadow: var(--up);
368
422
  font: inherit; min-width: 5rem;
369
423
  }
@@ -372,22 +426,34 @@ button, [type=submit], [type=button], [type=reset],
372
426
  box-shadow: var(--dn); translate: 0 .5px;
373
427
  }
374
428
  [type=submit], .primary { background: var(--ac); border-color: transparent; color: var(--on-ac) }
429
+ /* The filled button lightens on hover. color-mix does that in oklab, which is
430
+ close enough and is Baseline; relative colour does it by adding to L
431
+ directly, which holds the hue exactly when --ac is a wide-gamut override.
432
+ Both derive from --ac, so a custom accent never needs a second declaration. */
375
433
  :is([type=submit], .primary, a.primary):hover:not(:disabled) {
376
- background: oklch(from var(--ac) calc(l + .05) c h);
434
+ background: color-mix(in oklab, var(--ac), #fff 9%);
435
+ }
436
+ @supports (color: oklch(from red l c h)) {
437
+ :is([type=submit], .primary, a.primary):hover:not(:disabled) {
438
+ background: oklch(from var(--ac) calc(l + .05) c h);
439
+ }
377
440
  }
378
441
 
379
442
  /* button detail: sizes, variants, toggles, icon and block forms */
380
- [data-size=sm] { padding: .2em .7em; font-size: .85em; min-width: 0 }
381
- [data-size=lg] { padding: .55em 1.5em; font-size: 1.05em }
443
+ /* Scoped to controls. data-size, data-block and data-variant are names other
444
+ component libraries and framework wrappers use too, and a bare attribute
445
+ selector would restyle a stranger's <span data-size="sm"> on contact. */
446
+ :is(button, a, input, select, label)[data-size=sm] { padding: .2em .7em; font-size: .85em; min-width: 0 }
447
+ :is(button, a, input, select, label)[data-size=lg] { padding: .55em 1.5em; font-size: 1.05em }
382
448
  button[data-icon] { min-width: 0; padding: .38em .62em; line-height: 1 }
383
- [data-block] { display: block; width: 100%; min-width: 0 }
384
- [data-variant=ghost] { background: none; border-color: transparent; box-shadow: none }
385
- [data-variant=ghost]:hover:not(:disabled) { background: var(--face); border-color: var(--bd) }
386
- [data-variant=link] {
449
+ :is(button, a, input, select, label)[data-block] { display: block; width: 100%; min-width: 0 }
450
+ :is(button, a, input)[data-variant=ghost] { background: none; border-color: transparent; box-shadow: none }
451
+ :is(button, a, input)[data-variant=ghost]:hover:not(:disabled) { background: var(--face); border-color: var(--bd) }
452
+ :is(button, a, input)[data-variant=link] {
387
453
  background: none; border: 0; box-shadow: none; min-width: 0; padding-inline: .2em;
388
454
  color: var(--ac); text-decoration: underline; text-underline-offset: 3px;
389
455
  }
390
- [data-variant=link]:hover:not(:disabled) { background: none; text-decoration-thickness: 2px }
456
+ :is(button, a, input)[data-variant=link]:hover:not(:disabled) { background: none; text-decoration-thickness: 2px }
391
457
  button[aria-pressed=true] { background: var(--face); color: var(--ac); font-weight: 700 }
392
458
  button[aria-pressed=true]:hover:not(:disabled) {
393
459
  background: color-mix(in srgb, var(--fg) 7%, var(--face));
@@ -397,7 +463,13 @@ button[aria-pressed=true]:hover:not(:disabled) {
397
463
  :is([type=submit], .primary):active:not(:disabled) { box-shadow: inset 0 1px 3px #0006 }
398
464
 
399
465
  @supports (corner-shape: squircle) {
400
- * { corner-shape: squircle } /* inert wherever the radius is 0 */
466
+ /* the surfaces this sheet rounds, not `*` — a universal rule would reshape
467
+ your components too, and this one is a taste, not a repair */
468
+ :is(button, [type=submit], [type=button], [type=reset], input, select, textarea,
469
+ fieldset, details, dialog, [popover], pre, kbd, code, samp, img, video,
470
+ menu, aside, [data-avatar], [data-skeleton], .card, .badge) {
471
+ corner-shape: squircle;
472
+ }
401
473
  }
402
474
 
403
475
  /* ---- disclosure / misc -------------------------------------- */
@@ -412,11 +484,11 @@ details[open] > summary { margin-bottom: .6rem }
412
484
  dialog {
413
485
  background: var(--el); color: var(--fg); border: 1px solid var(--bd);
414
486
  border-radius: var(--rw); padding: 1.4rem; max-width: min(30rem, 92dvw);
415
- box-shadow: 0 32px 64px -16px oklch(from var(--sh) l c h / .45);
487
+ box-shadow: 0 32px 64px -16px color-mix(in srgb, var(--sh) 45%, transparent);
416
488
  }
417
489
  dialog > :last-child:not(footer) { margin-bottom: 0 }
418
490
  dialog, [popover] { overscroll-behavior: contain }
419
- dialog::backdrop { background: oklch(from var(--sh) l c h / .35); backdrop-filter: blur(3px) }
491
+ dialog::backdrop { background: color-mix(in srgb, var(--sh) 35%, transparent); backdrop-filter: blur(3px) }
420
492
 
421
493
  /* dialog anatomy: an optional header and footer, pinned while the body scrolls.
422
494
  Sticky rather than a grid, because a classless sheet has no wrapper around
@@ -445,7 +517,8 @@ dialog > footer > :is(p, span):first-child { margin: 0; margin-inline-end: auto;
445
517
  /* sheets come up, they do not appear */
446
518
  @media (prefers-reduced-motion: no-preference) and (update: fast) {
447
519
  dialog, dialog::backdrop {
448
- transition: opacity .2s, translate .2s, display .2s allow-discrete, overlay .2s allow-discrete;
520
+ transition: opacity var(--dur-2) var(--ease-out), translate var(--dur-2) var(--ease-out),
521
+ display var(--dur-2) allow-discrete, overlay var(--dur-2) allow-discrete;
449
522
  }
450
523
  dialog { opacity: 0; translate: 0 10px }
451
524
  dialog[open] { opacity: 1; translate: 0 }
@@ -455,7 +528,8 @@ dialog > footer > :is(p, span):first-child { margin: 0; margin-inline-end: auto;
455
528
  @starting-style { dialog[open]::backdrop { opacity: 0 } }
456
529
  [popover] {
457
530
  opacity: 0; translate: 0 -4px;
458
- transition: opacity .13s, translate .13s, display .13s allow-discrete, overlay .13s allow-discrete;
531
+ transition: opacity .13s var(--ease-out), translate .13s var(--ease-out),
532
+ display .13s allow-discrete, overlay .13s allow-discrete;
459
533
  }
460
534
  [popover]:popover-open { opacity: 1; translate: 0 }
461
535
  @starting-style { [popover]:popover-open { opacity: 0; translate: 0 -4px } }
@@ -476,10 +550,13 @@ progress::-moz-progress-bar { background: var(--ac); border-radius: calc(infinit
476
550
  /* details can only animate to auto height once keywords interpolate */
477
551
  @supports selector(::details-content) {
478
552
  @media (prefers-reduced-motion: no-preference) and (update: fast) {
479
- :root { interpolate-size: allow-keywords }
553
+ /* on details, not :root: allow-keywords is inherited, and setting it
554
+ document-wide would quietly change how your own height:auto
555
+ transitions behave */
556
+ details { interpolate-size: allow-keywords }
480
557
  details::details-content {
481
558
  block-size: 0; overflow: clip;
482
- transition: block-size .28s ease, content-visibility .28s allow-discrete;
559
+ transition: block-size .28s var(--ease), content-visibility .28s allow-discrete;
483
560
  }
484
561
  details[open]::details-content { block-size: auto }
485
562
  }
@@ -523,7 +600,7 @@ input[type=checkbox][role=switch] {
523
600
  input[switch]::after, input[role=switch]::after {
524
601
  content: ""; position: absolute; inset-block-start: .14em; inset-inline-start: .14em;
525
602
  width: 1.02em; height: 1.02em; border-radius: 50%; background: #fff; box-shadow: var(--up);
526
- transition: translate .16s ease;
603
+ transition: translate .16s var(--ease-out);
527
604
  }
528
605
  input[switch]:checked, input[role=switch]:checked { background: var(--ac) }
529
606
  /* translate is physical, so the knob is told which way to travel */
@@ -535,7 +612,7 @@ input[switch]:checked::after, input[role=switch]:checked::after { translate: .9e
535
612
  background: var(--el); color: var(--fg); border: 1px solid var(--bd);
536
613
  border-radius: var(--r); padding: .3rem; min-width: 12rem;
537
614
  max-block-size: 60dvh; overflow: auto; font-size: .95em;
538
- box-shadow: var(--up2), 0 20px 40px -14px oklch(from var(--sh) l c h / .4);
615
+ box-shadow: var(--up2), 0 20px 40px -14px color-mix(in srgb, var(--sh) 40%, transparent);
539
616
  }
540
617
  /* anchor to the invoking button; without support it centres, which is fine.
541
618
  Deliberately NOT scoped to :popover-open — the close is transitioned, so a
@@ -583,14 +660,21 @@ nav[aria-label] ol li + li::before { content: "/"; margin-inline-end: .5em; colo
583
660
  }
584
661
  @keyframes fertig-spin { to { rotate: 360deg } }
585
662
 
586
- /* tooltip: [data-tooltip] */
663
+ /* tooltip: [data-tooltip]. Decorative only, and deliberately so: generated
664
+ content is not reliably exposed to assistive tech and there is no way to
665
+ dismiss it without script (WCAG 1.4.13), so put the same words in aria-label
666
+ or aria-describedby and never say anything here that is said nowhere else.
667
+ A scroll container clips it — [role=group] is one. */
587
668
  [data-tooltip] { position: relative }
588
- [data-tooltip]:is(:hover, :focus-visible)::after {
589
- content: attr(data-tooltip); position: absolute; bottom: 100%; left: 50%;
669
+ [data-tooltip]:is(:hover, :focus-visible, :active)::after {
670
+ content: attr(data-tooltip); position: absolute;
671
+ inset-block-end: 100%; inset-inline-start: 50%;
590
672
  translate: -50% -.45em; z-index: 10; white-space: nowrap; pointer-events: none;
591
673
  background: light-dark(#1d1d1f, #3a3a40); color: #fff; font-size: .8em;
592
674
  padding: .25em .55em; border-radius: var(--r); box-shadow: var(--up2);
593
675
  }
676
+ /* translate is physical; the offset has to be told which way to go */
677
+ [data-tooltip]:dir(rtl)::after { translate: 50% -.45em }
594
678
 
595
679
  /* tones: retint any component by redefining its accent */
596
680
  [data-tone=ok] { --ac: light-dark(var(--sage-700), oklch(76% .085 155)) }
@@ -662,6 +746,27 @@ dialog[data-side=bottom] {
662
746
  position-area: none; inset: auto 1rem 1rem auto; min-width: 16rem;
663
747
  }
664
748
  }
749
+ /* Two open toasts land on the same corner coordinates and sit on top of each
750
+ other, and an open popover is in the top layer so no ordinary wrapper can
751
+ stack them. Make the region the popover instead and the toasts plain
752
+ children of it: <div popover=manual data-toast-region> with a
753
+ <div data-toast> per message. */
754
+ [popover][data-toast-region] {
755
+ position: fixed; inset: auto 1rem 1rem auto; margin: 0;
756
+ display: flex; flex-direction: column; align-items: flex-end; gap: .6rem;
757
+ background: none; border: 0; box-shadow: none; padding: 0;
758
+ max-block-size: none; overflow: visible; pointer-events: none;
759
+ }
760
+ [popover][data-toast-region] > [data-toast] {
761
+ pointer-events: auto; position: static; inset: auto;
762
+ min-width: 16rem; max-width: min(24rem, 92dvw); padding: .8rem 1rem;
763
+ background: var(--el); color: var(--fg); border: 1px solid var(--bd);
764
+ border-radius: var(--r); font-size: .95em;
765
+ box-shadow: var(--up2), 0 20px 40px -14px color-mix(in srgb, var(--sh) 40%, transparent);
766
+ }
767
+ @supports (position-area: block-end span-inline-end) {
768
+ [popover][data-toast-region] { position-area: none; inset: auto 1rem 1rem auto }
769
+ }
665
770
 
666
771
  /* list group: a plain <menu> outside a popover is a bordered list */
667
772
  menu {
@@ -718,7 +823,7 @@ nav menu :is(a, button)[aria-current] {
718
823
  /* on the toolbar .wide cannot mean "be 66rem wide" — the bar stays full-bleed
719
824
  and its contents move out to the wide column, so an app screen's wordmark
720
825
  lines up with the app's own content rather than floating mid-screen. */
721
- body > nav.wide { max-width: none; --nw: 66rem }
826
+ :is(body, body > :where(#root, #app, #__next, [data-fertig])) > nav.wide { max-width: none; --nw: 66rem }
722
827
  .row { display: flex; flex-wrap: wrap; gap: .7rem; align-items: center }
723
828
  /* Form controls are full-width blocks by default, which is right in a form and
724
829
  wrong in a toolbar. Inside a .row they size to the row instead. */
@@ -736,7 +841,7 @@ body > nav.wide { max-width: none; --nw: 66rem }
736
841
  .grid + * { margin-top: 1.1rem }
737
842
  .card {
738
843
  container-type: inline-size; /* query it: @container (min-width: …) */
739
- border: 1px solid var(--bd); border-radius: var(--r); padding: .9rem;
844
+ border: 1px solid var(--bd); border-radius: var(--r); padding: 1.1rem;
740
845
  background: var(--el); box-shadow: var(--up2);
741
846
  display: flex; flex-direction: column; /* so footers line up across a row */
742
847
  gap: .85rem; /* spacing is the container's job */
@@ -803,27 +908,33 @@ body > nav.wide { max-width: none; --nw: 66rem }
803
908
  }
804
909
 
805
910
  /* card anatomy: an optional header, footer, and full-bleed media */
806
- .card > :is(header, footer) {
807
- margin-inline: -.9rem; padding: .55rem .9rem; background: var(--face);
808
- }
911
+ /* No fill on the header and footer. A tinted strip across the top of a card is
912
+ the old panel-heading pattern and it is what dates a sheet on sight; a
913
+ hairline does the same separating job and lets the card read as one surface. */
914
+ .card > :is(header, footer) { margin-inline: -1.1rem; padding-inline: 1.1rem }
809
915
  .card > header {
810
- margin-top: -.9rem; font-weight: 700;
811
- border-bottom: 1px solid var(--bd); border-radius: var(--r) var(--r) 0 0;
916
+ margin-top: -.25rem; padding-bottom: .7rem; font-weight: 600;
917
+ border-bottom: 1px solid var(--bd);
812
918
  }
813
919
  .card > footer {
814
- margin-top: auto; margin-bottom: -.9rem; /* pinned to the bottom edge */
920
+ margin-top: auto; margin-bottom: -.25rem; padding-top: .7rem;
815
921
  color: var(--mut); font-size: .85em;
816
- border-top: 1px solid var(--bd); border-radius: 0 0 var(--r) var(--r);
922
+ border-top: 1px solid var(--bd);
817
923
  }
818
924
  .card > :is(img, video):first-child {
819
- align-self: stretch; width: auto; max-width: none; margin: -.9rem -.9rem 0;
925
+ align-self: stretch; width: auto; max-width: none; margin: -1.1rem -1.1rem 0;
820
926
  aspect-ratio: 16 / 9; object-fit: cover;
821
927
  border: 0; border-radius: var(--r) var(--r) 0 0; box-shadow: none;
822
928
  }
823
- /* inset, not a border, so a tone never shifts the card geometry */
824
- .card[data-tone] { box-shadow: var(--up2), inset 0 2px 0 var(--ac) }
825
- /* a header paints over the card inset, so it carries the rule itself */
826
- .card[data-tone] > header:first-child { box-shadow: inset 0 2px 0 var(--ac) }
929
+ /* A background layer rather than a border or an inset shadow: a border would
930
+ shift the card's geometry, and an inset shadow does not follow the corner
931
+ radius cleanly at this size — it reads as a detached bar with gaps at the
932
+ top corners. A gradient is clipped by the radius, so the tone hugs the curve. */
933
+ .card[data-tone] {
934
+ background: linear-gradient(var(--ac), var(--ac)) top / 100% 3px no-repeat,
935
+ var(--el);
936
+ }
937
+
827
938
  /* a card that is itself a link or button reacts like one */
828
939
  :is(a, button).card {
829
940
  display: flex; width: 100%; /* keep the column from .card */
@@ -832,7 +943,7 @@ body > nav.wide { max-width: none; --nw: 66rem }
832
943
  }
833
944
  :is(a, button).card:hover:not(:disabled) {
834
945
  border-color: color-mix(in srgb, var(--ac) 45%, var(--bd));
835
- box-shadow: var(--up2), 0 12px 26px -14px oklch(from var(--sh) l c h / .4);
946
+ box-shadow: var(--up2), 0 12px 26px -14px color-mix(in srgb, var(--sh) 40%, transparent);
836
947
  translate: 0 -2px;
837
948
  }
838
949
  :is(a, button).card:active:not(:disabled) { translate: 0 0 }
@@ -844,7 +955,7 @@ body > nav.wide { max-width: none; --nw: 66rem }
844
955
  @media (prefers-reduced-motion: no-preference) and (update: fast) { html { scroll-behavior: smooth } }
845
956
  :target { scroll-margin-top: 4rem } /* clears the toolbar */
846
957
  :is(h1,h2,h3,h4):target { background: color-mix(in srgb, var(--ac) 12%, transparent) }
847
- a[target=_blank]::after { content: " ↗"; font-size: .85em; opacity: .6 }
958
+ a[target=_blank]:not(:has(> img, > svg))::after { content: " ↗"; font-size: .85em; opacity: .6 }
848
959
  a:has(> img), a:has(> code) { text-decoration: none }
849
960
  sup, sub { line-height: 0; font-size: .75em } /* stop them stretching lines */
850
961
  q { quotes: "“" "”" "‘" "’" }
@@ -863,13 +974,28 @@ input:user-invalid, textarea:user-invalid { border-color: light-dark(#c0392b, #f
863
974
  input:user-invalid + small { color: light-dark(#c0392b, #ff6b6b) }
864
975
  summary:hover { color: var(--ac) }
865
976
  /* thin scrollbars, macOS weight */
866
- * { scrollbar-width: thin; scrollbar-color: color-mix(in srgb, var(--fg) 25%, transparent) transparent }
977
+ /* scrollbar-width is Safari 18.2, scrollbar-color Safari 26.2 — both younger
978
+ than the floor, and both left ungated on purpose: there is no fallback to
979
+ write. An engine without them paints its own scrollbar, which is exactly
980
+ what a scrollbar should look like there anyway. Same reasoning for
981
+ accent-color at the top of the sheet. */
982
+ /* Not `*`: the page's own scrollbar keeps the width the OS gave it. Thinning
983
+ every scroller on the page narrows hit targets the sheet never made, which
984
+ is a target-size problem for anyone using a pointer imprecisely. */
985
+ :is(pre, table, textarea, dialog, [popover], menu, fieldset,
986
+ [role=group], [data-carousel], [data-layout=sidebar] > *) {
987
+ scrollbar-width: thin;
988
+ scrollbar-color: color-mix(in srgb, var(--fg) 25%, transparent) transparent;
989
+ }
867
990
 
868
991
  @page { margin: 2cm }
869
992
  @media print {
870
993
  :root { --bg: #fff; --el: #fff; --fg: #000; --bd: #999 }
871
- body > nav, body > footer { display: none }
872
- body > * { border: 0; max-width: none }
994
+ :is(body, body > :where(#root, #app, #__next, [data-fertig])) > nav { display: none }
995
+ /* the footer stays: the legal line, the contact and the citations live there,
996
+ and a printout without them is missing the part people keep */
997
+ :is(body, body > :where(#root, #app, #__next, [data-fertig])) > footer { box-shadow: none; margin-bottom: 0 }
998
+ body > *, body > :where(#root, #app, #__next, [data-fertig]) > * { border: 0; max-width: none }
873
999
  p, blockquote, li { orphans: 3; widows: 3 } /* no stranded single lines */
874
1000
  a[href^="http"]::after { content: " (" attr(href) ")"; font-size: .8em; color: #555 }
875
1001
  pre, table, figure, details, tr, img { break-inside: avoid }
@@ -884,6 +1010,9 @@ summary:hover { color: var(--ac) }
884
1010
  min-height: 44px;
885
1011
  }
886
1012
  input[type=checkbox], input[type=radio] { width: 1.4em; height: 1.4em }
1013
+ /* 44px is the AAA target (2.5.5). The small variant opts down to the 24px
1014
+ AA floor (2.5.8) rather than stopping being small on a phone. */
1015
+ :is(button, a, input, select, label)[data-size=sm] { min-height: 32px }
887
1016
  }
888
1017
 
889
1018
  /* the OS is already inverting; stop images being inverted twice */
@@ -916,8 +1045,45 @@ summary:hover { color: var(--ac) }
916
1045
 
917
1046
  @media (prefers-reduced-motion: no-preference) and (update: fast) {
918
1047
  a, button, input, select, textarea, summary, tr,
919
- [type=submit], [type=button], [type=reset] {
920
- transition: box-shadow .15s, background-color .15s, border-color .15s, translate .08s;
1048
+ [type=submit], [type=button], [type=reset], .card, .badge,
1049
+ [role=tab], [role=group] > *, [popover] :is(a, button) {
1050
+ transition: box-shadow var(--dur-1) var(--ease),
1051
+ background-color var(--dur-1) var(--ease),
1052
+ border-color var(--dur-1) var(--ease),
1053
+ color var(--dur-1) var(--ease),
1054
+ translate .08s var(--ease-out);
1055
+ }
1056
+ /* the ring must never be animated — it has to be there the instant focus
1057
+ lands, not fade in after it */
1058
+ :focus-visible { transition: none }
1059
+ }
1060
+
1061
+
1062
+ /* ---- below the floor ---------------------------------------
1063
+ Everything above assumes light-dark(). An engine without it does not
1064
+ degrade to "unstyled but readable" — every colour token becomes invalid at
1065
+ computed-value time, so backgrounds go transparent and colour is inherited,
1066
+ and the page can land unreadable rather than plain. These are the same
1067
+ colours flattened to sRGB. Last in the layer, so it wins on order over the
1068
+ token block at the top. Nothing else needs a twin: an invalid --ac on a
1069
+ [data-tone] or [data-accent] element just inherits the one from :root. */
1070
+ @supports not (color: light-dark(#000, #fff)) {
1071
+ :root {
1072
+ --bg: #ece9e5; --el: #fefdfc; --face: #f8f5f2; --fg: #1d1a17;
1073
+ --mut: #5e5a55; --bd: #1d1a1721; --tb: #f8f5f2d9;
1074
+ --ac: #494fcc; --on-ac: #fff;
1075
+ }
1076
+ @media (prefers-color-scheme: dark) {
1077
+ :root:not([data-theme=light]) {
1078
+ --bg: #0f0d0a; --el: #1a1815; --face: #24211e; --fg: #f0eeec;
1079
+ --mut: #aba7a2; --bd: #f0eeec21; --tb: #24211ed9;
1080
+ --ac: #a3b2fe; --on-ac: #06121f;
1081
+ }
1082
+ }
1083
+ [data-theme=dark] {
1084
+ --bg: #0f0d0a; --el: #1a1815; --face: #24211e; --fg: #f0eeec;
1085
+ --mut: #aba7a2; --bd: #f0eeec21; --tb: #24211ed9;
1086
+ --ac: #a3b2fe; --on-ac: #06121f;
921
1087
  }
922
1088
  }
923
1089
 
@@ -932,6 +1098,9 @@ summary:hover { color: var(--ac) }
932
1098
  .card, .badge, details, dialog, [popover], fieldset, table {
933
1099
  border: 1px solid CanvasText;
934
1100
  }
935
- [role=tab][aria-selected=true], button[aria-pressed=true] { border-color: Highlight }
1101
+ /* [role=tab] sets border:0, so there is nothing for border-color to land
1102
+ on, and the rule above just removed the box-shadow that marked it */
1103
+ [role=tab][aria-selected=true] { border-bottom: 2px solid Highlight }
1104
+ button[aria-pressed=true] { border-color: Highlight }
936
1105
  }
937
1106
  }