Reverts the explicit subnets: the pools were freed by deleting five networks, so
the network role is back to its single idempotent task - ensure the network
exists and Caddy is on it - and the Sites no longer declare a subnet. The
deployment readme keeps the note that a new Site needs a config, and therefore a
new tag, but no longer asks for a free /24.
If the pools fill up again the failure is recognisable: 'all predefined address
pools have been fully subnetted', and the choices are deleting stale networks or
declaring a subnet per Site.
The deploy failed on hngmohali with 'all predefined address pools have been
fully subnetted': every 172.17-172.30/16 is in use, 172.31/16 is the host's own
VPC so Docker will not allocate from it, and all sixteen 192.168.0-240/20 blocks
are taken. Docker therefore refuses a network created without a subnet.
Each Site now names a free network_subnet (10.210.0.0/16, one /24 each), the role
looks the network up first and only creates it when it is absent and with that
subnet, and it fails with an explanation when a Site declares none. Existing
networks are left alone, because Docker cannot change a subnet in place and the
Sites already running have theirs. The test checks every Site declares one, and
the deployment readme says a new Site needs a free subnet.
deploy.sh now deploys every Site by default, with SITE=<name> to narrow it, which
is what the local edit was reaching for.
One image serves every Site, so a new Site is three small files: an inventory
entry, a vars file naming its web address and data root, and a config that seeds
its theme and first category list (baked into the image, which is why a new Site
needs a new tag).
Both reuse the directory their old static menus were served from, and the caddy
role drops the snippet the retired nginx-menu playbook wrote for each hostname,
so a deploy repoints each address at its own container with nothing else to do.
The seeded category lists are a starting point, not a decision: categories, names
and icons are all editable in each Site's own admin. A test now checks the
inventory, config and vars agree for every Site and that no config is orphaned,
because a half-added Site was the obvious way to get this wrong.
Prettier owns formatting: .prettierrc.json, format and format:check scripts, and
a one-off pass over the repo so the check is green from the start. Embedded
language formatting is off deliberately - the renderers build HTML in html
tagged templates and letting Prettier reflow those would change the markup
customers receive. docs/ is excluded because it is hand-wrapped prose, and shell
scripts have no Prettier parser.
ESLint covers the JavaScript: the two browser scripts, the build and check
scripts, and its own config. no-var is scoped out for the browser scripts on
purpose; they are plain ES5-shaped IIFEs and a hoisting difference would only
show up in a browser.
ESLint cannot lint the TypeScript here: typescript@7 is the native port with no
JavaScript compiler API, and typescript-eslint requires a 4.8-6.1 peer, so it
refuses to install and forcing it through would leave a parser that cannot read
the compiler. tsc --noEmit stays the TypeScript gate; README records the two
ways to change that.
lint and format:check now run in deploy.sh, and VS Code formats on save with
Prettier and surfaces ESLint findings.
Three things that needed a rebuild and a redeploy, or had no way in at all, now
have a Site identity panel in the admin: the restaurant's name and tagline, an
SVG icon upload, and a switch for whether a menu says when it was published.
They live in data/site.json and data/brand/icon.svg, extending ADR 0007: what
the owner changes is content, and the config is the seed plus the theme, which
still needs a rebuild.
An uploaded SVG is a document that would run with this origin's authority, so it
is reduced to shapes on the way in and served with default-src 'none'. The
archive list keeps its dates even when the switch is off, because those dates
are how one old menu is told from another.
Also fixes the live check's own trap: the app caches identity in memory, so the
tests drive it through the app rather than a second store instance, which would
be a second writer.
The document still preloaded the Archivo file the old design used, which is now
gone: a 404 on every page load. It preloads Playfair Display and Plus Jakarta
Sans instead, and the asset test names a font that exists.
The bar hid them behind a switcher and a sheet. It now names every menu that
exists, which is what was asked for; it scrolls sideways when the categories
outgrow the width, and the current one is the only filled item. Without the
script it is simply a row of links, so nothing is lost when JavaScript is off.
Categories were baked into config/<site>.site.json, so adding one meant a
rebuild and a redeploy. They are now data: data/sections.json, seeded from the
config the first time, and managed in the admin (add, rename, reorder, remove).
A category's web address is fixed when it is created because that is what a QR
code points at, and a category cannot be removed while it still holds menus.
The landing and the top bar were the ugliest parts. The landing was a column of
hairlines with tiny meta — a broadsheet, and the paper that makes this app worth
looking at was nowhere on it. It is now a shelf: each category is a row whose
right edge is a sliver of that menu's first page, bleeding off the screen, with
the shelf edge in brass and the name in heavy tight Archivo. The bar was a row
of teal pills that would not survive more categories: it now names the menu you
are reading and opens a sheet listing the rest, each with its own paper sliver,
and the current one is the only teal thing on screen. The sheet is a link to the
landing page, so navigating still works without the script.
Also fixes the category manager's own layout, which I shipped unstyled, and the
markup contract the route tests described.
Two defects, both found by driving the real editor in Firefox:
1. The editor drew grab handles with a grab cursor on sets it refuses to
reorder, so the live Food menu looked draggable and did nothing. Handles and
the move buttons are now rendered only when reordering is possible, and the
notice says what can still be done.
2. Reordering was saved only from dragend. Browsers suppress mouse events during
a drag and dragend is not guaranteed to arrive, which left the rows visibly
reordered while the server kept the old order — the drag looked like it
worked and then reverted on reload. The order now commits from dragover after
a pause in the drag, with drop/dragend/mouse events as faster paths, deduped
against the last confirmed order and reported in the page.
To make (1) coherent the Current set may now be reordered while its Pages stay
fixed: order is presentation, media URLs are content-hashed and pages are
rendered per request, so nothing stale can be served (ADR 0006). Archives stay
frozen. Admin pages are no-store and assets cache for 5 minutes, so a fix like
this one is not masked by a stale admin.js.
scripts/check-dnd.mjs drives the real editor and asserts the drag persists; it
is the only automated check that reaches this path.
A file past the size limit makes the multipart stream itself throw, which
escaped the per-file catch and reached the error handler: the phone that just
sent a 35 MB photo got 'Something went wrong'. Catch the limit errors around the
part loop, say which limit was hit, and report it once rather than twice. Also
asserts that derivatives carry no EXIF while the Original keeps the uploaded
bytes.
Every mutating form now carries the CSRF token as a hidden field, and the
upload route accepts it as a multipart field as well as a header. Driving the
real UI in Firefox found what the header-only tests could not: Create Draft,
Save, Publish, Delete and the up/down buttons all posted without a token and
were rejected. Adds npm run check-site, which verifies a running Site from the
outside and exits non-zero so it can gate a cutover.
verify reports integrity problems and repair rebuilds derivatives from the
Originals, which is what makes the file layout trustworthy without a database.
The image runs TypeScript on Node 24 with no build step, writes only under
MENU_ROOT, and the provision script writes the untracked site.env using the
image's own hasher so the server needs no Node.
The admin signs in with the shared password, creates a Menu Set, drops photos
into it (shrunk in the browser first), reorders Pages by dragging or with
up/down buttons, previews the Draft exactly as customers would see it, and
publishes it over the previous set. Restore and delete close the loop. Mutations
need a session, a same-origin request and a CSRF token derived from the session;
a Draft's media is served only to a session holder.
Landing page, Section pages and archived sets rendered per request from the
store, with the approved design: hand-written CSS, self-hosted Archivo, a
copper pour line in the Section rail, an admin-only Thumbnail and a text page
index. Media is keyed by Page id so URLs survive publishing, and a Draft's
media needs a session. Includes the scrypt admin auth and the login throttle
the media rule needs.
The store keeps a Menu Set's state in the directory layout (drafts/ versus
published/) and its metadata in a manifest, with publishing as a single
directory rename. Editing is allowed on Drafts only, so a published menu can
never change under a customer. Stages 2 and 3 of docs/plan.md, with tests.