diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..735ca52 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,62 @@ +name: Verify and deploy public case + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Verify public case and workflow pins + run: make verify-showcase + + package: + if: github.event_name != 'pull_request' + needs: verify + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + pages: write + id-token: write + steps: + - name: Check out repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Configure Pages + uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + with: + path: ./docs + + deploy: + if: github.event_name != 'pull_request' + needs: package + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 diff --git a/Makefile b/Makefile index 263a424..efcc4c1 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,7 @@ APP_PATH := $(DERIVED_DATA)/Build/Products/$(CONFIGURATION)-iphonesimulator/UIKi BUNDLE_ID := com.example.UIKitLifecycleDemo XCBEAUTIFY ?= xcbeautify -.PHONY: build test-ui run open logs clean +.PHONY: build test-ui run open logs clean verify-showcase build: @mkdir -p "$(LOG_DIR)" @@ -75,3 +75,7 @@ logs: clean: @xcodebuild -project "$(PROJECT)" -scheme "$(SCHEME)" -derivedDataPath "$(DERIVED_DATA)" clean >/dev/null @rm -rf "$(DERIVED_DATA)" "$(LOG_DIR)" + +verify-showcase: + @python3 scripts/audit-showcase.py + @python3 scripts/verify-actions-pinned.py diff --git a/README.md b/README.md index 88818f3..410668e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,54 @@ -# UIKitLifecycleDemo +# UIKit Lifecycle Lab -一个纯 UIKit、纯代码的中文学习实验室。它不是提醒事项业务 App,而是一台“UIKit 显微镜”:你可以在 App 内 Logs、Xcode Console、断点和 Call Stack 里观察 UIKit 什么时候自动调用生命周期、delegate、target-action、closure 回传、snapshot 刷新和 cell 复用。 +一个纯 UIKit、纯代码的中文学习实验室。它不是提醒事项业务 App,而是一台“UIKit 显微镜”:用 Guided Steps、结构化日志、Xcode Call Stack 和 UI Test,观察 UIKit 什么时候自动调用生命周期、delegate、target-action、closure 回传、snapshot 刷新和 cell 复用。 + +> **English summary:** A code-first UIKit learning lab that makes framework-driven behavior observable through guided experiments, structured logs, call stacks, and UI tests. + +[公开案例页](https://estelledc.github.io/UIKitLifecycleDemo/) · [Jason Hub](https://estelledc.github.io/) · [About](https://estelledc.github.io/about/) · [Resume](https://estelledc.github.io/resume/) + +## 公开案例 + +### Problem + +UIKit 的很多关键方法不是业务代码直接调用的。只读 `viewDidLoad`、`didSelectItemAt` 或 `saveReminder` 的定义,很容易把框架生命周期、delegate 回调、按钮事件和业务 closure 混在一起。 + +### Role + +- **Jason**:定义学习目标、选择观察机制、编排验证路线,并用真实 Xcode / Simulator 结果验收。 +- **Codex / AI**:辅助搭建 Demo、实现结构化日志与文档、迭代自动化测试。 +- **Xcode / Simulator / XCTest**:提供最终运行证据;README 中的预期顺序不替代真实构建与观察。 + +### System + +```text +Predict -> Run -> Observe -> Verify -> Recap + Guide App Logs Stack/Test 复盘 +``` + +1. `GuidedStep` 把操作、观察位置、预测问题、预期日志和胜利条件放在同一张训练卡里。 +2. `DemoLogStore` 同时保存结构化事件并输出 Xcode Console。 +3. App 内 Log Panel 支持分类、搜索、关键事件、暂停滚动与复制。 +4. Call Stack 回答“谁触发”,UI Test 检查关键用户流程。 + +### Evidence + +| 可核验事实 | 当前仓库依据 | +|---|---| +| 9 个引导步骤 | `UIKitLifecycleDemo/GuidedExperiment.swift` | +| 14 类结构化日志 | `UIKitLifecycleDemo/DemoLogStore.swift` | +| 2 条 UI Test 流程 | `UIKitLifecycleDemoUITests/UIKitLifecycleDemoUITests.swift` | +| 纯 UIKit、纯代码入口 | `SceneDelegate -> UINavigationController -> ReminderListViewController` | +| 可重复构建 | `Makefile` 中的 `build / run / test-ui` | + +这些数字描述当前实现范围,不代表学习效果。 + +### Limitations + +- 这是学习实验室,不是生产级提醒事项 App。 +- 不包含网络、持久化、EventKit 或真实业务状态。 +- Guide 描述预期观察,不会生成或伪造日志。 +- 两条 UI Test 只覆盖核心编辑流程和 Guide / Logs 入口,尚未覆盖全部高级实验。 +- 项目不声称仅凭阅读或运行 Demo 就能掌握 UIKit。 ## 5 分钟 Guided Tour @@ -20,18 +68,18 @@ 你最终要亲眼确认: - `viewDidLoad` 只在 view 第一次加载后执行。 -- `viewWillAppear / viewDidAppear` 每次页面重新出现都会执行。 +- `viewWillAppear / viewDidAppear` 在页面重新出现时再次执行。 - `didSelectItemAt` 是 UIKit 收到 cell 点击后的 delegate 回调。 -- `saveReminder` 是按钮事件通过 target-action 或 UIAction 触发。 +- `saveReminder` 由 target-action 或 UIAction 触发。 - `onSave closure` 是 Detail 把编辑结果交回 List 的业务通道。 -- Detail pop 后出现 `deinit`,说明详情页释放了。 +- Detail pop 后出现 `deinit`,说明该详情页实例释放。 -## 如何运行 +完整说明见 [docs/guided-learning.md](docs/guided-learning.md)。 + +## 本地运行 ```bash -cd explorations/_active/UIKitLifecycleDemo make build -make open make run make test-ui ``` @@ -51,79 +99,63 @@ make build SIMULATOR_NAME="iPhone 16 Pro" 环境准备见 [docs/setup.md](docs/setup.md)。 -## 如何打开 Logs - -像看慢放录像一样,Logs 面板让你不用追着 Xcode Console 跑。 - -操作: +## 如何观察 -1. 启动 App。 -2. 点击右上角 `Learn`。 -3. 点击 `Logs`。 -4. 用 Filter 一次选择一个分类,例如先选 `Lifecycle`,再分别切到 `Delegate`、`Action`、`Closure`、`Snapshot`、`Cell`、`NavStack`。 -5. 打开 `Only Key Events` 只看关键事件。 -6. 点击 `Pause Scroll` 暂停自动滚动;再次点击 `Resume Scroll` 恢复自动滚动。 -7. 点击 `Copy` 复制当前可见日志给 ChatGPT 分析。 +### App 内 Logs -注意:当前 Log Panel 的 Filter 一次只能选择一个分类。需要组合观察时,可以保持 `All` 并使用 Search 搜索 `viewDidLoad`、`saveReminder` 等关键词,或分别切换分类。 +1. 点击 `Learn -> Logs`。 +2. 用 Filter 分别观察 `Lifecycle`、`Delegate`、`Action`、`Closure`、`Snapshot`、`Cell`、`NavStack`。 +3. 打开 `Only Key Events` 只看关键事件。 +4. 用 `Pause Scroll / Resume Scroll` 控制自动滚动。 +5. 点击 `Copy` 复制当前可见日志。 详细说明见 [docs/log-panel.md](docs/log-panel.md)。 -## 如何使用 Guide +### Xcode Call Stack -Guide 是带学模式。每张卡片都有: +Logs 负责看顺序,Call Stack 负责看“是谁调用的”。建议断点: -- 当前做什么 -- 应该看哪里 -- 操作前预测 -- 预期日志 -- 完成后理解问题 -- 一句话复盘 -- 胜利条件 +- `ReminderListViewController.viewDidLoad` +- `ReminderListViewController.viewWillAppear` +- `ReminderListViewController.collectionView(_:didSelectItemAt:)` +- `ReminderDetailViewController.viewDidLoad` +- `ReminderDetailViewController.saveReminder` -详细说明见 [docs/guided-learning.md](docs/guided-learning.md)。 +断点命中后在 LLDB 输入 `bt`,寻找 `UIKitCore`、`UIViewController`、`UICollectionViewController`、`UINavigationController` 与 `UIApplication sendAction`。完整步骤见 [docs/call-stack-teaching.md](docs/call-stack-teaching.md)。 -## 完整实验目录 +## 实验入口 入口:`Learn -> Experiments` | 实验 | 观察重点 | |---|---| -| Show / Push / Present | 展示方式、navigation stack、pop/dismiss | +| Show / Push / Present | 展示方式、navigation stack、pop / dismiss | | Snapshot Full Apply / Reload / Reconfigure | diffable snapshot 如何刷新列表 | | Toggle Save | target-action 与 UIAction 的差异 | -| Toggle Closure | weak/strong capture 与 deinit / Memory Graph | +| Toggle Closure | weak / strong capture 与 `deinit` / Memory Graph | | Load 50 Reuse Items | cell init、updateConfiguration、prepareForReuse | | String Identity Experiment | 重复 String identifier 的风险 | -| Manual UIViewController Version | 手动创建 collectionView 的对照实验 | - -## Xcode Call Stack 验证 - -Logs 负责看顺序,Call Stack 负责看“是谁调用的”。 - -建议断点: +| Manual UIViewController Version | 手动创建 collection view 的对照实验 | -- `ReminderListViewController.viewDidLoad` -- `ReminderListViewController.viewWillAppear` -- `ReminderListViewController.collectionView(_:didSelectItemAt:)` -- `ReminderDetailViewController.viewDidLoad` -- `ReminderDetailViewController.saveReminder` +## 公开展示验证 -断点命中后,在 LLDB 输入: +GitHub Pages 源文件位于 `docs/`,不改动核心 UIKit 演示代码。执行: -```lldb -bt +```bash +make verify-showcase +git diff --check ``` -重点找: +其中 `make verify-showcase` 会检查: -- `UIKitCore` -- `UIViewController` -- `UICollectionViewController` -- `UINavigationController` -- `UIApplication sendAction` +- Problem / Role / System / Evidence / Limitations 与 Jason / AI 边界。 +- canonical、Open Graph、Twitter Card、JSON-LD 和分享图片尺寸。 +- 站内链接、锚点、图片 alt 与尺寸属性。 +- 9 / 14 / 2 三项展示数字是否仍与 Swift 源码一致。 +- Jason DS v2、reduced-motion、焦点样式与隐私标记。 +- 所有第三方 GitHub Actions 是否固定到完整 40 位 commit SHA。 -详细步骤见 [docs/call-stack-teaching.md](docs/call-stack-teaching.md)。 +分享图需要重建时,可先安装 Pillow,再运行 `python3 scripts/generate-showcase-assets.py`。 ## 项目结构 @@ -143,41 +175,21 @@ UIKitLifecycleDemo/ StringIdentityExperimentViewController.swift ManualCollectionViewController.swift UIKitLifecycleDemoUITests/ - UIKitLifecycleDemoUITests.swift docs/ + index.html # 公开案例页 + assets/ # 页面样式、真实模拟器截图、Jason DS、分享图 +scripts/ + audit-showcase.py + verify-actions-pinned.py Makefile CHANGELOG.md LICENSE ``` -入口链路: - -```text -SceneDelegate --> UIWindow --> UINavigationController --> ReminderListViewController -``` - -## 学习方法 - -日常类比:UIKit 像舞台管理员。你写的 view controller 不是自己上台,而是等舞台管理员通知“该加载 view 了”“该出现了”“按钮被点了”“cell 被选中了”。 - -技术定义: - -- 生命周期:UIKit 根据页面状态自动调用。 -- delegate:UIKit 把用户操作回调给你。 -- target-action:UIKit 根据控件事件调用 selector/action。 -- closure:业务代码自己设计的数据回传通道。 - -Codex 学习流程见: - -- [docs/codex-learning-workflow.md](docs/codex-learning-workflow.md) -- [docs/agent-flow.md](docs/agent-flow.md) -- [docs/computer-use-sop.md](docs/computer-use-sop.md) - ## 版本范围 - `1.0.0`:基础 UIKit 生命周期、collection view、delegate、target-action、closure。 -- `1.1.0`:扩展可观测实验。 -- `1.2.0`:App 内 Logs、Guided Learning、慢速 Computer Use SOP。 +- `1.1.0`:扩展可观察实验。 +- `1.2.0`(Building):App 内 Logs、Guided Learning 与 Computer Use SOP;尚未正式发布。 + +项目代码使用 MIT License。课程式说明和运行结论以仓库当前源码及本地验证为准。 diff --git a/docs/.nojekyll b/docs/.nojekyll new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/docs/.nojekyll @@ -0,0 +1 @@ + diff --git a/docs/404.html b/docs/404.html new file mode 100644 index 0000000..4748ec5 --- /dev/null +++ b/docs/404.html @@ -0,0 +1,31 @@ + + + + + + 页面未找到 · UIKit Lifecycle Lab | Jason Xun + + + + + + + + + + + + + + + + + + + + 跳到正文 +
UIKit Lifecycle LabJason Xun · Engineering lab
+

404 / TRACE NOT FOUND

这条地址没有对应的生命周期轨迹。

返回实验首页,运行一次 Save,观察 delegate、生命周期、target-action 与 closure 的真实顺序。

+ + + diff --git a/docs/assets/app.js b/docs/assets/app.js new file mode 100644 index 0000000..9d9598a --- /dev/null +++ b/docs/assets/app.js @@ -0,0 +1,132 @@ +(function () { + "use strict"; + + const root = document.documentElement; + const storageKey = "uikit-lifecycle-showcase-theme"; + const media = window.matchMedia("(prefers-color-scheme: dark)"); + + function readTheme() { + try { + return localStorage.getItem(storageKey) || ""; + } catch (_) { + return ""; + } + } + + function writeTheme(theme) { + try { + localStorage.setItem(storageKey, theme); + } catch (_) {} + } + + function isDark() { + const explicit = root.getAttribute("data-theme"); + return explicit ? explicit === "dark" : media.matches; + } + + function updateButtons() { + const dark = isDark(); + document.querySelectorAll("[data-theme-toggle]").forEach((button) => { + button.textContent = dark ? "Light" : "Dark"; + button.setAttribute("aria-pressed", String(dark)); + button.setAttribute("aria-label", dark ? "切换到浅色模式" : "切换到深色模式"); + }); + } + + const storedTheme = readTheme(); + if (storedTheme === "light" || storedTheme === "dark") { + root.setAttribute("data-theme", storedTheme); + } + updateButtons(); + + document.addEventListener("click", (event) => { + const toggle = event.target.closest("[data-theme-toggle]"); + if (!toggle) return; + const nextTheme = isDark() ? "light" : "dark"; + root.setAttribute("data-theme", nextTheme); + writeTheme(nextTheme); + updateButtons(); + }); + + document.addEventListener("keydown", (event) => { + if (event.key !== "Escape") return; + document.querySelectorAll(".jx-site-nav__menu[open]").forEach((menu) => { + menu.removeAttribute("open"); + menu.querySelector("summary")?.focus(); + }); + }); + + const saveLab = document.querySelector("[data-save-lab]"); + const runSave = saveLab?.querySelector("[data-run-save]"); + const saveOutput = saveLab?.querySelector("[data-save-output]"); + const saveSteps = Array.from(saveLab?.querySelectorAll("[data-mechanism]") || []); + let saveTimers = []; + + function clearSaveTimers() { + saveTimers.forEach((timer) => window.clearTimeout(timer)); + saveTimers = []; + } + + function resetSaveTrace() { + saveSteps.forEach((step) => { + step.classList.remove("is-active", "is-complete"); + step.removeAttribute("aria-current"); + }); + } + + function activateSaveStep(index) { + saveSteps.forEach((step, stepIndex) => { + step.classList.toggle("is-complete", stepIndex < index); + step.classList.toggle("is-active", stepIndex === index); + if (stepIndex === index) step.setAttribute("aria-current", "step"); + else step.removeAttribute("aria-current"); + }); + const active = saveSteps[index]; + if (active && saveOutput) { + const label = active.querySelector("span")?.textContent || `step ${index + 1}`; + const method = active.querySelector("code")?.textContent || ""; + saveOutput.textContent = `${label} · ${method}`; + } + } + + function finishSaveTrace() { + saveSteps.forEach((step) => { + step.classList.remove("is-active"); + step.classList.add("is-complete"); + step.removeAttribute("aria-current"); + }); + saveLab?.removeAttribute("aria-busy"); + if (runSave) { + runSave.disabled = false; + runSave.textContent = "Replay Save trace"; + } + if (saveOutput) saveOutput.textContent = "Save 完成:closure 回传后,列表 snapshot 已刷新。"; + } + + if (saveLab && runSave && saveSteps.length === 4) { + saveLab.classList.add("is-enhanced"); + runSave.hidden = false; + runSave.addEventListener("click", () => { + clearSaveTimers(); + resetSaveTrace(); + runSave.disabled = true; + runSave.textContent = "Running Save trace…"; + saveLab.setAttribute("aria-busy", "true"); + + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { + saveSteps.forEach((_, index) => activateSaveStep(index)); + finishSaveTrace(); + return; + } + + saveSteps.forEach((_, index) => { + saveTimers.push(window.setTimeout(() => activateSaveStep(index), index * 480)); + }); + saveTimers.push(window.setTimeout(finishSaveTrace, saveSteps.length * 480)); + }); + } + + media.addEventListener("change", () => { + if (!readTheme()) updateButtons(); + }); +})(); diff --git a/docs/assets/favicon.png b/docs/assets/favicon.png new file mode 100644 index 0000000..c1d8e1a Binary files /dev/null and b/docs/assets/favicon.png differ diff --git a/docs/assets/jx/VERSION b/docs/assets/jx/VERSION new file mode 100644 index 0000000..7ec1d6d --- /dev/null +++ b/docs/assets/jx/VERSION @@ -0,0 +1 @@ +2.1.0 diff --git a/docs/assets/jx/base.css b/docs/assets/jx/base.css new file mode 100644 index 0000000..96dc573 --- /dev/null +++ b/docs/assets/jx/base.css @@ -0,0 +1,165 @@ +/* Jason DS · Base v2.0.0 + * 默认 reset + body 排版基底 + chrome layer 强制 sans + */ + +*, +*::before, +*::after { box-sizing: border-box; } + +html { + scroll-behavior: smooth; + -webkit-text-size-adjust: 100%; + -moz-text-size-adjust: 100%; + text-size-adjust: 100%; +} + +body { + margin: 0; + background: var(--jx-paper); + color: var(--jx-ink); + font-family: var(--jx-font-sans-zh); + font-size: var(--jx-fs-body); + line-height: var(--jx-leading-body); + font-feature-settings: var(--jx-font-features-body); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; +} + +img, +picture, +video, +canvas { + display: block; + max-width: 100%; +} + +button, +input, +select, +textarea { + font: inherit; +} + +:target { + scroll-margin-top: calc(var(--jx-header-height) + var(--jx-space-4)); +} + +body :lang(en) { + font-family: var(--jx-font-sans-latin); + letter-spacing: var(--jx-tracking-body); +} + +/* Headings:display serif,CJK / Latin 字距分流 */ +h1, h2, h3, h4 { + font-family: var(--jx-font-display); + font-weight: var(--jx-weight-heading); + line-height: var(--jx-leading-tight); + margin-block: var(--jx-space-5) var(--jx-space-4); +} +h1 { font-size: var(--jx-fs-h1); letter-spacing: var(--jx-tracking-h1); } +h2 { font-size: var(--jx-fs-h2); letter-spacing: var(--jx-tracking-h2); } +h3 { font-size: var(--jx-fs-h3); letter-spacing: var(--jx-tracking-h3); } +h4 { font-size: var(--jx-fs-lg); letter-spacing: var(--jx-tracking-h3); } +h1:lang(zh), h2:lang(zh), h3:lang(zh), h4:lang(zh) { letter-spacing: var(--jx-tracking-cjk); } + +/* Body links */ +a { + color: var(--jx-link); + text-decoration: underline; + text-decoration-thickness: 1px; + text-underline-offset: 0.18em; + transition: color var(--jx-duration-fast) var(--jx-easing); +} +a:hover { color: var(--jx-accent-hover); } +a:focus-visible { + outline: none; + box-shadow: var(--jx-shadow-focus); + border-radius: var(--jx-radius-2); +} + +button:focus-visible, +summary:focus-visible, +[tabindex]:focus-visible { + outline: 2px solid var(--jx-accent); + outline-offset: 3px; +} + +.jx-container { + width: min(calc(100% - (2 * var(--jx-gutter))), var(--jx-container)); + margin-inline: auto; +} + +.jx-container--narrow { + width: min(calc(100% - (2 * var(--jx-gutter))), var(--jx-container-narrow)); + margin-inline: auto; +} + +.jx-section { + padding-block: var(--jx-section-y); +} + +.jx-visually-hidden { + position: absolute !important; + width: 1px !important; + height: 1px !important; + padding: 0 !important; + margin: -1px !important; + overflow: hidden !important; + clip: rect(0, 0, 0, 0) !important; + white-space: nowrap !important; + border: 0 !important; +} + +/* Mono inline */ +code, kbd, samp { + font-family: var(--jx-font-mono); + font-size: 0.92em; + letter-spacing: var(--jx-tracking-mono); + font-feature-settings: var(--jx-font-features-mono); +} + +/* Block code 默认 hairline + sunk surface */ +pre { + font-family: var(--jx-font-mono); + font-size: var(--jx-fs-sm); + line-height: var(--jx-leading-mono); + background: var(--jx-surface-sunk); + padding: var(--jx-space-4) var(--jx-space-5); + border-radius: var(--jx-radius-3); + box-shadow: var(--jx-shadow-hairline); + overflow-x: auto; + margin-block: var(--jx-space-5); +} +pre code { background: transparent; padding: 0; font-size: inherit; } + +/* Quotes */ +blockquote { + margin-block: var(--jx-space-5); + padding-inline-start: var(--jx-space-5); + border-inline-start: 2px solid var(--jx-rule-strong); + font-style: italic; + color: var(--jx-ink-2); +} + +/* HR:默认就是 hairline */ +hr { + border: none; + border-top: 1px solid var(--jx-rule-strong); + margin-block: var(--jx-section-y); +} + +/* 选中态 */ +::selection { + background: var(--jx-accent-tint); + color: var(--jx-ink); +} + +/* Reduce motion */ +@media (prefers-reduced-motion: reduce) { + html { scroll-behavior: auto; } + * { + animation-duration: 0.01ms !important; + transition-duration: 0.01ms !important; + } +} diff --git a/docs/assets/jx/components.css b/docs/assets/jx/components.css new file mode 100644 index 0000000..c430b87 --- /dev/null +++ b/docs/assets/jx/components.css @@ -0,0 +1,792 @@ +/* Jason DS · Eyebrow v1.0.0 */ + +.jx-eyebrow { + display: inline-flex; + align-items: center; + gap: var(--jx-space-3); + font-family: var(--jx-font-mono); + font-size: var(--jx-fs-micro); + font-weight: var(--jx-weight-heading); + text-transform: uppercase; + letter-spacing: var(--jx-tracking-eyebrow); + color: var(--jx-ink-muted); + font-feature-settings: var(--jx-font-features-mono); +} +.jx-eyebrow__rule { + display: inline-block; + width: var(--jx-space-6); + height: 1px; + background: var(--jx-rule-strong); +} + +/* Jason DS · Status Chip v2.0.0 + * Lifecycle states with shape redundancy. Legacy learning/building/shipped + * values remain supported for existing learning sites. + * Required: color + glyph + mono label (color-blind safe) + */ + +.jx-chip { + display: inline-flex; + align-items: center; + gap: var(--jx-space-2); + padding: 2px var(--jx-space-3); + font-family: var(--jx-font-mono); + font-size: var(--jx-fs-micro); + font-weight: var(--jx-weight-heading); + text-transform: uppercase; + letter-spacing: var(--jx-tracking-eyebrow); + border-radius: var(--jx-radius-pill); + background: var(--_chip-tint); + color: var(--_chip-color); + font-feature-settings: var(--jx-font-features-mono); + white-space: nowrap; +} +.jx-chip::before { + content: var(--_chip-glyph); + font-size: 1em; + line-height: 1; +} +.jx-chip[data-state="learning"] { + --_chip-color: var(--jx-state-learning); + --_chip-tint: var(--jx-state-learning-tint); + --_chip-glyph: var(--jx-state-learning-glyph); +} +.jx-chip[data-state="building"] { + --_chip-color: var(--jx-state-building); + --_chip-tint: var(--jx-state-building-tint); + --_chip-glyph: var(--jx-state-building-glyph); +} +.jx-chip[data-state="shipped"] { + --_chip-color: var(--jx-state-shipped); + --_chip-tint: var(--jx-state-shipped-tint); + --_chip-glyph: var(--jx-state-shipped-glyph); +} +.jx-chip[data-state="concept"] { + --_chip-color: var(--jx-state-concept); + --_chip-tint: var(--jx-state-concept-tint); + --_chip-glyph: var(--jx-state-concept-glyph); +} +.jx-chip[data-state="prototype"] { + --_chip-color: var(--jx-state-prototype); + --_chip-tint: var(--jx-state-prototype-tint); + --_chip-glyph: var(--jx-state-prototype-glyph); +} +.jx-chip[data-state="maintained"] { + --_chip-color: var(--jx-state-maintained); + --_chip-tint: var(--jx-state-maintained-tint); + --_chip-glyph: var(--jx-state-maintained-glyph); +} +.jx-chip[data-state="archived"] { + --_chip-color: var(--jx-state-archived); + --_chip-tint: var(--jx-state-archived-tint); + --_chip-glyph: var(--jx-state-archived-glyph); +} + +/* Jason DS · External Pill v1.0.0 + * 出站链接统一 hairline + 右上 ↗ 箭头 (no icon font) + */ + +.jx-pill { + display: inline-flex; + align-items: center; + gap: var(--jx-space-2); + padding: var(--jx-space-2) var(--jx-space-4); + font-family: var(--jx-font-mono); + font-size: var(--jx-fs-xs); + letter-spacing: var(--jx-tracking-mono); + color: var(--jx-ink-2); + text-decoration: none; + border-radius: var(--jx-radius-pill); + box-shadow: var(--jx-shadow-hairline); + background: transparent; + transition: color var(--jx-duration-fast) var(--jx-easing), + box-shadow var(--jx-duration-fast) var(--jx-easing); +} +.jx-pill::after { + content: "↗"; + font-size: 0.92em; + color: var(--jx-ink-muted); + transition: transform var(--jx-duration-fast) var(--jx-easing); +} +.jx-pill:hover { + color: var(--jx-accent); + box-shadow: 0 0 0 1px var(--jx-accent); +} +.jx-pill:hover::after { + color: var(--jx-accent); + transform: translate(1px, -1px); +} +.jx-pill:focus-visible { + outline: none; + box-shadow: var(--jx-shadow-focus); +} + +/* Jason DS · Footer v1.0.0 + * 三栏:colophon / 索引 / last-updated + * Layer 1:9 仓必有此结构 + */ + +.jx-footer { + margin-top: var(--jx-section-y); + padding: var(--jx-space-7) var(--jx-gutter); + border-top: 1px solid var(--jx-rule-strong); + display: grid; + grid-template-columns: 1fr 2fr 1fr; + gap: var(--jx-space-6); + max-width: var(--jx-container); + margin-inline: auto; + font-family: var(--jx-font-mono); + font-size: var(--jx-fs-micro); + letter-spacing: var(--jx-tracking-eyebrow); + text-transform: uppercase; + color: var(--jx-ink-muted); + font-feature-settings: var(--jx-font-features-mono); +} +.jx-footer__colophon strong { + display: block; + font-family: var(--jx-font-display); + font-size: var(--jx-fs-sm); + font-weight: var(--jx-weight-heading); + letter-spacing: var(--jx-tracking-h3); + text-transform: none; + color: var(--jx-ink); + margin-bottom: var(--jx-space-2); +} +.jx-footer__index { + display: flex; + flex-wrap: wrap; + gap: var(--jx-space-3) var(--jx-space-5); +} +.jx-footer__index a { + color: var(--jx-ink-2); + text-decoration: none; + transition: color var(--jx-duration-fast) var(--jx-easing); +} +.jx-footer__index a:hover { color: var(--jx-accent); } +.jx-footer__stamp { + text-align: end; + color: var(--jx-ink-faint); +} +@media (max-width: 720px) { + .jx-footer { + grid-template-columns: 1fr; + } + .jx-footer__stamp { + text-align: start; + border-top: 1px solid var(--jx-rule); + padding-top: var(--jx-space-3); + } +} + +/* Jason DS · Return to Hub v1.0.0 + * 9 satellite 必现,固定左上 mono caption。 + * Hub 自身不引此组件。 + */ + +.jx-return-to-hub { + position: relative; /* 各仓可在自己 :root 下加 fixed/absolute */ + display: inline-flex; + align-items: center; + gap: var(--jx-space-2); + padding: var(--jx-space-2) 0; + font-family: var(--jx-font-mono); + font-size: var(--jx-fs-micro); + text-transform: lowercase; + letter-spacing: var(--jx-tracking-eyebrow); + color: var(--jx-ink-muted); + text-decoration: none; + font-feature-settings: var(--jx-font-features-mono); + transition: color var(--jx-duration-fast) var(--jx-easing); +} +.jx-return-to-hub::before { content: "←"; } +.jx-return-to-hub:hover { + color: var(--jx-accent-hub); /* 强制墨蓝逃生通道,satellite override --jx-accent 时仍墨蓝 */ +} +.jx-return-to-hub:focus-visible { + outline: none; + box-shadow: var(--jx-shadow-focus); +} + +/* Jason DS · Site chrome v2.0.0 + * Shared identity, accessible navigation, skip link, and action styles. + */ + +.jx-skip-link { + position: fixed; + inset: var(--jx-space-3) auto auto var(--jx-space-3); + z-index: 1000; + padding: var(--jx-space-2) var(--jx-space-4); + color: var(--jx-cta-fg); + background: var(--jx-cta-bg); + border-radius: var(--jx-radius-2); + text-decoration: none; + transform: translateY(calc(-100% - var(--jx-space-5))); + transition: transform var(--jx-duration-fast) var(--jx-easing); +} + +.jx-skip-link:focus { + transform: translateY(0); +} + +.jx-site-header { + position: sticky; + inset-block-start: 0; + z-index: 100; + min-height: var(--jx-header-height); + color: var(--jx-ink); + background: color-mix(in oklab, var(--jx-paper), transparent 8%); + border-bottom: 1px solid var(--jx-rule); + backdrop-filter: blur(16px) saturate(120%); +} + +.jx-site-header__inner { + width: min(calc(100% - (2 * var(--jx-gutter))), var(--jx-container)); + min-height: var(--jx-header-height); + margin-inline: auto; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--jx-space-5); +} + +.jx-site-header__identity { + min-width: 0; + display: flex; + align-items: baseline; + gap: var(--jx-space-3); + color: var(--jx-ink); + text-decoration: none; +} + +.jx-site-header__identity strong { + font-family: var(--jx-font-sans-zh); + font-size: var(--jx-fs-sm); + font-weight: var(--jx-weight-heading); + white-space: nowrap; +} + +.jx-site-header__identity span { + overflow: hidden; + color: var(--jx-ink-muted); + font-family: var(--jx-font-mono); + font-size: var(--jx-fs-micro); + letter-spacing: var(--jx-tracking-mono); + text-overflow: ellipsis; + white-space: nowrap; +} + +.jx-site-nav, +.jx-site-nav__links { + display: flex; + align-items: center; + gap: var(--jx-space-5); +} + +.jx-site-nav a { + color: var(--jx-ink-2); + font-size: var(--jx-fs-sm); + font-weight: var(--jx-weight-ui); + text-decoration: none; +} + +.jx-site-nav a:hover, +.jx-site-nav a[aria-current="page"] { + color: var(--jx-ink); +} + +.jx-site-nav__menu { + display: none; +} + +.jx-action { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 44px; + padding: var(--jx-space-3) var(--jx-space-5); + color: var(--jx-cta-fg); + background: var(--jx-cta-bg); + border: 1px solid var(--jx-cta-bg); + border-radius: var(--jx-radius-3); + font-weight: var(--jx-weight-ui); + line-height: 1; + text-decoration: none; + transition: transform var(--jx-duration-fast) var(--jx-easing), + background var(--jx-duration-fast) var(--jx-easing); +} + +.jx-action:hover { + color: var(--jx-cta-fg); + transform: translateY(-1px); +} + +.jx-action--secondary { + color: var(--jx-ink); + background: transparent; + border-color: var(--jx-rule-strong); +} + +.jx-action--secondary:hover { + color: var(--jx-ink); + background: var(--jx-surface-sunk); +} + +@media (max-width: 760px) { + .jx-site-header__inner { + align-items: flex-start; + padding-block: var(--jx-space-3); + } + + .jx-site-nav__links { + display: none; + } + + .jx-site-nav__menu { + display: block; + position: relative; + } + + .jx-site-nav__menu summary { + min-width: 44px; + min-height: 44px; + display: grid; + place-items: center; + cursor: pointer; + color: var(--jx-ink); + font-family: var(--jx-font-mono); + font-size: var(--jx-fs-xs); + list-style: none; + } + + .jx-site-nav__menu summary::-webkit-details-marker { + display: none; + } + + .jx-site-nav__menu nav { + position: absolute; + inset: calc(100% + var(--jx-space-2)) 0 auto auto; + min-width: 200px; + display: grid; + gap: var(--jx-space-1); + padding: var(--jx-space-3); + background: var(--jx-surface); + border-radius: var(--jx-radius-4); + box-shadow: var(--jx-shadow-stack); + } + + .jx-site-nav__menu nav a { + min-height: 44px; + display: flex; + align-items: center; + padding-inline: var(--jx-space-3); + border-radius: var(--jx-radius-2); + } +} + + +/* Jason DS · Project proof v2.0.0 + * Hiring-facing evidence block used on project and documentation homepages. + */ + +.jx-proof { + display: grid; + grid-template-columns: minmax(0, 1.45fr) minmax(240px, 0.75fr); + gap: clamp(var(--jx-space-6), 5vw, var(--jx-space-8)); + padding: clamp(var(--jx-space-6), 5vw, var(--jx-space-8)); + background: var(--jx-surface); + border-radius: var(--jx-radius-5); + box-shadow: var(--jx-shadow-stack); +} + +.jx-proof__summary { + max-width: var(--jx-measure-body); + color: var(--jx-ink-2); + font-size: var(--jx-fs-lg); +} + +.jx-proof__summary-en { + max-width: var(--jx-measure-lede); + color: var(--jx-ink-muted); + font-family: var(--jx-font-sans-latin); + font-size: var(--jx-fs-sm); +} + +.jx-proof__meta, +.jx-proof__facts { + margin: 0; + display: grid; + gap: var(--jx-space-4); +} + +.jx-proof__meta div, +.jx-proof__facts div { + padding-block-end: var(--jx-space-4); + border-bottom: 1px solid var(--jx-rule); +} + +.jx-proof dt, +.jx-proof__label { + margin-bottom: var(--jx-space-1); + color: var(--jx-ink-muted); + font-family: var(--jx-font-mono); + font-size: var(--jx-fs-micro); + letter-spacing: var(--jx-tracking-eyebrow); + text-transform: uppercase; +} + +.jx-proof dd { + margin: 0; + color: var(--jx-ink); + font-weight: var(--jx-weight-ui); +} + +.jx-proof__metrics { + margin-block: var(--jx-space-6) 0; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: var(--jx-space-3); +} + +.jx-proof__metric { + min-height: 112px; + padding: var(--jx-space-4); + background: var(--jx-surface-sunk); + border-radius: var(--jx-radius-3); + box-shadow: var(--jx-shadow-hairline); +} + +.jx-proof__metric strong { + display: block; + margin-bottom: var(--jx-space-2); + font-family: var(--jx-font-display); + font-size: var(--jx-fs-h3); + font-weight: var(--jx-weight-heading); + line-height: var(--jx-leading-tight); +} + +.jx-proof__metric span, +.jx-proof__limitation { + color: var(--jx-ink-muted); + font-size: var(--jx-fs-sm); +} + +.jx-proof__links { + margin-top: var(--jx-space-5); + display: flex; + flex-wrap: wrap; + gap: var(--jx-space-3); +} + +@media (max-width: 760px) { + .jx-proof { + grid-template-columns: 1fr; + padding: var(--jx-space-5); + } + + .jx-proof__summary { + font-size: var(--jx-fs-body); + } +} + + +/* Jason DS · Case study v2.0.0 + * Long-form public case page: hero, narrative, evidence, and honest limits. + */ + +.jx-case-hero { + padding-block: clamp(var(--jx-space-7), 9vw, 112px) var(--jx-section-y); + border-bottom: 1px solid var(--jx-rule-strong); +} + +.jx-case-hero h1 { + max-width: var(--jx-measure-title); + margin-block: var(--jx-space-4); + font-size: var(--jx-fs-display-fluid); + line-height: var(--jx-leading-display); +} + +.jx-case-hero__lede { + max-width: var(--jx-measure-lede); + color: var(--jx-ink-2); + font-size: var(--jx-fs-lg); +} + +.jx-case-hero__en { + max-width: var(--jx-measure-lede); + color: var(--jx-ink-muted); + font-family: var(--jx-font-sans-latin); + font-size: var(--jx-fs-sm); +} + +.jx-case-section { + padding-block: var(--jx-section-y); + border-bottom: 1px solid var(--jx-rule); +} + +.jx-case-section__grid { + display: grid; + grid-template-columns: minmax(180px, 0.38fr) minmax(0, 1fr); + gap: clamp(var(--jx-space-6), 6vw, var(--jx-space-8)); +} + +.jx-case-section__heading { + margin: 0; + color: var(--jx-ink-muted); + font-family: var(--jx-font-mono); + font-size: var(--jx-fs-xs); + letter-spacing: var(--jx-tracking-eyebrow); + text-transform: uppercase; +} + +.jx-case-section__body { + max-width: var(--jx-measure-body); +} + +.jx-evidence-list { + margin: 0; + padding: 0; + display: grid; + gap: var(--jx-space-3); + list-style: none; +} + +.jx-evidence-list a, +.jx-evidence-list span { + min-height: 48px; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--jx-space-4); + padding: var(--jx-space-3) var(--jx-space-4); + color: var(--jx-ink); + background: var(--jx-surface); + border-radius: var(--jx-radius-3); + box-shadow: var(--jx-shadow-hairline); + text-decoration: none; +} + +.jx-evidence-list a::after { + content: "↗"; + color: var(--jx-ink-muted); +} + +.jx-case-limit { + padding: var(--jx-space-5); + color: var(--jx-ink-2); + background: var(--jx-surface-sunk); + border-inline-start: 3px solid var(--jx-state-building); + border-radius: var(--jx-radius-3); +} + +@media (max-width: 720px) { + .jx-case-section__grid { + grid-template-columns: 1fr; + gap: var(--jx-space-4); + } + + .jx-case-hero__lede { + font-size: var(--jx-fs-body); + } +} + +/* Jason DS · Scan proof v2.1.0 + * Fast-scan evidence layer: case question, proof rail, and evidence provenance. + */ + +.jx-case-question { + display: grid; + grid-template-columns: minmax(9rem, 0.34fr) minmax(0, 1fr); + gap: clamp(var(--jx-space-5), 5vw, var(--jx-space-8)); + padding-block: clamp(var(--jx-space-5), 4vw, var(--jx-space-7)); + border-block: 1px solid var(--jx-rule-strong); +} + +.jx-case-question__label { + margin: 0; + color: var(--jx-ink-muted); + font-family: var(--jx-font-mono); + font-size: var(--jx-fs-micro); + font-weight: var(--jx-weight-heading); + letter-spacing: var(--jx-tracking-eyebrow); + line-height: var(--jx-leading-mono); + text-transform: uppercase; +} + +.jx-case-question__prompt { + display: block; + max-width: 34em; + margin: 0; + color: var(--jx-ink); + font-family: var(--jx-font-display); + font-size: clamp(1.25rem, 2.4vw, 1.8rem); + font-weight: var(--jx-weight-heading); + letter-spacing: var(--jx-tracking-h3); + line-height: 1.35; +} + +.jx-case-question__context { + max-width: var(--jx-measure-body); + margin: var(--jx-space-3) 0 0; + color: var(--jx-ink-muted); + font-size: var(--jx-fs-sm); +} + +.jx-proof-rail { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 12rem), 1fr)); + margin: 0; + padding: 0; + border-block: 1px solid var(--jx-rule-strong); + list-style: none; +} + +.jx-proof-rail > * { + min-width: 0; + padding: var(--jx-space-5); + border-inline-end: 1px solid var(--jx-rule); +} + +.jx-proof-rail > *:last-child { + border-inline-end: 0; +} + +.jx-proof-rail a { + display: block; + min-block-size: 44px; + color: inherit; + text-decoration: none; +} + +.jx-proof-rail a:hover .jx-proof-rail__value { + color: var(--jx-accent); +} + +.jx-proof-rail__label, +.jx-proof-rail__source { + display: block; + color: var(--jx-ink-muted); + font-family: var(--jx-font-mono); + font-size: var(--jx-fs-micro); + letter-spacing: var(--jx-tracking-eyebrow); + line-height: var(--jx-leading-mono); + text-transform: uppercase; +} + +.jx-proof-rail__value { + display: block; + margin-block: var(--jx-space-2); + color: var(--jx-ink); + font-family: var(--jx-font-display); + font-size: clamp(1.35rem, 2.7vw, 2rem); + font-weight: var(--jx-weight-heading); + letter-spacing: var(--jx-tracking-h3); + line-height: var(--jx-leading-tight); + overflow-wrap: anywhere; + transition: color var(--jx-duration-fast) var(--jx-easing); +} + +.jx-proof-rail__detail { + display: block; + color: var(--jx-ink-2); + font-size: var(--jx-fs-sm); + line-height: 1.5; +} + +.jx-source-tag { + --_source-color: var(--jx-state-maintained); + --_source-tint: var(--jx-state-maintained-tint); + display: inline-flex; + min-block-size: 24px; + align-items: center; + gap: var(--jx-space-2); + padding-inline: var(--jx-space-2); + color: var(--_source-color); + background: var(--_source-tint); + border-radius: var(--jx-radius-pill); + font-family: var(--jx-font-mono); + font-size: var(--jx-fs-micro); + font-weight: var(--jx-weight-heading); + letter-spacing: var(--jx-tracking-mono); + line-height: 1; + white-space: nowrap; +} + +.jx-source-tag::before { + content: "◆"; + font-size: 0.72em; +} + +.jx-source-tag[data-source="measured"] { + --_source-color: var(--jx-state-shipped); + --_source-tint: var(--jx-state-shipped-tint); +} + +.jx-source-tag[data-source="build"] { + --_source-color: var(--jx-state-maintained); + --_source-tint: var(--jx-state-maintained-tint); +} + +.jx-source-tag[data-source="history"] { + --_source-color: var(--jx-state-building); + --_source-tint: var(--jx-state-building-tint); +} + +.jx-source-tag[data-source="external"] { + --_source-color: var(--jx-state-concept); + --_source-tint: var(--jx-state-concept-tint); +} + +.jx-source-tag[data-source="private"] { + --_source-color: var(--jx-state-archived); + --_source-tint: var(--jx-state-archived-tint); +} + +.jx-verification-line { + display: flex; + min-block-size: 44px; + align-items: center; + flex-wrap: wrap; + gap: var(--jx-space-3); + padding-block: var(--jx-space-3); + color: var(--jx-ink-muted); + border-block: 1px solid var(--jx-rule); + font-family: var(--jx-font-mono); + font-size: var(--jx-fs-xs); + line-height: var(--jx-leading-mono); +} + +.jx-verification-line::before { + content: "✓"; + color: var(--jx-state-shipped); + font-weight: var(--jx-weight-heading); +} + +@media (max-width: 720px) { + .jx-case-question { + grid-template-columns: 1fr; + gap: var(--jx-space-3); + } + + .jx-proof-rail { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .jx-proof-rail > *:nth-child(even) { + border-inline-end: 0; + } +} + +@media (max-width: 440px) { + .jx-proof-rail { + grid-template-columns: 1fr; + } + + .jx-proof-rail > * { + border-inline-end: 0; + border-bottom: 1px solid var(--jx-rule); + } + + .jx-proof-rail > *:last-child { + border-bottom: 0; + } +} diff --git a/docs/assets/jx/tokens.css b/docs/assets/jx/tokens.css new file mode 100644 index 0000000..c668a15 --- /dev/null +++ b/docs/assets/jx/tokens.css @@ -0,0 +1,184 @@ +/* Jason DS · Tokens v2.0.0 + * SoT: dotfiles/home-claude/design-system/jx-tokens.css + * 展示站通过 vendor copy 同步:scripts/sync-jx-to-repos.sh + * + * 命名空间硬规则: + * - --jx-* 前缀禁改 + * - 取值在 satellite :root 覆盖(layer 2) + * - 完全自定义元素用 ---* 二级命名空间 + */ + +:root { + color-scheme: light dark; + + /* ── Color · light ── */ + --jx-paper: #f6f6f3; + --jx-surface: #ffffff; + --jx-surface-sunk: #eeede9; + --jx-ink: #16181d; + --jx-ink-2: #41454d; + --jx-ink-muted: #6b7079; + --jx-ink-faint: #9097a1; + --jx-rule: rgba(22,24,29,0.08); + --jx-rule-soft: rgba(22,24,29,0.04); + --jx-rule-strong: var(--jx-ink); + --jx-accent: #2240d6; + --jx-accent-hover: color-mix(in oklab, var(--jx-accent), black 10%); + --jx-accent-tint: color-mix(in oklab, var(--jx-accent), white 88%); + --jx-focus-ring: rgba(34,64,214,0.28); + --jx-link: var(--jx-accent); + --jx-cta-bg: var(--jx-ink); + --jx-cta-fg: var(--jx-paper); + --jx-accent-hub: #2240d6; /* 逃生通道:satellite override --jx-accent 时仍能强制墨蓝 */ + + /* ── Status · 三态 ── */ + --jx-state-learning: #1f6f78; + --jx-state-learning-tint: color-mix(in oklab, var(--jx-state-learning), white 86%); + --jx-state-learning-glyph: "△"; + --jx-state-building: #8a5519; + --jx-state-building-tint: color-mix(in oklab, var(--jx-state-building), white 86%); + --jx-state-building-glyph: "◇"; + --jx-state-shipped: #2c5e3a; + --jx-state-shipped-tint: color-mix(in oklab, var(--jx-state-shipped), white 86%); + --jx-state-shipped-glyph: "■"; + --jx-state-concept: #6950a1; + --jx-state-concept-tint: color-mix(in oklab, var(--jx-state-concept), white 86%); + --jx-state-concept-glyph: "○"; + --jx-state-prototype: var(--jx-state-building); + --jx-state-prototype-tint: var(--jx-state-building-tint); + --jx-state-prototype-glyph: "◇"; + --jx-state-maintained: #285f73; + --jx-state-maintained-tint: color-mix(in oklab, var(--jx-state-maintained), white 86%); + --jx-state-maintained-glyph: "◆"; + --jx-state-archived: #62666e; + --jx-state-archived-tint: color-mix(in oklab, var(--jx-state-archived), white 86%); + --jx-state-archived-glyph: "□"; + + /* ── Typography ── */ + --jx-font-display: "Source Serif 4", "Spectral", "Songti SC", Georgia, serif; + --jx-font-sans-zh: -apple-system, BlinkMacSystemFont, "PingFang SC", "HarmonyOS Sans SC", "Source Han Sans SC", "Noto Sans CJK SC", "Microsoft YaHei", system-ui, sans-serif; + --jx-font-sans-latin: "Inter", "Geist", ui-sans-serif, system-ui, sans-serif; + --jx-font-mono: "JetBrains Mono", "IBM Plex Mono", ui-monospace, "Cascadia Code", "SF Mono", Menlo, monospace; + --jx-font-features-body: "ss01", "cv11", "calt" 1, "liga" 1; + --jx-font-features-mono: "zero", "ss02"; + + --jx-fs-micro: 11px; + --jx-fs-xs: 12px; + --jx-fs-sm: 13px; + --jx-fs-body: 16px; + --jx-fs-lg: 19px; + --jx-fs-h3: 24px; + --jx-fs-h2: 32px; + --jx-fs-h1: 44px; + --jx-fs-display: 64px; + --jx-fs-display-fluid: clamp(2.5rem, 6vw, 4rem); + + --jx-weight-reading: 400; + --jx-weight-ui: 500; + --jx-weight-heading: 600; + + --jx-leading-display: 1.04; + --jx-leading-tight: 1.18; + --jx-leading-body: 1.62; + --jx-leading-mono: 1.45; + + --jx-tracking-display: -0.028em; + --jx-tracking-h1: -0.022em; + --jx-tracking-h2: -0.016em; + --jx-tracking-h3: -0.01em; + --jx-tracking-body: 0; + --jx-tracking-mono: 0.02em; + --jx-tracking-eyebrow: 0.14em; + --jx-tracking-cjk: 0; + + --jx-measure-title: 18ch; + --jx-measure-lede: 32em; + --jx-measure-body: 62ch; + + /* ── Space ── */ + --jx-space-1: 4px; + --jx-space-2: 8px; + --jx-space-3: 12px; + --jx-space-4: 16px; + --jx-space-5: 24px; + --jx-space-6: 32px; + --jx-space-7: 48px; + --jx-space-8: 72px; + --jx-section-y: clamp(48px, 7.5vw, 80px); + --jx-gutter: clamp(16px, 3vw, 24px); + --jx-container: 1160px; + --jx-container-narrow: 760px; + --jx-header-height: 64px; + + /* ── Radius ── */ + --jx-radius-1: 2px; + --jx-radius-2: 4px; + --jx-radius-3: 6px; + --jx-radius-4: 10px; + --jx-radius-5: 14px; + --jx-radius-pill: 9999px; + + /* ── Shadow ── */ + --jx-shadow-hairline: 0 0 0 1px var(--jx-rule); + --jx-shadow-hairline-strong: 0 0 0 1px var(--jx-rule-strong); + --jx-shadow-stack: + 0 0 0 1px rgba(22,24,29,0.06), + 0 1px 1px rgba(22,24,29,0.03), + 0 6px 20px -10px rgba(22,24,29,0.10), + inset 0 0 0 1px rgba(255,255,255,0.55); + --jx-shadow-stack-dark: + 0 0 0 1px rgba(255,255,255,0.06), + 0 1px 1px rgba(0,0,0,0.4), + 0 8px 28px -12px rgba(0,0,0,0.55), + inset 0 0 0 1px rgba(255,255,255,0.04); + --jx-shadow-focus: 0 0 0 3px var(--jx-focus-ring); + --jx-shadow-press: inset 0 1px 0 rgba(22,24,29,0.06); + + /* ── Motion ── */ + --jx-easing: cubic-bezier(0.2, 0.8, 0.2, 1); + --jx-easing-press: cubic-bezier(0.4, 0, 0.6, 1); + --jx-duration-fast: 120ms; + --jx-duration-base: 200ms; + --jx-duration-slow: 320ms; +} + +/* ── Color · dark mirror ── */ +@media (prefers-color-scheme: dark) { + :root { + --jx-paper: #0e1014; + --jx-surface: #161922; + --jx-surface-sunk: #0a0c10; + --jx-ink: #ebecef; + --jx-ink-2: #b0b4bd; + --jx-ink-muted: #7c818c; + --jx-ink-faint: #5a5f69; + --jx-rule: rgba(255,255,255,0.07); + --jx-rule-soft: rgba(255,255,255,0.035); + --jx-rule-strong: var(--jx-ink); + --jx-accent: #6c8aff; + --jx-accent-hover: color-mix(in oklab, var(--jx-accent), white 10%); + --jx-accent-tint: color-mix(in oklab, var(--jx-accent), black 70%); + --jx-focus-ring: rgba(108,138,255,0.36); + --jx-shadow-stack: var(--jx-shadow-stack-dark); + --jx-state-learning: #69b6bf; + --jx-state-learning-tint: color-mix(in oklab, var(--jx-state-learning), black 76%); + --jx-state-building: #d49a57; + --jx-state-building-tint: color-mix(in oklab, var(--jx-state-building), black 76%); + --jx-state-shipped: #77b88b; + --jx-state-shipped-tint: color-mix(in oklab, var(--jx-state-shipped), black 76%); + --jx-state-concept: #b9a1eb; + --jx-state-concept-tint: color-mix(in oklab, var(--jx-state-concept), black 76%); + --jx-state-maintained: #79b4ca; + --jx-state-maintained-tint: color-mix(in oklab, var(--jx-state-maintained), black 76%); + --jx-state-archived: #a4a8b0; + --jx-state-archived-tint: color-mix(in oklab, var(--jx-state-archived), black 76%); + } +} + +/* ── Print mode ── */ +@media print { + :root { + --jx-paper: #ffffff; + --jx-ink: #000000; + } +} diff --git a/docs/assets/og-uikit.png b/docs/assets/og-uikit.png new file mode 100644 index 0000000..d0668c7 Binary files /dev/null and b/docs/assets/og-uikit.png differ diff --git a/docs/assets/style.css b/docs/assets/style.css new file mode 100644 index 0000000..def6750 --- /dev/null +++ b/docs/assets/style.css @@ -0,0 +1,541 @@ +/* UIKitLifecycleDemo public showcase · satellite layer over Jason DS v2 */ + +:root { + --uikit-blue: #0a66d6; + --uikit-blue-soft: #e8f1ff; + --uikit-navy: #101827; + --uikit-code: #0d1420; + --uikit-green: #4bc173; + --jx-paper: #f4f7fb; + --jx-surface: #ffffff; + --jx-surface-sunk: #e9eef5; + --jx-ink: #111827; + --jx-ink-2: #344054; + --jx-ink-muted: #667085; + --jx-ink-faint: #98a2b3; + --jx-rule: rgba(17, 24, 39, 0.10); + --jx-rule-soft: rgba(17, 24, 39, 0.055); + --jx-rule-strong: rgba(17, 24, 39, 0.72); + --jx-accent: var(--uikit-blue); + --jx-accent-hover: #0757b8; + --jx-accent-tint: var(--uikit-blue-soft); + --jx-link: var(--uikit-blue); + --jx-focus-ring: rgba(10, 102, 214, 0.28); +} + +:root[data-theme="dark"] { + --uikit-blue: #64a8ff; + --uikit-blue-soft: #172d4d; + --uikit-navy: #edf4ff; + --uikit-code: #090d14; + --uikit-green: #65d58a; + --jx-paper: #0b1018; + --jx-surface: #121a26; + --jx-surface-sunk: #0f1722; + --jx-ink: #eef4fc; + --jx-ink-2: #c3cfdd; + --jx-ink-muted: #8795a8; + --jx-ink-faint: #667386; + --jx-rule: rgba(255, 255, 255, 0.10); + --jx-rule-soft: rgba(255, 255, 255, 0.055); + --jx-rule-strong: rgba(255, 255, 255, 0.74); + --jx-accent: var(--uikit-blue); + --jx-accent-hover: #8abfff; + --jx-accent-tint: var(--uikit-blue-soft); + --jx-link: var(--uikit-blue); + --jx-focus-ring: rgba(100, 168, 255, 0.34); + --jx-state-building: #f0ad63; + --jx-state-building-tint: #392715; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme]) { + --uikit-blue: #64a8ff; + --uikit-blue-soft: #172d4d; + --uikit-navy: #edf4ff; + --uikit-code: #090d14; + --uikit-green: #65d58a; + --jx-paper: #0b1018; + --jx-surface: #121a26; + --jx-surface-sunk: #0f1722; + --jx-ink: #eef4fc; + --jx-ink-2: #c3cfdd; + --jx-ink-muted: #8795a8; + --jx-ink-faint: #667386; + --jx-rule: rgba(255, 255, 255, 0.10); + --jx-rule-soft: rgba(255, 255, 255, 0.055); + --jx-rule-strong: rgba(255, 255, 255, 0.74); + --jx-accent: var(--uikit-blue); + --jx-accent-hover: #8abfff; + --jx-accent-tint: var(--uikit-blue-soft); + --jx-link: var(--uikit-blue); + --jx-focus-ring: rgba(100, 168, 255, 0.34); + --jx-state-building: #f0ad63; + --jx-state-building-tint: #392715; + } +} + +html { scroll-padding-top: calc(var(--jx-header-height) + var(--jx-space-4)); } +body { overflow-wrap: anywhere; } +main { display: block; } + +.jx-site-header { min-height: var(--jx-header-height); } +.jx-site-header__identity strong { font-size: 14px; } +.jx-site-header__identity span { color: var(--jx-accent); } + +.theme-button { + min-width: 52px; + min-height: 40px; + padding: var(--jx-space-2) var(--jx-space-3); + color: var(--jx-ink-2); + background: transparent; + border: 1px solid var(--jx-rule); + border-radius: var(--jx-radius-pill); + cursor: pointer; + font-family: var(--jx-font-mono); + font-size: var(--jx-fs-xs); + transition: color var(--jx-duration-fast) var(--jx-easing), + border-color var(--jx-duration-fast) var(--jx-easing), + background var(--jx-duration-fast) var(--jx-easing); +} +.theme-button:hover { + color: var(--jx-ink); + background: var(--jx-surface-sunk); + border-color: var(--jx-rule-strong); +} + +.case-hero { + display: grid; + grid-template-columns: minmax(0, 1.12fr) minmax(340px, .88fr); + gap: clamp(var(--jx-space-7), 6vw, 88px); + align-items: center; + padding-block: clamp(56px, 8vw, 104px) var(--jx-section-y); + border-bottom: 1px solid var(--jx-rule-strong); +} +.case-hero__copy { min-width: 0; } +.case-hero__meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--jx-space-3) var(--jx-space-5); + margin-bottom: var(--jx-space-5); +} +.case-kicker { + margin: 0 0 var(--jx-space-3); + color: var(--jx-accent); + font-family: var(--jx-font-mono); + font-size: var(--jx-fs-xs); + font-weight: var(--jx-weight-heading); + letter-spacing: var(--jx-tracking-eyebrow); + text-transform: uppercase; +} +.case-hero h1 { + max-width: 14ch; + margin: 0 0 var(--jx-space-5); + font-size: clamp(2.65rem, 5.7vw, 4.9rem); + line-height: .99; + letter-spacing: -.045em; +} +.case-hero__lede { + max-width: 36em; + margin: 0 0 var(--jx-space-3); + color: var(--jx-ink-2); + font-size: clamp(1.02rem, 1.6vw, 1.2rem); +} +.case-hero__en { + max-width: 40rem; + margin: 0 0 var(--jx-space-6); + color: var(--jx-ink-muted); + font-family: var(--jx-font-sans-latin); + font-size: var(--jx-fs-sm); +} +.case-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--jx-space-3); +} + +.runtime-card { + min-width: 0; + overflow: hidden; + color: #d8e2ef; + background: var(--uikit-code); + border: 1px solid rgba(255, 255, 255, .12); + border-radius: 22px; + box-shadow: 16px 18px 0 color-mix(in srgb, var(--jx-accent) 14%, transparent); + transform: rotate(1deg); +} +.runtime-card__bar { + min-height: 48px; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--jx-space-3); + padding: 0 var(--jx-space-5); + color: #8593a6; + border-bottom: 1px solid rgba(255, 255, 255, .10); + font-family: var(--jx-font-mono); + font-size: 10px; + font-weight: 700; + letter-spacing: .12em; +} +.runtime-live { + display: inline-flex; + align-items: center; + gap: 7px; + color: var(--uikit-green); +} +.runtime-live::before { + width: 7px; + height: 7px; + content: ""; + background: currentColor; + border-radius: 50%; +} +.runtime-card__body { + display: grid; + grid-template-columns: 128px minmax(0, 1fr); + gap: var(--jx-space-4); + padding: var(--jx-space-4); +} +.runtime-device { + align-self: start; + margin: 0; + overflow: hidden; + background: #f2f2f7; + border: 4px solid #273140; + border-radius: 24px; +} +.runtime-device img { + width: 100%; + height: auto; + display: block; +} +.runtime-device figcaption { + padding: 7px 5px; + color: #5f7188; + background: #0a1019; + font-family: var(--jx-font-mono); + font-size: 8px; + text-align: center; +} +.runtime-sequence { min-width: 0; } +.runtime-sequence__title { + margin: 0 0 var(--jx-space-3); + color: #d8e2ef; + font-size: var(--jx-fs-xs); + font-weight: 650; +} +.runtime-run { + width: 100%; + min-height: 44px; + margin-bottom: var(--jx-space-3); + padding: var(--jx-space-2) var(--jx-space-3); + color: #07111e; + background: #79b5ff; + border: 0; + border-radius: var(--jx-radius-pill); + cursor: pointer; + font-family: var(--jx-font-mono); + font-size: var(--jx-fs-xs); + font-weight: 700; + letter-spacing: var(--jx-tracking-mono); +} +.runtime-run:hover { background: #9bc8ff; } +.runtime-run:disabled { cursor: wait; opacity: .7; } +.runtime-trace { + margin: 0; + padding: 0; + list-style: none; +} +.runtime-trace li { + min-height: 58px; + display: grid; + grid-template-columns: 1fr; + gap: 2px; + align-content: center; + padding: var(--jx-space-2) var(--jx-space-3); + border-bottom: 1px solid rgba(255, 255, 255, .07); + border-inline-start: 2px solid transparent; + transition: opacity 180ms ease, background 180ms ease, border-color 180ms ease; +} +.runtime-trace li:last-child { border-bottom: 0; } +.runtime-card.is-enhanced .runtime-trace li { opacity: 1; } +.runtime-card.is-enhanced .runtime-trace li.is-active, +.runtime-card.is-enhanced .runtime-trace li.is-complete { + opacity: 1; + background: rgba(100, 168, 255, .09); + border-inline-start-color: #64a8ff; +} +.runtime-card.is-enhanced .runtime-trace li.is-active { + background: rgba(75, 193, 115, .12); + border-inline-start-color: var(--uikit-green); +} +.runtime-trace span { + color: #78adf0; + font-family: var(--jx-font-mono); + font-size: 9px; + letter-spacing: .04em; +} +.runtime-trace code { + color: #f3f7fc; + font-size: 11px; + font-weight: 650; +} +.runtime-trace small { + color: #8e9db0; + font-family: var(--jx-font-sans-latin); + font-size: 9px; +} +.runtime-output, +.runtime-noscript { + display: block; + margin: var(--jx-space-3) 0 0; + color: #8293a8; + font-family: var(--jx-font-mono); + font-size: 9px; +} +.runtime-noscript { color: #f0ad63; } +.runtime-test { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 2px var(--jx-space-3); + padding: var(--jx-space-3) var(--jx-space-5); + color: #91a0b4; + border-top: 1px solid rgba(255, 255, 255, .10); + text-decoration: none; +} +.runtime-test strong { + grid-row: 1 / 3; + align-self: center; + color: var(--uikit-green); + font-family: var(--jx-font-mono); + font-size: var(--jx-fs-xs); +} +.runtime-test span, +.runtime-test small { font-size: 9px; } +.runtime-test:hover span { color: #d8e2ef; } + +.case-proof { margin-top: var(--jx-section-y); } +.case-proof h2 { + margin: var(--jx-space-3) 0 var(--jx-space-5); + font-size: clamp(1.75rem, 3vw, 2.6rem); +} +.case-proof .jx-proof__summary { margin: 0 0 var(--jx-space-3); } +.case-proof .jx-proof__summary-en { margin: 0; } +.case-proof .jx-proof__metric strong { color: var(--jx-accent); } + +.jx-case-section { scroll-margin-top: calc(var(--jx-header-height) + var(--jx-space-4)); } +.jx-case-section__body > p:first-child { margin-top: 0; } + +.system-flow { + max-width: none; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--jx-space-4); +} +.system-flow article { + min-height: 238px; + display: flex; + flex-direction: column; + padding: var(--jx-space-5); + background: var(--jx-surface); + border-radius: var(--jx-radius-4); + box-shadow: var(--jx-shadow-hairline); +} +.system-flow article > span { + color: var(--jx-accent); + font-family: var(--jx-font-mono); + font-size: var(--jx-fs-micro); + font-weight: var(--jx-weight-heading); + letter-spacing: var(--jx-tracking-eyebrow); + text-transform: uppercase; +} +.system-flow h3 { + margin: auto 0 var(--jx-space-3); + font-size: 1.28rem; +} +.system-flow p { + margin: 0; + color: var(--jx-ink-muted); + font-size: var(--jx-fs-sm); +} + +.observation-grid { + max-width: none; + display: grid; + grid-template-columns: minmax(230px, .68fr) minmax(0, 1.32fr); + gap: clamp(var(--jx-space-6), 6vw, var(--jx-space-8)); + align-items: center; +} +.device-shot { margin: 0; } +.device-shot__screen { + max-width: 330px; + margin-inline: auto; + overflow: hidden; + background: #f2f2f7; + border: 8px solid #111318; + border-radius: 46px; + box-shadow: var(--jx-shadow-stack); +} +.device-shot img { + width: 100%; + height: auto; +} +.device-shot figcaption { + max-width: 38ch; + margin: var(--jx-space-4) auto 0; + color: var(--jx-ink-muted); + font-size: var(--jx-fs-xs); + text-align: center; +} +.mechanism-map { + overflow: hidden; + background: var(--jx-surface); + border-radius: var(--jx-radius-4); + box-shadow: var(--jx-shadow-stack); +} +.mechanism-map__label { + margin: 0; + padding: var(--jx-space-4) var(--jx-space-5); + color: var(--jx-ink-muted); + border-bottom: 1px solid var(--jx-rule); + font-family: var(--jx-font-mono); + font-size: var(--jx-fs-micro); + font-weight: var(--jx-weight-heading); + letter-spacing: var(--jx-tracking-eyebrow); +} +.mechanism-map ol { + margin: 0; + padding: 0; + list-style: none; +} +.mechanism-map li { + display: grid; + grid-template-columns: 126px minmax(0, .9fr) minmax(0, 1.1fr); + gap: var(--jx-space-4); + align-items: baseline; + padding: var(--jx-space-5); + border-bottom: 1px solid var(--jx-rule); +} +.mechanism-map li:last-child { border-bottom: 0; } +.mechanism-map span { + color: var(--jx-accent); + font-family: var(--jx-font-mono); + font-size: var(--jx-fs-xs); +} +.mechanism-map strong { color: var(--jx-ink); } +.mechanism-map small { color: var(--jx-ink-muted); } + +.evidence-list a > span { + min-height: 0; + display: grid; + justify-content: initial; + gap: var(--jx-space-1); + padding: 0; + background: transparent; + border-radius: 0; + box-shadow: none; +} +.evidence-list strong { color: var(--jx-ink); } +.evidence-list small { + color: var(--jx-ink-muted); + font-size: var(--jx-fs-xs); +} + +.test-proof { + display: grid; + gap: var(--jx-space-2); + margin: var(--jx-space-5) 0; + padding: var(--jx-space-5); + color: var(--jx-ink-2); + background: var(--jx-accent-tint); + border: 1px solid color-mix(in srgb, var(--jx-accent) 28%, transparent); + border-radius: var(--jx-radius-4); +} +.test-proof > span { + color: var(--jx-accent); + font-family: var(--jx-font-mono); + font-size: var(--jx-fs-micro); + font-weight: var(--jx-weight-heading); + letter-spacing: var(--jx-tracking-eyebrow); +} +.test-proof > strong { font-size: var(--jx-fs-lg); } +.test-proof p { margin: 0; color: var(--jx-ink-muted); font-size: var(--jx-fs-sm); } + +.run-grid { + max-width: none; + display: grid; + grid-template-columns: minmax(0, .78fr) minmax(0, 1.22fr); + gap: var(--jx-space-6); + align-items: start; +} +.run-grid h3 { margin-top: 0; } +.run-grid p { color: var(--jx-ink-muted); } +.run-grid pre { margin: 0; } +.jx-case-limit p { margin: 0; } + +.jx-footer { max-width: var(--jx-container); } + +@media (max-width: 940px) { + .case-hero { + grid-template-columns: 1fr; + } + .case-hero h1 { max-width: 16ch; } + .runtime-card { + width: min(100%, 650px); + transform: none; + } + .system-flow { grid-template-columns: 1fr; } + .system-flow article { min-height: 170px; } + .observation-grid { grid-template-columns: 1fr; } +} + +@media (max-width: 720px) { + .case-hero { + padding-block: var(--jx-space-7) var(--jx-section-y); + } + .case-hero h1 { + max-width: 15ch; + font-size: clamp(2.45rem, 12vw, 3.7rem); + } + .case-hero__meta .jx-eyebrow { display: none; } + .case-actions { + display: grid; + grid-template-columns: 1fr; + } + .case-actions > * { width: 100%; } + .runtime-card { box-shadow: 8px 9px 0 color-mix(in srgb, var(--jx-accent) 14%, transparent); } + .runtime-card__body { grid-template-columns: 112px minmax(0, 1fr); } + .mechanism-map li { + grid-template-columns: 1fr; + gap: var(--jx-space-2); + } + .run-grid { grid-template-columns: 1fr; } + .jx-site-nav__menu nav .theme-button { + width: 100%; + min-height: 44px; + justify-self: stretch; + } +} + +@media (max-width: 420px) { + .jx-site-header__identity span { display: none; } + .runtime-card__bar { padding-inline: var(--jx-space-4); } + .runtime-card__body { grid-template-columns: 1fr; } + .runtime-device { width: 104px; } + .runtime-test { grid-template-columns: 1fr; } + .runtime-test strong { grid-row: auto; } + .device-shot__screen { border-width: 6px; border-radius: 38px; } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + scroll-behavior: auto !important; + animation-duration: .01ms !important; + animation-iteration-count: 1 !important; + transition-duration: .01ms !important; + } + .runtime-card { transform: none; } +} diff --git a/docs/assets/uikit-list.png b/docs/assets/uikit-list.png new file mode 100644 index 0000000..59a7c48 Binary files /dev/null and b/docs/assets/uikit-list.png differ diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000..ca860d0 --- /dev/null +++ b/docs/index.html @@ -0,0 +1,276 @@ + + + + + + + + + + UIKit Lifecycle Lab · Jason Xun + + + + + + + + + + + + + + + + + + + + + + + + + + + 跳到主要内容 + +
+ +
+ +
+
+
+
+ Building + Jason Xun product system +
+

UIKit · Guided Learning · Observable Runtime

+

把 UIKit 的隐式调用,变成一条可以观察的证据链

+

这不是提醒事项业务 App,而是一台 UIKit 显微镜:学习者先预测,再操作真实界面,通过结构化日志与 Call Stack 分清生命周期、delegate、target-action 和 closure。

+

A code-first UIKit learning lab that makes framework-driven behavior observable through guided experiments, structured logs, call stacks, and UI tests.

+ +
+ +
+
+ SAVE TRACE / REAL UI + SOURCE PATH + PROGRESSIVE LAB +
+
+
+ iPhone 17 Pro Simulator 中用于 Save 实验的提醒列表 +
REAL SIMULATOR / iPhone 17 Pro
+
+
+

一次编辑与 Save,穿过四种调用机制。

+ +
    +
  1. 01 · delegatedidSelectItemAt列表把选择回调给业务控制器
  2. +
  3. 02 · lifecycleviewDidLoadUIKit 请求编辑页完成首次加载
  4. +
  5. 03 · target-actionsaveReminderSave 按钮触发注册的 action
  6. +
  7. 04 · closureonSave → snapshot业务 closure 回传并刷新列表
  8. +
+ 静态顺序已完整展示。 + +
+
+ + 2 / 2 UI TEST PASS + 2026-07-11 · local iPhone 17 Pro Simulator + 9.456s edit/save · 27.483s Logs/Guide · source ↗ + +
+
+ +
+
+

Problem / 问题

+

UIKit 最难的部分,往往不是写代码,而是看不见“谁调用了谁”

+

只读方法名,很容易把框架生命周期、delegate 回调、按钮 action 和业务 closure 混在一起。这个系统把一次学习拆成 Predict → Run → Observe → Verify → Recap,并把证据留在 App 内 Logs、Xcode Console、Call Stack 和 UI Test 中。

+

The design problem is observability: learners need to distinguish framework callbacks from application-controlled data flow using evidence they can reproduce.

+
+
9个源码定义的引导步骤
+
14类结构化日志
+
2条 XCTest UI 流程
+
+
+
+
Role / Jason
定义学习目标、选择观察机制、编排验证路线并验收真实运行结果
+
AI collaboration
Codex / AI 辅助搭建 Demo、实现日志与文档、迭代自动化测试
+
Stack
Swift · UIKit · UICollectionView · XCTest · Xcode
+
Constraint
纯代码、无 SwiftUI、无第三方业务库、无网络与持久化
+
+
+ +
+
+

System / 系统

+
+
+ 01 · Predict +

先问“接下来会发生什么”

+

GuidedStep 把操作、观察位置、预测问题、预期日志和胜利条件放在同一张训练卡里。

+
+
+ 02 · Observe +

让同一条事件同时可读、可筛选

+

DemoLogStore 保存结构化事件并继续输出 Console;Log Panel 支持分类、搜索、关键事件与暂停滚动。

+
+
+ 03 · Verify +

用 Call Stack 与 UI Test 交叉验证

+

断点回答“谁触发”,UI Test 验证编辑回传与 Guide / Logs 入口,避免把文档里的预期当成运行事实。

+
+
+
+
+ +
+
+

Interface / 观察面

+
+
+
+ iPhone 17 Pro 模拟器中的 UIKitLifecycleDemo 提醒列表首页 +
+
真实 Simulator 构建截图,不是网页 mockup。业务外壳保持简单,把注意力留给 Learn、Logs 与调用链。
+
+
+

ONE FLOW · FOUR MECHANISMS

+
    +
  1. UIKit lifecycleviewDidLoad → viewWillAppear框架根据页面状态触发
  2. +
  3. DelegatedidSelectItemAtcollection view 把点击回调给业务代码
  4. +
  5. Target-actionsaveReminder按钮事件调用 action
  6. +
  7. ClosureonSave → snapshot业务代码主动回传编辑结果
  8. +
+
+
+
+
+ +
+
+

Evidence / 证据

+
+

展示页只陈述能够从当前仓库或本地构建直接复核的事实。

+
+ LAST VERIFIED · 2026-07-11 + 2 / 2 UI Test passed +

本地 iPhone 17 Pro Simulator:testEditFirstReminderTitle 9.456s;testOpenLogsAndGuide 27.483s。来源是当前 XCTest 文件与本次本地运行,不代表完整回归覆盖。

+
+ +
+
+
+ +
+
+

Run / 复现

+
+
+

用真实 Xcode 与 Simulator 观察

+

网页负责解释系统,运行证据仍来自 UIKit App。仓库默认使用 iPhone 17 Pro Simulator,也可通过 Make 参数切换设备。

+
+
git clone https://github.com/estelledc/UIKitLifecycleDemo.git
+cd UIKitLifecycleDemo
+make build
+make run
+make test-ui
+
+
+
+ +
+
+

Limitations / 边界

+
+

这是一套学习实验室,不是生产级提醒事项 App。它不包含网络、持久化、EventKit 或真实业务状态;Guide 描述预期观察,不会伪造日志;两条 UI Test 只覆盖核心编辑流程和 Guide / Logs 入口,尚未覆盖所有高级实验。项目也不声称仅凭阅读或运行 Demo 就能掌握 UIKit。

+
+
+
+
+ + + + + + diff --git a/docs/robots.txt b/docs/robots.txt new file mode 100644 index 0000000..ca89747 --- /dev/null +++ b/docs/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://estelledc.github.io/UIKitLifecycleDemo/sitemap.xml diff --git a/docs/sitemap.xml b/docs/sitemap.xml new file mode 100644 index 0000000..7662950 --- /dev/null +++ b/docs/sitemap.xml @@ -0,0 +1,7 @@ + + + + https://estelledc.github.io/UIKitLifecycleDemo/ + 2026-07-11 + + diff --git a/scripts/audit-showcase.py b/scripts/audit-showcase.py new file mode 100644 index 0000000..63b0e19 --- /dev/null +++ b/scripts/audit-showcase.py @@ -0,0 +1,251 @@ +"""Audit the GitHub Pages case, its links, metadata, and evidence claims.""" + +from __future__ import annotations + +import json +import re +import struct +from html.parser import HTMLParser +from pathlib import Path +from urllib.parse import unquote, urlsplit + + +ROOT = Path(__file__).resolve().parents[1] +DOCS = ROOT / "docs" +INDEX = DOCS / "index.html" +CANONICAL = "https://estelledc.github.io/UIKitLifecycleDemo/" + + +class ShowcaseParser(HTMLParser): + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.ids: set[str] = set() + self.refs: list[str] = [] + self.h1_count = 0 + self.images: list[dict[str, str]] = [] + self.json_ld: list[str] = [] + self._json_chunks: list[str] | None = None + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + values = {key: value or "" for key, value in attrs} + if values.get("id"): + self.ids.add(values["id"]) + if tag == "h1": + self.h1_count += 1 + if tag == "img": + self.images.append(values) + for attribute in ("href", "src"): + if values.get(attribute): + self.refs.append(values[attribute]) + if tag == "script" and values.get("type") == "application/ld+json": + self._json_chunks = [] + + def handle_endtag(self, tag: str) -> None: + if tag == "script" and self._json_chunks is not None: + self.json_ld.append("".join(self._json_chunks)) + self._json_chunks = None + + def handle_data(self, data: str) -> None: + if self._json_chunks is not None: + self._json_chunks.append(data) + + +def png_dimensions(path: Path) -> tuple[int, int]: + data = path.read_bytes()[:24] + if len(data) != 24 or data[:8] != b"\x89PNG\r\n\x1a\n" or data[12:16] != b"IHDR": + raise ValueError(f"not a valid PNG: {path}") + return struct.unpack(">II", data[16:24]) + + +def source_facts() -> dict[str, int]: + guided = (ROOT / "UIKitLifecycleDemo" / "GuidedExperiment.swift").read_text(encoding="utf-8") + logs = (ROOT / "UIKitLifecycleDemo" / "DemoLogStore.swift").read_text(encoding="utf-8") + ui_tests = ( + ROOT / "UIKitLifecycleDemoUITests" / "UIKitLifecycleDemoUITests.swift" + ).read_text(encoding="utf-8") + category_block = logs.split("enum DemoLogCategory", 1)[1].split("var title", 1)[0] + return { + "9": len(re.findall(r"\bGuidedStep\(", guided)), + "14": len(re.findall(r"^\s*case\s+\w+", category_block, flags=re.MULTILINE)), + "2": len(re.findall(r"^\s*func\s+test\w+\(", ui_tests, flags=re.MULTILINE)), + } + + +def local_target(raw: str) -> Path | None: + parsed = urlsplit(raw) + if parsed.scheme or parsed.netloc or not parsed.path or parsed.path.startswith("/"): + return None + return (DOCS / unquote(parsed.path)).resolve() + + +def audit() -> list[str]: + errors: list[str] = [] + if not INDEX.is_file(): + return ["missing docs/index.html"] + + html = INDEX.read_text(encoding="utf-8") + parser = ShowcaseParser() + parser.feed(html) + + required_markers = [ + "Problem / 问题", + "Role / Jason", + "System / 系统", + "Evidence / 证据", + "Limitations / 边界", + "Codex / AI 辅助搭建 Demo", + "不声称仅凭阅读或运行 Demo 就能掌握 UIKit", + f'', + '', + '', + 'class="jx-skip-link"', + 'data-theme-toggle aria-pressed="false"', + 'data-save-lab', + 'data-run-save hidden', + 'data-mechanism="delegate"', + 'data-mechanism="lifecycle"', + 'data-mechanism="target-action"', + 'data-mechanism="closure"', + "2 / 2 UI TEST PASS", + "LAST VERIFIED · 2026-07-11", + "9.456s edit/save · 27.483s Logs/Guide", + "JavaScript 未启用", + ] + for marker in required_markers: + if marker not in html: + errors.append(f"index.html: missing marker: {marker}") + + for destination in ( + "https://estelledc.github.io/", + "https://estelledc.github.io/about/", + "https://estelledc.github.io/resume/", + "https://github.com/estelledc/UIKitLifecycleDemo", + ): + if destination not in html: + errors.append(f"index.html: missing portfolio destination: {destination}") + + if parser.h1_count != 1: + errors.append(f"index.html: expected exactly one h1, found {parser.h1_count}") + if len(parser.json_ld) != 1: + errors.append(f"index.html: expected one JSON-LD block, found {len(parser.json_ld)}") + else: + try: + structured = json.loads(parser.json_ld[0]) + if structured.get("@context") != "https://schema.org": + errors.append("index.html: unexpected JSON-LD context") + graph = structured.get("@graph", []) + person = next((item for item in graph if item.get("@type") == "Person"), {}) + if person.get("@id") != "https://estelledc.github.io/#person" or person.get("name") != "Jason Xun": + errors.append("index.html: shared Jason Xun #person identity is missing") + except json.JSONDecodeError as error: + errors.append(f"index.html: invalid JSON-LD: {error}") + + for image in parser.images: + if not image.get("alt", "").strip(): + errors.append(f"index.html: image missing alt: {image.get('src', '')}") + if not image.get("width") or not image.get("height"): + errors.append(f"index.html: image missing width/height: {image.get('src', '')}") + if image.get("loading") not in {"eager", "lazy"}: + errors.append(f"index.html: image missing loading strategy: {image.get('src', '')}") + if image.get("decoding") != "async": + errors.append(f"index.html: image missing async decoding: {image.get('src', '')}") + + for raw in parser.refs: + if raw.startswith("#"): + if raw[1:] not in parser.ids: + errors.append(f"index.html: broken fragment: {raw}") + continue + target = local_target(raw) + if target is not None and not target.is_file(): + errors.append(f"index.html: broken local reference: {raw}") + + for claimed, actual in source_facts().items(): + if int(claimed) != actual: + errors.append(f"source fact drift: expected {claimed}, found {actual}") + if f"{claimed}" not in html: + errors.append(f"index.html: evidence value {claimed} is not rendered") + + image_contract = { + DOCS / "assets" / "og-uikit.png": (1200, 630), + DOCS / "assets" / "favicon.png": (64, 64), + DOCS / "assets" / "uikit-list.png": (1206, 2622), + } + for path, expected in image_contract.items(): + if not path.is_file(): + errors.append(f"missing image asset: {path.relative_to(ROOT)}") + continue + try: + actual = png_dimensions(path) + if actual != expected: + errors.append(f"{path.relative_to(ROOT)}: expected {expected}, found {actual}") + except ValueError as error: + errors.append(str(error)) + + version = (DOCS / "assets" / "jx" / "VERSION").read_text(encoding="utf-8").strip() + if version != "2.1.0": + errors.append(f"Jason DS version is {version}, expected 2.1.0") + + css = (DOCS / "assets" / "style.css").read_text(encoding="utf-8") + if "prefers-reduced-motion: reduce" not in css: + errors.append("style.css: missing reduced-motion support") + if "@media (max-width: 420px)" not in css or ".runtime-card__body { grid-template-columns: 1fr; }" not in css: + errors.append("style.css: missing 320px Save-trace layout contract") + if "focus-visible" not in (DOCS / "assets" / "jx" / "base.css").read_text(encoding="utf-8"): + errors.append("Jason DS base: missing focus-visible styles") + + app_js = (DOCS / "assets" / "app.js").read_text(encoding="utf-8") + for marker in ( + "activateSaveStep", + "finishSaveTrace", + "prefers-reduced-motion: reduce", + 'setAttribute("aria-busy", "true")', + ): + if marker not in app_js: + errors.append(f"app.js: progressive Save trace marker missing: {marker}") + + hero_image = re.search(r']+src="assets/uikit-list.png"[^>]*>', html) + if not hero_image: + errors.append("index.html: real Simulator image is missing") + else: + image_tag = hero_image.group(0) + for marker in ('loading="eager"', 'decoding="async"', 'fetchpriority="high"'): + if marker not in image_tag: + errors.append(f"index.html: hero Simulator image missing {marker}") + + published_text = "\n".join( + path.read_text(encoding="utf-8", errors="ignore") + for path in DOCS.rglob("*") + if path.is_file() and path.suffix.lower() in {".html", ".css", ".js", ".md", ".txt", ".xml"} + ) + for private_marker in ("/Users/", "bytedance", "intern-journal"): + if private_marker in published_text: + errors.append(f"published docs contain private marker: {private_marker}") + + if not (DOCS / ".nojekyll").is_file(): + errors.append("missing docs/.nojekyll") + if not (DOCS / "404.html").is_file(): + errors.append("missing docs/404.html") + if CANONICAL not in (DOCS / "sitemap.xml").read_text(encoding="utf-8"): + errors.append("sitemap.xml: canonical URL missing") + if "sitemap.xml" not in (DOCS / "robots.txt").read_text(encoding="utf-8"): + errors.append("robots.txt: sitemap declaration missing") + + return errors + + +def main() -> int: + errors = audit() + if errors: + for error in errors: + print(f"ERROR: {error}") + return 1 + facts = source_facts() + print( + "OK: public case, metadata, local links, assets, and source evidence verified " + f"({facts['9']} guided steps, {facts['14']} log categories, {facts['2']} UI tests)" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/generate-showcase-assets.py b/scripts/generate-showcase-assets.py new file mode 100644 index 0000000..f700c6b --- /dev/null +++ b/scripts/generate-showcase-assets.py @@ -0,0 +1,104 @@ +"""Generate deterministic social and favicon assets for the public case page.""" + +from __future__ import annotations + +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFont + + +ROOT = Path(__file__).resolve().parents[1] +ASSETS = ROOT / "docs" / "assets" +SCREENSHOT = ASSETS / "uikit-list.png" + +SANS = [ + "/System/Library/Fonts/STHeiti Medium.ttc", + "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", +] +SERIF = [ + "/System/Library/Fonts/Supplemental/Songti.ttc", + "/usr/share/fonts/opentype/noto/NotoSerifCJK-Regular.ttc", + "/usr/share/fonts/truetype/noto/NotoSerifCJK-Regular.ttc", +] + + +def font(candidates: list[str], size: int) -> ImageFont.FreeTypeFont: + for candidate in candidates: + path = Path(candidate) + if path.exists(): + return ImageFont.truetype(str(path), size=size) + raise RuntimeError("No CJK font found. Install Noto CJK or run this script on macOS.") + + +def rounded_mask(size: tuple[int, int], radius: int) -> Image.Image: + mask = Image.new("L", size, 0) + ImageDraw.Draw(mask).rounded_rectangle((0, 0, size[0] - 1, size[1] - 1), radius=radius, fill=255) + return mask + + +def generate_favicon() -> None: + icon = Image.new("RGB", (64, 64), "#0a66d6") + draw = ImageDraw.Draw(icon) + draw.rounded_rectangle((2, 2, 61, 61), radius=15, outline="#0757b8", width=2) + draw.text((15, 8), "U", font=font(SANS, 40), fill="#ffffff") + icon.save(ASSETS / "favicon.png", "PNG", optimize=True) + + +def generate_social_card() -> None: + width, height = 1200, 630 + image = Image.new("RGB", (width, height), "#f4f7fb") + draw = ImageDraw.Draw(image) + ink = "#111827" + muted = "#667085" + blue = "#0a66d6" + rule = "#d7deea" + + sans_18 = font(SANS, 18) + sans_17 = font(SANS, 17) + sans_26 = font(SANS, 26) + sans_30 = font(SANS, 30) + serif_64 = font(SERIF, 64) + + draw.rounded_rectangle((40, 38, 1160, 592), radius=22, fill="#ffffff", outline=rule, width=2) + draw.rectangle((40, 38, 54, 592), fill=blue) + draw.text((86, 76), "JASON / PRODUCT SYSTEMS", font=sans_18, fill=blue) + draw.text((835, 76), "UIKIT · OBSERVABLE RUNTIME", font=sans_18, fill=muted) + draw.line((86, 118, 1114, 118), fill=rule, width=2) + + draw.text((86, 162), "UIKit 隐式调用,", font=serif_64, fill=ink) + draw.text((86, 236), "变成可观察的证据链", font=serif_64, fill=ink) + draw.text((90, 333), "Predict → Run → Observe → Verify → Recap", font=sans_26, fill=muted) + draw.text((90, 382), "09 GUIDE STEPS · 14 LOG CATEGORIES · 02 UI TEST FLOWS", font=sans_18, fill=blue) + + trace_box = (86, 436, 802, 540) + draw.rounded_rectangle(trace_box, radius=12, fill="#0d1420") + labels = [("001", "viewDidLoad"), ("002", "didSelectItemAt"), ("003", "saveReminder"), ("004", "onSave")] + x = 108 + for number, label in labels: + draw.text((x, 458), number, font=sans_18, fill="#5f7188") + draw.text((x, 488), label, font=sans_17, fill="#d8e2ef") + x += 168 + + if not SCREENSHOT.exists(): + raise RuntimeError(f"Missing simulator screenshot: {SCREENSHOT}") + screenshot = Image.open(SCREENSHOT).convert("RGB") + screenshot.thumbnail((218, 474), Image.Resampling.LANCZOS) + phone_size = (screenshot.width + 14, screenshot.height + 14) + phone = Image.new("RGB", phone_size, "#111318") + phone.paste(screenshot, (7, 7)) + phone_mask = rounded_mask(phone_size, 34) + image.paste(phone, (880, 132), phone_mask) + + draw.rounded_rectangle((846, 142, 1007, 180), radius=19, fill=blue) + draw.text((867, 148), "REAL SIMULATOR", font=sans_18, fill="#ffffff") + draw.text((90, 557), "PURE UIKIT · SWIFT · XCODE · XCTEST", font=sans_18, fill=muted) + + image.save(ASSETS / "og-uikit.png", "PNG", optimize=True) + + +if __name__ == "__main__": + ASSETS.mkdir(parents=True, exist_ok=True) + generate_favicon() + generate_social_card() + print("generated docs/assets/favicon.png and docs/assets/og-uikit.png") diff --git a/scripts/verify-actions-pinned.py b/scripts/verify-actions-pinned.py new file mode 100644 index 0000000..401db67 --- /dev/null +++ b/scripts/verify-actions-pinned.py @@ -0,0 +1,49 @@ +"""Fail when a third-party GitHub Action is not pinned to a full commit SHA.""" + +from __future__ import annotations + +import re +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +WORKFLOWS = ROOT / ".github" / "workflows" +USES_RE = re.compile(r"^\s*(?:-\s*)?uses:\s*['\"]?([^'\"\s#]+)") +SHA_RE = re.compile(r"^[0-9a-f]{40}$") + + +def find_errors() -> tuple[list[str], int]: + errors: list[str] = [] + checked = 0 + for path in sorted([*WORKFLOWS.glob("*.yml"), *WORKFLOWS.glob("*.yaml")]): + for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): + match = USES_RE.match(line) + if not match: + continue + action = match.group(1) + if action.startswith("./") or action.startswith("docker://"): + continue + checked += 1 + if "@" not in action: + errors.append(f"{path.relative_to(ROOT)}:{line_number}: missing action ref: {action}") + continue + ref = action.rsplit("@", 1)[1] + if not SHA_RE.fullmatch(ref): + errors.append( + f"{path.relative_to(ROOT)}:{line_number}: action must use a 40-char SHA: {action}" + ) + return errors, checked + + +def main() -> int: + errors, checked = find_errors() + if errors: + for error in errors: + print(f"ERROR: {error}") + return 1 + print(f"OK: {checked} third-party GitHub Action references are pinned to full SHAs") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())