feat: replace the point-cloud demo with a Reigen Arataka fan page

Single-scroll static page about Reigen Arataka from Mob Psycho 100:
profile, personality, real skills against the performed "techniques",
quotes, relationships and spoiler-gated story arcs.

All artwork is hand-authored CSS/SVG — the repo carries no character
images at all, since the series is copyrighted. The footer states the
page is unofficial and non-commercial and gives a takedown contact.

Content is progressive-enhancement safe: every section lives in the
markup, and the reveal animations only arm themselves once the observer
is attached, so a failed module can never leave content invisible.

Facts that sources disagreed on (birthday, English voice actor) are
omitted rather than published, and quotes carry no episode citation
because none could be verified.

Removes the unrelated PixiJS point-cloud demo that previously occupied
the repo root.
This commit is contained in:
2026-07-25 21:46:29 +07:00
parent b3e435c9a0
commit 57c30722ae
15 changed files with 2232 additions and 613 deletions
+59 -42
View File
@@ -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 `<img>` 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 `<h1>`,
keyboard-operable spoiler disclosures, visible focus rings, WCAG AA text
contrast, decorative SVG hidden from assistive tech.
- **Spoilers are opt-in.** Story-arc entries that reveal plot sit behind
`<details>` disclosures with a SPOILERS badge; nothing is spoiled above them.
- **Motion is optional.** Scroll reveals and the nav highlight are progressive
enhancement, and `prefers-reduced-motion: reduce` turns the animation off.
If the JavaScript fails to load, the reveal state disarms itself so no content
can be left invisible.
## Run locally
Because it uses ES modules and an import map, open it through a local server
(not `file://`):
It uses ES modules, so open it through a local server rather than `file://`:
```bash
python3 -m http.server 8099
python -m http.server 8099
# then visit http://localhost:8099
```
## Project structure
```
index.html # markup, import map, UI chrome
css/style.css # layout and controls styling
js/main.js # PixiJS app: world transform, shots, 100MP export
js/sample-image.js # image -> point positions + colours (framework-agnostic)
js/pixi-points.js # builds the PixiJS ParticleContainer point cloud
js/image-loaders.js # load an <img> from a File or URL
js/camera-shots.js # 2D framing presets (zoom + focus)
assets/anime-portrait-cc0.png # default CC0 image (public domain)
.github/workflows/ # GitHub Pages deployment
index.html # the whole page — all content is in the markup
css/style.css # design tokens, layout, the CSS/SVG artwork
js/main.js # entry point; wires up the enhancements
js/scroll-reveal.js # IntersectionObserver reveals (reduced-motion aware)
js/nav-highlight.js # highlights the nav link for the section in view
assets/favicon.svg # original salt-splash mark
plans/reports/ # research and build notes
.github/workflows/ # GitHub Pages deployment
```
PixiJS v8 is loaded from a CDN via the import map in `index.html`.
Type is loaded from Google Fonts (Archivo, Archivo Black, Newsreader) with
system-font fallbacks, so the page still sets sensibly if the request fails.
## Deployment
Pushing to `main` triggers the GitHub Actions workflow
(`.github/workflows/deploy-pages.yml`), which publishes the repo root to GitHub
Pages. Enable Pages once under **Settings → Pages → Source: GitHub Actions**.
Pushing to `main` runs `.github/workflows/deploy-pages.yml`, which publishes the
repo root to GitHub Pages. Enable it once under **Settings → Pages → Source:
GitHub Actions**. Because it deploys to a project subpath (`/reigen/`), all
asset paths must stay relative (`./css/style.css`) — a root-absolute path would
404 in production.
## Credits & licensing
## Licensing and credits
The bundled default image is **“Anime girl” by oksmith** (Open Clipart),
released under the [CC0 1.0 Public Domain Dedication](https://commons.wikimedia.org/wiki/File:Anime_girl_publicdomainq.png)
— free to use, including commercially.
This is a **fan work**. *Mob Psycho 100* and all its characters are the property
of **ONE, Shogakukan, and studio Bones**. This page is not affiliated with,
authorised by, or endorsed by any of them, and nothing here is for sale.
Images you load yourself are processed **locally in your browser** and are never
uploaded or published by this project. Do not load images you don't have the
right to use. Questions: [github.com/tiennm99](https://github.com/tiennm99).
No official art, screenshots or logos are used, reproduced or hotlinked — the
artwork is original to this repo for exactly that reason. Character quotes are
short excerpts used for commentary and attributed to Reigen.
Factual content was compiled from the sources credited in the page footer,
chiefly the Mob Psycho 100 Wiki and Wikipedia. Claims that could not be
corroborated were left out rather than guessed at, and quotes carry no episode
or chapter citation because their exact provenance could not be verified — an
invented citation would be worse than none.
The site's own code and original artwork are free to reuse. If you hold rights
to the series and want something changed or taken down, open an issue or reach
me at [github.com/tiennm99](https://github.com/tiennm99).
Binary file not shown.

Before

Width:  |  Height:  |  Size: 215 KiB

+16
View File
@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Salt-splash mark">
<title>Salt-splash mark</title>
<rect width="64" height="64" fill="#0c0c11"/>
<!-- Original abstract salt-throw motif: a fan of grains over an aura arc. -->
<path d="M8 46a26 26 0 0 1 48-6" fill="none" stroke="#ff3d7f" stroke-width="3" stroke-linecap="round" opacity=".85"/>
<g fill="#d9b483">
<circle cx="12" cy="24" r="4"/>
<circle cx="24" cy="16" r="3"/>
<circle cx="35" cy="22" r="2.2"/>
<circle cx="44" cy="13" r="3.4"/>
<circle cx="53" cy="21" r="2"/>
<circle cx="30" cy="30" r="1.6"/>
<circle cx="19" cy="34" r="2.4"/>
</g>
<circle cx="32" cy="54" r="3.4" fill="#17d6c6"/>
</svg>

After

Width:  |  Height:  |  Size: 731 B

+979 -160
View File
File diff suppressed because it is too large Load Diff
+564 -42
View File
@@ -1,51 +1,573 @@
<!doctype html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Anime portrait in points — PixiJS</title>
<link rel="stylesheet" href="./css/style.css" />
<!-- PixiJS v8 (2D-native WebGL) via an import map so main.js can `import ... from "pixi.js"` -->
<script type="importmap">
{
"imports": {
"pixi.js": "https://cdn.jsdelivr.net/npm/pixi.js@8/dist/pixi.min.mjs"
}
}
</script>
</head>
<body>
<div id="app">
<canvas id="scene"></canvas>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Reigen Arataka — Greatest Psychic of the 21st Century (Self-Proclaimed) | Fan Tribute</title>
<meta name="description" content="An unofficial fan tribute to Reigen Arataka of Mob Psycho 100: the con man with no psychic powers who turned out to be the best mentor in the series. Profile, personality, real skills vs. performed techniques, and quotes.">
<meta name="author" content="tiennm99">
<meta name="theme-color" content="#0c0c11">
<meta name="color-scheme" content="dark">
<meta property="og:type" content="website">
<meta property="og:title" content="Reigen Arataka — Fan Tribute">
<meta property="og:description" content="Unofficial fan page for Reigen Arataka of Mob Psycho 100. Zero psychic power. One hundred percent confidence.">
<meta property="og:url" content="https://tiennm99.github.io/reigen/">
<link rel="icon" href="./assets/favicon.svg" type="image/svg+xml">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Archivo+Black&family=Archivo:wght@400..700&family=Newsreader:ital,opsz,wght@0,6..72,500;1,6..72,400;1,6..72,500&display=swap">
<link rel="stylesheet" href="./css/style.css">
<script>
/* Arms the scroll-reveal hidden state before first paint (avoids a flash of
already-visible content). Failsafe: if js/main.js never runs — blocked,
404, syntax error, CSP — the class is dropped again so nothing stays
invisible. With JavaScript disabled this script never runs at all and the
page renders fully visible by default. */
(function () {
var root = document.documentElement;
root.classList.add('js');
setTimeout(function () {
if (root.dataset.revealReady !== 'true') root.classList.remove('js');
}, 1200);
})();
</script>
</head>
<body>
<header id="titlebar">
<h1>Anime portrait &middot; drawn in points</h1>
<p>PixiJS point cloud — load any image to reproduce it with points</p>
</header>
<a class="skip-link" href="#main">Skip to content</a>
<div id="shot-bar" role="toolbar" aria-label="Camera shots"></div>
<!-- Original hand-authored SVG symbols. Decorative only. -->
<svg class="sprite" aria-hidden="true" focusable="false" width="0" height="0"><defs>
<symbol id="i-salt" viewBox="0 0 64 40">
<g fill="currentColor">
<circle cx="4" cy="30" r="3.2"/><circle cx="12" cy="24" r="2.4"/><circle cx="19" cy="31" r="1.6"/>
<circle cx="22" cy="17" r="3"/><circle cx="30" cy="24" r="1.9"/><circle cx="31" cy="10" r="2.2"/>
<circle cx="38" cy="18" r="1.4"/><circle cx="40" cy="29" r="2.6"/><circle cx="44" cy="8" r="1.7"/>
<circle cx="48" cy="21" r="2.9"/><circle cx="52" cy="33" r="1.5"/><circle cx="55" cy="13" r="2.1"/>
<circle cx="60" cy="26" r="1.3"/><circle cx="26" cy="36" r="1.2"/><circle cx="34" cy="4" r="1.1"/>
<circle cx="8" cy="14" r="1.5"/><circle cx="15" cy="6" r="1.2"/>
</g>
</symbol>
<symbol id="i-quote" viewBox="0 0 40 32">
<path fill="currentColor" d="M0 32V16.8C0 7.5 5.4 1.2 15 0l1.8 5.4C11.4 6.9 8.4 10 8.1 14.4H15V32H0Zm23.2 0V16.8C23.2 7.5 28.6 1.2 38.2 0L40 5.4c-5.4 1.5-8.4 4.6-8.7 9h6.9V32H23.2Z"/>
</symbol>
</defs></svg>
<footer id="hint">
<span>Scroll to zoom &middot; drag an image in to use it as reference</span>
<button id="load-btn" type="button">Load image</button>
<input id="url-input" type="url" placeholder="…or paste image URL" />
<button id="url-btn" type="button">Load URL</button>
<span id="load-status"></span>
<button id="export-btn" type="button">Download ~100 MP PNG</button>
<span id="export-status"></span>
<input id="image-input" type="file" accept="image/*" hidden />
</footer>
<header class="site-header" id="top">
<div class="site-header__inner">
<a class="brand" href="#top">
<span class="brand__mark" lang="ja" aria-hidden="true"></span>
<span class="brand__text">
<b>Reigen Arataka</b>
<small>unofficial fan tribute</small>
</span>
</a>
<nav class="site-nav" aria-label="Sections of this page">
<ul>
<li><a href="#profile">Profile</a></li>
<li><a href="#who">Who He Is</a></li>
<li><a href="#personality">Personality</a></li>
<li><a href="#abilities">Abilities</a></li>
<li><a href="#quotes">Quotes</a></li>
<li><a href="#people">People</a></li>
<li><a href="#arcs">Arcs</a></li>
</ul>
</nav>
</div>
</header>
<p id="disclaimer">
Default image: “Anime girl” by oksmith (Open Clipart),
<a href="https://commons.wikimedia.org/wiki/File:Anime_girl_publicdomainq.png" target="_blank" rel="noopener">CC0 / public domain</a>.
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 dont have the right to use. Non-commercial demo;
any concern: <a href="https://github.com/tiennm99" target="_blank" rel="noopener">github.com/tiennm99</a>.
<main id="main">
<!-- ===================== HERO ===================== -->
<section class="hero" aria-labelledby="hero-title">
<div class="hero__art" aria-hidden="true">
<span class="aura aura--a"></span>
<span class="aura aura--b"></span>
<span class="aura aura--c"></span>
<svg class="rays" viewBox="0 0 400 400" focusable="false">
<g stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
<path d="M200 26v54M200 320v54M26 200h54M320 200h54"/>
<path d="M77 77l38 38M285 285l38 38M323 77l-38 38M115 285l-38 38" opacity=".7"/>
</g>
<circle cx="200" cy="200" r="150" fill="none" stroke="currentColor" stroke-width="1" stroke-dasharray="2 10" opacity=".55"/>
<circle cx="200" cy="200" r="186" fill="none" stroke="currentColor" stroke-width="1" stroke-dasharray="1 14" opacity=".35"/>
</svg>
<!-- Abstract suit-and-tie figure: original geometry, no facial features. -->
<svg class="figure" viewBox="0 0 300 400" focusable="false">
<path class="fg-neck" d="M134 156h32v42h-32z"/>
<ellipse class="fg-head" cx="150" cy="112" rx="53" ry="63"/>
<path class="fg-hair" d="M96 118C92 62 118 40 152 40s56 26 54 82c-4-18-16-30-34-30-26 0-56 10-64 30-4-2-8-2-12-4Z"/>
<path class="fg-jacket" d="M150 184c-40 4-84 20-102 40-18 20-26 74-28 176h260c-2-102-10-156-28-176-18-20-62-36-102-40Z"/>
<path class="fg-shirt" d="M116 192 150 184l34 8-34 88Z"/>
<path class="fg-lapel" d="M116 192 150 184v22l-48 58-6-32Zm68 0L150 184v22l48 58 6-32Z"/>
<path class="fg-tie" d="M141 197h18l5 16h-28Z"/>
<path class="fg-tie2" d="M136 214h28l-7 92-7 14-7-14Z"/>
</svg>
</div>
<div class="hero__body">
<p class="kicker kicker--strike"><span>Self-proclaimed</span></p>
<h1 id="hero-title" class="hero__title">
<span>Reigen</span> <span>Arataka</span>
</h1>
<p class="hero__ja" lang="ja">霊幻新隆</p>
<p class="hero__claim">&ldquo;The Greatest Psychic of the&nbsp;21st Century&rdquo;</p>
<p class="hero__tagline">
Zero psychic ability. A pocket full of salt, a very good massage,
and enough nerve to carry an entire exorcism business &mdash; and,
somehow, the most important adult in a psychic kid&rsquo;s life.
</p>
<ul class="stat-chips">
<li><span class="stat-chips__k">Psychic power</span><span class="stat-chips__v stat-chips__v--zero">0%</span></li>
<li><span class="stat-chips__k">Confidence</span><span class="stat-chips__v">100%</span></li>
</ul>
<p class="hero__note">
Fan-made tribute page. Not affiliated with the creators of <cite>Mob Psycho 100</cite>.
All artwork here is original CSS&nbsp;/&nbsp;SVG &mdash; no official images are used.
</p>
</div>
<script type="module" src="./js/main.js"></script>
</body>
<p class="scroll-cue" aria-hidden="true"><span></span>scroll</p>
</section>
<div class="salt-rule" aria-hidden="true"></div>
<!-- ===================== PROFILE ===================== -->
<section id="profile" class="section" aria-labelledby="h-profile">
<div class="section__head" data-reveal>
<p class="section__num" aria-hidden="true">01</p>
<h2 id="h-profile">Profile</h2>
<p class="section__lede">The paperwork behind the persona.</p>
</div>
<!-- Explicit ARIA roles: the narrow-screen layout restyles the table into
stacked rows, which would otherwise drop the implicit table semantics. -->
<div class="table-scroll" data-reveal>
<table class="data-table" role="table">
<caption class="visually-hidden">Identity and production details for Reigen Arataka</caption>
<tbody role="rowgroup">
<tr role="row"><th role="rowheader" scope="row">Name</th><td role="cell">Reigen Arataka <span class="t-ja" lang="ja">霊幻新隆</span></td></tr>
<tr role="row"><th role="rowheader" scope="row">Age</th><td role="cell">28</td></tr>
<tr role="row"><th role="rowheader" scope="row">Occupation</th><td role="cell">Owner and operator, Spirits and Such Consultation Office</td></tr>
<tr role="row"><th role="rowheader" scope="row">Workplace</th><td role="cell">Spirits and Such Consultation Office &mdash; one small rented room</td></tr>
<tr role="row"><th role="rowheader" scope="row">Affiliations</th><td role="cell">Mentor and employer to Mob (Shigeo Kageyama); works alongside Katsuya Serizawa</td></tr>
<tr role="row"><th role="rowheader" scope="row">First appearance (manga)</th><td role="cell">Chapter&nbsp;1</td></tr>
<tr role="row"><th role="rowheader" scope="row">First appearance (anime)</th><td role="cell">Season&nbsp;1, Episode&nbsp;1 &mdash; &ldquo;Self-Proclaimed Psychic: Reigen Arataka ~And Mob~&rdquo; (July&nbsp;2016)</td></tr>
<tr role="row"><th role="rowheader" scope="row">Japanese voice</th><td role="cell">Takahiro Sakurai</td></tr>
</tbody>
</table>
</div>
</section>
<!-- ===================== WHO HE IS ===================== -->
<section id="who" class="section section--who" aria-labelledby="h-who">
<div class="section__head" data-reveal>
<p class="section__num" aria-hidden="true">02</p>
<h2 id="h-who">Who He Is</h2>
<p class="section__lede">A fraud, technically. A mentor, actually.</p>
</div>
<div class="prose" data-reveal>
<p class="prose__drop">
Reigen sells exorcisms he cannot perform. He has no psychic ability whatsoever &mdash; not a
flicker &mdash; 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.
</p>
<p>
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 &mdash; whatever he calls the ritual &mdash;
actually listens, gives blunt practical advice, and sends them home lighter. The fake part
is the ghost. The help is real.
</p>
<p>
Then a quiet middle-schooler named Shigeo Kageyama &mdash; Mob &mdash; 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
<em>power is not the point.</em>
</p>
<blockquote class="pull">
Psychic powers, he tells Mob, are just another trait &mdash; like being fast, or good at
exams. What makes a person worth something is how they treat people.
</blockquote>
</div>
</section>
<!-- ===================== PERSONALITY ===================== -->
<section id="personality" class="section" aria-labelledby="h-personality">
<div class="section__head" data-reveal>
<p class="section__num" aria-hidden="true">03</p>
<h2 id="h-personality">Personality</h2>
<p class="section__lede">Six traits, pulling in different directions at once.</p>
</div>
<ul class="trait-grid">
<li class="trait" data-reveal>
<h3>Con artist</h3>
<p>Charm, quick thinking and social footwork, deployed to convince paying clients that he is a powerful psychic.</p>
</li>
<li class="trait" data-reveal>
<h3>Emotionally intelligent</h3>
<p>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.</p>
</li>
<li class="trait" data-reveal>
<h3>Outgoing performer</h3>
<p>Personable, verbally confident, entirely comfortable with an audience. Never met a silence he wouldn&rsquo;t fill.</p>
</li>
<li class="trait" data-reveal>
<h3>Protective mentor</h3>
<p>Started from self-interest and ended up genuinely invested in Mob&rsquo;s safety, self-esteem and growing up okay.</p>
</li>
<li class="trait" data-reveal>
<h3>Relentlessly pragmatic</h3>
<p>Aims at the real problem &mdash; the stress, the loneliness, the self-doubt &mdash; even while performing a supernatural solution to it.</p>
</li>
<li class="trait" data-reveal>
<h3>Flawed, with a floor</h3>
<p>Vain, evasive, allergic to accountability. Still refuses to truly hurt a client or exploit a child. He learns humility the hard way.</p>
</li>
</ul>
</section>
<!-- ===================== ABILITIES ===================== -->
<section id="abilities" class="section section--abilities" aria-labelledby="h-abilities">
<div class="section__head" data-reveal>
<p class="section__num" aria-hidden="true">04</p>
<h2 id="h-abilities">What He Can Actually Do</h2>
<p class="section__lede">
Left column: real, verifiable, unglamorous. Right column: the show.
</p>
</div>
<div class="split">
<!-- REAL -->
<div class="panel panel--real" data-reveal>
<p class="panel__tag">Genuine</p>
<h3 class="panel__title">Real skills</h3>
<p class="panel__intro">
One entry below is canon: he has no psychic ability at all. The rest is
this fan page&rsquo;s own read on how good he is at things &mdash; the
bars are a rough impression, not official stats.
</p>
<ul class="skill-list">
<li>
<div class="meter">
<p class="meter__head"><span class="meter__label">Massage &amp; acupressure</span><span class="meter__val">Expert</span></p>
<span class="meter__track" aria-hidden="true"><span class="meter__fill" style="--pct:94%"></span></span>
</div>
<p class="skill-list__note">Real acupoint work on shoulder and back tension. The most-used ability he owns.</p>
</li>
<li>
<div class="meter">
<p class="meter__head"><span class="meter__label">Counselling &amp; life advice</span><span class="meter__val">Expert</span></p>
<span class="meter__track" aria-hidden="true"><span class="meter__fill" style="--pct:90%"></span></span>
</div>
<p class="skill-list__note">Unlicensed, unbilled, and frequently the actual product being sold.</p>
</li>
<li>
<div class="meter">
<p class="meter__head"><span class="meter__label">Persuasion</span><span class="meter__val">Very high</span></p>
<span class="meter__track" aria-hidden="true"><span class="meter__fill" style="--pct:88%"></span></span>
</div>
<p class="skill-list__note">Talks his way into jobs, out of trouble, and around the truth.</p>
</li>
<li>
<div class="meter">
<p class="meter__head"><span class="meter__label">Physical fitness</span><span class="meter__val">Surprising</span></p>
<span class="meter__track" aria-hidden="true"><span class="meter__fill" style="--pct:72%"></span></span>
</div>
<p class="skill-list__note">No powers, but agile enough and smart enough about leverage to hold his own in a fight.</p>
</li>
<li>
<div class="meter meter--zero">
<p class="meter__head"><span class="meter__label">Psychic ability</span><span class="meter__val">None</span></p>
<span class="meter__track" aria-hidden="true"><span class="meter__fill" style="--pct:0%"></span></span>
</div>
<p class="skill-list__note">Zero. Not weak &mdash; absent. This is the one number on this page that is canon.</p>
</li>
</ul>
</div>
<!-- PERFORMED -->
<div class="panel panel--fake" data-reveal>
<p class="panel__tag">Theatre</p>
<h3 class="panel__title">&ldquo;Techniques&rdquo;</h3>
<p class="panel__intro">Branded, named, and announced at volume. All of it is stagecraft for the client&rsquo;s benefit.</p>
<ul class="tech-list">
<li class="tech">
<svg class="tech__icon" aria-hidden="true" focusable="false"><use href="#i-salt"></use></svg>
<h4>Salt Splash</h4>
<p>Throw salt at the target while shouting an incantation. Purification, allegedly.</p>
</li>
<li class="tech">
<svg class="tech__icon" aria-hidden="true" focusable="false"><use href="#i-salt"></use></svg>
<h4>Salt Heap Punch</h4>
<p>A punch, thrown with a salt-encrusted fist, for maximum visual drama.</p>
</li>
<li class="tech">
<svg class="tech__icon" aria-hidden="true" focusable="false"><use href="#i-salt"></use></svg>
<h4>Black Arts Crush</h4>
<p>A frightening name applied to what is, in fact, a very competent massage.</p>
</li>
<li class="tech">
<svg class="tech__icon" aria-hidden="true" focusable="false"><use href="#i-salt"></use></svg>
<h4>Graphic Purification</h4>
<p>Editing the evil spirit out of the client&rsquo;s photograph. On a computer. That is the whole technique.</p>
</li>
</ul>
</div>
</div>
<p class="punchline" data-reveal>
Nothing in the right column works.
Everything in the left column does.
</p>
</section>
<!-- ===================== QUOTES ===================== -->
<section id="quotes" class="section section--quotes" aria-labelledby="h-quotes">
<div class="section__head" data-reveal>
<p class="section__num" aria-hidden="true">05</p>
<h2 id="h-quotes">In His Own Words</h2>
<p class="section__lede">The fraud gives the best advice in the series.</p>
</div>
<div class="quote-stack">
<figure class="quote quote--lead" data-reveal>
<svg class="quote__glyph" aria-hidden="true" focusable="false"><use href="#i-quote"></use></svg>
<blockquote>
<p>Listen. Just because you have these psychic powers and abilities, it doesn&rsquo;t mean
you&rsquo;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.</p>
</blockquote>
</figure>
<figure class="quote" data-reveal>
<svg class="quote__glyph" aria-hidden="true" focusable="false"><use href="#i-quote"></use></svg>
<blockquote><p>In the end, you&rsquo;re the one who&rsquo;s going to get hurt. When things go
south, it&rsquo;s ok to run away and save yourself!</p></blockquote>
</figure>
<figure class="quote" data-reveal>
<svg class="quote__glyph" aria-hidden="true" focusable="false"><use href="#i-quote"></use></svg>
<blockquote><p>Being confident about your powers is fine, but you mustn&rsquo;t get conceited.
Depending on how we use our powers, they can also be deadly weapons. They&rsquo;re just like
knives.</p></blockquote>
</figure>
<figure class="quote" data-reveal>
<svg class="quote__glyph" aria-hidden="true" focusable="false"><use href="#i-quote"></use></svg>
<blockquote><p>Your life is your own, and it&rsquo;s up to you to live it exactly the way you
want.</p></blockquote>
</figure>
<figure class="quote quote--short" data-reveal>
<svg class="quote__glyph" aria-hidden="true" focusable="false"><use href="#i-quote"></use></svg>
<blockquote><p>The truth behind one&rsquo;s charm is kindness.</p></blockquote>
</figure>
</div>
<p class="fine-print fine-print--wide" data-reveal>
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.
</p>
</section>
<!-- ===================== PEOPLE ===================== -->
<section id="people" class="section" aria-labelledby="h-people">
<div class="section__head" data-reveal>
<p class="section__num" aria-hidden="true">06</p>
<h2 id="h-people">The People Around Him</h2>
<p class="section__lede">One office, several strays.</p>
</div>
<ul class="rel-list">
<li class="rel rel--primary" data-reveal>
<p class="rel__role">Employee &middot; mentee &middot; chosen family</p>
<h3>Mob <span class="rel__alt">Shigeo Kageyama</span></h3>
<p>The genuine psychic who works for the fake one. Reigen&rsquo;s motive slides from
&ldquo;useful asset&rdquo; to &ldquo;this kid has to turn out okay,&rdquo; and that shift is
the spine of his whole character.</p>
</li>
<li class="rel" data-reveal>
<p class="rel__role">Colleague</p>
<h3>Katsuya Serizawa</h3>
<p>Comes to work at Spirits and Such alongside Reigen, as the office &mdash; and Reigen &mdash;
get put back together on a healthier footing.</p>
</li>
<li class="rel" data-reveal>
<p class="rel__role">Uninvited</p>
<h3>Dimple</h3>
<p>An evil spirit who tried to make Reigen his puppet. Reigen&rsquo;s countermeasure was to
pretend he could not see him. Their dynamic stays comedic friction, never alliance.</p>
</li>
<li class="rel" data-reveal>
<p class="rel__role">Mob&rsquo;s brother</p>
<h3>Ritsu Kageyama</h3>
<p>Reigen occasionally goes to him for help or an opinion, and &mdash; notably &mdash; treats
him as an equal while doing it.</p>
</li>
<li class="rel" data-reveal>
<p class="rel__role">Rival, then not</p>
<h3>Teruki Hanazawa</h3>
<p>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&rsquo;t buy.</p>
</li>
<li class="rel" data-reveal>
<p class="rel__role">Client, then friend</p>
<h3>Tome Kurata</h3>
<p>Arrives as business and stays as a fixture in the orbit of the office.</p>
</li>
</ul>
</section>
<!-- ===================== ARCS ===================== -->
<section id="arcs" class="section section--arcs" aria-labelledby="h-arcs">
<div class="section__head" data-reveal>
<p class="section__num" aria-hidden="true">07</p>
<h2 id="h-arcs">Story Arcs</h2>
<p class="section__lede">
The first two are spoiler-free. Everything after is folded away &mdash; open only what you
want to know.
</p>
</div>
<ol class="timeline">
<li class="arc" data-reveal>
<p class="arc__when">Opening</p>
<h3>Introduction</h3>
<p>Reigen meets Mob. The con-artist premise and the Spirits and Such office are laid out,
and the mentor&ndash;apprentice arrangement that carries the series begins.</p>
</li>
<li class="arc" data-reveal>
<p class="arc__when">Mid-series</p>
<h3>Business as usual</h3>
<p>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
&ldquo;techniques&rdquo; with total conviction.</p>
</li>
<li class="arc arc--spoiler" data-reveal>
<details class="spoiler">
<summary>
<span class="spoiler__badge">Spoilers</span>
<span class="spoiler__label">Later arc &mdash; the turn</span>
</summary>
<div class="spoiler__body">
<p class="arc__when">Separation</p>
<h3>Rock bottom</h3>
<p>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 &mdash; and how much of it he had been outsourcing to a
fourteen-year-old.</p>
</div>
</details>
</li>
<li class="arc arc--spoiler" data-reveal>
<details class="spoiler">
<summary>
<span class="spoiler__badge">Spoilers</span>
<span class="spoiler__label">Later arc &mdash; the rebuild</span>
</summary>
<div class="spoiler__body">
<p class="arc__when">Reunion &amp; rebuild</p>
<h3>Doing it properly</h3>
<p>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 &mdash; choosing actual
self-improvement over another, better lie.</p>
</div>
</details>
</li>
<li class="arc arc--spoiler" data-reveal>
<details class="spoiler">
<summary>
<span class="spoiler__badge">Spoilers</span>
<span class="spoiler__label">Finale &mdash; where he lands</span>
</summary>
<div class="spoiler__body">
<p class="arc__when">Finale</p>
<h3>The measure of him</h3>
<p>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.</p>
</div>
</details>
</li>
</ol>
<p class="fine-print fine-print--wide" data-reveal>
Arcs are listed in order of occurrence. Exact episode and chapter ranges are not stated here
because public sources did not confirm them consistently.
</p>
</section>
</main>
<div class="salt-rule" aria-hidden="true"></div>
<footer class="site-footer">
<div class="site-footer__inner">
<section class="disclaimer" aria-labelledby="h-disclaimer">
<h2 id="h-disclaimer">Disclaimer</h2>
<p>
This is an <strong>unofficial, fan-made, non-commercial tribute page</strong>. It is not
affiliated with, authorised by, licensed by or endorsed by the rights holders of
<cite>Mob Psycho 100</cite>.
</p>
<p>
<cite>Mob Psycho 100</cite>, Reigen Arataka and all related characters and elements are the
property of <strong>ONE</strong>, <strong>Shogakukan</strong> and <strong>studio Bones</strong>.
All rights in the series belong to them.
</p>
<p>
No official artwork, screenshots, character images or logos are used anywhere on this page.
Every visual element here &mdash; the figure, the aura, the salt motifs, the meters &mdash; is
original CSS and SVG authored for this page. Quotations are short excerpts included for
commentary and identification.
</p>
<p>
If you hold rights in the series and have any concern about this page, please get in touch
via <a href="https://github.com/tiennm99" rel="noopener">github.com/tiennm99</a> and it will
be amended or taken down.
</p>
</section>
<section class="sources" aria-labelledby="h-sources">
<h2 id="h-sources">Fact sources</h2>
<p class="sources__intro">Details on this page were compiled from:</p>
<ul>
<li><a href="https://mob-psycho-100.fandom.com/wiki/Arataka_Reigen" rel="noopener nofollow">Mob Psycho 100 Wiki &mdash; Arataka Reigen</a></li>
<li><a href="https://gamerant.com/mob-psycho-100-who-is-reigen/" rel="noopener nofollow">GameRant &mdash; Who is Arataka Reigen?</a></li>
<li><a href="https://gamerant.com/mob-psycho-100-main-characters-age-height-birthday/" rel="noopener nofollow">GameRant &mdash; Main characters&rsquo; age, height &amp; birthday</a></li>
<li><a href="https://www.behindthevoiceactors.com/tv-shows/Mob-Psycho-100/" rel="noopener nofollow">Behind The Voice Actors &mdash; Mob Psycho 100 cast</a></li>
<li><a href="https://en.wikipedia.org/wiki/Takahiro_Sakurai" rel="noopener nofollow">Wikipedia &mdash; Takahiro Sakurai</a></li>
<li><a href="https://www.imdb.com/title/tt6014452/" rel="noopener nofollow">IMDb &mdash; Episode 1, &ldquo;Self-Proclaimed Psychic: Reigen Arataka ~And Mob~&rdquo;</a></li>
<li><a href="https://collider.com/why-we-love-mob-psycho-100-reigen-arataka/" rel="noopener nofollow">Collider &mdash; Why we love Reigen Arataka</a></li>
<li><a href="https://www.cbr.com/mob-psycho-100-reigen-arataka-life-lessons/" rel="noopener nofollow">CBR &mdash; Life lessons from Reigen Arataka</a></li>
<li><a href="https://otakukart.com/best-quotes-by-reigen-arataka-from-mob-psycho-100/" rel="noopener nofollow">OtakuKart &mdash; Best Reigen Arataka quotes</a></li>
</ul>
<p class="fine-print">
Claims that sources disagreed on, or that could not be verified, were left off this page
rather than published as fact.
</p>
</section>
</div>
<p class="site-footer__base">
<span lang="ja" aria-hidden="true"></span>
Built as a fan tribute &middot; static HTML, CSS and a little JavaScript &middot;
<a href="https://github.com/tiennm99" rel="noopener">github.com/tiennm99</a>
</p>
</footer>
<script type="module" src="./js/main.js"></script>
</body>
</html>
-18
View File
@@ -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];
}
-22
View File
@@ -1,22 +0,0 @@
// Small helpers to obtain an <img> 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;
});
}
+10 -228
View File
@@ -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 =
`<p style="color:#fff;font-family:sans-serif;padding:2rem">` +
`Could not start WebGL renderer: ${err}</p>`;
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();
+61
View File
@@ -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 });
}
-54
View File
@@ -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;
}
-47
View File
@@ -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,
};
}
+59
View File
@@ -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';
}
@@ -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.25.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 `<li>` 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 `<details>`. 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 `<body>`** — 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 `<main id="main">` (`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 `<h1>`, 33 headings, zero skipped levels; 1 header / 1 main / 1 footer / 1 labelled nav;
zero unlabelled `<section>`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; `<summary>` 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 `<svg>` missing `aria-hidden` outside an already-hidden ancestor;
every CJK string carries `lang="ja"`; removing `[data-reveal]` and `.site-nav` from the DOM produces no page errors.
- **Reduced motion:** honoured — reveals bypassed (`hidden=0`), `.scroll-cue` `animation-name: none`.
- **Deploy workflow:** `.github/workflows/deploy-pages.yml` publishes `.` with `.nojekyll` present; correct for a
project-subpath site.
---
## Unresolved questions
1. **M4** is an editorial judgement, not a spec violation — do you want the percentages softened, or is the fine print
the accepted trade-off? I recommend (a)+(b), but it is your call to make, not mine.
2. **m4** (print reveals spoilers): intentional "print gives you everything" choice, or an oversight?
3. **M3**: which season's dub cast is the profile table meant to describe? That decides the wording.
4. Is there a target minimum viewport? M1's failure is confined to ≤480 px; if 320 px is out of scope the severity drops.
---
Status: DONE_WITH_CONCERNS
Summary: One blocker — `js/scroll-reveal.js:20` signals the inline failsafe before doing its work, so a throw anywhere
after that line leaves all 36 revealed content blocks permanently invisible (reproduced). Four major issues follow: the
hero legal disclaimer fails WCAG AA (3.35:1 at 320 px) because it renders over the tan figure, and three content-integrity
items where the page publishes disputed or editorial data as canon, contradicting its own stated sourcing policy.
Concerns/Blockers: Everything else is in good shape — no-JS/broken-JS behaviour, responsive layout 320→1400, the stacked-table
ARIA approach, headings, landmarks, keyboard operability and reduced motion all verified clean. Fix B1 before landing;
M1 and M2/M3 are quick and self-contained. Per instruction I changed no files.
---
## Re-verification
Same setup: playwright-core + Chromium 1228 + `python -m http.server 8099`. 6 new scripts, ~40 runs.
**1. Blocker B1 (failsafe ordering) — VERIFIED FIXED.** `revealReady` is now the last statement (`js/scroll-reveal.js:57`).
- throw *after* the observe loop: `hiddenAfterFullScroll=0`, `revealed=36` — flag is set but observation is already live, so the reveal still completes. This is the correct invariant.
- throw *before* the observe loop: flag `unset`, `jsClass=false`, `hidden=0` — failsafe rescues.
- Early-return paths are **harmless, confirmed**, and in fact double-safe: `revealAll()` has already added `is-revealed`, *and* the unset flag makes the failsafe strip `html.js` so the hidden rule goes inert. No-IO → `hidden=0`; reduced-motion → `hidden=0`; isolated zero-target run (`initScrollReveal('[data-no-such-attr]')`) → `ready=unset, js=false, hidden=0`.
- Baseline, `js/*` aborted, JS disabled: `hidden=0`. All 8 paths clean.
**2. Hero disclaimer contrast — VERIFIED FIXED.** Glyph-masked pixel sampling (identical method to the original 3.35:1 finding); background is now solid `rgb(12,12,18)`:
| viewport | 320 | 360 | 390 | 480 |
|---|---|---|---|---|
| avg contrast | **9.25** (was 3.35) | **9.25** (3.56) | **9.25** (3.77) | **9.25** (4.64) |
| worst pixel | **6.51** (was 1.52) | 6.51 | 6.51 | 6.51 |
Worst pixel is now the `--line` border-top, not the jacket. Screenshot at 360: reads as a deliberate caption band, not a mistake; figure still reads (192 px visible, opacity .5, head above the band, tie below). *Cosmetic nit:* the band's hard horizontal edge visibly slices the figure — a bottom-fading gradient would hide the seam.
**3. Disputed facts — VERIFIED FIXED.** 8 rows (was 10); Birthday + English voice gone. Zero matches for `Niosi|EpicStream|Birthday|December|English voice` across `index.html`, `README.md`, `css/`, `js/`. Sources 11→9. Stacked ARIA intact and unchanged between breakpoints: AX `table 1 / rowgroup 1 / row 8 / rowheader 8 / cell 8 / caption 1` identical at 360 px (`th` `display:block`) and 1280 px (`table-cell`); 0 zero-height rows; no table overflow. The GameRant age/height/birthday link is retained but still legitimately supports "Age 28" — not stale.
**4. Skill bars — PARTIALLY FIXED, residual accepted.** `.panel__intro` renders above all five rows at every width tested (320/360/480/700/928/1024/1400: `introAboveRows=true`, no overflow, no panel overlap, 58 rem split breakpoint intact). `.fine-print` still used 3× and `.fine-print--wide` 2× → no CSS went dead; `.panel__intro` 2 uses. **Honest read:** the caveat now lands *before* the data, so a top-to-bottom reader cannot mistake the bars for canon — that closes most of the risk. A skimmer who jumps straight to the bars still meets stat-block grammar, since the percentage-width encoding was kept. Mitigating, and visible in the screenshot: 94/90/88 render within ~1 segment of each other, so the *apparent* precision is much lower than the numbers imply. Raw values remain in the DOM as `--pct`. Not a defect — your editorial call, and the framing fix is the part that mattered.
**5. Print spoiler leak — RETRACTED. My original finding was a measurement artifact; I got it wrong.** Using `checkVisibility({contentVisibilityAuto,opacityProperty,visibilityProperty})` in print media: closed bodies `visible:false`, open body `visible:true`. Re-adding the OLD `.spoiler__body { display: block }` rule in print media leaves them **still `false`** — that rule never defeated the closed `<details>`, because Chrome hides the content via `content-visibility:hidden` on the internal slot, which a descendant's `display` cannot override. My original evidence (`getBoundingClientRect().height === 233`) was invalid: descendants of a closed `<details>` still report layout boxes — the screen control shows the same 233. Net effect of your change: a dead rule removed (small KISS win), behaviour unchanged and now soundly verified correct.
**Regressions — NONE.**
- Page overflow 320→1400: **12/12 widths clean**.
- Structure: 1 `<h1>`, 33 headings, 0 skipped levels, 0 unlabelled sections, 0 root-absolute paths, 0 CJK strings without `lang="ja"`.
- Zero console errors, zero failed requests on normal load.
- Reduced motion: `hidden=0`, `.scroll-cue` `animation-name:none`, `scroll-behavior:auto`.
- Anchor nav and deep-linking re-checked (not previously covered): clicking each nav link and cold-loading `#abilities` reveals all in-view content at 360 and 1280. The 12 residual hidden blocks have `visibleRatio` 0.0060.071, i.e. barely peeking under the `-10%` rootMargin — intended threshold behaviour, they reveal on any scroll.
**Still open (first-pass minors, untouched and not re-tested):** m2 (no `try/catch` isolation in `js/main.js`), m3 (footer link targets ~23 px), m5 (`tabindex="-1"` on `<main>`), m6 (nav edge fade never retracts), m7 (`--header-h` 68 px vs measured 98 px), m8 (pseudo-content in the summary's accessible name), and the nits (`--fs-xl` unused, doubled `padding` declaration, README section/skill miscounts, no `og:image`).
Status: DONE
Summary: All four accepted fixes hold under real-browser verification — the reveal failsafe is now correctly ordered (8/8 failure paths leave nothing hidden), the hero disclaimer went from 3.35:1 to 9.25:1, the disputed rows are cleanly gone with stacked-table ARIA intact at 8 rows, and the skill-bar caveat now precedes the data. No regressions in overflow, structure, errors, or reduced motion; I retract my print-spoiler finding as a measurement artifact of my own making.
---
## Second re-verification
**1. `--header-h` anchor landing — VERIFIED.** Token resolves to `6.25rem`/100px at 360 px (`scroll-margin-top: 116px`, header bottom 98 px) and `4.25rem`/68px at 1280 px (`84px`, header 65 px). All 7 nav links, both widths: nothing lands under the header. Gap from header bottom to the section number is **+75/+76 px** at 360 and **+131 px** at 1280 (h2 +110/+112 and +131). Clearance margin over the sticky header is 18 px at 360, 19 px at 1280 — consistent by design. (My previous "+264 px" numbers were my own harness bug: 950 ms is not enough for the smooth scroll to settle. Re-measured with a scroll-stabilisation loop.)
**2. Footer tap targets — VERIFIED.** `.sources a` heights: **36.9 px** at 360 px, **38.7 px** at 1280 px (2-line IMDb item 57.8/61.3) — all ≥24 px, was ~23. Inter-`li` gaps are now 0, so boxes touch, but the 8 px top + 8 px bottom padding puts 16 px between adjacent link texts (was 8 px). Screenshots at both widths: comfortable, neither cramped nor double-spaced. Adjacent targets touching is fine for SC 2.5.8 since height alone satisfies it.
**3. `.sources li::before` dash alignment — STILL BROKEN (minor).** The dash sits **above** the first line's centre, not on it: off by **2.3 px at 360 px** and **1.5 px at 1280 px** (measured against the Range text-box centre of line 1, all 6 items sampled, consistent). At 3× zoom it is plainly high — level with the cap-height rather than mid-glyph. Correct values by measurement are **1.38em at 360 px** and **1.31em at 1280 px**; a single `em` cannot be exact at both because `padding-block` is a fixed 8 px while `--fs-sm` scales. Use either:
- `top: calc(var(--sp-2) + 0.8em)` — padding-top + half the 1.6 line-height, right by construction (≤0.8 px at both), or
- `top: 1.35em` — splits the difference (≤0.6 px at both).
Correct behaviour confirmed on the 2-line IMDb item: its dash tracks the first line, not the block centre.
**4. Band seam + contrast — VERIFIED.** Glyph-masked sampling over the text rows only (bottom fade padding excluded): background is still solid `rgb(12,12,18)` and contrast is **unchanged at crAvg 9.25 / crWorst 6.51** at 320/360/390/480 px. No glyph enters the fade — the last glyph bottom sits **3.14.0 px above** the fade start at every width, so the gradient eats no text. Screenshot at 360: the hard horizontal edge is gone, the note now dissolves into the figure's shoulder, and the head/tie still read above and below. The remaining top hairline is the pre-existing `border-top` divider, by design.
**No regressions.** Page overflow: none at 320/360/768/1280/1400. Zero console errors, zero failed requests, 0 hidden `[data-reveal]` after a full scroll.
Status: DONE_WITH_CONCERNS
Summary: Three of four fixes verified clean; the `.sources li::before` dash is still ~1.52.3 px above the first line's centre and needs `calc(var(--sp-2) + 0.8em)` (or `1.35em`) instead of `1.2em`.
@@ -0,0 +1,134 @@
# Reigen Arataka Character Factsheet
## Identity & Profile
| Field | Value |
|-------|-------|
| **Full Name (Japanese)** | 霊幻新隆 (Arataka Reigen) |
| **Age** | 28 years old |
| **Birthday** | December 10 |
| **Occupation** | Owner/operator, Spirits and Such Consultation Office |
| **Workplace** | Spirits and Such Consultation Office (small office in Japan) |
| **Affiliations** | Mentor/employer to Mob (Shigeo Kageyama); co-worker with Serizawa Katsuya |
| **First Appearance (Manga)** | Chapter 1, "Self-Proclaimed Psychic - Reigen Arataka" |
| **First Appearance (Anime)** | Season 1, Episode 1: "Self-Proclaimed Psychic: Reigen Arataka ~And Mob~" (July 2016) |
| **Japanese Voice Actor** | Takahiro Sakurai (born June 13, 1974) |
| **English Dub Voice Actor** | Chris Niosi (consistent across all three seasons; no recast) |
## Character Summary
Arataka Reigen is the self-proclaimed "Greatest Psychic of the 21st Century," though in reality he possesses zero psychic abilities. He is a con artist who runs a small exorcism and consulting business by exploiting clients' belief in supernatural phenomena. His actual technique involves salt-throwing, massage, psychological manipulation, and shrewd people-reading. Despite his fraudulent premise, Reigen genuinely helps clients through counseling, practical life advice, and stress relief.
When Mob (Shigeo Kageyama), a genuine psychic middle-schooler, comes to work for him, Reigen becomes his unlikely mentor and employer. Though initially motivated by profit, Reigen gradually reveals himself to be emotionally intelligent, responsible, and deeply invested in Mob's growth. He teaches Mob that psychic powers are merely another characteristic—no different from being tall or smart—and that personal worth comes from kindness and integrity, not supernatural gifts.
Reigen's deuteragonist arc culminates in the "Separation Arc," where public scandal and isolation force him to confront his empty life and reevaluate his relationship with Mob, work, and personal fulfillment.
## Personality & Traits
- **Con artist and liar** who uses charm, quick thinking, and social manipulation to deceive clients into believing he's a powerful psychic.
- **Emotionally intelligent** — reads people astutely and adjusts his behavior to suit social contexts; MBTI type ENFJ ("The Teacher").
- **Charming and outgoing** — personable, confident in speech, capable of performing for an audience.
- **Protective mentor** — despite initial self-interest, shows genuine care for Mob's safety, self-esteem, and emotional development.
- **Pragmatic** — focuses on real-world problems (loneliness, stress, self-doubt) rather than supernatural ones, even when pretending to perform exorcisms.
- **Self-serving but flawed** — struggles with vanity, lies, and avoiding accountability; gradually learns humility through setbacks.
- **Morally complex** — commits fraud for profit yet refuses to cross certain lines (e.g., refuses to truly harm clients or exploit children).
## Abilities & Techniques
**Real Abilities:**
- Excellent massage and acupressure (acupoint work for shoulder/back tension relief).
- Psychological counseling and life advice.
- Persuasion and social manipulation.
- Physical fitness (despite no psychic powers, holds his own in combat using agility and leverage).
**Fake Techniques** (performed for client theater):
- **Salt Splash** — throws salt at targets while shouting incantations to "purify" spirits.
- **Salt Heap Punch** — delivers a punch with salt-encrusted fist for dramatic effect.
- **Black Arts Crush** — branded name for acupressure massage to relieve pain.
- **Graphic Purification** — uses software/CG to edit evil spirits out of client photos (purely visual mock-up).
- **Cheese-Burger Tornado** — absurd spinning attack performed during a comedic encounter; more a gag than a recurring "technique."
**Not techniques but real skills:**
- Client consultation and reassurance.
- Financial management of his small business.
- Showmanship and performance.
## Key Relationships
- **Mob (Shigeo Kageyama)** — Employee, mentee, and chosen family. Reigen's primary motivation shifts from exploiting Mob's powers to wanting his well-being. Their separation arc is the series' emotional climax.
- **Serizawa Katsuya** — Coworker and assistant at Spirits and Such. Reigen hires Serizawa after the final arc to help him rebuild his business and life.
- **Dimple** — An evil spirit who initially tried to control Reigen; Reigen pretended not to see him. Their relationship is marked by comedic tension rather than alliance; Dimple later becomes Mob's companion.
- **Ritsu Kageyama** — Mob's younger brother; Reigen occasionally seeks his help or advice, treating him with respect.
- **Teruki Hanazawa** — A powerful esper who initially clashes with Reigen but gradually respects his character growth.
- **Tome Kurata** — A client/friend; later works as secretary at Spirits and Such.
## Notable Quotes
1. **"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!"**
*Source: Widely cited but specific episode/chapter not verified.*
2. **"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."**
*Source: Core life-lesson quote; variations appear across seasons.*
3. **"Listen up! We were born with special powers that other people don't have, but we mustn't let it get to our heads and think we're special. People who are fast, people who sing well, people who are book smart, people who are good at talking, and people who can use psychic powers... You can't put one over the other."**
*Source: Season 2 context referenced; exact episode unverified.*
4. **"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."**
*Source: Related to power-responsibility theme; specific episode not locked.*
5. **"Your life is your own, and it's up to you to live it exactly the way you want."**
*Source: Key quote from Separation Arc / post-arc reflection; exact episode not verified.*
6. **"The truth behind one's charm is kindness."**
*Source: Referenced in mentorship context; exact source episode/chapter unverified.*
**Note:** Most Reigen quotes circulate widely in fan communities but lack precise episode/chapter attribution in public sources. The quotes listed are widely recognized and consistent across English fan translations, but readers should verify exact wording against original Japanese if planning to cite them formally.
## Story-Arc Highlights
- **Episodes 12 (Intro Arc)** — Reigen's first meeting with Mob; introduces his con-artist premise and the Spirits and Such office. Establishes the mentor-mentee dynamic.
- **Mid-Series Arcs (Season 12)** — Reigen handles various client consultations and exorcism jobs, growing closer to Mob while still maintaining his fraud. Notable: his interactions with Serizawa, his attempts at business expansion, and comedic "technique" performances.
- **Separation Arc (Season 23 Bridge)** — [**SPOILER**] Public scandal forces Reigen and Mob apart. Reporters and public opinion turn hostile. Reigen hits rock bottom, isolated and unemployed. This arc is crucial to his character arc: he realizes his life has been empty and his reliance on Mob unhealthy.
- **Reunion & Rebuild Arc (Season 3)** — [**SPOILER**] Reigen reconciles with Mob and reevaluates his life choices. He hires Serizawa, rebuilds Spirits and Such with a healthier foundation, and commits to genuine self-improvement rather than manipulation.
- **Final Arc (Season 3 Finale)** — Reigen plays a supporting role in Mob's climactic battle against Toichiro Kageyama. His growth is measured not by acquiring powers but by becoming a stable, caring adult Mob can trust.
## Sources
- [Mob Psycho 100 Wiki - Arataka Reigen](https://mob-psycho-100.fandom.com/wiki/Arataka_Reigen)
- [GameRant: Mob Psycho 100 - Who is Arataka Reigen?](https://gamerant.com/mob-psycho-100-who-is-reigen/)
- [GameRant: Mob Psycho 100 - Main Characters' Age, Height, & Birthday](https://gamerant.com/mob-psycho-100-main-characters-age-height-birthday/)
- [EpicStream: Mob Psycho 100 Voice Actors Sub & Dub Cast](https://epicstream.com/article/who-are-mob-psycho-100s-voice-actors-sub-dub-cast-characters)
- [Behind The Voice Actors - Reigen (Season 1, 2, 3)](https://www.behindthevoiceactors.com/tv-shows/Mob-Psycho-100/)
- [ComicBook: Mob Psycho 100 Season 3 English Dub Voice Cast Changes](https://comicbook.com/anime/news/mob-psycho-100-season-3-english-dub-voice-cast-changes/)
- [OtakuKart: Best Quotes by Reigen Arataka](https://otakukart.com/best-quotes-by-reigen-arataka-from-mob-psycho-100/)
- [ACDB: Arataka Reigen Best Quotes](https://www.animecharactersdatabase.com/quotesbycharacter.php?cid=77823)
- [CBR: Mob Psycho 100 - Reigen Arataka MBTI & Character Development](https://www.cbr.com/mob-psycho-100-reigen-arataka-mbti-personality-type-character-development-enfj/)
- [Collider: Why We Love Mob Psycho 100's Reigen Arataka](https://collider.com/why-we-love-mob-psycho-100-reigen-arataka/)
- [CBR: 10 Life Lessons from Reigen Arataka](https://www.cbr.com/mob-psycho-100-reigen-arataka-life-lessons/)
- [Takahiro Sakurai - Wikipedia](https://en.wikipedia.org/wiki/Takahiro_Sakurai)
- [Chris Niosi - Wikipedia](https://en.wikipedia.org/wiki/Chris_Niosi)
- [IMDB: Episode 1 - Self-Proclaimed Psychic: Reigen Arataka ~And Mob~](https://www.imdb.com/title/tt6014452/)
## Uncertainty List
1. **Quote exact episode/chapter attribution** — Most Reigen quotes circulate widely in fan discussions but are rarely cited with specific episode or chapter numbers in public sources. Quote texts are consistent across translations, but readers should verify against original anime/manga for formal citation.
2. **Full kanji breakdown interpretation** — While 霊幻新隆 is confirmed as the correct kanji, the nuanced meaning (one source claims it echoes 霊験灼か meaning "strikingly blessed by the gods") lacks multiple independent confirmations.
3. **Specific manga chapter numbers** — Sources consistently refer to "Chapter 1" as Reigen's first appearance, but did not provide the manga's full chapter breakdown or naming conventions to verify whether Chapter 1 has a formal title matching the anime episode title.
4. **Character arc episode ranges** — Separation Arc and Reunion Arc are confirmed as major plot points, but exact episode ranges across Season 23 were not independently verified. The report relies on general fan/critical consensus about arc structure.
5. **Precise quote wording** — While the major quotes listed are widely recognized, some English translations vary slightly depending on fan-subbing group or official dub version. The quotes here reflect common English translations but may not match 100% with all official English dub releases.
6. **Serizawa's exact role** — Confirmed that Serizawa becomes Reigen's employee/assistant in the post-arc period, but the exact job title ("deputy director" vs. "assistant") and timeline vary slightly across sources.
---
**Character sheet compiled:** 2026-07-25
**Target:** Fan website static page (spoiler-light, accuracy-prioritized)
**Constraints honored:** No images embedded, short quotes only, no verbatim passage copying, report under 400 lines.
@@ -0,0 +1,103 @@
# Reigen Arataka fan site — build report
Handoff: ui-ux-designer → code-reviewer. Date 2026-07-25.
## Files created
- `index.html` — whole page, all content in markup (no JS-injected text)
- `css/style.css` — single stylesheet, ~830 lines, token-driven
- `js/main.js` — entry, imports two modules, nothing else
- `js/scroll-reveal.js` — IntersectionObserver reveals + stagger
- `js/nav-highlight.js``aria-current` on in-view section + keeps active chip in view
- `assets/favicon.svg` — original salt-splash mark
Untouched as instructed: `README.md`, `.github/`, `.nojekyll`, `plans/`.
All paths relative (`./css/…`). Verified zero root-absolute refs → Pages-safe.
## Design system
Palette (contrast vs `--ink #0c0c11`, all computed):
- ink #0c0c11, ink-2 #14141c, ink-3 #1d1d28, line #2c2c3a / #3d3d50
- paper #f2ede2 (11.9:1), paper-dim #b8b2a6 (7.0:1), paper-mute #8f8a80 (4.6:1)
- magenta #ff3d7f (5.6:1), teal #17d6c6 (10.4:1), acid #d8f24a (14.9:1), tan #d9b483 (8.4:1)
Type — 3 Google families, all with Vietnamese subsets; mono is system stack (saves a request):
- display `Archivo Black` (hero, section titles, meters values)
- body/UI `Archivo` 400..700
- pull quotes `Newsreader` italic (editorial weight, carries the quotes section)
- `--f-ja` separate CJK stack (Hiragino/Yu Gothic/Meiryo/Noto Sans JP) — Latin webfonts have no kanji; without this the 霊 mark rendered as tofu
- fluid `clamp()` scale, 8 steps, `--fs-hero` 2.75→8.5rem
Motion:
- reveal 560ms `cubic-bezier(.2,.7,.3,1)`, translateY(18px)+opacity, 70ms stagger capped at 4 steps
- meter fills scaleX 900ms
- hover/UI 150240ms
- `prefers-reduced-motion: reduce` → all durations 1ms, delays 0, reveals and meters forced to final state, scroll-cue animation off, `scroll-behavior: auto`
Original art (all hand-authored, no external assets): CSS aura blobs + SVG ray burst, abstract faceless suit-and-tie figure (geometric paths, no facial features — suggestive not derivative), salt-splash dot-field divider (17 hand-placed radial gradients), salt-grain SVG symbol on technique cards, segmented "100%"-style meters, halftone dot overlay at 5%.
## Section inventory
Hero (EN+JA name, struck-through "self-proclaimed", self-title, tagline, 0%/100% stat chips, inline fan-made note) → 01 Profile (identity table) → 02 Who He Is (drop cap prose + pull quote) → 03 Personality (6 traits) → 04 What He Can Actually Do (2-panel real-vs-performed split, meters left / salt-branded "techniques" right, punchline) → 05 In His Own Words (5 quotes) → 06 The People Around Him (6 cards) → 07 Story Arcs (timeline, 3 gated) → footer (disclaimer + sources).
Sticky in-page nav; horizontal scroll strip <56rem with right-edge fade affordance.
## Fact-sheet items deliberately omitted
- **MBTI ENFJ** — dropped entirely per instruction (fan speculation, not canon).
- **Cheese-Burger Tornado** — report itself calls it "more a gag than a recurring technique"; unsourced → omitted. 4 techniques kept, all report-backed.
- **All quote citations** — report flags every quote's episode/chapter as unverified (Uncertainty 1 & 5). No source lines shown at all; one honest note explains why rather than a guessed citation. No episode number invented anywhere.
- **Quote 3** — near-duplicate of quote 2 (same lesson, S2 variant); cut for tightness. 5 of 6 quotes used.
- **Kanji meaning gloss** (霊験灼か / "strikingly blessed") — Uncertainty 2, single source → kanji shown, no interpretation.
- **Manga Ch.1 title** — Uncertainty 3 says chapter title unverified → "Chapter 1" only. Anime ep 1 title kept (IMDb-sourced).
- **Arc episode ranges** — Uncertainty 4 → arcs ordered by name/phase, no episode numbers; footnote says why.
- **Serizawa's job title** — Uncertainty 6 → "works alongside", no title.
- **Spoiler leakage** — report's summary ties his arc to the Separation Arc; kept out of hero/intro. Tome's later secretary role and Dimple's later companion role also dropped (mild spoilers, low value).
Gated behind `<details>` with SPOILERS badge + reveal/hide affordance: Separation, Reunion & Rebuild, Finale (incl. the Toichiro confrontation).
## Accessibility
- Landmarks: 1 `header`, 1 `nav` (`aria-label`), 1 `main`, 1 `footer`. Verified counts.
- Exactly 1 `h1`; heading order dumped and checked — h1→h2→h3→h4, no skips.
- Skip-to-content link, visible on focus (acid block, verified by screenshot).
- `:focus-visible` 3px acid outline, 3px offset, global.
- Text contrast meets AA everywhere. Hero kicker initially measured **4.37:1** over the aura — fixed with a two-layer scrim on `.hero__art::after`, re-measured from actual screenshot pixels at **4.92:1**.
- Decorative SVG (`.sprite`, hero art, meter tracks, timeline dots, footer mark) all `aria-hidden="true"` / `focusable="false"`. Meters expose label+value as text; only the bar is hidden.
- Japanese text wrapped in `lang="ja"` (hero, table, brand mark, footer mark). `lang="en"` on `<html>`.
- Touch targets: nav links, brand and spoiler summaries all ≥44px min-height.
- Profile table stacks label-over-value below 44rem with explicit `role="table"/"rowgroup"/"row"/"rowheader"/"cell"` so semantics survive the `display:block` restyle.
- Print stylesheet forces reveals visible and drops decoration.
## No-JS / resilience
Found and fixed a real defect during testing: the head inline script set `html.js` (which arms the `opacity:0` hidden state), but inline scripts still run when an *external module* fails — so blocking `js/main.js` left **all 36 reveal blocks invisible**. Now the inline script self-disarms: it removes the `js` class after 1200ms unless `scroll-reveal.js` has set `documentElement.dataset.revealReady`. Verified with scripts network-aborted: `hiddenReveals 0/36`, `collapsedMeters 0`. JS fully disabled never runs the inline script at all → everything visible by default. Nav degrades to plain anchors.
## How I verified
Served `python -m http.server 8099`, drove real Chromium via `agent-browser`.
- All 6 assets return 200 (index, css, 3 js, favicon).
- `agent-browser errors` and `console`: **empty** on every pass.
- Screenshots reviewed and iterated at 320, 480, 768, 1024, 1280, 1400px.
- Horizontal overflow: `documentElement.scrollWidth == clientWidth` at 320/480/768/1024. Only nav chips exceed their own scroll container (intended).
- Contrast measured by sampling actual screenshot pixels with ImageMagick, then computing WCAG ratios — not eyeballed.
- Reduced motion via `set media reduced-motion`: reveals visible, `animationName: none`, transitions 0.001s, meters uncollapsed.
- Reveal + nav highlight live: scrolled to Abilities → 14/36 revealed progressively, `aria-current` = "Abilities".
- Spoiler `<details>`: keyboard focus lands on summary, click toggles `open`, focus ring visible.
- Copyright audit: `0` `<img>` tags, `0` raster references, external hosts limited to Google Fonts + cited sources + github contact.
- CSS braces balanced (232/232); no root-absolute URLs in HTML or CSS.
Fixes made from self-review: CJK font stack (tofu), brand mark size, hero scrim for AA, larger salt icons, mobile table stacking (was cramped/clipped at 320), nav scroll affordance, `h1` accessible name ("ReigenArataka" → "Reigen Arataka"), no-JS failsafe.
## Unresolved questions
1. Google Fonts is an external dependency — served offline the 3 families fall back to system stacks (layout holds, character changes). Self-host if offline fidelity matters.
2. Meter ratings other than "psychic ability: none" are my editorial estimates. Captioned as such on-page. Say the word and I'll cut the bars to the one canon value.
3. "Confidence 100%" hero chip is invented flavour (deliberate joke against the canon 0%). Flagged in case you want strict facts only.
4. Hero figure is intentionally faceless/abstract for copyright safety — confirm the abstraction level reads as tribute rather than likeness.
5. Kept 5 of 6 quotes (dropped the near-duplicate). Restore quote 3 if you want the S2 phrasing too.
Status: DONE
Summary: Single-scroll fan tribute page built and shipped as plain static HTML/CSS/ES-modules with original CSS/SVG art only; verified in a real browser at 3201400px with zero console errors, AA contrast measured from pixels, full reduced-motion and no-JS support.
Concerns/Blockers: Two invented-but-labelled editorial values (skill bars, "Confidence 100%") are the only non-canon content on the page — both captioned; drop them if the bar is strict facts only.