@webority/theme 0.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,720 @@
1
+ // =====================================================================
2
+ // Lumex — Shared custom component CSS.
3
+ // Imported AFTER Bootstrap. Anything that needs to render identically
4
+ // on the Razor marketing site AND the React customer portal lives here.
5
+ // Per-project styling (page layouts, route-specific tweaks) stays in
6
+ // the consuming project, NOT in this shared theme.
7
+ // =====================================================================
8
+
9
+
10
+ // ---- Brand wordmark / logo ------------------------------------------
11
+ .lumex-logo {
12
+ height: 2rem;
13
+ width: auto;
14
+ }
15
+
16
+
17
+ // ---- Section helpers ------------------------------------------------
18
+ // Marketing + portal both use vertically-padded sections with the
19
+ // brand's spacing rhythm. Bootstrap's py-* gets you part-way; these
20
+ // are the named variants the design system reaches for.
21
+ .section-y-sm { padding-block: 3rem; }
22
+ .section-y { padding-block: 5rem; }
23
+ .section-y-lg { padding-block: 7rem; }
24
+ @media (min-width: 768px) {
25
+ .section-y-sm { padding-block: 4rem; }
26
+ .section-y { padding-block: 6rem; }
27
+ .section-y-lg { padding-block: 9rem; }
28
+ }
29
+
30
+
31
+ // ---- Eyebrow (accent-dot + label) -----------------------------------
32
+ .eyebrow {
33
+ display: inline-flex;
34
+ align-items: center;
35
+ gap: 0.5rem;
36
+ font-size: 14px;
37
+ font-weight: 700;
38
+ line-height: 1;
39
+ color: var(--wui-primary);
40
+ }
41
+ .eyebrow::before {
42
+ content: "";
43
+ width: 0.5rem;
44
+ height: 0.5rem;
45
+ border-radius: 9999px;
46
+ background: var(--wui-primary);
47
+ }
48
+ .eyebrow--on-dark {
49
+ color: rgba(255, 255, 255, 0.85);
50
+ }
51
+ .eyebrow--on-dark::before {
52
+ background: #FFFFFF;
53
+ }
54
+
55
+
56
+ // ---- Display heading -------------------------------------------------
57
+ // Bootstrap's .display-1..6 ladder skews bigger than our locked design.
58
+ // .h-display drops in where the marketing hero needs the locked
59
+ // font-weight 500 + tight letter-spacing.
60
+ .h-display {
61
+ font-weight: 500;
62
+ letter-spacing: -0.03em;
63
+ line-height: 1.05;
64
+ }
65
+
66
+
67
+ // ---- Gradient backgrounds -------------------------------------------
68
+ .bg-hero-gradient { background: var(--wui-hero-gradient); }
69
+ .bg-cta-gradient { background: var(--wui-cta-gradient); }
70
+
71
+
72
+ // ---- Button polish (used by both Razor + React) ---------------------
73
+ // Flat buttons — no drop shadow. Active-press feedback (1px nudge) is
74
+ // retained because it's a tactile signal, not a depth cue.
75
+ .btn-primary {
76
+ // Pin hover/active to the reference's `--accent-dark` (#2440C4).
77
+ // Bootstrap's auto-shaded $primary would compute a slightly different
78
+ // shade, drifting off-spec by a few hex points.
79
+ --bs-btn-hover-bg: #2440C4;
80
+ --bs-btn-hover-border-color: #2440C4;
81
+ --bs-btn-active-bg: #2440C4;
82
+ --bs-btn-active-border-color: #2440C4;
83
+ &:active:not(:disabled) {
84
+ transform: translateY(1px);
85
+ }
86
+ }
87
+
88
+
89
+ // ---- Marketing pill buttons (locked design parity) -------------------
90
+ // Pixel-locked to the standalone reference's `btn-squircle inline-flex
91
+ // h-12 items-center text-sm font-medium` pattern. Each marketing CTA
92
+ // layers .btn-pill on a Bootstrap variant — these classes only override
93
+ // Bootstrap's btn CSS vars (height / padding / radius / font), so
94
+ // every variant inherits the right size without redefining colors.
95
+ //
96
+ // Reference values:
97
+ // - height : 48px (h-12 = 3rem)
98
+ // - radius : 16px (matches `.btn-squircle` literal)
99
+ // - font : 14px / 500 (text-sm font-medium)
100
+ // - padding-x : 20px (px-5) default — Watch demo / outline-dark
101
+ // Primary CTA overrides to 24px (px-6) inline
102
+ // - corner-shape : squircle (iOS-style smooth curve)
103
+ //
104
+ // Example:
105
+ // <a class="btn btn-primary btn-pill" style="--bs-btn-padding-x:1.5rem;">…</a>
106
+ // <a class="btn btn-light btn-pill-sm">Learn more</a>
107
+ .btn-pill {
108
+ --bs-btn-padding-y: 0; // height driven by min-height + flex centering
109
+ --bs-btn-padding-x: 1.25rem; // 20px (px-5)
110
+ --bs-btn-font-size: 0.875rem; // 14px
111
+ --bs-btn-font-weight: 500;
112
+ --bs-btn-line-height: 1.25;
113
+ --bs-btn-border-radius: 16px; // matches .btn-squircle exactly
114
+ min-height: 3rem; // 48px (h-12)
115
+
116
+
117
+ display: inline-flex;
118
+ align-items: center;
119
+ gap: 0.375rem; // 6px — matches primary arrow's ml-1.5
120
+ }
121
+ .btn-pill-sm {
122
+ --bs-btn-padding-y: 0;
123
+ --bs-btn-padding-x: 0.875rem; // 14px (px-3.5)
124
+ --bs-btn-font-size: 0.875rem; // 14px (text-sm)
125
+ --bs-btn-font-weight: 500;
126
+ --bs-btn-line-height: 1.25;
127
+ --bs-btn-border-radius: 12px;
128
+ min-height: 2.5rem; // 40px (h-10) — reference "Learn more" CTA
129
+
130
+
131
+ display: inline-flex;
132
+ align-items: center;
133
+ gap: 0.375rem;
134
+ }
135
+
136
+
137
+ // ---- Ghost-dark button (frosted glass on light surface) -------------
138
+ // Pixel-locked to the reference's `WatchDemoButton` "outline-dark" variant
139
+ // (src/components/marketing/brand/watch-demo-button.tsx):
140
+ // - border : rgba(13, 24, 43, 0.15) (foreground/15 in marketing-root)
141
+ // - bg : rgba(255, 255, 255, 0.6) (surface/60)
142
+ // - text : #0d182b (text-foreground in marketing-root)
143
+ // - blur : 4px (backdrop-blur-sm)
144
+ // - hover bg: #ffffff (hover:bg-surface = full opacity)
145
+ .btn-ghost-dark {
146
+ --bs-btn-color: #0d182b;
147
+ --bs-btn-bg: rgba(255, 255, 255, 0.60);
148
+ --bs-btn-border-color: rgba(13, 24, 43, 0.15);
149
+ --bs-btn-hover-color: #0d182b;
150
+ --bs-btn-hover-bg: #ffffff;
151
+ --bs-btn-hover-border-color: rgba(13, 24, 43, 0.20);
152
+ --bs-btn-active-color: #0d182b;
153
+ --bs-btn-active-bg: #ffffff;
154
+ --bs-btn-active-border-color: rgba(13, 24, 43, 0.20);
155
+ backdrop-filter: blur(4px);
156
+ -webkit-backdrop-filter: blur(4px);
157
+ }
158
+
159
+
160
+ // ---- Card "lifted" (shadowless) -------------------------------------
161
+ // Previously a soft drop-shadow card; now flat. The `.card-lifted`
162
+ // class is preserved so existing call sites don't break — it now
163
+ // expresses elevation only via the 1px hover translate (kept as a
164
+ // tactile signal, not a depth cue). No box-shadow, here or on hover.
165
+ .card-lifted {
166
+ transition: transform 0.2s var(--wui-ease-out-quart);
167
+ &:hover {
168
+ transform: translateY(-1px);
169
+ }
170
+ }
171
+
172
+
173
+ // ---- Inputs (Lumex canonical) ---------------------------------------
174
+ // 5 locked states — default / hover / focus / disabled / error —
175
+ // matching the reference `<Input>` component. Bootstrap drives the base
176
+ // look via $input-* vars in _lumex-variables.scss; these blocks add the
177
+ // hover-border-darken cue and the leading/trailing-slot wrapper.
178
+ //
179
+ // The error state is driven by `aria-invalid="true"` (NOT by Bootstrap's
180
+ // .is-invalid class) so accessibility tooling and visual cue stay in
181
+ // sync without extra markup.
182
+
183
+ .form-control,
184
+ .form-select {
185
+ transition: border-color 0.15s ease;
186
+ }
187
+ .form-control:hover:not(:focus):not(:disabled):not([readonly]):not([aria-invalid="true"]),
188
+ .form-select:hover:not(:focus):not(:disabled):not([aria-invalid="true"]) {
189
+ border-color: var(--color-border-strong); // --wui-text-3 @ 60%
190
+ }
191
+ .form-control[aria-invalid="true"],
192
+ .form-select[aria-invalid="true"] {
193
+ border-color: var(--bs-danger);
194
+ }
195
+ .form-control[aria-invalid="true"]:focus,
196
+ .form-select[aria-invalid="true"]:focus {
197
+ border-color: var(--bs-danger);
198
+ box-shadow: none;
199
+ }
200
+
201
+ // Optional helper class for forms that prefer the explicit Bootstrap
202
+ // validation pattern. Mirrors aria-invalid styling.
203
+ .form-control.is-invalid,
204
+ .form-select.is-invalid {
205
+ border-color: var(--bs-danger);
206
+ background-image: none; // kill Bootstrap's exclamation-mark SVG
207
+ padding-right: 0.875rem;
208
+ }
209
+ .form-control.is-invalid:focus,
210
+ .form-select.is-invalid:focus {
211
+ border-color: var(--bs-danger);
212
+ box-shadow: none;
213
+ }
214
+
215
+
216
+ // ---- Input group (leading / trailing slots) ------------------------
217
+ // Single outlined box that contains leading icon + input + trailing
218
+ // icon (or clear button). Border lives on the wrapper, so hover / focus
219
+ // / error states are visible regardless of which child has focus.
220
+ //
221
+ // Usage:
222
+ // <div class="lumex-input-group">
223
+ // <span class="lumex-input-leading"><i class="bi bi-currency-dollar"></i></span>
224
+ // <input type="text" class="form-control" placeholder="0.00" />
225
+ // </div>
226
+ //
227
+ // <div class="lumex-input-group">
228
+ // <input type="password" class="form-control" placeholder="Password" />
229
+ // <button type="button" class="lumex-input-trailing"><i class="bi bi-eye"></i></button>
230
+ // </div>
231
+ .lumex-input-group {
232
+ display: flex;
233
+ align-items: center;
234
+ gap: 0.5rem;
235
+ padding-inline: 0.875rem;
236
+ height: calc(2.5rem + 2px); // 40px box + 1px border each side
237
+ background: var(--wui-bg-light, #FFFFFF);
238
+ border: 1px solid var(--bs-border-color);
239
+ border-radius: 0.625rem; // 10px — matches the bare input radius
240
+ transition: border-color 0.15s ease;
241
+
242
+
243
+ }
244
+ .lumex-input-group:hover:not(:focus-within):not(.is-disabled) {
245
+ border-color: rgba(120, 130, 157, 0.6);
246
+ }
247
+ .lumex-input-group:focus-within {
248
+ border-color: var(--wui-primary, #2F50E4);
249
+ }
250
+ .lumex-input-group.is-disabled,
251
+ .lumex-input-group:has(input:disabled) {
252
+ background: var(--wui-bg-muted, #F5F6FA);
253
+ cursor: not-allowed;
254
+ border-color: var(--bs-border-color);
255
+ }
256
+ .lumex-input-group.has-error,
257
+ .lumex-input-group:has(input[aria-invalid="true"]) {
258
+ border-color: var(--bs-danger);
259
+ }
260
+
261
+ // Strip the bare input's own chrome — the wrapper carries it all.
262
+ .lumex-input-group > .form-control {
263
+ flex: 1 1 auto;
264
+ min-width: 0;
265
+ height: 100%;
266
+ padding-block: 0;
267
+ padding-inline: 0;
268
+ border: 0;
269
+ background: transparent;
270
+ box-shadow: none !important;
271
+ border-radius: 0;
272
+ }
273
+ .lumex-input-group > .form-control:focus,
274
+ .lumex-input-group > .form-control:hover {
275
+ border: 0;
276
+ box-shadow: none !important;
277
+ background: transparent;
278
+ }
279
+
280
+ // Leading + trailing slots — flex children that hold icons or buttons.
281
+ // Default muted color, inherits hover/focus state from the wrapper.
282
+ .lumex-input-leading,
283
+ .lumex-input-trailing {
284
+ display: inline-flex;
285
+ align-items: center;
286
+ justify-content: center;
287
+ color: var(--wui-text-3, #78829D);
288
+ flex-shrink: 0;
289
+ font-size: 1rem;
290
+ line-height: 1;
291
+ pointer-events: none; // icons are visual only by default
292
+ }
293
+
294
+ // When the trailing slot is a button (clear, toggle, etc.), reset
295
+ // button chrome and re-enable pointer events.
296
+ button.lumex-input-trailing,
297
+ button.lumex-input-leading {
298
+ background: transparent;
299
+ border: 0;
300
+ padding: 0;
301
+ pointer-events: auto;
302
+ cursor: pointer;
303
+ transition: color 0.15s ease;
304
+ }
305
+ button.lumex-input-trailing:hover,
306
+ button.lumex-input-leading:hover {
307
+ color: var(--wui-text-1, #252F4A);
308
+ }
309
+ button.lumex-input-trailing:focus-visible,
310
+ button.lumex-input-leading:focus-visible {
311
+ outline: none;
312
+ color: var(--wui-primary);
313
+ border-radius: 9999px;
314
+ }
315
+
316
+
317
+ // ---- Link with the marketing wipe underline (matches locked design) -
318
+ // Uses background-image (not a pseudo-element with inline-block) so the link
319
+ // stays inline. inline-block + overflow:hidden used to give the link its own
320
+ // baseline, which dropped it below surrounding prose whenever the parent had
321
+ // a non-default font-size (see Pricing footnote "Talk to sales" regression).
322
+
323
+ .link-wipe {
324
+ background-image: linear-gradient(currentColor, currentColor);
325
+ background-repeat: no-repeat;
326
+ background-position-x: 100%;
327
+ background-position-y: 100%;
328
+ background-size: 0 1px;
329
+ transition: background-size 500ms cubic-bezier(0.4, 0, 0.2, 1);
330
+ }
331
+ @media (hover: hover) {
332
+ .link-wipe:hover {
333
+ background-position-x: 0%;
334
+ background-size: 100% 1px;
335
+ }
336
+ }
337
+ .link-wipe--accent {
338
+ background-image: linear-gradient(var(--wui-primary), var(--wui-primary));
339
+ }
340
+
341
+
342
+ // ---- Tabular-numerals helper for KPIs / counters --------------------
343
+ .tabular-nums { font-variant-numeric: tabular-nums; }
344
+
345
+
346
+ // ---- Container with the marketing 1512px canvas ---------------------
347
+ // Pair with --wui-container-pad (responsive). Distinct from Bootstrap's
348
+ // .container (max-width steps at 540/720/960/1140/1320). Use this when a
349
+ // section needs to breathe up to 1512px on wide monitors.
350
+ .container-cloves {
351
+ max-width: var(--wui-container-max, 1512px);
352
+ margin-inline: auto;
353
+ padding-inline: var(--wui-container-pad, 24px);
354
+ width: 100%;
355
+ }
356
+
357
+
358
+ // ---- Squircle corners (iOS-style) -----------------------------------
359
+ // `corner-shape: squircle` renders a smoother continuous curve instead
360
+ // of a simple circular arc — the same effect iOS app icons use. Mirrors
361
+ // the locked design's global `* { corner-shape: squircle }` rule.
362
+ // Browser support: Chrome 138+ and Safari 16+ with -webkit- prefix.
363
+ // Older browsers ignore the property and fall back to standard
364
+ // border-radius — no breakage.
365
+ //
366
+ // Apply by default to EVERY element, then explicitly opt
367
+ // `.rounded-circle` and `.rounded-pill` back to `round` — squircle on
368
+ // a 50% / 9999px radius reads as a subtly rounded square rather than a
369
+ // true circle/pill.
370
+ * {
371
+
372
+
373
+ }
374
+ .rounded-circle,
375
+ .rounded-pill {
376
+
377
+
378
+ }
379
+
380
+ // Named opt-in utilities — preserved for places where authors want a
381
+ // specific squircle radius without relying on Bootstrap's $border-radius.
382
+ .btn-squircle {
383
+ border-radius: 16px;
384
+
385
+
386
+ }
387
+ .rounded-squircle {
388
+ border-radius: 32px;
389
+
390
+
391
+ }
392
+
393
+
394
+ // ---- Hero radial wash ------------------------------------------------
395
+ // Soft tinted glow over an elevated surface — sits behind hero copy.
396
+ .hero-wash {
397
+ background:
398
+ radial-gradient(80% 50% at 50% 0%,
399
+ rgba(47, 80, 228, 0.08), transparent 70%),
400
+ var(--wui-bg-elevated);
401
+ }
402
+
403
+
404
+ // ---- Page entry fade -------------------------------------------------
405
+ // Apply to <main> in _Layout so every page entry has a polite fade-up.
406
+ .page-fade-in {
407
+ animation: pageFadeIn 0.25s var(--wui-ease-ui) backwards;
408
+ }
409
+
410
+
411
+ // ---- Single-element fade-in ------------------------------------------
412
+ .fade-in {
413
+ animation: fadeIn 220ms var(--wui-ease-out-quart);
414
+ }
415
+
416
+
417
+ // ---- Stagger groups --------------------------------------------------
418
+ // Wrap a container in `.stagger-children` and its direct children
419
+ // cascade in 40ms apart, capped at 12 children. For bigger groups,
420
+ // use the IntersectionObserver-driven `[data-reveal]` pattern below.
421
+ .stagger-children > * {
422
+ animation: staggerRise 360ms var(--wui-ease-out-quart) backwards;
423
+ }
424
+ .stagger-children > *:nth-child(1) { animation-delay: 0ms; }
425
+ .stagger-children > *:nth-child(2) { animation-delay: 40ms; }
426
+ .stagger-children > *:nth-child(3) { animation-delay: 80ms; }
427
+ .stagger-children > *:nth-child(4) { animation-delay: 120ms; }
428
+ .stagger-children > *:nth-child(5) { animation-delay: 160ms; }
429
+ .stagger-children > *:nth-child(6) { animation-delay: 200ms; }
430
+ .stagger-children > *:nth-child(7) { animation-delay: 240ms; }
431
+ .stagger-children > *:nth-child(8) { animation-delay: 280ms; }
432
+ .stagger-children > *:nth-child(9) { animation-delay: 320ms; }
433
+ .stagger-children > *:nth-child(10) { animation-delay: 360ms; }
434
+ .stagger-children > *:nth-child(11) { animation-delay: 400ms; }
435
+ .stagger-children > *:nth-child(12) { animation-delay: 440ms; }
436
+
437
+
438
+ // ---- Opt-in single-element stagger -----------------------------------
439
+ // Use when you need granular control: set `--stagger: 240ms` inline on
440
+ // individual items inside a non-stagger-children container.
441
+ .stagger-item {
442
+ opacity: 0;
443
+ animation: rise 320ms var(--wui-ease-out-quart) forwards;
444
+ animation-delay: var(--stagger, 0ms);
445
+ }
446
+
447
+
448
+ // ---- Press feedback --------------------------------------------------
449
+ // Lightweight tactile alternative to a Material ripple. Add to any
450
+ // pressable element (button, card link, chip). 80ms ease-out.
451
+ .press-feedback {
452
+ transition: transform 80ms ease-out;
453
+ }
454
+ .press-feedback:active:not(:disabled) {
455
+ transform: scale(0.96);
456
+ }
457
+
458
+
459
+ // ---- Subtle hover lift -----------------------------------------------
460
+ .lift-on-hover {
461
+ transition: transform 0.2s var(--wui-ease-out-quart),
462
+ box-shadow 0.2s var(--wui-ease-out-quart);
463
+ }
464
+ .lift-on-hover:hover {
465
+ transform: translateY(-1px);
466
+ }
467
+
468
+
469
+ // ---- Loading shimmer skeleton ----------------------------------------
470
+ .shimmer {
471
+ position: relative;
472
+ overflow: hidden;
473
+ background: var(--wui-bg-muted);
474
+ }
475
+ .shimmer::after {
476
+ content: "";
477
+ position: absolute;
478
+ inset: 0;
479
+ background: linear-gradient(90deg,
480
+ transparent,
481
+ rgba(255, 255, 255, 0.5),
482
+ transparent);
483
+ transform: translateX(-100%);
484
+ animation: shimmer 1.4s infinite;
485
+ }
486
+
487
+
488
+ // ---- Marquee (customer-logo carousel etc.) ---------------------------
489
+ // Pair with a flex wrapper that duplicates its children so the
490
+ // translateX(-50%) loops seamlessly. Example:
491
+ // <div class="overflow-hidden">
492
+ // <div class="d-flex animate-marquee" style="width: max-content;">
493
+ // <ul class="d-flex gap-5 mb-0 list-unstyled">...</ul>
494
+ // <ul class="d-flex gap-5 mb-0 list-unstyled" aria-hidden="true">...</ul>
495
+ // </div>
496
+ // </div>
497
+ .animate-marquee {
498
+ animation: marketing-marquee 32s linear infinite;
499
+ }
500
+
501
+
502
+ // ---- Scroll-triggered reveals (paired with wwwroot/js/reveal.js) -----
503
+ // Author opts in by adding `data-reveal` to a section. The JS module
504
+ // adds `.is-visible` when the element scrolls into view; CSS transitions
505
+ // fade + lift on the transition. Stagger descendants via
506
+ // `[data-reveal-stagger] > *` with `--reveal-step` inline (default 40ms).
507
+ [data-reveal] {
508
+ opacity: 0;
509
+ transform: translateY(8px);
510
+ transition:
511
+ opacity 540ms var(--wui-ease-out-quart),
512
+ transform 540ms var(--wui-ease-out-quart);
513
+ will-change: opacity, transform;
514
+ }
515
+ [data-reveal].is-visible {
516
+ opacity: 1;
517
+ transform: none;
518
+ }
519
+
520
+ [data-reveal-stagger] > * {
521
+ opacity: 0;
522
+ transform: translateY(8px);
523
+ transition:
524
+ opacity 540ms var(--wui-ease-out-quart),
525
+ transform 540ms var(--wui-ease-out-quart);
526
+ }
527
+ [data-reveal-stagger].is-visible > * {
528
+ opacity: 1;
529
+ transform: none;
530
+ }
531
+ [data-reveal-stagger].is-visible > *:nth-child(1) { transition-delay: calc(var(--reveal-step, 40ms) * 0); }
532
+ [data-reveal-stagger].is-visible > *:nth-child(2) { transition-delay: calc(var(--reveal-step, 40ms) * 1); }
533
+ [data-reveal-stagger].is-visible > *:nth-child(3) { transition-delay: calc(var(--reveal-step, 40ms) * 2); }
534
+ [data-reveal-stagger].is-visible > *:nth-child(4) { transition-delay: calc(var(--reveal-step, 40ms) * 3); }
535
+ [data-reveal-stagger].is-visible > *:nth-child(5) { transition-delay: calc(var(--reveal-step, 40ms) * 4); }
536
+ [data-reveal-stagger].is-visible > *:nth-child(6) { transition-delay: calc(var(--reveal-step, 40ms) * 5); }
537
+ [data-reveal-stagger].is-visible > *:nth-child(7) { transition-delay: calc(var(--reveal-step, 40ms) * 6); }
538
+ [data-reveal-stagger].is-visible > *:nth-child(8) { transition-delay: calc(var(--reveal-step, 40ms) * 7); }
539
+ [data-reveal-stagger].is-visible > *:nth-child(9) { transition-delay: calc(var(--reveal-step, 40ms) * 8); }
540
+ [data-reveal-stagger].is-visible > *:nth-child(10) { transition-delay: calc(var(--reveal-step, 40ms) * 9); }
541
+ [data-reveal-stagger].is-visible > *:nth-child(11) { transition-delay: calc(var(--reveal-step, 40ms) * 10); }
542
+ [data-reveal-stagger].is-visible > *:nth-child(12) { transition-delay: calc(var(--reveal-step, 40ms) * 11); }
543
+
544
+
545
+ // ---- Keyframes -------------------------------------------------------
546
+ @keyframes fadeIn {
547
+ from { opacity: 0; transform: translateY(4px); }
548
+ to { opacity: 1; transform: translateY(0); }
549
+ }
550
+ @keyframes pageFadeIn {
551
+ from { opacity: 0; transform: translateY(6px); }
552
+ to { opacity: 1; transform: translateY(0); }
553
+ }
554
+ @keyframes rise {
555
+ from { opacity: 0; transform: translateY(8px); }
556
+ to { opacity: 1; transform: translateY(0); }
557
+ }
558
+ @keyframes staggerRise {
559
+ from { opacity: 0; transform: translateY(8px); }
560
+ to { opacity: 1; transform: translateY(0); }
561
+ }
562
+ @keyframes shimmer {
563
+ 0% { transform: translateX(-100%); }
564
+ 100% { transform: translateX(100%); }
565
+ }
566
+ @keyframes marketing-marquee {
567
+ from { transform: translateX(0); }
568
+ to { transform: translateX(-50%); }
569
+ }
570
+
571
+
572
+ // ---- Refined native scrollbars --------------------------------------
573
+ // Mirrors the locked design's always-visible scrollbar treatment:
574
+ //
575
+ // - 8px thin WebKit thumb (also Firefox via `scrollbar-width: thin`)
576
+ // - Quiet grey-blue tint always visible at rest (~40% alpha)
577
+ // - Brighter on hover (~60% alpha) and active (~80%)
578
+ // - Pill thumb with a 2px transparent border (visual cushion)
579
+ // - `scrollbar-gutter: stable` on <html>/<body> so the page never
580
+ // shifts when scrollable content grows/shrinks. Internal containers
581
+ // (avatars, badges, rounded buttons) intentionally skip the gutter
582
+ // reservation — applying it everywhere would push centered glyphs
583
+ // off-center inside rounded boxes.
584
+ //
585
+ // `wwwroot/js/scroll-state.js` toggles `html.is-scrolling` for ~800ms
586
+ // after each scroll event; the thumb gets an extra opacity bump while
587
+ // the class is on, giving subtle "I'm scrolling now" feedback without
588
+ // sacrificing the always-visible affordance.
589
+ html,
590
+ body {
591
+ scrollbar-gutter: stable;
592
+ }
593
+ * {
594
+ scrollbar-width: thin;
595
+ scrollbar-color: rgba(145, 158, 171, 0.45) transparent;
596
+ }
597
+ *::-webkit-scrollbar {
598
+ width: 8px;
599
+ height: 8px;
600
+ }
601
+ *::-webkit-scrollbar-track {
602
+ background: transparent;
603
+ }
604
+ *::-webkit-scrollbar-thumb {
605
+ background-color: rgba(145, 158, 171, 0.45);
606
+ border-radius: 9999px;
607
+ border: 2px solid transparent;
608
+ background-clip: padding-box;
609
+ min-height: 36px;
610
+ min-width: 36px;
611
+ transition: background-color 220ms ease;
612
+ }
613
+ *::-webkit-scrollbar-thumb:hover {
614
+ background-color: rgba(145, 158, 171, 0.65);
615
+ background-clip: padding-box;
616
+ }
617
+ *::-webkit-scrollbar-thumb:active {
618
+ background-color: rgba(145, 158, 171, 0.85);
619
+ background-clip: padding-box;
620
+ }
621
+ *::-webkit-scrollbar-corner {
622
+ background: transparent;
623
+ }
624
+
625
+ // While the page is actively scrolling, give the thumb a slight extra
626
+ // pop so the user can track its position more easily.
627
+ html.is-scrolling * {
628
+ scrollbar-color: rgba(145, 158, 171, 0.65) transparent;
629
+ }
630
+ html.is-scrolling *::-webkit-scrollbar-thumb {
631
+ background-color: rgba(145, 158, 171, 0.65);
632
+ }
633
+
634
+ // Utility: completely hide a scrollbar (e.g., for marquee/horizontal
635
+ // carousels where the rail itself is visually intrusive).
636
+ .scrollbar-hide {
637
+ scrollbar-width: none;
638
+ }
639
+ .scrollbar-hide::-webkit-scrollbar {
640
+ display: none;
641
+ }
642
+
643
+
644
+ // ---- Render perf primitives ----------------------------------------
645
+ //
646
+ // `content-visibility: auto` lets the browser skip layout + paint for
647
+ // offscreen sections — a 1500px page with 8 sections paints in ~⅓ the
648
+ // time after this change. Sections opt in via [data-section] (already
649
+ // used on most homepage sections) or .section-y / .section-y-lg /
650
+ // .section-y-sm wrappers. `contain-intrinsic-size` reserves a sane
651
+ // height so the scrollbar doesn't snap as sections paint in.
652
+ //
653
+ // Notes:
654
+ // - Don't apply to the inner hero or any element that hosts
655
+ // position: sticky descendants — `content-visibility` creates a
656
+ // containing block which breaks sticky positioning in those areas.
657
+ // - First section is excluded — it's above the fold on every page,
658
+ // no benefit and risks delaying LCP.
659
+ [data-section]:not(:first-of-type):not([data-section="inner-hero"]):not([data-section="home-hero"]):not([data-section-skip-cv]),
660
+ .section-y:not(:first-of-type):not([data-section-skip-cv]),
661
+ .section-y-sm:not(:first-of-type):not([data-section-skip-cv]),
662
+ .section-y-lg:not(:first-of-type):not([data-section-skip-cv]) {
663
+ content-visibility: auto;
664
+ contain-intrinsic-size: auto 720px;
665
+ }
666
+
667
+ // GPU-promote frequently-animated elements at rest so the compositor
668
+ // can flip them on hover without a paint hit. We deliberately do NOT
669
+ // add will-change globally — that costs memory on every element. Only
670
+ // elements with motion-on-interact get it.
671
+ .card-lifted,
672
+ .lift-on-hover,
673
+ .press-feedback,
674
+ .contact-submit,
675
+ .btn-pill,
676
+ .btn-pill-sm,
677
+ [data-reveal],
678
+ [data-reveal-stagger] > * {
679
+ will-change: auto; // baseline — opt out of promotion
680
+ }
681
+ .card-lifted:hover,
682
+ .lift-on-hover:hover,
683
+ .press-feedback:active,
684
+ [data-reveal]:not(.is-visible),
685
+ [data-reveal-stagger]:not(.is-visible) > * {
686
+ will-change: transform, opacity;
687
+ }
688
+
689
+ // Reduce paint on form-field focus rings — box-shadow repaints are
690
+ // cheaper when the painted layer is isolated.
691
+ .contact-field,
692
+ .custom-select-toggle,
693
+ .contact-submit {
694
+ backface-visibility: hidden;
695
+ transform: translateZ(0);
696
+ }
697
+
698
+
699
+ // ---- Reduced motion -------------------------------------------------
700
+ @media (prefers-reduced-motion: reduce) {
701
+ .card-lifted,
702
+ .lift-on-hover,
703
+ .link-wipe,
704
+ .press-feedback {
705
+ transition: none;
706
+ }
707
+ .page-fade-in,
708
+ .fade-in,
709
+ .stagger-children > *,
710
+ .stagger-item,
711
+ .animate-marquee,
712
+ .shimmer::after,
713
+ [data-reveal],
714
+ [data-reveal-stagger] > * {
715
+ animation: none;
716
+ opacity: 1;
717
+ transform: none;
718
+ transition: none;
719
+ }
720
+ }