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 + + + + + + + +
+ + +
+ v0.6.0 · Alpha · Apache-2.0 +

把每一个目标,立定成一条可核验的线。

+

+ Ordane 是一个以目标为纲的组织工作台:文件树即组织架构,岗位即可寻址的数字员工。协作只有被 line 管理时才产生效率——不再靠 Agent 互相猜测彼此在做什么。 +

+ +
+ 状态 Electron 桌面客户端 + 原则 本地优先 · 数据不出域 + 许可 Apache-2.0 +
+
+ +
+
+
+ org-workbench · examples/oss-maintainer + 引擎可用 +
+ Ordane 桌面客户端实际界面:左侧组织树、中间组织图(岗位卡片带预算与审批状态)、右侧本地对话面板 +
+

真实客户端截图 · 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 桌面
  • +
+
心智 工具,不是监控
+
+ +
+ ○ 规划中 +

组织版

+

数据资产 + 事实驱动经营。连接器回拉多源数据,实体化后经 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; +});