diff --git a/README.md b/README.md
index a7f217c..43893d9 100644
--- a/README.md
+++ b/README.md
@@ -1,66 +1,83 @@
-# Anime portrait in points — PixiJS
+# Reigen Arataka — a fan page
-A tiny web app that **reproduces an image with points**: it samples an image's
-pixels into a coloured point cloud rendered with **PixiJS v8** (2D-native WebGL)
-through a pan/zoom "world" container. Plain JavaScript, no build step.
+An unofficial, non-commercial fan page about **Reigen Arataka** (霊幻新隆), the
+self-proclaimed "Greatest Psychic of the 21st Century" from ONE's *Mob Psycho
+100*. A single scrolling page of plain HTML, CSS and ES modules — no build step,
+no framework, no dependencies to install.
-Ships with a **CC0 / public-domain** anime portrait as the default. Load any
-image of your own to reproduce that instead — the sampling is image-agnostic.
+**Live:** https://tiennm99.github.io/reigen/
-**Live demo:** https://tiennm99.github.io/reigen/
+## What's on it
-## Features
+Ten sections in one scroll: hero, profile, who he is, personality, an
+abilities panel that sets his four real skills against his four branded fake
+"techniques", quotes, the people around him, story arcs, and the credits.
-- **Reproduce any image with points** — each opaque pixel of the sampled image
- becomes one round, per-point-tinted particle in a PixiJS `ParticleContainer`
- (hundreds of thousands of points; framing via a parent container transform).
-- **Load your own image** — click *Load image*, drag an image onto the canvas,
- or paste an image URL. Images you load are processed **locally in your
- browser** and never uploaded; remote URLs are fetched client-side only.
-- **6 shot framings** (full, portrait, face, eyes, top, detail) with a smooth
- tween; scroll to zoom.
-- **Download ~100 MP PNG** — a single 100-megapixel framebuffer isn't feasible
- in WebGL, so the current framing is rendered in **supersampled tiles** into a
- ~10000×10000 image and saved.
+- **No anime imagery.** Every visual — the aura fields, ray burst, faceless
+ suited figure, salt-splash dividers, favicon — is hand-authored CSS and inline
+ SVG. There is not a single `` tag or raster file in the repo, which is
+ deliberate: see [Licensing](#licensing-and-credits).
+- **Content lives in the HTML**, not injected by JavaScript, so the page reads
+ fine with scripting off and is indexable.
+- **Accessible by construction** — semantic landmarks, skip link, one `
- Default image: “Anime girl” by oksmith (Open Clipart),
- CC0 / public domain.
- Load your own image to reproduce it with points — images you load are
- processed locally in your browser and never uploaded. Do not load
- copyrighted images you don’t have the right to use. Non-commercial demo;
- any concern: github.com/tiennm99.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Self-proclaimed
+
+ ReigenArataka
+
+
霊幻新隆
+
“The Greatest Psychic of the 21st Century”
+
+ Zero psychic ability. A pocket full of salt, a very good massage,
+ and enough nerve to carry an entire exorcism business — and,
+ somehow, the most important adult in a psychic kid’s life.
+
+
+
+
Psychic power0%
+
Confidence100%
+
+
+
+ Fan-made tribute page. Not affiliated with the creators of Mob Psycho 100.
+ All artwork here is original CSS / SVG — no official images are used.
-
-
+
scroll
+
+
+
+
+
+
+
+
01
+
Profile
+
The paperwork behind the persona.
+
+
+
+
+
+
Identity and production details for Reigen Arataka
+
+
Name
Reigen Arataka 霊幻新隆
+
Age
28
+
Occupation
Owner and operator, Spirits and Such Consultation Office
+
Workplace
Spirits and Such Consultation Office — one small rented room
+
Affiliations
Mentor and employer to Mob (Shigeo Kageyama); works alongside Katsuya Serizawa
+ Reigen sells exorcisms he cannot perform. He has no psychic ability whatsoever — not a
+ flicker — and he has built a whole small business on the confident opposite claim.
+ His toolkit is salt, showmanship, a sharp read on people, and hands that genuinely know
+ where the knot in your shoulder is.
+
+
+ The joke is that his clients still leave better off. What they usually walk in with is
+ loneliness, stress, guilt or self-doubt, and Reigen — whatever he calls the ritual —
+ actually listens, gives blunt practical advice, and sends them home lighter. The fake part
+ is the ghost. The help is real.
+
+
+ Then a quiet middle-schooler named Shigeo Kageyama — Mob — walks in with powers
+ that could level a building, and asks the con man for guidance. Reigen hires him. The money
+ motive is obvious and he never quite pretends otherwise. But what he ends up giving Mob is
+ the one thing no esper in the series can supply: the steady, unglamorous message that
+ power is not the point.
+
+
+ Psychic powers, he tells Mob, are just another trait — like being fast, or good at
+ exams. What makes a person worth something is how they treat people.
+
+
+
+
+
+
+
+
03
+
Personality
+
Six traits, pulling in different directions at once.
+
+
+
+
+
Con artist
+
Charm, quick thinking and social footwork, deployed to convince paying clients that he is a powerful psychic.
+
+
+
Emotionally intelligent
+
Reads a room and a person fast, then adjusts his register to match. The same skill that makes him a good liar makes him a good counsellor.
+
+
+
Outgoing performer
+
Personable, verbally confident, entirely comfortable with an audience. Never met a silence he wouldn’t fill.
+
+
+
Protective mentor
+
Started from self-interest and ended up genuinely invested in Mob’s safety, self-esteem and growing up okay.
+
+
+
Relentlessly pragmatic
+
Aims at the real problem — the stress, the loneliness, the self-doubt — even while performing a supernatural solution to it.
+
+
+
Flawed, with a floor
+
Vain, evasive, allergic to accountability. Still refuses to truly hurt a client or exploit a child. He learns humility the hard way.
+
+
+
+
+
+
+
+
04
+
What He Can Actually Do
+
+ Left column: real, verifiable, unglamorous. Right column: the show.
+
+
+
+
+
+
+
Genuine
+
Real skills
+
+ One entry below is canon: he has no psychic ability at all. The rest is
+ this fan page’s own read on how good he is at things — the
+ bars are a rough impression, not official stats.
+
+
+
+
+
Massage & acupressureExpert
+
+
+
Real acupoint work on shoulder and back tension. The most-used ability he owns.
+
+
+
+
Counselling & life adviceExpert
+
+
+
Unlicensed, unbilled, and frequently the actual product being sold.
+
+
+
+
PersuasionVery high
+
+
+
Talks his way into jobs, out of trouble, and around the truth.
+
+
+
+
Physical fitnessSurprising
+
+
+
No powers, but agile enough and smart enough about leverage to hold his own in a fight.
+
+
+
+
Psychic abilityNone
+
+
+
Zero. Not weak — absent. This is the one number on this page that is canon.
+
+
+
+
+
+
+
Theatre
+
“Techniques”
+
Branded, named, and announced at volume. All of it is stagecraft for the client’s benefit.
+
+
+
+
Salt Splash
+
Throw salt at the target while shouting an incantation. Purification, allegedly.
+
+
+
+
Salt Heap Punch
+
A punch, thrown with a salt-encrusted fist, for maximum visual drama.
+
+
+
+
Black Arts Crush
+
A frightening name applied to what is, in fact, a very competent massage.
+
+
+
+
Graphic Purification
+
Editing the evil spirit out of the client’s photograph. On a computer. That is the whole technique.
+
+
+
+
+
+
+ Nothing in the right column works.
+ Everything in the left column does.
+
+
+
+
+
+
+
05
+
In His Own Words
+
The fraud gives the best advice in the series.
+
+
+
+
+
+
+
Listen. Just because you have these psychic powers and abilities, it doesn’t mean
+ you’re any less of a human than anyone else. There are people who are fast, people who
+ are book smart, and people with strong body odor. Psychic powers are just like that:
+ another characteristic.
+
+
+
+
+
+
In the end, you’re the one who’s going to get hurt. When things go
+ south, it’s ok to run away and save yourself!
+
+
+
+
+
Being confident about your powers is fine, but you mustn’t get conceited.
+ Depending on how we use our powers, they can also be deadly weapons. They’re just like
+ knives.
+
+
+
+
+
Your life is your own, and it’s up to you to live it exactly the way you
+ want.
+
+
+
+
+
The truth behind one’s charm is kindness.
+
+
+
+
+ Wording follows the English translations that circulate most widely. Public sources do not
+ pin these lines to specific episodes or chapters, so no citation is given rather than a
+ guessed one. Phrasing varies between subtitle and dub versions.
+
+
+
+
+
+
+
06
+
The People Around Him
+
One office, several strays.
+
+
+
+
+
Employee · mentee · chosen family
+
Mob Shigeo Kageyama
+
The genuine psychic who works for the fake one. Reigen’s motive slides from
+ “useful asset” to “this kid has to turn out okay,” and that shift is
+ the spine of his whole character.
+
+
+
Colleague
+
Katsuya Serizawa
+
Comes to work at Spirits and Such alongside Reigen, as the office — and Reigen —
+ get put back together on a healthier footing.
+
+
+
Uninvited
+
Dimple
+
An evil spirit who tried to make Reigen his puppet. Reigen’s countermeasure was to
+ pretend he could not see him. Their dynamic stays comedic friction, never alliance.
+
+
+
Mob’s brother
+
Ritsu Kageyama
+
Reigen occasionally goes to him for help or an opinion, and — notably — treats
+ him as an equal while doing it.
+
+
+
Rival, then not
+
Teruki Hanazawa
+
A genuinely powerful esper who starts out clashing with Reigen and ends up respecting the
+ man for the one thing he has that powers can’t buy.
+
+
+
Client, then friend
+
Tome Kurata
+
Arrives as business and stays as a fixture in the orbit of the office.
+
+
+
+
+
+
+
+
07
+
Story Arcs
+
+ The first two are spoiler-free. Everything after is folded away — open only what you
+ want to know.
+
+
+
+
+
+
Opening
+
Introduction
+
Reigen meets Mob. The con-artist premise and the Spirits and Such office are laid out,
+ and the mentor–apprentice arrangement that carries the series begins.
+
+
+
+
Mid-series
+
Business as usual
+
A run of client jobs and staged exorcisms. Reigen keeps the fraud running while getting
+ steadily closer to Mob, tries his hand at expanding the business, and performs his named
+ “techniques” with total conviction.
+
+
+
+
+
+ Spoilers
+ Later arc — the turn
+
+
+
Separation
+
Rock bottom
+
A public scandal drives Reigen and Mob apart. The press and public opinion turn on
+ him, and he ends up isolated and out of work. Stripped of the act, he has to look at how
+ hollow his life had become — and how much of it he had been outsourcing to a
+ fourteen-year-old.
+
+
+
+
+
+
+
+ Spoilers
+ Later arc — the rebuild
+
+
+
Reunion & rebuild
+
Doing it properly
+
Reigen and Mob reconcile. He reassesses how he has been living, brings Serizawa in,
+ and puts Spirits and Such back together on an honest footing — choosing actual
+ self-improvement over another, better lie.
+
+
+
+
+
+
+
+ Spoilers
+ Finale — where he lands
+
+
+
Finale
+
The measure of him
+
In the climactic confrontation Reigen is support, not spectacle. He never acquires a
+ single power. His arc resolves instead into something rarer in this genre: a stable,
+ caring adult that Mob can actually rely on.
+
+
+
+
+
+
+ Arcs are listed in order of occurrence. Exact episode and chapter ranges are not stated here
+ because public sources did not confirm them consistently.
+
+
+
+
+
+
+
+
+
+
+
diff --git a/js/camera-shots.js b/js/camera-shots.js
deleted file mode 100644
index c73e623..0000000
--- a/js/camera-shots.js
+++ /dev/null
@@ -1,18 +0,0 @@
-// 2D framing presets, expressed relative to the sampled content: `zoom` is a
-// multiplier over the fit-to-screen scale; `cx`/`cy` are the focus point in
-// normalised content coordinates (0..1). Upper region ≈ face/hair for portraits.
-/** @typedef {{id:string,label:string,zoom:number,cx:number,cy:number}} Shot */
-
-/** @type {Shot[]} */
-export const SHOTS = [
- { id: "full", label: "① Full", zoom: 1.0, cx: 0.5, cy: 0.5 },
- { id: "portrait", label: "② Portrait", zoom: 1.3, cx: 0.5, cy: 0.4 },
- { id: "face", label: "③ Face", zoom: 2.1, cx: 0.5, cy: 0.3 },
- { id: "eyes", label: "④ Eyes", zoom: 3.2, cx: 0.5, cy: 0.28 },
- { id: "top", label: "⑤ Top", zoom: 2.0, cx: 0.5, cy: 0.12 },
- { id: "detail", label: "⑥ Detail", zoom: 4.0, cx: 0.5, cy: 0.3 },
-];
-
-export function getShot(id) {
- return SHOTS.find((s) => s.id === id) ?? SHOTS[0];
-}
diff --git a/js/image-loaders.js b/js/image-loaders.js
deleted file mode 100644
index 9f832a2..0000000
--- a/js/image-loaders.js
+++ /dev/null
@@ -1,22 +0,0 @@
-// Small helpers to obtain an from a File or a URL. Remote URLs request
-// CORS so the pixels can be read; the load rejects if the host denies it.
-
-export function loadImageFromFile(file) {
- return new Promise((resolve, reject) => {
- const url = URL.createObjectURL(file);
- const img = new Image();
- img.onload = () => { URL.revokeObjectURL(url); resolve(img); };
- img.onerror = (e) => { URL.revokeObjectURL(url); reject(e); };
- img.src = url;
- });
-}
-
-export function loadImageFromURL(url) {
- return new Promise((resolve, reject) => {
- const img = new Image();
- img.crossOrigin = "anonymous";
- img.onload = () => resolve(img);
- img.onerror = reject;
- img.src = url;
- });
-}
diff --git a/js/main.js b/js/main.js
index c6e1424..f477a8c 100644
--- a/js/main.js
+++ b/js/main.js
@@ -1,230 +1,12 @@
-// Scene bootstrap on PixiJS v8 (2D-native). The active image is sampled into a
-// ParticleContainer point cloud; framing is done by transforming a parent
-// "world" container (zoom/pan), and a tiled render exports a ~100 MP PNG.
-import { Application, Container, RenderTexture } from "pixi.js";
-import { sampleImage } from "./sample-image.js";
-import { buildParticleCloud } from "./pixi-points.js";
-import { loadImageFromFile, loadImageFromURL } from "./image-loaders.js";
-import { SHOTS, getShot } from "./camera-shots.js";
+/**
+ * Entry point for the Reigen Arataka fan tribute page.
+ *
+ * All page content lives in index.html — this module only adds two optional
+ * enhancements. If it fails to load, the page stays fully readable.
+ */
-const BG_HEX = 0xe9e3d6;
-const BG_CSS = "#e9e3d6";
-const canvas = document.getElementById("scene");
+import { initScrollReveal } from './scroll-reveal.js';
+import { initNavHighlight } from './nav-highlight.js';
-const app = new Application();
-try {
- await app.init({
- canvas,
- background: BG_HEX,
- antialias: true,
- resolution: Math.min(window.devicePixelRatio || 1, 3),
- autoDensity: true,
- resizeTo: window,
- preference: "webgl",
- });
-} catch (err) {
- document.body.innerHTML =
- `
` +
- `Could not start WebGL renderer: ${err}
`;
- throw err;
-}
-
-const world = new Container();
-app.stage.addChild(world);
-
-let cloud = null;
-let currentShotId = SHOTS[0].id;
-let userZoom = 1; // wheel multiplier on top of the shot zoom
-let tween = null;
-
-const lerp = (a, b, t) => a + (b - a) * t;
-const easeInOut = (t) => (t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2);
-const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
-
-function contentSize() {
- return cloud ? { w: cloud.sw * cloud.cell, h: cloud.sh * cloud.cell } : { w: 1, h: 1 };
-}
-function fitScale() {
- const { w, h } = contentSize();
- return Math.min(app.screen.width / w, app.screen.height / h) * 0.92;
-}
-function targetTransform(shot) {
- const { w, h } = contentSize();
- const scale = fitScale() * shot.zoom * userZoom;
- return {
- scale,
- x: app.screen.width / 2 - shot.cx * w * scale,
- y: app.screen.height / 2 - shot.cy * h * scale,
- };
-}
-function applyTransform(t) {
- world.scale.set(t.scale);
- world.position.set(t.x, t.y);
-}
-
-function goToShot(id, instant = false) {
- currentShotId = id;
- userZoom = 1;
- const t = targetTransform(getShot(id));
- if (instant) { applyTransform(t); tween = null; }
- else tween = { from: { scale: world.scale.x, x: world.x, y: world.y }, to: t, t: 0 };
- document.querySelectorAll(".shot-btn")
- .forEach((b) => b.classList.toggle("active", b.dataset.shot === id));
-}
-
-function setImage(img) {
- const sample = sampleImage(img, { maxSample: 900 });
- const pc = buildParticleCloud(app.renderer, sample, { cell: 1, coverage: 1.45 });
- if (cloud) {
- world.removeChild(cloud);
- const oldTex = cloud._dotTex;
- cloud.destroy();
- oldTex?.destroy(true);
- }
- cloud = pc;
- world.addChild(cloud);
- goToShot(currentShotId, true);
- return pc.count;
-}
-
-// --- Shot buttons -----------------------------------------------------------
-const shotBar = document.getElementById("shot-bar");
-SHOTS.forEach((shot, i) => {
- const btn = document.createElement("button");
- btn.className = "shot-btn" + (i === 0 ? " active" : "");
- btn.textContent = shot.label;
- btn.dataset.shot = shot.id;
- btn.addEventListener("click", () => goToShot(shot.id));
- shotBar.appendChild(btn);
-});
-
-// --- Wheel zoom -------------------------------------------------------------
-canvas.addEventListener("wheel", (e) => {
- e.preventDefault();
- if (tween) return;
- userZoom = clamp(userZoom * (e.deltaY < 0 ? 1.1 : 0.9), 0.4, 12);
- applyTransform(targetTransform(getShot(currentShotId)));
-}, { passive: false });
-
-window.addEventListener("resize", () => applyTransform(targetTransform(getShot(currentShotId))));
-
-// --- Image loading (file / drag-drop / URL / bundled default) ---------------
-const loadStatus = document.getElementById("load-status");
-function useImage(img) {
- const n = setImage(img);
- if (loadStatus) loadStatus.textContent = `Loaded (${n.toLocaleString()} pts)`;
-}
-async function useImageFile(file) {
- if (!file || !file.type.startsWith("image/")) return;
- if (loadStatus) loadStatus.textContent = "Loading image…";
- try { useImage(await loadImageFromFile(file)); }
- catch { if (loadStatus) loadStatus.textContent = "Could not read that image."; }
-}
-
-const fileInput = document.getElementById("image-input");
-document.getElementById("load-btn").addEventListener("click", () => fileInput.click());
-fileInput.addEventListener("change", () => useImageFile(fileInput.files[0]));
-
-const urlInput = document.getElementById("url-input");
-async function useImageURL() {
- const url = urlInput.value.trim();
- if (!url) return;
- if (loadStatus) loadStatus.textContent = "Loading URL…";
- try { useImage(await loadImageFromURL(url)); }
- catch { if (loadStatus) loadStatus.textContent = "Could not load that URL (CORS blocked, or not an image)."; }
-}
-document.getElementById("url-btn").addEventListener("click", useImageURL);
-urlInput.addEventListener("keydown", (e) => { if (e.key === "Enter") useImageURL(); });
-
-canvas.addEventListener("dragover", (e) => e.preventDefault());
-canvas.addEventListener("drop", (e) => { e.preventDefault(); useImageFile(e.dataTransfer.files[0]); });
-
-// --- Tiled ~100 MP PNG export ----------------------------------------------
-let exporting = false;
-const SS = 2; // supersample per tile then downscale for anti-aliasing
-async function exportHighRes(targetMP = 100) {
- if (exporting || !cloud) return;
- exporting = true;
- const statusEl = document.getElementById("export-status");
-
- // Content rect currently visible (in content units).
- const s = world.scale.x;
- const cl = (0 - world.x) / s, ct = (0 - world.y) / s;
- const rectW = app.screen.width / s, rectH = app.screen.height / s;
- const aspect = rectW / rectH;
-
- let bigW = Math.min(16000, Math.round(Math.sqrt(targetMP * 1e6 * aspect)));
- let bigH = Math.min(16000, Math.round(bigW / aspect));
- const TILE = 2000;
- const tilesX = Math.ceil(bigW / TILE), tilesY = Math.ceil(bigH / TILE);
- const tileW = Math.ceil(bigW / tilesX), tileH = Math.ceil(bigH / tilesY);
-
- const big = document.createElement("canvas");
- big.width = tileW * tilesX;
- big.height = tileH * tilesY;
- const ctx = big.getContext("2d");
- ctx.imageSmoothingEnabled = true;
- ctx.imageSmoothingQuality = "high";
- ctx.fillStyle = BG_CSS;
- ctx.fillRect(0, 0, big.width, big.height);
-
- const saved = { scale: world.scale.x, x: world.x, y: world.y };
- const subW = rectW / tilesX, subH = rectH / tilesY;
-
- for (let iy = 0; iy < tilesY; iy++) {
- for (let ix = 0; ix < tilesX; ix++) {
- const subL = cl + subW * ix;
- const subT = ct + subH * iy;
- const tileScale = (tileW * SS) / subW;
- world.scale.set(tileScale);
- world.position.set(-subL * tileScale, -subT * tileScale);
- const rt = RenderTexture.create({ width: tileW * SS, height: tileH * SS });
- app.renderer.render({ container: world, target: rt });
- const tileCanvas = app.renderer.extract.canvas(rt);
- ctx.drawImage(tileCanvas, ix * tileW, iy * tileH, tileW, tileH);
- rt.destroy(true);
- }
- if (statusEl) {
- statusEl.textContent = `Rendering ${Math.round(((iy + 1) / tilesY) * 100)}%…`;
- await new Promise((r) => setTimeout(r));
- }
- }
-
- world.scale.set(saved.scale);
- world.position.set(saved.x, saved.y);
-
- big.toBlob((blob) => {
- const url = URL.createObjectURL(blob);
- const a = document.createElement("a");
- a.href = url;
- a.download = `points-${big.width}x${big.height}.png`;
- a.click();
- URL.revokeObjectURL(url);
- if (statusEl) statusEl.textContent = `Saved ${big.width}×${big.height}px`;
- exporting = false;
- }, "image/png");
-}
-document.getElementById("export-btn").addEventListener("click", () => {
- const el = document.getElementById("export-status");
- if (el) el.textContent = "Preparing…";
- setTimeout(() => exportHighRes(100), 30);
-});
-
-// --- Tween loop -------------------------------------------------------------
-app.ticker.add((ticker) => {
- if (!tween) return;
- tween.t = Math.min(1, tween.t + ticker.deltaMS / 900);
- const e = easeInOut(tween.t);
- world.scale.set(lerp(tween.from.scale, tween.to.scale, e));
- world.position.set(lerp(tween.from.x, tween.to.x, e), lerp(tween.from.y, tween.to.y, e));
- if (tween.t >= 1) tween = null;
-});
-
-// --- Load the bundled CC0 default (falls back to ./reference.png) -----------
-(async () => {
- for (const src of ["./assets/anime-portrait-cc0.png", "./reference.png"]) {
- try { useImage(await loadImageFromURL(src)); return; }
- catch { /* try next */ }
- }
- if (loadStatus) loadStatus.textContent = "Load an image to begin.";
-})();
+initScrollReveal();
+initNavHighlight();
diff --git a/js/nav-highlight.js b/js/nav-highlight.js
new file mode 100644
index 0000000..40c3122
--- /dev/null
+++ b/js/nav-highlight.js
@@ -0,0 +1,61 @@
+/**
+ * Marks the in-page nav link for the section currently in view with
+ * aria-current="true" (styled in CSS).
+ *
+ * Without this module the nav is still a plain list of working anchor links.
+ */
+
+export function initNavHighlight(navSelector = '.site-nav') {
+ const nav = document.querySelector(navSelector);
+ if (!nav) return;
+
+ const links = Array.from(nav.querySelectorAll('a[href^="#"]'));
+ const entries = links
+ .map((link) => ({ link, section: document.querySelector(link.hash) }))
+ .filter((entry) => entry.section);
+
+ if (entries.length === 0 || !('IntersectionObserver' in window)) return;
+
+ let currentLink = null;
+
+ const setCurrent = (link) => {
+ if (link === currentLink) return;
+ if (currentLink) currentLink.removeAttribute('aria-current');
+ if (link) link.setAttribute('aria-current', 'true');
+ currentLink = link;
+ if (link) keepInView(nav, link);
+ };
+
+ const visible = new Set();
+
+ const observer = new IntersectionObserver(
+ (records) => {
+ for (const record of records) {
+ if (record.isIntersecting) visible.add(record.target);
+ else visible.delete(record.target);
+ }
+ // Highest section still in view wins, so scrolling up feels right too.
+ const active = entries.find((entry) => visible.has(entry.section));
+ setCurrent(active ? active.link : null);
+ },
+ { rootMargin: '-30% 0px -55% 0px', threshold: 0 }
+ );
+
+ entries.forEach((entry) => observer.observe(entry.section));
+}
+
+/** Scrolls the horizontal nav strip so the active chip stays reachable. */
+function keepInView(nav, link) {
+ const strip = nav.querySelector('ul');
+ if (!strip || strip.scrollWidth <= strip.clientWidth) return;
+
+ const linkBox = link.getBoundingClientRect();
+ const stripBox = strip.getBoundingClientRect();
+ if (linkBox.left >= stripBox.left && linkBox.right <= stripBox.right) return;
+
+ const delta = linkBox.left - stripBox.left - (stripBox.width - linkBox.width) / 2;
+ const behavior = window.matchMedia('(prefers-reduced-motion: reduce)').matches
+ ? 'auto'
+ : 'smooth';
+ strip.scrollBy({ left: delta, behavior });
+}
diff --git a/js/pixi-points.js b/js/pixi-points.js
deleted file mode 100644
index 5fb1370..0000000
--- a/js/pixi-points.js
+++ /dev/null
@@ -1,54 +0,0 @@
-// Build a PixiJS v8 ParticleContainer point cloud from sampled image pixels.
-// Each pixel becomes one round, per-point-tinted particle. Positions/scales are
-// static (framing is done by transforming the parent world container), so the
-// container uses the fastest static-particle path.
-
-import { ParticleContainer, Particle, Graphics } from "pixi.js";
-
-// A soft round white dot texture, tinted per particle. `r` is its pixel radius.
-export function makeDotTexture(renderer, r = 8) {
- const g = new Graphics().circle(0, 0, r).fill(0xffffff);
- const tex = renderer.generateTexture(g);
- g.destroy();
- return tex;
-}
-
-/**
- * @param {import("pixi.js").Renderer} renderer
- * @param {{xs:Int16Array, ys:Int16Array, colors:Uint32Array, sw:number, sh:number}} sample
- * @param {object} [opts] { cell: world units per sample cell, coverage }
- * @returns {ParticleContainer} with .sw/.sh/.cell/.count attached
- */
-export function buildParticleCloud(renderer, sample, opts = {}) {
- const cell = opts.cell ?? 1;
- const coverage = opts.coverage ?? 1.4;
- const dotR = 8;
- const dotTex = makeDotTexture(renderer, dotR);
- // dot world size = (2*dotR) * scale; we want it ~= cell*coverage.
- const scale = (cell * coverage) / (2 * dotR);
-
- const pc = new ParticleContainer({
- dynamicProperties: { position: false, scale: false, rotation: false, color: false },
- });
-
- const { xs, ys, colors } = sample;
- for (let i = 0; i < xs.length; i++) {
- pc.addParticle(new Particle({
- texture: dotTex,
- x: xs[i] * cell,
- y: ys[i] * cell,
- scaleX: scale,
- scaleY: scale,
- tint: colors[i],
- anchorX: 0.5,
- anchorY: 0.5,
- }));
- }
-
- pc.sw = sample.sw;
- pc.sh = sample.sh;
- pc.cell = cell;
- pc.count = xs.length;
- pc._dotTex = dotTex; // keep a handle so we can free it on destroy
- return pc;
-}
diff --git a/js/sample-image.js b/js/sample-image.js
deleted file mode 100644
index 5264e6e..0000000
--- a/js/sample-image.js
+++ /dev/null
@@ -1,47 +0,0 @@
-// Framework-agnostic image sampler: read a raster image's pixels into parallel
-// arrays of point positions (in sample-grid units) and packed RGB colours.
-// Used to "reproduce an image with points". Skips (near-)transparent pixels so
-// PNG cut-outs keep their shape.
-
-/**
- * @param {HTMLImageElement} img
- * @param {object} [opts]
- * @param {number} [opts.maxSample] max sampled pixels on the long side
- * @param {boolean} [opts.skipAlpha] drop near-transparent pixels
- * @returns {{xs:Int16Array, ys:Int16Array, colors:Uint32Array, sw:number, sh:number, count:number}}
- */
-export function sampleImage(img, opts = {}) {
- const maxSample = opts.maxSample ?? 900;
- const skipAlpha = opts.skipAlpha ?? true;
-
- const ar = img.width / img.height;
- let sh = Math.min(maxSample, img.height || maxSample);
- let sw = Math.round(sh * ar);
- if (sw > maxSample) { sw = maxSample; sh = Math.round(sw / ar); }
-
- const cv = document.createElement("canvas");
- cv.width = sw;
- cv.height = sh;
- const ctx = cv.getContext("2d", { willReadFrequently: true });
- ctx.drawImage(img, 0, 0, sw, sh);
- const data = ctx.getImageData(0, 0, sw, sh).data;
-
- const xs = [], ys = [], colors = [];
- for (let y = 0; y < sh; y++) {
- for (let x = 0; x < sw; x++) {
- const i = (y * sw + x) * 4;
- if (skipAlpha && data[i + 3] < 16) continue;
- xs.push(x);
- ys.push(y);
- colors.push((data[i] << 16) | (data[i + 1] << 8) | data[i + 2]);
- }
- }
- return {
- xs: Int16Array.from(xs),
- ys: Int16Array.from(ys),
- colors: Uint32Array.from(colors),
- sw,
- sh,
- count: xs.length,
- };
-}
diff --git a/js/scroll-reveal.js b/js/scroll-reveal.js
new file mode 100644
index 0000000..310431a
--- /dev/null
+++ b/js/scroll-reveal.js
@@ -0,0 +1,59 @@
+/**
+ * Reveals [data-reveal] elements as they enter the viewport.
+ *
+ * Progressive enhancement contract:
+ * - the hidden state lives behind `html.js`, so without this module running
+ * every element is already visible;
+ * - if IntersectionObserver is missing, everything is revealed immediately;
+ * - under `prefers-reduced-motion: reduce` the class is applied with no
+ * transition (CSS neutralises the transform/opacity), so nothing animates.
+ */
+
+const STAGGER_MS = 70;
+const MAX_STAGGER_STEPS = 4;
+
+export function initScrollReveal(selector = '[data-reveal]') {
+ const targets = Array.from(document.querySelectorAll(selector));
+ if (targets.length === 0) return;
+
+ const revealAll = () => targets.forEach((el) => el.classList.add('is-revealed'));
+
+ if (!('IntersectionObserver' in window)) {
+ revealAll();
+ return;
+ }
+
+ const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+ if (reduced) {
+ revealAll();
+ return;
+ }
+
+ // Stagger siblings that share a parent so grids cascade instead of popping.
+ const indexInGroup = new Map();
+ targets.forEach((el) => {
+ const parent = el.parentElement;
+ const next = (indexInGroup.get(parent) ?? -1) + 1;
+ indexInGroup.set(parent, next);
+ const step = Math.min(next, MAX_STAGGER_STEPS);
+ if (step > 0) el.style.setProperty('--reveal-delay', `${step * STAGGER_MS}ms`);
+ });
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ for (const entry of entries) {
+ if (!entry.isIntersecting) continue;
+ entry.target.classList.add('is-revealed');
+ observer.unobserve(entry.target);
+ }
+ },
+ { rootMargin: '0px 0px -10% 0px', threshold: 0.08 }
+ );
+
+ targets.forEach((el) => observer.observe(el));
+
+ // Signal the inline failsafe in index.html only once observation is actually
+ // established. Signalling any earlier means a throw further down this
+ // function would disarm the failsafe while elements are still hidden.
+ document.documentElement.dataset.revealReady = 'true';
+}
diff --git a/plans/reports/from-code-reviewer-to-integrator-reigen-fansite-review-report.md b/plans/reports/from-code-reviewer-to-integrator-reigen-fansite-review-report.md
new file mode 100644
index 0000000..2c4002e
--- /dev/null
+++ b/plans/reports/from-code-reviewer-to-integrator-reigen-fansite-review-report.md
@@ -0,0 +1,247 @@
+# Reigen fan page — production-readiness review
+
+Reviewer: code-reviewer · 2026-07-25 · branch `main` @ b3e435c (working tree clean)
+Scope: `index.html` (573 L), `css/style.css` (994 L), `js/main.js`, `js/scroll-reveal.js`, `js/nav-highlight.js`, `assets/favicon.svg`, `README.md`. ~1714 LOC.
+
+**Method — empirical.** Browser automation *was* available: `playwright-core` driving the local Chromium build at
+`~/AppData/Local/ms-playwright/chromium-1228`, against `python -m http.server 8099`. 10 scripted runs: robustness matrix
+(JS off / `js/*` aborted / partial graph / throw-after-signal / IO removed / 2.5 s-delayed modules), 12-width responsive
+sweep, pixel-sampled contrast (screenshot with glyphs forced transparent → canvas decode → real rendered background),
+CDP `Accessibility.getFullAXTree`, keyboard driving, print-media emulation, dead-selector scan. Findings marked
+**[verified]** were reproduced; **[suspected]** is code-reading only.
+
+---
+
+## Blocker
+
+### B1. `revealReady` is signalled before the work, so a mid-execution throw hides the page permanently [verified]
+`js/scroll-reveal.js:20` — `document.documentElement.dataset.revealReady = 'true'` is the **first** statement of
+`initScrollReveal()`. The inline failsafe (`index.html:29-31`) only drops `html.js` if that flag is unset at 1200 ms.
+Anything that throws *after* line 20 disarms the failsafe and leaves `.js [data-reveal] { opacity: 0 }` (`css/style.css:955`)
+in force forever.
+
+Reproduced by serving a `scroll-reveal.js` that sets the flag then throws:
+
+```
+E: reveal signals then throws
+ totalReveal=36 hiddenBeforeScroll=36 hiddenAfterFullScroll=36
+```
+
+36 of 36 revealed blocks — the whole profile table, all prose, all traits, both ability panels, every quote, all arcs —
+stay at `opacity: 0` after scrolling the full page. Only the hero and footer remain readable. Failure scenario: any
+future edit to the stagger loop / `matchMedia` / `IntersectionObserver` construction, a partial CDN or extension-mangled
+delivery, or a browser that throws on `IntersectionObserver` options. Passing CI proves nothing here.
+
+Fix (both halves):
+1. Move the signal to the end of successful setup — after the `targets.forEach(el => observer.observe(el))` loop, and
+ after each early `return` path (`revealAll()` branches).
+2. Make the module fail loudly-but-safely: wrap the body in `try { … } catch (e) { document.documentElement.classList.remove('js'); throw e; }`.
+
+Verified-clean counterparts, for contrast — the rest of the progressive-enhancement contract is sound:
+JS fully disabled → `hiddenWithText=0`; `js/*` aborted → `0`; only `nav-highlight.js` aborted → `0`;
+`IntersectionObserver` deleted → `0`; modules delayed 2.5 s (arriving after the disarm) → `0`, degrades to no animation.
+
+---
+
+## Major
+
+### M1. Hero disclaimer fails WCAG AA at ≤480 px — it sits on top of the tan figure [verified]
+`css/style.css:425-431` (`.hero__note`, `--paper-mute` #8f8a80) over `css/style.css:305-311` (`.figure`, `--tan` at
+`opacity: .5`). The scrim at `css/style.css:258-269` darkens only the *left* 70% and the *top* 40%; the figure is
+bottom-right, exactly where the note lands once the hero body is full-width.
+
+Pixel-sampled against the real rendered background:
+
+| viewport | bg avg | contrast | verdict (13 px text, needs 4.5:1) |
+|---|---|---|---|
+| 320 | rgb(56,58,52) | **3.35:1** | FAIL |
+| 360 | rgb(52,54,49) | **3.56:1** | FAIL |
+| 390 | rgb(48,50,46) | **3.77:1** | FAIL |
+| 480 | rgb(30,35,34) | 4.64:1 | marginal |
+| ≥600 | rgb(13-16,…) | 5.2–5.5:1 | pass |
+
+Worst single glyph column is **1.5:1** (background rgb(118,104,80) — the jacket). Screenshot at 360 px confirms the
+text is rendered across the figure's head and shoulder. This is the "fan-made / not affiliated" notice, i.e. the one
+paragraph with legal weight, unreadable on the most common viewport class.
+
+Fix: extend the scrim's vertical gradient to cover the bottom (add a `linear-gradient(0deg, rgba(12,12,17,.8), transparent 45%)`
+layer), or below `48rem` move `.figure` clear of the copy / drop its opacity, or give `.hero__note` its own
+`background: rgba(12,12,17,.72)` + padding. Do not fix by lightening `--paper-mute` alone — the worst pixel is 1.5:1.
+
+### M2. Profile table publishes a disputed birthday as flat fact [verified — contradicts the page's own policy]
+`index.html:153` → `Birthday | December 10`. Public sources disagree (December 10 vs October 10 across fan databases).
+The footer at `index.html:558-560` states *"Claims that sources disagreed on, or that could not be verified, were left
+off this page rather than published as fact"*, and `README.md:76-79` repeats it. The page breaks its own stated
+integrity rule in row 3 of its first data table. Fix: drop the row, or label it `December 10 (sources vary)`.
+
+### M3. `English voice: Chris Niosi` is presented as the single canon answer [verified as incomplete]
+`index.html:160`. The role was recast across the run — Kyle McCarley voiced Reigen for Season-2-era dub material, with
+Niosi credited again for Season 3. A bare name reads as "the" English VA. Fix: `Chris Niosi (S1, S3); Kyle McCarley (S2-era)`,
+or scope the row to the season the rest of the row cites. Same policy conflict as M2.
+
+### M4. The skill bars read as canon stats, not as fan flavour — plainly [verified by inspection of rendered page]
+Asked directly: **to a first-time visitor these read as canon stats.** `index.html:256-284` renders four precise
+percentages (94 / 90 / 88 / 72%) as filled segmented meters in a panel headed "Genuine → Real skills", inside a page
+whose immediately preceding section is a sourced production data table. The visual grammar is a stat block; the
+"Expert / Very high / Surprising" labels do not undercut it because the bar lengths are visibly distinct and precise.
+
+Mitigations exist but are too weak: the disclaimer (`index.html:289`) is `--fs-xs` / `--paper-mute` fine print placed
+*after* all five rows, i.e. the least prominent text in the panel, read last; and the "one number on this page that is
+canon" note (`index.html:286`) is attached to a different row. Note also the meter track is `aria-hidden="true"`, so
+screen-reader users get "Massage & acupressure — Expert" and never see a number — the two audiences are shown
+different claims.
+
+Fix (pick one): (a) move the disclaimer above the list as a visible `.panel__intro` sibling to the "Theatre" panel's
+intro, so the framing lands before the data; (b) drop numeric precision to a coarse 5-segment "4 of 5" rendering that
+cannot be misread as a percentage; (c) both. Everything else in this area is clean — no episode or chapter citations on
+any quote, the "why no citation" note at `index.html:372-376` is honest, arcs carry no invented ranges, and
+`Chapter 1` / `S1E1 + title + July 2016` are real and verifiable.
+
+---
+
+## Minor
+
+- **m1. `README.md:29-30` overstates the failsafe** — *"If the JavaScript fails to load, the reveal state disarms
+ itself so no content can be left invisible"*. False for B1 (loads, then throws). `README.md:23-24` claims
+ "WCAG AA text contrast" — false at ≤480 px per M1. Fix both after the code fixes. [verified]
+- **m2. No error isolation in the entry point** — `js/main.js:11-12` calls `initScrollReveal()` then
+ `initNavHighlight()` bare. A throw in the first prevents the second (and vice-versa for future inits). Wrap each in
+ `try/catch`. [suspected — ordering read from source; B1 is the reproduced consequence]
+- **m3. Footer source links miss the 24 px target minimum** — `css/style.css:927-928`: `.sources li` links measure
+ 15 px tall with an 8 px `gap` (≈23 px effective spacing) at 390 px. Each `
` contains only a link, so the
+ inline-text exemption for WCAG 2.2 SC 2.5.8 is arguable at best. Fix: `gap: var(--sp-3)` or
+ `.sources li a { display: inline-block; padding-block: 0.3rem }`. Nav links (37 px) and summaries (44 px) are fine. [verified]
+- **m4. Print force-opens every spoiler** — `css/style.css:992` `.spoiler__body { display: block }` overrides the
+ closed ``. In print media the closed body renders 233 px tall, so printing or PDF-ing the page dumps all
+ three spoiler arcs while the summary still reads "reveal". If intentional, it defeats the opt-in gate for anyone who
+ saves the page to read later. Decide: drop the rule, or add a print-only note that spoilers are expanded. [verified]
+- **m5. Skip link leaves focus on ``** — after Tab+Enter, `document.activeElement === BODY` (scrollY 65, i.e. the
+ scroll lands correctly). Chromium's sequential-focus starting point makes the next Tab land inside `main`, so it
+ works here, but this relies on non-universal behaviour. Add `tabindex="-1"` to `` (`index.html:79`). [verified]
+- **m6. Nav edge fade never retracts** — `css/style.css:189-196` `.site-nav::after` is unconditional below `56rem`.
+ Scrolled fully to the end at 360 px, it still overlays the last 23 px of the "Arcs" chip, dimming its trailing glyphs
+ from 9.25:1 to 6.5:1 (still AA-pass, so cosmetic) and implying content that isn't there. Fix: fade it out via a
+ scroll-driven class, or `mask-image` on the `ul` instead. [verified]
+- **m7. `--header-h` is a wrong hardcoded guess** — `css/style.css:53` declares `4.25rem` (68 px); measured header is
+ **98 px** below 900 px viewport and 65 px above. `.section { scroll-margin-top: calc(68px + 1rem) }` is therefore
+ 14 px short on mobile. No visible defect *today* — anchor landings measured clean at 360 and 1280 px (`#profile`
+ numTop 141 vs header bottom 98) because `--sp-9` section padding absorbs the error — but the token is a latent trap.
+ Fix: `scroll-margin-top: 7rem` for the narrow case, or set `--header-h` from a media query. [verified]
+- **m8. Pseudo-element text leaks into the disclosure's accessible name** — `css/style.css:887-896` `content: "reveal"`
+ yields AX name `"SPOILERS LATER ARC — THE TURN REVEAL"` (and `… HIDE` when open), duplicating state that the
+ `DisclosureTriangle` role + `expanded` property already convey. Harmless but noisy. [verified]
+
+## Nit
+
+- `--fs-xl` (`css/style.css:40`) is defined and never referenced — the *only* unused token, and the dead-selector scan
+ found **zero** unmatched selectors across the whole sheet. Token/CSS hygiene is genuinely good. [verified]
+- `css/style.css:898` sets `padding` then `padding-top` in the same block; fold into one declaration.
+- `css/style.css:684` `background-size: 11px 100%` is redundant — the `repeating-linear-gradient` already has an 11 px period.
+- `css/style.css:749` `.quote` transitions `transform`, but no rule ever changes its transform.
+- `README.md:12` says "Ten sections"; nine are listed. `README.md:13` says "four real skills"; the list has five rows.
+- No `og:image` (`index.html:11-14`), so link previews render bare.
+
+---
+
+## Clean areas (one line each)
+
+- **No-JS / broken-JS robustness:** clean apart from B1 — 5 of 6 failure modes leave 0 hidden elements.
+- **Responsive 320→1400 px:** clean. `documentElement.scrollWidth === clientWidth` at all 12 widths tested; zero
+ page-level horizontal overflow; the only internal overflows are intentional (`.site-nav ul` scroller, `.hero__art`
+ clip, `.visually-hidden` caption). No overflow or layout break with Google Fonts blocked either.
+- **Stacked profile table ARIA:** clean and *effective*, not just valid. CDP AX tree is byte-identical at 360 px
+ (`display:block`) and 1280 px (native): `table 1, rowgroup 1, row 10, rowheader 10, cell 10, caption 1`, table name
+ "Identity and production details for Reigen Arataka". Rows render at 77 px with no zero-height rows; `th` never
+ clips (`scrollWidth === clientWidth` at the 44 rem boundary); no horizontal scroll needed at any width.
+- **Structure:** exactly one `
`, 33 headings, zero skipped levels; 1 header / 1 main / 1 footer / 1 labelled nav;
+ zero unlabelled ``s.
+- **Keyboard & focus:** clean. Skip link is first in tab order with a 3 px `--acid` outline; nav links, summaries,
+ footer links and brand all show the same visible ring; `` toggles on both Enter and Space.
+- **Contrast elsewhere:** 29 of 31 pixel-sampled targets pass (only `.hero__note` fails; `.scroll-cue` is
+ `aria-hidden` decoration at 5.2:1 avg). The token comments' claimed ratios are conservative, not optimistic.
+- **Errors & paths:** zero console errors and zero failed requests on normal load; zero root-absolute `src`/`href`
+ (GitHub Pages subpath safe); no empty links; no `