studio-engine 0.55.0 → 0.56.1

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)
@@ -81,6 +85,15 @@
81
85
  `spin` is kept because it is byte-identical to Tailwind's built-in.
82
86
  */
83
87
 
88
+ /* CASCADE LAYER — read before removing.
89
+ Everything below sits in @layer components so a CONSUMER's own utility
90
+ keeps winning until it deliberately adopts the primitive. Unlayered CSS
91
+ outranks every layered rule, so an unlayered engine sheet silently
92
+ overrode a consumer's same-named utility class the moment its lock bumped —
93
+ nothing in the consumer's own repo had to change for its styling to move.
94
+ @property blocks stay OUTSIDE: they are global registrations, not rules. */
95
+
96
+ @layer components {
84
97
  /* =============================================================================
85
98
  1. studio-border-glow — mask-composite rainbow border glow
86
99
  Extracted verbatim from mcritchie-studio app/assets/tailwind/application.css.
@@ -519,7 +532,9 @@
519
532
  can carry two colors: --studio-team-glow-color-b defaults to the first, so a
520
533
  one-color caller sees no change, and a caller with a pair to show (a Pokémon's
521
534
  two types) gets one per wedge. The wedges are swept by animating a registered
535
+ }
522
536
  @property angle (rotating the GRADIENT, not the element — so no oversized-child
537
+ @layer components {
523
538
  clipping trick is needed). Opacity is a var (--studio-team-glow-opacity) with a
524
539
  transition, so a consumer can fade the glow in/out (the living style guide
525
540
  slides it between step cards as a modal's step machine advances).
@@ -558,11 +573,13 @@
558
573
  Tune: --studio-team-glow-color / -color-b / -opacity / -speed / -thickness /
559
574
  -bloom
560
575
  ========================================================================== */
576
+ }
561
577
  @property --studio-team-glow-angle {
562
578
  syntax: "<angle>";
563
579
  inherits: false;
564
580
  initial-value: 0deg;
565
581
  }
582
+ @layer components {
566
583
  .studio-team-glow {
567
584
  --studio-team-glow-color: var(--color-cta);
568
585
  /* The SECOND wedge's color. Defaults to the first, so a one-color caller sees
@@ -697,11 +714,13 @@
697
714
  Usage: <div class="conic-surface rounded-2xl p-8"> ...content... </div>
698
715
  Tune: --conic-surface-from / --conic-surface-to / --conic-surface-duration
699
716
  ========================================================================== */
717
+ }
700
718
  @property --conic-surface-angle {
701
719
  syntax: "<angle>";
702
720
  inherits: false;
703
721
  initial-value: 0deg;
704
722
  }
723
+ @layer components {
705
724
  .conic-surface {
706
725
  position: relative;
707
726
  isolation: isolate;
@@ -1035,12 +1054,14 @@
1035
1054
 
1036
1055
  /* Registered custom property — makes --bar-progress interpolatable so a single
1037
1056
  transition animates all five segment widths together (one ease, not five). */
1057
+ }
1038
1058
  @property --bar-progress {
1039
1059
  syntax: "<number>";
1040
1060
  initial-value: 0;
1041
1061
  inherits: true;
1042
1062
  }
1043
1063
 
1064
+ @layer components {
1044
1065
  /* The motion PROPERTIES (transition / animation) live on these classes — NOT on
1045
1066
  the view's inline style — precisely so the reduced-motion override below can
1046
1067
  reach and cancel them. An inline `style="transition: …"` beats a class rule on
@@ -1251,3 +1272,574 @@ body.modal-open { overflow: hidden; }
1251
1272
  @media (prefers-reduced-motion: reduce) {
1252
1273
  .pulse-cta { animation: none; }
1253
1274
  }
1275
+
1276
+ /* ── ACTION: hold-to-confirm ──────────────────────────────────────────────────
1277
+ A press-and-hold CTA and the carbonation behind it. Ported from turf-monster
1278
+ (2026-08), where the confirm-entry button is the last thing a player touches
1279
+ before money moves — a hold is the affordance for "mean it", and the fizz is
1280
+ what makes it worth looking at.
1281
+
1282
+ Markup contract (studio/_hold_button + studio/_fizz_layer):
1283
+
1284
+ span.hold-stack positioning + z-order context
1285
+ span.hold-fizz resting bubble layer (behind the button)
1286
+ span.hold-fizz.hold-fizz-extra hover-only layer (lively level only)
1287
+ button.hold-btn the button itself, painted OVER the bubbles
1288
+ div.hold-icon progress ring + tick
1289
+ ul.hold-text four sliding labels: idle/hold/done/blocked
1290
+ div.nudge-debug dev-only countdown to the next nudge
1291
+
1292
+ The face themes off ROLE tokens, so it wears each app's own brand: the
1293
+ resting gradient is the primary ramp, the confirmed state a deeper cut of the
1294
+ same ramp, and the blocked state the danger role. Every color also reads a
1295
+ `--hold-*` INPUT first, so an ancestor can re-theme one button (or a page of
1296
+ them) without touching this file — a declaration here would otherwise beat
1297
+ anything inherited.
1298
+
1299
+ States are JS-applied classes on the button: .process (holding), .loading
1300
+ (submitted, in flight), .success, .error, .nudge / .nudge-soft (the
1301
+ attention shake). The bubbles read them with :has(), because they are the
1302
+ button's SIBLING rather than its child — see the fizz section below. */
1303
+
1304
+ .hold-btn {
1305
+ --duration: 2000ms;
1306
+ --color: #f6f8ff;
1307
+ /* --bg is the button's SOLID color; .hold-icon::before punches the ring's
1308
+ hole with it, so it has to stay a flat value. The face wears --bg-image on
1309
+ top: lit from the top-left down into the deep end of the ramp, so the CTA
1310
+ reads as a raised surface instead of a paint chip. A state that wants a
1311
+ flat face (the transparent hold, the red error) sets --bg-image: none. */
1312
+ --bg: var(--color-cta);
1313
+ --bg-from: var(--hold-bg-from, rgb(var(--color-primary-400-rgb)));
1314
+ --bg-mid: var(--hold-bg-mid, rgb(var(--color-primary-rgb)));
1315
+ --bg-to: var(--hold-bg-to, rgb(var(--color-primary-700-rgb)));
1316
+ --bg-image: linear-gradient(160deg, var(--bg-from) 0%, var(--bg-mid) 45%, var(--bg-to) 100%);
1317
+ /* Confirmed stays in the SAME family as the face you pressed, dropped into
1318
+ the near-black end of the ramp. An off-family success color (turf shipped a
1319
+ mint/teal one for a year) reads as a jump to some other button, and a check
1320
+ drawn in that same color barely shows on it — hence the white tick below. */
1321
+ --success-from: var(--hold-success-from, rgb(var(--color-primary-rgb)));
1322
+ --success-to: var(--hold-success-to, rgb(var(--color-primary-900-rgb)));
1323
+ --success-glow-rgb: var(--hold-success-glow-rgb, var(--color-primary-800-rgb));
1324
+ --progress-border: var(--color-inset);
1325
+ --progress-active: #f6f8ff;
1326
+ --progress-success: #f6f8ff;
1327
+ --tick-stroke: var(--progress-active);
1328
+
1329
+ position: relative;
1330
+ width: 100%;
1331
+ padding: 14px 32px;
1332
+ font-size: 14px;
1333
+ font-weight: 600;
1334
+ line-height: 19px;
1335
+ border: 1px solid rgb(var(--color-primary-rgb) / 0.5);
1336
+ border-radius: 12px;
1337
+ outline: none;
1338
+ cursor: pointer;
1339
+ user-select: none;
1340
+ -webkit-tap-highlight-color: transparent;
1341
+ backface-visibility: hidden;
1342
+ color: var(--color);
1343
+ background: var(--bg);
1344
+ background-image: var(--bg-image, none);
1345
+ box-shadow: 0 0 20px rgb(var(--color-primary-rgb) / 0.25);
1346
+ transform: scale(var(--scale, 1));
1347
+ transition: transform 0.3s, box-shadow 0.3s, background 0.3s, border-color 0.3s;
1348
+ }
1349
+
1350
+ /* The attention shake. JS adds the class and removes it on animationend. */
1351
+ .hold-btn.nudge {
1352
+ animation: hold-nudge 0.5s ease-in-out 1;
1353
+ }
1354
+
1355
+ .hold-btn.nudge-soft {
1356
+ animation: hold-nudge-soft 0.4s ease-in-out 1;
1357
+ }
1358
+
1359
+ /* The progress ring: a fat-stroked circle whose dash array fills over
1360
+ --duration, with the button's own background punched through the middle. */
1361
+ .hold-btn > .hold-icon {
1362
+ position: absolute;
1363
+ top: 50%;
1364
+ left: 14px;
1365
+ width: 20px;
1366
+ height: 20px;
1367
+ margin-top: -10px;
1368
+ border-radius: 50%;
1369
+ background: var(--progress-border);
1370
+ opacity: var(--icon-o, 0);
1371
+ transform: translateX(var(--icon-x, -4px));
1372
+ transition: transform 0.3s, opacity 0.2s;
1373
+ }
1374
+
1375
+ .hold-btn > .hold-icon::before {
1376
+ content: '';
1377
+ position: absolute;
1378
+ top: 2px;
1379
+ left: 2px;
1380
+ z-index: 1;
1381
+ width: 16px;
1382
+ height: 16px;
1383
+ background: var(--bg);
1384
+ border-radius: inherit;
1385
+ transform: scale(var(--bg-scale, 1));
1386
+ transition: transform 0.32s ease;
1387
+ }
1388
+
1389
+ .hold-btn > .hold-icon svg {
1390
+ display: block;
1391
+ width: 20px;
1392
+ height: 20px;
1393
+ fill: none;
1394
+ }
1395
+
1396
+ .hold-btn > .hold-icon svg.progress {
1397
+ transform: rotate(-90deg) scale(var(--progress-scale, 1));
1398
+ transition: transform 0.5s ease;
1399
+ }
1400
+
1401
+ .hold-btn > .hold-icon svg.progress circle {
1402
+ stroke: var(--progress-active);
1403
+ stroke-width: 16;
1404
+ stroke-dashoffset: 1;
1405
+ stroke-dasharray: var(--progress-array, 0) 52;
1406
+ transition: stroke-dasharray var(--duration) linear;
1407
+ }
1408
+
1409
+ .hold-btn > .hold-icon svg.tick {
1410
+ position: absolute;
1411
+ top: 0;
1412
+ left: 0;
1413
+ stroke: var(--tick-stroke);
1414
+ stroke-width: 3;
1415
+ stroke-linecap: round;
1416
+ stroke-linejoin: round;
1417
+ transition: stroke 0.3s ease 0.7s;
1418
+ }
1419
+
1420
+ .hold-btn > .hold-icon svg.tick polyline {
1421
+ stroke-dasharray: 18 18 18;
1422
+ stroke-dashoffset: var(--tick-offset, 18);
1423
+ transition: stroke-dashoffset 0.4s ease 0.7s;
1424
+ }
1425
+
1426
+ /* Four labels stacked in one column; the state slides the column. */
1427
+ .hold-btn .hold-text {
1428
+ position: relative;
1429
+ margin: 0;
1430
+ padding: 0;
1431
+ list-style: none;
1432
+ text-align: center;
1433
+ pointer-events: none;
1434
+ backface-visibility: hidden;
1435
+ transform: translate3d(var(--ul-x, 0), 0, 0);
1436
+ transition: transform 0.3s;
1437
+ }
1438
+
1439
+ .hold-btn .hold-text li {
1440
+ backface-visibility: hidden;
1441
+ transform: translateY(var(--ul-y, 0));
1442
+ transition: transform 0.3s ease 0.16s, opacity 0.2s ease 0.16s;
1443
+ }
1444
+
1445
+ .hold-btn .hold-text li:not(:first-child) {
1446
+ position: absolute;
1447
+ left: 0;
1448
+ right: 0;
1449
+ }
1450
+
1451
+ .hold-btn .hold-text li:nth-child(1) {
1452
+ top: 0;
1453
+ opacity: var(--ul-o-1, 1);
1454
+ }
1455
+
1456
+ .hold-btn .hold-text li:nth-child(2) {
1457
+ top: 100%;
1458
+ opacity: var(--ul-o-2, 0);
1459
+ }
1460
+
1461
+ .hold-btn .hold-text li:nth-child(3) {
1462
+ top: 200%;
1463
+ opacity: var(--ul-o-3, 0);
1464
+ }
1465
+
1466
+ .hold-btn .hold-text li:nth-child(4) {
1467
+ top: 300%;
1468
+ opacity: var(--ul-o-4, 0);
1469
+ }
1470
+
1471
+ .hold-btn:hover:not(.process):not(.success) {
1472
+ filter: brightness(0.92);
1473
+ border-color: rgb(var(--color-primary-rgb) / 0.7);
1474
+ box-shadow: 0 0 30px rgb(var(--color-primary-rgb) / 0.4);
1475
+ }
1476
+
1477
+ .hold-btn:active:not(.success) {
1478
+ --scale: 0.97;
1479
+ }
1480
+
1481
+ /* Holding. The face goes flat and dark so the ring and the building glow have
1482
+ somewhere to read against; the gradient would fight both. */
1483
+ .hold-btn.process {
1484
+ --icon-x: 0;
1485
+ --icon-o: 1;
1486
+ --ul-x: 8px;
1487
+ --ul-y: -100%;
1488
+ --ul-o-1: 0;
1489
+ --ul-o-2: 1;
1490
+ --ul-o-3: 0;
1491
+ --progress-array: 52;
1492
+ --bg: var(--color-page);
1493
+ --bg-image: none;
1494
+ border-color: rgb(var(--color-primary-rgb) / 0.7);
1495
+ box-shadow:
1496
+ 0 0 30px rgb(var(--color-primary-rgb) / 0.45),
1497
+ 0 0 80px rgb(var(--color-primary-rgb) / 0.25),
1498
+ 0 0 120px rgb(var(--color-primary-rgb) / 0.1),
1499
+ inset 0 0 40px rgb(var(--color-primary-rgb) / 0.1);
1500
+ animation: none;
1501
+ transition: transform 0.3s, box-shadow 2.5s ease-out, border-color 1s, background 0.5s;
1502
+ }
1503
+
1504
+ /* Confirmed. */
1505
+ .hold-btn.success {
1506
+ --scale: 1.03;
1507
+ --icon-x: 6px;
1508
+ --icon-o: 1;
1509
+ --ul-x: 8px;
1510
+ --ul-y: -200%;
1511
+ --ul-o-1: 0;
1512
+ --ul-o-2: 0;
1513
+ --ul-o-3: 1;
1514
+ --progress-array: 52;
1515
+ --progress-border: none;
1516
+ --progress-scale: 0.11;
1517
+ --tick-stroke: var(--progress-success);
1518
+ --tick-offset: 36;
1519
+ --bg-scale: 0;
1520
+ --bg: var(--success-to);
1521
+ --bg-image: linear-gradient(160deg, var(--success-from) 0%, var(--success-to) 100%);
1522
+ --color: #fff;
1523
+ border-color: var(--success-from);
1524
+ box-shadow:
1525
+ 0 0 40px rgb(var(--success-glow-rgb) / 0.7),
1526
+ 0 0 100px rgb(var(--success-glow-rgb) / 0.4),
1527
+ 0 0 160px rgb(var(--success-glow-rgb) / 0.15);
1528
+ animation: hold-glow-pulse 0.8s ease-in-out 1;
1529
+ }
1530
+
1531
+ .hold-btn.success > .hold-icon svg.progress {
1532
+ animation: hold-tick 0.3s linear forwards 0.4s;
1533
+ }
1534
+
1535
+ /* Blocked. */
1536
+ .hold-btn.error {
1537
+ --icon-o: 0;
1538
+ --ul-x: 0;
1539
+ --ul-y: -300%;
1540
+ --ul-o-1: 0;
1541
+ --ul-o-2: 0;
1542
+ --ul-o-3: 0;
1543
+ --ul-o-4: 1;
1544
+ --bg: var(--color-danger);
1545
+ --bg-image: none;
1546
+ border-color: rgb(239 68 68 / 0.7);
1547
+ box-shadow: 0 0 30px rgb(239 68 68 / 0.4);
1548
+ animation: none;
1549
+ cursor: default;
1550
+ }
1551
+
1552
+ /* Submitted, waiting on the server. Keeps the hold's glow for continuity and
1553
+ swaps the filled ring for a spinning arc, so it reads "still working" rather
1554
+ than "done". */
1555
+ .hold-btn.loading {
1556
+ --icon-x: 0;
1557
+ --icon-o: 1;
1558
+ --ul-x: 8px;
1559
+ --ul-y: -100%;
1560
+ --ul-o-1: 0;
1561
+ --ul-o-2: 1;
1562
+ --ul-o-3: 0;
1563
+ --bg: var(--color-page);
1564
+ --bg-image: none;
1565
+ border-color: rgb(var(--color-primary-rgb) / 0.7);
1566
+ box-shadow:
1567
+ 0 0 30px rgb(var(--color-primary-rgb) / 0.45),
1568
+ 0 0 80px rgb(var(--color-primary-rgb) / 0.25),
1569
+ 0 0 120px rgb(var(--color-primary-rgb) / 0.1),
1570
+ inset 0 0 40px rgb(var(--color-primary-rgb) / 0.1);
1571
+ cursor: default;
1572
+ }
1573
+
1574
+ .hold-btn.loading > .hold-icon {
1575
+ background: transparent;
1576
+ }
1577
+
1578
+ .hold-btn.loading > .hold-icon::before {
1579
+ display: none;
1580
+ }
1581
+
1582
+ .hold-btn.loading > .hold-icon svg.progress {
1583
+ /* NOT the shared `spin` keyframe. `spin` declares only `to`, so its implicit
1584
+ `from` is whatever the element already carries — and this element carries
1585
+ `rotate(-90deg)` (the ring's 12-o'clock origin, set above). That made the
1586
+ loading icon sweep -90deg -> 360deg, i.e. 450deg per iteration, and snap
1587
+ back across a 90deg seam 1.25x a second. `spin` stays correct for `.spinner`,
1588
+ which has no base transform, so this needs its own keyframe rather than an
1589
+ edit there. The scale is carried through so the animation does not drop it. */
1590
+ animation: hold-spin-progress 0.8s linear infinite;
1591
+ transition: none;
1592
+ }
1593
+
1594
+ @keyframes hold-spin-progress {
1595
+ from {
1596
+ transform: rotate(-90deg) scale(var(--progress-scale, 1));
1597
+ }
1598
+ to {
1599
+ transform: rotate(270deg) scale(var(--progress-scale, 1));
1600
+ }
1601
+ }
1602
+
1603
+ .hold-btn.loading > .hold-icon svg.progress circle {
1604
+ stroke: rgb(var(--color-primary-rgb));
1605
+ stroke-width: 3;
1606
+ stroke-dasharray: 30 21;
1607
+ stroke-dashoffset: 0;
1608
+ transition: none;
1609
+ }
1610
+
1611
+ /* Dev-only countdown to the next nudge. Hidden unless the host is in dev mode
1612
+ (studio's .dev-mode body class), and always hidden once the hold starts. */
1613
+ .nudge-debug {
1614
+ position: absolute;
1615
+ top: 50%;
1616
+ right: 10px;
1617
+ width: 24px;
1618
+ height: 24px;
1619
+ display: none;
1620
+ pointer-events: none;
1621
+ transform: translateY(-50%);
1622
+ }
1623
+
1624
+ .dev-mode .nudge-debug {
1625
+ display: block;
1626
+ }
1627
+
1628
+ .nudge-debug svg {
1629
+ width: 24px;
1630
+ height: 24px;
1631
+ transform: rotate(-90deg);
1632
+ }
1633
+
1634
+ .nudge-debug svg circle.track {
1635
+ fill: none;
1636
+ stroke: rgb(255 255 255 / 0.15);
1637
+ stroke-width: 3;
1638
+ }
1639
+
1640
+ .nudge-debug svg circle.fill {
1641
+ fill: none;
1642
+ stroke: var(--color-cta);
1643
+ stroke-width: 3;
1644
+ stroke-linecap: round;
1645
+ stroke-dasharray: 56.55;
1646
+ stroke-dashoffset: 0;
1647
+ transition: stroke-dashoffset 0.3s linear;
1648
+ }
1649
+
1650
+ .nudge-debug .countdown-num {
1651
+ position: absolute;
1652
+ inset: 0;
1653
+ display: flex;
1654
+ align-items: center;
1655
+ justify-content: center;
1656
+ font-family: ui-monospace, monospace;
1657
+ font-size: 9px;
1658
+ font-weight: 800;
1659
+ color: #f6f8ff;
1660
+ }
1661
+
1662
+ .hold-btn.process .nudge-debug,
1663
+ .hold-btn.success .nudge-debug {
1664
+ opacity: 0;
1665
+ }
1666
+
1667
+ /* ── ACTION: the fizz ─────────────────────────────────────────────────────────
1668
+ Carbonation behind the button. The bubbles are the button's SIBLING, never
1669
+ its child: the button's `transform` makes it a stacking context, and a
1670
+ stacking context paints its own background BEFORE its negative-z children, so
1671
+ a bubble inside it can never get behind its face. As a sibling the button
1672
+ paints over them and they show only where they escape its edges. `isolation`
1673
+ keeps that z-order private to the stack.
1674
+
1675
+ The bubbles are placed and coloured by Studio::FizzHelper, which hands each
1676
+ one its drift, size, delay, cycle and colour as custom properties — so ONE
1677
+ keyframe set drives all of them, and amplitude is the STATE's job, expressed
1678
+ as a multiplier on the bubble's own drift.
1679
+
1680
+ ZONES + PALETTE. The button is cut into a 3x2 grid of zones, and a bubble
1681
+ only ever wears the colours of the zone it sits in, so a six-item palette
1682
+ reads as six things standing around the button rather than one shaken bag of
1683
+ confetti. Each zone owns three slots (--fizz-c-1..18): the resting layer
1684
+ wears the first, and the hover layer alternates the second and third.
1685
+ Nothing bound? Every bubble falls back to its own hue, so the effect is
1686
+ never colourless.
1687
+
1688
+ LEVELS. .hold-stack rests with one layer simmering. .fizz-lively rests at a
1689
+ full boil and, on hover, DOUBLES THE COUNT rather than the speed: a second
1690
+ layer (seeded apart, so it lands in the first one's gaps) fades in over it
1691
+ and fades back out when the pointer leaves. */
1692
+
1693
+ .hold-stack {
1694
+ position: relative;
1695
+ display: block;
1696
+ isolation: isolate;
1697
+ }
1698
+
1699
+ .hold-stack > .hold-btn {
1700
+ z-index: 1;
1701
+ }
1702
+
1703
+ .hold-stack > .hold-fizz {
1704
+ position: absolute;
1705
+ inset: 0;
1706
+ z-index: 0;
1707
+ pointer-events: none;
1708
+ }
1709
+
1710
+ .hold-stack .fizz-bit {
1711
+ position: absolute;
1712
+ width: var(--fs, 4px);
1713
+ height: var(--fs, 4px);
1714
+ margin-top: calc(var(--fs, 4px) / -2);
1715
+ margin-left: calc(var(--fs, 4px) / -2);
1716
+ border-radius: 100px;
1717
+ /* --fc resolves to this bubble's colour slot, or its own hue when the slot is
1718
+ unbound. The hairline rim keeps a near-black palette colour visible on a
1719
+ dark page. */
1720
+ background: var(--fc, hsl(292 92% 70%));
1721
+ box-shadow:
1722
+ 0 0 6px var(--fc, hsl(292 92% 70%)),
1723
+ 0 0 0 0.5px rgb(255 255 255 / 0.35);
1724
+ opacity: 0;
1725
+ will-change: transform, opacity;
1726
+ animation: fizz-simmer var(--ft, 1.8s) linear var(--fd, 0s) infinite;
1727
+ }
1728
+
1729
+ /* Hover and the nudge shake both mean "look at me" — boil for the moment.
1730
+ Calm only: lively rests at this boil already and answers hover its own way,
1731
+ below, so it must not pick this rule up (it outranks lively's rest rule). */
1732
+ .hold-stack:not(.fizz-lively):has(> .hold-btn:hover:not(.process):not(.loading):not(.success):not(.error)) .fizz-bit {
1733
+ animation: fizz-boil calc(var(--ft, 1.8s) * 0.62) linear calc(var(--fd, 0s) * 0.35) infinite;
1734
+ }
1735
+
1736
+ .hold-stack.fizz-lively .fizz-bit {
1737
+ animation: fizz-boil calc(var(--ft, 1.8s) * 0.62) linear calc(var(--fd, 0s) * 0.35) infinite;
1738
+ }
1739
+
1740
+ /* The hover layer keeps running while hidden. It has to: killing the animation
1741
+ would snap the bubbles to their base opacity: 0 and leave nothing to fade. */
1742
+ .hold-stack > .hold-fizz-extra {
1743
+ opacity: 0;
1744
+ transition: opacity 0.45s ease;
1745
+ }
1746
+
1747
+ /* The guard list is as much the point as the hover: a held, submitting,
1748
+ confirmed or blocked button speaks for itself, so the hover crowd fades away
1749
+ and leaves the ring and the burst to read cleanly. */
1750
+ .hold-stack.fizz-lively:has(> .hold-btn:hover:not(.process):not(.loading):not(.success):not(.error)) > .hold-fizz-extra {
1751
+ opacity: 1;
1752
+ }
1753
+
1754
+ .hold-stack:has(> .hold-btn.nudge) .fizz-bit,
1755
+ .hold-stack:has(> .hold-btn.nudge-soft) .fizz-bit {
1756
+ animation: fizz-boil calc(var(--ft, 1.8s) * 0.55) linear calc(var(--fd, 0s) * 0.25) infinite;
1757
+ }
1758
+
1759
+ .hold-stack:has(> .hold-btn.process) .fizz-bit,
1760
+ .hold-stack:has(> .hold-btn.loading) .fizz-bit {
1761
+ animation: fizz-boil calc(var(--ft, 1.8s) * 0.45) linear calc(var(--fd, 0s) * 0.2) infinite;
1762
+ }
1763
+
1764
+ .hold-stack:has(> .hold-btn.success) .fizz-bit {
1765
+ animation: fizz-burst 1.1s cubic-bezier(0.12, 0.72, 0.24, 1) forwards;
1766
+ }
1767
+
1768
+ .hold-stack:has(> .hold-btn.error) .fizz-bit {
1769
+ animation: none;
1770
+ opacity: 0;
1771
+ }
1772
+
1773
+ @keyframes hold-tick {
1774
+ 100% { transform: rotate(-90deg) translate(0, -5px) scale(var(--progress-scale)); }
1775
+ }
1776
+
1777
+ @keyframes hold-glow-pulse {
1778
+ 50% {
1779
+ box-shadow:
1780
+ 0 0 60px rgb(var(--success-glow-rgb) / 0.9),
1781
+ 0 0 140px rgb(var(--success-glow-rgb) / 0.5),
1782
+ 0 0 200px rgb(var(--success-glow-rgb) / 0.2);
1783
+ }
1784
+ }
1785
+
1786
+ @keyframes hold-nudge {
1787
+ 0%, 100% { transform: scale(1) rotate(0deg); }
1788
+ 15% { transform: scale(1.06) rotate(-2deg); }
1789
+ 30% { transform: scale(1.06) rotate(2deg); }
1790
+ 45% { transform: scale(1.04) rotate(-1.5deg); }
1791
+ 60% { transform: scale(1.03) rotate(1deg); }
1792
+ 75% { transform: scale(1.02) rotate(-0.5deg); }
1793
+ 90% { transform: scale(1.01) rotate(0.3deg); }
1794
+ }
1795
+
1796
+ @keyframes hold-nudge-soft {
1797
+ 0%, 100% { transform: scale(1) rotate(0deg); }
1798
+ 20% { transform: scale(1.03) rotate(-1deg); }
1799
+ 40% { transform: scale(1.03) rotate(1deg); }
1800
+ 60% { transform: scale(1.02) rotate(-0.5deg); }
1801
+ 80% { transform: scale(1.01) rotate(0.5deg); }
1802
+ }
1803
+
1804
+ /* One bubble's life. --fx/--fy are ITS drift; the multiplier is the state's
1805
+ intensity. Transform and opacity only, so every bubble stays on the
1806
+ compositor. */
1807
+ @keyframes fizz-simmer {
1808
+ 0% { opacity: 0; transform: translate3d(0, 0, 0) scale(0.5); }
1809
+ 30% { opacity: 0.4; }
1810
+ 70% { opacity: 0.24; }
1811
+ 100% { opacity: 0; transform: translate3d(calc(var(--fx, 0px) * 0.4), calc(var(--fy, 0px) * 0.4), 0) scale(1); }
1812
+ }
1813
+
1814
+ @keyframes fizz-boil {
1815
+ 0% { opacity: 0; transform: translate3d(0, 0, 0) scale(0.4); }
1816
+ 20% { opacity: 0.95; }
1817
+ 70% { opacity: 0.7; }
1818
+ 100% { opacity: 0; transform: translate3d(var(--fx, 0px), var(--fy, 0px), 0) scale(1.15); }
1819
+ }
1820
+
1821
+ /* The burst holds its brightness through most of the flight on purpose: an
1822
+ opacity that eases out with the travel is over in ~100ms and reads as a
1823
+ flicker. */
1824
+ @keyframes fizz-burst {
1825
+ 0% { opacity: 0.3; transform: translate3d(0, 0, 0) scale(0.6); }
1826
+ 12% { opacity: 1; transform: translate3d(calc(var(--fx, 0px) * 0.4), calc(var(--fy, 0px) * 0.4), 0) scale(1.5); }
1827
+ 65% { opacity: 0.9; }
1828
+ 100% { opacity: 0; transform: translate3d(calc(var(--fx, 0px) * 2.6), calc(var(--fy, 0px) * 2.6), 0) scale(0.5); }
1829
+ }
1830
+
1831
+ /* Decoration, not information — a reduced-motion viewer loses nothing by losing
1832
+ it, and sixty looping bubbles is exactly what that setting is for.
1833
+ !important because this is an OFF SWITCH: the state rules above are more
1834
+ specific than any selector this media query can write, so without it the
1835
+ guard silently loses and a lively button keeps fizzing at someone who asked
1836
+ it not to. */
1837
+ @media (prefers-reduced-motion: reduce) {
1838
+ .hold-stack .fizz-bit,
1839
+ .hold-stack .hold-fizz-extra {
1840
+ animation: none !important;
1841
+ opacity: 0 !important;
1842
+ transition: none !important;
1843
+ }
1844
+ }
1845
+ }