@pixerate/flair 0.9.0 → 0.10.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.
@@ -1465,14 +1465,535 @@ void mainImage(out vec4 fragColor, in vec2 fragCoord) {
1465
1465
  ]
1466
1466
  };
1467
1467
 
1468
+ // src/catalog/interaction.ts
1469
+ var r1 = (v) => Math.round(v * 10) / 10;
1470
+ var r2 = (v) => Math.round(v * 100) / 100;
1471
+ var n = (name) => `{{${name}}}`;
1472
+ var mixHex = (a, b, t) => {
1473
+ const x = Array.isArray(a) ? a : [1, 1, 1];
1474
+ const y = Array.isArray(b) ? b : [1, 1, 1];
1475
+ return rgbToHex([0, 1, 2].map((i) => x[i] + (y[i] - x[i]) * t));
1476
+ };
1477
+ function fit(text, width, max) {
1478
+ const e = Math.max(0.6, textWidthEm(String(text)));
1479
+ const size = Math.min(max, width / e);
1480
+ return { size: r1(size), length: r1(e * size) };
1481
+ }
1482
+ var about = (cx, cy, s) => `translate(${cx} ${cy}) scale(${r2(s)}) translate(${-cx} ${-cy})`;
1483
+ var snap = (id, attr, value, delay, step = 1) => ({
1484
+ id,
1485
+ step,
1486
+ attrs: { [attr]: [value, value] },
1487
+ delay,
1488
+ ms: 0
1489
+ });
1490
+ var SANS = `system-ui, -apple-system, 'Segoe UI', 'Helvetica Neue', Arial, sans-serif`;
1491
+ var HEART2 = "M12 20.5 C12 20.5 3 14.8 3 8.8 C3 6 5.2 3.8 8 3.8 C9.7 3.8 11.2 4.7 12 6.1 C12.8 4.7 14.3 3.8 16 3.8 C18.8 3.8 21 6 21 8.8 C21 14.8 12 20.5 12 20.5 Z";
1492
+ var PARTICLES = 8;
1493
+ var BURST_FROM = 22;
1494
+ var BURST_TO = 46;
1495
+ var HEART_X = 72;
1496
+ var HEART_Y = 60;
1497
+ var heartPose = (k) => `translate(${HEART_X} ${HEART_Y}) scale(${r2(2.2 * k)}) translate(-12 -12)`;
1498
+ var likeButton = {
1499
+ kind: "vector",
1500
+ id: "like-button",
1501
+ name: "Like button",
1502
+ description: "A heart that fills with a springy pop, a ring and a burst of particles as its like count ticks up, and empties again on the next click.",
1503
+ category: "interaction",
1504
+ tags: ["interactive", "ui", "click", "like", "heart", "toggle", "social", "micro-interaction"],
1505
+ trigger: "click",
1506
+ hold: 900,
1507
+ presets: [
1508
+ { name: "Dark", values: { background: [0.06, 0.07, 0.1], chip: [0.13, 0.14, 0.19], outline: [0.6, 0.63, 0.7], ink: [0.9, 0.91, 0.95] } },
1509
+ { name: "Purple", values: { heart: [0.6, 0.3, 1], accent: [1, 0.4, 0.75], count: 1204 } }
1510
+ ],
1511
+ derived: (v) => ({ liked: Number(v.count) + 1 }),
1512
+ motion: {
1513
+ svg: `<svg viewBox="0 0 240 120"><!-- {{count: int min=0 max=99999 default=42 label='Start count'}} {{background: color default=#FFFFFF label='Background'}} {{chip: color default=#F3F4F6 label='Button'}} {{outline: color default=#6B7280 label='Outline'}} {{heart: color default=#F43F5E label='Heart'}} {{accent: color default=#FB923C label='Sparks'}} {{ink: color default=#111827 label='Count colour'}} --><rect width="240" height="120" fill="{{background}}"/><rect x="20" y="24" width="200" height="72" rx="36" fill="{{chip}}"/><circle id="ring" cx="${HEART_X}" cy="${HEART_Y}" r="14" fill="none" stroke="{{heart}}" stroke-width="0"/>${Array.from(
1514
+ { length: PARTICLES },
1515
+ (_, k) => {
1516
+ const a = (k * 360 / PARTICLES - 90) * (Math.PI / 180);
1517
+ return `<circle id="p${k}" cx="${r1(HEART_X + BURST_FROM * Math.cos(a))}" cy="${r1(HEART_Y + BURST_FROM * Math.sin(a))}" r="0" fill="${k % 2 ? "{{accent}}" : "{{heart}}"}"/>`;
1518
+ }
1519
+ ).join("")}<g id="hg" transform="${heartPose(1)}"><path id="heartShape" d="${HEART2}" fill="{{heart}}" fill-opacity="0" stroke="{{outline}}" stroke-width="1.8" stroke-linejoin="round"/></g><text id="tally" x="106" y="70.5" font-family="${SANS}" font-size="30" font-weight="700" fill="{{ink}}">{{count}}</text></svg>`,
1520
+ animate: [
1521
+ // Step 1, like: squash, pop past full size and settle while it fills.
1522
+ { id: "hg", step: 1, attrs: { transform: [heartPose(1), heartPose(0.78)] }, ms: 110, ease: "out" },
1523
+ { id: "hg", step: 1, attrs: { transform: [heartPose(0.78), heartPose(1.22)] }, delay: 110, ms: 170, ease: "out" },
1524
+ { id: "hg", step: 1, attrs: { transform: [heartPose(1.22), heartPose(1)] }, delay: 280, ms: 220, ease: "in-out" },
1525
+ { id: "heartShape", step: 1, attrs: { "fill-opacity": [0, 1], stroke: ["{{outline}}", "{{heart}}"] }, delay: 110, ms: 120 },
1526
+ // The ring and sparks burst out, then snap back once they are gone.
1527
+ snap("ring", "stroke-width", 0, 0),
1528
+ { id: "ring", step: 1, attrs: { r: [14, 44], "stroke-width": [10, 0] }, delay: 130, ms: 480, ease: "out" },
1529
+ snap("ring", "r", 14, 700),
1530
+ ...Array.from({ length: PARTICLES }, (_, k) => {
1531
+ const a = (k * 360 / PARTICLES - 90) * (Math.PI / 180);
1532
+ const [x0, y0] = [r1(HEART_X + BURST_FROM * Math.cos(a)), r1(HEART_Y + BURST_FROM * Math.sin(a))];
1533
+ const [x1, y1] = [r1(HEART_X + BURST_TO * Math.cos(a)), r1(HEART_Y + BURST_TO * Math.sin(a))];
1534
+ return [
1535
+ snap(`p${k}`, "r", 0, 0),
1536
+ { id: `p${k}`, step: 1, attrs: { cx: [x0, x1], cy: [y0, y1], r: [4.5, 0] }, delay: 150, ms: 520, ease: "out" },
1537
+ snap(`p${k}`, "cx", x0, 700),
1538
+ snap(`p${k}`, "cy", y0, 700)
1539
+ ];
1540
+ }).flat(),
1541
+ { id: "tally", step: 1, count: [n("count"), n("liked")], attrs: { fill: ["{{ink}}", "{{heart}}"] }, delay: 150, ms: 260, ease: "out" },
1542
+ // Step 2, unlike: a small dip as it empties and the count goes back.
1543
+ { id: "hg", step: 2, attrs: { transform: [heartPose(1), heartPose(0.86)] }, ms: 120, ease: "out" },
1544
+ { id: "hg", step: 2, attrs: { transform: [heartPose(0.86), heartPose(1)] }, delay: 120, ms: 200, ease: "in-out" },
1545
+ { id: "heartShape", step: 2, attrs: { "fill-opacity": [1, 0], stroke: ["{{heart}}", "{{outline}}"] }, ms: 180 },
1546
+ { id: "tally", step: 2, count: [n("liked"), n("count")], attrs: { fill: ["{{heart}}", "{{ink}}"] }, ms: 220, ease: "out" }
1547
+ ]
1548
+ }
1549
+ };
1550
+ var toggleSwitch = {
1551
+ kind: "vector",
1552
+ id: "toggle-switch",
1553
+ name: "Toggle switch",
1554
+ description: "A switch whose knob slides across, squashing a little mid-slide, as the track blends to its on colour; the next click slides it back.",
1555
+ category: "interaction",
1556
+ tags: ["interactive", "ui", "click", "toggle", "switch", "settings", "micro-interaction"],
1557
+ trigger: "click",
1558
+ hold: 900,
1559
+ presets: [
1560
+ { name: "iOS", values: { on: [0.2, 0.78, 0.35] } },
1561
+ { name: "Dark", values: { background: [0.06, 0.07, 0.1], off: [0.22, 0.24, 0.3], on: [0.55, 0.45, 1], knob: [0.96, 0.96, 1] } }
1562
+ ],
1563
+ motion: (() => {
1564
+ const slide = (from, to, step) => [
1565
+ { id: "slide", step, attrs: { transform: [`translate(${from} 0)`, `translate(${to} 0)`] }, ms: 320, ease: "in-out" },
1566
+ // Stretched along the slide and flattened halfway, round again at the end.
1567
+ { id: "knob", step, attrs: { rx: [19, 24], ry: [19, 16.5] }, ms: 160, ease: "out" },
1568
+ { id: "knob", step, attrs: { rx: [24, 19], ry: [16.5, 19] }, delay: 160, ms: 160, ease: "in-out" }
1569
+ ];
1570
+ return {
1571
+ svg: `<svg viewBox="0 0 200 120"><!-- {{background: color default=#FFFFFF label='Background'}} {{off: color default=#D1D5DB label='Track off'}} {{on: color default=#6366F1 label='Track on'}} {{knob: color default=#FFFFFF label='Knob'}} --><rect width="200" height="120" fill="{{background}}"/><rect id="track" x="40" y="35" width="120" height="50" rx="25" fill="{{off}}"/><g id="slide" transform="translate(0 0)"><ellipse cx="65" cy="62.5" rx="18" ry="18" fill="#000000" opacity="0.16"/><ellipse id="knob" cx="65" cy="60" rx="19" ry="19" fill="{{knob}}"/></g></svg>`,
1572
+ animate: [
1573
+ ...slide(0, 70, 1),
1574
+ { id: "track", step: 1, attrs: { fill: ["{{off}}", "{{on}}"] }, ms: 320, ease: "in-out" },
1575
+ ...slide(70, 0, 2),
1576
+ { id: "track", step: 2, attrs: { fill: ["{{on}}", "{{off}}"] }, ms: 320, ease: "in-out" }
1577
+ ]
1578
+ };
1579
+ })()
1580
+ };
1581
+ var checkbox = {
1582
+ kind: "vector",
1583
+ id: "checkbox",
1584
+ name: "Checkbox",
1585
+ description: "A labelled checkbox that fills with a press and draws its tick on, then undraws it and empties on the next click.",
1586
+ category: "interaction",
1587
+ tags: ["interactive", "ui", "click", "checkbox", "form", "toggle", "todo", "micro-interaction"],
1588
+ trigger: "click",
1589
+ hold: 900,
1590
+ presets: [
1591
+ { name: "Green", values: { accent: [0.13, 0.7, 0.4] } },
1592
+ { name: "Dark", values: { background: [0.06, 0.07, 0.1], border: [0.4, 0.43, 0.5], ink: [0.92, 0.93, 0.96], accent: [0.95, 0.6, 0.15], tick: [0.06, 0.07, 0.1] } }
1593
+ ],
1594
+ derived: (v) => {
1595
+ const { size, length } = fit(v.label, 216, 24);
1596
+ return { size, length, base: r1(60 + size * 0.35) };
1597
+ },
1598
+ motion: {
1599
+ svg: `<svg viewBox="0 0 320 120"><!-- {{label: text default='Remember me' label='Label'}} {{background: color default=#FFFFFF label='Background'}} {{border: color default=#9CA3AF label='Border'}} {{accent: color default=#6366F1 label='Checked'}} {{tick: color default=#FFFFFF label='Tick'}} {{ink: color default=#111827 label='Label colour'}} --><rect width="320" height="120" fill="{{background}}"/><g id="boxg" transform="${about(48, 60, 1)}"><rect id="box" x="30" y="42" width="36" height="36" rx="9" fill="{{accent}}" fill-opacity="0" stroke="{{border}}" stroke-width="3"/><path id="tick" d="M39 60.5 L45.5 67 L58 53.5" fill="none" stroke="{{tick}}" stroke-width="4.5" stroke-linecap="round" stroke-linejoin="round" opacity="0"/></g><text x="84" y="{{base}}" textLength="{{length}}" lengthAdjust="spacingAndGlyphs" font-family="${SANS}" font-size="{{size}}" font-weight="500" fill="{{ink}}">{{label}}</text></svg>`,
1600
+ animate: [
1601
+ // Step 1, check: the box dips as it fills, then the tick draws on.
1602
+ { id: "boxg", step: 1, attrs: { transform: [about(48, 60, 1), about(48, 60, 0.86)] }, ms: 100, ease: "out" },
1603
+ { id: "boxg", step: 1, attrs: { transform: [about(48, 60, 0.86), about(48, 60, 1)] }, delay: 100, ms: 220, ease: "in-out" },
1604
+ { id: "box", step: 1, attrs: { "fill-opacity": [0, 1], stroke: ["{{border}}", "{{accent}}"] }, ms: 160 },
1605
+ // Shown only once it starts drawing: a round cap would leave a dot.
1606
+ { id: "tick", step: 1, attrs: { opacity: [0, 1] }, delay: 120, ms: 40 },
1607
+ { id: "tick", step: 1, draw: true, delay: 120, ms: 260, ease: "out" },
1608
+ // Step 2, uncheck: the tick undraws, then the box empties.
1609
+ { id: "tick", step: 2, draw: [1, 0], ms: 160, ease: "in-out" },
1610
+ { id: "tick", step: 2, attrs: { opacity: [1, 0] }, delay: 140, ms: 30 },
1611
+ { id: "box", step: 2, attrs: { "fill-opacity": [1, 0], stroke: ["{{accent}}", "{{border}}"] }, delay: 100, ms: 180 },
1612
+ { id: "boxg", step: 2, attrs: { transform: [about(48, 60, 1), about(48, 60, 0.92)] }, delay: 100, ms: 90, ease: "out" },
1613
+ { id: "boxg", step: 2, attrs: { transform: [about(48, 60, 0.92), about(48, 60, 1)] }, delay: 190, ms: 160, ease: "in-out" }
1614
+ ]
1615
+ }
1616
+ };
1617
+ function capsule(p, q, width, flatEnd = false) {
1618
+ const h = width / 2;
1619
+ const a = Math.atan2(q[1] - p[1], q[0] - p[0]);
1620
+ const pts = [];
1621
+ const cap = (c, from) => {
1622
+ for (let k = 0; k <= 8; k++) {
1623
+ const t = from + k * Math.PI / 8;
1624
+ pts.push([c[0] + h * Math.cos(t), c[1] + h * Math.sin(t)]);
1625
+ }
1626
+ };
1627
+ if (flatEnd) pts.push([q[0] + h * Math.cos(a - Math.PI / 2), q[1] + h * Math.sin(a - Math.PI / 2)], [q[0] + h * Math.cos(a + Math.PI / 2), q[1] + h * Math.sin(a + Math.PI / 2)]);
1628
+ else cap(q, a - Math.PI / 2);
1629
+ cap(p, a + Math.PI / 2);
1630
+ return `M${pts.map((pt) => `${r2(pt[0])} ${r2(pt[1])}`).join(" L")} Z`;
1631
+ }
1632
+ var menuMorphs = /* @__PURE__ */ new Map();
1633
+ function menuMorph(weight) {
1634
+ if (!menuMorphs.has(weight)) {
1635
+ const bars = [7, 12, 17].map((y) => capsule([5, y], [19, y], weight)).join(" ");
1636
+ const meet = (weight / 2 + 0.05) / Math.SQRT2;
1637
+ const cross = [
1638
+ capsule([6.5, 6.5], [17.5, 17.5], weight),
1639
+ capsule([17.5, 6.5], [12 + meet, 12 - meet], weight, true),
1640
+ capsule([6.5, 17.5], [12 - meet, 12 + meet], weight, true)
1641
+ ].join(" ");
1642
+ menuMorphs.set(weight, morphPaths(bars, cross, { points: 48 }));
1643
+ }
1644
+ return menuMorphs.get(weight);
1645
+ }
1646
+ var menuPose = (turn) => `translate(60 60) rotate(${turn}) scale(3) translate(-12 -12)`;
1647
+ var menuToggle = {
1648
+ kind: "vector",
1649
+ id: "menu-toggle",
1650
+ name: "Menu toggle",
1651
+ description: "A hamburger menu button whose three bars morph into a close cross as it spins half a turn, and back on the next click.",
1652
+ category: "interaction",
1653
+ tags: ["interactive", "ui", "click", "menu", "hamburger", "close", "morph", "navigation", "micro-interaction"],
1654
+ trigger: "click",
1655
+ hold: 900,
1656
+ presets: [
1657
+ { name: "Light", values: { background: [1, 1, 1], button: [0.95, 0.96, 0.97], active: [0.07, 0.09, 0.15], ink: [0.07, 0.09, 0.15], activeInk: [1, 1, 1] } },
1658
+ { name: "Bold", values: { weight: 3, button: [0.98, 0.8, 0.1], active: [0.95, 0.3, 0.2], ink: [0.1, 0.1, 0.1], activeInk: [1, 1, 1] } }
1659
+ ],
1660
+ derived: (v) => {
1661
+ const [bars, cross] = menuMorph(Math.round(Number(v.weight) * 4) / 4);
1662
+ return { bars, cross };
1663
+ },
1664
+ motion: {
1665
+ svg: `<svg viewBox="0 0 120 120"><!-- {{weight: float min=1 max=3.5 default=2 step=0.25 label='Line weight'}} {{background: color default=#0F172A label='Background'}} {{button: color default=#1E293B label='Button'}} {{active: color default=#6366F1 label='Button open'}} {{ink: color default=#F8FAFC label='Icon'}} {{activeInk: color default=#FFFFFF label='Icon open'}} --><rect width="120" height="120" fill="{{background}}"/><rect id="bg" x="16" y="16" width="88" height="88" rx="24" fill="{{button}}"/><g id="icon" transform="${menuPose(0)}"><path id="glyph" d="{{bars}}" fill="{{ink}}" fill-rule="evenodd"/></g></svg>`,
1666
+ animate: [
1667
+ // Step 1, open: bars to cross while turning half a turn.
1668
+ { id: "glyph", step: 1, attrs: { d: ["{{bars}}", "{{cross}}"], fill: ["{{ink}}", "{{activeInk}}"] }, ms: 450, ease: "in-out" },
1669
+ { id: "icon", step: 1, attrs: { transform: [menuPose(0), menuPose(180)] }, ms: 450, ease: "in-out" },
1670
+ { id: "bg", step: 1, attrs: { fill: ["{{button}}", "{{active}}"] }, ms: 300, ease: "in-out" },
1671
+ // Step 2, close: on round the same way (-180° looks like 180°), ending where it started.
1672
+ { id: "glyph", step: 2, attrs: { d: ["{{cross}}", "{{bars}}"], fill: ["{{activeInk}}", "{{ink}}"] }, ms: 450, ease: "in-out" },
1673
+ { id: "icon", step: 2, attrs: { transform: [menuPose(-180), menuPose(0)] }, ms: 450, ease: "in-out" },
1674
+ { id: "bg", step: 2, attrs: { fill: ["{{active}}", "{{button}}"] }, ms: 300, ease: "in-out" }
1675
+ ]
1676
+ }
1677
+ };
1678
+ var BOOKMARK = "M41 22 H79 Q84 22 84 27 V98 L60 81 L36 98 V27 Q36 22 41 22 Z";
1679
+ var bounce = (y) => `translate(0 ${y})`;
1680
+ var bookmark = {
1681
+ kind: "vector",
1682
+ id: "bookmark",
1683
+ name: "Bookmark",
1684
+ description: "A bookmark that hops and fills from top to bottom when saved, then drains and dips when unsaved.",
1685
+ category: "interaction",
1686
+ tags: ["interactive", "ui", "click", "bookmark", "save", "toggle", "micro-interaction"],
1687
+ trigger: "click",
1688
+ hold: 900,
1689
+ presets: [
1690
+ { name: "Gold", values: { fill: [0.98, 0.72, 0.1] } },
1691
+ { name: "Dark", values: { background: [0.06, 0.07, 0.1], outline: [0.6, 0.63, 0.7], fill: [0.4, 0.8, 1] } }
1692
+ ],
1693
+ motion: {
1694
+ svg: `<svg viewBox="0 0 120 120"><!-- {{background: color default=#FFFFFF label='Background'}} {{outline: color default=#6B7280 label='Outline'}} {{fill: color default=#6366F1 label='Saved'}} --><defs><clipPath id="fillClip"><rect id="level" x="30" y="18" width="60" height="0"/></clipPath></defs><rect width="120" height="120" fill="{{background}}"/><g id="mark" transform="${bounce(0)}"><path d="${BOOKMARK}" fill="{{fill}}" clip-path="url(#fillClip)"/><path id="edge" d="${BOOKMARK}" fill="none" stroke="{{outline}}" stroke-width="4" stroke-linejoin="round"/></g></svg>`,
1695
+ animate: [
1696
+ // Step 1, save: a hop, a drop past rest and a settle, filling top down.
1697
+ { id: "mark", step: 1, attrs: { transform: [bounce(0), bounce(-7)] }, ms: 130, ease: "out" },
1698
+ { id: "mark", step: 1, attrs: { transform: [bounce(-7), bounce(4)] }, delay: 130, ms: 170, ease: "in-out" },
1699
+ { id: "mark", step: 1, attrs: { transform: [bounce(4), bounce(0)] }, delay: 300, ms: 200, ease: "in-out" },
1700
+ { id: "level", step: 1, attrs: { height: [0, 84] }, delay: 60, ms: 380, ease: "in-out" },
1701
+ { id: "edge", step: 1, attrs: { stroke: ["{{outline}}", "{{fill}}"] }, ms: 200 },
1702
+ // Step 2, unsave: drains top down (then the empty clip snaps back up) with a small dip.
1703
+ { id: "level", step: 2, attrs: { y: [18, 102], height: [84, 0] }, ms: 320, ease: "in-out" },
1704
+ snap("level", "y", 18, 320, 2),
1705
+ { id: "mark", step: 2, attrs: { transform: [bounce(0), bounce(3)] }, ms: 120, ease: "out" },
1706
+ { id: "mark", step: 2, attrs: { transform: [bounce(3), bounce(0)] }, delay: 120, ms: 180, ease: "in-out" },
1707
+ { id: "edge", step: 2, attrs: { stroke: ["{{fill}}", "{{outline}}"] }, delay: 100, ms: 200 }
1708
+ ]
1709
+ }
1710
+ };
1711
+ var swapLabels = (outAt, back) => [
1712
+ { id: "label", step: 1, attrs: { opacity: [1, 0], transform: ["translate(0 0)", "translate(0 -10)"] }, delay: outAt, ms: 180, ease: "out" },
1713
+ { id: "done", step: 1, attrs: { opacity: [0, 1], transform: ["translate(0 10)", "translate(0 0)"] }, delay: outAt + 120, ms: 220, ease: "out" },
1714
+ { id: "done", step: 1, attrs: { opacity: [1, 0], transform: ["translate(0 0)", "translate(0 -10)"] }, delay: back, ms: 180, ease: "out" },
1715
+ { id: "label", step: 1, attrs: { opacity: [0, 1], transform: ["translate(0 10)", "translate(0 0)"] }, delay: back + 120, ms: 220, ease: "out" },
1716
+ snap("done", "transform", "translate(0 10)", back + 200)
1717
+ ];
1718
+ var checkFlash = (at, back) => [
1719
+ { id: "check", step: 1, attrs: { opacity: [0, 1] }, delay: at, ms: 40 },
1720
+ { id: "check", step: 1, draw: true, delay: at, ms: 280, ease: "out" },
1721
+ { id: "check", step: 1, attrs: { opacity: [1, 0] }, delay: back, ms: 160 },
1722
+ { id: "check", step: 1, draw: [0, 0], delay: back + 200, ms: 0 }
1723
+ ];
1724
+ var buttonLabels = (a, b, room, max) => {
1725
+ const ems = [a, b].map((t) => Math.max(0.6, textWidthEm(String(t))));
1726
+ const size = Math.min(max, room / Math.max(...ems));
1727
+ return { size: r1(size), base: r1(50 + size * 0.35), labelLen: r1(ems[0] * size), doneLen: r1(ems[1] * size) };
1728
+ };
1729
+ var labelTexts = (x) => `<text id="label" x="${x}" y="{{base}}" textLength="{{labelLen}}" lengthAdjust="spacingAndGlyphs" transform="translate(0 0)" font-family="${SANS}" font-size="{{size}}" font-weight="600" fill="{{ink}}">{{label}}</text><text id="done" x="${x}" y="{{base}}" textLength="{{doneLen}}" lengthAdjust="spacingAndGlyphs" transform="translate(0 10)" opacity="0" font-family="${SANS}" font-size="{{size}}" font-weight="600" fill="{{ink}}">{{doneText}}</text>`;
1730
+ var COPY_BACK = 1600;
1731
+ var copyPose = (s) => `translate(54 50) scale(${r2(1.2 * s)}) translate(-12 -12)`;
1732
+ var copyButton = {
1733
+ kind: "vector",
1734
+ id: "copy-button",
1735
+ name: "Copy button",
1736
+ description: "A copy button whose icon shrinks away for a drawn check and a \u201CCopied!\u201D label, turning back by itself a moment later.",
1737
+ category: "interaction",
1738
+ tags: ["interactive", "ui", "click", "copy", "clipboard", "feedback", "button", "micro-interaction"],
1739
+ trigger: "click",
1740
+ hold: 600,
1741
+ presets: [
1742
+ { name: "Light", values: { background: [1, 1, 1], button: [0.95, 0.96, 0.97], success: [0.86, 0.97, 0.9], ink: [0.07, 0.09, 0.15] } },
1743
+ { name: "Code", values: { label: "Copy code", doneText: "Copied to clipboard", button: [0.2, 0.22, 0.28] } }
1744
+ ],
1745
+ derived: (v) => buttonLabels(v.label, v.doneText, 126, 19),
1746
+ motion: {
1747
+ svg: `<svg viewBox="0 0 240 100"><!-- {{label: text default='Copy' label='Label'}} {{doneText: text default='Copied!' label='Copied label'}} {{background: color default=#0F172A label='Background'}} {{button: color default=#1E293B label='Button'}} {{success: color default=#166534 label='Copied'}} {{ink: color default=#F8FAFC label='Text and icon'}} --><rect width="240" height="100" fill="{{background}}"/><rect id="btn" x="20" y="24" width="200" height="52" rx="14" fill="{{button}}"/><g id="copy" transform="${copyPose(1)}" opacity="1" fill="none" stroke="{{ink}}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="8.5" y="8.5" width="11.5" height="11.5" rx="2.5"/><path d="M4.5 15.5 V6.5 C4.5 5.4 5.4 4.5 6.5 4.5 H15.5"/></g><g transform="${copyPose(1)}"><path id="check" d="M5 12.5 L10 17.5 L19 7" fill="none" stroke="{{ink}}" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" opacity="0"/></g>${labelTexts(80)}</svg>`,
1748
+ animate: [
1749
+ // One step that returns by itself: each click replays it.
1750
+ { id: "copy", step: 1, attrs: { transform: [copyPose(1), copyPose(0.3)], opacity: [1, 0] }, ms: 180, ease: "out" },
1751
+ { id: "btn", step: 1, attrs: { fill: ["{{button}}", "{{success}}"] }, ms: 200 },
1752
+ ...checkFlash(150, COPY_BACK),
1753
+ ...swapLabels(0, COPY_BACK),
1754
+ { id: "copy", step: 1, attrs: { transform: [copyPose(0.3), copyPose(1)], opacity: [0, 1] }, delay: COPY_BACK + 80, ms: 260, ease: "out" },
1755
+ { id: "btn", step: 1, attrs: { fill: ["{{success}}", "{{button}}"] }, delay: COPY_BACK, ms: 300 }
1756
+ ]
1757
+ }
1758
+ };
1759
+ var SEND_BACK = 1800;
1760
+ var planeScale = (s) => `scale(${r2(s)}) translate(-12 -12)`;
1761
+ var sendButton = {
1762
+ kind: "vector",
1763
+ id: "send-button",
1764
+ name: "Send button",
1765
+ description: "A send button whose paper plane winds up and flies off up and to the right as the label turns to \u201CSent\u201D with a check, then it resets.",
1766
+ category: "interaction",
1767
+ tags: ["interactive", "ui", "click", "send", "submit", "message", "button", "micro-interaction"],
1768
+ trigger: "click",
1769
+ hold: 600,
1770
+ presets: [
1771
+ { name: "Sky", values: { button: [0.05, 0.6, 0.9], success: [0.05, 0.45, 0.7] } },
1772
+ { name: "Mail", values: { label: "Send email", doneText: "Delivered", button: [0.9, 0.3, 0.25], success: [0.13, 0.6, 0.35] } }
1773
+ ],
1774
+ derived: (v) => buttonLabels(v.label, v.doneText, 120, 20),
1775
+ motion: {
1776
+ svg: `<svg viewBox="0 0 240 100"><!-- {{label: text default='Send' label='Label'}} {{doneText: text default='Sent' label='Sent label'}} {{background: color default=#FFFFFF label='Background'}} {{button: color default=#4F46E5 label='Button'}} {{success: color default=#16A34A label='Sent'}} {{ink: color default=#FFFFFF label='Text and icon'}} --><rect width="240" height="100" fill="{{background}}"/><rect id="btn" x="20" y="24" width="200" height="52" rx="26" fill="{{button}}"/><path id="flight" d="M56 50 C88 46 150 22 262 -36" fill="none"/><g transform="translate(56 50) scale(1.1) translate(-12 -12)"><path id="check" d="M5 12.5 L10 17.5 L19 7" fill="none" stroke="{{ink}}" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" opacity="0"/></g>${labelTexts(82)}<g id="plane" transform="translate(56 50)"><g id="planeIcon" transform="${planeScale(1.05)}" opacity="1"><path d="M22 2 L15 22 L11 13 L2 9 Z" fill="{{ink}}" stroke="{{ink}}" stroke-width="1.5" stroke-linejoin="round"/><path d="M22 2 L11 13" stroke="{{button}}" stroke-width="1.5" stroke-linecap="round"/></g></g></svg>`,
1777
+ animate: [
1778
+ // Wind up, then fly off along the curve, shrinking and fading.
1779
+ { id: "planeIcon", step: 1, attrs: { transform: [planeScale(1.05), planeScale(0.88)] }, ms: 120, ease: "out" },
1780
+ { id: "plane", step: 1, along: "flight", delay: 120, ms: 600, ease: "in-out" },
1781
+ { id: "planeIcon", step: 1, attrs: { transform: [planeScale(0.88), planeScale(0.5)] }, delay: 120, ms: 600, ease: "in-out" },
1782
+ { id: "planeIcon", step: 1, attrs: { opacity: [1, 0] }, delay: 520, ms: 200 },
1783
+ { id: "btn", step: 1, attrs: { fill: ["{{button}}", "{{success}}"] }, delay: 450, ms: 250 },
1784
+ ...swapLabels(150, SEND_BACK),
1785
+ ...checkFlash(500, SEND_BACK),
1786
+ // Back home: the plane pops in where it started.
1787
+ { id: "plane", step: 1, along: "flight", at: [0, 0], delay: SEND_BACK, ms: 0 },
1788
+ { id: "planeIcon", step: 1, attrs: { transform: [planeScale(0), planeScale(1.05)], opacity: [0, 1] }, delay: SEND_BACK + 100, ms: 300, ease: "out" },
1789
+ { id: "btn", step: 1, attrs: { fill: ["{{success}}", "{{button}}"] }, delay: SEND_BACK, ms: 300 }
1790
+ ]
1791
+ }
1792
+ };
1793
+ var [ARROW, RING] = morphPaths(
1794
+ "M10.6 4 H13.4 V11.6 H17.2 L12 17 L6.8 11.6 H10.6 Z M5 18.6 H19 V20.6 H5 Z",
1795
+ (() => {
1796
+ const circle2 = (r) => `M${Array.from({ length: 48 }, (_, k) => {
1797
+ const a = k / 48 * Math.PI * 2 - Math.PI / 2;
1798
+ return `${r2(12 + r * Math.cos(a))} ${r2(12 + r * Math.sin(a))}`;
1799
+ }).join(" L")} Z`;
1800
+ return `${circle2(9.6)} ${circle2(8)}`;
1801
+ })(),
1802
+ { points: 56 }
1803
+ );
1804
+ var RING_R = r2(8.8 * 2.2);
1805
+ var DOWNLOAD_BACK = 2300;
1806
+ var iconAt = (y) => `translate(60 ${y}) scale(2.2) translate(-12 -12)`;
1807
+ var downloadButton = {
1808
+ kind: "vector",
1809
+ id: "download-button",
1810
+ name: "Download button",
1811
+ description: "A round download button whose arrow drops and morphs into a progress ring that fills, ends on a check, then resets.",
1812
+ category: "interaction",
1813
+ tags: ["interactive", "ui", "click", "download", "progress", "morph", "button", "micro-interaction"],
1814
+ trigger: "click",
1815
+ hold: 600,
1816
+ presets: [
1817
+ { name: "Light", values: { background: [1, 1, 1], button: [0.93, 0.95, 1], ink: [0.31, 0.27, 0.9], track: [0.78, 0.8, 0.95], progress: [0.31, 0.27, 0.9], success: [0.86, 0.97, 0.9] } },
1818
+ { name: "Ocean", values: { button: [0.03, 0.4, 0.6], progress: [0.4, 0.95, 0.85], success: [0.05, 0.55, 0.5] } }
1819
+ ],
1820
+ motion: {
1821
+ svg: `<svg viewBox="0 0 120 120"><!-- {{background: color default=#0F172A label='Background'}} {{button: color default=#4F46E5 label='Button'}} {{success: color default=#16A34A label='Done'}} {{ink: color default=#FFFFFF label='Icon'}} {{track: color default=#818CF8 label='Ring track'}} {{progress: color default=#FFFFFF label='Progress'}} --><rect width="120" height="120" fill="{{background}}"/><circle id="btn" cx="60" cy="60" r="42" fill="{{button}}"/><g id="icon" transform="${iconAt(60)}"><path id="glyph" d="${ARROW}" fill="{{ink}}" fill-rule="evenodd"/></g><circle id="bar" cx="60" cy="60" r="${RING_R}" fill="none" stroke="{{progress}}" stroke-width="3.6" transform="rotate(-90 60 60)"/><g transform="translate(60 60) scale(1.5) translate(-12 -12)"><path id="check" d="M6 12.5 L10.5 17 L18.5 8" fill="none" stroke="{{ink}}" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" opacity="0"/></g></svg>`,
1822
+ animate: [
1823
+ // The arrow drops, then rises back as it morphs into the ring's track.
1824
+ { id: "icon", step: 1, attrs: { transform: [iconAt(60), iconAt(67)] }, ms: 180, ease: "out" },
1825
+ { id: "icon", step: 1, attrs: { transform: [iconAt(67), iconAt(60)] }, delay: 180, ms: 260, ease: "in-out" },
1826
+ { id: "glyph", step: 1, attrs: { d: [ARROW, RING], fill: ["{{ink}}", "{{track}}"] }, delay: 180, ms: 340, ease: "in-out" },
1827
+ // Progress round the ring (a flat-ended line, so nothing shows at 0), then a check.
1828
+ { id: "bar", step: 1, draw: true, delay: 520, ms: 900, ease: "in-out" },
1829
+ { id: "btn", step: 1, attrs: { fill: ["{{button}}", "{{success}}"] }, delay: 1400, ms: 250 },
1830
+ { id: "check", step: 1, attrs: { opacity: [0, 1] }, delay: 1450, ms: 40 },
1831
+ { id: "check", step: 1, draw: true, delay: 1450, ms: 300, ease: "out" },
1832
+ // Reset: check and ring fade, the track morphs back into the arrow.
1833
+ { id: "check", step: 1, attrs: { opacity: [1, 0] }, delay: DOWNLOAD_BACK, ms: 150 },
1834
+ { id: "bar", step: 1, attrs: { opacity: [1, 0] }, delay: DOWNLOAD_BACK, ms: 200 },
1835
+ { id: "check", step: 1, draw: [0, 0], delay: DOWNLOAD_BACK + 200, ms: 0 },
1836
+ { id: "bar", step: 1, draw: [0, 0], delay: DOWNLOAD_BACK + 200, ms: 0 },
1837
+ snap("bar", "opacity", 1, DOWNLOAD_BACK + 200),
1838
+ { id: "glyph", step: 1, attrs: { d: [RING, ARROW], fill: ["{{track}}", "{{ink}}"] }, delay: DOWNLOAD_BACK + 100, ms: 420, ease: "in-out" },
1839
+ { id: "btn", step: 1, attrs: { fill: ["{{success}}", "{{button}}"] }, delay: DOWNLOAD_BACK, ms: 300 }
1840
+ ]
1841
+ }
1842
+ };
1843
+ var linkHover = {
1844
+ kind: "vector",
1845
+ id: "link-hover",
1846
+ name: "Link hover",
1847
+ description: "A text link whose underline grows from left to right on hover, its arrow nudging right as the text changes colour.",
1848
+ category: "interaction",
1849
+ tags: ["interactive", "ui", "hover", "link", "underline", "navigation", "micro-interaction"],
1850
+ trigger: "hover",
1851
+ hold: 800,
1852
+ presets: [
1853
+ { name: "Dark", values: { background: [0.06, 0.07, 0.1], ink: [0.85, 0.87, 0.92], hover: [0.4, 0.85, 1] } },
1854
+ { name: "Editorial", values: { text: "Continue reading", ink: [0.1, 0.1, 0.1], hover: [0.85, 0.2, 0.15] } }
1855
+ ],
1856
+ // The text and its arrow centred together; the arrow is drawn round (0, 0) and placed.
1857
+ derived: (v) => {
1858
+ const { size, length } = fit(v.text, 230, 28);
1859
+ const total = length + size * 1.05;
1860
+ const left = 160 - total / 2;
1861
+ const base = 50 + size * 0.35;
1862
+ const ax = left + length + size * 0.35;
1863
+ const ay = base - size * 0.33;
1864
+ const s = size / 28;
1865
+ return {
1866
+ size,
1867
+ length,
1868
+ left: r1(left),
1869
+ base: r1(base),
1870
+ lineY: r1(base + size * 0.2),
1871
+ lineH: r1(Math.max(2, size * 0.08)),
1872
+ arrowAt: `translate(${r1(ax)} ${r1(ay)}) scale(${r2(s)})`,
1873
+ arrowTo: `translate(${r1(ax + size * 0.25)} ${r1(ay)}) scale(${r2(s)})`
1874
+ };
1875
+ },
1876
+ motion: {
1877
+ svg: `<svg viewBox="0 0 320 100"><!-- {{text: text default='Read the docs' label='Link text'}} {{background: color default=#FFFFFF label='Background'}} {{ink: color default=#1F2937 label='Text colour'}} {{hover: color default=#4F46E5 label='Hover colour'}} --><rect width="320" height="100" fill="{{background}}"/><text id="link" x="{{left}}" y="{{base}}" textLength="{{length}}" lengthAdjust="spacingAndGlyphs" font-family="${SANS}" font-size="{{size}}" font-weight="600" fill="{{ink}}">{{text}}</text><rect id="under" x="{{left}}" y="{{lineY}}" width="0" height="{{lineH}}" rx="1" fill="{{hover}}"/><g id="arrow" transform="{{arrowAt}}"><path id="arrowPath" d="M0 0 H18 M11 -7 L18 0 L11 7" fill="none" stroke="{{ink}}" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/></g></svg>`,
1878
+ animate: [
1879
+ { id: "link", attrs: { fill: ["{{ink}}", "{{hover}}"] }, ms: 220 },
1880
+ { id: "under", attrs: { width: [0, n("length")] }, ms: 320, ease: "out" },
1881
+ { id: "arrow", attrs: { transform: ["{{arrowAt}}", "{{arrowTo}}"] }, ms: 300, ease: "out" },
1882
+ { id: "arrowPath", attrs: { stroke: ["{{ink}}", "{{hover}}"] }, ms: 220 }
1883
+ ]
1884
+ }
1885
+ };
1886
+ var buttonHoverFill = {
1887
+ kind: "vector",
1888
+ id: "button-hover-fill",
1889
+ name: "Button hover fill",
1890
+ description: "An outlined pill button that fills with a slanted sweep from the left on hover, its label inverting colour as the fill passes.",
1891
+ category: "interaction",
1892
+ tags: ["interactive", "ui", "hover", "button", "fill", "cta", "micro-interaction"],
1893
+ trigger: "hover",
1894
+ hold: 800,
1895
+ presets: [
1896
+ { name: "Dark", values: { background: [0.06, 0.07, 0.1], surface: [0.06, 0.07, 0.1], accent: [0.98, 0.8, 0.2] } },
1897
+ { name: "Coral", values: { label: "Book a demo", accent: [0.96, 0.35, 0.3] } }
1898
+ ],
1899
+ derived: (v) => {
1900
+ const { size, length } = fit(v.label, 190, 22);
1901
+ return { size, length, base: r1(60 + size * 0.35) };
1902
+ },
1903
+ motion: (() => {
1904
+ const sweep = (x) => `M10 26 H${x} L${x - 24} 94 H10 Z`;
1905
+ const label = (fill, clip = "") => `<text x="160" y="{{base}}" text-anchor="middle" textLength="{{length}}" lengthAdjust="spacingAndGlyphs" font-family="${SANS}" font-size="{{size}}" font-weight="700" letter-spacing="0.5" fill="${fill}"${clip}>{{label}}</text>`;
1906
+ return {
1907
+ svg: `<svg viewBox="0 0 320 120"><!-- {{label: text default='Get started' label='Label'}} {{background: color default=#FFFFFF label='Background'}} {{surface: color default=#FFFFFF label='Button'}} {{accent: color default=#4F46E5 label='Accent'}} --><defs><clipPath id="swept"><path id="cover" d="${sweep(40)}"/></clipPath></defs><rect width="320" height="120" fill="{{background}}"/><g id="btn" transform="translate(0 0)"><rect x="40" y="30" width="240" height="60" rx="30" fill="{{surface}}" stroke="{{accent}}" stroke-width="2.5"/><rect x="40" y="30" width="240" height="60" rx="30" fill="{{accent}}" stroke="{{accent}}" stroke-width="2.5" clip-path="url(#swept)"/>${label("{{accent}}")}${label("{{surface}}", ` clip-path="url(#swept)"`)}</g></svg>`,
1908
+ animate: [
1909
+ { id: "cover", attrs: { d: [sweep(40), sweep(310)] }, ms: 380, ease: "in-out" },
1910
+ { id: "btn", attrs: { transform: ["translate(0 0)", "translate(0 -2)"] }, ms: 240, ease: "out" }
1911
+ ]
1912
+ };
1913
+ })()
1914
+ };
1915
+ var tipPose = (s, dy) => `translate(160 ${104 + dy}) scale(${r2(s)}) translate(-160 -104)`;
1916
+ var tooltipPop = {
1917
+ kind: "vector",
1918
+ id: "tooltip-pop",
1919
+ name: "Tooltip pop",
1920
+ description: "A small help badge that pops a tooltip bubble up above it on hover, rising and fading in with a slight overshoot.",
1921
+ category: "interaction",
1922
+ tags: ["interactive", "ui", "hover", "tooltip", "help", "popover", "micro-interaction"],
1923
+ trigger: "hover",
1924
+ hold: 800,
1925
+ presets: [
1926
+ { name: "Light", values: { background: [0.97, 0.97, 0.98], bubble: [1, 1, 1], tipInk: [0.1, 0.1, 0.15], badge: [0.6, 0.63, 0.7] } },
1927
+ { name: "Warning", values: { tip: "This can't be undone", accent: [0.95, 0.4, 0.15], bubble: [0.95, 0.4, 0.15], tipInk: [1, 1, 1] } }
1928
+ ],
1929
+ // The bubble is as wide as its text, centred over the badge.
1930
+ derived: (v) => {
1931
+ const { size, length } = fit(v.tip, 250, 15);
1932
+ const w = length + 28;
1933
+ return { size, length, bx: r1(160 - w / 2), bw: r1(w), tipY: r1(79 + size * 0.35) };
1934
+ },
1935
+ motion: {
1936
+ svg: `<svg viewBox="0 0 320 180"><!-- {{tip: text default='Saved to your library' label='Tooltip'}} {{background: color default=#0F172A label='Background'}} {{badge: color default=#475569 label='Badge'}} {{accent: color default=#6366F1 label='Badge hovered'}} {{bubble: color default=#F8FAFC label='Bubble'}} {{tipInk: color default=#0F172A label='Tooltip text'}} --><rect width="320" height="180" fill="{{background}}"/><g id="tip" transform="${tipPose(0.5, 8)}" opacity="0"><rect x="{{bx}}" y="60" width="{{bw}}" height="38" rx="10" fill="{{bubble}}"/><path d="M151 96 L160 105 L169 96 Z" fill="{{bubble}}"/><text x="160" y="{{tipY}}" text-anchor="middle" textLength="{{length}}" lengthAdjust="spacingAndGlyphs" font-family="${SANS}" font-size="{{size}}" font-weight="500" fill="{{tipInk}}">{{tip}}</text></g><g id="badgeG" transform="${about(160, 134, 1)}"><circle id="badge" cx="160" cy="134" r="18" fill="{{badge}}"/><text x="160" y="141" text-anchor="middle" font-family="${SANS}" font-size="20" font-weight="700" fill="#FFFFFF">?</text></g></svg>`,
1937
+ animate: [
1938
+ { id: "tip", attrs: { transform: [tipPose(0.5, 8), tipPose(1.07, -3)] }, ms: 190, ease: "out" },
1939
+ { id: "tip", attrs: { transform: [tipPose(1.07, -3), tipPose(1, 0)] }, delay: 190, ms: 150, ease: "in-out" },
1940
+ { id: "tip", attrs: { opacity: [0, 1] }, ms: 150 },
1941
+ { id: "badge", attrs: { fill: ["{{badge}}", "{{accent}}"] }, ms: 200 },
1942
+ { id: "badgeG", attrs: { transform: [about(160, 134, 1), about(160, 134, 1.08)] }, ms: 200, ease: "out" }
1943
+ ]
1944
+ }
1945
+ };
1946
+ var pressButton = {
1947
+ kind: "vector",
1948
+ id: "press-button",
1949
+ name: "Press button",
1950
+ description: "A chunky 3D button whose face pushes down onto its base while held, its shadow tightening, and springs back up on release.",
1951
+ category: "interaction",
1952
+ tags: ["interactive", "ui", "press", "button", "3d", "tactile", "micro-interaction"],
1953
+ trigger: "press",
1954
+ hold: 800,
1955
+ presets: [
1956
+ { name: "Arcade red", values: { face: [0.94, 0.27, 0.27], background: [0.1, 0.09, 0.12] } },
1957
+ { name: "Mint", values: { face: [0.3, 0.85, 0.6], ink: [0.05, 0.25, 0.15], label: "Let's go" } }
1958
+ ],
1959
+ derived: (v) => {
1960
+ const { size, length } = fit(v.label, 170, 22);
1961
+ return { size, length, base: r1(67 + size * 0.35), side: mixHex(v.face, [0, 0, 0], 0.35), pressed: mixHex(v.face, [0, 0, 0], 0.08) };
1962
+ },
1963
+ // Played backwards on release, the brief lift at the start of the press becomes a spring past rest at the end.
1964
+ motion: {
1965
+ svg: `<svg viewBox="0 0 320 140"><!-- {{label: text default='Press me' label='Label'}} {{background: color default=#F1F5F9 label='Background'}} {{face: color default=#6366F1 label='Button'}} {{ink: color default=#FFFFFF label='Label colour'}} {{shadow: color default=#0F172A label='Shadow'}} --><rect width="320" height="140" fill="{{background}}"/><ellipse id="shadow" cx="160" cy="114" rx="108" ry="9" fill="{{shadow}}" opacity="0.3"/><rect x="60" y="50" width="200" height="58" rx="16" fill="{{side}}"/><g id="faceG" transform="translate(0 0)"><rect id="faceRect" x="60" y="38" width="200" height="58" rx="16" fill="{{face}}"/><text x="160" y="{{base}}" text-anchor="middle" textLength="{{length}}" lengthAdjust="spacingAndGlyphs" font-family="${SANS}" font-size="{{size}}" font-weight="800" fill="{{ink}}">{{label}}</text></g></svg>`,
1966
+ animate: [
1967
+ { id: "faceG", attrs: { transform: ["translate(0 0)", "translate(0 -1.5)"] }, ms: 40, ease: "out" },
1968
+ { id: "faceG", attrs: { transform: ["translate(0 -1.5)", "translate(0 9)"] }, delay: 40, ms: 90, ease: "out" },
1969
+ { id: "shadow", attrs: { rx: [108, 96], ry: [9, 5], opacity: [0.3, 0.18] }, delay: 40, ms: 90, ease: "out" },
1970
+ { id: "faceRect", attrs: { fill: ["{{face}}", "{{pressed}}"] }, delay: 40, ms: 90 }
1971
+ ]
1972
+ }
1973
+ };
1974
+ var interactionEffects = [
1975
+ likeButton,
1976
+ toggleSwitch,
1977
+ checkbox,
1978
+ menuToggle,
1979
+ bookmark,
1980
+ copyButton,
1981
+ sendButton,
1982
+ downloadButton,
1983
+ linkHover,
1984
+ buttonHoverFill,
1985
+ tooltipPop,
1986
+ pressButton
1987
+ ];
1988
+
1468
1989
  // src/catalog/morph.ts
1469
1990
  var NUMBER = /-?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?/gi;
1470
1991
  var DEG = Math.PI / 180;
1471
- var r2 = (v) => {
1992
+ var r22 = (v) => {
1472
1993
  const r = Math.round(v * 100) / 100;
1473
1994
  return Object.is(r, -0) ? 0 : r;
1474
1995
  };
1475
- var n = (name) => `{{${name}}}`;
1996
+ var n2 = (name) => `{{${name}}}`;
1476
1997
  var pointsOf = (d) => {
1477
1998
  const nums = (d.match(NUMBER) ?? []).map(Number);
1478
1999
  return Array.from({ length: nums.length >> 1 }, (_, i) => [nums[2 * i], nums[2 * i + 1]]);
@@ -1482,10 +2003,10 @@ function mapPath(d, f) {
1482
2003
  let i = 0;
1483
2004
  return d.replace(NUMBER, () => {
1484
2005
  const k = i++;
1485
- return String(r2(moved[k >> 1][k % 2]));
2006
+ return String(r22(moved[k >> 1][k % 2]));
1486
2007
  });
1487
2008
  }
1488
- var polyline = (pts, close = false) => `M${pts.map((p) => `${r2(p[0])} ${r2(p[1])}`).join(" L")}${close ? " Z" : ""}`;
2009
+ var polyline = (pts, close = false) => `M${pts.map((p) => `${r22(p[0])} ${r22(p[1])}`).join(" L")}${close ? " Z" : ""}`;
1489
2010
  function arc(cx, cy, rx, ry, from, to, every = 8) {
1490
2011
  const steps = Math.max(1, Math.ceil(Math.abs(to - from) / every));
1491
2012
  return Array.from({ length: steps + 1 }, (_, k) => {
@@ -1532,20 +2053,20 @@ function around(c, r, from, to, avoid) {
1532
2053
  if (((avoid - from) % 360 + 360) % 360 < delta) delta -= 360;
1533
2054
  return arc(c[0], c[1], r, r, from, from + delta);
1534
2055
  }
1535
- function crescent(c1, r12, c2, r2_) {
2056
+ function crescent(c1, r13, c2, r2_) {
1536
2057
  const [dx, dy] = [c2[0] - c1[0], c2[1] - c1[1]];
1537
2058
  const d = Math.hypot(dx, dy);
1538
- const a = (r12 * r12 - r2_ * r2_ + d * d) / (2 * d);
1539
- const h = Math.sqrt(Math.max(0, r12 * r12 - a * a));
2059
+ const a = (r13 * r13 - r2_ * r2_ + d * d) / (2 * d);
2060
+ const h = Math.sqrt(Math.max(0, r13 * r13 - a * a));
1540
2061
  const [px, py] = [c1[0] + a * dx / d, c1[1] + a * dy / d];
1541
2062
  const i1 = [px - h * dy / d, py + h * dx / d];
1542
2063
  const i2 = [px + h * dy / d, py - h * dx / d];
1543
2064
  const angle = (c, p) => Math.atan2(p[1] - c[1], p[0] - c[0]) / DEG;
1544
- const outer = around(c1, r12, angle(c1, i1), angle(c1, i2), angle(c1, c2));
2065
+ const outer = around(c1, r13, angle(c1, i1), angle(c1, i2), angle(c1, c2));
1545
2066
  const inner = around(c2, r2_, angle(c2, i2), angle(c2, i1), angle(c2, c1) + 180);
1546
2067
  return polyline([...outer, ...inner.slice(1, -1)], true);
1547
2068
  }
1548
- var HEART2 = "M12 20.5 C12 20.5 3 14.8 3 8.8 C3 6 5.2 3.8 8 3.8 C9.7 3.8 11.2 4.7 12 6.1 C12.8 4.7 14.3 3.8 16 3.8 C18.8 3.8 21 6 21 8.8 C21 14.8 12 20.5 12 20.5 Z";
2069
+ var HEART3 = "M12 20.5 C12 20.5 3 14.8 3 8.8 C3 6 5.2 3.8 8 3.8 C9.7 3.8 11.2 4.7 12 6.1 C12.8 4.7 14.3 3.8 16 3.8 C18.8 3.8 21 6 21 8.8 C21 14.8 12 20.5 12 20.5 Z";
1549
2070
  var placed = (d, cx, cy, s) => mapPath(d, (p) => [cx + (p[0] - 12) * s, cy + (p[1] - 12) * s]);
1550
2071
  function fitted(d, box, points) {
1551
2072
  const outline = morphSequence([d], { points })[0];
@@ -1596,8 +2117,8 @@ var shapeMorph = {
1596
2117
  motion: {
1597
2118
  svg: `<svg viewBox="0 0 100 100"><!-- {{from: text default='${SHAPE_FROM}' label='From path'}} {{to: text default='${SHAPE_TO}' label='To path'}} {{fillFrom: color default=#6366F1 label='Fill (from)'}} {{fillTo: color default=#F59E0B label='Fill (to)'}} {{strokeFrom: color default=#C7D2FE label='Outline (from)'}} {{strokeTo: color default=#FEF3C7 label='Outline (to)'}} {{background: color default=#0F172A label='Background'}} {{width: float min=0 max=6 default=1.5 step=0.25 label='Outline width'}} {{morph: float min=200 max=3000 default=1000 step=50 label='Morph ms'}} {{pause: float min=0 max=3000 default=700 step=50 label='Pause ms'}} --><rect width="100" height="100" fill="{{background}}"/><path id="shape" d="{{a}}" fill="{{fillFrom}}" fill-rule="evenodd" stroke="{{strokeFrom}}" stroke-width="{{width}}" stroke-linejoin="round"/></svg>`,
1598
2119
  animate: [
1599
- { id: "shape", attrs: { d: ["{{a}}", "{{b}}"], fill: ["{{fillFrom}}", "{{fillTo}}"], stroke: ["{{strokeFrom}}", "{{strokeTo}}"] }, delay: n("pause"), ms: n("morph"), ease: "in-out" },
1600
- { id: "shape", attrs: { d: ["{{b}}", "{{a}}"], fill: ["{{fillTo}}", "{{fillFrom}}"], stroke: ["{{strokeTo}}", "{{strokeFrom}}"] }, delay: n("back"), ms: n("morph"), ease: "in-out" }
2120
+ { id: "shape", attrs: { d: ["{{a}}", "{{b}}"], fill: ["{{fillFrom}}", "{{fillTo}}"], stroke: ["{{strokeFrom}}", "{{strokeTo}}"] }, delay: n2("pause"), ms: n2("morph"), ease: "in-out" },
2121
+ { id: "shape", attrs: { d: ["{{b}}", "{{a}}"], fill: ["{{fillTo}}", "{{fillFrom}}"], stroke: ["{{strokeTo}}", "{{strokeFrom}}"] }, delay: n2("back"), ms: n2("morph"), ease: "in-out" }
1601
2122
  ]
1602
2123
  }
1603
2124
  };
@@ -1624,7 +2145,7 @@ var ICON_PAIRS = (() => {
1624
2145
  ],
1625
2146
  ["M10.7 5 H13.3 V10.7 H19 V13.3 H13.3 V19 H10.7 V13.3 H5 V10.7 H10.7 Z", stroke([[5, 12.5], [10, 17.5], [19, 7]], 2.6)],
1626
2147
  [sun.join(" "), crescent([11.5, 12.5], 8.5, [16.5, 7.5], 7)],
1627
- [`${HEART2} ${placed(HEART2, 12, 12, 0.7)}`, HEART2]
2148
+ [`${HEART3} ${placed(HEART3, 12, 12, 0.7)}`, HEART3]
1628
2149
  ];
1629
2150
  })();
1630
2151
  var iconMorphs = /* @__PURE__ */ new Map();
@@ -1665,7 +2186,7 @@ var iconToggle = {
1665
2186
  }
1666
2187
  };
1667
2188
  var SEQUENCE = morphSequence(
1668
- [circle(100, 100, 72), star(100, 104, 5, 82, 36), placed(HEART2, 100, 98, 8.4), star(100, 100, 3, 78, 78)],
2189
+ [circle(100, 100, 72), star(100, 104, 5, 82, 36), placed(HEART3, 100, 98, 8.4), star(100, 100, 3, 78, 78)],
1669
2190
  { points: 96 }
1670
2191
  );
1671
2192
  var morphSequenceEffect = {
@@ -1688,7 +2209,7 @@ var morphSequenceEffect = {
1688
2209
  svg: `<svg viewBox="0 0 200 200"><!-- {{c0: color default=#38BDF8 label='Circle'}} {{c1: color default=#FACC15 label='Star'}} {{c2: color default=#F43F5E label='Heart'}} {{c3: color default=#A78BFA label='Hexagon'}} {{background: color default=#0F172A label='Background'}} {{morph: float min=200 max=3000 default=900 step=50 label='Morph ms'}} {{pause: float min=0 max=3000 default=500 step=50 label='Pause ms'}} --><rect width="200" height="200" fill="{{background}}"/><path id="shape" d="${SEQUENCE[0]}" fill="{{c0}}" fill-rule="evenodd"/></svg>`,
1689
2210
  animate: SEQUENCE.map((d, i) => {
1690
2211
  const next = (i + 1) % SEQUENCE.length;
1691
- return { id: "shape", attrs: { d: [d, SEQUENCE[next]], fill: [`{{c${i}}}`, `{{c${next}}}`] }, delay: n(`at${i}`), ms: n("morph"), ease: "in-out" };
2212
+ return { id: "shape", attrs: { d: [d, SEQUENCE[next]], fill: [`{{c${i}}}`, `{{c${next}}}`] }, delay: n2(`at${i}`), ms: n2("morph"), ease: "in-out" };
1692
2213
  })
1693
2214
  }
1694
2215
  };
@@ -1729,13 +2250,13 @@ var gooeyMorph = {
1729
2250
  motion: {
1730
2251
  svg: `<svg viewBox="0 0 200 200"><!-- {{colorA: color default=#22D3EE label='Colour A'}} {{colorB: color default=#A855F7 label='Colour B'}} {{background: color default=#0B0B1A label='Background'}} {{goo: float min=1 max=14 default=7 step=0.5 label='Gooeyness'}} {{sharp: float min=8 max=40 default=20 step=1 label='Edge sharpness'}} {{morph: float min=300 max=3000 default=1200 step=50 label='Morph ms'}} {{pause: float min=0 max=3000 default=400 step=50 label='Pause ms'}} --><defs><linearGradient id="gooFill" gradientUnits="userSpaceOnUse" x1="30" y1="30" x2="170" y2="170"><stop offset="0" stop-color="{{colorA}}"/><stop offset="1" stop-color="{{colorB}}"/></linearGradient><filter id="goo" x="-20%" y="-20%" width="140%" height="140%"><feGaussianBlur in="SourceGraphic" stdDeviation="{{goo}}" result="soft"/><feColorMatrix in="soft" type="matrix" values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 {{sharp}} {{threshold}}"/></filter></defs><rect width="200" height="200" fill="{{background}}"/><g filter="url(#goo)" fill="url(#gooFill)">${GOO.map((stages, i) => `<path id="goo${i}" d="${stages[0]}" fill-rule="evenodd"/>`).join("")}</g></svg>`,
1731
2252
  animate: GOO.flatMap(
1732
- (stages, i) => stages.map((d, s) => ({ id: `goo${i}`, attrs: { d: [d, stages[(s + 1) % stages.length]] }, delay: n(`at${s}`), ms: n("morph"), ease: "in-out" }))
2253
+ (stages, i) => stages.map((d, s) => ({ id: `goo${i}`, attrs: { d: [d, stages[(s + 1) % stages.length]] }, delay: n2(`at${s}`), ms: n2("morph"), ease: "in-out" }))
1733
2254
  )
1734
2255
  }
1735
2256
  };
1736
2257
  var DOTS = 48;
1737
2258
  var DOT_STAGGER = 14;
1738
- var PARTICLE_FROM = HEART2;
2259
+ var PARTICLE_FROM = HEART3;
1739
2260
  var PARTICLE_TO = star(12, 12, 5, 10, 4.2);
1740
2261
  var noise = (i) => {
1741
2262
  const x = Math.sin(i * 12.9898 + 4.1414) * 43758.5453;
@@ -1764,15 +2285,15 @@ var particleMorph = {
1764
2285
  for (let i = 0; i < DOTS; i++) {
1765
2286
  const k = Math.floor(i * pa.length / DOTS);
1766
2287
  const [p, q] = [pa[k], pb[k]];
1767
- out[`ax${i}`] = r2(p[0]);
1768
- out[`ay${i}`] = r2(p[1]);
1769
- out[`bx${i}`] = r2(q[0]);
1770
- out[`by${i}`] = r2(q[1]);
2288
+ out[`ax${i}`] = r22(p[0]);
2289
+ out[`ay${i}`] = r22(p[1]);
2290
+ out[`bx${i}`] = r22(q[0]);
2291
+ out[`by${i}`] = r22(q[1]);
1771
2292
  for (const [name, seed] of [["s", i], ["t", i + DOTS]]) {
1772
2293
  const angle = seed * 2.39996;
1773
2294
  const far = scatter * (0.45 + 0.55 * noise(seed));
1774
- out[`${name}x${i}`] = r2((p[0] + q[0]) / 2 + far * Math.cos(angle));
1775
- out[`${name}y${i}`] = r2((p[1] + q[1]) / 2 + far * Math.sin(angle));
2295
+ out[`${name}x${i}`] = r22((p[0] + q[0]) / 2 + far * Math.cos(angle));
2296
+ out[`${name}y${i}`] = r22((p[1] + q[1]) / 2 + far * Math.sin(angle));
1776
2297
  }
1777
2298
  }
1778
2299
  return out;
@@ -1781,10 +2302,10 @@ var particleMorph = {
1781
2302
  const back = 3e3;
1782
2303
  const spread = DOTS * DOT_STAGGER;
1783
2304
  const leg = (i, at, from, mid, to, colours) => [
1784
- { id: `p${i}`, attrs: { cx: [n(`${from}x${i}`), n(`${mid}x${i}`)], cy: [n(`${from}y${i}`), n(`${mid}y${i}`)] }, delay: at + i * DOT_STAGGER, ms: 450, ease: "out" },
1785
- { id: `p${i}`, attrs: { cx: [n(`${mid}x${i}`), n(`${to}x${i}`)], cy: [n(`${mid}y${i}`), n(`${to}y${i}`)], fill: colours }, delay: at + 450 + i * DOT_STAGGER, ms: 650, ease: "in-out" }
2305
+ { id: `p${i}`, attrs: { cx: [n2(`${from}x${i}`), n2(`${mid}x${i}`)], cy: [n2(`${from}y${i}`), n2(`${mid}y${i}`)] }, delay: at + i * DOT_STAGGER, ms: 450, ease: "out" },
2306
+ { id: `p${i}`, attrs: { cx: [n2(`${mid}x${i}`), n2(`${to}x${i}`)], cy: [n2(`${mid}y${i}`), n2(`${to}y${i}`)], fill: colours }, delay: at + 450 + i * DOT_STAGGER, ms: 650, ease: "in-out" }
1786
2307
  ];
1787
- const fade = (id, at, show) => ({ id, attrs: { opacity: show ? [0, n("ghost")] : [n("ghost"), 0] }, delay: at, ms: 450, ease: "in-out" });
2308
+ const fade = (id, at, show) => ({ id, attrs: { opacity: show ? [0, n2("ghost")] : [n2("ghost"), 0] }, delay: at, ms: 450, ease: "in-out" });
1788
2309
  return {
1789
2310
  svg: `<svg viewBox="0 0 200 200"><!-- {{from: text default='${PARTICLE_FROM}' label='From path'}} {{to: text default='${PARTICLE_TO}' label='To path'}} {{fromColor: color default=#F472B6 label='From colour'}} {{toColor: color default=#FBBF24 label='To colour'}} {{background: color default=#0C0A1D label='Background'}} {{size: float min=0.8 max=6 default=2.6 step=0.1 label='Dot size'}} {{scatter: float min=0 max=80 default=34 step=1 label='Scatter'}} {{ghost: float min=0 max=1 default=0.16 label='Shape fill'}} --><rect width="200" height="200" fill="{{background}}"/><path id="ghostA" d="{{a}}" fill="{{fromColor}}" fill-rule="evenodd" opacity="{{ghost}}"/><path id="ghostB" d="{{b}}" fill="{{toColor}}" fill-rule="evenodd" opacity="0"/>${Array.from(
1790
2311
  { length: DOTS },
@@ -1845,11 +2366,11 @@ var digitMorph = {
1845
2366
  const left = 200 - (used * 22 - 2) * scale / 2;
1846
2367
  const interval = Number(v.morph) + DIGIT_REST;
1847
2368
  const lastEnd = Math.max(0, steps - 1) * interval + Number(v.morph);
1848
- const out = { scale: r2(scale), y: r2(100 - 16 * scale), back: lastEnd + DIGIT_REST * 3 };
2369
+ const out = { scale: r22(scale), y: r22(100 - 16 * scale), back: lastEnd + DIGIT_REST * 3 };
1849
2370
  for (let k = 1; k <= MAX_STEPS; k++) out[`t${k}`] = k <= steps ? (k - 1) * interval : lastEnd;
1850
2371
  for (let j = 0; j < SLOTS; j++) {
1851
2372
  const place = j - (SLOTS - used);
1852
- out[`x${j}`] = r2(left + Math.max(0, place) * 22 * scale);
2373
+ out[`x${j}`] = r22(left + Math.max(0, place) * 22 * scale);
1853
2374
  const glyphs = values.map((x) => {
1854
2375
  const text = String(x);
1855
2376
  const at = text.length - (used - place);
@@ -1872,15 +2393,575 @@ var digitMorph = {
1872
2393
  animate: Array.from({ length: SLOTS }, (_, j) => [
1873
2394
  ...Array.from({ length: MAX_STEPS }, (_2, i) => {
1874
2395
  const k = i + 1;
1875
- return { id: `s${j}`, attrs: { d: [`{{d${j}_${k - 1}}}`, `{{d${j}_${k}}}`], opacity: [n(`o${j}_${k - 1}`), n(`o${j}_${k}`)] }, delay: n(`t${k}`), ms: n("morph"), ease: "in-out" };
2396
+ return { id: `s${j}`, attrs: { d: [`{{d${j}_${k - 1}}}`, `{{d${j}_${k}}}`], opacity: [n2(`o${j}_${k - 1}`), n2(`o${j}_${k}`)] }, delay: n2(`t${k}`), ms: n2("morph"), ease: "in-out" };
1876
2397
  }),
1877
2398
  // Back to the first number in one morph, so it loops.
1878
- { id: `s${j}`, attrs: { d: [`{{d${j}_${MAX_STEPS}}}`, `{{d${j}_0}}`], opacity: [n(`o${j}_${MAX_STEPS}`), n(`o${j}_0`)] }, delay: n("back"), ms: n("morph"), ease: "in-out" }
2399
+ { id: `s${j}`, attrs: { d: [`{{d${j}_${MAX_STEPS}}}`, `{{d${j}_0}}`], opacity: [n2(`o${j}_${MAX_STEPS}`), n2(`o${j}_0`)] }, delay: n2("back"), ms: n2("morph"), ease: "in-out" }
1879
2400
  ]).flat()
1880
2401
  }
1881
2402
  };
1882
2403
  var morphEffects = [shapeMorph, iconToggle, morphSequenceEffect, gooeyMorph, particleMorph, digitMorph];
1883
2404
 
2405
+ // src/catalog/shaders/interaction.ts
2406
+ var CLICKS = `
2407
+ vec4 clickAt(int i, bool demo) {
2408
+ if (!demo || iClicks[0].w > 0.5) return iClicks[i];
2409
+ float k = floor(iTime / 2.0) - float(i);
2410
+ vec2 h = hash22(vec2(k * 1.37 + 0.5, 3.7));
2411
+ return vec4((0.15 + 0.7 * h) * iResolution.xy, k * 2.0, 1.0);
2412
+ }`.trim();
2413
+ var HOVER = `
2414
+ vec3 hoverPoint(bool demo, float idle) {
2415
+ vec2 drift = iResolution.xy * (0.5 + vec2(0.28 * sin(iTime * 0.63), 0.22 * sin(iTime * 0.91 + 1.3)));
2416
+ float h = clamp(iPointer.z, 0.0, 1.0);
2417
+ return vec3(mix(drift, iPointer.xy, h), mix(demo ? idle : 0.0, 1.0, h));
2418
+ }`.trim();
2419
+ var GLOW = `
2420
+ vec4 glow(vec3 c) {
2421
+ c = 1.0 - exp(-max(c, 0.0));
2422
+ float a = max(c.r, max(c.g, c.b));
2423
+ return vec4(c / max(a, 1e-4), a);
2424
+ }`.trim();
2425
+ var OVER = `
2426
+ vec4 over(vec4 acc, vec3 c, float a) {
2427
+ return vec4(acc.rgb * (1.0 - a) + c * a, acc.a * (1.0 - a) + a);
2428
+ }
2429
+ vec4 unpremultiply(vec4 acc) {
2430
+ return vec4(acc.rgb / max(acc.a, 1e-4), acc.a);
2431
+ }`.trim();
2432
+ var clickRipple = {
2433
+ kind: "shader",
2434
+ id: "click-ripple",
2435
+ name: "Click ripple",
2436
+ description: "Rings of water spreading out from every click and fading as they go.",
2437
+ category: "interaction",
2438
+ tags: ["interactive", "click", "overlay", "water", "ripple"],
2439
+ transparent: true,
2440
+ source: glsl(
2441
+ `// Click ripple: water rings spreading from each click (up to 8 at once).
2442
+ uniform vec3 uColor; // @control color default=#BFE6FF label="Colour"
2443
+ uniform float uSpeed; // @control min=0.05 max=2 default=0.5 label="Speed"
2444
+ uniform float uWidth; // @control min=0.002 max=0.05 default=0.012 label="Ring width"
2445
+ uniform float uLife; // @control min=0.3 max=6 default=2 label="Lifetime"
2446
+ uniform int uRings; // @control min=1 max=4 default=3 label="Rings per click"
2447
+ uniform bool uDemo; // @control default=true label="Demo when idle"`,
2448
+ HASH,
2449
+ CLICKS,
2450
+ OVER,
2451
+ `void mainImage(out vec4 fragColor, in vec2 fragCoord) {
2452
+ float px = 1.0 / iResolution.y;
2453
+ vec4 acc = vec4(0.0);
2454
+ for (int i = 7; i >= 0; i--) {
2455
+ vec4 c = clickAt(i, uDemo);
2456
+ if (c.w < 0.5) continue;
2457
+ float age = iTime - c.z;
2458
+ if (age < 0.0 || age > uLife) continue;
2459
+ float life = age / uLife;
2460
+ float fade = (1.0 - life) * (1.0 - life);
2461
+ float d = length(fragCoord - c.xy) * px;
2462
+ for (int j = 0; j < 4; j++) {
2463
+ if (j >= uRings) break;
2464
+ float r = (age - float(j) * 0.16) * uSpeed;
2465
+ if (r <= 0.0) continue;
2466
+ // Rings widen and soften as they spread.
2467
+ float w = max(uWidth * (1.0 + life * 1.5), 1.5 * px);
2468
+ float x = (d - r) / w;
2469
+ float k = fade * (1.0 - 0.28 * float(j)) * smoothstep(0.0, 0.05, r);
2470
+ // A bright crest with a darker trough just inside it.
2471
+ float crest = exp(-x * x * 2.0);
2472
+ float trough = exp(-(x + 1.6) * (x + 1.6) * 2.0);
2473
+ acc = over(acc, uColor * 0.15, trough * k * 0.35);
2474
+ acc = over(acc, mix(uColor, vec3(1.0), 0.5 * crest), crest * k);
2475
+ }
2476
+ }
2477
+ fragColor = unpremultiply(acc);
2478
+ }`
2479
+ ),
2480
+ presets: [
2481
+ { name: "Sonar", values: { uColor: [0.3, 1, 0.6], uSpeed: 0.3, uWidth: 6e-3, uLife: 4, uRings: 4 } },
2482
+ { name: "Raindrop", values: { uColor: [1, 1, 1], uSpeed: 0.8, uWidth: 0.02, uLife: 1.2, uRings: 2 } }
2483
+ ]
2484
+ };
2485
+ var clickBurst = {
2486
+ kind: "shader",
2487
+ id: "click-burst",
2488
+ name: "Click burst",
2489
+ description: "Sparks or confetti bursting out of every click, falling under gravity as they fade.",
2490
+ category: "interaction",
2491
+ tags: ["interactive", "click", "overlay", "particles", "celebration", "confetti"],
2492
+ transparent: true,
2493
+ source: glsl(
2494
+ `// Click burst: sparks (or confetti) thrown out of each click, slowed by drag and pulled down by gravity.
2495
+ uniform vec3 uColorA; // @control color default=#FFD166 label="Colour 1"
2496
+ uniform vec3 uColorB; // @control color default=#EF476F label="Colour 2"
2497
+ uniform vec3 uColorC; // @control color default=#06D6A0 label="Colour 3"
2498
+ uniform int uCount; // @control min=4 max=48 default=24 label="Count"
2499
+ uniform float uSpread; // @control min=0.1 max=2 default=0.8 label="Spread"
2500
+ uniform float uGravity; // @control min=0 max=3 default=1 label="Gravity"
2501
+ uniform float uLife; // @control min=0.4 max=4 default=1.6 label="Lifetime"
2502
+ uniform bool uConfetti; // @control default=false label="Confetti"
2503
+ uniform bool uDemo; // @control default=true label="Demo when idle"`,
2504
+ HASH,
2505
+ CLICKS,
2506
+ OVER,
2507
+ `float segment(vec2 p, vec2 a, vec2 b) {
2508
+ vec2 ab = b - a;
2509
+ float t = clamp(dot(p - a, ab) / max(dot(ab, ab), 1e-8), 0.0, 1.0);
2510
+ return length(p - a - ab * t);
2511
+ }
2512
+
2513
+ void mainImage(out vec4 fragColor, in vec2 fragCoord) {
2514
+ float px = 1.0 / iResolution.y;
2515
+ vec2 p = fragCoord * px;
2516
+ const float drag = 2.5;
2517
+ vec4 acc = vec4(0.0);
2518
+ for (int i = 7; i >= 0; i--) {
2519
+ vec4 c = clickAt(i, uDemo);
2520
+ if (c.w < 0.5) continue;
2521
+ float age = iTime - c.z;
2522
+ if (age < 0.0 || age > uLife) continue;
2523
+ float life = age / uLife;
2524
+ vec2 origin = c.xy * px;
2525
+ // A quick flash where it landed.
2526
+ float flash = exp(-age * 18.0) * exp(-dot(p - origin, p - origin) / 0.002);
2527
+ acc = over(acc, vec3(1.0), clamp(flash, 0.0, 1.0));
2528
+ float slow = (1.0 - exp(-drag * age)) / drag;
2529
+ for (int j = 0; j < 48; j++) {
2530
+ if (j >= uCount) break;
2531
+ vec2 h = hash22(vec2(float(j) * 7.13 + 1.0, c.z * 3.1 + float(i)));
2532
+ float h3 = hash12(h * 91.7 + c.z);
2533
+ float angle = h.x * 6.28318;
2534
+ vec2 v0 = vec2(cos(angle), sin(angle)) * uSpread * (0.3 + 0.7 * h.y) + vec2(0.0, uSpread * 0.35);
2535
+ vec2 pos = origin + v0 * slow - vec2(0.0, 0.5 * uGravity * 0.6 * age * age);
2536
+ if (abs(p.x - pos.x) > 0.06 || abs(p.y - pos.y) > 0.06) continue;
2537
+ vec3 col = h3 < 0.333 ? uColorA : (h3 < 0.667 ? uColorB : uColorC);
2538
+ float a;
2539
+ if (uConfetti) {
2540
+ // A tumbling paper rectangle.
2541
+ float spin = (h3 - 0.5) * 20.0 * age + angle;
2542
+ float cs = cos(spin);
2543
+ float sn = sin(spin);
2544
+ vec2 q = mat2(cs, sn, -sn, cs) * (p - pos);
2545
+ vec2 size = vec2(0.011, 0.006 * abs(cos(age * (4.0 + 6.0 * h.y) + h.x * 9.0)) + px);
2546
+ vec2 e = abs(q) - size;
2547
+ float sd = length(max(e, 0.0)) + min(max(e.x, e.y), 0.0);
2548
+ a = (1.0 - smoothstep(-px, px, sd)) * (1.0 - smoothstep(0.7, 1.0, life));
2549
+ col *= 0.75 + 0.25 * cs;
2550
+ } else {
2551
+ // A streak along its velocity, with a white-hot core.
2552
+ vec2 v = v0 * exp(-drag * age) - vec2(0.0, uGravity * 0.6 * age);
2553
+ float r = 0.004 * (1.0 - 0.6 * life) + px;
2554
+ float sd = segment(p, pos, pos - v * 0.05) - r;
2555
+ a = (1.0 - smoothstep(-px, px, sd)) * (1.0 - life);
2556
+ a += exp(-max(sd, 0.0) / (r * 2.0)) * 0.35 * (1.0 - life);
2557
+ col = mix(col, vec3(1.0), exp(-max(sd + r, 0.0) / (r * 0.5)) * 0.6);
2558
+ }
2559
+ acc = over(acc, col, clamp(a, 0.0, 1.0));
2560
+ }
2561
+ }
2562
+ fragColor = unpremultiply(acc);
2563
+ }`
2564
+ ),
2565
+ presets: [
2566
+ { name: "Confetti", values: { uConfetti: true, uCount: 36, uSpread: 1, uGravity: 1.4, uLife: 2.5, uColorC: [0.3, 0.55, 1] } },
2567
+ { name: "Embers", values: { uColorA: [1, 0.6, 0.15], uColorB: [1, 0.3, 0.1], uColorC: [1, 0.85, 0.5], uCount: 16, uSpread: 0.5, uGravity: 0.2 } }
2568
+ ]
2569
+ };
2570
+ var cursorGlow = {
2571
+ kind: "shader",
2572
+ id: "cursor-glow",
2573
+ name: "Cursor glow",
2574
+ description: "A soft light following the pointer, with a halo ring that tightens while it is pressed.",
2575
+ category: "interaction",
2576
+ tags: ["interactive", "hover", "press", "overlay", "glow", "cursor"],
2577
+ transparent: true,
2578
+ source: glsl(
2579
+ `// Cursor glow: a soft light at the pointer, scaled by how hovered, with a halo that tightens while pressed.
2580
+ uniform vec3 uColor; // @control color default=#7DD3FC label="Colour"
2581
+ uniform float uRadius; // @control min=0.03 max=0.6 default=0.18 label="Radius"
2582
+ uniform float uIntensity; // @control min=0 max=3 default=1.2 label="Intensity"
2583
+ uniform float uHalo; // @control min=0 max=1 default=0.7 label="Halo"
2584
+ uniform bool uDemo; // @control default=true label="Demo when idle"`,
2585
+ HOVER,
2586
+ GLOW,
2587
+ `void mainImage(out vec4 fragColor, in vec2 fragCoord) {
2588
+ vec3 hp = hoverPoint(uDemo, 0.7);
2589
+ float d = length(fragCoord - hp.xy) / iResolution.y;
2590
+ // How pressed: eases in while held; the idle demo pulses it.
2591
+ float press = 1.0 - exp(-max(iPointer.w, 0.0) * 4.0);
2592
+ if (uDemo) press = max(press, (1.0 - iPointer.z) * pow(max(sin(iTime * 1.3), 0.0), 3.0));
2593
+ float light = exp(-pow(d / uRadius, 2.0) * 2.0) * uIntensity * hp.z * (1.0 + 0.6 * press);
2594
+ float ringR = uRadius * mix(1.3, 0.55, press);
2595
+ float ringW = max(uRadius * 0.05, 1.5 / iResolution.y);
2596
+ float ring = exp(-pow((d - ringR) / ringW, 2.0)) * uHalo * hp.z * (0.6 + 0.8 * press);
2597
+ fragColor = glow(uColor * light + mix(uColor, vec3(1.0), 0.5) * ring * 1.5);
2598
+ }`
2599
+ ),
2600
+ presets: [
2601
+ { name: "Spotlight", values: { uColor: [1, 0.95, 0.85], uRadius: 0.4, uIntensity: 0.8, uHalo: 0 } },
2602
+ { name: "Neon", values: { uColor: [1, 0.2, 0.75], uRadius: 0.1, uIntensity: 2, uHalo: 1 } }
2603
+ ]
2604
+ };
2605
+ var hoverLiquid = {
2606
+ kind: "shader",
2607
+ id: "hover-liquid",
2608
+ name: "Liquid hover",
2609
+ description: "An image that swells and ripples like liquid around the pointer while it hovers.",
2610
+ category: "interaction",
2611
+ tags: ["interactive", "hover", "image", "liquid", "distortion"],
2612
+ source: glsl(
2613
+ `// Liquid hover: an image bulging and rippling around the pointer, as strongly as it is hovered.
2614
+ uniform sampler2D uImage; // @control filter=linear wrap=mirror default=${SAMPLE_IMAGES.landscape} label="Image"
2615
+ uniform float uStrength; // @control min=0 max=1 default=0.6 label="Strength"
2616
+ uniform float uRadius; // @control min=0.05 max=0.8 default=0.3 label="Radius"
2617
+ uniform float uWobble; // @control min=0 max=1 default=0.5 label="Wobble"
2618
+ uniform bool uDemo; // @control default=true label="Demo when idle"`,
2619
+ HASH,
2620
+ NOISE,
2621
+ HOVER,
2622
+ PICTURE,
2623
+ `void mainImage(out vec4 fragColor, in vec2 fragCoord) {
2624
+ vec2 uv = fragCoord / iResolution.xy;
2625
+ vec3 hp = hoverPoint(uDemo, 0.5);
2626
+ vec2 d = (fragCoord - hp.xy) / iResolution.y;
2627
+ float r = length(d);
2628
+ float s = uStrength * hp.z;
2629
+ float fall = exp(-pow(r / uRadius, 2.0) * 1.5);
2630
+ // Swell: pull samples towards the pointer, so it magnifies like a drop.
2631
+ vec2 off = -d * fall * s * 0.6;
2632
+ // Wobble: rings running out from the pointer and a slow liquid churn.
2633
+ vec2 dir = d / max(r, 1e-5);
2634
+ float wave = sin(r / uRadius * 14.0 - iTime * 5.0) * fall;
2635
+ off += dir * wave * uWobble * s * 0.02;
2636
+ vec2 churn = vec2(vnoise(d * 6.0 + iTime * 0.7), vnoise(d * 6.0 - iTime * 0.6 + 5.0)) - 0.5;
2637
+ off += churn * fall * s * uWobble * 0.05;
2638
+ vec3 col = picture(uImage, uv + off * vec2(iResolution.y / iResolution.x, 1.0));
2639
+ // Light catching the swell's upper-left slope.
2640
+ float slope = dot(dir, normalize(vec2(-0.6, 0.8))) * r / uRadius;
2641
+ col += 0.12 * s * fall * slope + 0.06 * s * wave * uWobble;
2642
+ fragColor = vec4(col, 1.0);
2643
+ }`
2644
+ ),
2645
+ presets: [
2646
+ { name: "Syrup", values: { uStrength: 0.9, uRadius: 0.45, uWobble: 0.15 } },
2647
+ { name: "Splash", values: { uStrength: 0.5, uRadius: 0.25, uWobble: 1 } }
2648
+ ]
2649
+ };
2650
+ var hoverReveal = {
2651
+ kind: "shader",
2652
+ id: "hover-reveal",
2653
+ name: "X-ray reveal",
2654
+ description: "A second image showing through a soft lens around the pointer that grows as it hovers and pulses on click.",
2655
+ category: "interaction",
2656
+ tags: ["interactive", "hover", "click", "image", "reveal", "x-ray"],
2657
+ source: glsl(
2658
+ `// X-ray reveal: a second image seen through a soft lens at the pointer.
2659
+ uniform sampler2D uImage; // @control filter=linear wrap=clamp default=${SAMPLE_IMAGES.landscape} label="Image"
2660
+ uniform sampler2D uReveal; // @control filter=linear wrap=clamp default=${SAMPLE_IMAGES.city} label="Revealed image"
2661
+ uniform float uRadius; // @control min=0.05 max=0.6 default=0.22 label="Lens radius"
2662
+ uniform float uSoftness; // @control min=0 max=1 default=0.35 label="Softness"
2663
+ uniform vec3 uRim; // @control color default=#A5F3FC label="Rim"
2664
+ uniform bool uDemo; // @control default=true label="Demo when idle"`,
2665
+ HASH,
2666
+ NOISE,
2667
+ CLICKS,
2668
+ HOVER,
2669
+ PICTURE,
2670
+ `void mainImage(out vec4 fragColor, in vec2 fragCoord) {
2671
+ vec2 uv = fragCoord / iResolution.xy;
2672
+ float px = 1.0 / iResolution.y;
2673
+ vec3 hp = hoverPoint(uDemo, 0.85);
2674
+ // Each click swells the lens, which settles back.
2675
+ float pulse = 0.0;
2676
+ for (int i = 0; i < 8; i++) {
2677
+ vec4 c = clickAt(i, uDemo);
2678
+ float age = iTime - c.z;
2679
+ if (c.w < 0.5 || age < 0.0) continue;
2680
+ pulse += 0.6 * exp(-age * 4.0) * (1.0 - exp(-age * 30.0));
2681
+ }
2682
+ float R = uRadius * hp.z * (1.0 + pulse);
2683
+ float d = length(fragCoord - hp.xy) * px;
2684
+ // A wobbly edge, so the lens looks like it is melting through.
2685
+ d += (vnoise(fragCoord * px * 9.0 + iTime * 0.6) - 0.5) * 0.25 * R;
2686
+ float soft = max(uSoftness * R * 0.6, 1.5 * px);
2687
+ float m = 1.0 - smoothstep(R - soft, R, d);
2688
+ vec3 col = mix(picture(uImage, uv), picture(uReveal, uv), m);
2689
+ float rim = exp(-pow((d - R + soft * 0.5) / max(soft * 0.5, 2.0 * px), 2.0)) * step(0.001, R);
2690
+ col += uRim * rim * 0.6;
2691
+ fragColor = vec4(col, 1.0);
2692
+ }`
2693
+ ),
2694
+ presets: [
2695
+ { name: "Peephole", values: { uRadius: 0.12, uSoftness: 0.05, uRim: [1, 1, 1] } },
2696
+ { name: "Fog lamp", values: { uRadius: 0.4, uSoftness: 1, uRim: [0, 0, 0] } }
2697
+ ]
2698
+ };
2699
+ var magnifier = {
2700
+ kind: "shader",
2701
+ id: "magnifier",
2702
+ name: "Magnifier",
2703
+ description: "A round magnifying glass over an image at the pointer, with a bright rim and a fringe of colour at its edge.",
2704
+ category: "interaction",
2705
+ tags: ["interactive", "hover", "image", "lens", "zoom"],
2706
+ source: glsl(
2707
+ `// Magnifier: a round lens over an image at the pointer, showing while hovered.
2708
+ uniform sampler2D uImage; // @control filter=linear wrap=mirror default=${SAMPLE_IMAGES.city} label="Image"
2709
+ uniform float uZoom; // @control min=1 max=5 default=2 label="Zoom"
2710
+ uniform float uRadius; // @control min=0.05 max=0.5 default=0.18 label="Radius"
2711
+ uniform float uAberration; // @control min=0 max=1 default=0.35 label="Colour fringe"
2712
+ uniform vec3 uRim; // @control color default=#FFFFFF label="Rim"
2713
+ uniform bool uDemo; // @control default=true label="Demo when idle"`,
2714
+ HOVER,
2715
+ PICTURE,
2716
+ `void mainImage(out vec4 fragColor, in vec2 fragCoord) {
2717
+ vec2 uv = fragCoord / iResolution.xy;
2718
+ vec3 hp = hoverPoint(uDemo, 1.0);
2719
+ float s = hp.z;
2720
+ vec3 col = picture(uImage, uv);
2721
+ float R = uRadius * (0.75 + 0.25 * s) * iResolution.y;
2722
+ vec2 d = fragCoord - hp.xy;
2723
+ float r = length(d);
2724
+ float k = r / R;
2725
+ // A soft shadow under the glass.
2726
+ float shadow = 1.0 - smoothstep(R * 0.9, R * 1.3, length(d - vec2(R * 0.08, -R * 0.12)));
2727
+ col *= 1.0 - 0.35 * shadow * s;
2728
+ if (k < 1.05) {
2729
+ // More magnified at the centre, squeezed towards the rim like real glass.
2730
+ vec2 q = d / uZoom * (1.0 + 0.4 * k * k * k * k);
2731
+ float fr = uAberration * 0.08 * k * k * k;
2732
+ vec3 lens = vec3(
2733
+ picture(uImage, (hp.xy + q * (1.0 + fr)) / iResolution.xy).r,
2734
+ picture(uImage, (hp.xy + q) / iResolution.xy).g,
2735
+ picture(uImage, (hp.xy + q * (1.0 - fr)) / iResolution.xy).b
2736
+ );
2737
+ lens += 0.16 * exp(-pow(length(d / R - vec2(-0.35, 0.4)) * 2.6, 2.0));
2738
+ lens *= 1.0 - 0.25 * pow(k, 6.0);
2739
+ col = mix(col, lens, (1.0 - smoothstep(R - 1.5, R, r)) * s);
2740
+ }
2741
+ float rimW = max(R * 0.035, 2.0);
2742
+ float lit = 0.6 + 0.4 * dot(d / max(r, 1e-4), normalize(vec2(-1.0, 1.0)));
2743
+ float rim = exp(-pow((r - R) / rimW, 2.0));
2744
+ col = mix(col, uRim * lit, rim * s * 0.9);
2745
+ fragColor = vec4(col, 1.0);
2746
+ }`
2747
+ ),
2748
+ presets: [
2749
+ { name: "Loupe", values: { uZoom: 4, uRadius: 0.1, uAberration: 0.1 } },
2750
+ { name: "Fishbowl", values: { uZoom: 1.5, uRadius: 0.35, uAberration: 1 } }
2751
+ ]
2752
+ };
2753
+ var holoCard = {
2754
+ kind: "shader",
2755
+ id: "holo-card",
2756
+ name: "Holo card",
2757
+ description: "A holographic foil trading card whose rainbow sheen and glare shift as the pointer tilts it.",
2758
+ category: "interaction",
2759
+ tags: ["interactive", "hover", "card", "holographic", "foil", "iridescent"],
2760
+ transparent: true,
2761
+ source: glsl(
2762
+ `// Holo card: a rounded card with rainbow foil and glare that move as the pointer tilts it.
2763
+ uniform sampler2D uImage; // @control filter=linear wrap=clamp default=${SAMPLE_IMAGES.portrait} label="Image"
2764
+ uniform float uFoil; // @control min=0 max=1 default=0.7 label="Foil"
2765
+ uniform float uGlare; // @control min=0 max=1 default=0.6 label="Glare"
2766
+ uniform float uTilt; // @control min=0 max=1 default=0.6 label="Tilt"
2767
+ uniform float uCorner; // @control min=0 max=0.25 default=0.06 label="Corner radius"
2768
+ uniform vec3 uBorder; // @control color default=#E2E8F0 label="Border"
2769
+ uniform bool uDemo; // @control default=true label="Demo when idle"`,
2770
+ HASH,
2771
+ NOISE,
2772
+ HOVER,
2773
+ OVER,
2774
+ PICTURE,
2775
+ `float roundedBox(vec2 p, vec2 hs, float r) {
2776
+ vec2 e = abs(p) - hs + r;
2777
+ return length(max(e, 0.0)) + min(max(e.x, e.y), 0.0) - r;
2778
+ }
2779
+
2780
+ void mainImage(out vec4 fragColor, in vec2 fragCoord) {
2781
+ vec2 res = iResolution.xy;
2782
+ const float aspect = 0.716;
2783
+ float h = min(res.y * 0.84, res.x * 0.84 / aspect);
2784
+ vec2 hs = vec2(h * aspect, h) * 0.5;
2785
+ vec3 hp = hoverPoint(uDemo, 0.7);
2786
+ vec2 tilt = clamp((hp.xy - 0.5 * res) / hs, -1.0, 1.0) * hp.z * uTilt;
2787
+ vec2 s = (fragCoord - 0.5 * res) / hs;
2788
+ // Fake perspective: the side the pointer is on leans away.
2789
+ vec2 q = s / max(1.0 - 0.15 * dot(s, tilt), 0.2);
2790
+ float radius = uCorner * hs.x * 2.0;
2791
+ float sd = roundedBox(q * hs, hs, radius);
2792
+ float mask = 1.0 - smoothstep(-1.0, 1.0, sd);
2793
+ // A soft shadow, falling away from the tilt.
2794
+ float shadowD = roundedBox(fragCoord - 0.5 * res + tilt * hs * 0.06 + vec2(0.0, h * 0.03), hs, radius);
2795
+ float shadow = (1.0 - smoothstep(-h * 0.04, h * 0.06, shadowD)) * 0.35;
2796
+ vec4 acc = over(vec4(0.0), vec3(0.0), shadow * (1.0 - mask));
2797
+ if (mask > 0.0) {
2798
+ vec2 cuv = q * 0.5 + 0.5;
2799
+ // The picture fills the card, cropped to fit.
2800
+ vec2 ts = vec2(textureSize(uImage, 0));
2801
+ float imageAspect = ts.x / max(ts.y, 1.0);
2802
+ vec2 fit = imageAspect > aspect ? vec2(aspect / imageAspect, 1.0) : vec2(1.0, imageAspect / aspect);
2803
+ vec3 base = picture(uImage, 0.5 + (cuv - 0.5) * fit);
2804
+ // Foil: rainbow bands that slide with the tilt, strongest in a broad band facing the light.
2805
+ float n = vnoise(cuv * 5.0);
2806
+ float phase = dot(cuv, vec2(0.7, 1.1)) * 1.6 + tilt.x * 0.9 - tilt.y * 0.6 + n * 0.5;
2807
+ vec3 rainbow = 0.5 + 0.5 * cos(6.28318 * (phase + vec3(0.0, 0.33, 0.67)));
2808
+ float lines = 0.75 + 0.25 * sin((cuv.x - cuv.y) * 90.0 + tilt.x * 8.0);
2809
+ float band = exp(-pow((cuv.x + cuv.y) * 0.7 - 0.7 - (tilt.x + tilt.y) * 0.6, 2.0) * 6.0);
2810
+ float foil = uFoil * (0.25 + 0.75 * band) * lines;
2811
+ // Overlay blend, so the picture keeps its darks under the foil.
2812
+ vec3 lit = mix(2.0 * base * rainbow, 1.0 - 2.0 * (1.0 - base) * (1.0 - rainbow), step(0.5, base));
2813
+ vec3 col = mix(base, lit + rainbow * 0.12, foil * 0.75);
2814
+ float sparkle = step(0.985, hash12(floor(cuv * vec2(90.0, 126.0))));
2815
+ col += sparkle * uFoil * band * (0.5 + 0.5 * sin(iTime * 3.0 + n * 20.0 + tilt.x * 10.0));
2816
+ // Glare: a highlight where the light hits, following the pointer.
2817
+ vec2 g = cuv - (0.5 + tilt * 0.55);
2818
+ col += uGlare * 0.6 * exp(-dot(g, g) * 7.0);
2819
+ // A thin foil border.
2820
+ float border = smoothstep(-0.03 * h - 1.0, -0.03 * h, sd);
2821
+ col = mix(col, uBorder * (0.8 + 0.4 * rainbow * uFoil), border);
2822
+ acc = over(acc, clamp(col, 0.0, 1.0), mask);
2823
+ }
2824
+ fragColor = unpremultiply(acc);
2825
+ }`
2826
+ ),
2827
+ presets: [
2828
+ { name: "Subtle", values: { uFoil: 0.3, uGlare: 0.35, uTilt: 0.4 } },
2829
+ { name: "Full foil", values: { uFoil: 1, uGlare: 0.8, uTilt: 1, uBorder: [0.98, 0.82, 0.35] } }
2830
+ ]
2831
+ };
2832
+ var pressCharge = {
2833
+ kind: "shader",
2834
+ id: "press-charge",
2835
+ name: "Press to charge",
2836
+ description: "A ring that fills and glows while the pointer is held and drains when it is let go, bursting if it was fully charged.",
2837
+ category: "interaction",
2838
+ tags: ["interactive", "press", "overlay", "hold", "button", "ring"],
2839
+ transparent: true,
2840
+ source: glsl(
2841
+ `// Press to charge: a ring that fills while held (iPointer.w > 0), drains when let go (iPointer.w < 0)
2842
+ // and bursts if it was let go fully charged.
2843
+ uniform vec3 uColor; // @control color default=#A78BFA label="Colour"
2844
+ uniform float uHold; // @control min=0.3 max=5 default=1.2 label="Seconds to charge"
2845
+ uniform float uSize; // @control min=0.08 max=0.45 default=0.22 label="Size"
2846
+ uniform float uThickness; // @control min=0.005 max=0.08 default=0.025 label="Thickness"
2847
+ uniform bool uDemo; // @control default=true label="Demo when idle"`,
2848
+ OVER,
2849
+ `void mainImage(out vec4 fragColor, in vec2 fragCoord) {
2850
+ float px = 1.0 / iResolution.y;
2851
+ // iPointer.w: seconds held while pressed, minus the seconds since letting go after, 0 before any press.
2852
+ float w = iPointer.w;
2853
+ // The press is the newest click, so this is how long it was held when let go.
2854
+ float heldFor = iTime + min(w, 0.0) - iClicks[0].z;
2855
+ // Until someone presses, a synthetic press: hold, charge, let go, burst, rest.
2856
+ if (uDemo && iClicks[0].w < 0.5 && w == 0.0) {
2857
+ float t = mod(iTime, uHold + 2.0) - 0.4;
2858
+ float hold = uHold + 0.4;
2859
+ w = t <= 0.0 ? 0.0 : (t < hold ? t : -(t - hold));
2860
+ heldFor = hold;
2861
+ }
2862
+ float held = max(w, 0.0);
2863
+ float since = max(-w, 0.0);
2864
+ float reached = w < 0.0 ? clamp(heldFor / uHold, 0.0, 1.0) : 0.0;
2865
+ // While held it fills; once let go it drains from where it got to, over 0.4 s.
2866
+ float drain = 1.0 - smoothstep(0.0, 0.4, since);
2867
+ float charge = w > 0.0 ? clamp(held / uHold, 0.0, 1.0) : reached * drain;
2868
+ float full = step(uHold, held);
2869
+ vec2 p = (fragCoord - 0.5 * iResolution.xy) * px;
2870
+ float r = length(p);
2871
+ float turn = fract(atan(p.x, p.y) / 6.28318 + 1.0);
2872
+ float R = uSize * (1.0 - 0.06 * charge + 0.03 * iPointer.z);
2873
+ float ringD = abs(r - R) - uThickness * 0.5;
2874
+ float ring = 1.0 - smoothstep(-px, px, ringD);
2875
+ vec4 acc = vec4(0.0);
2876
+ // The core fills faintly, and the track shows where the charge goes.
2877
+ acc = over(acc, uColor, (1.0 - smoothstep(R - px, R + px, r)) * 0.1 * charge);
2878
+ acc = over(acc, uColor * 0.6, ring * (0.22 + 0.1 * iPointer.z));
2879
+ // The charge: an arc clockwise from the top, glowing brighter as it fills.
2880
+ float arcEnd = (charge - turn) * 6.28318 * R / px;
2881
+ float arc = ring * clamp(arcEnd + 0.5, 0.0, 1.0) * step(0.001, charge);
2882
+ float pulse = 1.0 + full * 0.25 * sin(iTime * 9.0);
2883
+ float halo = exp(-max(ringD, 0.0) / (uThickness * 1.5)) * charge * charge * 0.6 * clamp(arcEnd + 0.5, 0.0, 1.0);
2884
+ acc = over(acc, uColor, clamp(halo * pulse, 0.0, 1.0));
2885
+ acc = over(acc, mix(uColor, vec3(1.0), 0.35 * charge * pulse), arc);
2886
+ // A ping when it fills, and a flash with a ring bursting out when let go fully charged.
2887
+ float flashAge = -1.0;
2888
+ float flashSize = 0.0;
2889
+ if (w > 0.0 && held >= uHold) { flashAge = held - uHold; flashSize = 0.5; }
2890
+ if (w < 0.0 && reached >= 1.0) { flashAge = since; flashSize = 1.0; }
2891
+ if (flashAge >= 0.0 && flashAge < 0.8) {
2892
+ float k = 1.0 - flashAge / 0.8;
2893
+ float burstR = R + flashAge * 0.6 * flashSize;
2894
+ float burst = exp(-pow((r - burstR) / (uThickness * (1.0 + flashAge * 3.0)), 2.0)) * k * k * flashSize;
2895
+ acc = over(acc, mix(uColor, vec3(1.0), 0.5), clamp(burst, 0.0, 1.0));
2896
+ acc = over(acc, vec3(1.0), exp(-r * r / (R * R * 0.5)) * exp(-flashAge * 6.0) * 0.8 * flashSize);
2897
+ }
2898
+ fragColor = unpremultiply(acc);
2899
+ }`
2900
+ ),
2901
+ presets: [
2902
+ { name: "Hold to confirm", values: { uColor: [0.95, 0.3, 0.3], uHold: 2, uSize: 0.15, uThickness: 0.015 } },
2903
+ { name: "Power up", values: { uColor: [0.2, 0.9, 1], uHold: 0.8, uSize: 0.3, uThickness: 0.05 } }
2904
+ ]
2905
+ };
2906
+ var hoverSwap = {
2907
+ kind: "shader",
2908
+ id: "hover-swap",
2909
+ name: "Hover swap",
2910
+ description: "One image burning away into another from the pointer as it hovers, along a glowing ragged edge.",
2911
+ category: "interaction",
2912
+ tags: ["interactive", "hover", "image", "dissolve", "transition"],
2913
+ source: glsl(
2914
+ `// Hover swap: image A dissolves into image B as hover goes 0 \u2192 1, spreading from the pointer.
2915
+ uniform sampler2D uFrom; // @control filter=linear wrap=clamp default=${SAMPLE_IMAGES.landscape} label="Image"
2916
+ uniform sampler2D uTo; // @control filter=linear wrap=clamp default=${SAMPLE_IMAGES.city} label="Hover image"
2917
+ uniform vec3 uEdge; // @control color default=#FFB86B label="Edge glow"
2918
+ uniform float uEdgeWidth; // @control min=0 max=0.3 default=0.08 label="Edge width"
2919
+ uniform float uGrain; // @control min=1 max=20 default=6 label="Noise scale"
2920
+ uniform bool uDemo; // @control default=true label="Demo when idle"`,
2921
+ HASH,
2922
+ NOISE,
2923
+ FBM,
2924
+ HOVER,
2925
+ PICTURE,
2926
+ `void mainImage(out vec4 fragColor, in vec2 fragCoord) {
2927
+ vec2 uv = fragCoord / iResolution.xy;
2928
+ vec3 hp = hoverPoint(uDemo, 0.0);
2929
+ float h = clamp(iPointer.z, 0.0, 1.0);
2930
+ // Idle, hover swings slowly in and out.
2931
+ float demo = uDemo ? smoothstep(0.1, 0.9, 0.5 - 0.5 * cos(iTime * 0.9)) : 0.0;
2932
+ float progress = mix(demo, 1.0, h);
2933
+ // Distance from the pointer, as a share of the way to the farthest corner.
2934
+ vec2 o = hp.xy / iResolution.y;
2935
+ vec2 size = iResolution.xy / iResolution.y;
2936
+ float far = max(max(length(o), length(o - vec2(size.x, 0.0))), max(length(o - vec2(0.0, size.y)), length(o - size)));
2937
+ float dist = length(fragCoord / iResolution.y - o) / far;
2938
+ float f = 0.65 * dist + 0.35 * fbm(fragCoord / iResolution.y * uGrain, 5);
2939
+ float w = max(uEdgeWidth, 0.005);
2940
+ float t = progress * (1.0 + w);
2941
+ float m = 1.0 - smoothstep(t - w, t, f);
2942
+ vec3 col = mix(picture(uFrom, uv), picture(uTo, uv), m);
2943
+ float edge = m * (1.0 - m) * 4.0 * step(0.001, uEdgeWidth);
2944
+ col = mix(col, uEdge, edge * 0.85);
2945
+ fragColor = vec4(col, 1.0);
2946
+ }`
2947
+ ),
2948
+ presets: [
2949
+ { name: "Clean cut", values: { uEdgeWidth: 0.01, uGrain: 2 } },
2950
+ { name: "Embers", values: { uEdge: [1, 0.35, 0.1], uEdgeWidth: 0.2, uGrain: 14 } }
2951
+ ]
2952
+ };
2953
+ var interactionShaders = [
2954
+ clickRipple,
2955
+ clickBurst,
2956
+ cursorGlow,
2957
+ hoverLiquid,
2958
+ hoverReveal,
2959
+ magnifier,
2960
+ holoCard,
2961
+ pressCharge,
2962
+ hoverSwap
2963
+ ];
2964
+
1884
2965
  // src/catalog/shaders/morph.ts
1885
2966
  var pair = (from, to) => `uniform sampler2D uFrom; // @control filter=linear wrap=clamp default=${from} label="From image"
1886
2967
  uniform sampler2D uTo; // @control filter=linear wrap=clamp default=${to} label="To image"
@@ -2330,13 +3411,13 @@ var morphShaders = [
2330
3411
  ];
2331
3412
 
2332
3413
  // src/catalog/shaders/overlay.ts
2333
- var GLOW = `
3414
+ var GLOW2 = `
2334
3415
  vec4 glow(vec3 c) {
2335
3416
  c = 1.0 - exp(-max(c, 0.0));
2336
3417
  float a = max(c.r, max(c.g, c.b));
2337
3418
  return vec4(c / max(a, 1e-4), a);
2338
3419
  }`.trim();
2339
- var OVER = `
3420
+ var OVER2 = `
2340
3421
  vec4 over(vec4 acc, vec3 c, float a) {
2341
3422
  return vec4(acc.rgb * (1.0 - a) + c * a, acc.a * (1.0 - a) + a);
2342
3423
  }
@@ -2358,7 +3439,7 @@ uniform float uSpeed; // @control min=0 max=3 default=1 label="Speed"
2358
3439
  uniform float uSize; // @control min=0.5 max=2 default=1 label="Drop size"
2359
3440
  uniform vec3 uTint; // @control color default=#CFE3F0 label="Tint"`,
2360
3441
  HASH,
2361
- OVER,
3442
+ OVER2,
2362
3443
  `// One drop of radius r at offset d: a faint tinted body, a darker rim and a bright highlight.
2363
3444
  vec4 paintDrop(vec4 acc, vec2 d, float r, float px) {
2364
3445
  float dist = length(d);
@@ -2492,7 +3573,7 @@ uniform vec3 uColor; // @control color default=#FFC98A label="Colour"
2492
3573
  uniform float uStreak; // @control min=0 max=2 default=1 label="Streak length"
2493
3574
  uniform float uIntensity; // @control min=0 max=2 default=1 label="Intensity"`,
2494
3575
  HASH,
2495
- GLOW,
3576
+ GLOW2,
2496
3577
  `void mainImage(out vec4 fragColor, in vec2 fragCoord) {
2497
3578
  vec2 p = (fragCoord - 0.5 * iResolution.xy) / iResolution.y;
2498
3579
  float aspect = iResolution.x / iResolution.y;
@@ -2542,7 +3623,7 @@ uniform float uSoftness; // @control min=0.05 max=1 default=0.5 label="Softness
2542
3623
  uniform float uGrain; // @control min=0 max=0.3 default=0.08 label="Grain"
2543
3624
  uniform float uGrainSize; // @control min=1 max=4 default=1.5 label="Grain size (px)"`,
2544
3625
  HASH,
2545
- OVER,
3626
+ OVER2,
2546
3627
  `void mainImage(out vec4 fragColor, in vec2 fragCoord) {
2547
3628
  float aspect = iResolution.x / iResolution.y;
2548
3629
  // 0 at the centre, 1 at the edges; rounder shapes measure across the frame's aspect.
@@ -2579,7 +3660,7 @@ uniform float uWidth; // @control min=0.001 max=0.05 default=0.006 label="Wi
2579
3660
  uniform vec3 uColor; // @control color default=#4FF0FF label="Colour"
2580
3661
  uniform float uInterval; // @control min=0 max=5 default=1 label="Pause (s)"
2581
3662
  uniform float uTrail; // @control min=0 max=1 default=0.5 label="Trail"`,
2582
- GLOW,
3663
+ GLOW2,
2583
3664
  `void mainImage(out vec4 fragColor, in vec2 fragCoord) {
2584
3665
  vec2 p = (fragCoord - 0.5 * iResolution.xy) / iResolution.y;
2585
3666
  float aspect = iResolution.x / iResolution.y;
@@ -2653,7 +3734,7 @@ uniform vec3 uHeat; // @control color default=#FF7A1A label="Heat"
2653
3734
  uniform float uSpeed; // @control min=0 max=3 default=1 label="Rise speed"
2654
3735
  uniform float uSize; // @control min=0.3 max=3 default=1 label="Size"`,
2655
3736
  HASH,
2656
- GLOW,
3737
+ GLOW2,
2657
3738
  `void mainImage(out vec4 fragColor, in vec2 fragCoord) {
2658
3739
  vec2 p = fragCoord / iResolution.y;
2659
3740
  float aspect = iResolution.x / iResolution.y;
@@ -3705,24 +4786,24 @@ var imageTreatments = [
3705
4786
  ];
3706
4787
 
3707
4788
  // src/catalog/text.ts
3708
- var r1 = (n3) => Math.round(n3 * 10) / 10;
3709
- function fit(text, width, max, ems = textWidthEm(text)) {
4789
+ var r12 = (n4) => Math.round(n4 * 10) / 10;
4790
+ function fit2(text, width, max, ems = textWidthEm(text)) {
3710
4791
  const e = Math.max(0.6, ems);
3711
4792
  const size = Math.min(max, width / e);
3712
- return { size: r1(size), length: r1(e * size), left: r1(200 - e * size / 2) };
4793
+ return { size: r12(size), length: r12(e * size), left: r12(200 - e * size / 2) };
3713
4794
  }
3714
4795
  var lettersOf = (text) => Array.from(String(text));
3715
- var mixHex = (a, b, t) => {
4796
+ var mixHex2 = (a, b, t) => {
3716
4797
  const x = Array.isArray(a) ? a : [1, 1, 1];
3717
4798
  const y = Array.isArray(b) ? b : [1, 1, 1];
3718
4799
  return rgbToHex([0, 1, 2].map((i) => x[i] + (y[i] - x[i]) * t));
3719
4800
  };
3720
4801
  var holds = (id, attr, keys) => keys.map(([delay, value]) => ({ id, attrs: { [attr]: [value, value] }, delay, ms: 0 }));
3721
- var n2 = (name) => `{{${name}}}`;
4802
+ var n3 = (name) => `{{${name}}}`;
3722
4803
  var MONO = `ui-monospace, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace`;
3723
4804
  var MONO_EM = 0.6;
3724
4805
  var HEAVY = `'Arial Black', 'Helvetica Neue', Impact, system-ui, sans-serif`;
3725
- var SANS = `system-ui, 'Helvetica Neue', Arial, sans-serif`;
4806
+ var SANS2 = `system-ui, 'Helvetica Neue', Arial, sans-serif`;
3726
4807
  var BLINKS = 7;
3727
4808
  var typewriter = {
3728
4809
  kind: "vector",
@@ -3739,14 +4820,14 @@ var typewriter = {
3739
4820
  // Monospace, so the caret lands after the last letter however long the text is.
3740
4821
  derived: (v) => {
3741
4822
  const count = Math.max(1, lettersOf(v.text).length);
3742
- const size = r1(Math.min(44, 340 / ((count + 1) * MONO_EM)));
3743
- const left = r1(200 - (count + 1) * MONO_EM * size / 2);
4823
+ const size = r12(Math.min(44, 340 / ((count + 1) * MONO_EM)));
4824
+ const left = r12(200 - (count + 1) * MONO_EM * size / 2);
3744
4825
  const typing = Math.round(count * Number(v.pace));
3745
4826
  return {
3746
4827
  size,
3747
4828
  left,
3748
- base: r1(100 + size * 0.35),
3749
- end: r1(left + count * MONO_EM * size),
4829
+ base: r12(100 + size * 0.35),
4830
+ end: r12(left + count * MONO_EM * size),
3750
4831
  typing,
3751
4832
  ...Object.fromEntries(Array.from({ length: BLINKS }, (_, k) => [`blink${k}`, typing + k * 500]))
3752
4833
  };
@@ -3754,9 +4835,9 @@ var typewriter = {
3754
4835
  motion: {
3755
4836
  svg: `<svg viewBox="0 0 400 200"><!-- {{text: text default='Hello, world.' label='Text'}} {{paper: color default=#111827 label='Background'}} {{ink: color default=#F9FAFB label='Text colour'}} {{pace: float min=30 max=300 default=90 step=5 label='Ms per letter'}} --><rect width="400" height="200" fill="{{paper}}"/><text id="typed" x="{{left}}" y="{{base}}" font-family="${MONO}" font-size="{{size}}" fill="{{ink}}" xml:space="preserve">{{text}}</text><text id="caret" x="{{end}}" y="{{base}}" font-family="${MONO}" font-size="{{size}}" fill="{{ink}}" opacity="0">\u258C</text></svg>`,
3756
4837
  animate: [
3757
- { id: "typed", write: "{{text}}", caret: "\u258C", ms: n2("typing"), ease: "linear" },
4838
+ { id: "typed", write: "{{text}}", caret: "\u258C", ms: n3("typing"), ease: "linear" },
3758
4839
  // Hidden while the writer shows its own caret, then blinking where it stopped.
3759
- ...holds("caret", "opacity", [[0, 0], ...Array.from({ length: BLINKS }, (_, k) => [n2(`blink${k}`), k % 2 ? 0 : 1])])
4840
+ ...holds("caret", "opacity", [[0, 0], ...Array.from({ length: BLINKS }, (_, k) => [n3(`blink${k}`), k % 2 ? 0 : 1])])
3760
4841
  ]
3761
4842
  }
3762
4843
  };
@@ -3774,17 +4855,17 @@ var scrambleText = {
3774
4855
  ],
3775
4856
  derived: (v) => {
3776
4857
  const count = Math.max(1, lettersOf(v.text).length);
3777
- const size = r1(Math.min(60, 360 / (count * MONO_EM)));
3778
- const width = r1(count * MONO_EM * size);
3779
- const base = r1(100 + size * 0.35);
3780
- return { size, base, barX: r1(200 - width / 2), barY: r1(base + size * 0.3), barW: width, decode: Math.round(count * Number(v.pace)), glowWide: Number(v.glow) * 2 };
4858
+ const size = r12(Math.min(60, 360 / (count * MONO_EM)));
4859
+ const width = r12(count * MONO_EM * size);
4860
+ const base = r12(100 + size * 0.35);
4861
+ return { size, base, barX: r12(200 - width / 2), barY: r12(base + size * 0.3), barW: width, decode: Math.round(count * Number(v.pace)), glowWide: Number(v.glow) * 2 };
3781
4862
  },
3782
4863
  motion: {
3783
4864
  svg: `<svg viewBox="0 0 400 200"><!-- {{text: text default='ACCESS GRANTED' label='Text'}} {{glyphs: text default='ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#%&@$*+' label='Scramble glyphs'}} {{background: color default=#05070D label='Background'}} {{ink: color default=#E2E8F0 label='Text colour'}} {{accent: color default=#22D3EE label='Accent'}} {{pace: float min=20 max=250 default=70 step=5 label='Ms per letter'}} {{glow: float min=0 max=6 default=2 step=0.5 label='Glow'}} --><defs><filter id="glow" x="-20%" y="-50%" width="140%" height="200%"><feGaussianBlur stdDeviation="{{glowWide}}" result="blur"/><feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge></filter></defs><rect width="400" height="200" fill="{{background}}"/><text id="word" x="200" y="{{base}}" text-anchor="middle" font-family="${MONO}" font-size="{{size}}" font-weight="700" fill="{{ink}}" filter="url(#glow)" xml:space="preserve">{{text}}</text><rect id="bar" x="{{barX}}" y="{{barY}}" width="0" height="3" fill="{{accent}}"/></svg>`,
3784
4865
  animate: [
3785
4866
  // Pure noise for a moment (the scramble runs before the change starts), then it decodes.
3786
- { id: "word", write: "{{text}}", scramble: "{{glyphs}}", delay: 500, ms: n2("decode"), ease: "linear" },
3787
- { id: "bar", attrs: { width: [0, n2("barW")] }, delay: 500, ms: n2("decode"), ease: "linear" }
4867
+ { id: "word", write: "{{text}}", scramble: "{{glyphs}}", delay: 500, ms: n3("decode"), ease: "linear" },
4868
+ { id: "bar", attrs: { width: [0, n3("barW")] }, delay: 500, ms: n3("decode"), ease: "linear" }
3788
4869
  ]
3789
4870
  }
3790
4871
  };
@@ -3810,20 +4891,20 @@ var kineticStagger = {
3810
4891
  const base = 100 + size * 0.35;
3811
4892
  const stagger = Number(v.stagger);
3812
4893
  const out = {
3813
- size: r1(size),
3814
- base: r1(base),
3815
- rise: r1(base - size * 0.1),
3816
- drop: r1(base + size * 0.6),
3817
- lineX: r1(200 - ems * size / 2),
3818
- lineY: r1(base + size * 0.2),
3819
- lineW: r1(ems * size),
4894
+ size: r12(size),
4895
+ base: r12(base),
4896
+ rise: r12(base - size * 0.1),
4897
+ drop: r12(base + size * 0.6),
4898
+ lineX: r12(200 - ems * size / 2),
4899
+ lineY: r12(base + size * 0.2),
4900
+ lineW: r12(ems * size),
3820
4901
  lineAt: Math.round(letters.length * stagger + 400)
3821
4902
  };
3822
4903
  let x = 200 - ems * size / 2;
3823
4904
  for (let i = 0; i < SLOTS2; i++) {
3824
4905
  const w = (widths[i] ?? 0) * size;
3825
4906
  out[`c${i}`] = letters[i]?.trim() ?? "";
3826
- out[`x${i}`] = r1(x + w / 2);
4907
+ out[`x${i}`] = r12(x + w / 2);
3827
4908
  out[`d${i}`] = Math.round(i * stagger);
3828
4909
  out[`e${i}`] = Math.round(i * stagger + 380);
3829
4910
  x += w;
@@ -3840,13 +4921,13 @@ var kineticStagger = {
3840
4921
  {
3841
4922
  id: `l${i}`,
3842
4923
  attrs: { transform: [`translate({{x${i}}} {{drop}}) rotate(-28) scale(0.3)`, `translate({{x${i}}} {{rise}}) rotate(5) scale(1.12)`], opacity: [0, 1] },
3843
- delay: n2(`d${i}`),
4924
+ delay: n3(`d${i}`),
3844
4925
  ms: 380,
3845
4926
  ease: "out"
3846
4927
  },
3847
- { id: `l${i}`, attrs: { transform: [`translate({{x${i}}} {{rise}}) rotate(5) scale(1.12)`, `translate({{x${i}}} {{base}}) rotate(0) scale(1)`] }, delay: n2(`e${i}`), ms: 260, ease: "in-out" }
4928
+ { id: `l${i}`, attrs: { transform: [`translate({{x${i}}} {{rise}}) rotate(5) scale(1.12)`, `translate({{x${i}}} {{base}}) rotate(0) scale(1)`] }, delay: n3(`e${i}`), ms: 260, ease: "in-out" }
3848
4929
  ]).flat(),
3849
- { id: "line", attrs: { width: [0, n2("lineW")] }, delay: n2("lineAt"), ms: 500, ease: "in-out" }
4930
+ { id: "line", attrs: { width: [0, n3("lineW")] }, delay: n3("lineAt"), ms: 500, ease: "in-out" }
3850
4931
  ]
3851
4932
  }
3852
4933
  };
@@ -3867,16 +4948,16 @@ var neonSign = {
3867
4948
  derived: (v) => {
3868
4949
  const letters = lettersOf(v.text);
3869
4950
  const k = Math.min(Math.max(0, Math.round(Number(v.faulty)) - 1), Math.max(0, letters.length - 1));
3870
- const { size, length } = fit(String(v.text), 340, 84);
4951
+ const { size, length } = fit2(String(v.text), 340, 84);
3871
4952
  return {
3872
4953
  head: letters.slice(0, k).join(""),
3873
4954
  mid: letters[k] ?? "",
3874
4955
  tail: letters.slice(k + 1).join(""),
3875
4956
  size,
3876
4957
  length,
3877
- base: r1(100 + size * 0.3),
4958
+ base: r12(100 + size * 0.3),
3878
4959
  far: Number(v.glow) * 3,
3879
- core: mixHex(v.tube, [1, 1, 1], 0.6)
4960
+ core: mixHex2(v.tube, [1, 1, 1], 0.6)
3880
4961
  };
3881
4962
  },
3882
4963
  motion: (() => {
@@ -3908,14 +4989,14 @@ var strokeDrawText = {
3908
4989
  ],
3909
4990
  // <text> takes no pathLength, so `draw` cannot measure it: the dash grows instead, longer than any letter's outline.
3910
4991
  derived: (v) => {
3911
- const { size, length } = fit(String(v.text), 360, 100);
3912
- return { size, length, base: r1(100 + size * 0.35), dash: Math.round(size * 6) };
4992
+ const { size, length } = fit2(String(v.text), 360, 100);
4993
+ return { size, length, base: r12(100 + size * 0.35), dash: Math.round(size * 6) };
3913
4994
  },
3914
4995
  motion: {
3915
4996
  svg: `<svg viewBox="0 0 400 200"><!-- {{text: text default='Draw' label='Text'}} {{background: color default=#FFFBF5 label='Background'}} {{outline: color default=#1E293B label='Outline'}} {{fill: color default=#F97316 label='Fill'}} {{width: float min=0.5 max=6 default=2 step=0.5 label='Outline width'}} {{fillOpacity: float min=0 max=1 default=1 label='Fill opacity'}} --><rect width="400" height="200" fill="{{background}}"/><text id="word" x="200" y="{{base}}" text-anchor="middle" textLength="{{length}}" lengthAdjust="spacingAndGlyphs" font-family="${HEAVY}" font-size="{{size}}" font-weight="900" fill="{{fill}}" fill-opacity="0" stroke="{{outline}}" stroke-width="{{width}}" stroke-linejoin="round" stroke-dasharray="0 {{dash}}">{{text}}</text></svg>`,
3916
4997
  animate: [
3917
4998
  { id: "word", attrs: { "stroke-dasharray": ["0 {{dash}}", "{{dash}} 0"] }, ms: 2e3, ease: "in-out" },
3918
- { id: "word", attrs: { "fill-opacity": [0, n2("fillOpacity")] }, delay: 1500, ms: 700, ease: "out" }
4999
+ { id: "word", attrs: { "fill-opacity": [0, n3("fillOpacity")] }, delay: 1500, ms: 700, ease: "out" }
3919
5000
  ]
3920
5001
  }
3921
5002
  };
@@ -3932,21 +5013,21 @@ var splitReveal = {
3932
5013
  { name: "Gold on black", values: { background: [0.02, 0.02, 0.02], ink: [0.96, 0.84, 0.55], line: [0.96, 0.84, 0.55] } }
3933
5014
  ],
3934
5015
  derived: (v) => {
3935
- const { size, length, left } = fit(String(v.text), 360, 88);
5016
+ const { size, length, left } = fit2(String(v.text), 360, 88);
3936
5017
  return {
3937
5018
  size,
3938
5019
  length,
3939
- base: r1(100 + size * 0.36),
3940
- shift: r1(size * 0.8),
3941
- lineX1: r1(left - 10),
3942
- lineX2: r1(400 - left + 10),
3943
- captionY: r1(100 + size * 0.36 + 30)
5020
+ base: r12(100 + size * 0.36),
5021
+ shift: r12(size * 0.8),
5022
+ lineX1: r12(left - 10),
5023
+ lineX2: r12(400 - left + 10),
5024
+ captionY: r12(100 + size * 0.36 + 30)
3944
5025
  };
3945
5026
  },
3946
5027
  motion: (() => {
3947
- const word = `<text x="200" y="{{base}}" text-anchor="middle" textLength="{{length}}" lengthAdjust="spacingAndGlyphs" font-family="${SANS}" font-size="{{size}}" font-weight="800" fill="{{ink}}">{{text}}</text>`;
5028
+ const word = `<text x="200" y="{{base}}" text-anchor="middle" textLength="{{length}}" lengthAdjust="spacingAndGlyphs" font-family="${SANS2}" font-size="{{size}}" font-weight="800" fill="{{ink}}">{{text}}</text>`;
3948
5029
  return {
3949
- svg: `<svg viewBox="0 0 400 200"><!-- {{text: text default='Chapter One' label='Text'}} {{caption: text default='The beginning' label='Caption'}} {{background: color default=#0F172A label='Background'}} {{ink: color default=#F8FAFC label='Text colour'}} {{line: color default=#38BDF8 label='Line'}} --><defs><clipPath id="above"><rect width="400" height="100"/></clipPath><clipPath id="below"><rect y="100" width="400" height="100"/></clipPath></defs><rect width="400" height="200" fill="{{background}}"/><g clip-path="url(#above)"><g id="top" transform="translate(0 {{shift}})">${word}</g></g><g clip-path="url(#below)"><g id="bottom" transform="translate(0 -{{shift}})">${word}</g></g><path id="line" d="M{{lineX1}} 100 H{{lineX2}}" stroke="{{line}}" stroke-width="2.5" stroke-linecap="round"/><text id="caption" x="200" y="{{captionY}}" text-anchor="middle" font-family="${SANS}" font-size="13" letter-spacing="3" fill="{{ink}}" opacity="0">{{caption}}</text></svg>`,
5030
+ svg: `<svg viewBox="0 0 400 200"><!-- {{text: text default='Chapter One' label='Text'}} {{caption: text default='The beginning' label='Caption'}} {{background: color default=#0F172A label='Background'}} {{ink: color default=#F8FAFC label='Text colour'}} {{line: color default=#38BDF8 label='Line'}} --><defs><clipPath id="above"><rect width="400" height="100"/></clipPath><clipPath id="below"><rect y="100" width="400" height="100"/></clipPath></defs><rect width="400" height="200" fill="{{background}}"/><g clip-path="url(#above)"><g id="top" transform="translate(0 {{shift}})">${word}</g></g><g clip-path="url(#below)"><g id="bottom" transform="translate(0 -{{shift}})">${word}</g></g><path id="line" d="M{{lineX1}} 100 H{{lineX2}}" stroke="{{line}}" stroke-width="2.5" stroke-linecap="round"/><text id="caption" x="200" y="{{captionY}}" text-anchor="middle" font-family="${SANS2}" font-size="13" letter-spacing="3" fill="{{ink}}" opacity="0">{{caption}}</text></svg>`,
3950
5031
  animate: [
3951
5032
  { id: "line", draw: true, ms: 600, ease: "in-out" },
3952
5033
  { id: "top", attrs: { transform: ["translate(0 {{shift}})", "translate(0 0)"] }, delay: 450, ms: 800, ease: "out" },
@@ -3972,7 +5053,7 @@ var highlighter = {
3972
5053
  ],
3973
5054
  // The swipe runs a little past the phrase at both ends, across its lower half, with a ragged second pass.
3974
5055
  derived: (v) => {
3975
- const { size, length, left } = fit(String(v.text), 330, 56);
5056
+ const { size, length, left } = fit2(String(v.text), 330, 56);
3976
5057
  const base = 100 + size * 0.35;
3977
5058
  const x0 = left - size * 0.25;
3978
5059
  const x1 = 400 - left + size * 0.2;
@@ -3980,15 +5061,15 @@ var highlighter = {
3980
5061
  return {
3981
5062
  size,
3982
5063
  length,
3983
- base: r1(base),
3984
- swipe: `M${r1(x0)} ${r1(y + size * 0.05)} Q${r1((x0 + x1) / 2)} ${r1(y - size * 0.06)} ${r1(x1)} ${r1(y - size * 0.02)}`,
3985
- ragged: `M${r1(x0 + size * 0.15)} ${r1(y + size * 0.3)} L${r1(x1 - size * 0.1)} ${r1(y + size * 0.26)}`,
3986
- thick: r1(size * 0.72),
3987
- thin: r1(size * 0.16)
5064
+ base: r12(base),
5065
+ swipe: `M${r12(x0)} ${r12(y + size * 0.05)} Q${r12((x0 + x1) / 2)} ${r12(y - size * 0.06)} ${r12(x1)} ${r12(y - size * 0.02)}`,
5066
+ ragged: `M${r12(x0 + size * 0.15)} ${r12(y + size * 0.3)} L${r12(x1 - size * 0.1)} ${r12(y + size * 0.26)}`,
5067
+ thick: r12(size * 0.72),
5068
+ thin: r12(size * 0.16)
3988
5069
  };
3989
5070
  },
3990
5071
  motion: {
3991
- svg: `<svg viewBox="0 0 400 200"><!-- {{text: text default='Ship it faster' label='Phrase'}} {{background: color default=#FFFFFF label='Background'}} {{ink: color default=#111827 label='Text colour'}} {{marker: color default=#FDE047 label='Marker'}} --><rect width="400" height="200" fill="{{background}}"/><g transform="rotate(-1.2 200 100)" opacity="0.9"><path id="swipe" d="{{swipe}}" fill="none" stroke="{{marker}}" stroke-width="{{thick}}"/><path id="ragged" d="{{ragged}}" fill="none" stroke="{{marker}}" stroke-width="{{thin}}" stroke-linecap="round"/></g><text id="phrase" x="200" y="{{base}}" text-anchor="middle" textLength="{{length}}" lengthAdjust="spacingAndGlyphs" font-family="${SANS}" font-size="{{size}}" font-weight="700" fill="{{ink}}" opacity="0">{{text}}</text></svg>`,
5072
+ svg: `<svg viewBox="0 0 400 200"><!-- {{text: text default='Ship it faster' label='Phrase'}} {{background: color default=#FFFFFF label='Background'}} {{ink: color default=#111827 label='Text colour'}} {{marker: color default=#FDE047 label='Marker'}} --><rect width="400" height="200" fill="{{background}}"/><g transform="rotate(-1.2 200 100)" opacity="0.9"><path id="swipe" d="{{swipe}}" fill="none" stroke="{{marker}}" stroke-width="{{thick}}"/><path id="ragged" d="{{ragged}}" fill="none" stroke="{{marker}}" stroke-width="{{thin}}" stroke-linecap="round"/></g><text id="phrase" x="200" y="{{base}}" text-anchor="middle" textLength="{{length}}" lengthAdjust="spacingAndGlyphs" font-family="${SANS2}" font-size="{{size}}" font-weight="700" fill="{{ink}}" opacity="0">{{text}}</text></svg>`,
3992
5073
  animate: [
3993
5074
  { id: "phrase", attrs: { opacity: [0, 1] }, ms: 300, ease: "out" },
3994
5075
  { id: "swipe", draw: true, delay: 350, ms: 650, ease: "in-out" },
@@ -4023,36 +5104,36 @@ var wordCycle = {
4023
5104
  const step = size * 1.3;
4024
5105
  const wx = left + (lead + 0.3) * size;
4025
5106
  return {
4026
- size: r1(size),
4027
- base: r1(base),
4028
- px: r1(left),
4029
- plen: r1(Math.max(0.6, lead) * size),
4030
- wx: r1(wx),
4031
- clipX: r1(wx - 4),
4032
- clipY: r1(base - size * 1),
4033
- clipW: r1(Math.max(...ems) * size + 8),
4034
- clipH: r1(size * 1.32),
4035
- underY: r1(base + size * 0.18),
5107
+ size: r12(size),
5108
+ base: r12(base),
5109
+ px: r12(left),
5110
+ plen: r12(Math.max(0.6, lead) * size),
5111
+ wx: r12(wx),
5112
+ clipX: r12(wx - 4),
5113
+ clipY: r12(base - size * 1),
5114
+ clipW: r12(Math.max(...ems) * size + 8),
5115
+ clipH: r12(size * 1.32),
5116
+ underY: r12(base + size * 0.18),
4036
5117
  ...Object.fromEntries(
4037
5118
  [...words, words[0]].flatMap((_, k) => [
4038
- [`y${k}`, r1(base + k * step)],
4039
- [`o${k}`, r1(k * step)],
4040
- [`len${k}`, r1(ems[k % CYCLE_WORDS] * size)]
5119
+ [`y${k}`, r12(base + k * step)],
5120
+ [`o${k}`, r12(k * step)],
5121
+ [`len${k}`, r12(ems[k % CYCLE_WORDS] * size)]
4041
5122
  ])
4042
5123
  ),
4043
5124
  ...Object.fromEntries(words.map((w, k) => [`w${k}`, w]))
4044
5125
  };
4045
5126
  },
4046
5127
  motion: {
4047
- svg: `<svg viewBox="0 0 400 200"><!-- {{prefix: text default='We build' label='Lead-in'}} {{word1: text default=apps label='Word 1'}} {{word2: text default=brands label='Word 2'}} {{word3: text default=websites label='Word 3'}} {{word4: text default=games label='Word 4'}} {{background: color default=#FFFFFF label='Background'}} {{ink: color default=#0F172A label='Text colour'}} {{accent: color default=#6366F1 label='Accent'}} --><defs><clipPath id="window"><rect x="{{clipX}}" y="{{clipY}}" width="{{clipW}}" height="{{clipH}}"/></clipPath></defs><rect width="400" height="200" fill="{{background}}"/><text x="{{px}}" y="{{base}}" textLength="{{plen}}" lengthAdjust="spacingAndGlyphs" font-family="${SANS}" font-size="{{size}}" font-weight="700" fill="{{ink}}">{{prefix}}</text><g clip-path="url(#window)"><g id="reel" transform="translate(0 -0)">${Array.from(
5128
+ svg: `<svg viewBox="0 0 400 200"><!-- {{prefix: text default='We build' label='Lead-in'}} {{word1: text default=apps label='Word 1'}} {{word2: text default=brands label='Word 2'}} {{word3: text default=websites label='Word 3'}} {{word4: text default=games label='Word 4'}} {{background: color default=#FFFFFF label='Background'}} {{ink: color default=#0F172A label='Text colour'}} {{accent: color default=#6366F1 label='Accent'}} --><defs><clipPath id="window"><rect x="{{clipX}}" y="{{clipY}}" width="{{clipW}}" height="{{clipH}}"/></clipPath></defs><rect width="400" height="200" fill="{{background}}"/><text x="{{px}}" y="{{base}}" textLength="{{plen}}" lengthAdjust="spacingAndGlyphs" font-family="${SANS2}" font-size="{{size}}" font-weight="700" fill="{{ink}}">{{prefix}}</text><g clip-path="url(#window)"><g id="reel" transform="translate(0 -0)">${Array.from(
4048
5129
  { length: CYCLE_WORDS + 1 },
4049
- (_, k) => `<text x="{{wx}}" y="{{y${k}}}" textLength="{{len${k}}}" lengthAdjust="spacingAndGlyphs" font-family="${SANS}" font-size="{{size}}" font-weight="800" fill="{{accent}}">{{w${k % CYCLE_WORDS}}}</text>`
5130
+ (_, k) => `<text x="{{wx}}" y="{{y${k}}}" textLength="{{len${k}}}" lengthAdjust="spacingAndGlyphs" font-family="${SANS2}" font-size="{{size}}" font-weight="800" fill="{{accent}}">{{w${k % CYCLE_WORDS}}}</text>`
4050
5131
  ).join("")}</g></g><rect id="under" x="{{wx}}" y="{{underY}}" width="{{len0}}" height="4" rx="2" fill="{{accent}}"/></svg>`,
4051
5132
  animate: Array.from({ length: CYCLE_WORDS }, (_, i) => {
4052
5133
  const at = { delay: (i + 1) * SHOW_MS + i * ROLL_MS, ms: ROLL_MS, ease: "in-out" };
4053
5134
  return [
4054
5135
  { id: "reel", attrs: { transform: [`translate(0 -{{o${i}}})`, `translate(0 -{{o${i + 1}}})`] }, ...at },
4055
- { id: "under", attrs: { width: [n2(`len${i}`), n2(`len${i + 1}`)] }, ...at }
5136
+ { id: "under", attrs: { width: [n3(`len${i}`), n3(`len${i + 1}`)] }, ...at }
4056
5137
  ];
4057
5138
  }).flat()
4058
5139
  }
@@ -4073,9 +5154,9 @@ var liquidFill = {
4073
5154
  ],
4074
5155
  // The surface starts under the letters and ends above their caps.
4075
5156
  derived: (v) => {
4076
- const { size, length } = fit(String(v.text), 360, 110);
5157
+ const { size, length } = fit2(String(v.text), 360, 110);
4077
5158
  const base = 100 + size * 0.36;
4078
- return { size, length, base: r1(base), low: r1(base + 12), high: r1(base - size * 0.82 - 12) };
5159
+ return { size, length, base: r12(base), low: r12(base + 12), high: r12(base - size * 0.82 - 12) };
4079
5160
  },
4080
5161
  motion: (() => {
4081
5162
  const word = `x="200" y="{{base}}" text-anchor="middle" textLength="{{length}}" lengthAdjust="spacingAndGlyphs" font-family="${HEAVY}" font-size="{{size}}" font-weight="900"`;
@@ -4122,17 +5203,17 @@ var glitchText = {
4122
5203
  { name: "Vapor", values: { left: [1, 0.3, 0.85], right: [0.4, 0.6, 1], background: [0.08, 0.02, 0.12], amount: 1.4 } }
4123
5204
  ],
4124
5205
  derived: (v) => {
4125
- const { size, length } = fit(String(v.text), 340, 100);
5206
+ const { size, length } = fit2(String(v.text), 340, 100);
4126
5207
  const base = 100 + size * 0.36;
4127
5208
  const top = base - size * 0.78;
4128
- const cuts = [0, ...BANDS.map((f) => r1(top + f * (base - top))), 200];
5209
+ const cuts = [0, ...BANDS.map((f) => r12(top + f * (base - top))), 200];
4129
5210
  const unit = size * Number(v.amount);
4130
- const shift = ([x, y]) => `translate(${r1(x * unit)} ${r1(y * unit)})`;
5211
+ const shift = ([x, y]) => `translate(${r12(x * unit)} ${r12(y * unit)})`;
4131
5212
  return {
4132
5213
  size,
4133
5214
  length,
4134
- base: r1(base),
4135
- ...Object.fromEntries([0, 1, 2, 3].flatMap((b) => [[`cut${b}`, cuts[b]], [`band${b}`, r1(cuts[b + 1] - cuts[b])]])),
5215
+ base: r12(base),
5216
+ ...Object.fromEntries([0, 1, 2, 3].flatMap((b) => [[`cut${b}`, cuts[b]], [`band${b}`, r12(cuts[b + 1] - cuts[b])]])),
4136
5217
  ...Object.fromEntries(
4137
5218
  GLITCH.flatMap((key, k) => [
4138
5219
  [`red${k}`, shift(key.red)],
@@ -4172,21 +5253,21 @@ var extrudedText = {
4172
5253
  ],
4173
5254
  // Each layer sits a step further back along the extrude direction, shaded from the side colour to the shadow.
4174
5255
  derived: (v) => {
4175
- const { size, length } = fit(String(v.text), 320, 96);
5256
+ const { size, length } = fit2(String(v.text), 320, 96);
4176
5257
  const step = size * 0.03 * Number(v.depth);
4177
5258
  const at = (deg, i) => {
4178
5259
  const a = deg * Math.PI / 180;
4179
- return `translate(${r1(Math.cos(a) * step * i)} ${r1(Math.sin(a) * step * i)})`;
5260
+ return `translate(${r12(Math.cos(a) * step * i)} ${r12(Math.sin(a) * step * i)})`;
4180
5261
  };
4181
5262
  return {
4182
5263
  size,
4183
5264
  length,
4184
- base: r1(92 + size * 0.35),
5265
+ base: r12(92 + size * 0.35),
4185
5266
  ...Object.fromEntries(
4186
5267
  Array.from({ length: LAYERS }, (_, k) => {
4187
5268
  const i = LAYERS - k;
4188
5269
  return [
4189
- [`fill${i}`, mixHex(v.side, v.shade, i / LAYERS)],
5270
+ [`fill${i}`, mixHex2(v.side, v.shade, i / LAYERS)],
4190
5271
  [`a${i}`, at(EXTRUDE_FROM, i)],
4191
5272
  [`b${i}`, at(EXTRUDE_TO, i)]
4192
5273
  ];
@@ -4233,6 +5314,7 @@ var EFFECT_CATEGORIES = [
4233
5314
  "overlay",
4234
5315
  "text",
4235
5316
  "morph",
5317
+ "interaction",
4236
5318
  "transition",
4237
5319
  "loader",
4238
5320
  "data",
@@ -4915,9 +5997,10 @@ var SHADER_EFFECTS = [
4915
5997
  ...overlayEffects,
4916
5998
  ...textShaders,
4917
5999
  ...morphShaders,
6000
+ ...interactionShaders,
4918
6001
  ...transitions
4919
6002
  ];
4920
- var EFFECTS = [...SHADER_EFFECTS, ...vectorEffects, ...textEffects, ...morphEffects].sort(
6003
+ var EFFECTS = [...SHADER_EFFECTS, ...vectorEffects, ...textEffects, ...morphEffects, ...interactionEffects].sort(
4921
6004
  (a, b) => EFFECT_CATEGORIES.indexOf(a.category) - EFFECT_CATEGORIES.indexOf(b.category)
4922
6005
  );
4923
6006
  var byId = new Map(EFFECTS.map((e) => [e.id, e]));
@@ -4970,4 +6053,4 @@ export {
4970
6053
  effectControls,
4971
6054
  effectDefaults
4972
6055
  };
4973
- //# sourceMappingURL=chunk-F754SFRY.js.map
6056
+ //# sourceMappingURL=chunk-STGGN4MR.js.map