diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 0000000..3c34a9f
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,48 @@
+
+
+## Tracking record
+
+Closes #
+
+## Summary
+
+
+
+## Verification
+
+CI (`verify`) must be green. It runs, on every push:
+
+- `npm run verify:contrast` — every text/background pair the page renders, both themes, against the WCAG thresholds. Tokens are parsed from `index.html`, so the audit cannot drift from the file.
+- `npm run verify:render` — renders the page, screenshots hero / `#runtime` / `#ladder`, asserts all `.reveal` blocks trigger and the principles tab switches, and fails closed on page errors. Screenshots are uploaded as a run artifact.
+- fail-close guard — re-runs the render check with injected page errors and fails the build if it *passes*, so a green run cannot be a silent no-op.
+
+Anything CI cannot see (how it looks, whether the copy is true) goes below:
+
+- [ ] Checked at phone width as well as desktop
+- [ ] Checked in both light and dark mode
+
+## Factual accuracy
+
+This page is a public claim about what the product does. For each capability or
+status the diff adds or changes, name where it is backed:
+
+
+
+- [ ] No capability is described as shipped unless a linked repo says so
+- [ ] No pricing, invented metrics, or customer names
+- [ ] Screenshots contain example-workspace data only
+
+## Risk and rollback
+
+
diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml
new file mode 100644
index 0000000..bf663d7
--- /dev/null
+++ b/.github/workflows/verify.yml
@@ -0,0 +1,75 @@
+name: verify
+
+on:
+ pull_request:
+ push:
+ branches: [main]
+
+permissions:
+ contents: read
+
+concurrency:
+ group: verify-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ verify:
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: npm
+
+ # npm ci honours package-lock.json, so the toolchain version is pinned
+ # rather than resolved at run time.
+ - name: Install pinned toolchain
+ run: npm ci
+
+ - name: Report resolved versions
+ run: |
+ node -v
+ node -e "console.log('playwright-core', require('playwright-core/package.json').version)"
+
+ - name: Cache browser
+ uses: actions/cache@v4
+ with:
+ path: ~/.cache/ms-playwright
+ key: playwright-chromium-${{ hashFiles('package-lock.json') }}
+
+ - name: Install browser
+ run: npx --no-install playwright-core install --with-deps chromium
+
+ # Pure arithmetic over the stylesheet's own tokens; no browser needed.
+ - name: Contrast audit
+ run: npm run verify:contrast
+
+ # Renders index.html, screenshots hero/runtime/ladder, asserts 9/9 reveal
+ # blocks and the tab switcher, and fails closed on page errors.
+ - name: Render check
+ run: npm run verify:render
+
+ # Guards the guard: proves the render check still exits non-zero when the
+ # page errors, so a green run above cannot be a silent no-op.
+ - name: Render check fails closed
+ run: |
+ if npm run verify:failclose; then
+ echo "::error::render-check exited 0 on a page with injected errors; it is no longer failing closed"
+ exit 1
+ fi
+ echo "fail-close verified: injected errors produced a non-zero exit"
+
+ - name: Upload section screenshots
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: section-screenshots
+ path: |
+ hero.png
+ runtime.png
+ ladder.png
+ if-no-files-found: warn
+ retention-days: 14
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..2676084
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+node_modules/
+# render-check writes section screenshots into the working directory
+hero.png
+runtime.png
+ladder.png
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..a88c990
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,29 @@
+# Changelog
+
+本仓库采用 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/) 格式。
+版本号在首个正式发布前以里程碑标注;本仓库只承载官网静态站点,无独立版本号,跟随 `index.html` 的实质性改动记录。
+
+## [Unreleased]
+
+含 PR #3(feat: add Ordane marketing site)。
+
+### Added
+
+- 首个可发布的官网页面:hero、line 原语说明、能力清单、三条原则(含可切换 tab)、Runtime 抽象层轨道图、三级收编阶梯、CTA、页脚。内容取自 `org-workbench` 现有仓库事实,附一张该客户端 `examples/oss-maintainer` 示例工作区的真实截图(未经修饰)。
+- 导航与 favicon 图标:替换初始占位图形为正式设计稿(teal 环 + 播放三角标记)。
+- `scripts/render-check.js`:站点的渲染校验脚本。无头渲染 `index.html`,对 hero / runtime / ladder 三段分别定位、滚动、截图并检查尺寸与文件大小,逐屏推进滚动以确保 9 个 `.reveal` 区块全部被 IntersectionObserver 触发,点击三条原则的 tab 验证面板切换。**失败即非零退出**(`try/finally` 保证浏览器关闭),判定口径为:
+ - **致命**:`pageerror`;页面自身代码产生的 `console.error`;除字体主机以外任何失败的请求;以上各项断言不通过。
+ - **非致命但记录**:`fonts.googleapis.com` / `fonts.gstatic.com` 的字体请求失败,按 URL 判定后计入 `WEBFONT_LOAD_FAILURES`——这取决于运行者的网络而非页面本身(未做此分类前,一次运行曾因 4 条 `ERR_NETWORK_CHANGED` 误报失败)。`Failed to load resource` 这类 console 回声不重复计数,统一由 `requestfailed` 分类。
+
+ 带 `--self-test-failclose` 会在同一页面注入一条 console 错误和一个抛出的异常,用于证明失败路径确实以非零退出,而不只是打印。
+
+### Fixed
+
+- 手机宽度横向溢出:Runtime 轨道图的三个 Host 节点用 `left: 90%` 等绝对定位 + `white-space: nowrap`,390px 下容器仅约 334px 宽,把文档撑到比视口宽 71px(`docScrollW=461` vs `winW=390`),导致整页可以左右拖动。720px 以下改为纵向堆叠(隐藏轨道圆环、节点转为静态流式布局并允许换行),修复后 390 / 768 / 1440 三个宽度均无横向溢出。
+- 浅色模式主 CTA 按钮 hover 对比度:`.btn-primary:hover` 会把背景换成一个更亮的 teal,近白色标签文字掉到 3.90:1(低于小文本 4.5:1 门槛)。语义修正为「浅色底 hover 变深、深色底 hover 变亮」,token 由 `--teal-bright` 更名为 `--teal-hover`(浅色 58% → 40% L),修复后浅色 8.20:1、深色 9.78:1。全站 22 对文字/背景组合 × 明暗两套共 44 项已逐一计算,现全部通过。
+- 三条原则的 tab 只实现了半套 ARIA:声明了 `role="tablist"` / `role="tab"` / `aria-selected`,但面板缺 `role="tabpanel"`、按钮缺 `aria-controls`、面板缺 `aria-labelledby`,且没有方向键导航与 `tabindex` 管理——读屏会播报「tab, 1 of 3」而方向键无反应。现补全为完整模式(含 roving tabindex 与 ←/→/↑/↓/Home/End)。
+- 标题层级跳级:页脚三个栏目标题为 `
`,其前最近的标题是 CTA 区的 ``,跳过了 h3。改为 ``。
+- `mem 长期 Context` 能力状态由 `Shipped` 改为 `Building`,个人版文案由"本地记忆"改为"本地历史 readback"——`org-workbench` README 明确长期 Context 与委派链仍未完成,此前的措辞把未交付能力当成了已发布能力。
+- `openai-compatible` Runtime 节点改为虚线、降低视觉权重,标注"Preview · 未 live-qualified"——该能力仅存在于 `digital-employee` 仓库且标注为 preview/fixture-conformant,`org-workbench` 自身未提供对应证据。
+- 深色模式 `--ink-3`(56% → 60.7% L)与浅色模式 `--ink-3`(55% → 53.3% L):对全部三个背景(paper/band/card)的小字号文本对比度均低于 WCAG 4.5:1 门槛(3.81–4.26:1 / 4.31:1),逐一计算 OKLCH → 线性 sRGB → 相对亮度后调整,修复后各组合 4.62–5.20:1。
+- 浅色模式 `--amber`(64% → 54.5% L):`.st-building` 状态标签在浅色模式下对 paper/band/card 均低于 4.5:1(3.13–3.51:1),同一方法计算后调整,修复后 4.60–5.17:1;深色模式 `--amber` 原值已通过,未改动。
diff --git a/README.md b/README.md
index 180e8e1..6a8c383 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,8 @@
# Ordane
Official marketing site for **Ordane** — the goal-first organization workbench.
+
+**Live:** https://bytefolk.github.io/ordane/
Ordane is built on [`org-workbench`](https://github.com/bytefolk/org-workbench):
file tree is the org chart, positions are addressable digital employees, and
a goal is chartered as a `line` with ownership, order, and dedup.
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..594283d
--- /dev/null
+++ b/index.html
@@ -0,0 +1,592 @@
+
+
+
+
+
+
+Ordane — Studio
+
+
+
+
+
+
+
+
+
+
ORDANE · ORDANE · ORDANE · ORDANE
+
ORDANE · ORDANE · ORDANE · ORDANE
+
ORDANE · ORDANE · ORDANE · ORDANE
+
+
+
+
v0.6.0 · Alpha · Apache-2.0
+
把每一个目标,立定成一条可核验的线。
+
+ Ordane 是一个以目标为纲的组织工作台:文件树即组织架构,岗位即可寻址的数字员工。协作只有被 line 管理时才产生效率——不再靠 Agent 互相猜测彼此在做什么。
+
+
+
+ 状态 Electron 桌面客户端
+ 原则 本地优先 · 数据不出域
+ 许可 Apache-2.0
+
+
+
+
+
+
+ org-workbench · examples/oss-maintainer
+ 引擎可用
+
+

+
+
真实客户端截图 · oss-maintainer 示例工作区 · 未经修饰
+
+
+
+
+
+
+
+
The Primitive
+
Agent 协作最大的成本,从来不是 token 单价。
+
是失控。多个 Agent 扑向同一个目标,形成的是竞争而不是分工——重复劳动、互相覆盖、用 token 空转。
+
+
+
+
Ordane 引入 line 这个基本单位:一个 goal 立项即一条线,线上有归属、顺序、去重。Agent 是线上的岗位,协作只有在被线管理时才产生效率。
+
+
+ Agent 时代的 Jira —— 不是把人换成 Agent,是把 Jira 换成一个懂 Agent 语义的目标管理层。
+
+
+
+
+
+
Non-Negotiable
+
三条不动摇的原则
+
本地优先,契约先行,披露即授权——点开每一条看细节。
+
+
+
+
+
+
+
+
+
+
本地优先,数据不出域
+
控制面跑在员工电脑上
+
127.0.0.1 + 每启动 boot-token,凭据经 0600 auth-payload 传递,从来不出本地边界。合规叙事天生干净。
+
+
+
契约先行,可替换、可测评
+
Agent Loop as a Service
+
turn 契约、org 契约、Runtime 抽象层,模型 / Host 可换,上层业务不改。所有能力经 golden case 测评验证,不凭直觉路由。
+
+
+
披露是授权动作,不是系统动作
+
秘书 vs 参谋
+
个人版零披露完整可用;每次披露员工可见、可撤、有 ledger。团队版只消费"事实",永远不碰"过程"。
+
+
+
+
+
●workspace init一条命令把目录变成带组织树的数字业务工作区
Shipped
+
●org tree / apply组织契约 v1alpha1,本地 0600 存储;apply 重算权限、留审计
Shipped
+
●chat @position岗位寻址对话;turn 契约 R3;崩溃 → indeterminate,不自动重试
Shipped
+
●org-permissions.v1Authority Scope / Context Scope 逐岗位派发;越权拒绝并给恢复指引
Shipped
+
◐mem 长期 ContextStrict MemoryPort 架构与 agent/human token 分离已落地;跨会话长期召回与委派链仍在验证,未计入已发布能力
Building
+
◐Windows 支持原生进程树清理、.cmd spawn、qoder Windows 使能
Building
+
+
+
+
+
+
+
Runtime Abstraction
+
模型 / Host 可换,上层业务不改。
+
Ordane 不绑定单一 Agent Host——同一套 turn 契约下,claude-local 与 qoder 已有本机验证证据,openai-compatible 处于预览阶段。
+
+
+
+
+
+
Ordaneturn 契约 R3
+
claude-localAgent Host
+
qoderAgent Host
+
openai-compatiblePreview · 未 live-qualified
+
+
+
+
+
+
+
Adoption Ladder
+
个人 → 团队 → 组织
+
每一级都独立完整,向上一级不是"升级",是"接入"。
+
+
+
+
+
● 已跑通
+
个人版
+
零披露完整可用。一条命令把目录变成数字业务工作区,点人即聊。
+
+ - workspace init
+ - org tree · chat @position
+ - 本地对话闭环 · 本地历史 readback
+ - CLI · Electron 桌面
+
+
心智 工具,不是监控
+
+
+
◐ 规划中 · Q4
+
团队版
+
线管目标 · 授权披露。目标立项即一条 line,同时定义披露边界。
+
+ - line 原语(归属 · 顺序 · 去重)
+ - 模型路由(by difficulty)
+ - 授权 ledger · 可撤回
+ - 团队看板
+
+
心智 秘书 → 参谋
+
+
+
○ 规划中
+
组织版
+
数据资产 + 事实驱动经营。连接器回拉多源数据,实体化后经 Authority 消费。
+
+ - SaaS 连接器
+ - 数据目录 skill
+ - 事实驱动经营看板
+ - 目标—决策—效果闭环
+
+
心智 Palantir 本体的轻量版
+
+
+
+
+
+
+
+
我们不是给 AI 一个更聪明的搜索框。
+
是给 AI 一套可执行的企业业务模型——文件树即组织架构,岗位即可寻址的数字员工。
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..8c44071
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,32 @@
+{
+ "name": "@bytefolk/ordane-site",
+ "version": "0.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "@bytefolk/ordane-site",
+ "version": "0.0.0",
+ "license": "Apache-2.0",
+ "devDependencies": {
+ "playwright-core": "1.62.1"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.62.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
+ "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..60ce9e5
--- /dev/null
+++ b/package.json
@@ -0,0 +1,24 @@
+{
+ "name": "@bytefolk/ordane-site",
+ "private": true,
+ "version": "0.0.0",
+ "description": "Static marketing site for Ordane. No build step; this manifest only pins the verification toolchain.",
+ "license": "Apache-2.0",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/bytefolk/ordane.git"
+ },
+ "scripts": {
+ "verify": "npm run verify:contrast && npm run verify:render",
+ "verify:contrast": "node scripts/contrast-check.js index.html",
+ "verify:render": "node scripts/render-check.js \"$PWD/index.html\"",
+ "verify:failclose": "node scripts/render-check.js \"$PWD/index.html\" --self-test-failclose",
+ "browser": "playwright-core install chromium"
+ },
+ "devDependencies": {
+ "playwright-core": "1.62.1"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+}
diff --git a/scripts/contrast-check.js b/scripts/contrast-check.js
new file mode 100644
index 0000000..6789cb1
--- /dev/null
+++ b/scripts/contrast-check.js
@@ -0,0 +1,122 @@
+// WCAG contrast audit for index.html.
+//
+// node scripts/contrast-check.js [path/to/index.html]
+//
+// Tokens are parsed out of the stylesheet rather than restated here, so this
+// cannot silently drift from the file it is checking. Every pair below is a
+// combination the page actually renders; add a row whenever a new one appears.
+// Exits 1 if any pair is below its threshold.
+//
+// Method: OKLCH -> linear sRGB -> WCAG relative luminance -> contrast ratio.
+// Thresholds: 4.5:1 for text under 18.66px bold / 24px regular, 3:1 above it
+// and for non-text UI boundaries.
+const fs = require('fs');
+const path = require('path');
+
+const FILE = process.argv[2] || path.join(__dirname, '..', 'index.html');
+const src = fs.readFileSync(FILE, 'utf8');
+
+function oklchToLinearSrgb(L, C, H) {
+ const h = (H * Math.PI) / 180;
+ const a = C * Math.cos(h);
+ const b = C * Math.sin(h);
+ const l = L / 100;
+ const l_ = l + 0.3963377774 * a + 0.2158037573 * b;
+ const m_ = l - 0.1055613458 * a - 0.0638541728 * b;
+ const s_ = l - 0.0894841775 * a - 1.2914855480 * b;
+ const L3 = l_ ** 3, M3 = m_ ** 3, S3 = s_ ** 3;
+ return [
+ +4.0767416621 * L3 - 3.3077115913 * M3 + 0.2309699292 * S3,
+ -1.2684380046 * L3 + 2.6097574011 * M3 - 0.3413193965 * S3,
+ -0.0041960863 * L3 - 0.7034186147 * M3 + 1.7076147010 * S3,
+ ];
+}
+
+function luminance(spec) {
+ let lin;
+ if (typeof spec === 'string') {
+ const h = spec.replace('#', '');
+ lin = [0, 2, 4]
+ .map(i => parseInt(h.slice(i, i + 2), 16) / 255)
+ .map(c => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
+ } else {
+ lin = oklchToLinearSrgb(spec[0], spec[1], spec[2]).map(v => Math.max(0, v));
+ }
+ return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2];
+}
+
+function ratio(fg, bg) {
+ const a = luminance(fg), b = luminance(bg);
+ const hi = Math.max(a, b), lo = Math.min(a, b);
+ return (hi + 0.05) / (lo + 0.05);
+}
+
+// Parse one declaration block's custom properties.
+function tokensFrom(selector) {
+ const at = src.indexOf(selector);
+ if (at === -1) throw new Error(`selector not found in ${FILE}: ${selector}`);
+ const block = src.slice(at, src.indexOf('}', at));
+ const out = {};
+ const oklch = /--([a-z0-9-]+):\s*oklch\(([\d.]+)%\s+([\d.]+)\s+([\d.]+)\)/g;
+ const hex = /--([a-z0-9-]+):\s*(#[0-9a-fA-F]{6})/g;
+ let m;
+ while ((m = oklch.exec(block))) out[m[1]] = [parseFloat(m[2]), parseFloat(m[3]), parseFloat(m[4])];
+ while ((m = hex.exec(block))) out[m[1]] = m[2];
+ return out;
+}
+
+const light = tokensFrom(':root {');
+const dark = tokensFrom(':root[data-theme="dark"] {');
+// oklch(100% 0 0) has no hue component to match, so seed it explicitly.
+if (!light.card) light.card = [100, 0, 0];
+
+const SMALL = 4.5;
+const PAIRS = [
+ ['nav + body text ink-2 on paper', 'ink-2', 'paper', SMALL],
+ ['headings ink on paper', 'ink', 'paper', SMALL],
+ ['section eyebrow teal on paper', 'teal', 'paper', SMALL],
+ ['section eyebrow teal on band', 'teal', 'band', SMALL],
+ ['cap rows / tab label ink-2 on band', 'ink-2', 'band', SMALL],
+ ['tier body ink-2 on card', 'ink-2', 'card', SMALL],
+ ['tier status teal on card', 'teal', 'card', SMALL],
+ ['shipped state teal on band', 'teal', 'band', SMALL],
+ ['float head live teal on card', 'teal', 'card', SMALL],
+ ['orbit node label ink on card', 'ink', 'card', SMALL],
+ ['hero meta / caption ink-3 on paper', 'ink-3', 'paper', SMALL],
+ ['caption on band ink-3 on band', 'ink-3', 'band', SMALL],
+ ['tier mind / node tag ink-3 on card', 'ink-3', 'card', SMALL],
+ ['building state amber on band', 'amber', 'band', SMALL],
+ ['primary button label teal-ink on teal', 'teal-ink', 'teal', SMALL],
+ ['selected tab label teal-ink on teal', 'teal-ink', 'teal', SMALL],
+ ['orbit centre label teal-ink on teal', 'teal-ink', 'teal', SMALL],
+ ['cta band body teal-ink on teal', 'teal-ink', 'teal', SMALL],
+ ['cta button label teal on teal-ink', 'teal', 'teal-ink', SMALL],
+ ['eyebrow pill teal on teal-soft', 'teal', 'teal-soft', SMALL],
+ ['inline code teal on band', 'teal', 'band', SMALL],
+ ['primary button hover teal-ink on teal-hover', 'teal-ink', 'teal-hover', SMALL],
+];
+
+let failed = 0;
+let checked = 0;
+
+for (const [themeName, T] of [['LIGHT', light], ['DARK', dark]]) {
+ console.log(`\n${themeName}`);
+ console.log('-'.repeat(72));
+ for (const [label, fg, bg, need] of PAIRS) {
+ if (!(fg in T)) { console.log(` MISSING TOKEN --${fg} in ${themeName}`); failed++; continue; }
+ if (!(bg in T)) { console.log(` MISSING TOKEN --${bg} in ${themeName}`); failed++; continue; }
+ const r = ratio(T[fg], T[bg]);
+ const ok = r >= need;
+ checked++;
+ if (!ok) failed++;
+ console.log(` ${label.padEnd(46)} ${r.toFixed(2).padStart(5)}:1 need ${need} ${ok ? 'PASS' : 'FAIL'}`);
+ }
+}
+
+console.log(`\nCHECKED=${checked} FAILED=${failed}`);
+if (failed) {
+ console.log('RESULT=FAIL');
+ process.exitCode = 1;
+} else {
+ console.log('RESULT=PASS');
+}
diff --git a/scripts/render-check.js b/scripts/render-check.js
new file mode 100644
index 0000000..dbc27bb
--- /dev/null
+++ b/scripts/render-check.js
@@ -0,0 +1,146 @@
+// Renders the Ordane site headlessly, screenshots the three sections the PR
+// description claims to check, asserts page health, and FAILS CLOSED
+// (process.exitCode = 1) on any page error or failed assertion. Webfont
+// requests to fonts.googleapis.com / fonts.gstatic.com failing is reported but
+// NOT fatal: that depends on the runner's network, not on this page.
+//
+// Run it in an isolated, version-pinned environment so a warm browser cache
+// cannot mask a version mismatch (playwright-core's CLI is named
+// `playwright-core`, not `playwright`):
+//
+// WORK=$(mktemp -d); export PLAYWRIGHT_BROWSERS_PATH="$WORK/browsers"
+// cd "$WORK" && npm init -y >/dev/null
+// npm install playwright-core@1.62.1
+// npx --no-install playwright-core install chromium
+// node /path/to/repo/scripts/render-check.js /path/to/repo/index.html
+//
+// Add --self-test-failclose to inject a console.error and a thrown error into
+// the same page; everything else stays identical, so a non-zero exit there
+// proves the error path fails closed rather than merely printing.
+const { chromium } = require('playwright-core');
+const fs = require('fs');
+
+const SITE = process.argv[2];
+const SELF_TEST = process.argv.includes('--self-test-failclose');
+const SECTIONS = [
+ { name: 'hero', selector: 'header.hero' },
+ { name: 'runtime', selector: '#runtime' },
+ { name: 'ladder', selector: '#ladder' },
+];
+const EXPECTED_REVEALS = 9;
+
+(async () => {
+ const failures = [];
+ const errors = [];
+ const fontFailures = [];
+ const browser = await chromium.launch({ headless: true });
+ try {
+ console.log('BROWSER_VERSION=' + browser.version());
+ const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
+ // Fatal: the page's own JavaScript failing. Resource-load echoes are
+ // handled via requestfailed below, so they are excluded here to keep this
+ // assertion deterministic with respect to the page rather than the network.
+ page.on('console', m => {
+ if (m.type() !== 'error') return;
+ if (/^Failed to load resource/.test(m.text())) return;
+ errors.push('console: ' + m.text());
+ });
+ page.on('pageerror', e => errors.push('pageerror: ' + String(e)));
+
+ // The page pulls webfonts from fonts.googleapis.com / fonts.gstatic.com.
+ // Those failing says something about the runner's network, not about this
+ // page, so they are reported but not fatal. Any OTHER failed request is.
+ page.on('requestfailed', req => {
+ const url = req.url();
+ const why = (req.failure() && req.failure().errorText) || 'unknown';
+ if (/^https:\/\/fonts\.(googleapis|gstatic)\.com\//.test(url)) {
+ fontFailures.push(`${url} (${why})`);
+ } else {
+ errors.push(`requestfailed: ${url} (${why})`);
+ }
+ });
+
+ if (SELF_TEST) {
+ await page.addInitScript(() => {
+ console.error('injected console error (--self-test-failclose)');
+ setTimeout(() => { throw new Error('injected pageerror (--self-test-failclose)'); }, 50);
+ });
+ }
+
+ await page.goto('file://' + SITE, { waitUntil: 'load' });
+ await page.waitForTimeout(600);
+
+ for (const s of SECTIONS) {
+ const el = await page.$(s.selector);
+ if (!el) { failures.push(`section not found: ${s.selector}`); continue; }
+ await el.scrollIntoViewIfNeeded();
+ await page.waitForTimeout(700);
+ const box = await el.boundingBox();
+ if (!box || box.width < 200 || box.height < 100) {
+ failures.push(`section ${s.name} has implausible box: ${JSON.stringify(box)}`);
+ continue;
+ }
+ const file = `${s.name}.png`;
+ await el.screenshot({ path: file });
+ const bytes = fs.statSync(file).size;
+ if (bytes < 5000) failures.push(`screenshot ${file} suspiciously small: ${bytes} bytes`);
+ console.log(`SECTION=${s.name} box=${Math.round(box.width)}x${Math.round(box.height)} file=${file} bytes=${bytes}`);
+ }
+
+ // Progressive scroll: a single jump to the bottom skips most sections, so
+ // their IntersectionObservers never fire. Step through so every .reveal
+ // block actually passes through the viewport.
+ await page.evaluate(async () => {
+ const step = Math.round(window.innerHeight * 0.6);
+ for (let y = 0; y <= document.body.scrollHeight; y += step) {
+ window.scrollTo(0, y);
+ await new Promise(r => setTimeout(r, 120));
+ }
+ window.scrollTo(0, document.body.scrollHeight);
+ await new Promise(r => setTimeout(r, 300));
+ });
+ await page.waitForTimeout(600);
+
+ const reveals = await page.evaluate(() => {
+ const all = Array.from(document.querySelectorAll('.reveal'));
+ return { total: all.length, shown: all.filter(e => e.classList.contains('in')).length };
+ });
+ console.log(`REVEALS=${reveals.shown}/${reveals.total}`);
+ if (reveals.total !== EXPECTED_REVEALS) failures.push(`expected ${EXPECTED_REVEALS} reveal blocks, found ${reveals.total}`);
+ if (reveals.shown !== reveals.total) failures.push(`${reveals.total - reveals.shown} reveal block(s) never became visible`);
+
+ const tabBtn = await page.$('.tab-btn[data-tab="t2"]');
+ if (!tabBtn) {
+ failures.push('principles tab button [data-tab="t2"] not found');
+ } else {
+ await tabBtn.click();
+ await page.waitForTimeout(250);
+ const active = await page.evaluate(() => {
+ const p = document.querySelector('.tab-panel[data-active]');
+ return p ? p.id : null;
+ });
+ console.log(`ACTIVE_TAB_AFTER_CLICK=${active}`);
+ if (active !== 't2') failures.push(`tab click did not activate t2 (got ${active})`);
+ }
+ } finally {
+ await browser.close();
+ }
+
+ console.log('PAGE_ERRORS=' + (errors.length ? errors.join(' | ') : 'none'));
+ console.log('WEBFONT_LOAD_FAILURES=' + (fontFailures.length
+ ? `${fontFailures.length} (non-fatal, network-dependent): ${fontFailures.join(' | ')}`
+ : 'none'));
+ if (errors.length) failures.push(`${errors.length} page error(s) captured`);
+
+ if (failures.length) {
+ console.log('RESULT=FAIL');
+ for (const f of failures) console.log(' - ' + f);
+ process.exitCode = 1;
+ } else {
+ console.log('RESULT=PASS');
+ }
+})().catch(e => {
+ console.log('RESULT=FAIL');
+ console.log(' - uncaught: ' + e.message);
+ process.exitCode = 1;
+});