Imported from AndersonTsaiTW/zephyr-english (
AGENTS.md). Install upstream withnpx skills add AndersonTsaiTW/zephyr-english. Copyright stays with the author.
Working on Zephyr
What this is
Zephyr shows you one short English article a day. The text scrolls upward on its own at a set speed, so you cannot stop and re-read a sentence. You just keep up. When the text runs out you answer two or three questions to show you understood it, and then you see how fast you read and how many days in a row you have done this.
The whole thing takes two or three minutes. That is the point. It is meant to be a small daily habit, not a study session.
Two people use it. Both are adults learning English for CELPIP, the English test used for Canadian immigration. One speaks Spanish, one speaks Chinese.
The idea behind it: most learners read slowly because their eyes keep jumping backwards to check a word they already passed. If the text physically moves away, you cannot jump back, and you are forced to read forwards the way a native speaker does. That only works if the article is easy enough that you do not need to look back. Hence the difficulty rules below.
PLAN.md lists the work in packages. Take the next unfinished one.
Four rules you must not break
1. Never write the article text yourself
This is the most important rule in the project.
The body of an article has to be real text, written by a real person, copied from a source we are allowed to use. Those sources are listed in docs/content-sources.md.
You are allowed to shorten a source by deleting whole sentences or individual words. You are not allowed to add a sentence, reword a sentence, or rewrite something to make it simpler. If a passage is too hard, delete it and use a different one.
This is checked by a script, not left to trust. scripts/check-article.mjs compares every sentence you publish against the untouched original saved in content-raw/. Delete things and it passes. Change so much as a word order and it fails and names the sentence.
You can freely write everything else: the quiz questions, the word definitions, the buttons and labels, the code, and the documentation.
2. Write the interface in English
One reader speaks Spanish, the other Chinese, so English is the only language they share, and it is also the thing they are here to practise. Word definitions are short explanations in simple English, not translations.
3. No frameworks, no build step
Plain HTML, CSS and JavaScript. Articles are plain JSON files. The site/ folder is uploaded exactly as it sits, and nothing is compiled or generated on the way. Cloudflare copies the folder and serves it.
4. Every article says where it came from
Each article file carries a source block naming the author, the publication, the licence and the link. The app refuses to display an article that is missing it, so this is not something you can forget.
Where things live
site/ everything that gets published, and nothing else
index.html app.css app.js
about/index.html what Zephyr is, in prose, for people and for crawlers
robots.txt sitemap.xml what a search engine reads first
_headers tells Cloudflare how long browsers may cache files
content/articles/ one file per day, named 2026-08-12.json
content/articles/sample.json shown when there is no article for today
content/index.json the list of dates that have an article
content-raw/ the original untrimmed text of each article
scripts/ small Node programs, no libraries installed
fetch-source.mjs a page on the web to plain text, no model involved
list-voa.mjs what VOA has, and whether it is VOA's own writing
propose-trim.mjs proposes a cut. You still have to read it
harvest.mjs fills empty days with half-finished articles
data/sources.json where to look, and under what licence
docs/design.md colours, type sizes and what each screen looks like
docs/content-sources.md where articles come from and how to credit them
notes-zh/ the owner's own notes in Chinese, not published
content-raw/ is worth explaining. When you shorten a source into an article, the full original stays here. It is not published to the web. The checking script compares the published article against it. Without that copy, rule 1 could not be enforced.
Which is why fetch-source.mjs is written the way it is. It downloads a page and pulls the paragraphs out of the HTML by walking the tags. It cannot summarise, because it has no way to write a sentence. Anything that could paraphrase on the way into content-raw/ would leave the provenance check passing and meaning nothing.
Check your work before you say you are done
node scripts/smoke.mjs opens the real site in a real browser
node scripts/check-contrast.mjs checks every colour combination is readable
The first one matters more than it sounds. Twice now, a change passed every check we had, and the page still came up blank when a person opened it. Checking that the JavaScript parses, that the file downloads, and that the server returns "OK" all pass happily on a page showing nothing at all. Only opening it in a browser catches that.
So smoke.mjs opens the site in Chrome, clicks through a whole reading session, and checks that words actually appear on screen. If you add a screen, add checks for it.
How to write code here
Keep the app itself to three files: index.html, app.css, app.js. One JavaScript file is fine at this size. Split it only when it genuinely gets in the way. The about page is a separate document that borrows app.css for its colours, not a fourth piece of the app.
Read docs/design.md before you touch any screen. Colours come from named variables at the top of app.css. Never write an actual colour like #FFFFFF inside a rule for a button or a card, because that colour will be wrong in one of the two themes. Use the variable, and run the contrast checker afterwards.
Articles must be 220 to 320 words, score grade 9 or lower on the Flesch-Kincaid reading scale, and take at least 84 of every 100 words from the common two thousand.
One note about that word list. It comes from a research project and is shared under a licence that says: use it freely, credit us, and if you change it, share your changed version on the same terms. The credit is the comment block at the top of the file. Practically, this means leave the file alone. Nothing else in this repository is affected by that licence.
Scripts are plain Node with no packages installed.
When testing locally, run npx serve site and open the address it prints. Opening index.html by double-clicking will not work, because browsers refuse to let a page loaded from your hard drive fetch other files.
Do not add tracking, user accounts, or a server unless a work package specifically asks for one.
There are no accounts and there should not be. Reading speed, streak and history live in localStorage on the reader's own device. Two things guard that. The app asks for persistent storage on load, which stops a browser discarding the data when space runs low or, on Safari, after a week untouched. And the backup button in the top bar exports everything as a block of text the reader can paste into another phone, so moving device does not need a sign-in.
What still loses the record: clearing site data by hand, and reading in a private window. Say so plainly if anyone asks rather than implying the data is safer than it is.
Two bugs worth knowing about
Both of them looked identical from the outside. You opened the page and the article area was empty. Nothing in the code looked wrong either time, and both took hours to find.
The first was the reading panel growing taller than the window. Inside a flexible layout, an element will not shrink below the height of the text it contains unless you tell it min-height: 0. A long article pushed the panel past the bottom of the screen, and once the panel was exactly as tall as its own text, there was nothing left to scroll through, so the article reached its end on the first frame. Everything from body down to .reader now sets min-height: 0, and a new scrolling area will need it too.
The second was a countdown that would not leave. HTML's hidden attribute is supposed to hide an element, but a stylesheet saying that element is visible beats it. Our three-two-one overlay had such a style, so it sat on top of the article permanently, painted in the same colour as the panel behind it. That is why nothing looked broken. One line near the top of the stylesheet, [hidden] { display: none !important; }, gives the attribute its authority back. Hide things with the attribute and let that line do the work.
The share card
Finishing a day offers a card to share. It is drawn on a canvas in the browser, in drawShareCard(), so it works offline and needs no server. Web Share carries the image and the text together where the browser supports files; where it does not, the card can be shown on the page and saved by hand.
Two things about it are deliberate. It is always the dark palette, because a card lands in a chat beside other people's photographs and a white square reads as a blank message. And the streak wording only celebrates exact milestones, seven days, fourteen, thirty, and so on. Ranges would call day twelve "a week straight", which is both untrue and the kind of praise that stops meaning anything once you notice it.
Where articles come from now
A workflow runs every morning, reads scripts/data/sources.json, downloads pages nobody has looked at yet, measures them, and scaffolds an article for each day that does not have one. It opens a single pull request and adds to it.
What arrives is half an article. There is a title, a source block and a body cut down by deleting whole paragraphs. There is no topic line, no word explanations and no quiz, and site/content/index.json is untouched. That last part is what makes the whole thing safe: a date in the index is a promise that opening the app that morning gives you something to read and something to answer, so an article joins the index only when someone has finished it. Merging the pull request changes nothing a reader sees.
The split is deliberate rather than a limitation of the tooling. Downloading, measuring and deleting are all checkable, and check-article.mjs checks them. Whether a question is a fair test of whether someone followed the article is not checkable, so a person writes it.
scripts/data/seen-sources.json records every URL the harvester has looked at and what it decided, so it never downloads the same page twice. It grows by a few hundred lines a week and is meant to be committed.
If a run finds nothing, the sources are exhausted. Add to sources.json, and read the licence note at the top of it before you do.
Being found
Everything on the app screen is written by JavaScript once an article has loaded, so the file a crawler downloads holds a title, a button, and nothing anyone would want to read. That is why site/about/ exists. It is a plain page of prose saying what Zephyr is, why the text moves, how the speed is decided and where the articles come from, and it is the only thing on the site a search engine can read without running the app. The today card and the empty screen both link to it. If you change what Zephyr does, change that page in the same commit.
The app page also carries a WebApplication description in JSON-LD. robots.txt allows everything and names the sitemap, and sitemap.xml lists both pages. smoke.mjs checks all of this, because files of this kind get quietly broken by an unrelated change and nobody notices for a month.
Two things the repository cannot do for itself. Someone has to submit the site in Google Search Console while signed in, and something Google already crawls has to link to it. A new domain with nothing pointing at it is not waiting to be found. It is invisible.
Two more layout traps
Centred flex content that outgrows its container overflows in both directions, and the part above the top edge cannot be scrolled to at all. The results screen does this once the share card is shown, and on a shorter phone the card was simply invisible with no way to reach it. Those screens now use justify-content: safe center with overflow-y: auto, keeping the plain center on the line above for browsers that do not know the keyword.
The reader's run-out is a whole panel height, not a fraction of one. With a smaller run-out the last line stops partway down the panel and the read is declared over while text is still on screen. And the pace is measured against the height of the text alone: counting the padding as if it were words made the article cross faster than the speed on display claimed.
Writing for people
Anything a person reads, meaning documentation, text on screen, and commit messages, should sound like a person wrote it. Avoid the habits that make writing feel machine-made: long dashes, lists of exactly three things that pad rather than inform, a bolded phrase followed by a colon used as a bullet, emoji used as decoration, and closing paragraphs that cheerfully restate what you just said.
Explain a term the first time you use it, or do not use it. A reader who has to look something up to follow your sentence has been failed by the sentence.
Vary your sentence length. The humanizer skill has the full list of things to avoid.
Git and deploying
Commit messages start with the work package and say what changed in plain words, for example WP3: quiz flow. In the body, explain why the change was needed, not just what moved.
Pushing to main publishes the site to https://zephyr-english.com automatically, usually within a couple of minutes. Do not run wrangler pages deploy by hand, because then the deployment history no longer tells you which commit is actually live.
One warning from experience: right after a push, the old version is still being served, and every file still returns "OK". A successful download does not mean your change is live. Check that something you actually changed appears on the page.
Running more than one session at once
The project splits into three parts that only touch each other through committed files, so three people or sessions can work at once:
the reader itself WP2, WP3, WP4, WP6 must be done in order
choosing articles WP8 independent
delivery and reminders WP7, WP5 independent
The reader packages all edit the same parts of app.js, so they cannot be split up, and only one session may edit site/app.js at a time. Otherwise, give each line of work its own branch.