70 releases · newest first

Changelog

Every release of this site, written when the change was made rather than reconstructed afterward. The build number in the corner of every page points here.

BuiltWed Sep 16, 2026 · 00:26:52 PDT Version1.3.4 2026-09-16
1.0.xa fix, a correction, a small change
1.x.0a redesign, a new section, a new capability
2.0.0a rebuild. Nothing planned
1.3.42026-09-16

the checklist pages say when they were built, not just what.

Six public pages — /checklists/, each individual list, /checklists/print/, /emergencies/, /renaming/ and /camera/ — showed a version number and no build time, while every other page on the site shows both. The version was right there in the masthead and visible, so it read as done; nobody had compared it side by side with the stamp the rest of the site uses.

  • The build time is now rendered alongside the version on all six.
  • .stamp-sm was a class name with no rule behind it. It has one now, so four lines of stamp sit in the button column without crowding it.
  • A wider stamp made the masthead wrap on the four list pages that never capped their subtitle, dropping the print buttons onto a row of their own. Those four now cap it the way the other list pages already did.
  • The suite now walks the whole docroot asking whether each masthead carries the build time AND the version, not merely a link to the changelog. The old check matched the changelog page's own canonical link, which is a link with no stamp attached to it.
1.3.32026-09-16

the trips pages show the build where you would look for it.

  • The version was on /trips/ all along, buried mid-sentence in the footer disclaimer at the very bottom of the page. Technically present, which is how it kept passing a check that only asked whether the string existed. Every other page puts it in the masthead, so now this one does too.
  • The masthead became two columns to hold it, the same shape the rest of the site uses.
  • The suite now checks the stamp is IN THE MASTHEAD rather than anywhere in the document. "Somewhere on the page" was the wrong question.
1.3.22026-09-16

the build stamp really is on every page now.

  • Three pages had none. The changelog said "Now running 1.3.1" as plain text with no link and no build time — the page that explains the number, wearing it differently from everywhere else. The portal and its login page had nothing at all, because they are served by the daemon rather than written by the generator, so nothing ever stamped them.
  • The daemon now fills a slot on both from VERSION, read at request time so it follows an install without a restart, and inside a try so a missing file cannot stop the boat log from serving.
  • The suite now WALKS the docroot and checks every index.html for a stamp, instead of checking the handful of pages I happened to list. That is why the changelog went four releases without one: nobody was looking at the pages as a set.
1.3.12026-09-15

the harbor is full of boats, and the map now says so.

  • Rich looked at the satellite view and pointed out what it plainly shows: the inner basin is packed. Counting bright objects off the imagery, the north half between 122°31.5′W and 122°30.8′W is solid docks and standing moorings — hundreds of returns per cell — and my shaded "anchorage" ran straight across it.
  • The map now marks MARINAS & MOORINGS over that ground, and shows the room to anchor where the photograph actually shows clear water: the southern strip, trimmed clear of the marina docks, labeled limited.
  • The text says it too. This is the edge of somebody else's neighborhood, not an empty bay: arrive with daylight in hand, look before committing, and keep the city dock in mind.
  • SPELLING, again. The checker matched whole words only, so a derived form walked straight past a list containing its own root — the root was listed, the word with a suffix on it was not caught. Widened to allow suffixes, which immediately turned up five more British forms sitting in the generator, the portal and the release notes, some of them for weeks. 29 files corrected. (Entries here describe the words rather than spelling them: the check reads this file too, and the last attempt at this note failed its own build.)
1.3.02026-09-15

satellite maps, alongside the drawn ones.

The schematics had no frame of reference to land: correct about the regulation boundary, useless for recognising where you are. So there are now two of each, and they answer different questions — the photograph shows the land, the docks and the town; the drawing shows the marine-area boundaries and the exact no-anchor polygon. Neither replaces the other.

  • USGS National Map aerial imagery, public domain, fetched ONCE at authoring time and composited into a flat JPEG served from this site. A live tile layer would mean third-party JavaScript and a few dozen requests to somebody else's server on every page open — which CLAUDE.md forbids and the About page tells readers does not happen. Both would have become false.
  • Annotations are SVG over the picture, in the page's own palette, so they stay sharp at any zoom and they print. All four maps enlarge.
  • The harbor overlay marks the no-anchor zone from the regulation's own coordinates, the 122°30.6′W line, and the anchorage as an AREA with one boat's swing drawn to scale inside it — not a point, because where exactly to drop is a decision made on the sounder.
  • Landmark dots are placed by eye off the photograph and say so. The first attempt had them 440 m north of the water, on somebody's roof, because I was placing them from memory rather than from the image.
1.2.12026-09-15

the theme control, on every rendered page.

  • The individual trip pages showed no theme control. They carried the button, because it ships with the shared nav, but not the script that unhides and drives it — so it sat there hidden, doing nothing.
  • They also had no night palette, and the toggle cycles four states, so handing them the script alone would have produced a "night" that rendered as the light default. Night is now defined in those pages' own token names, with the maps and photos filtered to match.
  • The portal's control is now the same chip in the same corner as every other page, replacing the moon button in its header. It drives the portal's own two states rather than the site's four — there is no light mode at a helm station at night, and that is deliberate.
  • The authored pages also apply the saved theme in <head>, before the page paints. Without it they render the default first and correct themselves a moment later, so a reader who chose night gets a white flash — at night, which is the one time that matters.
  • The chip relabels itself after the saved theme is restored: that restore lands after first paint, so without it the label could say Dark over a night-red screen.
1.2.02026-09-15

enlargeable maps, anchoring you can follow alone, and build data on every page.

  • Both maps on the Eagle Harbor page open full screen. Done with :target rather than a script, so the overlay is a URL — the back button closes it, it works with JavaScript off, and it prints as the plain figure it is. A trip page is read on a phone at anchor; a map that cannot be enlarged there is a decoration.
  • The route map now carries the two ferry crossings it meets and the two places to duck into, Kingston and Port Madison. Naming a bail-out in the notes is advice; putting it on the map is what makes it usable.
  • The harbor map carries the approach track past Wing Point and the tender runs to both landings, about 0.4 nm each.
  • Anchoring single-handed, step by step, plus the eight-step drill in order. The boat at idle astern moves slower than you walk; nothing about this has to happen quickly, and writing the sequence down is what makes it the same sequence every time.
  • BUILD DATA ON EVERY PAGE. The authored trip pages were the only ones on the site carrying none — which is the worst place for it, since a trip page is what someone is still reading a year later. build_trips stamps each one with the version and build time on every run, and reports any authored page that has no slot for it rather than skipping quietly.
  • The portal's bars ran the full width of a desktop window over a 760px column, so it read as two different pages. They line up now.
1.1.12026-09-15

the site bar on the portal, and American English everywhere.

  • /admin/ carries the same sticky bar as every public page, rendered by the daemon from render.nav_html() rather than copied into the app: a third copy of the sections is a third thing to drift. It is imported lazily and inside a try — a boat log that will not start because a stylesheet is missing would be a poor trade for a navigation bar.
  • Admin reads as the current page there, and stays a link everywhere else.
  • The tab bar now answers to the same scheme: same mono, same 10px, same letter-spacing, uppercase. 61px down to 47px — it was holding 60px of a phone screen for three words.
  • SPELLING. The checker only ever walked conditions/ and only files with an extension it recognized, which left two holes. VERSION has no extension, so British forms in the release notes rode the changelog onto the site. And the authored trip pages live in www/, outside that tree entirely: one page had collected nineteen British spellings of the word for a sheltered body of water, plus the British forms of color, vapor, maneuvering, fueling, gray and gasoline. All fixed in the text, and the checker now walks VERSION and the whole docroot so neither hole is open. The Harbour Public House and Harbour Marina keep their spelling: it is their name.
1.1.02026-09-15

maps, anchoring, and the generator, on the Eagle Harbor page.

A trip page should answer the questions you would otherwise ask a chart and two forums. This one now does.

  • A route map drawn from the WDFW boundaries already in map.json — the same geometry the conditions pages use — framed on the route rather than on the areas, because area 9 runs to the Strait and put the whole trip in a corner. Nine waypoints on it, Mercator, with the marine areas named.
  • A harbor diagram for Eagle Harbor that is explicitly a SCHEMATIC. The shoreline detail is not in the data and drawing a harbor from memory is the one thing not to do. What it does carry is exact: the federal no-anchor polygon plotted from the coordinates in 33 CFR 165.1309, the 122°30.6′W line to stay west of, the anchorage with its 215 ft swing circle to scale, and the ¼ nm bar. It says all of that on its face.
  • Anchoring, at length: the approach, setting in soft mud, backing down, what good holding feels like here, the 8.5 ft that leaves overnight, the alarm radius, and getting it back with a century of junk on the bottom.
  • A generator section, because one is now likely aboard: what an hour of it buys in Wh, when it is decent to run it in a harbor people live in, and the two things that actually kill — gasoline vapor in a diesel bilge, and CO over the transom. Plus its own checklist.
  • The page generator now ends by applying the shared nav, because regenerating it had quietly restored the old breadcrumb.
1.0.12026-09-15

the nav on the trip pages pointed at the wrong place.

Both authored trip pages carried a hand-rolled breadcrumb whose first link, labeled "Conditions", went to "/" — correct back when / was the conditions index, wrong since the restructure moved it to /conditions/, and copied faithfully into the Eagle Harbor page because it was the convention.

  • Both pages now carry the same nav as everything else, with Trips marked as the section they sit in and back pointing at /trips/.
  • nav_html() learned about trip detail pages, so they get the same treatment area pages already had inside Conditions.
  • Those pages name their palette differently — --magenta where the site says --accent — so the nav aliases the two on itself rather than pinning a color, and follows the page's own light/dark switch.
  • The suite now reads the nav on every authored trip page and checks where each link actually goes. A hand-placed nav rots quietly; this is the only thing that would notice.
1.0.02026-09-15

the site ships.

Sixty builds in, this is finished enough to have a version rather than a serial. The first line of this file is the version: it is what tfu reports on an install, what every page stamps in its corner, and what that stamp links to. The changelog is built from this file, so the history is what was written at the time and not a summary assembled later. Everything below this entry is pre-1.0 development, kept as it was — the v numbers were a serial, v42 simply followed v41, and that is the whole reason for changing: a serial cannot tell you whether a release moved the furniture or fixed a typo.

  • 1.0.x — a fix, a correction, a small change. Most releases are these.
  • 1.x.0 — a redesign, a new section, a new capability. Rare on purpose.
  • 2.0.0 — a rebuild. Nothing planned, and it would have to be earned.

Before 1.0

Sixty builds under a plain serial: v42 followed v41 and told you nothing else. Kept as written.

v59

the third trip state gets a name that says what it means.

  • "Scrubbed" is now "Planned, not taken" on the index, with the blurb "Planned properly, then overtaken by events. The work stays." The stored value is unchanged: no trip has ever used another one, and the generator, the home page counts and the tests all already speak it.
  • Optional shelved_reason (scrubbed_reason also accepted) renders as an italic line under the summary, on the card. No reason, no element — an empty <p> would be worse than nothing — and it is only shown for the state it belongs to.
  • The home page was printing the raw status key, so the same trip would have read "scrubbed" there and "Planned, not taken" on the index. One label, used in both places.
  • Muted treatment extends the card styling that was already there rather than adding a second vocabulary for it.
v58

a test frame now takes the same path a real one does.

  • "Send a test frame" wrote the private copy only, so pressing it proved half the pipe while /camera/ went on saying no frame had arrived. Both callers now go through one store_still(): private copy, public copy, history, same code, same order.
  • Also recorded, because it cost an hour: headless Chrome cannot capture the share link. It renders "Failed to Connect to Livestream" — the WebRTC connection never establishes without a real media stack. A frame still has to come from something that can reach the camera directly, and the UniFi console is the machine already aboard that can.
v57

fix /camera/ — it was shipped without anyone looking at it.

  • The image element started VISIBLE with no src, so until the first fetch resolved (or forever, if it failed) the page drew a broken-image icon and its own alt text across a black box. Image now starts hidden, message starts visible: this page never shows an empty frame posing as the boat.
  • .campub-box img had display:block, which beats the hidden attribute's display:none from the UA stylesheet — so the image was hidden and displayed at the same time. Both children are absolutely positioned now, instead of being flex siblings that shared a row and squeezed the offline message into a column one word wide.
  • The page was carrying the CHECKLISTS' disclaimer, which is about training and USCG equipment rules and is nonsense under a photograph. It gets the site footer now.
  • The first paint no longer skips when the tab is in the background, the message says which case it is (no frame ever vs no recent frame), the masthead stamp right-aligns even when it wraps, and the picture is no longer held to the width of a prose column.
v56

the still, publishable — plus a way to prove the pipe without leaving the dock.

  • /camera/ on the public site: one frame, reloaded every 20 seconds, with how old it is stated next to it. The image is a plain file in the docroot written by the daemon as each still lands, served by nginx uncached — no proxy, no session, nothing dynamic. A frame older than three minutes is not shown as if it were current; the page says when the last one was.
  • Publishing is OFF in the defaults and has to be switched on deliberately. A picture of the boat every 20 seconds is a public statement about whether anyone is aboard, and about whoever else is on the dock.
  • "Send a test frame" in the portal proves the receive path end to end without setting anything up at the boat. The test card says TEST FRAME across it and the portal labels it, so it can never be mistaken for the camera.
  • deploy/cam-push.sh: shell and curl only, for the machine most likely to already be aboard and always on — the UniFi console itself, which has no python3. Warns that anything outside /data is deleted by firmware updates.
  • The portal's empty state now says the true thing: nothing has pushed a still yet, and here is the command that starts one.
v55

crop the player to the picture; make the still say what it is waiting for.

  • The share page is Ubiquiti's app: a bar across the top, and a footer with the camera model, the controls, and "Shared by <your name>". None of that is the boat. It is cross-origin so its DOM cannot be touched — what works is rendering it at a fixed 800x450, scaling the whole frame as a unit, and showing only the rectangle the video occupies. A percentage crop does NOT work: the bars are fixed height, so the video area does not scale proportionally with the iframe.
  • The four numbers are measured, live in one place as CSS custom properties, and are over-cropped by 3% so a small error costs a hair of picture instead of showing the player's white page. "Show the player" puts it back, since cropping also hides Ubiquiti's fullscreen and snapshot buttons.
  • FIX (pre-existing, unrelated): "ratings vary across the run" asserted that a clock-anchored fixture happened to contain more than one rating. At 21:00 every day scored "go" and the suite failed on a scorer that was working correctly. It now scores a fixed spread of conditions directly.
v54

a still from the boat every 20 seconds — pushed, not scraped.

  • The share link still cannot produce one and never will: it is a WebRTC player with no snapshot endpoint, and a cross-origin video cannot be sampled to a canvas. Capturing it server-side would need a headless browser decoding the stream continuously — the box has 412 MB of RAM, about 105 MB of it free, so that OOMs the machine that serves the site, and a 24/7 stream into it is 650 GB to 1.3 TB a month.
  • So the box receives instead. POST a JPEG to /api/cam with a token and the portal shows it, refreshed every 20 seconds while that screen is open. One small request per refresh instead of a live stream: it works on one bar and it stops when you leave.
  • The endpoint is off until a token is set, rejects anything that is not a JPEG (a misconfigured pusher's HTML error page would otherwise be served as "the boat"), caps the size, writes atomically so a reader never sees half a frame, and serves no-store. Only a logged-in session can read it.
  • deploy/cam-push.py is one sender: UniFi Protect local API to JPEG to POST, stdlib only, with backoff when the boat is off the air. Its Protect half is UNTESTED — it cannot be verified from outside the boat's network, and it says so at the top of the file.
  • nginx: /api/cam is the one location exempt from the password on the door, rate limited, because the alternative was putting the basic-auth credentials that guard the entire boat log into a config file on a boat to do a job that only needs "may add a photo". The captured config in deploy/ was also stale — it showed /api/ open when the live box had a password on it — and is now recaptured, with tests to keep it honest.
  • CORRECTION to what I said earlier in this work: the box has 16 GB free, not ~150 MB. Disk was never the constraint. Memory is.
v53

/renaming/ — why she is being renamed, and the order it happens in.

  • Why first, in Rich's words: the flyer, the Bluemoon, Seattle as the hailing port. Then the purge, the denaming, the renaming and christening, the paperwork, and the order of operations for the day.
  • The steps tick and print like the checklists, off the same machinery, so somebody else renaming their own boat gets a usable procedure.
  • The paperwork does NOT guess. A documented vessel and a state-registered one are different processes, so the page states the tell — name and hailing port lettered across the stern is the format the Coast Guard requires of a documented vessel — and branches, rather than picking for the reader. Every line of it says to verify against current rules.
  • This is the one page allowed to say OTIUM, because replacing that name is its subject. The suite asserts the exemption is exactly one page.
  • Cost: a sixth nav item puts the phone nav back to 107px from 84. Measured at 390px; nothing overflows, and shorter labels did not buy a row back at a legible size.
v52

the photo takes the room the third card made, and phones get finished.

  • With three cards the right-hand column runs about 500px past the bottom of the flyer, so the empty strip under the artwork was the only real room on that page. The photo spans the hero now — 970px wide instead of 612 — with a 30px pad below the cards so it reads as its own thing rather than a fourth card. Asset re-exported at 1080px so it is not upscaled; still no camera metadata.
  • /trips/ kept a 136px nav on a phone while every other page had 84, and its back link had no styling at all: that page carries its own copy of the nav CSS and the first pass only updated theme.html. Both copies now have the phone breakpoint, and the suite checks that they do.
  • Chart labels were 10px on a phone. 11px now, inside their own scroller, so nothing else moves.
  • The home-page fixture in the suite now builds the page that actually ships — three cards, not two. A fixture simpler than the real page stops testing the real page the moment it grows.
v51

back on every page, nested lists, and a phone that is not an afterthought.

  • The build number is a link now. It lands on a new About section saying what the v counts (a serial, one per release, not per commit and not per rebuild), that point revisions are coming but are not here yet, and that a build reaches the site only by passing its own test suite ON the server first.
  • Every page above the front page carries a back link in the nav, to a fixed parent. Not history.back(): a page opened from a search result or a shared link has nothing behind it, which is exactly when the reader is stuck. It works with scripting off and goes the same place every time.
  • The checklists and the emergency procedures nest, the way they do in the portal: the title and the first action stay visible, the rest opens in place. Ticks are shared between a list on the index and that list's own page, and are re-read on pageshow so a back gesture cannot show a stale page.
  • Phones: the sticky nav was holding 130px of an 844px screen — 15% of the phone, above every page, all visit. Now 84px. Touch gets larger targets than a mouse pointer does.
  • The build number now appears on every public page. Trips and the checklists had none at all, so "which build wrote this page" was unanswerable on seven of the eleven.
  • FIX: area pages scrolled sideways on a phone, and had since v48. The About page's source table was given the class .srcs, which was already the sources line at the foot of every area page, so a min-width meant for a table was applied to that line. The table is .srctab now, and the suite checks that .srcs carries no min-width.
v50

the checklists go public, and they print.

  • /checklists/ — the eight lists, one page each, tickable; /emergencies/ — the Mayday script and six procedures; /checklists/print/ — all of it on one page. Built from the SAME files the private portal reads, so there is one copy of the content and it cannot drift.
  • The vessel identity is replaced on the way out. A Mayday script that transmits someone else's boat name is not a template, it is a hazard, so OTIUM becomes <YOUR VESSEL NAME> and "32 foot white motor cruiser" becomes a prompt. The suite fails the build if a vessel name survives into what a stranger reads.
  • Ticks live in the reader's own browser and nowhere else: no account, no request, nothing recorded. The public side still does not know who is reading it.
  • Print: black on white, nav and buttons dropped, fold-aways opened, no item split across a page, checkboxes as empty squares for a pen.
  • The fold-aways are NOT a details element. A closed one hides its contents through a slot no stylesheet can reopen — the print rule applied, the hidden text even reported a bounding box, and every "how" was missing from the paper. checkVisibility() in a browser is what caught it.
  • The log book is not published and this cannot reach it: the builder reads two JSON files and writes HTML, and never opens the database.
v49

a live camera in the portal, behind the password.

  • A UniFi share link is a live WebRTC stream in Ubiquiti's own player. There is no still image behind one and no snapshot endpoint, so "an image every 20 seconds" is not available from a share link at all: the frame rate is a camera setting in Protect, and a cross-origin video cannot be sampled to a canvas. The page says so rather than pretending otherwise.
  • Because it cannot be slowed down, it can be stopped. The stream starts on request and the iframe is destroyed — not hidden — when you leave the view, hide the tab, press stop, or after ten minutes. A stream left running in a background tab is a hole in a cellular plan.
  • The share URL lives in /etc/tanglefoot/log.json on the box and is never committed. The suite walks every shipped file and fails if one appears.
  • Nothing public frames anything: the About page's "no third-party anything at runtime" is still true, and there is a test that keeps it true.
v48

an about page, at /about/.

  • Why the site exists, what a run actually does, what it refuses to do, where the numbers come from, how often it rebuilds, and a "for nerds" section. Prose lives in about.json; it is Rich's to edit without touching code.
  • Every number in the nerd section is read off the build as it is written: the rebuild cadence off the systemd timer unit, the cache window off the nginx capture, the horizon off the run, the generator's size off the source tree, the assertion count off the suite. A number that cannot be read produces no row rather than the word "unknown".
  • The suite's assertion count is stated as "N+" because check() calls in a loop are counted once. The count is a floor by construction, and there is a test for that.
  • About joins the nav on every page. The build writes it last, after the pages whose sizes it reports, and a failure there cannot take the forecast down: the about page explains the site, it is not part of it.
v47

the transom, explained.

  • The boat caption was repeating the masthead — hull and home port, said twice on one screen. It now answers the question the photo actually raises: the transom reads OAK HARBOR because that is the old home port, and she becomes TANGLEFOOT of Seattle while the slip stays in Everett.
  • Both captions break on a newline through one helper instead of the flyer having the behavior and the boat a plain string.
  • The alt text names the lettering, so a screen reader gets what a sighted reader gets from the photo.
v46

OTIUM on the landing page.

  • A photo of the boat under the two cards, spanning the card column. It is a grid item in .homecards, so it spans whatever number of columns the cards happen to form at that width.
  • 720x540, 84 KB, lazy-loaded, with its size declared so the page does not jump. The caption and alt text live in home.json.
  • The camera metadata is stripped. The original carried a GPS tag, which on a photo of a boat in her slip is a home address; the suite now fails if an APP1 block ever rides along with it.
v45

short stats windows, and two lines under the flyer.

  • Stats now go down to the last hour and the last 4 hours, alongside today, 7, 30 and 90 days. Every window filters on the timestamp instead of the date column, which could not express "the last hour" at all: at 00:10 it meant "today", ten minutes of data labeled as a day.
  • Windows of 48 hours or less chart by hour, longer ones by day, so a 4-hour view shows four bars rather than one.
  • The headline count and the table under it disagreed: views require a page request, people did too, so a window could report 0 people while listing one. People now counts anyone who was here, page view or not.
  • An index on visit.ts_utc, since every query now filters on it.
  • The flyer credit breaks after "in Seattle." — whose flyer it is on one line, why the boat is being renamed on the next.
v44

a night mode for the public pages, and a menu bar that behaves like one.

  • The nav is sticky and see-through now: the page slides under it instead of being pushed down by it, with a blur so the labels stay legible over whatever is passing beneath. Two @supports fallbacks, ending in a solid background, for browsers without backdrop-filter or color-mix.
  • Still right-aligned, with the gap between items widened from 16px to 28px and the theme chip set further out again. That spacing is most of what makes it read as a menu bar rather than a run-on line.
  • Night: red on black, the same idea as the portal's, now on every public page. The rating colors do NOT stay green and amber — that would defeat the point — they become four steps of the same red, differing in lightness, so a worse day still looks worse and nothing on screen is green. The map, its labels, and Jess's flyer all follow; the photograph is filtered rather than left to blaze white.
  • The toggle is four states now: Auto, Light, Dark, Night. Night sits last because it is reached for deliberately rather than cycled past, and the chip turns red when it is on.
  • One test hardcoded "3" for the number of themes defining a token, which adding a fourth broke for no good reason. It counts the themes now.
v432026-09-15

a way back to the public site, and a bar that stopped repeating itself.

  • The OTIUM wordmark in the header is a link to / now, and the landing page carries an explicit card for it. A logo that happens to be a link is a convention rather than a signpost; both is better than either.
  • The bottom bar was six destinations at 9px, five of which the landing page already offered. It is three now, with room to hit them: Home, Log entry, Emergency. Everything else goes through the landing page.
  • Emergency stays on the bar deliberately. In the ten minutes it matters, Back then Home then Emergency is two taps too many, and the landing page is not somewhere to be navigating at that moment.
  • Service worker cache v3, so a phone holding the old shell takes the new one rather than serving what it has.
v422026-09-15

who visited, by address, with people and machines kept apart.

  • Addresses are stored now. That reverses the earlier default, at Rich's request and on his own server: nginx already holds the same values for 14 days, the portal is behind two locks, and a webmaster looking at his own access log is not surveillance. The salted hash stays alongside, so counts survive when the addresses are dropped.
  • `tanglefoot-admin purge-ips <days>` forgets the addresses and leaves every count, page total and unique-visitor figure exactly as it was.
  • Two tables, switchable: PEOPLE and BOTS & SCANNERS. Columns are address, reverse-DNS name (flagged when it resolves to a datacenter), hits, pages, days seen, device and browser, the page they landed on, and last seen.
  • Tap any address for everything it asked for: time, path, status, bytes, referrer, with images grayed so pages stand out.
  • Also added: device split, status codes, bytes served, and the bot list by user-agent with how many addresses wore each one.
  • Shipped broken and caught on the box: an index over one of the new columns was declared in the schema, which runs before the ALTERs that add them, so an EXISTING database died with "no such column: ip". Every test built its database from scratch, where the column is there from the start, so none of them could see it. Indexes over late columns are created after the migration now, and there is a test that migrates a database shaped like the old one.
  • A privacy test was passing for the wrong reason. It read the database file to prove an address was not in it, but SQLite in WAL mode keeps recent writes in a side file, so it was checking a file the data had never reached. It now checkpoints and reads the -wal too — which is how the new assertion caught it immediately.
v412026-09-15

a landing page in the portal, and the deep link actually works.

  • Signing in now lands on a page with a way into each section — Log entry, Checklists, Emergency, Visits, Trips & settings — above the trip status and the recent entries. Cards rather than only the tab bar, because six glyphs say nothing about what is behind them and there will be more sections here than a strip can hold.
  • /admin/#stats was broken, in two separate ways. Boot read the hash and showed the right view, then the bootstrap fetch finished and called a flat show('now') that threw the choice away — Visits appeared for an instant and snapped back. And changing the hash while the app was already open does not reload the page at all, so nothing listened and nothing moved. Both fixed; a bookmark works whether the app was open or closed.
  • The service worker cache is v2, so a phone that has the old shell picks up the new one instead of serving what it already had.
v402026-09-15

the visit count is live.

  • /api/stats reads the access log on every request, so the number on screen is true as of the moment it was asked rather than as of whenever a timer last fired. The Visits page refreshes itself every 15 seconds while it is open, and stops the moment it is not — polling a page nobody is looking at, on a phone, on a boat, is rude to the battery.
  • That is only affordable because reading became incremental. Each log file now has a remembered inode, size and offset, so a refresh seeks to where it stopped and reads the bytes that arrived since: 0.42s for the first pass over four days, and unmeasurable for a refresh with nothing new. A changed inode or a shrunk file means logrotate has been at it, and the file is read from the top again.
  • The offsets were not actually being written on the first attempt. Python refuses tell() on a text file that is being iterated, and that OSError was swallowed by the same handler that skips an unreadable file — so every "incremental" read silently started from the top while all the visible behavior still looked right. Found by reading the stored offsets instead of trusting the timings. Logs are read as bytes now and the position is counted as they go by, which needs no tell() at all.
  • A log that cannot be read says so on the page instead of quietly serving the last good count as though it were current.
  • tanglefoot-logd gains read on /var/log/nginx through the adm group, and nothing else. The 20-minute timer stays as the backstop that keeps history accumulating when nobody has the page open.
  • Tabs are bookmarkable now: /admin/#stats opens straight to Visits.
v392026-09-15

a visitor counter, from the web server's own logs.

  • No JavaScript, no third-party analytics, no cookie banner, nothing in the page for an ad blocker to block. nginx has written down every request since the day it was configured, so this counts what already happened rather than starting a tally today.
  • The hard part is not counting, it is knowing what not to count. On this site's first four days, 1,561 of ~1,880 requests were scanners. A raw count said "330 people" for a site nobody has been shown. The real answer is 2.
  • Four filters, each added because the previous one was not enough: named crawlers by user-agent; anyone who probed for /wp-admin or similar, for that whole day, even the pages they did request; a page AND at least two of its images, because scanners fetching "/" plus its one photo were indistinguishable otherwise; and whole /24s where several addresses each poke the site a few times, which is one actor rather than a street of readers. What survives all four is checked by reverse DNS, and dropped if it answers from a datacenter — AWS and Google Cloud addresses wearing an iPhone user-agent were the last thing still counting as people.
  • Addresses are never stored. Visitors are a salted hash and the salt rotates monthly, so a hash counts someone within a window and is not a permanent name for a person. nginx keeps the real addresses for 14 days regardless; this neither adds to that nor extends it.
  • Ingest is idempotent on a hash of the log line, so the timer can run as often as it likes, and history outlives logrotate's 14 days.
  • tanglefoot-stats.timer, every 20 minutes, running as the tanglefoot user with the adm group: read on /var/log/nginx, write on its own database, nothing else.
  • Shown under Visits in the portal, which is private. The numbers are labeled as an estimate, because they are.
v382026-09-14

places on the map.

  • 26 named locations across the six areas, coordinates taken from NOAA CO-OPS station positions rather than estimated. Each entry records the station id and NOAA's own name for it, so any position can be checked. WDFW's own points layer was tried first and rejected: it is water ACCESS sites, overwhelmingly freshwater lakes, and "Lake Goss" is not a marine landmark.
  • The CHOICE of what is worth naming is config, in landmarks.json, and marked as hand-curated. Coordinates should come from data; which places matter to someone leaving Everett should not come from me.
  • Everett is drawn as the home berth, in the accent color, larger.
  • The renderer decides what survives. There are more places worth naming than there is room to name them, so labels are placed greedily with collision avoidance, eight candidate positions each, and anything that will not fit is dropped rather than drawn over something else. 21 of 26 land on the real map. The area numbers are reserved first and always win.
  • A third, caught by the install gate doing its job: one of the new tests opened map.json, which is a BUILD ARTIFACT fetched at build time and absent from a fresh install — exactly the state tfu tests in. The suite passed locally and failed on the box, nothing was installed, and the live site was untouched. The check now skips cleanly when there is no map.
  • Two bugs caught by looking rather than assuming. The placement loop assigned to x0 — which is the projection's own origin, closed over by X() — so the first label corrupted the projection and every one after it landed about 107,000 px off the left edge. And with only left/right tried, Everett collided with the "8-2" number both ways and was dropped: the single label that must never lose a collision was the first one thrown away.
v372026-09-14

the map has somewhere to be.

Rich: "not thrilled with how the map looks - could we superimpose an image behind it for more context?"

  • No raster. A tile basemap means someone else's terms of service, an external request on every page load, attribution, and an image that cannot follow the light/dark theme. The context is drawn instead, from data already being fetched.
  • The layer holds 16 marine areas and the site covers 6. The other 10 are now drawn underneath, muted and unlinked: the Strait of Juan de Fuca coming in from the west is what makes the whole thing read as Puget Sound rather than six green shapes in a void.
  • Cheap, because it is culled and coarsened. Areas whose geometry falls entirely outside the frame are dropped rather than drawn and clipped — the outer-coast areas run to the Pacific. The rest are simplified at 0.004 against the covered areas' 0.0015. 7 shapes for about 4 KB.
  • The DNR shoreline layer was tried first and rejected: 34,000 points, most of it inland creek traces, and as unfilled lines it never reads as land. 23 KB for a texture nobody would notice.
  • Context has its own theme tokens rather than borrowing --surface-2, which sat so close to the map's background that it was invisible. Tuned against the real page in both themes.
  • Neighbors carry their name on hover and say plainly that they are not covered here, so the map cannot imply a forecast that does not exist.
v362026-09-14

dates read the American way, and the tagline names the renaming.

  • Every reader-visible date is month-first with a comma before the year: "Sep 14, 2026", "Sep 16-19, 2026", "Sep 28 - Oct 3, 2026". Nine strftime formats, the trip date ranges, the built stamps, the harvest "checked" and edition lines, and the authored Port Townsend page.
  • Parsers were deliberately NOT changed. The WDFW feed is RFC 822 and will always send day-first; that date is now reformatted rather than sliced out of the string and printed as it arrived. Hand-written config still parses whichever order it was typed in, so an old harvest file keeps working.
  • The guard is on RENDERED pages, not on the format strings, because this fault can enter from a strftime, from hand-written config, or straight out of a feed — and only the finished page sees all three. Two test fixtures were carrying day-first timestamps the code no longer produces.
  • Tagline now says OTIUM is soon to be renamed TANGLEFOOT.
  • Tidied while in there: render.py imported re inline with __import__.
v352026-09-14

American English, a readable map, and a theme you can choose.

  • The flyer credit now reads the way Rich wrote it, naming Jess's DJ nights at the Bluemoon and the renaming to come.
  • 22 British spellings fixed. The old check tested seven words against ONE page and so never saw the admin portal at all — where "maneuvering", "fiberglass", "vapor gasoline", "judgment" and the rest were sitting in copy meant to be read during an emergency. The check now walks every file that carries reader-visible text, whole words only, 50 terms.
  • The map is legible. Labels were placed at the average of a ring's vertices, which is only the middle of a shape that is roughly a blob: Hood Canal is a hook and Saratoga Passage is a slot, so their numbers landed in slivers or outside the water. Labels now sit at the pole of inaccessibility — the interior point furthest from any edge — computed by coarse grid then local refinement. Bigger type, a heavier halo, a smaller variant where the water is tight, a pointer cursor, and the area's real name on hover.
  • A theme control on every public page: Auto, Light, Dark. Both stylesheets already answered prefers-color-scheme and already honored [data-theme]; nothing had ever set it. Applied BEFORE the body renders, because a toggle wired up at the end of the document repaints after first paint, which is a white flash in the face at anchor at night. Three states, so Auto stays reachable. Served hidden, so a reader without JavaScript is not shown a dead control.
  • Three test invariants had to be restated rather than deleted: the pages are no longer script-free, but what mattered was that they are COMPLETE without script, and that is now what is asserted. The storage-guard check counted uses against try blocks, which proves nothing when one try wraps two accesses; it checks the structure now.
v342026-09-14

a photo could outlive everything it belonged to.

Rich deleted his test trips and they went cleanly — except one photo, which survived with its file. It had entry_id NULL (taken from the Now screen before any entry existed) and trip_id NULL, because trip_id was never written for a photo at all: the column existed and nothing ever filled it. A row with both keys NULL belongs to nothing, is reached by no cascade, and cannot be seen in the app. It could only accumulate.

  • photo now carries the CLIENT's entry_uid and trip_uid as text beside the integer keys. Those are known the moment the shutter closes, offline, before anything has synced.
  • A photo can legitimately arrive before the entry it belongs to — both are queued on the phone and the upload can win, or the entry's sync can fail while the photo's succeeds. Resolving to an integer once at upload time and never revisiting it left the link NULL forever. An entry or trip arriving late now adopts the photos waiting on it.
  • Deleting sweeps photos by uid rather than by join, so one whose entry never synced is removed rather than orphaned.
  • tanglefoot-admin photos [--prune] lists photos and removes any attached to nothing, unlinking files only when no other row shares the content.
v332026-09-14

the four open items, closed.

  • build_all --check can exit 0. The unknown-rating guard ran before the check-mode return, and check-mode summaries carry no rating because no day has been scored yet, so it reported a code fault for every healthy area. One condition.
  • The map works. fetchmap was querying layer 2, "Marine Area Boundaries" — polylines, the boundary ARCS, carrying a WAC code and no area identity. Nothing could ever match and it refused every run, correctly, for as long as the file has existed. Layer 3, "Recreational Fishing Areas", is polygons with MACN/maNumber holding this site's exact slugs. Then a second bug behind it: area_key scanned every property, so Ports Susan and Gardner (OBJECTID 7) matched marine area 7, collided with the San Juans, and 8-2 — the home water — vanished from the map reported only as "no geometry matched". Identity fields are checked first now and id columns are never matched. All six areas render.
  • _example no longer renders as a real Taken card with invented actuals linking to a directory that does not exist. Underscore-prefixed directories are scaffolds, and the DIRECTORY is the slug: letting trip.json name its own meant a card could link to nothing, which try_files then answered with the landing page so it looked fine.
  • Seven pages no longer share one title. Each conditions page is named for its water, taken from the eyebrow it already had: "Possession Sound · Marine Area 8-2". cfg["title"] is untouched, so the masthead and the publish marker both still work.
  • Map strokes heavier and drawn against the page ground: on a day when every area rates the same, fill alone made six areas read as one blob.
v322026-09-14

a username on the door, and a way to find it.

  • /admin/ and /api/ now sit behind nginx auth_basic. A scanner that finds the path gets a 401 before any application code runs. The user file is /etc/nginx/.htpasswd-tanglefoot, outside the web root, and it ships containing only comments — so until a user is added the gate refuses everyone. It fails closed rather than open.
  • Two layers on purpose. Basic auth adds a username and stops traffic at the edge, but it resends the password on every request, has no session, no throttle, no CSRF and cannot be revoked without editing a file. The app's own login keeps all of that behind it. Neither is redundant.
  • An "Admin" link is on every public page now, set apart from the three public sections by a rule, in the faint ink, with rel=nofollow. It is not a fourth section and should not read as one.
  • The login page says plainly that the second password is the one that makes a session, so two prompts do not read as a mistake.
v312026-09-14

trips, a clock, and a way to delete things.

Rich used it and reported back: navigation clunky, no way to delete a test log, starting and stopping a trip too buried, wants time tracked, and wants "trip" as a type of activity.

  • A REAL BUG found in his data first: ending a voyage failed silently. The upsert's INSERT arm still had to satisfy NOT NULL even when the row existed, so a partial update — status and ended_* with no title — came back "NOT NULL constraint failed: voyage.title" and the trip stayed open. It was sitting in sync_reject, which is the only reason it was recoverable at all. Fixed by defaulting the INSERT arm and binding the ORIGINAL values to the UPDATE arm. The first attempt at that fix looked right and quietly renamed the trip to "Untitled trip" and reset its activity, because COALESCE(excluded.col, col) reads the default the insert proposed rather than what the client sent. Caught only by asserting the values instead of the absence of an error.
  • voyage -> trip everywhere, matching the public site's own vocabulary, with a migration that renames the table and its foreign keys in place. Existing rows keep their data and their links.
  • Trips now carry an activity: trip, day cruise, fishing, maintenance, sea trial, delivery, guests aboard, other.
  • Two clocks, because they are not the same number: ELAPSED is wall time since the start, UNDERWAY is time actually moving, accumulated across a one-tap start/stop. A night at anchor is twelve hours elapsed and none underway, and averaging speed or fuel over elapsed time gives nonsense.
  • Start, stop, edit and end are all on the front screen now instead of three taps into More.
  • Delete, at last: entries, trips, photos, checklist runs, fuel and maintenance. It is a queued op like everything else so it works with no signal, it is idempotent so a retry is harmless, and it removes the photo files that no ON DELETE CASCADE reaches — unless another row still points at the same content-addressed bytes.
  • Tap any entry to read it, edit its remarks, or delete it. A trips list under More shows everything the server holds so a test run made while learning the app can actually be got rid of, from the boat.
  • Every sub-screen has a back button now.
  • logd.py is importable without side effects. It used to create /var/lib/tanglefoot at import, so the round-trip test that would have caught the end-trip bug was being skipped rather than run.
v302026-09-14

tanglefoot-admin could not run.

Installed into /usr/local/bin as a copy, admin.py resolved its own directory with abspath(__file__) and so looked for auth.py and store.py in /usr/local/bin, where they are not. `sudo tanglefoot-admin passwd` — the one command needed to open the portal at all — died with ModuleNotFoundError the first time it was run on the box.

  • realpath(__file__) now, so a symlink resolves to the real directory, with a fallback to /opt/conditions/admin for a copy. Installed as a symlink.
  • Same fix applied to logd.py, which was not affected today only because systemd runs it by its real path.
  • The suite asserts both, so it cannot come back.
v292026-09-14

a private admin portal and a boat log you can keep underway.

New: /admin/, behind a password, nothing published. The public site is untouched.

  • OFFLINE FIRST, because that is the whole problem. Admiralty Inlet and half of Area 7 have no signal, and that is exactly where the log gets written. Entries go to IndexedDB and are acknowledged instantly; a service worker caches the shell so the portal opens and works with no bars at all; a queue flushes when signal returns. Verified by killing the server, logging an entry, reloading the whole app from cache with all eight checklists intact, then restarting the server and watching the queue drain on its own.
  • Sync is idempotent on a client-generated uid, so a phone that uploads and loses signal before it sees the acknowledgment does not create a second entry when it retries.
  • Nothing is ever silently dropped. A payload that cannot be applied lands in sync_reject with its reason. Losing an entry made in a gale is not an acceptable failure mode.
  • Auth: scrypt where the build has it, pbkdf2 where it does not, recorded per-hash. Sessions and trusted-device tokens are stored as SHA-256 of themselves, so a stolen database yields nothing replayable. CSRF double submit, exponential login throttle per IP. The password is set with `tanglefoot-admin passwd` on the box and is not settable any other way.
  • Photos are resized in the browser to ~2000 px before they are queued, and streamed to disk in 64 KB chunks on arrival, content-addressed by SHA-256. The box has ~150 MB free; holding a photo in memory is not available.
  • 97 checklist items across 8 lists, 69 of them carrying the HOW and 19 the thing to watch for. Six emergency procedures ordered by what you do FIRST, plus a Mayday script. Boat-specific numbers are deliberately left blank: a fabricated oil capacity in a checklist is worse than an empty field because it looks authoritative.
  • Night mode, red on black, because white light at the helm costs about twenty minutes of dark adaptation.
  • systemd unit is sandboxed, loopback-only, and capped at 120 MB so a runaway log daemon cannot take the site generator down with it.
v282026-09-14

Jess's Blue Moon Tuesday flyer, and a mark of its own.

The site had no artwork and no favicon. The flyer is where the boat's name comes from, so it belongs on the front page rather than in a folder.

  • The source was a phone photo of the framed print: glare across the right of the drawing and the lower-left corner, about a degree of rotation, and the frame and wall in shot. Cleaned by measuring rather than eyeballing — paper edges found by color warmth, rotation solved to 1px across 450, then flat-field correction (ink erased by repeated max-filtering, the remainder blurred to a glare field, the photo divided by it). That removes the blue cast and the bright blobs together, because both are illumination rather than ink. Black and white points from the histogram.
  • /assets/tanglefoot.jpg, 584x804, 101 KB. JPEG rather than PNG: the paper texture is photographic and q82 shows no ringing on the charcoal, at a sixth of the palettised size.
  • The favicon is the T from the flyer's own hand-lettering, on the flyer's paper, rounded. The full drawing was tried first and is unreadable below about 48px — the tangle grays out. The T holds at 16.
  • build_all copies conditions/assets/ into the docroot every run, favicon.ico to the root where browsers ask for it unprompted. The docroot stays entirely generator-owned: still nothing under it that a human has to remember to put there.
  • og:image, so a pasted link shows the flyer instead of a bare URL.
  • macOS tar wrote an AppleDouble "._favicon.ico" beside any file carrying an extended attribute, and it rode the build into the docroot as a served URL. COPYFILE_DISABLE=1 and --exclude='._*' on the tarball, and publish_assets skips dotfiles, so neither end can carry that litter again.
  • Caught in review at narrow widths: .homecards could demand a 300px track on a smaller phone, and the flyer's max-width exactly equalled its grid track. Both now use min()/minmax(0,..), verified no overflow from 1040px down to 360px.
v272026-09-14

/ is a landing page; the area index moved to /conditions/.

The site had no front door and no way between its parts. The six area pages carried no internal links whatsoever, and the index at / never linked to /trips/ — both sections were reachable only by typing a URL.

  • / is new: masthead, a card each for Conditions and Trips, sources and the disclaimer. Each card carries a live line off the summaries this run already produced, so the front page cannot disagree with the pages it links to. Home water is found by slug (home.json: home_area), not by sort order. An absent or failed area, and unreadable trip counts, each say so rather than showing an invented verdict.
  • The six-area index moved to /conditions/ unchanged: site.json, two fields. The file that has been answering /conditions/ until now was a stale leftover from an earlier experiment and is overwritten by the real one.
  • render.nav_html(where) puts the same nav on all four page types. On an area page Conditions stays a live link, because getting back up to the index is the point. No script; aria-current on the current item.
  • build_all.py writes the trips index too, via build_trips.build(). Nothing called build_trips.py before — not the timer, not tfu, not build_all — so a new trip did not appear until someone ran it by hand. One run now produces every generated page.
  • --docroot builds the whole site into a local directory instead of the server paths, so a build can be read as a diff before it is published. paths.relocate() is a no-op without it.
  • Two clock-dependent failures in the suite, both latent for releases: the station-leak check matched the FETCH note "off the Everett waterfront" and went red 15:00-16:59 Pacific, which is when the 15:00 timer fires; the rating check compared against a hand-copied set that predated the "rough" band and went red 22:30-23:30. tfu gates on this suite, so the gate was shut for four hours a day. Verified green at all 24 hourly positions.
  • First coverage of build_trips.render() at all, which is how an `import render` that shadowed this module's own render() passed a green suite and failed only when the script ran.
  • Docs corrected against systemctl cat, the journal and the nginx config: conditions.service does not exist, ExecStart was tanglefoot-update, the timer is every 3 hours rather than 06:00 daily, and curl -sI cannot verify anything on a site served with try_files ... /index.html.
  • tanglefoot-update died on "line 32: HOME: unbound variable" under systemd, which sets no HOME, because `set -u` met a bare $HOME. Every timer fire failed there — before the tests, before anything — from the moment this script self-updated onto the box. Now ${HOME:-}, skipping an empty entry. The timer no longer runs tfu at all, so this is belt and braces, but a deploy script that cannot run under the thing that runs it is a landmine either way.
  • The trips index declared no canonical URL, which the deploy check caught: with try_files answering every missing path 200 with the landing page, a page without one cannot be distinguished from the fallback. It has one now and the suite asserts it.
  • Open, deliberately not fixed here: build_all --check cannot exit 0; the WDFW map layer no longer carries an area identifier; _example renders as a real trip linking nowhere; seven pages share one <title>.
v26

the test suite stopped judging data it does not own.

Two releases in a row were blocked by the operator's own area configs. The suite gates the install AND runs against a staged copy that carries every config on the box — so a config this build never shipped, and cannot repair, could veto the upgrade. v25 fixed one symptom (bands); a config with an unfamiliar NAME then failed four more checks. Fixing symptoms one at a time was the wrong move: the gate was asking the wrong question.

  • The suite now gates only the slugs this build ships. Anything else on the box is reported as WARN and cannot fail the run. A summary line names them so they are visible rather than silently tolerated.
  • Deployed configs are validated by build_all instead, at build time, per area: a config missing required keys, or whose href/output disagrees with its filename, takes out ITS OWN page with a stated reason and leaves the other five alone. The run still exits non-zero.
  • backfill_dir(dir) repairs every *.json in the directory rather than walking a list of known slugs, so a stray name gets the universal defaults too. Verified by planting a config under a name this code has never heard of.
  • Caught while writing that: the per-config loop assigned its gate to the name `check`, which would have rebound the global and turned every later check in the file into a warning — a far worse bug than the one it fixed.
  • And: the check reporting the bands failure CRASHED on it (c["bands"] on a config with no bands), so a genuine failure printed a traceback instead of a failure list, and tfu shows that tail to whoever ran it. A check that dies on the condition it exists to report is worse than no check.
v25

the safety gate was blocking the build that fixes what it was failing on.

v24 refused to install on the live box: 2 of 478 checks failed, both of the form "every shipped area defines bands" / "carries the zone's plain-English name". The gate was right that something was wrong, and wrong about whose problem it was — five of the six area configs on that box were written before v17 added bands, and v24 is the build that repairs them. A release gate that refuses the repair is a trap, not a safeguard.

  • tfu now runs the config backfill against the STAGED install before running the tests, so an old deployment is brought up to shape and then judged, rather than judged and rejected on its age.
  • backfill covers bands, wind_limit_kt, seas_limit_ft, timezone and min_bytes, via a table of values that are the same for every area.
  • zone_name cannot be backfilled offline, so it is no longer required of a deployed config. fetch_zone carries the zone's own name back from the product (it is printed on the line under the code block) and generate.py fills a missing one in at build time. The ZONE itself is still required — a wrong zone is the San Juans being served Puget Sound's forecast.
  • Three bugs found while doing it, all by running the thing rather than reading it: * `sudo tfu` searched /root, because sudo resets HOME. The build was in /home/ubuntu where it had just been copied. It now searches the invoking user's home first and says where it looked when it finds nothing. * The search used `ls -1t a b c | sed 1p`, and ls exits non-zero when ANY argument is missing — which two of three usually are. Under pipefail that wiped a correct answer, so tfu reported "no build found" while looking straight at one. Now plain bash, no pipe. That is the third pipefail-family bug in this one script. * tfu's self-update overwrote my own working copy when I ran it from a source directory. Only an installed copy updates itself now. * fetch_zone's new zone name was assigned to `name`, which the period parser below reuses and resets to None. Renamed.
v24

a signed deploy endpoint, so a build can reach the box over HTTPS.

Tested first, built second: this container cannot reach the box on 22 (no ssh client, and the egress proxy is HTTPS-only), the sandbox behind the Mac has ssh but no DNS and none of Rich's keys, and there is no GitHub write credential here. Port 443 DOES reach the box. So the endpoint goes there, and SSH stays locked to one IP.

  • deploy/ holds a receiver (stdlib only), a systemd unit, a sudoers rule, an nginx block, a client, and a README that opens by saying plainly that this is an authenticated RCE endpoint on a public web server.
  • HMAC-SHA256 over timestamp, nonce and the SHA-256 of the body, compared with compare_digest. Signing the body hash and not just the envelope is what stops a captured header set from installing a different archive. Requests expire in 300s and every nonce is single use, so a captured request is dead. The key is never in a URL, a header, or a log line.
  • Nothing from the request reaches a command line. The body is written to one fixed path and tfu runs with a fixed argv, no shell. The sudoers rule pins the command AND that argument, so the grant is one exact invocation.
  • Loopback only, behind nginx: POST-only, 8 MB cap. Body size and clock are both checked BEFORE the body is read, so an unsigned caller cannot make the process hold 8 MB. Five failures from a source and it stops answering.
  • Verified by attacking it, not by reading it: unsigned, wrong key, tampered body with a valid signature, stale and future timestamps, short nonce, wrong path and oversized body were all refused; a valid deploy was accepted, the identical request replayed was refused as a spent nonce, a fresh nonce worked, /health disclosed nothing, the staged file was 0600 and byte-identical, and the key appeared in no log.
  • TFD_REQUIRE_APPROVAL=1 downgrades it from "deploy" to "stage a candidate" that a human installs. One environment variable, and a much smaller thing to defend. Documented next to the risk it mitigates.
  • What this does NOT protect against, stated in the README rather than buried: tfu runs the tests that are inside the archive, so its gate stops a bad build, not a hostile one. The key is the whole boundary.
v23

tfu is actually the only command again.

The old tanglefoot-update pulled from a git remote. Builds stopped arriving that way weeks ago — I cannot push to the repo — so it had been fetching an unchanged clone and reporting success ever since, while every real update went in by hand. It was the stale-updater problem in its purest form: a command that works, runs clean, and does nothing.

  • tfu now installs from a tarball, which is how builds actually land: tfu newest conditions-v*.tar.gz in ~ (by mtime, because v9 sorts after v10 as a string) tfu <file> that one tfu --rollback previous install back, and rebuild
  • It stages the whole install first and runs THE NEW BUILD'S OWN test suite against it. Nothing touches the live directory until that suite passes, so a build that fails its tests cannot reach the site. Verified by planting v22's exact bug in a fake v99 build: 3 checks failed, nothing installed, the live version stayed v22.
  • It carries the box's own files across. The tarball ships one area config; the box has six, plus map.json and any hand-edited harvest file. A straight replace would delete five areas and call it an upgrade.
  • It refuses a truncated transfer, an archive that is not the generator, and an empty source directory, each with the specific reason. A transfer still in flight looks identical to a finished one from the shell.
  • It keeps ITSELF current from the tarball, by atomic rename rather than writing in place — bash reads a script as it runs. Without this the updater drifts from the code it installs, which is how it got stale.
  • A failed BUILD does not roll code back. A build fails mostly because the world failed — NOAA down, MET rate-limited — and rolling back over an outage is the wrong response. It reports and names the rollback command.
  • Bug found while testing it: `tar tzf x | grep -q` under `set -o pipefail` reports failure on a perfectly good archive, because grep exits at the first match and tar dies on SIGPIPE. The first run rejected a valid build.
v22

the index said "No forecast" over a page that had one.

Nothing failed. 8-2 was rated ROUGH today, and the landing page had no label for "rough": v17 added the fourth band and the index's label table was never updated. Its lookup carried a default, so an unhandled state became a confident claim about the weather instead of an error.

  • The label table now covers every rating, and there is NO default. An unknown or missing rating renders as "Status unknown" and says outright that the fault is in the index and the area page has the forecast. "No forecast" is now reachable only from the one rating that means it.
  • scoring.RATINGS is the single list of what rate_day can return, and the suite derives the real set by CALLING rate_day across a grid of inputs rather than reading a list, then asserts the index can label every one. A fifth band fails the build instead of shipping as "No forecast".
  • build_all now refuses to accept two things it used to pass through: a summary that reports success but carries a rating the index cannot label, and a run that reports success without the page's mtime actually moving. Both log loudly and exit non-zero so systemd records a failure. A stale page under a fresh index is the worst combination, because the index vouches for it.
  • "1 good days in 14" on any area with exactly one.
v21

sections open by default, and stay closed if you close them.

  • Every collapsible section is now SERVED open, on every area, every visit. The seasons detail was open already; the creel table was not.
  • Closing one is remembered, and survives reloads and moves with you from one area page to the next. Reopening it forgets that immediately.
  • This costs the page its zero-script property, which it has had since v1, so the script is built to be ignorable: it CANNOT open or close anything the markup did not already decide, it builds no content, and its only action is to re-close a section this reader closed before. Everything else on the page renders without it. Verified in a real browser, not asserted: JS disabled entirely ............ 2/2 sections open storage throwing (site data blocked, private windows) ..... 2/2 sections open, no page errors first visit ..................... both open close one, reload ............... that one closed, the other open close one, different area ....... stays closed there too reopen it, reload ............... open again, storage cleared The failure mode of every one of those paths is "everything is open", which is the default this asked for in the first place.
  • The preference is keyed by SECTION, not by area: closing the creel table closes it on all six area pages rather than making you close it six times. Change the data-keep values to include the area if you want them tracked separately.
v20

every area has a season table, and three link/render bugs are out.

  • Marine Areas 7, 8-1, 10 and 12 now have real season tables, drafted from WDFW's sport fishing rules (digital edition of 18 Jun 2026) and the per-area crab pages, both fetched and quoted rather than recalled. Area 9's existing draft was rebuilt against the same edition: it had said the salmon species restrictions were "set separately" when the rules list them, so it was vaguer than the source it cited. They ship as DRAFTS - red banner, cited source, "do not rely on it" - because nobody has checked them against the live regulations. 8-2 remains the only hand-verified table, and a test fails the build if a second table ever claims to be verified.
  • The tables are not copies of each other, and a test enforces that. The differences are the point: Hood Canal closes halibut, lingcod AND cabezon year-round while every other area has a season for them, and allows four salmon instead of two. Area 7's crab season is still open when every other area's has ended. Area 10 ran crab two days a week where the rest of the Sound ran five. Area 8-1's salmon season runs 16 days longer than 8-2's, which matters because people treat "Area 8" as one place.
  • The "Crab seasons" link was a 404 on four of six areas. It was built from the marine-area slug, and WDFW's crab pages use entirely different ones ("port-susaneverett-area-8-2", not "ports-susan-gardner"). Every slug is now its own config key, confirmed by fetching the page. An area without one links the crab index rather than a specific page that is wrong.
  • The seasons table header said "Verified <date>" directly above a red banner saying the table was NOT verified, and the standing footer claimed every table "is verified by hand rather than scraped". Drafts now say "Drafted <date>" and describe themselves accurately.
  • Season cells were white-space:nowrap inside a fixed-layout table, so any season string longer than 8-2's ran out over the next column. It only looked fine because every existing string was short. Checked all four new tables for overflowing cells in a real browser; none.
  • harvest-9.json wrote its footer as a string where harvest.json writes a list, and the renderer iterated it - one <p> per character. The renderer now accepts both shapes.
v19

fishing reports by dock are back, on every area, from WDFW's own counts.

  • "What people are catching" is a new collapsible section on every marine area page: ramp by ramp, how many checks WDFW's samplers made, how many anglers they interviewed, what came back in the boats, and fish per angler. These are fish COUNTED at the dock by a state employee, not fish reported by the person who caught them, which makes them the only numbers on this page nobody had a reason to round up.
  • Relevance is per-area and comes from config, not from code. Each area names the "Catch area" labels that belong to its water, taken verbatim from WDFW's own filter list rather than guessed - a wrong label returns a plausible-looking table of somebody else's ramps, which is the failure mode this whole page exists to avoid. 8-2 claims the Tulalip Terminal Area and the Snohomish River mouth as well as "Area 8-2", because WDFW files those under their own names and they are the water people here actually fish. Hood Canal picks up Ayock Point, Hoodsport and Quilcene the same way. An area with no labels configured renders no section at all rather than a Sound-wide dump.
  • The export is read defensively. A renamed column stops the section with a note saying which column went missing, rather than reading the wrong field and publishing confident nonsense. An outage degrades. An area with no sampling in the window says so and gives the date of the last check - no check means nobody counted, not that nobody caught anything, and the page says that in those words.
  • Ramp names lose WDFW's database history: "Everett Ramp (formerly Norton Street Ramp (2010))" is just Everett Ramp to anyone deciding where to launch. The home ramp, when config names one, is tagged in the table.
  • Found while wiring this up: "What's open" and the fishing section were both rendering with id="harvest" - two elements, one id, on every page since v12. The inner one is now id="whatsopen", and a test fails the build if any section id repeats.
v18

American English, and the bands explain themselves.

  • Reader-facing copy converted: colors -> colors, gray -> gray, fortnight -> two weeks, center -> center, Amber -> Yellow in the legend. ("amber" survives as an internal band key in config and scoring; renaming that would be a data migration for a spelling change.) A test now fails the build if a British spelling reappears in reader copy.
  • A band key sits directly under the legend giving the actual numbers: Green up to 12 kt and 2 ft, Yellow to 18/4, Orange to 24/6, Red beyond that or any time NWS has an advisory in effect, Gray past the model's reach. You no longer have to reach the notes to find out what green means.
  • Dates in the verdict were missing their ordinals ("Sat the 19"), and the prose still said "the suggested limit" when there are now four of them. It names the green line specifically.
v17

four bands, anchored to an official threshold.

green comfortable up to 12 kt and 2 ft amber workable past comfortable, nowhere near hazardous orange rough past 18 kt or 4 ft - serious for a small boat red advisory past 24 kt or 6 ft, OR NWS has actually issued one

  • Red is no longer a number someone picked. The NWS zone product we already fetch carries its own hazard headline ("SMALL CRAFT ADVISORY IN EFFECT FROM..."), and when NWS has declared one, days inside its window go red regardless of what the model says. Their declaration beats any threshold in this code. A banner above the verdict quotes the headline. Only tier-1 days inherit it - an advisory covers the near term, not day 12.
  • Three bands would have made amber span 12 to 20 kt: one color from a pleasant afternoon to 18 kt and four-foot chop. Split so the middle has resolution where it matters.
  • sea_state() stopped at a flat "2+ ft" above 12 kt, which made the seas half of an advisory literally unreachable. The curve now continues to 3, 5, 8 and 12 ft so 18 kt and 28 kt are not the same number.
  • Bands live in config per area, so a different boat or a different water is a config edit, not a code change.
v16

the map draws itself, and season tables can exist before they are verified.

  • build_all now runs fetchmap.py automatically when map.json is missing or over 30 days old. Boundaries change about never; asking a human to remember a one-off command is a good way to never have a map. A failed fetch is a missing map, not a failed build.
  • The map gets a land/water backdrop so it reads as a map rather than shapes floating in space. The geometry was always real WDFW polygons - what you saw was my placeholder rectangles.
  • Season tables now have a verified flag. WDFW's per-area pages carry NO season tables (checked - they are prose pointing elsewhere), so the real per-area seasons come from the published rules. A table drafted from those but not yet checked by a human renders with a loud red DRAFT banner, cites its source, and tells the reader not to rely on it. 8-2 is marked verified; Marine Area 9 ships a drafted table.
v15

the limits now say where they come from, and every reference is a link.

  • There is no Coast Guard wind or sea limit for recreational boats; the USCG regulates equipment, documentation and conduct. The page says so, because "the Coast Guard says N knots" is a thing people believe.
  • The one official threshold is the NWS Small Craft Advisory: sustained winds 20-33 kt, seas above a regionally-set height, for conditions "that might be hazardous to small craft" (NOAA's own words, linked). The numbers on this page sit at roughly half that wind and a fraction of that sea, and are now described as a comfort threshold rather than a safety one, with green/amber/red explained against that.
  • Every source referenced is a link: USCG, the SCA definition, the live NWS product for the area's own zone, current warnings, WDFW emergency rules, the NOAA tide and current stations, MET Norway's API docs. No bare URLs and no unlinked claims of authority.
  • The test fixture's 6-hourly series was anchored to "now rounded to the hour" while the four columns are local 05/11/17/23, so the two grids only lined up depending on what time of day the suite ran: it passed at 05:xx and left 46 of 56 columns empty at 08:xx, silently weakening every check downstream. Anchored to the same local grid MET's real data lands on, and there is now a check that the fixture fills at least 90% of its columns - an empty fixture can no longer masquerade as a passing suite.
v14

version and build time on every page.

  • The masthead of every area page and the index now shows "Built <date, time to the second>" and "Version <vNN date>". Until now a page could not tell you which build produced it, which made "did that fix actually deploy?" unanswerable without SSH.
  • build_all prints a banner naming the version at the start of a run and again at the end with the area count, failure count and elapsed time.
v13

fixes what v12 broke: five of six areas showed "Unavailable" with

ValueError: too many values to unpack.

  • Adding links to feed items made them 3-tuples, but harvest_section still unpacked 2 - and it built that markup BEFORE the guard that returns early for an area with no seasons table, so areas that render no seasons section at all still ran the code and died on it. Guard moved to the top of the function; alerts now render their link when they have one. 8-2 survived only because its alerts were date-filtered to zero, and the suite missed it for the same reason: it only ever paired an empty harvest with an empty alert list. Now tested in that exact combination.
  • build_all now distinguishes a source outage from a code crash. An outage is a fact about the world and the index should say so. An exception is a fact about this code, and replacing a working index with one full of "Unavailable" turns a bug into an outage. On a crash the previous index is left in place, the crash is logged, and the run exits non-zero so systemd records a failure. Area pages that built are still published.
v12

a map, and fishing/shellfish on every area.

  • Marine area map on the landing page, which moves to the apex (tanglefoot.dev/). Each area is tinted by today's rating and links to its page. Boundaries come from WDFW's own GIS service, fetched by fetchmap.py - NOT hand-drawn. A marine-area map is a map of regulatory boundaries: someone deciding whether they are in 8-1 or 8-2 is deciding which rules apply to them, and an approximated line is a wrong answer to that question. No boundary data means no map.
  • Every area now has a Fishing & shellfish section: its own WDFW area page and crab-seasons page (slugs verified against WDFW's index, not guessed), the DOH shellfish safety map and beach closures, and WDFW's recent notices for that water with links.
  • Areas without a hand-verified season table say so outright rather than showing an empty heading. No seasons are stated for water nobody has checked; inventing one is the failure that costs a reader a citation.
  • Rule matching is per-area now. One global regex meant Neah Bay notices surfaced on a Possession Sound page.
  • The RSS feed's <link> is captured, so a notice is something you can open rather than a headline you then have to go hunting for.
  • Map height was derived from the data's own aspect, which for Puget Sound produced a page and a half of map. Fitted to a landscape box instead.
v11

the San Juans page was serving Puget Sound's marine forecast.

newarea.py had `zone or "pzz135"` as a fallback, so when the NWS zone lookup failed for an area it silently substituted Possession Sound's product. Area 7's page then presented PZZ135 "Puget Sound and Hood Canal" as authoritative guidance for Rosario Strait - wrong in a way no reader could see, and materially wrong for a crossing.

  • No fallback. newarea.py refuses to write an area whose zone did not resolve, because a config with the wrong zone is worse than no config.
  • generate.py degrades cleanly when an area has no zone rather than crashing: the page says the zone forecast is unavailable and falls back to the model, which is true.
  • The page now names the zone in plain English next to its code - "NWS PZZ135 - Puget Sound and Hood Canal". A borrowed zone becomes obvious to any reader instead of hiding behind a four-letter code.
v10

fixes what v9 broke on first contact: every area died with

KeyError: 'pills'.

  • When harvest.json became optional I invented an "empty harvest" default without checking the shape the renderer reads. It reads pills and table; my default had neither. There is now one EMPTY_HARVEST constant and a test that builds a whole page from it.
  • 8-2 failed too, for a second reason: the harvest path resolved beside the area config (areas/) where the file isn't. It now looks beside the config and then beside the code, and says so in the log if it finds neither.
  • An area with no seasons data renders no seasons section at all. An empty "What's open" heading reads as "nothing is open", which is a claim.
  • "Everett" was hardcoded in the tide panel heading and the sources block. Every travel area would have labeled its tides Everett. Now from config's station_name, which newarea.py fills in from the lookup.
v9

multi-area. Every area is one JSON file in areas/; the code is shared, so a change lands on all of them at once.

  • build_all.py builds every area then a landing page indexing them. The index is assembled from the summaries the builds return, so it cannot drift from the pages it links to. An area that fails is shown as unavailable with its reason rather than dropped - a missing row reads as "that area doesn't exist".
  • newarea.py discovers each area's NWS zone, tide station and current station by lookup, and TESTS each current station before accepting it. PUG1613 is the closest station to Everett and returns malformed data; proximity is not proof a station works.
  • generate.py refactored: run_one() returns (exit code, summary) so a runner can build many areas and assemble an index from real results.
  • scoring.DEFAULT_FETCH removed. It was Possession Sound's coastline and would have been silently applied to every new area - an Admiralty Inlet page would have said the fetch was running "down Port Susan". No table now means neutral weighting and no claim about fetch.
  • theme.html: the landing CSS was appended AFTER </style> and rendered as literal text at the top of the page. Every string assertion passed. There are now structural checks: one style block, nothing after it.
  • Areas shipped ready to create: 9 (Admiralty Inlet), 8-1 (Saratoga), 7 (San Juans), 10 (Central Sound), 12 (Hood Canal).
v8

the WDFW rule banner can now go quiet.

  • It was warning on five items that all PREDATE the last hand-verification of the seasons table, on a live public page. The seen-titles list it relied on was meant to be maintained by hand, so in practice it stayed empty and every matching item stayed "new" forever. An item published before the table was checked is already accounted for, so the feed is now filtered by date against harvest.json's "checked".
  • The relevance regex matched bare "possession", which catches "possession limit" - a phrase in half the titles WDFW publishes. Anchored to "Possession Sound". Marine Area 4 (Neah Bay) no longer trips it. Verified against the exact five items the live box flagged: all five go quiet, and a synthetic 8-1/8-2 winter crab notice dated after the check still fires.
v7

getting ready for a public audience.

  • Open Graph / Twitter card tags: a link pasted into Facebook was rendering as a bare URL with no title or description. The share title carries the marine area so nobody in a Sound-wide group reads one area's page as their own water.
  • The seasons table now tells the truth about its own age. Always shows a "confirm with WDFW" line; once the hand-verified date is 10+ days old it escalates to a red banner saying how stale it is and not to rely on it. Regulations are the one thing here that can cost a reader a citation.
  • "Marine Area 8-2" was hardcoded in the renderer. It now comes from config, so a second area cannot be silently mislabeled with the first one's name.
  • generate.py --config <file>: multi-area support. Each area is one JSON file; harvest.json is optional and resolved beside its config, so travel areas can ship weather and water with no seasons table at all.
  • Page moves to /conditions/8-2/ so the apex can be the boat's own site.
v6

renamed to "Boating Conditions for Overthinkers", and the whole page moved off second person: it now reads as suggested good practice rather than one owner's personal rules. "Peak wind vs. suggested limit", "suggested limit 10" on the chart, "Suggested limits" on the threshold row, and every verdict and tile sentence reworded to match. A new opening note says plainly that the numbers are a starting point, that no authority sets them, and that a green day is worth considering rather than permission.

The marker that guards publishing now defaults to the title, so renaming is a one-field edit - it used to be a separate required field, and changing the title alone would have made every later run refuse to publish. Publish guard also checks the page has exactly 14 day cards. Two more hardcoded thresholds removed from the notes ("10-kt limit", "1-foot threshold") - they now follow config like everything else.

v5

fixed-path temp files removed. test_offline.py wrote /tmp/rendered.html and generate.py --dry-run wrote /tmp/conditions-preview.html; on Ubuntu, fs.protected_regular stops even root from writing a sticky-dir file owned by another user, so a run as one user broke every later run as another. Both now use unique temp names. New check fails the suite if a fixed /tmp path reappears in any source file.

v4

wind-through-the-day trace under the peak-wind bar, drawn at the model's own resolution with the NWS zone wind as its own dashed line so the two sources can be seen to disagree. Inline SVG sky icons (no external requests).

Current times switched to 24h - they were 12h with no AM/PM, so an evening slack read as a morning one. Chart axis rows now align with their gridlines.

v3

currents (NOAA PUG1605 bin 40) - slack/max per day, current at each column time. Currents deliberately NOT fed into day ratings: this water runs under a knot, too little for wind-against-tide. Defensive current parser after PUG1613 returned strings where dicts were documented.

v2

gusts plumbing, per-day limiter, fetch-weighted sea state, wind bar reads config instead of a hardcoded 10 kt. Test suite no longer pinned to the machine it was written on. NOTE: gusts display nothing - MET Norway

publishes no gust data for this point. Needs the NWS gridpoint API.

v1

first generator: tides, zone forecast, model, rule feed, harvest table.

Built from NOAA tide and current predictions, NWS coastal zone forecasts and the MET Norway model. Not an official forecast — there is no Coast Guard wind limit for recreational boats, and the one official threshold is the NWS Small Craft Advisory at 20–33 kt sustained. The limits used here sit at roughly half that: a comfort line, not a safety one. Seasons are verified by hand for Marine Area 8-2 only — confirm everything with WDFW and the DOH shellfish safety map.