docs+framework: canon, system-profile, gdd, registry, 6a framework
This commit is contained in:
@@ -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 <body>
|
||||
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 <form>
|
||||
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:
|
||||
// { "<account-hash>": { "id": "intern", "pw": ["<pw-hash>", …], "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: <div class="blackout" x-data="blackout(4000, 'The system has noticed you')" x-show="visible" x-transition x-cloak x-text="text"></div>
|
||||
|
||||
// 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: <p x-data="typewriter('Access granted. Do not look back.')" x-text="output"></p>
|
||||
|
||||
// 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: <div class="reveal" :class="{ shown }" x-data="reveal">…</div> .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 <main> so opening it marks the read:
|
||||
// <main x-data="markRead('pages/forum/thread-fenglou.html')">
|
||||
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: <footer x-data="progress"> … <span class="progress"
|
||||
// x-text="seen.length + '/' + document.body.dataset.total">. <body data-page data-total> carries
|
||||
// the numbering. The static span text stays as the no-JS / pre-boot fallback.
|
||||
Alpine.data('progress', () => ({
|
||||
seen: [],
|
||||
init() {
|
||||
this.seen = JSON.parse(sessionStorage.getItem('seen') ?? '[]');
|
||||
const n = document.body.dataset.page;
|
||||
if (n && !this.seen.includes(n)) this.seen.push(n);
|
||||
sessionStorage.setItem('seen', JSON.stringify(this.seen));
|
||||
},
|
||||
}));
|
||||
});
|
||||
Vendored
+21
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user