# xanders.page

Hosted from `G:\Websites\xanderspage`. This folder is the web root, so anything
in here is reachable by the browser and anything outside it is not.

## Run it

```bash
python G:/Websites/xanderspage/tools/serve.py
```

Add `--lan` to reach it from the MacBooks on the same network. It prints the
addresses to type in. Off by default, because binding to 0.0.0.0 lets
anything on the wifi read this folder.

## The other files at the root

| | |
|---|---|
| `THE-MACHINE.md` | the fiction, and the backlog. What this is pretending to be and what is left to build |
| `TESTING.md` | the half hour pass to run on the two MacBooks, in order, with what correct looks like |
| `ROADMAP.md` | the servers and services, which are a separate project from this one |
| `ASSETS.md` | where every image came from, and the credits |

## The build, in order

There are sixteen scripts in `tools/` and the ordering between them used to
live only inside the scripts themselves, scattered across five README
sections. This is the whole of it in one place.

**Almost always, you want one of these two:**

```bash
python tools/pages.py
```

Rebuilds every page from the fragments in `tools/content/`, plus `index.html`,
`404.html`, `robots.txt`, `sitemap.xml` and the site map fragment. This is the
one you run after editing a fragment or changing `NAV`.

```bash
python tools/blog.py
```

Adds a post. It writes the fragment, then runs `pages.py` for you, then writes
`feed.xml`. **That order matters and is why it is not three commands:**
`feed.xml` is the live file other people's readers poll, so it must never be
the first thing to know about a post that is not on the page yet.

**After changing images:**

```bash
python tools/manifest.py     # any image added or removed, rebuilds js/manifest.js
python tools/thumbs.py       # wallpapers specifically, rebuilds the gallery thumbs
```

`manifest.py` is the one that actually matters: a browser cannot list a folder
over HTTP, so a picture the manifest does not know about does not exist.

**Before you walk away:**

```bash
python tools/check.py        # everything that has broken here before
python tools/check.py --links   # ...and a real request to every external host
python tools/backup.py       # one zip of everything that would hurt to lose
```

`check.py` folds in `checkhtml.py` and adds the checks that were missing.
Every lane is there because that exact thing has broken here at least once:

| | |
|---|---|
| tag balance | what `checkhtml.py` already did |
| dashes | the firmest rule on the project, and post bodies flow into `feed.xml`, where a stray one is cached in other people's readers |
| internal links | `index.html` once linked a page after it was renamed |
| css assets | a rule asked for `img/cursors/pointer.png`, which has never existed. A missing cursor falls back to the keyword after the comma and looks completely normal |
| manifest images | the 350 wallpapers and their thumbnails, whose paths exist in no file because they are built at runtime from `js/manifest.js` |
| stale fragments | a fragment edited and the page not rebuilt |
| orphan assets | js or css that nothing loads |
| Safari 7 | the hard floor of the project, and until recently the only rule with no mechanical check while the one about dashes had a thorough one |
| external links | only with `--links` |

**Notes are not faults.** Some things cannot be decided from the outside:
`js/authelia.js` calls `fetch` correctly, inside a `window.fetch` guard, and
no regular expression can tell that from an unguarded call. Those print under
their own heading and do not touch the exit code, because a command that
always exits 1 is a command whose output stops being read.

It changes nothing, ever, and exits 1 only if it found a fault.

**Occasional, and each one is explained in its own docstring:**

`shrinkwall.py` resizes the wallpapers, `layout.py` regenerates the Layout
block below, `cursors.py`, `icons.py`, `logo.py`, `logonbg.py` and
`mybuttons.py` redraw assets, `rasterize_server.py` helps `logo.py`.

**Every tool works out the repo root from its own location**, so they can be
run from anywhere and survive the folder being moved or copied.

Then open <http://localhost:8080>.

Always use the server rather than double clicking `index.html`. Opening a file
directly gives you a `file://` URL, and the wallpaper cycler will not work
there because the browser blocks scripts from reading local paths.

**Why not `python -m http.server`.** Because it lets the browser cache your
files. You edit a `.js` or `.css`, hit reload, get the old one back, and it
looks exactly like your change did not work. That cost real time three
separate times while this site was being built. `tools/serve.py` sends
`Cache-Control: no-store` so a reload is always a real reload. It also threads,
which matters because the buttons page pulls 141 images and a single threaded
server serves them one at a time.

Only use it locally. A real web server SHOULD cache; that is what makes a site
fast for visitors.

## Layout

```
index.html                 GENERATED by tools/pages.py
404.html                   GENERATED, a blue screen, served for missing pages
feed.xml                   GENERATED by tools/blog.py
robots.txt                 GENERATED by tools/pages.py
sitemap.xml                GENERATED by tools/pages.py
pages/                     GENERATED, one file per entry in tools/pages.py
tools/content/             THE EDITABLE BODY of every page

js/ads.js                  rotating vintage banner ads
js/apps.js                 the commands that are programs
js/authelia.js             talking to Authelia
js/boot.js                 the network boot sequence
js/bsod.js                 a crash should look like a crash
js/buttonpage.js           builds pages/collection.html
js/buttons.js              the scrolling 88x31 strip
js/chkdsk.js               the machine noticing it was not shut down
js/crt.js                  puts the site behind an old monitor
js/desktop.js              Pages 98
js/dos.js                  the fourth partition, the one nobody moved off
js/featured.js             the featured wallpaper on the front page
js/firmware.js             AMIBIOS, and the screens behind it
js/gallerypage.js          the wallpaper browser
js/guestbook.js            signing it, on both machines
js/guestbookpage.js        wiring the form on Pages Web
js/hack.js                 the password game
js/main.js                 the shared behaviour on every Aero page
js/manifest.js             GENERATED by tools/manifest.py, the image lists
js/minesweeper.js          the one everybody wants
js/motion.js               one answer to "should this move?"
js/osmenu.js               the operating system menu
js/paint.js                MS Paint, the Windows 98 one
js/player.js               the Windows Media Player 11 recreation
js/saver.js                the Pages 98 screen saver
js/sfx.js                  the noises an old machine made
js/shell.js                what is behind the login
js/store.js                localStorage that cannot take a page down
js/sysinfo.js              what the boot screen knows about you
js/tty.js                  the terminal login
js/wallpaper.js            the rotating desktop background
js/win98.js                a draggable Windows 98 window

css/boot.css               the network boot sequence
css/bsod.css               the blue screen
css/chrome.css             font, cursors, scrollbars
css/crt.css                the whole site on an old monitor
css/cursors.css            GENERATED by tools/cursors.py, real Vista cursors
css/desktop.css            Pages 98
css/osmenu.css             the NTLDR operating system menu
css/paint98.css            MS Paint, the Windows 98 one
css/player.css             Windows Media Player 11, light
css/saver98.css            the Pages 98 screen saver
css/style.css              the Frutiger Aero layout, every page but the takeovers
css/tty.css                the terminal login, RobCo flavoured
css/win98.css              a draggable Windows 98 window

tools/backup.py            Write one file containing everything that would hurt to lose
tools/blog.py              Write the blog page and the RSS feed from one list of posts
tools/check.py             Everything that has already gone wrong here once, checked in one command
tools/checkhtml.py         Check every generated page for unbalanced tags
tools/cursors.py           Convert the Windows 7 / Vista cursor pack into web usable PNGs
tools/icons.py             Pull the chosen Vista icons out of the .ico packs and write PNGs
tools/layout.py            Rewrite the Layout block in README.md from what is actually on disk
tools/logo.py              Shrink the logo SVG so it is usable on a web page
tools/logonbg.py           Draw the Windows 7 logon background
tools/manifest.py          Regenerate js/manifest.js from whatever is in img/
tools/mybuttons.py         Draw xanders.page's own 88x31 buttons
tools/pages.py             Generate the pages in pages/ from one shared shell
tools/rasterize_server.py  One shot helper: receives rendered PNGs from the browser and saves them
tools/serve.py             Local dev server that does not cache
tools/shrinkwall.py        Resize the wallpapers to something a 2010 laptop can actually eat
tools/thumbs.py            Build thumbnails for the wallpaper gallery
```

## After adding or removing any image

A browser cannot list a folder over HTTP, so the file names have to be baked
into a JavaScript file. Re-run this and the site picks up the change:

```bash
python G:/Websites/xanderspage/tools/manifest.py
```

If you add a wallpaper and it does not appear, this is why.

## The wallpaper cycler

Controls are in the left sidebar: back, hold, next, a category menu, and off.
Your category and on/off choice are remembered between visits.

It crossfades between two layers so there is no flash while a new photo
decodes. Default interval is 45 seconds, set by `WALL_SECONDS` at the top of
`js/wallpaper.js`.

**Why it is deliberately slow.** The originals in `img/wallpaper` are
1920x1200 and average 1124KB, not the 560KB this line used to claim. The
site serves `img/wallpaper-small` instead, which is the same 350 pictures
at 1600x1000 and a mean of 220KB, built by `tools/shrinkwall.py`. Still
worth being slow about: 220KB every 45 seconds on a 2010 laptop is a
download, not a decoration.
The 2010 MacBook has to decode each one. Only one image is ever loading at a
time and it is fully preloaded before being shown. If you speed the interval
up a lot, that machine will feel it.

**If text gets hard to read** over a busy photo, raise the alpha on
`.wallpaper-scrim` in section 13 of the CSS. It is the wash that sits between
the photo and the panels.

## The buttons

90 branded 88x31 buttons in `img/buttons/linked/`, and every one links
somewhere real. 41 came from <https://exo.pet>, and 49 from your own hellnet
archive. The list lives at the top of
`js/buttons.js` as a plain table of file, url, label.

Where a brand still exists the link goes to the live site, using the
Australian one where there is a choice: eBay, Amazon and ASUS all point at
`.com.au` / `/au/`. Where the brand is dead the link goes to its Wikipedia
article rather than a squatted domain, so clicking still tells you something
instead of landing on an ad farm. Flash is the one exception and points at
Ruffle, which emulates it.

Order is shuffled per page load. The set is written out twice for the seamless
loop, but both copies use the same URLs so each file downloads only once.

**To add one:** drop the gif in `img/buttons/linked/` and add a row to
`BUTTON_LINKS`. Files not in that table are ignored, so the strip can never
contain a broken link.

### The full visual sweep

All 6,782 files were eventually checked **by eye**, across 95 contact sheets,
because filenames like `SiliconValley_Bay_4156_csilogo2.gif` tell you nothing.
That is the only way to find a button called `button3.gif` with a real logo on
it, and it turned up 35 brands the filename search had missed.

**The four you asked about:**

| Brand | Result |
|---|---|
| Sony | not present. Zero Sony wordmarks or logos in 6,782 buttons |
| PlayStation | one weak hit: a photo of a PS2 captioned "PS2 FAN", with no PlayStation logo and no Sony mark. It is in the set, labelled honestly |
| HP | not present. Every single "HP" in the archive is Harry Potter, plus one IBM HomePage Builder button |
| NEC | no NEC wordmark. The closest is a "PC Engine" button, NEC's console, which is in the set |

The Japanese hardware seam does exist in the archive (Yamaha five times, Korg,
Oki, PC Engine), which is exactly where a Sony or NEC button would have lived.
It simply is not there. That reads as a real absence rather than a missed
search.

**Caveat worth stating.** The sweep was run by a sub agent which viewed 15 of
the 95 sheets itself and delegated the other 80 to parallel workers. The four
priority findings were individually zoomed and confirmed, and the spot checks
that were re-run held up, but the long tail of Tier 5 and Tier 6 entries in
that report has not been independently verified.

### Deliberately not used

Several of the strongest logo hits in the archive are **anti-brand** buttons:
"BOYCOTT NESTLE", "Best Buy Sucks", "StopEsso", "AOL Sucks", "telia Suger!".
They carry the real logo attached to the opposite message, so linking them to
the company would be odd. They were skipped.

Also skipped: about 19 real European and Asian hospital logos, and the large
anime and Harry Potter fan button clusters that fill roughly the back third of
the archive. Recognisable franchises, but fan made rather than official art.

### If you want more

The archive is ordered by GeoCities neighbourhood, so brands arrive in clumps
rather than evenly. `SiliconValley_Bay_4156_*` alone holds ten different real
brands. `SiliconValley_Foothills_6161_*` is a complete hi-fi manufacturer set
(California Audio Labs, REL, Theta Digital, Bryston, Meridian, TARA Labs).
`SunsetStrip_*` is where nearly every band logo lives. Mining a prefix is more
productive than searching the whole archive again.

### URL checking

Every destination was tested with a real request, which caught three problems
worth knowing about:

- **Angelfire and Tripod are dead.** Lycos shut both down in April 2026, after
  nearly thirty years. They now point at Wikipedia.
- `nintendo.com/en-au/` returns 404. The Australian site is `nintendo.com.au`.
- eBay, Dell, McAfee, Sega and Outlook return 403, 429 or 417 to a script but
  are perfectly fine in a browser. Those are bot blocks, not dead links, and
  were left alone.

Worth re-running that check occasionally, since the whole point of this list
is that clicking a button does something.

### The old hellnet archive

Still on disk at `img/buttons/`, all 6,781 of them, no longer used by the
page.

Listing them in the manifest cost 235KB of JavaScript on every page load for
something nothing read, so `tools/manifest.py` now skips them. That is why the
manifest went from 248KB to 10KB. Flip `LIST_ALL_BUTTONS` to `True` at the top
of that script if you ever want random buttons back.

## Icons: done

24 icons extracted from your Vista packs by `tools/icons.py` and written to
`img/icons/` at 16x16 and 32x32.

**The page uses the 32px files displayed at 16px.** The `.ico` containers do
hold a hand drawn 16x16, and using it directly seemed right, but it looked
blocky on a high density screen: the browser had to double every pixel to fill
a 16 CSS pixel box. Feeding it 32 real pixels for that box is an exact 2:1 and
looks correct on both old and retina displays. The `image-rendering:
-webkit-optimize-contrast` hint was dropped at the same time, since disabling
smoothing suits a pixel art GIF but not a photographic Vista icon.

A `.ico` file is a container holding the same icon drawn at several sizes, and
the small ones were redrawn by hand by a designer rather than machine shrunk.
So the script prefers a real embedded 16x16 and only resizes when the file
does not contain one. 23 of the 24 got a native version.

48 files, 24 icons at two sizes each, about 192KB in total.

All 20 sidebar links have an icon, and between them they use 19 of the set,
because `console` does Site Map and Pages Server Core both. Two more are used
in page bodies rather than in the sidebar: `people` on Who Am I and My
Buttons, and `warning` on the Music Player and Webring. That is 21 of the 24
spoken for.

**The three genuinely spare ones are `private`, `security` and `wallpaper`**,
ready for pages you have not built yet. Two of them are spare by accident
rather than by plan, which is worth knowing before you go looking for what
they were for: `wallpaper` was drawn for the Change Theme button, which the
rotating wallpaper replaced, and `private` was drawn for the Private Wing
link, which is Pages Server Core now and carries `console` instead.

(This used to claim four spares, and it was two lists spliced together. It
named `people` and `warning`, both of which had been put to work since, and
then a second list of six that is now used down to the last one: `calendar` is
Updates, `computer` is Pages 98, `console` is Site Map, `book` is The
Collection. The list above was counted from `ICONS` in `tools/icons.py`
against a grep of the generated pages rather than remembered, and it is worth
re-counting the same way rather than editing a name out of it.)

To use one:

```html
<img class="ico-img" src="img/icons/security@32.png" width="16" height="16" alt="">
```

To change a mapping, edit the `ICONS` dictionary at the top of
`tools/icons.py` and re-run it. Your source zips are untouched in
`G:\Aero Archive\Icons`; the extracted copies are in `_extracted` beside them
and can be deleted once you are happy.

**Why not the .ico files directly:** browsers will not reliably render `.ico`
as an inline image, and Safari 7 definitely will not.

**Why not windows-7-icons or windows-vista-icons:** those packs are numbered
`001.png` upward with no names, so there is no way to pick the right one
without looking at all 3600 by eye. The `windows_vista` pack has semantic
names like `vista_photo_gallery.ico`, which is why the whole set comes from
there and looks coherent.

## Gifs and banners

`img/gifs/exo.pet/` holds 13 decorative graphics: dividers, animated NEW
badges, work-in-progress headers and a "web weaving" under construction
banner, which is now in the right sidebar. See CREDITS.txt in that folder for
what each one is and what was deliberately left behind.

### For more under construction gifs and real banner ads

[GifCities](https://gifcities.org/) is the right tool. It is the Internet
Archive's GeoCities GIF search, 1.6 million unique images, purpose built for
this, and no small site pays bandwidth for your downloads. Useful searches:

  under construction / construction / workman / digger
  new / hot / updated
  banner / advert
  divider / line / bar
  email / mail / envelope

**468x60** is the classic banner ad size and the slot is already in the middle
column of the page, currently showing a placeholder. Drop a real GIF in and
delete the `.adslot__empty` div. The middle column is 588px so it fits with
room to spare.

Also worth knowing: **88x31** buttons, **468x60** banners, and **60x20**
microbuttons were the three standard sizes. You have thousands of the first,
none of the other two yet.

## The banner ads

Your 8 GIFs are in `img/gifs/ads/`. Seven are exactly 468x60, which was THE
standard web ad size for about a decade. `js/ads.js` picks two at random on
every page load, one for the top of the middle column and one for the bottom,
and guarantees they are different from each other.

468x60 does not fit the 200px sidebar, which is why the slots are in the
middle column at 588px.

`UNDER_CONSTRUCTION_1.gif` is 110x70 rather than 468x60, so it is excluded
from the ad rotation by the `AD_EXCLUDE` list at the top of `ads.js` and used
in the sidebar instead. Drop more banners into the folder, re-run
`tools/manifest.py`, and they join the rotation automatically.

## The logo

`D:\Xanders.page.svg` is your master. Keep it, but it cannot go on the page
as is: it is 6.8MB, and that is not a complexity you can optimise away. Six
bitmaps at 3782x3781 are embedded in it and composited through filters, plus
400KB of raw vector path data. Shrinking every bitmap to 128px still left the
file at 521KB, because the path data is a hard floor.

So it is rasterised once, at build time, to `img/logo-16/32/64/128/256.png`.
The header uses the 64px file displayed at 46px, so it stays sharp on a retina
screen, and costs 4.4KB. The 16 and 32 are the favicon.

### Regenerating it

There is no working SVG rasteriser installed (cairosvg needs a native Cairo
DLL that is not present), so the browser does the rendering:

1. `python tools/rasterize_server.py`
2. Recreate a render page that draws the SVG to a canvas and POSTs each size
   to `http://127.0.0.1:8099/save`. The old one is in git history.
3. Stop the server with Ctrl+C.

Only needed if the logo changes. `tools/logo.py` is kept as a record of the
SVG shrinking experiment and its result.

## Removed

**Change Theme** is gone, button and code. The rotating wallpaper replaced it.
The theme CSS is still in section 11 of the stylesheet if you want it back.

**Featured art** is gone; Featured project is now full width.

**The CSS drawn fake ads** are gone, along with the placeholder adoptable pet.
They were stand ins for exactly the real vintage ads you have now.

## The buttons page

**These are two pages, and this section used to describe them as one.** The
five part list that was here mixed the first part of one page with all of the
other. Worth saying because the same confusion is in `js/buttonpage.js`, whose
banner claimed for a long time that it builds `pages/buttons.html`. It builds
the other one.

`pages/buttons.html` is **mine to give away**, in three parts:

1. **Take one.** Four variants of my own 88x31, drawn by `tools/mybuttons.py`
   from the logo plus a gradient and a glass highlight, which is the `.glass`
   recipe from the stylesheet baked into a bitmap. There is a copy and paste
   snippet under them. Re-run the script to change the colours; they are a
   list at the top.
2. **Link exchange.** How to swap buttons, and where to send one.
3. **How these were made.** The script, and why they are PNG rather than GIF.

It is a static fragment. It loads no scripts at all.

`pages/collection.html` is **everybody else's**, built at runtime by
`js/buttonpage.js` from `BUTTON_LINKS` and `MANIFEST`, in four parts:

1. **Buttons that go somewhere.** All 90 rows of `BUTTON_LINKS`, A to Z by
   label.
2. **Banners.** All 9 from `img/gifs/ads`, A to Z. These stack rather than
   sitting in the grid, because 468x60 is too wide for it.
3. **Graphics and gifs.** The 14 from exo.pet.
4. **The archive.** A note about the collected buttons in `img/buttons`,
   deliberately not rendered. Showing them all would be 34MB in one page
   load.

   **The number is not written in the page.** `tools/pages.py` counts the
   directory at build time and substitutes it for the `<!--BUTTONS-->`
   comment in the fragment, so there is nowhere left to type it wrong.

   It was wrong four times before that, and the fourth was after it was
   supposedly fixed:

   | | |
   |---|---|
   | 6,764 | close, and typed by hand in five files |
   | 5,565 | "corrected" by counting `.gif` and `.png` and silently dropping 1,214 jpg, 73 bmp and 6 jpeg |
   | 6,855 | "corrected" again, still by hand, still wrong |
   | 6,875 | counted from the disk at last, and counted the wrong set: it walked `img/buttons` recursively and swept in `linked/` and `mine/` |

   So the lesson was never "count it". It was **say which set you are
   counting**. There are three in that folder: the archive, which is
   **6,781** loose files; `linked/`, which is the 90 the site actually
   shows; and `mine/`, which is the owner's own 4. 16 of the archive have
   no file extension at all, which is not a fault in the scrape: GeoCities
   served them and browsers sniffed them, and one loose `.txt` is not a
   button.

The page reads the same `BUTTON_LINKS` table `js/buttons.js` uses, so one
list feeds every view of the buttons and they cannot drift apart. Add a row
and they all update.

That table used to feed a scrolling strip across the top of the home page as
well. The strip is gone: the first thing the front page said was "here are
other people's sites", in motion, above anything of mine. None of the code
was deleted. `.marquee` and `.marquee__track` are still in `css/style.css`
marked as kept on purpose, `js/buttons.js` still fills a `#marqueeTrack` if
it finds one, and `tools/content/index.html` carries the markup in a comment
ready to drop back in somewhere else.

Sorting lowercases both sides before comparing, because a plain sort puts all
the capitals first and you get A, B, C, a, b, c.

## The pages

Twenty, and **all twenty are generated** by `tools/pages.py`, including
`index.html`.

The content of each page is a plain HTML fragment in `tools/content/`. To
change what a page says, edit the fragment and re-run the script. You never
have to touch Python.

```
tools/content/about.html   ->   pages/about.html
tools/content/index.html   ->   index.html
```

**Why index.html is generated too.** It used to be hand maintained, and it
immediately went stale: after `links.html` was renamed, the home page kept
linking to a file that no longer existed. Generating it from the same `NAV`
list kills that whole class of bug. It has its own shell, `SHELL_HOME`,
because it is three columns rather than two and its paths have no `../`, but
the sidebar comes from the same place as everywhere else.

Verified: all twenty pages carry an identical set of nav groups.

You can still edit a generated page directly. It just gets overwritten next
run, so lasting changes belong in the fragment.

```bash
python G:/Websites/xanderspage/tools/pages.py
```

### What each page does

| Page | State |
|---|---|
| about | real content, written to be replaced with yours |
| projects | two example cards, copy one to add more |
| gallery | says plainly that there is no art here yet, and points at the two rooms that are full |
| **wallpapers** | fully working, click to set the background |
| blog | one example post, newest first |
| **music** | fully working player, see below |
| games | explains why Flash games need Ruffle now |
| lostmedia | empty, with useful starting links |
| **guestbook** | a form that really sends, by filling in your mail program |
| webring | what a ring is, how to find one, example widget |
| buttons | only your own four, for other people to take |
| **collection** | the 90 linked buttons, banners and gifs |
| resources | where everything came from, doubles as credits |
| contact | email, obfuscated, with the reasoning |

### The Community split

These three were one page and it was muddled, because "my buttons" and
"webring" were doing the same job:

- **My Buttons** is now only your own four, plus the link exchange offer.
- **Webring** is only about rings: what they are, how to find one, and an
  example widget showing the shape of what a ring gives you.
- **The Collection** is everything you collected: 90 clickable buttons, 9
  banners, 13 graphics, and a note about the archive.
- **Resources** is the links to other people's archives, and doubles as the
  credits page.

### The music player

`pages/music.html` is a recreation of the Windows 7 Media Player "Now
Playing" view, and **it is real**. It drives an actual `<audio>` element:
seek, volume, mute, shuffle, repeat, next, previous, and a clickable
playlist all work.

Put files in `music/`, run `tools/manifest.py`, reload. Name them
`Artist - Title.mp3` and the two fields fill themselves in. With an empty
folder it says so rather than pretending.

Two honest limits, both stated on the page: **use MP3**, because Safari 7
will not play OGG; and **the visualiser is decoration**, because a real
spectrum analyser needs the Web Audio API which Safari 7 lacks.

The look is four things: the big round play button with its blue glow ring,
near black chrome with a subtle vertical gradient, a thin blue progress line
rather than a chunky bar, and small flat silver icons either side of the orb.
The play triangle is drawn with CSS borders and swaps to two pause bars via
a `box-shadow`, so there is no image to load.

Pages with nothing in them yet say so with an under construction gif rather
than pretending.

### The wallpaper gallery

`pages/wallpapers.html` shows all 350 as thumbnails. **Click one to set it as
the site background**, which hooks straight into the cycler. There is a
category filter, and the count updates with it.

Thumbnails come from `tools/thumbs.py` at 240x150. This matters: the full size
wallpapers are 1920x1200 at roughly 560KB, so a grid of 350 would be 195MB.
The thumbnails are 2.8MB for the whole set. Re-run after adding wallpapers;
it skips anything already current, so the second run is instant.

```bash
python G:/Websites/xanderspage/tools/thumbs.py
```

One handler sits on the grid container rather than 350 separate ones, and the
click bubbles up. Cheaper to set up and it survives a re-render.

### The guestbook

**It signs.** Fill in the name, the optional site and the message, press the
button, and your mail program opens with the whole entry already written:
addressed, with a subject, with your message in the body. You press send.
Entries go up on the page by hand from there.

Nothing is posted behind your back, and no request is made to any server.
A page that could send mail on your behalf without showing it to you is a
page you should not hand a mail client to.

Because a mail window opening somewhere else is a poor confirmation, the
entry is also written into `localStorage` and shown at the top of the book
straight away, in the book's own style, labelled **not posted yet, this copy
is in your browser only**. That label is not softened and not greyed out: it
is the sentence that stops somebody thinking their message is published when
it is not. There is a link to take it back again.

The same thing works on Pages 98, from the Guestbook window in the Start
menu. `js/guestbook.js` holds all of the logic and knows about neither
machine; `js/guestbookpage.js` wires the Aero form, and `appGuestbook` in
`js/desktop.js` wires the Windows one. One book, two front doors.

**This is the third design.** It started as four fields and a button that
validated your input and then told you it had thrown it away. That was
honest and it was also a prop, and it was the only thing on the whole site a
visitor was invited to touch. So the form was removed in favour of a
paragraph saying to send an email, which was more honest still and which
almost nobody would ever do, because copying an address out of a page and
retyping your message into a mail client is a wall. The version above is the
one that is both honest and usable: the wall is gone, and you can still see
exactly what is being sent before it goes.

**The address is never in the served HTML.** `hello` and `xanders.page` are
kept as separate strings and joined with `String.fromCharCode(64)` at the
moment you press the button. A harvester reading the markup finds nothing;
one running a full browser finds it, and always would have. The fallback
paragraph spells it out in words for the same reason.

**It escapes `<`, `>`, `&` and `"` before displaying anything you typed.**
Verified with a `<script>` payload and with a `<b>` in the name field: both
render as text. A site typed as `example.com` is turned into
`http://example.com` before it becomes an href, because a bare domain in a
link resolves relative to the current page and that is the single most
common way a guestbook link ends up broken. Outbound links carry
`nofollow` alongside `noopener noreferrer`.

**The fields are not cleared after signing**, deliberately. The send button
is in another window. Anybody who closes that window by accident, or who has
no mail client at all, still has every word they wrote in front of them.

## The six additions

**Reduced motion.** Both macOS and Windows have a system setting for people
who get motion sick from moving interfaces. The site now honours it: the
button marquee stops scrolling, the sticker stops bobbing, the blinking ad
border stops flashing, wallpaper crossfades become instant cuts, and both
visualisers hold still.

CSS alone was not enough. The visualisers write inline heights from a timer,
which would have fought the stylesheet, so `player.js` and `main.js` check in
script too. Neither calls `matchMedia` directly: both call `motionOn()`, which
is `js/motion.js` asking once and remembering, so the site switch and the
system setting cannot disagree. Safari 7 does not know the media feature and
simply animates, which is the correct fallback.

The front page visualiser also no longer runs at all while nothing is
playing. It used to write `height: 3px` to fourteen spans seven times a
second, forever, changing nothing.

Verified live: the query matched in the test browser and the bars stayed at
12px across 600ms.

**Animated GIFs cannot be paused from CSS.** The marquee stops moving but the
GIFs inside it keep animating. That is a real limit of the format, not
something worth faking.

**RSS.** `tools/blog.py` holds the posts and writes both the blog page and
`feed.xml`. One source, so a feed that disagrees with its own page is
impossible. Add a post at the top of `POSTS`, then:

```bash
python G:/Websites/xanderspage/tools/blog.py
python G:/Websites/xanderspage/tools/pages.py
```

RSS wants RFC 822 dates, which is an ugly format, so that conversion happens
in the script. The day and month names are written out by hand rather than
using `strftime`, because `strftime` follows the machine's locale and a feed
with French month names is a broken feed.

Every page carries a `<link rel="alternate">` so a reader finds the feed on
its own.

**Site map.** `pages/sitemap.html` is generated from the same `NAV` list the
sidebar uses, so it cannot list a page that does not exist or miss one that
does. Verified: 20 entries, 20 nav links.

**Updates page.** Deliberately separate from the blog. The blog is for things
you want to say; updates is for what changed, which is a much lower bar.
Keeping them apart means neither stops you posting.

**Currently box.** A small panel in the home sidebar: playing, reading,
watching, making. Edit it in `tools/content/index.html`.

**404 page.** `404.html` sits at the site root, not in `pages/`, because a
server serves it for a missing URL at *any* depth. A page at
`/pages/404.html` requested as `/some/deep/path` would resolve all its
relative links against the wrong folder, so every path inside it is root
relative.

`tools/serve.py` now serves it with a real 404 status, so you can actually
test it. On real hosting it needs one line:

```
handle_errors { rewrite * /404.html ; file_server }
```

That is Caddy. Nginx wants `error_page 404 /404.html;`.

## The Windows furniture

**The font is real Selawik.** Your download only had the source, no compiled
font, so it was built here from the UFO files with `ufo2ft`. Overlap removal
was skipped because the `pyclipper` extension will not load on this machine;
TrueType fills with the nonzero winding rule, so overlapping contours still
render correctly. 370 glyphs, which covers everything the site uses. 17KB
each as WOFF.

Selawik is Microsoft's own open source, metric compatible substitute for
Segoe UI. Segoe UI is the actual Vista and Windows 7 interface font and is
licensed to Microsoft, so it cannot go on a website. Selawik can, and it
measures the same.

**Getting it to apply everywhere took two mechanisms, not one.** The
universal selector in `chrome.css` has specificity 0, so `body { font: ... }`
in `style.css` beat it regardless of load order, because specificity outranks
order. Selawik is therefore named first in every font stack as well. The
universal rule stays as the safety net that catches form controls, which do
not inherit `font-family` from `body` at all.

Verified: body, headings, nav, paragraphs, inputs, selects and buttons all
resolve to Selawik; the clock and hit counter stay monospace on purpose.

**Cursors are the real Windows 7 / Vista set**, from the pack by "bak" at
rw-designer.com, which its author released to the Public Domain. Twelve of
them, converted from `.cur` to `.png` by `tools/cursors.py`, because browsers
do not reliably render `.cur`.

The hotspots are **read out of the original `.cur` headers**, not guessed. A
`.cur` is an `.ico` with two fields repurposed: bytes 4 and 5 of each
directory entry are the hotspot X and Y instead of colour planes and bit
count. PIL does not expose that, so the header is parsed by hand and the
values are written into the generated `css/cursors.css`, which is why that
file is generated rather than written.

Two cursors from the pack are deliberately excluded. "person select" and
"location select" are the only 48x48 entries and they carry no alpha channel,
so they convert with a solid black background.

**Scrollbars** are the Vista ones: 17px wide, pale sunken track, glossy
rounded thumb, and arrow buttons at both ends. The arrow buttons are the part
that makes it read as Vista rather than as a generic theme. Four extra rules
suppress the phantom middle pair WebKit draws by default. WebKit only, which
is the target anyway since both MacBooks run Safari.

## The front door

Going to xanders.page does not land on the site. It lands on a machine
starting up.

```
POST  ->  network boot  ->  operating system menu
                                  |
                                  +->  Pages Web          (the Aero site)
                                  +->  Pages 98           (the desktop)
                                  +->  Pages Server Core  (the terminal)

and behind F12 at the POST, before any of that:
                                  +->  MS-DOS 6.22        (the Hard Disk entry)
```

### It is a curtain, not a separate page

The home page is underneath the whole time. `#bootlayer` is a fixed overlay
in `index.html`, and choosing Pages Web just takes it away.

That matters for more than tidiness. While the boot plays, the page below is
laying out, `wallpaper.js` is preloading its first 560KB photo and
`buttons.js` is filling the marquee. By the time the curtain lifts, the site
is ready. A real boot page that redirected when it finished would mean the
home page only STARTS loading once the theatre is over, which is the one
arrangement where all of this would genuinely cost you time.

### When it plays, and when it does not

| you arrived | boot |
| --- | --- |
| typed the address, a bookmark, a link from elsewhere | yes |
| from another page on this site | no |
| `?boot` on the URL | always |
| `?safe` after a safe mode reboot | no |
| JavaScript off, or a crawler | no |

The rule is `document.referrer`: same origin means you are already inside and
the machine is already on. Coming home from the Gallery should not restart it.

This is decided by a small INLINE script in the `<head>`. It has to run
before the body paints, or you get a flash of the home page followed by a
black rectangle dropping on top of it. A separate file would be a second
request, and a second request is the exact delay being avoided.

It used to be the only inline script on the site. There are three now, all
in the head of `index.html` and all there for the same reason, generated
from constants in `tools/pages.py`: `BOOT_HEAD` decides whether to boot,
`PAINT_HEAD` paints the last wallpaper so navigation does not flash, and
`SPLASH_HEAD` puts the Pages 98 splash up before its desktop can appear
underneath it. Each one is a thing that must be settled before the first
paint, which is the only justification any of them has.

`#bootlayer` is `display: none` by DEFAULT and only shown when that script
adds `is-booting` to `<html>`. The direction is deliberate: with JavaScript
off, for a crawler, or if any of these files fail to load, the site simply
appears with no boot at all. The opposite default would nail a black
rectangle over the home page forever. Fail open, never fail closed, when the
thing being gated is decoration.

**The 404 page never boots.** It has a shell of its own, `SHELL_404` in
`tools/pages.py`, which carries no boot placeholders at all rather than being
passed empty ones. A page that only appears because something went wrong is
the last place to make somebody sit through a startup.

It is also the blue screen rather than a page of the site with an apology on
it, so it needs `bsod.css` and root relative paths, which is the other reason
it could not share the home page's shell.

### What the POST knows about the server

The banner, the drive detection, the PCI table and the Server Configuration
block on the POST screen all describe the actual machine this site is served
from, read off it on 20 August 2026 and written into `SERVER` at the top of
`js/firmware.js`.

```
AMIBIOS(C)2020 American Megatrends, Inc.
ASUSTeK PRIME H410M-A ACPI BIOS Revision 1401
CPU : Intel(R) Core(TM) i5-10400 CPU @ 2.90GHz
 Speed : 2.90 GHz    Count : 12
...
Auto-Detecting SATA 1      ..  CT1000BX500SSD1        1000GB
Auto-Detecting SATA 2      ..  CT1000BX500SSD1        1000GB
Auto-Detecting NVMe 1      ..  SSSTC CA5-8D512-Q11     512GB
```

**The best part was an accident.** This screen has imitated an American
Megatrends POST since it was written, chosen because AMI and Award were the
two you actually saw and AMI had the better looking Setup utility. The board
this site runs on has an American Megatrends BIOS, version 1401, dated 27
July 2020. The costume turned out to be the uniform, so the version and the
date on screen are simply the real ones now.

The PCI device IDs are read off the machine too, not chosen for plausibility.
`8086` is Intel, `10EC` is Realtek, `10DE` is NVIDIA and `1E95` is Solid State
Storage Technology, and every device listed is genuinely in the box. Look any
of them up and you get the right part.

It replaced a banner reading `XANDERS P980 ACPI BIOS Revision 0603` over a
processor string scraped out of whatever browser was reading the page, three
drives that all said `Not Detected`, and a Setup utility that claimed AMIBIOS
08.00.12 built in 2007. Setup and the POST disagreed with each other about
what machine you were on. Both read `SERVER` now, so they cannot.

**Every line on this screen costs the logo**, which is the constraint that
shapes it. `.boot__post` pins itself to the bottom on every write, so a POST
longer than the screen loses its top, and the top is where the American
Megatrends badge lives. The first pass at the real specifications ran to 52
lines against a screen that holds about 50, and the badge was simply gone.
It is 42 lines now, which leaves three spare rows.

**And the font is sized in `vmin`, not `vw`.** It used to be `1.05vw`, which
sizes the text off the width while the thing that actually runs out is the
height. That made the number of rows depend on the shape of the window:
about 50 on a 16:10 laptop, 45 on a 16:9 monitor, and 33 on a 2560x1080
ultrawide, where the old rule would have given a 27px font. So a sequence
tuned until it just fitted on one machine clipped itself on another, and
there was no way to see that without owning the other monitor. `vmin` takes
the smaller dimension, which in any landscape window is the height, so the
row count is constant at about 50 whatever the aspect ratio. That is what a
fixed 80x25 text screen should do. Verified at 1280x800, 1440x900, 1920x1080
and 2560x1080: 42 lines, no overflow, badge visible, 80 columns fitting
across, in all four.

**Nothing on this screen describes YOUR machine.** It briefly did: a "Your Terminal" block listing the browser, the screen and the graphics
adapter. It came out again because the network boot prints the same facts
and more of them about twenty seconds later, under "Probing host
hardware", and saying it twice in one boot was worse than saying it once.
Of the two places, this is the wrong one: the probe block scrolls and has
room, while this screen is fixed and pins to the bottom, so four lines
here is four lines of headroom the badge needs.

`df`, `free` and `neofetch` in Server Core report the same hardware, for the
same reason: `neofetch` exists to say what the machine is, which made it the
worst place on the site to make something up.

**Why it is typed out rather than fetched.** A static page cannot ask the
machine it was served from anything. Standing up an endpoint to report the
RAM in a desktop ornament is a service to run and guard forever. So the
values are baked in, and they go stale the day the hardware changes, which
is why the file carries the date they were taken.

**What is deliberately left out.** The network listing stops at the onboard
adapter. The machine has more networking on it than that, and what it is does
not belong on a public page: naming how a box is reached remotely is free
reconnaissance. Serial numbers and real addresses are out for the same reason.
Everything printed is either stamped on the case or written on the parts
inside it.

**And that rule applies to this file.** An earlier version of this paragraph
named the two tunnel products, while explaining that they were being kept off
the site. `README.md` sits in the web root and is served with a 200, so it is
every bit as public as the screen it describes. A rule about what not to
publish is worth nothing if the reasoning that states it is published
instead.

So the POST describes the server and nothing else, which is the one thing
the probe block cannot see. `js/firmware.js` no longer calls `sysReal()`
at all.

### What the boot knows about you

The block near the end of the network boot is real:

```
  Display    : 2880x1800, 24-bit colour @2x
  Renderer   : Intel HD Graphics 3000
  Platform   : Intel Macintosh
  Agent      : Safari 7.0
  Locale     : en-AU / Australia/Melbourne
  Link       : up
  Clock      : Thu 20 Aug 2026 14:32:08
```

All of that is read out of your own browser, locally, with no request of any
kind. A value the browser will not answer is LEFT OUT rather than guessed at,
which is why the two target Macs show no processor count: `hardwareConcurrency`
arrived in Safari 10.1 and `Intl` in Safari 10, so both fall through to the
fallbacks in `js/sysinfo.js`.

**The IP address and the MAC are invented.** The IP is drawn fresh on every
page load. The MAC and the GUID are drawn ONCE and then kept, in
`localStorage` under `xanders_machine`, because a machine whose hardware
address changed between two screens of the same boot would be a machine you
could not believe in. `sysIdentity()` in `js/sysinfo.js` owns that.

A page cannot know your real IP, and there is nothing here to ask.

- Your PUBLIC IP is known to servers you connect to, not to the page. Getting
  it means calling a third party lookup on every page load, handing a stranger
  a record of every visitor. On a site with no analytics, no cookie banner and
  no trackers, that would be the single most invasive thing on it, in aid of a
  joke.
- Your LAN IP used to leak through WebRTC: open a peer connection to nobody
  and read your 192.168 address out of the ICE candidates. That is dead. Every
  current browser hands out an mDNS `.local` UUID instead, precisely to stop
  this. Safari 7 has no WebRTC at all, so the oldest machine here could never
  have done it either.

Fabricated is also the better answer: a screenshot of this boot screen can be
posted anywhere without leaking where the machine lives. The MAC uses a real
vendor prefix so its first half survives a lookup, and the address always
lands in an RFC1918 range rather than on somebody else's network.

**If you ever want it real**, once Caddy is on the VPS it can hand out the
client address from your own server, with no third party involved:

```
ip.xanders.page {
    respond "{http.request.remote.host}"
}
```

Your server already sees the address, so nothing new is exposed. That is the
only honest way to do it, and it needs the server to exist first.

### The operating system menu

NTLDR, as it looked on NT 4, 2000, XP and Server 2003. Not the Vista and 7
Boot Manager, which is a different screen.

```
Please select the operating system to start:

  Pages Web
  Pages 98
  Pages Server Core

Use the up and down arrow keys to move the highlight to your choice.
Press ENTER to choose.

Seconds until highlighted choice will be started automatically: 10

For troubleshooting and advanced startup options for Pages, press F8.
```

Every line is the real wording. It is a VGA text mode screen, 80 by 25, and
every consequence of that is the look: one monospace face, one size, pure
white on pure black, no borders, no rounding, no shadows. There was no
graphics mode running yet to draw any of those with.

**The selected entry is inverse video**: a solid white bar with black text,
running a fixed width rather than hugging the label. That bar is the single
most recognisable detail on the screen, and a coloured marker or a leading
arrow is the usual mistake.

#### An arrow key kills the countdown

Real NTLDR cancelled its timer permanently the moment you touched an arrow
key: the countdown line vanished and the menu waited indefinitely.

Worth copying exactly, because it is authentic AND it is the accessibility
answer. A ten second timed gate in front of a whole website is a barrier for
anyone who reads slowly, uses a screen reader, or just looked away. The
original already solved that with the most discoverable possible gesture:
press an arrow, the clock stops, take as long as you like. No extra
affordance to explain and no setting to find.

Cancelled for good, not paused. A timer that creeps back after you thought
you had stopped it is worse than no timer. Reduced motion gets 20 seconds
instead of 10, because somebody who has told the browser to calm down is not
looking to be hurried by a clock.

#### The mouse works, and that is not authentic

NTLDR ran before any mouse driver loaded, so the real screen ignored the
mouse completely. This one does not, because it is the front door of a
website rather than actual firmware, and a visitor who reaches for the mouse
and finds the page dead concludes the site is broken, not that it is
faithful. One click selects, a second boots.

#### F8

Opens Pages Advanced Options. ESC goes back. Mostly a joke, except that
**Safe Mode is real**: it writes the same `xanders_wallon` key the sidebar
off switch uses and reloads, so the wallpaper cycling genuinely stops. That
is the heaviest thing on the home page and genuinely what a safe mode would
drop.

### Pages Server Core

Boots to `pages/private.html?booted`.

`?booted` means you have just watched one of these, so that page skips its
own network boot and opens straight on the terminal. Two boots back to back
is not twice as good; it is the point where a nice thing becomes a toll gate.

### Timing

About eleven seconds of network boot, then the operating system menu with its
ten second countdown, then 2.4 seconds of loading bar if you chose Pages Web.

**The loading bar comes after the menu, not before it**, and this section had
it the other way round for a long time. That order is the reason there are two
loading screens on the site rather than one: a bar means "the thing you picked
is starting", so it cannot run before you have picked. Pages Web is the one
that gets that bar, drawn by `osLoadThen` in `js/osmenu.js` over a home page
that was under the curtain all along and has nothing left to fetch. The other
two navigate away and load in their own voice on their own page: `SPLASH_HEAD`
for the Pages 98 desktop, and the Linux init scroll in `js/boot.js` for Pages
Server Core.

Reduced motion runs the moving parts at 45 percent of that. The two screens
with a clock running on a decision go the other way on purpose: the firmware
badge is held 1.6 times longer and the menu counts 20 seconds rather than 10,
because somebody who has asked the browser to calm down is not asking to be
hurried by a countdown.

Any click or key skips the boot, except a modifier combination, which has to
stay usable. And the menu is skipped by pressing ENTER.


## The private wing

Clicking Private Wing **opens a new tab**. It is a full screen takeover with
its own startup sequence, so it should not eat the tab you were reading in.

It uses `SHELL_BARE`, a third page shell with no header, no sidebar, no
footer, no wallpaper layers, and deliberately **no `style.css`**. That file
sets a page background, a fixed 980px column and body padding, all of which
fight a full screen takeover. The page brings its own CSS instead.

One consequence worth knowing if you add another bare page: dropping
`style.css` also drops its `box-sizing: border-box` reset. Without it,
`min-height: 100vh` plus padding measures taller than the window and the
logon screen scrolls when it should exactly fill. `css/tty.css` carries its
own reset for that reason.

(This paragraph said `login.css` for a long time. There is no such file and
there never has been: `css/` holds thirteen stylesheets and the terminal's is
`tty.css`. Pages 98 hit the same trap later and fixed it the same way, which
is written up beside the `border-box` rule in `css/desktop.css`.)

### The network boot

The machine boots **over the network**, not off a disk. Real firmware offers
"Boot from IPv4" next to the hard disk; a machine that takes it has no
operating system of its own, so it shouts on the wire, is handed an address by
DHCP, is told the name of a boot image, and pulls that image down over TFTP.

That is a better story for this page than a disk POST, because the page has
nothing of its own either. The private wing lives on a server somewhere else.
All the terminal ever does is ask that server who you are.

**The firmware comes first, and this list used to have it the other way
round.** The network boot is one phase rather than two: `sequence()` in
`js/boot.js` opens with `show("bootPost")`, starts `typePost()` immediately,
and never mentions `bootLoad` at all. The logo and the sweeping bar are the
firmware's now, which is why the only calls to `show("bootLoad")` are in
`run()` further down the same file, handing that screen to `js/firmware.js`.
The comment above `sequence()` gives the reasoning: an OEM splash belongs to
the firmware rather than to a boot device, and while it lived in here the
badge only appeared when the machine happened to be network booting.

(The old wording pointed at a specific line number for a call that is not
there, and the line it named is a line of comment. A line number in a README
is one inserted function away from being a lie, so this file names functions
instead.)

1. **POST.** `js/firmware.js`. The AMIBIOS banner, the memory count, the
   drive detection, DEL for Setup and F12 for the boot device menu. On a
   machine that has booted here before this screen is the manufacturer badge
   instead, the logo and the sweeping bar, held for `SPLASH_MS`, 3.6 seconds,
   with the same footer and the same keys armed behind it. ESC flips the badge
   away and shows the text underneath, the way ESC did on a real OEM machine.
2. **PXE.** `js/boot.js`. Intel Boot Agent banner, MAC and GUID, media check,
   `Start PXE over IPv4`, then DHCP with the lease details and TFTP with the
   boot file. The `DHCP.` and `TFTP.` lines spin a `- \ | /` while they wait,
   the way a real PXE stack does. Then `finish()` hands over, and where it
   hands to depends on the page: to the operating system menu on the home
   page, through the `window.bootHandoff` that `js/osmenu.js` sets, and
   straight to the terminal on the full screen pages, which set no handoff.

**Nothing skips the network boot on a normal arrival.** There used to be a
`localStorage` flag that skipped it after the first visit and a reduced motion
branch that skipped it before the first one. Between them the sequence was
close to unreachable, which is why it looked like it never ran. Both of those
are gone. What decides now is the inline head script in `index.html`: `?boot`
and `?restart` force the boot, `?safe` and `?noboot` skip it, a referrer from
this site skips it because an internal navigation must not replay the whole
thing, and anything else boots. `pages/private.html` carries `logon-root` and
so always boots, unless it arrives with `?booted`, which is the operating
system menu saying you watched one about thirty seconds ago. That one is
covered under Pages Server Core above, and the table under The front door
lists the same rules from the visitor's side.

**`?boot` is still special, and this file spent a while denying it.**
`sysQuickBoot()` in `js/sysinfo.js` is the only thing that tests for it, and
it answers false on the first line of the function, so an arrival carrying
`?boot` gets the full length sequence at full pace. Four exits carry it: the
recovery link on the 404 page, the identical one on the blue screen, the
Restart link in the well on the front page, and `exit` in the public Server
Core shell. Somebody asking for the boot gets the boot. Without it, a machine
that has finished a boot here before runs at the quick pace: `RATE` in
`js/boot.js` is 0.45, which shortens every delay in the sequence except the
badge hold, which is handed to the firmware unscaled. Having booted before is
remembered in `localStorage` under `xanders_quickboot` and checked against a
fingerprint of the machine, so a new browser or a new computer sees it in full
again.

A deliberate restart is a different flag and a different answer. Restart in
the sidebar, the Restart rows of the Shut Down dialog on Pages 98 and `reboot`
in the terminal all end up in `powerRestart()` in `js/power.js`, which goes to
`index.html?restart`. Nothing in `js/sysinfo.js` tests `?restart`, so quick
boot stays in force there on purpose: you have just been running this machine,
so it holds the dark beat and goes to the badge rather than counting its own
memory at you again.

And `?boot` does not bring the POST TEXT back. That is `sysSkipPost()`'s
question and its flag is `?post`. Four things show the text: `?post`, a
browser that has never seen it, a machine that has changed under the
fingerprint, and ESC on the badge. Turning Quick Boot off in Setup switches
the whole lot off.

The PXE phase does not have a hardcoded duration. It is measured off the
script that types into it, so a line can never be cut off half way by a number
somebody forgot to update.

#### Reduced motion still plays it, and still types

This is a deliberate reading of the setting rather than the reflexive one.
`prefers-reduced-motion` is about **movement**: things that slide, sweep,
spin, parallax or zoom, because those are what make people ill. Lines of text
appearing in order is content arriving, not the screen moving.

The version that skipped everything was strictly worse for the person who set
the preference. They got no network boot at all, on a page whose whole point
is the network boot.

So the things that actually move are the things that stop:

| moves | reduced motion |
| --- | --- |
| the PXE spinner | settles, prints once |
| the Vista loading bar | a still full width fill |
| the CRT sweep band | off |
| the tube flicker | off |
| the cursor blink | solid, still visible |
| text typing out | **still types**, roughly twice as fast |

#### Skipping

Any click or key press skips it, with four guards:

1. **Not a modifier combination.** Ctrl+A, Cmd+A, Cmd+R, Cmd+L and the rest
   have to keep working. A page that swallows the whole keyboard is worse than
   one with no shortcut at all.
2. **Not a bare modifier.** Resting a finger on Shift is not a decision.
3. **Trusted events only.** A synthetic click should not dismiss it.
4. **A 450ms arming delay**, so a stray click aimed at the previous page
   cannot land here and kill the whole thing before any of it is seen.

### The terminal

Green phosphor on black, modelled on the terminals in **Fallout: New Vegas**.
What makes those read the way they do, in the order the eye notices it:

1. **Bloom**, enormous, far past what a real CRT did. Three layered text
   shadows, and the whole tube glows. This is the single biggest thing. Too
   subtle and it is just green text on black.
2. **Curvature.** Dark and soft at the corners, brightest in the middle.
   Real barrel distortion needs SVG filters or 3D transforms Safari 7 cannot
   do, so it is faked with a heavily rounded frame, an inset shadow for the
   tube edge, and a radial falloff. At this scale the eye accepts it.
3. **Scanlines**, heavier than a real display, plus a bright band that sweeps
   slowly down the screen.
4. **Text that types out** one character at a time, with a block caret riding
   the end of the line. That caret stays put through the pause between two
   lines rather than blinking out and back, which is what a terminal waiting
   on output actually looks like.

The header block is an **homage** to those terminals, not a copy of their
text. Same shape, same cadence, our own machine.

Monochrome on purpose, including the failures. A real tty had one colour of
phosphor, and making the error red would be the single detail that gave it
away.

The password **echoes nothing**, the way a real login does. Not asterisks.
The cursor does not move while you type it, which is correct and is why the
prompt says `(password is not echoed)` the first time through.

A wrong password waits about a second before answering, then prints
`Login incorrect` and drops back to `login:`. Real logins pause like that.
The actual rate limiting is Authelia's, server side.

**How the typing works, and why it is not a keydown handler.** What you see is
text this page draws. What receives the keystrokes is four real, focusable
`<input>` elements lying invisibly over the screen: user name, password, code,
and the shell prompt once you are through the login.

They are transparent and overlaid rather than `display: none`, because a
hidden field cannot take focus and is invisible to password managers. Keeping
them real means autofill, password managers, IME input, accessibility tools
and the browser's own password handling all keep working. A hand rolled key
capture breaks every one of those, and you find out later.

Separate fields rather than one that switches type, because password managers
key off `autocomplete="username"`, `autocomplete="current-password"` and
`autocomplete="one-time-code"` being on distinct inputs.

The echoed user name is HTML escaped before it goes on screen. A user name is
exactly the field someone eventually tries to put a script tag in. Verified
with an `<img onerror>` payload: it renders as text, injects nothing.

#### The hidden submit button, and why Enter needs one

`<button class="tty__go" type="submit">` sits in the form at 1px, opacity 0.

**Without it, Enter does nothing.** A browser only submits a form by itself on
Enter when the form carries a submit button, or when it holds exactly one
field. This form has three, so Enter left the caret where it was and the
password typed itself onto the end of the login line, in plain sight.

It is hidden with opacity and a corner position rather than `display: none`,
because a button that is not rendered does not count for implicit submission
in every engine, which would put the bug straight back. `tabindex="-1"` and
`aria-hidden` keep it out of the tab order and the accessibility tree.

### Right click, extensions and keyboard shortcuts

**Right click is suppressed**, and it is worth being straight about what that
is: it stops the menu appearing and it stops nothing else. View source,
developer tools, Save As and every keyboard route to the same commands all
still work, because the browser owns those and a page does not get a say.

**Browser extensions cannot be blocked.** Not by this page, not by any page,
in any browser. Extensions run with more privilege than the page does and can
read and rewrite the document before a single line of its JavaScript runs.
Every trick that claims otherwise is either detecting one specific extension
or breaking the page for everyone. Nothing here pretends to do it.

**Keyboard shortcuts are mostly left alone.** This section used to say
"exactly one interception", and there are four. They are all deliberate, but
four is not one.

- **Ctrl+A / Cmd+A selects the screen text.** Left to the browser it would
  select the invisible input instead, which holds at most a user name, so
  select all would look broken. It is caught and pointed at the terminal
  output. Cmd+C then copies what you would expect.
- **Ctrl+Left and Ctrl+Right extend that selection**, by a character, or by a
  word with Shift held. Same reasoning: the selection being drawn is the
  terminal's own, so the keys that move a selection have to reach it.
- **A running program sees modifiers before the bail out**, and has to.
  Ctrl+C is the key that interrupts things, so handing it to the browser
  first would mean `sl` could never be stopped. The program is asked first
  and only keeps what it says it wants; anything it declines carries on to
  the browser, so Cmd+R still escapes a runaway toy.
- **Ctrl+M toggles the sound**, from anywhere on the site. That one is in
  `js/sfx.js` rather than the terminal. Cmd+M is minimise on a Mac and this
  is not that, and no browser claims Ctrl+M.
- **Everything else with a modifier passes straight through**: Cmd+R, Cmd+L,
  Cmd+W and Ctrl+Alt+A all reach the browser untouched.
- Arrows and number keys are taken while the 2FA menu is up, and also while
  the password game is running, which is offered after three failed sign ins.
  At the login prompt they belong to the text field.

Keys are read from `e.key` where the browser has it and `e.keyCode` where it
does not, which is the Safari 7 end of the range this site targets.

### How the sign in actually works

`AUTH_MODE = "api"` in `js/authelia.js`.

The page POSTs the user name and password to Authelia's `/api/firstfactor`.
**Authelia** decides whether they are right, not this page. Nothing is
validated in the browser. It cannot be: a static site has no server, so any
test would run in JavaScript a visitor can read, against a value they can also
read.

Then one of two things happens:

- **The reply carries a redirect.** Authelia considers you fully
  authenticated, so the terminal prints `ACCESS GRANTED` and follows it. This
  is the no-second-factor case.
- **The reply carries no redirect.** The password was accepted but a second
  factor is still owed, so the terminal shows the menu below.

That distinction used to be missing: a missing redirect fell back to
`AUTH_TARGET`, which sent a half authenticated browser at the protected app.
The app bounced it straight back, so nothing was ever exposed, but the screen
announced a success that had not happened yet.

**Three things the server needs:**

1. **Shared parent domain.** Authelia sets its session cookie for a domain;
   `xanders.page` and `auth.xanders.page` share `.xanders.page`.
2. **CORS with credentials.** Authelia must send
   `Access-Control-Allow-Origin: https://xanders.page` and
   `Access-Control-Allow-Credentials: true`. A wildcard origin is refused by
   browsers whenever credentials are involved, so it has to name the origin.
3. **HTTPS on both.** The code refuses to send a password otherwise, and says
   so rather than failing quietly.

Set `AUTH_MODE = "redirect"` to skip the form entirely and hand off to
Authelia's own portal instead.

### The second factor menu

After the password, the terminal offers a menu. Arrow keys or a number key,
Enter to choose. The selected row is drawn in inverse video, the way every
real terminal marked a selection.

```
  > [1]  AUTHENTICATOR CODE     six digits, from an app
    [2]  SECURITY KEY           WebAuthn, held in your hand
    [3]  SMS TEXT MESSAGE       not available
    [4]  EMAIL                  not available
```

#### Authelia does not do SMS or email

This matters, so it is on the page rather than buried here.

Authelia issues **three** kinds of second factor: a **TOTP** code from an
authenticator app, a **WebAuthn** security key, and a **Duo** push. There is
no SMS option and no email option to turn on.

SMS and email are still on the menu, because the honest thing is to say so
where you would go looking for them rather than leave you wondering whether
you missed a setting. Choosing either one opens a Windows 98 window that
explains it. **It does not send anything and it never pretends to.**

For what it is worth they would also be the two weakest choices. A text
message can be redirected to somebody else's phone without them touching your
account, and email would lean the whole login on a mailbox that is itself one
of the things behind this door.

#### What each option does

| option | what happens |
| --- | --- |
| Authenticator code | prompts in the terminal, POSTs to `/api/secondfactor/totp` |
| Security key | hands over to Authelia's own portal |
| SMS | opens the Windows 98 window explaining it is not available |
| Email | opens the Windows 98 window explaining it is not available |

A code is a string, so this page can collect one and post it. WebAuthn is not
a string: it is a challenge and response ceremony between the browser and a
physical key, bound to the origin that issued the challenge. That origin is
Authelia, so the ceremony has to happen on Authelia's portal. Duo is the same.
Faking either from here would mean inventing a security protocol on a page
that cannot verify anything.

Safari 7 has no WebAuthn at all, so that option checks for
`window.PublicKeyCredential` and says so plainly rather than handing you to a
portal that will fail.

Wrong codes are usually a **drifting device clock** rather than a wrong code,
so after three tries the terminal says so. Worth knowing before you go hunting
for a fault in the server.

### What is behind the login

The login used to redirect to the protected app the instant it succeeded,
which made the terminal you had just fought through a doorway and nothing
else. Now it drops into a shell.

```
ACCESS GRANTED

Last login: Thu Aug 20 14:32:08 2026 from 10.9.80.24

Pages Server Core (GNU/Linux 6.1.0-pages x86_64)

  the private wing
  everything here is yours and nobody else is looking

  type help for commands, or services for what is
  actually running behind this.

xander@pages:~$
```

The shell draws nothing of its own. It borrows `window.tty`, a small API
`js/tty.js` exposes: print, clear, take input, suspend, resume. Everything
about credentials stays in `tty.js` and everything about commands stays in
`shell.js`, and neither reaches into the other. That is why the two can be
read separately.

**Commands.** Twenty six in `js/shell.js`: `cat`, `cd`, `clear`, `date`,
`df`, `echo`, `exit`, `free`, `help`, `history`, `id`, `ifconfig`, `logout`,
`ls`, `mute`, `neofetch`, `ps`, `pwd`, `reboot`, `services`, `sound`, `sudo`,
`uname`, `uptime`, `vista`, `whoami`. Seven more in `js/apps.js` are programs
rather than commands, in that they hold the whole screen until you quit them:
`cowsay`, `fortune`, `man`, `matrix`, `ping`, `sl`, `top`.

**`cmd` is not among them any more.** A command list is exactly where a
deleted command survives longest: this one still carried it weeks after the
Command Prompt was removed. See the section below for why it went. The list
above was checked against `shellDefine` in both files rather than remembered,
and if you change the commands, check it again. A bare
service name opens that service. The up arrow walks the history and Ctrl+L
clears, because those are the first two things anybody reflexively tries and
a shell missing them feels broken in a way that is hard to name.

**The filesystem is invented** and lives in an object at the top of the file.
A directory is an object, a file is a string, and that is enough for `ls`,
`cd`, `cat` and `pwd`. So are the process list, the disk usage and the
uptime. This is a static site; there is no machine on the other end to ask.

**Nothing here is a security boundary and nothing here is secret.** Every
line of `shell.js` ships to the browser and anybody can read it, which is
exactly why there is nothing in it worth reading. The real private wing is
the services, and those sit behind Authelia on the server where a password
can actually be checked. The commands that open one are ordinary links; if
you are not signed in, Authelia bounces you at the door the same as typing
the address by hand.

`reboot` really does restart the whole thing, back to the network boot.
`vista` leaves for the Aero side. `exit` returns to the login prompt. There
is always a way back out, which there was not before.

### The MS-DOS Editor

Type `EDIT` at the DOS prompt, or `EDIT XANDERS.TXT` to open one of the files
`DIR` lists. `js/dos.js`.

`EDIT.COM` shipped with MS-DOS 5 and 6 and was, for a lot of people, the
first thing on a computer that looked like an application rather than a
command. The whole effect is colour: everything else on that screen was grey
on black, and this was a blue field with a grey bar across the top.

```
 File  Edit  Search  Options                              Help
 +------------------------ XANDERS.TXT ------------------------+
 |I got this machine up in 1996.                               |
 |                                                             |
 |It has been reinstalled three times since and this partition |
 |has survived every one of them, because every time I get as  |
 |far as looking at what is on it and decide to do it later.   |
 +-------------------------------------------------------------+
 ESC=Exit   11 lines                                    1:  1
```

The three colours are the real ones out of the sixteen VGA text mode had:
`#0000a8` is colour 1, the bars are colour 7, and the cursor is the character
underneath it drawn in inverse rather than a shape sitting beside it, which
is what a text mode cursor was.

**It edits.** Arrows move, typing inserts with its case intact, Enter splits
a line, Backspace joins one at the left margin, Delete works forwards, Home,
End, PageUp and PageDown work, and the buffer scrolls when it outgrows the
eighteen visible rows.

**It does not save**, and it says so on the way out instead of pretending.
The buffer is a copy, so opening `AUTOEXEC.BAT`, mangling it and pressing
Escape leaves the file exactly as `TYPE` reads it back.

**No word wrap.** EDIT scrolled sideways rather than wrapping, and sideways
scrolling is a lot of machinery for a case nobody will hit, so the line stops
accepting characters at 75 columns and the status bar says why.

The prompt underneath uppercases everything you type, because DOS did and a
command line has no use for case. The editor does not, because typing into a
text editor and being SHOUTED BACK AT is the most obvious way this could have
felt wrong.

It reads `keyCode` and the shift state rather than `e.key`, like everything
else in that file, because Safari 7 predates `e.key` and reports it as
undefined.

### The Command Prompt, and why it is gone

Typing `cmd` used to open a Windows XP console over the CRT: a real
draggable window from `js/win98.js`, with its own filesystem, its own
`color` command, `dir`, `ver`, `tasklist`, `systeminfo`, a `boot.ini`
listing both operating systems, and the right refusal when you typed a
Unix command at it. About four hundred and fifty lines, and it worked.

It was removed, along with `css/shell.css`, which existed only to style
the console inside it.

The reason is that it was the wrong joke in the wrong room. This
partition is Server Core: one tube, one prompt, no windows. That is its
entire character, and every other machine on this site already has a
GUI. A draggable window here undercut the only one that did not have
one, and the punchline of the Command Prompt (Windows commands work,
Unix commands do not) is a joke about being in Windows, told on the
machine whose whole point is that you are not.

If a Command Prompt is ever wanted again it belongs on Pages 98, where
windows live. The version history has all of it.

The one window still drawn on this partition is the two factor dialog
in `js/tty.js`, which explains why the login does not offer SMS. It is
ordinary HTML in a `win98Open` frame and needs no console styling.

### The Windows 98 window

`css/win98.css` and `js/win98.js`. Draggable by the title bar, click to raise,
close box and footer buttons.

The chrome is not rounded, not shadowed and not gradient filled. It is four
one pixel borders faking a bevel, and that exact stack is the whole look:

```
outer top/left   #ffffff     the light catching the edge
inner top/left   #dfdfdf
inner bottom/rt  #808080
outer bottom/rt  #000000
```

Get the order wrong and it reads as a modern card with grey borders. Buttons
invert the same bevel when pressed, and shift their label down and right by a
pixel, which is the part that sells it.

The title bar is the one gradient in the whole thing, navy to a lighter blue.

**Not Marlett.** Marlett is the Windows symbol font the real close boxes were
drawn from, but its close glyph lives on the letter `r`, not `x`, so asking
for an `x` got the wrong symbol back. It also does not exist on a Mac, which
is what this site is built for. A plain lowercase `x` in a monospace face is
what a small close box looked like and it looks the same everywhere.

**Dragging is done on the document, not on the title bar.** The obvious
implementation puts `mousemove` and `mouseup` on the bar, and it breaks the
moment you move the pointer faster than the browser repaints: the pointer
leaves the bar, the bar stops hearing about it, and the window sticks halfway
through the drag. `mousedown` goes on the bar, the other two on the document.

The window is clamped so it can always be dragged back. The top is clamped at
zero in particular, because a window dragged above the top loses its own title
bar and can never be recovered.

### Looking at it without a server

Authelia is not up yet, and without it the second factor menu is unreachable,
which makes it impossible to look at while building the page. So:

```
http://localhost:8080/pages/private.html?demo
```

`?demo` walks the whole flow with nothing behind it. Everything it does is
labelled on screen as a demonstration; it never claims anyone signed in, and
it never sends a request.

### The removed screens


The Windows 7 logon screen and its stylesheet are gone, replaced by the
terminal. `tools/logonbg.py` still draws the Windows 7 background if you ever
want that look back, but nothing references it now.

### The explanatory notice

Removed, as asked. It was written when the screen was purely cosmetic and
checked nothing, and it said so at length. Now that the form genuinely posts
to your own Authelia, most of what it said was out of date: this is a real
login to your own service on your own domain, not an imitation of anybody
else's.

The behaviour that mattered is still in the code rather than in prose. The
page only ever sends to `AUTH_BASE`, it refuses to send a password over
plain HTTP, and it never claims to have checked anything itself.

## Multi-page notes

Two things had to change once a second page existed, both worth knowing before
you add page three.

**`SITE_BASE`.** Paths built in JavaScript are relative to the page, not the
site root, so `img/wallpaper/x.jpg` breaks from inside `pages/`. Every page now
declares its depth before the scripts load:

```html
<script>var SITE_BASE = "";</script>      <!-- in the root -->
<script>var SITE_BASE = "../";</script>   <!-- in pages/ -->
```

Plain `<img src="">` tags in the HTML do not need this. Only paths built in JS.

**Guarded lookups.** `main.js` runs on every page, but most pages have no
music player, hit counter or mailing list form. An unguarded
`document.getElementById("thing").onclick = ...` returns null and throws,
which kills every script scheduled after it. So each block now checks the
element exists first. If you add a page and the wallpaper silently stops
working, this is the first thing to check.

**Cache.** Editing a `.js` file and reloading can serve you the old one. If a
change seems not to have taken, load the page with `?v=2` on the end.

## Fonts

**This is done.** The section below was written as instructions and has been
left in as a record of what was done, but none of it is outstanding work.

`fonts/` holds `selawik-regular`, `selawik-bold` and `selawik-light`, each as
both `.woff` and `.ttf`, and `css/chrome.css` declares all three with the
`.woff` first and the `.ttf` as the fallback. `fonts/Selawik-1.01` is still
there as the **source** repository, `.glyphs` and `.ufo` design files with no
usable font in it, which is why the built copies had to be fetched separately
from the releases page of Microsoft's Selawik repo.

What was done, for the record:

```css
@font-face {
  font-family: "Selawik";
  src: url("../fonts/selawik.ttf") format("truetype");
  font-weight: normal;
}
@font-face {
  font-family: "Selawik";
  src: url("../fonts/selawik-bold.ttf") format("truetype");
  font-weight: bold;
}
```

Selawik is metric compatible with Segoe UI, which is the actual Aero font and
is not redistributable. This is the legitimate way to get that look.

Until then the stack falls back to Lucida Grande on your Macs, which is the
correct system font for that era anyway.

## Note

The old copy at `C:\Claude\site` has been deleted. This folder is the only
copy now.
