BLK-105Blocks

Flashlight Hidden-Word Hunt — Spotlight Effect in React and CSS

A hide-and-seek UI for in-store and shopping-street campaigns where visitors hunt for a secret word on the site. Moving a mouse or finger over the dark scene drags a 110px spotlight with it; hold it over a hidden letter for 400ms and a progress ring fills, then the letter flies into one of five boxes at the top. Each round picks five of eight possible spots at random, so memorising positions does not help. When all five are found the timer stops and you get your time, a play-again button, a 1200×630 share image with the word masked out, and a ready-to-copy share line. The image is drawn on the device and nothing is sent anywhere. The default scene is an original SVG of a sweet shop on Halloween night; pass your own scene, word and spots as props to reuse it for year-end or Christmas campaigns. Arrow keys work too, and prefers-reduced-motion turns off the easing and the flying letters. No dependencies.

Preview

BLK-105動き・季節キャンペーン

夜の駄菓子屋に隠れた5文字を、懐中電灯で探す

  1. 未発見
  2. 未発見
  3. 未発見
  4. 未発見
  5. 未発見

経過0.0秒

なぞると計時が始まります

なぞると懐中電灯が照らします。まずは軒下の灯りの横から

店舗・商店街の「サイト内で合言葉を探す」キャンペーンに差し込める探索UIです。場面の上をなぞると光の輪がついてきて、文字の上で少し止まると上のマスに入ります。文字の場所は毎回変わります。

マウス・指: 場面の上をなぞる(指を離すと判定が外れます)/キーボード: 場面を選んで矢印キー、Shift で大きく動かす

結果を共有する

5文字そろうと、秒数と共有用の画像(1200×630)がここに出ます。画像は端末の中で作り、どこにも送信しません。

共有用の画像(暗い場面の一部・秒数・作品名・URL)

年末やクリスマスの企画に差し替える

場面の SVG・文字・候補位置を渡せば、同じ仕組みで別の季節に使えます。

keyword探させる文字列。既定は5文字
scene場面の SVG(viewBox 960×640)
spots候補位置(%)。文字数より多く
lantern最初から灯る明かりと、毎回使う候補
radius光の輪の半径。既定 110px
dwellMs取得までの時間。既定 400ms
title / shareUrl共有画像とコピー文に載せる作品名と URL
year-end-campaign.tsx
import FlashlightReveal from "@/components/halloween-flashlight-reveal";
import { WinterArcadeScene } from "./winter-arcade-scene"; // viewBox 960×640 の SVG

export default function YearEndCampaign() {
  return (
    <FlashlightReveal
      keyword="ふくびきけん"
      scene={<WinterArcadeScene />}
      spots={[
        { x: 22, y: 48 }, { x: 34, y: 72, rotate: 4 }, { x: 51, y: 40 },
        { x: 63, y: 76 }, { x: 78, y: 52 }, { x: 88, y: 30, rotate: -6 },
        { x: 12, y: 80 }, { x: 45, y: 20 }, { x: 70, y: 22 },
      ]}
      lantern={{ x: 14, y: 40, spot: 0, radius: 0.11 }}
      radius={110}
      dwellMs={400}
      title="年末の商店街で合言葉さがし"
      shareUrl="https://example.com/campaign/year-end"
    />
  );
}
Share on X

Read the full description

A hide-and-seek UI for in-store and shopping-street campaigns where visitors hunt for a secret word on the site. Moving a mouse or finger over the dark scene drags a 110px spotlight with it; hold it over a hidden letter for 400ms and a progress ring fills, then the letter flies into one of five boxes at the top. Each round picks five of eight possible spots at random, so memorising positions does not help. When all five are found the timer stops and you get your time, a play-again button, a 1200×630 share image with the word masked out, and a ready-to-copy share line. The image is drawn on the device and nothing is sent anywhere. The default scene is an original SVG of a sweet shop on Halloween night; pass your own scene, word and spots as props to reuse it for year-end or Christmas campaigns. Arrow keys work too, and prefers-reduced-motion turns off the easing and the flying letters. No dependencies.

Added:
2026-10-27
Dependencies:
None
tags
#block #motion #spotlight #flashlight #hidden-word #campaign #halloween #seasonal #svg-mask #canvas #share-image #pointer-events #keyboard #reduced-motion #hairline #no-dependency
"use client";

import {
  useCallback,
  useEffect,
  useId,
  useRef,
  useState,
  useSyncExternalStore,
  type KeyboardEvent,
  type PointerEvent,
  type ReactNode,
} from "react";

/**
 * 懐中電灯エフェクトの隠しキーワード探し
 *
 * 差別化: 既存の円形の演出(theme-switch-reveal の配色切り替え)は一度きりの画面遷移で、3D や検索の部品(product-configurator-3d・command-palette-search)は眺める・選ぶための道具。
 * 本ブロックは光の輪で場面を探る遊びとして、判定・進捗リング・計時・結果画像までを「なぞる」1操作で完結させた。
 * 英字の小見出し・見出しの「。」止め・末尾の矢印リンク・角丸・読み込み時の出現アニメは置かず、hairline 罫線と余白で組んでいる。
 *
 * 汎用技法メモ:
 * 1. **暗闇は SVG の mask で抜く**。場面の上に紺の矩形(不透明度 0.92 = 明るさ 8%)を重ね、mask の中の円を transform(translate)だけで動かす。
 *    円の塗りは radialGradient で、中心から (半径 − 24px) までは黒の不透明、縁で透明へ落とす。stop-opacity で抜くので、
 *    懐中電灯と軒下の灯りの2つの円が重なっても互いの縁で塗りつぶし合わない(白→黒の色の補間で作ると後から描いた円の縁が前の円を消す)。
 * 2. **追従は時間基準の補間**。毎フレーム 1 − exp(−dt/60ms) の割合で目標へ寄せるので、フレームが落ちる端末でも同じ速さで追いつく。
 *    動き終わって判定中の文字も無ければ requestAnimationFrame を止め、一覧の小さなプレビューでも回り続けない。
 * 3. **判定は文字の中心との距離**。輪の半径の半分以内(小さな画面では文字の大きさ以上)に入った瞬間に輪郭を灯し、
 *    400ms 留まると取得。進捗リングの stroke-dashoffset は React の state を通さず ref で直接書き換える(毎フレームの再描画を避ける)。
 * 4. **飛ぶ動きは Web Animations の transform だけ**。場面の文字と上部のマスの getBoundingClientRect の差を translate と scale にする。
 * 5. **共有画像は端末の中で作る**。場面の SVG を XMLSerializer で文字列にして Blob URL から canvas に描き、暗くした上で丸く抜く。
 *    隠し文字は SVG の外(HTML)に置いているので画像に写らない。合言葉は「?」で伏せる。どこへも送信しない。
 */

const AMBER = "#d97706";
const NAVY = "#151d36";
const NAVY_DEEP = "#0d1328";
const WALL = "#1c2647";
const ORANGE = "#e8873a";
const CREAM = "#efe6d2";
const GREEN = "#7d9078";
const DARK = "#05070f";

const SCENE_W = 960;
const SCENE_H = 640;

export type FlashlightSpot = { x: number; y: number; rotate?: number };
export type FlashlightLantern = { x: number; y: number; spot: number; radius?: number };

export type FlashlightRevealProps = {
  /** 探させる文字列。1文字ずつ候補位置へ散らす */
  keyword?: string;
  /** 場面の SVG。viewBox は 960×640(3:2)で描く */
  scene?: ReactNode;
  /** 文字の候補位置(場面に対する %)。keyword の文字数より多く用意する */
  spots?: FlashlightSpot[];
  /** 最初から灯っている明かり(%)。spot に指定した候補は毎回かならず使い、その文字の端だけを照らす */
  lantern?: FlashlightLantern;
  /** 光の輪の半径(px)。狭い画面では場面の幅に合わせて小さくなる */
  radius?: number;
  /** 文字の上に留まって取得するまでの時間(ms) */
  dwellMs?: number;
  /** 共有画像に載せる作品名 */
  title?: string;
  /** 共有画像とコピー文に載せる作品ページの URL */
  shareUrl?: string;
  /** 見出しの要素。ページの主見出しにするときだけ "h1" */
  headingTag?: "h1" | "h2" | "p";
};

const DEFAULT_SPOTS: FlashlightSpot[] = [
  { x: 26.9, y: 46.3, rotate: -4 },
  { x: 35.9, y: 70.6, rotate: 3 },
  { x: 55.2, y: 46.9, rotate: -2 },
  { x: 67.2, y: 76.3, rotate: 2 },
  { x: 86.3, y: 72.5, rotate: -6 },
  { x: 7.3, y: 81.6, rotate: 5 },
  { x: 92.1, y: 41.4, rotate: 8 },
  { x: 56.3, y: 22, rotate: 0 },
];

const DEFAULT_LANTERN: FlashlightLantern = { x: 17.7, y: 44.1, spot: 0, radius: 0.11 };
const START_LIGHT = { x: 40, y: 23 };

const DEFAULT_URL = "https://designs.first-ch.com/d/halloween-flashlight-reveal";

/** 既定の場面: ハロウィンの夜の駄菓子屋。線幅 2px・紺地に橙・生成り・くすんだ緑 */
export function DagashiNightScene() {
  const line = { stroke: CREAM, strokeWidth: 2, vectorEffect: "non-scaling-stroke" as const, strokeLinejoin: "round" as const, strokeLinecap: "round" as const };
  const jars = (y: number, xs: number[], w = 46) =>
    xs.map((x, i) => (
      <g key={`${y}-${x}`}>
        <rect x={x} y={y - 54} width={w} height={54} rx={8} fill={NAVY} {...line} />
        <rect x={x + 6} y={y - 62} width={w - 12} height={9} fill={GREEN} {...line} />
        <circle cx={x + w * 0.3} cy={y - 16} r={6} fill={i % 2 ? ORANGE : GREEN} />
        <circle cx={x + w * 0.62} cy={y - 22} r={6} fill={i % 2 ? CREAM : ORANGE} />
        <circle cx={x + w * 0.45} cy={y - 34} r={5} fill={i % 2 ? GREEN : CREAM} />
      </g>
    ));
  return (
    <svg
      data-scene=""
      xmlns="http://www.w3.org/2000/svg"
      viewBox={`0 0 ${SCENE_W} ${SCENE_H}`}
      width={SCENE_W}
      height={SCENE_H}
      preserveAspectRatio="xMidYMid slice"
      style={{ display: "block", width: "100%", height: "100%" }}
    >
      <rect width={SCENE_W} height={SCENE_H} fill={NAVY} />
      {[
        [60, 60], [140, 34], [250, 70], [560, 40], [700, 22], [910, 160], [780, 190], [40, 210], [940, 40],
      ].map(([x, y]) => (
        <circle key={`${x}-${y}`} cx={x} cy={y} r={2} fill={CREAM} opacity={0.7} />
      ))}
      <circle cx={830} cy={92} r={36} fill={CREAM} />
      <circle cx={846} cy={82} r={32} fill={NAVY} />
      {[
        [596, 66, 1],
        [636, 44, 0.8],
      ].map(([x, y, s]) => (
        <path
          key={x}
          transform={`translate(${x} ${y}) scale(${s})`}
          d="M0 6 Q8 -6 16 4 Q20 -2 24 4 Q28 -2 32 4 Q40 -6 48 6 Q38 4 32 14 Q28 8 24 12 Q20 8 16 14 Q10 4 0 6 Z"
          fill={NAVY_DEEP}
          {...line}
        />
      ))}

      <rect x={0} y={560} width={SCENE_W} height={80} fill={NAVY_DEEP} />
      <line x1={0} y1={560} x2={SCENE_W} y2={560} {...line} stroke={GREEN} />
      {[80, 220, 380, 540, 700, 860].map((x) => (
        <line key={x} x1={x} y1={600} x2={x + 60} y2={600} {...line} stroke={GREEN} />
      ))}

      {/* 建物 */}
      <rect x={120} y={176} width={640} height={384} fill={WALL} {...line} />
      <path d="M96 182 L162 108 L718 108 L784 182 Z" fill={NAVY_DEEP} {...line} />
      <line x1={140} y1={134} x2={740} y2={134} {...line} stroke={GREEN} />
      <line x1={118} y1={158} x2={762} y2={158} {...line} stroke={GREEN} />
      <rect x={322} y={116} width={284} height={50} fill={WALL} {...line} stroke={ORANGE} />
      <g transform="translate(384 141)">
        <ellipse rx={26} ry={13} fill={ORANGE} {...line} />
        <path d="M-26 0 L-42 -12 L-42 12 Z M26 0 L42 -12 L42 12 Z" fill={ORANGE} {...line} />
        <line x1={-8} y1={-12} x2={-8} y2={12} {...line} />
        <line x1={8} y1={-12} x2={8} y2={12} {...line} />
      </g>
      {/* 屋根の猫 */}
      <g transform="translate(690 108)">
        <path d="M-18 0 Q-22 -26 -6 -30 L6 -30 Q20 -26 16 0 Z" fill={NAVY_DEEP} {...line} />
        <path d="M-12 -30 L-14 -46 L-4 -38 L4 -38 L12 -46 L10 -30" fill={NAVY_DEEP} {...line} />
        <path d="M16 -4 Q34 -2 30 -22" fill="none" {...line} />
        <circle cx={-4} cy={-34} r={2} fill={ORANGE} />
        <circle cx={4} cy={-34} r={2} fill={ORANGE} />
      </g>

      {/* 縞の庇 */}
      {Array.from({ length: 14 }, (_, i) => (
        <rect key={i} x={104 + i * 48} y={194} width={48} height={34} fill={i % 2 ? NAVY_DEEP : ORANGE} />
      ))}
      <rect x={104} y={194} width={672} height={34} fill="none" {...line} />
      <path
        d={`M104 228 ${Array.from({ length: 14 }, (_, i) => `Q${128 + i * 48} 246 ${152 + i * 48} 228`).join(" ")}`}
        fill={NAVY_DEEP}
        {...line}
      />

      {/* 左の飾り棚 */}
      <rect x={150} y={252} width={290} height={270} fill={NAVY_DEEP} {...line} />
      {[330, 410, 488].map((y) => (
        <line key={y} x1={150} y1={y} x2={440} y2={y} {...line} />
      ))}
      {jars(330, [168, 316, 382])}
      {jars(410, [168, 234, 300, 366], 44)}
      {jars(488, [168, 230, 392], 40)}
      <line x1={295} y1={496} x2={295} y2={516} {...line} />

      {/* 入口と暖簾 */}
      <rect x={470} y={252} width={270} height={308} fill={NAVY_DEEP} {...line} />
      {[0, 1, 2].map((i) => (
        <rect key={i} x={478 + i * 86} y={252} width={80} height={92} fill={GREEN} {...line} />
      ))}
      <circle cx={605} cy={300} r={18} fill="none" {...line} />
      <rect x={570} y={430} width={160} height={130} fill={WALL} {...line} />
      <line x1={560} y1={430} x2={740} y2={430} {...line} stroke={ORANGE} />
      <rect x={590} y={404} width={34} height={26} fill={ORANGE} {...line} />
      <rect x={682} y={410} width={30} height={20} fill={GREEN} {...line} />

      {/* 軒下の提灯 */}
      <line x1={170} y1={240} x2={170} y2={252} {...line} />
      <rect x={156} y={250} width={28} height={8} fill={NAVY_DEEP} {...line} />
      <ellipse cx={170} cy={282} rx={22} ry={26} fill={ORANGE} {...line} />
      {[-10, 0, 10].map((d) => (
        <line key={d} x1={150} y1={282 + d} x2={190} y2={282 + d} {...line} stroke={NAVY_DEEP} />
      ))}
      <rect x={156} y={306} width={28} height={8} fill={NAVY_DEEP} {...line} />

      {/* 外の木箱 */}
      <rect x={28} y={478} width={86} height={82} fill={NAVY_DEEP} {...line} />
      <line x1={28} y1={498} x2={114} y2={498} {...line} />
      <line x1={28} y1={544} x2={114} y2={544} {...line} />

      {/* かぼちゃ */}
      {[
        [470, 538, 40, 26],
        [910, 542, 30, 20],
      ].map(([x, y, rx, ry]) => (
        <g key={x}>
          <ellipse cx={x} cy={y} rx={rx} ry={ry} fill={ORANGE} {...line} />
          <ellipse cx={x} cy={y} rx={rx * 0.45} ry={ry} fill="none" {...line} stroke={NAVY_DEEP} />
          <path d={`M${x} ${y - ry} q2 -10 8 -12`} fill="none" {...line} stroke={GREEN} />
          <path
            d={`M${x - rx * 0.5} ${y - 4} l${rx * 0.18} ${-ry * 0.4} l${rx * 0.18} ${ry * 0.4} Z M${x + rx * 0.14} ${y - 4} l${rx * 0.18} ${-ry * 0.4} l${rx * 0.18} ${ry * 0.4} Z M${x - rx * 0.45} ${y + ry * 0.3} q${rx * 0.45} ${ry * 0.4} ${rx * 0.9} 0`}
            fill={NAVY_DEEP}
            {...line}
            stroke={NAVY_DEEP}
          />
        </g>
      ))}

      {/* ガチャの機械 */}
      <rect x={792} y={500} width={72} height={60} fill={ORANGE} {...line} />
      <circle cx={828} cy={530} r={9} fill={NAVY_DEEP} {...line} />
      <circle cx={828} cy={464} r={38} fill={NAVY_DEEP} {...line} />
      {[
        [808, 488, ORANGE],
        [826, 492, GREEN],
        [846, 486, CREAM],
      ].map(([x, y, c]) => (
        <circle key={x as number} cx={x as number} cy={y as number} r={7} fill={c as string} />
      ))}

      {/* おばけ */}
      <path
        d="M856 300 Q852 220 884 214 Q916 220 912 300 L902 290 L892 302 L882 290 L872 302 L864 290 Z"
        fill={NAVY_DEEP}
        {...line}
      />
      <circle cx={872} cy={236} r={3} fill={CREAM} />
      <circle cx={894} cy={236} r={3} fill={CREAM} />
    </svg>
  );
}

const reduceStore = {
  subscribe: (cb: () => void) => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    mq.addEventListener("change", cb);
    return () => mq.removeEventListener("change", cb);
  },
  get: () => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
};
const noopSubscribe = () => () => {};
const serverFalse = () => false;
const clientTrue = () => true;

type Round = number[]; // 文字の順番 → 候補位置の番号

function makeRound(count: number, spotCount: number, fixed: number): Round {
  const others = Array.from({ length: spotCount }, (_, i) => i).filter((i) => i !== fixed);
  for (let i = others.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [others[i], others[j]] = [others[j], others[i]];
  }
  const chosen = [fixed, ...others.slice(0, count - 1)];
  for (let i = chosen.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [chosen[i], chosen[j]] = [chosen[j], chosen[i]];
  }
  return chosen;
}

type Flyer = { id: number; index: number; char: string; x: number; y: number; dx: number; dy: number; scale: number; size: number };

const loadImage = (src: string) =>
  new Promise<HTMLImageElement>((resolve, reject) => {
    const img = new Image();
    img.onload = () => resolve(img);
    img.onerror = reject;
    img.src = src;
  });

async function copyText(text: string) {
  try {
    await navigator.clipboard.writeText(text);
    return true;
  } catch {
    const ta = document.createElement("textarea");
    ta.value = text;
    ta.setAttribute("readonly", "");
    ta.style.position = "fixed";
    ta.style.opacity = "0";
    document.body.appendChild(ta);
    ta.select();
    const ok = document.execCommand("copy");
    ta.remove();
    return ok;
  }
}

const PROPS_EXAMPLE = `import FlashlightReveal from "@/components/halloween-flashlight-reveal";
import { WinterArcadeScene } from "./winter-arcade-scene"; // viewBox 960×640 の SVG

export default function YearEndCampaign() {
  return (
    <FlashlightReveal
      keyword="ふくびきけん"
      scene={<WinterArcadeScene />}
      spots={[
        { x: 22, y: 48 }, { x: 34, y: 72, rotate: 4 }, { x: 51, y: 40 },
        { x: 63, y: 76 }, { x: 78, y: 52 }, { x: 88, y: 30, rotate: -6 },
        { x: 12, y: 80 }, { x: 45, y: 20 }, { x: 70, y: 22 },
      ]}
      lantern={{ x: 14, y: 40, spot: 0, radius: 0.11 }}
      radius={110}
      dwellMs={400}
      title="年末の商店街で合言葉さがし"
      shareUrl="https://example.com/campaign/year-end"
    />
  );
}`;

const PROPS_ROWS: [string, string, string][] = [
  ["keyword", "string", "探させる文字列。既定は5文字"],
  ["scene", "ReactNode", "場面の SVG(viewBox 960×640)"],
  ["spots", "{ x, y, rotate? }[]", "候補位置(%)。文字数より多く"],
  ["lantern", "{ x, y, spot, radius? }", "最初から灯る明かりと、毎回使う候補"],
  ["radius", "number", "光の輪の半径。既定 110px"],
  ["dwellMs", "number", "取得までの時間。既定 400ms"],
  ["title / shareUrl", "string", "共有画像とコピー文に載せる作品名と URL"],
];

export default function FlashlightReveal({
  keyword = "おばけだぞ",
  scene,
  spots = DEFAULT_SPOTS,
  lantern = DEFAULT_LANTERN,
  radius = 110,
  dwellMs = 400,
  title = "夜の駄菓子屋の隠しキーワード探し",
  shareUrl = DEFAULT_URL,
  headingTag,
}: FlashlightRevealProps) {
  const Heading = headingTag === "h1" ? "h1" : "h2";
  const chars = Array.from(keyword);
  const count = chars.length;
  const uid = useId().replace(/[^a-zA-Z0-9_-]/g, "");
  const reduce = useSyncExternalStore(reduceStore.subscribe, reduceStore.get, serverFalse);
  const hydrated = useSyncExternalStore(noopSubscribe, clientTrue, serverFalse);

  const [size, setSize] = useState({ w: 0, h: 0 });
  const [round, setRound] = useState<Round>(() => makeRound(count, spots.length, lantern.spot));
  const [found, setFound] = useState<boolean[]>(() => chars.map(() => false));
  const [landed, setLanded] = useState<boolean[]>(() => chars.map(() => false));
  const [phase, setPhase] = useState<"idle" | "playing" | "done">("idle");
  const [elapsed, setElapsed] = useState(0);
  const [active, setActive] = useState<number | null>(null);
  const [flyers, setFlyers] = useState<Flyer[]>([]);
  const [share, setShare] = useState<{ url: string; sec: string } | null>(null);
  const [copied, setCopied] = useState<"text" | "code" | null>(null);
  const [message, setMessage] = useState("");

  const rootRef = useRef<HTMLElement>(null);
  const sceneRef = useRef<HTMLDivElement>(null);
  const artRef = useRef<HTMLDivElement>(null);
  const timeRef = useRef<HTMLSpanElement>(null);
  const maskLightRef = useRef<SVGCircleElement>(null);
  const ringRef = useRef<SVGGElement>(null);
  const arcRef = useRef<SVGCircleElement | null>(null);
  const letterRefs = useRef<(SVGTextElement | null)[]>([]);
  const maskRevealRef = useRef<SVGCircleElement>(null);
  const slotRefs = useRef<(HTMLSpanElement | null)[]>([]);

  const live = useRef({
    target: { x: 0, y: 0 },
    cur: { x: 0, y: 0 },
    placed: false,
    raf: 0,
    last: 0,
    progress: 0,
    active: null as number | null,
    judge: false,
    start: 0,
    phase: "idle" as "idle" | "playing" | "done",
    found: [] as boolean[],
    round: [] as Round,
    size: { w: 0, h: 0 },
    reduce: false,
    flyerId: 0,
  });

  const w = size.w;
  const R = w ? Math.round(Math.min(radius, Math.max(52, w * 0.15))) : radius;
  const edge = Math.min(24, R * 0.3);
  const letterSize = w ? Math.max(15, Math.round(w * 0.032)) : 15;
  const ringBox = Math.round(letterSize * 2.4);
  const arcR = ringBox / 2 - 3;
  const arcLen = 2 * Math.PI * arcR;
  const lanternR = (lantern.radius ?? 0.11) * w;

  // 最新の値を rAF・イベントから読めるように写す(描画中には ref を読まない)
  useEffect(() => {
    const s = live.current;
    s.found = found;
    s.round = round;
    s.phase = phase;
    s.reduce = reduce;
  }, [found, round, phase, reduce]);

  const applyLight = useCallback(() => {
    const { cur } = live.current;
    const t = `translate(${cur.x.toFixed(1)} ${cur.y.toFixed(1)})`;
    maskLightRef.current?.setAttribute("transform", t);
    maskRevealRef.current?.setAttribute("transform", t);
    ringRef.current?.setAttribute("transform", t);
  }, []);

  useEffect(() => {
    const el = sceneRef.current;
    if (!el) return;
    const ro = new ResizeObserver(() => {
      const next = { w: el.offsetWidth, h: el.offsetHeight };
      const s = live.current;
      if (!s.placed) {
        s.target = { x: (START_LIGHT.x / 100) * next.w, y: (START_LIGHT.y / 100) * next.h };
        s.cur = { ...s.target };
      } else if (s.size.w) {
        const k = next.w / s.size.w;
        s.target = { x: s.target.x * k, y: s.target.y * k };
        s.cur = { x: s.cur.x * k, y: s.cur.y * k };
      }
      s.size = next;
      setSize(next);
      applyLight();
    });
    ro.observe(el);
    return () => ro.disconnect();
  }, [applyLight]);

  useEffect(() => applyLight(), [applyLight, R, w, hydrated]);

  // 計時(表示は 0.1 秒刻み)
  useEffect(() => {
    if (phase !== "playing") return;
    const id = window.setInterval(() => setElapsed(performance.now() - live.current.start), 100);
    return () => window.clearInterval(id);
  }, [phase]);

  useEffect(() => () => cancelAnimationFrame(live.current.raf), []);

  useEffect(
    () => () => {
      if (share) URL.revokeObjectURL(share.url);
    },
    [share],
  );

  const setArc = useCallback(
    (p: number) => {
      if (arcRef.current) arcRef.current.style.strokeDashoffset = String(arcLen * (1 - Math.min(1, p)));
    },
    [arcLen],
  );
  const tickRef = useRef<(t: number) => void>(() => {});

  const makeShare = useCallback(
    async (sec: string) => {
      const art = artRef.current?.querySelector("svg");
      const root = rootRef.current;
      if (!art || !root) return;
      const clone = art.cloneNode(true) as SVGSVGElement;
      clone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
      clone.setAttribute("width", String(SCENE_W));
      clone.setAttribute("height", String(SCENE_H));
      clone.removeAttribute("style");
      const svgUrl = URL.createObjectURL(
        new Blob([new XMLSerializer().serializeToString(clone)], { type: "image/svg+xml;charset=utf-8" }),
      );
      try {
        const img = await loadImage(svgUrl);
        await document.fonts?.ready;
        const sans = getComputedStyle(root).fontFamily;
        const display = timeRef.current ? getComputedStyle(timeRef.current).fontFamily : sans;
        const cv = document.createElement("canvas");
        cv.width = 1200;
        cv.height = 630;
        const ctx = cv.getContext("2d");
        if (!ctx) return;
        ctx.fillStyle = "#ffffff";
        ctx.fillRect(0, 0, 1200, 630);

        // 暗い場面の一部(左 700×520)
        const sw = (700 / 520) * 400;
        ctx.drawImage(img, 70, 200, sw, 400, 0, 0, 700, 520);
        const dark = document.createElement("canvas");
        dark.width = 700;
        dark.height = 520;
        const dctx = dark.getContext("2d");
        if (dctx) {
          dctx.fillStyle = "rgba(5,7,15,0.92)";
          dctx.fillRect(0, 0, 700, 520);
          dctx.globalCompositeOperation = "destination-out";
          const hole = (x: number, y: number, r: number, a: number) => {
            const g = dctx.createRadialGradient(x, y, 0, x, y, r);
            g.addColorStop(0, `rgba(0,0,0,${a})`);
            g.addColorStop((r - 30) / r, `rgba(0,0,0,${a})`);
            g.addColorStop(1, "rgba(0,0,0,0)");
            dctx.fillStyle = g;
            dctx.beginPath();
            dctx.arc(x, y, r, 0, Math.PI * 2);
            dctx.fill();
          };
          hole(500, 400, 150, 1);
          hole(129, 107, 90, 0.85);
          ctx.drawImage(dark, 0, 0);
        }
        ctx.strokeStyle = "rgba(217,119,6,0.55)";
        ctx.lineWidth = 2;
        ctx.save();
        ctx.beginPath();
        ctx.rect(0, 0, 700, 520);
        ctx.clip();
        ctx.beginPath();
        ctx.arc(500, 400, 136, 0, Math.PI * 2);
        ctx.stroke();
        ctx.restore();

        // 右の結果
        ctx.fillStyle = AMBER;
        ctx.fillRect(700, 0, 2, 520);
        ctx.textBaseline = "alphabetic";
        ctx.fillStyle = "#57534e";
        ctx.font = `500 24px ${sans}`;
        ctx.fillText(`隠れた${count}文字を`, 748, 110);
        ctx.fillStyle = AMBER;
        ctx.font = `700 112px ${display}`;
        ctx.fillText(sec, 744, 250);
        const numW = ctx.measureText(sec).width;
        ctx.fillStyle = "#1c1917";
        ctx.font = `700 40px ${sans}`;
        ctx.fillText("秒", 752 + numW, 250);
        ctx.font = `700 32px ${sans}`;
        ctx.fillText("で見つけた", 748, 310);
        const box = Math.min(64, Math.floor((404 - (count - 1) * 10) / count));
        for (let i = 0; i < count; i++) {
          const x = 748 + i * (box + 10);
          ctx.strokeStyle = "#d6d3d1";
          ctx.lineWidth = 1;
          ctx.strokeRect(x + 0.5, 370.5, box, box);
          ctx.fillStyle = "#a8a29e";
          ctx.font = `700 ${Math.round(box * 0.5)}px ${sans}`;
          ctx.textAlign = "center";
          ctx.fillText("?", x + box / 2, 370 + box * 0.68);
          ctx.textAlign = "left";
        }
        ctx.fillStyle = "#78716c";
        ctx.font = `500 20px ${sans}`;
        ctx.fillText("あなたは何秒で見つけられますか", 748, 480);

        // 下の帯: 作品名と URL
        ctx.fillStyle = "#e7e5e4";
        ctx.fillRect(0, 520, 1200, 1);
        ctx.fillStyle = "#1c1917";
        ctx.font = `700 28px ${sans}`;
        ctx.fillText(title, 48, 566);
        ctx.fillStyle = "#57534e";
        ctx.font = `500 20px ${display}`;
        ctx.fillText(shareUrl.replace(/^https?:\/\//, ""), 48, 600);

        const blob = await new Promise<Blob | null>((r) => cv.toBlob(r, "image/png"));
        if (blob) setShare({ url: URL.createObjectURL(blob), sec });
      } finally {
        URL.revokeObjectURL(svgUrl);
      }
    },
    [count, shareUrl, title],
  );

  const finish = useCallback(() => {
    const s = live.current;
    const ms = performance.now() - s.start;
    s.phase = "done";
    s.judge = false;
    setPhase("done");
    setElapsed(ms);
    const sec = (ms / 1000).toFixed(1);
    setMessage(`${count}文字そろいました。${sec}秒でした`);
    void makeShare(sec);
  }, [count, makeShare]);

  const capture = useCallback(
    (index: number) => {
      const s = live.current;
      const nextFound = s.found.map((f, i) => f || i === index);
      s.found = nextFound;
      s.active = null;
      s.progress = 0;
      setFound(nextFound);
      setActive(null);
      const left = nextFound.filter((f) => !f).length;
      setMessage(left ? `「${chars[index]}」を見つけました。残り${left}文字` : "");

      const root = rootRef.current?.getBoundingClientRect();
      const from = letterRefs.current[s.round[index]]?.getBoundingClientRect();
      const to = slotRefs.current[index]?.getBoundingClientRect();
      if (s.reduce || !root || !from || !to) {
        setLanded((l) => l.map((v, i) => v || i === index));
      } else {
        const fx = from.left + from.width / 2 - root.left;
        const fy = from.top + from.height / 2 - root.top;
        const tx = to.left + to.width / 2 - root.left;
        const ty = to.top + to.height / 2 - root.top;
        const fs = from.height;
        setFlyers((f) => [
          ...f,
          { id: ++s.flyerId, index, char: chars[index], x: fx, y: fy, dx: tx - fx, dy: ty - fy, scale: (to.height * 0.5) / Math.max(1, fs * 0.82), size: fs },
        ]);
      }
      if (!left) finish();
    },
    [chars, finish],
  );

  const tick = useCallback(
    (t: number) => {
      const s = live.current;
      const dt = Math.min(500, t - s.last);
      s.last = t;
      const k = s.reduce ? 1 : 1 - Math.exp(-dt / 60);
      s.cur.x += (s.target.x - s.cur.x) * k;
      s.cur.y += (s.target.y - s.cur.y) * k;
      applyLight();

      let cand: number | null = null;
      if (s.judge && s.phase === "playing") {
        const thr = Math.max(R * 0.5, letterSize);
        let best = thr;
        s.round.forEach((spot, i) => {
          if (s.found[i]) return;
          const p = spots[spot];
          const d = Math.hypot((p.x / 100) * s.size.w - s.cur.x, (p.y / 100) * s.size.h - s.cur.y);
          if (d < best) {
            best = d;
            cand = i;
          }
        });
      }
      if (cand !== s.active) {
        s.active = cand;
        s.progress = 0;
        setActive(cand);
        setArc(0);
      } else if (cand !== null) {
        s.progress += dt;
        setArc(s.progress / dwellMs);
        if (s.progress >= dwellMs) capture(cand);
      }

      const moving = Math.abs(s.target.x - s.cur.x) + Math.abs(s.target.y - s.cur.y) > 0.4;
      s.raf = moving || s.active !== null ? requestAnimationFrame((n) => tickRef.current(n)) : 0;
    },
    [R, letterSize, spots, dwellMs, capture, applyLight, setArc],
  );

  useEffect(() => {
    tickRef.current = tick;
  }, [tick]);

  const kick = () => {
    const s = live.current;
    if (s.raf) return;
    s.last = performance.now();
    s.raf = requestAnimationFrame((n) => tickRef.current(n));
  };

  const begin = () => {
    const s = live.current;
    s.placed = true;
    s.judge = true;
    if (s.phase === "idle") {
      s.phase = "playing";
      s.start = performance.now();
      setPhase("playing");
      setMessage("探索を始めました");
    }
  };

  const onPointer = (e: PointerEvent<HTMLDivElement>) => {
    const el = sceneRef.current;
    if (!el) return;
    const r = el.getBoundingClientRect();
    const scale = r.width / el.offsetWidth || 1;
    live.current.target = { x: (e.clientX - r.left) / scale, y: (e.clientY - r.top) / scale };
    begin();
    kick();
  };

  const release = () => {
    const s = live.current;
    s.judge = false;
    if (s.active !== null) {
      s.active = null;
      s.progress = 0;
      setActive(null);
    }
  };

  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
    const dir: Record<string, [number, number]> = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
    const d = dir[e.key];
    if (!d) return;
    e.preventDefault();
    const s = live.current;
    const step = Math.max(16, s.size.w * 0.035) * (e.shiftKey ? 3 : 1);
    s.target = {
      x: Math.min(s.size.w, Math.max(0, s.target.x + d[0] * step)),
      y: Math.min(s.size.h, Math.max(0, s.target.y + d[1] * step)),
    };
    begin();
    kick();
  };

  const reset = () => {
    const s = live.current;
    cancelAnimationFrame(s.raf);
    s.raf = 0;
    const next = makeRound(count, spots.length, lantern.spot);
    const empty = chars.map(() => false);
    Object.assign(s, {
      round: next,
      found: empty,
      phase: "idle",
      judge: false,
      active: null,
      progress: 0,
      placed: false,
      target: { x: (START_LIGHT.x / 100) * s.size.w, y: (START_LIGHT.y / 100) * s.size.h },
    });
    s.cur = { ...s.target };
    applyLight();
    setRound(next);
    setFound(empty);
    setLanded(empty);
    setFlyers([]);
    setPhase("idle");
    setElapsed(0);
    setActive(null);
    setShare(null);
    setCopied(null);
    setMessage("新しい配置で始めます。文字の場所は前回と変わります");
    sceneRef.current?.focus({ preventScroll: true });
  };

  const flyerRef = (f: Flyer) => (el: HTMLSpanElement | null) => {
    if (!el || el.dataset.go) return;
    el.dataset.go = "1";
    const anim = el.animate(
      [
        { transform: "translate(0px, 0px) scale(1)", opacity: 1 },
        { transform: `translate(${f.dx}px, ${f.dy - 24}px) scale(${(1 + f.scale) / 1.6})`, opacity: 1, offset: 0.55 },
        { transform: `translate(${f.dx}px, ${f.dy}px) scale(${f.scale})`, opacity: 1 },
      ],
      { duration: 560, easing: "cubic-bezier(.2,.7,.2,1)", fill: "forwards" },
    );
    anim.onfinish = () => {
      setLanded((l) => l.map((v, i) => v || i === f.index));
      setFlyers((list) => list.filter((x) => x.id !== f.id));
    };
  };

  const sec = (elapsed / 1000).toFixed(1);
  const foundCount = found.filter(Boolean).length;
  const shareText = `隠れた${count}文字を${share?.sec ?? sec}秒で見つけた。あなたは? ${shareUrl}`;
  const doCopy = async (kind: "text" | "code") => {
    const ok = await copyText(kind === "text" ? shareText : PROPS_EXAMPLE);
    setCopied(ok ? kind : null);
    if (ok) setMessage(kind === "text" ? "コピー文をコピーしました" : "コードをコピーしました");
  };

  const btn =
    "inline-flex min-h-11 items-center justify-center border px-4 text-sm font-bold transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#d97706] outline-none";

  return (
    <section
      ref={rootRef}
      className="relative mx-auto w-full max-w-6xl border border-stone-200 bg-white text-stone-900"
      aria-labelledby={`${uid}-title`}
    >
      {/* 見出しと状況 */}
      <div className="grid gap-5 px-5 py-6 sm:px-8 sm:py-7 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-end lg:gap-10">
        <div className="max-w-2xl">
          <p className="font-display text-xs font-semibold tracking-[0.18em] text-stone-500">
            BLK-105<span className="mx-2 inline-block h-px w-5 bg-stone-300 align-middle" />
            <span className="font-sans tracking-normal">動き・季節キャンペーン</span>
          </p>
          <Heading id={`${uid}-title`} className="mt-3 text-[clamp(1.35rem,3.2vw,2rem)] font-bold leading-[1.4] tracking-tight text-balance [word-break:auto-phrase]">
            夜の駄菓子屋に隠れた{count}文字を、懐中電灯で探す
          </Heading>
        </div>

        <div className="flex flex-col gap-3 lg:items-end">
          <ol className="flex gap-2" aria-label={`見つけた文字 ${foundCount}/${count}`}>
            {chars.map((c, i) => (
              <li key={i}>
                <span
                  ref={(el) => {
                    slotRefs.current[i] = el;
                  }}
                  className={`flex h-11 w-11 items-center justify-center border text-xl font-bold sm:h-12 sm:w-12 sm:text-2xl ${
                    found[i] ? "border-[#d97706] bg-amber-50 text-stone-900" : "border-stone-300 bg-white text-stone-300"
                  }`}
                >
                  {found[i] && landed[i] ? c : <span aria-hidden="true" className="font-display text-xs">{i + 1}</span>}
                  <span className="sr-only">{found[i] ? c : "未発見"}</span>
                </span>
              </li>
            ))}
          </ol>
          <div className="flex min-h-11 items-center gap-4">
            <p className="text-sm text-stone-500">
              経過
              <span ref={timeRef} className="ml-2 font-display text-2xl font-bold tabular-nums text-stone-900">
                {sec}
              </span>
              <span className="ml-0.5 text-sm text-stone-900">秒</span>
            </p>
            {phase === "done" ? (
              <button type="button" onClick={reset} className={`${btn} restart border-[#d97706] bg-[#d97706] text-white hover:bg-amber-700`}>
                もう一度
              </button>
            ) : (
              <p className="text-sm text-stone-500">
                {phase === "idle" ? "なぞると計時が始まります" : `残り${count - foundCount}文字`}
              </p>
            )}
          </div>
        </div>
      </div>

      {/* 場面 */}
      <div
        ref={sceneRef}
        tabIndex={0}
        role="group"
        aria-roledescription="探索の場面"
        aria-label={`夜の駄菓子屋の場面。矢印キーで懐中電灯を動かします。Shift と一緒に押すと大きく動きます`}
        onPointerMove={onPointer}
        onPointerDown={onPointer}
        onPointerLeave={release}
        onPointerCancel={release}
        onKeyDown={onKeyDown}
        className="relative aspect-[3/2] w-full cursor-crosshair touch-none select-none overflow-hidden border-y border-stone-200 outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#d97706]"
        style={{ background: NAVY }}
      >
        <div ref={artRef} className="absolute inset-0" aria-hidden="true">
          {scene ?? <DagashiNightScene />}
        </div>

        {/* 暗闇(明るさ 8%)と光の輪 */}
        <svg
          className={`pointer-events-none absolute inset-0 z-20 h-full w-full transition-opacity duration-700 motion-reduce:transition-none ${
            phase === "done" ? "opacity-30" : "opacity-100"
          }`}
          aria-hidden="true"
        >
          <defs>
            <radialGradient id={`${uid}-hole`}>
              <stop offset="0" stopColor="#000" stopOpacity="1" />
              <stop offset={(R - edge) / R} stopColor="#000" stopOpacity="1" />
              <stop offset="1" stopColor="#000" stopOpacity="0" />
            </radialGradient>
            <radialGradient id={`${uid}-lamp`}>
              <stop offset="0" stopColor="#000" stopOpacity="0.92" />
              <stop offset="0.6" stopColor="#000" stopOpacity="0.8" />
              <stop offset="1" stopColor="#000" stopOpacity="0" />
            </radialGradient>
            <radialGradient id={`${uid}-warm`}>
              <stop offset="0" stopColor={ORANGE} stopOpacity="0.32" />
              <stop offset="1" stopColor={ORANGE} stopOpacity="0" />
            </radialGradient>
            <mask id={`${uid}-mask`} maskUnits="userSpaceOnUse" x="0" y="0" width="100%" height="100%">
              <rect width="100%" height="100%" fill="#fff" />
              {w > 0 && (
                <circle cx={(lantern.x / 100) * w} cy={(lantern.y / 100) * size.h} r={lanternR} fill={`url(#${uid}-lamp)`} />
              )}
              <circle ref={maskLightRef} r={R} fill={`url(#${uid}-hole)`} />
            </mask>
          </defs>
          <rect width="100%" height="100%" fill={DARK} fillOpacity="0.92" mask={`url(#${uid}-mask)`} />
          {w > 0 && <circle cx={(lantern.x / 100) * w} cy={(lantern.y / 100) * size.h} r={lanternR * 1.1} fill={`url(#${uid}-warm)`} />}
        </svg>

        {/* 隠し文字は暗闇の上に置き、光の届く範囲だけ mask で見せる(暗闇の下に置くと 8% の明るさでも輪郭が読めてしまう) */}
        {hydrated && w > 0 && (
          <svg className="pointer-events-none absolute inset-0 z-[25] h-full w-full" aria-hidden="true">
            <defs>
              <radialGradient id={`${uid}-see`}>
                <stop offset="0" stopColor="#fff" stopOpacity="1" />
                <stop offset={(R - edge) / R} stopColor="#fff" stopOpacity="1" />
                <stop offset="1" stopColor="#fff" stopOpacity="0" />
              </radialGradient>
              <radialGradient id={`${uid}-seelamp`}>
                <stop offset="0" stopColor="#fff" stopOpacity="0.9" />
                <stop offset="0.6" stopColor="#fff" stopOpacity="0.75" />
                <stop offset="1" stopColor="#fff" stopOpacity="0" />
              </radialGradient>
              <mask id={`${uid}-reveal`} maskUnits="userSpaceOnUse" x="0" y="0" width="100%" height="100%">
                <rect width="100%" height="100%" fill="#000" />
                <circle cx={(lantern.x / 100) * w} cy={(lantern.y / 100) * size.h} r={lanternR} fill={`url(#${uid}-seelamp)`} />
                <circle ref={maskRevealRef} r={R} fill={`url(#${uid}-see)`} />
              </mask>
            </defs>
            {round.map((spot, i) => {
              const p = spots[spot];
              const on = active === i;
              const x = (p.x / 100) * w;
              const y = (p.y / 100) * size.h;
              const shown = found[i] || phase === "done";
              return (
                <g key={`${spot}-${i}`} mask={shown ? undefined : `url(#${uid}-reveal)`}>
                  <text
                    ref={(el) => {
                      letterRefs.current[spot] = el;
                    }}
                    x={x}
                    y={y}
                    transform={`rotate(${p.rotate ?? 0} ${x} ${y})`}
                    textAnchor="middle"
                    dominantBaseline="central"
                    fontSize={letterSize}
                    fontWeight={900}
                    fill={shown || on ? "#fbbf24" : CREAM}
                    fillOpacity={found[i] && phase !== "done" ? 0.7 : 1}
                    stroke={on ? AMBER : "none"}
                    strokeWidth={on ? 2 : 0}
                    paintOrder="stroke"
                  >
                    {chars[i]}
                  </text>
                </g>
              );
            })}
          </svg>
        )}

        <svg className="pointer-events-none absolute inset-0 z-30 h-full w-full overflow-visible" aria-hidden="true">
          <g ref={ringRef} className={phase === "done" ? "opacity-0" : "opacity-100"}>
            <circle r={R - edge / 2} fill="none" stroke={AMBER} strokeOpacity="0.45" strokeWidth="1.5" />
            <circle r={3} fill={AMBER} fillOpacity="0.8" />
          </g>
        </svg>

        {active !== null && (
          <div
            className="pointer-events-none absolute z-30 -translate-x-1/2 -translate-y-1/2"
            style={{ left: `${spots[round[active]].x}%`, top: `${spots[round[active]].y}%`, width: ringBox, height: ringBox }}
            aria-hidden="true"
          >
            <svg width={ringBox} height={ringBox} className="-rotate-90">
              <circle cx={ringBox / 2} cy={ringBox / 2} r={arcR} fill="none" stroke={CREAM} strokeOpacity="0.25" strokeWidth="2" />
              <circle
                ref={(el) => {
                  arcRef.current = el;
                  if (el) el.style.strokeDashoffset = String(arcLen);
                }}
                cx={ringBox / 2}
                cy={ringBox / 2}
                r={arcR}
                fill="none"
                stroke="#f59e0b"
                strokeWidth="3"
                strokeDasharray={arcLen}
              />
            </svg>
          </div>
        )}

        {/* 自動テスト・案内用の候補位置の目印(見えない) */}
        {spots.map((p, i) => (
          <span
            key={i}
            data-spot={i}
            aria-hidden="true"
            className="absolute z-40 h-2 w-2 -translate-x-1/2 -translate-y-1/2 opacity-0"
            style={{ left: `${p.x}%`, top: `${p.y}%` }}
          />
        ))}

        {phase === "idle" && (
          <p className="pointer-events-none absolute left-3 top-3 z-30 max-w-[62%] bg-[#05070f]/80 px-3 py-2 text-xs leading-relaxed text-[#efe6d2] sm:left-5 sm:top-5 sm:text-sm">
            なぞると懐中電灯が照らします。まずは軒下の灯りの横から
          </p>
        )}
      </div>

      {flyers.map((f) => (
        <span
          key={f.id}
          ref={flyerRef(f)}
          aria-hidden="true"
          className="pointer-events-none absolute z-50 flex items-center justify-center font-black leading-none text-[#d97706]"
          style={{ left: f.x - f.size, top: f.y - f.size, width: f.size * 2, height: f.size * 2, fontSize: f.size * 0.82 }}
        >
          {f.char}
        </span>
      ))}

      <div className="grid gap-2 px-5 pt-5 sm:px-8 lg:grid-cols-[minmax(0,5fr)_minmax(0,6fr)] lg:gap-10">
        <p className="text-[15px] leading-[1.85] text-stone-600">
          店舗・商店街の「サイト内で合言葉を探す」キャンペーンに差し込める探索UIです。場面の上をなぞると光の輪がついてきて、文字の上で少し止まると上のマスに入ります。文字の場所は毎回変わります。
        </p>
        <p className="text-xs leading-relaxed text-stone-500 lg:pt-1">
          マウス・指: 場面の上をなぞる(指を離すと判定が外れます)/キーボード: 場面を選んで矢印キー、Shift で大きく動かす
        </p>
      </div>

      <p className="sr-only" aria-live="polite">
        {message}
      </p>

      {/* 結果と共有 */}
      <div className="mt-6 grid gap-6 border-t border-stone-200 px-5 py-7 sm:px-8 lg:grid-cols-[minmax(0,5fr)_minmax(0,6fr)] lg:gap-10">
        <div>
          <h3 className="text-base font-bold">結果を共有する</h3>
          {phase === "done" ? (
            <>
              <p className="mt-3 text-sm leading-relaxed text-stone-600">
                {count}文字そろうまで
                <span className="mx-1 font-display text-3xl font-bold tabular-nums text-[#d97706]">{sec}</span>秒でした。共有の画像では合言葉を伏せています。
              </p>
              <label className="mt-4 block text-xs font-bold text-stone-500" htmlFor={`${uid}-text`}>
                コピー文
              </label>
              <textarea
                id={`${uid}-text`}
                readOnly
                value={shareText}
                rows={3}
                className="mt-1 w-full resize-none border border-stone-300 bg-stone-50 p-3 text-sm leading-relaxed text-stone-800 outline-none focus-visible:outline-2 focus-visible:outline-[#d97706]"
              />
              <div className="mt-3 flex flex-wrap gap-2">
                <button type="button" onClick={() => doCopy("text")} className={`${btn} copy-text border-stone-900 bg-stone-900 text-white hover:bg-stone-700`}>
                  {copied === "text" ? "コピーしました" : "コピー文をコピー"}
                </button>
                {share && (
                  <a href={share.url} download={`flashlight-${share.sec}s.png`} className={`${btn} border-stone-300 bg-white text-stone-900 hover:border-[#d97706]`}>
                    画像を保存
                  </a>
                )}
                <button type="button" onClick={reset} className={`${btn} border-stone-300 bg-white text-stone-900 hover:border-[#d97706]`}>
                  もう一度
                </button>
              </div>
            </>
          ) : (
            <p className="mt-3 text-sm leading-relaxed text-stone-600">
              {count}文字そろうと、秒数と共有用の画像(1200×630)がここに出ます。画像は端末の中で作り、どこにも送信しません。
            </p>
          )}
        </div>
        <div className="self-start border border-stone-200 bg-stone-50">
          {share ? (
            // eslint-disable-next-line @next/next/no-img-element
            <img src={share.url} alt={`共有用の画像。隠れた${count}文字を${share.sec}秒で見つけた結果`} width={1200} height={630} className="block h-auto w-full" />
          ) : (
            <div className="flex aspect-[1200/630] items-center justify-center px-6 text-center text-sm text-stone-400">
              共有用の画像(暗い場面の一部・秒数・作品名・URL)
            </div>
          )}
        </div>
      </div>

      {/* 組み込み */}
      <div className="grid gap-6 border-t border-stone-200 px-5 py-7 sm:px-8 lg:grid-cols-[minmax(0,5fr)_minmax(0,6fr)] lg:gap-10">
        <div>
          <h3 className="text-base font-bold">年末やクリスマスの企画に差し替える</h3>
          <p className="mt-2 text-sm leading-relaxed text-stone-600">場面の SVG・文字・候補位置を渡せば、同じ仕組みで別の季節に使えます。</p>
          <table className="mt-4 w-full border-t border-stone-200 text-left text-[13px]">
            <tbody>
              {PROPS_ROWS.map(([name, type, note]) => (
                <tr key={name} className="border-b border-stone-200 align-top">
                  <th scope="row" className="py-2 pr-3 font-mono text-[12px] font-medium text-stone-900">
                    {name}
                  </th>
                  <td className="hidden py-2 pr-3 font-mono text-[12px] text-stone-500 sm:table-cell">{type}</td>
                  <td className="py-2 text-stone-600">{note}</td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
        <div className="min-w-0">
          <div className="flex items-center justify-between border border-b-0 border-stone-200 px-3 py-2">
            <span className="font-mono text-xs text-stone-500">year-end-campaign.tsx</span>
            <button type="button" onClick={() => doCopy("code")} className={`${btn} min-h-9 border-stone-300 bg-white px-3 text-xs text-stone-900 hover:border-[#d97706]`}>
              {copied === "code" ? "コピーしました" : "コードをコピー"}
            </button>
          </div>
          <pre className="max-h-80 overflow-auto border border-stone-200 bg-stone-950 p-4 font-mono text-[12px] leading-relaxed text-stone-100">
            <code>{PROPS_EXAMPLE}</code>
          </pre>
        </div>
      </div>
    </section>
  );
}

Add to your project via shadcn CLI

npx shadcn@latest add https://designs.first-ch.com/r/halloween-flashlight-reveal.json