Marauder's Map

A parchment unfolds in three panels, ink strokes draw the castle corridors, and a trail of footprints appears one by one — crossing Hogwarts to reveal "Harry Potter" on the tag.

The Marauder's Map: parchment unfolds with footprints tracing a path across the halls of Hogwarts Gryffindor Tower Staircase Marauder's Map I solemnly swear I am up to no good Great Hall Library Dungeons Astronomy Owlery Harry Potter
advanced ~3 min

Three-panel parchment unfolds in 3D, corridor ink strokes draw in, 10 footprints trail across the castle.

Why this matters This is the pattern behind most 'interactive illustration' work on the web. Master this and you can build map animations, diagram reveals, and step-by-step visual explainers.

What you'll learn

  • How rotateY + transform-origin + opacity compose into a credible 'unfolding' motion without a physics engine
  • The stroke-dasharray / stroke-dashoffset trick for hand-drawing SVG strokes in sequence
  • How to sequence many elements with only one @keyframes + staggered animation-delay
  • Using feTurbulence + feDisplacementMap to give digital strokes an aged, hand-drawn roughness

You should be comfortable with

  • Intermediate CSS
  • Familiarity with SVG path + filter basics

Bản ghi phiên dựng

Đây là toàn bộ phiên Claude Code dựng animation này.

Mỗi tool call, mỗi prompt, mỗi chỉnh sửa — được ghi lại. Bấm ▶ hoặc Space để xem nó tự tái dựng. Kéo thanh thời gian để nhảy đến bất kỳ khoảnh khắc nào. Khung Live Render sẽ cập nhật theo.

Nhật ký dựng

Toàn bộ phiên Claude Code. Dùng ← → hoặc click chapter để di chuyển.

  1. #0 Bash · git fetch origin main 2>&1 | tail -1 git checkout -b feat/quidditch-owner-sprint origin/main 2>&1 | tail -2 .claude/hooks/start-animation-build.sh owner-sprint 2>&1
    {
      "cursor": 0,
      "ts": "2026-04-16T11:27:32Z",
      "kind": "tool",
      "tool": "Bash",
      "input": {
        "command": "git fetch origin main 2>&1 | tail -1\ngit checkout -b feat/quidditch-owner-sprint origin/main 2>&1 | tail -2\n.claude/hooks/start-animation-build.sh owner-sprint 2>&1",
        "description": "Fresh branch + capture"
      },
      "output": {
        "stdout": "   452aa33d..c7e6f0c8  main       -> origin/main\nM\tsession-log.md\nbranch 'feat/quidditch-owner-sprint' set up to track 'origin/main'.\nstarted animation build: slug=owner-sprint sessionId=sess-1776338852-93130",
        "stderr": "",
        "interrupted": false,
        "isImage": false,
        "noOutputExpected": false
      },
      "postStateChecksum": {}
    }
  2. #1 Read · <repo>/src/animations/snitch-trail/index.astro
    {
      "cursor": 1,
      "ts": "2026-04-16T11:27:37Z",
      "kind": "tool",
      "tool": "Read",
      "input": {
        "file_path": "<repo>/src/animations/snitch-trail/index.astro"
      },
      "output": {
        "type": "text",
        "file": {
          "filePath": "<repo>/src/animations/snitch-trail/index.astro",
          "content": "---\n// Snitch trail — a golden snitch following an SVG path with a fading\n// gold-sparkle trail. First real animation on the Quidditch playground.\n---\n\n<div class=\"snitch-stage\">\n  <svg viewBox=\"0 0 800 450\" preserveAspectRatio=\"xMidYMid meet\" aria-labelledby=\"snitch-title\">\n    <title id=\"snitch-title\">A golden snitch tracing a figure-eight flight path with a shimmering trail</title>\n\n    <defs>\n      <path id=\"flight-path\" d=\"M 80 300 Q 200 100, 400 225 T 720 150\" />\n      <radialGradient id=\"snitch-gradient\" cx=\"35%\" cy=\"30%\" r=\"60%\">\n        <stop offset=\"0%\" stop-color=\"#ffd88a\" />\n        <stop offset=\"55%\" stop-color=\"#d4a02a\" />\n        <stop offset=\"100%\" stop-color=\"#8a6416\" />\n      </radialGradient>\n      <filter id=\"glow\" x=\"-50%\" y=\"-50%\" width=\"200%\" height=\"200%\">\n        <feGaussianBlur stdDeviation=\"3\" />\n      </filter>\n    </defs>\n\n    <!-- Flight path (subtle guide, disabled by default) -->\n    <use href=\"#flight-path\" class=\"path-guide\" />\n\n    <!-- Trail sparkles: same offset-path, staggered delay, fading opacity -->\n    <circle class=\"trail trail-1\" r=\"4\" />\n    <circle class=\"trail trail-2\" r=\"3\" />\n    <circle class=\"trail trail-3\" r=\"2\" />\n    <circle class=\"trail trail-4\" r=\"1.5\" />\n    <circle class=\"trail trail-5\" r=\"1\" />\n\n    <!-- Snitch body -->\n    <g class=\"snitch\">\n      <circle class=\"snitch-glow\" r=\"14\" />\n      <circle class=\"snitch-body\" r=\"9\" fill=\"url(#snitch-gradient)\" />\n      <g class=\"wing wing-left\">\n        <ellipse rx=\"10\" ry=\"4\" />\n      </g>\n      <g class=\"wing wing-right\">\n        <ellipse rx=\"10\" ry=\"4\" />\n      </g>\n    </g>\n  </svg>\n</div>\n\n<style>\n  .snitch-stage {\n    display: block;\n    width: 100%;\n    aspect-ratio: 16 / 9;\n    background: radial-gradient(circle at 50% 50%, #1b1c1d 0%, #08090a 70%);\n  }\n\n  svg {\n    width: 100%;\n    height: 100%;\n    display: block;\n  }\n\n  .path-guide {\n    fill: none;\n    stroke: rgba(212, 160, 42, 0.08);\n    stroke-width: 1;\n    stroke-dasharray: 2 6;\n  }\n\n  .snitch,\n  .trail {\n    /* v2: follow the flight path. offset-path mirrors the SVG <path>. */\n    offset-path: path(\"M 80 300 Q 200 100, 400 225 T 720 150\");\n    animation: fly 7s cubic-bezier(0.42, 0, 0.58, 1) infinite;\n  }\n\n  .snitch {\n    offset-rotate: auto;\n  }\n\n  /* Trails: each staggered slightly behind the snitch with decreasing opacity. */\n  .trail {\n    fill: #ffd88a;\n    filter: url(#glow);\n    mix-blend-mode: screen;\n  }\n  .trail-1 { animation-delay: -0.12s; opacity: 0.7; }\n  .trail-2 { animation-delay: -0.24s; opacity: 0.55; }\n  .trail-3 { animation-delay: -0.36s; opacity: 0.4; }\n  .trail-4 { animation-delay: -0.48s; opacity: 0.25; }\n  .trail-5 { animation-delay: -0.6s;  opacity: 0.15; }\n\n  @keyframes fly {\n    0%   { offset-distance: 0%; }\n    45%  { offset-distance: 100%; }\n    55%  { offset-distance: 100%; }\n    100% { offset-distance: 0%; }\n  }\n\n  .snitch-glow {\n    fill: #d4a02a;\n    filter: url(#glow);\n    opacity: 0.5;\n  }\n\n  .snitch-body {\n    stroke: rgba(255, 216, 138, 0.4);\n    stroke-width: 0.5;\n  }\n\n  .wing ellipse {\n    fill: rgba(247, 248, 248, 0.82);\n    stroke: rgba(255, 255, 255, 0.4);\n    stroke-width: 0.3;\n  }\n\n  .wing-left  { transform: translate(-10px, 0); animation: flap-left  0.16s ease-in-out infinite alternate; }\n  .wing-right { transform: translate( 10px, 0); animation: flap-right 0.16s ease-in-out infinite alternate; }\n\n  @keyframes flap-left {\n    from { transform: translate(-10px, 0) rotate(-22deg) scaleY(0.9); }\n    to   { transform: translate(-10px, 0) rotate(-6deg)  scaleY(1.1); }\n  }\n  @keyframes flap-right {\n    from { transform: translate(10px, 0) rotate(22deg) scaleY(0.9); }\n    to   { transform: translate(10px, 0) rotate(6deg)  scaleY(1.1); }\n  }\n\n  /* Accessibility: freeze motion for users who ask for reduced motion.\n     Snitch sits mid-path so the stage still reads as \"snitch in flight.\" */\n  @media (prefers-reduced-motion: reduce) {\n    .snitch, .trail, .wing-left, .wing-right {\n      animation: none;\n    }\n    .snitch {\n      offset-distance: 50%;\n    }\n    .trail {\n      display: none;\n    }\n  }\n</style>\n",
          "numLines": 140,
          "startLine": 1,
          "totalLines": 140
        }
      },
      "postStateChecksum": {}
    }
  3. #2 Write · <repo>/src/animations/snitch-trail/index.astro
    {
      "cursor": 2,
      "ts": "2026-04-16T11:28:16Z",
      "kind": "tool",
      "tool": "Write",
      "input": {
        "file_path": "<repo>/src/animations/snitch-trail/index.astro",
        "content": "---\n// Snitch trail — a golden snitch tracing a figure-eight flight path with a\n// bright shimmering trail on a darkened Quidditch sky. First real animation\n// on the Quidditch playground, polished for first-impression impact.\n---\n\n<div class=\"snitch-stage\">\n  <svg viewBox=\"0 0 800 450\" preserveAspectRatio=\"xMidYMid meet\" aria-labelledby=\"snitch-title\">\n    <title id=\"snitch-title\">A golden snitch tracing a figure-eight flight path with a shimmering trail</title>\n\n    <defs>\n      <path id=\"flight-path\" d=\"M 60 320 C 180 90, 380 360, 520 180 S 760 330, 740 120\" />\n      <radialGradient id=\"snitch-gradient\" cx=\"35%\" cy=\"30%\" r=\"65%\">\n        <stop offset=\"0%\" stop-color=\"#fff4c7\" />\n        <stop offset=\"30%\" stop-color=\"#ffd88a\" />\n        <stop offset=\"70%\" stop-color=\"#d4a02a\" />\n        <stop offset=\"100%\" stop-color=\"#7a5412\" />\n      </radialGradient>\n      <radialGradient id=\"trail-gradient\" cx=\"50%\" cy=\"50%\" r=\"50%\">\n        <stop offset=\"0%\" stop-color=\"#fff2a8\" stop-opacity=\"1\" />\n        <stop offset=\"70%\" stop-color=\"#ffd88a\" stop-opacity=\"0.6\" />\n        <stop offset=\"100%\" stop-color=\"#d4a02a\" stop-opacity=\"0\" />\n      </radialGradient>\n      <filter id=\"glow\" x=\"-80%\" y=\"-80%\" width=\"260%\" height=\"260%\">\n        <feGaussianBlur stdDeviation=\"3.5\" />\n      </filter>\n      <filter id=\"soft-glow\" x=\"-100%\" y=\"-100%\" width=\"300%\" height=\"300%\">\n        <feGaussianBlur stdDeviation=\"8\" />\n      </filter>\n    </defs>\n\n    <!-- Subtle flight path guide -->\n    <use href=\"#flight-path\" class=\"path-guide\" />\n\n    <!-- Star-field: scattered fixed sparkles for atmosphere -->\n    <g class=\"starfield\" aria-hidden=\"true\">\n      <circle cx=\"120\" cy=\"80\" r=\"1\" class=\"star\" style=\"animation-delay:0s\" />\n      <circle cx=\"670\" cy=\"60\" r=\"1.2\" class=\"star\" style=\"animation-delay:-0.6s\" />\n      <circle cx=\"380\" cy=\"45\" r=\"0.8\" class=\"star\" style=\"animation-delay:-1.2s\" />\n      <circle cx=\"750\" cy=\"270\" r=\"0.9\" class=\"star\" style=\"animation-delay:-1.8s\" />\n      <circle cx=\"80\" cy=\"180\" r=\"1.1\" class=\"star\" style=\"animation-delay:-2.4s\" />\n      <circle cx=\"220\" cy=\"390\" r=\"0.7\" class=\"star\" style=\"animation-delay:-3.0s\" />\n      <circle cx=\"580\" cy=\"400\" r=\"1\" class=\"star\" style=\"animation-delay:-3.6s\" />\n    </g>\n\n    <!-- Trail: 10 staggered gold orbs with glow + mix-blend-mode: screen -->\n    <circle class=\"trail trail-1\" r=\"7\" fill=\"url(#trail-gradient)\" />\n    <circle class=\"trail trail-2\" r=\"6\" fill=\"url(#trail-gradient)\" />\n    <circle class=\"trail trail-3\" r=\"5\" fill=\"url(#trail-gradient)\" />\n    <circle class=\"trail trail-4\" r=\"4\" fill=\"url(#trail-gradient)\" />\n    <circle class=\"trail trail-5\" r=\"3.5\" fill=\"url(#trail-gradient)\" />\n    <circle class=\"trail trail-6\" r=\"3\" fill=\"url(#trail-gradient)\" />\n    <circle class=\"trail trail-7\" r=\"2.5\" fill=\"url(#trail-gradient)\" />\n    <circle class=\"trail trail-8\" r=\"2\" fill=\"url(#trail-gradient)\" />\n    <circle class=\"trail trail-9\" r=\"1.5\" fill=\"url(#trail-gradient)\" />\n    <circle class=\"trail trail-10\" r=\"1\" fill=\"url(#trail-gradient)\" />\n\n    <!-- Snitch body -->\n    <g class=\"snitch\">\n      <!-- Outer halo -->\n      <circle class=\"snitch-halo\" r=\"22\" />\n      <!-- Inner glow -->\n      <circle class=\"snitch-glow\" r=\"15\" />\n      <!-- Wings behind body -->\n      <g class=\"wing wing-left\">\n        <ellipse rx=\"14\" ry=\"5\" />\n        <ellipse class=\"wing-detail\" rx=\"10\" ry=\"2.5\" />\n      </g>\n      <g class=\"wing wing-right\">\n        <ellipse rx=\"14\" ry=\"5\" />\n        <ellipse class=\"wing-detail\" rx=\"10\" ry=\"2.5\" />\n      </g>\n      <!-- Body -->\n      <circle class=\"snitch-body\" r=\"10\" fill=\"url(#snitch-gradient)\" />\n      <!-- Highlight spark -->\n      <circle class=\"snitch-spark\" cx=\"-3\" cy=\"-3\" r=\"3\" />\n    </g>\n  </svg>\n</div>\n\n<style>\n  .snitch-stage {\n    display: block;\n    width: 100%;\n    aspect-ratio: 16 / 9;\n    background:\n      radial-gradient(circle at 30% 20%, rgba(94, 106, 210, 0.08) 0%, transparent 45%),\n      radial-gradient(circle at 70% 80%, rgba(212, 160, 42, 0.06) 0%, transparent 50%),\n      radial-gradient(circle at 50% 50%, #1b1c1d 0%, #08090a 70%);\n  }\n\n  svg {\n    width: 100%;\n    height: 100%;\n    display: block;\n  }\n\n  .path-guide {\n    fill: none;\n    stroke: rgba(212, 160, 42, 0.1);\n    stroke-width: 1;\n    stroke-dasharray: 2 8;\n    animation: path-fade 6s ease-in-out infinite;\n  }\n\n  @keyframes path-fade {\n    0%, 100% { stroke-opacity: 0.04; }\n    50%      { stroke-opacity: 0.14; }\n  }\n\n  /* Star-field */\n  .star {\n    fill: #ffd88a;\n    filter: url(#glow);\n    animation: twinkle 3.5s ease-in-out infinite;\n  }\n  @keyframes twinkle {\n    0%, 100% { opacity: 0.15; }\n    50%      { opacity: 0.75; }\n  }\n\n  /* Flight path motion */\n  .snitch,\n  .trail {\n    offset-path: path(\"M 60 320 C 180 90, 380 360, 520 180 S 760 330, 740 120\");\n    animation: fly 6s cubic-bezier(0.45, 0, 0.55, 1) infinite;\n  }\n\n  .snitch {\n    offset-rotate: auto;\n  }\n\n  /* Trail — 10 orbs with screen blend for bright gold shimmer */\n  .trail {\n    filter: url(#glow);\n    mix-blend-mode: screen;\n    pointer-events: none;\n  }\n  .trail-1  { animation-delay: -0.10s; }\n  .trail-2  { animation-delay: -0.20s; }\n  .trail-3  { animation-delay: -0.30s; }\n  .trail-4  { animation-delay: -0.40s; }\n  .trail-5  { animation-delay: -0.50s; }\n  .trail-6  { animation-delay: -0.60s; }\n  .trail-7  { animation-delay: -0.72s; }\n  .trail-8  { animation-delay: -0.86s; }\n  .trail-9  { animation-delay: -1.00s; }\n  .trail-10 { animation-delay: -1.15s; }\n\n  @keyframes fly {\n    0%   { offset-distance: 0%;   }\n    48%  { offset-distance: 100%; }\n    52%  { offset-distance: 100%; }\n    100% { offset-distance: 0%;   }\n  }\n\n  /* Snitch body parts */\n  .snitch-halo {\n    fill: #d4a02a;\n    filter: url(#soft-glow);\n    opacity: 0.35;\n  }\n  .snitch-glow {\n    fill: #ffd88a;\n    filter: url(#glow);\n    opacity: 0.55;\n  }\n  .snitch-body {\n    stroke: rgba(255, 244, 199, 0.5);\n    stroke-width: 0.5;\n  }\n  .snitch-spark {\n    fill: #ffffff;\n    opacity: 0.75;\n    filter: url(#glow);\n  }\n\n  /* Wings */\n  .wing ellipse {\n    fill: rgba(250, 250, 252, 0.78);\n    stroke: rgba(255, 255, 255, 0.5);\n    stroke-width: 0.3;\n  }\n  .wing .wing-detail {\n    fill: rgba(255, 255, 255, 0.28);\n    stroke: none;\n  }\n\n  .wing-left  { transform: translate(-14px, 0); animation: flap-left  0.14s ease-in-out infinite alternate; }\n  .wing-right { transform: translate( 14px, 0); animation: flap-right 0.14s ease-in-out infinite alternate; }\n\n  @keyframes flap-left {\n    from { transform: translate(-14px, 0) rotate(-24deg) scaleY(0.85); }\n    to   { transform: translate(-14px, 0) rotate(-4deg)  scaleY(1.15); }\n  }\n  @keyframes flap-right {\n    from { transform: translate(14px, 0) rotate(24deg) scaleY(0.85); }\n    to   { transform: translate(14px, 0) rotate(4deg)  scaleY(1.15); }\n  }\n\n  /* Reduced motion — freeze mid-flight, hide trail + wings */\n  @media (prefers-reduced-motion: reduce) {\n    .snitch, .trail, .wing-left, .wing-right, .star, .path-guide {\n      animation: none;\n    }\n    .snitch { offset-distance: 50%; }\n    .trail { display: none; }\n  }\n</style>\n"
      },
      "output": {
        "type": "update",
        "filePath": "<repo>/src/animations/snitch-trail/index.astro",
        "content": "---\n// Snitch trail — a golden snitch tracing a figure-eight flight path with a\n// bright shimmering trail on a darkened Quidditch sky. First real animation\n// on the Quidditch playground, polished for first-impression impact.\n---\n\n<div class=\"snitch-stage\">\n  <svg viewBox=\"0 0 800 450\" preserveAspectRatio=\"xMidYMid meet\" aria-labelledby=\"snitch-title\">\n    <title id=\"snitch-title\">A golden snitch tracing a figure-eight flight path with a shimmering trail</title>\n\n    <defs>\n      <path id=\"flight-path\" d=\"M 60 320 C 180 90, 380 360, 520 180 S 760 330, 740 120\" />\n      <radialGradient id=\"snitch-gradient\" cx=\"35%\" cy=\"30%\" r=\"65%\">\n        <stop offset=\"0%\" stop-color=\"#fff4c7\" />\n        <stop offset=\"30%\" stop-color=\"#ffd88a\" />\n        <stop offset=\"70%\" stop-color=\"#d4a02a\" />\n        <stop offset=\"100%\" stop-color=\"#7a5412\" />\n      </radialGradient>\n      <radialGradient id=\"trail-gradient\" cx=\"50%\" cy=\"50%\" r=\"50%\">\n        <stop offset=\"0%\" stop-color=\"#fff2a8\" stop-opacity=\"1\" />\n        <stop offset=\"70%\" stop-color=\"#ffd88a\" stop-opacity=\"0.6\" />\n        <stop offset=\"100%\" stop-color=\"#d4a02a\" stop-opacity=\"0\" />\n      </radialGradient>\n      <filter id=\"glow\" x=\"-80%\" y=\"-80%\" width=\"260%\" height=\"260%\">\n        <feGaussianBlur stdDeviation=\"3.5\" />\n      </filter>\n      <filter id=\"soft-glow\" x=\"-100%\" y=\"-100%\" width=\"300%\" height=\"300%\">\n        <feGaussianBlur stdDeviation=\"8\" />\n      </filter>\n    </defs>\n\n    <!-- Subtle flight path guide -->\n    <use href=\"#flight-path\" class=\"path-guide\" />\n\n    <!-- Star-field: scattered fixed sparkles for atmosphere -->\n    <g class=\"starfield\" aria-hidden=\"true\">\n      <circle cx=\"120\" cy=\"80\" r=\"1\" class=\"star\" style=\"animation-delay:0s\" />\n      <circle cx=\"670\" cy=\"60\" r=\"1.2\" class=\"star\" style=\"animation-delay:-0.6s\" />\n      <circle cx=\"380\" cy=\"45\" r=\"0.8\" class=\"star\" style=\"animation-delay:-1.2s\" />\n      <circle cx=\"750\" cy=\"270\" r=\"0.9\" class=\"star\" style=\"animation-delay:-1.8s\" />\n      <circle cx=\"80\" cy=\"180\" r=\"1.1\" class=\"star\" style=\"animation-delay:-2.4s\" />\n      <circle cx=\"220\" cy=\"390\" r=\"0.7\" class=\"star\" style=\"animation-delay:-3.0s\" />\n      <circle cx=\"580\" cy=\"400\" r=\"1\" class=\"star\" style=\"animation-delay:-3.6s\" />\n    </g>\n\n    <!-- Trail: 10 staggered gold orbs with glow + mix-blend-mode: screen -->\n    <circle class=\"trail trail-1\" r=\"7\" fill=\"url(#trail-gradient)\" />\n    <circle class=\"trail trail-2\" r=\"6\" fill=\"url(#trail-gradient)\" />\n    <circle class=\"trail trail-3\" r=\"5\" fill=\"url(#trail-gradient)\" />\n    <circle class=\"trail trail-4\" r=\"4\" fill=\"url(#trail-gradient)\" />\n    <circle class=\"trail trail-5\" r=\"3.5\" fill=\"url(#trail-gradient)\" />\n    <circle class=\"trail trail-6\" r=\"3\" fill=\"url(#trail-gradient)\" />\n    <circle class=\"trail trail-7\" r=\"2.5\" fill=\"url(#trail-gradient)\" />\n    <circle class=\"trail trail-8\" r=\"2\" fill=\"url(#trail-gradient)\" />\n    <circle class=\"trail trail-9\" r=\"1.5\" fill=\"url(#trail-gradient)\" />\n    <circle class=\"trail trail-10\" r=\"1\" fill=\"url(#trail-gradient)\" />\n\n    <!-- Snitch body -->\n    <g class=\"snitch\">\n      <!-- Outer halo -->\n      <circle class=\"snitch-halo\" r=\"22\" />\n      <!-- Inner glow -->\n      <circle class=\"snitch-glow\" r=\"15\" />\n      <!-- Wings behind body -->\n      <g class=\"wing wing-left\">\n        <ellipse rx=\"14\" ry=\"5\" />\n        <ellipse class=\"wing-detail\" rx=\"10\" ry=\"2.5\" />\n      </g>\n      <g class=\"wing wing-right\">\n        <ellipse rx=\"14\" ry=\"5\" />\n        <ellipse class=\"wing-detail\" rx=\"10\" ry=\"2.5\" />\n      </g>\n      <!-- Body -->\n      <circle class=\"snitch-body\" r=\"10\" fill=\"url(#snitch-gradient)\" />\n      <!-- Highlight spark -->\n      <circle class=\"snitch-spark\" cx=\"-3\" cy=\"-3\" r=\"3\" />\n    </g>\n  </svg>\n</div>\n\n<style>\n  .snitch-stage {\n    display: block;\n    width: 100%;\n    aspect-ratio: 16 / 9;\n    background:\n      radial-gradient(circle at 30% 20%, rgba(94, 106, 210, 0.08) 0%, transparent 45%),\n      radial-gradient(circle at 70% 80%, rgba(212, 160, 42, 0.06) 0%, transparent 50%),\n      radial-gradient(circle at 50% 50%, #1b1c1d 0%, #08090a 70%);\n  }\n\n  svg {\n    width: 100%;\n    height: 100%;\n    display: block;\n  }\n\n  .path-guide {\n    fill: none;\n    stroke: rgba(212, 160, 42, 0.1);\n    stroke-width: 1;\n    stroke-dasharray: 2 8;\n    animation: path-fade 6s ease-in-out infinite;\n  }\n\n  @keyframes path-fade {\n    0%, 100% { stroke-opacity: 0.04; }\n    50%      { stroke-opacity: 0.14; }\n  }\n\n  /* Star-field */\n  .star {\n    fill: #ffd88a;\n    filter: url(#glow);\n    animation: twinkle 3.5s ease-in-out infinite;\n  }\n  @keyframes twinkle {\n    0%, 100% { opacity: 0.15; }\n    50%      { opacity: 0.75; }\n  }\n\n  /* Flight path motion */\n  .snitch,\n  .trail {\n    offset-path: path(\"M 60 320 C 180 90, 380 360, 520 180 S 760 330, 740 120\");\n    animation: fly 6s cubic-bezier(0.45, 0, 0.55, 1) infinite;\n  }\n\n  .snitch {\n    offset-rotate: auto;\n  }\n\n  /* Trail — 10 orbs with screen blend for bright gold shimmer */\n  .trail {\n    filter: url(#glow);\n    mix-blend-mode: screen;\n    pointer-events: none;\n  }\n  .trail-1  { animation-delay: -0.10s; }\n  .trail-2  { animation-delay: -0.20s; }\n  .trail-3  { animation-delay: -0.30s; }\n  .trail-4  { animation-delay: -0.40s; }\n  .trail-5  { animation-delay: -0.50s; }\n  .trail-6  { animation-delay: -0.60s; }\n  .trail-7  { animation-delay: -0.72s; }\n  .trail-8  { animation-delay: -0.86s; }\n  .trail-9  { animation-delay: -1.00s; }\n  .trail-10 { animation-delay: -1.15s; }\n\n  @keyframes fly {\n    0%   { offset-distance: 0%;   }\n    48%  { offset-distance: 100%; }\n    52%  { offset-distance: 100%; }\n    100% { offset-distance: 0%;   }\n  }\n\n  /* Snitch body parts */\n  .snitch-halo {\n    fill: #d4a02a;\n    filter: url(#soft-glow);\n    opacity: 0.35;\n  }\n  .snitch-glow {\n    fill: #ffd88a;\n    filter: url(#glow);\n    opacity: 0.55;\n  }\n  .snitch-body {\n    stroke: rgba(255, 244, 199, 0.5);\n    stroke-width: 0.5;\n  }\n  .snitch-spark {\n    fill: #ffffff;\n    opacity: 0.75;\n    filter: url(#glow);\n  }\n\n  /* Wings */\n  .wing ellipse {\n    fill: rgba(250, 250, 252, 0.78);\n    stroke: rgba(255, 255, 255, 0.5);\n    stroke-width: 0.3;\n  }\n  .wing .wing-detail {\n    fill: rgba(255, 255, 255, 0.28);\n    stroke: none;\n  }\n\n  .wing-left  { transform: translate(-14px, 0); animation: flap-left  0.14s ease-in-out infinite alternate; }\n  .wing-right { transform: translate( 14px, 0); animation: flap-right 0.14s ease-in-out infinite alternate; }\n\n  @keyframes flap-left {\n    from { transform: translate(-14px, 0) rotate(-24deg) scaleY(0.85); }\n    to   { transform: translate(-14px, 0) rotate(-4deg)  scaleY(1.15); }\n  }\n  @keyframes flap-right {\n    from { transform: translate(14px, 0) rotate(24deg) scaleY(0.85); }\n    to   { transform: translate(14px, 0) rotate(4deg)  scaleY(1.15); }\n  }\n\n  /* Reduced motion — freeze mid-flight, hide trail + wings */\n  @media (prefers-reduced-motion: reduce) {\n    .snitch, .trail, .wing-left, .wing-right, .star, .path-guide {\n      animation: none;\n    }\n    .snitch { offset-distance: 50%; }\n    .trail { display: none; }\n  }\n</style>\n",
        "structuredPatch": [
          {
            "oldStart": 1,
            "oldLines": 6,
            "newStart": 1,
            "newLines": 7,
            "lines": [
              " ---",
              "-// Snitch trail — a golden snitch following an SVG path with a fading",
              "-// gold-sparkle trail. First real animation on the Quidditch playground.",
              "+// Snitch trail — a golden snitch tracing a figure-eight flight path with a",
              "+// bright shimmering trail on a darkened Quidditch sky. First real animation",
              "+// on the Quidditch playground, polished for first-impression impact.",
              " ---",
              " ",
              " <div class=\"snitch-stage\">"
            ]
          },
          {
            "oldStart": 8,
            "oldLines": 37,
            "newStart": 9,
            "newLines": 71,
            "lines": [
              "     <title id=\"snitch-title\">A golden snitch tracing a figure-eight flight path with a shimmering trail</title>",
              " ",
              "     <defs>",
              "-      <path id=\"flight-path\" d=\"M 80 300 Q 200 100, 400 225 T 720 150\" />",
              "-      <radialGradient id=\"snitch-gradient\" cx=\"35%\" cy=\"30%\" r=\"60%\">",
              "-        <stop offset=\"0%\" stop-color=\"#ffd88a\" />",
              "-        <stop offset=\"55%\" stop-color=\"#d4a02a\" />",
              "-        <stop offset=\"100%\" stop-color=\"#8a6416\" />",
              "+      <path id=\"flight-path\" d=\"M 60 320 C 180 90, 380 360, 520 180 S 760 330, 740 120\" />",
              "+      <radialGradient id=\"snitch-gradient\" cx=\"35%\" cy=\"30%\" r=\"65%\">",
              "+        <stop offset=\"0%\" stop-color=\"#fff4c7\" />",
              "+        <stop offset=\"30%\" stop-color=\"#ffd88a\" />",
              "+        <stop offset=\"70%\" stop-color=\"#d4a02a\" />",
              "+        <stop offset=\"100%\" stop-color=\"#7a5412\" />",
              "       </radialGradient>",
              "-      <filter id=\"glow\" x=\"-50%\" y=\"-50%\" width=\"200%\" height=\"200%\">",
              "-        <feGaussianBlur stdDeviation=\"3\" />",
              "+      <radialGradient id=\"trail-gradient\" cx=\"50%\" cy=\"50%\" r=\"50%\">",
              "+        <stop offset=\"0%\" stop-color=\"#fff2a8\" stop-opacity=\"1\" />",
              "+        <stop offset=\"70%\" stop-color=\"#ffd88a\" stop-opacity=\"0.6\" />",
              "+        <stop offset=\"100%\" stop-color=\"#d4a02a\" stop-opacity=\"0\" />",
              "+      </radialGradient>",
              "+      <filter id=\"glow\" x=\"-80%\" y=\"-80%\" width=\"260%\" height=\"260%\">",
              "+        <feGaussianBlur stdDeviation=\"3.5\" />",
              "       </filter>",
              "+      <filter id=\"soft-glow\" x=\"-100%\" y=\"-100%\" width=\"300%\" height=\"300%\">",
              "+        <feGaussianBlur stdDeviation=\"8\" />",
              "+      </filter>",
              "     </defs>",
              " ",
              "-    <!-- Flight path (subtle guide, disabled by default) -->",
              "+    <!-- Subtle flight path guide -->",
              "     <use href=\"#flight-path\" class=\"path-guide\" />",
              " ",
              "-    <!-- Trail sparkles: same offset-path, staggered delay, fading opacity -->",
              "-    <circle class=\"trail trail-1\" r=\"4\" />",
              "-    <circle class=\"trail trail-2\" r=\"3\" />",
              "-    <circle class=\"trail trail-3\" r=\"2\" />",
              "-    <circle class=\"trail trail-4\" r=\"1.5\" />",
              "-    <circle class=\"trail trail-5\" r=\"1\" />",
              "+    <!-- Star-field: scattered fixed sparkles for atmosphere -->",
              "+    <g class=\"starfield\" aria-hidden=\"true\">",
              "+      <circle cx=\"120\" cy=\"80\" r=\"1\" class=\"star\" style=\"animation-delay:0s\" />",
              "+      <circle cx=\"670\" cy=\"60\" r=\"1.2\" class=\"star\" style=\"animation-delay:-0.6s\" />",
              "+      <circle cx=\"380\" cy=\"45\" r=\"0.8\" class=\"star\" style=\"animation-delay:-1.2s\" />",
              "+      <circle cx=\"750\" cy=\"270\" r=\"0.9\" class=\"star\" style=\"animation-delay:-1.8s\" />",
              "+      <circle cx=\"80\" cy=\"180\" r=\"1.1\" class=\"star\" style=\"animation-delay:-2.4s\" />",
              "+      <circle cx=\"220\" cy=\"390\" r=\"0.7\" class=\"star\" style=\"animation-delay:-3.0s\" />",
              "+      <circle cx=\"580\" cy=\"400\" r=\"1\" class=\"star\" style=\"animation-delay:-3.6s\" />",
              "+    </g>",
              " ",
              "+    <!-- Trail: 10 staggered gold orbs with glow + mix-blend-mode: screen -->",
              "+    <circle class=\"trail trail-1\" r=\"7\" fill=\"url(#trail-gradient)\" />",
              "+    <circle class=\"trail trail-2\" r=\"6\" fill=\"url(#trail-gradient)\" />",
              "+    <circle class=\"trail trail-3\" r=\"5\" fill=\"url(#trail-gradient)\" />",
              "+    <circle class=\"trail trail-4\" r=\"4\" fill=\"url(#trail-gradient)\" />",
              "+    <circle class=\"trail trail-5\" r=\"3.5\" fill=\"url(#trail-gradient)\" />",
              "+    <circle class=\"trail trail-6\" r=\"3\" fill=\"url(#trail-gradient)\" />",
              "+    <circle class=\"trail trail-7\" r=\"2.5\" fill=\"url(#trail-gradient)\" />",
              "+    <circle class=\"trail trail-8\" r=\"2\" fill=\"url(#trail-gradient)\" />",
              "+    <circle class=\"trail trail-9\" r=\"1.5\" fill=\"url(#trail-gradient)\" />",
              "+    <circle class=\"trail trail-10\" r=\"1\" fill=\"url(#trail-gradient)\" />",
              "+",
              "     <!-- Snitch body -->",
              "     <g class=\"snitch\">",
              "-      <circle class=\"snitch-glow\" r=\"14\" />",
              "-      <circle class=\"snitch-body\" r=\"9\" fill=\"url(#snitch-gradient)\" />",
              "+      <!-- Outer halo -->",
              "+      <circle class=\"snitch-halo\" r=\"22\" />",
              "+      <!-- Inner glow -->",
              "+      <circle class=\"snitch-glow\" r=\"15\" />",
              "+      <!-- Wings behind body -->",
              "       <g class=\"wing wing-left\">",
              "-        <ellipse rx=\"10\" ry=\"4\" />",
              "+        <ellipse rx=\"14\" ry=\"5\" />",
              "+        <ellipse class=\"wing-detail\" rx=\"10\" ry=\"2.5\" />",
              "       </g>",
              "       <g class=\"wing wing-right\">",
              "-        <ellipse rx=\"10\" ry=\"4\" />",
              "+        <ellipse rx=\"14\" ry=\"5\" />",
              "+        <ellipse class=\"wing-detail\" rx=\"10\" ry=\"2.5\" />",
              "       </g>",
              "+      <!-- Body -->",
              "+      <circle class=\"snitch-body\" r=\"10\" fill=\"url(#snitch-gradient)\" />",
              "+      <!-- Highlight spark -->",
              "+      <circle class=\"snitch-spark\" cx=\"-3\" cy=\"-3\" r=\"3\" />",
              "     </g>",
              "   </svg>",
              " </div>"
            ]
          },
          {
            "oldStart": 48,
            "oldLines": 7,
            "newStart": 83,
            "newLines": 10,
            "lines": [
              "     display: block;",
              "     width: 100%;",
              "     aspect-ratio: 16 / 9;",
              "-    background: radial-gradient(circle at 50% 50%, #1b1c1d 0%, #08090a 70%);",
              "+    background:",
              "+      radial-gradient(circle at 30% 20%, rgba(94, 106, 210, 0.08) 0%, transparent 45%),",
              "+      radial-gradient(circle at 70% 80%, rgba(212, 160, 42, 0.06) 0%, transparent 50%),",
              "+      radial-gradient(circle at 50% 50%, #1b1c1d 0%, #08090a 70%);",
              "   }",
              " ",
              "   svg {"
            ]
          },
          {
            "oldStart": 59,
            "oldLines": 81,
            "newStart": 97,
            "newLines": 113,
            "lines": [
              " ",
              "   .path-guide {",
              "     fill: none;",
              "-    stroke: rgba(212, 160, 42, 0.08);",
              "+    stroke: rgba(212, 160, 42, 0.1);",
              "     stroke-width: 1;",
              "-    stroke-dasharray: 2 6;",
              "+    stroke-dasharray: 2 8;",
              "+    animation: path-fade 6s ease-in-out infinite;",
              "   }",
              " ",
              "+  @keyframes path-fade {",
              "+    0%, 100% { stroke-opacity: 0.04; }",
              "+    50%      { stroke-opacity: 0.14; }",
              "+  }",
              "+",
              "+  /* Star-field */",
              "+  .star {",
              "+    fill: #ffd88a;",
              "+    filter: url(#glow);",
              "+    animation: twinkle 3.5s ease-in-out infinite;",
              "+  }",
              "+  @keyframes twinkle {",
              "+    0%, 100% { opacity: 0.15; }",
              "+    50%      { opacity: 0.75; }",
              "+  }",
              "+",
              "+  /* Flight path motion */",
              "   .snitch,",
              "   .trail {",
              "-    /* v2: follow the flight path. offset-path mirrors the SVG <path>. */",
              "-    offset-path: path(\"M 80 300 Q 200 100, 400 225 T 720 150\");",
              "-    animation: fly 7s cubic-bezier(0.42, 0, 0.58, 1) infinite;",
              "+    offset-path: path(\"M 60 320 C 180 90, 380 360, 520 180 S 760 330, 740 120\");",
              "+    animation: fly 6s cubic-bezier(0.45, 0, 0.55, 1) infinite;",
              "   }",
              " ",
              "   .snitch {",
              "     offset-rotate: auto;",
              "   }",
              " ",
              "-  /* Trails: each staggered slightly behind the snitch with decreasing opacity. */",
              "+  /* Trail — 10 orbs with screen blend for bright gold shimmer */",
              "   .trail {",
              "-    fill: #ffd88a;",
              "     filter: url(#glow);",
              "     mix-blend-mode: screen;",
              "+    pointer-events: none;",
              "   }",
              "-  .trail-1 { animation-delay: -0.12s; opacity: 0.7; }",
              "-  .trail-2 { animation-delay: -0.24s; opacity: 0.55; }",
              "-  .trail-3 { animation-delay: -0.36s; opacity: 0.4; }",
              "-  .trail-4 { animation-delay: -0.48s; opacity: 0.25; }",
              "-  .trail-5 { animation-delay: -0.6s;  opacity: 0.15; }",
              "+  .trail-1  { animation-delay: -0.10s; }",
              "+  .trail-2  { animation-delay: -0.20s; }",
              "+  .trail-3  { animation-delay: -0.30s; }",
              "+  .trail-4  { animation-delay: -0.40s; }",
              "+  .trail-5  { animation-delay: -0.50s; }",
              "+  .trail-6  { animation-delay: -0.60s; }",
              "+  .trail-7  { animation-delay: -0.72s; }",
              "+  .trail-8  { animation-delay: -0.86s; }",
              "+  .trail-9  { animation-delay: -1.00s; }",
              "+  .trail-10 { animation-delay: -1.15s; }",
              " ",
              "   @keyframes fly {",
              "-    0%   { offset-distance: 0%; }",
              "-    45%  { offset-distance: 100%; }",
              "-    55%  { offset-distance: 100%; }",
              "-    100% { offset-distance: 0%; }",
              "+    0%   { offset-distance: 0%;   }",
              "+    48%  { offset-distance: 100%; }",
              "+    52%  { offset-distance: 100%; }",
              "+    100% { offset-distance: 0%;   }",
              "   }",
              " ",
              "-  .snitch-glow {",
              "+  /* Snitch body parts */",
              "+  .snitch-halo {",
              "     fill: #d4a02a;",
              "+    filter: url(#soft-glow);",
              "+    opacity: 0.35;",
              "+  }",
              "+  .snitch-glow {",
              "+    fill: #ffd88a;",
              "     filter: url(#glow);",
              "-    opacity: 0.5;",
              "+    opacity: 0.55;",
              "   }",
              "-",
              "   .snitch-body {",
              "-    stroke: rgba(255, 216, 138, 0.4);",
              "+    stroke: rgba(255, 244, 199, 0.5);",
              "     stroke-width: 0.5;",
              "   }",
              "+  .snitch-spark {",
              "+    fill: #ffffff;",
              "+    opacity: 0.75;",
              "+    filter: url(#glow);",
              "+  }",
              " ",
              "+  /* Wings */",
              "   .wing ellipse {",
              "-    fill: rgba(247, 248, 248, 0.82);",
              "-    stroke: rgba(255, 255, 255, 0.4);",
              "+    fill: rgba(250, 250, 252, 0.78);",
              "+    stroke: rgba(255, 255, 255, 0.5);",
              "     stroke-width: 0.3;",
              "   }",
              "+  .wing .wing-detail {",
              "+    fill: rgba(255, 255, 255, 0.28);",
              "+    stroke: none;",
              "+  }",
              " ",
              "-  .wing-left  { transform: translate(-10px, 0); animation: flap-left  0.16s ease-in-out infinite alternate; }",
              "-  .wing-right { transform: translate( 10px, 0); animation: flap-right 0.16s ease-in-out infinite alternate; }",
              "+  .wing-left  { transform: translate(-14px, 0); animation: flap-left  0.14s ease-in-out infinite alternate; }",
              "+  .wing-right { transform: translate( 14px, 0); animation: flap-right 0.14s ease-in-out infinite alternate; }",
              " ",
              "   @keyframes flap-left {",
              "-    from { transform: translate(-10px, 0) rotate(-22deg) scaleY(0.9); }",
              "-    to   { transform: translate(-10px, 0) rotate(-6deg)  scaleY(1.1); }",
              "+    from { transform: translate(-14px, 0) rotate(-24deg) scaleY(0.85); }",
              "+    to   { transform: translate(-14px, 0) rotate(-4deg)  scaleY(1.15); }",
              "   }",
              "   @keyframes flap-right {",
              "-    from { transform: translate(10px, 0) rotate(22deg) scaleY(0.9); }",
              "-    to   { transform: translate(10px, 0) rotate(6deg)  scaleY(1.1); }",
              "+    from { transform: translate(14px, 0) rotate(24deg) scaleY(0.85); }",
              "+    to   { transform: translate(14px, 0) rotate(4deg)  scaleY(1.15); }",
              "   }",
              " ",
              "-  /* Accessibility: freeze motion for users who ask for reduced motion.",
              "-     Snitch sits mid-path so the stage still reads as \"snitch in flight.\" */",
              "+  /* Reduced motion — freeze mid-flight, hide trail + wings */",
              "   @media (prefers-reduced-motion: reduce) {",
              "-    .snitch, .trail, .wing-left, .wing-right {",
              "+    .snitch, .trail, .wing-left, .wing-right, .star, .path-guide {",
              "       animation: none;",
              "     }",
              "-    .snitch {",
              "-      offset-distance: 50%;",
              "-    }",
              "-    .trail {",
              "-      display: none;",
              "-    }",
              "+    .snitch { offset-distance: 50%; }",
              "+    .trail { display: none; }",
              "   }",
              " </style>"
            ]
          }
        ],
        "originalFile": "---\n// Snitch trail — a golden snitch following an SVG path with a fading\n// gold-sparkle trail. First real animation on the Quidditch playground.\n---\n\n<div class=\"snitch-stage\">\n  <svg viewBox=\"0 0 800 450\" preserveAspectRatio=\"xMidYMid meet\" aria-labelledby=\"snitch-title\">\n    <title id=\"snitch-title\">A golden snitch tracing a figure-eight flight path with a shimmering trail</title>\n\n    <defs>\n      <path id=\"flight-path\" d=\"M 80 300 Q 200 100, 400 225 T 720 150\" />\n      <radialGradient id=\"snitch-gradient\" cx=\"35%\" cy=\"30%\" r=\"60%\">\n        <stop offset=\"0%\" stop-color=\"#ffd88a\" />\n        <stop offset=\"55%\" stop-color=\"#d4a02a\" />\n        <stop offset=\"100%\" stop-color=\"#8a6416\" />\n      </radialGradient>\n      <filter id=\"glow\" x=\"-50%\" y=\"-50%\" width=\"200%\" height=\"200%\">\n        <feGaussianBlur stdDeviation=\"3\" />\n      </filter>\n    </defs>\n\n    <!-- Flight path (subtle guide, disabled by default) -->\n    <use href=\"#flight-path\" class=\"path-guide\" />\n\n    <!-- Trail sparkles: same offset-path, staggered delay, fading opacity -->\n    <circle class=\"trail trail-1\" r=\"4\" />\n    <circle class=\"trail trail-2\" r=\"3\" />\n    <circle class=\"trail trail-3\" r=\"2\" />\n    <circle class=\"trail trail-4\" r=\"1.5\" />\n    <circle class=\"trail trail-5\" r=\"1\" />\n\n    <!-- Snitch body -->\n    <g class=\"snitch\">\n      <circle class=\"snitch-glow\" r=\"14\" />\n      <circle class=\"snitch-body\" r=\"9\" fill=\"url(#snitch-gradient)\" />\n      <g class=\"wing wing-left\">\n        <ellipse rx=\"10\" ry=\"4\" />\n      </g>\n      <g class=\"wing wing-right\">\n        <ellipse rx=\"10\" ry=\"4\" />\n      </g>\n    </g>\n  </svg>\n</div>\n\n<style>\n  .snitch-stage {\n    display: block;\n    width: 100%;\n    aspect-ratio: 16 / 9;\n    background: radial-gradient(circle at 50% 50%, #1b1c1d 0%, #08090a 70%);\n  }\n\n  svg {\n    width: 100%;\n    height: 100%;\n    display: block;\n  }\n\n  .path-guide {\n    fill: none;\n    stroke: rgba(212, 160, 42, 0.08);\n    stroke-width: 1;\n    stroke-dasharray: 2 6;\n  }\n\n  .snitch,\n  .trail {\n    /* v2: follow the flight path. offset-path mirrors the SVG <path>. */\n    offset-path: path(\"M 80 300 Q 200 100, 400 225 T 720 150\");\n    animation: fly 7s cubic-bezier(0.42, 0, 0.58, 1) infinite;\n  }\n\n  .snitch {\n    offset-rotate: auto;\n  }\n\n  /* Trails: each staggered slightly behind the snitch with decreasing opacity. */\n  .trail {\n    fill: #ffd88a;\n    filter: url(#glow);\n    mix-blend-mode: screen;\n  }\n  .trail-1 { animation-delay: -0.12s; opacity: 0.7; }\n  .trail-2 { animation-delay: -0.24s; opacity: 0.55; }\n  .trail-3 { animation-delay: -0.36s; opacity: 0.4; }\n  .trail-4 { animation-delay: -0.48s; opacity: 0.25; }\n  .trail-5 { animation-delay: -0.6s;  opacity: 0.15; }\n\n  @keyframes fly {\n    0%   { offset-distance: 0%; }\n    45%  { offset-distance: 100%; }\n    55%  { offset-distance: 100%; }\n    100% { offset-distance: 0%; }\n  }\n\n  .snitch-glow {\n    fill: #d4a02a;\n    filter: url(#glow);\n    opacity: 0.5;\n  }\n\n  .snitch-body {\n    stroke: rgba(255, 216, 138, 0.4);\n    stroke-width: 0.5;\n  }\n\n  .wing ellipse {\n    fill: rgba(247, 248, 248, 0.82);\n    stroke: rgba(255, 255, 255, 0.4);\n    stroke-width: 0.3;\n  }\n\n  .wing-left  { transform: translate(-10px, 0); animation: flap-left  0.16s ease-in-out infinite alternate; }\n  .wing-right { transform: translate( 10px, 0); animation: flap-right 0.16s ease-in-out infinite alternate; }\n\n  @keyframes flap-left {\n    from { transform: translate(-10px, 0) rotate(-22deg) scaleY(0.9); }\n    to   { transform: translate(-10px, 0) rotate(-6deg)  scaleY(1.1); }\n  }\n  @keyframes flap-right {\n    from { transform: translate(10px, 0) rotate(22deg) scaleY(0.9); }\n    to   { transform: translate(10px, 0) rotate(6deg)  scaleY(1.1); }\n  }\n\n  /* Accessibility: freeze motion for users who ask for reduced motion.\n     Snitch sits mid-path so the stage still reads as \"snitch in flight.\" */\n  @media (prefers-reduced-motion: reduce) {\n    .snitch, .trail, .wing-left, .wing-right {\n      animation: none;\n    }\n    .snitch {\n      offset-distance: 50%;\n    }\n    .trail {\n      display: none;\n    }\n  }\n</style>\n",
        "userModified": false
      },
      "postStateChecksum": {}
    }
  4. #3 Bash · pkill -f "astro dev" 2>&1 ; sleep 2 (bun run dev > /tmp/astro-dev.log 2>&1 &) sleep 15 curl -sS -o /dev/null -w "preview: %{http_code}\n" http://localhost:4321/animation-preview/snitch-trail .claude/hooks/snap-animation.sh snitch-trail 2>&1 | tail -2
    {
      "cursor": 3,
      "ts": "2026-04-16T11:28:48Z",
      "kind": "tool",
      "tool": "Bash",
      "input": {
        "command": "pkill -f \"astro dev\" 2>&1 ; sleep 2\n(bun run dev > /tmp/astro-dev.log 2>&1 &)\nsleep 15\ncurl -sS -o /dev/null -w \"preview: %{http_code}\\n\" http://localhost:4321/animation-preview/snitch-trail\n.claude/hooks/snap-animation.sh snitch-trail 2>&1 | tail -2",
        "timeout": 60000,
        "description": "Snap polished Snitch Trail"
      },
      "output": {
        "stdout": "preview: 200\nsnap saved: <repo>/.session/animation-renders/sess-1776338852-93130/cursor-0000003.png\ncursor=3 snap=<repo>/.session/animation-renders/sess-1776338852-93130/cursor-0000003.png",
        "stderr": "",
        "interrupted": false,
        "isImage": false,
        "noOutputExpected": false
      },
      "postStateChecksum": {}
    }
  5. #4 Bash · mkdir -p src/animations/marauders-map
    {
      "cursor": 4,
      "ts": "2026-04-16T11:28:53Z",
      "kind": "tool",
      "tool": "Bash",
      "input": {
        "command": "mkdir -p src/animations/marauders-map",
        "description": "Create dir"
      },
      "output": {
        "stdout": "",
        "stderr": "",
        "interrupted": false,
        "isImage": false,
        "noOutputExpected": true
      },
      "postStateChecksum": {}
    }
  6. #5 Write · <repo>/src/animations/marauders-map/index.astro
    {
      "cursor": 5,
      "ts": "2026-04-16T11:29:40Z",
      "kind": "tool",
      "tool": "Write",
      "input": {
        "file_path": "<repo>/src/animations/marauders-map/index.astro",
        "content": "---\n// Marauder's Map — a parchment unfolds to reveal a footprint trail\n// crossing it, with ink strokes fading in to draw the map as it unfolds.\n// Pure SVG + CSS, no JS.\n---\n\n<div class=\"map-stage\">\n  <svg viewBox=\"0 0 900 520\" preserveAspectRatio=\"xMidYMid meet\" aria-labelledby=\"map-title\">\n    <title id=\"map-title\">The Marauder's Map: parchment unfolds with footprints tracing a path across the halls of Hogwarts</title>\n\n    <defs>\n      <linearGradient id=\"parchment\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n        <stop offset=\"0%\" stop-color=\"#e9d7a6\" />\n        <stop offset=\"40%\" stop-color=\"#dcc58b\" />\n        <stop offset=\"100%\" stop-color=\"#b89658\" />\n      </linearGradient>\n      <radialGradient id=\"parchment-vignette\" cx=\"50%\" cy=\"50%\" r=\"55%\">\n        <stop offset=\"55%\" stop-color=\"rgba(0,0,0,0)\" />\n        <stop offset=\"100%\" stop-color=\"rgba(60,30,0,0.35)\" />\n      </radialGradient>\n      <filter id=\"rough\" x=\"0\" y=\"0\">\n        <feTurbulence baseFrequency=\"0.9\" numOctaves=\"2\" seed=\"3\" />\n        <feDisplacementMap in=\"SourceGraphic\" scale=\"1.2\" />\n      </filter>\n      <filter id=\"ink-soft\">\n        <feGaussianBlur stdDeviation=\"0.4\" />\n      </filter>\n      <!-- Footprint shape: stylized sole + toes, works as <use> -->\n      <symbol id=\"footprint\" viewBox=\"-8 -14 16 28\">\n        <ellipse cx=\"0\" cy=\"0\" rx=\"5\" ry=\"8\" />\n        <circle cx=\"-3\" cy=\"-10\" r=\"1.5\" />\n        <circle cx=\"-1\" cy=\"-11.5\" r=\"1.5\" />\n        <circle cx=\"1.5\" cy=\"-12\" r=\"1.5\" />\n        <circle cx=\"4\" cy=\"-11\" r=\"1.2\" />\n      </symbol>\n    </defs>\n\n    <!-- Unfolding parchment — 3 panels that rotate in from closed state -->\n    <g class=\"parchment-group\">\n      <!-- Left panel -->\n      <g class=\"panel panel-left\" style=\"transform-origin: 300px 260px\">\n        <rect x=\"0\" y=\"40\" width=\"300\" height=\"440\" fill=\"url(#parchment)\" rx=\"2\" />\n        <rect x=\"0\" y=\"40\" width=\"300\" height=\"440\" fill=\"url(#parchment-vignette)\" rx=\"2\" />\n        <!-- Corridor ink lines -->\n        <path class=\"ink ink-1\" d=\"M 40 100 L 260 100 L 260 200 L 100 200 L 100 340 L 240 340\" />\n        <path class=\"ink ink-2\" d=\"M 40 400 L 220 400 L 220 440\" />\n        <text class=\"map-label\" x=\"150\" y=\"75\" text-anchor=\"middle\">Gryffindor Tower</text>\n        <text class=\"map-label small\" x=\"260\" y=\"220\" text-anchor=\"end\">Staircase</text>\n      </g>\n\n      <!-- Center panel -->\n      <g class=\"panel panel-center\">\n        <rect x=\"300\" y=\"20\" width=\"300\" height=\"480\" fill=\"url(#parchment)\" rx=\"2\" />\n        <rect x=\"300\" y=\"20\" width=\"300\" height=\"480\" fill=\"url(#parchment-vignette)\" rx=\"2\" />\n        <!-- Title banner -->\n        <text class=\"map-title-text\" x=\"450\" y=\"80\" text-anchor=\"middle\">Marauder's Map</text>\n        <text class=\"map-oath\" x=\"450\" y=\"108\" text-anchor=\"middle\">I solemnly swear I am up to no good</text>\n        <!-- Great Hall, Library, Dungeons outlines -->\n        <path class=\"ink ink-3\" d=\"M 340 160 L 560 160 L 560 240 L 420 240 L 420 320 L 560 320 L 560 420 L 340 420 Z\" />\n        <path class=\"ink ink-4\" d=\"M 340 280 L 400 280\" />\n        <path class=\"ink ink-5\" d=\"M 480 240 L 480 320\" />\n        <text class=\"map-label\" x=\"500\" y=\"185\" text-anchor=\"middle\">Great Hall</text>\n        <text class=\"map-label\" x=\"370\" y=\"300\" text-anchor=\"middle\">Library</text>\n        <text class=\"map-label\" x=\"520\" y=\"395\" text-anchor=\"middle\">Dungeons</text>\n      </g>\n\n      <!-- Right panel -->\n      <g class=\"panel panel-right\" style=\"transform-origin: 600px 260px\">\n        <rect x=\"600\" y=\"40\" width=\"300\" height=\"440\" fill=\"url(#parchment)\" rx=\"2\" />\n        <rect x=\"600\" y=\"40\" width=\"300\" height=\"440\" fill=\"url(#parchment-vignette)\" rx=\"2\" />\n        <path class=\"ink ink-6\" d=\"M 640 100 L 840 100 L 840 180 L 700 180 L 700 280 L 860 280\" />\n        <path class=\"ink ink-7\" d=\"M 640 380 L 800 380 L 800 440\" />\n        <text class=\"map-label\" x=\"740\" y=\"75\" text-anchor=\"middle\">Astronomy</text>\n        <text class=\"map-label small\" x=\"730\" y=\"310\" text-anchor=\"middle\">Owlery</text>\n      </g>\n    </g>\n\n    <!-- Footprint trail: 8 prints appearing in sequence along a path through Hogwarts -->\n    <g class=\"footsteps\">\n      <use href=\"#footprint\" class=\"step step-1\"  transform=\"translate(140, 130) rotate(90)\"  width=\"16\" height=\"28\" />\n      <use href=\"#footprint\" class=\"step step-2\"  transform=\"translate(200, 200) rotate(90)\"  width=\"16\" height=\"28\" />\n      <use href=\"#footprint\" class=\"step step-3\"  transform=\"translate(260, 200) rotate(110)\" width=\"16\" height=\"28\" />\n      <use href=\"#footprint\" class=\"step step-4\"  transform=\"translate(330, 190) rotate(85)\"  width=\"16\" height=\"28\" />\n      <use href=\"#footprint\" class=\"step step-5\"  transform=\"translate(390, 220) rotate(95)\"  width=\"16\" height=\"28\" />\n      <use href=\"#footprint\" class=\"step step-6\"  transform=\"translate(450, 260) rotate(120)\" width=\"16\" height=\"28\" />\n      <use href=\"#footprint\" class=\"step step-7\"  transform=\"translate(510, 330) rotate(135)\" width=\"16\" height=\"28\" />\n      <use href=\"#footprint\" class=\"step step-8\"  transform=\"translate(580, 400) rotate(145)\" width=\"16\" height=\"28\" />\n      <use href=\"#footprint\" class=\"step step-9\"  transform=\"translate(660, 400) rotate(90)\"  width=\"16\" height=\"28\" />\n      <use href=\"#footprint\" class=\"step step-10\" transform=\"translate(740, 340) rotate(70)\"  width=\"16\" height=\"28\" />\n    </g>\n\n    <!-- Name label that appears with the footprints (\"Harry Potter\") -->\n    <g class=\"name-tag\">\n      <rect x=\"700\" y=\"305\" width=\"96\" height=\"20\" rx=\"3\" />\n      <text x=\"748\" y=\"319\" text-anchor=\"middle\">Harry Potter</text>\n    </g>\n  </svg>\n</div>\n\n<style>\n  .map-stage {\n    display: block;\n    width: 100%;\n    aspect-ratio: 16 / 9;\n    background:\n      radial-gradient(circle at 30% 30%, rgba(180, 130, 60, 0.08) 0%, transparent 55%),\n      radial-gradient(circle at 70% 70%, rgba(94, 106, 210, 0.04) 0%, transparent 50%),\n      #0b0d0e;\n  }\n  svg { width: 100%; height: 100%; display: block; }\n\n  /* Unfolding parchment — side panels rotate in from folded-closed */\n  .parchment-group {\n    animation: parchment-glow 6s ease-in-out infinite;\n  }\n  @keyframes parchment-glow {\n    0%, 100% { filter: drop-shadow(0 4px 14px rgba(0,0,0,0.45)); }\n    50%      { filter: drop-shadow(0 6px 22px rgba(212, 160, 42, 0.18)); }\n  }\n\n  .panel-center {\n    animation: center-fade 8s ease-in-out infinite;\n    transform-origin: 450px 260px;\n  }\n  .panel-left  { animation: unfold-left  8s ease-in-out infinite; }\n  .panel-right { animation: unfold-right 8s ease-in-out infinite; }\n\n  @keyframes center-fade {\n    0%, 8%    { opacity: 0; transform: scale(0.92); }\n    18%, 92%  { opacity: 1; transform: scale(1); }\n    100%      { opacity: 0; transform: scale(0.92); }\n  }\n  @keyframes unfold-left {\n    0%, 12%   { transform: rotateY(95deg); opacity: 0; }\n    28%, 92%  { transform: rotateY(0deg);  opacity: 1; }\n    100%      { transform: rotateY(95deg); opacity: 0; }\n  }\n  @keyframes unfold-right {\n    0%, 12%   { transform: rotateY(-95deg); opacity: 0; }\n    28%, 92%  { transform: rotateY(0deg);   opacity: 1; }\n    100%      { transform: rotateY(-95deg); opacity: 0; }\n  }\n\n  /* Ink strokes draw themselves in sequence */\n  .ink {\n    fill: none;\n    stroke: #3a2912;\n    stroke-width: 1.8;\n    stroke-linecap: round;\n    stroke-linejoin: round;\n    stroke-dasharray: 600;\n    stroke-dashoffset: 600;\n    filter: url(#ink-soft);\n    animation: draw-ink 8s ease-out infinite;\n  }\n  .ink-1 { animation-delay: -7.0s; }\n  .ink-2 { animation-delay: -6.6s; }\n  .ink-3 { animation-delay: -6.3s; }\n  .ink-4 { animation-delay: -6.0s; }\n  .ink-5 { animation-delay: -5.8s; }\n  .ink-6 { animation-delay: -5.5s; }\n  .ink-7 { animation-delay: -5.2s; }\n\n  @keyframes draw-ink {\n    0%, 32%  { stroke-dashoffset: 600; }\n    48%, 92% { stroke-dashoffset: 0; }\n    100%     { stroke-dashoffset: 600; }\n  }\n\n  /* Text labels */\n  .map-label {\n    font-family: \"Luminari\", \"IM Fell English\", serif;\n    font-size: 11px;\n    fill: #2a1a08;\n    opacity: 0;\n    animation: label-fade 8s ease-out infinite;\n  }\n  .map-label.small { font-size: 9px; }\n  .map-title-text {\n    font-family: \"Luminari\", \"IM Fell English\", serif;\n    font-size: 22px;\n    font-weight: 700;\n    fill: #2a1a08;\n    letter-spacing: 0.08em;\n    opacity: 0;\n    animation: label-fade 8s ease-out infinite;\n    animation-delay: -6.2s;\n  }\n  .map-oath {\n    font-family: \"IM Fell English\", serif;\n    font-size: 9px;\n    font-style: italic;\n    fill: rgba(42, 26, 8, 0.7);\n    opacity: 0;\n    animation: label-fade 8s ease-out infinite;\n    animation-delay: -6s;\n  }\n  @keyframes label-fade {\n    0%, 42%  { opacity: 0; }\n    58%, 90% { opacity: 1; }\n    100%     { opacity: 0; }\n  }\n\n  /* Footsteps appear one at a time after the map is drawn */\n  .step {\n    fill: #3a2912;\n    opacity: 0;\n    animation: step-fade 8s ease-out infinite;\n  }\n  .step-1  { animation-delay: -3.6s; }\n  .step-2  { animation-delay: -3.3s; }\n  .step-3  { animation-delay: -3.0s; }\n  .step-4  { animation-delay: -2.7s; }\n  .step-5  { animation-delay: -2.4s; }\n  .step-6  { animation-delay: -2.1s; }\n  .step-7  { animation-delay: -1.8s; }\n  .step-8  { animation-delay: -1.5s; }\n  .step-9  { animation-delay: -1.2s; }\n  .step-10 { animation-delay: -0.9s; }\n\n  @keyframes step-fade {\n    0%   { opacity: 0; transform: scale(0.6); }\n    6%   { opacity: 1; transform: scale(1.25); }\n    14%  { opacity: 1; transform: scale(1);    }\n    60%  { opacity: 1; }\n    80%  { opacity: 0.25; }\n    100% { opacity: 0; }\n  }\n\n  /* \"Harry Potter\" name tag rides with the front-most footprint */\n  .name-tag {\n    opacity: 0;\n    animation: tag-fade 8s ease-out infinite;\n    animation-delay: -1.0s;\n  }\n  .name-tag rect {\n    fill: rgba(58, 41, 18, 0.9);\n    stroke: #2a1a08;\n    stroke-width: 0.5;\n  }\n  .name-tag text {\n    font-family: \"Luminari\", \"IM Fell English\", serif;\n    font-size: 10px;\n    fill: #e9d7a6;\n  }\n  @keyframes tag-fade {\n    0%, 70%   { opacity: 0; transform: translateY(5px); }\n    78%, 92%  { opacity: 1; transform: translateY(0); }\n    100%      { opacity: 0; transform: translateY(5px); }\n  }\n\n  @media (prefers-reduced-motion: reduce) {\n    .parchment-group, .panel-left, .panel-right, .panel-center,\n    .ink, .map-label, .map-title-text, .map-oath, .step, .name-tag {\n      animation: none;\n    }\n    .panel-left, .panel-right, .panel-center { opacity: 1; transform: none; }\n    .ink { stroke-dashoffset: 0; }\n    .map-label, .map-title-text, .map-oath, .step, .name-tag { opacity: 1; }\n    .step { transform: scale(1); }\n  }\n</style>\n"
      },
      "output": {
        "type": "create",
        "filePath": "<repo>/src/animations/marauders-map/index.astro",
        "content": "---\n// Marauder's Map — a parchment unfolds to reveal a footprint trail\n// crossing it, with ink strokes fading in to draw the map as it unfolds.\n// Pure SVG + CSS, no JS.\n---\n\n<div class=\"map-stage\">\n  <svg viewBox=\"0 0 900 520\" preserveAspectRatio=\"xMidYMid meet\" aria-labelledby=\"map-title\">\n    <title id=\"map-title\">The Marauder's Map: parchment unfolds with footprints tracing a path across the halls of Hogwarts</title>\n\n    <defs>\n      <linearGradient id=\"parchment\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n        <stop offset=\"0%\" stop-color=\"#e9d7a6\" />\n        <stop offset=\"40%\" stop-color=\"#dcc58b\" />\n        <stop offset=\"100%\" stop-color=\"#b89658\" />\n      </linearGradient>\n      <radialGradient id=\"parchment-vignette\" cx=\"50%\" cy=\"50%\" r=\"55%\">\n        <stop offset=\"55%\" stop-color=\"rgba(0,0,0,0)\" />\n        <stop offset=\"100%\" stop-color=\"rgba(60,30,0,0.35)\" />\n      </radialGradient>\n      <filter id=\"rough\" x=\"0\" y=\"0\">\n        <feTurbulence baseFrequency=\"0.9\" numOctaves=\"2\" seed=\"3\" />\n        <feDisplacementMap in=\"SourceGraphic\" scale=\"1.2\" />\n      </filter>\n      <filter id=\"ink-soft\">\n        <feGaussianBlur stdDeviation=\"0.4\" />\n      </filter>\n      <!-- Footprint shape: stylized sole + toes, works as <use> -->\n      <symbol id=\"footprint\" viewBox=\"-8 -14 16 28\">\n        <ellipse cx=\"0\" cy=\"0\" rx=\"5\" ry=\"8\" />\n        <circle cx=\"-3\" cy=\"-10\" r=\"1.5\" />\n        <circle cx=\"-1\" cy=\"-11.5\" r=\"1.5\" />\n        <circle cx=\"1.5\" cy=\"-12\" r=\"1.5\" />\n        <circle cx=\"4\" cy=\"-11\" r=\"1.2\" />\n      </symbol>\n    </defs>\n\n    <!-- Unfolding parchment — 3 panels that rotate in from closed state -->\n    <g class=\"parchment-group\">\n      <!-- Left panel -->\n      <g class=\"panel panel-left\" style=\"transform-origin: 300px 260px\">\n        <rect x=\"0\" y=\"40\" width=\"300\" height=\"440\" fill=\"url(#parchment)\" rx=\"2\" />\n        <rect x=\"0\" y=\"40\" width=\"300\" height=\"440\" fill=\"url(#parchment-vignette)\" rx=\"2\" />\n        <!-- Corridor ink lines -->\n        <path class=\"ink ink-1\" d=\"M 40 100 L 260 100 L 260 200 L 100 200 L 100 340 L 240 340\" />\n        <path class=\"ink ink-2\" d=\"M 40 400 L 220 400 L 220 440\" />\n        <text class=\"map-label\" x=\"150\" y=\"75\" text-anchor=\"middle\">Gryffindor Tower</text>\n        <text class=\"map-label small\" x=\"260\" y=\"220\" text-anchor=\"end\">Staircase</text>\n      </g>\n\n      <!-- Center panel -->\n      <g class=\"panel panel-center\">\n        <rect x=\"300\" y=\"20\" width=\"300\" height=\"480\" fill=\"url(#parchment)\" rx=\"2\" />\n        <rect x=\"300\" y=\"20\" width=\"300\" height=\"480\" fill=\"url(#parchment-vignette)\" rx=\"2\" />\n        <!-- Title banner -->\n        <text class=\"map-title-text\" x=\"450\" y=\"80\" text-anchor=\"middle\">Marauder's Map</text>\n        <text class=\"map-oath\" x=\"450\" y=\"108\" text-anchor=\"middle\">I solemnly swear I am up to no good</text>\n        <!-- Great Hall, Library, Dungeons outlines -->\n        <path class=\"ink ink-3\" d=\"M 340 160 L 560 160 L 560 240 L 420 240 L 420 320 L 560 320 L 560 420 L 340 420 Z\" />\n        <path class=\"ink ink-4\" d=\"M 340 280 L 400 280\" />\n        <path class=\"ink ink-5\" d=\"M 480 240 L 480 320\" />\n        <text class=\"map-label\" x=\"500\" y=\"185\" text-anchor=\"middle\">Great Hall</text>\n        <text class=\"map-label\" x=\"370\" y=\"300\" text-anchor=\"middle\">Library</text>\n        <text class=\"map-label\" x=\"520\" y=\"395\" text-anchor=\"middle\">Dungeons</text>\n      </g>\n\n      <!-- Right panel -->\n      <g class=\"panel panel-right\" style=\"transform-origin: 600px 260px\">\n        <rect x=\"600\" y=\"40\" width=\"300\" height=\"440\" fill=\"url(#parchment)\" rx=\"2\" />\n        <rect x=\"600\" y=\"40\" width=\"300\" height=\"440\" fill=\"url(#parchment-vignette)\" rx=\"2\" />\n        <path class=\"ink ink-6\" d=\"M 640 100 L 840 100 L 840 180 L 700 180 L 700 280 L 860 280\" />\n        <path class=\"ink ink-7\" d=\"M 640 380 L 800 380 L 800 440\" />\n        <text class=\"map-label\" x=\"740\" y=\"75\" text-anchor=\"middle\">Astronomy</text>\n        <text class=\"map-label small\" x=\"730\" y=\"310\" text-anchor=\"middle\">Owlery</text>\n      </g>\n    </g>\n\n    <!-- Footprint trail: 8 prints appearing in sequence along a path through Hogwarts -->\n    <g class=\"footsteps\">\n      <use href=\"#footprint\" class=\"step step-1\"  transform=\"translate(140, 130) rotate(90)\"  width=\"16\" height=\"28\" />\n      <use href=\"#footprint\" class=\"step step-2\"  transform=\"translate(200, 200) rotate(90)\"  width=\"16\" height=\"28\" />\n      <use href=\"#footprint\" class=\"step step-3\"  transform=\"translate(260, 200) rotate(110)\" width=\"16\" height=\"28\" />\n      <use href=\"#footprint\" class=\"step step-4\"  transform=\"translate(330, 190) rotate(85)\"  width=\"16\" height=\"28\" />\n      <use href=\"#footprint\" class=\"step step-5\"  transform=\"translate(390, 220) rotate(95)\"  width=\"16\" height=\"28\" />\n      <use href=\"#footprint\" class=\"step step-6\"  transform=\"translate(450, 260) rotate(120)\" width=\"16\" height=\"28\" />\n      <use href=\"#footprint\" class=\"step step-7\"  transform=\"translate(510, 330) rotate(135)\" width=\"16\" height=\"28\" />\n      <use href=\"#footprint\" class=\"step step-8\"  transform=\"translate(580, 400) rotate(145)\" width=\"16\" height=\"28\" />\n      <use href=\"#footprint\" class=\"step step-9\"  transform=\"translate(660, 400) rotate(90)\"  width=\"16\" height=\"28\" />\n      <use href=\"#footprint\" class=\"step step-10\" transform=\"translate(740, 340) rotate(70)\"  width=\"16\" height=\"28\" />\n    </g>\n\n    <!-- Name label that appears with the footprints (\"Harry Potter\") -->\n    <g class=\"name-tag\">\n      <rect x=\"700\" y=\"305\" width=\"96\" height=\"20\" rx=\"3\" />\n      <text x=\"748\" y=\"319\" text-anchor=\"middle\">Harry Potter</text>\n    </g>\n  </svg>\n</div>\n\n<style>\n  .map-stage {\n    display: block;\n    width: 100%;\n    aspect-ratio: 16 / 9;\n    background:\n      radial-gradient(circle at 30% 30%, rgba(180, 130, 60, 0.08) 0%, transparent 55%),\n      radial-gradient(circle at 70% 70%, rgba(94, 106, 210, 0.04) 0%, transparent 50%),\n      #0b0d0e;\n  }\n  svg { width: 100%; height: 100%; display: block; }\n\n  /* Unfolding parchment — side panels rotate in from folded-closed */\n  .parchment-group {\n    animation: parchment-glow 6s ease-in-out infinite;\n  }\n  @keyframes parchment-glow {\n    0%, 100% { filter: drop-shadow(0 4px 14px rgba(0,0,0,0.45)); }\n    50%      { filter: drop-shadow(0 6px 22px rgba(212, 160, 42, 0.18)); }\n  }\n\n  .panel-center {\n    animation: center-fade 8s ease-in-out infinite;\n    transform-origin: 450px 260px;\n  }\n  .panel-left  { animation: unfold-left  8s ease-in-out infinite; }\n  .panel-right { animation: unfold-right 8s ease-in-out infinite; }\n\n  @keyframes center-fade {\n    0%, 8%    { opacity: 0; transform: scale(0.92); }\n    18%, 92%  { opacity: 1; transform: scale(1); }\n    100%      { opacity: 0; transform: scale(0.92); }\n  }\n  @keyframes unfold-left {\n    0%, 12%   { transform: rotateY(95deg); opacity: 0; }\n    28%, 92%  { transform: rotateY(0deg);  opacity: 1; }\n    100%      { transform: rotateY(95deg); opacity: 0; }\n  }\n  @keyframes unfold-right {\n    0%, 12%   { transform: rotateY(-95deg); opacity: 0; }\n    28%, 92%  { transform: rotateY(0deg);   opacity: 1; }\n    100%      { transform: rotateY(-95deg); opacity: 0; }\n  }\n\n  /* Ink strokes draw themselves in sequence */\n  .ink {\n    fill: none;\n    stroke: #3a2912;\n    stroke-width: 1.8;\n    stroke-linecap: round;\n    stroke-linejoin: round;\n    stroke-dasharray: 600;\n    stroke-dashoffset: 600;\n    filter: url(#ink-soft);\n    animation: draw-ink 8s ease-out infinite;\n  }\n  .ink-1 { animation-delay: -7.0s; }\n  .ink-2 { animation-delay: -6.6s; }\n  .ink-3 { animation-delay: -6.3s; }\n  .ink-4 { animation-delay: -6.0s; }\n  .ink-5 { animation-delay: -5.8s; }\n  .ink-6 { animation-delay: -5.5s; }\n  .ink-7 { animation-delay: -5.2s; }\n\n  @keyframes draw-ink {\n    0%, 32%  { stroke-dashoffset: 600; }\n    48%, 92% { stroke-dashoffset: 0; }\n    100%     { stroke-dashoffset: 600; }\n  }\n\n  /* Text labels */\n  .map-label {\n    font-family: \"Luminari\", \"IM Fell English\", serif;\n    font-size: 11px;\n    fill: #2a1a08;\n    opacity: 0;\n    animation: label-fade 8s ease-out infinite;\n  }\n  .map-label.small { font-size: 9px; }\n  .map-title-text {\n    font-family: \"Luminari\", \"IM Fell English\", serif;\n    font-size: 22px;\n    font-weight: 700;\n    fill: #2a1a08;\n    letter-spacing: 0.08em;\n    opacity: 0;\n    animation: label-fade 8s ease-out infinite;\n    animation-delay: -6.2s;\n  }\n  .map-oath {\n    font-family: \"IM Fell English\", serif;\n    font-size: 9px;\n    font-style: italic;\n    fill: rgba(42, 26, 8, 0.7);\n    opacity: 0;\n    animation: label-fade 8s ease-out infinite;\n    animation-delay: -6s;\n  }\n  @keyframes label-fade {\n    0%, 42%  { opacity: 0; }\n    58%, 90% { opacity: 1; }\n    100%     { opacity: 0; }\n  }\n\n  /* Footsteps appear one at a time after the map is drawn */\n  .step {\n    fill: #3a2912;\n    opacity: 0;\n    animation: step-fade 8s ease-out infinite;\n  }\n  .step-1  { animation-delay: -3.6s; }\n  .step-2  { animation-delay: -3.3s; }\n  .step-3  { animation-delay: -3.0s; }\n  .step-4  { animation-delay: -2.7s; }\n  .step-5  { animation-delay: -2.4s; }\n  .step-6  { animation-delay: -2.1s; }\n  .step-7  { animation-delay: -1.8s; }\n  .step-8  { animation-delay: -1.5s; }\n  .step-9  { animation-delay: -1.2s; }\n  .step-10 { animation-delay: -0.9s; }\n\n  @keyframes step-fade {\n    0%   { opacity: 0; transform: scale(0.6); }\n    6%   { opacity: 1; transform: scale(1.25); }\n    14%  { opacity: 1; transform: scale(1);    }\n    60%  { opacity: 1; }\n    80%  { opacity: 0.25; }\n    100% { opacity: 0; }\n  }\n\n  /* \"Harry Potter\" name tag rides with the front-most footprint */\n  .name-tag {\n    opacity: 0;\n    animation: tag-fade 8s ease-out infinite;\n    animation-delay: -1.0s;\n  }\n  .name-tag rect {\n    fill: rgba(58, 41, 18, 0.9);\n    stroke: #2a1a08;\n    stroke-width: 0.5;\n  }\n  .name-tag text {\n    font-family: \"Luminari\", \"IM Fell English\", serif;\n    font-size: 10px;\n    fill: #e9d7a6;\n  }\n  @keyframes tag-fade {\n    0%, 70%   { opacity: 0; transform: translateY(5px); }\n    78%, 92%  { opacity: 1; transform: translateY(0); }\n    100%      { opacity: 0; transform: translateY(5px); }\n  }\n\n  @media (prefers-reduced-motion: reduce) {\n    .parchment-group, .panel-left, .panel-right, .panel-center,\n    .ink, .map-label, .map-title-text, .map-oath, .step, .name-tag {\n      animation: none;\n    }\n    .panel-left, .panel-right, .panel-center { opacity: 1; transform: none; }\n    .ink { stroke-dashoffset: 0; }\n    .map-label, .map-title-text, .map-oath, .step, .name-tag { opacity: 1; }\n    .step { transform: scale(1); }\n  }\n</style>\n",
        "structuredPatch": [],
        "originalFile": null,
        "userModified": false
      },
      "postStateChecksum": {}
    }
  7. #6 Read · <repo>/src/pages/animation-preview/[slug].astro
    {
      "cursor": 6,
      "ts": "2026-04-16T11:29:48Z",
      "kind": "tool",
      "tool": "Read",
      "input": {
        "file_path": "<repo>/src/pages/animation-preview/[slug].astro"
      },
      "output": {
        "type": "text",
        "file": {
          "filePath": "<repo>/src/pages/animation-preview/[slug].astro",
          "content": "---\nexport const prerender = false;\n\nimport PlaceholderHero from \"../../animations/placeholder/index.astro\";\nimport SnitchTrailHero from \"../../animations/snitch-trail/index.astro\";\n\nconst { slug } = Astro.params;\nif (!slug) return new Response(\"Not Found\", { status: 404 });\n\nconst HERO_COMPONENTS = {\n  placeholder: PlaceholderHero,\n  \"snitch-trail\": SnitchTrailHero,\n} as const;\n\nconst Hero = (HERO_COMPONENTS as Record<string, typeof PlaceholderHero>)[slug];\nif (!Hero) return new Response(`No hero for slug \"${slug}\"`, { status: 404 });\n\n// Bare headless preview route used by Plan B's snap-animation.sh helper.\n// Not meant for public discovery — blocked via robots.\nAstro.response.headers.set(\"cache-control\", \"no-store\");\nAstro.response.headers.set(\"x-robots-tag\", \"noindex, nofollow\");\n---\n\n<!doctype html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"utf-8\" />\n    <title>{slug} preview</title>\n    <meta name=\"robots\" content=\"noindex, nofollow\" />\n    <style>\n      html, body { margin: 0; padding: 0; background: #08090a; height: 100vh; }\n      .stage {\n        display: flex;\n        align-items: center;\n        justify-content: center;\n        width: 100vw;\n        height: 100vh;\n      }\n      .stage > * { max-width: min(1080px, 90vw); width: 100%; }\n    </style>\n  </head>\n  <body>\n    <main class=\"stage\">\n      <Hero />\n    </main>\n  </body>\n</html>\n",
          "numLines": 48,
          "startLine": 1,
          "totalLines": 48
        }
      },
      "postStateChecksum": {}
    }
  8. #7 Edit · <repo>/src/pages/animation-preview/[slug].astro
    {
      "cursor": 7,
      "ts": "2026-04-16T11:29:56Z",
      "kind": "tool",
      "tool": "Edit",
      "input": {
        "file_path": "<repo>/src/pages/animation-preview/[slug].astro",
        "old_string": "import PlaceholderHero from \"../../animations/placeholder/index.astro\";\nimport SnitchTrailHero from \"../../animations/snitch-trail/index.astro\";\n\nconst { slug } = Astro.params;\nif (!slug) return new Response(\"Not Found\", { status: 404 });\n\nconst HERO_COMPONENTS = {\n  placeholder: PlaceholderHero,\n  \"snitch-trail\": SnitchTrailHero,\n} as const;",
        "new_string": "import PlaceholderHero from \"../../animations/placeholder/index.astro\";\nimport SnitchTrailHero from \"../../animations/snitch-trail/index.astro\";\nimport MaraudersMapHero from \"../../animations/marauders-map/index.astro\";\n\nconst { slug } = Astro.params;\nif (!slug) return new Response(\"Not Found\", { status: 404 });\n\nconst HERO_COMPONENTS = {\n  placeholder: PlaceholderHero,\n  \"snitch-trail\": SnitchTrailHero,\n  \"marauders-map\": MaraudersMapHero,\n} as const;",
        "replace_all": false
      },
      "output": {
        "filePath": "<repo>/src/pages/animation-preview/[slug].astro",
        "oldString": "import PlaceholderHero from \"../../animations/placeholder/index.astro\";\nimport SnitchTrailHero from \"../../animations/snitch-trail/index.astro\";\n\nconst { slug } = Astro.params;\nif (!slug) return new Response(\"Not Found\", { status: 404 });\n\nconst HERO_COMPONENTS = {\n  placeholder: PlaceholderHero,\n  \"snitch-trail\": SnitchTrailHero,\n} as const;",
        "newString": "import PlaceholderHero from \"../../animations/placeholder/index.astro\";\nimport SnitchTrailHero from \"../../animations/snitch-trail/index.astro\";\nimport MaraudersMapHero from \"../../animations/marauders-map/index.astro\";\n\nconst { slug } = Astro.params;\nif (!slug) return new Response(\"Not Found\", { status: 404 });\n\nconst HERO_COMPONENTS = {\n  placeholder: PlaceholderHero,\n  \"snitch-trail\": SnitchTrailHero,\n  \"marauders-map\": MaraudersMapHero,\n} as const;",
        "originalFile": "---\nexport const prerender = false;\n\nimport PlaceholderHero from \"../../animations/placeholder/index.astro\";\nimport SnitchTrailHero from \"../../animations/snitch-trail/index.astro\";\n\nconst { slug } = Astro.params;\nif (!slug) return new Response(\"Not Found\", { status: 404 });\n\nconst HERO_COMPONENTS = {\n  placeholder: PlaceholderHero,\n  \"snitch-trail\": SnitchTrailHero,\n} as const;\n\nconst Hero = (HERO_COMPONENTS as Record<string, typeof PlaceholderHero>)[slug];\nif (!Hero) return new Response(`No hero for slug \"${slug}\"`, { status: 404 });\n\n// Bare headless preview route used by Plan B's snap-animation.sh helper.\n// Not meant for public discovery — blocked via robots.\nAstro.response.headers.set(\"cache-control\", \"no-store\");\nAstro.response.headers.set(\"x-robots-tag\", \"noindex, nofollow\");\n---\n\n<!doctype html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"utf-8\" />\n    <title>{slug} preview</title>\n    <meta name=\"robots\" content=\"noindex, nofollow\" />\n    <style>\n      html, body { margin: 0; padding: 0; background: #08090a; height: 100vh; }\n      .stage {\n        display: flex;\n        align-items: center;\n        justify-content: center;\n        width: 100vw;\n        height: 100vh;\n      }\n      .stage > * { max-width: min(1080px, 90vw); width: 100%; }\n    </style>\n  </head>\n  <body>\n    <main class=\"stage\">\n      <Hero />\n    </main>\n  </body>\n</html>\n",
        "structuredPatch": [
          {
            "oldStart": 3,
            "oldLines": 6,
            "newStart": 3,
            "newLines": 7,
            "lines": [
              " ",
              " import PlaceholderHero from \"../../animations/placeholder/index.astro\";",
              " import SnitchTrailHero from \"../../animations/snitch-trail/index.astro\";",
              "+import MaraudersMapHero from \"../../animations/marauders-map/index.astro\";",
              " ",
              " const { slug } = Astro.params;",
              " if (!slug) return new Response(\"Not Found\", { status: 404 });"
            ]
          },
          {
            "oldStart": 10,
            "oldLines": 6,
            "newStart": 11,
            "newLines": 7,
            "lines": [
              " const HERO_COMPONENTS = {",
              "   placeholder: PlaceholderHero,",
              "   \"snitch-trail\": SnitchTrailHero,",
              "+  \"marauders-map\": MaraudersMapHero,",
              " } as const;",
              " ",
              " const Hero = (HERO_COMPONENTS as Record<string, typeof PlaceholderHero>)[slug];"
            ]
          }
        ],
        "userModified": false,
        "replaceAll": false
      },
      "postStateChecksum": {}
    }
  9. #8 Edit · <repo>/src/pages/hogwarts/quidditch/[slug].astro
    {
      "cursor": 8,
      "ts": "2026-04-16T11:29:58Z",
      "kind": "tool",
      "tool": "Edit",
      "input": {
        "file_path": "<repo>/src/pages/hogwarts/quidditch/[slug].astro",
        "old_string": "import PlaceholderHero from \"../../../animations/placeholder/index.astro\";\nimport SnitchTrailHero from \"../../../animations/snitch-trail/index.astro\";",
        "new_string": "import PlaceholderHero from \"../../../animations/placeholder/index.astro\";\nimport SnitchTrailHero from \"../../../animations/snitch-trail/index.astro\";\nimport MaraudersMapHero from \"../../../animations/marauders-map/index.astro\";",
        "replace_all": false
      },
      "output": {
        "filePath": "<repo>/src/pages/hogwarts/quidditch/[slug].astro",
        "oldString": "import PlaceholderHero from \"../../../animations/placeholder/index.astro\";\nimport SnitchTrailHero from \"../../../animations/snitch-trail/index.astro\";",
        "newString": "import PlaceholderHero from \"../../../animations/placeholder/index.astro\";\nimport SnitchTrailHero from \"../../../animations/snitch-trail/index.astro\";\nimport MaraudersMapHero from \"../../../animations/marauders-map/index.astro\";",
        "originalFile": "---\nexport const prerender = false;\n\nimport Base from \"../../../layouts/Base.astro\";\nimport { getEmDashEntry } from \"emdash\";\nimport { getCurrentLang } from \"../../../utils/lang\";\nimport { loadFromR2 } from \"../../../lib/animations/load-from-r2.ts\";\nimport RawViewer from \"../../../components/animations/RawViewer.astro\";\nimport LiveRender from \"../../../components/animations/LiveRender.astro\";\nimport { env } from \"cloudflare:workers\";\n\nimport PlaceholderHero from \"../../../animations/placeholder/index.astro\";\nimport SnitchTrailHero from \"../../../animations/snitch-trail/index.astro\";\n\ninterface AnimationData {\n  title: string;\n  description?: string;\n  tags?: string[];\n  hero_component?: string;\n  primary_session_id?: string;\n  language?: string;\n}\n\nconst { slug } = Astro.params;\nif (!slug) return Astro.redirect(\"/hogwarts/quidditch/\");\n\nconst currentLang = getCurrentLang(Astro);\nconst isVi = currentLang === \"vi\";\n\nconst entryResult = await getEmDashEntry(\"animations\" as never, slug, { locale: \"en\" } as never);\nconst entry = (entryResult as unknown as { entry?: { id: string; data: AnimationData }; cacheHint?: unknown }).entry;\nconst cacheHint = (entryResult as unknown as { cacheHint?: unknown }).cacheHint;\nif (!entry) return new Response(\"Not Found\", { status: 404 });\nif (cacheHint) Astro.cache.set(cacheHint as never);\n\n// Plan B: R2-backed loader queries animation_sessions in D1, fetches the\n// latest published transcript.jsonl.gz from R2 MEDIA, and returns the same\n// shape the fixture did. Falls back to the on-disk fixture when R2 has no\n// data yet — preserves the placeholder during rollout.\nconst fixture = await loadFromR2(\n  slug,\n  env as Parameters<typeof loadFromR2>[1],\n);\n\n// Hero component registry — explicit map. Astro can't dynamically import\n// user-authored components from a runtime-computed path while keeping\n// tree-shaking correct.\nconst HERO_COMPONENTS = {\n  placeholder: PlaceholderHero,\n  \"snitch-trail\": SnitchTrailHero,\n} as const;\n\nconst Hero = (HERO_COMPONENTS as Record<string, typeof PlaceholderHero>)[slug];\n---\n\n<Base\n  title={`${entry.data.title} — Quidditch`}\n  description={entry.data.description ?? \"Animation playground artifact\"}\n  breadcrumbs={[\n    { label: \"Hogwarts\", href: \"/\" },\n    { label: isVi ? \"Sân Quidditch\" : \"Quidditch Pitch\", href: \"/hogwarts/quidditch/\" },\n    { label: entry.data.title },\n  ]}\n>\n  <article class=\"animation-page\">\n    <header class=\"anim-head\">\n      <h1>{entry.data.title}</h1>\n      {entry.data.description && <p class=\"anim-desc\">{entry.data.description}</p>}\n    </header>\n\n    <section class=\"hero-stage\" aria-label=\"Hero animation\">\n      {Hero ? <Hero /> : (\n        <div class=\"hero-missing\">\n          No hero component registered for slug \"{slug}\". Add it to\n          HERO_COMPONENTS in src/pages/hogwarts/quidditch/[slug].astro.\n        </div>\n      )}\n    </section>\n\n    <section class=\"diary-section\" aria-label=\"Build diary\">\n      <div class=\"section-head\">\n        <h2>{isVi ? \"Nhật ký dựng\" : \"Build diary\"}</h2>\n        <p class=\"section-sub\">\n          {isVi\n            ? \"Toàn bộ phiên Claude Code. Dùng ← → hoặc click chapter để di chuyển.\"\n            : \"Full Claude Code session. Use ← → or click a chapter to scrub.\"}\n        </p>\n      </div>\n      <RawViewer transcript={fixture.transcript} chapters={fixture.chapters} />\n    </section>\n\n    <LiveRender slug={slug} sourceHistory={fixture.sourceHistory} />\n  </article>\n</Base>\n\n<style>\n  .animation-page { max-width: 1080px; margin: 0 auto; padding: 2rem 1.5rem 4rem; }\n  .anim-head { margin-bottom: 2rem; }\n  .anim-head h1 { font-size: clamp(1.75rem, 4vw, 2.5rem); margin: 0 0 0.5rem; letter-spacing: -0.02em; }\n  .anim-desc { color: var(--secondary, #8a8f98); max-width: 60ch; margin: 0; line-height: 1.6; }\n\n  .hero-stage {\n    border: 1px solid var(--border, rgba(255, 255, 255, 0.06));\n    border-radius: 12px;\n    overflow: hidden;\n    margin-bottom: 3rem;\n  }\n  .hero-missing {\n    padding: 3rem;\n    text-align: center;\n    color: var(--muted, #62666d);\n    font-family: var(--mono, ui-monospace);\n    font-size: 0.875rem;\n  }\n\n  .diary-section { margin-top: 2rem; }\n  .section-head { margin-bottom: 1.25rem; }\n  .section-head h2 { margin: 0 0 0.25rem; font-size: 1.125rem; }\n  .section-sub { color: var(--muted, #62666d); margin: 0; font-size: 0.875rem; }\n</style>\n",
        "structuredPatch": [
          {
            "oldStart": 11,
            "oldLines": 6,
            "newStart": 11,
            "newLines": 7,
            "lines": [
              " ",
              " import PlaceholderHero from \"../../../animations/placeholder/index.astro\";",
              " import SnitchTrailHero from \"../../../animations/snitch-trail/index.astro\";",
              "+import MaraudersMapHero from \"../../../animations/marauders-map/index.astro\";",
              " ",
              " interface AnimationData {",
              "   title: string;"
            ]
          }
        ],
        "userModified": false,
        "replaceAll": false
      },
      "postStateChecksum": {}
    }
  10. #9 Edit · <repo>/src/pages/hogwarts/quidditch/[slug].astro
    {
      "cursor": 9,
      "ts": "2026-04-16T11:30:01Z",
      "kind": "tool",
      "tool": "Edit",
      "input": {
        "file_path": "<repo>/src/pages/hogwarts/quidditch/[slug].astro",
        "old_string": "const HERO_COMPONENTS = {\n  placeholder: PlaceholderHero,\n  \"snitch-trail\": SnitchTrailHero,\n} as const;",
        "new_string": "const HERO_COMPONENTS = {\n  placeholder: PlaceholderHero,\n  \"snitch-trail\": SnitchTrailHero,\n  \"marauders-map\": MaraudersMapHero,\n} as const;",
        "replace_all": false
      },
      "output": {
        "filePath": "<repo>/src/pages/hogwarts/quidditch/[slug].astro",
        "oldString": "const HERO_COMPONENTS = {\n  placeholder: PlaceholderHero,\n  \"snitch-trail\": SnitchTrailHero,\n} as const;",
        "newString": "const HERO_COMPONENTS = {\n  placeholder: PlaceholderHero,\n  \"snitch-trail\": SnitchTrailHero,\n  \"marauders-map\": MaraudersMapHero,\n} as const;",
        "originalFile": "---\nexport const prerender = false;\n\nimport Base from \"../../../layouts/Base.astro\";\nimport { getEmDashEntry } from \"emdash\";\nimport { getCurrentLang } from \"../../../utils/lang\";\nimport { loadFromR2 } from \"../../../lib/animations/load-from-r2.ts\";\nimport RawViewer from \"../../../components/animations/RawViewer.astro\";\nimport LiveRender from \"../../../components/animations/LiveRender.astro\";\nimport { env } from \"cloudflare:workers\";\n\nimport PlaceholderHero from \"../../../animations/placeholder/index.astro\";\nimport SnitchTrailHero from \"../../../animations/snitch-trail/index.astro\";\nimport MaraudersMapHero from \"../../../animations/marauders-map/index.astro\";\n\ninterface AnimationData {\n  title: string;\n  description?: string;\n  tags?: string[];\n  hero_component?: string;\n  primary_session_id?: string;\n  language?: string;\n}\n\nconst { slug } = Astro.params;\nif (!slug) return Astro.redirect(\"/hogwarts/quidditch/\");\n\nconst currentLang = getCurrentLang(Astro);\nconst isVi = currentLang === \"vi\";\n\nconst entryResult = await getEmDashEntry(\"animations\" as never, slug, { locale: \"en\" } as never);\nconst entry = (entryResult as unknown as { entry?: { id: string; data: AnimationData }; cacheHint?: unknown }).entry;\nconst cacheHint = (entryResult as unknown as { cacheHint?: unknown }).cacheHint;\nif (!entry) return new Response(\"Not Found\", { status: 404 });\nif (cacheHint) Astro.cache.set(cacheHint as never);\n\n// Plan B: R2-backed loader queries animation_sessions in D1, fetches the\n// latest published transcript.jsonl.gz from R2 MEDIA, and returns the same\n// shape the fixture did. Falls back to the on-disk fixture when R2 has no\n// data yet — preserves the placeholder during rollout.\nconst fixture = await loadFromR2(\n  slug,\n  env as Parameters<typeof loadFromR2>[1],\n);\n\n// Hero component registry — explicit map. Astro can't dynamically import\n// user-authored components from a runtime-computed path while keeping\n// tree-shaking correct.\nconst HERO_COMPONENTS = {\n  placeholder: PlaceholderHero,\n  \"snitch-trail\": SnitchTrailHero,\n} as const;\n\nconst Hero = (HERO_COMPONENTS as Record<string, typeof PlaceholderHero>)[slug];\n---\n\n<Base\n  title={`${entry.data.title} — Quidditch`}\n  description={entry.data.description ?? \"Animation playground artifact\"}\n  breadcrumbs={[\n    { label: \"Hogwarts\", href: \"/\" },\n    { label: isVi ? \"Sân Quidditch\" : \"Quidditch Pitch\", href: \"/hogwarts/quidditch/\" },\n    { label: entry.data.title },\n  ]}\n>\n  <article class=\"animation-page\">\n    <header class=\"anim-head\">\n      <h1>{entry.data.title}</h1>\n      {entry.data.description && <p class=\"anim-desc\">{entry.data.description}</p>}\n    </header>\n\n    <section class=\"hero-stage\" aria-label=\"Hero animation\">\n      {Hero ? <Hero /> : (\n        <div class=\"hero-missing\">\n          No hero component registered for slug \"{slug}\". Add it to\n          HERO_COMPONENTS in src/pages/hogwarts/quidditch/[slug].astro.\n        </div>\n      )}\n    </section>\n\n    <section class=\"diary-section\" aria-label=\"Build diary\">\n      <div class=\"section-head\">\n        <h2>{isVi ? \"Nhật ký dựng\" : \"Build diary\"}</h2>\n        <p class=\"section-sub\">\n          {isVi\n            ? \"Toàn bộ phiên Claude Code. Dùng ← → hoặc click chapter để di chuyển.\"\n            : \"Full Claude Code session. Use ← → or click a chapter to scrub.\"}\n        </p>\n      </div>\n      <RawViewer transcript={fixture.transcript} chapters={fixture.chapters} />\n    </section>\n\n    <LiveRender slug={slug} sourceHistory={fixture.sourceHistory} />\n  </article>\n</Base>\n\n<style>\n  .animation-page { max-width: 1080px; margin: 0 auto; padding: 2rem 1.5rem 4rem; }\n  .anim-head { margin-bottom: 2rem; }\n  .anim-head h1 { font-size: clamp(1.75rem, 4vw, 2.5rem); margin: 0 0 0.5rem; letter-spacing: -0.02em; }\n  .anim-desc { color: var(--secondary, #8a8f98); max-width: 60ch; margin: 0; line-height: 1.6; }\n\n  .hero-stage {\n    border: 1px solid var(--border, rgba(255, 255, 255, 0.06));\n    border-radius: 12px;\n    overflow: hidden;\n    margin-bottom: 3rem;\n  }\n  .hero-missing {\n    padding: 3rem;\n    text-align: center;\n    color: var(--muted, #62666d);\n    font-family: var(--mono, ui-monospace);\n    font-size: 0.875rem;\n  }\n\n  .diary-section { margin-top: 2rem; }\n  .section-head { margin-bottom: 1.25rem; }\n  .section-head h2 { margin: 0 0 0.25rem; font-size: 1.125rem; }\n  .section-sub { color: var(--muted, #62666d); margin: 0; font-size: 0.875rem; }\n</style>\n",
        "structuredPatch": [
          {
            "oldStart": 49,
            "oldLines": 6,
            "newStart": 49,
            "newLines": 7,
            "lines": [
              " const HERO_COMPONENTS = {",
              "   placeholder: PlaceholderHero,",
              "   \"snitch-trail\": SnitchTrailHero,",
              "+  \"marauders-map\": MaraudersMapHero,",
              " } as const;",
              " ",
              " const Hero = (HERO_COMPONENTS as Record<string, typeof PlaceholderHero>)[slug];"
            ]
          }
        ],
        "userModified": false,
        "replaceAll": false
      },
      "postStateChecksum": {}
    }
  11. #10 Bash · # Drop + re-seed the animations collection on local + wrangler D1 sqlite3 data.db "DELETE FROM _emdash_collections WHERE slug='animations';" 2>&1 sqlite3 .wrangler/state/v3/d1/miniflare-D1DatabaseObject/1e5f993cdfbf413bf46e80ed5d9ca9d4a615e6af09e7e04958b139bcc29648fc.sqlite "DELETE FROM _emdash_collections WHERE slug='animations';" 2>&1 npx emdash seed seed/seed.json --validate 2>&1 | tail -2 echo "---seed local---" npx emdash seed seed/seed.json --url http://localhost:4321 2>&1 | tail -5 echo "---seed wrangler---" npx emdash seed seed/seed.json --database .wrangler/state/v3/d1/miniflare-D1DatabaseObject/1e5f993cdfbf413bf46e80ed5d9ca9d4a615e6af09e7e04958b139bcc29648fc.sqlite 2>&1 | tail -3 # Extract marauders-map + updated snitch-trail rows for remote sqlite3 data.db ".mode insert ec_animations" "SELECT * FROM ec_animations WHERE slug IN ('marauders-map');" > /tmp/new-anim.sql cat /tmp/new-anim.sql | tail -2 echo "---apply to remote---" npx wrangler d1 execute pensieve-db --remote --file /tmp/new-anim.sql 2>&1 | grep -E "rows_written|success" | head -3
    {
      "cursor": 10,
      "ts": "2026-04-16T11:30:23Z",
      "kind": "tool",
      "tool": "Bash",
      "input": {
        "command": "# Drop + re-seed the animations collection on local + wrangler D1\nsqlite3 data.db \"DELETE FROM _emdash_collections WHERE slug='animations';\" 2>&1\nsqlite3 .wrangler/state/v3/d1/miniflare-D1DatabaseObject/1e5f993cdfbf413bf46e80ed5d9ca9d4a615e6af09e7e04958b139bcc29648fc.sqlite \"DELETE FROM _emdash_collections WHERE slug='animations';\" 2>&1\n\nnpx emdash seed seed/seed.json --validate 2>&1 | tail -2\necho \"---seed local---\"\nnpx emdash seed seed/seed.json --url http://localhost:4321 2>&1 | tail -5\necho \"---seed wrangler---\"\nnpx emdash seed seed/seed.json --database .wrangler/state/v3/d1/miniflare-D1DatabaseObject/1e5f993cdfbf413bf46e80ed5d9ca9d4a615e6af09e7e04958b139bcc29648fc.sqlite 2>&1 | tail -3\n\n# Extract marauders-map + updated snitch-trail rows for remote\nsqlite3 data.db \".mode insert ec_animations\" \"SELECT * FROM ec_animations WHERE slug IN ('marauders-map');\" > /tmp/new-anim.sql\ncat /tmp/new-anim.sql | tail -2\necho \"---apply to remote---\"\nnpx wrangler d1 execute pensieve-db --remote --file /tmp/new-anim.sql 2>&1 | grep -E \"rows_written|success\" | head -3",
        "timeout": 120000,
        "description": "Re-seed + sync to remote D1"
      },
      "output": {
        "stdout": "✔ Seed file is valid\n✔ Validation complete\n---seed local---\nℹ Database up to date\n◐ Applying seed...\n\n ERROR  Seed failed: table \"ec_animations\" already exists\n\n---seed wrangler---\n\n ERROR  Seed failed: table \"ec_animations\" already exists\n\n---apply to remote---\n    \"success\": true,\n      \"rows_written\": 0,",
        "stderr": "",
        "interrupted": false,
        "isImage": false,
        "noOutputExpected": false
      },
      "postStateChecksum": {}
    }
  12. #11 Bash · # Generate a ULID-like id for marauders-map + insert directly ID=$(node -e "console.log('01' + Math.random().toString(36).substring(2, 6).toUpperCase() + Date.now().toString(36).toUpperCase().padEnd(20, '0').substring(0, 20))") NOW=$(date -u +"%Y-%m-%dT%H:%M:%SZ") echo "id: $ID" # Insert into local data.db sqlite3 data.db "INSERT OR REPLACE INTO ec_animations (id, slug, status, created_at, updated_at, published_at, version, locale, translation_group, title, description, tags, hero_component, og_image, primary_session_id, language) VALUES ('$ID', 'marauders-map', 'published', '$NOW', '$NOW', '$NOW', 1, 'en', '$ID', 'Marauder''s Map', 'A parchment unfolds in three panels, ink strokes draw the castle corridors, and a trail of footprints appears one by one — crossing Hogwarts to reveal \"Harry Potter\" on the tag.', '[\"svg\",\"sequence\",\"harry-potter\"]', 'src/animations/marauders-map/index.astro', NULL, '', 'en');" 2>&1 | tail -2 # Insert into wrangler local D1 sqlite3 .wrangler/state/v3/d1/miniflare-D1DatabaseObject/1e5f993cdfbf413bf46e80ed5d9ca9d4a615e6af09e7e04958b139bcc29648fc.sqlite "INSERT OR REPLACE INTO ec_animations (id, slug, status, created_at, updated_at, published_at, version, locale, translation_group, title, description, tags, hero_component, og_image, primary_session_id, language) VALUES ('$ID', 'marauders-map', 'published', '$NOW', '$NOW', '$NOW', 1, 'en', '$ID', 'Marauder''s Map', 'A parchment unfolds in three panels, ink strokes draw the castle corridors, and a trail of footprints appears one by one — crossing Hogwarts to reveal \"Harry Potter\" on the tag.', '[\"svg\",\"sequence\",\"harry-potter\"]', 'src/animations/marauders-map/index.astro', NULL, '', 'en');" 2>&1 | tail -2 # Insert into REMOTE D1 npx wrangler d1 execute pensieve-db --remote --command "INSERT OR REPLACE INTO ec_animations (id, slug, status, created_at, updated_at, published_at, version, locale, translation_group, title, description, tags, hero_component, og_image, primary_session_id, language) VALUES ('$ID', 'marauders-map', 'published', '$NOW', '$NOW', '$NOW', 1, 'en', '$ID', 'Marauder''s Map', 'A parchment unfolds in three panels, ink strokes draw the castle corridors, and a trail of footprints appears one by one — crossing Hogwarts to reveal \"Harry Potter\" on the tag.', '[\"svg\",\"sequence\",\"harry-potter\"]', 'src/animations/marauders-map/index.astro', NULL, '', 'en');" 2>&1 | grep -E "rows_written|success" | head -3 echo "---verify---" npx wrangler d1 execute pensieve-db --remote --command "SELECT slug, status, title FROM ec_animations;" 2>&1 | grep -oE '"(slug|status|title)":\s*[^,}]+' | head -12
    {
      "cursor": 11,
      "ts": "2026-04-16T11:30:46Z",
      "kind": "tool",
      "tool": "Bash",
      "input": {
        "command": "# Generate a ULID-like id for marauders-map + insert directly\nID=$(node -e \"console.log('01' + Math.random().toString(36).substring(2, 6).toUpperCase() + Date.now().toString(36).toUpperCase().padEnd(20, '0').substring(0, 20))\")\nNOW=$(date -u +\"%Y-%m-%dT%H:%M:%SZ\")\necho \"id: $ID\"\n\n# Insert into local data.db\nsqlite3 data.db \"INSERT OR REPLACE INTO ec_animations (id, slug, status, created_at, updated_at, published_at, version, locale, translation_group, title, description, tags, hero_component, og_image, primary_session_id, language) VALUES ('$ID', 'marauders-map', 'published', '$NOW', '$NOW', '$NOW', 1, 'en', '$ID', 'Marauder''s Map', 'A parchment unfolds in three panels, ink strokes draw the castle corridors, and a trail of footprints appears one by one — crossing Hogwarts to reveal \\\"Harry Potter\\\" on the tag.', '[\\\"svg\\\",\\\"sequence\\\",\\\"harry-potter\\\"]', 'src/animations/marauders-map/index.astro', NULL, '', 'en');\" 2>&1 | tail -2\n\n# Insert into wrangler local D1\nsqlite3 .wrangler/state/v3/d1/miniflare-D1DatabaseObject/1e5f993cdfbf413bf46e80ed5d9ca9d4a615e6af09e7e04958b139bcc29648fc.sqlite \"INSERT OR REPLACE INTO ec_animations (id, slug, status, created_at, updated_at, published_at, version, locale, translation_group, title, description, tags, hero_component, og_image, primary_session_id, language) VALUES ('$ID', 'marauders-map', 'published', '$NOW', '$NOW', '$NOW', 1, 'en', '$ID', 'Marauder''s Map', 'A parchment unfolds in three panels, ink strokes draw the castle corridors, and a trail of footprints appears one by one — crossing Hogwarts to reveal \\\"Harry Potter\\\" on the tag.', '[\\\"svg\\\",\\\"sequence\\\",\\\"harry-potter\\\"]', 'src/animations/marauders-map/index.astro', NULL, '', 'en');\" 2>&1 | tail -2\n\n# Insert into REMOTE D1\nnpx wrangler d1 execute pensieve-db --remote --command \"INSERT OR REPLACE INTO ec_animations (id, slug, status, created_at, updated_at, published_at, version, locale, translation_group, title, description, tags, hero_component, og_image, primary_session_id, language) VALUES ('$ID', 'marauders-map', 'published', '$NOW', '$NOW', '$NOW', 1, 'en', '$ID', 'Marauder''s Map', 'A parchment unfolds in three panels, ink strokes draw the castle corridors, and a trail of footprints appears one by one — crossing Hogwarts to reveal \\\"Harry Potter\\\" on the tag.', '[\\\"svg\\\",\\\"sequence\\\",\\\"harry-potter\\\"]', 'src/animations/marauders-map/index.astro', NULL, '', 'en');\" 2>&1 | grep -E \"rows_written|success\" | head -3\n\necho \"---verify---\"\nnpx wrangler d1 execute pensieve-db --remote --command \"SELECT slug, status, title FROM ec_animations;\" 2>&1 | grep -oE '\"(slug|status|title)\":\\s*[^,}]+' | head -12",
        "timeout": 60000,
        "description": "Direct insert marauders-map everywhere"
      },
      "output": {
        "stdout": "id: 01E21BMO1EEIQF000000000000\n    \"success\": true,\n      \"rows_written\": 14,\n---verify---\n\"slug\": \"placeholder\"\n\"status\": \"published\"\n\"title\": \"Placeholder — Spinning Snitch\"\n\"slug\": \"snitch-trail\"\n\"status\": \"published\"\n\"title\": \"Snitch Trail\"\n\"slug\": \"marauders-map\"\n\"status\": \"published\"\n\"title\": \"Marauder's Map\"",
        "stderr": "",
        "interrupted": false,
        "isImage": false,
        "noOutputExpected": false
      },
      "postStateChecksum": {}
    }
  13. #12 Bash · pkill -f "astro dev" 2>&1 ; sleep 2 (bun run dev > /tmp/astro-dev.log 2>&1 &) sleep 15 curl -sS -o /dev/null -w "preview: %{http_code}\n" http://localhost:4321/animation-preview/marauders-map .claude/hooks/snap-animation.sh marauders-map 2>&1 | tail -2 # Wait long enough to catch the parchment unfolded state + footprints sleep 3 .claude/hooks/snap-animation.sh marauders-map 2>&1 | tail -2 echo "---local grid---" curl -sS -A "Mozilla/5.0" http://localhost:4321/hogwarts/quidditch/ 2>&1 | grep -oE "Snitch Trail|Marauder|Placeholder" | sort -u
    {
      "cursor": 12,
      "ts": "2026-04-16T11:31:28Z",
      "kind": "tool",
      "tool": "Bash",
      "input": {
        "command": "pkill -f \"astro dev\" 2>&1 ; sleep 2\n(bun run dev > /tmp/astro-dev.log 2>&1 &)\nsleep 15\ncurl -sS -o /dev/null -w \"preview: %{http_code}\\n\" http://localhost:4321/animation-preview/marauders-map\n.claude/hooks/snap-animation.sh marauders-map 2>&1 | tail -2\n# Wait long enough to catch the parchment unfolded state + footprints\nsleep 3\n.claude/hooks/snap-animation.sh marauders-map 2>&1 | tail -2\necho \"---local grid---\"\ncurl -sS -A \"Mozilla/5.0\" http://localhost:4321/hogwarts/quidditch/ 2>&1 | grep -oE \"Snitch Trail|Marauder|Placeholder\" | sort -u",
        "timeout": 120000,
        "description": "Snap + verify grid"
      },
      "output": {
        "stdout": "preview: 200\nsnap saved: <repo>/.session/animation-renders/sess-1776338852-93130/cursor-0000012.png\ncursor=12 snap=<repo>/.session/animation-renders/sess-1776338852-93130/cursor-0000012.png\nsnap saved: <repo>/.session/animation-renders/sess-1776338852-93130/cursor-0000012.png\ncursor=12 snap=<repo>/.session/animation-renders/sess-1776338852-93130/cursor-0000012.png\n---local grid---\nMarauder\nPlaceholder\nSnitch Trail",
        "stderr": "",
        "interrupted": false,
        "isImage": false,
        "noOutputExpected": false
      },
      "postStateChecksum": {}
    }
  14. #13 Edit · <repo>/src/pages/hogwarts/quidditch/[slug].astro
    {
      "cursor": 13,
      "ts": "2026-04-16T11:31:48Z",
      "kind": "tool",
      "tool": "Edit",
      "input": {
        "file_path": "<repo>/src/pages/hogwarts/quidditch/[slug].astro",
        "old_string": "    <section class=\"hero-stage\" aria-label=\"Hero animation\">\n      {Hero ? <Hero /> : (\n        <div class=\"hero-missing\">\n          No hero component registered for slug \"{slug}\". Add it to\n          HERO_COMPONENTS in src/pages/hogwarts/quidditch/[slug].astro.\n        </div>\n      )}\n    </section>\n\n    <section class=\"diary-section\" aria-label=\"Build diary\">\n      <div class=\"section-head\">\n        <h2>{isVi ? \"Nhật ký dựng\" : \"Build diary\"}</h2>\n        <p class=\"section-sub\">\n          {isVi\n            ? \"Toàn bộ phiên Claude Code. Dùng ← → hoặc click chapter để di chuyển.\"\n            : \"Full Claude Code session. Use ← → or click a chapter to scrub.\"}\n        </p>\n      </div>",
        "new_string": "    <section class=\"hero-stage\" aria-label=\"Hero animation\">\n      {Hero ? <Hero /> : (\n        <div class=\"hero-missing\">\n          No hero component registered for slug \"{slug}\". Add it to\n          HERO_COMPONENTS in src/pages/hogwarts/quidditch/[slug].astro.\n        </div>\n      )}\n    </section>\n\n    <section class=\"intro-banner\" aria-label=\"About this recording\">\n      <div class=\"intro-inner\">\n        <p class=\"intro-kicker\">{isVi ? \"Bản ghi phiên dựng\" : \"Build recording\"}</p>\n        <h2 class=\"intro-lede\">\n          {isVi\n            ? \"Đây là toàn bộ phiên Claude Code dựng animation này.\"\n            : \"This is the full Claude Code session that built this animation.\"}\n        </h2>\n        <p class=\"intro-body\">\n          {isVi\n            ? \"Mỗi tool call, mỗi prompt, mỗi chỉnh sửa — được ghi lại. Bấm ▶ hoặc Space để xem nó tự tái dựng. Kéo thanh thời gian để nhảy đến bất kỳ khoảnh khắc nào. Khung Live Render sẽ cập nhật theo.\"\n            : \"Every tool call, every prompt, every edit — captured. Press ▶ or Space to watch it rebuild itself. Drag the timeline to jump to any moment. The Live Render pane updates as you scrub.\"}\n        </p>\n        <p class=\"intro-hint\" aria-hidden=\"true\">\n          <kbd>Space</kbd> <span>play/pause</span>\n          <kbd>←</kbd><kbd>→</kbd> <span>step</span>\n          <kbd>Shift</kbd>+<kbd>click</kbd> <span class=\"copy-hint\">a chapter to share it</span>\n        </p>\n      </div>\n    </section>\n\n    <section class=\"diary-section\" aria-label=\"Build diary\">\n      <div class=\"section-head\">\n        <h2>{isVi ? \"Nhật ký dựng\" : \"Build diary\"}</h2>\n        <p class=\"section-sub\">\n          {isVi\n            ? \"Toàn bộ phiên Claude Code. Dùng ← → hoặc click chapter để di chuyển.\"\n            : \"Full Claude Code session. Use ← → or click a chapter to scrub.\"}\n        </p>\n        <button type=\"button\" class=\"share-btn\" data-share aria-live=\"polite\">\n          <span class=\"share-icon\" aria-hidden=\"true\">🔗</span>\n          <span class=\"share-label\">{isVi ? \"Sao chép liên kết đến điểm này\" : \"Copy link to this moment\"}</span>\n        </button>\n      </div>",
        "replace_all": false
      },
      "output": {
        "filePath": "<repo>/src/pages/hogwarts/quidditch/[slug].astro",
        "oldString": "    <section class=\"hero-stage\" aria-label=\"Hero animation\">\n      {Hero ? <Hero /> : (\n        <div class=\"hero-missing\">\n          No hero component registered for slug \"{slug}\". Add it to\n          HERO_COMPONENTS in src/pages/hogwarts/quidditch/[slug].astro.\n        </div>\n      )}\n    </section>\n\n    <section class=\"diary-section\" aria-label=\"Build diary\">\n      <div class=\"section-head\">\n        <h2>{isVi ? \"Nhật ký dựng\" : \"Build diary\"}</h2>\n        <p class=\"section-sub\">\n          {isVi\n            ? \"Toàn bộ phiên Claude Code. Dùng ← → hoặc click chapter để di chuyển.\"\n            : \"Full Claude Code session. Use ← → or click a chapter to scrub.\"}\n        </p>\n      </div>",
        "newString": "    <section class=\"hero-stage\" aria-label=\"Hero animation\">\n      {Hero ? <Hero /> : (\n        <div class=\"hero-missing\">\n          No hero component registered for slug \"{slug}\". Add it to\n          HERO_COMPONENTS in src/pages/hogwarts/quidditch/[slug].astro.\n        </div>\n      )}\n    </section>\n\n    <section class=\"intro-banner\" aria-label=\"About this recording\">\n      <div class=\"intro-inner\">\n        <p class=\"intro-kicker\">{isVi ? \"Bản ghi phiên dựng\" : \"Build recording\"}</p>\n        <h2 class=\"intro-lede\">\n          {isVi\n            ? \"Đây là toàn bộ phiên Claude Code dựng animation này.\"\n            : \"This is the full Claude Code session that built this animation.\"}\n        </h2>\n        <p class=\"intro-body\">\n          {isVi\n            ? \"Mỗi tool call, mỗi prompt, mỗi chỉnh sửa — được ghi lại. Bấm ▶ hoặc Space để xem nó tự tái dựng. Kéo thanh thời gian để nhảy đến bất kỳ khoảnh khắc nào. Khung Live Render sẽ cập nhật theo.\"\n            : \"Every tool call, every prompt, every edit — captured. Press ▶ or Space to watch it rebuild itself. Drag the timeline to jump to any moment. The Live Render pane updates as you scrub.\"}\n        </p>\n        <p class=\"intro-hint\" aria-hidden=\"true\">\n          <kbd>Space</kbd> <span>play/pause</span>\n          <kbd>←</kbd><kbd>→</kbd> <span>step</span>\n          <kbd>Shift</kbd>+<kbd>click</kbd> <span class=\"copy-hint\">a chapter to share it</span>\n        </p>\n      </div>\n    </section>\n\n    <section class=\"diary-section\" aria-label=\"Build diary\">\n      <div class=\"section-head\">\n        <h2>{isVi ? \"Nhật ký dựng\" : \"Build diary\"}</h2>\n        <p class=\"section-sub\">\n          {isVi\n            ? \"Toàn bộ phiên Claude Code. Dùng ← → hoặc click chapter để di chuyển.\"\n            : \"Full Claude Code session. Use ← → or click a chapter to scrub.\"}\n        </p>\n        <button type=\"button\" class=\"share-btn\" data-share aria-live=\"polite\">\n          <span class=\"share-icon\" aria-hidden=\"true\">🔗</span>\n          <span class=\"share-label\">{isVi ? \"Sao chép liên kết đến điểm này\" : \"Copy link to this moment\"}</span>\n        </button>\n      </div>",
        "originalFile": "---\nexport const prerender = false;\n\nimport Base from \"../../../layouts/Base.astro\";\nimport { getEmDashEntry } from \"emdash\";\nimport { getCurrentLang } from \"../../../utils/lang\";\nimport { loadFromR2 } from \"../../../lib/animations/load-from-r2.ts\";\nimport RawViewer from \"../../../components/animations/RawViewer.astro\";\nimport LiveRender from \"../../../components/animations/LiveRender.astro\";\nimport { env } from \"cloudflare:workers\";\n\nimport PlaceholderHero from \"../../../animations/placeholder/index.astro\";\nimport SnitchTrailHero from \"../../../animations/snitch-trail/index.astro\";\nimport MaraudersMapHero from \"../../../animations/marauders-map/index.astro\";\n\ninterface AnimationData {\n  title: string;\n  description?: string;\n  tags?: string[];\n  hero_component?: string;\n  primary_session_id?: string;\n  language?: string;\n}\n\nconst { slug } = Astro.params;\nif (!slug) return Astro.redirect(\"/hogwarts/quidditch/\");\n\nconst currentLang = getCurrentLang(Astro);\nconst isVi = currentLang === \"vi\";\n\nconst entryResult = await getEmDashEntry(\"animations\" as never, slug, { locale: \"en\" } as never);\nconst entry = (entryResult as unknown as { entry?: { id: string; data: AnimationData }; cacheHint?: unknown }).entry;\nconst cacheHint = (entryResult as unknown as { cacheHint?: unknown }).cacheHint;\nif (!entry) return new Response(\"Not Found\", { status: 404 });\nif (cacheHint) Astro.cache.set(cacheHint as never);\n\n// Plan B: R2-backed loader queries animation_sessions in D1, fetches the\n// latest published transcript.jsonl.gz from R2 MEDIA, and returns the same\n// shape the fixture did. Falls back to the on-disk fixture when R2 has no\n// data yet — preserves the placeholder during rollout.\nconst fixture = await loadFromR2(\n  slug,\n  env as Parameters<typeof loadFromR2>[1],\n);\n\n// Hero component registry — explicit map. Astro can't dynamically import\n// user-authored components from a runtime-computed path while keeping\n// tree-shaking correct.\nconst HERO_COMPONENTS = {\n  placeholder: PlaceholderHero,\n  \"snitch-trail\": SnitchTrailHero,\n  \"marauders-map\": MaraudersMapHero,\n} as const;\n\nconst Hero = (HERO_COMPONENTS as Record<string, typeof PlaceholderHero>)[slug];\n---\n\n<Base\n  title={`${entry.data.title} — Quidditch`}\n  description={entry.data.description ?? \"Animation playground artifact\"}\n  breadcrumbs={[\n    { label: \"Hogwarts\", href: \"/\" },\n    { label: isVi ? \"Sân Quidditch\" : \"Quidditch Pitch\", href: \"/hogwarts/quidditch/\" },\n    { label: entry.data.title },\n  ]}\n>\n  <article class=\"animation-page\">\n    <header class=\"anim-head\">\n      <h1>{entry.data.title}</h1>\n      {entry.data.description && <p class=\"anim-desc\">{entry.data.description}</p>}\n    </header>\n\n    <section class=\"hero-stage\" aria-label=\"Hero animation\">\n      {Hero ? <Hero /> : (\n        <div class=\"hero-missing\">\n          No hero component registered for slug \"{slug}\". Add it to\n          HERO_COMPONENTS in src/pages/hogwarts/quidditch/[slug].astro.\n        </div>\n      )}\n    </section>\n\n    <section class=\"diary-section\" aria-label=\"Build diary\">\n      <div class=\"section-head\">\n        <h2>{isVi ? \"Nhật ký dựng\" : \"Build diary\"}</h2>\n        <p class=\"section-sub\">\n          {isVi\n            ? \"Toàn bộ phiên Claude Code. Dùng ← → hoặc click chapter để di chuyển.\"\n            : \"Full Claude Code session. Use ← → or click a chapter to scrub.\"}\n        </p>\n      </div>\n      <RawViewer transcript={fixture.transcript} chapters={fixture.chapters} />\n    </section>\n\n    <LiveRender slug={slug} sourceHistory={fixture.sourceHistory} />\n  </article>\n</Base>\n\n<style>\n  .animation-page { max-width: 1080px; margin: 0 auto; padding: 2rem 1.5rem 4rem; }\n  .anim-head { margin-bottom: 2rem; }\n  .anim-head h1 { font-size: clamp(1.75rem, 4vw, 2.5rem); margin: 0 0 0.5rem; letter-spacing: -0.02em; }\n  .anim-desc { color: var(--secondary, #8a8f98); max-width: 60ch; margin: 0; line-height: 1.6; }\n\n  .hero-stage {\n    border: 1px solid var(--border, rgba(255, 255, 255, 0.06));\n    border-radius: 12px;\n    overflow: hidden;\n    margin-bottom: 3rem;\n  }\n  .hero-missing {\n    padding: 3rem;\n    text-align: center;\n    color: var(--muted, #62666d);\n    font-family: var(--mono, ui-monospace);\n    font-size: 0.875rem;\n  }\n\n  .diary-section { margin-top: 2rem; }\n  .section-head { margin-bottom: 1.25rem; }\n  .section-head h2 { margin: 0 0 0.25rem; font-size: 1.125rem; }\n  .section-sub { color: var(--muted, #62666d); margin: 0; font-size: 0.875rem; }\n</style>\n",
        "structuredPatch": [
          {
            "oldStart": 79,
            "oldLines": 6,
            "newStart": 79,
            "newLines": 27,
            "lines": [
              "       )}",
              "     </section>",
              " ",
              "+    <section class=\"intro-banner\" aria-label=\"About this recording\">",
              "+      <div class=\"intro-inner\">",
              "+        <p class=\"intro-kicker\">{isVi ? \"Bản ghi phiên dựng\" : \"Build recording\"}</p>",
              "+        <h2 class=\"intro-lede\">",
              "+          {isVi",
              "+            ? \"Đây là toàn bộ phiên Claude Code dựng animation này.\"",
              "+            : \"This is the full Claude Code session that built this animation.\"}",
              "+        </h2>",
              "+        <p class=\"intro-body\">",
              "+          {isVi",
              "+            ? \"Mỗi tool call, mỗi prompt, mỗi chỉnh sửa — được ghi lại. Bấm ▶ hoặc Space để xem nó tự tái dựng. Kéo thanh thời gian để nhảy đến bất kỳ khoảnh khắc nào. Khung Live Render sẽ cập nhật theo.\"",
              "+            : \"Every tool call, every prompt, every edit — captured. Press ▶ or Space to watch it rebuild itself. Drag the timeline to jump to any moment. The Live Render pane updates as you scrub.\"}",
              "+        </p>",
              "+        <p class=\"intro-hint\" aria-hidden=\"true\">",
              "+          <kbd>Space</kbd> <span>play/pause</span>",
              "+          <kbd>←</kbd><kbd>→</kbd> <span>step</span>",
              "+          <kbd>Shift</kbd>+<kbd>click</kbd> <span class=\"copy-hint\">a chapter to share it</span>",
              "+        </p>",
              "+      </div>",
              "+    </section>",
              "+",
              "     <section class=\"diary-section\" aria-label=\"Build diary\">",
              "       <div class=\"section-head\">",
              "         <h2>{isVi ? \"Nhật ký dựng\" : \"Build diary\"}</h2>"
            ]
          },
          {
            "oldStart": 87,
            "oldLines": 6,
            "newStart": 108,
            "newLines": 10,
            "lines": [
              "             ? \"Toàn bộ phiên Claude Code. Dùng ← → hoặc click chapter để di chuyển.\"",
              "             : \"Full Claude Code session. Use ← → or click a chapter to scrub.\"}",
              "         </p>",
              "+        <button type=\"button\" class=\"share-btn\" data-share aria-live=\"polite\">",
              "+          <span class=\"share-icon\" aria-hidden=\"true\">🔗</span>",
              "+          <span class=\"share-label\">{isVi ? \"Sao chép liên kết đến điểm này\" : \"Copy link to this moment\"}</span>",
              "+        </button>",
              "       </div>",
              "       <RawViewer transcript={fixture.transcript} chapters={fixture.chapters} />",
              "     </section>"
            ]
          }
        ],
        "userModified": false,
        "replaceAll": false
      },
      "postStateChecksum": {}
    }
  15. #14 Edit · <repo>/src/pages/hogwarts/quidditch/[slug].astro
    {
      "cursor": 14,
      "ts": "2026-04-16T11:32:05Z",
      "kind": "tool",
      "tool": "Edit",
      "input": {
        "file_path": "<repo>/src/pages/hogwarts/quidditch/[slug].astro",
        "old_string": "  .diary-section { margin-top: 2rem; }\n  .section-head { margin-bottom: 1.25rem; }\n  .section-head h2 { margin: 0 0 0.25rem; font-size: 1.125rem; }\n  .section-sub { color: var(--muted, #62666d); margin: 0; font-size: 0.875rem; }\n</style>",
        "new_string": "  /* Intro banner */\n  .intro-banner {\n    margin: 0 0 2.5rem;\n    padding: 1.5rem 1.75rem;\n    border: 1px solid var(--border, rgba(255, 255, 255, 0.06));\n    border-radius: 12px;\n    background:\n      radial-gradient(circle at 0% 0%, rgba(94, 106, 210, 0.08) 0%, transparent 60%),\n      var(--elevated, #141516);\n  }\n  .intro-kicker {\n    font-family: var(--mono, ui-monospace);\n    font-size: 0.6875rem;\n    text-transform: uppercase;\n    letter-spacing: 0.12em;\n    color: var(--accent, #5e6ad2);\n    margin: 0 0 0.5rem;\n  }\n  .intro-lede {\n    font-size: clamp(1rem, 2.4vw, 1.25rem);\n    letter-spacing: -0.01em;\n    margin: 0 0 0.75rem;\n    line-height: 1.35;\n    max-width: 60ch;\n  }\n  .intro-body {\n    color: var(--secondary, #8a8f98);\n    max-width: 64ch;\n    margin: 0 0 1rem;\n    line-height: 1.6;\n    font-size: 0.9375rem;\n  }\n  .intro-hint {\n    display: flex;\n    flex-wrap: wrap;\n    gap: 0.5rem 0.75rem;\n    align-items: center;\n    margin: 0;\n    color: var(--muted, #62666d);\n    font-size: 0.75rem;\n    font-family: var(--mono, ui-monospace);\n  }\n  .intro-hint kbd {\n    display: inline-block;\n    padding: 0.125rem 0.4rem;\n    border: 1px solid var(--border, rgba(255, 255, 255, 0.12));\n    border-radius: 4px;\n    background: rgba(255, 255, 255, 0.03);\n    color: var(--text, #f7f8f8);\n    font-size: 0.7rem;\n    font-family: inherit;\n    margin-right: 0.2rem;\n  }\n\n  /* Share button */\n  .share-btn {\n    margin-top: 0.75rem;\n    appearance: none;\n    display: inline-flex;\n    align-items: center;\n    gap: 0.5rem;\n    background: transparent;\n    border: 1px solid var(--border, rgba(255, 255, 255, 0.08));\n    color: var(--secondary, #8a8f98);\n    font-family: inherit;\n    font-size: 0.8125rem;\n    padding: 0.375rem 0.75rem;\n    border-radius: 6px;\n    cursor: pointer;\n    transition: border-color 120ms ease, color 120ms ease, background 120ms ease;\n  }\n  .share-btn:hover {\n    border-color: var(--accent, #5e6ad2);\n    color: var(--text, #f7f8f8);\n    background: rgba(94, 106, 210, 0.08);\n  }\n  .share-btn.is-copied {\n    border-color: #3fb950;\n    color: #3fb950;\n  }\n  .share-btn.is-copied .share-icon::before { content: \"✓\"; }\n  .share-btn.is-copied .share-icon { font-size: 0 0; }\n  .share-btn.is-copied .share-icon::before { font-size: 0.9rem; }\n\n  .diary-section { margin-top: 2rem; }\n  .section-head { margin-bottom: 1.25rem; }\n  .section-head h2 { margin: 0 0 0.25rem; font-size: 1.125rem; }\n  .section-sub { color: var(--muted, #62666d); margin: 0; font-size: 0.875rem; }\n</style>\n\n<script>\n  // Share button — copies current page URL with the active #cursor-N\n  // hash to clipboard. Shows ✓ \"Copied!\" for 1.5s.\n  (function attachShareButton() {\n    document.addEventListener(\"DOMContentLoaded\", () => {\n      const btn = document.querySelector<HTMLButtonElement>(\"[data-share]\");\n      if (!btn) return;\n      const label = btn.querySelector<HTMLElement>(\".share-label\");\n      const originalText = label?.textContent ?? \"\";\n      btn.addEventListener(\"click\", async () => {\n        const url = window.location.origin + window.location.pathname + window.location.hash;\n        try {\n          await navigator.clipboard.writeText(url);\n          btn.classList.add(\"is-copied\");\n          if (label) label.textContent = \"Copied!\";\n          window.setTimeout(() => {\n            btn.classList.remove(\"is-copied\");\n            if (label) label.textContent = originalText;\n          }, 1500);\n        } catch {\n          // clipboard API unavailable — do nothing\n        }\n      });\n    });\n  })();\n</script>",
        "replace_all": false
      },
      "output": {
        "filePath": "<repo>/src/pages/hogwarts/quidditch/[slug].astro",
        "oldString": "  .diary-section { margin-top: 2rem; }\n  .section-head { margin-bottom: 1.25rem; }\n  .section-head h2 { margin: 0 0 0.25rem; font-size: 1.125rem; }\n  .section-sub { color: var(--muted, #62666d); margin: 0; font-size: 0.875rem; }\n</style>",
        "newString": "  /* Intro banner */\n  .intro-banner {\n    margin: 0 0 2.5rem;\n    padding: 1.5rem 1.75rem;\n    border: 1px solid var(--border, rgba(255, 255, 255, 0.06));\n    border-radius: 12px;\n    background:\n      radial-gradient(circle at 0% 0%, rgba(94, 106, 210, 0.08) 0%, transparent 60%),\n      var(--elevated, #141516);\n  }\n  .intro-kicker {\n    font-family: var(--mono, ui-monospace);\n    font-size: 0.6875rem;\n    text-transform: uppercase;\n    letter-spacing: 0.12em;\n    color: var(--accent, #5e6ad2);\n    margin: 0 0 0.5rem;\n  }\n  .intro-lede {\n    font-size: clamp(1rem, 2.4vw, 1.25rem);\n    letter-spacing: -0.01em;\n    margin: 0 0 0.75rem;\n    line-height: 1.35;\n    max-width: 60ch;\n  }\n  .intro-body {\n    color: var(--secondary, #8a8f98);\n    max-width: 64ch;\n    margin: 0 0 1rem;\n    line-height: 1.6;\n    font-size: 0.9375rem;\n  }\n  .intro-hint {\n    display: flex;\n    flex-wrap: wrap;\n    gap: 0.5rem 0.75rem;\n    align-items: center;\n    margin: 0;\n    color: var(--muted, #62666d);\n    font-size: 0.75rem;\n    font-family: var(--mono, ui-monospace);\n  }\n  .intro-hint kbd {\n    display: inline-block;\n    padding: 0.125rem 0.4rem;\n    border: 1px solid var(--border, rgba(255, 255, 255, 0.12));\n    border-radius: 4px;\n    background: rgba(255, 255, 255, 0.03);\n    color: var(--text, #f7f8f8);\n    font-size: 0.7rem;\n    font-family: inherit;\n    margin-right: 0.2rem;\n  }\n\n  /* Share button */\n  .share-btn {\n    margin-top: 0.75rem;\n    appearance: none;\n    display: inline-flex;\n    align-items: center;\n    gap: 0.5rem;\n    background: transparent;\n    border: 1px solid var(--border, rgba(255, 255, 255, 0.08));\n    color: var(--secondary, #8a8f98);\n    font-family: inherit;\n    font-size: 0.8125rem;\n    padding: 0.375rem 0.75rem;\n    border-radius: 6px;\n    cursor: pointer;\n    transition: border-color 120ms ease, color 120ms ease, background 120ms ease;\n  }\n  .share-btn:hover {\n    border-color: var(--accent, #5e6ad2);\n    color: var(--text, #f7f8f8);\n    background: rgba(94, 106, 210, 0.08);\n  }\n  .share-btn.is-copied {\n    border-color: #3fb950;\n    color: #3fb950;\n  }\n  .share-btn.is-copied .share-icon::before { content: \"✓\"; }\n  .share-btn.is-copied .share-icon { font-size: 0 0; }\n  .share-btn.is-copied .share-icon::before { font-size: 0.9rem; }\n\n  .diary-section { margin-top: 2rem; }\n  .section-head { margin-bottom: 1.25rem; }\n  .section-head h2 { margin: 0 0 0.25rem; font-size: 1.125rem; }\n  .section-sub { color: var(--muted, #62666d); margin: 0; font-size: 0.875rem; }\n</style>\n\n<script>\n  // Share button — copies current page URL with the active #cursor-N\n  // hash to clipboard. Shows ✓ \"Copied!\" for 1.5s.\n  (function attachShareButton() {\n    document.addEventListener(\"DOMContentLoaded\", () => {\n      const btn = document.querySelector<HTMLButtonElement>(\"[data-share]\");\n      if (!btn) return;\n      const label = btn.querySelector<HTMLElement>(\".share-label\");\n      const originalText = label?.textContent ?? \"\";\n      btn.addEventListener(\"click\", async () => {\n        const url = window.location.origin + window.location.pathname + window.location.hash;\n        try {\n          await navigator.clipboard.writeText(url);\n          btn.classList.add(\"is-copied\");\n          if (label) label.textContent = \"Copied!\";\n          window.setTimeout(() => {\n            btn.classList.remove(\"is-copied\");\n            if (label) label.textContent = originalText;\n          }, 1500);\n        } catch {\n          // clipboard API unavailable — do nothing\n        }\n      });\n    });\n  })();\n</script>",
        "originalFile": "---\nexport const prerender = false;\n\nimport Base from \"../../../layouts/Base.astro\";\nimport { getEmDashEntry } from \"emdash\";\nimport { getCurrentLang } from \"../../../utils/lang\";\nimport { loadFromR2 } from \"../../../lib/animations/load-from-r2.ts\";\nimport RawViewer from \"../../../components/animations/RawViewer.astro\";\nimport LiveRender from \"../../../components/animations/LiveRender.astro\";\nimport { env } from \"cloudflare:workers\";\n\nimport PlaceholderHero from \"../../../animations/placeholder/index.astro\";\nimport SnitchTrailHero from \"../../../animations/snitch-trail/index.astro\";\nimport MaraudersMapHero from \"../../../animations/marauders-map/index.astro\";\n\ninterface AnimationData {\n  title: string;\n  description?: string;\n  tags?: string[];\n  hero_component?: string;\n  primary_session_id?: string;\n  language?: string;\n}\n\nconst { slug } = Astro.params;\nif (!slug) return Astro.redirect(\"/hogwarts/quidditch/\");\n\nconst currentLang = getCurrentLang(Astro);\nconst isVi = currentLang === \"vi\";\n\nconst entryResult = await getEmDashEntry(\"animations\" as never, slug, { locale: \"en\" } as never);\nconst entry = (entryResult as unknown as { entry?: { id: string; data: AnimationData }; cacheHint?: unknown }).entry;\nconst cacheHint = (entryResult as unknown as { cacheHint?: unknown }).cacheHint;\nif (!entry) return new Response(\"Not Found\", { status: 404 });\nif (cacheHint) Astro.cache.set(cacheHint as never);\n\n// Plan B: R2-backed loader queries animation_sessions in D1, fetches the\n// latest published transcript.jsonl.gz from R2 MEDIA, and returns the same\n// shape the fixture did. Falls back to the on-disk fixture when R2 has no\n// data yet — preserves the placeholder during rollout.\nconst fixture = await loadFromR2(\n  slug,\n  env as Parameters<typeof loadFromR2>[1],\n);\n\n// Hero component registry — explicit map. Astro can't dynamically import\n// user-authored components from a runtime-computed path while keeping\n// tree-shaking correct.\nconst HERO_COMPONENTS = {\n  placeholder: PlaceholderHero,\n  \"snitch-trail\": SnitchTrailHero,\n  \"marauders-map\": MaraudersMapHero,\n} as const;\n\nconst Hero = (HERO_COMPONENTS as Record<string, typeof PlaceholderHero>)[slug];\n---\n\n<Base\n  title={`${entry.data.title} — Quidditch`}\n  description={entry.data.description ?? \"Animation playground artifact\"}\n  breadcrumbs={[\n    { label: \"Hogwarts\", href: \"/\" },\n    { label: isVi ? \"Sân Quidditch\" : \"Quidditch Pitch\", href: \"/hogwarts/quidditch/\" },\n    { label: entry.data.title },\n  ]}\n>\n  <article class=\"animation-page\">\n    <header class=\"anim-head\">\n      <h1>{entry.data.title}</h1>\n      {entry.data.description && <p class=\"anim-desc\">{entry.data.description}</p>}\n    </header>\n\n    <section class=\"hero-stage\" aria-label=\"Hero animation\">\n      {Hero ? <Hero /> : (\n        <div class=\"hero-missing\">\n          No hero component registered for slug \"{slug}\". Add it to\n          HERO_COMPONENTS in src/pages/hogwarts/quidditch/[slug].astro.\n        </div>\n      )}\n    </section>\n\n    <section class=\"intro-banner\" aria-label=\"About this recording\">\n      <div class=\"intro-inner\">\n        <p class=\"intro-kicker\">{isVi ? \"Bản ghi phiên dựng\" : \"Build recording\"}</p>\n        <h2 class=\"intro-lede\">\n          {isVi\n            ? \"Đây là toàn bộ phiên Claude Code dựng animation này.\"\n            : \"This is the full Claude Code session that built this animation.\"}\n        </h2>\n        <p class=\"intro-body\">\n          {isVi\n            ? \"Mỗi tool call, mỗi prompt, mỗi chỉnh sửa — được ghi lại. Bấm ▶ hoặc Space để xem nó tự tái dựng. Kéo thanh thời gian để nhảy đến bất kỳ khoảnh khắc nào. Khung Live Render sẽ cập nhật theo.\"\n            : \"Every tool call, every prompt, every edit — captured. Press ▶ or Space to watch it rebuild itself. Drag the timeline to jump to any moment. The Live Render pane updates as you scrub.\"}\n        </p>\n        <p class=\"intro-hint\" aria-hidden=\"true\">\n          <kbd>Space</kbd> <span>play/pause</span>\n          <kbd>←</kbd><kbd>→</kbd> <span>step</span>\n          <kbd>Shift</kbd>+<kbd>click</kbd> <span class=\"copy-hint\">a chapter to share it</span>\n        </p>\n      </div>\n    </section>\n\n    <section class=\"diary-section\" aria-label=\"Build diary\">\n      <div class=\"section-head\">\n        <h2>{isVi ? \"Nhật ký dựng\" : \"Build diary\"}</h2>\n        <p class=\"section-sub\">\n          {isVi\n            ? \"Toàn bộ phiên Claude Code. Dùng ← → hoặc click chapter để di chuyển.\"\n            : \"Full Claude Code session. Use ← → or click a chapter to scrub.\"}\n        </p>\n        <button type=\"button\" class=\"share-btn\" data-share aria-live=\"polite\">\n          <span class=\"share-icon\" aria-hidden=\"true\">🔗</span>\n          <span class=\"share-label\">{isVi ? \"Sao chép liên kết đến điểm này\" : \"Copy link to this moment\"}</span>\n        </button>\n      </div>\n      <RawViewer transcript={fixture.transcript} chapters={fixture.chapters} />\n    </section>\n\n    <LiveRender slug={slug} sourceHistory={fixture.sourceHistory} />\n  </article>\n</Base>\n\n<style>\n  .animation-page { max-width: 1080px; margin: 0 auto; padding: 2rem 1.5rem 4rem; }\n  .anim-head { margin-bottom: 2rem; }\n  .anim-head h1 { font-size: clamp(1.75rem, 4vw, 2.5rem); margin: 0 0 0.5rem; letter-spacing: -0.02em; }\n  .anim-desc { color: var(--secondary, #8a8f98); max-width: 60ch; margin: 0; line-height: 1.6; }\n\n  .hero-stage {\n    border: 1px solid var(--border, rgba(255, 255, 255, 0.06));\n    border-radius: 12px;\n    overflow: hidden;\n    margin-bottom: 3rem;\n  }\n  .hero-missing {\n    padding: 3rem;\n    text-align: center;\n    color: var(--muted, #62666d);\n    font-family: var(--mono, ui-monospace);\n    font-size: 0.875rem;\n  }\n\n  .diary-section { margin-top: 2rem; }\n  .section-head { margin-bottom: 1.25rem; }\n  .section-head h2 { margin: 0 0 0.25rem; font-size: 1.125rem; }\n  .section-sub { color: var(--muted, #62666d); margin: 0; font-size: 0.875rem; }\n</style>\n",
        "structuredPatch": [
          {
            "oldStart": 140,
            "oldLines": 8,
            "newStart": 140,
            "newLines": 119,
            "lines": [
              "     font-size: 0.875rem;",
              "   }",
              " ",
              "+  /* Intro banner */",
              "+  .intro-banner {",
              "+    margin: 0 0 2.5rem;",
              "+    padding: 1.5rem 1.75rem;",
              "+    border: 1px solid var(--border, rgba(255, 255, 255, 0.06));",
              "+    border-radius: 12px;",
              "+    background:",
              "+      radial-gradient(circle at 0% 0%, rgba(94, 106, 210, 0.08) 0%, transparent 60%),",
              "+      var(--elevated, #141516);",
              "+  }",
              "+  .intro-kicker {",
              "+    font-family: var(--mono, ui-monospace);",
              "+    font-size: 0.6875rem;",
              "+    text-transform: uppercase;",
              "+    letter-spacing: 0.12em;",
              "+    color: var(--accent, #5e6ad2);",
              "+    margin: 0 0 0.5rem;",
              "+  }",
              "+  .intro-lede {",
              "+    font-size: clamp(1rem, 2.4vw, 1.25rem);",
              "+    letter-spacing: -0.01em;",
              "+    margin: 0 0 0.75rem;",
              "+    line-height: 1.35;",
              "+    max-width: 60ch;",
              "+  }",
              "+  .intro-body {",
              "+    color: var(--secondary, #8a8f98);",
              "+    max-width: 64ch;",
              "+    margin: 0 0 1rem;",
              "+    line-height: 1.6;",
              "+    font-size: 0.9375rem;",
              "+  }",
              "+  .intro-hint {",
              "+    display: flex;",
              "+    flex-wrap: wrap;",
              "+    gap: 0.5rem 0.75rem;",
              "+    align-items: center;",
              "+    margin: 0;",
              "+    color: var(--muted, #62666d);",
              "+    font-size: 0.75rem;",
              "+    font-family: var(--mono, ui-monospace);",
              "+  }",
              "+  .intro-hint kbd {",
              "+    display: inline-block;",
              "+    padding: 0.125rem 0.4rem;",
              "+    border: 1px solid var(--border, rgba(255, 255, 255, 0.12));",
              "+    border-radius: 4px;",
              "+    background: rgba(255, 255, 255, 0.03);",
              "+    color: var(--text, #f7f8f8);",
              "+    font-size: 0.7rem;",
              "+    font-family: inherit;",
              "+    margin-right: 0.2rem;",
              "+  }",
              "+",
              "+  /* Share button */",
              "+  .share-btn {",
              "+    margin-top: 0.75rem;",
              "+    appearance: none;",
              "+    display: inline-flex;",
              "+    align-items: center;",
              "+    gap: 0.5rem;",
              "+    background: transparent;",
              "+    border: 1px solid var(--border, rgba(255, 255, 255, 0.08));",
              "+    color: var(--secondary, #8a8f98);",
              "+    font-family: inherit;",
              "+    font-size: 0.8125rem;",
              "+    padding: 0.375rem 0.75rem;",
              "+    border-radius: 6px;",
              "+    cursor: pointer;",
              "+    transition: border-color 120ms ease, color 120ms ease, background 120ms ease;",
              "+  }",
              "+  .share-btn:hover {",
              "+    border-color: var(--accent, #5e6ad2);",
              "+    color: var(--text, #f7f8f8);",
              "+    background: rgba(94, 106, 210, 0.08);",
              "+  }",
              "+  .share-btn.is-copied {",
              "+    border-color: #3fb950;",
              "+    color: #3fb950;",
              "+  }",
              "+  .share-btn.is-copied .share-icon::before { content: \"✓\"; }",
              "+  .share-btn.is-copied .share-icon { font-size: 0 0; }",
              "+  .share-btn.is-copied .share-icon::before { font-size: 0.9rem; }",
              "+",
              "   .diary-section { margin-top: 2rem; }",
              "   .section-head { margin-bottom: 1.25rem; }",
              "   .section-head h2 { margin: 0 0 0.25rem; font-size: 1.125rem; }",
              "   .section-sub { color: var(--muted, #62666d); margin: 0; font-size: 0.875rem; }",
              " </style>",
              "+",
              "+<script>",
              "+  // Share button — copies current page URL with the active #cursor-N",
              "+  // hash to clipboard. Shows ✓ \"Copied!\" for 1.5s.",
              "+  (function attachShareButton() {",
              "+    document.addEventListener(\"DOMContentLoaded\", () => {",
              "+      const btn = document.querySelector<HTMLButtonElement>(\"[data-share]\");",
              "+      if (!btn) return;",
              "+      const label = btn.querySelector<HTMLElement>(\".share-label\");",
              "+      const originalText = label?.textContent ?? \"\";",
              "+      btn.addEventListener(\"click\", async () => {",
              "+        const url = window.location.origin + window.location.pathname + window.location.hash;",
              "+        try {",
              "+          await navigator.clipboard.writeText(url);",
              "+          btn.classList.add(\"is-copied\");",
              "+          if (label) label.textContent = \"Copied!\";",
              "+          window.setTimeout(() => {",
              "+            btn.classList.remove(\"is-copied\");",
              "+            if (label) label.textContent = originalText;",
              "+          }, 1500);",
              "+        } catch {",
              "+          // clipboard API unavailable — do nothing",
              "+        }",
              "+      });",
              "+    });",
              "+  })();",
              "+</script>"
            ]
          }
        ],
        "userModified": false,
        "replaceAll": false
      },
      "postStateChecksum": {}
    }
  16. #15 Bash · bun run typecheck 2>&1 | grep -E "quidditch/|animations/" | head -5 echo "(empty = clean)"
    {
      "cursor": 15,
      "ts": "2026-04-16T11:32:23Z",
      "kind": "tool",
      "tool": "Bash",
      "input": {
        "command": "bun run typecheck 2>&1 | grep -E \"quidditch/|animations/\" | head -5\necho \"(empty = clean)\"",
        "description": "Typecheck"
      },
      "output": {
        "stdout": "\u001b[96msrc/pages/hogwarts/quidditch/[slug].astro\u001b[0m:\u001b[93m10\u001b[0m:\u001b[93m21\u001b[0m - \u001b[91merror\u001b[0m\u001b[90m ts(2307): \u001b[0mCannot find module 'cloudflare:workers' or its corresponding type declarations.\n(empty = clean)",
        "stderr": "",
        "interrupted": false,
        "isImage": false,
        "noOutputExpected": false
      },
      "postStateChecksum": {}
    }
  17. #16 Bash · pkill -f "astro dev" 2>&1 ; sleep 2 git add src/animations/snitch-trail/index.astro src/animations/marauders-map/ src/pages/animation-preview/ "src/pages/hogwarts/quidditch/[slug].astro" seed/seed.json git commit -m "$(cat <<'EOF' feat(animations): polish snitch trail + add marauders map + intro banner + share button Four owner-sprint improvements shipping together: SNITCH TRAIL — visibly punchier. Smoother cubic-bezier flight curve with two control points for a true figure-eight feel. 10-orb trail (up from 5) with per-orb glow + radial gradient fill for a real shimmer. Fuller wing detail (inner highlight ellipse). Outer halo + gradient + white spark highlight on body. Star-field behind for atmosphere. Path guide gently pulses. The hero finally reads "golden snitch" on first glance. MARAUDERS MAP — new second animation. Three-panel parchment unfolds with CSS 3D rotateY, ink strokes draw the castle corridors with stroke-dashoffset, labels fade in, then a trail of 10 footprints appears one-by-one crossing from Gryffindor Tower to the Dungeons. "Harry Potter" name tag appears with the lead footprint. Pure SVG + CSS, no JS. INTRO BANNER — every per-animation page now leads with a framed intro: "This is the full Claude Code session that built this animation." Keyboard-shortcut hints visible up front (Space / ← → / Shift+click). Bilingual (vi/en). SHARE BUTTON — "Copy link to this moment" under the diary head copies the current URL with its #cursor-N hash. Click it after scrubbing to share that exact frame. ✓ Copied! confirmation for 1.5s. Co-Authored-By: deathemperor <loc.truongh@gmail.com> EOF )" 2>&1 | tail -3 git push origin feat/quidditch-owner-sprint 2>&1 | tail -3 gh pr create --base main --head feat/quidditch-owner-sprint --title "feat: polish snitch + add marauders map + intro + share" --body "$(cat <<'EOF' ## Owner-sprint changes **Snitch Trail polish** — visibly better first-impression. Smoother flight curve, 10-orb trail, full gradient fills, star-field, fuller wings, brighter glow stack. **Marauder's Map** — new second animation on the playground. Parchment unfolds in 3 panels, ink draws in, 10 footprints appear one-by-one. **Intro banner** — every per-animation page leads with a framed explanation + keyboard shortcut hints. **Share button** — "Copy link to this moment" copies the current URL with its \`#cursor-N\` hash. ## Test plan - [x] Both animations render locally - [x] Landing grid shows 3 tiles (placeholder, snitch-trail, marauders-map) - [x] Remote D1 has marauders-map row - [ ] Post-deploy verification 🤖 Generated with [Claude Code](https://claude.com/claude-code) EOF )" 2>&1 | tail -3 sleep 3 gh pr merge --merge 2>&1 | tail -3
    {
      "cursor": 16,
      "ts": "2026-04-16T11:33:06Z",
      "kind": "tool",
      "tool": "Bash",
      "input": {
        "command": "pkill -f \"astro dev\" 2>&1 ; sleep 2\ngit add src/animations/snitch-trail/index.astro src/animations/marauders-map/ src/pages/animation-preview/ \"src/pages/hogwarts/quidditch/[slug].astro\" seed/seed.json\n\ngit commit -m \"$(cat <<'EOF'\nfeat(animations): polish snitch trail + add marauders map + intro banner + share button\n\nFour owner-sprint improvements shipping together:\n\nSNITCH TRAIL — visibly punchier. Smoother cubic-bezier flight curve with\ntwo control points for a true figure-eight feel. 10-orb trail (up from\n5) with per-orb glow + radial gradient fill for a real shimmer. Fuller\nwing detail (inner highlight ellipse). Outer halo + gradient + white\nspark highlight on body. Star-field behind for atmosphere. Path guide\ngently pulses. The hero finally reads \"golden snitch\" on first glance.\n\nMARAUDERS MAP — new second animation. Three-panel parchment unfolds\nwith CSS 3D rotateY, ink strokes draw the castle corridors with\nstroke-dashoffset, labels fade in, then a trail of 10 footprints\nappears one-by-one crossing from Gryffindor Tower to the Dungeons.\n\"Harry Potter\" name tag appears with the lead footprint. Pure SVG +\nCSS, no JS.\n\nINTRO BANNER — every per-animation page now leads with a framed intro:\n\"This is the full Claude Code session that built this animation.\"\nKeyboard-shortcut hints visible up front (Space / ← → / Shift+click).\nBilingual (vi/en).\n\nSHARE BUTTON — \"Copy link to this moment\" under the diary head copies\nthe current URL with its #cursor-N hash. Click it after scrubbing to\nshare that exact frame. ✓ Copied! confirmation for 1.5s.\n\nCo-Authored-By: deathemperor <loc.truongh@gmail.com>\nEOF\n)\" 2>&1 | tail -3\n\ngit push origin feat/quidditch-owner-sprint 2>&1 | tail -3\n\ngh pr create --base main --head feat/quidditch-owner-sprint --title \"feat: polish snitch + add marauders map + intro + share\" --body \"$(cat <<'EOF'\n## Owner-sprint changes\n\n**Snitch Trail polish** — visibly better first-impression. Smoother flight curve, 10-orb trail, full gradient fills, star-field, fuller wings, brighter glow stack.\n\n**Marauder's Map** — new second animation on the playground. Parchment unfolds in 3 panels, ink draws in, 10 footprints appear one-by-one.\n\n**Intro banner** — every per-animation page leads with a framed explanation + keyboard shortcut hints.\n\n**Share button** — \"Copy link to this moment\" copies the current URL with its \\`#cursor-N\\` hash.\n\n## Test plan\n- [x] Both animations render locally\n- [x] Landing grid shows 3 tiles (placeholder, snitch-trail, marauders-map)\n- [x] Remote D1 has marauders-map row\n- [ ] Post-deploy verification\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\nEOF\n)\" 2>&1 | tail -3\n\nsleep 3\ngh pr merge --merge 2>&1 | tail -3",
        "timeout": 120000,
        "description": "Commit + push + PR + merge"
      },
      "output": {
        "stdout": "[feat/quidditch-owner-sprint 6826658d] feat(animations): polish snitch trail + add marauders map + intro banner + share button\n 5 files changed, 547 insertions(+), 58 deletions(-)\n create mode 100644 src/animations/marauders-map/index.astro\nremote: \nTo github.com:deathemperor/pensieve.git\n * [new branch]        feat/quidditch-owner-sprint -> feat/quidditch-owner-sprint\nhttps://github.com/deathemperor/pensieve/pull/12",
        "stderr": "",
        "interrupted": false,
        "isImage": false,
        "noOutputExpected": false
      },
      "postStateChecksum": {}
    }

Live render

No source history available for this session.

This session's build predates the source-history pipeline (or the transcript touched no files under src/animations/marauders-map/). Once a new session is published, the live render will appear here.

Snapshots

2 renders captured during the build. Click to jump.

  1. marauders-map at cursor 3#3
  2. marauders-map at cursor 12#12

Practice Try it yourself

Edit the code on the left, see the result on the right. Your edits are scratch-only — reload resets to the last saved version. Auto-runs when you pause typing; hit Cmd+Enter (or the Run button) to force it.

No source history yet — practice pad loads once this animation has a published recording with source-history captured.
Phù phép bởi CC