@n8mills/design-tokens 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/components.css ADDED
@@ -0,0 +1,943 @@
1
+ /* =========================================================================
2
+ portfolio / components.css
3
+ ---------------------------------------------------------------------------
4
+ The component layer of the three-tier token system. Reads SEMANTIC tokens
5
+ from tokens.css (--color-*, --brand-*, --font-*, --space-*, --radius-*,
6
+ --duration-*) and never raw primitives (--neutral-*, --green-*).
7
+
8
+ Every component below has:
9
+ - A base class with semantic naming.
10
+ - Optional modifier classes for variants.
11
+ - A short comment block explaining the role and any unusual decision.
12
+ - A live sample in the under-the-hood "Components" section.
13
+
14
+ See docs/design-system.md section 9 for the component inventory and
15
+ contribution rules. See tokens.css for the primitive + semantic layers.
16
+ ========================================================================= */
17
+
18
+
19
+ /* -----------------------------------------------------------------------------
20
+ Button
21
+ ---
22
+ Variants:
23
+ .btn base, never used alone; the default 44px size + transition
24
+ .btn--primary lime fill, ink glyph, brand-primary CTA
25
+ .btn--secondary transparent fill, ink border, secondary CTA
26
+ .btn--icon round, single icon glyph, hairline border
27
+ Sizes (opt-in modifiers that wire the --btn-* tokens; base .btn equals md):
28
+ .btn--sm 32px compact: dense cards, toolbars, filters (WCAG 2.5.8 AA)
29
+ .btn--md 44px the default min-height, same as base .btn (WCAG 2.5.5 AAA);
30
+ note its padding is the tokenised 8px/16px while base .btn
31
+ keeps 10px/18px, so the class is not a strict no-op (DC-03)
32
+ .btn--lg 52px hero CTAs, primary conversion (WCAG 2.5.5 AAA)
33
+ States: hover lifts via translateY(-1px) on primary, lime tint on
34
+ icon; :active is the touch-visible press (background step, no transform,
35
+ hover-independent); :disabled dims and blocks the pointer;
36
+ prefers-reduced-motion collapses the transform. Hover fills are gated
37
+ behind (hover: hover) because Chromium sticks :hover after a tap;
38
+ :focus-visible keeps ungated duplicates so keyboard styling never
39
+ changes.
40
+ ----------------------------------------------------------------------------- */
41
+ .btn {
42
+ appearance: none;
43
+ box-sizing: border-box;
44
+ display: inline-flex;
45
+ align-items: center;
46
+ justify-content: center;
47
+ gap: var(--btn-gap);
48
+ min-height: var(--touch-target-min);
49
+ padding: var(--btn-pad-y) var(--btn-pad-x);
50
+ font-family: var(--btn-font);
51
+ font-size: var(--btn-font-size);
52
+ font-weight: var(--btn-font-weight);
53
+ line-height: 1;
54
+ white-space: nowrap;
55
+ border-radius: var(--btn-radius);
56
+ border: 1px solid transparent;
57
+ cursor: pointer;
58
+ text-decoration: none;
59
+ transition: var(--btn-transition);
60
+ }
61
+ /* M-07: btn focus uses a positive 2px offset (--focus-ring-offset, +2px) so
62
+ the ring sits OUTSIDE the button border instead of inset (an inset ring was
63
+ hard to see on small icon buttons and clipped behind taller adjacent
64
+ content). Contexts that legitimately want an INSET ring (carousel tabs,
65
+ gallery thumbs, ap2-flink) set a literal outline-offset: -2px, not this
66
+ token. */
67
+ .btn:focus-visible {
68
+ outline: 2px solid var(--btn-focus-ring);
69
+ outline-offset: var(--focus-ring-offset);
70
+ }
71
+ .btn--primary {
72
+ background: var(--btn-primary-bg);
73
+ color: var(--btn-primary-fg);
74
+ border-color: var(--btn-primary-border);
75
+ }
76
+ /* Hover gated to hover-capable pointers: on touch, a tap applies and
77
+ STICKS :hover (Chromium), freezing the ink fill on and swallowing the
78
+ press cue below. The :focus-visible duplicate stays ungated so
79
+ keyboard users keep the exact same styling (Tesla review Group 4). */
80
+ @media (hover: hover) {
81
+ .btn--primary:hover {
82
+ background: var(--btn-primary-bg-hover);
83
+ color: var(--btn-primary-fg-hover);
84
+ border-color: var(--btn-primary-border);
85
+ transform: translateY(-1px);
86
+ }
87
+ }
88
+ .btn--primary:focus-visible {
89
+ background: var(--btn-primary-bg-hover);
90
+ color: var(--btn-primary-fg-hover);
91
+ border-color: var(--btn-primary-border);
92
+ transform: translateY(-1px);
93
+ }
94
+ /* Touch-visible press: distinct from BOTH the lime rest and the ink
95
+ hover. Background step only, transform none: primary CTAs sit inside
96
+ the Featured scroll-snap track, where a swipe-start brushes :active
97
+ and a transform would flash mid-swipe. Declared after hover/focus so
98
+ it wins while they apply. */
99
+ .btn--primary:active {
100
+ background: var(--brand-lime-dim);
101
+ color: var(--brand-ink);
102
+ border-color: var(--btn-primary-border);
103
+ transform: none;
104
+ }
105
+ .btn--secondary {
106
+ background: transparent;
107
+ color: var(--btn-secondary-fg);
108
+ border-color: var(--btn-secondary-border);
109
+ }
110
+ @media (hover: hover) {
111
+ .btn--secondary:hover {
112
+ background: var(--btn-secondary-bg-hover);
113
+ border-color: var(--btn-secondary-border-hover);
114
+ }
115
+ }
116
+ .btn--secondary:focus-visible {
117
+ background: var(--btn-secondary-bg-hover);
118
+ border-color: var(--btn-secondary-border-hover);
119
+ }
120
+ /* Press: one visible background step past the sunken hover (the press
121
+ grammar the old mobile-nav link used). */
122
+ .btn--secondary:active {
123
+ background: var(--color-border);
124
+ border-color: var(--btn-secondary-border-hover);
125
+ }
126
+ /* Task #87: .btn--icon border bumped to --color-text-tertiary so it meets
127
+ WCAG 2.2 SC 1.4.11 (3:1 non-text contrast minimum against bg).
128
+ --color-border (~1.27:1) and --color-border-strong (~1.48:1) both fail.
129
+ --color-text-tertiary (--neutral-600 in light) gives ~5:1 on --color-bg.
130
+ Auto-flips appropriately in dark mode via the token system. The icon
131
+ itself is at ~10:1, so the border + icon together clearly demarcate
132
+ each control. */
133
+ /* Round is the canonical icon-button shape: every real consumer (header theme /
134
+ menu / back controls, and the footer social marks) is a circle. This used to
135
+ default to a rounded square that rendered nowhere but the under-the-hood demo,
136
+ with a #site-header override supplying the circle. The circle now lives on the
137
+ component itself, so there is one source of truth and the demo cannot drift. */
138
+ .btn--icon {
139
+ width: var(--touch-target-min);
140
+ height: var(--touch-target-min);
141
+ padding: 0;
142
+ border-radius: var(--btn-radius);
143
+ border-color: var(--btn-icon-border);
144
+ background: transparent;
145
+ color: var(--btn-icon-fg);
146
+ }
147
+ /* Mobile button rule (design-system.md section 9, Button behaviour).
148
+ Section-level primary CTAs (featured-card Read the case study / View on
149
+ Figma, the under-the-hood teaser Explore the design system, and the
150
+ accessibility-card See the full design system) expand to full width on
151
+ mobile for a thumb-friendly tap area. Inline buttons (in-flow inside
152
+ paragraphs) stay auto-width. This list is the single registry the mobile
153
+ full-width gate checks (portfolio/tokens/mobile-consistency-lint.mjs); add
154
+ a new section CTA here and to that gate's registry together. */
155
+ @media (max-width: 768px) {
156
+ .feat-cta-corner .btn,
157
+ .dsc-cta,
158
+ .ap2-cta {
159
+ width: 100%;
160
+ justify-content: center;
161
+ }
162
+ }
163
+ @media (hover: hover) {
164
+ .btn--icon:hover {
165
+ background: var(--btn-primary-bg);
166
+ color: var(--btn-primary-fg);
167
+ border-color: var(--btn-primary-border);
168
+ }
169
+ }
170
+ .btn--icon:focus-visible {
171
+ background: var(--btn-primary-bg);
172
+ color: var(--btn-primary-fg);
173
+ border-color: var(--btn-primary-border);
174
+ }
175
+ /* Press: lime-dim step so the tap reads even where the lime hover fill
176
+ never fires (touch). Scoped colour: the dark-header override in
177
+ sections.css carries the matching :active companion. */
178
+ .btn--icon:active {
179
+ background: var(--brand-lime-dim);
180
+ color: var(--btn-primary-fg);
181
+ border-color: var(--btn-primary-border);
182
+ }
183
+ /* Shared icon sizing for Lucide glyphs inside any .btn (arrow, external-link,
184
+ download). Keeps icon-plus-text CTAs consistent without per-CTA overrides. */
185
+ .btn i,
186
+ .btn svg {
187
+ width: var(--btn-glyph);
188
+ height: var(--btn-glyph);
189
+ flex-shrink: 0;
190
+ }
191
+ /* Size modifiers. Wire the --btn-* tokens. Base .btn is the 44px default (= md),
192
+ so unmodified buttons are unchanged; opt in to --sm for dense UI or --lg for
193
+ hero CTAs. min-height sets the height, padding scales the hit area, and the
194
+ glyph nudges down at --sm and up at --lg. Every size clears WCAG 2.5.8 AA
195
+ (24px); md and lg also clear the 2.5.5 AAA 44px enhanced target. */
196
+ .btn--sm { min-height: var(--btn-sm); padding: var(--space-1) var(--space-3); font-size: var(--size-xs); gap: var(--space-1); }
197
+ .btn--md { min-height: var(--btn-md); padding: var(--space-2) var(--space-4); font-size: var(--size-sm); }
198
+ .btn--lg { min-height: var(--btn-lg); padding: var(--space-3) var(--space-5); font-size: var(--size-base); }
199
+ .btn--sm i,
200
+ .btn--sm svg { width: var(--btn-glyph-sm); height: var(--btn-glyph-sm); }
201
+ .btn--lg i,
202
+ .btn--lg svg { width: var(--btn-glyph-lg); height: var(--btn-glyph-lg); }
203
+ /* Icon-only buttons stay 1:1 at every size; the full radius keeps them circular. */
204
+ .btn--icon.btn--sm { width: var(--btn-sm); height: var(--btn-sm); padding: 0; }
205
+ .btn--icon.btn--md { width: var(--btn-md); height: var(--btn-md); padding: 0; }
206
+ .btn--icon.btn--lg { width: var(--btn-lg); height: var(--btn-lg); padding: 0; }
207
+
208
+ /* Disabled. A real resting state (previously unstyled): dimmed, no pointer, no
209
+ hover lift. Disabled controls are exempt from WCAG 1.4.3 contrast, so the
210
+ dimmed glyph is acceptable. Covers <button disabled> and the .is-disabled
211
+ hook for elements (links) that cannot carry the native attribute. */
212
+ .btn:disabled,
213
+ .btn[disabled],
214
+ .btn.is-disabled {
215
+ opacity: 0.45;
216
+ cursor: not-allowed;
217
+ pointer-events: none;
218
+ transform: none;
219
+ }
220
+ @media (prefers-reduced-motion: reduce) {
221
+ .btn { transition: none; }
222
+ .btn--primary:hover,
223
+ .btn--primary:focus-visible { transform: none; }
224
+ }
225
+
226
+
227
+ /* -----------------------------------------------------------------------------
228
+ Card
229
+ ---
230
+ .card base, hairline border, soft radius, off-white surface
231
+ .card--dark dark-surface variant for dark slabs (ink bg, on-ink text)
232
+ ----------------------------------------------------------------------------- */
233
+ .card {
234
+ background: var(--card-bg);
235
+ border: var(--card-border);
236
+ border-radius: var(--card-radius);
237
+ padding: var(--card-padding);
238
+ color: var(--card-fg);
239
+ }
240
+
241
+ .card--dark {
242
+ background: var(--card-dark-bg);
243
+ color: var(--card-dark-fg);
244
+ border-color: var(--card-dark-border);
245
+ }
246
+
247
+ /* -----------------------------------------------------------------------------
248
+ Card variants (W48)
249
+ ---
250
+ The frame (surface, border, radius, padding) always renders; variants change
251
+ only the inset or the inner content. Hover lives ONLY on .card--interactive:
252
+ static cards keep the resting outline (the card hover taxonomy; guarded by
253
+ tokens/card-surface-lint.mjs rule 4).
254
+ .card--compact / --spacious padding tiers (16 / 32; base is standard 24)
255
+ .card--interactive clickable: pair with a real <a>/<button>
256
+ .card--empty / --error a centred message, frame still renders
257
+ .card--loading a skeleton matching card proportions
258
+ ----------------------------------------------------------------------------- */
259
+
260
+ /* A card rendered as a link keeps the card's own foreground (so a theme-pinned
261
+ card stays readable) and drops the underline. Using `inherit` here let the link
262
+ pull the page text colour instead, which failed contrast on a light-pinned card
263
+ in dark mode (.card--interactive title/body, caught by the WCAG gate). */
264
+ a.card { color: var(--card-fg); text-decoration: none; }
265
+
266
+ /* Padding tiers: the --card-pad-* tokens as a modifier API (base = standard). */
267
+ .card--compact { padding: var(--card-pad-compact); }
268
+ .card--spacious { padding: var(--card-pad-spacious); }
269
+
270
+ /* Interactive: the one clickable-card hover, generalised from the role /
271
+ testimonial hover. Border strengthens; the card lifts 2px on a fine pointer,
272
+ dropped under reduced motion. */
273
+ .card--interactive {
274
+ cursor: pointer;
275
+ transition: border-color var(--duration-base) var(--ease-default),
276
+ transform var(--duration-base) var(--ease-default);
277
+ }
278
+ .card--interactive:hover,
279
+ .card--interactive:focus-within { border-color: var(--color-border-strong); }
280
+ .card--interactive:focus-visible { outline: var(--border-focus); outline-offset: 3px; }
281
+ /* Touch-visible press: background step, no transform (cards can sit in
282
+ scroll-snap tracks where a swipe-start brushes :active). The hover
283
+ above is border-only (no fill flip), so it stays ungated. */
284
+ .card--interactive:active {
285
+ border-color: var(--color-border-strong);
286
+ background: var(--color-surface-sunken);
287
+ }
288
+ @media (hover: hover) and (pointer: fine) {
289
+ .card--interactive:hover,
290
+ .card--interactive:focus-within { transform: translateY(-2px); }
291
+ }
292
+
293
+ /* Content states. Empty and error centre a message so the card is never a blank
294
+ box; loading shows a skeleton sized to the card. */
295
+ .card--empty,
296
+ .card--error {
297
+ display: flex;
298
+ flex-direction: column;
299
+ align-items: center;
300
+ justify-content: center;
301
+ gap: var(--space-2);
302
+ min-height: 132px;
303
+ text-align: center;
304
+ }
305
+ .card--empty i[data-lucide],
306
+ .card--error i[data-lucide] { width: 24px; height: 24px; color: var(--color-text-tertiary); }
307
+ .card-state-msg { margin: 0; font-size: var(--size-sm); color: var(--color-text-secondary); }
308
+ .card-state-hint { margin: 0; font-size: var(--text-caption); color: var(--color-text-tertiary); line-height: var(--line-body); }
309
+
310
+ /* Loading skeleton: a media block plus title and body lines on the sunken
311
+ surface. The shimmer sweep auto-disables under reduced motion, leaving static
312
+ blocks that still read as loading. */
313
+ .card--loading { display: flex; flex-direction: column; gap: var(--space-3); }
314
+ .card-skel {
315
+ position: relative;
316
+ overflow: hidden;
317
+ background: var(--color-surface-sunken);
318
+ border-radius: var(--radius-sm);
319
+ }
320
+ .card-skel--media { height: 92px; }
321
+ .card-skel--title { height: 18px; width: 55%; }
322
+ .card-skel--line { height: 12px; }
323
+ .card-skel--line.is-short { width: 78%; }
324
+ .card-skel::after {
325
+ content: "";
326
+ position: absolute;
327
+ inset: 0;
328
+ transform: translateX(-100%);
329
+ background: linear-gradient(90deg, transparent, rgb(var(--brand-ink-rgb) / 0.06), transparent);
330
+ animation: card-skel-sweep 1.4s var(--ease-default) infinite;
331
+ }
332
+ @keyframes card-skel-sweep { to { transform: translateX(100%); } }
333
+ @media (prefers-reduced-motion: reduce) {
334
+ .card-skel::after { display: none; }
335
+ }
336
+
337
+
338
+ /* -----------------------------------------------------------------------------
339
+ Chip / Tag
340
+ ---
341
+ Used for skill tags, technology badges, status pills. Smaller and lighter
342
+ than a button, no interactivity expected.
343
+ .chip ink ring on subtle bg
344
+ ----------------------------------------------------------------------------- */
345
+ .chip {
346
+ display: inline-flex;
347
+ align-items: center;
348
+ gap: var(--chip-gap);
349
+ padding: var(--chip-pad-y) var(--chip-pad-x);
350
+ font-family: var(--chip-font);
351
+ font-feature-settings: var(--font-feature-mono);
352
+ font-size: var(--chip-font-size);
353
+ line-height: var(--chip-line);
354
+ font-weight: var(--chip-font-weight);
355
+ color: var(--chip-fg);
356
+ background: var(--chip-bg);
357
+ border: 1px solid var(--chip-border);
358
+ border-radius: var(--chip-radius);
359
+ }
360
+ .chip i[data-lucide],
361
+ .chip svg[data-lucide],
362
+ .chip svg.lucide {
363
+ width: var(--chip-icon-size);
364
+ height: var(--chip-icon-size);
365
+ flex: 0 0 var(--chip-icon-size);
366
+ display: block;
367
+ color: var(--chip-fg);
368
+ stroke-width: 2;
369
+ }
370
+ /* Sandbox cherry-pick: normalizer wrapper for chips that need a raster or
371
+ SVG-image icon instead of a Lucide stroke icon (e.g. brand logos). Pair
372
+ it with `<span class="chip-icon"><img|svg ...></span>` so brand marks
373
+ render at the same 14x14 footprint as Lucide icons. */
374
+ .chip .chip-icon {
375
+ width: var(--chip-icon-size);
376
+ height: var(--chip-icon-size);
377
+ flex: 0 0 var(--chip-icon-size);
378
+ display: inline-flex;
379
+ align-items: center;
380
+ justify-content: center;
381
+ line-height: 0;
382
+ }
383
+ .chip .chip-icon > img,
384
+ .chip .chip-icon > svg {
385
+ width: var(--chip-icon-size);
386
+ height: var(--chip-icon-size);
387
+ display: block;
388
+ object-fit: contain;
389
+ }
390
+ .chip:hover {
391
+ border-color: var(--chip-border-hover);
392
+ }
393
+ /* (No .chip:active: every chip in the site is a non-interactive <span>
394
+ label; the old :active here was a visual no-op that read as a press
395
+ precedent. Removed in the Tesla-review pressed-state pass.) */
396
+
397
+ /* -----------------------------------------------------------------------------
398
+ Badge — a read-only status or count pill. Not interactive (no hover/focus).
399
+ ---
400
+ A badge reports a STATUS (In progress, Live at scale, In production) or a count;
401
+ a chip carries a LABEL. Quiet hairline outline, secondary mono text, sentence
402
+ case. An optional brand-lime status dot via .badge__dot, or a leading Lucide
403
+ icon. Consumed by the skills discipline statuses. (The hero "Available for work"
404
+ is a one-off, not a badge: it has no outline, just text and a pulsing dot.)
405
+ .badge hairline ring, quiet secondary text, mono, sentence case
406
+ .badge__dot leading brand-lime status dot
407
+ ----------------------------------------------------------------------------- */
408
+ .badge {
409
+ display: inline-flex;
410
+ align-items: center;
411
+ gap: var(--badge-gap);
412
+ padding: var(--badge-pad-y) var(--badge-pad-x);
413
+ font-family: var(--badge-font);
414
+ font-feature-settings: var(--font-feature-mono);
415
+ font-size: var(--badge-font-size);
416
+ font-weight: var(--badge-font-weight);
417
+ color: var(--badge-fg);
418
+ background: transparent;
419
+ border: 1px solid var(--badge-border);
420
+ border-radius: var(--badge-radius);
421
+ white-space: nowrap;
422
+ }
423
+ .badge i[data-lucide],
424
+ .badge svg[data-lucide],
425
+ .badge svg.lucide {
426
+ width: var(--badge-icon-size);
427
+ height: var(--badge-icon-size);
428
+ flex: 0 0 var(--badge-icon-size);
429
+ display: block;
430
+ color: var(--badge-fg);
431
+ stroke-width: 2;
432
+ }
433
+ .badge__dot {
434
+ width: var(--badge-dot-size);
435
+ height: var(--badge-dot-size);
436
+ flex: 0 0 var(--badge-dot-size);
437
+ border-radius: var(--radius-full);
438
+ background: var(--badge-dot);
439
+ }
440
+
441
+
442
+ /* -----------------------------------------------------------------------------
443
+ Avatar
444
+ ---
445
+ .avatar round, sized via --size class, lime ring on hover
446
+ ----------------------------------------------------------------------------- */
447
+ .avatar {
448
+ display: inline-flex;
449
+ align-items: center;
450
+ justify-content: center;
451
+ width: var(--touch-target-min);
452
+ height: var(--touch-target-min);
453
+ border-radius: 50%;
454
+ overflow: hidden;
455
+ background: var(--color-surface-sunken);
456
+ border: 2px solid var(--color-border);
457
+ flex-shrink: 0;
458
+ transition: border-color var(--duration-fast) var(--ease-default),
459
+ transform var(--duration-fast) var(--ease-default);
460
+ }
461
+ .avatar img { width: 100%; height: 100%; object-fit: cover; display: block; }
462
+ .avatar:hover,
463
+ .avatar:focus-visible {
464
+ border-color: var(--color-border-brand);
465
+ transform: scale(1.04);
466
+ }
467
+
468
+
469
+ /* -----------------------------------------------------------------------------
470
+ Eyebrow + SectionHead triplet
471
+ ---
472
+ Every top-level section uses the same heading pattern:
473
+ p.section-label : "Eyebrow text"
474
+ h2.section-title : "Main heading."
475
+ p.section-subtitle : "Optional supporting line."
476
+ (Audit C-07: example markup re-expressed as selector + text so no
477
+ stray placeholder h2 appears in the source.)
478
+ Light and dark surfaces share the same scale and weight; only colours
479
+ flip via .section-dark on the parent or the element itself.
480
+ ----------------------------------------------------------------------------- */
481
+ .section-label {
482
+ font-family: var(--font-mono);
483
+ font-feature-settings: var(--font-feature-mono);
484
+ font-size: var(--text-label);
485
+ font-weight: var(--weight-semibold);
486
+ text-transform: uppercase;
487
+ letter-spacing: var(--tracking-eyebrow);
488
+ color: var(--color-text-tertiary);
489
+ margin: 0 0 var(--space-2);
490
+ display: inline-flex;
491
+ align-items: center;
492
+ gap: var(--space-2);
493
+ line-height: var(--chip-line);
494
+ }
495
+ .section-label::before {
496
+ content: '';
497
+ width: 12px;
498
+ height: 2px;
499
+ /* W39: theme-aware accent (ink in light, lime in dark). Raw lime was
500
+ ~1.4:1 on light surfaces, an invisible dash. Always-dark slabs keep
501
+ explicit lime via the .section-dark override in sections.css. */
502
+ background: var(--color-accent);
503
+ border-radius: var(--radius-sm);
504
+ flex: 0 0 12px;
505
+ }
506
+ .section-label--no-bar::before { display: none; }
507
+ .section-label--center {
508
+ display: flex;
509
+ justify-content: center;
510
+ }
511
+ /* Variant: icon-led eyebrow (audit M-23, 2026-07-10). The decorative glyph is
512
+ sized to the label. Used by the accessibility proof card and the
513
+ design-system teaser, which previously each minted their own eyebrow class
514
+ (.ap2-eyebrow / .dsc-eyebrow) and had already drifted from the canon. */
515
+ .section-label--icon i,
516
+ .section-label--icon svg { width: 14px; height: 14px; flex: none; }
517
+ /* Variant: accent-emphasis colour for eyebrows sitting on marquee cards. */
518
+ .section-label--accent { color: var(--color-accent-emphasis); }
519
+
520
+ /* Task #43: halve heading-to-body gap globally. Was var(--space-5) = 24px,
521
+ now var(--space-3) = 12px. Tighter rhythm across every section. */
522
+ .section-title {
523
+ font-family: var(--font-display);
524
+ font-weight: var(--weight-extrabold);
525
+ font-size: var(--text-h1);
526
+ line-height: var(--line-display);
527
+ letter-spacing: var(--display-tight);
528
+ margin: 0 0 var(--space-3);
529
+ text-wrap: balance;
530
+ max-width: 22ch;
531
+ color: var(--color-text-primary);
532
+ font-style: normal;
533
+ }
534
+ /* Brand period accent. Rendered as generated content (not a DOM node) so it
535
+ is correctly treated as decoration: visible on screen, but invisible to the
536
+ accessibility tree and to scanners (which otherwise mis-flag a lime period
537
+ as low-contrast "text"). WCAG-clean across axe, Pa11y, IBM and WAVE. */
538
+ .section-title::after {
539
+ content: ".";
540
+ color: var(--brand-lime);
541
+ text-shadow: var(--accent-period-shadow);
542
+ }
543
+ /* (The legacy span.dot heading rule was deleted 2026-07-10, audit M-25: the
544
+ last two heading spans are gone; every heading period is the ::after above.
545
+ The hero ticker's separator dots are a different pattern in sections.css.) */
546
+
547
+ /* Task #43: tighten subtitle-to-content gap from var(--space-7) = 48px down
548
+ to var(--space-5) = 24px. Less wasted whitespace below every section's
549
+ intro paragraph before content begins. */
550
+ .section-subtitle {
551
+ font-family: var(--font-body);
552
+ font-size: var(--text-body);
553
+ line-height: var(--line-body);
554
+ color: var(--color-text-secondary);
555
+ max-width: 60ch;
556
+ margin: 0 0 var(--space-5);
557
+ }
558
+
559
+ /* -----------------------------------------------------------------------------
560
+ RoleCard
561
+ ---
562
+ Timeline entry: company logo, role title, dates, bullets, tags. Light
563
+ variant for default surfaces, dark variant inside dark slabs.
564
+ ----------------------------------------------------------------------------- */
565
+ .role-card {
566
+ display: grid;
567
+ grid-template-columns: 88px 1fr;
568
+ gap: var(--space-5);
569
+ padding: var(--space-6) 0;
570
+ border-top: var(--border-hairline);
571
+ }
572
+ .role-card:first-of-type { border-top: 0; padding-top: 0; }
573
+ /* Audit C-27: removed .role-card__logo, .role-card__logo img, .role-card__head,
574
+ .role-card__company, .role-card__dates, .role-card__body, .role-card__tags.
575
+ These BEM modifiers had no markup consumers; visible role-card styling lives
576
+ in sections.css under #recent-work selectors. */
577
+
578
+
579
+ /* -----------------------------------------------------------------------------
580
+ FAQRow
581
+ ---
582
+ Native <details> based. Click summary to expand, no JS required.
583
+ ----------------------------------------------------------------------------- */
584
+ /* Task #229: the border-bottom that used to live here stacked with the
585
+ #faq .faq-list > li { border-bottom } rule further down. Two 1 px
586
+ hairlines rendered 1 px apart between every row, reading as a doubled
587
+ line. The list-li rule is canonical; this one was redundant. */
588
+ .faq-row {
589
+ /* border handled by #faq .faq-list > li */
590
+ }
591
+ /* Audit C-28: removed .faq-row__summary, .faq-row__summary::-webkit-details-marker,
592
+ .faq-row__summary::after, .faq-row[open] .faq-row__summary::after, .faq-row__body.
593
+ These BEM modifiers had no markup consumers; visible FAQ toggle styling lives
594
+ in sections.css under #faq selectors. */
595
+
596
+
597
+ /* -----------------------------------------------------------------------------
598
+ Visually hidden (utility, not a component but used everywhere)
599
+ ----------------------------------------------------------------------------- */
600
+ .sr-only:not(:focus):not(:active) {
601
+ position: absolute;
602
+ width: 1px;
603
+ height: 1px;
604
+ overflow: hidden;
605
+ clip: rect(0 0 0 0);
606
+ white-space: nowrap;
607
+ }
608
+
609
+ /* ============================================================
610
+ Mobile carousel component (.ds-carousel)
611
+ Opt in by adding `ds-carousel` + `data-ds-carousel` to a grid
612
+ container. Below 768px the container becomes a swipeable
613
+ scroll-snap track (one card per view) with a single row of dots
614
+ (built by the ds-carousel init in index.html). At 769px and up it
615
+ is inert and the container keeps its own grid layout. Dots + swipe
616
+ only, no arrows. Selector note (audit AR-06): the section grid rules
617
+ used to live behind raw #id scopes that only !important could beat;
618
+ they are now flattened to [id="..."] attribute selectors (class tier,
619
+ no :where() involved), and this component wins with its own honest
620
+ specificity, the class plus the data-ds-carousel opt-in
621
+ attribute (0,2,0), which also outranks the container layout rule
622
+ `main > section.container-* > *` (0,1,2). No !important needed.
623
+ ============================================================ */
624
+ @media (max-width: 768px) {
625
+ /* Base carousel chrome at the component's natural specificity, so
626
+ section-scoped rules (now [id=...] class-tier) can still tune margins
627
+ and spacing the way they always did. */
628
+ .ds-carousel[data-ds-carousel] {
629
+ overflow-x: auto;
630
+ overflow-y: hidden;
631
+ scroll-snap-type: x mandatory;
632
+ -webkit-overflow-scrolling: touch;
633
+ scroll-behavior: smooth;
634
+ scrollbar-width: none;
635
+ -ms-overflow-style: none;
636
+ /* Full-bleed the scroll track to the screen edges so a card slides off the
637
+ viewport edge instead of vanishing at the section gutter. The negative
638
+ inline margin cancels the section's var(--space-4) gutter (which is the
639
+ full gutter at <=768px); padding-inline + scroll-padding-inline re-inset
640
+ the cards so they rest and snap aligned to that gutter. Card width
641
+ (flex: 0 0 100%) is unchanged because 100% now resolves against the
642
+ padded content box, so it stays one card per view with no next-card peek.
643
+ padding-block plus a matching negative margin-block gives vertical room
644
+ so a card's hover lift + shadow are not clipped by overflow-y, which is
645
+ forced to clip here because overflow-x is auto (CSS does not allow one
646
+ axis to scroll while the other stays visible). Net vertical footprint is
647
+ unchanged. */
648
+ padding-inline: var(--space-4);
649
+ scroll-padding-inline: var(--space-4);
650
+ padding-block: var(--space-3);
651
+ margin-block: calc(var(--space-3) * -1);
652
+ }
653
+ /* The doubled attribute is a deliberate specificity bump (0,3,0), carrying
654
+ exactly the declarations that used to be !important: the carousel layout
655
+ must beat the [id=...]-scoped section grid rules (0,2,0) that load after
656
+ this file, and the container layout rule main > section.container-* > *
657
+ (0,1,3). Everything else stays at natural specificity above (AR-06). */
658
+ .ds-carousel[data-ds-carousel][data-ds-carousel] {
659
+ display: flex;
660
+ grid-template-columns: none;
661
+ flex-wrap: nowrap;
662
+ gap: var(--space-3);
663
+ /* Parent-agnostic full-bleed: force the track to the viewport width and
664
+ centre it on the viewport, regardless of the parent's width/max-width
665
+ (the #skills track is a direct .container-base child with an explicit
666
+ width:100%, which a negative inline margin alone only shifts, not
667
+ widens). width:100vw + margin-inline:calc(50% - 50vw) spans edge to edge;
668
+ padding-inline + scroll-padding-inline re-inset cards to the text gutter.
669
+ Only applies <=768px where the carousel is active; desktop is inert. */
670
+ width: 100vw;
671
+ max-width: none;
672
+ margin-inline: calc(50% - 50vw);
673
+ }
674
+ .ds-carousel[data-ds-carousel]::-webkit-scrollbar { display: none; }
675
+ .ds-carousel[data-ds-carousel] > * {
676
+ min-width: 0;
677
+ scroll-snap-align: start;
678
+ }
679
+ .ds-carousel[data-ds-carousel][data-ds-carousel] > * {
680
+ flex: 0 0 100%;
681
+ max-width: 100%;
682
+ }
683
+ }
684
+
685
+ /* Pagination dots, built by the init. The dot styling is unconditional so the
686
+ under-the-hood showcase renders the real component at any width; live
687
+ carousel dot rows stay mobile-only via the display gate below (AR-01). */
688
+ .ds-dots {
689
+ display: none;
690
+ align-items: center;
691
+ justify-content: center;
692
+ gap: var(--space-2);
693
+ flex-wrap: nowrap;
694
+ margin-top: var(--space-4);
695
+ }
696
+ /* WCAG 2.2 SC 2.5.8 Target Size: the visible dot stays 9px, but the button
697
+ itself is a 24x24 transparent hit area that centres the visual dot drawn
698
+ via ::before. Meets the 24px minimum without enlarging the dot visually. */
699
+ .ds-dots button {
700
+ width: var(--touch-target-dot, 24px);
701
+ height: var(--touch-target-dot, 24px);
702
+ padding: 0;
703
+ border: 0;
704
+ background: transparent;
705
+ flex: none;
706
+ cursor: pointer;
707
+ display: grid;
708
+ place-items: center;
709
+ -webkit-tap-highlight-color: transparent;
710
+ }
711
+ .ds-dots button::before {
712
+ content: "";
713
+ width: 9px;
714
+ height: 9px;
715
+ border-radius: var(--radius-full);
716
+ background: var(--color-dot-inactive);
717
+ /* Active dot snaps to a pill (no width animation): the mid-grow frames read as a
718
+ half-formed / split pill on mobile when the active state moves mid-swipe. Colour
719
+ still cross-fades fast (0.1s) so the dot reads inactive or accent, never muddy. */
720
+ transition: background 0.1s var(--ease-linear);
721
+ }
722
+ .ds-dots button.is-active::before {
723
+ width: 26px;
724
+ background: var(--color-dot-active);
725
+ }
726
+ .ds-dots button:focus-visible {
727
+ /* Theme-aware ring (audit AX-06): the old hardcoded lime was 1.06:1 on the
728
+ light page. Always-dark bands re-scope --color-focus-ring to lime. */
729
+ outline: var(--border-focus);
730
+ outline-offset: var(--focus-ring-offset);
731
+ border-radius: var(--radius-full);
732
+ }
733
+ @media (max-width: 768px) {
734
+ .ds-dots { display: flex; }
735
+ }
736
+ @media (prefers-reduced-motion: reduce) {
737
+ .ds-carousel { scroll-behavior: auto; }
738
+ .ds-dots button::before { transition: none; }
739
+ }
740
+
741
+ /* ============================================================
742
+ Carousel nav (.ds-carousel-nav)
743
+ One shared desktop/tablet control for the Featured projects and
744
+ Testimonials carousels: a thin progress rail (position), a mono
745
+ "N / total" counter, and bare Lucide chevrons that flank the
746
+ counter. Replaces the old split arrows-left / dots-right bars.
747
+ The generic mobile .ds-dots component (disciplines, certifications,
748
+ compact testimonials) is separate and unchanged. Colours follow the
749
+ page theme via --color-dot-active (ink on light, lime on dark); the
750
+ always-dark testimonials band re-maps the tokens locally (sections.css),
751
+ the same way its dots already do. Rail + counter are decorative and
752
+ aria-hidden; position is announced via the carousels' live region.
753
+ ============================================================ */
754
+ .ds-carousel-nav {
755
+ display: flex;
756
+ align-items: center;
757
+ gap: var(--space-4);
758
+ /* The track is a SUBTLE groove (a light line), not an inactive-dot grey:
759
+ a dot grey is dark enough to blur into the ink fill. The fill is the
760
+ accent. Both are re-mappable per band (see the testimonials override in
761
+ sections.css). */
762
+ --cn-rail-track: var(--color-border);
763
+ --cn-rail-fill: var(--color-dot-active);
764
+ /* Press circle behind the chevron glyph; re-mapped per band like the
765
+ rail vars (testimonials + UTH stages in sections.css). */
766
+ --cn-arrow-press: var(--color-surface-sunken);
767
+ }
768
+ .ds-carousel-nav__rail {
769
+ position: relative;
770
+ width: 96px;
771
+ height: 3px;
772
+ flex: 0 0 auto;
773
+ background: var(--cn-rail-track);
774
+ border-radius: var(--radius-full);
775
+ overflow: hidden;
776
+ }
777
+ .ds-carousel-nav__fill {
778
+ position: absolute;
779
+ left: 0;
780
+ top: 0;
781
+ height: 100%;
782
+ width: 0;
783
+ background: var(--cn-rail-fill);
784
+ border-radius: var(--radius-full);
785
+ transition: width var(--duration-base) var(--ease-default);
786
+ }
787
+ .ds-carousel-nav__pager {
788
+ display: flex;
789
+ align-items: center;
790
+ gap: var(--space-2);
791
+ }
792
+ /* Bare chevron: visually small, no circle or border. A >=44px (WCAG 2.5.5
793
+ AAA) invisible tap area is centred on the glyph via ::before, so the arrow
794
+ stays an easy target without adding visible chrome. */
795
+ .ds-carousel-nav__arrow {
796
+ position: relative;
797
+ width: var(--btn-glyph-lg);
798
+ height: var(--btn-glyph-lg);
799
+ display: inline-flex;
800
+ align-items: center;
801
+ justify-content: center;
802
+ padding: 0;
803
+ border: 0;
804
+ background: transparent;
805
+ color: var(--color-text-secondary);
806
+ cursor: pointer;
807
+ border-radius: var(--radius-full);
808
+ transition: color var(--duration-fast) var(--ease-default);
809
+ }
810
+ .ds-carousel-nav__arrow::before {
811
+ content: "";
812
+ position: absolute;
813
+ top: 50%;
814
+ left: 50%;
815
+ width: var(--touch-target-min);
816
+ height: var(--touch-target-min);
817
+ transform: translate(-50%, -50%);
818
+ }
819
+ .ds-carousel-nav__arrow:hover { color: var(--color-text-primary); }
820
+ .ds-carousel-nav__arrow:focus-visible {
821
+ outline: var(--border-focus);
822
+ outline-offset: var(--focus-ring-offset);
823
+ }
824
+ /* Touch-visible press: filled circle + full-strength glyph. Background
825
+ only, no transform: the arrows flank live scroll-snap tracks (Tesla
826
+ review Group 4). */
827
+ .ds-carousel-nav__arrow:active:not(:disabled) {
828
+ background: var(--cn-arrow-press);
829
+ color: var(--color-text-primary);
830
+ }
831
+ /* Boundary state: carousels that do not loop (Featured, mobile swipe) disable
832
+ the arrow at each end. Dimmed + non-interactive; disabled controls are exempt
833
+ from WCAG 1.4.3 contrast, so the low opacity is fine. */
834
+ .ds-carousel-nav__arrow:disabled { opacity: 0.3; cursor: default; }
835
+ .ds-carousel-nav__arrow i,
836
+ .ds-carousel-nav__arrow svg {
837
+ width: var(--btn-glyph-lg);
838
+ height: var(--btn-glyph-lg);
839
+ }
840
+ .ds-carousel-nav__count {
841
+ font-family: var(--font-mono);
842
+ font-size: var(--size-xs);
843
+ letter-spacing: var(--tracking-wide);
844
+ color: var(--color-text-secondary);
845
+ font-variant-numeric: tabular-nums;
846
+ white-space: nowrap;
847
+ }
848
+ .ds-carousel-nav__count b {
849
+ font-weight: var(--weight-semibold);
850
+ color: var(--color-text-primary);
851
+ }
852
+ @media (prefers-reduced-motion: reduce) {
853
+ .ds-carousel-nav__fill { transition: none; }
854
+ }
855
+ /* Full-width variant for the mobile swipe carousels (disciplines, certifications,
856
+ compact testimonials), built by the ds-carousel init in index.html: the rail
857
+ stretches to fill the row, the counter + chevrons sit at the end, and the whole
858
+ nav is hidden on desktop where those carousels are an inert grid (the swipe
859
+ track only exists at <=768px). Replaces the old .ds-dots row for one nav pattern
860
+ site-wide. */
861
+ .ds-carousel-nav--full { display: none; width: 100%; margin-top: var(--space-4); }
862
+ .ds-carousel-nav--full .ds-carousel-nav__rail { flex: 1; width: auto; }
863
+ @media (max-width: 768px) {
864
+ .ds-carousel-nav--full { display: flex; }
865
+ }
866
+
867
+ /* =========================================================================
868
+ Relocated from tokens.css (2026-06-02): these are COMPONENTS, not tokens.
869
+ tokens.css is now generated from tokens.json, so component rules live here.
870
+ ========================================================================= */
871
+ .placeholder {
872
+ display: flex;
873
+ flex-direction: column;
874
+ align-items: center;
875
+ justify-content: center;
876
+ gap: var(--space-3);
877
+ min-height: 200px;
878
+ padding: var(--space-6);
879
+ background: var(--color-surface);
880
+ border: 2px dashed var(--color-border);
881
+ border-radius: var(--radius-sm);
882
+ color: var(--color-text-tertiary);
883
+ text-align: center;
884
+ font-family: var(--font-body);
885
+ font-size: var(--size-sm);
886
+ }
887
+ .placeholder i[data-lucide],
888
+ .placeholder svg {
889
+ width: 48px;
890
+ height: 48px;
891
+ color: var(--color-text-tertiary);
892
+ opacity: 0.5;
893
+ }
894
+ .placeholder-label {
895
+ font-weight: var(--weight-medium);
896
+ letter-spacing: var(--tracking-wide);
897
+ text-transform: uppercase;
898
+ color: var(--color-text-secondary);
899
+ }
900
+ .placeholder-hint {
901
+ font-size: var(--text-caption);
902
+ color: var(--color-text-tertiary);
903
+ opacity: 0.7;
904
+ max-width: 280px;
905
+ line-height: var(--line-body);
906
+ }
907
+ .placeholder--gallery { min-height: 320px; }
908
+
909
+ /* Audit M-49: contexts that need an INSET focus ring because their parent
910
+ uses overflow:hidden. Negative offset keeps the 2px halo painted INSIDE
911
+ the element rather than clipped away. */
912
+ .glb-thumb:focus-visible,
913
+ .full-track .f-card:focus-visible {
914
+ outline-offset: -2px;
915
+ }
916
+
917
+ /* ============================================================
918
+ Fractional-DPR outline crispness (Tesla review Group 2, 2026-07-02).
919
+ On screens whose devicePixelRatio is fractional (Windows laptops at
920
+ 125%/150% scaling report 1.25/1.5; the Tesla Model Y L 16in screen
921
+ measures 1.333 in-car), a 1px border maps onto one-and-a-fraction
922
+ physical pixels and anti-aliases into an uneven, fractured line.
923
+ A whole 2px keeps control outlines crisp in that window; true 1x
924
+ and integer 2x (Retina) screens are excluded and keep the 1px look.
925
+ Deliberate trade-offs:
926
+ - Desktop browser zoom (roughly 110-175% on a 1x screen) also lands
927
+ in the window, so outlines thicken while zoomed. Accepted.
928
+ - Controls only: cards and dividers stay 1px (the --border-hairline
929
+ composite is NOT touched; it has ~44 consumers).
930
+ - Bumping the BASE .btn is deliberate: border-box keeps geometry
931
+ stable, the primary's lime-on-lime border stays invisible, and
932
+ side-by-side primary + secondary CTAs keep identical text metrics.
933
+ - Any later `border:` SHORTHAND on these controls silently resets
934
+ the width inside this window; override with border-color /
935
+ border-style instead.
936
+ min/max-resolution dppx form (not range syntax) for Safari reach. */
937
+ @media (min-resolution: 1.1dppx) and (max-resolution: 1.9dppx) {
938
+ .btn,
939
+ .chip,
940
+ .badge {
941
+ border-width: 2px;
942
+ }
943
+ }