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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +47 -0
- data/app/assets/tailwind/studio_engine/engine-motion.css +592 -0
- data/app/helpers/studio/fizz_helper.rb +125 -0
- data/app/views/studio/_fizz_layer.html.erb +16 -0
- data/app/views/studio/_hold_button.html.erb +405 -0
- data/app/views/studio/profiles/_identity.html.erb +23 -2
- data/app/views/studio/profiles/_identity_mini.html.erb +74 -23
- data/app/views/studio/profiles/_identity_styles.html.erb +144 -0
- data/app/views/studio/profiles/_save_controls.html.erb +79 -0
- data/app/views/studio/profiles/edit.html.erb +51 -25
- data/app/views/style/_tricks.html.erb +59 -0
- data/lib/studio/version.rb +1 -1
- metadata +6 -3
- data/app/views/studio/profiles/_save_bar.html.erb +0 -39
|
@@ -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
|
+
}
|