morethanui 1.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/dist/mtui.css ADDED
@@ -0,0 +1,2394 @@
1
+ /* ============================================================================
2
+ MTUI (MoreThanUI) — tokens.css
3
+ Transcribed from DESIGN.md v1.2 §2. Section banners match spec sections
4
+ so this file can be reviewed table-by-table against the spec.
5
+ ----------------------------------------------------------------------------
6
+ Knobs (root-only, set on <html>, enum values only — DESIGN.md §3 Knob scope):
7
+ data-theme = light | dark (default light)
8
+ data-accent = clay | cobalt | fern | plum (default clay)
9
+ data-radius = sharp | soft | round (default round)
10
+ data-density = comfortable | compact (default comfortable)
11
+ data-tint = accent | warm (default accent — neutrals take
12
+ the accent hue; "warm" keeps the
13
+ fixed warm neutral; see §2.14)
14
+ Components reference ONLY the tokens in this file. No other color, size,
15
+ radius, or timing values exist in MTUI.
16
+ ============================================================================ */
17
+
18
+
19
+ /* ========================== §2.1 Neutrals — light ========================== */
20
+ :root {
21
+ color-scheme: light;
22
+ --bg: #F3F0EA; /* app background (warm cream) */
23
+ --surface: #FFFFFF; /* cards, sheets, menus */
24
+ --surface-2: #EAE6DD; /* wells, fields, secondary buttons */
25
+ --surface-3: #DFD9CD; /* hover of wells, switch tracks */
26
+ --text: #1C1A17;
27
+ --text-muted: #67625A;
28
+ --text-faint: #A8A297; /* decorative only — never information */
29
+ --inverse: #1C1A17; /* toasts, tooltips */
30
+ --on-inverse: #F3F0EA;
31
+ --scrim: rgba(28, 26, 23, .44);
32
+ /* --border intentionally does not exist. MTUI separates by tone, not lines.
33
+ The only sanctioned borders are the forced-colors block at the end of
34
+ this file and the layout `outline` diagnostic. */
35
+ }
36
+
37
+ /* ========================== §2.2 Neutrals — dark =========================== */
38
+ [data-theme="dark"] {
39
+ color-scheme: dark;
40
+ --bg: #161513;
41
+ --surface: #211F1C;
42
+ --surface-2: #2B2925;
43
+ --surface-3: #38352F;
44
+ --text: #F0EDE7;
45
+ --text-muted: #A39D92;
46
+ --text-faint: #6B665D;
47
+ --inverse: #F0EDE7;
48
+ --on-inverse: #1C1A17;
49
+ --scrim: rgba(0, 0, 0, .55);
50
+ }
51
+
52
+ /* ==================== §2.3 Accent options (knob: accent) ===================
53
+ Solids identical in both themes; tint + tint-text swap per theme.
54
+ Dark tint/text values equal the matching identity tint (§2.5):
55
+ clay→rose, cobalt→sky, fern→mint, plum→lavender.
56
+ --on-accent is always white. --focus is always the current accent. */
57
+ :root {
58
+ --on-accent: #FFFFFF;
59
+ --focus: var(--accent);
60
+
61
+ /* Secondary brand color (§2.3b). Mirrors --accent by default, so the four
62
+ presets stay monochrome; the custom-theme generator overrides these to a
63
+ second hue. Used by data-variant="accent" and available to apps. */
64
+ --accent-2: var(--accent);
65
+ --accent-2-hover: var(--accent-hover);
66
+ --accent-2-text: var(--accent-text);
67
+ --accent-2-tint: var(--accent-tint);
68
+ --on-accent-2: var(--on-accent);
69
+ }
70
+
71
+ /* clay (default) */
72
+ :root, [data-accent="clay"] {
73
+ --accent: #BC4B2A;
74
+ --accent-hover: #A93F20;
75
+ --accent-text: #8F3016;
76
+ --accent-tint: #F3DCD6;
77
+ }
78
+ [data-theme="dark"],
79
+ [data-theme="dark"][data-accent="clay"] {
80
+ --accent-text: #F0A896;
81
+ --accent-tint: #3A2723;
82
+ }
83
+
84
+ /* cobalt */
85
+ [data-accent="cobalt"] {
86
+ --accent: #3556C9;
87
+ --accent-hover: #2B49B2;
88
+ --accent-text: #2B44A6;
89
+ --accent-tint: #DCE3F7;
90
+ }
91
+ [data-theme="dark"][data-accent="cobalt"] {
92
+ --accent-text: #A9C3EC;
93
+ --accent-tint: #232B45;
94
+ }
95
+
96
+ /* fern */
97
+ [data-accent="fern"] {
98
+ --accent: #2F7A50;
99
+ --accent-hover: #276A44;
100
+ --accent-text: #245D3D;
101
+ --accent-tint: #DCEEE2;
102
+ }
103
+ [data-theme="dark"][data-accent="fern"] {
104
+ --accent-text: #8ED1AB;
105
+ --accent-tint: #22352A;
106
+ }
107
+
108
+ /* plum */
109
+ [data-accent="plum"] {
110
+ --accent: #6E46BD;
111
+ --accent-hover: #5F3AA8;
112
+ --accent-text: #56349B;
113
+ --accent-tint: #E6E0F2;
114
+ }
115
+ [data-theme="dark"][data-accent="plum"] {
116
+ --accent-text: #C9BCF0;
117
+ --accent-tint: #2E2A3C;
118
+ }
119
+
120
+ /* ============ §2.14 Neutral tinting (knob: tint, default "accent") =========
121
+ The §2.1/§2.2 neutrals above are the warm baseline AND the data-tint="warm"
122
+ opt-out AND the fallback for engines without relative-color syntax. By
123
+ default the neutral HUE follows the accent (blue accent -> blue-grey UI,
124
+ like Telegram/Material). Only the hue moves: each token keeps the exact
125
+ lightness + chroma measured from the warm ramp, so the tonal ladder and
126
+ every §2.6 contrast pair hold at any accent. --scrim stays translucent. */
127
+ @supports (color: oklch(from white l c h)) {
128
+ :root:not([data-theme="dark"]):not([data-tint="warm"]) {
129
+ --bg: oklch(from var(--accent) 95.6% 0.010 h);
130
+ --surface: oklch(from var(--accent) 99% 0.006 h); /* pure white -> whisper of hue */
131
+ --surface-2: oklch(from var(--accent) 92.6% 0.014 h);
132
+ --surface-3: oklch(from var(--accent) 88.7% 0.018 h);
133
+ --text: oklch(from var(--accent) 21.9% 0.008 h);
134
+ --text-muted: oklch(from var(--accent) 49.8% 0.014 h);
135
+ --text-faint: oklch(from var(--accent) 71.4% 0.017 h);
136
+ --inverse: oklch(from var(--accent) 21.9% 0.008 h);
137
+ --on-inverse: oklch(from var(--accent) 95.6% 0.010 h);
138
+ }
139
+ [data-theme="dark"]:not([data-tint="warm"]) {
140
+ --bg: oklch(from var(--accent) 19.6% 0.010 h);
141
+ --surface: oklch(from var(--accent) 24% 0.014 h);
142
+ --surface-2: oklch(from var(--accent) 28.2% 0.018 h);
143
+ --surface-3: oklch(from var(--accent) 33% 0.022 h);
144
+ --text: oklch(from var(--accent) 94.7% 0.008 h);
145
+ --text-muted: oklch(from var(--accent) 69.8% 0.016 h);
146
+ --text-faint: oklch(from var(--accent) 51.2% 0.015 h);
147
+ --inverse: oklch(from var(--accent) 94.7% 0.008 h);
148
+ --on-inverse: oklch(from var(--accent) 21.9% 0.008 h);
149
+ }
150
+ }
151
+
152
+ /* ========================= §2.4 Status colors ============================== */
153
+ :root {
154
+ --danger: #B3402F; /* solid, white text — same value in dark:
155
+ the lighter #C9553F fails AA with white */
156
+ --danger-text: #8F3016;
157
+ --danger-tint: #F3DCD6;
158
+ --success-text: #245D3D;
159
+ --success-tint: #DCEEE2;
160
+ --warning-text: #7A5600;
161
+ --warning-tint: #F2E8D0;
162
+ }
163
+ [data-theme="dark"] {
164
+ --danger-text: #F0A896;
165
+ --danger-tint: #3A2723;
166
+ --success-text: #8ED1AB;
167
+ --success-tint: #22352A;
168
+ --warning-text: #E4C27E;
169
+ --warning-tint: #383021;
170
+ }
171
+
172
+ /* =========== §2.5 Identity tints (avatars, chips, alerts — never layout) === */
173
+ :root {
174
+ --tint-rose-bg: #F3DCD6; --tint-rose-text: #8F3016;
175
+ --tint-sky-bg: #DCE3F7; --tint-sky-text: #2B44A6;
176
+ --tint-mint-bg: #DCEEE2; --tint-mint-text: #245D3D;
177
+ --tint-butter-bg: #F2E8D0; --tint-butter-text: #7A5600;
178
+ --tint-lavender-bg: #E6E0F2; --tint-lavender-text: #56349B;
179
+ }
180
+ [data-theme="dark"] {
181
+ --tint-rose-bg: #3A2723; --tint-rose-text: #F0A896;
182
+ --tint-sky-bg: #232B45; --tint-sky-text: #A9C3EC;
183
+ --tint-mint-bg: #22352A; --tint-mint-text: #8ED1AB;
184
+ --tint-butter-bg: #383021; --tint-butter-text: #E4C27E;
185
+ --tint-lavender-bg: #2E2A3C; --tint-lavender-text: #C9BCF0;
186
+ }
187
+
188
+ /* ============================== §2.7 Type ==================================
189
+ One family. Weight does hierarchy — never letter-spacing, never uppercase.
190
+ Font files: self-hosted Noto Sans (+ Noto Sans Arabic for ar, Noto Sans
191
+ Mono for code only). @font-face lives in base.css; system-ui is fallback. */
192
+ :root {
193
+ --font: "Noto Sans", system-ui, sans-serif;
194
+ --font-mono: "Noto Sans Mono", ui-monospace, monospace;
195
+
196
+ /* style: size / line-height / weight / tracking */
197
+ --fs-page: 38px; --lh-page: 1.12; --fw-page: 800; --ls-page: -0.02em;
198
+ --fs-section: 26px; --lh-section: 1.2; --fw-section: 700; --ls-section: -0.01em;
199
+ --fs-card: 19px; --lh-card: 1.3; --fw-card: 700;
200
+ --fs-control: 15px; --lh-control: 1.4; --fw-control: 600; /* buttons, row titles (spec: 15–16) */
201
+ --fs-body: 16px; --lh-body: 1.6; --fw-body: 400; /* inputs use this: 16px floor stops iOS zoom */
202
+ --fs-secondary: 14px; --lh-secondary: 1.5; /* color: --text-muted */
203
+ --fs-label: 13px; --fw-label: 600; /* color: --text-muted */
204
+ --fs-code: 13px; --lh-code: 1.7; /* mono, in --surface-2 block */
205
+ }
206
+
207
+ /* ============================ §2.8 Spacing =================================
208
+ 4px base scale. Value-named so the token says what it is. Gaps and padding
209
+ come exclusively from this scale — there is no token for anything else. */
210
+ :root {
211
+ --sp-4: 4px; --sp-8: 8px; --sp-12: 12px;
212
+ --sp-16: 16px; --sp-20: 20px; --sp-24: 24px;
213
+ --sp-32: 32px; --sp-40: 40px; --sp-48: 48px;
214
+ }
215
+
216
+ /* =================== §2.9 Radius presets (knob: radius) ==================== */
217
+ :root, [data-radius="round"] { /* default */
218
+ --r-ctl: 999px; /* buttons, switches, segmented */
219
+ --r-card: 24px;
220
+ --r-input: 16px;
221
+ --r-chip: 999px;
222
+ }
223
+ [data-radius="soft"] {
224
+ --r-ctl: 14px;
225
+ --r-card: 20px;
226
+ --r-input: 12px;
227
+ --r-chip: 10px;
228
+ }
229
+ [data-radius="sharp"] {
230
+ --r-ctl: 8px;
231
+ --r-card: 12px;
232
+ --r-input: 8px;
233
+ --r-chip: 6px;
234
+ }
235
+
236
+ /* ==================== §2.10 Density (knob: density) ========================
237
+ 44px is the floor — compact never goes below it for anything tappable. */
238
+ :root, [data-density="comfortable"] {
239
+ --ctl-h: 48px; /* buttons */
240
+ --field-h: 48px; /* inputs, selects */
241
+ --row-h: 64px; /* list rows */
242
+ --pad: 20px; /* card padding */
243
+ --gap: 16px; /* default stack/row gap */
244
+ }
245
+ [data-density="compact"] {
246
+ --ctl-h: 44px;
247
+ --field-h: 44px;
248
+ --row-h: 52px;
249
+ --pad: 14px;
250
+ --gap: 10px;
251
+ }
252
+ :root { --tap-min: 44px; } /* absolute floor, never overridden */
253
+
254
+ /* ======================= §2.11 Elevation (tonal) ===========================
255
+ Levels ARE the neutral ladder: 0 = --bg, 1 = --surface, 2 = --surface-2,
256
+ overlay = --scrim + level 1. No box-shadow for depth, ever.
257
+ The three sanctioned non-depth rings: */
258
+ :root {
259
+ --ring-focus: 0 0 0 2px var(--surface), 0 0 0 4px var(--accent);
260
+ --ring-avatar: 0 0 0 3px var(--surface); /* stacked avatars */
261
+ --ring-popover: 0 0 0 6px var(--bg); /* popover separation, not shadow */
262
+ }
263
+
264
+ /* ============================ §2.12 Motion =================================
265
+ Animate transform and opacity only (60fps rule). Color/background
266
+ transitions allowed for state tints (no reflow). */
267
+ :root {
268
+ --ease: cubic-bezier(.22, .9, .28, 1);
269
+ --spring: cubic-bezier(.34, 1.2, .4, 1);
270
+
271
+ --t-micro: 220ms; /* hover/press color: 200–250ms --ease */
272
+ --t-lift: 280ms; /* card lift, --ease */
273
+ --t-knob: 300ms; /* switch/checkbox/radio knobs, --spring */
274
+ --t-popover: 300ms; /* transform --spring; opacity 220ms --ease */
275
+ --t-tab: 350ms; /* tab pill slide, --spring */
276
+ --t-delete: 350ms; /* opacity + scale(.96) — fade reserved for this */
277
+ --t-dialog: 400ms; /* --spring; scrim color 300ms */
278
+ --t-toast: 500ms; /* --spring */
279
+ --t-skeleton: 1.5s; /* sheen loop: blurred bar translating across */
280
+ }
281
+
282
+ @media (prefers-reduced-motion: reduce) {
283
+ :root {
284
+ --t-micro: 0ms; --t-lift: 0ms; --t-knob: 0ms; --t-popover: 0ms;
285
+ --t-tab: 0ms; --t-delete: 0ms; --t-dialog: 0ms; --t-toast: 0ms;
286
+ }
287
+ /* --t-skeleton intentionally kept: a static skeleton reads as broken.
288
+ base.css swaps the sheen for a gentle non-translating variant instead. */
289
+ }
290
+
291
+ /* =================== §2.14 Theme generator guardrails ======================
292
+ Brands may generate a custom accent. Hue is free; lightness and chroma are
293
+ clamped so contrast and calm survive. The generator (a build-time script
294
+ or the docs page, never runtime app code) must emit exactly:
295
+
296
+ --accent: oklch(52% C H) C clamped to 0.06–0.17
297
+ --accent-hover: oklch(47% C H)
298
+ --accent-tint: light oklch(92% min(0.32*C, 0.05) H)
299
+ dark oklch(30% 0.35*C H)
300
+ --accent-text: light oklch(41% 0.85*C H)
301
+ dark oklch(84% 0.45*C H)
302
+
303
+ 52% lightness at any hue keeps >=4.5:1 with white text.
304
+ Neutrals, type, spacing, and motion are NOT brand-tunable. */
305
+
306
+ /* ================== §3 High contrast (forced-colors) =======================
307
+ The one sanctioned exception to zero borders. Tonal separation disappears
308
+ under forced colors, so components draw real borders with system colors.
309
+ This base block covers global concerns; every component adds its own
310
+ forced-colors rules (see CLAUDE.md checklist). */
311
+ @media (forced-colors: active) {
312
+ :root {
313
+ --scrim: Canvas;
314
+ --ring-focus: none; /* focus becomes a real outline per component */
315
+ --ring-avatar: none;
316
+ --ring-popover: none;
317
+ }
318
+ }
319
+ /* ============================================================================
320
+ MTUI (MoreThanUI) — base.css
321
+ Tier: Basic (zero JS). Reset, self-hosted fonts, typography classes
322
+ (DESIGN.md §2.7), and the accessibility floor every component inherits.
323
+ Requires tokens.css loaded first.
324
+ ============================================================================ */
325
+
326
+
327
+ /* ============================== @font-face =================================
328
+ Self-hosted variable fonts (fonts/, OFL licensed). Noto Sans Arabic is
329
+ registered under the SAME family name "Noto Sans" with an Arabic
330
+ unicode-range, so the frozen --font token covers Arabic with no extra
331
+ rules: Latin glyphs resolve from the Latin files, Arabic falls through. */
332
+ @font-face {
333
+ font-family: "Noto Sans";
334
+ font-style: normal;
335
+ font-weight: 400 800;
336
+ font-display: swap;
337
+ src: url("../fonts/noto-sans-latin.woff2") format("woff2");
338
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6,
339
+ U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122,
340
+ U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
341
+ }
342
+ @font-face {
343
+ font-family: "Noto Sans";
344
+ font-style: normal;
345
+ font-weight: 400 800;
346
+ font-display: swap;
347
+ src: url("../fonts/noto-sans-latin-ext.woff2") format("woff2");
348
+ unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7,
349
+ U+02DD-02FF, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB,
350
+ U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
351
+ }
352
+ @font-face {
353
+ font-family: "Noto Sans";
354
+ font-style: normal;
355
+ font-weight: 400 800;
356
+ font-display: swap;
357
+ src: url("../fonts/noto-sans-arabic.woff2") format("woff2");
358
+ unicode-range: U+0600-06FF, U+0750-077F, U+0870-088E, U+0890-0891,
359
+ U+0897-08E1, U+08E3-08FF, U+200C-200E, U+2010-2011, U+204F, U+2E41,
360
+ U+FB50-FDFF, U+FE70-FE74, U+FE76-FEFC;
361
+ }
362
+ @font-face {
363
+ font-family: "Noto Sans Mono";
364
+ font-style: normal;
365
+ font-weight: 400;
366
+ font-display: swap;
367
+ src: url("../fonts/noto-sans-mono-latin.woff2") format("woff2");
368
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6,
369
+ U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122,
370
+ U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
371
+ }
372
+
373
+
374
+ /* ================================ Reset ==================================== */
375
+ *,
376
+ *::before,
377
+ *::after {
378
+ box-sizing: border-box;
379
+ }
380
+
381
+ * {
382
+ margin: 0;
383
+ }
384
+
385
+ html {
386
+ -webkit-text-size-adjust: 100%;
387
+ }
388
+
389
+ body {
390
+ min-height: 100dvh; /* shell presets (layout.css) own scroll; frame never does */
391
+ background: var(--bg);
392
+ color: var(--text);
393
+ font-family: var(--font);
394
+ font-size: var(--fs-body);
395
+ font-weight: var(--fw-body);
396
+ line-height: var(--lh-body);
397
+ }
398
+
399
+ img,
400
+ svg,
401
+ video,
402
+ canvas {
403
+ display: block;
404
+ max-inline-size: 100%;
405
+ }
406
+
407
+ button,
408
+ input,
409
+ select,
410
+ textarea {
411
+ font: inherit;
412
+ color: inherit;
413
+ }
414
+
415
+ /* Plain lists in components (menus, rows) carry their own semantics/styles. */
416
+ ul[role="list"],
417
+ ol[role="list"] {
418
+ list-style: none;
419
+ padding: 0;
420
+ }
421
+
422
+
423
+ /* ===================== Typography classes (§2.7) ===========================
424
+ One class per spec row. Hierarchy is weight and size — never uppercase,
425
+ never letter-spacing beyond the two negative headline trackings.
426
+ These classes ARE the type API; headings carry whichever class fits. */
427
+ .t-page {
428
+ font-size: var(--fs-page);
429
+ line-height: var(--lh-page);
430
+ font-weight: var(--fw-page);
431
+ letter-spacing: var(--ls-page);
432
+ }
433
+
434
+ .t-section {
435
+ font-size: var(--fs-section);
436
+ line-height: var(--lh-section);
437
+ font-weight: var(--fw-section);
438
+ letter-spacing: var(--ls-section);
439
+ }
440
+
441
+ .t-card {
442
+ font-size: var(--fs-card);
443
+ line-height: var(--lh-card);
444
+ font-weight: var(--fw-card);
445
+ }
446
+
447
+ .t-row {
448
+ font-size: var(--fs-control);
449
+ line-height: var(--lh-control);
450
+ font-weight: var(--fw-control);
451
+ }
452
+
453
+ .t-body {
454
+ font-size: var(--fs-body);
455
+ line-height: var(--lh-body);
456
+ font-weight: var(--fw-body);
457
+ }
458
+
459
+ .t-secondary {
460
+ font-size: var(--fs-secondary);
461
+ line-height: var(--lh-secondary);
462
+ font-weight: var(--fw-body);
463
+ color: var(--text-muted);
464
+ }
465
+
466
+ .t-label {
467
+ font-size: var(--fs-label);
468
+ font-weight: var(--fw-label);
469
+ color: var(--text-muted);
470
+ }
471
+
472
+ /* Type style only — the well-styled block is the phase-4 `.code` component.
473
+ Code, numerals, emails stay LTR inside RTL text (DESIGN.md §3 RTL). */
474
+ .t-code {
475
+ font-family: var(--font-mono);
476
+ font-size: var(--fs-code);
477
+ line-height: var(--lh-code);
478
+ direction: ltr;
479
+ unicode-bidi: isolate;
480
+ text-align: start;
481
+ }
482
+
483
+ /* Utility for non-code LTR islands in RTL rows: numbers, emails, URLs. */
484
+ .t-ltr {
485
+ direction: ltr;
486
+ unicode-bidi: isolate;
487
+ }
488
+
489
+
490
+ /* ========================= Accessibility floor ============================= */
491
+
492
+ /* Focus: the token ring, on everything focusable, keyboard-only. */
493
+ :focus-visible {
494
+ outline: none;
495
+ box-shadow: var(--ring-focus);
496
+ }
497
+
498
+ /* Reduced motion: durations already collapse via the --t-* tokens
499
+ (tokens.css). --t-skeleton intentionally survives; the skeleton component
500
+ (components.css) swaps its translating sheen for a non-moving variant
501
+ under this same query. No component declares its own durations, so no
502
+ blanket animation kill-switch is needed here. */
503
+
504
+ /* Forced colors: tonal separation disappears, so the floor draws real
505
+ structure with system colors. Components add their own border blocks
506
+ (CLAUDE.md checklist); this covers the globals. */
507
+ @media (forced-colors: active) {
508
+ :focus-visible {
509
+ outline: 2px solid CanvasText;
510
+ outline-offset: 2px;
511
+ box-shadow: none;
512
+ }
513
+
514
+ .t-secondary,
515
+ .t-label {
516
+ color: CanvasText;
517
+ }
518
+ }
519
+ /* ============================================================================
520
+ MTUI (MoreThanUI) — layout.css
521
+ Tier: Basic (zero JS). The five screen presets + the three constrained
522
+ primitives + the outline diagnostic (DESIGN.md §2.15).
523
+ Requires tokens.css. App code never writes layout CSS or media queries —
524
+ the media queries in THIS file are the only sanctioned ones.
525
+ Breakpoints: 768px (tab bar → rail, 4 → 8 columns), 900px (split panes),
526
+ 1200px (8 → 12 columns).
527
+ ============================================================================ */
528
+
529
+
530
+ /* ================================ shell ====================================
531
+ App frame: header + content + navigation. One per app.
532
+ <body class="shell">
533
+ <header class="shell-header">…</header>
534
+ <nav class="shell-nav">…</nav>
535
+ <main class="shell-content">…</main>
536
+ </body>
537
+ Nav is a bottom tab bar below 768px, an inline-start rail at ≥768px.
538
+ The frame never scrolls; .shell-content is the app's only scroller. */
539
+ .shell {
540
+ display: grid;
541
+ block-size: 100dvh;
542
+ overflow: hidden;
543
+ grid-template-areas:
544
+ "header"
545
+ "content"
546
+ "nav";
547
+ grid-template-rows: auto 1fr auto;
548
+ }
549
+
550
+ .shell-header {
551
+ grid-area: header;
552
+ display: flex;
553
+ align-items: center;
554
+ gap: var(--gap);
555
+ min-block-size: var(--ctl-h);
556
+ padding-block: var(--sp-8);
557
+ padding-inline: var(--pad);
558
+ padding-block-start: calc(var(--sp-8) + env(safe-area-inset-top, 0px));
559
+ background: var(--bg);
560
+ }
561
+
562
+ .shell-content {
563
+ grid-area: content;
564
+ overflow-y: auto;
565
+ overscroll-behavior: contain;
566
+ -webkit-overflow-scrolling: touch;
567
+ }
568
+
569
+ .shell-nav {
570
+ grid-area: nav;
571
+ display: flex;
572
+ justify-content: space-evenly;
573
+ align-items: stretch;
574
+ gap: var(--sp-4);
575
+ background: var(--surface);
576
+ padding-block-end: env(safe-area-inset-bottom, 0px);
577
+ -webkit-user-select: none;
578
+ user-select: none;
579
+ }
580
+
581
+ .shell-nav > * {
582
+ min-block-size: var(--tap-min);
583
+ min-inline-size: var(--tap-min);
584
+ display: flex;
585
+ flex-direction: column;
586
+ align-items: center;
587
+ justify-content: center;
588
+ gap: var(--sp-4);
589
+ /* Reset native <a>/<button> chrome so tabs read as tabs, not links. */
590
+ border: 0;
591
+ background: none;
592
+ color: var(--text-muted);
593
+ text-decoration: none;
594
+ border-radius: var(--r-ctl);
595
+ font: inherit;
596
+ font-size: var(--fs-label);
597
+ font-weight: var(--fw-label);
598
+ cursor: pointer;
599
+ -webkit-tap-highlight-color: transparent;
600
+ transition:
601
+ color var(--t-micro) var(--ease),
602
+ background-color var(--t-micro) var(--ease);
603
+ }
604
+ .shell-nav > *:hover { color: var(--text); }
605
+ /* Active tab: mark the current item with aria-current (icon + label follow
606
+ currentColor, so the whole tab lights up in the accent). */
607
+ .shell-nav > [aria-current]:not([aria-current="false"]) { color: var(--accent); }
608
+
609
+ @media (min-width: 768px) {
610
+ .shell {
611
+ grid-template-areas:
612
+ "nav header"
613
+ "nav content";
614
+ grid-template-rows: auto 1fr;
615
+ grid-template-columns: auto 1fr;
616
+ }
617
+
618
+ .shell-nav {
619
+ flex-direction: column;
620
+ justify-content: flex-start;
621
+ padding-block: var(--pad);
622
+ padding-inline: var(--sp-8);
623
+ padding-block-end: var(--pad);
624
+ inline-size: 5rem;
625
+ }
626
+ }
627
+
628
+
629
+ /* ============================ screen presets ================================
630
+ One preset per screen, the outermost element inside .shell-content (or
631
+ <body> in shell-less pages). Presets own responsive behavior; screens
632
+ never add their own. */
633
+
634
+ /* screen-stack — settings, forms, articles, detail pages.
635
+ Single scrolling column; max-width 40rem centered ≥768px, full-bleed
636
+ below; --pad inline padding; --gap rhythm. */
637
+ .screen-stack {
638
+ display: flex;
639
+ flex-direction: column;
640
+ gap: var(--gap);
641
+ padding-block: var(--pad);
642
+ padding-inline: var(--pad);
643
+ inline-size: 100%;
644
+ }
645
+
646
+ @media (min-width: 768px) {
647
+ .screen-stack {
648
+ max-inline-size: calc(40rem + 2 * var(--pad));
649
+ margin-inline: auto;
650
+ }
651
+ }
652
+
653
+ /* screen-list — feeds, chats, search results.
654
+ Full-bleed scroll region + sticky header slot; rows manage their own
655
+ inline padding. Pull-to-refresh (phase 6) mounts here. */
656
+ .screen-list {
657
+ display: flex;
658
+ flex-direction: column;
659
+ min-block-size: 100%;
660
+ }
661
+
662
+ .screen-list > header {
663
+ position: sticky;
664
+ inset-block-start: 0;
665
+ z-index: 1;
666
+ background: var(--bg);
667
+ padding-block: var(--sp-8);
668
+ padding-inline: var(--pad);
669
+ }
670
+
671
+ /* screen-split — list + detail (mail, chat apps).
672
+ Two panes at ≥900px: side pane clamped 20–24rem, detail flexes; each
673
+ pane scrolls independently. Below 900px exactly one pane shows —
674
+ data-show="detail" on the preset switches from the side pane to the
675
+ detail pane (app toggles it; the push animation is a later enhancement). */
676
+ .screen-split {
677
+ display: grid;
678
+ block-size: 100%;
679
+ overflow: hidden;
680
+ grid-template-columns: 1fr;
681
+ }
682
+
683
+ .screen-split > * {
684
+ overflow-y: auto;
685
+ overscroll-behavior: contain;
686
+ }
687
+
688
+ @media (max-width: 899.98px) {
689
+ .screen-split > :last-child { display: none; }
690
+ .screen-split[data-show="detail"] > :first-child { display: none; }
691
+ .screen-split[data-show="detail"] > :last-child { display: block; }
692
+ }
693
+
694
+ @media (min-width: 900px) {
695
+ .screen-split {
696
+ grid-template-columns: clamp(20rem, 30%, 24rem) 1fr;
697
+ }
698
+ }
699
+
700
+ /* screen-center — auth, onboarding, empty and error states.
701
+ Content block centered both axes, max-width 24rem, safe-area aware. */
702
+ .screen-center {
703
+ display: flex;
704
+ flex-direction: column;
705
+ align-items: center;
706
+ justify-content: center;
707
+ gap: var(--gap);
708
+ min-block-size: 100%;
709
+ padding-block: var(--pad);
710
+ padding-inline: max(var(--pad), env(safe-area-inset-left, 0px), env(safe-area-inset-right, 0px));
711
+ }
712
+
713
+ .screen-center > * {
714
+ inline-size: 100%;
715
+ max-inline-size: 24rem;
716
+ }
717
+
718
+
719
+ /* ============================== primitives =================================
720
+ stack / row / grid — every option is an enum or a scale step. There is
721
+ deliberately no way to express an arbitrary gap, template, or width. */
722
+
723
+ /* stack — vertical flow. gap: scale step (default --gap);
724
+ align: start | center | stretch (default stretch). */
725
+ .stack {
726
+ display: flex;
727
+ flex-direction: column;
728
+ gap: var(--gap);
729
+ }
730
+ .stack[data-align="start"] { align-items: flex-start; }
731
+ .stack[data-align="center"] { align-items: center; }
732
+
733
+ /* row — horizontal flow. gap: scale step (default --gap);
734
+ align: start | center | end | between (default center); wrap: on. */
735
+ .row {
736
+ display: flex;
737
+ align-items: center;
738
+ gap: var(--gap);
739
+ }
740
+ .row[data-align="start"] { align-items: flex-start; }
741
+ .row[data-align="end"] { align-items: flex-end; }
742
+ .row[data-align="between"] { justify-content: space-between; }
743
+ .row[data-wrap="on"] { flex-wrap: wrap; }
744
+
745
+ /* gap enum — shared by stack and row. Grid gutter is fixed and not here. */
746
+ .stack[data-gap="4"], .row[data-gap="4"] { gap: var(--sp-4); }
747
+ .stack[data-gap="8"], .row[data-gap="8"] { gap: var(--sp-8); }
748
+ .stack[data-gap="12"], .row[data-gap="12"] { gap: var(--sp-12); }
749
+ .stack[data-gap="16"], .row[data-gap="16"] { gap: var(--sp-16); }
750
+ .stack[data-gap="20"], .row[data-gap="20"] { gap: var(--sp-20); }
751
+ .stack[data-gap="24"], .row[data-gap="24"] { gap: var(--sp-24); }
752
+ .stack[data-gap="32"], .row[data-gap="32"] { gap: var(--sp-32); }
753
+ .stack[data-gap="40"], .row[data-gap="40"] { gap: var(--sp-40); }
754
+ .stack[data-gap="48"], .row[data-gap="48"] { gap: var(--sp-48); }
755
+
756
+ /* grid — fixed column system: 4 columns <768px, 8 at 768–1199px, 12 at
757
+ ≥1200px; gutter fixed 12px. Children take integer spans per breakpoint
758
+ (data-span, data-span-md, data-span-lg); spans clamp to the column
759
+ count — a span ≥ the column count becomes a full row, nothing overflows.
760
+ Later breakpoints fall back: base → md → lg. */
761
+ .grid {
762
+ display: grid;
763
+ gap: var(--sp-12);
764
+ grid-template-columns: repeat(4, 1fr);
765
+ }
766
+ .grid > * { grid-column: span 1; }
767
+
768
+ @media (max-width: 767.98px) {
769
+ .grid > [data-span="2"] { grid-column: span 2; }
770
+ .grid > [data-span="3"] { grid-column: span 3; }
771
+ .grid > [data-span="4"], .grid > [data-span="5"], .grid > [data-span="6"],
772
+ .grid > [data-span="7"], .grid > [data-span="8"], .grid > [data-span="9"],
773
+ .grid > [data-span="10"], .grid > [data-span="11"], .grid > [data-span="12"] {
774
+ grid-column: 1 / -1;
775
+ }
776
+ }
777
+
778
+ @media (min-width: 768px) and (max-width: 1199.98px) {
779
+ .grid { grid-template-columns: repeat(8, 1fr); }
780
+ .grid > [data-span="2"] { grid-column: span 2; }
781
+ .grid > [data-span="3"] { grid-column: span 3; }
782
+ .grid > [data-span="4"] { grid-column: span 4; }
783
+ .grid > [data-span="5"] { grid-column: span 5; }
784
+ .grid > [data-span="6"] { grid-column: span 6; }
785
+ .grid > [data-span="7"] { grid-column: span 7; }
786
+ .grid > [data-span="8"], .grid > [data-span="9"], .grid > [data-span="10"],
787
+ .grid > [data-span="11"], .grid > [data-span="12"] { grid-column: 1 / -1; }
788
+ /* md overrides base */
789
+ .grid > [data-span-md="1"] { grid-column: span 1; }
790
+ .grid > [data-span-md="2"] { grid-column: span 2; }
791
+ .grid > [data-span-md="3"] { grid-column: span 3; }
792
+ .grid > [data-span-md="4"] { grid-column: span 4; }
793
+ .grid > [data-span-md="5"] { grid-column: span 5; }
794
+ .grid > [data-span-md="6"] { grid-column: span 6; }
795
+ .grid > [data-span-md="7"] { grid-column: span 7; }
796
+ .grid > [data-span-md="8"], .grid > [data-span-md="9"], .grid > [data-span-md="10"],
797
+ .grid > [data-span-md="11"], .grid > [data-span-md="12"] { grid-column: 1 / -1; }
798
+ }
799
+
800
+ @media (min-width: 1200px) {
801
+ .grid { grid-template-columns: repeat(12, 1fr); }
802
+ .grid > [data-span="2"] { grid-column: span 2; }
803
+ .grid > [data-span="3"] { grid-column: span 3; }
804
+ .grid > [data-span="4"] { grid-column: span 4; }
805
+ .grid > [data-span="5"] { grid-column: span 5; }
806
+ .grid > [data-span="6"] { grid-column: span 6; }
807
+ .grid > [data-span="7"] { grid-column: span 7; }
808
+ .grid > [data-span="8"] { grid-column: span 8; }
809
+ .grid > [data-span="9"] { grid-column: span 9; }
810
+ .grid > [data-span="10"] { grid-column: span 10; }
811
+ .grid > [data-span="11"] { grid-column: span 11; }
812
+ .grid > [data-span="12"] { grid-column: 1 / -1; }
813
+ /* md persists at lg unless lg overrides */
814
+ .grid > [data-span-md="1"] { grid-column: span 1; }
815
+ .grid > [data-span-md="2"] { grid-column: span 2; }
816
+ .grid > [data-span-md="3"] { grid-column: span 3; }
817
+ .grid > [data-span-md="4"] { grid-column: span 4; }
818
+ .grid > [data-span-md="5"] { grid-column: span 5; }
819
+ .grid > [data-span-md="6"] { grid-column: span 6; }
820
+ .grid > [data-span-md="7"] { grid-column: span 7; }
821
+ .grid > [data-span-md="8"] { grid-column: span 8; }
822
+ .grid > [data-span-md="9"], .grid > [data-span-md="10"],
823
+ .grid > [data-span-md="11"], .grid > [data-span-md="12"] { grid-column: 1 / -1; }
824
+ /* lg overrides md and base */
825
+ .grid > [data-span-lg="1"] { grid-column: span 1; }
826
+ .grid > [data-span-lg="2"] { grid-column: span 2; }
827
+ .grid > [data-span-lg="3"] { grid-column: span 3; }
828
+ .grid > [data-span-lg="4"] { grid-column: span 4; }
829
+ .grid > [data-span-lg="5"] { grid-column: span 5; }
830
+ .grid > [data-span-lg="6"] { grid-column: span 6; }
831
+ .grid > [data-span-lg="7"] { grid-column: span 7; }
832
+ .grid > [data-span-lg="8"] { grid-column: span 8; }
833
+ .grid > [data-span-lg="9"] { grid-column: span 9; }
834
+ .grid > [data-span-lg="10"] { grid-column: span 10; }
835
+ .grid > [data-span-lg="11"] { grid-column: span 11; }
836
+ .grid > [data-span-lg="12"] { grid-column: 1 / -1; }
837
+ }
838
+
839
+
840
+ /* ========================= outline diagnostic ==============================
841
+ data-outline on any preset or primitive draws dashed overlays on itself
842
+ and every layout box inside it. Build-time diagnostic only — exempt from
843
+ the no-borders rule (DESIGN.md §2.15), never ships on. Outlines don't
844
+ affect layout metrics. */
845
+ [data-outline] :is(.stack, .row, .grid, .screen-stack, .screen-list,
846
+ .screen-split, .screen-center, .shell-header, .shell-nav, .shell-content),
847
+ :is(.stack, .row, .grid, .screen-stack, .screen-list,
848
+ .screen-split, .screen-center, .shell)[data-outline] {
849
+ outline: 1px dashed var(--text-muted);
850
+ outline-offset: -1px;
851
+ }
852
+
853
+ [data-outline] .grid > *,
854
+ .grid[data-outline] > * {
855
+ outline: 1px dashed var(--accent);
856
+ outline-offset: -1px;
857
+ }
858
+
859
+ /* Live column count on outlined grids. */
860
+ .grid[data-outline]::before,
861
+ [data-outline] .grid::before {
862
+ grid-column: 1 / -1;
863
+ font-size: var(--fs-label);
864
+ font-weight: var(--fw-label);
865
+ color: var(--accent);
866
+ content: "4 columns";
867
+ }
868
+ @media (min-width: 768px) {
869
+ .grid[data-outline]::before,
870
+ [data-outline] .grid::before { content: "8 columns"; }
871
+ }
872
+ @media (min-width: 1200px) {
873
+ .grid[data-outline]::before,
874
+ [data-outline] .grid::before { content: "12 columns"; }
875
+ }
876
+
877
+
878
+ /* ========================== forced-colors floor ============================
879
+ Tonal region separation disappears; the frame draws real borders. */
880
+ @media (forced-colors: active) {
881
+ .shell-header { border-block-end: 1px solid CanvasText; }
882
+ .shell-nav { border-block-start: 1px solid CanvasText; }
883
+ .screen-list > header { border-block-end: 1px solid CanvasText; }
884
+ /* Active tab is color-only otherwise; give it a system-color cue that
885
+ survives forced colors (accent and muted both collapse to CanvasText). */
886
+ .shell-nav > [aria-current]:not([aria-current="false"]) {
887
+ color: Highlight;
888
+ text-decoration: underline;
889
+ }
890
+
891
+ @media (min-width: 768px) {
892
+ .shell-header { border-block-end: none; }
893
+ .shell-nav {
894
+ border-block-start: none;
895
+ border-inline-end: 1px solid CanvasText;
896
+ }
897
+ }
898
+ }
899
+ /* ============================================================================
900
+ MTUI (MoreThanUI) — components.css
901
+ Tier: Basic (zero JS) for every component in this file.
902
+ Semantic tokens only. Zero borders, zero drop shadows — separation is
903
+ tonal; the only borders live in the forced-colors block at the end.
904
+ Interaction language (set by .btn, copied by everything):
905
+ hover = background tone shift and/or translateY(-2px) (cards -3px,
906
+ rows nudge 3px inline-start)
907
+ press = scale(.96–.98)
908
+ focus = --ring-focus (global, base.css)
909
+ fade = never (reserved for deletion + popover exit)
910
+ Icons: real Lucide paths only — inline SVG in markup, or the CSS masks
911
+ below for pseudo-element icons (they tint via background-color). Never
912
+ border-drawn glyphs.
913
+ ============================================================================ */
914
+
915
+ :root {
916
+ --icon-chevron-down: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
917
+ --icon-check: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E");
918
+ --icon-loader: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 12a9 9 0 1 1-6.219-8.56'/%3E%3C/svg%3E");
919
+
920
+ /* Shipped icon set (real Lucide paths). Placed via .icon[data-icon] below,
921
+ so apps/AI never hand-author path data. viewBox/stroke match Lucide. */
922
+ --icon-home: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z'/%3E%3Cpath d='M9 22V12h6v10'/%3E%3C/svg%3E");
923
+ --icon-search: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'/%3E%3Cpath d='m21 21-4.3-4.3'/%3E%3C/svg%3E");
924
+ --icon-settings: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z'/%3E%3Ccircle cx='12' cy='12' r='3'/%3E%3C/svg%3E");
925
+ --icon-user: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2'/%3E%3Ccircle cx='12' cy='7' r='4'/%3E%3C/svg%3E");
926
+ --icon-bell: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9'/%3E%3Cpath d='M10.3 21a1.94 1.94 0 0 0 3.4 0'/%3E%3C/svg%3E");
927
+ --icon-mail: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect width='20' height='16' x='2' y='4' rx='2'/%3E%3Cpath d='m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7'/%3E%3C/svg%3E");
928
+ --icon-calendar: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M8 2v4'/%3E%3Cpath d='M16 2v4'/%3E%3Crect width='18' height='18' x='3' y='4' rx='2'/%3E%3Cpath d='M3 10h18'/%3E%3C/svg%3E");
929
+ --icon-plus: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M5 12h14'/%3E%3Cpath d='M12 5v14'/%3E%3C/svg%3E");
930
+ --icon-x: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M18 6 6 18'/%3E%3Cpath d='m6 6 12 12'/%3E%3C/svg%3E");
931
+ --icon-chevron-right: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m9 18 6-6-6-6'/%3E%3C/svg%3E");
932
+ --icon-chevron-left: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m15 18-6-6 6-6'/%3E%3C/svg%3E");
933
+ --icon-chevron-up: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m18 15-6-6-6 6'/%3E%3C/svg%3E");
934
+ --icon-more-vertical: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='1'/%3E%3Ccircle cx='12' cy='5' r='1'/%3E%3Ccircle cx='12' cy='19' r='1'/%3E%3C/svg%3E");
935
+ --icon-trash: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 6h18'/%3E%3Cpath d='M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6'/%3E%3Cpath d='M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2'/%3E%3C/svg%3E");
936
+ --icon-heart: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.29 1.51 4.04 3 5.5l7 7Z'/%3E%3C/svg%3E");
937
+ --icon-star: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolygon points='12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26'/%3E%3C/svg%3E");
938
+ --icon-share: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='18' cy='5' r='3'/%3E%3Ccircle cx='6' cy='12' r='3'/%3E%3Ccircle cx='18' cy='19' r='3'/%3E%3Cline x1='8.59' x2='15.42' y1='13.51' y2='17.49'/%3E%3Cline x1='15.41' x2='8.59' y1='6.51' y2='10.49'/%3E%3C/svg%3E");
939
+ --icon-pencil: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 20h9'/%3E%3Cpath d='M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z'/%3E%3C/svg%3E");
940
+ --icon-info: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cpath d='M12 16v-4'/%3E%3Cpath d='M12 8h.01'/%3E%3C/svg%3E");
941
+ --icon-alert-triangle: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3'/%3E%3Cpath d='M12 9v4'/%3E%3Cpath d='M12 17h.01'/%3E%3C/svg%3E");
942
+ --icon-circle-check: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cpath d='m9 12 2 2 4-4'/%3E%3C/svg%3E");
943
+ --icon-circle-x: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cpath d='m15 9-6 6'/%3E%3Cpath d='m9 9 6 6'/%3E%3C/svg%3E");
944
+ }
945
+
946
+ /* ================================ icons ====================================
947
+ <span class="icon" data-icon="home"></span> — monochrome (currentColor
948
+ mask), scales with font-size. Decorative by default; for a standalone
949
+ meaning add role="img" + aria-label. For an icon outside the set, inline a
950
+ Lucide SVG (stroke-width 2, currentColor). */
951
+ .icon {
952
+ display: inline-block;
953
+ inline-size: 1.25em;
954
+ block-size: 1.25em;
955
+ vertical-align: -0.2em;
956
+ flex-shrink: 0;
957
+ background: currentColor;
958
+ -webkit-mask: var(--i) center / contain no-repeat;
959
+ mask: var(--i) center / contain no-repeat;
960
+ }
961
+ .icon[data-icon="home"] { --i: var(--icon-home); }
962
+ .icon[data-icon="search"] { --i: var(--icon-search); }
963
+ .icon[data-icon="settings"] { --i: var(--icon-settings); }
964
+ .icon[data-icon="user"] { --i: var(--icon-user); }
965
+ .icon[data-icon="bell"] { --i: var(--icon-bell); }
966
+ .icon[data-icon="mail"] { --i: var(--icon-mail); }
967
+ .icon[data-icon="calendar"] { --i: var(--icon-calendar); }
968
+ .icon[data-icon="plus"] { --i: var(--icon-plus); }
969
+ .icon[data-icon="x"] { --i: var(--icon-x); }
970
+ .icon[data-icon="check"] { --i: var(--icon-check); }
971
+ .icon[data-icon="chevron-up"] { --i: var(--icon-chevron-up); }
972
+ .icon[data-icon="chevron-down"] { --i: var(--icon-chevron-down); }
973
+ .icon[data-icon="chevron-left"] { --i: var(--icon-chevron-left); }
974
+ .icon[data-icon="chevron-right"] { --i: var(--icon-chevron-right); }
975
+ .icon[data-icon="more-vertical"] { --i: var(--icon-more-vertical); }
976
+ .icon[data-icon="trash"] { --i: var(--icon-trash); }
977
+ .icon[data-icon="heart"] { --i: var(--icon-heart); }
978
+ .icon[data-icon="star"] { --i: var(--icon-star); }
979
+ .icon[data-icon="share"] { --i: var(--icon-share); }
980
+ .icon[data-icon="pencil"] { --i: var(--icon-pencil); }
981
+ .icon[data-icon="info"] { --i: var(--icon-info); }
982
+ .icon[data-icon="alert-triangle"] { --i: var(--icon-alert-triangle); }
983
+ .icon[data-icon="circle-check"] { --i: var(--icon-circle-check); }
984
+ .icon[data-icon="circle-x"] { --i: var(--icon-circle-x); }
985
+ /* chevron-right is the directional one; flip in RTL like .chevron */
986
+ [dir="rtl"] .icon[data-icon="chevron-right"],
987
+ [dir="rtl"] .icon[data-icon="chevron-left"] { transform: scaleX(-1); }
988
+
989
+
990
+ /* ================================ button ===================================
991
+ <button class="btn" data-variant="primary">Save changes</button>
992
+ data-variant: primary | accent | secondary (default) | ghost | danger
993
+ (accent uses the secondary brand color --accent-2, which
994
+ mirrors --accent until a custom theme sets it apart)
995
+ data-size: icon (square, icon-only — give it an aria-label)
996
+ data-loading: swaps the label for an in-place spinner, keeps size. */
997
+ .btn {
998
+ position: relative;
999
+ display: inline-flex;
1000
+ align-items: center;
1001
+ justify-content: center;
1002
+ gap: var(--sp-8);
1003
+ min-block-size: var(--ctl-h);
1004
+ min-inline-size: var(--tap-min);
1005
+ padding-inline: var(--sp-20);
1006
+ border: 0;
1007
+ border-radius: var(--r-ctl);
1008
+ background: var(--surface-2);
1009
+ color: var(--text);
1010
+ font-size: var(--fs-control);
1011
+ font-weight: var(--fw-control);
1012
+ line-height: var(--lh-control);
1013
+ text-decoration: none;
1014
+ cursor: pointer;
1015
+ -webkit-tap-highlight-color: transparent;
1016
+ transition:
1017
+ background-color var(--t-micro) var(--ease),
1018
+ color var(--t-micro) var(--ease),
1019
+ transform var(--t-micro) var(--ease);
1020
+ }
1021
+ /* Invisible hit-slop, not a visual effect: hover moves the button -2px, so
1022
+ a cursor resting near the original bottom edge would otherwise end up
1023
+ outside the shifted box, firing mouseleave, which un-hovers it, which
1024
+ shifts it back under the cursor, re-triggering hover — flicker. This
1025
+ pseudo rides along with the same transform (it's part of .btn's own
1026
+ box) so the effective hoverable region still reaches ~2px past the
1027
+ shift in every direction, comfortably covering that boundary case. */
1028
+ .btn::before {
1029
+ content: "";
1030
+ position: absolute;
1031
+ inset: calc(-1 * var(--sp-4));
1032
+ }
1033
+
1034
+ .btn:hover { background: var(--surface-3); transform: translateY(-2px); }
1035
+ .btn:active { transform: scale(.97); }
1036
+
1037
+ .btn[data-variant="primary"] { background: var(--accent); color: var(--on-accent); }
1038
+ .btn[data-variant="primary"]:hover { background: var(--accent-hover); }
1039
+
1040
+ .btn[data-variant="accent"] { background: var(--accent-2); color: var(--on-accent-2); }
1041
+ .btn[data-variant="accent"]:hover { background: var(--accent-2-hover); }
1042
+
1043
+ .btn[data-variant="ghost"] { background: transparent; }
1044
+ .btn[data-variant="ghost"]:hover { background: var(--surface-2); }
1045
+
1046
+ /* No --danger-hover token exists — danger hover is movement only. */
1047
+ .btn[data-variant="danger"] { background: var(--danger); color: var(--on-accent); }
1048
+
1049
+ .btn[data-size="icon"] { inline-size: var(--ctl-h); padding-inline: 0; }
1050
+
1051
+ .btn:disabled {
1052
+ color: var(--text-faint); /* sanctioned: §2.1 lists disabled text here */
1053
+ cursor: default;
1054
+ }
1055
+ .btn:disabled:hover { background: var(--surface-2); transform: none; }
1056
+ .btn[data-variant="primary"]:disabled,
1057
+ .btn[data-variant="accent"]:disabled,
1058
+ .btn[data-variant="danger"]:disabled { background: var(--surface-2); }
1059
+
1060
+ /* Loading: label goes invisible (size preserved), the Lucide loader icon
1061
+ centers on top and rotates. Rotation reuses --t-skeleton, the
1062
+ loading-loop duration token. */
1063
+ .btn[data-loading] { pointer-events: none; color: transparent; }
1064
+ .btn[data-loading]::after {
1065
+ content: "";
1066
+ position: absolute;
1067
+ inset: 0;
1068
+ margin: auto;
1069
+ inline-size: var(--sp-20);
1070
+ block-size: var(--sp-20);
1071
+ background: var(--text);
1072
+ -webkit-mask: var(--icon-loader) center / contain no-repeat;
1073
+ mask: var(--icon-loader) center / contain no-repeat;
1074
+ animation: mtui-spin var(--t-skeleton) linear infinite;
1075
+ }
1076
+ .btn[data-variant="primary"][data-loading]::after,
1077
+ .btn[data-variant="danger"][data-loading]::after {
1078
+ background: var(--on-accent);
1079
+ }
1080
+ .btn[data-variant="accent"][data-loading]::after { background: var(--on-accent-2); }
1081
+
1082
+ @keyframes mtui-spin { to { transform: rotate(360deg); } }
1083
+
1084
+
1085
+ /* ================================= card ====================================
1086
+ <div class="card">…</div>
1087
+ Interactive: <a class="card" href>…</a> / <button class="card">. */
1088
+ .card {
1089
+ background: var(--surface);
1090
+ border-radius: var(--r-card);
1091
+ padding: var(--pad);
1092
+ }
1093
+
1094
+ a.card,
1095
+ button.card {
1096
+ position: relative;
1097
+ display: block;
1098
+ inline-size: 100%;
1099
+ border: 0;
1100
+ color: inherit;
1101
+ font: inherit;
1102
+ text-align: start;
1103
+ text-decoration: none;
1104
+ cursor: pointer;
1105
+ -webkit-tap-highlight-color: transparent;
1106
+ transition: transform var(--t-lift) var(--ease);
1107
+ }
1108
+ /* Same hit-slop reasoning as .btn::before — the -3px lift needs a bigger
1109
+ buffer than the button's -2px. */
1110
+ a.card::before,
1111
+ button.card::before {
1112
+ content: "";
1113
+ position: absolute;
1114
+ inset: calc(-1 * var(--sp-8));
1115
+ }
1116
+ a.card:hover,
1117
+ button.card:hover { transform: translateY(-3px); }
1118
+ a.card:active,
1119
+ button.card:active { transform: scale(.98); }
1120
+
1121
+
1122
+ /* ================================= field ===================================
1123
+ <label class="field">
1124
+ <span class="t-label">Email</span>
1125
+ <input type="email" placeholder="you@example.com">
1126
+ <span class="field-hint">We never share it.</span>
1127
+ <span class="field-error">Enter a valid email.</span>
1128
+ </label>
1129
+ Selects wrap in a chevron holder: <span class="select"><select>…</select></span>
1130
+ Error state: data-invalid on the .field (hint hides, error shows). */
1131
+ .field {
1132
+ display: flex;
1133
+ flex-direction: column;
1134
+ gap: var(--sp-8);
1135
+ }
1136
+
1137
+ .field input,
1138
+ .field select,
1139
+ .field textarea {
1140
+ min-block-size: var(--field-h);
1141
+ padding-inline: var(--sp-16);
1142
+ border: 0;
1143
+ border-radius: var(--r-input);
1144
+ background: var(--surface-2);
1145
+ color: var(--text);
1146
+ font-size: var(--fs-body); /* 16px floor stops iOS zoom-on-focus */
1147
+ line-height: var(--lh-body);
1148
+ transition: background-color var(--t-micro) var(--ease);
1149
+ }
1150
+ .field input:hover,
1151
+ .field select:hover,
1152
+ .field textarea:hover { background: var(--surface-3); }
1153
+
1154
+ .field textarea {
1155
+ min-block-size: calc(2 * var(--field-h));
1156
+ padding-block: var(--sp-12);
1157
+ resize: vertical;
1158
+ }
1159
+
1160
+ .field ::placeholder { color: var(--text-faint); opacity: 1; }
1161
+
1162
+ .select { position: relative; display: block; }
1163
+ .select select {
1164
+ appearance: none;
1165
+ inline-size: 100%;
1166
+ padding-inline-end: var(--sp-40);
1167
+ }
1168
+ .select::after {
1169
+ content: "";
1170
+ position: absolute;
1171
+ inset-block: 0;
1172
+ inset-inline-end: var(--sp-16);
1173
+ margin-block: auto;
1174
+ inline-size: var(--sp-16);
1175
+ block-size: var(--sp-16);
1176
+ background: var(--text-muted);
1177
+ -webkit-mask: var(--icon-chevron-down) center / contain no-repeat;
1178
+ mask: var(--icon-chevron-down) center / contain no-repeat;
1179
+ pointer-events: none;
1180
+ }
1181
+
1182
+ .field-hint,
1183
+ .field-error {
1184
+ font-size: var(--fs-secondary);
1185
+ line-height: var(--lh-secondary);
1186
+ }
1187
+ .field-hint { color: var(--text-muted); }
1188
+ .field-error { color: var(--danger-text); display: none; }
1189
+
1190
+ .field[data-invalid] input,
1191
+ .field[data-invalid] select,
1192
+ .field[data-invalid] textarea { background: var(--danger-tint); }
1193
+ .field[data-invalid] .field-error { display: block; }
1194
+ .field[data-invalid] .field-hint { display: none; }
1195
+
1196
+
1197
+ /* ================================ toggles ==================================
1198
+ <input type="checkbox" class="switch"> (pair inside a row label)
1199
+ <input type="checkbox" class="checkbox">
1200
+ <input type="radio" class="radio" name="…">
1201
+ Canonical row: <label class="row" data-align="between">
1202
+ <span class="t-row">Dark mode</span>
1203
+ <input type="checkbox" class="switch"></label>
1204
+ The label is the ≥44px tap target; knob motion is --t-knob --spring. */
1205
+ .switch,
1206
+ .checkbox,
1207
+ .radio {
1208
+ appearance: none;
1209
+ flex-shrink: 0;
1210
+ margin: 0;
1211
+ cursor: pointer;
1212
+ -webkit-tap-highlight-color: transparent;
1213
+ }
1214
+
1215
+ .switch {
1216
+ inline-size: var(--sp-48);
1217
+ block-size: var(--sp-24);
1218
+ border-radius: var(--r-ctl);
1219
+ background: var(--surface-3);
1220
+ position: relative;
1221
+ transition: background-color var(--t-micro) var(--ease);
1222
+ }
1223
+ .switch::before {
1224
+ content: "";
1225
+ position: absolute;
1226
+ inset-block-start: 2px; /* 1px-scale internal detail (sanctioned) */
1227
+ inset-inline-start: 2px;
1228
+ inline-size: var(--sp-20);
1229
+ block-size: var(--sp-20);
1230
+ border-radius: 50%;
1231
+ background: var(--on-accent);
1232
+ transition: translate var(--t-knob) var(--spring);
1233
+ }
1234
+ .switch:checked { background: var(--accent); }
1235
+ .switch:checked::before { translate: var(--sp-24) 0; }
1236
+ [dir="rtl"] .switch:checked::before { translate: calc(-1 * var(--sp-24)) 0; }
1237
+
1238
+ .checkbox,
1239
+ .radio {
1240
+ inline-size: var(--sp-24);
1241
+ block-size: var(--sp-24);
1242
+ background: var(--surface-2);
1243
+ display: inline-flex;
1244
+ align-items: center;
1245
+ justify-content: center;
1246
+ transition: background-color var(--t-micro) var(--ease);
1247
+ }
1248
+ .checkbox:hover, .radio:hover { background: var(--surface-3); }
1249
+ .checkbox:checked, .radio:checked { background: var(--accent); }
1250
+
1251
+ /* Radius clamps so the round preset doesn't turn checkboxes into radios. */
1252
+ .checkbox { border-radius: min(var(--r-chip), 33%); }
1253
+ .radio { border-radius: 50%; }
1254
+
1255
+ .checkbox::before {
1256
+ content: "";
1257
+ inline-size: var(--sp-16);
1258
+ block-size: var(--sp-16);
1259
+ background: var(--on-accent);
1260
+ -webkit-mask: var(--icon-check) center / contain no-repeat;
1261
+ mask: var(--icon-check) center / contain no-repeat;
1262
+ transform: scale(0);
1263
+ transition: transform var(--t-knob) var(--spring);
1264
+ }
1265
+ .checkbox:checked::before { transform: scale(1); }
1266
+
1267
+ .radio::before {
1268
+ content: "";
1269
+ inline-size: var(--sp-8);
1270
+ block-size: var(--sp-8);
1271
+ border-radius: 50%;
1272
+ background: var(--on-accent);
1273
+ transform: scale(0);
1274
+ transition: transform var(--t-knob) var(--spring);
1275
+ }
1276
+ .radio:checked::before { transform: scale(1); }
1277
+
1278
+
1279
+ /* ============================ badge and chip ===============================
1280
+ <span class="badge" data-tint="mint">New</span>
1281
+ <span class="badge" data-status="danger">3 errors</span>
1282
+ <button class="chip" aria-pressed="true">Filter</button>
1283
+ <span class="chip">Tag <button class="chip-x" aria-label="Remove"><span class="icon" data-icon="x"></span></button></span> */
1284
+ .badge {
1285
+ display: inline-flex;
1286
+ align-items: center;
1287
+ gap: var(--sp-4);
1288
+ min-block-size: var(--sp-20);
1289
+ padding-inline: var(--sp-8);
1290
+ border-radius: var(--r-chip);
1291
+ background: var(--surface-2);
1292
+ color: var(--text-muted);
1293
+ font-size: var(--fs-label);
1294
+ font-weight: var(--fw-label);
1295
+ }
1296
+
1297
+ .chip {
1298
+ display: inline-flex;
1299
+ align-items: center;
1300
+ gap: var(--sp-4);
1301
+ min-block-size: var(--sp-32);
1302
+ padding-inline: var(--sp-12);
1303
+ border: 0;
1304
+ border-radius: var(--r-chip);
1305
+ background: var(--surface-2);
1306
+ color: var(--text);
1307
+ font-size: var(--fs-label);
1308
+ font-weight: var(--fw-label);
1309
+ -webkit-tap-highlight-color: transparent;
1310
+ }
1311
+ button.chip {
1312
+ cursor: pointer;
1313
+ transition:
1314
+ background-color var(--t-micro) var(--ease),
1315
+ color var(--t-micro) var(--ease),
1316
+ transform var(--t-micro) var(--ease);
1317
+ }
1318
+ button.chip:hover { background: var(--surface-3); }
1319
+ button.chip:active { transform: scale(.96); }
1320
+ .chip[aria-pressed="true"],
1321
+ .chip[data-selected] { background: var(--accent-tint); color: var(--accent-text); }
1322
+
1323
+ .chip-x {
1324
+ display: inline-flex;
1325
+ align-items: center;
1326
+ justify-content: center;
1327
+ border: 0;
1328
+ padding: 0;
1329
+ background: none;
1330
+ color: inherit;
1331
+ font: inherit;
1332
+ cursor: pointer;
1333
+ position: relative;
1334
+ }
1335
+ .chip-x::after { content: ""; position: absolute; inset: calc(-1 * var(--sp-12)); } /* tap area */
1336
+
1337
+ /* Shared tint/status color pairs (badge, chip, avatar, alert, empty). */
1338
+ [data-tint="rose"] { background: var(--tint-rose-bg); color: var(--tint-rose-text); }
1339
+ [data-tint="sky"] { background: var(--tint-sky-bg); color: var(--tint-sky-text); }
1340
+ [data-tint="mint"] { background: var(--tint-mint-bg); color: var(--tint-mint-text); }
1341
+ [data-tint="butter"] { background: var(--tint-butter-bg); color: var(--tint-butter-text); }
1342
+ [data-tint="lavender"] { background: var(--tint-lavender-bg); color: var(--tint-lavender-text); }
1343
+ [data-status="success"] { background: var(--success-tint); color: var(--success-text); }
1344
+ [data-status="warning"] { background: var(--warning-tint); color: var(--warning-text); }
1345
+ [data-status="danger"] { background: var(--danger-tint); color: var(--danger-text); }
1346
+
1347
+
1348
+ /* ================================ avatar ===================================
1349
+ <span class="avatar" data-tint="sky">AK</span>
1350
+ <span class="avatar"><img src="…" alt=""></span>
1351
+ <span class="avatar-stack"> …avatars… <span class="avatar">+3</span></span>
1352
+ data-size: sm | md (default) | lg */
1353
+ .avatar {
1354
+ display: inline-flex;
1355
+ align-items: center;
1356
+ justify-content: center;
1357
+ inline-size: var(--sp-40);
1358
+ block-size: var(--sp-40);
1359
+ border-radius: 50%;
1360
+ overflow: hidden;
1361
+ flex-shrink: 0;
1362
+ background: var(--surface-2);
1363
+ color: var(--text-muted);
1364
+ font-size: var(--fs-label);
1365
+ font-weight: var(--fw-label);
1366
+ }
1367
+ .avatar[data-size="sm"] { inline-size: var(--sp-32); block-size: var(--sp-32); }
1368
+ .avatar[data-size="lg"] { inline-size: var(--sp-48); block-size: var(--sp-48); }
1369
+ .avatar img { inline-size: 100%; block-size: 100%; object-fit: cover; }
1370
+
1371
+ .avatar-stack { display: inline-flex; }
1372
+ .avatar-stack .avatar { box-shadow: var(--ring-avatar); }
1373
+ .avatar-stack .avatar + .avatar { margin-inline-start: calc(-1 * var(--sp-8)); }
1374
+
1375
+
1376
+ /* =============================== list item =================================
1377
+ (`.row` belongs to the layout primitive, so the list row is `.item`.)
1378
+ <a class="item" href>
1379
+ <span class="avatar" data-tint="mint">M</span>
1380
+ <span class="item-text">
1381
+ <span class="item-title">Maya Chen</span>
1382
+ <span class="item-sub">Sounds good — tomorrow works.</span>
1383
+ </span>
1384
+ <svg class="chevron" …></svg>
1385
+ </a>
1386
+ Lists separate by tone/gap only — never dividers. */
1387
+ .item {
1388
+ display: flex;
1389
+ align-items: center;
1390
+ gap: var(--gap);
1391
+ min-block-size: var(--row-h);
1392
+ padding-block: var(--sp-8);
1393
+ padding-inline: var(--pad);
1394
+ color: inherit;
1395
+ text-decoration: none;
1396
+ }
1397
+
1398
+ a.item,
1399
+ button.item {
1400
+ border: 0;
1401
+ border-radius: var(--r-input);
1402
+ background: none;
1403
+ font: inherit;
1404
+ text-align: start;
1405
+ inline-size: 100%;
1406
+ cursor: pointer;
1407
+ -webkit-tap-highlight-color: transparent;
1408
+ transition:
1409
+ background-color var(--t-micro) var(--ease),
1410
+ transform var(--t-micro) var(--ease);
1411
+ }
1412
+ a.item:hover,
1413
+ button.item:hover {
1414
+ /* Soft wash: half-strength well tone, works on surface and bg alike.
1415
+ Engines without color-mix fall back to the solid well. */
1416
+ background: var(--surface-2);
1417
+ background: color-mix(in srgb, var(--surface-2) 50%, transparent);
1418
+ }
1419
+ a.item:active,
1420
+ button.item:active { transform: scale(.98); }
1421
+
1422
+ .item-text {
1423
+ flex: 1;
1424
+ min-inline-size: 0;
1425
+ display: flex;
1426
+ flex-direction: column;
1427
+ gap: 2px; /* 1px-scale detail: keeps title/sub optically paired */
1428
+ }
1429
+ .item-title {
1430
+ font-size: var(--fs-control);
1431
+ font-weight: var(--fw-control);
1432
+ line-height: var(--lh-control);
1433
+ }
1434
+ .item-sub {
1435
+ color: var(--text-muted);
1436
+ font-size: var(--fs-secondary);
1437
+ line-height: var(--lh-secondary);
1438
+ }
1439
+ .item-title,
1440
+ .item-sub {
1441
+ overflow: hidden;
1442
+ text-overflow: ellipsis;
1443
+ white-space: nowrap;
1444
+ }
1445
+
1446
+ /* Trailing region: because .item-text is flex:1, anything after it sits at
1447
+ the trailing edge (a .chevron, a badge, a timestamp). .item-meta stacks a
1448
+ couple of those (e.g. time over an unread count). */
1449
+ .item-meta {
1450
+ flex-shrink: 0;
1451
+ display: flex;
1452
+ flex-direction: column;
1453
+ align-items: flex-end;
1454
+ gap: var(--sp-4);
1455
+ color: var(--text-muted);
1456
+ font-size: var(--fs-label);
1457
+ text-align: end;
1458
+ }
1459
+
1460
+ /* Directional chevrons flip with direction (use on any inline icon). */
1461
+ [dir="rtl"] .chevron { transform: scaleX(-1); }
1462
+
1463
+
1464
+ /* ============================== segmented ==================================
1465
+ <fieldset class="segmented">
1466
+ <label><input type="radio" name="view" checked><span>Day</span></label>
1467
+ <label><input type="radio" name="view"><span>Week</span></label>
1468
+ </fieldset>
1469
+ Selected = surface pill on the well (tonal). The sliding pill animation
1470
+ is the phase-5 x-tabs enhancement; Basic swaps instantly. */
1471
+ .segmented {
1472
+ position: relative;
1473
+ display: flex;
1474
+ border: 0;
1475
+ margin: 0;
1476
+ padding: var(--sp-4);
1477
+ border-radius: var(--r-ctl);
1478
+ background: var(--surface-2);
1479
+ }
1480
+ .segmented label { flex: 1; display: block; position: relative; }
1481
+ .segmented input {
1482
+ appearance: none;
1483
+ position: absolute;
1484
+ inset: 0;
1485
+ margin: 0;
1486
+ cursor: pointer;
1487
+ border-radius: var(--r-ctl);
1488
+ }
1489
+ .segmented span {
1490
+ position: relative;
1491
+ pointer-events: none; /* the stretched input owns the whole hit area */
1492
+ display: flex;
1493
+ align-items: center;
1494
+ justify-content: center;
1495
+ min-block-size: calc(var(--ctl-h) - 2 * var(--sp-4));
1496
+ padding-inline: var(--sp-12);
1497
+ border-radius: var(--r-ctl);
1498
+ color: var(--text-muted);
1499
+ font-size: var(--fs-control);
1500
+ font-weight: var(--fw-control);
1501
+ transition: background-color var(--t-micro) var(--ease), color var(--t-micro) var(--ease);
1502
+ }
1503
+ .segmented input:checked + span { background: var(--surface); color: var(--text); }
1504
+ .segmented input:focus-visible + span { box-shadow: var(--ring-focus); }
1505
+
1506
+ /* Sliding pill (350ms --spring, per §2.12) — pure CSS via :has(); engines
1507
+ without :has() keep the instant per-segment fallback above. Supports up
1508
+ to 5 segments. */
1509
+ @supports selector(:has(*)) {
1510
+ .segmented input:checked + span { background: transparent; }
1511
+ .segmented::before {
1512
+ content: "";
1513
+ position: absolute;
1514
+ inset-block: var(--sp-4);
1515
+ inset-inline-start: var(--sp-4);
1516
+ inline-size: calc((100% - 2 * var(--sp-4)) / var(--seg-count, 1));
1517
+ border-radius: var(--r-ctl);
1518
+ background: var(--surface);
1519
+ translate: calc(var(--seg-index, 0) * 100%) 0;
1520
+ transition: translate var(--t-tab) var(--spring);
1521
+ }
1522
+ [dir="rtl"] .segmented::before { translate: calc(var(--seg-index, 0) * -100%) 0; }
1523
+
1524
+ .segmented:has(> label:nth-child(2):last-child) { --seg-count: 2; }
1525
+ .segmented:has(> label:nth-child(3):last-child) { --seg-count: 3; }
1526
+ .segmented:has(> label:nth-child(4):last-child) { --seg-count: 4; }
1527
+ .segmented:has(> label:nth-child(5):last-child) { --seg-count: 5; }
1528
+ .segmented:has(> label:nth-child(1) input:checked) { --seg-index: 0; }
1529
+ .segmented:has(> label:nth-child(2) input:checked) { --seg-index: 1; }
1530
+ .segmented:has(> label:nth-child(3) input:checked) { --seg-index: 2; }
1531
+ .segmented:has(> label:nth-child(4) input:checked) { --seg-index: 3; }
1532
+ .segmented:has(> label:nth-child(5) input:checked) { --seg-index: 4; }
1533
+ }
1534
+
1535
+
1536
+ /* ============================== accordion ==================================
1537
+ <details class="accordion">
1538
+ <summary>Shipping details</summary>
1539
+ <div class="accordion-body">…</div>
1540
+ </details>
1541
+ Tier: Basic (native, instant open/close) + lightly Enhanced via
1542
+ js/accordion.js (smooth height + chevron animation both ways, WAAPI).
1543
+ data-expanded is JS-only and decouples chevron timing from the native
1544
+ [open] attribute — js/accordion.js intentionally keeps [open] true until
1545
+ the CLOSE animation finishes so the body stays rendered while it
1546
+ collapses; without that decoupling the chevron would stay rotated for
1547
+ the whole close animation and only snap back at the very end. */
1548
+ .accordion {
1549
+ background: var(--surface);
1550
+ border-radius: var(--r-card);
1551
+ overflow: hidden;
1552
+ }
1553
+ .accordion summary {
1554
+ display: flex;
1555
+ align-items: center;
1556
+ justify-content: space-between;
1557
+ gap: var(--gap);
1558
+ min-block-size: var(--ctl-h);
1559
+ padding-block: var(--sp-8);
1560
+ padding-inline: var(--pad);
1561
+ font-size: var(--fs-control);
1562
+ font-weight: var(--fw-control);
1563
+ border-radius: var(--r-card);
1564
+ cursor: pointer;
1565
+ list-style: none;
1566
+ -webkit-tap-highlight-color: transparent;
1567
+ transition: background-color var(--t-micro) var(--ease);
1568
+ }
1569
+ .accordion summary::-webkit-details-marker { display: none; }
1570
+ .accordion summary:hover { background: var(--surface-2); }
1571
+ .accordion summary::after {
1572
+ content: "";
1573
+ flex-shrink: 0;
1574
+ inline-size: var(--sp-16);
1575
+ block-size: var(--sp-16);
1576
+ background: var(--text-muted);
1577
+ -webkit-mask: var(--icon-chevron-down) center / contain no-repeat;
1578
+ mask: var(--icon-chevron-down) center / contain no-repeat;
1579
+ transition: transform var(--t-knob) var(--spring);
1580
+ }
1581
+ /* No-JS fallback: native [open] drives the chevron directly. */
1582
+ .accordion:not([data-expanded])[open] summary::after { transform: rotate(180deg); }
1583
+ /* JS-managed: data-expanded solely drives it once js/accordion.js is
1584
+ handling clicks, independent of [open]'s lingering-true close window. */
1585
+ .accordion[data-expanded="true"] summary::after { transform: rotate(180deg); }
1586
+ .accordion-body { padding-block: var(--sp-4) var(--pad); padding-inline: var(--pad); }
1587
+
1588
+
1589
+ /* =============================== code block ================================
1590
+ <pre class="code">…</pre> · inline: <code class="code-inline">…</code>
1591
+ The only mono in MTUI. Stays LTR inside RTL documents. */
1592
+ .code {
1593
+ background: var(--surface-2);
1594
+ border-radius: var(--r-input);
1595
+ padding: var(--pad);
1596
+ overflow-x: auto;
1597
+ font-family: var(--font-mono);
1598
+ font-size: var(--fs-code);
1599
+ line-height: var(--lh-code);
1600
+ direction: ltr;
1601
+ unicode-bidi: isolate;
1602
+ text-align: start;
1603
+ }
1604
+ .code-inline {
1605
+ background: var(--surface-2);
1606
+ border-radius: min(var(--r-chip), 33%);
1607
+ padding-inline: var(--sp-4);
1608
+ font-family: var(--font-mono);
1609
+ font-size: var(--fs-code);
1610
+ direction: ltr;
1611
+ unicode-bidi: isolate;
1612
+ }
1613
+
1614
+
1615
+ /* ================================ skeleton =================================
1616
+ <span class="skeleton" data-shape="title|text|circle|row"></span>
1617
+ Sheen = a soft bar translating across (--t-skeleton loop) — never an
1618
+ opacity pulse. Compose shapes to mirror the final layout. */
1619
+ .skeleton {
1620
+ display: block;
1621
+ position: relative;
1622
+ overflow: hidden;
1623
+ background: var(--surface-2);
1624
+ border-radius: var(--r-input);
1625
+ min-block-size: var(--sp-16);
1626
+ }
1627
+ .skeleton[data-shape="title"] { min-block-size: var(--sp-24); max-inline-size: 60%; }
1628
+ .skeleton[data-shape="text"] { border-radius: var(--r-chip); }
1629
+ .skeleton[data-shape="circle"] {
1630
+ inline-size: var(--sp-40);
1631
+ block-size: var(--sp-40);
1632
+ min-block-size: var(--sp-40);
1633
+ border-radius: 50%;
1634
+ flex-shrink: 0;
1635
+ }
1636
+ .skeleton[data-shape="row"] { min-block-size: var(--row-h); }
1637
+
1638
+ .skeleton::after {
1639
+ content: "";
1640
+ position: absolute;
1641
+ inset-block: 0;
1642
+ inset-inline-start: 0;
1643
+ inline-size: 65%;
1644
+ background: linear-gradient(
1645
+ 90deg,
1646
+ transparent,
1647
+ color-mix(in srgb, var(--surface-3) 70%, transparent),
1648
+ transparent
1649
+ );
1650
+ /* linear, not --ease: --ease is an interaction curve (snappy start, soft
1651
+ stop) — on an infinite loop that reads as a fast dart each cycle. A
1652
+ wide, softly-tinted band gliding at constant speed is what reads as
1653
+ calm/ambient instead of mechanical. */
1654
+ animation: mtui-sheen var(--t-skeleton) linear infinite;
1655
+ }
1656
+ [dir="rtl"] .skeleton::after {
1657
+ background: linear-gradient(
1658
+ -90deg,
1659
+ transparent,
1660
+ color-mix(in srgb, var(--surface-3) 70%, transparent),
1661
+ transparent
1662
+ );
1663
+ animation-name: mtui-sheen-rtl;
1664
+ }
1665
+ @keyframes mtui-sheen {
1666
+ from { transform: translateX(-100%); }
1667
+ to { transform: translateX(215%); }
1668
+ }
1669
+ @keyframes mtui-sheen-rtl {
1670
+ from { transform: translateX(100%); }
1671
+ to { transform: translateX(-215%); }
1672
+ }
1673
+ /* Reduced motion: sheen stops translating but stays visible — a frozen
1674
+ skeleton reads as broken (decision recorded in ROADMAP step 1). */
1675
+ @media (prefers-reduced-motion: reduce) {
1676
+ .skeleton::after {
1677
+ animation: none;
1678
+ inset-inline: 25%;
1679
+ inline-size: 50%;
1680
+ }
1681
+ }
1682
+
1683
+
1684
+ /* ============================== empty state ================================
1685
+ <div class="empty">
1686
+ <span class="empty-icon"><svg …></svg></span>
1687
+ <span class="t-card">No messages yet</span>
1688
+ <p class="t-secondary">Start a conversation and it shows up here.</p>
1689
+ <button class="btn" data-variant="primary">New message</button>
1690
+ </div>
1691
+ Error flavor: data-status="danger" on .empty-icon + a Retry button. */
1692
+ .empty {
1693
+ display: flex;
1694
+ flex-direction: column;
1695
+ align-items: center;
1696
+ gap: var(--sp-12);
1697
+ text-align: center;
1698
+ padding-block: var(--sp-32);
1699
+ padding-inline: var(--pad);
1700
+ max-inline-size: 24rem;
1701
+ margin-inline: auto;
1702
+ }
1703
+ .empty-icon {
1704
+ display: flex;
1705
+ align-items: center;
1706
+ justify-content: center;
1707
+ inline-size: var(--sp-48);
1708
+ block-size: var(--sp-48);
1709
+ border-radius: 50%;
1710
+ background: var(--accent-tint);
1711
+ color: var(--accent-text);
1712
+ }
1713
+
1714
+
1715
+ /* ================================= alert ===================================
1716
+ <div class="alert" data-status="success">
1717
+ <svg …></svg>
1718
+ <div><span class="alert-title">Saved</span>
1719
+ <p>Your changes are live.</p></div>
1720
+ </div>
1721
+ One plain sentence. Never a layout region. */
1722
+ .alert {
1723
+ display: flex;
1724
+ gap: var(--sp-12);
1725
+ padding: var(--sp-16);
1726
+ border-radius: var(--r-card);
1727
+ background: var(--surface-2);
1728
+ color: var(--text);
1729
+ font-size: var(--fs-secondary);
1730
+ line-height: var(--lh-secondary);
1731
+ }
1732
+ .alert > svg { flex-shrink: 0; }
1733
+ .alert-title {
1734
+ display: block;
1735
+ font-size: var(--fs-control);
1736
+ font-weight: var(--fw-control);
1737
+ line-height: var(--lh-control);
1738
+ }
1739
+ .alert p { margin: 0; }
1740
+
1741
+
1742
+ /* ================================ dialog ===================================
1743
+ Native <dialog> on the scrim. Open with .showModal(); a
1744
+ <form method="dialog"> closes it without JS. 400ms spring in, scrim
1745
+ fades in over its own 300ms (§2.12 — scrim color transition is
1746
+ sanctioned; content never fades).
1747
+ <dialog class="dialog" id="confirm">
1748
+ <form method="dialog" class="stack" data-gap="12">
1749
+ <span class="t-card">Delete this note?</span>
1750
+ <p class="t-secondary">This can't be undone.</p>
1751
+ <div class="row" data-gap="8" data-align="between">
1752
+ <button class="btn" value="cancel">Cancel</button>
1753
+ <button class="btn" data-variant="danger" value="confirm">Delete</button>
1754
+ </div>
1755
+ </form>
1756
+ </dialog> */
1757
+ /* Transition-based (not @keyframes) so CLOSING animates too: closing a
1758
+ native <dialog> just flips [open] off and lets the UA drop it from the
1759
+ top layer — a one-shot entrance @keyframes animation never runs in
1760
+ reverse for that. `display`/`overlay` transitions with allow-discrete
1761
+ keep the dialog rendered and interpolating right up until the moment it
1762
+ actually leaves the top layer, so close mirrors open instead of
1763
+ flashing away. Degrades gracefully (no animation, no flash of missing
1764
+ content) on engines without @starting-style/allow-discrete support. */
1765
+ .dialog {
1766
+ margin: auto; /* restore UA centering killed by the base reset */
1767
+ border: 0;
1768
+ padding: var(--pad);
1769
+ background: var(--surface);
1770
+ color: var(--text);
1771
+ border-radius: var(--r-card);
1772
+ inline-size: min(24rem, calc(100vw - 2 * var(--pad)));
1773
+ opacity: 1;
1774
+ transform: translateY(0) scale(1);
1775
+ transition:
1776
+ opacity var(--t-dialog) var(--ease),
1777
+ transform var(--t-dialog) var(--spring),
1778
+ overlay var(--t-dialog) allow-discrete,
1779
+ display var(--t-dialog) allow-discrete;
1780
+ }
1781
+ .dialog:not([open]) {
1782
+ opacity: 0;
1783
+ transform: translateY(var(--sp-16)) scale(.96);
1784
+ }
1785
+ @starting-style {
1786
+ .dialog[open] {
1787
+ opacity: 0;
1788
+ transform: translateY(var(--sp-16)) scale(.96);
1789
+ }
1790
+ }
1791
+
1792
+ .dialog::backdrop {
1793
+ background: transparent;
1794
+ transition:
1795
+ background-color var(--t-popover) var(--ease),
1796
+ overlay var(--t-popover) allow-discrete,
1797
+ display var(--t-popover) allow-discrete;
1798
+ }
1799
+ .dialog[open]::backdrop { background: var(--scrim); }
1800
+ @starting-style {
1801
+ .dialog[open]::backdrop { background: transparent; }
1802
+ }
1803
+
1804
+
1805
+ /* ================================= menu ====================================
1806
+ Popover-attribute menu on the 6px bg ring (never a shadow). Enter =
1807
+ 300ms spring transform + 220ms opacity (the sanctioned popover fade).
1808
+ js/menu.js anchors it to its trigger; without JS it opens centered —
1809
+ still fully usable (light-dismiss and Esc are platform behavior).
1810
+ <button class="btn" popovertarget="m1">Options</button>
1811
+ <div class="menu" id="m1" popover>
1812
+ <button class="menu-item">Rename</button>
1813
+ <button class="menu-item" data-danger>Delete</button>
1814
+ </div> */
1815
+ /* One shared foundation for every dropdown/popover this library ships —
1816
+ select, datepicker, colorswatches, context menu, the plain menu demo —
1817
+ so they read as one system instead of each inventing its own look.
1818
+ The 1px --surface-3 border is the sanctioned overlay exception to the
1819
+ zero-borders rule (DESIGN.md §2.9, v1.3): floating panels open over an
1820
+ unpredictable backdrop, where the --ring-popover bg-ring reads as a stray
1821
+ line — a subtle tonal border reads as intentional against any backdrop.
1822
+ The exception is overlays ONLY; on-page components stay borderless. */
1823
+ .menu {
1824
+ border: 1px solid var(--surface-3);
1825
+ padding: var(--sp-8);
1826
+ background: var(--surface);
1827
+ color: var(--text);
1828
+ border-radius: var(--r-card);
1829
+ }
1830
+ .menu:popover-open {
1831
+ animation:
1832
+ mtui-menu-pop var(--t-popover) var(--spring),
1833
+ mtui-menu-fade var(--t-micro) var(--ease);
1834
+ }
1835
+ @keyframes mtui-menu-pop {
1836
+ from { transform: translateY(calc(-1 * var(--sp-4))) scale(.98); }
1837
+ }
1838
+ @keyframes mtui-menu-fade {
1839
+ from { opacity: 0; }
1840
+ }
1841
+
1842
+ .menu-item {
1843
+ display: flex;
1844
+ align-items: center;
1845
+ gap: var(--sp-12);
1846
+ inline-size: 100%;
1847
+ min-block-size: var(--tap-min);
1848
+ padding-inline: var(--sp-12);
1849
+ border: 0;
1850
+ border-radius: var(--r-input);
1851
+ background: none;
1852
+ color: inherit;
1853
+ font-size: var(--fs-control);
1854
+ font-weight: var(--fw-control);
1855
+ text-align: start;
1856
+ cursor: pointer;
1857
+ -webkit-tap-highlight-color: transparent;
1858
+ transition: background-color var(--t-micro) var(--ease);
1859
+ }
1860
+ .menu-item:hover {
1861
+ background: var(--surface-2);
1862
+ background: color-mix(in srgb, var(--surface-2) 50%, transparent);
1863
+ }
1864
+ .menu-item:active { transform: scale(.98); }
1865
+ .menu-item[data-danger] { color: var(--danger-text); }
1866
+
1867
+
1868
+ /* ================================= tabs ====================================
1869
+ <x-tabs> (js/x-tabs.js, tier: Enhanced over Basic segmented) shows only
1870
+ the panel matching the checked radio. Without JS all panels render —
1871
+ functional Basic fallback.
1872
+ <x-tabs>
1873
+ <fieldset class="segmented">
1874
+ <label><input type="radio" name="t" value="general" checked><span>General</span></label>
1875
+ <label><input type="radio" name="t" value="privacy"><span>Privacy</span></label>
1876
+ </fieldset>
1877
+ <div data-panel="general">…</div>
1878
+ <div data-panel="privacy">…</div>
1879
+ </x-tabs> */
1880
+ x-tabs {
1881
+ display: flex;
1882
+ flex-direction: column;
1883
+ gap: var(--gap);
1884
+ }
1885
+
1886
+
1887
+ /* ================================= toast ===================================
1888
+ <x-toast> mount (js/x-toast.js, JS-only tier) + mtui.toast('Saved').
1889
+ Inverse pill dropping from top center (500ms spring), stack of 3,
1890
+ leaves by sliding up. Never carries the only copy of critical info. */
1891
+ x-toast {
1892
+ position: fixed;
1893
+ inset-block-start: 0;
1894
+ inset-inline: 0;
1895
+ z-index: 10; /* the only fixed layer MTUI creates — above sticky
1896
+ headers (z1) and any modest app chrome */
1897
+ display: flex;
1898
+ flex-direction: column;
1899
+ align-items: center;
1900
+ gap: var(--sp-8);
1901
+ padding-block-start: calc(var(--sp-16) + env(safe-area-inset-top, 0px));
1902
+ pointer-events: none;
1903
+ }
1904
+ .toast {
1905
+ pointer-events: auto;
1906
+ display: flex;
1907
+ align-items: center;
1908
+ gap: var(--sp-12);
1909
+ min-block-size: var(--ctl-h);
1910
+ padding-inline: var(--sp-20);
1911
+ max-inline-size: calc(100vw - 2 * var(--pad));
1912
+ background: var(--inverse);
1913
+ color: var(--on-inverse);
1914
+ border-radius: var(--r-ctl);
1915
+ font-size: var(--fs-control);
1916
+ font-weight: var(--fw-control);
1917
+ animation: mtui-toast-in var(--t-toast) var(--spring);
1918
+ }
1919
+ /* Same duration/easing as the entrance (DESIGN.md §2.12 lists toast as a
1920
+ single "500ms spring" row — using the snappier --t-knob/--ease here,
1921
+ over the SAME long travel distance, made the exit ~40% faster than the
1922
+ entrance covering identical ground: it read as flung rather than calm. */
1923
+ .toast[data-leaving] {
1924
+ animation: mtui-toast-out var(--t-toast) var(--spring) forwards;
1925
+ }
1926
+ /* Travel distance is an ABSOLUTE clearance, not the toast's own %height —
1927
+ a %-based translate only carries toast #2/#3 a short relative distance,
1928
+ so they visibly drop through/behind whatever already sits above them
1929
+ instead of falling from above the whole stack. -6×--sp-48 comfortably
1930
+ clears a full 3-toast stack (including a 2-line + action toast) plus
1931
+ top safe-area padding, regardless of which slot a toast lands in. */
1932
+ @keyframes mtui-toast-in {
1933
+ from { transform: translateY(calc(-6 * var(--sp-48))) scale(.92); }
1934
+ }
1935
+ @keyframes mtui-toast-out {
1936
+ to { transform: translateY(calc(-6 * var(--sp-48))) scale(.92); }
1937
+ }
1938
+ .toast-action {
1939
+ border: 0;
1940
+ background: none;
1941
+ color: inherit;
1942
+ font: inherit;
1943
+ font-weight: var(--fw-card);
1944
+ padding-inline: var(--sp-4);
1945
+ min-block-size: var(--tap-min);
1946
+ cursor: pointer;
1947
+ }
1948
+
1949
+
1950
+ /* ==================== interaction: no text selection =======================
1951
+ Anything clicked or toggled never selects its text — a double-click that
1952
+ highlights a label reads as broken. */
1953
+ .btn, .chip, .chip-x, a.item, button.item,
1954
+ .segmented, .accordion summary, .avatar, .avatar-stack,
1955
+ .badge, .field .t-label, .empty-icon,
1956
+ .menu-item, .toast {
1957
+ -webkit-user-select: none;
1958
+ user-select: none;
1959
+ }
1960
+
1961
+ /* Rows wrapping a toggle. Kept as separate rules: an engine without :has()
1962
+ drops the whole selector list it appears in, and must not lose the block
1963
+ above. */
1964
+ label:has(.switch),
1965
+ label:has(.checkbox),
1966
+ label:has(.radio) {
1967
+ -webkit-user-select: none;
1968
+ user-select: none;
1969
+ }
1970
+
1971
+
1972
+ /* ================================ x-select ==================================
1973
+ Tier: Enhanced over Basic .select (native <select> — see js/x-select.js).
1974
+ The native select is hidden (kept as value holder + no-JS fallback);
1975
+ this trigger + panel take over as the visible face. */
1976
+ .select-trigger {
1977
+ position: relative;
1978
+ display: flex;
1979
+ align-items: center;
1980
+ inline-size: 100%;
1981
+ min-block-size: var(--field-h);
1982
+ padding-inline: var(--sp-16);
1983
+ padding-inline-end: var(--sp-40);
1984
+ border: 0;
1985
+ border-radius: var(--r-input);
1986
+ background: var(--surface-2);
1987
+ color: var(--text);
1988
+ font-size: var(--fs-body);
1989
+ line-height: var(--lh-body);
1990
+ text-align: start;
1991
+ cursor: pointer;
1992
+ -webkit-tap-highlight-color: transparent;
1993
+ transition: background-color var(--t-micro) var(--ease);
1994
+ }
1995
+ .select-trigger:hover { background: var(--surface-3); }
1996
+ .select-trigger::after {
1997
+ content: "";
1998
+ position: absolute;
1999
+ inset-block: 0;
2000
+ inset-inline-end: var(--sp-16);
2001
+ margin-block: auto;
2002
+ inline-size: var(--sp-16);
2003
+ block-size: var(--sp-16);
2004
+ background: var(--text-muted);
2005
+ -webkit-mask: var(--icon-chevron-down) center / contain no-repeat;
2006
+ mask: var(--icon-chevron-down) center / contain no-repeat;
2007
+ transition: transform var(--t-micro) var(--ease);
2008
+ }
2009
+ .select-trigger[aria-expanded="true"]::after { transform: rotate(180deg); }
2010
+ .select-trigger:disabled { color: var(--text-faint); cursor: default; }
2011
+
2012
+ .select-panel {
2013
+ max-block-size: 18rem;
2014
+ overflow-y: auto;
2015
+ }
2016
+ .select-option[aria-selected="true"] {
2017
+ background: var(--accent-tint);
2018
+ color: var(--accent-text);
2019
+ }
2020
+ .select-option:disabled { color: var(--text-faint); cursor: default; }
2021
+
2022
+
2023
+ /* ============================== x-datepicker =================================
2024
+ Tier: Enhanced over Basic native <input type="date"> (js/x-datepicker.js).
2025
+ Single date only — no ranges, no times (kept out of v1 scope). */
2026
+ .datepicker-panel {
2027
+ padding: var(--sp-8);
2028
+ inline-size: 20rem;
2029
+ }
2030
+ .datepicker-header {
2031
+ display: flex;
2032
+ align-items: center;
2033
+ gap: var(--sp-8);
2034
+ padding: var(--sp-8);
2035
+ }
2036
+ .datepicker-header-label {
2037
+ flex: 1;
2038
+ border: 0;
2039
+ background: none;
2040
+ border-radius: var(--r-input);
2041
+ padding-block: var(--sp-4);
2042
+ text-align: center;
2043
+ font: inherit;
2044
+ font-size: var(--fs-control);
2045
+ font-weight: var(--fw-control);
2046
+ color: inherit;
2047
+ cursor: pointer;
2048
+ -webkit-tap-highlight-color: transparent;
2049
+ transition: background-color var(--t-micro) var(--ease);
2050
+ }
2051
+ .datepicker-header-label:hover { background: var(--surface-2); }
2052
+ .datepicker-nav {
2053
+ display: flex;
2054
+ align-items: center;
2055
+ justify-content: center;
2056
+ inline-size: var(--tap-min);
2057
+ block-size: var(--tap-min);
2058
+ border: 0;
2059
+ border-radius: 50%;
2060
+ background: none;
2061
+ color: var(--text-muted);
2062
+ cursor: pointer;
2063
+ -webkit-tap-highlight-color: transparent;
2064
+ transition: background-color var(--t-micro) var(--ease);
2065
+ }
2066
+ .datepicker-nav:hover { background: var(--surface-2); }
2067
+ .datepicker-nav::after {
2068
+ content: "";
2069
+ inline-size: var(--sp-16);
2070
+ block-size: var(--sp-16);
2071
+ background: currentColor;
2072
+ -webkit-mask: var(--icon-chevron-down) center / contain no-repeat;
2073
+ mask: var(--icon-chevron-down) center / contain no-repeat;
2074
+ transform: rotate(90deg);
2075
+ }
2076
+ .datepicker-nav[data-dir="next"]::after { transform: rotate(-90deg); }
2077
+ [dir="rtl"] .datepicker-nav::after { transform: rotate(-90deg); }
2078
+ [dir="rtl"] .datepicker-nav[data-dir="next"]::after { transform: rotate(90deg); }
2079
+
2080
+ /* :not([hidden]) — an unconditional `display` here would override the
2081
+ [hidden] attribute's UA display:none at equal specificity, same trap as
2082
+ the swatches-panel popover fix above. */
2083
+ .datepicker-weekdays:not([hidden]),
2084
+ .datepicker-grid[data-view="days"] {
2085
+ display: grid;
2086
+ grid-template-columns: repeat(7, 1fr);
2087
+ gap: 2px; /* 1px-scale internal detail (sanctioned) — tight day grid */
2088
+ }
2089
+ .datepicker-grid[data-view="months"] {
2090
+ display: grid;
2091
+ grid-template-columns: repeat(4, 1fr);
2092
+ gap: var(--sp-8);
2093
+ padding-block: var(--sp-8);
2094
+ }
2095
+ .datepicker-grid[data-view="years"] {
2096
+ display: flex;
2097
+ flex-direction: column;
2098
+ gap: 2px;
2099
+ max-block-size: 14rem;
2100
+ overflow-y: auto;
2101
+ scrollbar-width: thin;
2102
+ }
2103
+ .datepicker-weekdays > span {
2104
+ display: flex;
2105
+ align-items: center;
2106
+ justify-content: center;
2107
+ block-size: var(--sp-32);
2108
+ color: var(--text-muted);
2109
+ font-size: var(--fs-label);
2110
+ font-weight: var(--fw-label);
2111
+ }
2112
+ .datepicker-day {
2113
+ display: flex;
2114
+ align-items: center;
2115
+ justify-content: center;
2116
+ aspect-ratio: 1;
2117
+ border: 0;
2118
+ border-radius: 50%;
2119
+ background: none;
2120
+ color: var(--text);
2121
+ font-size: var(--fs-secondary);
2122
+ cursor: pointer;
2123
+ -webkit-tap-highlight-color: transparent;
2124
+ transition: background-color var(--t-micro) var(--ease);
2125
+ }
2126
+ .datepicker-day:hover { background: var(--surface-2); }
2127
+ .datepicker-day[data-today] { color: var(--accent-text); font-weight: var(--fw-control); }
2128
+ .datepicker-day[aria-selected="true"] { background: var(--accent); color: var(--on-accent); }
2129
+ .datepicker-day:disabled {
2130
+ color: var(--text-faint);
2131
+ cursor: default;
2132
+ pointer-events: none;
2133
+ }
2134
+ .datepicker-day[data-outside] { color: var(--text-faint); }
2135
+
2136
+ .datepicker-month {
2137
+ aspect-ratio: auto;
2138
+ min-block-size: var(--tap-min);
2139
+ border-radius: var(--r-input);
2140
+ font-size: var(--fs-control);
2141
+ font-weight: var(--fw-control);
2142
+ }
2143
+ .datepicker-year {
2144
+ aspect-ratio: auto;
2145
+ inline-size: 100%;
2146
+ min-block-size: var(--tap-min);
2147
+ border-radius: var(--r-input);
2148
+ font-size: var(--fs-control);
2149
+ font-weight: var(--fw-control);
2150
+ flex-shrink: 0;
2151
+ }
2152
+ .datepicker-footer {
2153
+ display: flex;
2154
+ justify-content: center;
2155
+ padding-block-start: var(--sp-8);
2156
+ }
2157
+
2158
+
2159
+ /* ============================ x-colorswatches ================================
2160
+ Tier: Enhanced over Basic native <input type="color"> (js/x-colorswatches.js).
2161
+ Curated swatches only — never a free color wheel. */
2162
+ .swatches-trigger {
2163
+ position: relative;
2164
+ display: flex;
2165
+ align-items: center;
2166
+ gap: var(--sp-12);
2167
+ inline-size: 100%;
2168
+ min-block-size: var(--field-h);
2169
+ padding-inline: var(--sp-16);
2170
+ border: 0;
2171
+ border-radius: var(--r-input);
2172
+ background: var(--surface-2);
2173
+ color: var(--text);
2174
+ font-size: var(--fs-body);
2175
+ cursor: pointer;
2176
+ -webkit-tap-highlight-color: transparent;
2177
+ transition: background-color var(--t-micro) var(--ease);
2178
+ }
2179
+ .swatches-trigger:hover { background: var(--surface-3); }
2180
+ .swatches-trigger-swatch {
2181
+ inline-size: var(--sp-24);
2182
+ block-size: var(--sp-24);
2183
+ border-radius: 50%;
2184
+ flex-shrink: 0;
2185
+ }
2186
+
2187
+ .swatches-panel {
2188
+ /* display is scoped to :popover-open — an unconditional `display: grid`
2189
+ here would override the UA's default "hidden until shown" popover
2190
+ rule (author styles win over the UA stylesheet at equal specificity),
2191
+ leaving the panel permanently visible even when closed. */
2192
+ padding: var(--sp-12);
2193
+ }
2194
+ .swatches-panel:popover-open {
2195
+ display: grid;
2196
+ grid-template-columns: repeat(5, 1fr);
2197
+ gap: var(--sp-8);
2198
+ }
2199
+ .swatch {
2200
+ position: relative;
2201
+ inline-size: var(--sp-32);
2202
+ block-size: var(--sp-32);
2203
+ border: 0;
2204
+ border-radius: 50%;
2205
+ cursor: pointer;
2206
+ -webkit-tap-highlight-color: transparent;
2207
+ transition: transform var(--t-micro) var(--ease);
2208
+ }
2209
+ .swatch:hover { transform: scale(1.08); }
2210
+ .swatch[aria-checked="true"] {
2211
+ box-shadow: 0 0 0 2px var(--surface), 0 0 0 4px currentColor;
2212
+ }
2213
+ .swatch::after {
2214
+ content: "";
2215
+ position: absolute;
2216
+ inset: 0;
2217
+ margin: auto;
2218
+ inline-size: 60%;
2219
+ block-size: 60%;
2220
+ background: var(--swatch-check-color, var(--on-accent));
2221
+ -webkit-mask: var(--icon-check) center / contain no-repeat;
2222
+ mask: var(--icon-check) center / contain no-repeat;
2223
+ opacity: 0;
2224
+ transition: opacity var(--t-micro) var(--ease);
2225
+ }
2226
+ .swatch[aria-checked="true"]::after { opacity: 1; }
2227
+
2228
+
2229
+ /* ============================= x-contextmenu ==================================
2230
+ Tier: Enhanced. Wraps any region; opens an MTUI menu (reuses .menu) on
2231
+ right-click or long-press (js/x-contextmenu.js). No-JS: the browser's
2232
+ native context menu appears — content itself is unaffected. */
2233
+ x-contextmenu { display: contents; }
2234
+
2235
+
2236
+ /* =========================== pull-to-refresh ================================
2237
+ Tier: Enhanced over .screen-list (js/pull-refresh.js). No-JS: no
2238
+ affordance, the list scrolls normally. */
2239
+ x-pull-refresh {
2240
+ display: block;
2241
+ block-size: 100%;
2242
+ overflow: hidden;
2243
+ }
2244
+ x-pull-refresh > .screen-list,
2245
+ x-pull-refresh > [data-pull-scroll] {
2246
+ block-size: 100%;
2247
+ overflow-y: auto;
2248
+ /* it's a drag target (mouse or touch) — dragging shouldn't select the
2249
+ row text underneath. */
2250
+ -webkit-user-select: none;
2251
+ user-select: none;
2252
+ }
2253
+ .pull-indicator {
2254
+ position: absolute;
2255
+ inset-inline: 0;
2256
+ inset-block-start: 0;
2257
+ display: flex;
2258
+ align-items: center;
2259
+ justify-content: center;
2260
+ block-size: var(--sp-48);
2261
+ margin-block-start: calc(-1 * var(--sp-48));
2262
+ color: var(--text-muted);
2263
+ pointer-events: none;
2264
+ }
2265
+ .pull-indicator::after {
2266
+ content: "";
2267
+ inline-size: var(--sp-20);
2268
+ block-size: var(--sp-20);
2269
+ background: currentColor;
2270
+ -webkit-mask: var(--icon-loader) center / contain no-repeat;
2271
+ mask: var(--icon-loader) center / contain no-repeat;
2272
+ }
2273
+ .pull-indicator[data-spinning]::after {
2274
+ animation: mtui-spin var(--t-skeleton) linear infinite;
2275
+ }
2276
+
2277
+
2278
+ /* ============================== x-theme =====================================
2279
+ Tier: JS-only (js/x-theme.js). An in-app appearance controller — the
2280
+ Telegram "Appearance" panel, MTUI-native: flip theme, tap an accent, set
2281
+ corners + density. Writes only to <html> knobs (root-only rule) and
2282
+ persists to localStorage. Built from canonical .segmented groups plus a
2283
+ row of accent dots. No-JS: empty (the app keeps its current/persisted
2284
+ theme; an in-app switcher inherently needs JS). */
2285
+ x-theme { display: block; }
2286
+ .theme-group + .theme-group { margin-block-start: var(--gap); }
2287
+ .theme-group > .t-label {
2288
+ display: block;
2289
+ margin-block-end: var(--sp-8);
2290
+ color: var(--text-muted);
2291
+ }
2292
+ .theme-accents {
2293
+ display: flex;
2294
+ gap: var(--sp-8);
2295
+ flex-wrap: wrap;
2296
+ }
2297
+ .theme-accent {
2298
+ position: relative;
2299
+ display: grid;
2300
+ place-items: center;
2301
+ inline-size: var(--tap-min);
2302
+ block-size: var(--tap-min);
2303
+ cursor: pointer;
2304
+ -webkit-tap-highlight-color: transparent;
2305
+ }
2306
+ .theme-accent input {
2307
+ appearance: none;
2308
+ position: absolute;
2309
+ inset: 0;
2310
+ margin: 0;
2311
+ cursor: pointer;
2312
+ border-radius: 50%;
2313
+ }
2314
+ .theme-accent-dot {
2315
+ position: relative;
2316
+ inline-size: var(--sp-32);
2317
+ block-size: var(--sp-32);
2318
+ border-radius: 50%;
2319
+ background: var(--dot);
2320
+ pointer-events: none;
2321
+ transition: transform var(--t-micro) var(--ease);
2322
+ }
2323
+ .theme-accent:hover .theme-accent-dot { transform: translateY(-2px); }
2324
+ .theme-accent input:active + .theme-accent-dot { transform: scale(.96); }
2325
+ .theme-accent input:focus-visible + .theme-accent-dot { box-shadow: var(--ring-focus); }
2326
+ /* Selected = white check inside the dot (all accents pass white contrast). */
2327
+ .theme-accent input:checked + .theme-accent-dot::after {
2328
+ content: "";
2329
+ position: absolute;
2330
+ inset: 22%;
2331
+ background: var(--on-accent);
2332
+ -webkit-mask: var(--icon-check) center / contain no-repeat;
2333
+ mask: var(--icon-check) center / contain no-repeat;
2334
+ }
2335
+
2336
+ /* Shared: none of the Enhanced trigger/option surfaces select their text.
2337
+ General rule: anything that's a click-toggle, drag, or long-press
2338
+ target gets user-select:none — dragging/pressing shouldn't leave a
2339
+ text selection behind, which reads as broken. */
2340
+ .select-trigger, .select-option, .datepicker-day, .datepicker-nav,
2341
+ .datepicker-header-label, .swatches-trigger, .swatch, x-contextmenu,
2342
+ .theme-accent {
2343
+ -webkit-user-select: none;
2344
+ user-select: none;
2345
+ }
2346
+
2347
+
2348
+ /* ============================ forced colors ================================
2349
+ Tonal separation disappears — every component draws real borders. */
2350
+ @media (forced-colors: active) {
2351
+ .btn, .chip, .badge, .avatar, .segmented, .alert, .card,
2352
+ .field input, .field select, .field textarea,
2353
+ .switch, .checkbox, .radio, .accordion, .code, .code-inline,
2354
+ .skeleton, .empty-icon {
2355
+ border: 1px solid ButtonBorder;
2356
+ }
2357
+ /* Rows separate by tone only — give them a real divider in forced colors. */
2358
+ .item { border-block-end: 1px solid CanvasText; }
2359
+ .switch::before, .radio:checked::before { background: ButtonText; }
2360
+ .switch:checked, .checkbox:checked, .radio:checked {
2361
+ background: SelectedItem;
2362
+ }
2363
+ .segmented input:checked + span { border: 1px solid ButtonBorder; }
2364
+ .segmented::before { display: none; }
2365
+ .btn[data-loading]::after,
2366
+ .select::after,
2367
+ .accordion summary::after,
2368
+ .checkbox::before {
2369
+ forced-color-adjust: none;
2370
+ background: ButtonText;
2371
+ }
2372
+ .checkbox:checked::before { background: SelectedItemText; }
2373
+ .skeleton::after { display: none; }
2374
+ .dialog, .menu, .menu-item, .toast { border: 1px solid CanvasText; }
2375
+
2376
+ .select-trigger, .swatches-trigger, .datepicker-panel, .datepicker-day,
2377
+ .swatch {
2378
+ border: 1px solid ButtonBorder;
2379
+ }
2380
+ .select-trigger::after, .datepicker-nav::after, .pull-indicator::after {
2381
+ forced-color-adjust: none;
2382
+ background: ButtonText;
2383
+ }
2384
+ .select-option[aria-selected="true"],
2385
+ .datepicker-day[aria-selected="true"] { background: SelectedItem; }
2386
+ .swatch[aria-checked="true"] { box-shadow: none; outline: 2px solid ButtonText; }
2387
+ .swatch::after { forced-color-adjust: none; background: ButtonText; }
2388
+
2389
+ /* x-theme: dots lose their tonal fill — draw a border, mark the checked
2390
+ one with an outline + keep the check glyph visible. */
2391
+ .theme-accent-dot { forced-color-adjust: none; border: 1px solid ButtonBorder; }
2392
+ .theme-accent input:checked + .theme-accent-dot { outline: 2px solid ButtonText; }
2393
+ .theme-accent input:checked + .theme-accent-dot::after { background: ButtonText; }
2394
+ }