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