@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/DESIGN.md +429 -0
- package/LICENSE +21 -0
- package/README.md +126 -0
- package/components.css +943 -0
- package/design-system.md +466 -0
- package/package.json +58 -0
- package/tokens.css +498 -0
- package/tokens.d.ts +266 -0
- package/tokens.js +265 -0
- package/tokens.json +441 -0
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
|
+
}
|