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
143 changes: 143 additions & 0 deletions src/app/components/landing/clip-library-demo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { AnimatePresence, motion } from "motion/react";
import { useState } from "react";

const SAMPLE_CLIPS = [
{
id: "facade",
name: "ParametricFacade",
description: "Panel divisions with custom depth offsets",
tags: ["architecture", "facade"],
},
{
id: "curve",
name: "DisplayCurveLength",
description: "Curve length labels with unit formatting",
tags: ["analysis"],
},
{
id: "voronoi",
name: "VoronoiPattern",
description: "Surface subdivision with attractor points",
tags: ["geometry"],
},
] as const;

type DemoMode = "preview" | "copy" | "share";

/**
* Tiny in-page product mock: preview, or copy/share one sample clip.
* Uses Hopper Card accents (green-300 Shared, tag chips) — not a full tour.
*/
export function ClipLibraryDemo({
mode = "preview",
featuredId = "facade",
}: {
mode?: DemoMode;
featuredId?: (typeof SAMPLE_CLIPS)[number]["id"];
}) {
const [copiedId, setCopiedId] = useState<string | null>(null);
const [sharedId, setSharedId] = useState<string | null>(null);

const handleCopy = (id: string) => {
setSharedId(null);
setCopiedId(id);
window.setTimeout(() => setCopiedId((cur) => (cur === id ? null : cur)), 1800);
};

const handleShare = (id: string) => {
setCopiedId(null);
setSharedId(id);
window.setTimeout(() => setSharedId((cur) => (cur === id ? null : cur)), 2800);
};
Comment on lines +41 to +51

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Prevent stale feedback timers from clearing a later action.

Clicking the same button again leaves its earlier timeout active, so it can clear the newer “copied”/“shared” feedback at the original deadline. Cancel and replace each mode’s existing timeout before scheduling a new one; also clear pending timers on unmount.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/components/landing/clip-library-demo.tsx` around lines 42 - 52,
Update handleCopy and handleShare to store each mode’s timeout handle, clear the
existing handle before scheduling a replacement, and retain the latest handle.
Add unmount cleanup to clear any pending copied and shared timers so stale
callbacks cannot update state after the component is removed.


return (
<div className="relative">
<ul className="flex flex-col gap-2.5">
{SAMPLE_CLIPS.map((clip) => {
const featured = clip.id === featuredId;
const justCopied = copiedId === clip.id;
const justShared = sharedId === clip.id;
const showSharedBadge =
(mode === "preview" && clip.id === "facade") || justShared;

return (
<li
key={clip.id}
className={`relative rounded-md p-3 ring-1 transition-colors ${
featured
? "bg-neutral-900 ring-neutral-500"
: "bg-neutral-950 ring-neutral-800 opacity-70"
}`}
>
{showSharedBadge && (
<span className="absolute top-2.5 right-2.5 rounded-md bg-green-300 px-2 text-xs font-bold text-neutral-800">
Shared
</span>
)}
<div className="flex items-start justify-between gap-3">
<div
className={`min-w-0 ${showSharedBadge ? "pr-16" : ""}`}
>
<p className="truncate text-sm font-semibold text-white">
{clip.name}
</p>
<p className="mt-0.5 line-clamp-1 text-xs text-neutral-400">
{clip.description}
</p>
<div className="mt-2 flex flex-wrap gap-1.5">
{clip.tags.map((tag) => (
<span
key={tag}
className="rounded-sm bg-neutral-600 px-2 text-xs font-semibold text-neutral-100"
>
{tag}
</span>
))}
</div>
</div>
{featured && mode === "copy" && (
<button
type="button"
onClick={() => handleCopy(clip.id)}
className={`shrink-0 px-2 text-sm font-bold transition-colors ${
justCopied
? "rounded-md bg-green-300 text-neutral-800"
: "text-neutral-400 hover:text-neutral-50"
}`}
>
{justCopied ? "copied!" : "copy"}
</button>
)}
{featured && mode === "share" && !justShared && (
<button
type="button"
onClick={() => handleShare(clip.id)}
className="shrink-0 px-2 text-sm font-bold text-neutral-400 transition-colors hover:text-neutral-50"
>
share
</button>
)}
</div>
</li>
);
})}
</ul>

<AnimatePresence>
{sharedId && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 4 }}
transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
className="absolute inset-x-0 -bottom-14 flex justify-center"
>
<div className="rounded-md border border-green-300/30 bg-neutral-950 px-3 py-2 font-mono text-xs text-green-300">
hopperclip.com/share?…
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
232 changes: 232 additions & 0 deletions src/app/components/landing/inspect-diff-visual.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
import { motion } from "motion/react";

/**
* Quiet product frame for the LP: graph + soft diff highlight.
* Dark palette to match the landing page — not the real GH canvas look.
*/
export function InspectDiffVisual() {
return (
<div
className="relative overflow-hidden rounded-xl border border-neutral-800 bg-neutral-950"
aria-hidden
>
<svg
viewBox="0 0 480 220"
className="h-auto w-full"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<pattern
id="lp-gh-grid"
width="40"
height="40"
patternUnits="userSpaceOnUse"
>
<path
d="M 40 0 L 0 0 0 40"
fill="none"
stroke="#262626"
strokeWidth="1"
/>
</pattern>
</defs>
<rect width="480" height="220" fill="#0a0a0a" />
<rect width="480" height="220" fill="url(#lp-gh-grid)" />

{/* wires */}
<motion.path
d="M 118 70 C 160 70, 160 70, 190 70"
fill="none"
stroke="#525252"
strokeWidth="1.5"
initial={{ pathLength: 0 }}
whileInView={{ pathLength: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.8, delay: 0.2 }}
/>
<motion.path
d="M 118 150 C 160 150, 160 110, 190 110"
fill="none"
stroke="#86efac"
strokeWidth="2"
initial={{ pathLength: 0, opacity: 0 }}
whileInView={{ pathLength: 1, opacity: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.9, delay: 0.55 }}
/>
<motion.path
d="M 300 90 C 340 90, 340 150, 362 150"
fill="none"
stroke="#f87171"
strokeWidth="1.5"
strokeDasharray="4 3"
initial={{ opacity: 0 }}
whileInView={{ opacity: 0.85 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.7 }}
/>

{/* value nodes */}
<g transform="translate(48, 48)">
<rect
width="70"
height="44"
rx="4"
fill="#262626"
stroke="#525252"
/>
<text
x="10"
y="18"
fontSize="9"
fill="#737373"
fontFamily="ui-monospace, monospace"
>
Number
</text>
<text
x="10"
y="34"
fontSize="11"
fill="#e5e5e5"
fontFamily="ui-sans-serif, system-ui"
>
12.0
</text>
</g>

<g transform="translate(48, 128)">
<rect
width="70"
height="44"
rx="4"
fill="#262626"
stroke="#525252"
/>
<text
x="10"
y="18"
fontSize="9"
fill="#737373"
fontFamily="ui-monospace, monospace"
>
Number
</text>
<text
x="10"
y="34"
fontSize="11"
fill="#e5e5e5"
fontFamily="ui-sans-serif, system-ui"
>
4.0
</text>
</g>

{/* component — unmodified */}
<g transform="translate(190, 48)">
<rect
width="110"
height="64"
rx="4"
fill="#171717"
stroke="#404040"
/>
<rect width="110" height="18" rx="4" fill="#262626" />
<rect y="14" width="110" height="8" fill="#262626" />
<text
x="8"
y="13"
fontSize="9"
fill="#d4d4d4"
fontFamily="ui-sans-serif, system-ui"
>
Divide Curve
</text>
<circle cx="0" cy="22" r="4" fill="#525252" />
<circle cx="0" cy="42" r="4" fill="#525252" />
<circle cx="110" cy="32" r="4" fill="#525252" />
</g>

{/* component — modified */}
<motion.g
transform="translate(190, 128)"
initial={{ opacity: 0.35 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.45 }}
>
<rect
width="110"
height="52"
rx="4"
fill="#171717"
stroke="#fbbf24"
strokeWidth="2"
/>
<rect width="110" height="18" rx="4" fill="#262626" />
<rect y="14" width="110" height="8" fill="#262626" />
<text
x="8"
y="13"
fontSize="9"
fill="#d4d4d4"
fontFamily="ui-sans-serif, system-ui"
>
Extrude
</text>
<circle cx="0" cy="28" r="4" fill="#525252" />
<circle cx="110" cy="28" r="4" fill="#525252" />
</motion.g>

{/* panel — added */}
<motion.g
transform="translate(362, 128)"
initial={{ opacity: 0, y: 6 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.75 }}
Comment on lines +183 to +188

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Locate SVG elements that combine a transform attribute with Motion x/y transforms.
rg -n -U 'transform=.*\n(?:.*\n){0,8}(?:initial|whileInView)=\{\{[^}]*\b[xy]:' \
  src/app/components/landing/inspect-diff-visual.tsx

Repository: tsoumdoa/hopperclip

Length of output: 157


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the target component and relevant package metadata without running repo code.
echo "== candidate file excerpt =="
sed -n '160,225p' src/app/components/landing/inspect-diff-visual.tsx

echo
echo "== motion/framer package versions from package manifests =="
for f in package.json pnpm-lock.yaml package-lock.json yarn.lock bun.lockb; do
  if [ -f "$f" ]; then
    echo "-- $f --"
    if [ "$f" = "package.json" ]; then
      rg -n '"(framer-motion|motion|react-dom)"' "$f" -C 1 || true
    else
      rg -n '(framer-motion|motion)\W|/framer-motion@|/motion@' "$f" -C 1 | head -80 || true
    fi
  fi
done

echo
echo "== all transform+motion svg groups near each other =="
python3 - <<'PY'
from pathlib import Path
p=Path('src/app/components/landing/inspect-diff-visual.tsx')
text=p.read_text().splitlines()
for i,line in enumerate(text,1):
    if '<motion.g' in line.lower() or '<motion.path' in line.lower() or 'transform=' in line:
        if any('initial' in l or 'whileInView' in l or 'transform=' in l for l in text[max(1,i-12):i+12]):
            print(f"-- around line {i} --")
            for j in range(max(1,i-8), min(len(text),i+14)+1):
                print(f"{j:4}: {text[j-1]}")
            print()
PY

Repository: tsoumdoa/hopperclip

Length of output: 10863


🌐 Web query:

Motion docs f12 0.27.2 SVG transforms style transforms transform attribute

💡 Result:

In Motion (formerly Framer Motion), SVG transforms are handled specifically to overcome limitations of standard SVG behavior [1][2]. Key behaviors regarding SVG transforms, styles, and attributes in version 0.27.2 and the broader Motion 12+ ecosystem include: Transform Origins: By default, SVG elements apply transforms relative to the top-left corner of the viewBox [1][2]. Motion automatically changes this behavior for motion components to match CSS, making transforms relative to the element's center [1][2]. This is achieved by defaulting to transform-box: fill-box [1][3]. You can restore the default SVG behavior or customize it by explicitly setting the transformBox style (e.g., transformBox: "view-box") [1][2]. Styles vs. Attributes: - Motion generally prefers animating values via CSS styles (transform property) rather than the SVG transform attribute, as this provides better performance and consistent behavior across modern browsers [4][3]. - When animating properties like x, y, or scale on SVG elements, Motion components will default to using CSS transforms (translateX/translateY) [5]. - If you specifically need to animate positional attributes (like the x or y attributes of a or ), you should use the attr-prefixed props: attrX, attrY, and attrScale [1][6][5]. Recent Pipeline Unification: As of recent updates (part of the unified Motion architecture), SVG values that exist as valid CSS properties (such as fill, opacity, and others) are rendered as inline styles rather than presentation attributes [3]. This approach ensures deterministic specificity (inline styles take priority) and improves performance by avoiding attribute-mutation overhead [3]. For scenarios where you must animate the SVG transform attribute directly (e.g., complex strings or legacy compatibility), Motion does not provide a native shorthand for animating the raw string. You may need to use external parsing logic or utilize Motion's mix function to interpolate transform matrices manually before updating the attribute via a ref [7]. Top results: [1][2][3][5]

Citations:


Preserve the panel’s SVG translation while animating it.

Motion renders SVG y as a CSS transform, so this group loses the transform="translate(362, 128)" position during animation. Wrap the animated group in a static translated <g> so it keeps the wire endpoint offset.

Proposed fix
-				<motion.g
-					transform="translate(362, 128)"
+				<g transform="translate(362, 128)">
+					<motion.g
 					initial={{ opacity: 0, y: 4 }}
 					whileInView={{ opacity: 1, y: 0 }}
 					viewport={{ once: true }}
 					transition={{ duration: 0.5, delay: 0.75 }}
-				>
+					>
						...
-				</motion.g>
+					</motion.g>
+				</g>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/components/landing/inspect-diff-visual.tsx` around lines 183 - 188,
Update the animated motion.g group in the inspect-diff visual so its existing
translate(362, 128) positioning is preserved: wrap it in a static SVG g carrying
the translation, and move the animation props to the inner motion.g. Keep the
current animation behavior and wire endpoint offset unchanged.

>
<rect
width="70"
height="44"
rx="4"
fill="#171717"
stroke="#86efac"
strokeWidth="2"
/>
<text
x="10"
y="18"
fontSize="9"
fill="#737373"
fontFamily="ui-monospace, monospace"
>
Panel
</text>
<text
x="10"
y="34"
fontSize="10"
fill="#e5e5e5"
fontFamily="ui-sans-serif, system-ui"
>
ok
</text>
</motion.g>
</svg>

<div className="absolute right-3 bottom-3 flex gap-2 font-mono text-[10px] tracking-wide uppercase">
<span className="rounded bg-green-300 px-1.5 py-0.5 font-bold text-neutral-800">
+ added
</span>
<span className="rounded border border-neutral-700 bg-neutral-950/90 px-1.5 py-0.5 text-amber-300">
~ modified
</span>
<span className="rounded border border-neutral-700 bg-neutral-950/90 px-1.5 py-0.5 text-red-400">
− removed
</span>
</div>
</div>
);
}
Loading