69f34413c9
對應 main 之 a5faec5。檔尾加 `export { createCjkAutospace }` 讓
Vite / Rollup / TS 之 ESM 消費端可 named import;IIFE 串接端
sed 剝最後一行即可。
4476 lines
159 KiB
HTML
4476 lines
159 KiB
HTML
<!-- 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><wbr></code> 每 6 字元亂插斷詞機會,產生兩個觀感問題:(1)位置與音節邊界無關,斷在怪位;(2)<code><wbr></code> 為 zero-width,斷處不顯連字符,讀者無法辨認「上行末是被斷開的詞」。</p>
|
||
|
||
<p>Pass C 走另一條路:把 <code>[A-Za-z]{N,}</code>(預設 <code>N=6</code>)之長英文段包進 <code><span lang="en"></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><span lang></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><script></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><strong>是</strong> 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.nvim、cdrop。
|
||
- 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-autospace;skip 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-pass:strip 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 三類都中斷 run(pill 與 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 或作者標
|
||
語的英文段都會被 catch;e.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 C(opt-in) */
|
||
if (longWordCfg) { processLongWords(root, longWordCfg); }
|
||
}
|
||
|
||
return { apply: apply };
|
||
}
|
||
|
||
/* ----- 多形 export -----
|
||
*
|
||
* 四種消費場景並存:
|
||
* ESM(Vite / Rollup / TypeScript):使用 `export { createCjkAutospace }`,
|
||
* 可 `import { createCjkAutospace } from "./cjk-autospace.js"`;
|
||
* CommonJS(Node `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` 顯式關閉。
|
||
|
||
對應 CSS(runtime.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>
|