// 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); // Resolve both tables relative to the mounting page: data-index is kept relative to the mounting // page (search.html template contract), so a deep search carries "../../data/keywords.secret.json" // and its forbidden table lives beside it. Deriving forbidden from the index directory keeps the // root hub ("data/…") and a deep page ("../../data/…") both correct. const idx = this.$el.dataset.index; const forbiddenUrl = new URL('forbidden.json', new URL(idx, location.href)).href; const forbiddenTable = await (await fetch(forbiddenUrl)).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(idx)).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