From e030f695913e0dcfbe621bdd7707702558aef879 Mon Sep 17 00:00:00 2001 From: XingfenD Date: Wed, 30 Sep 2026 12:57:28 +0800 Subject: [PATCH] docs+framework: canon, system-profile, gdd, registry, 6a framework --- .dockerignore | 27 +++ .gitignore | 38 ++++ README.md | 74 ++++++++ assets/css/base.css | 130 +++++++++++++ assets/css/secret.css | 15 ++ assets/css/surface.css | 48 +++++ assets/js/components.js | 326 +++++++++++++++++++++++++++++++++ assets/js/vendor/alpine.min.js | 21 +++ data/forbidden.json | 1 + data/keywords.secret.json | 1 + data/keywords.surface.json | 1 + index.html | 24 +++ pages/about.html | 28 +++ pages/aid-case.html | 28 +++ pages/aid.html | 30 +++ pages/cases.html | 28 +++ pages/contact.html | 28 +++ pages/home.html | 28 +++ pages/juanfang/dossier.html | 38 ++++ pages/juanfang/login.html | 35 ++++ pages/juanfang/query.html | 34 ++++ pages/juanfang/results.html | 45 +++++ pages/juanfang/scan.html | 45 +++++ pages/lawyer.html | 28 +++ pages/notices.html | 30 +++ pages/s02-close-a.html | 29 +++ pages/s02-close-b.html | 29 +++ pages/s03-coda.html | 29 +++ pages/sitemap.html | 36 ++++ pages/team.html | 30 +++ search.html | 50 +++++ 31 files changed, 1334 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 README.md create mode 100644 assets/css/base.css create mode 100644 assets/css/secret.css create mode 100644 assets/css/surface.css create mode 100644 assets/js/components.js create mode 100644 assets/js/vendor/alpine.min.js create mode 100644 data/forbidden.json create mode 100644 data/keywords.secret.json create mode 100644 data/keywords.surface.json create mode 100644 index.html create mode 100644 pages/about.html create mode 100644 pages/aid-case.html create mode 100644 pages/aid.html create mode 100644 pages/cases.html create mode 100644 pages/contact.html create mode 100644 pages/home.html create mode 100644 pages/juanfang/dossier.html create mode 100644 pages/juanfang/login.html create mode 100644 pages/juanfang/query.html create mode 100644 pages/juanfang/results.html create mode 100644 pages/juanfang/scan.html create mode 100644 pages/lawyer.html create mode 100644 pages/notices.html create mode 100644 pages/s02-close-a.html create mode 100644 pages/s02-close-b.html create mode 100644 pages/s03-coda.html create mode 100644 pages/sitemap.html create mode 100644 pages/team.html create mode 100644 search.html diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6e518f3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,27 @@ +# .dockerignore for a make-my-arg game. It ships inside assets/starter/, so it lands in the project +# root when the starter tree is copied at scaffold time (step 6) — no rename, no second copy step. +# Deploying the static tree is a COPY, so this is the second half of the .gitignore job: what Git keeps +# out of history, the image must keep out of the visitor's hands. +# +# What stays OUT of the image, and why: +# data/*.src.json — plaintext keyword / credential tables: the answer key in a request away (R3). +# docs/ — the GDD, reachability chain, puzzle audit, site graph: every answer in prose. +# tools/ viewer/ — the checkers, config and renderer template: dev machinery, not pages. +# deploy/ — deploy scripts and manifests. +# .git/ node_modules/ — nothing a static host needs. +# +# Everything else ships: index.html, pages/ (or apps/), assets/ (css, js, img, audio, docs), +# the hashed data/*.json tables, portal.html / query.html / results.html and README.md as desired. + +.git +.gitignore +.dockerignore +Dockerfile* +node_modules + +docs +tools +viewer +deploy + +data/*.src.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..82e1c50 --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# .gitignore for a make-my-arg game. It ships inside assets/starter/, so it lands in the project +# root when the starter tree is copied at scaffold time (step 6) — no rename, no second copy step. +# It keeps development by-products out of the repository while everything the deploy needs +# (index.html, pages/, assets/, data/*.json, tools/, viewer/) stays tracked. +# +# Two different jobs, two different files: .gitignore keeps junk out of Git history, .dockerignore / +# the rsync exclude list keeps development material out of the deployed image. A plaintext table must be +# listed in both (it is worse than junk: it is the answer key, R3). + +# ── Development-only plaintext sources (R3): the answer key, never committed, never deployed ── +data/*.src.json + +# ── dev/ops trees: no site page lives here (the tools skip them via CONFIG.skipDirs) ── +docs/site-graph* +memory/ +review_comments.md +*.log + +# ── Story source material (may be copyright-protected; keep it locally, do not publish it) ── +docs/source-material/ + +# ── OS files ── +.DS_Store +Thumbs.db + +# ── Editor / IDE ── +.vscode/ +.idea/ + +# ── Agent scratch dirs (an agent working in this repo writes its own logs here) ── +.codex/ +.opencode/ +.claude/ +.superpowers/ +docs/superpowers/ + +# ── Node (the tools need no install; a project that adds one gets node_modules) ── +node_modules/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..9e2d9d6 --- /dev/null +++ b/README.md @@ -0,0 +1,74 @@ +# 《游戏名》 + +make-my-arg 的起点项目:每个生成的项目都把这棵树复制为起点,再按 `docs/system-profile.md` 组装、删改、填充。 +纯静态、无构建步骤——HTML + CSS + 本地 vendored 的 Alpine v3 + 零依赖 Node 工具,任何静态托管都能跑。 + +## 模板说明(成稿时改写或删除本节) + +- `《》` 是占位符:组织名、页面标题、正文文案,全部换成虚构自己的话。本页随项目一起改写。 +- 模板里的 `` 注释是写作指引,不是给玩家看的内容:填充页面时逐条删除。step 8 的 chrome 扫描会把残留提示当泄漏处理(R4)。 +- `` 随故事的语言设置。 +- 入口页带一个指向 make-my-arg 技能仓库的 GitHub 链接(对技能的引用,请勿删除);入口除此之外只有进入按钮,没有“关于”页。 +- 这棵树只放**不随模块变化**的那部分:入口外壳、共用 css/js、两份忽略文件、README。带模块标记的文件——`search.html`(M1)、`data/`(M1/M2/M6)、`secret.css`(M5)、`phone.css` / `chrome.css`(设备类)、`pages/` 与受限区域、结局页(M10)——由 step 6a 从技能仓库的 `assets/templates/`(js/css/html 前端代码模板,索引见其 README)按 `docs/system-profile.md` 的选择复制创建;profile 没选的机制一个都不出现。`tools/` 和 `viewer/` 由 step 6a 从 skill 的 `assets/` 拼装进来,它们各有唯一出处,不要在项目里分叉维护。 +- 起点项目里没有 `data/*.src.json`:它自带的 `.gitignore` 会把这些明文表忽略掉,而明文表只应在 6a 选中对应模块时才创建。 + +## 目录结构 + +| 路径 | 说明 | +|---|---| +| `index.html` | 入口外壳(M9):身份赋予 + 进入按钮;底部带指向 make-my-arg 仓库的引用链接;规则说明只在虚构确有必要时出现 | +| `pages/` | 站点页面,一篇一文档;受限区域用虚构自己的词命名(`internal/` `staff/` `archive/`),绝不叫 `secret/`,文件名不许剧透 | +| `assets/css/base.css` | 共用骨架:常驻顶栏、页面栏宽、文档套件、门禁表单、`x-cloak`、M11 纹理 | +| `assets/css/surface.css` | 公开层皮肤:色板与字体,唯一随虚构气质改动的地方 | +| `assets/css/secret.css` | M5 深层皮肤;profile 未选 M5 时这个文件不存在 | +| `assets/js/components.js` | 共用内核:`hash()`、session store 与 `readState`(M14);只注册 profile 选中的组件 | +| `assets/js/vendor/alpine.min.js` | `node tools/vendor-alpine.mjs` 拉取并校验 sha256 后落下,永不改动、不走 CDN | +| `.gitignore` | 随起点项目落到项目根:开发副产物不进 Git 历史 | +| `.dockerignore` | 随起点项目落到项目根:明文表 / `docs/` / 机制文件不进部署镜像 | +| `assets/img/` `audio/` `docs/` | 图片、音频谜题、可下载的仿制文档(pdf/xlsx),用到才建 | +| `data/keywords.*.src.json` | 明文索引表,开发专用,不进部署树 | +| `data/keywords.*.json` | `node tools/build-keywords.mjs` 产出的哈希表,站点真正读取的文件 | +| `data/forbidden.json` | M6 禁用词模块(哈希 + 触发态文案) | +| `data/credentials.src.json` | M2 派生凭据的出处登记(部件 + 规则),开发专用 | +| `tools/` | step 6a 从 `assets/tools/` 复制:检查器与 `config.mjs` | +| `viewer/` | step 6a 从 `assets/viewer/` 复制:站点图渲染树,不是站点页面 | +| `docs/` | step 1–5 的产物:story canon(五张表)、system-profile、GDD、触达链、谜题分析 | +| `deploy/` | 部署脚本与清单,用到才建 | + +`tools/`、`viewer/`、`docs/`、`deploy/` 里没有站点页面:走图工具按 `CONFIG.skipDirs` 跳过它们,所以生成的站点图永远不会把页数撑大。 + +## 本地运行 + +静态站点,没有构建: + +```bash +python3 -m http.server 8000 # 然后打开 http://localhost:8000 +``` + +直接双击 `index.html` 也能看,但检索模块要 `fetch()` 读 `data/*.json`,需要 http(s),所以请用上面的本地服务。 + +## 检查(`tools/` 拼装后可用) + +```bash +node tools/build-keywords.mjs # 改动任何 data/keywords*.src.json 之后,重新生成哈希表 +node tools/check-links.mjs # 死链 + 公开索引泄漏;期望 "0 dead · 0 layer leaks" +node tools/check-solvable.mjs # 冷启动走查:每页可达、每个门可解、关键词都被读过 +node tools/check-credentials.mjs # 派生凭据:部件 + 规则 + 零明文(存在 credentials.src.json 时) +node tools/check-reachability.mjs # 演算副本:把凭据注入一次性副本,复跑可达性 +node tools/site-graph.mjs # 产出 docs/site-graph.json 与 docs/site-graph/(报告型,有问题也退出 0) +node tools/check-solvable.mjs --self-test # 改过 tools/config.mjs 之后自检匹配器 +``` + +每次内容编辑后重跑,不只是部署前。改名、改层名、改标记只改 `tools/config.mjs` 一个文件,不要重写检查器。 + +## 部署 + +静态树部署就是一次拷贝,因此有第二条泄漏路径:明文 `data/*.src.json`(答案本身,R3)、`docs/`(GDD 与触达链就是全部答案)、`tools/` `viewer/` `deploy/` 机制文件,都不得进入部署目录。两份忽略文件就在这棵树里,随复制落到项目根;GitHub Pages 直接发布仓库即可。 + +## 设计文档 + +- `docs/system-profile.md` — step 2 与用户一起选定的容器与模块 +- `docs/gdd.md` — 游戏设计文档:front matter(资产清单 + 实体登记表)+ 八节 +- `docs/reachability.md` — 触达链分析:每页从哪来、守门是什么 +- `docs/puzzle-audit.md` — 谜题设计分析:必要性 / 可得性 / 直观性 / 泄漏扫描 +- `docs/self-check.md` — step 8 的逐项通过 / 失败清单 diff --git a/assets/css/base.css b/assets/css/base.css new file mode 100644 index 0000000..dcb0081 --- /dev/null +++ b/assets/css/base.css @@ -0,0 +1,130 @@ +/* base.css — the shared skeleton of a make-my-arg project. + * + * Structure only: the persistent top bar, the page column, the document kit, the gate form, the + * entry shell, x-cloak. Every colour, font and surface lives in surface.css (and secret.css on + * deep pages, M5) — a project edits the skin, never this file's layout, per page. + * + * Conventions this file owes the rest of the system: + * · header is sticky and opaque, site-wide, deep pages included (base.md §2) + * · every input names its field; nothing here hints at an answer (R4) + * · .hidden-text / .blurred are the M11 texture kit: black-on-black selectable text, redaction + * as a clue. Client-side masking is not privacy — anything hidden this way is public (R12) + */ + +/* ── Alpine: hide a component until it initializes, so no raw template flashes ── */ +[x-cloak] { display: none !important; } + +/* ── Reset ── */ +* { box-sizing: border-box; } +body { margin: 0; } +img { max-width: 100%; } +ul, ol { padding-left: 1.4em; } + +/* ── Persistent top bar ───────────────────────────────────────────────────────── + Sticky to the viewport top; background must stay opaque so long-page body text never shows + through. The nav links are this organization's own IA — together with the footer links (and the + M1 search box) they are the only cross-page links a public page carries (R7). */ +header { + position: sticky; top: 0; z-index: 10; + background: var(--bar, #f3e2d0); + border-bottom: 1px solid var(--rule, #d8c4b0); +} +header nav { + max-width: 60rem; margin: 0 auto; padding: .55rem 1rem; + display: flex; flex-wrap: wrap; align-items: baseline; gap: .2rem 1.1rem; + font-family: var(--sans, system-ui, sans-serif); font-size: .9rem; +} +header nav a { color: var(--ink, #4a4a4a); text-decoration: none; } +header nav a:hover { color: var(--accent, #c1440e); text-decoration: underline; } +header nav .brand { font-weight: 700; letter-spacing: .02em; } +header nav form { display: flex; gap: .35rem; margin-left: auto; } + +/* ── Page column: one document per page ── */ +main { + max-width: 60rem; margin: 0 auto; padding: 1.6rem 1rem 3rem; + line-height: 1.75; +} +main > h1 { font-size: 1.5rem; margin: 0 0 1rem; } +main > h2 { font-size: 1.15rem; margin: 1.8rem 0 .6rem; } +main p { margin: .6rem 0; } + +/* ── Footer: the site's own links + the M7 progress number ── */ +footer { + border-top: 1px solid var(--rule, #d8c4b0); + font-family: var(--sans, system-ui, sans-serif); font-size: .8rem; color: var(--ink-2, #6b6b6b); +} +footer .footer-inner { max-width: 60rem; margin: 0 auto; padding: .9rem 1rem; display: flex; flex-wrap: wrap; gap: .3rem 1rem; } +footer a { color: var(--ink-2, #6b6b6b); } +.progress { margin-left: auto; font-family: var(--mono, monospace); letter-spacing: .04em; } + +/* ── Document kit: what a real system prints ───────────────────────────────────── + .sheet is a formal document (R6: issuing body, number, date, addressee, body, seal, + distribution list); .narrow tightens it to a memo or a single record. */ +.sheet { border: 1px solid var(--rule, #d8c4b0); background: var(--paper-2, #fffdf9); padding: 1.4rem 1.6rem; } +.sheet.narrow { max-width: 34rem; margin: 0 auto; } +.sheet .doc-head { border-bottom: 2px solid var(--ink, #4a4a4a); padding-bottom: .5rem; margin-bottom: 1rem; } +.sheet .doc-no { float: right; font-family: var(--mono, monospace); font-size: .85rem; } +.sheet .doc-meta { font-size: .85rem; color: var(--ink-2, #6b6b6b); } +.sheet .doc-seal { margin-top: 1.2rem; text-align: right; color: var(--stamp, #a11212); font-weight: 700; letter-spacing: .15em; } +.sheet table { width: 100%; border-collapse: collapse; margin: .8rem 0; } +.sheet th, .sheet td { border: 1px solid var(--rule, #d8c4b0); padding: .35rem .6rem; text-align: left; } +.sheet th { background: var(--wash, #f7f1e8); } +.doc-list { list-style: none; padding: 0; } +.doc-list li { display: flex; gap: .8rem; padding: .35rem 0; border-bottom: 1px dashed var(--rule, #d8c4b0); } +.doc-list time { color: var(--ink-2, #6b6b6b); font-family: var(--mono, monospace); font-size: .85rem; white-space: nowrap; } + +/* ── M11 world texture ── */ +.handwrite { font-family: var(--hand, "Kaiti SC", "STKaiti", cursive); color: var(--stamp, #a11212); } +.hidden-text { background: #000; color: #000; user-select: text; } +.hidden-text::selection { color: #f00; background: #333; } /* visible only when selected */ +.blurred { filter: blur(6px); user-select: none; } /* redaction is itself a clue */ +.spacer { height: 180px; } /* whitespace as pacing */ + +/* ── Gate form (M2) ───────────────────────────────────────────────────────────── + The input names its field and stops there: a placeholder that carries the answer leaks it (R4). + Failure copy points at the source obliquely ("密码错误 🎂"), never at the derivation. */ +.gate { max-width: 22rem; margin: 2rem auto; } +.gate label { display: block; font-family: var(--sans, system-ui, sans-serif); font-size: .85rem; margin: .9rem 0 .25rem; } +.gate input, .gate select { + width: 100%; padding: .45rem .6rem; font: inherit; + border: 1px solid var(--rule, #d8c4b0); background: var(--paper-2, #fffdf9); color: var(--ink, #4a4a4a); +} +.gate input:focus-visible, .gate select:focus-visible { outline: 2px solid var(--accent, #c1440e); outline-offset: 1px; } +.gate-error { color: var(--stamp, #a11212); font-size: .85rem; min-height: 1.2em; margin: .5rem 0 0; } +.btn { + display: inline-block; padding: .45rem 1.4rem; font: inherit; cursor: pointer; + border: 1px solid var(--accent, #c1440e); background: var(--accent, #c1440e); color: var(--paper, #f9ebde); + text-decoration: none; +} +.btn:hover { filter: brightness(1.08); } +.btn.ghost { background: transparent; color: var(--accent, #c1440e); } + +/* ── Entry shell (M9): the cover before the site ─────────────────────────────── + No site header here — the player has not entered yet. Copy stays light: role assignment, the + system's own one-liner, and the start button. */ +.entry { min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; padding: 2rem 1rem; } +.entry h1 { font-size: 2rem; margin: 0 0 .6rem; letter-spacing: .04em; } +.entry .entry-lede { max-width: 30rem; color: var(--ink-2, #6b6b6b); margin: 0 0 2rem; } +.entry .entry-role { font-family: var(--sans, system-ui, sans-serif); font-size: .9rem; color: var(--ink-2, #6b6b6b); margin: -1rem 0 2rem; } +/* The entry's about link: quiet, below the start button. */ +.entry .entry-meta { margin: 1.4rem 0 0; font-family: var(--sans, system-ui, sans-serif); font-size: .78rem; } +.entry .entry-meta a { color: var(--ink-2, #6b6b6b); } + +/* ── About page: a plain column, no document pretense ── */ +.about { max-width: 40rem; } +.about h2 { font-size: 1.05rem; margin: 1.8rem 0 .5rem; } +.about p { margin: .6rem 0; } + +/* ── Staging (M12): full-page overlays own the viewport while they hold it ── */ +.blackout { + position: fixed; inset: 0; z-index: 100; display: flex; align-items: center; justify-content: center; + background: #000; color: #bbb; font-family: var(--mono, monospace); letter-spacing: .1em; +} + +/* ── Small screens: the bar wraps, the column narrows, nothing clips ── */ +@media (max-width: 820px) { + header nav { gap: .2rem .7rem; } + header nav form { margin-left: 0; width: 100%; } + main { padding: 1.1rem .9rem 2.4rem; } + .sheet { padding: 1rem; } +} diff --git a/assets/css/secret.css b/assets/css/secret.css new file mode 100644 index 0000000..1645e1a --- /dev/null +++ b/assets/css/secret.css @@ -0,0 +1,15 @@ +/* secret.css — M5 deep-layer skin. Linked by deep pages only (or applied via the session reskin + * token; references/structure/components.md §5). Retunes the custom properties base.css reads, so + * layout stays in base.css and the whole page shifts together: background, text, rules, accents. + * The fiction's own palette goes here — the values below are a placeholder for a cold archive mood. */ +:root { + --ink: #9e9e9e; --ink-2: #6f6f6f; + --paper: #1a1a1c; --paper-2: #232326; + --rule: #3a3a3e; --wash: #202023; + --accent: #b3261e; --stamp: #db1400; + --bar: #141416; +} +body { background: #1a1a1c; color: #9e9e9e; } /* the visible whole-page shift (step 8) */ +body.secret { background: #1a1a1c; color: #9e9e9e; } /* class applied by session.skin() at boot */ +body.secret h2 { color: #db1400; } +body.secret a { color: #b3261e; } diff --git a/assets/css/surface.css b/assets/css/surface.css new file mode 100644 index 0000000..43bed37 --- /dev/null +++ b/assets/css/surface.css @@ -0,0 +1,48 @@ +/* surface.css — the public skin of a make-my-arg project. + * + * The ONE file that carries the fiction's palette and type; base.css above stays structure-only. + * Set these tokens from the system's own era and register (a county gazette, a hospital intranet, a + * 1990s BBS), then leave them alone — a page that needs its own colour is a page that has left the + * disguise. Deep pages link secret.css instead (M5); when the profile did not select M5, this file + * is the whole skin. + * + * Palette vocabulary, shared with the site-graph viewer so a generated graph reads as the same + * paper: --paper --ink --rule --stamp. */ + +:root { + --paper: #f9ebde; /* page ground */ + --paper-2: #fffdf9; /* document ground, one step off the page */ + --wash: #f7f1e8; /* table headers, quiet fills */ + --ink: #4a4a4a; /* body text */ + --ink-2: #6b6b6b; /* secondary text, footer */ + --rule: #d8c4b0; /* hairlines and borders */ + --accent: #c1440e; /* links, buttons, the one warm accent */ + --stamp: #a11212; /* seals, warnings, forbidden-state copy */ + --bar: #f3e2d0; /* sticky header ground — must stay opaque */ + + --serif: "Songti SC", "SimSun", "Noto Serif CJK SC", Georgia, serif; /* prose */ + --sans: "PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC", system-ui, sans-serif; /* nav, data, UI */ + --mono: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; /* numbers, dockets */ + --hand: "Kaiti SC", "STKaiti", "KaiTi", cursive; /* hand-copied notes */ +} + +body { + background: var(--paper); + color: var(--ink); + font-family: var(--serif); + font-size: 16px; + -webkit-font-smoothing: antialiased; +} + +a { color: var(--accent); } +a:hover { text-decoration: none; } + +/* Headings sit in the same serif as the body: a real system does not set its own notices in a + display face. Weight and size carry the hierarchy instead. */ +h1, h2, h3 { font-weight: 700; line-height: 1.35; } + +/* Quiet, tabular figures anywhere the system prints a number: dockets, dates, counts. */ +time, .num, .progress, .doc-no { font-family: var(--mono, monospace); font-variant-numeric: tabular-nums; } + +/* Selection keeps the paper register; the M11 black-on-black text overrides it deliberately. */ +::selection { background: var(--rule); color: var(--ink); } diff --git a/assets/js/components.js b/assets/js/components.js new file mode 100644 index 0000000..e5a4e86 --- /dev/null +++ b/assets/js/components.js @@ -0,0 +1,326 @@ +// components.js — the shared kernel every page of a make-my-arg project loads. +// +// Loaded by every page BEFORE the vendored Alpine runtime (script order: base.md §2). It carries +// only what never changes between projects: the md5 primitive, the hash helper, the session store, +// and the readState store. The module components themselves are registered on alpine:init from the +// template files in the skill repo (assets/templates/js/modules/) — copy in ONLY the components +// docs/system-profile.md selected and delete the rest: shipping machinery the profile did not +// select is a module drift (references/common-mistakes.md §6). Never redefine hash / session / +// readState inside a copied body — this file is their single home. +// +// Hash contract (four sides, byte-identical — references/structure/tooling.md §3 item 4): +// this helper · tools/hash.mjs · tools/build-keywords.mjs · tools/check-credentials.mjs +// String(w).trim().toLowerCase() -> md5 -> base64 +// Switching algorithms means editing all of them in one change, then re-generating every table +// built under the old rule (R3). + +/* ── md5 (RFC 1321) ────────────────────────────────────────────────────────────── + Synchronous and dependency-free: WebCrypto has no MD5, so the primitive lives here. The output + is byte-identical to node:crypto's md5, which is what the tools above compute. */ +const md5 = (input) => { + const msg = new TextEncoder().encode(String(input)); + const blocks = Math.ceil((msg.length + 9) / 64); // 0x80 pad + 8-byte length + const buf = new Uint8Array(blocks * 64); + buf.set(msg); + buf[msg.length] = 0x80; + const view = new DataView(buf.buffer); + const bits = msg.length * 8; // safe below 2^53 bits + view.setUint32(buf.length - 8, bits >>> 0, true); + view.setUint32(buf.length - 4, Math.floor(bits / 4294967296), true); + + // Per-step rotate amounts and the sine-derived constant table (RFC 1321 T[1..64]). + const S = [7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, + 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, + 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, + 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21]; + const K = Array.from({ length: 64 }, (_, i) => Math.floor(Math.abs(Math.sin(i + 1)) * 4294967296)); + + let a0 = 0x67452301, b0 = 0xefcdab89, c0 = 0x98badcfe, d0 = 0x10325476; + for (let off = 0; off < buf.length; off += 64) { + const M = Array.from({ length: 16 }, (_, i) => view.getUint32(off + i * 4, true)); + let A = a0, B = b0, C = c0, D = d0; + for (let i = 0; i < 64; i++) { + let F, g; + if (i < 16) { F = (B & C) | (~B & D); g = i; } + else if (i < 32) { F = (D & B) | (~D & C); g = (5 * i + 1) % 16; } + else if (i < 48) { F = B ^ C ^ D; g = (3 * i + 5) % 16; } + else { F = C ^ (B | ~D); g = (7 * i) % 16; } + F = (F + A + K[i] + M[g]) | 0; + A = D; D = C; C = B; + B = (B + ((F << S[i]) | (F >>> (32 - S[i])))) | 0; // rotate left, then add + } + a0 = (a0 + A) | 0; b0 = (b0 + B) | 0; c0 = (c0 + C) | 0; d0 = (d0 + D) | 0; + } + const hex = []; + for (const v of [a0, b0, c0, d0]) + for (let i = 0; i < 4; i++) hex.push(((v >>> (i * 8)) & 0xff).toString(16).padStart(2, '0')); + return hex.join(''); +}; + +/* ── hash: the one helper gate inputs and keyword tables are keyed by ───────────── + Mirrors tools/hash.mjs exactly. md5 is hex here, base64 out — never mix the two forms. */ +const hash = (w) => btoa(String.fromCharCode(...md5(String(w).trim().toLowerCase()).match(/../g).map((h) => parseInt(h, 16)))); + +/* ── Session store ────────────────────────────────────────────────────────────── + Access state MUST survive a result opened in a new tab (target="_blank"): per-tab + sessionStorage does not, so the state lives in a session cookie (no max-age/expires → cleared + when the browser closes) with sessionStorage as the private-mode fallback. Rename ACCESS_KEY + per project. Session state is technical, never explained to the player. */ +const ACCESS_KEY = 'access', SKIN_KEY = 'skin'; +const session = { + _read(key) { + const m = document.cookie.match(new RegExp('(?:^|; )' + key + '=([^;]*)')); + if (m) { try { return JSON.parse(decodeURIComponent(m[1])); } catch (e) { /* fall through */ } } + try { return JSON.parse(sessionStorage.getItem(key) || 'null'); } catch (e) { return null; } + }, + _write(key, val) { + const v = encodeURIComponent(JSON.stringify(val)); + document.cookie = `${key}=${v}; path=/; SameSite=Lax`; // session cookie: deliberately no expiry + try { sessionStorage.setItem(key, JSON.stringify(val)); } catch (e) { /* private mode */ } + }, + access() { return this._read(ACCESS_KEY) || []; }, + grant(ids) { this._write(ACCESS_KEY, [...new Set([...this.access(), ...ids])]); }, + reskin(name) { if (name) this._write(SKIN_KEY, name); }, // optional: a skin token deep pages apply + skin() { return this._read(SKIN_KEY); }, +}; + +/* ── Read-state store (M14) ───────────────────────────────────────────────── + World-response state (references/guardrails.md R13): an entry appears once its source page was + read. Persist ONLY when docs/system-profile.md records the persistent/world-response contract + (references/structure/form-device.md §3); on a remote system swap localStorage for sessionStorage. + The checker-visible contract is the data-after attribute on the anchor — this store is what the + DOM render reads. Storage is technical, never explained to the player. */ +const READ_KEY = 'read'; // rename per project +const readState = { + mark(page) { + try { const s = new Set(JSON.parse(localStorage.getItem(READ_KEY) || '[]')); s.add(page); + localStorage.setItem(READ_KEY, JSON.stringify([...s])); } catch (e) { /* private mode */ } + }, + has(page) { + try { return JSON.parse(localStorage.getItem(READ_KEY) || '[]').includes(page); } catch (e) { return false; } + }, +}; + +/* ── Component registration ───────────────────────────────────────────────────── + The vendored runtime auto-starts right after its own script tag, so registrations queue on the + alpine:init event it dispatches before the DOM walk. Register ONLY what the profile selected; + each component body ships as a real file in the skill repo under assets/templates/js/modules/ + (step 6a copies the selected bodies into this listener; mount markup: assets/templates/html/, rules: + references/structure/components.md): + search.js (M1) · x-data="search", data-index="data/keywords.surface.json" + gate.js (M2) · x-data="gate", data-expect-hash on each input, data-next / x-show="unlocked" + access.js (M3) · x-data="access", data-access on the protected page (references/structure/form-system.md) + staging.js (M12) · blackout / typewriter / reveal — timers released in destroy() + progress.js (M7) · footer span, data-page / data-total on + read-state.js (M14) · x-data="markRead(page)" on the source page; anchors carry data-after + (references/structure/form-device.md §3–§4) + phone.js (device class phone) · x-data="phone" on the shell + M5 reskin needs no component here: deep pages link secret.css directly and session.reskin() above + carries the token. The stores above (hash / session / readState) are THIS file — copied bodies + depend on them; never redefine one inside a component. No component may route or switch scenes: + Alpine owns in-page lifecycle only (R1). */ +/* Project registrations — docs/system-profile.md (Container A + nested D, pure-web): + search (M1) · gate (M2, shapes A/B/C incl. login-multi) · access (M3) · staging (M12) · + progress (M7, SESSION MODE) · markRead (M14). + State contract: login = kernel session cookie; progress = none persistent — the M7 footer + numbering runs off a session-scoped seen list (the template's localStorage swapped to + sessionStorage per references/structure/form-device.md §3 / components.md §6: remote system, + cleared on browser close, no save/continue/wipe boot surface); world-response = kernel readState + store + markRead + data-after anchors (2 edges, landed in 6b). + Out of the game per the profile: phone.js (pure-web), forbidden.css (M6 off — data/forbidden.json + ships as an empty zero-entry table ONLY because the verbatim search.js fetches it unconditionally; + no forbidden state can fire and no M6 skin is linked). Bodies below are copied verbatim from + assets/templates/js/modules/ except the one sanctioned storage swap in progress. + No component routes or switches scenes (R1). */ +document.addEventListener('alpine:init', () => { +// M1 search — Alpine component body. Depends on the starter kernel's hash() +// (assets/starter/assets/js/components.js, byte-identical to tools/hash.mjs). +// Copy into the project's components.js inside the alpine:init listener when the profile selects M1. +// Mount shape: assets/templates/html/search.html (layer scoping, R8; result titles are catalog +// entries, R9; see references/structure/components.md §2). +Alpine.data('search', () => ({ + q: '', + state: 'empty', // empty | forbidden | hit | miss + results: [], + forbidden: null, + + async init() { // Alpine lifecycle: runs before the component renders + this.q = new URLSearchParams(location.search).get('q')?.trim().toLowerCase() ?? ''; + if (!this.q) return; + const enc = hash(this.q); + + const forbiddenTable = await (await fetch('data/forbidden.json')).json(); + const hit = Object.values(forbiddenTable).find(f => f.keywords.includes(enc)); + if (hit) { // State 3: forbidden — full-page reskin (background/title/logo/footer) + this.forbidden = hit; + this.state = 'forbidden'; + document.body.classList.add('body-forbidden'); + document.querySelector('.progress').textContent = 'ex/36'; + return; + } + + const map = await (await fetch(this.$el.dataset.index)).json(); + this.results = map[enc] ?? []; + this.state = this.results.length ? 'hit' : 'miss'; + }, +})); + +// M2 gate — Alpine component body. Depends on the starter kernel's hash() and session store +// (assets/starter/assets/js/components.js) — do NOT redefine them here; the session cookie helper +// and ACCESS_KEY/SKIN_KEY live in the kernel, shared with the access component (references/structure/components.md §3). +// Shapes and mount markup: assets/templates/html/gate-a.html (navigate on success), +// gate-b.html (unlock in place — the post-gate block must carry x-show="unlocked", the marker +// check-solvable.mjs keys on), login-multi.html (system containers, data-grants map). +// Credential triad + design rules (R3, R4): references/structure/components.md §3. +Alpine.data('gate', () => ({ + error: '', busy: false, unlocked: false, successText: '', _t: null, + init() { this.successText = this.$el.dataset.successText || ''; }, + submit() { + const el = this.$root; // data-* live on the component root; the submit event's target is the
+ const inputs = Array.from(el.querySelectorAll('input[data-expect-hash],input[data-expect-any]')); + // Single-box multi-identity (login-multi.html). data-grants is a JSON map on the root: + // { "": { "id": "intern", "pw": ["", …], "reskin": "secret" }, … } + // It resolves WHICH identity the typed account grants and validates the password against that identity, + // so one login box can serve several roles without splitting into several forms on the page. data-grant + // stays on the root as the flat list of identities check-solvable walks; data-grants is the runtime truth. + let ok, grantIds = []; + if (el.dataset.grants) { + const map = JSON.parse(el.dataset.grants); + const acc = inputs.find((i) => i.type !== 'password'); + const pw = inputs.find((i) => i.type === 'password'); + const entry = acc ? map[hash(acc.value || '')] : null; + ok = !!entry && (!pw || (entry.pw || []).includes(hash(pw.value || ''))); // account+password are paired + if (ok) { grantIds = entry.id ? [entry.id] : []; session.reskin(entry.reskin); } + } else { + // Legacy / multi-field gates: every data-expect-hash input must match; data-expect-any accepts any + // non-empty value (a declared wildcard, references/structure/form-device.md §4); data-grant is unconditional. + ok = inputs.length > 0 && inputs.every((inp) => { + if (inp.dataset.expectAny) return (inp.value || '').trim() !== ''; + const expects = (inp.dataset.expectHash || '').split(',').map((s) => s.trim()).filter(Boolean); + return expects.includes(hash(inp.value || '')); + }); + grantIds = (el.dataset.grant || '').split(',').map((s) => s.trim()).filter(Boolean); + } + if (ok) { + if (grantIds.length) session.grant(grantIds); + const next = el.dataset.next; + const hold = parseInt(el.dataset.successHold || '0', 10); + const staged = this.successText && hold > 0; + const finish = () => { if (next) location.href = next; else { this.busy = false; this.unlocked = true; } }; + if (staged) { this.busy = true; this._t = setTimeout(finish, hold); } else finish(); + return; + } + this.error = el.dataset.failHint || 'Verification failed.'; // inline red text; a bare alert breaks the facade + el.animate([{ transform: 'translateX(0)' }, { transform: 'translateX(-6px)' }, + { transform: 'translateX(6px)' }, { transform: 'translateX(0)' }], 320); + }, + destroy() { clearTimeout(this._t); }, +})); + +// M3 access — Alpine component body. Depends on the starter kernel's session store +// (assets/starter/assets/js/components.js — cookie-first, sessionStorage fallback in private mode); +// do NOT redefine session or hash here. Copy into the project's components.js inside the +// alpine:init listener when the profile selects M3 (system containers B/C/D). +// Mount shape: assets/templates/html/access-protected.html (references/structure/form-system.md §3). +// The x-show="unlocked" block is the marker check-solvable.mjs keys on; a page opens ONLY to the +// accounts it names — no privilege ladder (R10). +Alpine.data('access', () => ({ + unlocked: false, + init() { + const held = session.access(); // kernel helper: cross-tab session cookie + const need = (this.$el.dataset.access || '').split(',').map((s) => s.trim()).filter(Boolean); + this.unlocked = need.some((r) => (r === '*' ? held.length > 0 : held.includes(r))); + const skin = session.skin(); // optional reskin token granted at login (M5) + if (skin) document.body.classList.add(skin); + }, +})); + +// M12 interaction staging — Alpine component bodies: blackout / typewriter / reveal. +// All timers and observers are registered in init() and released in destroy(), so a staged +// effect survives or dies with its component. Copy the selected bodies into the project's +// components.js inside the alpine:init listener when the profile selects M12. +// Sensory variants (audio puzzle, flashlight mask) live in the fiction's own page; the same +// lifecycle rule holds. references/design-playbook.md §2 M12; declare hardware on the entry page (step 8). + +// "The system has noticed you": delayed blackout after unlock; optional `next` navigates 5s later. +Alpine.data('blackout', (delay = 3000, text = '', next = null) => ({ + visible: false, text, showTimer: null, exitTimer: null, + init() { + this.showTimer = setTimeout(() => { + this.visible = true; + if (next) this.exitTimer = setTimeout(() => (location.href = next), 5000); + }, delay); + }, + destroy() { clearTimeout(this.showTimer); clearTimeout(this.exitTimer); }, +})); +// mount:
+ +// Delete the element's original text character by character, then retype newText — ceremonial effect. +Alpine.data('typewriter', (newText, speed = 60) => ({ + output: '', timer: null, + init() { + const original = this.$el.textContent; + let i = original.length; + this.output = original; + this.timer = setInterval(() => { + this.output = original.slice(0, --i); + if (i > 0) return; + clearInterval(this.timer); + let j = 0; + this.timer = setInterval(() => { + this.output = newText.slice(0, ++j); + if (j >= newText.length) clearInterval(this.timer); + }, speed); + }, speed); + }, + destroy() { clearInterval(this.timer); }, +})); +// mount:

+ +// Scroll reveal: IntersectionObserver owned by the component, controlling reading pace. +Alpine.data('reveal', () => ({ + shown: false, observer: null, + init() { + this.observer = new IntersectionObserver(([entry]) => { + if (!entry.isIntersecting) return; + this.shown = true; + this.observer.disconnect(); + }); + this.observer.observe(this.$el); + }, + destroy() { this.observer?.disconnect(); }, +})); +// mount:
…
.reveal{opacity:0;transition:opacity .6s}.reveal.shown{opacity:1} + +// M14 read-state — the runtime side of world-response state (references/guardrails.md R13): an entry +// appears once its source page was read. The STORE (readState + READ_KEY) lives in the starter kernel +// (assets/starter/assets/js/components.js) — never redefine it here. This file adds only the mount +// component: the SOURCE page marks itself as read by carrying it. +// The CHECKER-visible contract is the data-after attribute on the entry anchor +// (assets/templates/html/read-entry.html): keep the page path written in data-after and in the +// markRead(...) argument identical, or the walk proves an order the DOM does not render +// (references/structure/form-device.md §4). +// Persist ONLY when docs/system-profile.md records the persistent/world-response contract; the kernel +// store is where `progress: session` swaps localStorage for sessionStorage. +// +// Mount on the SOURCE page's
so opening it marks the read: +//
+Alpine.data('markRead', (page) => ({ init() { readState.mark(page); } })); + +// M7 collection progress — Alpine component body (assets/templates/js/modules/progress.js). +// State contract: NO persistence — this is the template's verbatim body with the single +// sanctioned storage swap localStorage -> sessionStorage (references/structure/form-device.md §3: +// session-only or none for a remote system; docs/system-profile.md records `none`). +// Mount on the shared footer: