Let the owner manage categories, and give the menus a shelf

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.
This commit is contained in:
tanshu committed 2026-09-22 22:24:53 +05:30
1 parent 7bf67cc299
commit c31b73d553
25 files changed
+1225 -190

No files matched your search

+32 -2
View File
@@ -89,9 +89,11 @@ describe('the landing page', () => {
assert.equal(response.statusCode, 200);
assert.match(response.body, /Hops n Grains/);
assert.match(response.body, /Handcrafted Microbrewery/);
assert.match(response.body, /<span class="row__label">Bar<\/span>/);
assert.match(response.body, /class="shelf" href="\/bar\/"/);
assert.match(response.body, /<span class="shelf__name">Bar<\/span>/);
assert.match(response.body, /2 pages/);
assert.doesNotMatch(response.body, /row__label">Wine/);
assert.match(response.body, /class="shelf__peek"/, 'the shelf shows a sliver of the menu paper');
assert.doesNotMatch(response.body, /shelf__name">Wine/);
});
it('escapes a label that tries to inject markup', async () => {
@@ -128,6 +130,9 @@ describe('a Section page', () => {
assert.doesNotMatch(body, /Older menus/);
assert.doesNotMatch(body, /This is an older menu/);
assert.match(body, /src="\/assets\/app\.js"/);
assert.match(body, /class="switcher"/, 'the bar names the menu and opens the switcher');
assert.match(body, /<span class="switcher__label">Bar<\/span>/);
assert.match(body, /id="menu-sheet"/, 'the sheet exists for the script to open');
});
it('serves both the trailing-slash and bare URLs', async () => {
@@ -142,6 +147,31 @@ describe('a Section page', () => {
});
});
describe('the menu switcher', () => {
it('lists every Section that has a menu, in category order, marking the one being read', async () => {
await seedPublishedBar();
const store = app.menu.store;
const food = await store.create('food', { label: 'Food Menu' });
await store.addPage(food.id, { data: pngBytes('food'), originalName: 'f.jpg' });
await store.publish(food.id);
const page = await app.inject({ method: 'GET', url: '/bar/' });
const listed = [...page.body.matchAll(/class="sheet__item[^"]*"\s+href="\/([a-z-]+)\//g)].map(
(match) => match[1],
);
assert.deepEqual(listed, ['food', 'bar']);
assert.equal((page.body.match(/sheet__item is-current/g) ?? []).length, 1);
assert.match(page.body, />Showing</);
});
it('leaves a category with no menu out until it has one', async () => {
await seedPublishedBar();
const page = await app.inject({ method: 'GET', url: '/bar/' });
assert.doesNotMatch(page.body, /sheet__name">Wine/);
assert.equal((await app.inject({ method: 'GET', url: '/wine/' })).statusCode, 404);
});
});
describe('an archived Menu Set', () => {
it('is reachable at its date URL, marked as older, and does not list itself', async () => {
const store = app.menu.store;