原生起手模板与脚手架
原生起手模板(assets/starter-vanilla/)是一套零依赖、可直接复制的响应式 HTML/CSS/JS 示例页面,配合脚手架脚本 scripts/scaffold-ark-ui.py 将其复制到新项目中作为 Ark UI 风格的落地起点。模板内置风格族选择器与四档应用深度选择器,用于演示并承载「视觉语法 + 深度分级」体系。
Purpose and Scope
本页覆盖以下内容:
- 脚手架脚本
scripts/scaffold-ark-ui.py的完整实现:参数解析、变体映射、非空目录保护与复制策略。 - 原生模板三件套
assets/starter-vanilla/(index.html/styles.css/app.js)的结构、钩子契约与运行时行为。 - 模板核心机制:主题(风格族)切换、深度切换、滚动监听、进场动画、标签页键盘导航、URL 参数初始化。
- 重命名钩子的风险与审计联动:JS 选择器与 HTML 钩子的对应关系,以及
scripts/audit-ark-ui.mjs的校验保障。
以下相关主题由兄弟页面覆盖,本页仅作交叉引用:
- React 变体(
assets/react/ArkUI.jsx与ark-ui.css的theme/depth接口):属于脚手架--variant react的另一分支,详见相关 React 页面。 - 风格族 × 深度规则矩阵(
references/family-depth-matrix.md):各风格族在四档深度下的壳层、内容与仪表规则。 - 前端实现证据(
references/frontend-evidence.md):壳层网格与响应式断点的证据记录。 - 审计脚本(
scripts/audit-ark-ui.mjs):资源完整性的自动化校验流程。
Overview
Ark UI Skill 的目标是让使用者在任意项目中快速落地一套基于证据的视觉语法。对于没有既有组件体系的项目,最直接的路径是复制一份已经验证过的起手模板,然后替换文案与结构。这正是 assets/starter-vanilla/ 的定位——SKILL.md 中明确写道:
"Reuse project components and tokens when present. Otherwise copy
assets/starter-vanilla/or useassets/react/ArkUI.jsxwithassets/react/ark-ui.css." —— SKILL.md
README.md 对该目录的描述为「带风格与深度选择器的原生 HTML/CSS/JS 示例」(README.md),SKILL.md 的资产清单进一步强调它是「无依赖的响应式演示与起点」(SKILL.md)。
模板的三个关键设计目标:
| 目标 | 实现方式 |
|---|---|
| 零依赖 | 仅使用浏览器原生 API(IntersectionObserver、matchMedia、URLSearchParams),无任何构建步骤或第三方库 |
| 可切换的风格族 | themeProfiles 查表 + html[data-ark-theme] 属性驱动,五个风格族一键切换全套文案与视觉 |
| 可切换的深度 | html[data-ark-depth] 四档属性,与风格族选择器并列暴露(SKILL.md) |
术语约定:
- 风格族:
ark/endfield/exa/popucom/corporate五个视觉家族,由data-theme按钮组选择。 - 应用深度:
minimal/moderate/complex/maximal四档信息密度级别,由data-depth按钮组选择。 - 钩子:HTML 上的
data-*属性(data-copy、data-section、data-reveal、data-target等),是 JS 查询与操作的唯一契约。
Architecture
整体架构分为三层:脚手架层(复制分发)、模板资产层(三件套协作)、消费方(使用者项目)。脚手架脚本只做纯复制、不做任何改写,这是一个刻意的设计决策——保证复制出去的文件与仓库内被审计、被演示的文件完全一致。
各组件职责:
scripts/scaffold-ark-ui.py:入口工具。解析目标目录与--variant,校验目标目录为不存在或为空,然后把对应变体目录整体复制过去。index.html:结构与钩子载体。根元素上声明初始data-ark-theme="endfield"与data-ark-depth="complex";所有可替换文案都带data-copy键名。styles.css:视觉层。通过根元素的data-ark-theme/data-ark-depth属性值进行级联匹配,实现风格与深度的视觉切换(JS 只改属性值,不直接操作样式)。app.js:行为层。负责主题/深度切换、滚动联动导航、进场动画触发、标签页键盘导航、移动端菜单开关,以及从 URL 参数恢复状态。
这种「JS 改属性、CSS 读属性」的职责分离是模板的核心架构决策:状态只有一个真源(根元素的两个 data 属性),样式消费与行为写入完全解耦,因此重命名样式类不会破坏行为逻辑,只要 data-* 钩子保持稳定。
脚手架实现:scaffold-ark-ui.py
脚本结构
脚本共 46 行,模块文档字符串即其全部职责声明:"Copy an Ark UI starter without modifying an existing non-empty directory."(把 Ark UI 起手模板复制出去,且绝不改动既有的非空目录)。
1#!/usr/bin/env python3
2"""Copy an Ark UI starter without modifying an existing non-empty directory."""
3
4from __future__ import annotations
5
6import argparse
7import shutil
8from pathlib import Path
9
10
11SKILL_ROOT = Path(__file__).resolve().parent.parent
12VARIANTS = {
13 "vanilla": SKILL_ROOT / "assets" / "starter-vanilla",
14 "react": SKILL_ROOT / "assets" / "react",
15}Source: scripts/scaffold-ark-ui.py
SKILL_ROOT 通过 Path(__file__).resolve().parent.parent 定位仓库根目录,使脚本无论从何处被调用都能正确解析资产路径。VARIANTS 字典把 --variant 的两个合法取值映射到对应资产目录:vanilla 指向 assets/starter-vanilla/(本页主题),react 指向 assets/react/(兄弟页面主题)。
参数解析
1def parse_args() -> argparse.Namespace:
2 parser = argparse.ArgumentParser(description=__doc__)
3 parser.add_argument("destination", type=Path, help="New or empty output directory")
4 parser.add_argument("--variant", choices=sorted(VARIANTS), default="vanilla")
5 return parser.parse_args()Source: scripts/scaffold-ark-ui.py
两个参数:
destination(位置参数,Path):目标目录,必须是不存在或为空的目录。--variant(可选,默认vanilla):从sorted(VARIANTS)即["react", "vanilla"]中选择变体,传入非法值时 argparse 会直接报错退出。
注意 description=__doc__ 把模块文档字符串复用为 CLI 帮助文本,这样帮助信息与代码职责声明天然保持同步。
复制流程与安全保护
1def main() -> int:
2 args = parse_args()
3 source = VARIANTS[args.variant]
4 destination = args.destination.expanduser().resolve()
5
6 if destination.exists() and any(destination.iterdir()):
7 raise SystemExit(f"Refusing to overwrite non-empty directory: {destination}")
8
9 destination.mkdir(parents=True, exist_ok=True)
10 for item in source.iterdir():
11 target = destination / item.name
12 if item.is_dir():
13 shutil.copytree(item, target)
14 else:
15 shutil.copy2(item, target)
16
17 print(f"Created {args.variant} Ark UI starter at {destination}")
18 return 0
19
20
21if __name__ == "__main__":
22 raise SystemExit(main())Source: scripts/scaffold-ark-ui.py
执行流程(main()):
- 解析变体源目录:
VARIANTS[args.variant]取得待复制的资产目录。 - 规范化目标路径:
expanduser().resolve()展开~并转为绝对路径。 - 非空目录保护:若目标已存在且
any(destination.iterdir())为真,立即raise SystemExit报Refusing to overwrite non-empty directory。这是脚手架唯一的失败模式——它是刻意的一次性初始化工具,避免静默覆盖使用者的既有文件。 - 创建目录:
mkdir(parents=True, exist_ok=True)允许父目录不存在,也允许目标本身已存在(但为空)。 - 逐项复制:遍历源目录条目,目录用
shutil.copytree,文件用shutil.copy2(copy2会保留 mtime 等元数据)。当前 vanilla 变体只有三个平铺文件,目录分支为未来新增子目录预留。 - 输出与退出码:打印
Created {variant} Ark UI starter at {destination},返回0。入口通过raise SystemExit(main())传播退出码,供 CI 或脚本链使用。
CLI 调用方式
python3 scripts/scaffold-ark-ui.py ./my-ark-page # 默认 vanilla
python3 scripts/scaffold-ark-ui.py ./my-app --variant react # React 变体SKILL.md 在资产清单中记录了该脚本的用途:"copy a starter into a new or empty destination"(SKILL.md)。
模板核心机制
HTML 骨架与钩子契约
模板页面是一个虚构的「Orbital Field Lab」运营界面,但所有文案都通过 data-copy 键名标注,便于主题切换时批量替换。根元素是全站状态唯一真源:
1<!doctype html>
2<html lang="en" data-ark-theme="endfield" data-ark-depth="complex">
3 <head>
4 <meta charset="utf-8" />
5 <meta name="viewport" content="width=device-width, initial-scale=1" />
6 <meta name="color-scheme" content="dark light" />
7 <title>Orbital Field Lab</title>
8 <link rel="stylesheet" href="styles.css?v=4" />
9 <script src="app.js?v=4" defer></script>
10 </head>
11 <body>
12 <a class="skip-link" href="#main">Skip to main content</a>
13 <div class="ark-shell">关键细节:
data-ark-theme="endfield"/data-ark-depth="complex"是初始状态,也是 JS 未执行(无 JS 环境)时的 CSS 回退基线。app.js以defer加载,保证 DOM 解析完成后再执行,无需等待 load 事件。- 资源带
?v=4查询串作为缓存版本号。 <a class="skip-link">提供键盘跳转主内容,是模板无障碍基线的一部分。
页面骨架由 .ark-shell 内的五个区域组成:
| 区域 | 类名 | 职责 |
|---|---|---|
| 顶栏 | .ark-topbar | 品牌标识(data-copy="brand")、状态指示、移动端菜单按钮 |
| 侧栏导航 | .ark-rail(id="ark-rail") | 三个 data-target 按钮实现区块跳转,aria-current 标记当前区块 |
| 主内容 | .ark-main | 三个 <section data-section> 区块(overview / modules / archive) |
| 英雄区 | .ark-hero | 装饰几何(.ark-orbit / .ark-vector)、文案、仪表读数 |
| 页脚 | .ark-footer | 免责声明(虚构演示 / 原创资产) |
侧栏导航按钮展示了典型的「一属性双职责」钩子设计:
<button class="ark-rail-item is-active" type="button" data-target="overview" aria-current="page">
<span class="ark-rail-index">01</span><span data-copy="nav1">Overview</span>
</button>data-target 的值与目标 <section> 的 id 对应(同时该 section 带 data-section 属性),JS 同时用它做滚动定位与滚动监听的反向高亮。
选择器选择器:风格族与深度按钮组
archive 区块内置两组互斥的单选按钮,是模板最具复用价值的部分:
1<div class="ark-theme-picker ark-reveal" data-reveal role="group" aria-label="Visual family">
2 <button type="button" data-theme="ark">ARK</button>
3 <button class="is-selected" type="button" data-theme="endfield" aria-pressed="true">ENDFIELD</button>
4 <button type="button" data-theme="exa">EX ASTRIS</button>
5 <button type="button" data-theme="popucom">POPUCOM</button>
6 <button type="button" data-theme="corporate">CORPORATE</button>
7</div>
8
9<div class="ark-depth-control ark-reveal" data-reveal>
10 <p class="ark-panel-label">APPLICATION DEPTH / COMPLEX BASELINE</p>
11 <div class="ark-depth-picker" role="group" aria-label="Application depth">
12 <button type="button" data-depth="minimal" aria-pressed="false">01 / MINIMAL</button>
13 <button type="button" data-depth="moderate" aria-pressed="false">02 / MODERATE</button>
14 <button class="is-selected" type="button" data-depth="complex" aria-pressed="true">03 / COMPLEX</button>
15 <button type="button" data-depth="maximal" aria-pressed="false">04 / MAXIMAL</button>
16 </div>
17</div>两组按钮共用同一交互模式:data-theme / data-depth 声明取值,.is-selected 类与 aria-pressed 属性双通道表达选中态——前者供 CSS 消费,后者供屏幕阅读器消费。
app.js 行为层详解
app.js 全文 175 行,按职责可拆为六段:DOM 钩子收集、主题文案查表、移动端菜单、滚动联动、进场动画、标签页键盘导航。
第一段:钩子收集。 开头一次性把所有 data-* 钩子转成数组,后续逻辑不再触碰选择器字符串:
1const root = document.documentElement;
2const rail = document.querySelector('#ark-rail');
3const menuButton = document.querySelector('.ark-menu-button');
4const railButtons = [...document.querySelectorAll('.ark-rail-item[data-target]')];
5const sections = [...document.querySelectorAll('[data-section]')];
6const revealNodes = [...document.querySelectorAll('[data-reveal]')];
7const themeButtons = [...document.querySelectorAll('[data-theme]')];
8const depthButtons = [...document.querySelectorAll('[data-depth]')];
9const tabs = [...document.querySelectorAll('[role="tab"]')];
10const copyNodes = [...document.querySelectorAll('[data-copy]')];Source: assets/starter-vanilla/app.js
这一段是审计脚本重点关注的位置:scripts/audit-ark-ui.mjs 会校验 "Literal JavaScript selectors have matching HTML hooks",如果重命名了类名或 ID 而漏改 JS 选择器,审计会报出具体的缺失选择器列表(scripts/audit-ark-ui.mjs)。
第二段:主题文案查表。 themeProfiles 为五个风格族各自维护一套完整文案(documentTitle、brand、nav1-3、title/titleAccent、lede、三个指标等约 20 个键),保证切换风格族时连语气和场景都随之改变,而不仅是换配色:
1const themeProfiles = {
2 ark: {
3 documentTitle: 'Nightline Deployment Index', brand: 'TERRA INDEX', brandCode: 'OPERATION / 07', status: 'SHIFT ACTIVE',
4 nav1: 'Operation', nav2: 'Dossiers', nav3: 'Archive', kicker: 'DEPLOYMENT CONTROL / NIGHTLINE', title: 'OPERATION', titleAccent: '/ NIGHTLINE',
5 lede: 'Review three active zones, compare the selected deployment, and commit the next route without duplicating status across the stage.',
6 primaryAction: 'Review deployment', secondaryAction: 'Open dossier', readoutTitle: 'ZONE // C-07', readoutState: 'READY',
7 metric1Label: 'Teams', metric1Value: '03', metric2Label: 'Routes', metric2Value: '02', metric3Label: 'Window', metric3Value: '08:40',
8 meta1: 'SHIFT / 20:40', meta2: 'ZONE / C-07', meta3: 'FICTIONAL SAMPLE',
9 },
10 endfield: { /* ... 同构的键集合 ... */ },
11 exa: { /* ... */ },
12 popucom: { /* ... */ },
13 corporate: { /* ... */ },
14};Source: assets/starter-vanilla/app.js
第三段:移动端菜单。 状态写在 rail.dataset.open,同时同步 aria-expanded,点击菜单按钮取反,点击任意导航项后强制关闭:
1function setMenu(open) {
2 rail.dataset.open = String(open);
3 menuButton?.setAttribute('aria-expanded', String(open));
4}
5
6menuButton?.addEventListener('click', () => {
7 setMenu(rail.dataset.open !== 'true');
8});Source: assets/starter-vanilla/app.js
第四段:滚动联动。 用 IntersectionObserver 监听三个 section,按 intersectionRatio 排序取最大者作为当前区块,同步侧栏高亮与 aria-current;阈值取 [0.25, 0.55, 0.75] 三档以获得更平滑的切换时机:
1const sectionObserver = new IntersectionObserver((entries) => {
2 const visible = entries
3 .filter((entry) => entry.isIntersecting)
4 .sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0];
5 if (!visible) return;
6
7 railButtons.forEach((button) => {
8 const active = button.dataset.target === visible.target.dataset.section;
9 button.classList.toggle('is-active', active);
10 if (active) button.setAttribute('aria-current', 'page');
11 else button.removeAttribute('aria-current');
12 });
13}, { threshold: [0.25, 0.55, 0.75] });Source: assets/starter-vanilla/app.js
导航点击使用 scrollIntoView,并尊重用户的减弱动效偏好——这是模板动效基线的直接体现(HTML 文案里同样写明 "disable loops for reduced motion"):
1railButtons.forEach((button) => {
2 button.addEventListener('click', () => {
3 const target = document.getElementById(button.dataset.target);
4 target?.scrollIntoView({ behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' });
5 setMenu(false);
6 });
7});Source: assets/starter-vanilla/app.js
主题切换与 URL 参数初始化
setTheme 是风格族切换的核心:写根属性、改标题、批量替换 data-copy 文案、更新按钮选中态,四步一体。未知主题名回退到 endfield:
1function setTheme(theme) {
2 const profile = themeProfiles[theme] || themeProfiles.endfield;
3 root.dataset.arkTheme = theme;
4 document.title = profile.documentTitle;
5 copyNodes.forEach((node) => {
6 const value = profile[node.dataset.copy];
7 if (value) node.textContent = value;
8 });
9 themeButtons.forEach((button) => {
10 const selected = button.dataset.theme === theme;
11 button.classList.toggle('is-selected', selected);
12 button.setAttribute('aria-pressed', String(selected));
13 });
14}Source: assets/starter-vanilla/app.js
深度切换是同构的更简版本,只改根属性与按钮态(深度不涉及文案替换):
1function setDepth(depth) {
2 root.dataset.arkDepth = depth;
3 depthButtons.forEach((button) => {
4 const selected = button.dataset.depth === depth;
5 button.classList.toggle('is-selected', selected);
6 button.setAttribute('aria-pressed', String(selected));
7 });
8}Source: assets/starter-vanilla/app.js
初始化时优先读取 URL 查询参数 ?theme= 与 ?depth=,取值不合法则回退到 HTML 上声明的初始属性值——这让演示链接可以直接指向某个「风格 × 深度」组合:
1const params = new URLSearchParams(location.search);
2const requestedTheme = params.get('theme');
3const requestedDepth = params.get('depth');
4setTheme(themeProfiles[requestedTheme] ? requestedTheme : (root.dataset.arkTheme || 'endfield'));
5setDepth(['minimal', 'moderate', 'complex', 'maximal'].includes(requestedDepth) ? requestedDepth : (root.dataset.arkDepth || 'complex'));Source: assets/starter-vanilla/app.js
标签页键盘导航
archive 区块的标签页实现了完整的 WAI-ARIA Tabs 模式:点击选择、tabIndex 漫游(roving tabindex)、方向键/Home/End 导航、通过 aria-controls 反查面板并切换 hidden:
1function selectTab(tab) {
2 tabs.forEach((candidate) => {
3 const selected = candidate === tab;
4 candidate.setAttribute('aria-selected', String(selected));
5 candidate.tabIndex = selected ? 0 : -1;
6 document.getElementById(candidate.getAttribute('aria-controls')).hidden = !selected;
7 });
8}
9
10tabs.forEach((tab, index) => {
11 tab.addEventListener('click', () => selectTab(tab));
12 tab.addEventListener('keydown', (event) => {
13 if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
14 event.preventDefault();
15 let next = index;
16 if (event.key === 'ArrowLeft') next = (index - 1 + tabs.length) % tabs.length;
17 if (event.key === 'ArrowRight') next = (index + 1) % tabs.length;
18 if (event.key === 'Home') next = 0;
19 if (event.key === 'End') next = tabs.length - 1;
20 tabs[next].focus();
21 selectTab(tabs[next]);
22 });
23});Source: assets/starter-vanilla/app.js
文件末尾还有一个全局兜底:任意位置按 Escape 都会关闭移动端菜单。
核心流程
脚手架复制流程
模板运行时初始化与交互流程
两条流程图分别对应「复制分发」与「运行时行为」两个维度:前者发生在开发期(一次性),后者发生在每次页面加载与交互时(持续性)。
进场动画与无 JS 回退
进场动画采用「主观察器 + 几何兜底」双保险策略。revealObserver 在元素进入视口 12% 时置 dataset.visible = 'true' 并立即 unobserve——一次性触发,避免反复进出视口导致的动画抖动:
1const revealObserver = new IntersectionObserver((entries, observer) => {
2 entries.forEach((entry) => {
3 if (!entry.isIntersecting) return;
4 entry.target.dataset.visible = 'true';
5 observer.unobserve(entry.target);
6 });
7}, { threshold: 0.12 });Source: assets/starter-vanilla/app.js
兜底逻辑处理两种边界情况:首屏元素在脚本执行前就已可见(首屏直出)、以及 IntersectionObserver 行为差异或滚动过快导致漏触发。revealInViewport 用 getBoundingClientRect 手动判定,并挂到 scroll 与 resize(均为 passive: true,不阻塞滚动),再用双层 requestAnimationFrame 在首帧布局稳定后补一次:
1revealNodes
2 .filter((node) => node.dataset.visible !== 'true')
3 .forEach((node) => revealObserver.observe(node));
4
5function revealInViewport() {
6 revealNodes.forEach((node) => {
7 if (node.dataset.visible === 'true') return;
8 const rect = node.getBoundingClientRect();
9 if (rect.top < innerHeight * 0.94 && rect.bottom > 0) node.dataset.visible = 'true';
10 });
11}
12
13addEventListener('scroll', revealInViewport, { passive: true });
14addEventListener('resize', revealInViewport, { passive: true });
15requestAnimationFrame(() => requestAnimationFrame(revealInViewport));Source: assets/starter-vanilla/app.js
英雄区块则绕过观察器直接标记为可见,避免首屏主内容被动画遮挡:
revealNodes
.filter((node) => node.closest('#overview'))
.forEach((node) => { node.dataset.visible = 'true'; });Source: assets/starter-vanilla/app.js
使用示例
基本用法:复制原生起手模板
python3 scripts/scaffold-ark-ui.py ./my-ark-page
# Created vanilla Ark UI starter at /abs/path/my-ark-page执行后目标目录得到 index.html、styles.css、app.js 三个文件,直接用浏览器打开 index.html 即可运行,无需构建工具。
高级用法:通过 URL 参数直达指定组合
模板初始化逻辑支持查询参数,适合在演示文档或评审链接中直接定位某个风格族 × 深度组合:
index.html?theme=popucom&depth=minimal参数取值不合法时自动回退:theme 回退到 themeProfiles[requestedTheme] 不存在的分支即 HTML 初始值(endfield);depth 用数组白名单校验,回退到 complex(app.js L143-L147)。
替换文案的正确姿势
模板中所有可替换文案都带 data-copy 键名。替换文案时应同时更新两处保持一致:HTML 中的初始值与 themeProfiles 中对应键的值。例如品牌名出现在 HTML(初始态)与 app.js(切换态):
<span><strong data-copy="brand">ORBITAL</strong><small data-copy="brandCode">FIELD LAB / 07</small></span>endfield: {
documentTitle: 'Frontier Logistics Console', brand: 'ORBITAL', brandCode: 'FIELD LAB / 07', status: 'RELAY ONLINE',Source: assets/starter-vanilla/app.js
只改 HTML 不改 themeProfiles 会导致切换主题再切回时文案被旧值覆盖;反过来只改 JS 则首屏仍是旧文案。这正是 SKILL.md 强调的约束在文案维度的体现:
"When renaming starter classes, IDs, data attributes, or ARIA targets, update every JavaScript selector and reference in the same pass. A styled page with broken DOM wiring is not complete." —— SKILL.md
配置选项
脚手架 CLI 参数
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
destination | Path(位置参数,必填) | — | 输出目录;必须不存在或为空,否则脚本以非零码退出 |
--variant | 枚举 react / vanilla | vanilla | 选择要复制的起手模板变体 |
模板运行时状态属性
| 属性 / 参数 | 位置 | 合法取值 | 默认(HTML 初始值) | 说明 |
|---|---|---|---|---|
data-ark-theme | <html> 根元素 | ark / endfield / exa / popucom / corporate | endfield | 风格族选择器,同时驱动文案替换 |
data-ark-depth | <html> 根元素 | minimal / moderate / complex / maximal | complex | 应用深度选择器 |
?theme= | URL 查询参数 | 同 data-ark-theme | 回退到根属性值 | 初始化时覆盖主题 |
?depth= | URL 查询参数 | 同 data-ark-depth | 回退到根属性值 | 初始化时覆盖深度 |
HTML 钩子契约总表
| 钩子 | 元素 | 消费方 | 用途 |
|---|---|---|---|
data-copy="<key>" | 任意文案元素 | setTheme | 主题切换时按 themeProfiles[key] 替换 textContent |
data-theme="<family>" | 主题按钮 | 点击监听 | 声明按钮对应的风格族 |
data-depth="<level>" | 深度按钮 | 点击监听 | 声明按钮对应的深度档位 |
data-target="<section-id>" | 侧栏导航按钮 | 点击监听 + sectionObserver | 滚动定位与反向高亮 |
data-section="<name>" | <section> | sectionObserver | 与 data-target 匹配判定当前区块 |
data-reveal | 进场动画元素 | revealObserver | 触发一次性进场动画 |
role="tab" / aria-controls | 标签按钮 | selectTab | 键盘导航与面板显隐 |
data-open | .ark-rail | setMenu | 移动端菜单开合状态 |
API 参考
scripts/scaffold-ark-ui.py
main() -> int
执行脚手架复制流程。
参数: 无(通过 parse_args() 读取命令行参数 destination 与 --variant)。
返回: 0 表示复制成功。
抛出:
SystemExit(非零码):目标目录存在且非空时,消息为Refusing to overwrite non-empty directory: {destination}。argparse默认行为:--variant取值不在["react", "vanilla"]内时打印用法并退出。
parse_args() -> argparse.Namespace
解析 CLI 参数,description=__doc__ 复用模块文档字符串作为帮助文本。
assets/starter-vanilla/app.js
setTheme(theme: string) -> void
切换风格族。查 themeProfiles[theme],未知值回退 endfield;写 root.dataset.arkTheme、更新 document.title、遍历 copyNodes 按 data-copy 键替换文案、同步主题按钮 .is-selected 与 aria-pressed。
setDepth(depth: string) -> void
切换应用深度。写 root.dataset.arkDepth 并同步深度按钮选中态。不做文案替换(深度不影响文案内容)。
setMenu(open: boolean) -> void
开合移动端菜单。写 rail.dataset.open 并同步 menuButton 的 aria-expanded。menuButton 为可选链访问,元素缺失时静默跳过。
selectTab(tab: Element) -> void
选择标签页。遍历 tabs:设置 aria-selected、漫游 tabIndex(选中 0 其余 -1),并通过 aria-controls 反查面板切换 hidden。
revealInViewport() -> void
进场动画几何兜底。遍历未标记可见的 revealNodes,getBoundingClientRect 判定是否在视口内(rect.top < innerHeight * 0.94 && rect.bottom > 0),是则置 dataset.visible = 'true'。
故障模式、边界情况与并发
脚手架层
| 故障模式 | 触发条件 | 行为 | 设计意图 |
|---|---|---|---|
| 拒绝覆盖非空目录 | destination.exists() and any(destination.iterdir()) | SystemExit + 明确消息,非零退出 | 一次性初始化工具的防误删保护,绝不静默覆盖使用者文件 |
| 变体名非法 | --variant 不在 ["react", "vanilla"] | argparse 报错退出 | choices=sorted(VARIANTS) 让合法值与字典定义天然同步 |
| 目标目录不存在 | destination 路径不存在 | 自动 mkdir(parents=True) | 允许一次指定深层路径 |
| 目标存在但为空 | destination.exists() 但 iterdir() 为空 | 正常复制 | 空目录视为合法起点 |
路径含 ~ | 相对或波浪号路径 | expanduser().resolve() 规范化 | 避免拼接出错误绝对路径 |
模板运行时层
| 边界情况 | 代码位置 | 处理方式 |
|---|---|---|
未知主题名传入 setTheme | themeProfiles[theme] || themeProfiles.endfield | 回退 endfield,避免读到 undefined 后崩溃 |
URL ?theme= 非法 | themeProfiles[requestedTheme] ? requestedTheme : (root.dataset.arkTheme || 'endfield') | 双重回退:先 HTML 初始值,再硬编码 endfield |
URL ?depth= 非法 | 数组白名单 .includes(requestedDepth) | 回退 HTML 初始值或 complex |
| 无 JS 环境 | HTML 根元素上的初始 data-ark-theme / data-ark-depth | CSS 直接按初始属性级联,页面可读(只是不可切换、无滚动联动) |
| 首屏元素动画遮挡 | revealNodes.filter(node => node.closest('#overview')) 直置可见 | 英雄区不参与进场动画 |
IntersectionObserver 漏触发 | revealInViewport + scroll/resize 被动监听 + 双 requestAnimationFrame | 几何判定兜底,确保任何路径下内容可见 |
| 减弱动效偏好 | matchMedia('(prefers-reduced-motion: reduce)') | 滚动行为从 smooth 降级为 auto |
| 菜单按钮不存在 | menuButton?.addEventListener、menuButton?.setAttribute | 可选链静默跳过 |
data-copy 键在 profile 中缺失 | if (value) node.textContent = value | 跳过替换,保留 HTML 原文 |
| 导航目标缺失 | target?.scrollIntoView | 可选链静默跳过 |
| Escape 关闭菜单 | 全局 keydown 监听 | 任意位置按 Escape 关闭移动端菜单 |
并发与一致性
模板是纯前端静态页面,无网络请求、无存储写入,因此不存在服务端并发问题。需要关注的一致性点有两处:
- 重命名一致性:
data-*钩子是 HTML 与 JS 的唯一契约。重命名类名、ID、data 属性或 ARIA 目标时必须在同一次修改中更新所有 JS 选择器与引用——否则样式正常但行为断裂,正是 SKILL.md 所说的「A styled page with broken DOM wiring is not complete」。 - 文案一致性:
data-copy键在 HTML 初始值与themeProfiles各 profile 中必须同键同义(见「替换文案的正确姿势」)。
这两处一致性都由 scripts/audit-ark-ui.mjs 自动校验:JS 字面量选择器必须能在 HTML 中找到对应钩子,审计失败时会列出全部缺失选择器(scripts/audit-ark-ui.mjs)。
性能与运维要点
- 被动事件监听:
scroll与resize均以{ passive: true }注册,浏览器无需等待监听器即可滚动,保证兜底逻辑零滚动阻塞。 - 一次性观察:
revealObserver触发后立即unobserve,观察器持有引用持续释放,避免长页面滚动中累积回调。 - 单次钩子收集:
app.js开头一次性收集全部data-*钩子为数组,后续逻辑全部复用,避免交互中重复查询 DOM。 - 双 rAF 延迟:
requestAnimationFrame(() => requestAnimationFrame(revealInViewport))确保首帧布局稳定后再做几何判定,避免在布局未定时读到错误 rect。 - 无构建链:零依赖意味着复制即可用,运维上无需 node_modules、无需打包器版本管理;
?v=4查询串承担缓存版本号职责。 - 纯复制的可审计性:脚手架只
copy2不改写,复制出去的文件与仓库内被audit-ark-ui.mjs校验、被 promo/showcases 演示的文件逐字节一致,出问题可直接对源排查。
扩展点
- 新增风格族:三步走——① 在
themeProfiles增加同名键与完整文案集;② 在.ark-theme-picker增加<button data-theme="<新族>">;③ 在styles.css为html[data-ark-theme="<新族>"]编写样式分支。JS 无需新增逻辑(setTheme对合法键通用)。 - 新增深度档位:在
.ark-depth-picker加按钮、在styles.css写对应data-ark-depth分支,并同步更新app.js中setDepth白名单数组['minimal','moderate','complex','maximal'](app.js L147)。 - 新增导航区块:添加
<section id="<x>" data-section="<x>">并在.ark-rail加<button data-target="<x>">,sectionObserver 与点击逻辑自动覆盖。 - 新增文案槽位:HTML 加
data-copy="<新键>",并在每个themeProfilesprofile 补齐该键;缺失键会被if (value)跳过,HTML 原文保留。 - 新增脚手架变体:在
VARIANTS字典加一个条目(目录需位于SKILL_ROOT下),--variant的合法值即自动扩展,无需改动main()。
相关链接
- SKILL.md 资产清单与重命名约束
- README.md 目录说明
- scripts/scaffold-ark-ui.py 完整实现
- assets/starter-vanilla/index.html
- assets/starter-vanilla/app.js
- scripts/audit-ark-ui.mjs 钩子校验
- React 变体(
assets/react/ArkUI.jsx+ark-ui.css的theme/depth接口)与风格族×深度规则矩阵(references/family-depth-matrix.md)由兄弟页面覆盖。