BLK-108Blocks

Build a Yes/No Plan Finder from a Pricing Table | Embeddable HTML

Paste a pricing table (plans × features marked ✓/✗) as TSV and this React block builds a branching yes/no plan finder with the fewest possible questions, ready to embed on a landing page as plain HTML. A small ✓/✗ table sits at the top of the quiz: the rows used as questions and the current question share the same amber highlight, and only the columns of plans still in the running stay dark as you answer. Plans whose ✓/✗ columns are identical are flagged in large type; features shared by every plan are greyed out as “no question needed”. Results show the plan name and a “See this plan” button. “Copy for landing page” copies one div + style + script snippet (if the clipboard is blocked, the code box opens and a “Select code” button selects it) that runs in static HTML and in a WordPress Custom HTML block (no external loads, yn- prefixed classes). The table is stored in the URL hash for sharing, with three fictional sample industries. Up to 5 plans × 10 features. No dependencies.

Preview

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

最短2問で選べる

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

診断プレビュー

質問 1 / 最大 2

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

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

見本(架空の3業種)

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

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

読み取った表

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

Read the full description

Paste a pricing table (plans × features marked ✓/✗) as TSV and this React block builds a branching yes/no plan finder with the fewest possible questions, ready to embed on a landing page as plain HTML. A small ✓/✗ table sits at the top of the quiz: the rows used as questions and the current question share the same amber highlight, and only the columns of plans still in the running stay dark as you answer. Plans whose ✓/✗ columns are identical are flagged in large type; features shared by every plan are greyed out as “no question needed”. Results show the plan name and a “See this plan” button. “Copy for landing page” copies one div + style + script snippet (if the clipboard is blocked, the code box opens and a “Select code” button selects it) that runs in static HTML and in a WordPress Custom HTML block (no external loads, yn- prefixed classes). The table is stored in the URL hash for sharing, with three fictional sample industries. Up to 5 plans × 10 features. No dependencies.

Added:
2026-10-30
Dependencies:
None
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>
  );
}

Add to your project via shadcn CLI

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