BLK-108Blocks

料金表からYES/NO診断を作成|HTML埋め込み

料金表(プラン×機能の○×)をTSV貼り付けすると、プランを選び分けるYES/NOの分岐診断を最短の質問順で自動作成し、LPにHTML埋め込みできる診断フォーム作成ブロックです。診断の冒頭に小さな○×表を置き、質問に使う行といまの問いを同じアンバーで対応づけ、答えるごとに残るプランの列だけが濃く残ります。○×がすべて同じで区別できないプランは大きく警告し、全プランで同じ機能は「質問不要」として灰色で示します。結果はプラン名と「このプランを見る」ボタン。「LPに貼る」で div・style・script を1つにしたHTMLをコピーでき(コピーできない環境ではコードの欄を開いて選択まで案内)、静的HTMLと WordPress のカスタムHTMLブロックで動きます(外部読み込みなし・クラスは yn-)。表は URL に入れて共有でき、架空のホームページ制作・英会話スクール・スポーツジムの見本付き。5プラン×10機能まで。依存ライブラリなし。

プレビュー

料金表の○×から質問を組む

最短2問で選べる

3プラン・平均1.7問全プランを区別できます

診断プレビュー

質問 1 / 最大 2

料金表の○×と、質問に使う行
6行中2行を質問にライトスタンダードプロ
Q1お知らせの更新機能×○○
予約・申込の受付××○

Q1お知らせの更新機能は必要ですか?

見本(架空の3業種)

  • ホームページ制作3プラン×6機能
  • 英会話スクール4プラン×7機能
  • スポーツジム5プラン×7機能

1行目はプラン名、2行目からは機能名と○/×。「リンク先」の行は任意です。5プラン×10機能まで、各セル40字まで(リンク先は200字)

読み取った表

機能ライトスタンダードプロ質問
スマホ対応○○○質問不要
お知らせの更新機能×○○使う
お問い合わせフォーム○○○質問不要
予約・申込の受付××○使う
英語ページ××○無くても選べる
公開後の月次レポート×○○無くても選べる
X でシェア

説明の全文を読む

料金表(プラン×機能の○×)をTSV貼り付けすると、プランを選び分けるYES/NOの分岐診断を最短の質問順で自動作成し、LPにHTML埋め込みできる診断フォーム作成ブロックです。診断の冒頭に小さな○×表を置き、質問に使う行といまの問いを同じアンバーで対応づけ、答えるごとに残るプランの列だけが濃く残ります。○×がすべて同じで区別できないプランは大きく警告し、全プランで同じ機能は「質問不要」として灰色で示します。結果はプラン名と「このプランを見る」ボタン。「LPに貼る」で div・style・script を1つにしたHTMLをコピーでき(コピーできない環境ではコードの欄を開いて選択まで案内)、静的HTMLと WordPress のカスタムHTMLブロックで動きます(外部読み込みなし・クラスは yn-)。表は URL に入れて共有でき、架空のホームページ制作・英会話スクール・スポーツジムの見本付き。5プラン×10機能まで。依存ライブラリなし。

追加日:
2026-10-30
依存:
なし
tags
#block #pricing #plan-finder #quiz #yes-no-chart #decision-tree #tsv #spreadsheet #embed #wordpress #share-url #tabs #accessibility #keyboard #reduced-motion #hairline #no-dependency
"use client";

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

/**
 * 料金表の○×から組むプラン診断(最短の質問順)
 *
 * 差別化: 既存の料金系(pricing-plans・pricing-compare-table・estimate-range-explainer)は読み手に表を見せるだけの見本。
 * 本ブロックは表を「入力」として受け取り、選び分けに要る質問の木を組み直して、LPに貼れる診断として書き出す道具にした。
 * 英字の小見出し・見出しの「。」止め・末尾の矢印リンク・角丸は使わず、節は hairline 罫線と余白で区切る。
 *
 * 汎用技法メモ:
 * 1. **最短の木は総当たりで求める**。プランは5つまでなので、残っているプランの組(ビットの集合)は最大32通り。
 *    組ごとに「全機能で二分した場合」をメモ化して比べ、最悪の質問数→平均の質問数→二分の均等さ→表の上の行の順で選ぶ。
 *    貪欲に「いちばん均等に割れる機能」を選ぶだけだと、最悪の質問数が1問増える表がある。
 * 2. **全プランで○×が同じ行は質問に使えない**(二分できない)。○×の並びがすべて同じプランは、どの質問でも分けられないので警告する。
 * 3. **誤りがあっても診断を消さない**。直前の正しい表を控えておき、誤りは行番号付きで並べる。
 * 4. **共有は hash に入れる**。表を UTF-8 → base64url にして #yn= に置く。サーバーに何も送らない。
 * 5. **書き出しは div・style・script の1かたまり**。クラスは yn- 接頭辞、利用者の文字は textContent で入れ、JSON の「<」は < に替えて
 *    script の終わりと誤読されないようにする。リンク先は https:// ・ / ・ # ・ ./ で始まるものだけ通す。
 * 6. **切り替えは120msの opacity と transform だけ**。切り替え後は問いの見出しへフォーカスを移し、prefers-reduced-motion では動かさない。
 *    答えのボタンは問いが替わっても作り直さず、押した側だけ key を替えて短く光らせる(反応は即時のまま、受け付けたことが見える)。
 * 7. **表と問いを同じ色で結ぶ**。診断の冒頭に小さな○×表を置き、いまの問いの行と問いの頭の「Q1」札を同じアンバーにする。
 *    答えで外れたプランの列は薄くし、結果ではそのプランの列見出しを塗る。狭い幅では質問に使う行だけを出す。
 */

const MAX_PLANS = 5;
const MAX_FEATURES = 10;
const MAX_CELL = 40;
const MAX_LINK = 200;
const PAGE_URL = "https://designs.first-ch.com/d/yesno-chart-flow";

type Plan = { name: string; link: string };
type Feature = { name: string; marks: boolean[] };
export type PlanTable = { plans: Plan[]; features: Feature[] };
type Parsed = { ok: true; table: PlanTable } | { ok: false; errors: string[] };
type TreeNode = { kind: "q"; f: number; yes: TreeNode; no: TreeNode } | { kind: "r"; plans: number[] };
type Built = { node: TreeNode; max: number; sum: number };
type Tab = "table" | "tree" | "output";

const tsv = (rows: string[][]) => rows.map((r) => r.join("\t")).join("\n");

const SAMPLES: { id: string; label: string; size: string; tsv: string }[] = [
  {
    id: "web",
    label: "ホームページ制作",
    size: "3プラン×6機能",
    tsv: tsv([
      ["機能", "ライト", "スタンダード", "プロ"],
      ["スマホ対応", "○", "○", "○"],
      ["お知らせの更新機能", "×", "○", "○"],
      ["お問い合わせフォーム", "○", "○", "○"],
      ["予約・申込の受付", "×", "×", "○"],
      ["英語ページ", "×", "×", "○"],
      ["公開後の月次レポート", "×", "○", "○"],
      ["リンク先", "#plan-light", "#plan-standard", "#plan-pro"],
    ]),
  },
  {
    id: "school",
    label: "英会話スクール",
    size: "4プラン×7機能",
    tsv: tsv([
      ["機能", "月4回", "月8回", "通い放題", "オンライン専用"],
      ["教室でのレッスン", "○", "○", "○", "×"],
      ["オンラインレッスン", "×", "×", "○", "○"],
      ["月8回以上のレッスン", "×", "○", "○", "○"],
      ["講師の指名", "×", "×", "○", "×"],
      ["検定対策コース", "×", "○", "○", "×"],
      ["教材費込み", "○", "○", "○", "○"],
      ["振替受講", "○", "○", "○", "×"],
      ["リンク先", "#course-4", "#course-8", "#course-free", "#course-online"],
    ]),
  },
  {
    id: "gym",
    label: "スポーツジム",
    size: "5プラン×7機能",
    tsv: tsv([
      ["機能", "平日昼", "夜間", "フルタイム", "家族会員", "法人会員"],
      ["平日の昼の利用", "○", "×", "○", "○", "○"],
      ["平日の夜の利用", "×", "○", "○", "○", "○"],
      ["土日の利用", "×", "×", "○", "○", "○"],
      ["プールの利用", "×", "×", "○", "○", "○"],
      ["家族の同伴", "×", "×", "×", "○", "○"],
      ["月1回のパーソナル指導", "×", "×", "×", "○", "○"],
      ["専用ロッカー", "×", "×", "○", "○", "○"],
    ]),
  },
];
const DEFAULT_TSV = SAMPLES[0].tsv;

const norm = (v: string) => v.normalize("NFKC").trim().toLowerCase();
const YES = new Set(["○", "〇", "◯", "◎", "o", "✓", "✔", "はい", "yes", "y", "true", "1", "あり", "有"]);
const NO = new Set(["×", "✕", "✖", "✗", "x", "いいえ", "no", "n", "false", "0", "-", "ー", "―", "なし", "無"]);
const LINK_LABELS = new Set(["リンク先", "リンク", "url", "link"]);
const len = (s: string) => [...s].length;
const clip = (s: string, n = 16) => (len(s) > n ? `${[...s].slice(0, n).join("")}…` : s);

function markOf(v: string): boolean | null {
  const k = norm(v);
  if (YES.has(k)) return true;
  if (NO.has(k)) return false;
  return null;
}

/** リンク先に使ってよい形か(javascript: などの実行される URL を通さない) */
export const safeLink = (v: string) => /^(https?:\/\/|\/|#|\.{1,2}\/)[^\s"'<>`]*$/i.test(v);

/** スプレッドシートからコピーした TSV(タブが無ければカンマ区切り)を読む。誤りは行番号付きで全部返す */
export function parseTable(src: string): Parsed {
  const errors: string[] = [];
  const sep = src.includes("\t") ? "\t" : ",";
  const rows = src
    .replace(/\r\n?/g, "\n")
    .split("\n")
    .map((l, i) => ({ n: i + 1, cells: l.split(sep).map((c) => c.trim()) }))
    .filter((r) => r.cells.some((c) => c !== ""));
  if (!rows.length) return { ok: false, errors: ["表が空です。1行目にプラン名、2行目から機能名と○×を入れてください"] };

  const [head, ...body] = rows;
  const hc = [...head.cells];
  while (hc.length > 1 && hc[hc.length - 1] === "") hc.pop();
  const names = hc.slice(1);
  if (names.length < 2) errors.push(`${head.n}行目: プラン名を2つ以上、タブで区切って並べてください(1列目は空欄か「機能」)`);
  if (names.length > MAX_PLANS) errors.push(`${head.n}行目: プランは${MAX_PLANS}つまでです(いま${names.length}つ)`);
  names.forEach((nm, j) => {
    if (!nm) errors.push(`${head.n}行目: ${j + 2}列目のプラン名が空欄です`);
    else if (len(nm) > MAX_CELL) errors.push(`${head.n}行目: プラン名「${clip(nm)}」が${MAX_CELL}字を超えています(${len(nm)}字)`);
    else if (names.indexOf(nm) !== j) errors.push(`${head.n}行目: プラン名「${nm}」が2回出てきます`);
  });

  const links = names.map(() => "");
  const features: (Feature & { n: number })[] = [];
  let linkLine = 0;
  for (const r of body) {
    const label = r.cells[0];
    const cells = r.cells.slice(1);
    while (cells.length > names.length && cells[cells.length - 1] === "") cells.pop();
    if (LINK_LABELS.has(norm(label))) {
      if (linkLine) {
        errors.push(`${r.n}行目: 「リンク先」の行は1つだけにしてください(${linkLine}行目にもあります)`);
        continue;
      }
      linkLine = r.n;
      names.forEach((nm, j) => {
        const v = cells[j] ?? "";
        if (!v) return;
        if (len(v) > MAX_LINK) errors.push(`${r.n}行目: 「${nm}」のリンク先が${MAX_LINK}字を超えています`);
        else if (!safeLink(v)) errors.push(`${r.n}行目: 「${nm}」のリンク先「${clip(v)}」は使えません。https:// か / か # で始めてください`);
        else links[j] = v;
      });
      continue;
    }
    if (!label) {
      errors.push(`${r.n}行目: 1列目に機能名を入れてください`);
      continue;
    }
    if (len(label) > MAX_CELL) errors.push(`${r.n}行目: 機能名「${clip(label)}」が${MAX_CELL}字を超えています(${len(label)}字)`);
    if (features.some((f) => f.name === label)) errors.push(`${r.n}行目: 機能名「${clip(label)}」が2回出てきます`);
    if (cells.length > names.length) errors.push(`${r.n}行目: 列が多すぎます(プランは${names.length}つです)`);
    const marks: boolean[] = [];
    names.forEach((nm, j) => {
      const v = cells[j] ?? "";
      const m = markOf(v);
      if (m === null) {
        errors.push(
          v === "△"
            ? `${r.n}行目: 「${clip(nm, 10)}」の列の△は使えません。○か×のどちらかにしてください`
            : `${r.n}行目: 「${clip(nm, 10)}」の列が${v ? `「${clip(v, 10)}」` : "空欄"}です。○か×を入れてください`,
        );
      } else marks.push(m);
    });
    features.push({ name: label, marks, n: r.n });
  }
  if (!features.length && names.length >= 2) errors.push(`${head.n + 1}行目から、機能名と○×を1行以上入れてください`);
  if (features.length > MAX_FEATURES) {
    errors.push(`${features[MAX_FEATURES].n}行目: 機能は${MAX_FEATURES}行までです(いま${features.length}行)`);
  }
  if (errors.length) return { ok: false, errors };
  return {
    ok: true,
    table: {
      plans: names.map((name, j) => ({ name, link: links[j] })),
      features: features.map(({ name, marks }) => ({ name, marks })),
    },
  };
}

const popcount = (m: number) => {
  let c = 0;
  for (let x = m; x; x &= x - 1) c++;
  return c;
};
const bits = (m: number, n: number) => Array.from({ length: n }, (_, j) => j).filter((j) => m & (1 << j));

/** 最悪の質問数→平均の質問数→二分の均等さ→表の上の行、の順で最良の木を選ぶ */
export function buildTree(t: PlanTable): Built {
  const n = t.plans.length;
  const cols = t.features.map((f) => f.marks.reduce((m, v, j) => (v ? m | (1 << j) : m), 0));
  const memo = new Map<number, Built>();
  const solve = (mask: number): Built => {
    const hit = memo.get(mask);
    if (hit) return hit;
    let best: (Built & { bal: number }) | null = null;
    cols.forEach((c, f) => {
      const y = mask & c;
      const no = mask & ~c;
      if (!y || !no) return;
      const a = solve(y);
      const b = solve(no);
      const cand = {
        node: { kind: "q", f, yes: a.node, no: b.node } as TreeNode,
        max: 1 + Math.max(a.max, b.max),
        sum: a.sum + b.sum + popcount(mask),
        bal: Math.min(popcount(y), popcount(no)),
      };
      if (
        !best ||
        cand.max < best.max ||
        (cand.max === best.max && (cand.sum < best.sum || (cand.sum === best.sum && cand.bal > best.bal)))
      ) {
        best = cand;
      }
    });
    const out: Built = best
      ? { node: (best as Built).node, max: (best as Built).max, sum: (best as Built).sum }
      : { node: { kind: "r", plans: bits(mask, n) }, max: 0, sum: 0 };
    memo.set(mask, out);
    return out;
  };
  return solve((1 << n) - 1);
}

export const questionOf = (name: string) => (/[??]$/.test(name) ? name : `${name}は必要ですか?`);

const nodeAt = (root: TreeNode, path: boolean[]) => {
  let n = root;
  for (const a of path) {
    if (n.kind !== "q") break;
    n = a ? n.yes : n.no;
  }
  return n;
};

function walk(node: TreeNode, fn: (n: TreeNode) => void) {
  fn(node);
  if (node.kind === "q") {
    walk(node.yes, fn);
    walk(node.no, fn);
  }
}

function summarize(t: PlanTable, b: Built) {
  const used = new Set<number>();
  const groups: number[][] = [];
  walk(b.node, (n) => {
    if (n.kind === "q") used.add(n.f);
    else if (n.plans.length > 1) groups.push(n.plans);
  });
  const same = t.features.map((f) => f.marks.every((m) => m === f.marks[0]));
  return { used, groups, same, avg: b.sum / t.plans.length };
}

/** 診断に使う表。入力が誤りなら、直前に表示していた正しい表(最後の正しい編集→hash→initialTsv→既定の見本)に戻す */
export function pickGoodTsv(o: { source: string; lastGood: string | null; hashTsv: string | null; initialTsv: string }) {
  if (parseTable(o.source).ok) return o.source;
  if (o.lastGood) return o.lastGood;
  if (o.hashTsv && parseTable(o.hashTsv).ok) return o.hashTsv;
  if (parseTable(o.initialTsv).ok) return o.initialTsv;
  return DEFAULT_TSV;
}

// ---- 共有(#yn= に表を入れる)----
function encodeHash(s: string) {
  const bytes = new TextEncoder().encode(s);
  let bin = "";
  bytes.forEach((c) => (bin += String.fromCharCode(c)));
  return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function decodeHash(h: string): string | null {
  const m = h.match(/^#yn=([A-Za-z0-9_-]{1,8000})$/);
  if (!m) return null;
  try {
    const bin = atob(m[1].replace(/-/g, "+").replace(/_/g, "/"));
    return new TextDecoder("utf-8", { fatal: true }).decode(Uint8Array.from(bin, (c) => c.charCodeAt(0)));
  } catch {
    return null;
  }
}
const subscribeHash = (cb: () => void) => {
  window.addEventListener("hashchange", cb);
  return () => window.removeEventListener("hashchange", cb);
};
const readHash = () => window.location.hash;
const serverHash = () => "";

// ---- 書き出し(div・style・script の1かたまり)----
type Packed = { q: string; y: Packed; n: Packed } | { r: number[] };
const pack = (t: PlanTable, n: TreeNode): Packed =>
  n.kind === "q" ? { q: questionOf(t.features[n.f].name), y: pack(t, n.yes), n: pack(t, n.no) } : { r: n.plans };

function hashId(s: string) {
  let h = 5381;
  for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) >>> 0;
  return h.toString(36);
}

const EMBED_CSS = `.yn-root{box-sizing:border-box;max-width:560px;margin:0 auto;padding:24px;border:1px solid #e7e5e4;background:#fff;color:#1c1917;font-family:inherit;line-height:1.7;text-align:left}
.yn-root *{box-sizing:border-box}
.yn-root .yn-step{margin:0 0 6px;font-size:13px;color:#57534e;font-variant-numeric:tabular-nums}
.yn-root .yn-lead{margin:0 0 4px;font-size:13px;color:#57534e}
.yn-root .yn-q{margin:0 0 18px;font-size:20px;line-height:1.5;font-weight:700;color:#1c1917;outline:none}
.yn-root .yn-name{display:inline-block;padding-bottom:4px;border-bottom:4px solid #d97706;font-size:28px;line-height:1.3}
.yn-root .yn-q:focus-visible{outline:2px solid #d97706;outline-offset:4px}
.yn-root .yn-ans{display:grid;grid-template-columns:1fr 1fr;gap:8px}
.yn-root .yn-btn{min-height:52px;margin:0;padding:8px 16px;border:1px solid #d6d3d1;border-radius:0;background:#fff;color:#1c1917;font:inherit;font-weight:700;cursor:pointer}
.yn-root .yn-btn:hover{border-color:#d97706;background:#fffbeb}
.yn-root .yn-btn:active{border-color:#d97706;background:#fde68a;transform:translateY(1px)}
.yn-root .yn-go{display:inline-flex;align-items:center;min-height:48px;margin:0 8px 8px 0;padding:8px 20px;background:#b45309;color:#fff;font-weight:700;text-decoration:none}
.yn-root .yn-go:hover{background:#92400e}
.yn-root .yn-foot{display:flex;gap:20px;margin-top:20px;padding-top:8px;border-top:1px solid #e7e5e4}
.yn-root .yn-link{min-height:44px;margin:0;padding:0;border:0;background:none;color:#44403c;font:inherit;font-size:14px;text-decoration:underline;text-underline-offset:3px;cursor:pointer}
.yn-root .yn-link[disabled]{color:#a8a29e;cursor:default;text-decoration:none}
.yn-root .yn-trail{margin:14px 0 0;padding:0;list-style:none;font-size:13px;color:#57534e}
.yn-root .yn-trail li{padding:2px 0 2px 10px;border-left:2px solid #d97706}
.yn-root .yn-btn:focus-visible,.yn-root .yn-go:focus-visible,.yn-root .yn-link:focus-visible{outline:2px solid #d97706;outline-offset:2px}
.yn-root .yn-in{animation:yn-in .12s ease-out}
.yn-root .yn-in .yn-go{animation:yn-cta .6s ease-out .12s both}
@keyframes yn-in{from{opacity:0;transform:translateX(8px)}to{opacity:1;transform:none}}
@keyframes yn-cta{from{box-shadow:0 0 0 0 rgba(217,119,6,.55)}to{box-shadow:0 0 0 10px rgba(217,119,6,0)}}
@media (prefers-reduced-motion:reduce){.yn-root .yn-in,.yn-root .yn-in .yn-go{animation:none}.yn-root .yn-btn:active{transform:none}}`;

// 同じ診断を1ページに2つ貼っても動くように、id ではなく data-yn で探し、まだ動かしていないかたまりだけを動かす
// __DATA__ と __ID__ は1回の置換で埋める(入れた表の文字に __ID__ があっても、それを置き換えない)
const EMBED_JS = `(function(){var D=__DATA__,L=document.getElementsByClassName("yn-root"),k;
for(k=0;k<L.length;k++)if(L[k].getAttribute("data-yn")==="__ID__"&&!L[k].getAttribute("data-yn-on")){L[k].setAttribute("data-yn-on","1");init(L[k])}
function init(r){var path=[],body=r.querySelector(".yn-body"),step=r.querySelector(".yn-step"),back=r.querySelector(".yn-back"),reset=r.querySelector(".yn-reset");
function at(){var n=D.t;for(var i=0;i<path.length&&n.q;i++)n=path[i]?n.y:n.n;return n}
function el(t,c,x){var e=document.createElement(t);if(c)e.className=c;if(x!=null)e.textContent=x;return e}
function ok(u){return /^(https?:\\/\\/|\\/|#|\\.{1,2}\\/)[^\\s"'<>\`]*$/i.test(u)}
function show(f){var n=at(),h=el("h3","yn-q");h.tabIndex=-1;body.textContent="";body.className="yn-body";if(f){void body.offsetWidth;body.className="yn-body yn-in"}
if(n.q){step.textContent="質問 "+(path.length+1)+" / 最大 "+D.m;h.textContent=n.q;body.appendChild(h);var a=el("div","yn-ans");
[["はい",true],["いいえ",false]].forEach(function(o){var b=el("button","yn-btn",o[0]);b.type="button";b.onclick=function(){path.push(o[1]);show(1)};a.appendChild(b)});body.appendChild(a)}
else{var ps=n.r.map(function(i){return D.p[i]});step.textContent=path.length+"問で決まりました";
body.appendChild(el("p","yn-lead",ps.length>1?"表の○×では、次のどれかまで絞れました":"おすすめのプラン"));
h.className="yn-q yn-name";h.textContent=ps.map(function(p){return p.n}).join("・");body.appendChild(h);
ps.forEach(function(p){if(p.u&&ok(p.u)){var g=el("a","yn-go",ps.length>1?p.n+"を見る":"このプランを見る");g.href=p.u;body.appendChild(g)}})}
var tr=el("ol","yn-trail"),m=D.t;for(var i=0;i<path.length&&m.q;i++){tr.appendChild(el("li","",m.q+" "+(path[i]?"はい":"いいえ")));m=path[i]?m.y:m.n}
if(path.length)body.appendChild(tr);back.disabled=!path.length;reset.disabled=!path.length;if(f)h.focus()}
back.onclick=function(){if(path.length){path.pop();show(1)}};reset.onclick=function(){path=[];show(1)};
r.addEventListener("keydown",function(e){if(e.key==="ArrowLeft"&&!e.altKey&&!e.ctrlKey&&!e.metaKey&&path.length){e.preventDefault();path.pop();show(1)}});
show(0)}})();`;

/** LP に貼る HTML を作る。外部読み込みなし・クラスは yn- 接頭辞・利用者の文字は textContent で入れる */
export function buildEmbed(t: PlanTable, b: Built) {
  const data = {
    t: pack(t, b.node),
    p: t.plans.map((p) => ({ n: p.name, u: safeLink(p.link) ? p.link : "" })),
    m: b.max,
  };
  const json = JSON.stringify(data).replace(/</g, "\\u003c").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
  const id = `yn-${hashId(json)}`;
  return `<div class="yn-root" data-yn="${id}">
<p class="yn-step"></p>
<div class="yn-body"><noscript>この診断は JavaScript を有効にすると使えます。</noscript></div>
<div class="yn-foot"><button type="button" class="yn-link yn-back" disabled>1問戻る</button><button type="button" class="yn-link yn-reset" disabled>最初から</button></div>
</div>
<style>
${EMBED_CSS}
</style>
<script>
${EMBED_JS.replace(/__DATA__|__ID__/g, (m) => (m === "__DATA__" ? json : id))}
</script>`;
}

// ---- 表示 ----
const focusRing =
  "outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#d97706]";
// 切り替えの演出。どれも opacity と transform(と一度だけの影)で、120〜320ms。prefers-reduced-motion では使わない
const KEYFRAMES = `@keyframes yn-in{from{opacity:0;transform:translateX(8px)}to{opacity:1;transform:none}}
@keyframes yn-fade{from{opacity:0}to{opacity:1}}
@keyframes yn-flash{from{background-color:#fde68a;border-color:#d97706}to{background-color:#fff;border-color:#d6d3d1}}
@keyframes yn-pop{from{opacity:0;transform:scale(.96)}to{opacity:1;transform:none}}
@keyframes yn-bar{from{transform:scaleX(0)}to{transform:scaleX(1)}}
@keyframes yn-cta{from{box-shadow:0 0 0 0 rgba(217,119,6,.55)}to{box-shadow:0 0 0 10px rgba(217,119,6,0)}}`;
const TABS: { id: Tab; label: string }[] = [
  { id: "table", label: "表" },
  { id: "tree", label: "質問の木" },
  { id: "output", label: "出力" },
];

export type YesNoChartFlowProps = {
  /** 最初に読む表(TSV)。URL の #yn= があればそちらを優先 */
  initialTsv?: string;
  headingTag?: "h1" | "h2" | "p";
};

export default function YesNoChartFlow({ initialTsv = DEFAULT_TSV, headingTag }: YesNoChartFlowProps) {
  const Heading = headingTag ?? "h2";
  const QTag = headingTag === "p" ? "p" : "h3";
  const uid = useId().replace(/[^a-zA-Z0-9_-]/g, "");
  const hash = useSyncExternalStore(subscribeHash, readHash, serverHash);
  const hashTsv = useMemo(() => decodeHash(hash), [hash]);

  const [draft, setDraft] = useState<string | null>(null);
  const [lastGood, setLastGood] = useState<string | null>(null);
  const [tab, setTab] = useState<Tab>("table");
  const [nav, setNav] = useState<{ sig: string; path: boolean[] }>({ sig: "", path: [] });
  const [copied, setCopied] = useState<{ what: "embed" | "url" | "fail"; at: number } | null>(null);
  const [codeOpen, setCodeOpen] = useState(false);
  const [flash, setFlash] = useState<{ a: boolean; k: string } | null>(null);
  const codeRef = useRef<HTMLTextAreaElement>(null);

  const source = draft ?? hashTsv ?? initialTsv;
  const parsed = useMemo(() => parseTable(source), [source]);
  const goodTsv = useMemo(
    () => pickGoodTsv({ source, lastGood, hashTsv, initialTsv }),
    [source, lastGood, hashTsv, initialTsv],
  );
  const table = useMemo(() => {
    const p = parseTable(goodTsv);
    return p.ok ? p.table : (parseTable(DEFAULT_TSV) as { ok: true; table: PlanTable }).table;
  }, [goodTsv]);
  const built = useMemo(() => buildTree(table), [table]);
  const sum = useMemo(() => summarize(table, built), [table, built]);
  const embed = useMemo(() => buildEmbed(table, built), [table, built]);
  const embedKb = useMemo(() => (new TextEncoder().encode(embed).length / 1024).toFixed(1), [embed]);
  const sig = goodTsv;
  const path = nav.sig === sig ? nav.path : [];
  const node = nodeAt(built.node, path);
  const errors = parsed.ok ? [] : parsed.errors;
  const sampleId = SAMPLES.find((s) => s.tsv === source)?.id;

  const focusNext = useRef(false);
  const qRef = useRef<HTMLElement>(null);
  const keyOf = (p: boolean[]) => `${sig.length}:${p.map((a) => (a ? 1 : 0)).join("")}`;
  const pathKey = keyOf(path);
  useEffect(() => {
    if (!focusNext.current) return;
    focusNext.current = false;
    qRef.current?.focus();
  }, [pathKey]);

  const go = (next: boolean[], focus = true) => {
    focusNext.current = focus;
    setNav({ sig, path: next });
  };
  const answer = (a: boolean) => {
    setFlash({ a, k: keyOf([...path, a]) });
    go([...path, a]);
  };
  const back = () => path.length && go(path.slice(0, -1));

  const writeHash = (v: string) => {
    const url = `${window.location.pathname}${window.location.search}#yn=${encodeHash(v)}`;
    window.history.replaceState(window.history.state, "", url);
  };
  const edit = (v: string) => {
    setDraft(v);
    if (parseTable(v).ok) {
      setLastGood(v);
      writeHash(v);
    }
  };

  const copy = async (text: string, what: "embed" | "url") => {
    try {
      await navigator.clipboard.writeText(text);
      setCopied({ what, at: Date.now() });
    } catch {
      setCopied({ what: "fail", at: Date.now() });
      // コピーできない環境(権限・http・埋め込みの iframe)では、コードの欄を開いて手で選べるようにする
      if (what === "embed") setCodeOpen(true);
    }
  };
  const selectCode = () => {
    setCodeOpen(true);
    requestAnimationFrame(() => {
      const el = codeRef.current;
      if (!el) return;
      el.scrollIntoView({ block: "nearest" });
      el.focus();
      el.select();
    });
  };
  const shareUrl = () => `${PAGE_URL}#yn=${encodeHash(goodTsv)}`;

  const onTabKey = (e: KeyboardEvent, i: number) => {
    const d = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0;
    if (!d) return;
    e.preventDefault();
    e.stopPropagation();
    const next = TABS[(i + d + TABS.length) % TABS.length].id;
    setTab(next);
    document.getElementById(`${uid}-tab-${next}`)?.focus();
  };

  const onPreviewKey = (e: KeyboardEvent) => {
    if (e.key !== "ArrowLeft" || e.altKey || e.ctrlKey || e.metaKey || !path.length) return;
    e.preventDefault();
    back();
  };

  const name = (j: number) => table.plans[j].name;
  const quote = (js: number[]) => js.map((j) => `「${name(j)}」`).join("と");

  // 問いの道(答えた順の問い)
  const trail: { q: string; a: boolean }[] = [];
  {
    let n = built.node;
    for (const a of path) {
      if (n.kind !== "q") break;
      trail.push({ q: questionOf(table.features[n.f].name), a });
      n = a ? n.yes : n.no;
    }
  }

  // 表のどの行がいまの問いか・残っているプラン(プレビュー冒頭の小さな○×表で、表と問いを対応づける)
  const remaining = new Set<number>();
  walk(node, (n) => {
    if (n.kind === "r") n.plans.forEach((j) => remaining.add(j));
  });
  const answered = new Map<number, { i: number; a: boolean }>();
  {
    let n = built.node;
    path.forEach((a, i) => {
      if (n.kind !== "q") return;
      answered.set(n.f, { i, a });
      n = a ? n.yes : n.no;
    });
  }
  const current = node.kind === "q" ? node.f : -1;
  const flashOn = flash && flash.k === pathKey ? flash.a : null;
  const anim = path.length ? "animate-[yn-in_120ms_ease-out] motion-reduce:animate-none" : "";

  return (
    <section
      className="@container mx-auto w-full max-w-6xl border border-stone-200 bg-white text-base text-stone-900"
      aria-labelledby={`${uid}-title`}
    >
      <style>{KEYFRAMES}</style>
      {/* 見出しと結論(何問で選べるか)。広い幅では1段にまとめ、すぐ下の小さな○×表まで最初の画面に入れる */}
      <div className="grid @2xl:grid-cols-[minmax(0,1fr)_auto] @2xl:items-end" aria-live="polite">
        <div className="px-5 py-3.5 @2xl:px-8 @2xl:py-5">
          <p className="hidden font-display text-xs font-semibold tracking-[0.18em] text-stone-500 @2xl:block">
            BLK-108<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="max-w-3xl text-[17px] font-bold leading-[1.45] tracking-tight text-balance [word-break:auto-phrase] @2xl:mt-2 @2xl:text-[clamp(1.2rem,2.6vw,1.6rem)]"
          >
            料金表の○×から質問を組む
          </Heading>
        </div>
        <div className="summary flex items-center gap-x-3 border-t border-stone-200 px-5 py-2 @2xl:my-4 @2xl:items-end @2xl:gap-x-5 @2xl:border-l @2xl:border-t-0 @2xl:px-8 @2xl:py-1">
          <p className="shrink-0 leading-none">
            <span className="mr-1 text-[13px] font-bold text-stone-600 @2xl:text-sm">最短</span>
            <span className="font-display text-[2.25rem] font-bold leading-none tabular-nums text-stone-900 @2xl:text-[3.5rem]">
              {built.max}
            </span>
            <span className="ml-1 text-[15px] font-bold @2xl:text-lg">{sum.groups.length ? "問で絞れる" : "問で選べる"}</span>
          </p>
          <p className="min-w-0 text-xs leading-snug text-stone-600 [word-break:auto-phrase] @2xl:mb-1 @2xl:text-sm">
            <span>{table.plans.length}プラン・平均{sum.avg.toFixed(1)}問</span>
            {!sum.groups.length && (
              <span className="block font-bold text-stone-800 @2xl:mt-0.5">
                <span aria-hidden="true" className="mr-1 text-[#b45309]">✓</span>全プランを区別できます
              </span>
            )}
          </p>
        </div>
        {sum.groups.length > 0 && (
          // 区別できないプランは、幅に関わらず帯をまたいで大きく出す
          <div className="border-t border-stone-200 px-5 py-4 @2xl:col-span-2 @2xl:px-8">
            <div className="warn border-l-4 border-[#d97706] bg-amber-50 py-3 pl-4 pr-3">
              <p className="text-lg font-bold leading-snug text-stone-900">
                {sum.groups.length === 1 ? quote(sum.groups[0]) : `${sum.groups.length}組のプラン`}は質問で区別できません
              </p>
              <ul className="mt-1 space-y-1 text-[15px] leading-relaxed text-stone-700">
                {sum.groups.map((g) => (
                  <li key={g.join()}>
                    {quote(g)}は表の○×がすべて同じです。違いの出る機能を1行足すと分けられます
                  </li>
                ))}
              </ul>
            </div>
          </div>
        )}
        {errors.length > 0 && (
          <p className="border-t border-stone-200 px-5 py-3 @2xl:col-span-2 @2xl:px-8">
            <span className="block border-l-4 border-red-600 py-1 pl-4 text-[15px] font-bold leading-relaxed text-red-700">
              表に誤りが{errors.length}件あります。診断は直前の正しい表のまま表示しています
            </span>
          </p>
        )}
      </div>

      <div className="grid border-t border-stone-200 @2xl:grid-cols-[minmax(0,9fr)_minmax(0,11fr)]">
        {/* 診断プレビュー */}
        <div
          role="region"
          aria-label="診断プレビュー"
          onKeyDown={onPreviewKey}
          className="preview bg-stone-50/60 px-5 pb-6 pt-3 @2xl:border-r @2xl:border-stone-200 @2xl:px-7 @2xl:pt-6"
        >
          <div className="@2xl:sticky @2xl:top-24">
          <div className="flex items-center justify-between gap-3">
            <p className="text-sm font-bold text-stone-600">診断プレビュー</p>
            <p key={pathKey} className={`font-display text-sm tabular-nums text-stone-500 ${anim}`}>
              {node.kind === "q" ? `質問 ${path.length + 1} / 最大 ${built.max}` : `${path.length}問で決定`}
            </p>
          </div>
          <div className="mt-2 flex gap-1" aria-hidden="true">
            {Array.from({ length: Math.max(built.max, 1) }, (_, i) => (
              <span
                key={i}
                className={`h-1 flex-1 transition-colors duration-[120ms] motion-reduce:transition-none ${
                  i < path.length ? "bg-[#d97706]" : "bg-stone-200"
                }`}
              />
            ))}
          </div>

          <MiniTable
            table={table}
            used={sum.used}
            same={sum.same}
            answered={answered}
            current={current}
            currentNo={path.length + 1}
            remaining={remaining}
            done={node.kind === "r"}
          />

          <div className="mt-3 @2xl:mt-4 @2xl:min-h-52">
            <div key={pathKey} className={anim}>
              {node.kind === "q" ? (
                <QTag
                  ref={qRef as never}
                  tabIndex={-1}
                  className="question text-[clamp(1.2rem,3.2cqw,1.6rem)] font-bold leading-snug text-balance outline-none focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#d97706] [word-break:auto-phrase]"
                >
                  <span className="mr-2 inline-block bg-[#d97706] px-1.5 align-[0.15em] font-display text-xs leading-5 text-white">
                    Q{path.length + 1}
                  </span>
                  {questionOf(table.features[node.f].name)}
                </QTag>
              ) : (
                <div className="result">
                  <p className="text-sm text-stone-600">
                    {node.plans.length > 1 ? "表の○×では、次のどれかまで絞れました" : "おすすめのプラン"}
                  </p>
                  <QTag
                    ref={qRef as never}
                    tabIndex={-1}
                    className="plan-name relative mt-1 inline-block pb-1 text-[clamp(1.6rem,4.5cqw,2.25rem)] font-bold leading-tight outline-none animate-[yn-pop_200ms_ease-out] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#d97706] motion-reduce:animate-none"
                  >
                    {node.plans.map(name).join("・")}
                    <span
                      aria-hidden="true"
                      className="absolute inset-x-0 bottom-0 h-1 origin-left bg-[#d97706] animate-[yn-bar_240ms_ease-out] motion-reduce:animate-none"
                    />
                  </QTag>
                  <div className="mt-4 flex flex-wrap gap-2">
                    {node.plans.map((j) =>
                      table.plans[j].link ? (
                        <a
                          key={j}
                          href={table.plans[j].link}
                          className={`see-plan inline-flex min-h-12 items-center bg-amber-700 px-5 font-bold text-white transition-colors animate-[yn-cta_600ms_ease-out_120ms_both] hover:bg-amber-800 active:bg-amber-900 motion-reduce:animate-none motion-reduce:transition-none ${focusRing}`}
                        >
                          {node.plans.length > 1 ? `${name(j)}を見る` : "このプランを見る"}
                        </a>
                      ) : null,
                    )}
                  </div>
                  {node.plans.every((j) => !table.plans[j].link) && (
                    <p className="mt-3 text-sm leading-relaxed text-stone-600">
                      表に「リンク先」の行を足すと「このプランを見る」ボタンが出ます
                    </p>
                  )}
                </div>
              )}
            </div>
            {node.kind === "q" && (
              // 答えのボタンは問いが替わっても作り直さない(押した側だけ短く光らせて、受け付けたことを示す)
              <div className="mt-3 grid grid-cols-2 gap-2 @2xl:mt-4">
                {([true, false] as const).map((a) => (
                  <button
                    key={`${a}-${flashOn === a ? pathKey : ""}`}
                    type="button"
                    onClick={() => answer(a)}
                    className={`${a ? "answer-yes" : "answer-no"} min-h-14 border border-stone-300 bg-white px-4 text-lg font-bold transition-[background-color,border-color,transform] duration-100 hover:border-[#d97706] hover:bg-amber-50 active:translate-y-px active:border-[#d97706] active:bg-amber-100 motion-reduce:transition-none motion-reduce:active:translate-y-0 ${
                      flashOn === a ? "animate-[yn-flash_320ms_ease-out] motion-reduce:animate-none" : ""
                    } ${focusRing}`}
                  >
                    {a ? "はい" : "いいえ"}
                  </button>
                ))}
              </div>
            )}
            {trail.length > 0 && (
              <ol className="mt-5 space-y-1 text-sm leading-relaxed text-stone-600">
                {trail.map((s, i) => (
                  <li key={i} className="border-l-2 border-[#d97706] pl-3">
                    {s.q} <b className="font-bold text-stone-900">{s.a ? "はい" : "いいえ"}</b>
                  </li>
                ))}
              </ol>
            )}
          </div>

          <div className="mt-6 flex flex-wrap items-center gap-x-6 border-t border-stone-200 pt-2">
            <button
              type="button"
              onClick={back}
              disabled={!path.length}
              className={`back min-h-11 text-[15px] underline underline-offset-4 disabled:text-stone-400 disabled:no-underline ${focusRing}`}
            >
              1問戻る
            </button>
            <button
              type="button"
              onClick={() => go([])}
              disabled={!path.length}
              className={`restart min-h-11 text-[15px] underline underline-offset-4 disabled:text-stone-400 disabled:no-underline ${focusRing}`}
            >
              最初から
            </button>
            <span className="ml-auto hidden text-xs text-stone-500 @md:inline">←キーでも1問戻れます</span>
          </div>
          </div>
        </div>

        {/* 表・質問の木・出力 */}
        <div className="@container min-w-0 border-t border-stone-200 @2xl:border-t-0">
          <div role="tablist" aria-label="表示の切り替え" className="grid grid-cols-3 border-b border-stone-200">
            {TABS.map((t, i) => {
              const on = t.id === tab;
              return (
                <button
                  key={t.id}
                  id={`${uid}-tab-${t.id}`}
                  type="button"
                  role="tab"
                  aria-selected={on}
                  aria-controls={`${uid}-panel`}
                  tabIndex={on ? 0 : -1}
                  data-tab={t.id}
                  onClick={() => setTab(t.id)}
                  onKeyDown={(e) => onTabKey(e, i)}
                  className={`relative min-h-12 border-r border-stone-200 px-3 text-[15px] font-bold last:border-r-0 outline-none focus-visible:z-10 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#d97706] ${
                    on ? "bg-white text-stone-900" : "bg-stone-50 text-stone-500 hover:text-stone-900"
                  }`}
                >
                  {t.label}
                  {t.id === "table" && errors.length > 0 && (
                    <span className="ml-1.5 font-display text-xs tabular-nums text-red-700">{errors.length}</span>
                  )}
                  <span
                    aria-hidden="true"
                    className={`absolute inset-x-0 bottom-0 h-0.5 origin-left bg-[#d97706] transition-transform duration-150 motion-reduce:transition-none ${on ? "scale-x-100" : "scale-x-0"}`}
                  />
                </button>
              );
            })}
          </div>

          <div
            key={tab}
            id={`${uid}-panel`}
            role="tabpanel"
            aria-labelledby={`${uid}-tab-${tab}`}
            className="animate-[yn-fade_120ms_ease-out] px-5 py-6 motion-reduce:animate-none @lg:px-7"
          >
            {tab === "table" && (
              <TablePanel
                uid={uid}
                source={source}
                sampleId={sampleId}
                errors={errors}
                table={table}
                used={sum.used}
                same={sum.same}
                onEdit={edit}
              />
            )}
            {tab === "tree" && (
              <div>
                <p className="text-sm leading-relaxed text-stone-600">
                  問いを押すと、左の診断がその問いから始まります。選んだ道はアンバーで示します
                </p>
                <ul className="tree mt-4 text-[15px] leading-relaxed">
                  <TreeItem node={built.node} at={[]} cur={path} table={table} onJump={(p) => go(p, false)} />
                </ul>
              </div>
            )}
            {tab === "output" && (
              <div>
                <div className="flex flex-wrap gap-2">
                  <button
                    type="button"
                    onClick={() => copy(embed, "embed")}
                    className={`copy-embed min-h-12 bg-stone-900 px-5 font-bold text-white transition-colors hover:bg-amber-800 active:bg-amber-900 motion-reduce:transition-none ${focusRing}`}
                  >
                    LPに貼る
                  </button>
                  <button
                    type="button"
                    onClick={() => copy(shareUrl(), "url")}
                    className={`copy-url min-h-12 border border-stone-300 bg-white px-5 font-bold transition-colors hover:border-[#d97706] active:bg-amber-50 motion-reduce:transition-none ${focusRing}`}
                  >
                    この表のURLをコピー
                  </button>
                </div>
                <div className="mt-2 min-h-6 text-sm text-stone-700" aria-live="polite">
                  {copied?.what === "embed" && <p>HTMLをコピーしました({embedKb}KB)。貼る先の手順は下のとおりです</p>}
                  {copied?.what === "url" && <p>この表を開くURLをコピーしました</p>}
                  {copied?.what === "fail" && (
                    <div className="copy-fail border-l-4 border-[#d97706] bg-amber-50 py-2 pl-4 pr-3">
                      <p className="font-bold text-stone-900">
                        このブラウザでは自動でコピーできませんでした。下の欄のコードを選んでコピーしてください
                      </p>
                      <button
                        type="button"
                        onClick={selectCode}
                        className={`select-code mt-2 min-h-11 border border-[#d97706] bg-white px-4 font-bold text-amber-900 transition-colors hover:bg-amber-100 active:bg-amber-200 motion-reduce:transition-none ${focusRing}`}
                      >
                        コードを選択
                      </button>
                    </div>
                  )}
                </div>
                <dl className="mt-4 divide-y divide-stone-200 border-y border-stone-200 text-[15px] leading-relaxed">
                  <OutRow k="中身">div・style・script の1かたまり({embedKb}KB)。外部の読み込みは無く、クラスはすべて yn- で始まります</OutRow>
                  <OutRow k="静的HTML">body の中の置きたい位置に貼ります</OutRow>
                  <OutRow k="WordPress">カスタムHTMLブロックに貼ります。script を保存できる権限(管理者など)のアカウントで保存してください</OutRow>
                  <OutRow k="リンク">各プランの「リンク先」の行で決まります。https:// ・ / ・ # で始まるものだけ使えます</OutRow>
                </dl>
                <p className="mt-5 text-sm font-bold text-stone-600">書き出したHTMLの表示</p>
                <iframe
                  title="書き出したHTMLの表示"
                  sandbox="allow-scripts"
                  srcDoc={`<!doctype html><html lang="ja"><meta charset="utf-8"><body style="margin:0;padding:12px;font-family:system-ui,sans-serif">${embed}</body></html>`}
                  className="mt-2 h-80 w-full border border-stone-200 bg-white"
                />
                <details
                  className="group mt-4"
                  open={codeOpen}
                  onToggle={(e) => setCodeOpen((e.currentTarget as HTMLDetailsElement).open)}
                >
                  <summary className={`flex min-h-11 cursor-pointer items-center gap-2 text-[15px] font-bold ${focusRing}`}>
                    <span aria-hidden="true" className="font-display inline-block w-3 text-center group-open:hidden">+</span>
                    <span aria-hidden="true" className="font-display hidden w-3 text-center group-open:inline-block">−</span>
                    コードを見る
                  </summary>
                  <label htmlFor={`${uid}-code`} className="sr-only">
                    書き出したHTML
                  </label>
                  <textarea
                    id={`${uid}-code`}
                    ref={codeRef}
                    readOnly
                    value={embed}
                    spellCheck={false}
                    rows={12}
                    onFocus={(e) => e.currentTarget.select()}
                    className="embed-code mt-2 block w-full resize-y whitespace-pre border border-stone-200 bg-stone-50 p-3 font-mono text-xs leading-relaxed outline-none focus-visible:border-[#d97706] focus-visible:outline-2 focus-visible:outline-[#d97706]"
                  />
                </details>
              </div>
            )}
          </div>
        </div>
      </div>
    </section>
  );
}

/** プレビュー冒頭の小さな○×表。質問に使う行・いまの問いの行・残っているプランを、問いと同じアンバーで示す */
function MiniTable({
  table,
  used,
  same,
  answered,
  current,
  currentNo,
  remaining,
  done,
}: {
  table: PlanTable;
  used: Set<number>;
  same: boolean[];
  answered: Map<number, { i: number; a: boolean }>;
  current: number;
  currentNo: number;
  remaining: Set<number>;
  done: boolean;
}) {
  return (
    <div className="mt-3">
      <table className="minitab w-full table-fixed border-collapse text-[13px] leading-snug">
        <caption className="sr-only">料金表の○×と、質問に使う行</caption>
        <colgroup>
          {/* 機能名の列はプランが増えるほど細くする(3プランで46%・5プランで34%)。名前は切らずに折り返す */}
          <col style={{ width: `${Math.max(34, 64 - table.plans.length * 6)}%` }} />
        </colgroup>
        <thead>
          <tr className="border-b border-stone-300">
            <th scope="col" className="py-1 pr-2 text-left text-xs font-bold text-stone-500">
              <span className="hidden @2xl:inline">料金表 → 質問</span>
              {/* 狭い幅は質問に使う行だけを出すので、省いた行の数を見出しで示す */}
              <span className="[word-break:auto-phrase] @2xl:hidden">
                {table.features.length}行中{used.size}行を質問に
              </span>
            </th>
            {table.plans.map((p, j) => {
              const on = remaining.has(j);
              return (
                <th
                  key={p.name}
                  scope="col"
                  title={p.name}
                  className={`px-0.5 py-1 text-center text-[11px] font-bold leading-tight [overflow-wrap:anywhere] transition-[opacity,background-color] duration-150 motion-reduce:transition-none ${
                    on ? (done ? "bg-[#d97706] text-white" : "text-stone-800") : "text-stone-400 opacity-40"
                  }`}
                >
                  {p.name}
                </th>
              );
            })}
          </tr>
        </thead>
        <tbody>
          {table.features.map((f, i) => {
            const u = used.has(i);
            const now = i === current;
            const ans = answered.get(i);
            return (
              <tr
                key={f.name}
                className={`border-b border-stone-200 transition-colors duration-150 motion-reduce:transition-none ${
                  now ? "bg-amber-100" : ""
                } ${u ? "" : "hidden @2xl:table-row"}`}
              >
                <th scope="row" className={`py-1 pr-2 text-left font-medium ${now ? "border-l-4 border-[#d97706] pl-1.5" : "pl-0"}`}>
                  <span className="flex min-w-0 items-start gap-1.5">
                    {now ? (
                      <span className="shrink-0 bg-[#d97706] px-1 font-display text-[11px] leading-4 text-white">Q{currentNo}</span>
                    ) : ans ? (
                      <span className="shrink-0 border border-[#d97706] px-1 font-display text-[11px] leading-[14px] text-amber-800">
                        Q{ans.i + 1}
                      </span>
                    ) : null}
                    <span
                      className={`min-w-0 [overflow-wrap:anywhere] [word-break:auto-phrase] ${now ? "font-bold text-amber-950" : u ? "text-stone-800" : same[i] ? "text-stone-400" : "text-stone-500"}`}
                    >
                      {f.name}
                    </span>
                  </span>
                </th>
                {f.marks.map((m, j) => (
                  <td
                    key={j}
                    className={`py-1 text-center transition-opacity duration-150 motion-reduce:transition-none ${
                      remaining.has(j) ? "" : "opacity-30"
                    } ${now && m ? "font-bold text-amber-900" : m ? (u ? "text-stone-900" : "text-stone-400") : "text-stone-300"}`}
                  >
                    <span aria-label={m ? "あり" : "なし"}>{m ? "○" : "×"}</span>
                  </td>
                ))}
              </tr>
            );
          })}
        </tbody>
      </table>
    </div>
  );
}

function OutRow({ k, children }: { k: string; children: ReactNode }) {
  return (
    <div className="grid gap-1 py-2.5 @md:grid-cols-[7rem_minmax(0,1fr)]">
      <dt className="font-bold">{k}</dt>
      <dd className="text-stone-700">{children}</dd>
    </div>
  );
}

function TablePanel({
  uid,
  source,
  sampleId,
  errors,
  table,
  used,
  same,
  onEdit,
}: {
  uid: string;
  source: string;
  sampleId?: string;
  errors: string[];
  table: PlanTable;
  used: Set<number>;
  same: boolean[];
  onEdit: (v: string) => void;
}) {
  return (
    <div>
      <p className="text-sm font-bold text-stone-600">見本(架空の3業種)</p>
      <ul className="mt-2 grid border-l border-t border-stone-200 @md:grid-cols-3">
        {SAMPLES.map((s) => {
          const on = s.id === sampleId;
          return (
            <li key={s.id} className={`flex items-center justify-between gap-2 border-b border-r border-stone-200 py-2 pl-3 pr-2 ${on ? "bg-amber-50" : ""}`}>
              <span className="leading-tight">
                <span className="block text-[15px] font-bold">{s.label}</span>
                <span className="text-xs text-stone-500">{s.size}</span>
              </span>
              <button
                type="button"
                data-sample={s.id}
                aria-pressed={on}
                onClick={() => onEdit(s.tsv)}
                className={`min-h-11 shrink-0 border px-3 text-sm font-bold transition-colors motion-reduce:transition-none ${
                  on ? "border-[#d97706] bg-white text-amber-800" : "border-stone-300 bg-white hover:border-[#d97706]"
                } ${focusRing}`}
              >
                {on ? "表示中" : "この型を使う"}
              </button>
            </li>
          );
        })}
      </ul>

      <label htmlFor={`${uid}-tsv`} className="mt-5 block text-sm font-bold text-stone-600">
        料金表(スプレッドシートの範囲をコピーして貼る)
      </label>
      <textarea
        id={`${uid}-tsv`}
        value={source}
        onChange={(e) => onEdit(e.target.value)}
        spellCheck={false}
        rows={8}
        aria-describedby={`${uid}-help ${uid}-errors`}
        aria-invalid={errors.length > 0}
        className={`tsv mt-2 block w-full resize-y whitespace-pre border bg-white p-3 font-mono text-[13px] leading-relaxed [tab-size:12] ${
          errors.length ? "border-red-600" : "border-stone-300"
        } outline-none focus-visible:border-[#d97706] focus-visible:outline-2 focus-visible:outline-offset-0 focus-visible:outline-[#d97706]`}
      />
      <p id={`${uid}-help`} className="mt-2 text-sm leading-relaxed text-stone-600">
        1行目はプラン名、2行目からは機能名と○/×。「リンク先」の行は任意です。{MAX_PLANS}プラン×{MAX_FEATURES}機能まで、各セル{MAX_CELL}字まで(リンク先は{MAX_LINK}字)
      </p>
      <div id={`${uid}-errors`} aria-live="polite">
        {errors.length > 0 && (
          <ul className="errors mt-3 space-y-1 border-l-4 border-red-600 py-1 pl-4 text-[15px] leading-relaxed text-red-700">
            {errors.slice(0, 8).map((e) => (
              <li key={e}>{e}</li>
            ))}
            {errors.length > 8 && <li>ほか{errors.length - 8}件</li>}
          </ul>
        )}
      </div>

      <p className="mt-6 text-sm font-bold text-stone-600">
        {errors.length ? "直前の正しい表" : "読み取った表"}
      </p>
      <div className="mt-2 overflow-x-auto" role="region" aria-label="読み取った表" tabIndex={0}>
        <table className="w-full min-w-[22rem] border-collapse text-[14px]">
          <thead>
            <tr className="border-b border-stone-300">
              <th scope="col" className="py-2 pr-3 text-left font-bold text-stone-500">機能</th>
              {table.plans.map((p) => (
                <th key={p.name} scope="col" className="px-2 py-2 text-center font-bold">
                  {p.name}
                </th>
              ))}
              <th scope="col" className="py-2 pl-3 text-left font-bold text-stone-500">質問</th>
            </tr>
          </thead>
          <tbody>
            {table.features.map((f, i) => {
              const s = same[i];
              const u = used.has(i);
              return (
                <tr key={f.name} className={`border-b border-stone-200 ${s ? "bg-stone-50 text-stone-400" : ""}`}>
                  <th scope="row" className={`py-2 pr-3 text-left font-medium ${u ? "text-stone-900" : ""}`}>
                    {f.name}
                  </th>
                  {f.marks.map((m, j) => (
                    <td key={j} className={`px-2 py-2 text-center ${s ? "" : m ? "text-stone-900" : "text-stone-400"}`}>
                      <span aria-label={m ? "あり" : "なし"}>{m ? "○" : "×"}</span>
                    </td>
                  ))}
                  <td className="whitespace-nowrap py-2 pl-3 text-[13px]">
                    {s ? (
                      <span>質問不要</span>
                    ) : u ? (
                      <span className="font-bold text-amber-800">使う</span>
                    ) : (
                      <span className="text-stone-500">無くても選べる</span>
                    )}
                  </td>
                </tr>
              );
            })}
          </tbody>
        </table>
      </div>
    </div>
  );
}

function TreeItem({
  node,
  at,
  cur,
  table,
  onJump,
  answer,
}: {
  node: TreeNode;
  at: boolean[];
  cur: boolean[];
  table: PlanTable;
  onJump: (p: boolean[]) => void;
  answer?: boolean;
}) {
  const onPath = at.length <= cur.length && at.every((a, i) => a === cur[i]);
  const here = onPath && at.length === cur.length;
  const label =
    answer === undefined ? null : (
      <span className={`mr-2 font-bold ${onPath ? "text-amber-800" : "text-stone-500"}`}>{answer ? "はい" : "いいえ"}</span>
    );
  if (node.kind === "r") {
    const many = node.plans.length > 1;
    return (
      <li className="py-1">
        {label}
        <button
          type="button"
          onClick={() => onJump(at)}
          aria-current={here ? "step" : undefined}
          className={`min-h-9 px-1 text-left ${focusRing} ${
            onPath ? "bg-amber-50 font-bold text-amber-900" : many ? "text-stone-900" : "text-stone-800"
          }`}
        >
          {node.plans.map((j) => table.plans[j].name).join("・")}
          {many && <span className="ml-2 text-xs font-bold text-amber-800">区別できない</span>}
        </button>
      </li>
    );
  }
  return (
    <li className="py-1">
      {label}
      <button
        type="button"
        onClick={() => onJump(at)}
        aria-current={here ? "step" : undefined}
        className={`min-h-9 px-1 text-left ${focusRing} ${onPath ? "bg-amber-50 font-bold text-amber-900" : "text-stone-900"}`}
      >
        <span className="font-display mr-1.5 text-xs tabular-nums text-stone-500">Q{at.length + 1}</span>
        {questionOf(table.features[node.f].name)}
      </button>
      <ul className={`ml-2 mt-1 border-l pl-4 ${onPath && cur.length > at.length ? "border-[#d97706]" : "border-stone-200"}`}>
        <TreeItem node={node.yes} at={[...at, true]} cur={cur} table={table} onJump={onJump} answer />
        <TreeItem node={node.no} at={[...at, false]} cur={cur} table={table} onJump={onJump} answer={false} />
      </ul>
    </li>
  );
}

shadcn CLI でプロジェクトに追加

npx shadcn@latest add https://designs.first-ch.com/r/yesno-chart-flow.json