Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 25 additions & 17 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -351,28 +351,34 @@ code {
position: absolute;
z-index: 2;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
pointer-events: none;
}

.duck-hot-smoke > span {
position: absolute;
width: 0.42rem;
height: 0.42rem;
border: 1px solid rgba(242, 236, 221, 0.42);
border-radius: 50%;
.duck-hot-smoke path {
fill: none;
stroke: #f2ecdd;
stroke-width: 4.8;
stroke-linecap: round;
stroke-linejoin: round;
opacity: 0;
animation: duck-hot-smoke 1.25s ease-out infinite;
filter: blur(0.35px);
transform-box: fill-box;
transform-origin: center bottom;
animation: duck-hot-wisp 1.25s ease-out infinite;
}

.duck-hot-smoke > span:nth-child(1) { left: 33%; bottom: 6%; }
.duck-hot-smoke > span:nth-child(2) { left: 38%; bottom: 7%; animation-delay: 310ms; }
.duck-hot-smoke > span:nth-child(3) { right: 38%; bottom: 7%; animation-delay: 620ms; }
.duck-hot-smoke > span:nth-child(4) { right: 33%; bottom: 6%; animation-delay: 930ms; }
.duck-hot-wisp-left { --smoke-drift: -5px; --smoke-turn: -5deg; animation-delay: -420ms; }
.duck-hot-wisp-center { --smoke-drift: 2px; --smoke-turn: 3deg; animation-delay: -140ms; }
.duck-hot-wisp-right { --smoke-drift: 5px; --smoke-turn: 5deg; animation-delay: -760ms; }

@keyframes duck-hot-smoke {
0% { transform: translate(0, 0) scale(0.45); opacity: 0; }
24% { opacity: 0.38; }
100% { transform: translate(-1px, -12px) scale(1.08); opacity: 0; }
@keyframes duck-hot-wisp {
0% { transform: translate(0, 8px) scale(0.55); opacity: 0; stroke-width: 4; }
18% { opacity: 0.56; }
55% { transform: translate(calc(var(--smoke-drift) * 0.35), -10px) scale(0.82) rotate(var(--smoke-turn)); opacity: 0.42; stroke-width: 5; }
100% { transform: translate(var(--smoke-drift), -29px) scale(1.08) rotate(var(--smoke-turn)); opacity: 0; stroke-width: 5.6; }
}

@keyframes duck-stomp {
Expand Down Expand Up @@ -469,7 +475,7 @@ code {
.duck-mark-agitated.duck-mark-settling .duck-stomp-left,
.duck-mark-agitated.duck-mark-settling .duck-stomp-right { animation: none; }
.duck-mark-overheated .duck-foot-heat { animation: none; }
.duck-hot-smoke > span { animation: none; opacity: 0.35; }
.duck-hot-smoke path { animation: none; opacity: 0.4; }
.hero-duck-button:hover .hero-duck,
.hero-duck-button:active .hero-duck { transform: none; }
html { scroll-behavior: auto; }
Expand Down Expand Up @@ -1010,6 +1016,8 @@ code {

.share-strip-inner { display: flex; align-items: center; justify-content: space-between; gap: 1.5rem; padding-block: clamp(2.5rem, 6vw, 4.2rem); }

.share-strip-actions { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 0.75rem; }

.share-strip .eyebrow { color: var(--orange-deep); }
.share-strip .eyebrow::before { border-color: var(--orange-deep); background: radial-gradient(circle, var(--orange) 0 0.1rem, transparent 0.11rem); }

Expand Down Expand Up @@ -1117,7 +1125,7 @@ code {
.behavior-byline { margin-top: 0.45rem; font-size: 0.56rem; }
.behavior-footer { min-width: 0; margin-top: -0.4rem; }
.share-strip-inner { display: block; }
.share-strip .button-secondary { margin-top: 1.2rem; }
.share-strip-actions { justify-content: flex-start; margin-top: 1.2rem; }
.footer-compact { grid-template-columns: 1fr; gap: 0.9rem; padding-block: 2rem 1.1rem; }
.footer-compact-links { justify-content: flex-start; }
.footer-compact-meta { grid-column: auto; }
Expand Down
6 changes: 5 additions & 1 deletion src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ArrowRight } from "lucide-react";
import { getAllBehaviors, getRegistryStats } from "@/lib/registry";
import { BehaviorCatalog } from "@/components/BehaviorCatalog";
import { CopyPromptButton } from "@/components/CopyPromptButton";
import { InteractiveDuck } from "@/components/InteractiveDuck";
import { QuackAnchor } from "@/components/QuackAction";

Expand Down Expand Up @@ -69,7 +70,10 @@ export default function HomePage() {
<section className="share-strip">
<div className="site-container share-strip-inner">
<div><span className="eyebrow">Have a new move?</span><h2>Add it to the shelf.</h2></div>
<QuackAnchor href="https://github.com/ob1-s/uduck-registry" target="_blank" rel="noopener noreferrer" className="button-secondary">Read the contributor guide <ArrowRight size={15} aria-hidden="true" /></QuackAnchor>
<div className="share-strip-actions">
<CopyPromptButton />
<QuackAnchor href="https://github.com/ob1-s/uduck-registry" target="_blank" rel="noopener noreferrer" className="button-secondary">Read the contributor guide <ArrowRight size={15} aria-hidden="true" /></QuackAnchor>
</div>
</div>
</section>
</>
Expand Down
26 changes: 26 additions & 0 deletions src/components/CopyPromptButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"use client";

import { useState } from "react";
import { Check, Copy } from "lucide-react";
import { QuackButton } from "./QuackAction";

const REGISTRY_PROMPT = "Please look up https://github.com/ob1-s/uduck-registry and add my Microduck policy to the registry, then open a PR.";

export function CopyPromptButton() {
const [copied, setCopied] = useState(false);

function handleCopy() {
void navigator.clipboard.writeText(REGISTRY_PROMPT).then(() => setCopied(true));
}

return (
<QuackButton
type="button"
className="button-secondary"
onClick={handleCopy}
aria-label={copied ? "Registry prompt copied" : "Copy the registry prompt"}
>
{copied ? <><Check size={15} aria-hidden="true" /> Copied!</> : <><Copy size={15} aria-hidden="true" /> 1-click prompt</>}
</QuackButton>
);
}
42 changes: 18 additions & 24 deletions src/components/InteractiveDuck.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,13 @@ export function InteractiveDuck() {
const agitatedTimerRef = useRef<number | null>(null);
const stompTimerRef = useRef<number | null>(null);
const stompFadeTimerRef = useRef<number | null>(null);
const overheatedTimerRef = useRef<number | null>(null);
const soundCueTimerRef = useRef<number | null>(null);
const passiveOpenTimerRef = useRef<number | null>(null);
const passiveCloseTimerRef = useRef<number | null>(null);
const hoverCueRolledRef = useRef(false);
const passiveHoverBoostRef = useRef(false);
const passiveHoverBoostUntilRef = useRef(0);
const angryQuackCountRef = useRef(0);
const nextAgitatedQuackRef = useRef(0);

useEffect(() => {
Expand All @@ -48,7 +48,6 @@ export function InteractiveDuck() {
if (agitatedTimerRef.current !== null) window.clearTimeout(agitatedTimerRef.current);
if (stompTimerRef.current !== null) window.clearInterval(stompTimerRef.current);
if (stompFadeTimerRef.current !== null) window.clearTimeout(stompFadeTimerRef.current);
if (overheatedTimerRef.current !== null) window.clearTimeout(overheatedTimerRef.current);
if (soundCueTimerRef.current !== null) window.clearTimeout(soundCueTimerRef.current);
if (passiveOpenTimerRef.current !== null) window.clearTimeout(passiveOpenTimerRef.current);
if (passiveCloseTimerRef.current !== null) window.clearTimeout(passiveCloseTimerRef.current);
Expand Down Expand Up @@ -108,11 +107,12 @@ export function InteractiveDuck() {
}

function triggerQuack(agitated: boolean, quiet = false, sound: "real" | "action" = "real") {
if (!playQuack(agitated, quiet, sound)) return;
if (!playQuack(agitated, quiet, sound)) return false;

setIsQuacking(true);
if (mouthTimerRef.current !== null) window.clearTimeout(mouthTimerRef.current);
mouthTimerRef.current = window.setTimeout(() => setIsQuacking(false), agitated ? 560 : 420);
return true;
}

function handleHover() {
Expand Down Expand Up @@ -179,31 +179,25 @@ export function InteractiveDuck() {
recentClicks.push(now);
clickTimesRef.current = recentClicks;

const agitated = isAgitated || recentClicks.length >= 5;
const wasAgitated = isAgitated;
const agitated = wasAgitated || recentClicks.length >= 5;
if (agitated && !wasAgitated) angryQuackCountRef.current = 0;
setIsAgitated(agitated);

if (agitated && !isOverheated && overheatedTimerRef.current === null) {
overheatedTimerRef.current = window.setTimeout(() => {
setIsOverheated(true);
overheatedTimerRef.current = null;
}, 2600);
}

if (agitated && stompTimerRef.current === null) {
playStomp();
stompTimerRef.current = window.setInterval(playStomp, 140);
}

triggerQuack(agitated);
const angryQuackPlayed = triggerQuack(agitated);
if (agitated && wasAgitated && angryQuackPlayed) {
angryQuackCountRef.current += 1;
if (angryQuackCountRef.current >= 2) setIsOverheated(true);
}

if (agitated) {
if (agitatedTimerRef.current !== null) window.clearTimeout(agitatedTimerRef.current);
agitatedTimerRef.current = window.setTimeout(() => {
if (overheatedTimerRef.current !== null) {
window.clearTimeout(overheatedTimerRef.current);
overheatedTimerRef.current = null;
}

if (stompTimerRef.current !== null) {
window.clearInterval(stompTimerRef.current);
stompTimerRef.current = null;
Expand All @@ -219,12 +213,13 @@ export function InteractiveDuck() {
setIsOverheated(false);
setIsSettling(false);
clickTimesRef.current = [];
angryQuackCountRef.current = 0;
nextAgitatedQuackRef.current = 0;
stompFadeTimerRef.current = null;
}, 140);
}, 160);
}, 140);
}, 2200);
}, 1800);
}
}

Expand All @@ -244,12 +239,11 @@ export function InteractiveDuck() {
</span>
)}
{isOverheated && (
<span className="duck-hot-smoke" aria-hidden="true">
<span />
<span />
<span />
<span />
</span>
<svg className="duck-hot-smoke" viewBox="0 0 150 150" aria-hidden="true">
<path className="duck-hot-wisp duck-hot-wisp-left" d="M53 130 C46 123 61 117 53 110 C47 104 58 97 55 89" />
<path className="duck-hot-wisp duck-hot-wisp-center" d="M75 129 C68 121 82 116 75 108 C69 101 80 96 77 87" />
<path className="duck-hot-wisp duck-hot-wisp-right" d="M98 130 C106 123 91 117 99 109 C105 103 95 97 100 89" />
</svg>
)}
<DuckMark
size={150}
Expand Down