@wtfalch/design 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/dist/components/Button.d.ts +19 -27
  2. package/dist/components/Button.js +61 -1
  3. package/dist/components/Callout.d.ts +0 -21
  4. package/dist/components/Callout.js +7 -0
  5. package/dist/components/Card.js +7 -0
  6. package/dist/components/Checkbox.d.ts +0 -31
  7. package/dist/components/Checkbox.js +7 -0
  8. package/dist/components/Command.js +10 -2
  9. package/dist/components/DangerZone.js +7 -0
  10. package/dist/components/Dialog.d.ts +0 -21
  11. package/dist/components/Dialog.js +7 -0
  12. package/dist/components/Field.d.ts +22 -50
  13. package/dist/components/Field.js +18 -9
  14. package/dist/components/Identity.d.ts +0 -25
  15. package/dist/components/Identity.js +7 -0
  16. package/dist/components/Input.d.ts +10 -0
  17. package/dist/components/Input.js +31 -3
  18. package/dist/components/Kbd.d.ts +7 -0
  19. package/dist/components/Kbd.js +34 -0
  20. package/dist/components/Markdown.js +7 -0
  21. package/dist/components/Menu.js +10 -2
  22. package/dist/components/Modal.d.ts +0 -33
  23. package/dist/components/Modal.js +7 -0
  24. package/dist/components/Pagination.d.ts +0 -33
  25. package/dist/components/Pagination.js +7 -0
  26. package/dist/components/Popover.js +7 -0
  27. package/dist/components/Rows.js +7 -0
  28. package/dist/components/ScrollArea.js +7 -0
  29. package/dist/components/Select.d.ts +8 -1
  30. package/dist/components/Select.js +34 -5
  31. package/dist/components/Shell.d.ts +35 -0
  32. package/dist/components/Shell.js +26 -0
  33. package/dist/components/SizeGrid.js +7 -0
  34. package/dist/components/Slider.js +7 -0
  35. package/dist/components/SplitPane.js +7 -0
  36. package/dist/components/Stat.d.ts +45 -0
  37. package/dist/components/Stat.js +31 -0
  38. package/dist/components/Tabs.d.ts +21 -0
  39. package/dist/components/Tabs.js +8 -1
  40. package/dist/components/Textarea.js +14 -4
  41. package/dist/components/ThemeSwitch.d.ts +19 -0
  42. package/dist/components/ThemeSwitch.js +65 -0
  43. package/dist/components/Toast.d.ts +24 -18
  44. package/dist/components/Toast.js +44 -3
  45. package/dist/components/Toggle.d.ts +0 -30
  46. package/dist/components/Toggle.js +7 -0
  47. package/dist/components/Tooltip.js +7 -0
  48. package/dist/components/Tour.d.ts +0 -23
  49. package/dist/components/Tour.js +7 -0
  50. package/dist/components/fieldWiring.d.ts +44 -0
  51. package/dist/components/fieldWiring.js +30 -0
  52. package/dist/index.d.ts +23 -3
  53. package/dist/index.js +17 -1
  54. package/dist/styles/index.css +259 -13
  55. package/dist/tf.css +259 -13
  56. package/dist/themes/choice.d.ts +71 -0
  57. package/dist/themes/choice.js +81 -0
  58. package/dist/valet.css +259 -13
  59. package/package.json +2 -1
package/dist/tf.css CHANGED
@@ -363,7 +363,28 @@ body::before {
363
363
  where the flat grey reads as recessed rather than as something to use. It is
364
364
  derived rather than declared -- a new token would have to be added to every
365
365
  theme in `themes.ts`, and a theme that missed it would lose its controls. */
366
+ /* An anchor drawn as a button. Three things the element brings that a
367
+ `<button>` does not, and all three have to go: the underline, the link
368
+ colour, and the text alignment a block anchor inherits. `cursor: pointer`
369
+ it already has. Disabled is `aria-disabled` here -- an anchor ignores the
370
+ native attribute -- so every `:disabled` rule above has an
371
+ `[aria-disabled='true']` twin rather than a rewrite, which leaves each
372
+ `button` selector at the specificity it had and every committed baseline
373
+ untouched. */
374
+ a.btn {
375
+ text-decoration: none;
376
+ color: var(--text);
377
+ text-align: center;
378
+ }
379
+
380
+ a.btn[aria-disabled='true'] {
381
+ /* A disabled anchor still takes the pointer, so say so. The click itself is
382
+ blocked in the capture phase by the component. */
383
+ cursor: not-allowed;
384
+ }
385
+
366
386
  button,
387
+ a.btn,
367
388
  input,
368
389
  textarea,
369
390
  select {
@@ -387,7 +408,8 @@ select {
387
408
  the icon; the button simply had no opinion about how to arrange two things.
388
409
  `inline-flex` and a gap gives it one, and the gap replaces the space
389
410
  character people were putting between an icon and its label. */
390
- button {
411
+ button,
412
+ a.btn {
391
413
  display: inline-flex;
392
414
  align-items: center;
393
415
  justify-content: center;
@@ -413,7 +435,8 @@ button[aria-pressed='true']:hover:not(:disabled) { border-color: var(--accent);
413
435
 
414
436
  /* The glyph never shrinks: in a narrow row flexbox would take it from the icon
415
437
  before the text, and half an icon is not a smaller icon. */
416
- button > svg { flex: none; }
438
+ button > svg,
439
+ a.btn > svg { flex: none; }
417
440
 
418
441
  .size-sm { font-size: var(--text-xs); padding: var(--space-1) var(--space-2); }
419
442
 
@@ -439,6 +462,7 @@ input, textarea, select { width: 100%; }
439
462
  textarea { resize: none; }
440
463
 
441
464
  button:hover:not(:disabled),
465
+ a.btn:hover:not([aria-disabled='true']),
442
466
  input:hover:not(:disabled),
443
467
  textarea:hover:not(:disabled),
444
468
  select:hover:not(:disabled) { border-color: var(--accent); }
@@ -447,6 +471,7 @@ select:hover:not(:disabled) { border-color: var(--accent); }
447
471
  `:focus` on the fields because a focused field is a place you are typing and
448
472
  should say so however you got there. */
449
473
  button:focus-visible,
474
+ a.btn:focus-visible,
450
475
  input:focus,
451
476
  textarea:focus,
452
477
  select:focus { outline: none; border-color: var(--accent); }
@@ -540,11 +565,13 @@ input[type='checkbox']:disabled,
540
565
  input[type='radio']:disabled { cursor: not-allowed; background: var(--panel-2); }
541
566
 
542
567
  button:disabled,
568
+ a.btn[aria-disabled='true'],
543
569
  input:disabled,
544
570
  textarea:disabled,
545
571
  select:disabled { opacity: 0.45; cursor: not-allowed; }
546
572
 
547
- button.primary { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
573
+ button.primary,
574
+ a.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
548
575
 
549
576
  /* A disabled primary is not a faded primary.
550
577
  `opacity: .45` was the whole of it, and a coloured fill at 45% is still a
@@ -552,7 +579,8 @@ button.primary { background: var(--accent); border-color: var(--accent); color:
552
579
  thing on the panel. That got worse when onboarding started holding people at
553
580
  a disabled Next. So it gives up the fill entirely and becomes the shape of a
554
581
  disabled control -- the opacity then has nothing left to do. */
555
- button.primary:disabled {
582
+ button.primary:disabled,
583
+ a.btn.primary[aria-disabled='true'] {
556
584
  background: var(--panel-2);
557
585
  border-color: var(--border);
558
586
  color: var(--muted);
@@ -622,13 +650,15 @@ button[aria-busy='true']::after {
622
650
 
623
651
  /* Quiet: no border, no fill. For the action beside the action -- Cancel next to
624
652
  Save -- where a bordered box competes with the thing it is next to. */
625
- button.ghost {
653
+ button.ghost,
654
+ a.btn.ghost {
626
655
  background: transparent;
627
656
  border-color: transparent;
628
657
  color: var(--muted);
629
658
  }
630
659
 
631
- button.ghost:hover:not(:disabled) {
660
+ button.ghost:hover:not(:disabled),
661
+ a.btn.ghost:hover:not([aria-disabled='true']) {
632
662
  background: var(--panel-2);
633
663
  border-color: transparent;
634
664
  color: var(--text);
@@ -835,19 +865,22 @@ button.ghost:hover:not(:disabled) {
835
865
  /* `:where(button)` so this carries no specificity of its own: a component's
836
866
  press rule below -- a card, a tab, a rail item, all of them buttons --
837
867
  wins by coming later, instead of every one having to restate `button`. */
838
- :where(button):active:not(:disabled) {
868
+ :where(button):active:not(:disabled),
869
+ :where(a.btn):active:not([aria-disabled='true']) {
839
870
  background: color-mix(in srgb, var(--text) var(--press-ink), var(--control));
840
871
  border-color: var(--accent);
841
872
  transform: scale(var(--press-scale));
842
873
  transition: none;
843
874
  }
844
875
 
845
- button.primary:active:not(:disabled) {
876
+ button.primary:active:not(:disabled),
877
+ a.btn.primary:active:not([aria-disabled='true']) {
846
878
  background: color-mix(in srgb, var(--text) var(--press-ink), var(--accent));
847
879
  border-color: color-mix(in srgb, var(--text) var(--press-ink), var(--accent));
848
880
  }
849
881
 
850
- button.ghost:active:not(:disabled) {
882
+ button.ghost:active:not(:disabled),
883
+ a.btn.ghost:active:not([aria-disabled='true']) {
851
884
  background: color-mix(in srgb, var(--text) var(--press-ink), var(--panel-2));
852
885
  border-color: transparent;
853
886
  }
@@ -1070,7 +1103,8 @@ input:active, textarea:active, select:active { transform: none; }
1070
1103
  /* Full width, for the one action a card or a step is about. Not a size: a
1071
1104
  `block` button is the same height as any other, it just stops being a thing
1072
1105
  you have to aim at. */
1073
- button.block { display: flex; width: 100%; }
1106
+ button.block,
1107
+ a.btn.block { display: flex; width: 100%; }
1074
1108
 
1075
1109
  /* The default is `size-md`, spelled once for every control.
1076
1110
  The paragraph above promised one scale for buttons, inputs and selects, and
@@ -1079,6 +1113,7 @@ button.block { display: flex; width: 100%; }
1079
1113
  could line up. The download modal's search box against its Search button was
1080
1114
  the version you could see from across the room. */
1081
1115
  button,
1116
+ a.btn,
1082
1117
  input,
1083
1118
  textarea,
1084
1119
  select,
@@ -1229,18 +1264,21 @@ select,
1229
1264
 
1230
1265
  /* ---- ./dangerzone.css ---- */
1231
1266
  /* Destructive, and legible as such standing still. */
1232
- button.danger {
1267
+ button.danger,
1268
+ a.btn.danger {
1233
1269
  color: var(--bad);
1234
1270
  border-color: color-mix(in srgb, var(--bad) 45%, var(--border-strong));
1235
1271
  }
1236
1272
 
1237
- button.danger:hover:not(:disabled) {
1273
+ button.danger:hover:not(:disabled),
1274
+ a.btn.danger:hover:not([aria-disabled='true']) {
1238
1275
  background: color-mix(in srgb, var(--bad) 12%, var(--panel));
1239
1276
  border-color: var(--bad);
1240
1277
  color: var(--bad);
1241
1278
  }
1242
1279
 
1243
- button.danger:active:not(:disabled) {
1280
+ button.danger:active:not(:disabled),
1281
+ a.btn.danger:active:not([aria-disabled='true']) {
1244
1282
  background: color-mix(in srgb, var(--bad) 20%, var(--panel));
1245
1283
  border-color: var(--bad);
1246
1284
  }
@@ -3350,6 +3388,70 @@ input[type='range'].slider:disabled::-moz-range-thumb { opacity: 0; }
3350
3388
 
3351
3389
  .secret-lg > .secret-eye { padding: var(--space-2); }
3352
3390
 
3391
+ /* An input with something inside its edges: a glyph on the left, a hint or a
3392
+ clear button on the right. Same arrangement as `.secret` above, and for the
3393
+ same reason -- one grid cell with everything stacked in it, and the room
3394
+ reserved in the input's own padding, so nothing is positioned against a
3395
+ height that changes with `size`. */
3396
+ .adorned { display: grid; align-items: center; }
3397
+
3398
+ .adorned > input,
3399
+ .adorned > .adorned-mark,
3400
+ .adorned > .adorned-end { grid-area: 1 / 1; }
3401
+
3402
+ .adorned.block { width: 100%; }
3403
+
3404
+ .adorned > .adorned-mark {
3405
+ justify-self: start;
3406
+ margin-left: var(--space-2);
3407
+ color: var(--muted);
3408
+ /* The glyph is decoration over the box; clicks belong to the input under
3409
+ it, which is otherwise unreachable in its own left edge. */
3410
+ pointer-events: none;
3411
+ /* Over the input, not under it. Both share one grid cell, the input has an
3412
+ opaque `--control` background, and it comes later in the DOM -- so
3413
+ without this the glyph is painted and then covered, which looks exactly
3414
+ like an icon that failed to render. `.secret` never hit this because its
3415
+ button is written after the input. */
3416
+ z-index: 1;
3417
+ }
3418
+
3419
+ /* Room for the glyph, on the scale, so a density change moves the text with
3420
+ the mark rather than leaving one behind. `--space-8` is what `.secret`
3421
+ reserves on the right for the same reason; the scale has no 7, and an
3422
+ undefined custom property does not fall back -- the declaration computes
3423
+ to 0 and the text sits under the glyph. */
3424
+ .adorned-icon > input { padding-left: var(--space-8); }
3425
+
3426
+ /* No `--space-12` on the scale; two steps that are, added. Same arithmetic
3427
+ as `.secret-lg`. */
3428
+ .adorned-icon.adorned-lg > input { padding-left: calc(var(--space-10) + var(--space-1)); }
3429
+
3430
+ .adorned > .adorned-end {
3431
+ justify-self: end;
3432
+ display: flex;
3433
+ align-items: center;
3434
+ gap: var(--space-1);
3435
+ margin-right: var(--space-1);
3436
+ }
3437
+
3438
+ /* `:has`, not a sibling combinator: the input comes *before* the end cap in
3439
+ the DOM, so `~` reads the wrong way and matches nothing. */
3440
+ .adorned:has(> .adorned-end) > input { padding-right: var(--space-8); }
3441
+ .adorned-lg:has(> .adorned-end) > input {
3442
+ padding-right: calc(var(--space-10) + var(--space-1));
3443
+ }
3444
+
3445
+ /* Sized from its glyph, not `.icon-btn`'s 34px tile, which would be taller
3446
+ than a small input -- the note above `.secret-eye` in full. */
3447
+ .adorned-clear {
3448
+ width: auto; height: auto;
3449
+ padding: var(--space-1);
3450
+ opacity: 1;
3451
+ }
3452
+
3453
+ .adorned-lg .adorned-clear { padding: var(--space-2); }
3454
+
3353
3455
  /* ---- ./tour.css ---- */
3354
3456
  /* tour ----------------------------------------------------------------- */
3355
3457
 
@@ -3417,6 +3519,150 @@ input[type='range'].slider:disabled::-moz-range-thumb { opacity: 0; }
3417
3519
  anchored surfaces (`.pop`, `.menu-sheet`, `.cmd`) sit over everything and a
3418
3520
  rule of equal specificity that came earlier would lose to whatever the
3419
3521
  sheets they cover happen to set. */
3522
+ /* ---- ./kbd.css ---- */
3523
+ /* One rule where there were two identical ones, in `menu.css` and
3524
+ `command.css`. Both keep their class as well, so `Menu` and `Command` are
3525
+ `.kbd .menu-key` and `.kbd .cmd-key` now: the declarations are the same, so
3526
+ nothing moves, and the context classes stay available for a rule that ever
3527
+ needs to tell the two apart. */
3528
+ .kbd {
3529
+ flex: none;
3530
+ font-family: var(--font-mono);
3531
+ font-size: var(--text-2xs);
3532
+ color: var(--muted);
3533
+ letter-spacing: var(--tracking);
3534
+ }
3535
+
3536
+ /* ---- ./stat.css ---- */
3537
+ /* A figure with furniture. `--space-1` between the three lines because they
3538
+ are one object read top to bottom, not three paragraphs. */
3539
+ .stat {
3540
+ display: grid;
3541
+ gap: var(--space-1);
3542
+ padding: var(--space-4);
3543
+ border: var(--border-width) solid var(--border);
3544
+ border-radius: var(--radius-md);
3545
+ background: var(--panel);
3546
+ }
3547
+
3548
+ /* Inside something already bordered. A bordered tile in a bordered row is two
3549
+ boxes saying one thing. */
3550
+ .stat-bare {
3551
+ padding: 0;
3552
+ border: 0;
3553
+ border-radius: 0;
3554
+ background: none;
3555
+ gap: 0;
3556
+ line-height: 1.25;
3557
+ }
3558
+
3559
+ .stat > p { margin: 0; }
3560
+
3561
+ .stat-label {
3562
+ color: var(--muted);
3563
+ font-size: var(--text-xs);
3564
+ }
3565
+
3566
+ /* Tabular figures are the point: a column of numbers is compared by its
3567
+ digits' positions, and proportional ones move them per row. */
3568
+ .stat-value {
3569
+ font-size: var(--text-lg);
3570
+ font-weight: var(--weight-strong);
3571
+ font-variant-numeric: tabular-nums;
3572
+ }
3573
+
3574
+ .stat-bare .stat-value { font-size: var(--text-sm); }
3575
+
3576
+ .stat-note {
3577
+ color: var(--muted);
3578
+ font-size: var(--text-xs);
3579
+ }
3580
+
3581
+ /* Only the figure takes the colour. Colouring the label too makes the tile a
3582
+ status box, and a tone here is a remark about the number. */
3583
+ .stat[data-tone='info'] .stat-value { color: var(--info); }
3584
+ .stat[data-tone='good'] .stat-value { color: var(--good); }
3585
+ .stat[data-tone='warn'] .stat-value { color: var(--warn); }
3586
+ .stat[data-tone='bad'] .stat-value { color: var(--bad); }
3587
+
3588
+ /* ---- ./shell.css ---- */
3589
+ /* The frame. Layout only: every colour and every step is a token, and the
3590
+ page inside it owns its own spacing. */
3591
+ .shell {
3592
+ display: flex;
3593
+ flex-direction: column;
3594
+ min-height: 100vh;
3595
+ background: var(--bg);
3596
+ color: var(--text);
3597
+ }
3598
+
3599
+ .shell-head {
3600
+ border-bottom: var(--border-width) solid var(--border);
3601
+ background: var(--panel);
3602
+ /* `flex: none` so a long page does not squeeze the header. */
3603
+ flex: none;
3604
+ }
3605
+
3606
+ /* The measure, said once. `--shell-measure` rather than a literal so an app
3607
+ with a different one sets a variable instead of overriding three rules and
3608
+ missing the fourth -- which is how manage ended up with `head-inner-wide`
3609
+ and `main-wide` as separate classes. */
3610
+ .shell {
3611
+ --shell-measure: 64rem;
3612
+ --shell-measure-wide: 90rem;
3613
+ }
3614
+
3615
+ .shell-head-inner,
3616
+ .shell-nav,
3617
+ .shell-main {
3618
+ width: 100%;
3619
+ max-width: var(--shell-measure);
3620
+ margin-inline: auto;
3621
+ padding-inline: var(--space-4);
3622
+ }
3623
+
3624
+ .shell-wide { max-width: var(--shell-measure-wide); }
3625
+
3626
+ .shell-head-inner {
3627
+ display: flex;
3628
+ align-items: center;
3629
+ justify-content: space-between;
3630
+ gap: var(--space-4);
3631
+ padding-block: var(--space-3);
3632
+ min-width: 0;
3633
+ }
3634
+
3635
+ .shell-who {
3636
+ display: flex;
3637
+ align-items: center;
3638
+ gap: var(--space-3);
3639
+ min-width: 0;
3640
+ }
3641
+
3642
+ .shell-nav { padding-bottom: var(--space-2); }
3643
+
3644
+ .shell-main {
3645
+ flex: 1;
3646
+ padding-block: var(--space-6);
3647
+ /* `min-width: 0` because a flex child will not shrink below its content,
3648
+ and one wide table inside then pushes the whole page sideways. */
3649
+ min-width: 0;
3650
+ }
3651
+
3652
+ /* The label beside the theme control, when it is drawn. */
3653
+ .theme-switch {
3654
+ display: flex;
3655
+ align-items: center;
3656
+ gap: var(--space-2);
3657
+ min-width: 0;
3658
+ }
3659
+
3660
+ .theme-switch-label {
3661
+ color: var(--muted);
3662
+ font-size: var(--text-sm);
3663
+ white-space: nowrap;
3664
+ }
3665
+
3420
3666
  /* ---- ./scrollarea.css ---- */
3421
3667
  /* A box that scrolls, with its edges drawn.
3422
3668
 
@@ -0,0 +1,71 @@
1
+ /**
2
+ * A person's theme, remembered, and applied before the page is drawn.
3
+ *
4
+ * **Three apps wrote this and two of them wrote it identically.**
5
+ * `src/theme/first-paint.ts` is byte-for-byte the same file in
6
+ * wtfalch-manage and app-template; valet's is the same script with the
7
+ * comment rewritten. Each also keeps a `themes.ts` listing ids and labels the
8
+ * package already knows -- a `Theme` carries the `name` a picker shows, and a
9
+ * `Product` carries its themes and which one it wears by default -- so the
10
+ * app-side list was a second copy of a fact, kept in step by hand.
11
+ *
12
+ * **The script is a string, and it has to be.** It runs before the bundle, in
13
+ * a blocking `<script>` in `<head>`, because a theme applied after hydration
14
+ * means a person who chose Paper sees Night for a frame. That is the whole
15
+ * reason this is not simply a `useEffect`.
16
+ *
17
+ * **It sets `data-theme` and nothing else.** The palettes ship as CSS from
18
+ * `productCss`, generated from the same objects the contrast test measures,
19
+ * so the attribute is the entire mechanism. `applyTheme` writes custom
20
+ * properties instead and is for the case with no stylesheet to lean on --
21
+ * theming a subtree, or a product whose CSS is not the one loaded.
22
+ *
23
+ * A copy of this shipped with a bug worth keeping: a stored id the app no
24
+ * longer offers has to fall back, or a browser that remembers `sepia` from a
25
+ * palette you dropped renders unthemed. The list is inlined into the script
26
+ * for that check.
27
+ */
28
+ import type { BrandName } from '../components/brandMarks.js';
29
+ import { type Product } from '../products/index.js';
30
+ /** Where the choice lives. One key across the estate, so a person who picked
31
+ * Night on one app is not asked again on the next one under the same
32
+ * origin. The README has used this name since 0.1.0. */
33
+ export declare const THEME_STORAGE_KEY = "theme";
34
+ export interface ThemeChoice {
35
+ id: string;
36
+ /** What the picker calls it -- the theme's own `name`. */
37
+ label: string;
38
+ /** The line under it, where a picker has room. */
39
+ note: string;
40
+ }
41
+ /**
42
+ * What this product offers, in the order it declared them.
43
+ *
44
+ * `only` narrows to a subset, for an app that ships a product's palette
45
+ * without all of its themes. An id the product does not have is dropped
46
+ * rather than thrown on: the list is presentation, and a picker missing a row
47
+ * is better than a page that will not render.
48
+ */
49
+ export declare function themeChoices(product: BrandName | Product, only?: readonly string[]): ThemeChoice[];
50
+ /**
51
+ * The blocking script, as a string to put in a `<script>` in `<head>`.
52
+ *
53
+ * `dangerouslySetInnerHTML={{ __html: themeChoiceScript('tf') }}` in a Next
54
+ * root layout, above everything. It reads the stored id, checks it against
55
+ * what this app offers, and writes `data-theme` on `<html>`.
56
+ *
57
+ * Wrapped in try/catch because `localStorage` throws outright in a browser
58
+ * set to block site data, and an exception here happens before anything is
59
+ * drawn -- so the page that fails to read a preference would otherwise fail
60
+ * to render at all.
61
+ */
62
+ export declare function themeChoiceScript(product: BrandName | Product, options?: {
63
+ only?: readonly string[];
64
+ storageKey?: string;
65
+ }): string;
66
+ /** What the script would have written, for code that needs the same answer
67
+ * after hydration. Reads the same key and applies the same fallback. */
68
+ export declare function storedTheme(product: BrandName | Product, options?: {
69
+ only?: readonly string[];
70
+ storageKey?: string;
71
+ }): string;
@@ -0,0 +1,81 @@
1
+ /**
2
+ * A person's theme, remembered, and applied before the page is drawn.
3
+ *
4
+ * **Three apps wrote this and two of them wrote it identically.**
5
+ * `src/theme/first-paint.ts` is byte-for-byte the same file in
6
+ * wtfalch-manage and app-template; valet's is the same script with the
7
+ * comment rewritten. Each also keeps a `themes.ts` listing ids and labels the
8
+ * package already knows -- a `Theme` carries the `name` a picker shows, and a
9
+ * `Product` carries its themes and which one it wears by default -- so the
10
+ * app-side list was a second copy of a fact, kept in step by hand.
11
+ *
12
+ * **The script is a string, and it has to be.** It runs before the bundle, in
13
+ * a blocking `<script>` in `<head>`, because a theme applied after hydration
14
+ * means a person who chose Paper sees Night for a frame. That is the whole
15
+ * reason this is not simply a `useEffect`.
16
+ *
17
+ * **It sets `data-theme` and nothing else.** The palettes ship as CSS from
18
+ * `productCss`, generated from the same objects the contrast test measures,
19
+ * so the attribute is the entire mechanism. `applyTheme` writes custom
20
+ * properties instead and is for the case with no stylesheet to lean on --
21
+ * theming a subtree, or a product whose CSS is not the one loaded.
22
+ *
23
+ * A copy of this shipped with a bug worth keeping: a stored id the app no
24
+ * longer offers has to fall back, or a browser that remembers `sepia` from a
25
+ * palette you dropped renders unthemed. The list is inlined into the script
26
+ * for that check.
27
+ */
28
+ import { PRODUCTS } from '../products/index.js';
29
+ /** Where the choice lives. One key across the estate, so a person who picked
30
+ * Night on one app is not asked again on the next one under the same
31
+ * origin. The README has used this name since 0.1.0. */
32
+ export const THEME_STORAGE_KEY = 'theme';
33
+ function resolve(product) {
34
+ return typeof product === 'string' ? PRODUCTS[product] : product;
35
+ }
36
+ /**
37
+ * What this product offers, in the order it declared them.
38
+ *
39
+ * `only` narrows to a subset, for an app that ships a product's palette
40
+ * without all of its themes. An id the product does not have is dropped
41
+ * rather than thrown on: the list is presentation, and a picker missing a row
42
+ * is better than a page that will not render.
43
+ */
44
+ export function themeChoices(product, only) {
45
+ const p = resolve(product);
46
+ const ids = only ? only.filter((id) => id in p.themes) : Object.keys(p.themes);
47
+ return ids.map((id) => ({ id, label: p.themes[id].name, note: p.themes[id].note }));
48
+ }
49
+ /**
50
+ * The blocking script, as a string to put in a `<script>` in `<head>`.
51
+ *
52
+ * `dangerouslySetInnerHTML={{ __html: themeChoiceScript('tf') }}` in a Next
53
+ * root layout, above everything. It reads the stored id, checks it against
54
+ * what this app offers, and writes `data-theme` on `<html>`.
55
+ *
56
+ * Wrapped in try/catch because `localStorage` throws outright in a browser
57
+ * set to block site data, and an exception here happens before anything is
58
+ * drawn -- so the page that fails to read a preference would otherwise fail
59
+ * to render at all.
60
+ */
61
+ export function themeChoiceScript(product, options = {}) {
62
+ const p = resolve(product);
63
+ const ids = themeChoices(product, options.only).map((c) => c.id);
64
+ const key = JSON.stringify(options.storageKey ?? THEME_STORAGE_KEY);
65
+ const fallback = JSON.stringify(ids.includes(p.defaultTheme) ? p.defaultTheme : (ids[0] ?? p.defaultTheme));
66
+ return `(function(){try{var t=localStorage.getItem(${key});document.documentElement.dataset.theme=${JSON.stringify(ids)}.indexOf(t)>=0?t:${fallback}}catch(e){}})()`;
67
+ }
68
+ /** What the script would have written, for code that needs the same answer
69
+ * after hydration. Reads the same key and applies the same fallback. */
70
+ export function storedTheme(product, options = {}) {
71
+ const p = resolve(product);
72
+ const ids = themeChoices(product, options.only).map((c) => c.id);
73
+ const fallback = ids.includes(p.defaultTheme) ? p.defaultTheme : (ids[0] ?? p.defaultTheme);
74
+ try {
75
+ const stored = localStorage.getItem(options.storageKey ?? THEME_STORAGE_KEY);
76
+ return stored && ids.includes(stored) ? stored : fallback;
77
+ }
78
+ catch {
79
+ return fallback;
80
+ }
81
+ }