Skip to content

Commit dc2b5ec

Browse files
committed
feat(site): add AJ mascot celebrate jump pop-up and 2s checkmark animation on code copy
1 parent fce0baa commit dc2b5ec

3 files changed

Lines changed: 174 additions & 29 deletions

File tree

‎apps/site/src/components/portable-text/PortableTextContent.astro‎

Lines changed: 86 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,32 @@ const components = {
9696
}
9797
}
9898

99+
const copyTimers = new WeakMap<HTMLElement, number>();
100+
101+
async function copyTextToClipboard(text: string): Promise<void> {
102+
if (navigator.clipboard?.writeText) {
103+
try {
104+
await navigator.clipboard.writeText(text);
105+
return;
106+
} catch {
107+
// Fall through to textarea copy fallback
108+
}
109+
}
110+
const ta = document.createElement("textarea");
111+
ta.value = text;
112+
ta.setAttribute("readonly", "");
113+
ta.style.position = "fixed";
114+
ta.style.top = "-9999px";
115+
ta.style.opacity = "0";
116+
document.body.appendChild(ta);
117+
ta.select();
118+
try {
119+
document.execCommand("copy");
120+
} finally {
121+
document.body.removeChild(ta);
122+
}
123+
}
124+
99125
document.addEventListener("click", (event) => {
100126
const target = event.target;
101127
if (!(target instanceof Element)) {
@@ -104,10 +130,67 @@ const components = {
104130

105131
const copy = target.closest<HTMLElement>("button.copy-code");
106132
if (copy) {
107-
const code = copy.dataset.code ?? "";
108-
navigator.clipboard?.writeText(code).then(() => {
133+
const figure = copy.closest<HTMLElement>("figure");
134+
const code =
135+
copy.dataset.code ||
136+
figure?.querySelector("pre code")?.textContent ||
137+
"";
138+
139+
copyTextToClipboard(code).finally(() => {
109140
copy.classList.add("copied");
110-
setTimeout(() => copy.classList.remove("copied"), 1500);
141+
figure?.classList.add("is-copied");
142+
143+
// Trigger AJ Mascot Rig WAAPI Celebrate Jump Burst
144+
const reducedMotion = window.matchMedia(
145+
"(prefers-reduced-motion: reduce)",
146+
).matches;
147+
if (!reducedMotion && figure) {
148+
const bodyRoot = figure.querySelector<SVGGElement>(".aj-body-root");
149+
const earLeft =
150+
figure.querySelector<SVGGElement>(".aj-bone-ear-left");
151+
const earRight =
152+
figure.querySelector<SVGGElement>(".aj-bone-ear-right");
153+
154+
bodyRoot?.animate?.(
155+
[
156+
{ transform: "translateY(0px) scale(1, 1)" },
157+
{ transform: "translateY(10px) scale(1.06, 0.92)", offset: 0.18 },
158+
{
159+
transform: "translateY(-36px) scale(0.95, 1.07)",
160+
offset: 0.52,
161+
},
162+
{ transform: "translateY(0px) scale(1, 1)" },
163+
],
164+
{ duration: 680, easing: "cubic-bezier(0.34, 1.56, 0.64, 1)" },
165+
);
166+
earLeft?.animate?.(
167+
[
168+
{ transform: "rotate(0deg)" },
169+
{ transform: "rotate(-16deg)" },
170+
{ transform: "rotate(0deg)" },
171+
],
172+
{ duration: 520, easing: "ease-out" },
173+
);
174+
earRight?.animate?.(
175+
[
176+
{ transform: "rotate(0deg)" },
177+
{ transform: "rotate(16deg)" },
178+
{ transform: "rotate(0deg)" },
179+
],
180+
{ duration: 520, easing: "ease-out" },
181+
);
182+
}
183+
184+
const prevTimer = copyTimers.get(copy);
185+
if (prevTimer) {
186+
window.clearTimeout(prevTimer);
187+
}
188+
const timer = window.setTimeout(() => {
189+
copy.classList.remove("copied");
190+
figure?.classList.remove("is-copied");
191+
copyTimers.delete(copy);
192+
}, 2000);
193+
copyTimers.set(copy, timer);
111194
});
112195
return;
113196
}

‎apps/site/src/components/portable-text/PtCode.astro‎

Lines changed: 53 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { Code } from "astro:components";
33
import { stegaClean } from "@sanity/client/stega";
44
import type { Props as PtProps } from "astro-portabletext/types";
5+
import AjAnimatedMascot from "@/components/AjAnimatedMascot.astro";
56
import ExcalidrawDiagram from "@/components/ExcalidrawDiagram.astro";
67
import { normalizeLanguage } from "@/lib/code-languages";
78
@@ -40,21 +41,34 @@ const filename = node.filename;
4041
/>
4142
) : (
4243
code && (
43-
<figure class="not-prose duo-card group relative my-6 overflow-hidden">
44-
{filename && (
45-
<figcaption class="flex items-center justify-between border-b border-[#B4A7D6]/25 bg-[#0F172A] px-4 py-2.5 font-mono text-xs font-bold text-[#C5E86C]">
46-
<span>📄 {filename}</span>
47-
<span class="font-hand text-base text-[#B4A7D6]">{language}</span>
48-
</figcaption>
49-
)}
44+
<figure class="code-block-figure not-prose duo-card group relative my-6 overflow-visible">
45+
{/* AJ Mascot Rig Celebrate Jump Pop-Up on Copy */}
46+
<div
47+
class="copy-aj-popup pointer-events-none absolute -top-16 right-12 z-20 flex items-center gap-1.5 opacity-0 translate-y-6 scale-50 origin-bottom-right transition-all duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)]"
48+
aria-hidden="true"
49+
>
50+
<span class="-rotate-3 rounded-xl border-2 border-[#2A1155] bg-[#FEF08A] px-2.5 py-1 font-hand text-sm font-bold text-[#2A1155] shadow-sketch-sm whitespace-nowrap">
51+
Copied! 🐾
52+
</span>
53+
<AjAnimatedMascot
54+
state="celebrate"
55+
variant="full-body"
56+
angle="three-quarter-left"
57+
mouth="smile-open"
58+
cls="w-16 h-auto"
59+
interactive={false}
60+
/>
61+
</div>
62+
5063
<button
5164
type="button"
52-
class="copy-code absolute right-3 top-2.5 z-10 inline-flex size-8 items-center justify-center rounded-lg border border-[#B4A7D6]/35 bg-[#1E293B] text-[#F8FAFC] opacity-0 transition-opacity focus-visible:opacity-100 group-hover:opacity-100 hover:border-[#C5E86C]"
65+
class="copy-code absolute right-3 top-2.5 z-20 inline-flex size-8 cursor-pointer items-center justify-center rounded-lg border border-[#B4A7D6]/45 bg-[#1E293B]/95 text-[#F8FAFC] opacity-90 shadow-sm transition-all duration-200 focus-visible:opacity-100 group-hover:opacity-100 hover:border-[#C5E86C] hover:scale-105 active:scale-95"
5366
data-code={code}
5467
aria-label="Copy code"
68+
title="Copy code to clipboard"
5569
>
5670
<svg
57-
class="size-4"
71+
class="copy-icon-clipboard size-4 transition-all duration-200 ease-out"
5872
viewBox="0 0 24 24"
5973
fill="none"
6074
stroke="currentColor"
@@ -66,16 +80,37 @@ const filename = node.filename;
6680
<rect x="9" y="9" width="13" height="13" rx="2" />
6781
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
6882
</svg>
83+
<svg
84+
class="copy-icon-check absolute size-4 scale-0 -rotate-45 opacity-0 text-[#86EFAC] transition-all duration-200 ease-[cubic-bezier(0.34,1.56,0.64,1)]"
85+
viewBox="0 0 24 24"
86+
fill="none"
87+
stroke="currentColor"
88+
stroke-width="2.75"
89+
stroke-linecap="round"
90+
stroke-linejoin="round"
91+
aria-hidden="true"
92+
>
93+
<polyline points="20 6 9 17 4 12" />
94+
</svg>
6995
</button>
70-
<Code
71-
code={code}
72-
lang={language}
73-
themes={{ light: "github-light", dark: "github-dark" }}
74-
class:list={[
75-
"overflow-x-auto p-4 text-sm",
76-
filename ? "rounded-b-xl" : "rounded-xl",
77-
]}
78-
/>
96+
97+
<div class="overflow-hidden rounded-[calc(1.5rem-2px)]">
98+
{filename && (
99+
<figcaption class="flex items-center justify-between border-b border-[#B4A7D6]/25 bg-[#0F172A] px-4 py-2.5 pr-14 font-mono text-xs font-bold text-[#C5E86C]">
100+
<span>📄 {filename}</span>
101+
<span class="font-hand text-base text-[#B4A7D6]">{language}</span>
102+
</figcaption>
103+
)}
104+
<Code
105+
code={code}
106+
lang={language}
107+
themes={{ light: "github-light", dark: "github-dark" }}
108+
class:list={[
109+
"overflow-x-auto p-4 text-sm",
110+
filename ? "rounded-b-xl" : "rounded-xl",
111+
]}
112+
/>
113+
</div>
79114
</figure>
80115
)
81116
)

‎apps/site/src/styles/global.css‎

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -224,11 +224,13 @@
224224
* Tactile 3D Buttons (Dual-Theme: #2A1155 -> #7C3AED in Light Mode, #7C3AED with #4C1D95 lip in Dark Mode)
225225
*/
226226
.tactile-btn:not([hidden]):not(.hidden),
227+
.tactile-btn-secondary:not([hidden]):not(.hidden),
227228
.duo-btn:not([hidden]):not(.hidden) {
228229
display: inline-flex;
229230
}
230231

231232
.tactile-btn,
233+
.tactile-btn-secondary,
232234
.duo-btn {
233235
align-items: center;
234236
justify-content: center;
@@ -247,6 +249,7 @@
247249
}
248250

249251
.tactile-btn:focus-visible,
252+
.tactile-btn-secondary:focus-visible,
250253
.duo-btn:focus-visible {
251254
outline: none;
252255
box-shadow:
@@ -316,7 +319,8 @@
316319
0 4px 10px rgba(124, 58, 237, 0.3);
317320
}
318321

319-
/* Secondary / Outline Sketch Pill Button ("Watch Teardowns", "All Episodes") */
322+
/* Secondary / Outline Sketch Pill Button */
323+
.tactile-btn-secondary,
320324
.duo-btn-blue,
321325
.cc-btn-purple,
322326
.duo-btn-outline,
@@ -328,6 +332,7 @@
328332
box-shadow: 3px 4px 0px 0px #2a1155;
329333
}
330334

335+
.tactile-btn-secondary:hover,
331336
.duo-btn-blue:hover,
332337
.cc-btn-purple:hover,
333338
.duo-btn-outline:hover,
@@ -339,9 +344,21 @@
339344
box-shadow: 5px 6px 0px 0px #2a1155;
340345
}
341346

347+
.tactile-btn-secondary:active,
348+
.duo-btn-blue:active,
349+
.cc-btn-purple:active,
350+
.duo-btn-outline:active,
351+
.cc-btn-outline:active,
352+
.cc-btn-lavender:active {
353+
transform: translateY(2px);
354+
box-shadow: 1px 2px 0px 0px #2a1155;
355+
}
356+
357+
:root.dark .tactile-btn-secondary,
342358
:root.dark .duo-btn-blue,
343359
:root.dark .cc-btn-purple,
344360
:root.dark .duo-btn-outline,
361+
:root.dark .duo-btn-outline,
345362
:root.dark .cc-btn-outline,
346363
:root.dark .cc-btn-lavender {
347364
background-color: #131b2e;
@@ -350,6 +367,7 @@
350367
box-shadow: 3px 4px 0px 0px rgba(0, 0, 0, 0.75);
351368
}
352369

370+
:root.dark .tactile-btn-secondary:hover,
353371
:root.dark .duo-btn-blue:hover,
354372
:root.dark .cc-btn-purple:hover,
355373
:root.dark .duo-btn-outline:hover,
@@ -510,17 +528,26 @@
510528
}
511529

512530
.copy-code.copied {
513-
color: var(--primary);
531+
border-color: #86efac !important;
532+
background-color: #064e3b !important;
533+
color: #86efac !important;
534+
opacity: 1 !important;
535+
box-shadow: 0 0 16px rgba(134, 239, 172, 0.45);
514536
}
515537

516-
.copy-code.copied svg {
517-
display: none;
538+
.copy-code.copied .copy-icon-clipboard {
539+
transform: scale(0) rotate(45deg);
540+
opacity: 0;
541+
}
542+
543+
.copy-code.copied .copy-icon-check {
544+
transform: scale(1) rotate(0deg);
545+
opacity: 1;
518546
}
519547

520-
.copy-code.copied::after {
521-
content: "✓";
522-
font-size: 0.875rem;
523-
line-height: 1;
548+
.code-block-figure.is-copied .copy-aj-popup {
549+
opacity: 1;
550+
transform: translateY(0) scale(1);
524551
}
525552

526553
/* Persistent Right-Side Resizable Ask Agent Drawer */

0 commit comments

Comments
 (0)