Files
cjk-autospace/CHANGELOG.html
T
admin 69f34413c9 CHANGELOG:補 ESM export
對應 main 之 a5faec5。檔尾加 `export { createCjkAutospace }` 讓
Vite / Rollup / TS 之 ESM 消費端可 named import;IIFE 串接端
sed 剝最後一行即可。
2026-05-26 17:26:30 +08:00

4476 lines
159 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- built with theme=dracula, mode=report, at=2026-05-26T09:26:30Z -->
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>cjk-autospace — Changelog</title>
<meta name="generated-at" content="2026-05-22">
<meta name="source" content="cjk-autospace · docs branch">
<style>
/* ---------- Core tokens ----------
Theme-agnostic CSS custom properties. Values that DO depend on the
active theme (font-sans, palette colors, tints) live in
`themes/<name>.css`. Only ONE theme file is inlined per build, so
these core tokens layer beneath whichever theme was selected.
Layered cascade order at build time:
core/tokens.css ← this file (non-color, partial font)
themes/<active>.css ← font-sans + palette + dark + print
(rest of runtime.css) ← CJK refinements, reset, layout, …
`--font-mono` references `var(--font-sans)` so its CJK fallback
tracks whichever sans family the active theme installed — Orbit
Gothic for Dracula, Noto Sans for RUC. CSS custom property var()
resolution is lazy (at use site), so the forward reference to
--font-sans is safe even though it's declared later in the
cascade. */
:root
{
/* Shared font-serif chain — both themes use Noto Serif for body. */
--font-serif: "Noto Serif CJK SC", "Noto Serif SC", "Noto Serif TC", "Noto Serif KR", "Noto Serif JP", "Noto Serif", serif;
/* Shared mono chain — falls through to active theme's sans for CJK. */
--font-mono: "Maple Mono", "Maple Mono NF CN", monospace, var(--font-sans);
/* Mono OT feature suppression (kern/liga/calt + CJK width-substitution
chws/halt/palt) keeps diff / code columns byte-aligned regardless
of the body's typography opt-ins. */
--mono-features: "kern" off, "liga" off, "calt" off, "chws" off, "halt" off, "palt" off;
/* Layout */
--radius: 4px;
--gap: 16px;
--gap-lg: 24px;
--gap-xl: 40px;
/* Motion */
--ease-pickup: cubic-bezier(0.2, 0.7, 0.2, 1);
--ease-settle: cubic-bezier(0.22, 1, 0.36, 1);
--dur-pickup: 120ms;
--dur-reorder: 220ms;
--dur-settle: 180ms;
--dur-indicator: 80ms;
color-scheme: light dark;
}
/* Region-specific font-serif. Reorders the Noto Serif regional cuts so
the current locale's variant sits first. font-sans :lang() overrides
(if any) live in the active theme file. */
:lang(zh-TW),
:lang(zh-Hant),
:lang(zh-HK),
:lang(zh-MO)
{
--font-serif: "Noto Serif CJK SC", "Noto Serif TC", "Noto Serif SC", "Noto Serif KR", "Noto Serif JP", "Noto Serif", serif;
}
:lang(ja)
{
--font-serif: "Noto Serif CJK SC", "Noto Serif JP", "Noto Serif SC", "Noto Serif TC", "Noto Serif KR", "Noto Serif", serif;
}
:lang(ko)
{
--font-serif: "Noto Serif CJK SC", "Noto Serif KR", "Noto Serif SC", "Noto Serif TC", "Noto Serif JP", "Noto Serif", serif;
}
/* ---------- Theme: Dracula ----------
Dracula Pro family — Alucard (light) + Pro (dark). Palette values
from `Dracula Pro/design/palette.md`; element role assignments from
the Sublime tmTheme `Markdown Tweaks` section.
Active theme selection happens at build time (build.sh `--theme`
flag or `<!-- theme: ... -->` source comment). Only ONE theme's CSS
is inlined per build, so the selectors here use plain `:root` with
no `[data-theme-family]` qualifier — there is no other theme in
the document to disambiguate against.
Within this theme, light↔dark is a runtime mode controlled by
`<html data-theme-mode="light|dark">` (or OS preference, or the
legacy `<html data-theme="dark|light">` alias). */
:root
{
/* Sans = Orbit Gothic CJK SC → Orbit Gothic (Latin) → sans-serif.
CJK SC is the only CJK variant string we declare — Orbit Gothic
carries the full pan-CJK set in one file and selects SC / TC /
JP / KR glyph shapes via OpenType `locl` based on `<html lang>`. */
--font-sans: "Orbit Gothic CJK SC", "Orbit Gothic", sans-serif;
/* Alucard (light) — palette.md */
--bg: #F5F5F5;
--bg-elev: #FBFBFB;
--fg: #1F1F1F;
--muted: #635D97;
--subtle: #E8E8EF;
--subtle-2: #EFEFF3;
--selection: #CFCFDE;
--border: #D5D5DE;
--border-strong: #B5B5C6;
--rule: #1F1F1F;
--accent: #644AC9; /* purple — links / structural marker (tmTheme markup.underline) */
--info: #036A96; /* cyan — headings / fenced delimiters (tmTheme markup.heading) */
--success: #14710A; /* green — inline code, additions (tmTheme markup.raw.inline) */
--warn: #846E15; /* yellow */
--error: #CB3A2A; /* red — deletions */
--emphasis: #A3144D; /* pink — list markers, h3 (tmTheme list-item punctuation) */
--orange: #A34D14; /* orange — bold/italic/strike, h4 (tmTheme markup.bold/italic/strike) */
/* Tinted backgrounds — derived from semantic colors via color-mix.
Used for callout fills, diff lines, hover states, etc. */
--tint-accent: color-mix(in srgb, var(--accent) 8%, transparent);
--tint-info: color-mix(in srgb, var(--info) 8%, transparent);
--tint-success: color-mix(in srgb, var(--success) 8%, transparent);
--tint-warn: color-mix(in srgb, var(--warn) 10%, transparent);
--tint-error: color-mix(in srgb, var(--error) 8%, transparent);
--tint-emphasis: color-mix(in srgb, var(--emphasis) 8%, transparent);
--tint-orange: color-mix(in srgb, var(--orange) 8%, transparent);
--tint-row: color-mix(in srgb, var(--accent) 5%, transparent);
--add-bg: color-mix(in srgb, var(--success) 10%, transparent);
--del-bg: color-mix(in srgb, var(--error) 10%, transparent);
}
/* === Pro (dark) =====================================================
Applied when OS prefers dark AND mode isn't manually pinned to
light. The `:not()` chain lets a manually-set light mode survive
the OS preference. */
@media (prefers-color-scheme: dark)
{
:root:not([data-theme="light"]):not([data-theme-mode="light"])
{
--bg: #22212C;
--bg-elev: #2D2B3C;
--fg: #F8F8F2;
--muted: #7970A9;
--subtle: #2D2B3C;
--subtle-2: #3A3950;
--selection: #454158;
--border: #3A3950;
--border-strong: #57536D;
--rule: #F8F8F2;
--accent: #9580FF; /* purple (Pro neon) */
--info: #80FFEA; /* cyan */
--success: #8AFF80; /* green */
--warn: #FFFF80; /* yellow */
--error: #FF9580; /* red */
--emphasis: #FF80BF; /* pink */
--orange: #FFCA80; /* orange */
--tint-accent: color-mix(in srgb, var(--accent) 14%, transparent);
--tint-info: color-mix(in srgb, var(--info) 14%, transparent);
--tint-success: color-mix(in srgb, var(--success) 14%, transparent);
--tint-warn: color-mix(in srgb, var(--warn) 14%, transparent);
--tint-error: color-mix(in srgb, var(--error) 14%, transparent);
--tint-emphasis: color-mix(in srgb, var(--emphasis) 14%, transparent);
--tint-orange: color-mix(in srgb, var(--orange) 14%, transparent);
--tint-row: color-mix(in srgb, var(--accent) 9%, transparent);
--add-bg: color-mix(in srgb, var(--success) 16%, transparent);
--del-bg: color-mix(in srgb, var(--error) 16%, transparent);
}
}
/* Manual override — Pro dark regardless of OS preference. Covers the
new `data-theme-mode="dark"` and the legacy `data-theme="dark"`
alias. (No `[data-theme-family]` qualifier needed — theme is
build-time, not runtime.) */
:root[data-theme="dark"],
:root[data-theme-mode="dark"]
{
--bg: #22212C;
--bg-elev: #2D2B3C;
--fg: #F8F8F2;
--muted: #7970A9;
--subtle: #2D2B3C;
--subtle-2: #3A3950;
--selection: #454158;
--border: #3A3950;
--border-strong: #57536D;
--rule: #F8F8F2;
--accent: #9580FF;
--info: #80FFEA;
--success: #8AFF80;
--warn: #FFFF80;
--error: #FF9580;
--emphasis: #FF80BF;
--orange: #FFCA80;
--tint-accent: color-mix(in srgb, var(--accent) 14%, transparent);
--tint-info: color-mix(in srgb, var(--info) 14%, transparent);
--tint-success: color-mix(in srgb, var(--success) 14%, transparent);
--tint-warn: color-mix(in srgb, var(--warn) 14%, transparent);
--tint-error: color-mix(in srgb, var(--error) 14%, transparent);
--tint-emphasis: color-mix(in srgb, var(--emphasis) 14%, transparent);
--tint-orange: color-mix(in srgb, var(--orange) 14%, transparent);
--tint-row: color-mix(in srgb, var(--accent) 9%, transparent);
--add-bg: color-mix(in srgb, var(--success) 16%, transparent);
--del-bg: color-mix(in srgb, var(--error) 16%, transparent);
}
/* === Print: force Alucard light =====================================
Ensures print output is legible on white paper regardless of the
active mode. Covers every selector path that swaps to dark. */
@media print
{
:root,
:root[data-theme="dark"],
:root[data-theme="light"],
:root[data-theme-mode="dark"],
:root[data-theme-mode="light"]
{
--bg: #FFFFFF;
--bg-elev: #FFFFFF;
--fg: #1F1F1F;
--muted: #635D97;
--subtle: #E8E8EF;
--subtle-2: #EFEFF3;
--selection: #CFCFDE;
--border: #B5B5C6;
--border-strong: #635D97;
--rule: #1F1F1F;
--accent: #644AC9;
--info: #036A96;
--success: #14710A;
--warn: #846E15;
--error: #CB3A2A;
--emphasis: #A3144D;
--orange: #A34D14;
}
}
/* ---------- CJK typography refinements ----------
CSS-native primitives applied via :where(:lang(...)) at zero specificity.
Latin-only pages get no behavioural change.
Trim style: 全角式 (fullwidth glyphs preserved) + optical sidebearing
collapse at three positions. NOT 開明式 — that's a glyph-substitution
scheme (sentence-end fullwidth, non-terminal halfwidth) requiring
`chws`/`halt` OT features, not achievable via CSS sidebearing alone.
Here every punctuation keeps its fullwidth glyph; only the leading /
trailing /inner sidebearings of certain positions are collapsed per
W3C clreq §3.1.3 + the rule "if a fullwidth punctuation's sidebearing
borders another punctuation on that side, collapse it." Achieved via
`text-spacing-trim: trim-all`, which explicitly:
- Trims sidebearings between adjacent fullwidth punctuation pairs
(`);`, `。」`, `:「`, etc.) — combined width ~1.5em not 2em.
- Trims opening fullwidth at line start (each line, including
paragraph start — web typography convention is space-separated
paragraphs rather than first-line indent).
- Trims closing fullwidth at line end (so the closing punct sits
flush with the right edge instead of leaving a half-em hole).
`normal` was tried but proved equivalent to `space-first` in current
browsers (no adjacent trim). `trim-all` is the only spec value that
guarantees the adjacent-pair behavior we want.
Hanging punctuation: `hanging-punctuation: first allow-end`. Safari
honors fully; Firefox 145+ honors `force-end / allow-end`; Chromium
doesn't yet implement (silent no-op).
`text-autospace`: inserts ~1/4em between CJK and Latin/digits at
render time WITHOUT modifying the DOM, so the Markdown twin
(`<script id="md-source">`) and `textContent` stay clean.
`line-break: strict` enables full kinsoku processing (JIS X 4051 /
clreq §3.1.2). `overflow-wrap: anywhere` is the escape hatch for
unbreakable URLs / hashes / very-long English words that would
otherwise overflow.
`word-break` stays at the UA default (`normal`). Setting it to
`keep-all` forbids ALL inter-Han breaks, leaving up to one
punctuation-chunk of width empty on the right.
OT features `chws` / `halt` / `palt` are NOT enabled at body level —
they push toward halfwidth and break the trim style. Apply locally if
a dense component needs tighter set. */
:where(:lang(zh), :lang(ja), :lang(ko))
{
text-spacing-trim: trim-all;
text-autospace: no-autospace; /* native disabled; JS shim (runtime.js, Pangu.js rules) inserts <span class="cjk-autospace"> ( ) at boundaries; user-select: none on the span keeps the inserted space out of selection & clipboard */
hanging-punctuation: first allow-end;
line-break: strict;
overflow-wrap: anywhere;
}
/* Helper span injected by runtime.js's autospace shim. The span
contains a real ASCII space so layout treats it as genuine
whitespace: collapses at line ends / starts, allows line break
opportunities, and participates in justify expansion. None of
which an inline-block with a fixed em width can do — that was
the original shape and it broke two-end justify when a digit
ran up against the line edge.
`user-select: none` makes the browser skip this element when
computing the selection range, so neither the visual selection
rectangle nor the clipboard payload includes the inserted space. */
.cjk-autospace
{
-webkit-user-select: none;
user-select: none;
}
/* Pass C 配套:cjk-autospace lib 把長英文段包進 <span lang="en">,此處
讓瀏覽器原生 hyphens: auto 用 en 字典做斷詞 + 渲染連字符。連字符純
視覺(DOM 不存在),複製返回原文無污染。
hyphenate-limit-chars: 6 3 3 — 詞長 ≥6、斷點前/後各 ≥3 字符,避免
把 "before" 斷成 "be-fore" 之類粒度過細之結果。Safari/Firefox 對此
屬性支援不全,未生效時退至各家瀏覽器之默認啟發。
hyphenate-character: "-" — 強制斷字符為 U+002D HYPHEN-MINUS(窄拉丁
寬度)而非預設之 U+2010 HYPHEN。Noto Serif CJK SC 把 U+2010 設計為
全形 CJK 標點(1 em,等於一個中文字寬度)以服務 CJK 範圍表達,但
用在英文斷字會視覺斷裂;U+002D 在同字型中為窄拉丁,與作者鍵入的 -
一致。仍為純視覺渲染,DOM 與剪貼簿不受影響。 */
[lang|="en"]
{
hyphens: auto;
-webkit-hyphens: auto;
hyphenate-limit-chars: 6 3 3;
hyphenate-character: "-";
-webkit-hyphenate-character: "-";
}
/* Default two-sided justified alignment for any multi-line prose / data
container in CJK lang. Auto-applies — no class required. Principle:
text that wraps to 2+ lines should justify regardless of tag (table
cells, list items, definition descriptions, captions, etc.).
`text-align-last: left` keeps the paragraph-final line and any
single-line content (which IS its own last line) naturally left-
aligned, so only true middle lines stretch.
Code / mono content (`<pre>`, `<code>`, `<kbd>`, `<samp>`) opts out
via the mono rule far above (text-align: revert added there). */
:where(:lang(zh), :lang(ja), :lang(ko))
:is(p, li, blockquote, td, dd, summary, figcaption, caption, .justify, .justify *)
{
text-align: justify;
text-justify: inter-character;
text-align-last: left;
}
/* Agent escape hatches.
`.justify` — force-ON. Apply to any container to make the container
itself (if it carries direct text) and every text-bearing descendant
render justified. Already part of the default selector above via
`.justify, .justify *`.
`.no-justify` — force-OFF. Apply to any container to revert the
container and all descendants to natural left alignment. Use for
layouts where the default multi-line justify is undesirable (dense
captions, narrow data cells with fixed wraps, etc.). Specificity
0,1,1 on the descendant selector beats the default 0,0,1. */
:where(:lang(zh), :lang(ja), :lang(ko)) :is(.no-justify, .no-justify *)
{
text-align: start;
text-justify: auto;
text-align-last: auto;
}
/* Explicit-alignment utilities.
Three classes — `.text-left`, `.text-center`, `.text-right` — for
when the default left-justified prose alignment is wrong. Primary
use: numeric / right-aligned table columns (`<td class="text-right">
$1,234</td>`) and centered headers (`<th class="text-center">
Status</th>`). They also work on `<th>` (which otherwise sticks
at `text-align: left`), on `<caption>`, on any block-level
container, and propagate to descendants via the `*` selector so
`<table class="text-center">` centers all cells. Specificity is
(0,1,0) on the class and (0,1,0) on the descendant form, both
beating the default justify / th-left rules at (0,0,1).
`text-justify: auto` + `text-align-last: auto` also win over the
`inter-character` / `left` values from the default rule, so the
alignment is genuinely explicit — no residual inter-character
stretching. */
.text-left, .text-left *
{
text-align: left;
text-justify: auto;
text-align-last: auto;
}
.text-center, .text-center *
{
text-align: center;
text-justify: auto;
text-align-last: auto;
}
.text-right, .text-right *
{
text-align: right;
text-justify: auto;
text-align-last: auto;
}
/* ---------- Reset ---------- */
* { box-sizing: border-box; }
::selection { background: var(--selection); color: var(--fg); }
html { -webkit-text-size-adjust: 100%; }
html, body { margin: 0; padding: 0; background: var(--bg); color: var(--fg); }
body
{
font-family: var(--font-serif);
font-size: 16px;
font-weight: 500; /* Medium — CJK serif renders too thin at 400 on screen */
line-height: 1.7;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-feature-settings: "kern", "liga";
}
/* Mono opt-out: lock weight back to Regular (body inherits Medium 500),
disable all CJK width-substitution / typography features so diff /
code / table columns stay byte-aligned, and revert any inherited
justify (the default CJK rule justifies many tags; mono must not). */
code, kbd, pre, pre code,
.diff-block, .diff-block .line .lno, .diff-block .line .code,
.timeline .ts, .marginalia
{
font-weight: 400;
font-feature-settings: var(--mono-features);
text-autospace: no-autospace;
text-spacing-trim: space-all;
text-align: start;
text-justify: auto;
text-align-last: auto;
}
/* ---------- Typography ----------
Body is serif; headings are sans. The contrast is intentional — the page
reads like a typeset essay with sans display marks for structure.
Heading colors derive from tmTheme:
h1 = cyan (markup.heading) with a purple bar as structural marker
h2 = purple (markup.underline) with a 2px underline
h3 = pink (list-item punctuation)
h4 = orange (bold/italic/strike)
h5/h6 = muted (quote / separator), uppercase */
h1, h2, h3, h4, h5, h6
{
font-family: var(--font-sans);
line-height: 1.2;
}
h1
{
position: relative;
margin: 0 0 20px;
padding: 0 0 10px;
font-size: 32px;
font-weight: 700;
line-height: 1.1;
letter-spacing: -0.015em;
color: var(--info);
width: fit-content;
max-width: 100%;
}
/* Underline matches the title's fit-content width — same mechanism
as slides' cover deck-title. Cleaner than a left-side bar for
centred or short titles. */
h1::after
{
content: "";
display: block;
width: 100%;
height: 4px;
background: var(--accent);
margin-top: 10px;
}
h2
{
position: relative;
margin: 56px 0 16px;
padding-bottom: 5px;
padding-left: 18px;
width: fit-content;
max-width: 100%;
border-bottom: 2px solid var(--accent);
font-size: 22px;
font-weight: 700;
color: var(--accent);
letter-spacing: -0.01em;
}
h2::before
{
content: "";
position: absolute;
left: 0;
top: 50%;
width: 10px;
height: 10px;
background: var(--accent);
transform: translateY(-65%);
}
main > h2:first-child,
section > h2:first-child,
article > h2:first-child { margin-top: 0; }
h3
{
margin: 32px 0 8px;
font-size: 17px;
font-weight: 700;
color: var(--emphasis);
letter-spacing: -0.005em;
}
h4
{
margin: 24px 0 6px;
font-size: 14px;
font-weight: 700;
color: var(--orange);
letter-spacing: 0.02em;
}
h5, h6
{
margin: 20px 0 6px;
font-size: 11px;
font-weight: 700;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.14em;
}
p { margin: 0 0 14px; }
strong { font-weight: 700; color: var(--orange); } /* tmTheme markup.bold */
em { font-style: italic; color: var(--orange); } /* tmTheme markup.italic */
del, s
{
text-decoration: line-through;
text-decoration-color: var(--orange);
color: var(--orange);
}
a
{
color: var(--accent); /* tmTheme markup.underline */
text-decoration: underline;
text-decoration-thickness: 1px;
text-decoration-color: var(--accent);
text-underline-offset: 3px;
transition: color 0.14s, text-decoration-color 0.14s, text-decoration-thickness 0.14s;
}
a:hover
{
color: var(--emphasis);
text-decoration-color: var(--emphasis);
text-decoration-thickness: 2px;
}
a:visited { color: var(--emphasis); }
a:visited:hover { color: var(--orange); text-decoration-color: var(--orange); }
blockquote
{
margin: 24px 0;
padding: 8px 16px 8px 20px;
background: var(--tint-info);
border-left: 4px solid var(--info);
border-radius: 0 var(--radius) var(--radius) 0;
font-family: var(--font-serif);
font-style: italic;
font-size: 17px;
color: var(--muted); /* tmTheme markup.quote = muted italic */
line-height: 1.55;
}
blockquote > p:last-child { margin-bottom: 0; }
blockquote cite
{
display: block;
margin-top: 10px;
font-family: var(--font-sans);
font-style: normal;
font-size: 11px;
color: var(--muted);
letter-spacing: 0.12em;
text-transform: uppercase;
}
blockquote cite::before { content: "— "; }
ul, ol { margin: 12px 0; padding-left: 1.5em; }
li { margin: 4px 0; }
li::marker { color: var(--emphasis); } /* tmTheme list-item punctuation */
ol > li::marker
{
font-family: var(--font-mono);
font-feature-settings: "tnum";
font-size: 0.85em;
color: var(--emphasis);
}
ul ul li::marker,
ol ol li::marker { color: var(--muted); }
hr
{
border: none;
height: 32px;
margin: 40px 0;
text-align: center;
line-height: 32px;
font-family: var(--font-serif);
font-size: 22px;
}
hr::before
{
content: "✦ ✦ ✦";
color: var(--info);
letter-spacing: 0.4em;
}
hr.ornament::before
{
content: "❦";
color: var(--accent);
letter-spacing: 0;
font-size: 24px;
}
hr.line
{
height: 1px;
background: var(--border);
margin: 32px 0;
}
hr.line::before { content: none; }
.lede::first-letter
{
font-family: var(--font-serif);
float: left;
font-size: 4.2em;
line-height: 0.85;
padding: 8px 14px 0 0;
color: var(--accent);
font-weight: 700;
}
.marginalia
{
font-family: var(--font-mono);
font-size: 11px;
color: var(--muted);
letter-spacing: 0.06em;
}
/* ---------- Buttons ---------- */
.btn
{
font-family: var(--font-sans);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
background: transparent;
color: var(--muted);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 6px 12px;
cursor: pointer;
transition: color 0.14s, border-color 0.14s, background 0.14s;
white-space: nowrap;
}
.btn:hover { color: var(--accent); border-color: var(--accent); }
.btn:active { background: var(--subtle); }
.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* ---------- Code ---------- */
code
{
font-family: var(--font-mono);
font-size: 0.9em;
background: var(--subtle);
padding: 0.05em 0.4em;
border-radius: var(--radius);
color: var(--success); /* tmTheme markup.raw.inline */
/* Same opt-out as the composite mono rule above — inline `code` would
otherwise win the cascade (later + same specificity) and leak the
body's CJK features into mono spans. */
font-feature-settings: var(--mono-features);
}
pre
{
position: relative;
margin: 18px 0;
padding: 14px 16px;
background: var(--bg-elev);
border: 1px solid var(--border);
border-left: 3px solid var(--info); /* fenced delimiter strip */
border-radius: var(--radius);
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.6;
overflow-x: auto;
}
pre code
{
background: transparent;
padding: 0;
font-size: inherit;
color: var(--fg); /* tmTheme markup.raw.block (fg) */
}
pre .copy-btn
{
position: absolute;
top: 8px;
right: 8px;
padding: 2px 8px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
font-family: var(--font-sans);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--muted);
cursor: pointer;
opacity: 0;
transition: opacity 0.18s, color 0.14s, border-color 0.14s;
}
pre:hover .copy-btn,
pre .copy-btn:focus-visible { opacity: 1; }
pre .copy-btn:hover { color: var(--accent); border-color: var(--accent); }
kbd
{
display: inline-block;
padding: 0 6px;
background: var(--subtle);
border: 1px solid var(--border-strong);
border-bottom-width: 2px;
border-radius: var(--radius);
font-family: var(--font-mono);
font-size: 12px;
color: var(--fg);
}
/* ---------- Tables ---------- */
table
{
width: 100%;
border-collapse: collapse;
margin: 24px 0;
font-family: var(--font-sans);
font-size: 14px;
}
caption
{
caption-side: bottom;
margin-top: 10px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--muted);
text-align: left;
letter-spacing: 0.06em;
}
th
{
padding: 10px 14px;
background: var(--subtle);
border-bottom: 1px solid var(--border);
text-align: left;
font-weight: 700;
font-size: 11px;
color: var(--info);
letter-spacing: 0.12em;
text-transform: uppercase;
vertical-align: bottom;
}
th:first-child { border-top-left-radius: var(--radius); }
th:last-child { border-top-right-radius: var(--radius); }
td
{
padding: 10px 14px;
border: none;
border-bottom: 1px solid var(--border);
vertical-align: top;
}
tbody tr:nth-child(even) td { background: var(--bg-elev); }
tbody tr:hover td { background: var(--tint-row); }
tbody tr:last-child td { border-bottom: none; }
td code { font-size: 12px; }
/* ---------- Details ---------- */
details
{
margin: 12px 0;
padding: 0;
border: none;
}
details > summary
{
cursor: pointer;
outline: none;
list-style: none;
display: flex;
align-items: baseline;
gap: 8px;
padding: 4px 8px;
margin: 0 -8px;
border-radius: var(--radius);
font-family: var(--font-sans);
font-weight: 700;
color: var(--info);
transition: color 0.14s, background 0.14s;
}
details > summary:hover { background: var(--tint-info); }
details > summary::-webkit-details-marker { display: none; }
details > summary::before
{
content: "▸";
flex-shrink: 0;
width: 14px;
font-size: 0.82em;
color: var(--info);
transition: color 0.14s;
}
details[open] > summary::before { content: "▾"; }
details > summary:hover,
details > summary:hover::before { color: var(--accent); }
details > *:not(summary)
{
margin: 8px 0 12px 22px;
padding-left: 12px;
border-left: 1px solid var(--border);
}
details > *:not(summary):last-child { margin-bottom: 4px; }
/* ---------- Callouts ----------
Editor's-note treatment: left rule + uppercase label, tinted body fill
keyed to the severity color via color-mix. */
.callout
{
margin: 24px 0;
padding: 12px 16px 12px 18px;
background: color-mix(in srgb, var(--muted) 6%, transparent);
border-left: 4px solid var(--muted);
border-radius: 0 var(--radius) var(--radius) 0;
}
.callout > .label
{
display: block;
margin: 0 0 6px;
font-family: var(--font-sans);
font-weight: 700;
font-size: 10px;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--muted);
}
.callout > p { margin: 0 0 8px; font-family: var(--font-serif); }
.callout > p:last-child { margin-bottom: 0; }
.callout.info { background: var(--tint-info); border-left-color: var(--info); }
.callout.info > .label { color: var(--info); }
.callout.success { background: var(--tint-success); border-left-color: var(--success); }
.callout.success > .label { color: var(--success); }
.callout.warn { background: var(--tint-warn); border-left-color: var(--warn); }
.callout.warn > .label { color: var(--warn); }
.callout.error { background: var(--tint-error); border-left-color: var(--error); }
.callout.error > .label { color: var(--error); }
/* ---------- Badges ---------- */
.badge
{
display: inline-block;
padding: 2px 8px;
background: transparent;
border: 1px solid var(--border);
border-radius: var(--radius);
font-family: var(--font-sans);
font-size: 10px;
font-weight: 700;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.14em;
vertical-align: 0.10em;
line-height: 1.6;
}
.badge.sev-low { color: var(--success); border-color: var(--success); }
.badge.sev-med { color: var(--warn); border-color: var(--warn); }
.badge.sev-high { color: var(--error); border-color: var(--error); }
.badge.accent { color: var(--accent); border-color: var(--accent); }
/* ---------- Progress track (segmented phase overview) ----------
Solid-color segments — louder than badges by design, since this is
the template's first true data-viz element and needs glanceability.
Restraint lives in the typography (sans 11px uppercase letter-spaced,
not bold) and the slim 1.75em height.
Author API:
<div class="progress-track">
<div class="progress-seg done" style="--weight: 14">label</div>
<div class="progress-seg active" style="--weight: 10">label</div>
</div>
<div class="progress-legend">
<span class="l-done">已完成</span>
<span class="l-active">進行中</span>
</div>
`--weight` is a CSS custom property used as a *parameter* (not a
style declaration) — it maps to `flex` inside the CSS rule. This is
the sanctioned form for passing per-segment numeric data; CSS custom
properties via inline style are treated as data attributes for
parameter purposes, not as inline styling.
`color: var(--bg)` makes segment labels auto-invert with the theme:
light mode → near-white on saturated bg; dark mode → near-black on
neon bg. Both directions stay legible without per-theme overrides. */
.progress-track
{
display: flex;
height: 1.75em;
margin: var(--gap-lg) 0 8px;
overflow: hidden;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.progress-track > .progress-seg
{
flex: var(--weight, 1);
min-width: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 0 10px;
background: var(--muted);
color: var(--bg);
font-family: var(--font-sans);
font-size: 11px;
font-weight: 500;
letter-spacing: 0.06em;
text-transform: uppercase;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.progress-track > .progress-seg.done { background: var(--success); }
.progress-track > .progress-seg.active { background: var(--accent); }
.progress-track > .progress-seg.future { background: var(--info); }
.progress-track > .progress-seg.prep { background: var(--muted); }
.progress-legend
{
display: flex;
flex-wrap: wrap;
gap: 18px;
justify-content: center;
margin: 0 0 var(--gap-lg);
font-family: var(--font-sans);
font-size: 11px;
color: var(--muted);
letter-spacing: 0.04em;
}
.progress-legend > span
{
display: inline-flex;
align-items: center;
gap: 6px;
}
.progress-legend > span::before
{
content: "";
display: inline-block;
width: 10px;
height: 10px;
border-radius: 2px;
background: currentColor;
}
.progress-legend > .l-done { color: var(--success); }
.progress-legend > .l-active { color: var(--accent); }
.progress-legend > .l-future { color: var(--info); }
.progress-legend > .l-prep { color: var(--muted); }
/* ---------- Timeline ---------- */
.timeline
{
list-style: none;
margin: 24px 0 24px 104px;
padding: 0;
border-left: 1px dashed color-mix(in srgb, var(--info) 35%, var(--border));
}
.timeline > li
{
position: relative;
padding: 0 0 22px 22px;
min-height: 30px;
font-family: var(--font-serif);
}
.timeline > li:last-child { padding-bottom: 0; }
.timeline > li::before
{
content: "";
position: absolute;
left: -7px;
top: 5px;
width: 13px;
height: 13px;
background: var(--bg);
border: 2px solid var(--info);
border-radius: 50%;
box-sizing: border-box;
}
.timeline > li.sev-high::before { border-color: var(--error); background: var(--error); }
.timeline > li.sev-med::before { border-color: var(--warn); }
.timeline > li.sev-low::before { border-color: var(--success); }
.timeline > li.muted::before { border-color: var(--muted); background: var(--muted); }
.timeline > li.filled::before { border-color: var(--accent); background: var(--accent); }
.timeline .ts
{
position: absolute;
left: -104px;
top: 5px;
width: 92px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--muted);
text-align: right;
letter-spacing: 0.04em;
font-feature-settings: "tnum";
white-space: nowrap;
}
.timeline > li > p { margin: 0 0 6px; }
.timeline > li > p:last-child { margin-bottom: 0; }
.timeline > li > details { margin-top: 6px; }
.timeline > li > details > summary { padding-top: 0; }
@media (max-width: 720px)
{
.timeline { margin-left: 0; }
.timeline .ts
{
position: static;
display: block;
margin-bottom: 4px;
width: auto;
left: auto;
text-align: left;
}
}
/* ---------- Compare columns ----------
Each column wears a top color band: A=purple, B=pink, C=cyan — three
distinct emphasis colors from tmTheme. */
.compare-cols
{
display: grid;
gap: var(--gap);
margin: 24px 0;
counter-reset: col;
}
.compare-cols.cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.compare-cols.cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.compare-cols > .col
{
position: relative;
padding: 36px 18px 18px;
background: var(--bg-elev);
border: 1px solid var(--border);
border-top: 4px solid var(--accent);
border-radius: var(--radius);
counter-increment: col;
}
.compare-cols > .col:nth-child(2) { border-top-color: var(--emphasis); }
.compare-cols > .col:nth-child(3) { border-top-color: var(--info); }
.compare-cols > .col::after
{
content: counter(col, upper-alpha);
position: absolute;
top: 10px;
right: 14px;
font-family: var(--font-sans);
font-size: 13px;
font-weight: 700;
letter-spacing: 0.04em;
color: var(--accent);
}
.compare-cols > .col:nth-child(2)::after { color: var(--emphasis); }
.compare-cols > .col:nth-child(3)::after { color: var(--info); }
.compare-cols > .col.chosen
{
border-top-width: 6px;
padding-top: 34px; /* 6px border + 34px padding = same content top as non-chosen's 4 + 36 */
background: color-mix(in srgb, var(--accent) 4%, var(--bg-elev));
}
.compare-cols > .col:nth-child(2).chosen { background: color-mix(in srgb, var(--emphasis) 4%, var(--bg-elev)); }
.compare-cols > .col:nth-child(3).chosen { background: color-mix(in srgb, var(--info) 4%, var(--bg-elev)); }
.compare-cols > .col.chosen::before
{
content: "CHOSEN";
position: absolute;
top: 10px;
left: 14px;
font-family: var(--font-sans);
font-size: 9px;
font-weight: 700;
letter-spacing: 0.18em;
color: var(--accent);
}
.compare-cols > .col:nth-child(2).chosen::before { color: var(--emphasis); }
.compare-cols > .col:nth-child(3).chosen::before { color: var(--info); }
.compare-cols > .col > h4
{
margin: 0 0 12px;
padding: 0 36px 8px 0;
border-bottom: 1px solid var(--border);
font-size: 16px;
color: var(--fg);
text-transform: none;
letter-spacing: -0.005em;
}
.compare-cols > .col > p
{
margin: 0 0 10px;
font-family: var(--font-serif);
font-size: 14px;
line-height: 1.6;
color: var(--fg);
}
.compare-cols > .col > ul,
.compare-cols > .col > ol { margin: 8px 0; padding-left: 18px; font-size: 14px; font-family: var(--font-serif); }
@media (max-width: 720px)
{
.compare-cols.cols-2,
.compare-cols.cols-3 { grid-template-columns: 1fr; }
}
/* ---------- Tabs (underline) ---------- */
.tabs { margin: 24px 0; }
.tabs [role="tablist"]
{
display: flex;
gap: 0;
border-bottom: 1px solid var(--border);
overflow-x: auto;
}
.tabs [role="tab"]
{
margin-bottom: -1px;
padding: 10px 18px 9px;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
font-family: var(--font-sans);
font-size: 11px;
font-weight: 700;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.14em;
cursor: pointer;
white-space: nowrap;
transition: color 0.18s, border-color 0.18s;
}
.tabs [role="tab"]:hover { color: var(--fg); background: color-mix(in srgb, var(--info) 5%, transparent); }
.tabs [role="tab"][aria-selected="true"]
{
color: var(--info);
background: var(--tint-info);
border-bottom-color: var(--info);
border-bottom-width: 3px;
}
.tabs [role="tab"]:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.tabs [role="tabpanel"] { padding: 18px 0 6px; }
.tabs [role="tabpanel"][hidden] { display: none; }
/* ---------- Diff review ---------- */
.diff-review
{
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: var(--gap-lg);
margin: 24px 0;
}
@media (max-width: 880px) { .diff-review { grid-template-columns: 1fr; } }
.diff-block
{
overflow: hidden;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius);
font-family: var(--font-mono);
font-size: 13px;
}
.diff-block .file
{
padding: 8px 14px;
background: var(--subtle);
border-bottom: 1px solid var(--border);
font-family: var(--font-sans);
font-size: 11px;
font-weight: 700;
color: var(--info);
text-transform: uppercase;
letter-spacing: 0.14em;
}
.diff-block .hunk
{
padding: 4px 14px;
background: var(--subtle-2);
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
font-size: 11px;
color: var(--muted);
letter-spacing: 0.06em;
}
.diff-block .line
{
position: relative;
display: grid;
grid-template-columns: 48px 1fr;
align-items: baseline;
line-height: 1.6;
white-space: pre;
transition: background 0.08s;
}
.diff-block .line:hover { background: var(--subtle-2); }
.diff-block .line .lno
{
padding: 1px 10px 1px 0;
border-right: 1px solid var(--border);
color: var(--muted);
font-size: 11px;
font-feature-settings: "tnum";
text-align: right;
user-select: none;
}
.diff-block .line .code { padding: 1px 0 1px 12px; }
.diff-block .line.add { background: var(--add-bg); }
.diff-block .line.del { background: var(--del-bg); }
.diff-block .line.add::before,
.diff-block .line.del::before
{
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
}
.diff-block .line.add::before { background: var(--success); }
.diff-block .line.del::before { background: var(--error); }
.diff-block .line.add .code { color: var(--success); }
.diff-block .line.add .lno { color: var(--success); border-right-color: color-mix(in srgb, var(--success) 35%, transparent); }
.diff-block .line.del .code { color: var(--error); }
.diff-block .line.del .lno { color: var(--error); border-right-color: color-mix(in srgb, var(--error) 35%, transparent); }
.notes { display: flex; flex-direction: column; gap: 12px; }
.note
{
padding: 12px 14px;
background: var(--bg-elev);
border: 1px solid var(--border);
border-left: 4px solid var(--muted);
border-radius: var(--radius);
font-family: var(--font-serif);
font-size: 13px;
line-height: 1.6;
}
.note.sev-high { border-left-color: var(--error); }
.note.sev-med { border-left-color: var(--warn); }
.note.sev-low { border-left-color: var(--success); }
.note > header
{
display: flex;
gap: 8px;
align-items: center;
margin: 0;
padding: 0 0 6px;
border: none;
max-width: unset;
background: transparent;
font-family: var(--font-sans);
}
.note > header .target
{
margin-left: auto;
font-family: var(--font-mono);
font-size: 10px;
color: var(--muted);
letter-spacing: 0.04em;
}
.note > p:last-child { margin: 0; }
/* ---------- Diagrams ---------- */
.diagram
{
display: block;
max-width: 100%;
height: auto;
margin: 24px 0;
font-family: var(--font-sans);
font-size: 13px;
color: var(--fg);
}
.diagram .node
{
fill: var(--bg-elev);
stroke: var(--info);
stroke-width: 1.5;
}
.diagram .node.accent { stroke: var(--accent); }
.diagram .node.muted { fill: var(--subtle); stroke: var(--muted); }
.diagram .node.success { stroke: var(--success); }
.diagram .node.warn { stroke: var(--warn); }
.diagram .node.error { stroke: var(--error); }
.diagram .label
{
fill: var(--fg);
font-family: var(--font-sans);
font-weight: 600;
text-anchor: middle;
dominant-baseline: central;
}
.diagram .label.sub
{
fill: var(--muted);
font-family: var(--font-mono);
font-size: 11px;
font-weight: 400;
}
.diagram .label.left { text-anchor: start; }
.diagram .label.right { text-anchor: end; }
.diagram .edge { fill: none; stroke: var(--muted); stroke-width: 1.4; }
.diagram .edge.accent { stroke: var(--accent); stroke-width: 1.6; }
.diagram .edge.dashed { stroke-dasharray: 5 3; }
.diagram .cluster { fill: none; stroke: var(--border); stroke-dasharray: 4 3; }
svg.sprite { position: absolute; width: 0; height: 0; overflow: hidden; }
/* ---------- Phase 3: SVG node drag (snap-to-grid) ---------- */
body[data-interactive] svg.diagram[data-key] .grid-line
{
stroke: var(--border);
stroke-width: 0.5;
opacity: 0.45;
pointer-events: none;
shape-rendering: crispEdges;
}
body[data-interactive] svg.diagram[data-key] .node[data-drag]
{
cursor: grab;
touch-action: none;
transition:
stroke-width var(--dur-pickup) var(--ease-pickup),
stroke var(--dur-pickup) var(--ease-pickup);
}
body[data-interactive] svg.diagram[data-key] .node[data-drag]:hover { stroke-width: 2; }
body[data-interactive] svg.diagram[data-key] .node[data-drag]:focus-visible
{
outline: none;
stroke: var(--accent);
stroke-width: 2;
}
body[data-interactive] svg.diagram[data-key] .node[data-drag].dragging
{
cursor: grabbing;
stroke: var(--accent);
stroke-width: 2;
opacity: 0.92;
}
body[data-interactive] svg.diagram[data-key] .snap-guides { pointer-events: none; }
body[data-interactive] svg.diagram[data-key] .snap-line
{
stroke: var(--accent);
stroke-width: 1;
stroke-dasharray: 4 3;
opacity: 0.85;
shape-rendering: crispEdges;
}
body[data-interactive] svg.diagram[data-key] .snap-edge
{
fill: none;
stroke: var(--accent);
stroke-width: 2.5;
stroke-linecap: butt;
opacity: 1;
}
/* ---------- Mode: report — Interactive ----------
Phase 1 interactive: Submit button, contenteditable cells, form
`data-pick` radios / checkboxes — all controls used by the Submit
feedback flow. Drag-sort visuals for cardsets and [data-sort]
containers. Slides mode does not include these; slides are
typically presentational. */
/* ---------- Phase 1: Interactive ---------- */
.btn[data-action="submit"] { display: none; }
body[data-interactive] .btn[data-action="submit"]
{
display: inline-block;
color: var(--accent);
border-color: var(--accent);
}
body[data-interactive] .btn[data-action="submit"]:hover
{
color: var(--bg);
background: var(--accent);
border-color: var(--accent);
}
body[data-interactive] [contenteditable][data-md-key]
{
outline: 1px dashed transparent;
outline-offset: 3px;
border-radius: var(--radius);
transition: outline-color 0.14s, background 0.14s;
}
body[data-interactive] [contenteditable][data-md-key]:hover { outline-color: var(--border-strong); }
body[data-interactive] [contenteditable][data-md-key]:focus
{
outline: 1px solid var(--accent);
background: var(--subtle);
}
body[data-interactive] [data-pick]
{
display: flex;
flex-direction: column;
gap: 6px;
margin: 12px 0;
}
body[data-interactive] [data-pick].inline { flex-direction: row; flex-wrap: wrap; gap: 16px; }
body[data-interactive] [data-pick] label
{
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-family: var(--font-serif);
font-size: 14px;
}
body[data-interactive] [data-pick] input { accent-color: var(--accent); }
body[data-interactive] textarea[data-md-key],
body[data-interactive] input[type="text"][data-md-key]
{
width: 100%;
padding: 8px 10px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
font-family: var(--font-sans);
font-size: 14px;
line-height: 1.55;
color: var(--fg);
}
body[data-interactive] textarea[data-md-key] { min-height: 80px; resize: vertical; }
body[data-interactive] textarea[data-md-key]:focus,
body[data-interactive] input[type="text"][data-md-key]:focus
{
outline: none;
background: var(--subtle);
border-color: var(--accent);
}
/* ---------- Drag-sort: shared ---------- */
body[data-interactive] [data-sort] > [data-id],
body[data-interactive] .cardset > .card:not(.add)
{
touch-action: none;
}
body.dragging-active,
body.dragging-active * { user-select: none !important; -webkit-user-select: none !important; }
body[data-interactive] [data-sort].sorting,
body[data-interactive] .cardset.is-sorting
{
user-select: none;
-webkit-user-select: none;
position: relative;
}
/* ---------- Plain [data-sort] drag visual ---------- */
body[data-interactive] [data-sort] > [data-id] { cursor: grab; }
body[data-interactive] [data-sort] > [data-id]:active { cursor: grabbing; }
body[data-interactive] [data-sort] > [data-id].dragging
{
z-index: 5;
position: relative;
pointer-events: none;
cursor: grabbing;
opacity: 0.7;
outline: 1px dashed var(--accent);
outline-offset: 4px;
transition: none;
}
body[data-interactive] [data-sort] > [data-id].settling
{
z-index: 5;
pointer-events: none;
transition:
transform var(--dur-settle) var(--ease-settle),
opacity var(--dur-settle) var(--ease-settle);
opacity: 1;
}
/* Grip glyph on hover for plain sortable rows. */
body[data-interactive] .timeline[data-sort] > [data-id],
body[data-interactive] .compare-cols[data-sort] > [data-id]
{
position: relative;
}
body[data-interactive] .timeline[data-sort] > [data-id]::after,
body[data-interactive] .compare-cols[data-sort] > [data-id]::after
{
content: "⠿";
position: absolute;
color: var(--border-strong);
font-size: 14px;
opacity: 0;
pointer-events: none;
transition: opacity 0.14s, color 0.14s;
}
body[data-interactive] .timeline[data-sort] > [data-id]::after { right: 4px; top: 6px; }
body[data-interactive] .compare-cols[data-sort] > [data-id]::after { right: 6px; bottom: 6px; }
body[data-interactive] .timeline[data-sort] > [data-id]:hover::after,
body[data-interactive] .compare-cols[data-sort] > [data-id]:hover::after { opacity: 1; }
body[data-interactive] [data-sort] > [data-id].dragging::after { color: var(--accent); opacity: 1; }
/* ---------- Cardset drag visual ---------- */
body[data-interactive] .cardset > .card.dragging
{
z-index: 5;
position: relative;
pointer-events: none;
cursor: grabbing;
background: var(--bg);
border-color: var(--accent);
border-top-color: var(--accent);
opacity: 0.92;
transition: none;
will-change: transform;
}
body[data-interactive] .cardset > .card.settling
{
z-index: 5;
pointer-events: none;
transition:
transform var(--dur-settle) var(--ease-pickup),
opacity var(--dur-settle) var(--ease-pickup),
border-color var(--dur-settle) var(--ease-pickup),
border-top-color var(--dur-settle) var(--ease-pickup),
background var(--dur-settle) var(--ease-pickup);
}
/* ---------- Drop indicator ---------- */
.cardset > .drop-indicator
{
position: absolute;
box-sizing: border-box;
background: transparent;
border: 1.5px dashed var(--accent);
border-radius: var(--radius);
opacity: 0;
pointer-events: none;
z-index: 4;
transition:
opacity var(--dur-indicator) var(--ease-pickup),
top var(--dur-pickup) var(--ease-settle),
left var(--dur-pickup) var(--ease-settle),
width var(--dur-pickup) var(--ease-settle),
height var(--dur-pickup) var(--ease-settle);
}
.cardset > .drop-indicator.visible { opacity: 1; }
/* ---------- Cardsets (square / bar) ----------
Each card wears a 3px top-edge color band. Default = neutral, hover =
accent (purple), recommended = success (green), selected = accent,
user-added = emphasis (pink). The body border stays light to keep the
color signal at the top edge. */
.cardset
{
display: grid;
gap: var(--gap);
margin: 24px 0;
}
.cardset.square { grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); }
.cardset.bar { grid-template-columns: 1fr; }
.cardset > .card
{
position: relative;
padding: 18px 18px 16px;
background: var(--bg-elev);
border: 1px solid var(--border);
border-top: 3px solid var(--border-strong);
border-radius: var(--radius);
transition: border-color 0.16s, border-top-color 0.16s, background 0.14s;
}
.cardset.square > .card { min-height: 140px; }
.cardset.bar > .card { min-height: 64px; padding: 14px 18px 12px; }
.cardset > .card > h4
{
margin: 0 0 8px;
padding: 0 80px 0 0;
border: none;
font-family: var(--font-sans);
font-size: 15px;
font-weight: 700;
color: var(--fg);
text-transform: none;
letter-spacing: -0.005em;
}
.cardset.bar > .card > h4 { display: inline-block; margin-right: 12px; margin-bottom: 4px; padding-right: 12px; }
.cardset > .card > p
{
margin: 0 0 6px;
font-family: var(--font-serif);
font-size: 14px;
line-height: 1.55;
color: var(--fg);
}
.cardset > .card > p:last-of-type { margin-bottom: 0; }
.cardset > .card .rationale
{
display: none;
margin-top: 10px;
padding-top: 8px;
border-top: 1px dashed var(--border);
font-family: var(--font-serif);
font-style: italic;
font-size: 12px;
color: var(--muted);
}
.cardset > .card.recommended
{
background: color-mix(in srgb, var(--success) 5%, var(--bg-elev));
border-top-color: var(--success);
}
.cardset > .card.recommended .rationale { display: block; }
.cardset > .card.recommended::before
{
content: "RECOMMENDED";
position: absolute;
top: 8px;
right: 14px;
font-family: var(--font-sans);
font-size: 9px;
font-weight: 700;
letter-spacing: 0.18em;
color: var(--success);
}
body[data-interactive] .cardset[data-key][data-select="single"] > .card:not(.add),
body[data-interactive] .cardset[data-key][data-select="multi"] > .card:not(.add) { cursor: pointer; }
body[data-interactive] .cardset[data-key][data-select="none"] > .card:not(.add) { cursor: grab; }
body[data-interactive] .cardset[data-reorder="false"][data-select="none"] > .card:not(.add) { cursor: default; }
body[data-interactive] .cardset > .card:focus-visible
{
outline: 2px solid var(--accent);
outline-offset: 2px;
}
body[data-interactive] .cardset > .card.keyboard-moving
{
border-style: dashed;
border-color: var(--accent);
border-top-color: var(--accent);
animation: cardset-pulse 900ms ease-in-out infinite;
}
@keyframes cardset-pulse
{
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.sr-only
{
position: absolute !important;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
body[data-interactive] .cardset > .card.selected
{
background: var(--tint-accent);
border-color: var(--accent);
border-top-color: var(--accent);
}
body[data-interactive] .cardset > .card.selected::after
{
content: "✓";
position: absolute;
bottom: 8px;
right: 12px;
font-family: var(--font-sans);
font-size: 14px;
font-weight: 700;
color: var(--accent);
}
body[data-interactive] .cardset > .card:hover
{
border-top-color: var(--accent);
background: color-mix(in srgb, var(--accent) 4%, var(--bg-elev));
}
body[data-interactive] .cardset > .card.selected:hover
{
border-color: var(--accent);
border-top-color: var(--accent);
background: color-mix(in srgb, var(--accent) 12%, transparent);
}
body[data-interactive] .cardset > .card.recommended:hover
{
border-top-color: var(--success);
background: color-mix(in srgb, var(--success) 5%, var(--bg-elev));
}
body[data-interactive] .cardset > .card[data-user-added]:hover
{
border-top-color: var(--emphasis);
background: color-mix(in srgb, var(--emphasis) 5%, var(--bg-elev));
}
/* "Add" card */
.cardset > .card.add
{
display: flex;
align-items: center;
justify-content: center;
min-height: 64px;
padding: 14px;
background: transparent;
border: 1px dashed var(--border-strong);
color: var(--muted);
font-family: var(--font-sans);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
text-align: center;
cursor: pointer;
transition: color 0.14s, border-color 0.14s, background 0.14s;
}
.cardset.square > .card.add { min-height: 140px; }
.cardset > .card.add:hover { color: var(--accent); border-color: var(--accent); }
.cardset > .card.add::before,
.cardset > .card.add::after { content: none !important; }
/* User-added card */
.cardset > .card[data-user-added] { border-top-color: var(--emphasis); }
.cardset > .card[data-user-added]::before
{
content: "USER";
position: absolute;
top: 8px;
right: 14px;
font-family: var(--font-sans);
font-size: 9px;
font-weight: 700;
letter-spacing: 0.18em;
color: var(--emphasis);
}
.cardset > .card[data-user-added] [contenteditable]
{
outline: 1px dashed transparent;
outline-offset: 2px;
transition: outline-color 0.14s;
}
.cardset > .card[data-user-added] [contenteditable]:hover { outline-color: var(--border-strong); }
.cardset > .card[data-user-added] [contenteditable]:focus
{
background: var(--bg);
outline-color: var(--accent);
}
@media (max-width: 720px)
{
.cardset.square { grid-template-columns: 1fr; }
}
/* ---------- Mode: report — Layout ----------
The report mode is the long-form HTML output: a `<header class="page">`
+ `<main>` + `<footer class="page">` shape with constrained max-width.
Layout primitives, entrance animation, and the dynamic single-page
print logic (in `print.css` + `behavior.js`) are mode-specific —
the slides mode has its own layout under modes/slides/. */
/* ---------- Layout ---------- */
header.page,
main,
footer.page
{
max-width: 920px;
margin-left: auto;
margin-right: auto;
padding-left: var(--gap-xl);
padding-right: var(--gap-xl);
}
body[data-wide] header.page,
body[data-wide] main,
body[data-wide] footer.page { max-width: min(100%, 1280px); }
@media (max-width: 720px)
{
header.page,
main,
footer.page { padding-left: 20px; padding-right: 20px; }
}
header.page
{
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--gap-xl);
align-items: start;
padding-top: var(--gap-xl);
padding-bottom: var(--gap-lg);
border-bottom: 1px solid var(--rule);
}
header.page::after
{
content: "";
position: absolute;
left: var(--gap-xl);
bottom: -2px;
width: 72px;
height: 3px;
background: var(--accent);
}
@media (max-width: 720px)
{
header.page::after { left: 20px; }
}
header.page .meta h1
{
font-size: 36px;
line-height: 1.05;
letter-spacing: -0.02em;
margin: 0;
}
header.page .meta .subtitle
{
margin: 12px 0 0;
font-family: var(--font-serif);
font-style: italic;
font-size: 18px;
line-height: 1.45;
color: var(--muted);
max-width: 56ch;
}
header.page .meta .timestamp
{
margin: 18px 0 0;
font-family: var(--font-mono);
font-size: 11px;
color: var(--muted);
letter-spacing: 0.06em;
font-feature-settings: "tnum";
text-transform: uppercase;
}
header.page .meta .timestamp time,
header.page .meta .timestamp span { white-space: nowrap; }
header.page .actions
{
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
min-width: 116px;
}
@media (max-width: 720px)
{
header.page { grid-template-columns: 1fr; }
header.page .actions { flex-direction: row; flex-wrap: wrap; align-items: flex-start; }
}
main { padding-top: var(--gap-xl); padding-bottom: var(--gap-xl); }
/* ---------- Table of Contents (opt in via body[data-toc]) ----------
Runtime 由 behavior.js 之 setupToc 注入 <nav class="toc">,置於 <main>
首位。h2 顯主序、h3 縮排掛二級。預設 <details open>summary 可點收折。 */
main > nav.toc
{
margin: 0 0 var(--gap-xl) 0;
padding: var(--gap) var(--gap-lg);
border-left: 3px solid var(--accent);
background: var(--bg-elev);
font-size: 14px;
}
main > nav.toc summary
{
cursor: pointer;
list-style: none;
font-family: var(--font-sans);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
user-select: none;
}
main > nav.toc summary::-webkit-details-marker { display: none; }
main > nav.toc summary::before
{
content: "▸";
display: inline-block;
width: 1.2em;
transition: transform 0.15s;
}
main > nav.toc details[open] summary::before { transform: rotate(90deg); }
main > nav.toc details[open] summary
{
margin-bottom: 8px;
padding-bottom: 8px;
border-bottom: 1px solid var(--rule);
}
main > nav.toc ol
{
list-style: none;
margin: 0;
padding: 0;
counter-reset: toc-section;
}
main > nav.toc li.toc-h2
{
counter-increment: toc-section;
margin: 6px 0;
line-height: 1.4;
}
main > nav.toc li.toc-h2::before
{
content: counter(toc-section, decimal-leading-zero);
display: inline-block;
width: 2.4em;
color: var(--accent);
font-family: var(--font-mono);
font-size: 12px;
font-feature-settings: "tnum";
letter-spacing: 0.04em;
}
main > nav.toc li.toc-h3
{
margin: 3px 0 3px 2.4em;
font-size: 13px;
line-height: 1.4;
color: var(--muted);
}
main > nav.toc li.toc-h3::before
{
content: "—";
display: inline-block;
width: 1.5em;
color: var(--rule);
}
main > nav.toc a
{
color: var(--fg);
text-decoration: none;
border-bottom: 1px solid transparent;
}
main > nav.toc a:hover
{
color: var(--accent);
border-bottom-color: var(--accent);
}
main > nav.toc li.toc-h3 a { color: var(--muted); }
main > nav.toc li.toc-h3 a:hover { color: var(--accent); }
html { scroll-behavior: smooth; }
@media (prefers-reduced-motion: reduce)
{
html { scroll-behavior: auto; }
}
footer.page
{
margin-top: 64px;
padding-top: 16px;
padding-bottom: 32px;
border-top: 1px solid var(--border);
color: var(--muted);
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.06em;
}
/* ---------- Entrance animation ---------- */
@keyframes lift
{
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
header.page .meta > * { animation: lift 0.5s ease-out backwards; }
header.page .meta > *:nth-child(1) { animation-delay: 0.05s; }
header.page .meta > *:nth-child(2) { animation-delay: 0.12s; }
header.page .meta > *:nth-child(3) { animation-delay: 0.18s; }
header.page .actions { animation: lift 0.5s ease-out 0.22s backwards; }
main > * { animation: lift 0.5s ease-out 0.28s backwards; }
main > *:nth-child(n+2) { animation-delay: 0.34s; }
main > *:nth-child(n+4) { animation-delay: 0.40s; }
main > *:nth-child(n+6) { animation-delay: 0.46s; }
@media (prefers-reduced-motion: reduce)
{
*, *::before, *::after
{
animation-duration: 0.001ms !important;
animation-delay: 0ms !important;
transition-duration: 0.001ms !important;
}
}
/* ---------- Mode: report — Print / PDF ----------
Dynamic single-page PDF: runtime.js setupDynamicPrintPage() measures
content height before print and injects @page sized to fit. Slides
mode uses per-slide pagination (page-break-after: always) instead. */
/* ---------- Print ---------- */
/* ---------- Print / PDF export ----------
Target: "Save as PDF" produces a **single-page** document — the entire
artifact on one tall virtual page, with generous editorial margins, no
pagination cuts mid-component. Decisions:
- `@page { size: 210mm auto; }` — A4 width, height auto-grows to fit
content. Chrome's PDF engine honors this; Firefox / Safari may
fall back to the default page size, in which case the
`page-break-inside: avoid` defenses below still prevent splits at
ugly positions.
- Page-margin boxes (@top-right, @bottom-center) dropped — they'd
appear exactly once on the giant page, reading as floating boxes
rather than running headers. Single-page PDFs don't need page
numbers.
- 25mm top/bottom × 22mm left/right margins — editorial.
- Force Alucard palette regardless of OS / data-theme. Pro (dark)
wastes ink and reads poorly on paper.
- `print-color-adjust: exact` keeps designed tints (callouts, table
headers, progress segments, diff lines, top-edge bands).
- `page-break-inside: avoid` retained on cohesive blocks as a
defense for browsers that don't honor `size: auto` height.
- Headings `page-break-after: avoid` keeps them with their content.
- Details always rendered open; summaries flatten to plain headings.
- Links expand to show the URL (footnote-style).
- Screen-only UI (action buttons, copy buttons, theme toggle,
drag-grid lines, snap guides) hidden. */
@page
{
/* A4 fallback. Real page size is set dynamically at runtime by
`setupDynamicPrintPage()` in runtime.js — it measures the rendered
height before print and injects a fresh `@page` rule sized to fit
content exactly.
Margin: 0 here. The real margin lives in `body` padding (see the
@media print rule below), which the user's print dialog cannot
override. Putting margin INSIDE `body` keeps the content's
horizontal width stable across dialog choices — only that lets
the JS page-height heuristic stay accurate (dialog squeezing
content would otherwise force unpredictable extra line wraps). */
size: A4;
margin: 0;
}
@media print
{
/* Palette pinning to the active theme's light values lives in
`themes/<active>.css` (each theme file ships its own @media
print block). Below this point: layout-only print rules
(font-size, body padding, hidden chrome, page-break-avoid). */
html, body
{
background: #FFFFFF;
color: #1F1F1F;
font-size: 10.5pt;
font-family: var(--font-serif);
print-color-adjust: exact;
-webkit-print-color-adjust: exact;
}
/* Real margin lives here, not in @page margin. Two reasons:
1. Dialog "Margins: None" overrides @page margin to 0; body
padding survives that override.
2. Pinning the horizontal width stabilises print line-breaks so
the JS page-height calc stays accurate regardless of which
dialog Margins option the user picks.
Top padding is intentionally larger — printed page heads read
better with extra breathing room above the title. */
body
{
padding: 1.5cm 1cm 1cm 1cm;
box-sizing: border-box;
}
/* Hide screen-only chrome */
header.page .actions,
header.page::after,
pre .copy-btn,
#theme-toggle,
svg.diagram .grid-line,
svg.diagram .snap-guides { display: none !important; }
/* Full-bleed layout — give @page margin the responsibility */
header.page,
main,
footer.page
{
max-width: none;
padding-left: 0;
padding-right: 0;
margin-left: 0;
margin-right: 0;
}
header.page
{
padding-top: 0;
padding-bottom: 10pt;
margin-bottom: 10pt;
border-bottom: 0.5pt solid var(--rule);
}
main { padding-top: 0; padding-bottom: 0; }
footer.page
{
margin-top: 18pt;
padding-top: 6pt;
border-top: 0.25pt solid var(--border);
font-size: 8.5pt;
}
/* Typography in pt for print */
h1 { font-size: 22pt; margin-top: 0; margin-bottom: 10pt; }
h2 { font-size: 16pt; margin: 18pt 0 8pt; }
h3 { font-size: 13pt; margin: 14pt 0 6pt; }
h4 { font-size: 11pt; margin: 10pt 0 4pt; }
h5, h6 { font-size: 9pt; margin: 8pt 0 4pt; }
h1, h2, h3, h4, h5, h6
{
page-break-after: avoid;
break-after: avoid;
}
p, li
{
orphans: 3;
widows: 3;
}
/* Cohesive blocks: never split across pages */
pre, table,
.callout, .note,
.compare-cols > .col,
.cardset > .card,
.progress-track,
.timeline > li,
.diff-review,
svg.diagram
{
page-break-inside: avoid;
break-inside: avoid;
}
/* Repeat table headers / footers on every printed page */
thead { display: table-header-group; }
tfoot { display: table-footer-group; }
/* Details always expanded in print */
details { padding: 6pt 0; }
details > summary { background: transparent !important; padding: 0; margin: 0; }
details > summary::before { content: ""; }
details > *:not(summary)
{
display: revert !important;
margin: 6pt 0 0 0;
padding-left: 0;
border-left: none;
}
/* Links: footnote-style URL expansion for external links */
a { color: inherit; text-decoration: underline; }
a[href^="http"]::after,
a[href^="mailto:"]::after
{
content: " (" attr(href) ")";
font-size: 0.85em;
color: var(--muted);
}
a[href^="#"]::after,
a[href^="./"]::after,
a[href^="../"]::after { content: ""; }
/* Timeline: tighter left margin in print */
.timeline { margin-left: 80pt; }
/* Progress segments: keep solid colors with the inverted (white)
label that the screen rule sets via `color: var(--bg)`. Since
--bg is forced to white above, label color stays white. */
.progress-track > .progress-seg { color: #FFFFFF; }
/* Disable all transitions / animations during print */
*, *::before, *::after
{
animation: none !important;
transition: none !important;
}
}
</style>
</head>
<body>
<header class="page">
<div class="meta">
<h1>cjk-autospace — Changelog</h1>
<p class="subtitle">時間倒序。架構細節見同分支 <code>index.html</code></p>
<p class="timestamp"><time>2026-05-22</time> · <span>docs branch</span></p>
</div>
<div class="actions">
<button class="btn" type="button" data-action="export-md" title="Copy Markdown to clipboard">Copy MD</button>
<button class="btn" type="button" data-action="download-md" title="Download as .md">Download</button>
<button class="btn" type="button" data-action="expand-all" title="Expand all sections">Expand</button>
<button class="btn" type="button" data-action="collapse-all" title="Collapse all sections">Collapse</button>
<button class="btn" type="button" data-action="submit" title="Copy structured response to clipboard">Submit</button>
</div>
</header>
<main>
<h2>2026-05-26 — 補 ESM <code>export</code>(四種消費形式並存)</h2>
<p>下游 Vite / Rollup / TypeScript 之靜態 ESM 解析需要真正之 <code>export</code> 關鍵字;原 UMD-ish<code>module.exports</code> + <code>globalThis</code>)對 ESM 消費端仍須走「副作用導入 + 從 global 取 factory」之 workaround。檔尾在既有 CJS / globalThis 兩塊後加 <code>export { createCjkAutospace }</code>,順序刻意如此 —— 讓 IIFE 串接端(Claude 模板 <code>build.sh</code> / Nvim peek build hook)只需以 <code>sed '/^export {/d'</code> 剝最後一行。</p>
<p>四種消費形式並存:ESM <code>import</code> / CommonJS <code>require</code> / browser <code>globalThis</code> / IIFE 串接(後者需剝 <code>export</code> 行)。</p>
<h2>2026-05-26 — Pass C:長英文段 lang 包裝(預設啟用)</h2>
<p>解決 CJK 兩端對齊正文中行末長英文詞之斷行問題。原模板於 runtime 端用 <code>&lt;wbr&gt;</code> 每 6 字元亂插斷詞機會,產生兩個觀感問題:(1)位置與音節邊界無關,斷在怪位;(2)<code>&lt;wbr&gt;</code> 為 zero-width,斷處不顯連字符,讀者無法辨認「上行末是被斷開的詞」。</p>
<p>Pass C 走另一條路:把 <code>[A-Za-z]{N,}</code>(預設 <code>N=6</code>)之長英文段包進 <code>&lt;span lang="en"&gt;</code>,配合消費端 CSS</p>
<pre><code>[lang|="en"] {
hyphens: auto;
-webkit-hyphens: auto;
hyphenate-limit-chars: 6 3 3;
hyphenate-character: "-"; /* 強制 U+002D 而非預設 U+2010 */
-webkit-hyphenate-character: "-";
}</code></pre>
<ul>
<li>瀏覽器原生 <code>hyphens: auto</code> 用 en 字典做斷詞 + 渲染連字符;連字符純由 CSS 渲染,<strong>DOM 內不存在</strong> → 選取 / 複製返回原文無污染(符合「斷字符僅供渲染」之核心約束)</li>
<li>冪等:parent 為 <code>&lt;span lang&gt;</code> 直接跳過,重跑或 MutationObserver 觸發不會重複包裝</li>
<li>skip 鏈與 Pass A / B 共用 <code>isSkipped</code>CODE / KBD / PRE / SAMP / TT / VAR / SCRIPT / STYLE / TEXTAREA / INPUT / SVG / contentEditable / <code>data-md-key</code> 皆跳過</li>
<li><strong>預設啟用</strong><code>longWordWrap: false</code> 顯式關閉;<code>{ minLength, lang }</code> 自訂粒度與標語</li>
</ul>
<h3>字型陷阱:U+2010 之全形 CJK 設計</h3>
<p>瀏覽器 <code>hyphens: auto</code> 預設在斷處插 U+2010 HYPHEN。Noto Serif CJK SC 等 CJK serif 字型把 U+2010 設計為 <strong>全形 1 em</strong>(與一個漢字同寬),以服務 CJK 範圍表達(<code>1月—3月</code>);而 U+002D HYPHEN-MINUS 在同字型內為窄拉丁(約 0.35 em)。直接吃預設會看到「全形連字符」突兀地接在英文詞末。</p>
<p>解法是消費端 CSS 加 <code>hyphenate-character: "-"</code>,強制使用 U+002D;仍為純視覺渲染,DOM 與剪貼簿不受影響。lib 不涉入字型決策,僅在文檔中提示。</p>
<h2>2026-05-22 — 初始發布</h2>
<ul>
<li>工廠函式 <code>createCjkAutospace(options)</code>,回傳 <code>{ apply(root?) }</code>。Port 自 Pangu.js v7.2.1 規則表(MIT 授權),覆寫架構為雙 pass:
<ul>
<li><strong>Pass A — per-block Pangu on tag-stripped text</strong>:對每個 BLOCK 走 DFS 收集邏輯文本(樣式標籤透明、PILL 與子 BLOCK 中斷 run),整段套 <code>panguSpace</code>,把 MARK 反映射回 textNode/offset 並插入 <code>.cjk-autospace</code> marker span。</li>
<li><strong>Pass B — pill boundary, style-inline aware</strong>:對每個 PILL 透過樣式鏈遞迴找邏輯鄰字(<code>adjacentLogicalChar</code>),按 0/1/多+特殊規則處理空白。</li>
</ul>
</li>
<li>標籤三分類:STYLE_INLINES / PILL / BLOCK,皆可由消費端配置。</li>
<li>UMD-ish export:同時支援 ESM <code>import</code>、CommonJS <code>require</code>、browser global <code>globalThis.createCjkAutospace</code> 與 inline <code>&lt;script&gt;</code> 函式宣告形式。</li>
<li>TS 宣告:<code>cjk-autospace.d.ts</code> 提供完整 <code>CjkAutospaceOptions</code><code>CjkAutospaceInstance</code> 型別。</li>
<li>初始消費者:
<ul>
<li>Claude HTML 模板(<code>Base/templates/html/runtime/lib/cjk-autospace/</code>pillSelector <code>"code, kbd"</code></li>
<li>Peek.nvim Markdown 預覽 shim<code>Nvim/lua/user/lib/cjk-autospace/</code>pillSelector <code>"code, kbd, mark, samp"</code></li>
<li>cdrop<code>cdrop/web/src/lib/cjk-autospace/</code>pillSelector <code>"code, kbd, .cjk-pill"</code></li>
</ul>
</li>
<li>docs 分支:<code>index.html</code>(架構詳細文檔)+ <code>CHANGELOG.html</code>(本檔)。</li>
</ul>
<div class="callout info">
<span class="label">設計來源</span> 雙 pass 架構是原創設計,解決 Pangu.js 原版「text-node walker 看不到跨元素邊界」之問題(典型癥狀:<code>&lt;strong&gt;&lt;/strong&gt; Neovim</code> 此類邊界無法被識別)。
</div>
</main>
<footer class="page">
<p>cjk-autospace · docs branch · CHANGELOG</p>
</footer>
<svg class="sprite" aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg">
<defs>
<marker id="arrow-filled" viewBox="0 0 10 10" refX="9" refY="5"
markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="currentColor"/>
</marker>
<marker id="arrow-open" viewBox="0 0 10 10" refX="9" refY="5"
markerWidth="8" markerHeight="8" orient="auto-start-reverse">
<path d="M0,1 L9,5 L0,9" fill="none" stroke="currentColor"
stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
</marker>
<marker id="dot" viewBox="0 0 6 6" refX="3" refY="3"
markerWidth="6" markerHeight="6">
<circle cx="3" cy="3" r="2" fill="currentColor"/>
</marker>
</defs>
</svg>
<script type="text/markdown" id="md-source">
# cjk-autospace Changelog
## 2026-05-26 ESM `export`四種消費形式並存
- 檔尾加 `export { createCjkAutospace }`順序在 CJS / globalThis 兩塊之後 IIFE 串接端只需 `sed '/^export {/d'` 剝最後一行
- 四種消費形式並存ESM `import` / CommonJS `require` / browser `globalThis` / IIFE 串接
## 2026-05-26 Pass C長英文段 lang 包裝預設啟用
- [A-Za-z]{N,} 之長英文段包進 `<span lang="en">`配消費端 CSS `[lang|="en"] { hyphens: auto; ... }` 用瀏覽器原生 en 字典斷詞 + 渲染連字符
- 連字符純由 CSS 渲染**DOM 內不存在**選取 / 複製返回原文無污染
- 預設啟用`longWordWrap: false` 顯式關閉
- 字型陷阱CJK serif 字型把 U+2010 設計為全形1 em須加 `hyphenate-character: "-"` 強制 U+002D 才不會看到全形連字符
## 2026-05-22 初始發布
- 工廠函式 createCjkAutospace(options)Port Pangu.js v7.2.1 規則表
- pass 架構
- Pass A per-block Pangu on tag-stripped text
- Pass B pill boundary, style-inline aware
- 標籤三分類STYLE_INLINES / PILL / BLOCK皆可配置
- UMD-ish export + TS 宣告
- 初始消費者Claude HTML 模板Peek.nvimcdrop
- docs 分支index.html架構+ CHANGELOG.html本檔
</script>
<script>
"use strict";
/* cjk-autospace — Pangu.js v7.2.1 風格 CJK ↔ Latin 邊界自動間距 shim。
*
* 工廠模式:`createCjkAutospace(options).apply(root)`。配置由消費端注入;
* 預設值對應 HTML 模板 runtime 之原狀(pill = code, kbd;標籤三分類;
* marker class 為 .cjk-autospaceskip ancestor 為 mono / SVG /
* contentEditable / [data-md-key])。
*
* 標籤三分類:
* - STYLE_INLINES — 樣式標籤,shim 透明走過。跨樣式邊界(如
* <strong>是</strong> Neovim)由 Pass A 在剝離樣式後識別。
* - PILL — 行內塊(有獨立背景,如 <code>),邊界由 Pass B
* 處理。預設 'code, kbd',可由消費端擴充。
* - BLOCK — 段落級標籤,Pass A 把它們當作 run 邊界。
*
* 三 pass
* Pass A — 對每個 BLOCK 走 DFS 收集邏輯文本(STYLE 透明、PILL 與子
* BLOCK 中斷 run),整段套 panguSpace,把 MARK 反映射回 textNode/
* offset。MARK 對應原文若是單 ASCII space 則 strip 後插 marker
* 否則純插。actions 倒序執行確保前面偏移不被後面動作影響。
* Pass B — 對每個 PILL 透過 STYLE 鏈遞迴找邏輯鄰字(adjacentLogical-
* Char),按 0/1/多+特殊規則處理(0 個或 1 個 ASCII space 插
* marker;其餘跳過)。
* Pass C — 可選(options.longWordWrap)。把 [A-Za-z]{N,} 之長英文段
* 包進 <span lang="en">;配合消費端 CSS `[lang|="en"]{hyphens:auto}`
* 讓瀏覽器原生連字斷詞。連字符純由 CSS 渲染(DOM 內不存在),複製
* 不污染原文。預設關閉以保持向後相容。
*
* Marker = <span class="cjk-autospace" aria-hidden="true">(空白)</span>。
* 內含真實 ASCII 空白:layout 視為 whitespace(行末 collapse / 容許
* 換行 / justify 可拉伸);配 CSS user-select: none 讓選取與剪貼板跳
* 過該 span。textContent 不變(無視 span 內容)→ Copy / Paste / 序列
* 化都乾淨。
*/
function createCjkAutospace(options)
{
options = options || {};
/* ----- 預設配置 ----- */
const DEFAULT_STYLE_INLINES = [
"STRONG", "EM", "A", "B", "I", "U", "S", "MARK", "INS", "DEL",
"SUB", "SUP", "SMALL", "ABBR", "CITE", "DFN", "Q", "SPAN",
"VAR", "SAMP", "TIME", "BDO", "BDI", "RUBY", "RB", "RT", "RP", "WBR",
];
const DEFAULT_BLOCK_TAGS = [
"BODY", "P", "DIV", "H1", "H2", "H3", "H4", "H5", "H6", "LI",
"DT", "DD", "BLOCKQUOTE", "FIGURE", "FIGCAPTION", "TD", "TH",
"CAPTION", "SUMMARY", "DETAILS", "HEADER", "FOOTER", "SECTION",
"ARTICLE", "ASIDE", "NAV", "MAIN", "ADDRESS", "PRE", "UL", "OL",
"DL", "TABLE", "TR", "THEAD", "TBODY", "TFOOT", "FORM",
"FIELDSET", "LEGEND", "LABEL", "BR", "HR",
];
const DEFAULT_SKIP_TAGS = [
"CODE", "KBD", "PRE", "SAMP", "TT", "VAR",
"SCRIPT", "STYLE", "TEXTAREA", "INPUT",
];
const STYLE_SET = new Set(options.styleInlines || DEFAULT_STYLE_INLINES);
const BLOCK_SET = new Set(options.blockTags || DEFAULT_BLOCK_TAGS);
const SKIP_SET = new Set(options.skipTags || DEFAULT_SKIP_TAGS);
const pillSelector = options.pillSelector || "code, kbd";
const autospaceCls = options.autospaceClass || "cjk-autospace";
const skipAttr = options.skipAttribute || "data-md-key";
const userIsSkipped = typeof options.isSkipped === "function" ? options.isSkipped : null;
/* Pass C 設定。預設啟用(minLength=6 / lang="en");顯式傳 false 才
關閉。可給 { minLength, lang } 局部自訂。
注意:斷點前後最少字符數由消費端 CSS `hyphenate-limit-chars` 控
制(建議 `6 3 3`),lib 不涉入。 */
const longWordCfg = (function ()
{
const v = options.longWordWrap;
if (v === false) { return null; }
if (v === undefined || v === true || v === null) { return { minLength: 6, lang: "en" }; }
return {
minLength: typeof v.minLength === "number" && v.minLength >= 2 ? v.minLength : 6,
lang: typeof v.lang === "string" && v.lang ? v.lang : "en",
};
}());
/* ----- Pangu 字符類與規則表 ----- */
const PANGU_CJK = "⺀-⻿⼀-⿟"
+ "぀-ゟ゠-ヺー-ヿ"
+ "㄀-ㄯ㈀-㋿"
+ "㐀-䶿一-鿿豈-﫿";
const AN_CHARS = "A-Za-z0-9";
const A_CHARS = "A-Za-z";
const OPS_HYPHEN = "\\+\\*=&\\-";
const OPS_GRADE = "\\+\\-\\*";
const QUOTES_CLS = "`\"״";
const LBR_BASIC = "\\(\\[\\{";
const RBR_BASIC = "\\)\\]\\}";
const LBR_EXT = "\\(\\[\\{<>";
const RBR_EXT = "\\)\\]\\}<>";
const ANS_CJK_AFTER = A_CHARS + "Ͱ-Ͽ0-9@\\$%\\^&\\*\\-\\+\\\\=¡-ÿ⅐-↏✀-➿";
const ANS_BEFORE_CJK = A_CHARS + "Ͱ-Ͽ0-9\\$%\\^&\\*\\-\\+\\\\=¡-ÿ⅐-↏✀-➿";
const ANY_CJK = new RegExp("[" + PANGU_CJK + "]");
/* PUA marker chars. 不會撞到正常 prose 中之 glyph。 */
const MARK = "";
const PH_OPEN = "";
const PH_CLOSE = "";
const STRIP_CJK_SPACE_ANS = new RegExp("([" + PANGU_CJK + "]) ([" + ANS_CJK_AFTER + "])", "g");
const STRIP_ANS_SPACE_CJK = new RegExp("([" + ANS_BEFORE_CJK + "]) ([" + PANGU_CJK + "])", "g");
const DOTS_CJK = new RegExp("([\\.]{2,}|…)([" + PANGU_CJK + "])", "g");
const CJK_PUNCTUATION = new RegExp("([" + PANGU_CJK + "])([!;,\\?:]+)(?=[" + PANGU_CJK + AN_CHARS + "])", "g");
const AN_PUNCTUATION_CJK = new RegExp("([" + AN_CHARS + "])([!;,\\?]+)([" + PANGU_CJK + "])", "g");
const CJK_TILDE = new RegExp("([" + PANGU_CJK + "])(~+)(?!=)(?=[" + PANGU_CJK + AN_CHARS + "])", "g");
const CJK_TILDE_EQUALS = new RegExp("([" + PANGU_CJK + "])(~=)", "g");
const CJK_PERIOD = new RegExp("([" + PANGU_CJK + "])(\\.)(?![" + AN_CHARS + "\\./])(?=[" + PANGU_CJK + AN_CHARS + "])", "g");
const AN_PERIOD_CJK = new RegExp("([" + AN_CHARS + "])(\\.)([" + PANGU_CJK + "])", "g");
const AN_COLON_CJK = new RegExp("([" + AN_CHARS + "])(:)([" + PANGU_CJK + "])", "g");
const CJK_QUOTE = new RegExp("([" + PANGU_CJK + "])([" + QUOTES_CLS + "])", "g");
const QUOTE_CJK = new RegExp("([" + QUOTES_CLS + "])([" + PANGU_CJK + "])", "g");
const QUOTE_AN = new RegExp("([”])([" + AN_CHARS + "])", "g");
const CJK_QUOTE_AN = new RegExp("([" + PANGU_CJK + "])(\")([" + AN_CHARS + "])", "g");
const CJK_HASH = new RegExp("([" + PANGU_CJK + "])(#([^ ]))", "g");
const HASH_CJK = new RegExp("(([^ ])#)([" + PANGU_CJK + "])", "g");
const SINGLE_LETTER_GRADE = new RegExp("\\b([" + A_CHARS + "])([" + OPS_GRADE + "])([" + PANGU_CJK + "])", "g");
const CJK_OPERATOR_ANS = new RegExp("([" + PANGU_CJK + "])([" + OPS_HYPHEN + "])([" + AN_CHARS + "])", "g");
const ANS_OPERATOR_CJK = new RegExp("([" + AN_CHARS + "])([" + OPS_HYPHEN + "])([" + PANGU_CJK + "])", "g");
const CJK_LESS_THAN = new RegExp("([" + PANGU_CJK + "])(<)([" + AN_CHARS + "])", "g");
const LESS_THAN_CJK = new RegExp("([" + AN_CHARS + "])(<)([" + PANGU_CJK + "])", "g");
const CJK_GREATER_THAN = new RegExp("([" + PANGU_CJK + "])(>)([" + AN_CHARS + "])", "g");
const GREATER_THAN_CJK = new RegExp("([" + AN_CHARS + "])(>)([" + PANGU_CJK + "])", "g");
const CJK_LEFT_BRACKET = new RegExp("([" + PANGU_CJK + "])([" + LBR_EXT + "])", "g");
const RIGHT_BRACKET_CJK = new RegExp("([" + RBR_EXT + "])([" + PANGU_CJK + "])", "g");
const AN_LEFT_BRACKET = new RegExp("([" + AN_CHARS + "])(?<!\\.[" + AN_CHARS + "]*)([" + LBR_BASIC + "])", "g");
const RIGHT_BRACKET_AN = new RegExp("([" + RBR_BASIC + "])([" + AN_CHARS + "])", "g");
const CJK_ANS = new RegExp("([" + PANGU_CJK + "])([" + ANS_CJK_AFTER + "])", "g");
const ANS_CJK = new RegExp("([" + ANS_BEFORE_CJK + "])([" + PANGU_CJK + "])", "g");
const S_A = new RegExp("(%)([" + A_CHARS + "])", "g");
/* Compound words like GPT-4 / state-of-the-art mustn't get operator
spaces wedged into them. Mask before operator passes, restore
before bracket / CJK_ANS / ANS_CJK passes (so the boundary
between 是 and GPT-4 still gets spaced). */
const COMPOUND_WORD = /\b(?:[A-Za-z0-9]*[a-z][A-Za-z0-9]*-[A-Za-z0-9]+|[A-Za-z0-9]+-[A-Za-z0-9]*[a-z][A-Za-z0-9]*|[A-Za-z]+-[0-9]+|[A-Za-z]+[0-9]+-[A-Za-z0-9]+)(?:-[A-Za-z0-9]+)*\b/g;
const PH_PATTERN = new RegExp(PH_OPEN + "(\\d+)" + PH_CLOSE, "g");
function panguSpace(text)
{
if (text.length <= 1 || !ANY_CJK.test(text)) { return text; }
let s = text;
/* Pre-passstrip authored single spaces at CJK↔ANS boundaries so
the rest of the pipeline can treat the text as if it had been
written flush. */
s = s.replace(STRIP_CJK_SPACE_ANS, "$1$2");
s = s.replace(STRIP_ANS_SPACE_CJK, "$1$2");
s = s.replace(DOTS_CJK, "$1" + MARK + "$2");
s = s.replace(CJK_PUNCTUATION, "$1$2" + MARK);
s = s.replace(AN_PUNCTUATION_CJK, "$1$2" + MARK + "$3");
s = s.replace(CJK_TILDE, "$1$2" + MARK);
s = s.replace(CJK_TILDE_EQUALS, "$1" + MARK + "$2" + MARK);
s = s.replace(CJK_PERIOD, "$1$2" + MARK);
s = s.replace(AN_PERIOD_CJK, "$1$2" + MARK + "$3");
s = s.replace(AN_COLON_CJK, "$1$2" + MARK + "$3");
s = s.replace(CJK_QUOTE, "$1" + MARK + "$2");
s = s.replace(QUOTE_CJK, "$1" + MARK + "$2");
s = s.replace(QUOTE_AN, "$1" + MARK + "$2");
s = s.replace(CJK_QUOTE_AN, "$1$2" + MARK + "$3");
s = s.replace(CJK_HASH, "$1" + MARK + "$2");
s = s.replace(HASH_CJK, "$1" + MARK + "$3");
const compounds = [];
s = s.replace(COMPOUND_WORD, function (m)
{
compounds.push(m);
return PH_OPEN + (compounds.length - 1) + PH_CLOSE;
});
s = s.replace(SINGLE_LETTER_GRADE, "$1$2" + MARK + "$3");
s = s.replace(CJK_OPERATOR_ANS, "$1" + MARK + "$2" + MARK + "$3");
s = s.replace(ANS_OPERATOR_CJK, "$1" + MARK + "$2" + MARK + "$3");
s = s.replace(CJK_LESS_THAN, "$1" + MARK + "$2" + MARK + "$3");
s = s.replace(LESS_THAN_CJK, "$1" + MARK + "$2" + MARK + "$3");
s = s.replace(CJK_GREATER_THAN, "$1" + MARK + "$2" + MARK + "$3");
s = s.replace(GREATER_THAN_CJK, "$1" + MARK + "$2" + MARK + "$3");
/* Restore compound words BEFORE bracket / CJK_ANS / ANS_CJK
passes — otherwise their PUA placeholders look like neither
CJK nor AN and the boundaries silently skip. */
s = s.replace(PH_PATTERN, function (_m, idx) { return compounds[+idx] || ""; });
s = s.replace(CJK_LEFT_BRACKET, "$1" + MARK + "$2");
s = s.replace(RIGHT_BRACKET_CJK, "$1" + MARK + "$2");
s = s.replace(AN_LEFT_BRACKET, "$1" + MARK + "$2");
s = s.replace(RIGHT_BRACKET_AN, "$1" + MARK + "$2");
s = s.replace(CJK_ANS, "$1" + MARK + "$2");
s = s.replace(ANS_CJK, "$1" + MARK + "$2");
s = s.replace(S_A, "$1" + MARK + "$2");
return s;
}
/* ----- skip ancestor 判定 ----- */
function isSkipped(node)
{
if (userIsSkipped && userIsSkipped(node)) { return true; }
let p = node.parentNode;
while (p && p.nodeType === 1)
{
if (SKIP_SET.has(p.nodeName)) { return true; }
if (p.namespaceURI === "http://www.w3.org/2000/svg") { return true; }
if (p.isContentEditable) { return true; }
if (skipAttr && p.hasAttribute && p.hasAttribute(skipAttr)) { return true; }
p = p.parentNode;
}
return false;
}
/* ----- helpers ----- */
function insertSpanAt(parent, refNode)
{
const span = document.createElement("span");
span.className = autospaceCls;
span.setAttribute("aria-hidden", "true");
span.textContent = " ";
parent.insertBefore(span, refNode);
}
function insertMarkerAfterPosition(pos)
{
const tn = pos.textNode;
const afterIdx = pos.offset + 1;
if (afterIdx >= tn.data.length)
{
insertSpanAt(tn.parentNode, tn.nextSibling);
}
else
{
const split = tn.splitText(afterIdx);
insertSpanAt(split.parentNode, split);
}
}
function isAutospaceSpan(node)
{
return node && node.nodeType === 1
&& node.classList && node.classList.contains(autospaceCls);
}
/* ----- Pass A: per-block Pangu on tag-stripped text ----- */
function processBlockText(block)
{
const runs = [];
let curText = "";
let curMap = [];
function flushRun()
{
if (curText.length >= 2) { runs.push({ text: curText, map: curMap }); }
curText = "";
curMap = [];
}
function visit(node)
{
if (!node) { return; }
if (node.nodeType === 3)
{
if (isSkipped(node)) { return; }
const data = node.data;
for (let i = 0; i < data.length; i += 1)
{
curText += data.charAt(i);
curMap.push({ textNode: node, offset: i });
}
return;
}
if (node.nodeType !== 1) { return; }
if (isSkipped(node)) { return; }
if (isAutospaceSpan(node)) { flushRun(); return; }
const tag = node.nodeName;
/* PILL / BLOCK / SKIP_SET 三類都中斷 runpill 與 block 邊界
另由 Pass B / 各自子 block 處理)。 */
if (SKIP_SET.has(tag) || BLOCK_SET.has(tag))
{
flushRun();
return;
}
if (node.matches && node.matches(pillSelector))
{
flushRun();
return;
}
/* 樣式標籤或未知元素:透明遞迴。 */
let c = node.firstChild;
while (c) { visit(c); c = c.nextSibling; }
}
let c = block.firstChild;
while (c) { visit(c); c = c.nextSibling; }
flushRun();
for (let r = 0; r < runs.length; r += 1)
{
const text = runs[r].text;
const map = runs[r].map;
const spaced = panguSpace(text);
if (spaced === text) { continue; }
/* Walk spaced + text in parallel. 每個 MARK 在 spaced 中要麼
(a) 取代了原文同位之 single space,要麼 (b) 是純插入。 */
const actions = [];
let origIdx = 0;
let spacedIdx = 0;
while (spacedIdx < spaced.length)
{
const sc = spaced.charAt(spacedIdx);
if (sc === MARK)
{
const oc = origIdx < text.length ? text.charAt(origIdx) : null;
if (oc === " ")
{
actions.push({ before: map[origIdx - 1], space: map[origIdx] });
origIdx += 1;
}
else
{
actions.push({ before: map[origIdx - 1], space: null });
}
spacedIdx += 1;
continue;
}
origIdx += 1;
spacedIdx += 1;
}
/* 倒序執行,避免 text-node 修改影響前面動作之 offset。 */
for (let i = actions.length - 1; i >= 0; i -= 1)
{
const act = actions[i];
if (!act.before) { continue; }
if (act.space)
{
const tn = act.space.textNode;
tn.data = tn.data.slice(0, act.space.offset) + tn.data.slice(act.space.offset + 1);
}
insertMarkerAfterPosition(act.before);
}
}
}
/* ----- Pass B: pill boundary, style-inline aware ----- */
function adjacentLogicalChar(el, direction)
{
function pickEnd(tn)
{
if (direction === -1)
{
return { textNode: tn, offset: tn.data.length - 1, ch: tn.data.charAt(tn.data.length - 1) };
}
return { textNode: tn, offset: 0, ch: tn.data.charAt(0) };
}
function descend(into)
{
let n = direction === -1 ? into.lastChild : into.firstChild;
while (n)
{
if (n.nodeType === 3 && n.data.length > 0) { return pickEnd(n); }
if (n.nodeType === 1 && STYLE_SET.has(n.nodeName) && !isAutospaceSpan(n))
{
const r = descend(n);
if (r) { return r; }
}
n = direction === -1 ? n.previousSibling : n.nextSibling;
}
return null;
}
let n = direction === -1 ? el.previousSibling : el.nextSibling;
let p = el.parentNode;
while (true)
{
while (n)
{
if (n.nodeType === 3)
{
if (n.data.length > 0) { return pickEnd(n); }
n = direction === -1 ? n.previousSibling : n.nextSibling;
continue;
}
if (n.nodeType === 1)
{
if (isSkipped(n)) { return null; }
if (isAutospaceSpan(n)) { return null; }
if (STYLE_SET.has(n.nodeName))
{
const r = descend(n);
if (r) { return r; }
n = direction === -1 ? n.previousSibling : n.nextSibling;
continue;
}
/* Pill 或 block — 停。 */
return null;
}
n = direction === -1 ? n.previousSibling : n.nextSibling;
}
if (!p || !STYLE_SET.has(p.nodeName)) { return null; }
n = direction === -1 ? p.previousSibling : p.nextSibling;
p = p.parentNode;
}
}
const PILL_NEIGHBOR = new RegExp("[A-Za-z0-9" + PANGU_CJK + "]");
function processPills(root)
{
root.querySelectorAll(pillSelector).forEach(function (pill)
{
if (isSkipped(pill)) { return; }
const parent = pill.parentNode;
if (!parent) { return; }
const prev = adjacentLogicalChar(pill, -1);
if (prev)
{
if (PILL_NEIGHBOR.test(prev.ch))
{
insertSpanAt(parent, pill);
}
else if (prev.ch === " " && prev.offset >= 1)
{
const tn = prev.textNode;
const bc = tn.data.charAt(prev.offset - 1);
if (bc !== " " && PILL_NEIGHBOR.test(bc))
{
tn.data = tn.data.slice(0, prev.offset) + tn.data.slice(prev.offset + 1);
insertSpanAt(parent, pill);
}
}
}
const next = adjacentLogicalChar(pill, +1);
if (next)
{
if (PILL_NEIGHBOR.test(next.ch))
{
insertSpanAt(parent, pill.nextSibling);
}
else if (next.ch === " " && next.textNode.data.length >= 2)
{
const ac = next.textNode.data.charAt(1);
if (ac !== " " && PILL_NEIGHBOR.test(ac))
{
next.textNode.data = next.textNode.data.slice(1);
insertSpanAt(parent, pill.nextSibling);
}
}
}
});
}
/* ----- Pass C: long English word wrap ----- */
/* 冪等性:parent 為 <span lang="…"> 直接跳過。已被本 pass 或作者標
語的英文段都會被 catche.g. <span lang="en-US">…</span> 雖然 lang
不等於 "en",但 caller 已意圖標語,重複包裝無實益。 */
function isInLangSpan(node)
{
const p = node.parentNode;
if (!p || p.nodeType !== 1) { return false; }
if (p.nodeName !== "SPAN") { return false; }
return p.hasAttribute && p.hasAttribute("lang");
}
function wrapLongWordsIn(textNode, cfg)
{
const re = new RegExp("[A-Za-z]{" + cfg.minLength + ",}", "g");
const data = textNode.data;
const matches = [];
let m;
while ((m = re.exec(data)) !== null)
{
matches.push({ start: m.index, end: m.index + m[0].length });
}
if (matches.length === 0) { return; }
const frag = document.createDocumentFragment();
let cursor = 0;
for (let i = 0; i < matches.length; i += 1)
{
const mm = matches[i];
if (mm.start > cursor)
{
frag.appendChild(document.createTextNode(data.slice(cursor, mm.start)));
}
const span = document.createElement("span");
span.setAttribute("lang", cfg.lang);
span.textContent = data.slice(mm.start, mm.end);
frag.appendChild(span);
cursor = mm.end;
}
if (cursor < data.length)
{
frag.appendChild(document.createTextNode(data.slice(cursor)));
}
textNode.parentNode.replaceChild(frag, textNode);
}
function processLongWords(root, cfg)
{
const reTest = new RegExp("[A-Za-z]{" + cfg.minLength + ",}");
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
const targets = [];
let n = walker.nextNode();
while (n)
{
if (!isSkipped(n) && !isInLangSpan(n) && reTest.test(n.data))
{
targets.push(n);
}
n = walker.nextNode();
}
for (let i = 0; i < targets.length; i += 1)
{
wrapLongWordsIn(targets[i], cfg);
}
}
/* ----- 對外 API ----- */
function apply(root)
{
root = root || (typeof document !== "undefined" ? document.body : null);
if (!root) { return; }
/* Pass A:對每個 block 逐個處理。把 BLOCK_TAGS 轉成 selector
並對 root 之 descendants 查詢;若 root 本身是 block 也處理。 */
const blockSel = Array.from(BLOCK_SET).map(function (t) { return t.toLowerCase(); }).join(",");
if (blockSel)
{
root.querySelectorAll(blockSel).forEach(processBlockText);
}
if (root.nodeType === 1 && BLOCK_SET.has(root.nodeName))
{
processBlockText(root);
}
/* Pass B */
processPills(root);
/* Pass Copt-in */
if (longWordCfg) { processLongWords(root, longWordCfg); }
}
return { apply: apply };
}
/* ----- 多形 export -----
*
* 四種消費場景並存:
* ESMVite / Rollup / TypeScript):使用 `export { createCjkAutospace }`
* 可 `import { createCjkAutospace } from "./cjk-autospace.js"`
* CommonJSNode `require`):使用 `module.exports`
* Browser global / `<script>` 標籤:使用 `globalThis` 賦值;
* IIFE 串接(build.sh / Nvim peek hook 把本檔內容 cat 進單個閉包):
* `export` 語句屬 ESM 之 module-level 語法,串入函式體內會語法錯誤,
* 故串接端須剝掉 `export {` 起始之那行(單行 `sed '/^export {/d'`
* 即可)。其餘 module.exports / globalThis 兩塊在閉包內安全(typeof
* 檢查不會 throw、globalThis 賦值無副作用)。
*
* 順序:先 CJS / globalThis(不影響語法解析),最後 ESM `export`
* 讓串接端只需剝最後一塊。
*/
if (typeof module !== "undefined" && module.exports)
{
module.exports = { createCjkAutospace: createCjkAutospace };
}
if (typeof globalThis !== "undefined" && !globalThis.createCjkAutospace)
{
globalThis.createCjkAutospace = createCjkAutospace;
}
"use strict";
(function ()
{
const SVG_NS = "http://www.w3.org/2000/svg";
const EDGE_STATE = new WeakMap();
const LABEL_STATE = new WeakMap();
/* ---------- Action dispatcher ---------- */
document.addEventListener("click", (e) =>
{
const target = e.target.closest("[data-action]");
if (!target) { return; }
switch (target.getAttribute("data-action"))
{
case "export-md": { copyMarkdown(target); return; }
case "download-md": { downloadMarkdown(); return; }
case "expand-all": { setAllDetails(true); return; }
case "collapse-all": { setAllDetails(false); return; }
case "submit": { submitResponse(target); return; }
case "add-card": { addCardFromButton(target); return; }
}
});
function setAllDetails(open)
{
document.querySelectorAll("details").forEach(d => { d.open = open; });
}
/* ---------- Markdown / clipboard / blob ---------- */
function getMarkdown()
{
const node = document.getElementById("md-source");
if (!node) { return ""; }
return node.textContent.replace(/^\s*\n/, "").replace(/\n\s*$/, "");
}
function copyMarkdown(btn)
{
const md = getMarkdown();
if (!md) { return; }
copyText(md).then(() => { flash(btn, "Copied"); });
}
function downloadMarkdown()
{
const md = getMarkdown();
if (!md) { return; }
const raw = document.title || "export";
const safe = raw
.replace(/[\\/:*?"<>|\x00-\x1f]+/g, "-")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.replace(/^[-_.]+|[-_.]+$/g, "");
downloadBlob(md, (safe || "export") + ".md", "text/markdown");
}
function copyText(text)
{
if (navigator.clipboard && navigator.clipboard.writeText)
{
return navigator.clipboard.writeText(text).catch(() => { fallbackCopy(text); });
}
fallbackCopy(text);
return Promise.resolve();
}
function fallbackCopy(text)
{
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
try { document.execCommand("copy"); } catch (_) { /* ignore */ }
document.body.removeChild(ta);
}
function downloadBlob(content, filename, mime)
{
const blob = new Blob([ content ], { type: mime });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
function flash(el, msg)
{
if (!el) { return; }
const original = el.textContent;
el.textContent = msg;
setTimeout((() => { el.textContent = original; }), 900);
}
/* ---------- Phase 3: SVG node drag with snap-to-grid ---------- */
function setupDiagrams()
{
if (!document.body.matches("[data-interactive]")) { return; }
document.querySelectorAll("svg.diagram[data-key]").forEach((svg) =>
{
const step = parseFloat(svg.getAttribute("data-grid") || "40");
ensureGridLayer(svg, step);
ensureSnapGuides(svg);
cacheEdgeInitials(svg);
autoAnchorLabels(svg);
cacheLabelOffsets(svg);
svg.querySelectorAll(".node[data-drag]").forEach((node) =>
{
bindNodeDrag(svg, node, step);
});
});
}
/* Authoring forgiveness: attach a label to a node when the label's
anchor point sits strictly inside exactly one .node[data-drag]
bbox. Skips when ambiguous (zero or multiple matches) so explicit
data-anchor on edge labels / external annotations still wins. */
function autoAnchorLabels(svg)
{
const nodes = Array.from(svg.querySelectorAll(".node[data-drag]"));
svg.querySelectorAll(".label:not([data-anchor])").forEach((label) =>
{
const lx = parseFloat(label.getAttribute("x") || "0");
const ly = parseFloat(label.getAttribute("y") || "0");
let match = null;
let count = 0;
for (const node of nodes)
{
const x = parseFloat(node.getAttribute("x") || "0");
const y = parseFloat(node.getAttribute("y") || "0");
const w = parseFloat(node.getAttribute("width") || "0");
const h = parseFloat(node.getAttribute("height") || "0");
if (lx > x && lx < x + w && ly > y && ly < y + h)
{
match = node.getAttribute("data-drag");
count += 1;
}
}
if (count === 1 && match) { label.setAttribute("data-anchor", match); }
});
}
function ensureGridLayer(svg, step)
{
let grid = svg.querySelector(":scope > .grid");
if (!grid)
{
grid = document.createElementNS(SVG_NS, "g");
grid.setAttribute("class", "grid");
grid.setAttribute("aria-hidden", "true");
if (svg.firstChild) { svg.insertBefore(grid, svg.firstChild); }
else { svg.appendChild(grid); }
}
grid.innerHTML = "";
const vb = svg.viewBox.baseVal;
const w = vb && vb.width ? vb.width : parseFloat(svg.getAttribute("width") || "800");
const h = vb && vb.height ? vb.height : parseFloat(svg.getAttribute("height") || "480");
for (let x = 0; x <= w + 0.001; x += step)
{
const line = document.createElementNS(SVG_NS, "line");
line.setAttribute("class", "grid-line");
line.setAttribute("x1", String(x));
line.setAttribute("x2", String(x));
line.setAttribute("y1", "0");
line.setAttribute("y2", String(h));
grid.appendChild(line);
}
for (let y = 0; y <= h + 0.001; y += step)
{
const line = document.createElementNS(SVG_NS, "line");
line.setAttribute("class", "grid-line");
line.setAttribute("x1", "0");
line.setAttribute("x2", String(w));
line.setAttribute("y1", String(y));
line.setAttribute("y2", String(y));
grid.appendChild(line);
}
}
function ensureSnapGuides(svg)
{
let g = svg.querySelector(":scope > .snap-guides");
if (!g)
{
g = document.createElementNS(SVG_NS, "g");
g.setAttribute("class", "snap-guides");
g.setAttribute("aria-hidden", "true");
svg.appendChild(g);
}
return g;
}
function cacheEdgeInitials(svg)
{
svg.querySelectorAll(".edge[data-from], .edge[data-to]").forEach((edge) =>
{
const fromId = edge.getAttribute("data-from");
const toId = edge.getAttribute("data-to");
const fromNode = fromId ? svg.querySelector(`.node[data-drag="${fromId}"]`) : null;
const toNode = toId ? svg.querySelector(`.node[data-drag="${toId}"]`) : null;
EDGE_STATE.set(edge, {
d: edge.getAttribute("d") || "",
fromId, toId,
fromInitialX: fromNode ? parseFloat(fromNode.getAttribute("x") || "0") : 0,
fromInitialY: fromNode ? parseFloat(fromNode.getAttribute("y") || "0") : 0,
toInitialX: toNode ? parseFloat(toNode.getAttribute("x") || "0") : 0,
toInitialY: toNode ? parseFloat(toNode.getAttribute("y") || "0") : 0,
});
});
}
function cacheLabelOffsets(svg)
{
svg.querySelectorAll(".label[data-anchor]").forEach((label) =>
{
const id = label.getAttribute("data-anchor");
const node = svg.querySelector(`.node[data-drag="${id}"]`);
if (!node) { return; }
const nodeX = parseFloat(node.getAttribute("x") || "0");
const nodeY = parseFloat(node.getAttribute("y") || "0");
const labelX = parseFloat(label.getAttribute("x") || "0");
const labelY = parseFloat(label.getAttribute("y") || "0");
LABEL_STATE.set(label, {
id,
offsetX: labelX - nodeX,
offsetY: labelY - nodeY,
});
});
}
function clientToSvg(svg, clientX, clientY)
{
const pt = svg.createSVGPoint();
pt.x = clientX;
pt.y = clientY;
const ctm = svg.getScreenCTM();
if (!ctm) { return { x: clientX, y: clientY }; }
const local = pt.matrixTransform(ctm.inverse());
return { x: local.x, y: local.y };
}
/* Always-snap: each axis picks the edge (top/bottom or left/right) closest
to a grid line, applies that delta, and reports which edge(s) landed on
a grid line (used to render the snap guides). When the node's dimension
is a multiple of the step, both edges of that axis land simultaneously. */
function snapNode(x, y, w, h, step)
{
const topSnap = Math.round(y / step) * step;
const botSnap = Math.round((y + h) / step) * step;
const topDelta = topSnap - y;
const botDelta = botSnap - (y + h);
let dy, snappedTop = false, snappedBottom = false;
if (Math.abs(topDelta) <= Math.abs(botDelta))
{
dy = topDelta;
snappedTop = true;
}
else
{
dy = botDelta;
snappedBottom = true;
}
// If the other Y edge also lands on the grid after applying dy, flag it too.
const newTop = y + dy;
const newBot = newTop + h;
if (!snappedTop && Math.abs(Math.round(newTop / step) * step - newTop) < 0.001) { snappedTop = true; }
if (!snappedBottom && Math.abs(Math.round(newBot / step) * step - newBot) < 0.001) { snappedBottom = true; }
const leftSnap = Math.round(x / step) * step;
const rightSnap = Math.round((x + w) / step) * step;
const leftDelta = leftSnap - x;
const rightDelta = rightSnap - (x + w);
let dx, snappedLeft = false, snappedRight = false;
if (Math.abs(leftDelta) <= Math.abs(rightDelta))
{
dx = leftDelta;
snappedLeft = true;
}
else
{
dx = rightDelta;
snappedRight = true;
}
const newLeft = x + dx;
const newRight = newLeft + w;
if (!snappedLeft && Math.abs(Math.round(newLeft / step) * step - newLeft) < 0.001) { snappedLeft = true; }
if (!snappedRight && Math.abs(Math.round(newRight / step) * step - newRight) < 0.001) { snappedRight = true; }
return { dx, dy, snappedTop, snappedBottom, snappedLeft, snappedRight };
}
function bindNodeDrag(svg, node, step)
{
let pointerId = null;
let pickup = null;
let rafId = null;
let lastEvent = null;
let docHandlers = null;
node.setAttribute("tabindex", "0");
node.setAttribute("role", "img");
node.addEventListener("pointerdown", (e) =>
{
if (pointerId !== null) { return; }
if (e.button !== undefined && e.button !== 0) { return; }
pointerId = e.pointerId;
const local = clientToSvg(svg, e.clientX, e.clientY);
const x = parseFloat(node.getAttribute("x") || "0");
const y = parseFloat(node.getAttribute("y") || "0");
node.classList.add("dragging");
/* Read stroke-width AFTER .dragging is added so the clamp uses
the dragging-state width — keeps the full stroke inside the
viewBox at the edges instead of clipping a few pixels. */
const sw = parseFloat(getComputedStyle(node).strokeWidth) || 0;
pickup = { offsetX: local.x - x, offsetY: local.y - y, strokePad: sw / 2 };
document.body.classList.add("dragging-active");
// Raise the dragging node within its group so it renders above peers.
const parent = node.parentElement;
if (parent) { parent.appendChild(node); }
docHandlers = { move: onMove, up: onUp, cancel: onUp };
document.addEventListener("pointermove", docHandlers.move);
document.addEventListener("pointerup", docHandlers.up);
document.addEventListener("pointercancel", docHandlers.cancel);
e.preventDefault();
});
function onMove(e)
{
if (pointerId !== e.pointerId) { return; }
lastEvent = e;
if (rafId) { return; }
rafId = requestAnimationFrame(() =>
{
rafId = null;
if (pointerId === null || !lastEvent) { return; }
const ev = lastEvent;
const local = clientToSvg(svg, ev.clientX, ev.clientY);
const w = parseFloat(node.getAttribute("width") || "0");
const h = parseFloat(node.getAttribute("height") || "0");
let nx = local.x - pickup.offsetX;
let ny = local.y - pickup.offsetY;
const snap = snapNode(nx, ny, w, h, step);
nx += snap.dx;
ny += snap.dy;
const vb = svg.viewBox.baseVal;
const pad = pickup ? pickup.strokePad : 0;
if (vb && vb.width) { nx = Math.max(pad, Math.min(vb.width - w - pad, nx)); }
if (vb && vb.height) { ny = Math.max(pad, Math.min(vb.height - h - pad, ny)); }
moveNode(svg, node, nx, ny);
showSnapGuides(svg, node, snap);
});
}
function onUp(e)
{
if (pointerId !== e.pointerId) { return; }
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
if (docHandlers)
{
document.removeEventListener("pointermove", docHandlers.move);
document.removeEventListener("pointerup", docHandlers.up);
document.removeEventListener("pointercancel", docHandlers.cancel);
docHandlers = null;
}
node.classList.remove("dragging");
document.body.classList.remove("dragging-active");
clearSnapGuides(svg);
pointerId = null;
}
bindNodeKeyboard(svg, node, step);
}
function bindNodeKeyboard(svg, node, step)
{
node.addEventListener("keydown", (e) =>
{
if (e.target !== node) { return; }
if (!(e.altKey || e.shiftKey)) { return; }
let dx = 0, dy = 0;
if (e.key === "ArrowLeft") { dx = -step; }
else if (e.key === "ArrowRight") { dx = step; }
else if (e.key === "ArrowUp") { dy = -step; }
else if (e.key === "ArrowDown") { dy = step; }
else { return; }
e.preventDefault();
const w = parseFloat(node.getAttribute("width") || "0");
const h = parseFloat(node.getAttribute("height") || "0");
const x = parseFloat(node.getAttribute("x") || "0") + dx;
const y = parseFloat(node.getAttribute("y") || "0") + dy;
const sw = parseFloat(getComputedStyle(node).strokeWidth) || 0;
const pad = sw / 2;
const vb = svg.viewBox.baseVal;
const nx = vb && vb.width ? Math.max(pad, Math.min(vb.width - w - pad, x)) : x;
const ny = vb && vb.height ? Math.max(pad, Math.min(vb.height - h - pad, y)) : y;
moveNode(svg, node, nx, ny);
announce(`${node.getAttribute("data-drag")} moved to x=${nx}, y=${ny}.`);
});
}
function moveNode(svg, node, x, y)
{
node.setAttribute("x", String(x));
node.setAttribute("y", String(y));
const id = node.getAttribute("data-drag");
if (id)
{
updateEdgesForNode(svg, id);
updateLabelsForNode(svg, id);
}
}
function updateEdgesForNode(svg, nodeId)
{
svg.querySelectorAll(`.edge[data-from="${nodeId}"], .edge[data-to="${nodeId}"]`).forEach((edge) =>
{
const state = EDGE_STATE.get(edge);
if (!state) { return; }
const fromNode = state.fromId ? svg.querySelector(`.node[data-drag="${state.fromId}"]`) : null;
const toNode = state.toId ? svg.querySelector(`.node[data-drag="${state.toId}"]`) : null;
const fromDx = fromNode ? parseFloat(fromNode.getAttribute("x") || "0") - state.fromInitialX : 0;
const fromDy = fromNode ? parseFloat(fromNode.getAttribute("y") || "0") - state.fromInitialY : 0;
const toDx = toNode ? parseFloat(toNode.getAttribute("x") || "0") - state.toInitialX : 0;
const toDy = toNode ? parseFloat(toNode.getAttribute("y") || "0") - state.toInitialY : 0;
edge.setAttribute("d", shiftPath(state.d, fromDx, fromDy, toDx, toDy));
});
}
/* Shift the FIRST x,y pair of a path d (the M command's start) by the
from-node's delta, and the LAST x,y pair (the path's endpoint) by the
to-node's delta. Intermediate numbers (Bezier control points) are left
alone — they ride along with whichever endpoint they cluster near, and
in practice straight + simple curves render well enough. */
function shiftPath(d, fromDx, fromDy, toDx, toDy)
{
const tokens = d.match(/-?\d+(?:\.\d+)?/g);
if (!tokens || tokens.length < 4) { return d; }
const nums = tokens.map(parseFloat);
nums[0] = nums[0] + fromDx;
nums[1] = nums[1] + fromDy;
nums[nums.length - 2] = nums[nums.length - 2] + toDx;
nums[nums.length - 1] = nums[nums.length - 1] + toDy;
let i = 0;
return d.replace(/-?\d+(?:\.\d+)?/g, () => String(nums[i++]));
}
function updateLabelsForNode(svg, nodeId)
{
svg.querySelectorAll(`.label[data-anchor="${nodeId}"]`).forEach((label) =>
{
const state = LABEL_STATE.get(label);
if (!state) { return; }
const node = svg.querySelector(`.node[data-drag="${nodeId}"]`);
if (!node) { return; }
const nx = parseFloat(node.getAttribute("x") || "0");
const ny = parseFloat(node.getAttribute("y") || "0");
label.setAttribute("x", String(nx + state.offsetX));
label.setAttribute("y", String(ny + state.offsetY));
});
}
function showSnapGuides(svg, node, snap)
{
const guides = svg.querySelector(":scope > .snap-guides");
if (!guides) { return; }
guides.innerHTML = "";
const x = parseFloat(node.getAttribute("x") || "0");
const y = parseFloat(node.getAttribute("y") || "0");
const w = parseFloat(node.getAttribute("width") || "0");
const h = parseFloat(node.getAttribute("height") || "0");
const vb = svg.viewBox.baseVal;
const vw = vb && vb.width ? vb.width : 800;
const vh = vb && vb.height ? vb.height : 480;
if (snap.snappedTop)
{
appendSnapLine(guides, 0, y, vw, y);
appendSnapEdge(guides, x, y, x + w, y);
}
if (snap.snappedBottom)
{
appendSnapLine(guides, 0, y + h, vw, y + h);
appendSnapEdge(guides, x, y + h, x + w, y + h);
}
if (snap.snappedLeft)
{
appendSnapLine(guides, x, 0, x, vh);
appendSnapEdge(guides, x, y, x, y + h);
}
if (snap.snappedRight)
{
appendSnapLine(guides, x + w, 0, x + w, vh);
appendSnapEdge(guides, x + w, y, x + w, y + h);
}
}
function appendSnapLine(parent, x1, y1, x2, y2)
{
const line = document.createElementNS(SVG_NS, "line");
line.setAttribute("class", "snap-line");
line.setAttribute("x1", String(x1));
line.setAttribute("y1", String(y1));
line.setAttribute("x2", String(x2));
line.setAttribute("y2", String(y2));
parent.appendChild(line);
}
function appendSnapEdge(parent, x1, y1, x2, y2)
{
const line = document.createElementNS(SVG_NS, "line");
line.setAttribute("class", "snap-edge");
line.setAttribute("x1", String(x1));
line.setAttribute("y1", String(y1));
line.setAttribute("x2", String(x2));
line.setAttribute("y2", String(y2));
parent.appendChild(line);
}
function clearSnapGuides(svg)
{
const guides = svg.querySelector(":scope > .snap-guides");
if (guides) { guides.innerHTML = ""; }
}
/* ---------- Live region for a11y ---------- */
function ensureLiveRegion()
{
let region = document.getElementById("aria-live-region");
if (region) { return region; }
region = document.createElement("div");
region.id = "aria-live-region";
region.className = "sr-only";
region.setAttribute("role", "status");
region.setAttribute("aria-live", "polite");
document.body.appendChild(region);
return region;
}
function announce(msg)
{
const region = ensureLiveRegion();
region.textContent = msg;
}
/* ---------- pre[copy] / tabs ---------- */
document.querySelectorAll("pre").forEach((pre) =>
{
if (pre.querySelector(".copy-btn")) { return; }
const btn = document.createElement("button");
btn.type = "button";
btn.className = "copy-btn";
btn.textContent = "copy";
btn.addEventListener("click", () =>
{
const code = pre.querySelector("code");
const text = ( code ? code.textContent : pre.textContent );
copyText(text).then(() => { flash(btn, "✓"); });
});
pre.appendChild(btn);
});
document.querySelectorAll(".tabs").forEach((tabs) =>
{
const tablist = tabs.querySelector("[role=tablist]");
if (!tablist) { return; }
const tabsList = Array.from(tablist.querySelectorAll("[role=tab]"));
const panels = Array.from(tabs.querySelectorAll("[role=tabpanel]"));
function activate(i)
{
tabsList.forEach((t, j) =>
{
const selected = ( j === i );
t.setAttribute("aria-selected", String(selected));
t.setAttribute("tabindex", selected ? "0" : "-1");
if (panels[j]) { panels[j].hidden = !selected; }
});
}
const initial = tabsList.findIndex(t => t.getAttribute("aria-selected") === "true");
activate(initial >= 0 ? initial : 0);
tabsList.forEach((tab, i) =>
{
tab.addEventListener("click", () => { activate(i); tab.focus(); });
tab.addEventListener("keydown", (e) =>
{
let next = -1;
if (e.key === "ArrowRight" || e.key === "ArrowDown") { next = ( i + 1 ) % tabsList.length; }
else if (e.key === "ArrowLeft" || e.key === "ArrowUp") { next = ( i - 1 + tabsList.length ) % tabsList.length; }
else if (e.key === "Home") { next = 0; }
else if (e.key === "End") { next = tabsList.length - 1; }
if (next >= 0)
{
e.preventDefault();
activate(next);
tabsList[next].focus();
}
});
});
});
/* ---------- CJK typography shims ----------
Pass A / B(跨樣式 Pangu autospace + pill 邊界)+ Pass C(長英文
段包 `<span lang="en">` 給瀏覽器原生 `hyphens: auto` 用)皆由
cjk-autospace lib 提供(build.sh 將 lib 之 cjk-autospace.js 串接
至此 IIFE 之前)。Pass C 預設啟用;不需要時於 createCjkAutospace
options 加 `longWordWrap: false` 顯式關閉。
對應 CSSruntime.css):
[lang|="en"] { hyphens: auto; -webkit-hyphens: auto;
hyphenate-limit-chars: 6 3 3; } */
/* ---------- Init ---------- */
ensureLiveRegion();
setupDiagrams();
createCjkAutospace({}).apply();
/* ---------- Mode: report — Behavior ----------
Concatenated INTO the same IIFE as runtime.js by build.sh, so
helpers like flash, copyText, ensureLiveRegion (in core runtime.js)
are accessible via closure. The action dispatcher in core
references submitResponse / addCardFromButton declared here —
function declaration hoisting + IIFE-shared scope makes this work.
Slides mode (Phase 2) gets its own modes/slides/behavior.js with
no Submit / cardset / dynamic print logic. */
/* ---------- Constants (report-only) ---------- */
const SLOP = 4;
const SNAPSHOT = new Map();
let addCounter = 0;
/* ---------- Initial-state snapshot ---------- */
function snapshotInitial()
{
SNAPSHOT.clear();
document.querySelectorAll("[data-pick]").forEach((form) =>
{
const key = form.getAttribute("data-pick");
const vals = Array.from(form.querySelectorAll("input:checked"))
.map(i => i.value).sort().join("|");
SNAPSHOT.set(`pick:${key}`, vals);
});
document.querySelectorAll("[data-md-key]").forEach((el) =>
{
const key = el.getAttribute("data-md-key");
SNAPSHOT.set(`edit:${key}`, currentEditValue(el));
});
document.querySelectorAll("[data-sort], .cardset[data-key]").forEach((c) =>
{
const key = c.getAttribute("data-sort") || c.getAttribute("data-key");
if (!key) { return; }
SNAPSHOT.set(`order:${key}`, originalIds(c).join("|"));
});
document.querySelectorAll(".cardset[data-key]").forEach((set) =>
{
const key = set.getAttribute("data-key");
const selected = Array.from(set.querySelectorAll(".card.selected"))
.map(c => c.getAttribute("data-id")).sort().join("|");
SNAPSHOT.set(`selection:${key}`, selected);
});
document.querySelectorAll("svg.diagram[data-key]").forEach((svg) =>
{
const key = svg.getAttribute("data-key");
svg.querySelectorAll(".node[data-drag]").forEach((node) =>
{
const id = node.getAttribute("data-drag");
const x = parseFloat(node.getAttribute("x") || "0");
const y = parseFloat(node.getAttribute("y") || "0");
SNAPSHOT.set(`pos:${key}:${id}`, `${x},${y}`);
});
});
}
function currentEditValue(el)
{
if (el.tagName === "TEXTAREA" || el.tagName === "INPUT") { return el.value; }
return ( el.innerText || el.textContent || "" ).trim();
}
function originalIds(container)
{
return Array.from(container.children)
.filter(c => c.hasAttribute("data-id")
&& !c.classList.contains("add")
&& !c.hasAttribute("data-user-added"))
.map(c => c.getAttribute("data-id"));
}
function draggableChildren(container)
{
return Array.from(container.children)
.filter(c => c.hasAttribute("data-id") && !c.classList.contains("add"));
}
/* ---------- Submit ---------- */
function submitResponse(btn)
{
const md = buildResponse();
copyText(md).then(() => { flash(btn, "Copied response"); });
}
function buildResponse()
{
const title = document.title || "Response";
const now = new Date().toISOString().slice(0, 19).replace("T", " ");
const out = [];
out.push(`# Response — ${title}`);
out.push("");
out.push(`_Generated ${now} · paste this block into the conversation._`);
out.push("");
const picks = collectPicks();
if (picks.length > 0)
{
out.push("## Choices");
out.push("");
picks.forEach(({ key, values }) =>
{
out.push(`- **${key}**: ${values.map(v => `\`${v}\``).join(", ")}`);
});
out.push("");
}
const edits = collectEdits();
if (edits.length > 0)
{
out.push("## Edits");
out.push("");
edits.forEach(({ key, value }) =>
{
out.push(`### ${key}`);
out.push("");
value.split(/\r?\n/).forEach(line => out.push(`> ${line}`));
out.push("");
});
}
const orders = collectOrder();
if (orders.length > 0)
{
out.push("## Order");
out.push("");
orders.forEach(({ key, ids }) =>
{
out.push(`- **${key}**: ${ids.map(id => `\`${id}\``).join(" → ")}`);
});
out.push("");
}
const added = collectAdded();
if (added.length > 0)
{
out.push("## Added");
out.push("");
added.forEach(({ key, id, title: cardTitle, desc, selected }) =>
{
const sel = selected ? " · selected" : "";
out.push(`### ${key}\`${id}\`${sel}`);
out.push("");
if (cardTitle) { out.push(`**${cardTitle}**`); out.push(""); }
if (desc)
{
desc.split(/\r?\n/).forEach(line => out.push(`> ${line}`));
out.push("");
}
});
}
const drags = collectDrags();
if (drags.length > 0)
{
out.push("## Positions");
out.push("");
drags.forEach(({ key, nodes }) =>
{
out.push(`- **${key}**:`);
nodes.forEach(({ id, x, y }) =>
{
out.push(` - ${id}: x=${x}, y=${y}`);
});
});
out.push("");
}
if (picks.length + edits.length + orders.length + added.length + drags.length === 0)
{
out.push("_All values match defaults; no changes captured._");
}
return out.join("\n");
}
function collectPicks()
{
const result = [];
document.querySelectorAll("[data-pick]").forEach((form) =>
{
const key = form.getAttribute("data-pick");
const current = Array.from(form.querySelectorAll("input:checked")).map(i => i.value);
const currentKey = [ ...current ].sort().join("|");
const snap = SNAPSHOT.get(`pick:${key}`) || "";
if (current.length > 0 && currentKey !== snap)
{
result.push({ key, values: current });
}
});
document.querySelectorAll(".cardset[data-key]").forEach((set) =>
{
const key = set.getAttribute("data-key");
const mode = set.getAttribute("data-select") || "none";
if (mode === "none") { return; }
const current = Array.from(set.querySelectorAll(".card.selected"))
.map(c => c.getAttribute("data-id"));
const currentKey = [ ...current ].sort().join("|");
const snap = SNAPSHOT.get(`selection:${key}`) || "";
if (current.length > 0 && currentKey !== snap)
{
result.push({ key, values: current });
}
});
return result;
}
function collectEdits()
{
const result = [];
document.querySelectorAll("[data-md-key]").forEach((el) =>
{
const key = el.getAttribute("data-md-key");
const value = currentEditValue(el);
const snap = SNAPSHOT.get(`edit:${key}`) || "";
if (value !== "" && value !== snap)
{
result.push({ key, value });
}
});
return result;
}
function collectOrder()
{
const result = [];
document.querySelectorAll("[data-sort], .cardset[data-key]").forEach((c) =>
{
const key = c.getAttribute("data-sort") || c.getAttribute("data-key");
if (!key) { return; }
const ids = originalIds(c);
const orderKey = ids.join("|");
const snap = SNAPSHOT.get(`order:${key}`) || "";
if (ids.length > 0 && orderKey !== snap)
{
result.push({ key, ids });
}
});
return result;
}
function collectAdded()
{
const result = [];
document.querySelectorAll(".cardset .card[data-user-added]").forEach((card) =>
{
const set = card.closest(".cardset");
const key = set ? set.getAttribute("data-key") || "(unkeyed)" : "(unkeyed)";
const id = card.getAttribute("data-id");
const titleEl = card.querySelector("h4");
const descEl = card.querySelector("p");
const titleText = titleEl ? ( titleEl.innerText || titleEl.textContent || "" ).trim() : "";
const descText = descEl ? ( descEl.innerText || descEl.textContent || "" ).trim() : "";
result.push({
key, id,
title: titleText,
desc: descText,
selected: card.classList.contains("selected"),
});
});
return result;
}
function collectDrags()
{
const result = [];
document.querySelectorAll("svg.diagram[data-key]").forEach((svg) =>
{
const key = svg.getAttribute("data-key");
const nodes = [];
svg.querySelectorAll(".node[data-drag]").forEach((node) =>
{
const id = node.getAttribute("data-drag");
const x = parseFloat(node.getAttribute("x") || "0");
const y = parseFloat(node.getAttribute("y") || "0");
const snap = SNAPSHOT.get(`pos:${key}:${id}`);
if (snap !== `${x},${y}`)
{
nodes.push({ id, x, y });
}
});
if (nodes.length > 0) { result.push({ key, nodes }); }
});
return result;
}
/* ---------- Drag mechanics ---------- */
/* Layout detection: "y" (vertical column), "x" (single row), "grid" (wrap).
Decided by looking at how the first card relates to the others on the
y axis — same row vs. different row. */
function detectAxis(container)
{
const kids = draggableChildren(container);
if (kids.length < 2) { return "y"; }
const rects = kids.map(c => c.getBoundingClientRect());
const r0 = rects[0];
const sameRow = rects.filter(r => Math.abs(r.top - r0.top) < Math.max(8, r0.height / 2)).length;
if (sameRow === rects.length) { return "x"; }
if (sameRow === 1) { return "y"; }
return "grid";
}
/* Compute the target index for `draggingCard` from its CURRENT VISUAL
CENTER (the rect includes the inline transform), then do at most ONE
insert. For each non-dragging sibling, decide whether the dragging
card is "after" it in document flow:
- y : card center above/below the sibling's vertical midpoint
- x : card center left/right of the horizontal midpoint
- grid : row first — card center above the sibling's top → before;
below its bottom → after; same row → split by horizontal mid.
Using card center (not pointer) means the predicted landing position
follows the visual card, which is what the user is steering. */
function tryReorderWithFlip(container, draggingCard, axis)
{
const all = draggableChildren(container);
const currentIdx = all.indexOf(draggingCard);
if (currentIdx < 0) { return false; }
const others = all.filter(c => c !== draggingCard);
const cardRect = draggingCard.getBoundingClientRect();
const cx = cardRect.left + cardRect.width / 2;
const cy = cardRect.top + cardRect.height / 2;
let target = 0;
for (const sib of others)
{
const r = sib.getBoundingClientRect();
let after = false;
if (axis === "y")
{
after = cy > r.top + r.height / 2;
}
else if (axis === "x")
{
after = cx > r.left + r.width / 2;
}
else // grid
{
if (cy < r.top) { after = false; }
else if (cy > r.bottom) { after = true; }
else { after = cx > r.left + r.width / 2; }
}
if (after) { target += 1; }
}
if (target === currentIdx) { return false; }
// FLIP: capture, mutate, then invert+play on the affected siblings.
const firstRects = new Map(others.map(c => [ c, c.getBoundingClientRect() ]));
const anchor = others[target] || null;
if (anchor)
{
container.insertBefore(draggingCard, anchor);
}
else
{
const addCard = container.querySelector(":scope > .card.add");
container.insertBefore(draggingCard, addCard || null);
}
applyFlip(others, firstRects);
return true;
}
function applyFlip(cards, firstRects)
{
cards.forEach((c) =>
{
const last = c.getBoundingClientRect();
const first = firstRects.get(c);
if (!first) { return; }
const dx = first.left - last.left;
const dy = first.top - last.top;
if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) { return; }
c.style.transition = "none";
c.style.transform = `translate(${dx}px, ${dy}px)`;
void c.offsetWidth; // force reflow
c.style.transition = "transform var(--dur-reorder) var(--ease-settle)";
c.style.transform = "";
});
}
function updateDragTransform(card, e, offsetX, offsetY)
{
const previous = card.style.transform;
card.style.transform = "";
const natural = card.getBoundingClientRect();
const dx = ( e.clientX - offsetX ) - natural.left;
const dy = ( e.clientY - offsetY ) - natural.top;
card.style.transform = `translate(${dx}px, ${dy}px) scale(1.025)`;
}
function settleCard(card)
{
card.classList.remove("dragging");
card.classList.add("settling");
card.style.transform = ""; // CSS .settling provides the transition
setTimeout(() =>
{
card.classList.remove("settling");
card.style.transition = "";
card.style.transform = "";
}, 220);
}
/* ---------- Drop indicator ---------- */
function createDropIndicator(container)
{
let indicator = container.querySelector(":scope > .drop-indicator");
if (!indicator)
{
indicator = document.createElement("div");
indicator.className = "drop-indicator";
container.appendChild(indicator);
}
// Reflow before transitioning opacity
void indicator.offsetWidth;
indicator.classList.add("visible");
return indicator;
}
/* Position the indicator as a card-sized dashed frame at the dragging
card's *natural* slot (DOM position without the visual translate /
scale). Width / height / top / left are all set inline so the
indicator looks like the empty slot waiting to receive the card. */
function positionDropIndicator(container, indicator, draggingCard)
{
if (!indicator) { return; }
const containerRect = container.getBoundingClientRect();
const previousTransform = draggingCard.style.transform;
draggingCard.style.transform = "";
const naturalRect = draggingCard.getBoundingClientRect();
draggingCard.style.transform = previousTransform;
indicator.style.top = `${naturalRect.top - containerRect.top}px`;
indicator.style.left = `${naturalRect.left - containerRect.left}px`;
indicator.style.width = `${naturalRect.width}px`;
indicator.style.height = `${naturalRect.height}px`;
}
function removeDropIndicator(indicator, container)
{
indicator.classList.remove("visible");
setTimeout(() =>
{
if (indicator.parentElement === container) { container.removeChild(indicator); }
}, 120);
}
/* ---------- Cardset binding (new motion) ---------- */
function setupCardsets()
{
if (!document.body.matches("[data-interactive]")) { return; }
document.querySelectorAll(".cardset").forEach((set) =>
{
const hasSelected = set.querySelector(".card.selected");
if (!hasSelected)
{
set.querySelectorAll(".card.recommended").forEach(c => c.classList.add("selected"));
}
const mode = set.getAttribute("data-select") || "none";
const reorderable = set.getAttribute("data-reorder") !== "false";
draggableChildren(set).forEach(card => bindCard(set, card, mode, reorderable));
});
}
/* The drag uses *document-level* pointermove / pointerup listeners
attached on pointerdown — NOT setPointerCapture. Pointer capture has
been observed to drop when the captured element is moved across the
DOM by insertBefore in some browsers, which would end the drag
prematurely. Document listeners survive any DOM reshuffling. */
function bindCard(set, card, mode, reorderable)
{
let pointerId = null;
let startedDrag = false;
let pickupOffsetX = 0;
let pickupOffsetY = 0;
let startX = 0;
let startY = 0;
let axis = "y";
let rafId = null;
let lastEvent = null;
let indicator = null;
let docHandlers = null;
card.setAttribute("tabindex", "0");
card.setAttribute("role", mode === "none" ? "listitem" : "option");
card.addEventListener("pointerdown", (e) =>
{
if (pointerId !== null) { return; }
if (e.target.closest("input, textarea, button, select, a, [contenteditable]")) { return; }
if (e.button !== undefined && e.button !== 0) { return; }
pointerId = e.pointerId;
startedDrag = false;
startX = e.clientX;
startY = e.clientY;
if (reorderable)
{
axis = detectAxis(set);
const rect = card.getBoundingClientRect();
pickupOffsetX = e.clientX - rect.left;
pickupOffsetY = e.clientY - rect.top;
}
docHandlers = {
move: onPointerMove,
up: onPointerUp,
cancel: onPointerUp,
};
document.addEventListener("pointermove", docHandlers.move);
document.addEventListener("pointerup", docHandlers.up);
document.addEventListener("pointercancel", docHandlers.cancel);
e.preventDefault();
});
function onPointerMove(e)
{
if (pointerId !== e.pointerId) { return; }
if (!startedDrag)
{
if (Math.abs(e.clientX - startX) < SLOP && Math.abs(e.clientY - startY) < SLOP) { return; }
startedDrag = true;
if (reorderable)
{
set.setAttribute("data-axis", axis);
set.classList.add("is-sorting");
card.classList.add("dragging");
document.body.classList.add("dragging-active");
indicator = createDropIndicator(set);
positionDropIndicator(set, indicator, card);
}
}
if (!reorderable || !startedDrag) { return; }
lastEvent = e;
if (rafId) { return; }
rafId = requestAnimationFrame(() =>
{
rafId = null;
if (!startedDrag || !lastEvent) { return; }
const ev = lastEvent;
updateDragTransform(card, ev, pickupOffsetX, pickupOffsetY);
if (tryReorderWithFlip(set, card, axis))
{
positionDropIndicator(set, indicator, card);
}
});
}
function onPointerUp(e)
{
if (pointerId !== e.pointerId) { return; }
const wasDrag = startedDrag;
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
if (docHandlers)
{
document.removeEventListener("pointermove", docHandlers.move);
document.removeEventListener("pointerup", docHandlers.up);
document.removeEventListener("pointercancel", docHandlers.cancel);
docHandlers = null;
}
if (reorderable && wasDrag)
{
settleCard(card);
if (indicator) { removeDropIndicator(indicator, set); indicator = null; }
set.classList.remove("is-sorting");
set.removeAttribute("data-axis");
document.body.classList.remove("dragging-active");
}
pointerId = null;
startedDrag = false;
if (!wasDrag && mode !== "none")
{
toggleSelection(set, card, mode);
}
}
bindCardKeyboard(set, card, mode, reorderable);
}
function bindCardKeyboard(set, card, mode, reorderable)
{
card.addEventListener("keydown", (e) =>
{
if (e.target !== card) { return; }
if (( e.key === " " || e.key === "Enter" ) && mode !== "none")
{
e.preventDefault();
toggleSelection(set, card, mode);
return;
}
if (e.altKey && reorderable)
{
const siblings = draggableChildren(set);
const idx = siblings.indexOf(card);
if (idx < 0) { return; }
let newIdx = -1;
if (e.key === "ArrowDown" || e.key === "ArrowRight") { newIdx = Math.min(idx + 1, siblings.length - 1); }
else if (e.key === "ArrowUp" || e.key === "ArrowLeft") { newIdx = Math.max(idx - 1, 0); }
else if (e.key === "Home") { newIdx = 0; }
else if (e.key === "End") { newIdx = siblings.length - 1; }
else { return; }
e.preventDefault();
if (newIdx === idx) { return; }
keyboardReorder(set, card, idx, newIdx, siblings);
card.focus();
}
});
}
function keyboardReorder(set, card, fromIdx, toIdx, siblings)
{
const firstRects = new Map(siblings.map(c => [ c, c.getBoundingClientRect() ]));
const target = siblings[toIdx];
if (toIdx > fromIdx)
{
set.insertBefore(card, target.nextSibling);
}
else
{
set.insertBefore(card, target);
}
applyFlip(siblings, firstRects);
announce(`${cardLabel(card)} moved to position ${toIdx + 1} of ${siblings.length}.`);
}
function cardLabel(card)
{
const h = card.querySelector("h4");
const t = h ? ( h.innerText || h.textContent || "" ).trim() : "";
return t || card.getAttribute("data-id") || "card";
}
function toggleSelection(set, card, mode)
{
if (mode === "single")
{
set.querySelectorAll(".card.selected").forEach(c => c.classList.remove("selected"));
card.classList.add("selected");
}
else if (mode === "multi")
{
card.classList.toggle("selected");
}
const state = card.classList.contains("selected") ? "selected" : "deselected";
announce(`${cardLabel(card)} ${state}.`);
}
function addCardFromButton(btn)
{
const set = btn.closest(".cardset");
if (!set) { return; }
addCounter += 1;
const newCard = document.createElement("article");
newCard.className = "card";
newCard.setAttribute("data-id", `user-${addCounter}`);
newCard.setAttribute("data-user-added", "1");
const h4 = document.createElement("h4");
h4.contentEditable = "true";
h4.textContent = "New option";
const p = document.createElement("p");
p.contentEditable = "true";
p.textContent = "Describe this option.";
newCard.appendChild(h4);
newCard.appendChild(p);
const addBtn = set.querySelector(".card.add");
if (addBtn) { set.insertBefore(newCard, addBtn); }
else { set.appendChild(newCard); }
const mode = set.getAttribute("data-select") || "none";
const reorderable = set.getAttribute("data-reorder") !== "false";
bindCard(set, newCard, mode, reorderable);
h4.focus();
try
{
const range = document.createRange();
range.selectNodeContents(h4);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
}
catch (_) { /* ignore */ }
}
/* ---------- Plain [data-sort] containers (timeline / ol / ul) ---------- */
function setupSortables()
{
if (!document.body.matches("[data-interactive]")) { return; }
document.querySelectorAll("[data-sort]").forEach((c) =>
{
if (c.classList.contains("cardset")) { return; }
draggableChildren(c).forEach(child => bindSimpleDraggable(c, child));
});
}
function bindSimpleDraggable(container, child)
{
let pointerId = null;
let startedDrag = false;
let startX = 0;
let startY = 0;
let axis = "y";
let pickupOffsetX = 0;
let pickupOffsetY = 0;
let rafId = null;
let lastEvent = null;
let docHandlers = null;
child.addEventListener("pointerdown", (e) =>
{
if (pointerId !== null) { return; }
if (e.target.closest("input, textarea, button, select, a, [contenteditable]")) { return; }
if (e.button !== undefined && e.button !== 0) { return; }
pointerId = e.pointerId;
startedDrag = false;
startX = e.clientX;
startY = e.clientY;
axis = detectAxis(container);
const rect = child.getBoundingClientRect();
pickupOffsetX = e.clientX - rect.left;
pickupOffsetY = e.clientY - rect.top;
docHandlers = { move: onPointerMove, up: onPointerUp, cancel: onPointerUp };
document.addEventListener("pointermove", docHandlers.move);
document.addEventListener("pointerup", docHandlers.up);
document.addEventListener("pointercancel", docHandlers.cancel);
e.preventDefault();
});
function onPointerMove(e)
{
if (pointerId !== e.pointerId) { return; }
if (!startedDrag)
{
if (Math.abs(e.clientX - startX) < SLOP && Math.abs(e.clientY - startY) < SLOP) { return; }
startedDrag = true;
container.classList.add("sorting");
child.classList.add("dragging");
document.body.classList.add("dragging-active");
}
lastEvent = e;
if (rafId) { return; }
rafId = requestAnimationFrame(() =>
{
rafId = null;
if (!startedDrag || !lastEvent) { return; }
updateDragTransform(child, lastEvent, pickupOffsetX, pickupOffsetY);
tryReorderWithFlip(container, child, axis);
});
}
function onPointerUp(e)
{
if (pointerId !== e.pointerId) { return; }
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
if (docHandlers)
{
document.removeEventListener("pointermove", docHandlers.move);
document.removeEventListener("pointerup", docHandlers.up);
document.removeEventListener("pointercancel", docHandlers.cancel);
docHandlers = null;
}
if (startedDrag)
{
child.classList.add("settling");
child.style.transform = "";
setTimeout(() =>
{
child.classList.remove("dragging");
child.classList.remove("settling");
child.style.transition = "";
child.style.transform = "";
}, 220);
container.classList.remove("sorting");
document.body.classList.remove("dragging-active");
}
pointerId = null;
startedDrag = false;
}
}
/* ---------- Dynamic print page sizing ----------
Measure the document's rendered height and inject an @page rule
sized to fit content exactly. Produces a single-page PDF on "Save
as PDF" with no trailing blank space.
Page margin: 0. Real margins live in `body` padding (runtime.css
@media print). That keeps content width stable regardless of the
print dialog's Margins selector — and stable width is what makes
the screen-to-print height heuristic reliable.
Page-height heuristic: with horizontal padding fixed (1cm each
side), print rendering height roughly matches screen rendering
height. Ratio = 1.0 (was 0.85 — too aggressive an under-estimate;
caused tail clipping). The 50mm buffer absorbs the residual
variance plus whatever vertical margin a dialog like Chrome's
Default might add on top.
Vertical padding (body padding-top 1.5cm + padding-bottom 1cm =
25mm total) is reserved inside the page so the breathing room
above the title and below the footer is preserved.
Escape hatch: set `window.disablePrintAutoSize = true` (in the
DevTools console or before page load) to disable. The injected
rule is cleared and printing falls back to the static A4 in
runtime.css (multi-page). */
function setupDynamicPrintPage()
{
let styleEl = null;
function ensureStyle()
{
if (!styleEl)
{
styleEl = document.createElement("style");
styleEl.setAttribute("data-runtime-print", "");
document.head.appendChild(styleEl);
}
return styleEl;
}
function update()
{
const el = ensureStyle();
if (window.disablePrintAutoSize)
{
el.textContent = ""; /* fall back to runtime.css @page */
return;
}
const PX_TO_MM = 25.4 / 96;
const RATIO = 1.0;
const PAD_V_MM = 25; /* body padding-top 15 + padding-bottom 10 */
const BUFFER_MM = 0; /* absorbs dialog margin + heuristic variance */
const screenH = document.documentElement.scrollHeight;
const printContentH = Math.ceil(screenH * PX_TO_MM * RATIO);
const pageH = printContentH + PAD_V_MM + BUFFER_MM;
el.textContent = "@page { size: 210mm " + pageH + "mm; margin: 0; }";
}
update();
window.addEventListener("resize", update);
window.addEventListener("beforeprint", update);
}
/* ---------- Table of Contents (opt in via body[data-toc]) ----------
長文 report 可選掛載 TOC。掃 <main> 內所有 h2 / h3,缺 id 者補
toc-N,組成 <nav class="toc"><details open><summary>…<ol>…。
lang 取自 <html lang>,控 summary 之語系預設文字;作者可
data-toc="自訂標題" 覆蓋。少於兩個 heading 則不掛(無實益)。 */
function tocLabel()
{
const lang = (document.documentElement.lang || "").toLowerCase();
if (lang.startsWith("zh-hant") || lang.startsWith("zh-tw") || lang.startsWith("zh-hk")) { return "目錄"; }
if (lang.startsWith("zh")) { return "目录"; }
if (lang.startsWith("ja")) { return "目次"; }
if (lang.startsWith("ko")) { return "목차"; }
return "Contents";
}
function setupToc()
{
const attr = document.body.getAttribute("data-toc");
if (attr === null) { return; }
const main = document.querySelector("main");
if (!main) { return; }
const headings = Array.from(main.querySelectorAll("h2, h3"));
if (headings.length < 2) { return; }
const customLabel = (attr && attr !== "1" && attr !== "") ? attr : null;
const nav = document.createElement("nav");
nav.className = "toc";
nav.setAttribute("aria-label", customLabel || tocLabel());
const details = document.createElement("details");
details.open = true;
const summary = document.createElement("summary");
summary.textContent = customLabel || tocLabel();
details.appendChild(summary);
const ol = document.createElement("ol");
headings.forEach((h, i) =>
{
if (!h.id) { h.id = "toc-" + (i + 1); }
const li = document.createElement("li");
li.className = "toc-" + h.tagName.toLowerCase();
const a = document.createElement("a");
a.href = "#" + h.id;
a.textContent = h.textContent.trim();
li.appendChild(a);
ol.appendChild(li);
});
details.appendChild(ol);
nav.appendChild(details);
main.insertBefore(nav, main.firstElementChild);
}
/* ---------- Init (mode: report) ---------- */
setupToc();
setupCardsets();
setupSortables();
setupDynamicPrintPage();
snapshotInitial();
})();
</script>
</body>
</html>