diff --git a/skills/html-export/SKILL.md b/skills/html-export/SKILL.md
new file mode 100644
index 0000000..ec85277
--- /dev/null
+++ b/skills/html-export/SKILL.md
@@ -0,0 +1,28 @@
+---
+name: html-export
+description: Export one Gitea wiki page or issue as a single self-contained HTML file. Parse the link with tools/gitea-link.sh, resolve that kind's layout and style through tools/html-style.sh (kind, then DEFAULT, then the built-in report/minimal), then render with tools/html-render.sh, which puts the markdown through Gitea's own renderer and inlines every asset. A request without a wiki or issue link stops the skill immediately: never guess the repository, the page or the issue number. Use when a page or issue has to leave Gitea as a document; not for choosing which template a kind uses - that is jsc-gitea:html-style.
+---
+
+# html-export — a wiki page or issue becomes one HTML file
+
+The link is the only input. The output is one file that opens anywhere, with no external asset.
+
+## Steps
+
+1. **Link gate.** Run `tools/gitea-link.sh parse {url}`. Exit 3 or no link in the request: **stop and report it**. Never fall back to the working directory's remote or to a page name the user mentioned in passing. Completion condition: `kind` is `wiki` or `issue`, and `repo` plus `page` or `index` are known.
+2. **Work out the kind key** — it decides which template applies:
+ - wiki page → `WIKI:{prefix}`, where the prefix is the page name up to the first underscore (`ANALYZE_D3F1A2B0` → `WIKI:ANALYZE`; a page with no underscore uses the whole name).
+ - issue → run `tools/issue.sh labels-of {repo} {index}` and try `ISSUE:{label}` for each label in order; the first one `tools/html-style.sh get` answers with source `project` or `global` wins. No label matches: use `ISSUE:DEFAULT`.
+
+ Completion condition: exactly one kind key is chosen, and you can say which label or prefix produced it.
+3. Run `tools/html-style.sh get {key}`. It always prints `layoutstylesource`. **Read the third column and report it**: `project` or `global` means the user configured this kind; `default` means it fell back to the DEFAULT row; `builtin` means nothing is configured at all and `report`/`minimal` was used. For `default` and `builtin`, tell the user in one line that `jsc-gitea:html-style` can set this kind's own template. Completion condition: layout, style and source are reported before anything is rendered.
+4. Fetch the content: `jsc-gitea:wiki` `wiki-get` for a page, or `tools/issue.sh title` plus `tools/issue.sh body` for an issue. Exit 4 (page missing) or an API failure stops the skill with the page name or issue number in the report. Completion condition: the markdown and the document title are in hand.
+5. **Prepare the markdown — this step MUST run as a sub agent.** Convert `[[display|page]]` wiki links to absolute URLs from `wiki-url`; the renderer does not resolve them, so they would ship as literal brackets. Strip personal data — an exported file travels further than the page it came from. Leave everything else exactly as written; this step never rewrites the content. Completion condition: no `[[...]]` remains, and the diff against the source is limited to link conversion and personal-data removal.
+6. Ask per `jsc-ask:ask` rules where the file goes, proposing `./.jsc/html/{page-or-issue}.html`. State the impact scope: a path inside a repository gets committed unless it is ignored. Completion condition: the user has confirmed one output path.
+7. Render: `tools/html-render.sh --markdown {file} --title {title} --layout {layout} --style {style} --source-url {absolute URL} --out {path}`. Exit 1 means Gitea's renderer failed — report it and stop, with no half-rendered file left behind. Completion condition: the file exists, and the report names its path, the layout, the style and where that pair came from.
+
+## Rules
+
+- One link, one file. Batch export is a loop the caller runs, not something this skill decides on its own.
+- The rendered file inlines CSS and scripts on purpose: it is usually sent to someone outside Gitea, and an external asset breaks on their machine.
+- The layout and style are never chosen by inspecting the content. The configuration decides, and `jsc-gitea:html-style` owns the configuration.
diff --git a/skills/html-style/SKILL.md b/skills/html-style/SKILL.md
new file mode 100644
index 0000000..424bddd
--- /dev/null
+++ b/skills/html-style/SKILL.md
@@ -0,0 +1,23 @@
+---
+name: html-style
+description: Set which HTML layout and style a kind of Gitea wiki page or issue gets when jsc-gitea:html-export renders it. Offer all six layouts from tools/html-style.sh layouts and all five styles from tools/html-style.sh styles as decision-tree options per jsc-ask, then write the pair with tools/html-style.sh set - either to the project file .jsc/html-styles or to the global $JSC_HOME/html-styles.conf. Use when a kind of page or issue should come out looking different, or when the export reported source builtin or default; not for rendering a page, which is jsc-gitea:html-export.
+---
+
+# html-style — which template a kind of page gets
+
+One kind of page, one layout, one style. `jsc-gitea:html-export` reads what this skill writes.
+
+## Steps
+
+1. **Settle the kind key.** Show the current configuration with `tools/html-style.sh list` first, then ask per `jsc-ask:ask` rules which kind this run sets. The three shapes are fixed: `WIKI:{page-name prefix}` (`WIKI:PLAN`, `WIKI:ANALYZE`, `WIKI:LOG` …), `ISSUE:{label name}` (`ISSUE:bug`), and `DEFAULT` for everything that matches nothing else. Every option states its impact scope — `DEFAULT` changes every kind that has no row of its own. Completion condition: exactly one key is agreed, and its current value from `tools/html-style.sh get {key}` has been read back with its source column.
+2. **Pick the layout — offer all six.** Run `tools/html-style.sh layouts`; it prints each name with its Traditional Chinese description, taken from the template file itself. Present all six as options per `jsc-ask:ask` rules, each with what it does to the content (`report` builds a table of contents beside the text, `slide` turns every `##` into a keyboard-flipped page, `dashboard` turns them into cards, `spec` freezes table headers, `timeline` strings them along a line, `onepager` narrows everything into one printable page). Completion condition: the user has picked one layout name that the script listed.
+3. **Pick the style — offer all five.** Run `tools/html-style.sh styles` and present every one it prints (`minimal`, `corporate`, `dark`, `print`, `vivid`) with its description. Never trim the list to a shortlist: the point of this skill is that the user sees the whole set. Completion condition: the user has picked one style name that the script listed.
+4. **Pick the scope.** Ask per `jsc-ask:ask` rules: `--project` writes `./.jsc/html-styles`, which only applies inside this working directory and is committed with the repository; `--global` writes `$JSC_HOME/html-styles.conf`, which follows the user across every project on this machine. State that the project file wins whenever both hold the same key. Completion condition: the user has picked one scope.
+5. Write it: `tools/html-style.sh set {key} {layout} {style} [--project|--global]`. Exit 4 means the name is not one of the listed templates — go back to step 2 or 3 rather than editing the file by hand. Completion condition: the script exits 0 and prints the file it wrote.
+6. Read it back with `tools/html-style.sh get {key}` and report the resolved layout, style and source. Completion condition: the source column shows `project` or `global`, matching the scope chosen in step 4.
+
+## Rules
+
+- Only names the script listed may be written. A key holding a template that does not exist fails at export time, long after the mistake was made.
+- Removing a row is `tools/html-style.sh unset {key} [--project|--global]`; after that the kind falls back to `DEFAULT`, then to the built-in `report`/`minimal`.
+- New layouts live in `templates/html/layout/{name}.html` and new styles in `templates/html/style/{name}.css`, each starting with a one-line Traditional Chinese comment — that comment is what the option list shows.
diff --git a/templates/html/base.css b/templates/html/base.css
new file mode 100644
index 0000000..3694166
--- /dev/null
+++ b/templates/html/base.css
@@ -0,0 +1,131 @@
+/* 共用排版:所有版型與風格都吃這一份,顏色與字型一律走 CSS 變數,由風格檔決定。 */
+*, *::before, *::after { box-sizing: border-box; }
+
+body {
+ margin: 0;
+ background: var(--bg);
+ color: var(--fg);
+ font-family: var(--font);
+ font-size: 16px;
+ line-height: 1.75;
+ -webkit-text-size-adjust: 100%;
+}
+
+.page-head {
+ padding: 2.5rem 0 1.5rem;
+ border-bottom: 2px solid var(--accent);
+}
+
+.page-head h1 {
+ margin: 0 0 .35rem;
+ font-family: var(--font-head);
+ font-size: 2rem;
+ line-height: 1.3;
+ color: var(--head-fg);
+}
+
+.subtitle { margin: 0 0 .5rem; color: var(--muted); font-size: 1.05rem; }
+.subtitle:empty { display: none; }
+
+.meta {
+ margin: 0;
+ color: var(--muted);
+ font-size: .85rem;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 1rem;
+}
+
+.meta a.source { color: var(--accent); text-decoration: none; word-break: break-all; }
+.meta a.source:hover { text-decoration: underline; }
+
+.content { padding: 1.5rem 0 3rem; }
+
+.content h1, .content h2, .content h3, .content h4 {
+ font-family: var(--font-head);
+ color: var(--head-fg);
+ line-height: 1.35;
+ margin: 2rem 0 .75rem;
+}
+
+.content h1 { font-size: 1.7rem; }
+.content h2 { font-size: 1.4rem; }
+.content h3 { font-size: 1.15rem; }
+.content h4 { font-size: 1rem; }
+
+.content p { margin: 0 0 1rem; }
+.content ul, .content ol { margin: 0 0 1rem; padding-left: 1.5rem; }
+.content li { margin: .25rem 0; }
+.content li input[type="checkbox"] { margin-right: .4rem; }
+
+.content a { color: var(--accent); text-decoration: none; }
+.content a:hover { text-decoration: underline; }
+
+.content blockquote {
+ margin: 1rem 0;
+ padding: .6rem 1rem;
+ border-left: 4px solid var(--accent);
+ background: var(--card);
+ color: var(--muted);
+}
+
+.content code {
+ font-family: var(--font-mono);
+ font-size: .9em;
+ background: var(--code-bg);
+ padding: .15em .4em;
+ border-radius: 4px;
+}
+
+.content pre {
+ margin: 0 0 1rem;
+ padding: 1rem;
+ overflow-x: auto;
+ background: var(--code-bg);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+}
+
+.content pre code { background: none; padding: 0; }
+
+/* 表格一律可橫向捲動,寬表格不會把整頁撐開 */
+.table-wrap { overflow-x: auto; margin: 0 0 1.25rem; }
+
+.content table {
+ border-collapse: collapse;
+ width: 100%;
+ font-size: .95rem;
+}
+
+.content th, .content td {
+ border: 1px solid var(--border);
+ padding: .5rem .7rem;
+ text-align: left;
+ vertical-align: top;
+}
+
+.content th { background: var(--table-head-bg); color: var(--head-fg); font-weight: 600; }
+.content tbody tr:nth-child(even) { background: var(--stripe); }
+
+.content img { max-width: 100%; height: auto; }
+.content hr { border: 0; border-top: 1px solid var(--border); margin: 2rem 0; }
+
+.page-foot {
+ padding: 1.25rem 0 2rem;
+ border-top: 1px solid var(--border);
+ color: var(--muted);
+ font-size: .8rem;
+ display: flex;
+ flex-wrap: wrap;
+ gap: .75rem;
+ justify-content: space-between;
+}
+
+.badge {
+ display: inline-block;
+ padding: .1rem .5rem;
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ font-size: .75rem;
+ color: var(--muted);
+}
diff --git a/templates/html/base.js b/templates/html/base.js
new file mode 100644
index 0000000..8d8e22c
--- /dev/null
+++ b/templates/html/base.js
@@ -0,0 +1,63 @@
+// 共用行為:表格加捲動外框、依 h2 切段、產生目錄。各版型自己決定要用哪幾個。
+(function (w) {
+ 'use strict';
+
+ // 寬表格包一層可橫捲的外框,整頁就不會被撐開。
+ function wrapTables(root) {
+ root.querySelectorAll('table').forEach(function (t) {
+ if (t.parentElement && t.parentElement.classList.contains('table-wrap')) return;
+ var box = document.createElement('div');
+ box.className = 'table-wrap';
+ t.parentNode.insertBefore(box, t);
+ box.appendChild(t);
+ });
+ }
+
+ // 依 h2 把內容切成一段一段。h2 之前的內容自成第一段(前言)。
+ function splitBySection(root) {
+ var nodes = Array.prototype.slice.call(root.childNodes);
+ var sections = [];
+ var current = null;
+
+ function open(headingText) {
+ current = document.createElement('section');
+ current.className = 'jsc-section';
+ current.dataset.title = headingText || '';
+ sections.push(current);
+ }
+
+ nodes.forEach(function (node) {
+ if (node.nodeType === 1 && node.tagName === 'H2') {
+ open(node.textContent.trim());
+ } else if (!current) {
+ if (node.nodeType === 3 && !node.textContent.trim()) return;
+ open('');
+ }
+ current.appendChild(node);
+ });
+
+ root.innerHTML = '';
+ sections.forEach(function (s) { root.appendChild(s); });
+ return sections;
+ }
+
+ // 依 h2、h3 產生目錄,塞進指定容器。標題沒有 id 就補一個。
+ function buildToc(root, target) {
+ var heads = root.querySelectorAll('h2, h3');
+ if (!heads.length) { target.remove(); return; }
+ var list = document.createElement('ul');
+ heads.forEach(function (h, i) {
+ if (!h.id) h.id = 'sec-' + (i + 1);
+ var li = document.createElement('li');
+ li.className = 'toc-' + h.tagName.toLowerCase();
+ var a = document.createElement('a');
+ a.href = '#' + h.id;
+ a.textContent = h.textContent.trim();
+ li.appendChild(a);
+ list.appendChild(li);
+ });
+ target.appendChild(list);
+ }
+
+ w.jsc = { wrapTables: wrapTables, splitBySection: splitBySection, buildToc: buildToc };
+})(window);
diff --git a/templates/html/layout/dashboard.html b/templates/html/layout/dashboard.html
new file mode 100644
index 0000000..434fbce
--- /dev/null
+++ b/templates/html/layout/dashboard.html
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+{{TITLE}}
+
+
+
+
+
+