@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.
- package/dist/components/Button.d.ts +19 -27
- package/dist/components/Button.js +61 -1
- package/dist/components/Callout.d.ts +0 -21
- package/dist/components/Callout.js +7 -0
- package/dist/components/Card.js +7 -0
- package/dist/components/Checkbox.d.ts +0 -31
- package/dist/components/Checkbox.js +7 -0
- package/dist/components/Command.js +10 -2
- package/dist/components/DangerZone.js +7 -0
- package/dist/components/Dialog.d.ts +0 -21
- package/dist/components/Dialog.js +7 -0
- package/dist/components/Field.d.ts +22 -50
- package/dist/components/Field.js +18 -9
- package/dist/components/Identity.d.ts +0 -25
- package/dist/components/Identity.js +7 -0
- package/dist/components/Input.d.ts +10 -0
- package/dist/components/Input.js +31 -3
- package/dist/components/Kbd.d.ts +7 -0
- package/dist/components/Kbd.js +34 -0
- package/dist/components/Markdown.js +7 -0
- package/dist/components/Menu.js +10 -2
- package/dist/components/Modal.d.ts +0 -33
- package/dist/components/Modal.js +7 -0
- package/dist/components/Pagination.d.ts +0 -33
- package/dist/components/Pagination.js +7 -0
- package/dist/components/Popover.js +7 -0
- package/dist/components/Rows.js +7 -0
- package/dist/components/ScrollArea.js +7 -0
- package/dist/components/Select.d.ts +8 -1
- package/dist/components/Select.js +34 -5
- package/dist/components/Shell.d.ts +35 -0
- package/dist/components/Shell.js +26 -0
- package/dist/components/SizeGrid.js +7 -0
- package/dist/components/Slider.js +7 -0
- package/dist/components/SplitPane.js +7 -0
- package/dist/components/Stat.d.ts +45 -0
- package/dist/components/Stat.js +31 -0
- package/dist/components/Tabs.d.ts +21 -0
- package/dist/components/Tabs.js +8 -1
- package/dist/components/Textarea.js +14 -4
- package/dist/components/ThemeSwitch.d.ts +19 -0
- package/dist/components/ThemeSwitch.js +65 -0
- package/dist/components/Toast.d.ts +24 -18
- package/dist/components/Toast.js +44 -3
- package/dist/components/Toggle.d.ts +0 -30
- package/dist/components/Toggle.js +7 -0
- package/dist/components/Tooltip.js +7 -0
- package/dist/components/Tour.d.ts +0 -23
- package/dist/components/Tour.js +7 -0
- package/dist/components/fieldWiring.d.ts +44 -0
- package/dist/components/fieldWiring.js +30 -0
- package/dist/index.d.ts +23 -3
- package/dist/index.js +17 -1
- package/dist/styles/index.css +259 -13
- package/dist/tf.css +259 -13
- package/dist/themes/choice.d.ts +71 -0
- package/dist/themes/choice.js +81 -0
- package/dist/valet.css +259 -13
- 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
|
|
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
|
|
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
|
|
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
|
+
}
|