studio-engine 0.54.1 → 0.56.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.
@@ -23,6 +23,10 @@
23
23
  .conic-surface slow rotating conic-gradient wash (authored fresh)
24
24
  .pulse-cta attention pulse/glow for a CTA (ported, TM)
25
25
 
26
+ Action:
27
+ .hold-btn press-and-hold confirm button (ported, TM)
28
+ .hold-stack/.fizz-bit the carbonation behind it (ported, TM)
29
+
26
30
  Leveling:
27
31
  .level-badge(-1..-10) progressive level pill ladder (ported, TM)
28
32
  .level-up-pop level-up pop + glow burst (ported, TM)
@@ -507,38 +511,56 @@
507
511
  }
508
512
 
509
513
  /* =============================================================================
510
- 9b. studio-team-glow — single-color rotating "selected card" ring glow
514
+ 9b. studio-team-glow — rotating "selected card" ring glow, one or two colors
511
515
  Ported from turf-monster's per-team selected-card glow (.holo-border-glow):
512
- a single-color conic-gradient ring rotates around the element's edge, with a
513
- blurred bloom twin behind it. DISTINCT from .studio-border-glow (an animated
514
- RAINBOW border) and .studio-glow (a static soft box-shadow halo) — this is the
515
- "this card is selected / live" ring, tinted one color.
516
+ a conic-gradient ring rotates around the element's edge, with a blurred bloom
517
+ twin behind it. DISTINCT from .studio-border-glow (an animated RAINBOW border)
518
+ and .studio-glow (a static soft box-shadow halo) — this is the "this card is
519
+ selected / live" ring.
516
520
 
517
521
  The color is a CSS var (default the CTA role, so purple on McRitchie Studio),
518
- just like TM's per-team --glow-c. The wedge is swept by animating a registered
522
+ just like TM's per-team --glow-c. TWO opposed wedges travel the edge, and they
523
+ can carry two colors: --studio-team-glow-color-b defaults to the first, so a
524
+ one-color caller sees no change, and a caller with a pair to show (a Pokémon's
525
+ two types) gets one per wedge. The wedges are swept by animating a registered
519
526
  @property angle (rotating the GRADIENT, not the element — so no oversized-child
520
527
  clipping trick is needed). Opacity is a var (--studio-team-glow-opacity) with a
521
528
  transition, so a consumer can fade the glow in/out (the living style guide
522
529
  slides it between step cards as a modal's step machine advances).
523
530
 
524
- THE HOST IS A BARE WRAPPER, NOT THE CARD. Both wedge layers are pseudo-elements
525
- at z-index -1 / -2, and CSS paints an element's own background FIRST, then its
526
- negative-z descendants on top of it (CSS 2.1 Appendix E, steps 1 and 2). So a
527
- background on the HOST cannot cover the wedges — they wash its whole face, and
528
- the ring is gone. What covers the middle is an opaque CHILD in front: in-flow
529
- descendants paint later (step 3), leaving only the thickness-wide band around
530
- the edge. That is TM's holo-wrap / holo-card split, and it is what both
531
- consumers ship the engine's style/_modal_specimen (bare glow wrapper + the
532
- opaque .card article) and the hub's release-phase-meter (bare host + the bar's
533
- own fill). Demoing it the other way is what shipped a washed card to
534
- admin/style; the host also must NOT be clipped by an overflow-hidden ancestor
535
- near its border.
536
-
537
- Usage: <div class="studio-team-glow rounded-xl">
538
- <div class="rounded-xl bg-surface p-5">Selected</div>
539
- </div>
540
- red glow: style="--studio-team-glow-color: #E11D48"
541
- Tune: --studio-team-glow-color / -opacity / -speed / -thickness / -bloom
531
+ THE HOST MAY CARRY ITS OWN BACKGROUND the ring cuts the host's box out of
532
+ both wedge layers, so only the band around the edge paints. That hole is what
533
+ makes the primitive work in the two shapes its consumers need:
534
+
535
+ · the WRAPPER shape (TM's holo-wrap / holo-card split) a bare host with an
536
+ opaque child in front. The engine's style/_modal_specimen and the hub's
537
+ release-phase-meter both ship this. Unchanged by the hole: the child was
538
+ already covering that interior.
539
+ · the CARD shape the host IS the opaque surface. This is what a live board
540
+ card needs, because it cannot take a wrapper at all: Turbo targets the card
541
+ element for both replace and remove, so a wrapper is orphaned every time a
542
+ card leaves the board.
543
+
544
+ Without the hole the card shape is impossible, and not for a tunable reason:
545
+ both layers are pseudo-elements at z-index -1 / -2, and CSS paints an element's
546
+ own background FIRST, then its negative-z descendants ON TOP of it (CSS 2.1
547
+ Appendix E, steps 1 and 2) — so a background on the host is covered BY the
548
+ wedges, and the ring becomes a wash across the whole face. Only a child, or
549
+ this hole, can cover the middle.
550
+
551
+ The host must still NOT be clipped by an overflow-hidden ancestor near its
552
+ border, or the ring is cut off with it.
553
+
554
+ Usage: <div class="studio-team-glow rounded-xl bg-surface p-5">Selected</div>
555
+ wrapper shape, when the face is its own element:
556
+ <div class="studio-team-glow rounded-xl">
557
+ <div class="rounded-xl bg-surface p-5">Selected</div>
558
+ </div>
559
+ red glow: style="--studio-team-glow-color: #E11D48"
560
+ two types: style="--studio-team-glow-color: #F08030;
561
+ --studio-team-glow-color-b: #6890F0"
562
+ Tune: --studio-team-glow-color / -color-b / -opacity / -speed / -thickness /
563
+ -bloom
542
564
  ========================================================================== */
543
565
  @property --studio-team-glow-angle {
544
566
  syntax: "<angle>";
@@ -547,6 +569,11 @@
547
569
  }
548
570
  .studio-team-glow {
549
571
  --studio-team-glow-color: var(--color-cta);
572
+ /* The SECOND wedge's color. Defaults to the first, so a one-color caller sees
573
+ no change; a caller with two colors to show (a Pokémon's two types, a pair of
574
+ brand colors) gets one per wedge. Resolved on this element, so an inline
575
+ --studio-team-glow-color override feeds this default too. */
576
+ --studio-team-glow-color-b: var(--studio-team-glow-color);
550
577
  --studio-team-glow-opacity: 1;
551
578
  --studio-team-glow-speed: 4s;
552
579
  --studio-team-glow-thickness: 4px;
@@ -571,14 +598,47 @@
571
598
  var(--studio-team-glow-color) 12%,
572
599
  transparent 33%,
573
600
  transparent 50%,
574
- var(--studio-team-glow-color) 62%,
601
+ var(--studio-team-glow-color-b) 62%,
575
602
  transparent 83%
576
603
  );
577
604
  animation: studio-team-glow-spin var(--studio-team-glow-speed) linear infinite;
605
+ /* THE HOST'S OWN BOX IS PUNCHED OUT of both wedge layers, so what paints is the
606
+ band around the edge and nothing else. `padding` makes each layer's CONTENT
607
+ box exactly the host's box; mask-composite then removes that box from the
608
+ layer — the same mask trick §1's .studio-border-glow uses.
609
+ Why it is here: an opaque child in front covers the middle only when the host
610
+ CAN take a child. A live board card cannot — Turbo targets the card element
611
+ itself for replace and remove, so a wrapper is orphaned on every remove. With
612
+ the hole cut here the ring rides the card directly. For a wrapper host this
613
+ changes nothing visible: the child was already covering that interior. */
614
+ padding: var(--studio-team-glow-thickness);
615
+ -webkit-mask:
616
+ linear-gradient(#000 0 0) content-box,
617
+ linear-gradient(#000 0 0);
618
+ -webkit-mask-composite: xor;
619
+ mask:
620
+ linear-gradient(#000 0 0) content-box,
621
+ linear-gradient(#000 0 0);
622
+ mask-composite: exclude;
578
623
  }
579
624
  .studio-team-glow::after {
580
- /* Blurred bloom twin, a touch larger. */
581
- inset: calc(-1 * (var(--studio-team-glow-thickness) + 4px));
625
+ /* Blurred bloom twin, a touch larger: its wedges reach 4px PAST the ring, which
626
+ is what makes the bloom read as a halo, not a soft edge on the ring. Drop the
627
+ 4px and every WRAPPER consumer's ring visibly lightens (measured: 26% of the
628
+ pixels on style/_modal_specimen, 13% on the hub's release-phase-meter). The
629
+ box then carries a transparent BORDER of bloom-reach on top of that, and the
630
+ mask still holes out the host's box — so the blur
631
+ spreads outward into that reach and is cut where it has already faded, while
632
+ the inward half is cut at the host's edge (invisible: that edge is either the
633
+ card's own or the wrapper child's). Mask applies AFTER filter, which is why
634
+ the reach has to out-measure the blur — without it the halo ends on a hard
635
+ line at the layer's own box. */
636
+ inset: calc(-1 * (var(--studio-team-glow-thickness) + 4px + var(--studio-team-glow-bloom) * 2));
637
+ padding: calc(var(--studio-team-glow-thickness) + 4px);
638
+ border: calc(var(--studio-team-glow-bloom) * 2) solid transparent;
639
+ /* The wedges stop at the padding box (host + ring + the 4px); the border is empty
640
+ room the blur is allowed to spread into. */
641
+ background-clip: padding-box;
582
642
  z-index: -2;
583
643
  filter: blur(var(--studio-team-glow-bloom)) saturate(1.35);
584
644
  }
@@ -1195,3 +1255,557 @@ body.modal-open { overflow: hidden; }
1195
1255
  @media (prefers-reduced-motion: reduce) {
1196
1256
  .pulse-cta { animation: none; }
1197
1257
  }
1258
+
1259
+ /* ── ACTION: hold-to-confirm ──────────────────────────────────────────────────
1260
+ A press-and-hold CTA and the carbonation behind it. Ported from turf-monster
1261
+ (2026-08), where the confirm-entry button is the last thing a player touches
1262
+ before money moves — a hold is the affordance for "mean it", and the fizz is
1263
+ what makes it worth looking at.
1264
+
1265
+ Markup contract (studio/_hold_button + studio/_fizz_layer):
1266
+
1267
+ span.hold-stack positioning + z-order context
1268
+ span.hold-fizz resting bubble layer (behind the button)
1269
+ span.hold-fizz.hold-fizz-extra hover-only layer (lively level only)
1270
+ button.hold-btn the button itself, painted OVER the bubbles
1271
+ div.hold-icon progress ring + tick
1272
+ ul.hold-text four sliding labels: idle/hold/done/blocked
1273
+ div.nudge-debug dev-only countdown to the next nudge
1274
+
1275
+ The face themes off ROLE tokens, so it wears each app's own brand: the
1276
+ resting gradient is the primary ramp, the confirmed state a deeper cut of the
1277
+ same ramp, and the blocked state the danger role. Every color also reads a
1278
+ `--hold-*` INPUT first, so an ancestor can re-theme one button (or a page of
1279
+ them) without touching this file — a declaration here would otherwise beat
1280
+ anything inherited.
1281
+
1282
+ States are JS-applied classes on the button: .process (holding), .loading
1283
+ (submitted, in flight), .success, .error, .nudge / .nudge-soft (the
1284
+ attention shake). The bubbles read them with :has(), because they are the
1285
+ button's SIBLING rather than its child — see the fizz section below. */
1286
+
1287
+ .hold-btn {
1288
+ --duration: 2000ms;
1289
+ --color: #f6f8ff;
1290
+ /* --bg is the button's SOLID color; .hold-icon::before punches the ring's
1291
+ hole with it, so it has to stay a flat value. The face wears --bg-image on
1292
+ top: lit from the top-left down into the deep end of the ramp, so the CTA
1293
+ reads as a raised surface instead of a paint chip. A state that wants a
1294
+ flat face (the transparent hold, the red error) sets --bg-image: none. */
1295
+ --bg: var(--color-cta);
1296
+ --bg-from: var(--hold-bg-from, rgb(var(--color-primary-400-rgb)));
1297
+ --bg-mid: var(--hold-bg-mid, rgb(var(--color-primary-rgb)));
1298
+ --bg-to: var(--hold-bg-to, rgb(var(--color-primary-700-rgb)));
1299
+ --bg-image: linear-gradient(160deg, var(--bg-from) 0%, var(--bg-mid) 45%, var(--bg-to) 100%);
1300
+ /* Confirmed stays in the SAME family as the face you pressed, dropped into
1301
+ the near-black end of the ramp. An off-family success color (turf shipped a
1302
+ mint/teal one for a year) reads as a jump to some other button, and a check
1303
+ drawn in that same color barely shows on it — hence the white tick below. */
1304
+ --success-from: var(--hold-success-from, rgb(var(--color-primary-rgb)));
1305
+ --success-to: var(--hold-success-to, rgb(var(--color-primary-900-rgb)));
1306
+ --success-glow-rgb: var(--hold-success-glow-rgb, var(--color-primary-800-rgb));
1307
+ --progress-border: var(--color-inset);
1308
+ --progress-active: #f6f8ff;
1309
+ --progress-success: #f6f8ff;
1310
+ --tick-stroke: var(--progress-active);
1311
+
1312
+ position: relative;
1313
+ width: 100%;
1314
+ padding: 14px 32px;
1315
+ font-size: 14px;
1316
+ font-weight: 600;
1317
+ line-height: 19px;
1318
+ border: 1px solid rgb(var(--color-primary-rgb) / 0.5);
1319
+ border-radius: 12px;
1320
+ outline: none;
1321
+ cursor: pointer;
1322
+ user-select: none;
1323
+ -webkit-tap-highlight-color: transparent;
1324
+ backface-visibility: hidden;
1325
+ color: var(--color);
1326
+ background: var(--bg);
1327
+ background-image: var(--bg-image, none);
1328
+ box-shadow: 0 0 20px rgb(var(--color-primary-rgb) / 0.25);
1329
+ transform: scale(var(--scale, 1));
1330
+ transition: transform 0.3s, box-shadow 0.3s, background 0.3s, border-color 0.3s;
1331
+ }
1332
+
1333
+ /* The attention shake. JS adds the class and removes it on animationend. */
1334
+ .hold-btn.nudge {
1335
+ animation: hold-nudge 0.5s ease-in-out 1;
1336
+ }
1337
+
1338
+ .hold-btn.nudge-soft {
1339
+ animation: hold-nudge-soft 0.4s ease-in-out 1;
1340
+ }
1341
+
1342
+ /* The progress ring: a fat-stroked circle whose dash array fills over
1343
+ --duration, with the button's own background punched through the middle. */
1344
+ .hold-btn > .hold-icon {
1345
+ position: absolute;
1346
+ top: 50%;
1347
+ left: 14px;
1348
+ width: 20px;
1349
+ height: 20px;
1350
+ margin-top: -10px;
1351
+ border-radius: 50%;
1352
+ background: var(--progress-border);
1353
+ opacity: var(--icon-o, 0);
1354
+ transform: translateX(var(--icon-x, -4px));
1355
+ transition: transform 0.3s, opacity 0.2s;
1356
+ }
1357
+
1358
+ .hold-btn > .hold-icon::before {
1359
+ content: '';
1360
+ position: absolute;
1361
+ top: 2px;
1362
+ left: 2px;
1363
+ z-index: 1;
1364
+ width: 16px;
1365
+ height: 16px;
1366
+ background: var(--bg);
1367
+ border-radius: inherit;
1368
+ transform: scale(var(--bg-scale, 1));
1369
+ transition: transform 0.32s ease;
1370
+ }
1371
+
1372
+ .hold-btn > .hold-icon svg {
1373
+ display: block;
1374
+ width: 20px;
1375
+ height: 20px;
1376
+ fill: none;
1377
+ }
1378
+
1379
+ .hold-btn > .hold-icon svg.progress {
1380
+ transform: rotate(-90deg) scale(var(--progress-scale, 1));
1381
+ transition: transform 0.5s ease;
1382
+ }
1383
+
1384
+ .hold-btn > .hold-icon svg.progress circle {
1385
+ stroke: var(--progress-active);
1386
+ stroke-width: 16;
1387
+ stroke-dashoffset: 1;
1388
+ stroke-dasharray: var(--progress-array, 0) 52;
1389
+ transition: stroke-dasharray var(--duration) linear;
1390
+ }
1391
+
1392
+ .hold-btn > .hold-icon svg.tick {
1393
+ position: absolute;
1394
+ top: 0;
1395
+ left: 0;
1396
+ stroke: var(--tick-stroke);
1397
+ stroke-width: 3;
1398
+ stroke-linecap: round;
1399
+ stroke-linejoin: round;
1400
+ transition: stroke 0.3s ease 0.7s;
1401
+ }
1402
+
1403
+ .hold-btn > .hold-icon svg.tick polyline {
1404
+ stroke-dasharray: 18 18 18;
1405
+ stroke-dashoffset: var(--tick-offset, 18);
1406
+ transition: stroke-dashoffset 0.4s ease 0.7s;
1407
+ }
1408
+
1409
+ /* Four labels stacked in one column; the state slides the column. */
1410
+ .hold-btn .hold-text {
1411
+ position: relative;
1412
+ margin: 0;
1413
+ padding: 0;
1414
+ list-style: none;
1415
+ text-align: center;
1416
+ pointer-events: none;
1417
+ backface-visibility: hidden;
1418
+ transform: translate3d(var(--ul-x, 0), 0, 0);
1419
+ transition: transform 0.3s;
1420
+ }
1421
+
1422
+ .hold-btn .hold-text li {
1423
+ backface-visibility: hidden;
1424
+ transform: translateY(var(--ul-y, 0));
1425
+ transition: transform 0.3s ease 0.16s, opacity 0.2s ease 0.16s;
1426
+ }
1427
+
1428
+ .hold-btn .hold-text li:not(:first-child) {
1429
+ position: absolute;
1430
+ left: 0;
1431
+ right: 0;
1432
+ }
1433
+
1434
+ .hold-btn .hold-text li:nth-child(1) {
1435
+ top: 0;
1436
+ opacity: var(--ul-o-1, 1);
1437
+ }
1438
+
1439
+ .hold-btn .hold-text li:nth-child(2) {
1440
+ top: 100%;
1441
+ opacity: var(--ul-o-2, 0);
1442
+ }
1443
+
1444
+ .hold-btn .hold-text li:nth-child(3) {
1445
+ top: 200%;
1446
+ opacity: var(--ul-o-3, 0);
1447
+ }
1448
+
1449
+ .hold-btn .hold-text li:nth-child(4) {
1450
+ top: 300%;
1451
+ opacity: var(--ul-o-4, 0);
1452
+ }
1453
+
1454
+ .hold-btn:hover:not(.process):not(.success) {
1455
+ filter: brightness(0.92);
1456
+ border-color: rgb(var(--color-primary-rgb) / 0.7);
1457
+ box-shadow: 0 0 30px rgb(var(--color-primary-rgb) / 0.4);
1458
+ }
1459
+
1460
+ .hold-btn:active:not(.success) {
1461
+ --scale: 0.97;
1462
+ }
1463
+
1464
+ /* Holding. The face goes flat and dark so the ring and the building glow have
1465
+ somewhere to read against; the gradient would fight both. */
1466
+ .hold-btn.process {
1467
+ --icon-x: 0;
1468
+ --icon-o: 1;
1469
+ --ul-x: 8px;
1470
+ --ul-y: -100%;
1471
+ --ul-o-1: 0;
1472
+ --ul-o-2: 1;
1473
+ --ul-o-3: 0;
1474
+ --progress-array: 52;
1475
+ --bg: var(--color-page);
1476
+ --bg-image: none;
1477
+ border-color: rgb(var(--color-primary-rgb) / 0.7);
1478
+ box-shadow:
1479
+ 0 0 30px rgb(var(--color-primary-rgb) / 0.45),
1480
+ 0 0 80px rgb(var(--color-primary-rgb) / 0.25),
1481
+ 0 0 120px rgb(var(--color-primary-rgb) / 0.1),
1482
+ inset 0 0 40px rgb(var(--color-primary-rgb) / 0.1);
1483
+ animation: none;
1484
+ transition: transform 0.3s, box-shadow 2.5s ease-out, border-color 1s, background 0.5s;
1485
+ }
1486
+
1487
+ /* Confirmed. */
1488
+ .hold-btn.success {
1489
+ --scale: 1.03;
1490
+ --icon-x: 6px;
1491
+ --icon-o: 1;
1492
+ --ul-x: 8px;
1493
+ --ul-y: -200%;
1494
+ --ul-o-1: 0;
1495
+ --ul-o-2: 0;
1496
+ --ul-o-3: 1;
1497
+ --progress-array: 52;
1498
+ --progress-border: none;
1499
+ --progress-scale: 0.11;
1500
+ --tick-stroke: var(--progress-success);
1501
+ --tick-offset: 36;
1502
+ --bg-scale: 0;
1503
+ --bg: var(--success-to);
1504
+ --bg-image: linear-gradient(160deg, var(--success-from) 0%, var(--success-to) 100%);
1505
+ --color: #fff;
1506
+ border-color: var(--success-from);
1507
+ box-shadow:
1508
+ 0 0 40px rgb(var(--success-glow-rgb) / 0.7),
1509
+ 0 0 100px rgb(var(--success-glow-rgb) / 0.4),
1510
+ 0 0 160px rgb(var(--success-glow-rgb) / 0.15);
1511
+ animation: hold-glow-pulse 0.8s ease-in-out 1;
1512
+ }
1513
+
1514
+ .hold-btn.success > .hold-icon svg.progress {
1515
+ animation: hold-tick 0.3s linear forwards 0.4s;
1516
+ }
1517
+
1518
+ /* Blocked. */
1519
+ .hold-btn.error {
1520
+ --icon-o: 0;
1521
+ --ul-x: 0;
1522
+ --ul-y: -300%;
1523
+ --ul-o-1: 0;
1524
+ --ul-o-2: 0;
1525
+ --ul-o-3: 0;
1526
+ --ul-o-4: 1;
1527
+ --bg: var(--color-danger);
1528
+ --bg-image: none;
1529
+ border-color: rgb(239 68 68 / 0.7);
1530
+ box-shadow: 0 0 30px rgb(239 68 68 / 0.4);
1531
+ animation: none;
1532
+ cursor: default;
1533
+ }
1534
+
1535
+ /* Submitted, waiting on the server. Keeps the hold's glow for continuity and
1536
+ swaps the filled ring for a spinning arc, so it reads "still working" rather
1537
+ than "done". */
1538
+ .hold-btn.loading {
1539
+ --icon-x: 0;
1540
+ --icon-o: 1;
1541
+ --ul-x: 8px;
1542
+ --ul-y: -100%;
1543
+ --ul-o-1: 0;
1544
+ --ul-o-2: 1;
1545
+ --ul-o-3: 0;
1546
+ --bg: var(--color-page);
1547
+ --bg-image: none;
1548
+ border-color: rgb(var(--color-primary-rgb) / 0.7);
1549
+ box-shadow:
1550
+ 0 0 30px rgb(var(--color-primary-rgb) / 0.45),
1551
+ 0 0 80px rgb(var(--color-primary-rgb) / 0.25),
1552
+ 0 0 120px rgb(var(--color-primary-rgb) / 0.1),
1553
+ inset 0 0 40px rgb(var(--color-primary-rgb) / 0.1);
1554
+ cursor: default;
1555
+ }
1556
+
1557
+ .hold-btn.loading > .hold-icon {
1558
+ background: transparent;
1559
+ }
1560
+
1561
+ .hold-btn.loading > .hold-icon::before {
1562
+ display: none;
1563
+ }
1564
+
1565
+ .hold-btn.loading > .hold-icon svg.progress {
1566
+ animation: spin 0.8s linear infinite;
1567
+ transition: none;
1568
+ }
1569
+
1570
+ .hold-btn.loading > .hold-icon svg.progress circle {
1571
+ stroke: rgb(var(--color-primary-rgb));
1572
+ stroke-width: 3;
1573
+ stroke-dasharray: 30 21;
1574
+ stroke-dashoffset: 0;
1575
+ transition: none;
1576
+ }
1577
+
1578
+ /* Dev-only countdown to the next nudge. Hidden unless the host is in dev mode
1579
+ (studio's .dev-mode body class), and always hidden once the hold starts. */
1580
+ .nudge-debug {
1581
+ position: absolute;
1582
+ top: 50%;
1583
+ right: 10px;
1584
+ width: 24px;
1585
+ height: 24px;
1586
+ display: none;
1587
+ pointer-events: none;
1588
+ transform: translateY(-50%);
1589
+ }
1590
+
1591
+ .dev-mode .nudge-debug {
1592
+ display: block;
1593
+ }
1594
+
1595
+ .nudge-debug svg {
1596
+ width: 24px;
1597
+ height: 24px;
1598
+ transform: rotate(-90deg);
1599
+ }
1600
+
1601
+ .nudge-debug svg circle.track {
1602
+ fill: none;
1603
+ stroke: rgb(255 255 255 / 0.15);
1604
+ stroke-width: 3;
1605
+ }
1606
+
1607
+ .nudge-debug svg circle.fill {
1608
+ fill: none;
1609
+ stroke: var(--color-cta);
1610
+ stroke-width: 3;
1611
+ stroke-linecap: round;
1612
+ stroke-dasharray: 56.55;
1613
+ stroke-dashoffset: 0;
1614
+ transition: stroke-dashoffset 0.3s linear;
1615
+ }
1616
+
1617
+ .nudge-debug .countdown-num {
1618
+ position: absolute;
1619
+ inset: 0;
1620
+ display: flex;
1621
+ align-items: center;
1622
+ justify-content: center;
1623
+ font-family: ui-monospace, monospace;
1624
+ font-size: 9px;
1625
+ font-weight: 800;
1626
+ color: #f6f8ff;
1627
+ }
1628
+
1629
+ .hold-btn.process .nudge-debug,
1630
+ .hold-btn.success .nudge-debug {
1631
+ opacity: 0;
1632
+ }
1633
+
1634
+ /* ── ACTION: the fizz ─────────────────────────────────────────────────────────
1635
+ Carbonation behind the button. The bubbles are the button's SIBLING, never
1636
+ its child: the button's `transform` makes it a stacking context, and a
1637
+ stacking context paints its own background BEFORE its negative-z children, so
1638
+ a bubble inside it can never get behind its face. As a sibling the button
1639
+ paints over them and they show only where they escape its edges. `isolation`
1640
+ keeps that z-order private to the stack.
1641
+
1642
+ The bubbles are placed and coloured by Studio::FizzHelper, which hands each
1643
+ one its drift, size, delay, cycle and colour as custom properties — so ONE
1644
+ keyframe set drives all of them, and amplitude is the STATE's job, expressed
1645
+ as a multiplier on the bubble's own drift.
1646
+
1647
+ ZONES + PALETTE. The button is cut into a 3x2 grid of zones, and a bubble
1648
+ only ever wears the colours of the zone it sits in, so a six-item palette
1649
+ reads as six things standing around the button rather than one shaken bag of
1650
+ confetti. Each zone owns three slots (--fizz-c-1..18): the resting layer
1651
+ wears the first, and the hover layer alternates the second and third.
1652
+ Nothing bound? Every bubble falls back to its own hue, so the effect is
1653
+ never colourless.
1654
+
1655
+ LEVELS. .hold-stack rests with one layer simmering. .fizz-lively rests at a
1656
+ full boil and, on hover, DOUBLES THE COUNT rather than the speed: a second
1657
+ layer (seeded apart, so it lands in the first one's gaps) fades in over it
1658
+ and fades back out when the pointer leaves. */
1659
+
1660
+ .hold-stack {
1661
+ position: relative;
1662
+ display: block;
1663
+ isolation: isolate;
1664
+ }
1665
+
1666
+ .hold-stack > .hold-btn {
1667
+ z-index: 1;
1668
+ }
1669
+
1670
+ .hold-stack > .hold-fizz {
1671
+ position: absolute;
1672
+ inset: 0;
1673
+ z-index: 0;
1674
+ pointer-events: none;
1675
+ }
1676
+
1677
+ .hold-stack .fizz-bit {
1678
+ position: absolute;
1679
+ width: var(--fs, 4px);
1680
+ height: var(--fs, 4px);
1681
+ margin-top: calc(var(--fs, 4px) / -2);
1682
+ margin-left: calc(var(--fs, 4px) / -2);
1683
+ border-radius: 100px;
1684
+ /* --fc resolves to this bubble's colour slot, or its own hue when the slot is
1685
+ unbound. The hairline rim keeps a near-black palette colour visible on a
1686
+ dark page. */
1687
+ background: var(--fc, hsl(292 92% 70%));
1688
+ box-shadow:
1689
+ 0 0 6px var(--fc, hsl(292 92% 70%)),
1690
+ 0 0 0 0.5px rgb(255 255 255 / 0.35);
1691
+ opacity: 0;
1692
+ will-change: transform, opacity;
1693
+ animation: fizz-simmer var(--ft, 1.8s) linear var(--fd, 0s) infinite;
1694
+ }
1695
+
1696
+ /* Hover and the nudge shake both mean "look at me" — boil for the moment.
1697
+ Calm only: lively rests at this boil already and answers hover its own way,
1698
+ below, so it must not pick this rule up (it outranks lively's rest rule). */
1699
+ .hold-stack:not(.fizz-lively):has(> .hold-btn:hover:not(.process):not(.loading):not(.success):not(.error)) .fizz-bit {
1700
+ animation: fizz-boil calc(var(--ft, 1.8s) * 0.62) linear calc(var(--fd, 0s) * 0.35) infinite;
1701
+ }
1702
+
1703
+ .hold-stack.fizz-lively .fizz-bit {
1704
+ animation: fizz-boil calc(var(--ft, 1.8s) * 0.62) linear calc(var(--fd, 0s) * 0.35) infinite;
1705
+ }
1706
+
1707
+ /* The hover layer keeps running while hidden. It has to: killing the animation
1708
+ would snap the bubbles to their base opacity: 0 and leave nothing to fade. */
1709
+ .hold-stack > .hold-fizz-extra {
1710
+ opacity: 0;
1711
+ transition: opacity 0.45s ease;
1712
+ }
1713
+
1714
+ /* The guard list is as much the point as the hover: a held, submitting,
1715
+ confirmed or blocked button speaks for itself, so the hover crowd fades away
1716
+ and leaves the ring and the burst to read cleanly. */
1717
+ .hold-stack.fizz-lively:has(> .hold-btn:hover:not(.process):not(.loading):not(.success):not(.error)) > .hold-fizz-extra {
1718
+ opacity: 1;
1719
+ }
1720
+
1721
+ .hold-stack:has(> .hold-btn.nudge) .fizz-bit,
1722
+ .hold-stack:has(> .hold-btn.nudge-soft) .fizz-bit {
1723
+ animation: fizz-boil calc(var(--ft, 1.8s) * 0.55) linear calc(var(--fd, 0s) * 0.25) infinite;
1724
+ }
1725
+
1726
+ .hold-stack:has(> .hold-btn.process) .fizz-bit,
1727
+ .hold-stack:has(> .hold-btn.loading) .fizz-bit {
1728
+ animation: fizz-boil calc(var(--ft, 1.8s) * 0.45) linear calc(var(--fd, 0s) * 0.2) infinite;
1729
+ }
1730
+
1731
+ .hold-stack:has(> .hold-btn.success) .fizz-bit {
1732
+ animation: fizz-burst 1.1s cubic-bezier(0.12, 0.72, 0.24, 1) forwards;
1733
+ }
1734
+
1735
+ .hold-stack:has(> .hold-btn.error) .fizz-bit {
1736
+ animation: none;
1737
+ opacity: 0;
1738
+ }
1739
+
1740
+ @keyframes hold-tick {
1741
+ 100% { transform: rotate(-90deg) translate(0, -5px) scale(var(--progress-scale)); }
1742
+ }
1743
+
1744
+ @keyframes hold-glow-pulse {
1745
+ 50% {
1746
+ box-shadow:
1747
+ 0 0 60px rgb(var(--success-glow-rgb) / 0.9),
1748
+ 0 0 140px rgb(var(--success-glow-rgb) / 0.5),
1749
+ 0 0 200px rgb(var(--success-glow-rgb) / 0.2);
1750
+ }
1751
+ }
1752
+
1753
+ @keyframes hold-nudge {
1754
+ 0%, 100% { transform: scale(1) rotate(0deg); }
1755
+ 15% { transform: scale(1.06) rotate(-2deg); }
1756
+ 30% { transform: scale(1.06) rotate(2deg); }
1757
+ 45% { transform: scale(1.04) rotate(-1.5deg); }
1758
+ 60% { transform: scale(1.03) rotate(1deg); }
1759
+ 75% { transform: scale(1.02) rotate(-0.5deg); }
1760
+ 90% { transform: scale(1.01) rotate(0.3deg); }
1761
+ }
1762
+
1763
+ @keyframes hold-nudge-soft {
1764
+ 0%, 100% { transform: scale(1) rotate(0deg); }
1765
+ 20% { transform: scale(1.03) rotate(-1deg); }
1766
+ 40% { transform: scale(1.03) rotate(1deg); }
1767
+ 60% { transform: scale(1.02) rotate(-0.5deg); }
1768
+ 80% { transform: scale(1.01) rotate(0.5deg); }
1769
+ }
1770
+
1771
+ /* One bubble's life. --fx/--fy are ITS drift; the multiplier is the state's
1772
+ intensity. Transform and opacity only, so every bubble stays on the
1773
+ compositor. */
1774
+ @keyframes fizz-simmer {
1775
+ 0% { opacity: 0; transform: translate3d(0, 0, 0) scale(0.5); }
1776
+ 30% { opacity: 0.4; }
1777
+ 70% { opacity: 0.24; }
1778
+ 100% { opacity: 0; transform: translate3d(calc(var(--fx, 0px) * 0.4), calc(var(--fy, 0px) * 0.4), 0) scale(1); }
1779
+ }
1780
+
1781
+ @keyframes fizz-boil {
1782
+ 0% { opacity: 0; transform: translate3d(0, 0, 0) scale(0.4); }
1783
+ 20% { opacity: 0.95; }
1784
+ 70% { opacity: 0.7; }
1785
+ 100% { opacity: 0; transform: translate3d(var(--fx, 0px), var(--fy, 0px), 0) scale(1.15); }
1786
+ }
1787
+
1788
+ /* The burst holds its brightness through most of the flight on purpose: an
1789
+ opacity that eases out with the travel is over in ~100ms and reads as a
1790
+ flicker. */
1791
+ @keyframes fizz-burst {
1792
+ 0% { opacity: 0.3; transform: translate3d(0, 0, 0) scale(0.6); }
1793
+ 12% { opacity: 1; transform: translate3d(calc(var(--fx, 0px) * 0.4), calc(var(--fy, 0px) * 0.4), 0) scale(1.5); }
1794
+ 65% { opacity: 0.9; }
1795
+ 100% { opacity: 0; transform: translate3d(calc(var(--fx, 0px) * 2.6), calc(var(--fy, 0px) * 2.6), 0) scale(0.5); }
1796
+ }
1797
+
1798
+ /* Decoration, not information — a reduced-motion viewer loses nothing by losing
1799
+ it, and sixty looping bubbles is exactly what that setting is for.
1800
+ !important because this is an OFF SWITCH: the state rules above are more
1801
+ specific than any selector this media query can write, so without it the
1802
+ guard silently loses and a lively button keeps fizzing at someone who asked
1803
+ it not to. */
1804
+ @media (prefers-reduced-motion: reduce) {
1805
+ .hold-stack .fizz-bit,
1806
+ .hold-stack .hold-fizz-extra {
1807
+ animation: none !important;
1808
+ opacity: 0 !important;
1809
+ transition: none !important;
1810
+ }
1811
+ }