studio-engine 0.55.0 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 203b2f8cf1001d19c7ffb24c778ab0f1bdcb0b9aafa59762ded0ea5689eb2423
4
- data.tar.gz: a6dbf4164a79cd6b5de2a9cbee178e5455e55c619dfa7197f0db08ba398aaf5b
3
+ metadata.gz: 2e0f0009a05bd6e06865465368969671524d772ab938b78622539d846532d387
4
+ data.tar.gz: cf267435b701685424683ce6031948498b52a834551762d6be055cd126b46625
5
5
  SHA512:
6
- metadata.gz: 0bb5c9bebe4977fef13ab139729ebbc5c560b52f31e573d153e63a00a8a40b970519ac783ad51b45773661e182f5dc848e424b4922e5748117c29560fdfc2b20
7
- data.tar.gz: 4e16f3663bb43a61139cac420af9a2c265f91c5c5a6f708c32edf1ad5d1e92c6a559b545b148b4e134afe90d614a81de527906988a122b8326b9ae9ef881e6be
6
+ metadata.gz: 366f4b22dd590398e706f01b56b75ab5f82a60ca1bd6407f288a3898721cea9ab8914dc9e90c1865028ac3458ed299d9076d3768ae3bb3b86cb51ee26704fa70
7
+ data.tar.gz: a45734bb24a793ad91620694aa36457ce057ed12c86b1754cc3aa6a1365e552510fcbe2eb28932273a7a41e53c755f457395252d0f014e78ae6207dafd9fb5ee
data/CHANGELOG.md CHANGED
@@ -6,6 +6,53 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
6
6
 
7
7
  ### Added
8
8
 
9
+ - **The hold-to-confirm button, with its fizz — a new ACTION family in
10
+ `engine-motion.css`.** A press-and-hold CTA for an action a host does not want
11
+ taken by accident, ported from Turf Monster where it confirms a contest entry.
12
+ Render `studio/hold_button`; the specimens are on `/admin/style` (Tricks →
13
+ "Hold to confirm").
14
+
15
+ What ships:
16
+
17
+ - **`.hold-btn`** — the face and its five states: idle, `.process` (holding,
18
+ with the ring filling over `--duration`), `.loading` (submitted, spinning
19
+ arc), `.success` (confirmed, white check) and `.error` (blocked). The idle
20
+ face is a gradient down the primary ramp; the confirmed face is a deeper cut
21
+ of the SAME ramp, because an off-family success color reads as a jump to
22
+ some other button.
23
+ - **`.hold-stack` / `.hold-fizz` / `.fizz-bit`** — the carbonation. The
24
+ bubbles are the button's SIBLING, never its child: the button's `transform`
25
+ makes it a stacking context, so a bubble inside it can never get behind its
26
+ background. They show only where they escape its edges.
27
+ - **Six zones, three colors each.** The button is cut into a 3×2 grid and a
28
+ bubble only wears the colors of its own zone, so a six-item palette reads as
29
+ six things standing around the button. Bind `--fizz-c-1..18` (three per zone,
30
+ in order) on the stack — statically with the `fizz_colors:` local, or live
31
+ with `fizz_bind:` (an Alpine expression). Turf Monster's board binds the six
32
+ picked teams' light / dark / alt colors. Anything unbound falls back to the
33
+ bubble's own hue, so the effect is never colorless.
34
+ - **Two levels.** `fizz_level: :lively` (the default) rests at a full boil and
35
+ on hover DOUBLES THE BUBBLE COUNT — a second layer, seeded apart so it lands
36
+ in the first one's gaps, fades in over it. `fizz_level: :calm` simmers at
37
+ rest and boils on hover, one layer.
38
+ - **Per-instance theming without new CSS.** Every color reads a `--hold-*`
39
+ input before its default, so an ancestor can restyle one button or a page of
40
+ them: `--hold-bg-from` / `--hold-bg-mid` / `--hold-bg-to` for the resting
41
+ face, `--hold-success-from` / `--hold-success-to` / `--hold-success-glow-rgb`
42
+ for the confirmed one.
43
+ - **`prefers-reduced-motion` switches the bubbles off**, with `!important`,
44
+ because the state rules are more specific than anything the media query can
45
+ write — without it a lively button keeps fizzing at someone who asked it not
46
+ to.
47
+
48
+ Per-instance behavior travels as `data-*` on the button and is evaluated
49
+ against the surrounding `x-data` scope: `guard`, `validate` / `validate_at`,
50
+ `early_action` / `early_action_at` / `early_action_guard`, `on_hold_start` and
51
+ `on_success`. `Studio::FizzHelper` owns the bubble table and is seeded per
52
+ `hold_id`, so a button scatters identically on every render (a Turbo-restored
53
+ page matches the one it replaced) and two buttons on a page do not fizz in
54
+ lockstep.
55
+
9
56
  - **`.studio-team-glow` rings a host that paints its own background**, and can
10
57
  carry **two colors**.
11
58
 
@@ -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)
@@ -1251,3 +1255,557 @@ body.modal-open { overflow: hidden; }
1251
1255
  @media (prefers-reduced-motion: reduce) {
1252
1256
  .pulse-cta { animation: none; }
1253
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
+ }