開発者コンソール

刻印の規則(v0)

ハッシュから刻印を描く規則です。同じ規則で、誰でも同じ刻印を描けます。

入力

32 バイトのハッシュを入力にします。署名では、Intent の message_digest(base64url)を戻した 32 バイトです。バイトを先頭から d0, d1, … と呼びます。

message_digest (base64url)
TxO-68UEwD6K9RXDXelOszsebQO044wzHSUfwyTSQQY

bytes (hex)
4f13beebc504c03e8af515c35de94eb33b1e6d03b4e38c331d251fc324d24106

d0 = 0x4f, d1 = 0x13, d2 = 0xbe, … d31 = 0x06

刻印は、人が違いに気づくための補助です。見た目の似た刻印は探せば作れるので、署名が正しいかどうかは署名の検証で判定してください。

帯の並び

viewBox は -520 -520 1040 1040、中心は (0, 0) です。外から内へ、いつも同じ並びで描きます。

  • 縁r 500・481・464・444–458
    外周の罫、ハッシュの微小文字(full のみ)、内側の罫、刻みです。
  • 網中心 r 384
    位相をずらした正弦の束です。
  • 罫r 318
    網と地紋を分けます。
  • 地紋r 196–304
    半径をずらした波線を重ねます。
  • 花最大 r 176
    内トロコイド、または外トロコイドです。
  • 見当r 26・±44
    中心の円と十字です。

値の決め方

バイトから値
pick(list, b) = list[b mod 長さ]        u(b) = b / 255        clamp(v, lo, hi)

網(d0–d7)
  lobes    = pick([12, 14, 15, 16, 18, 20, 21, 24], d0)
  amp      = clamp((1 + u(d2) × 1.2) × 384 / lobes, 18, 46)
  strands  = round(clamp(pick([14, 16, 18, 20, 24], d1) × 30 / amp, 10, 28))
  mod      = pick([0, 2, 3, 4, 5, 6, 8, 9], d3)
  modDepth = 0.12 + u(d4) × 0.3
  twist    = u(d5) × 2π
  beat     = pick([2, 3, 4, 5], d6)
  beatAmp  = u(d7) × 6

地紋(d8–d14)
  lines    = pick([16, 20, 24, 28], d8)
  lobes    = pick([3, 4, 5, 6, 7, 8, 9, 10, 12], d9)
  amp      = (304 − 196) / (lines − 1) × (0.9 + u(d10) × 2.4)
  drift    = (u(d11) − 0.5) × 0.7
  beat     = pick([2, 3, 4, 5], d12)
  beatAmp  = u(d13) × 5
  twist    = u(d14) × 2π

花(d15–d19)
  petals   = pick([5, 6, 7, 8, 9, 10, 11, 12, 13], d15)
  strands  = pick([2, 3, 4, 5, 6], d16)
  ratio    = 0.36 + u(d17) × 0.34
  twist    = u(d18) × 2π
  epi      = d19 が奇数

刻み(d20–d21)
  count    = pick([96, 120, 144, 160, 180], d20)
  twist    = u(d21) × 2π

曲線

曲線
網     j = 0 … strands − 1、θ は 2π を lobes × 28 等分
       φ = 2π j / strands + twist
       E(θ) = 1 − modDepth + modDepth × cos(mod θ)        mod = 0 なら 1
       r(θ) = 384 + amp × sin(lobes θ + φ) × E(θ) + beatAmp × sin((lobes − beat) θ − φ)

地紋   j = 0 … lines − 1、θ は 2π を max(lobes, beat) × 36 等分
       r0 = 196 + amp、r1 = 304 − amp
       base = r0 + (r1 − r0) × j / (lines − 1)
       φ = twist + j × drift
       r(θ) = base + amp × sin(lobes θ + φ) + beatAmp × sin(beat θ − φ / 2)

花     j = 0 … strands − 1、t は 2π を petals × 48 等分
       a = 176 / (1 + ratio)、b = 176 − a
       k = petals + 1(epi)、または −(petals − 1)
       z(t) = a e^{it} + b e^{ikt} を、twist + 2π j / (strands × petals) だけ回す

刻み   半径 444 から 458 への線分を count 本。角度は twist から等間隔
見当   半径 26 の円と、−44 から 44 の縦横の線

点は (r cos θ, r sin θ)。座標は小数第 1 位で丸め、各線を閉じた折れ線にします。

彫り始め

線ごとにハッシュの 1 バイトを角度に読み、そこから彫り始めます(形は変わりません)。

彫り始め
i = 0, 1, … は彫る順(外 → 内)。縁の罫・刻み・網・罫・地紋・花・見当の順に数えます
s(i) = (d[i mod 32] + 97 × ⌊i / 32⌋) mod 256

折れ線   等分点のうち ⌊s × 点の数 / 256⌋ 番目から書き始める
円       角度 2π s / 256 の点 P から、P → −P → P の半円の弧を二つ
         P の座標は 0 から遠い側へ小数第 1 位に切り上げる(P が円の内側に入ると弧の中心がずれるため)
刻み     ⌊s × count / 256⌋ 本目から書き始める
見当     円は上と同じ。十字は変えない

どの線も θ(花は t)の増える向き、画面では時計回りに進みます。

表示サイズと段

表示サイズ(CSS px)で段を選び、小さいほど線を間引きます。線の太さは viewBox 単位で「px × 1040 ÷ 表示サイズ」です。

  • full240px 以上
    すべての帯と微小文字を描きます。細線 0.7px、罫 1px です。
  • mid120–239px
    微小文字を外し、刻みを半分、網・地紋・花の本数を半分にします。細線 0.6px です。
  • small48–119px
    刻みを外し、本数を 4 分の 1 にします(網は 4 本以上、地紋は 5 本以上)。
  • pip21–47px
    外周の罫と、比を 0.3 までにした花を 1 本だけ描きます。
  • dot20px 以下
    外周の罫と、中心の円だけです。

インクの状態

  • 未定着 — 署名を待っているもの。書いた直後のインクの青で描きます。
  • 定着 — 署名が済んだもの。黒で描き、線を 1.12 倍にします。
  • 腐食 — 内容が合わないもの。錆の色で描き、罫以外の線を途切れさせます(pathLength 1 に対して 0.011 と 0.007 の破線)。

試験値

実装を確かめるための値です。小数は 4 桁で丸めています。一つめは Clavis のマークの元になる約束文です。

試験値(JSON)
[
  {
    "input": "Clavis は Passkey の署名で、誰が・何に・いつ同意したかを再検証できる記録を、プロダクトに埋め込みます。",
    "digest": "R09cAcXyJdHJY7r8H8dGVxAfultyVlwNS_6eq9qsv08",
    "net": { "lobes": 24, "strands": 28, "amp": 22.9271, "mod": 2, "modDepth": 0.3518, "beat": 3, "beatAmp": 4.9176 },
    "field": { "lines": 20, "lobes": 3, "amp": 15.0665, "drift": 0.3418, "beat": 5, "beatAmp": 3.902 },
    "flower": { "petals": 11, "strands": 3, "ratio": 0.4013, "epi": true },
    "ticks": { "count": 180 },
    "full_640px": { "paths": 56, "first_net_index": 3, "first_net_start": 1, "first_net_point": "M390.1 7.3" }
  },
  {
    "input": "",
    "digest": "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU",
    "net": { "lobes": 16, "strands": 10, "amp": 46, "mod": 3, "modDepth": 0.2988, "beat": 2, "beatAmp": 0.4706 },
    "field": { "lines": 24, "lobes": 12, "amp": 15.0095, "drift": 0.199, "beat": 3, "beatAmp": 2.1765 },
    "flower": { "petals": 5, "strands": 6, "ratio": 0.592, "epi": false },
    "ticks": { "count": 96 },
    "full_640px": { "paths": 45, "first_net_index": 3, "first_net_start": 66, "first_net_point": "M-16.9 402.5" }
  }
]

描いて確かめる

SHA-256 R09cAcXyJdHJY7r8H8dGVxAfultyVlwNS_6eq9qsv08 · SHA-256 R09cAcXyJdHJY7r8H8dGVxAfultyVlwNS_6eq9qsv08 · SHA-256 R09cAcXyJdHJY7r8H8dGVxAfultyVlwNS_6eq9qsv08 · SHA-256 R09cAcXyJdHJY7r8H8dGVxAfultyVlwNS_6eq9qsv08 · SHA-256 R09cAcXyJdHJY7r8H8dGVxAfultyVlwNS_6eq9qsv08 · SHA-256 R09cAcXyJdHJY7r8H8dGVxAfultyVlwNS_6eq9qsv08 · SHA-256 R09cAcXyJdHJY7r8H8dGVxAfultyVlwNS_6eq9qsv08 ·
240px
96px
32px
net    lobes 24 · strands 28 · mod 2
field  lines 20 · lobes 3
flower petals 11 · strands 3 · epi
ticks  180

参照実装

この規則の TypeScript 実装です。DOM に依存せず、SVG の文字列か、線ごとの path を返します。

seal.ts を開く
/**
 * Clavis 刻印(Seal)v0 — 照合値(SHA-256 の 32 バイト)から印を決定的に描く。
 *
 * 同じ照合値からは同じ刻印、1 ビット違えば別の刻印になる。刻印は人が違いに気づくための補助で、
 * 照合は暗号の検証が決める(見た目の似た刻印は探せば作れる)。規則は公開仕様
 * (docs.clavis.nexus/spec/seal)と同じで、第三者が同じ刻印を描けることを前提にしている。
 *
 * 文法(外 → 内。viewBox は -520 -520 1040 1040、中心 0,0、外周 500)
 *   縁    外周の罫、照合値そのものの微小文字(full のみ)、硬貨の縁の刻み(d20–d21)
 *   網    位相をずらした正弦の束(d0–d7)
 *   地紋  半径をずらした波線を、位相を流しながら重ねる(d8–d14)
 *   花    内・外トロコイド(d15–d19)
 *   見当  中心のトンボ。押す場所
 * 帯の並びは常に同じで、値だけが照合値で変わる。
 *
 * 彫り始めも照合値で決まる。線ごとに照合値の 1 バイトを角度に読み、そこから時計回りに彫る
 * (`sealStart`)。形は変わらず、刻む動きだけが刻印ごと・線ごとに違う。
 *
 * DOM に依存しない。React では `sealGeometry` の `paths` を `<path>` に写す
 * (Trusted Types のため innerHTML は使わない)。書き出しは `sealSvg`。
 */

export const CLAVIS_SEAL_VERSION = "v0" as const;

/** Clavis のマークの元。North Star の約束文(docs/product/clavis-sign-north-star.md §1)。 */
export const CLAVIS_MARK_SOURCE =
  "Clavis は Passkey の署名で、誰が・何に・いつ同意したかを再検証できる記録を、プロダクトに埋め込みます。";

/** `CLAVIS_MARK_SOURCE` の UTF-8 を SHA-256 にかけた値(base64url)。マークはこの刻印。 */
export const CLAVIS_MARK_DIGEST = "R09cAcXyJdHJY7r8H8dGVxAfultyVlwNS_6eq9qsv08";

export type SealLod = "full" | "mid" | "small" | "pip" | "dot";

/** 刻印のインク。色は tokens(clavis.css)の `--wet` / `--ink` / `--rust` が持つ。`current` は置き場の文字色(ボタンの中など)。 */
export type SealInk = "wet" | "fixed" | "rust" | "blank" | "current";

export type SealParams = {
  net: {
    lobes: number;
    strands: number;
    amp: number;
    mod: number;
    modDepth: number;
    twist: number;
    beat: number;
    beatAmp: number;
  };
  field: {
    lines: number;
    lobes: number;
    amp: number;
    drift: number;
    beat: number;
    beatAmp: number;
    twist: number;
  };
  flower: { petals: number; strands: number; ratio: number; twist: number; epi: boolean };
  ticks: { count: number; twist: number };
};

export type SealPathKind = "ring" | "tick" | "line" | "core";

export type SealPath = {
  /** 彫る順(外 → 内)。CSS の `--i` に写す。 */
  index: number;
  /** 彫り始め(0–255。256 で一周)。`d` はこの角度から始まる。 */
  start: number;
  kind: SealPathKind;
  d: string;
  /** 線の太さ(viewBox 単位)。CSS の `--w` に写す。 */
  width: number;
};

export type SealMicrotext = {
  /** 微小文字を載せる円の path(12 時から時計回り)。 */
  ringPath: string;
  text: string;
  textLength: number;
};

export type SealGeometry = {
  version: typeof CLAVIS_SEAL_VERSION;
  lod: SealLod;
  params: SealParams;
  paths: SealPath[];
  microtext: SealMicrotext | null;
};

export const SEAL_VIEWBOX = "-520 -520 1040 1040";

/** 帯の位置(viewBox 単位)。 */
export const SEAL_BANDS = { net: 384, field: [196, 304] as const, flower: 176 };

const TAU = Math.PI * 2;
const pick = <T>(list: readonly T[], byte: number): T => list[byte % list.length]!;
const unit = (byte: number) => byte / 255;
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
const fmt = (n: number) => Math.round(n * 10) / 10;

/* ---------- 照合値 ---------- */

const B64U = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";

export function bytesToBase64Url(bytes: Uint8Array): string {
  let out = "";
  let i = 0;
  for (; i + 2 < bytes.length; i += 3) {
    const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8) | bytes[i + 2]!;
    out += B64U[(n >> 18) & 63]! + B64U[(n >> 12) & 63]! + B64U[(n >> 6) & 63]! + B64U[n & 63]!;
  }
  const rest = bytes.length - i;
  if (rest === 1) {
    const n = bytes[i]! << 16;
    out += B64U[(n >> 18) & 63]! + B64U[(n >> 12) & 63]!;
  } else if (rest === 2) {
    const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8);
    out += B64U[(n >> 18) & 63]! + B64U[(n >> 12) & 63]! + B64U[(n >> 6) & 63]!;
  }
  return out;
}

/**
 * 照合値を 32 バイトにする。base64url(SignProof の `message_digest` と同じ形)か、32 バイト以上の配列。
 * 形が合わなければ null(刻印を描かない)。
 */
export function sealDigestBytes(input: string | Uint8Array | null | undefined): Uint8Array | null {
  if (!input) return null;
  if (input instanceof Uint8Array) return input.length >= 32 ? input.slice(0, 32) : null;
  const raw = input.trim();
  if (!/^[A-Za-z0-9_-]{43,}$/.test(raw)) return null;
  const bytes: number[] = [];
  let acc = 0;
  let bits = 0;
  for (const ch of raw) {
    acc = (acc << 6) | B64U.indexOf(ch);
    bits += 6;
    if (bits >= 8) {
      bits -= 8;
      bytes.push((acc >> bits) & 0xff);
    }
  }
  return bytes.length >= 32 ? Uint8Array.from(bytes.slice(0, 32)) : null;
}

/* ---------- 刻印の値 ---------- */

/** 照合値から刻印の値を決める。帯ごとに別のバイトを読む。 */
export function sealParams(d: Uint8Array): SealParams {
  // 網: 振幅は「傾き」から決め、葉数が多いほど浅くする(網目の角度を保つ)。
  // 振幅が大きい帯ほど本数を減らし、網目の明るさを揃える。
  const nLobes = pick([12, 14, 15, 16, 18, 20, 21, 24], d[0]!);
  const nAmp = clamp(((1 + unit(d[2]!) * 1.2) * SEAL_BANDS.net) / nLobes, 18, 46);
  const net = {
    lobes: nLobes,
    strands: Math.round(clamp(pick([14, 16, 18, 20, 24], d[1]!) * (30 / nAmp), 10, 28)),
    amp: nAmp,
    mod: pick([0, 2, 3, 4, 5, 6, 8, 9], d[3]!),
    modDepth: 0.12 + unit(d[4]!) * 0.3,
    twist: unit(d[5]!) * TAU,
    beat: pick([2, 3, 4, 5], d[6]!),
    beatAmp: unit(d[7]!) * 6,
  };
  // 地紋: 波線の間隔の何倍揺らすかで、線が交わる深さが決まる。
  const fLines = pick([16, 20, 24, 28], d[8]!);
  const field = {
    lines: fLines,
    lobes: pick([3, 4, 5, 6, 7, 8, 9, 10, 12], d[9]!),
    amp: ((SEAL_BANDS.field[1] - SEAL_BANDS.field[0]) / (fLines - 1)) * (0.9 + unit(d[10]!) * 2.4),
    drift: (unit(d[11]!) - 0.5) * 0.7,
    beat: pick([2, 3, 4, 5], d[12]!),
    beatAmp: unit(d[13]!) * 5,
    twist: unit(d[14]!) * TAU,
  };
  const flower = {
    petals: pick([5, 6, 7, 8, 9, 10, 11, 12, 13], d[15]!),
    strands: pick([2, 3, 4, 5, 6], d[16]!),
    ratio: 0.36 + unit(d[17]!) * 0.34,
    twist: unit(d[18]!) * TAU,
    epi: (d[19]! & 1) === 1,
  };
  const ticks = { count: pick([96, 120, 144, 160, 180], d[20]!), twist: unit(d[21]!) * TAU };
  return { net, field, flower, ticks };
}

/* ---------- 曲線 ---------- */

/** 閉じた折れ線。等分点 `⌊start × 点の数 / 256⌋` から書き始める(点の並びを回すだけで、形は同じ)。 */
function polyline(points: number[], start: number): string {
  const n = points.length / 2;
  const k = Math.floor((start * n) / 256);
  const at = (i: number) => {
    const j = ((k + i) % n) * 2;
    return `${fmt(points[j]!)} ${fmt(points[j + 1]!)}`;
  };
  let s = `M${at(0)}`;
  for (let i = 1; i < n; i++) s += ` ${at(i)}`;
  return `${s}Z`;
}

/** 網。r(θ) = rc + A·sin(nθ + φ)·(1 − μ + μ·cos(kθ)) + B·sin((n − m)θ − φ) を、φ をずらして束ねる。 */
function netPaths(rc: number, p: SealParams["net"], strandCount: number, perLobe: number): number[][] {
  const paths: number[][] = [];
  const steps = p.lobes * perLobe;
  for (let j = 0; j < strandCount; j++) {
    const phase = (TAU * j) / strandCount + p.twist;
    const pts = new Array<number>(steps * 2);
    for (let i = 0; i < steps; i++) {
      const t = (TAU * i) / steps;
      const envelope = p.mod ? 1 - p.modDepth + p.modDepth * Math.cos(p.mod * t) : 1;
      let r = rc + p.amp * Math.sin(p.lobes * t + phase) * envelope;
      if (p.beatAmp) r += p.beatAmp * Math.sin((p.lobes - p.beat) * t - phase);
      pts[i * 2] = r * Math.cos(t);
      pts[i * 2 + 1] = r * Math.sin(t);
    }
    paths.push(pts);
  }
  return paths;
}

/** 地紋。半径をずらした波線を、位相を少しずつ流しながら重ねる(紙幣の細線地紋)。 */
function fieldPaths(r0: number, r1: number, p: SealParams["field"], perLobe: number): number[][] {
  const paths: number[][] = [];
  const steps = Math.max(p.lobes, p.beat) * perLobe;
  for (let j = 0; j < p.lines; j++) {
    const base = r0 + ((r1 - r0) * j) / (p.lines - 1);
    const phase = p.twist + j * p.drift;
    const pts = new Array<number>(steps * 2);
    for (let i = 0; i < steps; i++) {
      const t = (TAU * i) / steps;
      const r = base + p.amp * Math.sin(p.lobes * t + phase) + p.beatAmp * Math.sin(p.beat * t - phase * 0.5);
      pts[i * 2] = r * Math.cos(t);
      pts[i * 2 + 1] = r * Math.sin(t);
    }
    paths.push(pts);
  }
  return paths;
}

/** 花。z(t) = a·e^{it} + b·e^{i·k·t}。k = −(P−1) で内トロコイド、P+1 で外トロコイド。 */
function flowerPaths(maxR: number, p: SealParams["flower"], strandCount: number, steps: number): number[][] {
  const paths: number[][] = [];
  const k = p.epi ? p.petals + 1 : -(p.petals - 1);
  const a = maxR / (1 + p.ratio);
  const b = maxR - a;
  for (let j = 0; j < strandCount; j++) {
    const rot = p.twist + (TAU * j) / (strandCount * p.petals);
    const pts = new Array<number>(steps * 2);
    for (let i = 0; i < steps; i++) {
      const t = (TAU * i) / steps;
      const x = a * Math.cos(t) + b * Math.cos(k * t);
      const y = a * Math.sin(t) + b * Math.sin(k * t);
      pts[i * 2] = x * Math.cos(rot) - y * Math.sin(rot);
      pts[i * 2 + 1] = x * Math.sin(rot) + y * Math.cos(rot);
    }
    paths.push(pts);
  }
  return paths;
}

/** 0 から遠い側へ、小数第 1 位に切り上げる。 */
const fmtOut = (n: number) => (n < 0 ? -1 : 1) * (Math.ceil(Math.abs(n) * 10 - 1e-9) / 10);

/**
 * 円。角度 `2π × start / 256` の点から、半円の弧を二つ。
 * 点が円の内側に丸まると弧の中心がずれるので、外側へ丸める(半径は SVG の規則で点に合わせて広がる)。
 */
function circle(r: number, start: number): string {
  const a = (TAU * start) / 256;
  const x = fmtOut(r * Math.cos(a));
  const y = fmtOut(r * Math.sin(a));
  return `M${x} ${y}A${r} ${r} 0 1 1 ${fmt(-x)} ${fmt(-y)}A${r} ${r} 0 1 1 ${x} ${y}Z`;
}

/** 縁の刻み(硬貨のギザ)。`⌊start × count / 256⌋` 本目から書き始める。 */
function reeding(r0: number, r1: number, count: number, twist: number, start: number): string {
  const k = Math.floor((start * count) / 256);
  let s = "";
  for (let j = 0; j < count; j++) {
    const i = (k + j) % count;
    const t = twist + (TAU * i) / count;
    const c = Math.cos(t);
    const n = Math.sin(t);
    s += `M${fmt(r0 * c)} ${fmt(r0 * n)}L${fmt(r1 * c)} ${fmt(r1 * n)}`;
  }
  return s;
}

/** 中心の見当(トンボ)。押す場所であり、版の芯。 */
const register = (r: number, arm: number, start: number) => `${circle(r, start)}M${-arm} 0H${arm}M0 ${-arm}V${arm}`;

/**
 * 線 `index`(彫る順)の彫り始め(0–255)。照合値のバイトをそのまま角度に読む。
 * 32 本を超えたら一周ごとに 97 ずらし、同じバイトを読む線どうしが同じ角度から始まらないようにする。
 */
export function sealStart(digest: Uint8Array, index: number): number {
  return (digest[index % 32]! + 97 * Math.floor(index / 32)) % 256;
}

/* ---------- 段と幾何 ---------- */

/** 表示サイズ(CSS px)から段を選ぶ。小さくなるほど線を間引き、形だけを残す。 */
export function sealLodFor(px: number): SealLod {
  if (px >= 240) return "full";
  if (px >= 120) return "mid";
  if (px >= 48) return "small";
  if (px > 20) return "pip";
  return "dot";
}

const HAIR_PX: Record<SealLod, number> = { full: 0.7, mid: 0.6, small: 0.55, pip: 1.1, dot: 1.5 };
const RULE_PX: Record<SealLod, number> = { full: 1, mid: 0.9, small: 0.9, pip: 1.4, dot: 1.6 };

/**
 * 刻印の幾何。`px` は表示サイズで、線の太さを表示サイズから決める(拡大しても毛線のまま)。
 */
export function sealGeometry(digest: Uint8Array, options: { px: number; lod?: SealLod }): SealGeometry {
  const px = Math.max(1, options.px);
  const lod = options.lod ?? sealLodFor(px);
  const params = sealParams(digest);
  const unitPx = 1040 / px;
  const hair = HAIR_PX[lod] * unitPx;
  const rule = RULE_PX[lod] * unitPx;
  const paths: SealPath[] = [];
  /** 線を一本足す。`draw` は彫り始めを受け取って path を返す。 */
  const add = (draw: (start: number) => string, width: number, kind: SealPathKind) => {
    const index = paths.length;
    const start = sealStart(digest, index);
    paths.push({ index, start, kind, d: draw(start), width: Math.round(width * 100) / 100 });
  };
  const ring = (r: number) => (start: number) => circle(r, start);
  const curve = (pts: number[]) => (start: number) => polyline(pts, start);
  const done = (microtext: SealMicrotext | null = null): SealGeometry => ({
    version: CLAVIS_SEAL_VERSION,
    lod,
    params,
    paths,
    microtext,
  });

  if (lod === "dot") {
    add(ring(500 - rule / 2), rule, "ring");
    add(ring(150), 300, "core");
    return done();
  }
  if (lod === "pip") {
    add(ring(500 - rule / 2), rule, "ring");
    const petals = { ...params.flower, ratio: Math.min(params.flower.ratio, 0.3) };
    for (const pts of flowerPaths(330, petals, 1, petals.petals * 36)) add(curve(pts), hair, "line");
    return done();
  }

  const full = lod === "full";
  const thin = lod === "full" ? 1 : lod === "mid" ? 2 : 4;
  let microtext: SealMicrotext | null = null;
  add(ring(500), rule, "ring");
  if (full) {
    microtext = {
      ringPath: "M0 -481A481 481 0 1 1 -0.01 -481",
      text: `SHA-256 ${bytesToBase64Url(digest)} · `.repeat(7),
      textLength: Math.round(TAU * 481 - 6),
    };
    add(ring(464), hair, "ring");
    add((start) => reeding(444, 458, params.ticks.count, params.ticks.twist, start), hair, "tick");
  } else if (lod === "mid") {
    add(ring(464), hair, "ring");
    add((start) => reeding(440, 458, params.ticks.count / 2, params.ticks.twist, start), hair * 1.2, "tick");
  }

  const per = full ? 28 : lod === "mid" ? 18 : 14;
  for (const pts of netPaths(SEAL_BANDS.net, params.net, Math.max(4, Math.ceil(params.net.strands / thin)), per)) {
    add(curve(pts), hair, "line");
  }
  add(ring(318), hair, "ring");
  const f = { ...params.field, lines: Math.max(5, Math.ceil(params.field.lines / thin)) };
  for (const pts of fieldPaths(SEAL_BANDS.field[0] + f.amp, SEAL_BANDS.field[1] - f.amp, f, per + 8)) {
    add(curve(pts), hair, "line");
  }
  const petals = Math.max(1, Math.ceil(params.flower.strands / thin));
  for (const pts of flowerPaths(SEAL_BANDS.flower, params.flower, petals, params.flower.petals * (full ? 48 : 30))) {
    add(curve(pts), hair, "line");
  }
  add((start) => register(26, 44, start), hair * 1.2, "core");
  return done(microtext);
}

/* ---------- 画面に描く ---------- */

/** 直線をこの区間数より長く続けない(Safari の破線の都合。`sealDrawPath`) */
const LINE_RUN = 128;

/**
 * 画面で刻むための `d`。形は `d` と同じで、直線が続くところへ `LINE_RUN` 区間ごとに一つ、
 * まっすぐな 3 次曲線(制御点を線分の 1/3 と 2/3 に置く)を挟む。
 * Safari は直線だけが 256 区間を超えて続く線で破線を数か所からやり直すので、刻む線が
 * 何方向からも同時に現れる(Safari 27 で確認)。曲線が一つ混ざると、線の頭から刻む。
 * 仕様の `d`(試験値・書き出し)はこれを通さない。
 */
export function sealDrawPath(d: string): string {
  const tokens = d.match(/[MLHVAZ]|-?\d*\.?\d+(?:e-?\d+)?/gi) ?? [];
  let i = 0;
  const num = () => Number(tokens[i++]);
  const isNum = () => i < tokens.length && !/^[A-Z]$/i.test(tokens[i]!);
  let out = "";
  let mode = "";
  let x = 0;
  let y = 0;
  let sx = 0;
  let sy = 0;
  let run = 0;
  const cmd = (c: string) => {
    if (mode !== c) out += c;
    else out += " ";
    mode = c;
  };
  const third = (a: number, b: number, k: number) => Math.round((a + ((b - a) * k) / 3) * 100) / 100;
  const lineTo = (nx: number, ny: number) => {
    run += 1;
    if (run >= LINE_RUN) {
      cmd("C");
      out += `${third(x, nx, 1)} ${third(y, ny, 1)} ${third(x, nx, 2)} ${third(y, ny, 2)} ${nx} ${ny}`;
      run = 0;
    } else {
      cmd("L");
      out += `${nx} ${ny}`;
    }
    x = nx;
    y = ny;
  };
  while (i < tokens.length) {
    const c = tokens[i++]!.toUpperCase();
    if (c === "M") {
      x = sx = num();
      y = sy = num();
      cmd("M");
      out += `${x} ${y}`;
      while (isNum()) lineTo(num(), num());
    } else if (c === "L") {
      while (isNum()) lineTo(num(), num());
    } else if (c === "H") {
      while (isNum()) lineTo(num(), y);
    } else if (c === "V") {
      while (isNum()) lineTo(x, num());
    } else if (c === "A") {
      while (isNum()) {
        const arc = [num(), num(), num(), num(), num(), num(), num()];
        cmd("A");
        out += arc.join(" ");
        x = arc[5]!;
        y = arc[6]!;
        run = 0;
      }
    } else if (c === "Z") {
      cmd("Z");
      x = sx;
      y = sy;
      run += 1;
    }
  }
  return out;
}

/* ---------- 書き出し ---------- */

const escapeXml = (s: string) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");

/**
 * 単体の SVG 文書(favicon・OG・メール・印刷)。CSS に頼らず属性で塗る。
 * `background` を渡すと、その色の円盤の上に刻む(アイコン用)。
 */
export function sealSvg(
  digest: Uint8Array,
  options: { px: number; lod?: SealLod; color?: string; background?: string; title?: string },
): string {
  const g = sealGeometry(digest, options);
  const color = options.color ?? "#191512";
  const parts: string[] = [];
  if (options.title) parts.push(`<title>${escapeXml(options.title)}</title>`);
  if (options.background) parts.push(`<circle r="520" fill="${options.background}"/>`);
  if (g.microtext) {
    parts.push(
      `<path id="r" d="${g.microtext.ringPath}" fill="none"/>` +
        `<text fill="${color}" font-family="ui-monospace, Menlo, monospace" font-size="11" letter-spacing=".5">` +
        `<textPath href="#r" textLength="${g.microtext.textLength}" lengthAdjust="spacingAndGlyphs">${escapeXml(g.microtext.text)}</textPath></text>`,
    );
  }
  for (const p of g.paths) {
    parts.push(`<path d="${p.d}" fill="none" stroke="${color}" stroke-width="${p.width}" stroke-linecap="round"/>`);
  }
  const size = Math.round(options.px);
  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${SEAL_VIEWBOX}" width="${size}" height="${size}">${parts.join("")}</svg>`;
}