尚未检测到更新。GitHub 发布版本后会显示在这里。
个人 GitHub 账号下公开仓库的所有者可以登录并验证所有权,自行管理介绍,无需管理员批准。
登录并生成验证文件,在 30 分钟内将其提交到此仓库的默认分支。其他分支或分叉仓库中的文件无效。然后在下方检查文件,即可无需管理员批准直接管理项目。请保留文件以维持管理权限。
如需登录,将前往登录页面。登录后请回到此处,再次点击所选的验证按钮。
在项目文件夹中运行 /nomorevibe verify,即可验证所有权并自行管理介绍。
一分钟完成验证正在加载完整 README。 以下为已保存的摘录。
AniBeam is an anime discovery app drawn like a manga page. It shows what's trending, lets you search and browse by decade, and pulls a random title from a gacha machine. Each title tints the page in its own cover color. Live chat about episodes is planned for a later phase. Anime data comes from the AniList GraphQL API. AniBeam is not affiliated with AniList.查看完整 README ↗AniBeam is an anime discovery app drawn like a manga page. It shows what's trending, lets you search and browse by decade, and pulls a random title from a gacha machine. Each title tints the page in its own cover color. Live chat about episodes is planned for a later phase. Anime data comes from the AniList GraphQL API. AniBeam is not affiliated with AniList.
Phase 2, the design phase, Phase 4, My List, and Phase 5, the mood matcher and gacha, are complete (see Phase 2 complete, Phase 4 complete and Phase 5 complete). The app is a Discover page: an intro, this week's trending titles as a manga page, a pull machine, titles by decade, and search. Every title has its own address, where it opens in a full-screen title card with its banner, synopsis, tags and trailer, and Save, Want to try and Favorite, which this browser remembers and the My List page at
/listshows, linked from the header, the phone's menu and the intro (see My List). The pull machine has three mood sliders, Energy, Tone and Complexity, which rank the open decade's titles in the browser, with the top 3 matches under the rolled title and why each matched (see Mood sliders); it is a gacha: a roll drops a capsule and turns a card over on a title, with its rarity, its My List row, Details and Roll again (see The gacha); and a search with no results offers the sliders. Live chat and sync come next, in Phases 6 to 8.
node -v). The tests run TypeScript files directly, which older versions can't do.npm install # installs all workspaces
cp web/.env.example web/.env.local # frontend config (optional for now)
cp server/.env.example server/.env # chat server config (optional for now)
The .env.example files list every variable with a comment explaining it. Real .env files are gitignored, so never commit them.
| Command | What it does |
|---|---|
npm run dev | Starts the web app at http://localhost:5173 |
npm run dev:server | Starts the chat server at http://localhost:3001 (GET /health) |
npm run build | Type-checks and builds every workspace |
npm run typecheck | Type-checks every workspace |
npm run lint | Lints the web app with oxlint, which refuses dangerouslySetInnerHTML |
npm test | Runs the unit tests with Node's built-in test runner |
npm run test:browser | Runs the browser checks in web/e2e (see below) |
npm run format | Formats the repo with Prettier |
npm test and npm run test:browser stop a test after its time limit, and end each test file's process the moment its last test ends, so that a test left waiting can't stall them. Each file's results reach the runner through a pipe, which macOS writes in the background, so a process ended that way could lose the last of them, and a run passed on fewer tests (270 of 303 once). So every test process writes its output at once (web/scripts/blocking-test-output.ts), and a reporter fails the run, naming the file, if any test a file queued never reported (web/scripts/every-test-reported.ts). A file queues every test as it loads only if it awaits nothing at its top level after its first test, and a test holds every test file to that: a test registered after such an await would never be queued, and could go missing unseen.
The site deploys on Vercel. Sign in with GitHub and import this repository. Vercel finds two apps, web and the Fastify chat server, so import only web. Vercel then uses its Vite settings with web as the root directory. It installs the workspaces, runs npm run build, and serves dist. No environment variables are needed yet. web/vercel.json sends every address that is no file to index.html, so a title's own address, such as /anime/154587, reaches the app instead of Vercel's 404 (see Title addresses). npm run dev and vite preview do that on their own, so only a unit check (web/src/app/vercel.test.ts) can catch the rule going missing.
After that, every pull request gets a preview link, and merging into main updates the live site. The chat server will need a host that keeps WebSocket connections open, which is planned for Phase 6.
This repo is an npm workspaces monorepo with three packages:
AniBeam/
├── web/ # Frontend: React + Vite + TypeScript + Tailwind CSS v4
│ ├── e2e/ # Browser checks (*.e2e.ts) and the Chrome driver they share
│ │ └── anilist/ # AniList's answers, recorded for the browser checks
│ ├── public/ # Static files served as-is, including the fonts
│ ├── scripts/ # Maintenance scripts, such as the sample titles' refresh
│ └── src/
│ ├── app/ # App shell: root component and providers; the address says which title is open
│ ├── pages/ # Not made yet: Discover is drawn in app/, My List's page in features/list/
│ ├── features/ # Feature modules: logic + UI that pages are built from
│ │ ├── anime/ # AniList queries and display helpers
│ │ ├── theming/ # Adaptive accent color (deriveAccent, CSS variables)
│ │ ├── mood/ # The pull machine: mood sliders, scoring, the gacha
│ │ ├── list/ # Saved list (Saved / Want to try / Favorite)
│ │ ├── chat/ # Live chat UI
│ │ └── sync/ # Cross-device sync (stretch goal)
│ ├── components/ # Shared UI from the chosen design: header, panels, title card
│ ├── prototype/ # Design prototypes, development only (?variant=A|B|C|D)
│ ├── gallery/ # Component gallery, development only (?gallery)
│ ├── lib/ # Low-level clients and helpers (AniList client, query cache, the address, Firebase)
│ ├── styles/ # Tailwind entry point, design tokens, custom CSS
│ └── main.tsx # Entry point
├── server/ # Chat API: Fastify + Socket.IO + Postgres
│ └── src/
│ ├── routes/ # REST endpoints (e.g. message history)
│ ├── sockets/ # Socket.IO event handlers (join room, send message)
│ ├── db/ # Database schema, migrations, client
│ ├── auth/ # Firebase sign-in token verification
│ ├── moderation/ # Rate limits, reports, profanity filter
│ └── index.ts # Entry point
├── shared/ # Types and validation schemas used by both web and server
│ └── src/
└── package.json # Workspace root and shared scripts
Empty folders hold a .gitkeep file so git tracks them; each one is removed once real code lands in that folder. Unit tests live next to the code they test (*.test.ts). The browser checks live in web/e2e (*.e2e.ts).
Phase 2 was the front-end design phase: the look and the components it is built from, tried against AniList's real titles and changed with the user until they signed it off (spec §8). It was built out of order. Its design system came first (#4), then the Discover page for the course presentation (Phase 3's MVP, #5), and the rest of Phase 2 followed in pull requests from #11 to the sign-off, from 2026-10-12 to 2026-10-21.
What it delivered:
deriveAccent (each title's own color, kept readable), the components in web/src/components, and the component gallery at ?gallery (#4, #18)./anime/ and its id, with Back and Forward (#21).Since #14 (2026-10-14), the tests grew from 286 unit tests and 126 browser checks to 419 and 329, and, brotli-compressed, the JS every visit loads grew from 78,032 B to 87,701 B, the CSS from 8,356 B to 11,488 B, and the samples, loaded only while AniList can't be reached, from 3,337 B to 14,350 B, as they now carry each title's synopsis and tags. The fonts are unchanged, at about 100 KB.
The spec's three measurements (§9) were taken again on a production build at four sizes: 390x844 and 360x740 as phones, 768x1024, and 1440x900. Before is 6f0f33e (#10, 2026-10-11), where the rest of Phase 2 began; after is af176d6 (#28, 2026-10-21). AniList's answers came from the recordings (see Browser checks), and nothing reached AniList's API or YouTube. Layout shift is CLS as web-vitals counts it, where under 0.1 is good.
The Before column joins two sources. Most of it is what the research wrote down on 6f0f33e, its layout shift measured on a throttled network, a few loads against live AniList. The rest, mostly at 360 and 768 px, where the research wrote nothing down, comes from new runs on 6f0f33e, unthrottled like every After figure, with its one import of a package no longer installed pointed at the installed one, and the recordings re-keyed to its query text. The phone's 0.061 is the research's reading on 1 of 2 live loads, and the raw shift the new runs saw there, which Chrome's phone emulation hides from web-vitals (it read 0). At 768 the research wrote down at most 0.0001, where the new runs read 0.017 to 0.021, the intro re-centring under the tablet's header; the table gives the new figure, as the research didn't say how its run was set up. Largest contentful paint and a banner's cost on a throttled phone weren't measured again. The raw figures and the scripts that took them are kept locally with the project's research notes, which this repository doesn't publish.
| Measurement | Before (6f0f33e) | After (af176d6) |
|---|---|---|
| Layout shift, a first visit | Up to 0.061 on a phone (usually 0.0012 or less), at most 0.0007 at 1440, 0.017 to 0.021 at 768 | At most 0.0007 |
| Layout shift, a pull | 0.18 to 0.24 on phones and at 768 (0 at 1440) | 0 at every size |
| Layout shift, a slow first load (AniList held 3 s) seen at the decades | 0.17 to 0.32 at every size | 0 at every size |
| Layout shift, a title opened at its address | No title addresses yet | 0 at every size, answered at once or held 3 s |
| Keyboard, Tab through the page | 41 stops; rings cut by the screen's edge at every size, and at 1440 Shift+Tab left 4 stops under the header | 40 stops (38 under 900 px), each whole on the screen, both ways; no control left out |
| Keyboard, a title's card and its trailer | Only Open on AniList | Every control reached; Esc closes the trailer, then the card, and focus goes back each time |
| Reduced motion, from the device or the switch | The device only (no switch); nothing moved, but the card opened as a hard cut | Nothing moves anywhere, from either; only fades and color changes, of 150 to 400 ms (the longest, a decade cover's color coming back on hover or focus); the trailer doesn't play by itself, and nothing is left hidden |
| Core flow 2: search "frieren", open it, watch the trailer | No title address, no trailer | Passes at every size: 1 request to AniList for the search, none to open the title, nothing from YouTube before Watch trailer |
The measuring found one real problem, fixed with the sign-off checks: on a phone, Read more opened the synopsis above itself and pushed its own focused button below the screen (see Design). Two smaller ones are left open. In the checks, Tab past Open on YouTube goes into the trailer's frame, blocked there, and doesn't come back to Close; a real trailer is a hand check (below). At 768 px a pull pushes the decade strip down 377 px as the lever is pressed: web-vitals doesn't count it, as it comes with the press, and keeping the room for the result at that width is a choice still to make.
deriveAccent's unit tests, over 5,832 colors: every fill reads 4.5:1 against ink, and every text color 4.5:1 on the page (see Design). The browser doesn't measure it again.web/e2e/title.e2e.ts opens Frieren's card with no banner and with one that fails to load at 390, 834 and 1440 px (the band at 280 px too, and on phones held sideways), and the impact frame that stands in for a banner at 390 and 1440 px. The gallery shows no banner, a cover that fails to load, the small 230px cover and a wide cover. Not checked: a card without a banner at 360 or 768 px, both widths spec §3J lists; and the cold title link in the table above was measured on Frieren, which has a banner.motion.test.ts, from the device and from the switch (see Reduced motion).The browser checks run only in headless Chrome. These are still open:
role="switch"; the scroll slam, and overflow-x: clip with the splash cover's drift; the menu's swipe and drag; Back (a swipe from the screen's edge) with a title card open, and with its trailer open; a trailer playing in the page (playsinline)./list. Done when saving survives a reload and My List loads with one request. Core flow 2's last step, Favorite, waits for it./anime/:id?ep=N. Done when a signed-in visitor posts and signed-out visitors can read.A Browse page, and Discover's This Season and All-Time Top rows (spec §3.0), belong to no phase yet, and wait for a decision.
Phase 4 was My List (spec §3F and §8): Save, Want to try and a Favorite on every title, kept in this browser, and a My List page at /list. The spec's test for it is that saving survives a reload and My List loads with one request. After a mockup the user clicked through to settle its design, it came in three pull requests, from 2026-10-22 to 2026-10-23: the list and the title card's buttons (#31), the page (#32), and the links to it, which close the phase.
What it delivered:
web/src/features/list/store.ts: one entry for each title, with its status, its heart and when it last changed, kept in localStorage and merged title by title with another tab's copy. A title taken off leaves a delete marker, so an older copy can't bring it back. It is a small module over React's useSyncExternalStore, with no new package (#31)./list: Saved and Want to try with their counts, Favorites only, the 50 most lately changed titles in one request, an Undo in a removed title's place, and its states when titles are missing, AniList is out of reach or the browser is offline. It is a file of its own, loaded only when needed (#32)./list with no new page, and on My List the section links lead back to Discover's sections the same way.Since Phase 2 closed (f2ea028), the tests grew from 419 unit tests and 329 browser checks to 475 and 366.
After Phase 5, the first item of Phase 4's optional PR 4 came in, as the user picked it in a lab (#42): the stamps. The cover of a title on the list wears SAVED or WANT on a small white tab, with a heart for a favorite, on trending's panels, the decade strip, a search's results and the top 3 matches, and a screen reader hears the same at the end of the cover's name; on My List, a stamp says only what the page doesn't already. See My List. web/e2e/layout.e2e.ts "a listed title’s stamp ends its cover’s name" checks the names, the places and that nothing moves. They raised the JS every visit loads to 94,900 B and the CSS to 13,050 B (see Byte budgets now).
Before is f2ea028, where Phase 2 closed; after is this phase's last commit. Each figure is one a check in the gate reads, named beside it, so it is measured again on every run: in headless Chrome on the dev server, with AniList's answers from the recordings and nothing sent to AniList's API (see Browser checks), except the bytes, which npm test weighs on a production build, brotli-compressed. Layout shift is CLS as web-vitals counts it.
| Measurement | Before (f2ea028) | After | Checked by |
|---|---|---|---|
Requests to AniList, a cold visit to /list | No My List page | 1, for up to the 50 most lately changed titles, by id (all 12 in the check); none for Discover's lists | web/e2e/list.e2e.ts "a cold My List with 12 titles saved asks AniList once", in Chrome's own network log; web/e2e/list.e2e.ts "/list and /list/ stay at their address and show My List, asking AniList for none of Discover’s lists" |
| Requests, the tabs, Favorites only and a reload | No My List page | 0 | web/e2e/list.e2e.ts "a cold My List with 12 titles saved asks AniList once" |
| Requests, a card opened over My List, Undo, Remove them | No My List page | 0 | web/e2e/list.e2e.ts "a card opened over My List keeps My List under it, asks AniList for nothing"; web/e2e/list.e2e.ts "a title taken off in its card leaves an Undo in its place"; web/e2e/list.e2e.ts "a title AniList’s answer leaves out keeps its place with no details" |
| Saving survives a reload | No Save | Save stays pressed after a reload; the list is read back from storage | web/e2e/title.e2e.ts "the card’s My List group offers Save, Want to try and Favorite"; web/src/features/list/store.test.ts "the list is saved under anibeam:list:v1 as it changes, and read back from there" |
| Layout shift as My List's titles land, at 1440 px | No My List page | 0 with the answer held 3 s, cold and with titles other answers brought shown first; 0 as an answer leaves a title out; 0 with the fonts held till the page has drawn, then let go before the titles land or after | web/e2e/list.e2e.ts "while My List’s answer comes, an empty card holds each title’s place"; web/e2e/list.e2e.ts "a title AniList’s answer leaves out keeps its place with no details"; web/e2e/list.e2e.ts "nothing on My List moves however late its fonts come" |
| Layout shift, a press in the card's My List row | No Save | Nothing moves, from 280 to 1440 px | web/e2e/title.e2e.ts "from 280 to 1440px the My List row and the actions stay in the card’s column" |
| Layout shift, the header's links and the intro's buttons swapping | Nothing moves, My List not yet a link | Nothing moves, My List a link |
The JS and CSS budgets rose with My List in #31 and #32, as reported to the user then; this pull request left them where #32 did, and a check holds the table's budgets to the bundle check's. Since then, Read more and Show less move the synopsis smoothly, which raised the JS budget to 91,000 B (the figures above are after that change). Not measured: layout shift on My List at a phone's size or on a production build, and the Phase 2 table's four sizes were not taken again.
The browser checks run only in headless Chrome. These are still open for Phase 4, besides Phase 2's list above:
/ and from /list; and at 1100 px and wider, the four buttons flying into the header and back, My List's with them.list-12 is AniList's real answer for 12 saved ids, but neither live check sends My List's request, so only recording it again would show a change on AniList's side.Phase 5 was the mood matcher and the gacha (spec §3E and §8): three mood sliders, Energy, Tone and Complexity, that rank titles in the browser, and a machine that rolls one. The spec's test for it is that moving a slider costs no request and 20 rolls give 20 different titles. After a lab the user tried (#35), where they chose to put the sliders and the gacha on Discover's pull machine, not on a page of their own at /mood, it came in six pull requests, from 2026-10-24 to 2026-10-27: room in the gate's time limit for the checks to come (#36), the mood scoring (#37), the decade's mood pool (#38), the sliders on the machine (#39), the gacha (#40), and the search's door to the sliders, which closes the phase.
What it delivered:
score.ts and moodMap.ts (with scoring.ts), a file of its own that loads once the browser is idle (#37)./mood, the spec's address for the sliders, lands on the machine at /#pull.Since Phase 5 began (a9e1f07), the tests grew from 475 unit tests and 370 browser checks to 574 and 427.
Before is a9e1f07, where Phase 5 began (Phase 4's close, with Read more; #36 changed only the checks); after is this phase's last commit. Each figure is one a check in the gate reads, named beside it, so it is measured again on every run: in headless Chrome on the dev server, with AniList's answers from the recordings and nothing sent to AniList's API (see Browser checks), except the bytes, which npm test weighs on a production build, brotli-compressed. Layout shift is CLS as web-vitals counts it. A request is counted from Chrome's own log of what the page asks for.
| Measurement | Before (a9e1f07) | After | Checked by |
|---|---|---|---|
| Requests to AniList, a slider moved once the pool is in | No sliders | 0, for every stop of every slider by keyboard, Reset moods, a match's card opened and closed, and leaving the tab and coming back | web/e2e/mood.e2e.ts "once the pool is in, every stop of every slider by keyboard, Reset moods, a match’s card opened and closed, and leaving the tab and coming back send AniList nothing" |
| Requests, the first press on a slider | No sliders | 1, the decade's mood pool, before the press ends; for a pool saved over 6 hours ago, 1 refresh, with the matches held still, and none for the moves after it | web/e2e/mood.e2e.ts "a press on a slider then sends exactly 1 more, the 1990s pool"; web/e2e/mood.e2e.ts "the first press on a slider sends exactly 1 refresh" |
| Requests, a decade picked | 1, its titles | 1, slider set or not: its titles alone, or its titles and its pool together | web/e2e/mood.e2e.ts "with a slider set, picking the 1980s with the machine on screen costs exactly 1 request"; web/e2e/mood.e2e.ts "with every slider at No preference, picking the 1980s costs 1 request" |
| Requests, a roll | 0, a pull from the decade's titles on the page | 0 until page 3: none for 5 rolls with no slider set, nor for 20 at Very cozy; a roll made with fewer than 10 left to come up asks for page 3, once | web/e2e/mood.e2e.ts "sends exactly 1 request, and scrolling through the machine, a Tab walk through it and 5 pulls with no slider set send none"; web/e2e/mood.e2e.ts "20 rolls at Very cozy give 20 different titles"; web/e2e/mood.e2e.ts "with a pool cut to 24 that has a page 3" |
| 20 rolls | No check | 20 different titles of 20, none of the 2 on My List, no two seasons of one show | web/e2e/mood.e2e.ts "20 rolls at Very cozy give 20 different titles" |
| Layout shift, the sliders moved | No sliders | Nothing moves, at 1440 and 390 px; every line the machine can say fits the room kept for it at 390 and 320 px | web/e2e/mood.e2e.ts "moving the sliders moves nothing on the page, at 1440 and at 390"; web/e2e/mood.e2e.ts "every line the machine can say fits the room kept for it" |
| Layout shift, a roll | 0 at every size, a pull with no card |
The JS every visit loads grew 3,786 B over the phase, the CSS 1,041 B, in the measured steps the user agreed to (Phase 5's D5), each raise given with its reason in web/src/app/privacy.test.ts; the mood scoring, 955 B, loads once the browser is idle, after the page has drawn. This last pull request added 146 B of JS and 18 B of CSS. A check held the table's budgets to the bundle check's until the next raise, for the stamps on listed covers, as it holds the Byte budgets now table's. The gate's browser checks took about 1,180 s at the phase's start and about 1,336 s at its end. Not measured: layout shift on a production build, and the Phase 2 table's four sizes were not taken again.
The browser checks run only in headless Chrome. These are still open for Phase 5, besides Phase 2's and Phase 4's lists above:
/mood typed into the address bar.Each part of the production build, compressed with Brotli at quality 11, against its budget. web/src/app/privacy.test.ts "the production bundle stays within its byte budget" weighs them on every run, and its comment gives the reason for each raise. web/src/app/readme.test.ts holds each budget here to the bundle check's, so a raise is written in both places in the same commit. The Phase 4 and Phase 5 tables above keep the figures those phases closed on, and are no longer checked.
| Part | Now, of its budget |
|---|---|
| The JS every visit loads | 94,868 of 94,900 B |
| The CSS | 13,013 of 13,050 B |
| The JS loaded only when needed (the samples) | 14,350 of 14,500 B |
| My List's page, a file of its own | 2,344 of 2,500 B |
| The mood scoring, a file of its own | 955 of 1,500 B |
| Every other file | 356 of 500 B |
The browser calls AniList directly, so each visitor's IP address gets its own limit of 30 requests a minute. web/src/lib/anilist.ts holds every request back for up to a minute after a 429, after a failed connection, or when AniList reports no requests left. Failed connections wait too, because a rate-limit reply without CORS headers reaches the browser as a plain network error. When AniList says when the limit resets, the wait ends up to 2 seconds after that, at random. Devices on one network share one IP address, and so one limit and one reset time, and this keeps them from all asking again in the same instant.
TanStack Query saves results in localStorage (web/src/lib/queryClient.ts): a list for a day, and a title asked for at its address for a day while it airs, or for 30 days once AniList says it has finished, as its details won't change (spec §3H). When a refresh fails, the last good list stays saved. It caches each result under its exact query text and variables, so editing a query never shows results saved for the old version, and a saved copy brings none of them back. So a release that changes a query drops what returning visitors saved under the old one, once: as the lists begin to carry each title's trailer, every list's query changes, so a visitor back within a day of their last visit loses the lists saved then: the page asks AniList for them again, and while AniList is down shows the samples, as on a first visit. web/e2e/outage.e2e.ts holds that to the last release's query text, written out, and checks that a copy saved under today's comes back through the page's own persister. Each result comes back only while it is kept (keptFor in web/src/features/anime/queries.ts), counted from when AniList sent it, not from when the copy was last saved, which happens whenever anything in it changes. Each save writes out the whole copy, which a day of searches can grow to megabytes (150 searches make 3.7 million characters; a finished title kept 30 days adds about 3,000, and only one opened at its address): it waits for the page to draw what just arrived (its next frame, then just after it), so a search's titles never wait for it. In a hidden tab, which draws nothing, it goes at once, and so does one still waiting as the tab is hidden, as closing it does first. Change buster in queryClient.ts only if the code that reshapes results changes.
Saved trending titles are refreshed once they are over 30 minutes old. If the refresh brings a new order while the trending panels or the intro's covers are on screen, they keep the order the visitor is looking at until they are scrolled away, so nothing reshuffles in front of the visitor (web/src/lib/steadyList.ts).
A title asked for on its own, at its address, is asked for again after 6 hours while it is airing (its next episode changes). A finished one isn't for 30 days, and stays saved as long, so opened again at its address within the month it asks AniList for nothing.
Each title in a list comes with its details, its synopsis (description(asHtml: false)), running time, tags and where its trailer is (trailer { id site }: the video's site and id, never its thumbnail, an image on Google's servers, so that nothing loads from YouTube or Google until a trailer is played, spec §5), so a title opened from a list opens on them and asks AniList for nothing. Asking as a title opens would cost a request for each title opened, and a class on one school network, sharing one limit, would soon find every card saying AniList is busy. Before a list is cached, cardOf in web/src/features/anime/queries.ts cuts each title's tags to those the card shows (see tags.ts): at most 16 of the 28 a popular title has on average (75 for One Piece), which keeps the saved copy about a third smaller. A title opened at its address asks for the same fields in its one request, cut the same way.
Every media(...) or Media(...) query in the source must include the SAFE filter, which excludes adult titles, and npm test fails if one doesn't. SAFE only works on top-level queries. Titles nested inside other results, such as relations or recommendations, need a filter in code.
Saved results and the rate-limit pause belong to one browser tab. With several tabs open, the last tab to save wins.
For when AniList can't be reached, web/src/features/anime/samples.ts bundles 30 well-known titles, 3 to 5 from each decade. A section with no titles of its own shows them, labelled, once AniList refuses it (403), rate-limits it (429) or can't be reached, until AniList answers: trending becomes "Fan favorites", with the first 9 and no rank numbers, and the decade strip shows that decade's samples (Upcoming has none). They load only then, from a file of their own, and are never saved as AniList's titles. Each carries its synopsis, running time, where its trailer is and the tags its card shows, cut as a list's are, so its card opens with no request too. npm run samples --workspace web rewrites it from the list of IDs in web/scripts/refresh-samples.ts, with one AniList request. Run it to change the titles, or when the type check fails after a change to the fields AniBeam asks for. It writes nothing if a title is missing, Ecchi, or has no cover color or year, and npm test checks the file again, and that each title has a synopsis and only the tags its card shows. web/e2e/outage.e2e.ts opens the first sample's card with AniList kept away, at 390 and 1440 px, and reads its synopsis, running time and tags, with no request.
The look is the manga-page design chosen in Phase 2: paper, ink outlines, screentone and sound effects.
Design tokens are plain CSS custom properties in web/src/styles/index.css, such as --paper, --ink and --spring. Component CSS reads them directly, and an @theme inline block exposes them to Tailwind utilities like bg-paper and font-display. npm test fails if nothing reads a token, or if components.css copies a token's color, clamp() or other function instead of reading it.
The components live in web/src/components, with their styles in components.css. Every class there starts with ab-, so none collide with Tailwind utilities, and the file loads in the components layer.
Each title tints its panel, card or capsule with its AniList cover color. deriveAccent in web/src/features/theming turns that color into two colors. The fill reaches 4.5:1 contrast against ink, and the text color reaches 4.5:1 on the dotted paper. Only lightness and chroma change, so the hue stays the title's own. The tests check 5,832 colors spread across the RGB cube.
Opening a title floods the screen with its color from the panel picked, with speed lines, then the card lands and the title slams in word by word. Closing plays all of that backwards at twice the speed, about 0.8 s for a short title: the part that arrived last leaves first, and the color shrinks back into the panel. The close button floods brand red as its X folds into a back arrow, with a ring of speed lines: on hover, on the way back, and with the keyboard's focus once the card has finished opening. Under reduced motion the card fades in and out over 150 ms, and the button only changes color. A banner fades in on its own once it has loaded and the card's fade has played, however late it comes, and fades out before the card does: fading with the card, a dark banner changed over a quarter of the screen in one frame. The reduced-motion checks measure both fades frame by frame on a card with no banner and on one with a dark banner.
The card is crowned by the title's banner in an ink frame (spec §3A): clamp(200px, 25vw, 420px) tall, and on a phone, where the card stacks, a short strip in the banner's own 19:4 shape, never under 64px, so that below 320px wide (a phone zoomed past 125%) the close button still stands clear of its ink line. On a screen under 500px tall, as a phone held sideways, it takes a third of the screen's height at most (and still 64px), where sized by the width alone it took more than half of it. It keeps that size without a banner (about 38% of less popular titles have none) and with one that fails to load, which hides, so nothing on the card moves as a banner arrives or fails, and the card never waits for it. Where there is no banner to show, the band is an impact frame in the title's color: the wipe's speed lines bursting from a bright middle over the page's screentone, with no blur, and in a contrast theme the same lines and dots in the theme's own colors. The close button stands over the band's corner, so with the keyboard's focus a white line keeps its red apart from its red ring, and in a contrast theme, which drops that line and leaves a banner's colors as they are, a halo in the theme's page color keeps the ring clear of the banner. Below it the cover and the text start at the top and stay there, so details that arrive after the card opens only lengthen it. The title's Japanese name stands in a grey ghost column off the cover's top-left, mostly behind it beside the text; where the card stacks, on phones and tablets in portrait, it is one whole column beside the cover, inside the card, from the name's first character, a long name cut at a whole character. web/e2e/title.e2e.ts checks the title page's layout at 390, 834 and 1440 px (the band at 280 px too, and on phones held sideways, at 667x375 and 844x390), staging a missing or broken banner on Frieren's recorded answer with rewriteAniList.
Below its facts and buttons, under an ink rule, the card shows the title's synopsis (web/src/components/TitleDetails.tsx), and its running time joins the facts ("24m each", or "1h 50m" for a movie), in words for screen readers ("24 minutes each"), which can read "24m" as "24 meters". As the title lab chose, the synopsis shows five lines, the last fading into the paper, with a Read more button that opens the rest and Show less that closes it (aria-expanded, aria-controls). The button is there only while the five lines hide something: that is measured before the card is drawn, from a list too, where the text is laid out only as the card opens (the button is rendered in that frame, not the next, when it pushed the tags down), and again as the text changes size, as when the page's own font replaces a fallback, while the clamp, five line heights, stays as tall in either font. The text inside the clamp is watched as well as the clamp, since text that reflows past five lines, or back to five, leaves the clamp as tall as it was. Its source line, "(Source: …)" or "[Written by …]", becomes a caption, and a title with no description says "Description unavailable.". Below it are the title's top 8 tags by rank as chips, each with its rank, never an adult one (spec §3G); its top 8 spoiler tags join them in place, marked by a dashed border in the title's text color and, for screen readers, by the word "spoiler" hidden from the screen, only once "Show spoiler tags" is pressed (aria-expanded), and go again at a second press. A title with no spoiler tags has no button, and one with no tags no chips. Opened from a list the card shows all this at once, asking AniList for nothing, as the lists carry each title's details (see AniList requests: asking as each title opens would soon leave a class on one network with every card saying AniList is busy); opened at its address, from its one request. The details rise in with the facts as the card opens and leave with them as it closes; open buttons take the title's fill (its accent pair with ink) and a doubled ink border, so they show open by more than a color often pale on white, and in a contrast theme the theme's colors for a selected thing, where shown spoiler tags keep their dashes. Closed from far down a long synopsis, Show less brings its button back into view at once (checked in a card staged without scroll anchoring, as in a browser that has none), and opened, Read more does the same where the text it opens above it pushes it past the screen's foot, as on a phone, 8px clear of the card's edge, room for its ring. Tab reaches Read more and Show spoiler tags after Open on AniList and Watch trailer, and the status line's Try again (see Title addresses) after them. web/e2e/title.e2e.ts checks each of these at 390 and 1440 px, with layout shift under 0.1 as a card opens, from a list or cold at its address, with AniList's answers held 2 s, all but Read more kept in view as it opens: the card walk in web/e2e/keyboard.e2e.ts checks that, its ring whole on the screen, at 1440 px with motion and at 390 px under reduced motion (see Keyboard).
A title with a trailer AniBeam can play has a "Watch trailer" button, last of the card's actions; one without has none at all, not one disabled. Only YouTube trailers count, by an id of 11 letters, digits, - and _, trimmed (AniList sends one with a tab after it), so nothing else can reach the addresses made from it (trailerOf in web/src/features/anime/trailer.ts). The lists carry where each trailer is, so the card knows at once, but nothing loads from YouTube or Google until the button is pressed (spec §5): no thumbnail, no frame. Pressed, it opens the trailer over the card in a native modal dialog (web/src/components/TrailerDialog.tsx), playing from https://www.youtube-nocookie.com/embed/ID?rel=0&playsinline=1, YouTube's privacy-enhanced address, in the page on an iPhone, and with &autoplay=1 unless reduced motion is asked for, on the device or with the site's switch (spec §3J). The frame is made only while the dialog is open, so closing it stops the video; it sends the site's origin as its referrer, which YouTube asks of an embed, and has no sandbox, which would keep the player from working. Close sits outside the video and takes focus first, so the keyboard never starts inside YouTube's player, and "Open on YouTube" is always there, a link to the trailer's page in a new tab, for a video YouTube won't play inside another site. The panel is paper in an ink frame with a hard shadow in the title's color, its video 16:9 from the first frame, so nothing moves as it arrives; it pops in with motion allowed, and fades in under reduced motion, and fades out as it closes either way. Its bar is laid out by the panel's own width, not the screen's, so the title has a row of its own above Close and Open on YouTube wherever the panel is narrow: on a phone, and on a phone on its side, whose short screen narrows the panel to fit its video. Esc, Close or a click on the dimmed card around it closes it, and focus goes back to Watch trailer. A click there counts only if its press began there too, not in the panel, as when selecting the title ends past its edge, and not as the second click of a double-click (or a phone's double tap), whose first opened the trailer over the button. Pressing the button adds a history entry at the card's address, so Back (on an iPhone, a swipe from the screen's edge) closes the trailer and leaves the card, Back again closes the card, and Forward opens the trailer again; Esc twice closes both, the card fading or playing back out as Esc on a card alone has it, and focus goes back to what opened the card. The trailer takes Esc as a key, not as the browser's request to close: the page can hold a dialog open on that request only once for each thing the visitor does, and Esc is no such thing, so holding the trailer open would have the card vanish at the next Esc. A card opened at an entry where the trailer was open, as after a reload, opens without it, and goes Back to the card's own entry just before, so one Back still closes it after its trailer plays again. Forward reopens the trailer only at an entry this page load made (each load marks its own): after a reload, or in a tab the browser brought back, Forward to where the trailer was open opens the card alone and goes Back to its entry, as nobody has pressed Watch trailer since. checks the button (none for a trailer on another site or with an id that isn't YouTube's, staged by changing AniList's answer), the dialog, Esc, the clicks and Tab at 390 and 1440 px, the panel on phones on their sides (932 by 340 px down to 667 by 375), and reduced motion, a request to close as Android's Back gesture makes it, Back and Forward, a reload, Forward after a reload and a contrast theme at 1440 px, in a Chrome that can't resolve YouTube's or Google's addresses (see Browser checks): that opening a card asks them for nothing, and that the press asks for the embed alone. Playing a trailer on a phone and a laptop, and Esc pressed inside YouTube's player, are hand checks.
AniList's description isn't plain text: it holds <br>, <i>, <b>, now and then a link, and HTML entities. web/src/features/anime/synopsis.ts turns it into paragraphs of lines of plain runs, each in italics, bold or neither (a tag inside its like closes only itself, as in HTML), and takes its source line apart for a caption: the first paragraph that is only "(Source: …)" or "[Written by …]", as AniList puts the synopsis's own source before any note, which can name a source of its own. Every other tag drops and keeps its words. Entities decode by number, or by name for HTML's Latin-1 set (Nausicaä's ä) and its typographic marks, and only after the tags are read, so a hostile description (a script tag, an attribute, a javascript: link, <script>) can only show as text, which React escapes. Nothing in the app sets HTML from a string: npm run lint refuses dangerouslySetInnerHTML. Its unit tests use real descriptions saved from AniList (saved-details.json beside it). Beside it too, tags.ts picks a title's tags for the card: no adult tags, spoiler tags apart for "Show spoiler tags" (spec §3G), and the top 8 of each by rank.
The design prototypes in web/src/prototype are for development only. Add ?variant=A, B, C or D to the dev server's URL to open one. Production builds leave them out.
The component gallery, at ?gallery on the dev server, shows every component on the sample titles, so the design can be judged without AniList. Its title cases, in web/src/gallery/cases.ts, each change a sample's fields to a gap a real AniList title has: no banner, a 52-character title, one long word, no color, no score, the small 230px cover, a cover that fails to load, a wide cover, no genres, studio or episode count, no native title, and an episode countdown. It also shows every state of each section (loading, failed with Try again, the samples, empty, and the intro's and the pull machine's states, one at a time), the title card's status line in each of its states (AniList busy, then its tries ended, with Try again), every status line, the buttons, chips and Soon tags, and each cover color's accents with their contrast. Any title on it opens its card. It asks AniList's API for nothing (the covers load from its image server, as in the app), and like the prototypes it is for development only.
The mood sliders, MoodSlider in web/src/features/mood, are on Discover's pull machine, and in the gallery (see Mood sliders). Each is a native range input from -2 to 2, so the browser gives it the keyboard, touch and a screen reader's controls; its stops' words are in moods.ts. Its look, a capsule thumb on a white track with an ink dot at each stop, is in its own stylesheet, MoodSlider.css, which ships with every visit.
npm test builds the production bundle in memory and fails on any trace of development-only code: a file made from the prototypes' or the gallery's folder, or named like them, their markers, a class only their stylesheets name (as a CSS @import would bring in), or a check of the address for them (the word gallery, or variant as a word of its own). A file in web/public named like them fails too, as that folder ships as it is. It also holds the bundle to a byte budget, each part brotli-compressed: the JS every visit loads, the CSS, the JS loaded only when needed (the samples), My List's page (which must be a file of its own), the mood scoring (scoring.ts, score.ts and the mood map, which must be one file of its own, loaded once the browser is idle after the page has drawn), and every other file the build makes (index.html, and any image a module imports). A change that needs more bytes raises its budget in web/src/app/privacy.test.ts and says why.
Visitors who ask for reduced motion get no sliding, zooming or swinging. They can ask on their device, which the browser reports as prefers-reduced-motion, or on the site, with the Reduce motion switch in the footer, above AniList's credit.
The switch is a native checkbox with role="switch", in web/src/components/MotionSwitch.tsx. On, it sets data-motion="reduce" on <html>, which every motion rule and script obeys, and saves the choice as localStorage['anibeam:motion'] = 'reduce'. Off, it takes both away, and motion comes back without the page playing its entrances again: the animations that come back with it finish at once, at their last frame, so nothing already on the page moves, while what arrives later, such as the next pull, moves as usual. On a later visit, web/src/main.tsx puts a saved choice back on <html> before React draws anything, so no entrance plays. If storage is blocked or full, the choice lasts for the visit only.
The switch can only reduce motion. It stops motion on a device that allows it, but it can't bring motion back while the device asks for reduced motion. While the device asks, the switch shows on and locked (yellow, flat and dashed), with the line "On because your device is set to reduce motion." It stays focusable, with aria-disabled instead of disabled, so a screen reader reaches it and hears that line. A click or Space changes nothing, and a choice saved on an earlier visit stays saved for when the device stops asking. The switch follows the device's setting as the visitor changes it, with the page open. When the device stops asking, motion comes back the same way.
The switch comes after every title on the page, so a second skip link, "Skip to Reduce motion", follows "Skip to content". Like the first, it shows only while it has focus. It links to the switch's checkbox, #reduce-motion, the one part of the switch that can take focus. So from the top of the page, Tab, Tab and Enter put the focus on the switch, ready for Space.
The site's setting lives in one place, data-motion="reduce" on <html>. Styles and scripts read it there, together with the device's setting.
In the app's scripts, web/src/lib/motion.ts is the one place that asks the device:
reducedMotion() is true when <html> has data-motion="reduce" or the device asks for reduced motion. A script that moves anything asks it each time it is about to move, as the pull's reveal does.deviceReducesMotion() and watchDeviceMotion() read the device's setting alone and follow its changes, for the switch's locked state, and so that a title card playing its way back closes at once if the device starts asking for reduced motion.savedMotionChoice() and saveMotionChoice() read and write the saved choice, in try/catch.finishEntrances() finishes the animations that motion coming back on starts over, on the page's own clock only: the splash cover's drift follows the scroll, and transitions such as the switch's knob run on.watchMotionBackOn() calls back each time motion comes back on, by the switch or on the device, before the browser draws a frame of it, and the switch has it finish the entrances then. It watches the size of .ab-motion-sentinel, a box in the footer that no one sees, which the motion gates widen from 1px to 2px: a ResizeObserver calls back after layout, before the frame is drawn, while the media query's change event comes a frame late in Chrome.In the styles, every motion rule sits in one of the motion gates in web/src/components/components.css, written like this:
@media (prefers-reduced-motion: no-preference) {
:where(:root:not([data-motion='reduce'])) {
/* what motion adds */
}
}
The media query hears the device, and the :where() rule holds only while the site's setting is off. :where() adds no specificity, so the rules inside keep their rank. The production build writes the nesting out as plain selectors, for browsers that can't read nesting.
The calm look is every rule's default, and a gate adds the motion. Motion that needs another condition, such as a width, nests it inside the :where() rule. Rules for <html> itself, such as smooth scrolling, go in & { … } inside the :where() rule, which matches <html>: nested there as html { … } or :root { … }, a rule reaches only what lies inside <html>, so it never applies. Never write a rule under @media (prefers-reduced-motion: reduce), and don't use Tailwind's motion-safe: or motion-reduce: variants: a media query hears only the device, so the switch can't reach them. Tailwind's motion utilities, such as animate-spin or hover:-translate-y-1, can't sit in a gate at all, as Tailwind writes their rules outside it, and neither can a transition or an animation in a style prop: give the element an ab- class, and its motion a rule in a gate.
npm test holds the app to this. web/src/components/motion.test.ts reads every stylesheet in web/src but the development-only prototypes'. Outside the gates, it fails on a transition that names a property that moves or resizes a box, or all, or that names none, which means all, as transition: 0.2s does; on a hover or press rule that sets such a property; on an animation other than a fade of 150 ms or less that plays once; on smooth scrolling; and on a rule that pauses an animation (a paused animation outside the gates would hold its frame for good). The properties that move or resize a box are transform, translate, rotate and scale, any width or height, top, right, bottom, left and inset, and margins and padding, with their longhands and prefixed names. A var() in a transition outside the gates reads as each value a rule there sets its custom property to, and as its fallback; one with neither fails, as the check can't read it. It also fails on a rule in a gate but outside its :where() rule, on a rule in the :where() rule that names html or :root, and on a rule that asks the device about motion outside a gate. web/src/lib/motion.test.ts reads the app's scripts and stylesheets, all but the tests and the prototypes, and web/index.html. It fails on any line of code in a script other than lib/motion.ts that names prefers-reduced-motion; on Tailwind's motion variants; on its motion utilities in a class name, in the markup or after @apply (animate-…, transition, duration-…, scroll-smooth, translate-…, rotate-…, scale-… and skew-…, under a variant or not); and on a style prop that sets a transition, an animation or scroll-behavior. The browser checks run the reduced-motion group twice, once with the device asking and once with the site's setting on, and load the page with the choice saved. In both, a walk through every part that moves, on a desktop and a phone, must start no animation or transition that moves or resizes, by CSS or the Web Animations API, only fades and changes of color, listening for at least 500 ms after each step, so that one starting late is heard, and 1 s after each step leave none on the page: the scroll slam and the links docking, the decade strip scrolled sideways, a search, a title's card with its band, details and banner (the step waits for the banner to load or fail), Read more and the spoiler tags, the trailer and Esc out of it and the card, a pull, the phone's header hiding and coming back, the menu by its button and Esc and by a swipe and a drag, and the search bubble growing and folding. A script that moves something by setting its style starts no animation to hear, so in the swipe and the drag nothing in the header or the menu may change its transform under the finger, as the hamburger's bars and the menu do with motion allowed; checks the menu's drag on its own too. A card opened from a trending panel, and one closed by its Close button, have checks of their own in the group. The site's setting is turned on there with a click on the switch. Others check the switch itself: Space, the skip link, the locked state, a reload, the contrast themes, and phones, where it must show in full, with its locked line wrapping under it. And after a pull, turning motion back on, by the switch or on the device, must leave nothing on the page playing again in the first frame drawn after, the capsule included, while the knob slides off, the splash cover's drift follows the scroll, and the next pull plays in full. With motion allowed, a fresh load must still play the intro's entrance in full.
Everything on the page works from the keyboard. From the top, Tab reaches its 46 stops in reading order (43 on a phone, where the menu button stands in for the intro's four buttons), from the two skip links to the Reduce motion switch, and Shift+Tab goes back the same way, meeting the header's section links where the intro's buttons were, once those have docked (see Section links). The section links hand focus to their section's heading, so Tab goes on inside that section. Each stop shows its focus whole: the browser brings what takes focus in the page into view 10px in from the screen's edge, room for its ring (the footer's switch, 8px), and at the top 10px below the header, at every width: a phone's header stays on the screen too, and a scroll that Tab makes leaves it as it is (see Section links). web/e2e/keyboard.e2e.ts walks every stop both ways, on a desktop with motion and on a phone with reduced motion, and checks that each one's focus shows by WCAG's measure for a focus indicator. It walks a title's card the same way, at its address (Frieren, whose card has every button a card can have): from Close round through Open on AniList, Watch trailer, Read more and Show spoiler tags, the browser's own controls and the card itself, which scrolls, back to Close, and Shift+Tab back again; Read more pressed and pressed again; into the trailer, on its Close, to Open on YouTube and back, and Esc out to Watch trailer; and the round again with the status line's Try again in it, as AniList fails to give the title anew. It also uses the page by keyboard alone: Esc in the search box, the arrows, Home and End on a mood slider, the arrows on the decade chips, and Space on the lever and on the title card's Close button.
The intro's three buttons (See what's trending, Pull a random title, Browse by decade) are the page's section links, so the header's Trending, Pull and Decades wait hidden in their place while the buttons are on the page. A fourth follows them, My List, a link to its page at /list in the same white look, and a Tab stop; the header's My List waits hidden with the rest. From 1100 px the four sit in one row, which reaches under the cover fan's column, the fan clear above it; from 900 to 1099 px, where one row would run into the fan, in two rows of two, lined up in columns, never three and one. From 900px, where the header stays on screen, the buttons dock as the header reaches them: all four fly up into its slots in one 420 ms move with a slight overshoot, 45 ms apart, their labels shortening (My List's copy has one label, which stays steady, as it reads the same at both ends, and fades its shadow as a plain link's does), and fly back down into the intro once their place there is 200 px below the header again (or as far as they sit at the top of the page, on a short screen). Only a scroll by the visitor flies them; a jump, Tab, a reload, a resize or reduced motion (the device's or the site's switch) swaps the two sets at once, and Tab mid-flight lands it first. The header marks the section the visitor is in (the last whose top has passed two thirds of the way down the screen below the header), in sound-effect yellow, raised like a picked chip; any link turns ink when hovered or pressed. A copy flying in fades its shadow to the look it lands on, and a resize lands a flight at once. The intro's buttons are named Sections for screen readers, and the header's links Site; each set holds My List, which is never marked as the section the visitor is in. My List's links go to /list with no new page on a plain click, handing focus to My List's heading, as a section's link does to its own, and AniBeam goes to / the same way, a jump to the top: the buttons swap in at once, with no flight, and the search empties, the intro back in the same frame. A click with Ctrl, Cmd, Shift or Alt held is the browser's (a new tab, say). The header marks My List there as the page on screen, in the same yellow; the header shows no count beside it, so its width never changes and the buttons fly to where its links are. On My List the section links lead to Discover's sections, at /#ab-trending, /#pull and /#decades: a plain click goes there with no new page and jumps to the section's heading once Discover has drawn, focused, as on Discover. One set is shown, reachable and read at a time, a link with focus never hides, and nothing moves as they swap: the hidden set keeps its room. web/src/lib/sectionNav.ts holds the rules, as pure functions, and applies them, writing the set as data-links on the page's root; web/e2e/navigation.e2e.ts checks them, and the buttons' rows at widths from 900 to 1920 px, with the flight from each of their layouts.
Under 900 px the header is one 78 px row (from 320 px; see below): a hamburger and the logo at one side, the search bubble at the other (its placeholder says Search where the room beside the logo can't hold the bubble around Search titles, measured, so under about 358 px, and on wider phones too with widened text spacing (WCAG 1.4.12); the box is named Search titles at every width). Below 320 px, as on a phone zoomed to 125% or more, even Search would be cut there, so the search takes a row of its own under the hamburger and the logo, as it did before the menu, and the header publishes its taller height as ever. While the box has focus, the bubble grows down over the page a row at a time as what is typed wraps, easing to each row in 0.3 s (at once under reduced motion), up to three rows, past which the box scrolls a whole row at a time; the header keeps its one row, so nothing on the page moves. Enter adds no new line, and on a touch screen puts the keyboard away so the results show. A keyboard put away another way, as Android's Back does, also takes focus out of the box (unless an input method is converting a word), so the bubble folds and the header can hide again; the screen growing back by most of what the keyboard took says it has gone (keyboardAfter in web/src/lib/searchBox.ts). A pasted line break becomes a space, and Esc empties the box (unless an input method is converting a word: then Esc is the input method's, to undo the conversion). The box asks a phone's keyboard not to capitalise or correct what is typed, as a title in romaji is no word it knows, and its text is 16 px wherever a touch keyboard is likely (under 900 px, and on a touch screen of any width, such as a big iPhone held sideways), so iOS doesn't zoom the page as it takes focus. As focus leaves, the bubble folds back to one row, showing the first, so it never sits over the page, and a finger's scroll that starts on it scrolls the page. The box is a textarea, so that it can wrap; screen readers read a textarea as a plain text box, so it sits in a search landmark that says it is the search. From 900 px it keeps to one line, as before, and while it holds a query a Clear search button sits at its end, where even a long query scrolled to its last word stops short of it, the caret kept in view as the bubble settles after a paste. A long query also stays 18 px in from the bubble's left edge however far it has scrolled, and shows its start again as focus leaves. Like the search input's own, the button is no Tab stop (Esc does the same), and it sits beside the box's label, so the box is still read as Search titles alone. Under 900 px neither the header's links nor the intro's buttons are drawn. The header stays at the top of the screen, but slides up out of sight (0.2 s) once the page has scrolled 12 px down from its highest point, and back down (0.28 s, easing out) once it has scrolled 8 px up from its lowest, at once under reduced motion; near the top of the page it always shows. A scroll that a move of focus makes (Tab, a link's jump) and one the visitor didn't make (the page or the screen changing size) leave it as it is, and so does an open menu or title card. It never covers a focus ring: it goes up when one comes under it, and waits to come back while one is near the top. A heading that a tapped link focused shows no ring, so it counts for none, though an iPhone counts its focus as the keyboard's. Focus in it with its ring keeps it on the screen, and Shift+Tab back up into it shows it in place at once, without moving the page; a tap's focus, or the hamburger the menu hands focus back to after a tap or a swipe, doesn't hold it. A link's target lands 18 px below it either way. The hamburger opens the menu, : a modal panel, like a printed manga page, that slides in from the left (a 150 ms fade under reduced motion), with the three section links and My List, a link to with マイリスト beside it, last; a tap on it shuts the menu and goes there with no new page. It opens on the link to the section the visitor is in, or My List's on My List, marked in yellow, and holds the page still while it is open. A clearly sideways swipe to the right anywhere on the page opens it too, far enough (48 px) or quick enough: on the header's row, a trending panel, the pull machine or empty paper alike. Not one from within 24 px of the screen's left edge (the phone's own Back swipe), on the search bubble, where a drag selects text, or on a row that scrolls sideways itself, the decade strip, which the swipe scrolls as ever; not one across the title card, or while text is selected, before or during it; not one that goes on with a second finger down, resting or not, as two fingers on the glass make a pinch, the browser's zoom; and not one that, at any point, has gone less than twice as far across as up or down from where it started, which scrolls the page as ever, a scroll whose first few pixels hook sideways too. The swipe only reads the page's touch events, holding none of them up, so scrolling, zooming, selecting text and the strip's own scroll work as before, in iOS Safari too; a click straight after the swipe, where the finger lifted, does nothing. A link jumps as the header's links do, and the menu shuts on it, on its X, Esc, a tap on the dimmed page, Back, the address going to a section, the page widening past 900 px, or a drag to the left: the panel follows the finger (only a little to the right), shuts past 30% of its width or on a quick flick, and springs back otherwise; under reduced motion it stays still under the finger, and the drag still shuts it. A second finger laid down during a drag of the menu changes nothing: the first finger's drag goes on, and its lift alone lets the panel spring back or shut. During a swipe on the page, a second finger that taps and lifts before the first moves on changes nothing either. Pinch-zoomed in, neither swipe works: a sideways drag on the page or in the menu pans the zoomed page, as anywhere else, so its edge can be brought into view, and the hamburger, the X, Esc and a tap on the dimmed page still open and shut the menu. The X, Esc, a tap on the dimmed page, Back, the address and a drag hand focus back to the hamburger (with no ring after a tap or a swipe); a link hands it to its section's heading, and widening, which takes the hamburger away, to the logo. The swipes' rules are in . Its links are named Sections for screen readers. checks the row at every phone width from 320 px (and at 360 and 375 px with widened text spacing), the search's own row below 320 px, the bubble's rows as a long title is typed at 320 and 390 px, each way the menu opens and shuts, the swipes with a finger's touches (from the header, the page's panels and paper, and the places that keep their own drags, with a second finger's, and zoomed in), and each rule of the header's hiding; its rules are in .
While titles load, each section shows empty panels the size of the titles to come, with its status line as a tag over them ("Loading…", or why the titles are late), so nothing on the page moves as the titles arrive. A pulled title takes its place the moment the lever is pressed, unseen until its capsule opens. web/e2e/layout.e2e.ts measures layout shift as web-vitals counts it for CLS, on a first visit and a slow one, on a phone and a wide screen, and on a pull on a phone, and holds each under 0.1, Google's mark for good. web/e2e/title.e2e.ts does the same as a title card opens, from a list or cold at its address, with AniList's answers held 2 s, and as its status line comes; its check that late details move nothing above them measures the cover and the title themselves, since Chrome counts little of a move inside the card's text column as layout shift.
Every title has its own address, /anime/ and its AniList id, such as /anime/154587 for Frieren, and every title on the page links to it. A plain click opens the title's card over the page and puts its address in the address bar, as a new history entry, so Back closes the card and Forward opens it again. The card opens from the list's copy of the title, so neither asks AniList for anything, and that copy is never saved as AniList's answer for the title. A click with Ctrl, Cmd, Shift or Alt held, or with another button, is left to the browser: a new tab, say, opens the title at its address. Close and Esc go Back when the page put the address there, and focus returns to what opened the card however it closes. While a card is open, the browser's tab shows its title.
A typed or shared address opens the title's card over Discover as AniList's answer arrives. On a visit that starts there, Discover's own lists wait until the card closes, so the link costs one request while it is open, and the lists ask for theirs, in one request, as it closes; closing it puts / in the address bar. If AniList has no anime for the id, or the title is an adult one, a note says "We couldn't find that title." in the card's place; if AniList can't answer, the note says why, as a section's status line does, with Try again when it can help, and stays, saying it is trying again, while AniList is asked again. The note fades in and out over 150 ms. A card opened at its address whose saved answer has gone out of date, as an airing title's does after 6 hours, asks again as it opens. If AniList answers 429 or fails, the card keeps the title it has (spec §3I), and a status line at the screen's foot says why in the note's words, with Try again once the app's tries end (cardNote in web/src/app/status.ts). It sits past the card's end, so nothing moves as it comes, and sticks to the screen's foot while the card scrolls; while it shows, a part Tab reaches scrolls clear of it (scroll-padding-bottom from its measured height), so no stop is hidden under it or out of a click's reach. A card opened from a list asks for nothing, so it never needs one. An address the app doesn't know becomes /. web/src/lib/route.ts reads the address and changes it; it reads only the path, so the query is left for chat's episode rooms (/anime/:id?ep=N, Phase 7) and the fragment for sync. web/e2e/addresses.e2e.ts checks these, in Chrome; Back and Forward in Firefox and Safari, and Back after a reload there, are hand checks.
The title card has a My List row above its actions, in web/src/components/ListControls.tsx: Save and Want to try, joined as a pair, and Favorite, with a heart. A title has one status at a time. Pressing Want to try on a saved title moves it there, and pressing the button already pressed takes the title off the list. The heart on a title that isn't listed saves it, with the heart, and taking a title off takes its heart too. Each button tells a screen reader whether it is pressed (aria-pressed), and its label never changes; pressed, it fills in red, with a white line inside its border, and a contrast theme shows it in its colors for a selected thing. A line inside the card, which only a screen reader hears, says when a title moves or is taken off, when an edit couldn't be saved, and when the list is full at 2,000 titles; a plain press says nothing, as the button says it. The buttons show a move or a removal too. The last two, which the buttons can't show, also appear in a short note under the row until the next press in the row or the card closes; a screen reader hears them from the line alone, not twice. The note's room is always kept, as tall as the longer note, so nothing on the card moves as it comes and goes. Once another tab changes what the buttons show, the line empties without a word. The row is the same in the card over a page and at a title's address.
My List's page is at /list, in Discover's place, in web/src/features/list/ListPage.tsx, linked from the header, the phone's menu and the intro's fourth button (see Section links). It has two tabs, Saved and Want to try, each with its count, and Favorites only, which shows the titles with the heart. All three are links that change the address (/list?tab=want&fav=1, read and written by web/src/features/list/view.ts), the current ones marked for screen readers (aria-current) and in sound-effect yellow with an ink line inside, so Back and Forward move between them and a view can be shared. The titles show as covers in the list's order, the most lately changed first ("Recently changed"), and each opens its title card over the page, where it is edited; the card's Open on AniList is the spec's Track on AniList link. The page asks AniList for its titles in one request, by id, for the 50 most lately changed of both tabs (AniList's page; with more on the list, it says "Showing your 50 most recent."), so the tabs and the filter ask for nothing more. The answer is fresh for 6 hours and kept 30 days, so a visit within 6 hours, with the same titles on the list, asks for nothing; a change to the titles the page takes (one saved or taken off, or with more than 50, an edit that changes which are the 50 most lately changed) asks once more, for the new set. Each view takes its titles as it opens: a heart or a move made in a card over the page changes neither which titles show nor their order, and asks for nothing. A title taken off the list leaves an Undo in its place, the size of its cover, so nothing moves: "Removed from Saved." (or Want to try) and Undo, which takes focus once the card closes (the title put back in the card, its cover takes it) and stays, with no timer, until the tab or the filter changes or the page is left. Undo puts the title back as it was, heart and all, as a new edit made after its delete marker, never at its old time, which a copy of the list holding the marker would beat (spec §6), and focus goes to its cover; it asks AniList for nothing, and AniList's saved answer for the list is left as it was. While the answer comes, an empty card holds each title's place, and titles that Discover's lists, a search or an earlier answer already brought show in theirs at once, so nothing moves as the rest arrive. An empty tab says so, with a link to what's trending, which goes to Discover with no new page, so offline too, and jumps to its Trending heading, focused, where the header's Trending link lands it, and Favorites only with no favorites in the tab offers Show all, which takes the empty state away and gives focus to My List's heading. With more than 50 titles on the list, a view whose own titles are all older than the 50 shown says so in their place ("Your 3 Want to try titles are older than your 50 most recent."), so its count never stands over nothing. A note under the titles says the list is saved in this browser only. A title AniList's answer leaves out (one it no longer has, or an adult title) keeps its place as a plain card, "Details unavailable", so nothing moves, and a line under the titles says how many couldn't be shown, with Remove them, which takes them off the list in one edit, each with its delete marker; a failed answer (a server error, a refusal) leaves out none, and offers Try again. With AniList out of reach and no answer saved, the sample titles on the list fill in their places, as on Discover, never saved as AniList's answer, and the status line adds "Sample titles fill in what they can."; offline, the answer saved within 30 days still shows, and with none saved, a title no other answer brought says "Details unavailable offline" until the connection is back. A search typed in the header on My List goes to Discover, at , as a new history entry, and shows its results there, so Back returns to My List, which stays put until the box changes again. The page is a file of its own, so a visit that never opens My List doesn't download it (). It loads as My List opens; on a visit that starts there, or at a card opened over it, before anything is drawn, so the header and the footer never show alone; and in the background, once the browser is idle, as soon as the list holds a title, saved then or on an earlier visit, so that My List opens offline too. Offline before it ever loaded, its place says "Details unavailable offline", with Try again, and the page draws as the connection comes back. Discover's lists stay off on , under a card opened over it and after a reload there too, so a cold visit costs one request: counts it in Chrome's own network log, and checks the tabs, the filter, the empty cards, that nothing moves as the titles land (layout shift 0.0000), Undo, a minute on too, the titles left out, a failed answer, AniList out of reach, offline, the search typed there, the page drawn whole in the first draw, and its file loaded in the background and opened offline. walks its Tab stops forward and back, on a desktop and a phone, with an Undo among them, and the reduced-motion walk in follows its tabs and Favorites only, takes a title off and presses Undo, starting nothing that moves.
The cover of a title on the list wears a stamp, in web/src/components/Stamp.tsx (Phase 4's optional PR 4, item 1, as the user picked it in a lab, #42): a white folder tab growing out of the top ink line of the cover's caption or title box, at its left, with SAVED or WANT in red capitals and a red heart for a favorite. It shows on trending's panels, 13 px there from 900 px under their larger captions; on the decade strip's covers and a search's; and on the machine's top 3 matches, at the cover's foot across from the rarity sticker. The rolled card and the title card have none, as their My List row says the same. Nor do the development-only gallery and prototypes, whose sample titles have real AniList ids, so a stamp there would show whatever this browser's list held: main.tsx draws them under Unstamped (in web/src/components/stamp.ts), and web/e2e/gallery.e2e.ts checks the gallery with every title on it listed. A screen reader hears it at the end of the cover's name, after the score or the season: "Violet Evergarden, Winter 2018, saved", or ", want to try", then ", favorite"; a match's name has it before its period, as "ONE PIECE, SSR, saved, favorite." The tab itself is hidden from screen readers, so nothing is heard twice. A stamp is not a button: it sits inside the cover's link, adds no Tab stop, and a click on it opens the title's card, as a click on the cover does; the title is edited there. On My List a stamp says only what the page doesn't already: its word only for a title moved off the tab shown (one moved in its card stays in the view it opened with), and its heart only with Favorites only off, so a favorite on the Saved tab shows the heart alone and a plain saved title nothing. Each stamp reads only its own title's entry from the store (useEntry), so an edit, in this tab or another, draws again only that title's covers. Placed over its box, it takes no room, so nothing moves as it comes, changes or goes, and a contrast theme draws it in the link's colors, as its caption's frame. web/src/components/stamp.test.ts tests its words and My List's rule, and web/e2e/layout.e2e.ts the names in Chrome's accessibility tree, that no button or link sits inside a link, each stamp on the ink line it grows out of, inside its panel and clear of the bubble, the rank and the rarity sticker, at 280 and 1440 px, layout shift 0 as stamps come on trending, the decade strip and a top match and as one changes, at 280 px, a contrast theme, the tab's 11 px and, from 900 px on trending, 13 px, a tap on a tab opening the title's card on the page, no stamp on the rolled card, and My List's rule, on the Saved tab and under Favorites only.
The list is kept in this browser's localStorage under one key, anibeam:list:v1, by one store, web/src/features/list/store.ts, the only code that writes it (spec §3F). For each title it holds the AniList id, the status, the heart and when it last changed, and nothing else: no title, no name or account, and nothing is sent anywhere (spec §5). So it stays in this browser on this device: another browser has its own list until sync (Phase 8), clearing the site's data deletes it, and Safari deletes a site's storage after 7 days without a visit. A title taken off keeps a small marker with its time, so that an older copy of the list can't bring it back. Open tabs share the list: each edit reads the saved list and merges it with the tab's own, title by title, the later change winning, so an edit made earlier in another tab is kept, and an open card shows another tab's edit at once. Two tabs saving at the very same moment can still write over one another's edit: a tab with a title's card open saves its own again as it hears the other's, and one without keeps it for the visit and saves it with its next edit, so it is lost only if that tab closes first. If storage is full, the saved AniList answers (anibeam:cache) are deleted to make room, as they can be asked for again and the list can't; if the edit still can't be saved, or the browser blocks storage, it lasts for the visit, and the line says so. A saved entry that can't be read is left out on its own, never the whole list. web/src/features/list/store.test.ts and said.test.ts test the store and the line's words, and web/e2e/title.e2e.ts the row: saving that survives a reload, the keyboard, two tabs, a contrast theme, and widths from 280 px.
Discover's pull machine, web/src/features/mood/PullMachine.tsx, has three mood sliders, Energy, Tone and Complexity, in its red body, with Reset moods while one is set. A tap on a slider's track sets the stop under the finger (Safari on an iPhone would only focus it), and no code ever focuses a slider. The machine holds the decade open in Browse by decade below it. With a slider set, its titles are ranked by how close each sits to the sliders, from its genres and tags (score.ts, in a file of its own that loads once the browser is idle), and a roll gives one of the 12 best; with none set, any title. Never one on My List, nor one of the last 30 rolled while another can come up. Under the hint or the rolled title, the top 3 matches each show their cover, rarity and a Why line naming the tags that matched, never a spoiler tag; with no slider set they are the decade's most popular ("Most popular in the 1990s"). A line over them says what holds them up: the pool on its way ("Filling the machine…"), the samples during an outage, offline, AniList busy, or the mood scoring that didn't load, with Try again; a screen reader hears it from the machine's status line as it begins, but never the sliders' words. The sliders, the last rolls and the rolled title are kept for the visit (session.ts), through a visit to My List.
The machine ranks the decade's titles on the page, the strip's 20, until it has the decade's mood pool, about 100 titles with every tag the scoring reads, in one request (poolQuery in web/src/features/anime/queries.ts). It asks for it only once it is wanted, so a visitor who only looks, or rolls a few times, costs AniList nothing more: a slider pressed (a pointer down or a key, never focus alone), a decade picked with one set (its titles and its pool then come in one request), a roll made with fewer than 10 titles left to come up, or a page none of whose titles can. It never asks before the decade's titles are in, nor during an outage, offline, or while a title opened at its address holds the page's lists. Once the pool is in, moving a slider asks AniList for nothing: the pool's key holds the decade alone, it stays fresh 6 hours, and coming back to the tab never refreshes it. A pool saved longer ago refreshes once, when it is next wanted; a refresh that would re-sort the matches on screen waits until they have left it (heldPool.ts). web/e2e/mood.e2e.ts counts these requests from Chrome's own log of what the page asks for.
A search with no matches says so on its line, then offers the sliders: "No titles found. Try the mood sliders.", the last words a link to /#pull. It is followed as the header's Pull is, with no new page: the page jumps to the machine, below the results on the same page, and its heading takes focus. A screen reader hears the search's outcome from a line of its own, which can hold no link, so it hears the same words without one: "No titles found for “zzqqxx”. Try the mood sliders.". The decade strip's own "No titles found." has no link. /mood, the spec's address for the sliders, typed by hand or followed from an old link, becomes /#pull in its place before the page first draws, keeping any query, and the page then jumps to the machine the same way; as no click led there, the heading is left unmarked, so the browser's own rule shows or hides its focus ring. Back goes to the page before it, never to /mood.
The machine's lever reads "Roll!", then "Roll again!", and a screen reader always hears it as "Roll a random anime", a name that never changes. A roll drops a capsule, pops it open and turns a card over on the title: 700 ms in all, the drop 250, the opening 150 and the turn 300, named once in web/src/features/mood/flip.ts, where the stylesheet and the checks take their moments from. Just before the card turns, its back glows in its rarity's color. A press while the card turns ends the turn and rolls nothing, and the next press rolls. Under Reduce motion, from the device or the site's switch, the card only fades in, over 150 ms, the lever stays still and nothing glows; motion turned off as the card turns ends the turn at its last frame. The rolled card shows the cover, the rarity sticker, the title, its season, format and studio, the Why line and the rule "Rarity is just the AniList score. It never changes what you roll.", then the title's own My List row, Details, which opens its title card and gets focus back as it closes, and Roll again. After a roll from the lever, focus stays on the lever. Wherever the card stacks beside the machine, from 821 to 1170px wide and up to about 1190px beside a scrollbar that takes room, as on Windows, its room is kept from the start, so a first roll moves neither the lever nor what is under the card; on narrower screens the card comes in under the machine, below the lever.
What can come up: never a title on My List, nor one of the last 30 rolled while another can, nor another season of a show rolled in the last 30 while a title of another show can (its seasons followed through the pool's prequels and sequels, in web/src/features/mood/roll.ts). With a slider set, a roll picks at random among the 12 best matches left; with none set, among them all. Once only recent rolls are left, the oldest comes back first, under the line "Every match has come up once. Repeats start now.". A roll made with fewer than 10 of the pool's titles left to come up (the one after the roll that leaves 9) asks AniList once for the pool's page 3, its next 50 titles, only if page 2 said there is one, and never for a slider's move; the saved copy keeps a page 3 only with its pool. Until it lands, no line and no roll says repeats start or how few are left, as its titles would then come up, and AniList's 429 on it shows the busy line; offline or during an outage, when it can't come, they say repeats start as they do. Once the machine holds all it will of the decade, under 20 titles left, the line says so ("Only 7 titles left to roll in this decade."). With a card rolled and every title on My List, it says "Everything in the machine is on your list. Try another decade below." and the lever is off. A screen reader hears each roll once, from the machine's status line, as its card shows ("Roll 7: Mushishi, SSR."), with the line's words when the roll brought them. They are kept with that roll, so a later change to My List never reads an old roll out again.
The checks that hold it:
web/e2e/mood.e2e.ts "20 rolls at Very cozy give 20 different titles": Phase 5's second done-when, in one page load under reduced motion, with 2 titles on My List, waiting on the status line each time, never on a timer, and asking AniList nothing.web/e2e/mood.e2e.ts "with a pool cut to 24 that has a page 3": a roll made with fewer than 10 left asks for page 3 once, none before it, and the sliders' moves still ask for nothing.web/e2e/mood.e2e.ts "with page 3 held by AniList’s 429": the pool's last titles roll under the busy line, none said as repeats, and page 3's titles come up once it lands.web/e2e/mood.e2e.ts "with page 3 wanted offline": the roll that leaves none to come up says repeats start, on the line and to a screen reader, and once back online page 3 lands and the line clears.web/e2e/mood.e2e.ts "in a decade with no page 3": the line counts the titles left down, then the repeats, and no page 3 is asked for.web/e2e/mood.e2e.ts "a change to My List with no roll": the line changes, never what was said of the last roll.web/e2e/mood.e2e.ts "with the rolled card showing, every title left put on My List": the lever and Roll again go off, and the line says why.web/e2e/mood.e2e.ts "Save on the rolled card" and web/e2e/mood.e2e.ts "Details on the rolled card".web/e2e/discover.e2e.ts "screen readers hear each roll in one sentence".web/e2e/discover.e2e.ts "a roll’s capsule, the glow on its card’s back and the turn are timed by flip.ts", each animation's delay and length as the browser times it.web/e2e/discover.e2e.ts "a roll’s card turns over mid-roll", on a stopped page clock, and web/e2e/discover.e2e.ts "as the capsule pops open, before the card turns", the glow.web/e2e/discover.e2e.ts "motion turned off mid-flip", web/e2e/discover.e2e.ts "a roll’s card never glows" and web/e2e/discover.e2e.ts "a roll’s card only fades in", for Reduce motion.web/e2e/layout.e2e.ts "at 280px the rolled card fits the screen whole" and web/e2e/layout.e2e.ts "beside the machine, from 821 to 1170px".The gacha took the JS every visit loads from 93,711 to 94,504 B, 793 B more (the plan said 600 to 800), about 200 B of it only the new name of My List's page file in an import and 95 B the review's fixes for page 3 on its way, and the CSS from 12,530 to 12,879 B, 349 B more (the plan said 400 to 550). See Byte budgets now.
Parts of the page land once, as they scroll into view: the trending panels, the decade and search cards, the pull machine and the section headings. The intro, the splash cover's drift, the title card and the pull's reveal play as before. A part waits, see-through in its place, until 15% of it shows, then slams in: a trending panel as solid ink from 1.14 times its size and turned 2.5 degrees, then its ink wipes off, its cover settles and its caption pops; a card pops in; the machine drops in; a heading's sound effect booms. Parts that come into view together land one after another, in reading order, 90 ms apart (cards 60 ms), and a card scrolled into view sideways lands then. After that nothing moves again: scrolling back replays nothing, and neither does a refresh that moves a panel. Keyboard focus lands a part at once, but a heading reached through a header link or an intro button, or handed focus by Try again, booms as it takes focus, and a printed page shows every part. Under reduced motion, from the device or the switch, nothing waits and nothing moves.
web/src/lib/landing.ts decides when: one IntersectionObserver, a MutationObserver that finds parts as titles load and lists change (before the browser draws them), and a focus listener. Each part carries data-land: wait, go while its slam plays, then done, with no animation left. The Landing section of web/src/components/components.css does the moving, inside the motion gate. The knobs, --slam-scale, --slam-tilt and --slam-stagger, are tokens in web/src/styles/index.css. A part showing by less than 15% lands once it has stayed in view 0.2 s, and a part lands as it shows below the sticky header, not under it, or at the screen's top while a phone's header is hidden. web/e2e/landing.e2e.ts checks it: nothing 15% on screen waits, a sliver lands after a moment, each part slams once and shows in time, nothing replays, reduced motion hides nothing, no part's box moves and the visitor's place holds as content arrives above, a phone never widens, nothing is drawn before it is marked, focus and print see every part, and the empty panels that hold the titles' place never land.
The display font is Dela Gothic One, by the Dela Gothic Project Authors. The body font is M PLUS Rounded 1c, by the Rounded M+ Project Authors. Both are self-hosted from web/public/fonts, so visitors' browsers never contact Google. npm test builds the production bundle in memory and fails if any file in it references Google Fonts.
The files are subsets with Latin letters, kana and the star symbol, about 100 KB for all three. Kanji fall back to a system font. They load with font-display: optional, and index.html preloads all three, so they come before the page first draws its text: a font that comes later is left out until the next visit, as swapped in it would move the page. web/e2e/list.e2e.ts checks both: nothing on My List moves with the fonts held till it has drawn, and a cold visit 40 ms from the server draws in the page's own fonts. Both fonts use the SIL Open Font License, and web/public/fonts/OFL.txt carries their copyright notices.
web/e2e holds checks that run the real page in headless Chrome: layout and layout shift on desktop and phone, keyboard use, the section links, the scroll slam, title addresses, reduced motion, Windows contrast themes, the title card and its trailer, search, the pull, trending refreshed while it is on screen, what a visitor sees when AniList is down, My List's page at /list, and the component gallery. Two more check AniList's API itself. They need no extra package. Node drives Chrome over its DevTools protocol.
To run them, start the app with npm run dev, then point CHROME_BIN at a Chrome or chrome-headless-shell binary:
CHROME_BIN="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" npm run test:browser
A run takes about 7 minutes: npm run test:browser runs 4 test files at once (--test-concurrency=4 in web/package.json), each with a Chrome of its own. The app waits a minute before retrying a failed connection, and the outage checks wait that minute out on the page's own clock, stopped and moved on by the check, so they take seconds, not two minutes.
Every check but the two live ones gets AniList's answers from web/e2e/anilist/, not from its API. A run would otherwise send AniList about 30 requests, 22 of them in its first minute, against its limit of 30 a minute for each IP address. Each file there holds AniList's real answer to one request the checks send, recorded by npm run e2e:record. web/e2e/browser.ts answers each request in Chrome with a copy of its recording, 200 ms after the page asks, about as long as AniList takes to answer. Chrome can't even look up AniList's address, so a request that got past the recordings would fail before it reached AniList. No browser's Chrome, live or recorded, can look up YouTube's or Google's addresses either (GOOGLE in browser.ts), so a check that presses Watch trailer sees the page ask for the embed, and nothing reaches YouTube.
The recordings are AniList's data. The folder holds only the 26 answers the checks ask for, about 3.5 MB of JSON (AniList's answers as they came, every tag of each title), with no images and no user data. Only the checks use it. npm test builds the production bundle in memory and fails if any code or file in it came from web/e2e/, whether an import, a glob or an alias brought it in, or a stylesheet or index.html named it. A second check reads the code in web/src for a path into web/e2e/ and names the line it finds.
Some checks stage answers AniList rarely gives, such as a refused request or titles all tagged Ecchi. browser.ts rewrites the answer before the page sees it, whether it was recorded or came from AniList. A rewrite can send headers too, such as a 429's X-RateLimit-Reset a second away, so that the app tries again a second later rather than a minute.
ANILIST sets where the answers come from:
ANILIST | Answers come from | Requests to AniList's API in a run |
|---|---|---|
recorded (default) | the recordings, except in the two live checks | 2 |
live | AniList, unless a check asks for recordings | about 30, 22 in the first minute |
off | the recordings, and the two live checks skip | none |
ANILIST=live CHROME_BIN="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" npm run test:browser
Run ANILIST=live on its own, while nothing else on the machine uses AniList. A live run tests this week's titles. The recordings keep the titles from the day they were recorded. Under ANILIST=off, a check that asks for a live browser fails, so a new one must skip under off, as the live checks do.
Run the recorder in web/ with npm run e2e:record, or from the root with npm run e2e:record --workspace web. It sends AniList one request at a time, at least 6.5 seconds apart, so never more than 10 in a minute. All 22 take about two and a half minutes. By default it records only the missing and stale recordings, and deletes any that no check asks for. npm run e2e:record -- --all records every one again, and npm run e2e:record -- trending search-frieren records just those two, by file name. It prints a line for each recording saying what changed.
Record again when:
npm test says a recording is missing or stale. A recording goes stale when the app changes the request it answers, in its query or its variables. Asking for one more field of every title makes all 22 stale.SEARCHES, the decade to DECADES, the id of a title opened at its address to TITLES, or the ids My List asks for to LISTS, in web/e2e/recordings.ts, then run the recorder. list-12 there is AniList's real answer for 12 saved ids; web/e2e/list.e2e.ts builds every other list's answer from titles already recorded, with a rewrite, so most My List checks need no recording of their own. Another kind of request goes in requests() there instead, and the message says so. If the request is the start of a search the check typed with search(), the page searched mid-word on a busy machine. The message then says to run the check again, with nothing to record. If it is the start of a recorded search, the page may have searched mid-word, and the message says to run the check again first.The recorder writes nothing unless every answer is a 200 with no errors and the titles still hold what each check relies on, such as Frieren first in a search for "frieren". If AniList's titles can't hold it any more, the recorder says which check relies on it. That check must change, and so must its rule in RULES in web/e2e/recordings.ts.
Keep SEARCHES, DECADES, TITLES and LISTS up to date by hand. When a check stops sending a search, take it out of SEARCHES, with any rule for it, and the recorder deletes its recording. Nothing else notices a recording that no check uses any more.
A new check that needs something particular of a title, such as a banner or a long name, should stage it with rewriteAniList in browser.ts instead of counting on a recording to have it.
Covers and banners still load from AniList's image server in every mode, so the checks need the internet. Three checks depend on those images:
AniList replaces covers now and then, and an old file can disappear. A cover that can't load doesn't show, and its panel shows the title's color instead. If one of the first two 1990s covers in the recordings has gone, the first of these checks fails with "the first decade covers never loaded". npm run e2e:record -- --all records the answers again, with the covers' current addresses. The splash panel's cover belongs to a sample title, so npm run samples in web/ refreshes it instead.
web/e2e/live.e2e.ts has the app send AniList's API two of its own requests: a first visit's one request for trending and the 1990s, then a search for the first trending title by its name. A run sends AniList these 2 requests and a CORS preflight, and one more of each when the first-visit check asks again (see below). The checks judge AniList's answer as it came over the network, by the rules in web/e2e/live.ts:
live.ts changes.AniList always has trending and 1990s titles, but while busy it can send a list of them empty. Twice, AniList took about 13 seconds to answer a first visit and sent no trending titles, and a run 2 minutes later passed. So when either list comes back empty, and nothing else is wrong with the answer, the first-visit check asks once more, 20 seconds later, from a fresh first visit. If the second answer has the titles, the check goes on with it, and its output says that AniList's first answer was empty. If a list comes back empty again, with nothing else wrong, the check skips: "AniList answered the app's first-visit request twice, 20 seconds apart, with no titles in data.trending.media, a list it always fills: it isn't answering in full right now. It's not the app." An empty list with anything else wrong in the answer, such as a cover over http, fails at once, naming all of it: AniList being busy doesn't explain the rest. A list with some titles, but fewer than the page shows, still fails at once as AniList's data. Before changing a rule for it, run the checks again.
Tailwind v4 is configured in CSS, not JavaScript. web/src/styles/index.css imports Tailwind and holds the design tokens. There is no tailwind.config.js or postcss.config.js; the @tailwindcss/vite plugin handles everything.
Tailwind makes a utility of any word that names one in the files it reads, even in a comment, and by default it reads every file. So index.css imports it with source(none) and names what it reads: web/src and web/index.html, but not the tests, the prototypes or the gallery (the browser checks and the scripts sit outside web/src). Reading everything, it had shipped about 600 bytes of rules no page used. npm test checks this with canaries, utility names written only in files Tailwind mustn't read, such as italic in privacy.test.ts: none may have a rule in the production stylesheet. The other way round, every Tailwind class the app's markup uses must have its rule there, and every one of the app's own ab- and is- classes a rule in its stylesheets, so a misspelt class fails.
Tailwind v4 uses native CSS cascade layers, ordered theme → base → components → utilities. A later layer wins regardless of selector specificity, so no !important is needed. One rule to remember: put custom component styles inside @layer components. CSS written outside any layer beats every utility class, so a utility like p-4 would stop working on that element.
The VITE_FIREBASE_* values identify the Firebase project and are visible in every Firebase website's code. Security comes from Firebase security rules and server-side token checks, not from hiding these values.
web/e2e/navigation.e2e.ts "swapping the links moves nothing" |
| Keyboard, Tab through Discover | 40 stops (38 under 900 px) | 41, with the intro's My List (38 under 900 px, where the menu holds it); My List's page walked both ways too | web/e2e/keyboard.e2e.ts "Tab walks every stop of the page in order"; web/e2e/keyboard.e2e.ts "on My List, Tab walks every stop in order" |
| The links to My List | Greyed and dashed, not links | Links to /list from the header, the menu and the intro, with no new page, focus going to My List's heading; a click with Ctrl, Cmd, Shift or Alt held is the browser's; from /list, the section links jump to Discover's sections | web/e2e/navigation.e2e.ts "from 900px the header’s My List is a link to /list"; web/e2e/navigation.e2e.ts "a tap on the menu’s My List shuts the menu and lands on /list"; web/e2e/navigation.e2e.ts "on My List the section links lead to Discover’s sections"; web/e2e/keyboard.e2e.ts "Enter on My List’s link"; web/e2e/navigation.e2e.ts "a click on My List or AniBeam with Ctrl, Cmd, Shift or Alt held is left to the browser" |
| Bytes, the JS every visit loads | 87,701 of 88,000 B | 90,864 of 91,000 B | web/src/app/privacy.test.ts "the production bundle stays within its byte budget" |
| Bytes, the CSS | 11,488 of 11,500 B | 11,856 of 12,000 B | the same |
| Bytes, the JS loaded only when needed (the samples) | 14,350 of 14,500 B | 14,350 of 14,500 B | the same |
| Bytes, My List's page, a file of its own | No My List page | 2,329 of 2,500 B | the same |
| Under 0.1 on a phone, roll after roll; from 821 to 1170 px the rolled card's room is kept beside the machine from the start, so a first roll moves neither the lever nor what is under it; at 280 px the card fits the screen whole |
web/e2e/layout.e2e.ts "a pull on a phone jumps under 0.1"; web/e2e/layout.e2e.ts "beside the machine, from 821 to 1170px"; web/e2e/layout.e2e.ts "at 280px the rolled card fits the screen whole" |
| Layout shift, a first visit | Under 0.1 | Under 0.1, on a phone and at 1440 px, with motion or without | web/e2e/layout.e2e.ts "a first visit loads with a layout shift under 0.1" |
| Layout shift, a search with no matches | No link on its line | Under 0.1 at 390, 768 and 1440 px, its line one line tall with the link, as without it | web/e2e/layout.e2e.ts "a search with no matches jumps under 0.1" |
| A search with no matches | "No titles found." alone | Its line links to the mood sliders, followed with no new page by a click, Enter or a tap, focus on the Pull heading; its focus ring whole, on one line, at 280, 390 and 1440 px; screen readers hear the same words with no link; the decade strip's note has none | web/e2e/navigation.e2e.ts "a search with no matches links its line to the mood sliders"; web/e2e/discover.e2e.ts "the link to the mood sliders on a search with no matches shows its whole focus ring"; web/e2e/discover.e2e.ts "screen readers hear each search’s outcome from one status line"; web/e2e/refresh.e2e.ts "a decade with no titles says “No titles found.” alone" |
A visit to /mood | Became /, as any address the app doesn't know | Becomes /#pull in its place, keeping its query, the Pull heading focused, in view and left unmarked, and still there once the titles and the intro's covers land; only the first visit's request; Back leaves for the page before | web/e2e/addresses.e2e.ts "/mood, the mood sliders’ address in the spec, becomes /#pull" |
| The gate's browser checks | About 1,180 s, 370 checks | About 1,336 s, 427 checks (2 of them skipped while AniList is off) | npm run test:browser, by its own clock, in the gate |
| Bytes, the JS every visit loads | 90,864 of 91,000 B | 94,650 of 94,700 B | web/src/app/privacy.test.ts "the production bundle stays within its byte budget" |
| Bytes, the CSS | 11,856 of 12,000 B | 12,897 of 12,900 B | the same |
| Bytes, the JS loaded only when needed (the samples) | 14,350 of 14,500 B | 14,350 of 14,500 B | the same |
| Bytes, My List's page, a file of its own | 2,329 of 2,500 B | 2,330 of 2,500 B | the same |
| Bytes, the mood scoring, a file of its own | No such file | 955 of 1,500 B | the same |
| Bytes, every other file | 354 of 500 B | 370 of 500 B | the same |
web/e2e/trailer.e2e.tsweb/e2e/navigation.e2e.tsweb/src/components/MenuDrawer.tsx/listweb/src/lib/swipe.tsweb/e2e/navigation.e2e.tstopAfterweb/src/lib/sectionNav.ts/web/src/features/list/page.ts/listweb/e2e/list.e2e.tsweb/e2e/keyboard.e2e.tsweb/e2e/discover.e2e.ts