--- name: release-os8088 description: Build os8088 and publish the floppy images to the os8088.com website repo as a pull request, plus a GitHub release on the OS repo. Use when the user asks to cut a release, publish a new build, ship the latest images to the website, or update os8088.com with a new version. --- # Release os8088 Builds the floppy images, publishes them into the website repository next door along with the notes for its releases page, opens a pull request there, and cuts a GitHub release on this repo. **The GitHub release carries ONE asset: a zip.** `os8088-.zip` holds every image, a `README.md` that explains every file in it -- grouped by what the reader is trying to do, so the pair most people need comes first and each group says how to use its own files -- and a SHA256SUMS covering all of them. Eleven loose `.img` files used to hang off the release, and a reader had to know which pair they wanted before they could download anything. Step 3a builds it. The website is unchanged -- its download page still serves the images individually out of `public/disk/`, because the browser demo streams them and a table of per-image checksums is the point of that page. The release notes get written once and used twice: `data/releases.json` in the website repo (step 4a) and the GitHub release (step 6) say the same thing, and the site's /releases/ page is that file rendered. When the release adds a whole program -- or there is a video about it -- it also gets a **Spotlight page** (step 4b), which is a page of its own under `/spotlight/` with its own screenshots. A line on the releases page does not carry a new program. **Read "Writing the copy" below before writing a word of any of them.** ## Writing the copy The reader is someone who found the project and is curious. They may write assembly, or they may just like old computers. Write so both finish the sentence. Assume interest, never knowledge. **Say it in this order:** what changed, what it does now, and what that means for someone using it. Nothing else is required. Rules, in order of how often they get broken: 1. **Short sentences, one idea each.** If a sentence needs a comma-spliced aside or a dash to hold together, it is two sentences. 2. **Explain the term the first time you use it**, in the same sentence and in a few words: "Hercules, a monochrome graphics card from 1982", "the FAT12 filesystem DOS floppies use". Do this once per release, not once per highlight. 3. **No internal shorthand.** A `§` number, a source label, an `.inc` file, a register name or a symbol like `gfx_fill` means nothing outside this repo. If a spec section is the authority, name it in a short closing sentence -- never use it as the explanation. 4. **Numbers instead of adjectives.** "Redraw dropped from 158 character cells a frame to 14" beats "much faster". If there is no measurement, say what changed and leave the speed claim out. 5. **No marketing.** Cut "powerful", "seamless", "blazing", "beautiful", "exciting", "we're thrilled", "the best yet", "finally". No superlatives, no exclamation marks, no first-person plural selling the work. State the fact and stop. 6. **No fluff.** Every sentence adds something a reader did not already have. Do not restate the title in the body. Do not open with throat-clearing ("As part of our ongoing work..."). Do not pad a small change into a paragraph -- a one-sentence highlight is a fine highlight. 7. **Leave out the war story** unless it changes what someone does. The debugging that got you there is interesting to you and to nobody reading a download page. 8. **Plain words.** "Faster" not "performant". "Uses less memory" not "optimises the footprint". "You can now" not "enables the ability to". The check before you commit: read each sentence and ask whether someone who has never opened this repository understands it. If they would have to, rewrite it or cut it. An example of the difference, on a real change: > **Too dense:** The player now gates widget drawing on a word of dirty bits, > applying the period's update-region idea at the widget -- 28,365 glyph cells > down to 2,468 over the same ten seconds of playback, per the spec section > that owns it. > **Write this instead:** The player used to redraw its whole face every time > the screen updated, which was more work than the machine could finish between > frames, so playback stuttered. It now redraws only the parts that changed. > Over the same ten seconds of music that is 2,468 character cells drawn > instead of 28,365. Lengths that fit the page and stay readable: | field | length | |---|---| | `summary` | 2-4 sentences. What this release is, leading with the one thing that matters most. | | `highlights[].body` | 2-5 sentences. One change each. | | `notes` | 1-3 sentences, written as instructions to the reader. | ## Locating the two repositories Never hardcode an absolute path. Resolve both from the current checkout, and use these variables in every command below: ```bash OS_REPO="$(git rev-parse --show-toplevel)" WEB_REPO="${WEB_REPO:-$OS_REPO/../os8088-web}" ``` | repo | what it is | role | |---|---|---| | `$OS_REPO` | this checkout | builds the images, gets the git tag and GitHub release | | `$WEB_REPO` | a sibling checkout of the website | receives the images, the manifest and the release notes, gets the pull request | If `$WEB_REPO` does not exist, the website half is simply not available in this working copy. Say so plainly, **do the build and the GitHub release anyway** (steps 1-3 and 6), and skip steps 4 and 5. Do not go looking around the filesystem for it, and do not clone it uninvited -- offer, and let the user decide. If the user keeps their website checkout somewhere else, they can point at it with `WEB_REPO=/path/to/os8088-web`. ## Arguments - `$1` (optional) -- the release version, e.g. `v1.0.20260727`. If the user did not give one, derive it: take the version the OS reports in its own About box (`grep -n 'os8088' kernel/apps.inc` -- currently `1.0`) and append today's date, giving `v.`. Tell the user the version you picked. - `--shots` -- also recapture every screenshot from the new build. Do this whenever the change touches anything visible. It boots ~15 QEMU instances and takes a few minutes. - `--no-pr` -- do everything except opening the pull request and the release. ## Steps ### 1. Preflight Run these and stop if any fails, reporting exactly what is wrong: ```bash cd "$OS_REPO" git status --porcelain # uncommitted OS changes? git rev-parse --abbrev-ref HEAD command -v nasm qemu-system-i386 python3 gh auth status ls "$WEB_REPO/tools/release.py" ``` If the OS working tree is dirty, **stop and ask** whether to release anyway -- the manifest records the commit hash, and releasing uncommitted work makes that hash a lie. If the user says go ahead, note it in the PR body. If the website repo has uncommitted changes, stop and ask; the release commits to a fresh branch off `main` and would otherwise sweep unrelated work into it. ### 2. Build the images ```bash cd "$OS_REPO" make clean && make && make emu ls -l build/os8088.img build/os8088-120.img build/os8088-720.img \ build/os8088-360.img \ build/apps.img build/apps120.img build/apps720.img build/apps360.img \ build/media360.img \ build/office360.img build/network360.img build/games360.img \ build/emu.img ``` All thirteen must exist -- step 3a refuses to pack without them. (Four geometries of each pair since SPEC.md 19's 1.2MB disk, plus the 360KB-only media disk and SPEC.md 24.6's three 360KB-only category disks -- at that size the spreadsheet and the chart viewer ship on the office disk and on NO other.) The build enforces its own invariants -- a 512-byte boot sector and a kernel that fits under offset 0xA000 -- so a build failure here is a real problem, not something to work around. Report the kernel size; if it has grown, say by how much and how much headroom is left (the ceiling is 0xA000 = 40,960 bytes for image + bss). **`make emu` is not optional, though `make` does not run it.** It builds `build/emu.img`, the **emulator system disk** (SPEC.md 9.11.7): the `kern_emu` kernel -- `kern_big` plus the VMware absolute pointer, `VMMOUSE.DRV` -- with a `SYSTEM.CFG` that already switches the driver on. In QEMU, VMware, VirtualBox and the v86 browser emulator the pointer then follows the host mouse with no grab. It needs nothing a bare `make` does not, which is why `mkzip.py` treats it as required rather than on-demand. Three things about it that are decided and not to be "fixed" at release time: - **It pairs with the SHIPPED `apps.img`.** `kern_emu` holds the same API table at the same offsets, so there is no emu software disk and must not be. - **It is 1.44MB only.** The driver is 386 code, and no machine that needs a 360KB floppy can run it. The Makefile says a 720KB one is `--size 720` if it is ever wanted; that is a Makefile change, not a release step. - **Its kernel lives in `build/emuk/`**, never in `build/`, so nothing in the rest of this procedure boots it by accident. **Then the on-demand disks**, which `make` does not build and the zip carries when they exist: ```bash tools/setup-cc.sh # SmallerC; needed by cword and allapps make allapps # apps-all-N.img -- every program, a set of disks make worddisk cworddisk # word*.img, cword*.img make c64disk # c64*.img make weavedisk # weave*.img -- the Weave family's disk make loomdisk # loom*.img -- the same family's IDE disk, # with the demo SOURCES instead of the # compiled bundles make runcpm-src && make runcpmdisk # runcpm*.img make scribedisk paccmandisk # scribe*.img, paccman*.img make 1942disk redlinedisk # 1942*.img, redline*.img make apple2rom && make apple2disk # apple2*.img -- apple2rom fetches the # ROM once; `make clean` spares it make live # os8088-usb.img + os8088.iso -- the live # USB image and the live CD (SPEC.md 80). # Needs the fetch on the line above and # the C toolchain, like allapps make usb-emu # os8088-emu-usb.img -- THE WEBSITE'S DEMO # DISK (SPEC.md 80.7), the same volume on # kern_emu with VMMOUSE.DRV wanted. Not # in the zip; the web repo's release.py # publishes it, and skipping it leaves # the demo on last release's build ``` Offer these, do not assume them. If `tools/setup-cc.sh` cannot run -- no network, no host toolchain -- **release the thirteen and say which on-demand disks were skipped**; they are a convenience, and a release that waits on one is a release that does not happen. `mkzip.py` prints the ones it did not find, so that list is generated rather than remembered. Boot any that were built in step 3 like any other image (they go in B:, `make test TESTAPPS=build/word.img`). **The live media carries the story files, and that is decided.** Since #188, `make live` puts every story in `tools/getstories.py`'s MANIFEST on the live USB image and the live CD, so the zip carries them inside those two files. The user decided at v1.0.20260916 that this ships: the MANIFEST is curated to files that are free to redistribute -- three Infocom and Activision giveaways (the two Samplers, Mini-Zork, ZTUU) and authors' own freeware -- and none of the Infocom games that were sold. Do not stop to ask about it again. What still stays out is the story disk as a zip entry. `zork*.img` is not in `mkzip.py`'s `ITEMS`, and `STORIES=` can add files a user owns but may not redistribute, so **never run a release build with `STORIES=` set**. If a new entry is proposed for the MANIFEST, it has to be free to redistribute, because it ships in the next release. ### 3. Smoke-test the build before publishing Never publish an image that has not been booted. ```bash cd "$OS_REPO" rm -f build/qmp.sock build/qemu.pid make test sleep 8 python3 tools/qmp.py build/qmp.sock 'screendump build/smoke.ppm' python3 tools/qmp.py build/qmp.sock 'quit' magick build/smoke.ppm build/smoke.png # or: convert, on older ImageMagick ``` **Look at `build/smoke.png` with the Read tool.** You are checking for: the menu bar across the top with the chip glyph, then Locator, File and Builtins; a Disk A and a Disk B icon down the right-hand side; the mouse pointer. If the screen is blank or garbled, stop -- do not publish. Delete the two scratch files afterwards; `build/` is gitignored, but leave it tidy. **Then boot the emulator disk** -- `emu.img` with `apps.img`, the README's own QEMU line with no mouse on it: ```bash python3 .claude/skills/release-os8088/emusmoke.py --shot build/smoke-emu.png ``` It must print `PASS`. A screenshot alone cannot pass this disk: an emulator disk whose driver never attached boots to the same desktop, silently on the serial mouse, which is the one failure that matters here. So the script reads the answer out of the guest -- the driver attached, the backdoor won the mouse, and three absolute positions sent through QMP land where they were sent -- and kills only the QEMU it started. **Then look at `build/smoke-emu.png`** the same way as `smoke.png`: the same desktop, with the pointer in the middle of the screen, where the script left it. Delete it afterwards. **If `make live` ran, boot both live images the same way** -- the rule is per image, and a zip does not exempt the two biggest files in it: ```bash qemu-system-i386 -display none -qmp unix:build/qmp.sock,server,nowait \ -drive file=build/os8088-usb.img,format=raw -boot c \ -chardev msmouse,id=m0 -serial chardev:m0 & sleep 10 python3 tools/qmp.py build/qmp.sock 'screendump build/smoke-usb.ppm' python3 tools/qmp.py build/qmp.sock 'quit' # ...then the same five lines with `-cdrom build/os8088.iso -boot d` ``` **Look at both screenshots.** The desktop must show a Disk A icon AND a Disk C icon down the right-hand side -- C: is the live partition the kernel adopted, and its absence means the image booted the kernel and lost the volume, which is exactly the failure a screenshot catches and a checksum cannot. ### 3a. Pack the release zip The one asset the GitHub release gets. Build it only after step 3 has booted the images -- the zip is what people download, and packing an unbooted image is the same mistake with an extra layer of wrapping on it. ```bash cd "$OS_REPO" python3 .claude/skills/release-os8088/mkzip.py --version "$VERSION" ``` It writes `build/os8088-.zip` and prints the entry count, the packed and unpacked sizes, the zip's own sha256, and which on-demand disks were not built. **Quote that sha256 in the release notes** -- it is the only checksum a reader of the GitHub release gets, since there is no longer a per-file table there. The script does three things worth knowing about: - **Its file list is an allowlist, not a glob.** `build/` also holds the story disks and every test gate's scratch image, and a glob ships those the first time somebody runs an unrelated target before cutting a release. If a new disk should be in releases, add it to `ITEMS` in `mkzip.py`, in the group a reader would look for it under -- that is the only place the list lives, and the README's description of it is written there too. A disk that fits no group gets a new entry in `GROUPS`, with the instructions for using it. - **It refuses to pack a partial release.** A missing image that `make` or `make emu` builds stops it; a missing on-demand disk is reported and skipped. - **The zip is byte-for-byte reproducible.** Timestamps come from the date in the version string and the images are already deterministic, so the same version from the same commit packs to the same bytes. Do not add anything that varies per run. Then look inside it, the same way step 3 makes you look at the screenshot: ```bash cd "$(mktemp -d)" && unzip -q "$OS_REPO/build/os8088-$VERSION.zip" cd "os8088-$VERSION" && shasum -a 256 -c SHA256SUMS && cat README.md ``` **Read the README with the Read tool.** It is written for someone who has never seen this project, so "Writing the copy" governs it exactly as it governs the release notes. It is `README.md`: Markdown that reads the same in Notepad as on GitHub, generated from two tables in `mkzip.py`: - **`GROUPS`** -- the situations a reader is in, in the order the README takes them: *Start here* (the system and software pair per disk size, as a table), *Extra disks for a 360KB machine*, *In an emulator or virtual machine* (the emulator disk), *No floppy drive* (the live USB image and CD), *Every program, on a set of disks*, *One program per disk*, and *About this zip* (checksums and licence files). Each carries the instructions for using its own files -- the QEMU line, the `dd` line -- so they sit beside the files they are about. - **`ITEMS`** -- the things a reader chooses between, each in one group, with the images it comes as and a line saying what it is for. This is also the allowlist of what can go in the zip. Only what was packed is described, and a group with nothing in this zip is left out whole. **Check that every file in the unpacked directory has its own line** in the README, that the version and commit are this release's, and that no group explains a file that is not there. The QEMU command lines in it are the real ones -- if `make run`'s invocation ever changes, `GROUPS` in `mkzip.py` has to change with it, and the way to know is to run the commands out of the unpacked directory and see the desktop come up. The emulator group's line is the one `emusmoke.py` boots, and it has **no mouse line on purpose**: QEMU answers the absolute pointer by default, and the serial mouse beside it would be a second pointing device with the buttons split across the two. ### 4. Publish into the website repo ```bash cd "$WEB_REPO" git checkout main && git pull --ff-only git checkout -b "release/$VERSION" python3 tools/release.py --version "$VERSION" --os-repo "$OS_REPO" [--shots] ``` Pass `--os-repo` explicitly rather than relying on its default, which assumes the OS checkout is the sibling directory `../jop`. `release.py` copies the four images into `public/disk/`, regenerates the gzipped copies the browser demo streams, writes `public/releases.json`, adds this release to `data/releases.json`, and rebuilds the site. The download page's table of sizes and checksums is generated from that manifest at build time, so it cannot drift. #### 4a. Write the release notes into `data/releases.json` **This is yours to write -- the script cannot.** `release.py` fills in only what it reads off the build: version, date, commit, kernel size, file list. It leaves `summary`, `highlights` and `notes` empty and prints a reminder saying so. The releases page (`$WEB_REPO/site/releases.html`, published at os8088.com/releases/) renders them, so an unfilled entry ships as a version number with no story attached. Write the same words you are about to put in the GitHub release notes in step 6 -- write them once, here, and reuse them there. **Follow "Writing the copy" above for all three fields:** - `summary` -- what this release is, in 2-4 plain sentences, most important thing first. - `highlights` -- one entry per change worth reading about: `title`, the optional PR number as `issue`, and a `body` that explains it rather than restating the title. Titles are plain too: name the change, do not sell it. HTML is allowed in `body`; keep it ASCII, and use `--` the way the rest of the site does. - `notes` -- anything that changes how the system is *used* and would otherwise surprise someone (a menu item that moved, a default that flipped). Write it as an instruction to the reader. Rendered as a call-out. **Check whether the images actually changed** -- `git diff --stat HEAD -- public/disk/` in the website repo, after `release.py` has run. The build is deterministic, so a release whose work was all in a package or on the story disk produces four images byte for byte identical to the previous release. That is a fine release to cut, and it is a lie by omission not to say so: the headline feature is not in the download, and someone will boot the image looking for it. Put it in the summary and again in `notes`. The optional `ramBytes` / `ramCap` / `sourceLines` / `modules` fields render the size figures on the page. `ramCap` is `KERN_CODE_MAX` -- **65536**, and read it out of `kernel/kernel.asm` rather than trusting this line, because it has moved once already. The other three are not printed by the build, so measure them -- from `$OS_REPO`: ```bash # ramBytes: image + .bss, the number the build-time assertion guards. # The kernel refuses to assemble over the cap, so bypass the %error to read it. sed 's/%error "kernel too big.*/%warning bypassed/' kernel/kernel.asm > /tmp/ksz.asm printf '%%assign KT KTEXT_SIZE\n%%assign KB KBSS_SIZE\n%%warning KTEXT=KT KBSS=KB\n' >> /tmp/ksz.asm nasm -f bin -I kernel/ -o /dev/null /tmp/ksz.asm # warning prints both; ramBytes = KT + KB # sourceLines and modules: the boot sector, kernel.asm and everything it # actually includes, the SDK header, and every package's .asm AND the .inc # files that .asm includes. The dead kernel .inc files are not included by # anything and do not count; neither does apps/frotz/zharness.inc, which is # development-only and never in a shipped build. # # ANCHOR THE GREP. `grep '%include'` also matches the word in a comment, and # both figures were wrong for it: a stray match became a garbage filename wc # silently skipped, and `grep -c` counted it as a 35th kernel module when # there are 34. python3 - <<'EOF' import glob, os, re def n(p): return open(p, 'rb').read().count(b'\n') def incs(path, dirs): out = [] for f in re.findall(r'^\s*%include\s+"([^"]+)"', open(path).read(), re.M): for d in dirs: # an .asm names its include EITHER q = os.path.normpath(os.path.join(d, f)) # beside itself or if os.path.exists(q): # apps/-relative, because that is the out.append(q) # -I nasm is given. Resolve BOTH: the break # apps/-relative form is how cword and return out # runcpm name theirs, and only trying # the sibling form silently drops them kern = incs('kernel/kernel.asm', ['kernel']) apps = sorted(glob.glob('apps/*/*.asm')) pkg = [] for a in apps: for q in incs(a, [os.path.dirname(a), 'apps']): if q not in pkg and 'zharness' not in q and 'crt0' not in q: pkg.append(q) # crt0.asm is the C runtime, counted # once with the SDK rather than per # C package that includes it files = ['boot/boot.asm', 'kernel/kernel.asm'] + kern + ['apps/os88api.inc'] + apps + pkg files = list(dict.fromkeys(files)) print('sourceLines', sum(n(p) for p in files if os.path.exists(p))) print('modules ', len(kern)) # The C is NOT in sourceLines -- the page labels that figure "lines of # assembly". Count it separately and quote it in the entry when it moves. csrc = [p for p in sorted(glob.glob('apps/*/*.c') + glob.glob('apps/*/*.h')) if 'hosttest' not in p] print('C lines ', sum(n(p) for p in csrc)) EOF ``` **This recipe changed AGAIN at v1.0.20260818 and the series steps there too.** Package includes are named `apps/`-relative as often as they are named beside the `.asm` -- `%include "cword/cwmove.inc"` -- and resolving only the sibling form dropped them, cword's and runcpm's bulk among them; the same pass also counted `apps/os88api.inc` and `apps/cc/os88thunk.asm` twice, once as a glob hit and once as an include, hence the `dict.fromkeys`. Net 9,324 lines out on that tree: the old recipe reported 222,280 lines, this one 231,604, and the previous release recounts to 228,855 -- **which is the number that entry's note quotes, so the +2,749 that is real growth is not read as +9,765.** Entries before v1.0.20260818 still hold their own recipe's numbers. **And it changed at v1.0.20260810 before that.** It used to glob `apps/*/*.asm` only, which missed the `.inc` files four packages keep their bulk in -- ArtfulType, ModPlug, Tracker and Frotz, 37,309 lines between them at that release. The old recipe reported 115,528 lines and 35 modules for that tree; this one reports 152,837 and 34, and `data/releases.json` was restated to the new figures rather than left carrying a known undercount. Entries before it still hold old-recipe numbers and are not being recounted -- so **the step is between v1.0.20260809 and v1.0.20260810, and the entry says so.** When a figure jumps because the counting changed, say so where the figure is, or the jump reads as growth that did not happen. If you update these, the same figures are hardcoded in the website's prose -- `site/index.html`, `site/faq.html`, `site/how-it-works.html`, `site/download.html` and `site/how-it-works/graphics.html` all quote the kernel size, the RAM footprint, the headroom or the line count. Grep the old numbers across `$WEB_REPO/site/` and fix them in the same PR; nothing validates them. Then rebuild and verify: ```bash python3 tools/build.py # must report 0 problems python3 tools/linkcheck.py # must report 0 dead git status --porcelain ``` **Look at the releases page before you commit it.** Serve `public/` and read it, the same way step 3 makes you look at the smoke screenshot -- a release whose entry renders as an empty window is worse than no page at all: ```bash (cd "$WEB_REPO/public" && python3 -m http.server 8099 &) && sleep 2 # then open http://localhost:8099/releases/ and check this release's window # has its summary, its highlights, its figures and its four files ``` #### 4b. The Spotlight page, when the release earns one `/spotlight/` is the hub for one-page write-ups: a new program, a feature too big for a highlight, or anything with a video about it. `site/spotlight.html` is the index and `site/spotlight/.html` is the page. The nav already carries Spotlight (File menu in `tools/build.py`, and the footer dock in `site/_layout.html`), so a new page needs no wiring beyond its own entry on the index. **Decide first, and it is usually no.** A bug fix, a speed-up or a new menu item is a highlight on the releases page and nothing more. Write a Spotlight page when a reader would want a page: a program that did not exist before, or a video that needs somewhere to live. If in doubt, ask the user rather than producing a page nobody asked for. **1. Capture the screenshots.** They come out of the emulator, never a mockup. Scenes live in the website repo beside the others: ```bash cd "$WEB_REPO" # scenes.frotz.json is the worked example: five scenes, one per story. python3 tools/capture.py --scenes tools/scenes..json \ --out public/img/ --repo "$OS_REPO" --jobs 3 ``` A scene may set `"diskB": ".img"` to put a different floppy in drive B: than the software disk -- that is how Frotz's scenes reach the story disk `make zdisk` builds. Whatever the page shows must be built first; `make` alone does not build an on-demand disk. **Look at every captured PNG with the Read tool.** A lost click gives a plausible-looking screenshot of the wrong thing, and file size will not tell you: two of Frotz's five first came out sitting on an unanswered "Do you need instructions?" prompt with an otherwise empty window. **2. Write the page.** Copy `site/spotlight/frotz.html` and change it. Set `body_class: spot` in the metadata block -- that is what turns on the article layout, and without it the page is the ordinary stack of one-screendump-wide windows, which is what these pages exist to stop being. The layout has **two widths and nothing between**: 800px for anything with sentences in it, the full two columns for anything to look at. Alternating them is the structure. The pieces, all in the stylesheet's `spotlight` section: | | | |---|---| | masthead | a `.grid` of two: the pitch, `.specs` with three numbers, the buttons and a `.spot-toc` of jump links -- beside the one screendump that proves it | | `.spot-band` | inverted full-width section heading, each with one line saying why you would read that section. These are the anchors somebody skims by | | gallery | the screendumps in a plain `.grid`, two abreast | | `.spot-cards` | a `.grid` of short titled cards, for what would otherwise be one window with four `h3`s in it | | `.spot-steps` | numbered instructions, for the part somebody follows while typing | Order: masthead, what it *is* for someone who has never heard of it, the video, the gallery, how it works, what it deliberately does not do, how to run it. The copy rules above apply, plus one more: **a Spotlight page is written for someone who does not know the subject at all**, so explain the domain and not only the change. The Frotz page spends three paragraphs on what a Z-machine is before it says a word about the implementation. That is the right proportion. Still no marketing, still numbers instead of adjectives, and still no `§` numbers or symbol names. Two things a reader gets in the first five seconds, so write them last and hardest: the **deck** (`.spot-deck`, one or two sentences that are the whole page) and the **three numbers** in the `.specs` block. If you cannot fill those three cells with facts, the page is probably a highlight and not a spotlight. The copy rules above apply, plus one more: **a Spotlight page is written for someone who does not know the subject at all**, so explain the domain and not only the change. The Frotz page spends three paragraphs on what a Z-machine is before it says a word about the implementation. That is the right proportion. Still no marketing, still numbers instead of adjectives, and still no `§` numbers or symbol names. **3. Embed the video, if there is one.** Reuse the markup on the Frotz page -- an `` around an ``, plus `