Areas 8-1 and 8-2 winter crab opened.
WDFW's Oct 9, 2026 emergency rule opened Marine Areas 8-1 (Deception Pass, Hope Island, Skagit Bay) and 8-2 (Port Susan / Port Gardner) for late-season Dungeness, Oct 10 – Dec 31, seven days a week — five male Dungeness at 6¼″, six red rock and six tanner either sex, winter catch record card required. Both had been held back past the Oct 1 Puget Sound reopening (1.12.4) pending hard-shell sampling; this folds in the opener now that WDFW has confirmed it. Verified against WDFW's own 8-1 and 8-2 crab sub-area pages, both now reading "OPEN … daily from October 10 through December 31." 8-2 is the hand-verified home table; 8-1 stays verified:false like the other outer areas. No generator or detector change — 1.12.4's broad "Puget Sound" rule net is what flagged the opener on Oct 9; the tables had simply not been updated from that alert yet.
all five remaining area tables re-verified against WDFW.
8-2 was re-checked in 1.12.3; this does the other five (7, 9, 10, 12, 8-1) against the same primary sources — the 2026–27 eRegulations salmon and bottomfish tables each file already cites, and the WDFW emergency-rule feed for crab. What changed, and why it mattered: Crab, from the Sept 29, 2026 winter reopening rule: Areas 7 and 9 reopened Oct 1 – Dec 31 (seven days a week, five male Dungeness, six red rock / six tanner, winter card required); Area 12 is split — north of Ayock Point open on those dates, south closed for the rest of 2026; 8-1 is delayed pending hard-shell sampling; Area 10 is undetermined (WDFW is still evaluating 10 and 11) and stays shut. Every table had carried "winter season not announced." Salmon, from eRegulations: Area 10 Sinclair Inlet and West Elliott Bay, and Area 12 Skokomish mouth, each had an Oct 1 period the tables were missing and so were wrongly graying out on Oct 1 — Area 10 runs to Nov 15, Skokomish to Nov 30. Areas 7 and 9 area-wide salmon end Sept 30 with no October opening listed, so they are now closed (Area 9's Edmonds pier stays open year-round; Area 7's bays are explicitly closed Oct 1 – June 30). Each file's open_on summary was corrected, the two new WDFW rules recorded in seen_rule_titles, and checked advanced to Oct 5. These five stay marked verified:false — the flag means a human has eyeballed them, which is Rich's to set; the data now matches WDFW. Clams/oysters stay pointed at the DOH map, with a note that WDFW updated MA9 and MA12 beaches on Sept 30.
Area 8-2 harvest table re-verified against WDFW, and the winter-crab status corrected.
The 8-2 table had been flagged three weeks stale (last checked Sept 13) and its crab row still read "the 2026–27 winter season has not been announced." It has. On Sept 29, 2026 WDFW reopened most Puget Sound areas for winter Dungeness Oct 1 – Dec 31 but held Areas 8-1 and 8-2 closed, delayed until sampling confirms enough legal-size males have gone hard-shell; a reopening date is still to be announced. The crab pill and row now say that, with the winter limit (five male Dungeness, hard-shell, 6¼″) for when it opens. Salmon (closed Sept 25) and halibut (closed Sept 30) were confirmed against the 2026–27 regulation pamphlet and the emergency-rule feed and are correctly shut — the lapse clock already had them. The open_on summary, which still editorially read "Salmon · Halibut," now reads the bottomfish that are actually open (Cabezon · Surfperch). checked advanced to Oct 5. Data edit only — no code change. Sources: WDFW 2026–27 Puget Sound marine-area pamphlet and the emergency-rules feed item of Sept 29, 2026.
the lapse reaches the forecast cards' "Open" badge too.
1.12.0 and 1.12.1 put the "what's open" pills and the detail table on the lapse clock, but one "what's open" surface was still reading a frozen string: the little "Open: Salmon · Halibut" badge on every marine-forecast day card. It is fed straight from the hand-written open_on summary in the harvest file, which the lapse code never touched, so on October 3 — days after Salmon closed September 25 and Halibut September 30 — all fourteen day cards still announced both as open. open_on is now recomputed at render time from the pills, off the same _window_days clock the pills and table already use: a fishery named in the summary keeps its place only while its pill is open and its window has not lapsed, and once the line empties the badge does not render at all. A name with no matching pill is left as written — without an end date there is nothing to lapse it against, and dropping a genuinely open fishery would be the worse error. The stale string in harvest.json is left as the editorial summary it is; nothing downstream trusts it to be current any more. This is a display fix only. The harvest table itself was last hand-verified September 13 and is three weeks stale — the page already says so in its own escalating verify line — so the seasons still need a real re-check against WDFW for Marine Area 8-2 (Dungeness crab's winter season, in particular, may have opened October 1 on last year's precedent). That is a data edit, not a code one, and is not in this release.
the lapse reaches the detail table, not just the summary pills.
1.12.0 stopped the "what's open" pills from showing green "Open" on a season whose end date had passed, but the detail table underneath still did: Salmon, Tulalip and Halibut on 8-2 all read OPEN in the table on October 3, days after they closed. The table rows now carry the same explicit end date the pills do and lapse on the same clock — a row past its end shows a muted "Ended" badge, degraded to the check state, never to closed. End dates are the final day the row's own season string already names, so a multi-step season still in a later step stays open: area 10 salmon runs to November 15 and area 8-1 to October 11, and both still read Open, while the one-step windows that ended in September do not. Year-round rows (Surfperch, the piers) and Cabezon, open through November, are untouched. The date is a field, not parsed from the season text — area 7's red rock crab says only "Same days as Dungeness" and still lapses correctly. Also removed a dead copy of the pill-and-table renderer inside harvest_section: it built the markup, then threw it away and called the real renderer anyway, and still carried the pre-lapse logic that would have misled anyone reading it to debug exactly this.
the feed casts a wider net, and a closed window stops saying it is open.
The "what's open" pills counted down the days left while a season ran, then went silent the moment it ended — and silence here read as unchanged, so an expired window kept its green "Open" head. Salmon in 8-2 closed September 25 and was still showing Open on October 3. A window whose close date has passed now degrades to <b>Window ended — not reverified since</b> and drops to the "check" state. It degrades to check, never to closed: a window ending is not evidence the fishery shut, only that nobody has looked since, and the page says exactly that much and no more. The WDFW rule feed was only reaching four of the six areas this site covers. Areas 7, 9, 10 and 12 were never matched by number, so "Marine Area 12 salmon closure" slid past, and the October 1 winter-crab opening for 7, 9 and 12 only caught because it also said "Puget Sound" and "crab". The relevance net now names those areas and more of the home water by place — Admiralty, Hood Canal, Whidbey, Stillaguamish — and more of the species the harvest tables track. Salmon stays off the keyword list on purpose: WDFW publishes salmon rules for every river in the state, so a bare "salmon" dragged in Neah Bay and the Yakima. A salmon item for water we cover names its area, and it is caught on that. Every "confirm with WDFW" link pointed at /fishing/regulations/rules-concise, which WDFW has retired — it now returns 404. They all point at the live /fishing/regulations index instead.
Visits opens on today, with the range picker at the top.
The view defaulted to 30 days and put the range buttons at the bottom of a long page, so every visit began by scrolling past a month of numbers to ask for the day. It lands on <b>Today</b> now and the picker is the first thing on the screen; 7, 30 and 90 days are one tap away and unchanged. The picker's explanatory paragraph — when the records begin, how long nginx keeps its own logs, what purge-ips does — did not come up with it. Moving that to the top would have replaced a scroll past numbers with a scroll past prose. It sits at the foot of the view as "About these numbers", where somebody who wants it will look for it.
the slip panel says how old it is, and links to its source.
The panel led with a bare timestamp, which asks the reader to do the subtraction. The question anybody has about a live panel is whether it is still live, so it answers that: "Last updated 2026-09-18 10:45 · 4 minutes ago". The relative part is empty rather than wrong when the stamp cannot be parsed — a bad age sitting next to a good time is worse than the time by itself. Every number in that panel is derived from KWAEVERE370, so the panel now links to the station's own Wunderground dashboard. Anyone who wants the raw history or the gauges should be sent to the source rather than have this page pretend to be one.
put the tracker card back. It was never gone, only hidden.
1.11.0 moved the tracker config from /etc/tanglefoot/log.json to its own tracker.json and taught the reader to look in both. It did not teach the bootstrap flag, which still read the log.json key — and that key had just been emptied by the move. So /api/bootstrap answered "tracker": false, the portal skipped the hub card, and the whole panel disappeared. Nothing errored. The endpoint kept working, the hourly build kept recording fixes, the database kept filling up. The only broken thing was the door. That is the worst shape a bug can take: no stack trace, no failed build, no log line, just a feature quietly absent — and the only reason it was found is that somebody looked for it and said so. tracker_configured() is now the single place that answers "is there a tracker", and it asks the same config the reader asks, in the same order. The suite checks that bootstrap routes through it and that the helper consults the module rather than a config key of its own, so moving the file again cannot hide a working panel. The real lesson is narrower than "add a test". Moving configuration is a two-sided change: every reader of the old location has to move with it, and a grep for the old key name would have found this one in a second.
the tracker gets a map, and the build asks it every hour.
Two changes that are really one. Polling on the build's own schedule is what produces a track, and a track is what makes a map worth drawing. build_all.tracker_step() runs on every build, which is hourly. The device reports twice a day, so most runs find nothing new and say so in one line. A fix is stored once, keyed on the device's own timestamp — without that the table would hold twelve identical rows per report and "when did she last move" would have no answer. It writes only to the private database: where the boat is stays behind /admin/ and never reaches the public site. The fetching, parsing and storing moved out of logd into conditions/ tracker.py, which both the build and the portal now drive. One implementation on purpose: two would eventually disagree about how old a fix is, and the portal is the copy nobody cross-checks. The portal reads the stored track and only goes to the network when what it holds is stale, so most opens are a database read. The map is drawn on the box from map.json — the same WDFW marine-area polygons the public area maps are drawn from. No tiles, no tile host, no network, which is the only kind of map any use on a boat with no signal. Inline SVG, server-rendered like every other chart here, so it survives JavaScript being off and it prints. It is fitted to the track rather than to the Sound. A boat that has not left the marina should fill the frame instead of being one pixel somewhere near Everett — which means the scale bar is doing real work, because it is the only thing that tells a marina from a passage. A single fix is given a minimum span, since one point has no extent and the scale would otherwise be infinite. An empty track draws nothing at all: an empty frame reads as "no signal" when it actually means "no data yet". Config moved to /etc/tanglefoot/tracker.json, root:tanglefoot 640, so the generator and the log daemon read one file. The old log.json keys are still honored rather than telling someone their working tracker is unconfigured. Also: the timer has run hourly since 1.7.0 and this file's own notes still said every three hours in one place and every four in another. Both now say hourly, which is what the box does.
the tracker aboard, in the log portal.
There is a GPS on OTIUM. It reports once or twice a day, and that rate is the entire design of this panel: a position six hours old rendered as a live one is the only way a feature like this hurts anybody. So the age of the fix is the headline, in the boat's own type size, and the coordinates are the small print underneath it. The screen says outright that it is not a live track and not a safety system, and it does the arithmetic that makes the point — at 8 knots she covers eight miles in the hour after a fix. The token in the tracker URL is the credential: anyone holding that link knows where the boat is. It lives in /etc/tanglefoot/log.json beside the camera share and never in the repo, and the suite's leak scan now matches both shapes, plus any token= of 32 or more hex characters. That guard is tested against three planted values and against a short session id it must NOT fire on, rather than being assumed to work. Fetched server-side. The camera has to hand its share link to the page because it is a WebRTC player; this is JSON, so there is no reason to put the token in anyone's page source, browser history or screenshot. /api/bootstrap answers "tracker": true or false and nothing more. The feed prints a wall clock with no offset on it — "09/18/2026 06:01 AM". The zone that means is configuration, because reading it as UTC would report a fresh fix as seven hours stale or a stale one as fresh. A stamp that will not parse gives no age at all rather than a guess. tracker_home gives the panel a distance from the slip, so it answers is she where I left her instead of only printing a latitude. Every failure path is wrapped and returns a reason: no config, no network, a vendor having a bad day, malformed JSON, a clock ahead of the box. A boat log that would not open because a tracker vendor was down would be worse than no panel. No out-link to a map host. The portal is built for no signal, and it is the one file in the tree that may not reference an external URL at all; the position copies to the clipboard in decimal degrees and degrees-minutes, which is what a plotter and a radio call both want.
fog moves out of its own panel and onto each day.
1.9.0 put visibility in a section under the verdict. Wrong place. A block is a thing a reader scrolls past on a quiet week, and a quiet week is exactly when fog is the only thing on the page worth reading — it is the one number here that a calm forecast makes MORE likely, not less. It now sits in each day's meta row, beside the wind and the tide it applies to, colored by rank so a fogged morning is findable while scrolling. The limiter on a capped tile reads Fog. The panel, its styles and its nine tests are deleted rather than left dormant, and the replacement tests assert the inverse — that no fog section exists and that every forecast day carries a visibility line — so it cannot drift back in unnoticed. The science is explained once, in the notes, and a test fails the build above 130 words: what a dew point and a spread are, and why a clear still night is the one that fogs. Not repeated per tile. No scoring changed. Fog still caps a green day at workable, still only caps, an advisory still outranks it, and a spread past the model's fog fraction still cannot mark a day down. Housekeeping, stated because the changelog is the only account: 1.9.0 was installed twice, once for the panel and once for this. tfu moves the live install to /opt/conditions.prev on every run, so the second install overwrote the rollback target with the same version and --rollback became a no-op. That is what this version number is really for; the placement change could have ridden along on 1.9.0 otherwise. One tfu per release.
fog is scored, and it can take a day off the green band.
Wind and seas were the only things this site rated, so a morning you could not see across came out Excellent. 19 September 2026 is the case that forced it: flat calm, seas nil, every tile green, and 99% fog over Puget Sound at 0800. Nothing on the page mentioned it. Patchy fog or worse during daylight now caps a day at workable, and the limiter names it, so the sentence under the tile says fog rather than reaching for the next number down. Fog only ever caps — it does not make an already rough day rougher, because the wind is still what will hurt you out there. An NWS advisory still outranks everything. Three fields carry it and all three were already in the bytes the build downloads every run and threw away: fog_area_fraction, dew_point_ temperature and cloud_area_fraction_low. No new source, no extra request. Two tiers, and each row says which one it is on. Out to about two and a half days MET publishes a fog fraction and the row is a fog forecast. Past that only the dew point spread survives, and a spread is the condition fog forms in rather than fog — so out there it raises a watch and nothing more. A humidity figure nine days out cannot mark a day down. The thresholds are the ones the station panel has always used, so the measured reading at the slip and the forecast for the same water are one scale. Two ranks per day. The rating uses the daylight hours only, because fog at 0300 is a fact about the night; the label uses all 24, because someone planning an 0600 departure needs the night run named. Runs are contiguous, which matters more than it sounds: taking the first and last fogged hour reports a dawn fog and a separate midnight one as sixteen unbroken hours. New panel under the verdict on every area page, one row per forecast day with a 24-cell hour bar. It renders on a clear fortnight too — a section that disappears when there is no fog would teach a reader that its absence means something. One thing fixed on the way past. rate_day scored against bands["green"] while limiting_factor scored against wind_limit_kt and seas_limit_ft, and on areas 9 and 10 those differ — 12 kt and 2 ft against 10 kt and 1 ft. The visible result was a tile chipped one way and a sentence underneath explaining a different one, which is the "seas are what rule this one out" phantom on area-9 tiles the notes have been carrying. limiting_factor now takes bands. The underlying zone_wave_ft ceiling bug is untouched and still real; those areas simply stop reporting 2 ft as the limiter, because it is inside their green band.
an archived trip freezes its weather, and the build stops touching it.
A trip page leaves Planning either because the trip ran or because it did not. Either way its forecast stops being a forecast at that moment, and the page becomes a record. It stays published — the planning is what is worth keeping, and the anchorage, the scope arithmetic and the regulated areas outlive the weekend they were written for — but nothing in it is updated again. "weather_locked": "<date>" in trip.json is the switch. stamp_authored() skips a locked page entirely, so the nightly build no longer rewrites its masthead: a page carrying December's build time over September's forecast would be claiming a freshness it does not have, and the stamp would be the only moving part on a page whose whole point is that nothing moves. Locked pages say "Conditions frozen" and the freeze date instead, written once by hand. build_all reports how many it left alone, because saying nothing would read as the stamper having missed them. The masthead check now accepts either spelling, and the suite proves the skip the only way worth proving it: by running the real stamper over a locked page and an unlocked one side by side and checking that the first came back byte-identical. Eagle Harbor is the first page through it. Called off on the morning of departure, refreshed once against that morning's build and frozen there. Worth recording what that last build said, because it is not what the plan was written on: Friday had come back up to 10 kt in all three areas and Sunday had gone from a 5–8 kt day to a 10 kt one, putting the northerly on the nose for the whole return leg over a foul flood. The station's dew point spread read 2.0 °F that morning — fog likely — and home water was fogged 04:00–12:00 on the Saturday. The trip did not come off for any of that, but the page should not pretend the window it was sold on was still there.
authored trip pages get a canonical URL, and the untaken group gets the name it is actually called.
nginx here serves try_files $uri $uri/ /index.html, so every missing path returns 200 with the landing page and there is no 404 anywhere on this site. The canonical URL is the only thing that tells a real page from that fallback, which is why the deploy check compares canonicals rather than status codes. The authored trip pages carried none — so the one layer that is pushed by hand, and therefore the only layer that can actually go missing, was the one the check quietly skipped. Both trip pages now declare their own, the suite fails the build if an authored page has none, and that guard is tested by stripping a canonical rather than assumed to work. The third trip state is stored as "scrubbed" and was shown as "Planned, not taken". It now reads "Archived — not taken", which is what Rich calls it. The machine value does not change: the generator, the home page counts and the tests all speak "scrubbed", and no trip has ever used another one. The deploy recipe in CLAUDE.md named the box by raw IP, which bypasses the ssh config block that holds its identity file and fails with "Permission denied (publickey)". It names the `tanglefoot` alias now.
close the gap above the boat photo on the landing page.
The photo spans both columns of the hero, which makes it a second grid row, so the row gap was already separating it. Its own 30px top margin stacked on top of that and left it sitting clear of the flyer above. The margin is gone inside the hero, the way the flyer and the cards already reset theirs, and the row gap is 20px rather than 28. Gap above the photo goes from 58px to 20px. It reads as more than it was because the flyer column runs taller than the cards column beside it, and the photo waits for whichever is longer.
trend charts at the slip: wind and gust, air and dew point.
Two SVG charts under the station readings, drawn server-side for the same reason everything else here is: a chart that needed a runtime would vanish with JavaScript off and would not print. The temperature chart shades every hour where air and dew point were within four degrees of each other, which is the fog-watch threshold the panel already uses. The gap between those two lines IS the fog signal, so it is marked rather than left for a reader to measure by eye. The wind chart carries the twelve-knot green band this site scores home water against, drawn only when it falls inside the plotted range rather than pinned to an edge to look meaningful. Two honesty rules the drawing follows:
- The line is hourly rows only, a consistent statistic: each point is that hour's average wind and that hour's highest gust. The five-minute spot readings are a different measurement, so the newest one is a marked point and is never joined to the line.
- A gap in the record is drawn as a gap. If the station was offline for three hours the line breaks rather than running straight across as though nothing happened. Found while drawing them: the hourly record was only as current as the last one-off backfill, so the chart drew a line that stopped half a day short of the present. The build tops it up each run now, one call, idempotent. Separately the green band was not drawing at all — the limit lives in the area config and the summary handed to the chart never carried it.
the landing page reloads itself, so the slip reading stays current.
Left open on a laptop, the page froze at whatever the station said when it loaded. It now reloads every five and a half minutes. Five and a half, not five: nginx lets a browser hold that page for three hundred seconds, and a reload timed level with the cache window can be answered out of that same cache, refreshing nothing. Thirty seconds of margin makes every reload a real one. The interval is read off the station timer and the cache header rather than typed in. Only the landing page. Trip plans and checklists are read for minutes at a time and pulling one out from under someone mid-sentence would be worse than a stale number.
- The about page claimed the public pages carry "two small scripts" and named them. They carry three or four, and have for a while. It describes them now instead of counting them, because a count in prose is a promise that rots.
- "Nothing on the page phones anyone" is now "phones anyone else". A page that reloads itself does request something: itself. The claim about third parties is unchanged and still true.
- The reload is disclosed on the about page rather than left for a reader to work out from a page that moves on its own.
hourly builds, paid for by not fetching what has not changed.
The build runs every hour now. It costs less than the four-hourly one did, because a warm build makes 15 requests where it used to make 36 and only nine of those carry a body. Two mechanisms, because the sources are not alike. Tide and current PREDICTIONS are computed months ahead and cannot change between builds, so they are served from disk without asking anyone; the date window in the URL rolls over daily, which makes that at most one fetch per station per day however often the build runs. Forecasts do change, so those are revalidated with the ETag and Last-Modified the server gave us last time — a 304 costs both ends a header exchange and no body, which is the arrangement MET Norway's terms ask for by name. A warm build: 12 requests avoided entirely, 6 of the remaining 15 answered 304. Every cache failure is non-fatal — an unwritable directory, a corrupt entry — and the worst case is the request that would have been made anyway.
- The station panel now refreshes every five minutes.
- Every public sentence about how often anything updates is now read off the timer unit that runs it, rather than written down beside it. That was not a tidy-up: the panel was still telling readers it refreshed every fifteen minutes an hour after it had gone to five, and the staleness threshold was a constant tuned for a cadence that no longer existed. The threshold is derived too, floored at four hours and capped at a day.
- The tests that asserted the cadence did it by writing the number down a second time, which is the exact fault they existed to catch. They assert the relationship now, so changing a timer changes the expectation with it.
- A fixture had been leaving a stubbed fetch in place for the rest of the suite. The cache tests were exercising that stub instead of the code, and passing. They ask for the real implementation by name now.
stop fetching the same bytes six times, and read the station every five minutes.
Rich asked about going hourly on the build and to five minutes on the station. The station is a clear yes. The build is a no for now, and looking at why turned up something worth fixing regardless. A quarter of every build was already waste. The WDFW rule feed takes no arguments — one URL, fetched once per AREA, six times a build. Six areas share two NWS zones and each fetched its own copy. Nine of thirty-six requests were for bytes already in hand, which matters four times as much the moment the build runs hourly. A build now makes 27 requests instead of 36, and the log says "reusing this build's copy" rather than claiming to have fetched something it did not. The cache lives for the life of the process, which is exactly one build, so it cannot serve a reader anything stale. Failures are reused too, deliberately: areas sharing a zone should fail identically rather than differ by retry luck.
- The station panel now refreshes every five minutes rather than fifteen, which is how often the station actually reports. About 288 calls a day against a free allowance near 1500.
- At that cadence the readings table gains 288 rows a day, so spot readings older than three weeks are dropped as the refresher runs. The hourly record is never pruned — that is the record, and the pressure trend only ever reads recent rows.
the past forecasts were not gone after all.
I said the comparison could not be backfilled because nothing recorded what the model claimed before the site was watching. That was wrong. Every build that was committed left a copy of the area page in git, and each of those pages carries its own build stamp and its own row for that day. What the forecast said on a past day can be read straight back out of what the site published on it. Three completed days recovered that way, so the comparison starts now instead of on Saturday. The rule matches the live loop exactly: the peak for a day as stated by a page built on that day. Nothing interpolated, and a day with no build on it produces no row. Recovered days are marked as recovered in the table rather than passed off as having been recorded live.
- The first reading of this is that the forecast peak runs LOW against the station, by about 4 kt. The scoring notes had suspected the opposite. Three days is not a finding; it is a reason to keep watching.
- The renderer was still asking for a sample count the query stopped returning when the comparison moved to completed days. Every test passed because the panel was only ever rendered with an empty table. It now gets rendered from real rows produced by the real query, which is where that class of fault belongs.
the station step actually runs on the box.
1.6.0 shipped the configured path with an undefined name in it. The step is wrapped so nothing broke, but it logged "step failed, continuing" and did nothing for a build cycle. It passed every test because no development machine has a station key, so the configured branch returns before reaching the fault — the only place that code has ever run is the one place nobody was testing.
- The step is a function now instead of inline code, so the suite can call it with the fetch stubbed and an undefined name becomes a failing test rather than a log line nobody reads.
- The panel said it was "as fresh as the last build and no fresher", which stopped being true the moment the fifteen-minute refresher went in. It now says what it actually does.
the station's history, and a panel that refreshes between builds.
The key went in and the panel came alive, which exposed two things wrong with what it was built on. Both are fixed here. The comparison was reading a daily maximum off four build-time samples, and four samples a day is not a maximum. On 16 September the station's real peak gust was 15.6 kt; the highest thing any sample caught was 2.6. The table would have reported the forecast as wildly pessimistic when it was in fact slightly low. The forecast peak is now written down on the day it applies to, and the observed maximum is fetched from the station's own daily history once the day is over and can no longer change. Only completed days appear. It also reported the mean error, and a day that was five knots high cancels a day that was five knots low into an average of nothing. It now gives the lean and the typical miss separately, because "level on average, out by three knots on any given day" is two facts and only one of them is flattering.
- Past observations can be backfilled from the station's own record: python3 /opt/conditions/pws.py --days 30. Backfilled days carry no forecast peak and never will — nothing wrote down what the model claimed before the site was watching, and inventing one would make the table a fiction. They are there for pressure, fog and the shape of the wind.
- A separate timer refreshes only the panel, every fifteen minutes, by swapping one marked block inside the published page. No forecast is refetched and no page re-rendered. If the markers are missing, the fetch fails, or the render comes back empty, it writes nothing and the last good panel stays up. The replacement is atomic.
- Fifteen minutes rather than two: the free key allows about 1500 calls a day, this is a page about a fourteen day forecast, and 96 calls is polite to a service that costs nothing.
a way to install the station key without ever exposing it.
Rich offered to paste the API key into the chat. Declined: a key in a transcript is a key that has to be rotated to be trusted again. This does the same job without that cost. conditions/deploy/install-pws-key.sh prompts for the key with the input hidden, so it never appears on screen and never enters shell history. It reaches curl through a config file on stdin rather than a command-line argument, so it never appears in the process table either. It is checked against the live API before anything is written — a truncated paste fails at the prompt instead of quietly producing an empty panel for four hours.
- Writes atomically to a temp file, then moves it into place.
- root:tanglefoot 640, the permission the config needs and the one that crash-looped the log daemon when it was got wrong.
- Triggers a rebuild, so the panel appears immediately rather than at the next timer.
- Verified against a deliberately bad key: the API returns 401 and the script writes nothing.
the station panel stops advertising itself before it works.
Shipped in 1.5.0 sitting on the landing page reading "not reporting — no station configured", which is an empty box announcing a feature that does not exist yet. Worse than no box.
- A station that was never configured now renders nothing at all.
- A station that IS configured and has gone quiet still says so, because that one is real news: something aboard is off.
- It appears by itself the moment a key is installed. Nothing else to do.
a page that has stopped updating now says so.
Every guard on this site caught a page that was short or malformed. None of them caught the failure that actually matters to a reader: the build stops running, or a source hands back yesterday's data, and the site keeps serving something that looks perfectly current. The only clue was a build stamp nobody reads against their own clock. Two notices, kept separate because they fail separately. The first is computed while rendering, from the model's own run timestamp: if the forecast was already old when it was fetched, the page says how old and which run it came from. The second is the one no server can render, because a build that has stopped cannot render anything — the page carries the moment it was built, and a few lines of script compare that to the reader's clock. Past nine hours, which is two missed runs plus slack, it reveals a warning above the verdict.
- A clock behind the server reads as a negative age. That is a broken clock, not a fresh page, so the notice stays hidden rather than guessing.
- The script can only reveal a warning. It writes no content, so a reader with scripting off loses a caution and never a fact.
- The threshold is one constant shared by the page and the script, not a number written twice.
- Separately: an area page could pass every length and structure check and still publish an empty verdict. A blank headline or a verdict with no paragraphs now refuses to install, the same as a short page does.
the landing page stops promising a live view it cannot give.
The boat photo's caption ended "See her as she is right now", linking to /camera/. Nothing has ever pushed a frame to that endpoint, so the link led to a page with no picture on it. A promise the site cannot keep is worse than no promise, so the line is gone.
- The camera page itself still exists and still works. It is simply not advertised from the landing page until something is actually pushing.
- The suite used to assert that the caption pointed at the camera. It now asserts the opposite, and separately that the caption still ends on a finished sentence — removing a clause is an easy way to leave a dangling one behind.
the boat's own weather station, and the model held against it.
OTIUM carries a Weather Underground station, KWAEVERE370, at the slip in Everett. Until now every number on this site was model, forecast or prediction. This is the first measured one, and it sits at the home berth. The panel on / shows wind, gust, air, dew point, spread and a pressure trend computed from the record rather than from a single reading. The dew point spread is the fog number the trip pages already set Friday departures by: under two degrees is fog likely, under four is a watch. The half that matters over time is underneath it — what the forecast peak claimed for home water against the highest gust the station actually recorded, day by day, with the sample count shown so the reader can judge what the number is worth. That is the only honest way to answer whether the wind figures here run high, which the scoring notes have suspected for a while.
- Wind arrives in miles per hour and every other wind number here is knots. The conversion is applied and the suite checks both that the constant is right and that it is used, because a silent fifteen percent error is the exact fault this feature exists to catch.
- The key is a credential. It lives only on the box, and the suite fails the build if one appears in the tree — the guard is tested against a planted key rather than assumed to work. It caught the test's own fixture on the first run, which is the best evidence anyone could ask for.
- No config, no key, no network, a station that lost power, malformed JSON: each returns a reason, the panel says which, and every other page is untouched. A forecast site that broke because a boat lost power would be worse than no panel.
- A daily maximum needs more than one reading, so days with a single sample are excluded from the comparison rather than quietly averaged in.
the anchoring section explains the 5:1 instead of asserting it.
The plan said to pay out 175 ft and never said why that number rather than 245. Four diagrams and a table now do. Three are drawn true to scale; the one that is vertically exaggerated says so on its face, because a stretched drawing lies about the exact thing a reader is trying to measure. The argument, in short: the angle at the anchor is exact geometry, and the returns fall off fast — 3:1 to 5:1 buys 7.9 degrees, 5:1 to 7:1 buys 3.3. The swing circle is what the extra scope costs, and at 5:1 it is 423 ft across against 565 ft at 7:1. Eagle Harbor is full of other people's boats, which is what decides it. Chain catenary and the snubber are covered because they are why the smaller number is safe here.
- The swing figure in the through-the-night list was rode plus boat. Some of that rode is going down, so the horizontal reach is shorter: 211 ft of radius, not 215.
- A test counted exactly four map figures, which made adding a fifth diagram a failure rather than an improvement. It now asks the invariant that was meant all along — every figure carries its own enlarge and close control, whatever the count.
- Holding force is described, not quantified. The geometry is exact; how much grip each angle is worth depends on the anchor and the bottom, and any percentage would be a specific anchor in a specific mud.
a float plan you can send, and a build stamp you can read.
A trip plan nobody ashore is holding is a document, not a safety measure. Every trip card now carries a Send plan link that opens the reader's own mail client with the plan already written, and the Eagle Harbor page carries the full float plan as text: vessel, route, anchorage, return time, and what to do if it passes without word. Nothing is sent by the site and no recipient is guessed — the blanks that only the skipper can fill are left visibly blank. The build stamp on the authored trip pages was present, correct and unreadable. Its labels were painted in a hairline border token, about two to one against the paper at nine pixels, and the values were barely better. It passed every check that asked whether the markup existed, because it did. Both trip pages now read above six to one in both themes.
- Recipes for the food the shopping list was silently implying, each one pot or one pan, each using only what is on that list.
- Cookware checklist, including the manual can opener five tins depend on.
- Soy milk in place of oat milk.
- The suite now asks whether a trip page's stamp is painted in an ink token rather than a border token, which is the question the old check did not ask.
- The spelling check exempts percent-encoded proper nouns, so a pub's name inside a mail link stops being reported as a British spelling. A genuine British spelling in a link body still fails, because being URL-encoded does not make a spelling correct.
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.
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.
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.
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.)
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
/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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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__.
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.
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.
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.
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.
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.
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.
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.
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.
/ 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>.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.