Repository Wiki
Brandon030722/ark-ui-skill

原生起手模板与脚手架

原生起手模板(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 use assets/react/ArkUI.jsx with assets/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

整体架构分为三层:脚手架层(复制分发)、模板资产层(三件套协作)、消费方(使用者项目)。脚手架脚本只做纯复制、不做任何改写,这是一个刻意的设计决策——保证复制出去的文件与仓库内被审计、被演示的文件完全一致。

Loading diagram...

各组件职责:

  • 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 起手模板复制出去,且绝不改动既有的非空目录)。

python
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/(兄弟页面主题)。

参数解析

python
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 帮助文本,这样帮助信息与代码职责声明天然保持同步。

复制流程与安全保护

python
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()):

  1. 解析变体源目录:VARIANTS[args.variant] 取得待复制的资产目录。
  2. 规范化目标路径:expanduser().resolve() 展开 ~ 并转为绝对路径。
  3. 非空目录保护:若目标已存在且 any(destination.iterdir()) 为真,立即 raise SystemExit 报 Refusing to overwrite non-empty directory。这是脚手架唯一的失败模式——它是刻意的一次性初始化工具,避免静默覆盖使用者的既有文件。
  4. 创建目录:mkdir(parents=True, exist_ok=True) 允许父目录不存在,也允许目标本身已存在(但为空)。
  5. 逐项复制:遍历源目录条目,目录用 shutil.copytree,文件用 shutil.copy2(copy2 会保留 mtime 等元数据)。当前 vanilla 变体只有三个平铺文件,目录分支为未来新增子目录预留。
  6. 输出与退出码:打印 Created {variant} Ark UI starter at {destination},返回 0。入口通过 raise SystemExit(main()) 传播退出码,供 CI 或脚本链使用。

CLI 调用方式

bash
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 键名标注,便于主题切换时批量替换。根元素是全站状态唯一真源:

html
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">

Source: assets/starter-vanilla/index.html

关键细节:

  • 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免责声明(虚构演示 / 原创资产)

侧栏导航按钮展示了典型的「一属性双职责」钩子设计:

html
<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>

Source: assets/starter-vanilla/index.html

data-target 的值与目标 <section> 的 id 对应(同时该 section 带 data-section 属性),JS 同时用它做滚动定位与滚动监听的反向高亮。

选择器选择器:风格族与深度按钮组

archive 区块内置两组互斥的单选按钮,是模板最具复用价值的部分:

html
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>

Source: assets/starter-vanilla/index.html

两组按钮共用同一交互模式:data-theme / data-depth 声明取值,.is-selected 类与 aria-pressed 属性双通道表达选中态——前者供 CSS 消费,后者供屏幕阅读器消费。

app.js 行为层详解

app.js 全文 175 行,按职责可拆为六段:DOM 钩子收集、主题文案查表、移动端菜单、滚动联动、进场动画、标签页键盘导航。

第一段:钩子收集。 开头一次性把所有 data-* 钩子转成数组,后续逻辑不再触碰选择器字符串:

javascript
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 个键),保证切换风格族时连语气和场景都随之改变,而不仅是换配色:

javascript
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,点击菜单按钮取反,点击任意导航项后强制关闭:

javascript
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] 三档以获得更平滑的切换时机:

javascript
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"):

javascript
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:

javascript
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

深度切换是同构的更简版本,只改根属性与按钮态(深度不涉及文案替换):

javascript
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 上声明的初始属性值——这让演示链接可以直接指向某个「风格 × 深度」组合:

javascript
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:

javascript
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 都会关闭移动端菜单。

核心流程

脚手架复制流程

Loading diagram...

模板运行时初始化与交互流程

Loading diagram...

两条流程图分别对应「复制分发」与「运行时行为」两个维度:前者发生在开发期(一次性),后者发生在每次页面加载与交互时(持续性)。

进场动画与无 JS 回退

进场动画采用「主观察器 + 几何兜底」双保险策略。revealObserver 在元素进入视口 12% 时置 dataset.visible = 'true' 并立即 unobserve——一次性触发,避免反复进出视口导致的动画抖动:

javascript
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 在首帧布局稳定后补一次:

javascript
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

英雄区块则绕过观察器直接标记为可见,避免首屏主内容被动画遮挡:

javascript
revealNodes .filter((node) => node.closest('#overview')) .forEach((node) => { node.dataset.visible = 'true'; });

Source: assets/starter-vanilla/app.js

使用示例

基本用法:复制原生起手模板

bash
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 参数直达指定组合

模板初始化逻辑支持查询参数,适合在演示文档或评审链接中直接定位某个风格族 × 深度组合:

text
index.html?theme=popucom&depth=minimal

参数取值不合法时自动回退:theme 回退到 themeProfiles[requestedTheme] 不存在的分支即 HTML 初始值(endfield);depth 用数组白名单校验,回退到 complex(app.js L143-L147)。

替换文案的正确姿势

模板中所有可替换文案都带 data-copy 键名。替换文案时应同时更新两处保持一致:HTML 中的初始值与 themeProfiles 中对应键的值。例如品牌名出现在 HTML(初始态)与 app.js(切换态):

html
<span><strong data-copy="brand">ORBITAL</strong><small data-copy="brandCode">FIELD LAB / 07</small></span>

Source: assets/starter-vanilla/index.html

javascript
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 参数

参数类型默认值说明
destinationPath(位置参数,必填)—输出目录;必须不存在或为空,否则脚本以非零码退出
--variant枚举 react / vanillavanilla选择要复制的起手模板变体

模板运行时状态属性

属性 / 参数位置合法取值默认(HTML 初始值)说明
data-ark-theme<html> 根元素ark / endfield / exa / popucom / corporateendfield风格族选择器,同时驱动文案替换
data-ark-depth<html> 根元素minimal / moderate / complex / maximalcomplex应用深度选择器
?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-railsetMenu移动端菜单开合状态

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() 规范化避免拼接出错误绝对路径

模板运行时层

边界情况代码位置处理方式
未知主题名传入 setThemethemeProfiles[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-depthCSS 直接按初始属性级联,页面可读(只是不可切换、无滚动联动)
首屏元素动画遮挡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 关闭移动端菜单

并发与一致性

模板是纯前端静态页面,无网络请求、无存储写入,因此不存在服务端并发问题。需要关注的一致性点有两处:

  1. 重命名一致性:data-* 钩子是 HTML 与 JS 的唯一契约。重命名类名、ID、data 属性或 ARIA 目标时必须在同一次修改中更新所有 JS 选择器与引用——否则样式正常但行为断裂,正是 SKILL.md 所说的「A styled page with broken DOM wiring is not complete」。
  2. 文案一致性: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="<新键>",并在每个 themeProfiles profile 补齐该键;缺失键会被 if (value) 跳过,HTML 原文保留。
  • 新增脚手架变体:在 VARIANTS 字典加一个条目(目录需位于 SKILL_ROOT 下),--variant 的合法值即自动扩展,无需改动 main()。

相关链接

Sources

(3 files)
assets/starter-vanilla