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 +4 -4
- data/CHANGELOG.md +47 -0
- data/app/assets/tailwind/studio_engine/engine-motion.css +558 -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 +5 -2
- data/app/views/studio/profiles/_save_bar.html.erb +0 -39
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2e0f0009a05bd6e06865465368969671524d772ab938b78622539d846532d387
|
|
4
|
+
data.tar.gz: cf267435b701685424683ce6031948498b52a834551762d6be055cd126b46625
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
+
}
|