@wtfalch/design 0.1.0 → 0.3.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.
@@ -0,0 +1,104 @@
1
+ /**
2
+ * tf's themes: the three the package shipped with, and the ones the gallery
3
+ * was photographed in first.
4
+ *
5
+ * `system` is a theme, not a mode. A `prefers-color-scheme` block that
6
+ * overrides `:root` unconditionally means choosing a dark theme on a
7
+ * light-mode laptop gets silently repainted; that media query is scoped to
8
+ * this theme in `base.css`, so following the OS is a choice among the others
9
+ * rather than a rule above them.
10
+ */
11
+ export const system = {
12
+ name: 'System',
13
+ note: 'Follows your OS between light and dark',
14
+ scheme: 'dark',
15
+ // Empty on purpose: this is the one theme that must *not* state a palette,
16
+ // because the `prefers-color-scheme` block is scoped to it and needs the
17
+ // base values to fall through. Its swatch is a special case.
18
+ tokens: {},
19
+ };
20
+ /**
21
+ * The dark palette, written out rather than inherited.
22
+ *
23
+ * `night` was `tokens: {}` once, "whatever the base is", and that was wrong
24
+ * twice over. It made the theme depend on a file it does not own, and it made
25
+ * its swatch in the picker preview *the theme currently applied*, because an
26
+ * empty map falls back to the live values.
27
+ */
28
+ const NIGHT = {
29
+ '--bg': '#0f1115',
30
+ '--panel': '#161a21',
31
+ '--panel-2': '#1c222b',
32
+ '--border': '#262d38',
33
+ '--text': '#e6e9ef',
34
+ '--muted': '#8b94a4',
35
+ '--accent': '#5b9dff',
36
+ '--accent-dim': '#2a4877',
37
+ /* No `--on-accent` here on purpose, so it inherits the base near-black.
38
+ Night used to set `#ffffff`, which is 2.72:1 on this accent -- the exact
39
+ pair `tokens.css` records as the reason the token exists at all. The base
40
+ was fixed and the theme carrying the old palette was never revisited, so
41
+ the primary button in the app's fixed dark theme failed the body-text
42
+ minimum by a wide margin for as long as the token had been "fixed".
43
+ `#06181a` on `#5b9dff` is 6.69:1. Found by the contrast scan, which is the
44
+ argument for shipping the measurement rather than the rule. */
45
+ '--app-bg': '#0f1115',
46
+ };
47
+ export const night = {
48
+ name: 'Night',
49
+ note: 'The dark palette, fixed — ignores the OS',
50
+ scheme: 'dark',
51
+ tokens: NIGHT,
52
+ };
53
+ export const paper = {
54
+ name: 'Paper',
55
+ note: 'Light, with shadows that suit it',
56
+ scheme: 'light',
57
+ tokens: {
58
+ '--bg': '#f6f7f9',
59
+ '--panel': '#ffffff',
60
+ '--panel-2': '#f0f2f5',
61
+ '--border': '#e4e8ec',
62
+ '--border-strong': '#8792a1',
63
+ '--text': '#191d23',
64
+ '--muted': '#5d6773',
65
+ '--accent': '#0e7872',
66
+ '--accent-dim': '#7fbdb8',
67
+ '--on-accent': '#ffffff',
68
+ // The base set is tuned for a dark panel. Unoverridden, `--good` was
69
+ // 1.74:1 on white -- a `running` pill nobody could read.
70
+ '--good': '#1c7a4a',
71
+ '--warn': '#8a6216',
72
+ '--bad': '#b3312c',
73
+ // `#2f7fe6` was 3.96:1 on this theme's white panel -- the "information is
74
+ // blue" colour was measured on the dark base when it was added and never
75
+ // on a light one. Found by contrast.test.ts on its first run. This is the
76
+ // lightest step on the same hue that clears 4.5:1 on the panel *and* the
77
+ // page, with room: 5.24 on white, 4.88 on the page.
78
+ '--info': '#216bc9',
79
+ // Black shadows are right on a dark UI and muddy on a light one. This is
80
+ // the whole reason elevation had to become a token.
81
+ '--shadow-1': '0 4px 14px rgba(16, 24, 40, 0.08)',
82
+ '--shadow-2': '0 8px 24px rgba(16, 24, 40, 0.10)',
83
+ '--shadow-3': '0 12px 32px rgba(16, 24, 40, 0.12)',
84
+ '--scrim': 'rgba(16, 24, 40, 0.32)',
85
+ },
86
+ };
87
+ /** Keyed by the name `applyTheme` takes, which is `name` lowercased. */
88
+ export const TF_THEMES = { system, night, paper };
89
+ /**
90
+ * tf, the product.
91
+ *
92
+ * Its identity is empty on purpose: `tokens.css` carries tf's font, shape and
93
+ * density as the base values, so there is nothing to lay over them. `system`
94
+ * is the default because following the OS is the choice a tool open all day
95
+ * should make for you -- and because it is a `prefers-color-scheme` rule
96
+ * rather than a palette, tf's stylesheet writes no default on `:root`; tf sets
97
+ * `data-theme` before the bundle loads, as it always has.
98
+ */
99
+ export const tf = {
100
+ name: 'tf',
101
+ identity: {},
102
+ themes: TF_THEMES,
103
+ defaultTheme: 'system',
104
+ };
@@ -0,0 +1,23 @@
1
+ import type { Theme, ThemeTokens } from '../themes';
2
+ import type { Product } from './index';
3
+ /**
4
+ * valet's identity: what it is under every theme.
5
+ *
6
+ * The fonts are named, not shipped. `--font` reads a `--font-sans` variable
7
+ * the app defines with whatever loads its fonts, and falls back to the family
8
+ * by name; the gallery loads both families from `gallery/public/fonts` so the
9
+ * specimens are photographed in them. Sharper corners than the package
10
+ * default, because a console is read in rows and columns and a large radius
11
+ * rounds the grid away.
12
+ */
13
+ export declare const VALET_IDENTITY: Partial<ThemeTokens>;
14
+ export declare const light: Theme;
15
+ export declare const night: Theme;
16
+ /** Keyed by the name `applyTheme` takes: `name` lowercased, spaces to hyphens. */
17
+ export declare const VALET_THEMES: {
18
+ readonly valet: Theme;
19
+ readonly 'valet-night': Theme;
20
+ };
21
+ /** valet, the product: its badge in `brandMarks.ts`, the identity above, the
22
+ * two palettes, and light until somebody picks. */
23
+ export declare const valet: Product;
@@ -0,0 +1,103 @@
1
+ /**
2
+ * valet's two palettes.
3
+ *
4
+ * One hue that belongs to nothing else. The accent is an indigo, chosen so it
5
+ * sits apart from all four status tones: good is green, warn is amber, bad is
6
+ * red, and info is a cyan rather than a blue so a link and an information pill
7
+ * cannot be confused at a glance. The accent is what a person acts on; the
8
+ * tones are what the system says. They never share a colour.
9
+ *
10
+ * Every colour is measured, not judged: `contrast.test.ts` holds both palettes
11
+ * to the same pairs as tf's.
12
+ *
13
+ * The palettes name colours and nothing else. What makes valet valet under
14
+ * either of them -- the type and the corners -- is the identity below, on
15
+ * `:root` in valet's stylesheet and under every palette `applyTheme` lays on.
16
+ * Both palettes used to restate it, which is the shape that breaks the moment
17
+ * a theme is shared between products: silent on the font, it would have fallen
18
+ * back to tf's.
19
+ */
20
+ const FONT = 'var(--font-sans, "IBM Plex Sans"), -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
21
+ const FONT_MONO = 'var(--font-mono, "JetBrains Mono"), ui-monospace, SFMono-Regular, Menlo, monospace';
22
+ /**
23
+ * valet's identity: what it is under every theme.
24
+ *
25
+ * The fonts are named, not shipped. `--font` reads a `--font-sans` variable
26
+ * the app defines with whatever loads its fonts, and falls back to the family
27
+ * by name; the gallery loads both families from `gallery/public/fonts` so the
28
+ * specimens are photographed in them. Sharper corners than the package
29
+ * default, because a console is read in rows and columns and a large radius
30
+ * rounds the grid away.
31
+ */
32
+ export const VALET_IDENTITY = {
33
+ '--font': FONT,
34
+ '--font-mono': FONT_MONO,
35
+ '--radius-sm': '2px',
36
+ '--radius': '4px',
37
+ '--radius-md': '6px',
38
+ '--radius-lg': '10px',
39
+ };
40
+ export const light = {
41
+ name: 'valet',
42
+ note: 'Light. Ink on paper, indigo where you act.',
43
+ scheme: 'light',
44
+ tokens: {
45
+ '--bg': '#f4f5f8',
46
+ '--panel': '#ffffff',
47
+ '--panel-2': '#eceef3',
48
+ '--border': '#dcdfe7',
49
+ '--border-strong': '#7b8597',
50
+ '--text': '#171a21',
51
+ '--muted': '#5b6474',
52
+ '--accent': '#4f46e5',
53
+ '--accent-dim': '#a9a4f0',
54
+ '--on-accent': '#ffffff',
55
+ '--good': '#1b7f4b',
56
+ '--warn': '#8a5f0a',
57
+ '--bad': '#bf3a31',
58
+ '--info': '#0e6f8e',
59
+ '--app-bg': '#f4f5f8',
60
+ // Black shadows muddy a light surface; these carry the page's own ink.
61
+ '--shadow-1': '0 4px 14px rgba(23, 26, 33, 0.08)',
62
+ '--shadow-2': '0 8px 24px rgba(23, 26, 33, 0.10)',
63
+ '--shadow-3': '0 12px 32px rgba(23, 26, 33, 0.12)',
64
+ '--scrim': 'rgba(23, 26, 33, 0.32)',
65
+ },
66
+ };
67
+ export const night = {
68
+ name: 'valet night',
69
+ note: 'Dark. The same ink, lit from behind.',
70
+ scheme: 'dark',
71
+ tokens: {
72
+ '--bg': '#0c0f14',
73
+ '--panel': '#141820',
74
+ '--panel-2': '#1b2029',
75
+ '--border': '#262c37',
76
+ '--border-strong': '#616b7d',
77
+ '--text': '#e8eaf0',
78
+ '--muted': '#9ba4b5',
79
+ '--accent': '#8f88ff',
80
+ '--accent-dim': '#3f3a8f',
81
+ // Near-black on the pale indigo, 6.46:1. White would be 2.6:1.
82
+ '--on-accent': '#0d0b2e',
83
+ '--good': '#5fcb8f',
84
+ '--warn': '#e2ae58',
85
+ '--bad': '#f28b84',
86
+ '--info': '#57c4e8',
87
+ '--app-bg': '#0c0f14',
88
+ '--shadow-1': '0 6px 20px rgba(0, 0, 0, 0.28)',
89
+ '--shadow-2': '0 8px 28px rgba(0, 0, 0, 0.34)',
90
+ '--shadow-3': '0 10px 34px rgba(0, 0, 0, 0.38)',
91
+ '--scrim': 'rgba(0, 0, 0, 0.5)',
92
+ },
93
+ };
94
+ /** Keyed by the name `applyTheme` takes: `name` lowercased, spaces to hyphens. */
95
+ export const VALET_THEMES = { valet: light, 'valet-night': night };
96
+ /** valet, the product: its badge in `brandMarks.ts`, the identity above, the
97
+ * two palettes, and light until somebody picks. */
98
+ export const valet = {
99
+ name: 'valet',
100
+ identity: VALET_IDENTITY,
101
+ themes: VALET_THEMES,
102
+ defaultTheme: 'valet',
103
+ };
@@ -329,10 +329,10 @@ button[aria-busy='true']::after {
329
329
  width: 40%;
330
330
  background: currentColor;
331
331
  opacity: 0.55;
332
- animation: tf-busy 1.1s var(--ease) infinite;
332
+ animation: ds-busy 1.1s var(--ease) infinite;
333
333
  }
334
334
 
335
- @keyframes tf-busy {
335
+ @keyframes ds-busy {
336
336
  0% { transform: translateX(-100%); }
337
337
  100% { transform: translateX(350%); }
338
338
  }
@@ -423,13 +423,12 @@ button.ghost:hover:not(:disabled) {
423
423
  height: calc(var(--text-md) * 1.5);
424
424
  width: auto;
425
425
  color: var(--good);
426
- /* The cog turns about its own centre, which is the ink box's. */
426
+ /* A product that animates its mark turns it about its own centre, which is
427
+ the ink box's; tf's cog does. */
427
428
  transform-origin: center;
428
429
  }
429
430
 
430
- @keyframes brand-turn { to { transform: rotate(360deg); } }
431
-
432
- @keyframes tf-callout-clock {
431
+ @keyframes ds-callout-clock {
433
432
  from { transform: scaleX(1); }
434
433
  to { transform: scaleX(0); }
435
434
  }
@@ -515,16 +514,16 @@ button.ghost:hover:not(:disabled) {
515
514
 
516
515
  .icon-btn:hover { opacity: 1; color: var(--text); }
517
516
 
518
- @keyframes tf-bar {
517
+ @keyframes ds-bar {
519
518
  0% { transform: translateX(-110%); }
520
519
  100% { transform: translateX(320%); }
521
520
  }
522
521
 
523
- @keyframes tf-toast-in {
522
+ @keyframes ds-toast-in {
524
523
  from { opacity: 0; transform: translateY(8px); }
525
524
  }
526
525
 
527
- @keyframes tf-toast-out {
526
+ @keyframes ds-toast-out {
528
527
  to { opacity: 0; transform: translateY(4px); }
529
528
  }
530
529
 
@@ -545,7 +544,7 @@ button.ghost:hover:not(:disabled) {
545
544
  .icon-btn:hover:not(:disabled) { color: var(--text); }
546
545
 
547
546
  /* skeleton pulse ------------------------------------------------------ */
548
- @keyframes tf-pulse {
547
+ @keyframes ds-pulse {
549
548
  0%, 100% { opacity: 0.25; }
550
549
  50% { opacity: 0.65; }
551
550
  }
@@ -684,7 +683,7 @@ input:active, textarea:active, select:active { transform: none; }
684
683
  line-height: 1.5;
685
684
  }
686
685
 
687
- @keyframes tf-throb {
686
+ @keyframes ds-throb {
688
687
  0%, 100% { opacity: 0.55; }
689
688
  50% { opacity: 1; }
690
689
  }
@@ -720,7 +719,7 @@ input:active, textarea:active, select:active { transform: none; }
720
719
  /* A round is in flight. On the button that asks for one, because that is where
721
720
  you are looking when you have just pressed it -- and not in the foot, which
722
721
  would flicker every thirty seconds on a tile that is working perfectly. */
723
- @keyframes tf-spin { to { transform: rotate(360deg); } }
722
+ @keyframes ds-spin { to { transform: rotate(360deg); } }
724
723
 
725
724
  @keyframes dock-working {
726
725
  0%, 100% { opacity: .15; transform: scale(1); }
@@ -737,10 +736,10 @@ input:active, textarea:active, select:active { transform: none; }
737
736
  The animation is not made more robust; the shape is made indifferent to it. */
738
737
  .spin {
739
738
  display: inline-block;
740
- animation: tf-spin 900ms linear infinite;
739
+ animation: ds-spin 900ms linear infinite;
741
740
  }
742
741
 
743
- @keyframes tf-spin { to { transform: rotate(360deg); } }
742
+ @keyframes ds-spin { to { transform: rotate(360deg); } }
744
743
 
745
744
  @media (prefers-reduced-motion: reduce) {
746
745
  /* Still says "working" -- it just says it without moving. */
@@ -781,7 +780,7 @@ input:active, textarea:active, select:active { transform: none; }
781
780
  border: 0;
782
781
  }
783
782
 
784
- @keyframes tf-skel {
783
+ @keyframes ds-skel {
785
784
  from { opacity: 0.55; }
786
785
  to { opacity: 1; }
787
786
  }
@@ -1139,7 +1138,7 @@ button.danger:active:not(:disabled) {
1139
1138
  transform-origin: left;
1140
1139
  background: currentColor;
1141
1140
  opacity: 0.5;
1142
- animation: tf-callout-clock linear forwards;
1141
+ animation: ds-callout-clock linear forwards;
1143
1142
  }
1144
1143
 
1145
1144
  /* Held while the pointer is on it. A message that expires while you are reading
@@ -1561,10 +1560,10 @@ input[type='radio']:active:not(:disabled) {
1561
1560
  width: 220%; height: 220%;
1562
1561
  translate: -50% -50%;
1563
1562
  background: conic-gradient(from 0deg, transparent 0deg, transparent 60deg, color-mix(in oklab, var(--accent) 70%, transparent) 180deg, transparent 300deg, transparent 360deg);
1564
- animation: tf-spin 1s linear infinite;
1563
+ animation: ds-spin 1s linear infinite;
1565
1564
  }
1566
1565
 
1567
- @keyframes tf-spin { to { rotate: 1turn; } }
1566
+ @keyframes ds-spin { to { rotate: 1turn; } }
1568
1567
 
1569
1568
  @media (prefers-reduced-motion: reduce) {
1570
1569
  .toggle-sweep::before { animation: none; background: color-mix(in oklab, var(--accent) 45%, transparent); }
@@ -1578,7 +1577,7 @@ input[type='radio']:active:not(:disabled) {
1578
1577
  height: 8px;
1579
1578
  border-radius: var(--radius-sm);
1580
1579
  background: var(--muted);
1581
- animation: tf-pulse 1.4s ease-in-out infinite;
1580
+ animation: ds-pulse 1.4s ease-in-out infinite;
1582
1581
  vertical-align: middle;
1583
1582
  }
1584
1583
 
@@ -1619,7 +1618,7 @@ input[type='radio']:active:not(:disabled) {
1619
1618
 
1620
1619
  /* Breathing rather than sliding. A sweep travelling across a skeleton implies
1621
1620
  progress from left to right, and nothing here knows how far along it is. */
1622
- .skel-pulse { animation: tf-skel calc(var(--dur-slow) * 3) var(--ease) infinite alternate; }
1621
+ .skel-pulse { animation: ds-skel calc(var(--dur-slow) * 3) var(--ease) infinite alternate; }
1623
1622
 
1624
1623
  @media (prefers-reduced-motion: reduce) {
1625
1624
  /* Still distinguishable from a real block of colour, and still still. */
@@ -1761,7 +1760,7 @@ input[type='radio']:active:not(:disabled) {
1761
1760
  something happening now. */
1762
1761
  .rows-pick > .rows-row.is-loading {
1763
1762
  border-color: var(--accent);
1764
- animation: tf-pulse 1.4s ease-in-out infinite;
1763
+ animation: ds-pulse 1.4s ease-in-out infinite;
1765
1764
  }
1766
1765
 
1767
1766
  /* And nothing else can be chosen while it happens: picking a second model
@@ -1973,7 +1972,7 @@ input[type='radio']:active:not(:disabled) {
1973
1972
  happening" without claiming to know how much of it is left. */
1974
1973
  .bar-indeterminate > i {
1975
1974
  width: 35%;
1976
- animation: tf-bar 1.2s var(--ease) infinite;
1975
+ animation: ds-bar 1.2s var(--ease) infinite;
1977
1976
  }
1978
1977
 
1979
1978
  @media (prefers-reduced-motion: reduce) {
@@ -2103,7 +2102,7 @@ input[type='radio']:active:not(:disabled) {
2103
2102
  background: var(--panel);
2104
2103
  box-shadow: var(--shadow-2);
2105
2104
  font-size: var(--text-sm);
2106
- animation: tf-toast-in var(--dur-md) var(--ease);
2105
+ animation: ds-toast-in var(--dur-md) var(--ease);
2107
2106
  }
2108
2107
 
2109
2108
  /* The tone is the edge and the mark, in the tone's own colour. It used to be the
@@ -2133,7 +2132,7 @@ input[type='radio']:active:not(:disabled) {
2133
2132
  /* `data-exiting` is React Aria's: it keeps a toast in the DOM for the length
2134
2133
  of this animation after it is closed, then removes it, which is what the
2135
2134
  `.leaving` class and a second timer used to do by hand. */
2136
- .toast[data-exiting] { animation: tf-toast-out var(--dur-fast) var(--ease) forwards; }
2135
+ .toast[data-exiting] { animation: ds-toast-out var(--dur-fast) var(--ease) forwards; }
2137
2136
 
2138
2137
  @media (prefers-reduced-motion: reduce) {
2139
2138
  .toast, /* `data-exiting` is React Aria's: it keeps a toast in the DOM for the length
@@ -2779,39 +2778,6 @@ input[type='range'].slider:disabled::-moz-range-thumb { opacity: 0; }
2779
2778
 
2780
2779
  .table thead th[style*='right'] { text-align: right; }
2781
2780
 
2782
- /* ---- ./brand.css ---- */
2783
- /* Once the morph has finished (`Brand.tsx` adds the class at t = 1), the cog
2784
- turns -- a revolution in 5 s, a tooth every 0.8 s: the pace of a
2785
- mechanism, not a spinner. It was 2.4 s, which was too quick to William's
2786
- eye once the teeth were six and fat. Off under reduced motion, where the
2787
- mark never becomes a cog in the first place.
2788
-
2789
- Not applied since 2026-09-03 -- William asked to try it without, and
2790
- `Brand.tsx` says so where the class would go. The rule stays so that putting
2791
- the class back is the whole change; it stays HERE, rather than in the app
2792
- that first held it, because the component that would add the class is this
2793
- package's. The keyframes are in `base.css` beside `.brand`. */
2794
- .brand-turning { animation: brand-turn 5s linear infinite; }
2795
-
2796
- @media (prefers-reduced-motion: reduce) {
2797
- .brand-turning { animation: none; }
2798
- }
2799
-
2800
- .tf-dots { display: inline-flex; gap: var(--space-1); }
2801
-
2802
- .tf-dots i {
2803
- width: 5px; height: 5px; border-radius: 50%; background: currentColor;
2804
- animation: tf-bounce 1.1s ease-in-out infinite;
2805
- }
2806
-
2807
- .tf-dots i:nth-child(2) { animation-delay: 0.15s; }
2808
-
2809
- .tf-dots i:nth-child(3) { animation-delay: 0.3s; }
2810
-
2811
- @media (prefers-reduced-motion: reduce) {
2812
- .tf-dots i { animation: none; opacity: 0.6; }
2813
- }
2814
-
2815
2781
  /* ---- ./sizegrid.css ---- */
2816
2782
  /* Tile size, picked as a rectangle rather than read as a notation. */
2817
2783
  .size-grid-wrap { display: flex; align-items: center; gap: var(--space-2); }