diff --git a/deploy/seed-demo.php b/deploy/seed-demo.php index 8e6e786..d15da05 100644 --- a/deploy/seed-demo.php +++ b/deploy/seed-demo.php @@ -146,16 +146,43 @@ } echo " menu: " . count($catIds) . " categories, " . count($menu) . " items\n"; -// --- 3b) Site settings: brand + render the menu at the root ---------------- +// --- 3b) The homepage (a `single`-kind collection: one editable entry) ------ +// The public root ("/") renders this as a restaurant front page (theme +// `entry-home.php`); featured dishes come live from the menu via the plugin's +// view-data contributor. Modeled as content so the copy is editable in the CMS. +$collections->create('home', 'Home', '#', '', ['kind' => 'single', 'permissions' => []], [ + ['handle' => 'hero_kicker', 'label' => 'Hero kicker', 'type' => 'text', 'required' => false, 'options' => []], + ['handle' => 'hero_title', 'label' => 'Hero title', 'type' => 'text', 'required' => false, 'options' => []], + ['handle' => 'hero_tagline', 'label' => 'Hero tagline', 'type' => 'textarea', 'required' => false, 'options' => []], + ['handle' => 'about_title', 'label' => 'About title', 'type' => 'text', 'required' => false, 'options' => []], + ['handle' => 'about_body', 'label' => 'About body', 'type' => 'textarea', 'required' => false, 'options' => []], + ['handle' => 'hours', 'label' => 'Hours', 'type' => 'textarea', 'required' => false, 'options' => []], + ['handle' => 'address', 'label' => 'Address', 'type' => 'textarea', 'required' => false, 'options' => []], + ['handle' => 'phone', 'label' => 'Phone', 'type' => 'text', 'required' => false, 'options' => []], +]); +$homeCol = $repo->findByHandle('home'); +$entries->save($homeCol, new EntryInput('The Copper Table', 'home', 'published', [ + 'hero_kicker' => 'Est. 2014 · Modern American', + 'hero_title' => 'The Copper Table', + 'hero_tagline' => 'A neighbourhood kitchen for lunch and dinner — seasonal plates, an easy room, and a short list done well.', + 'about_title' => 'About the table', + 'about_body' => "We opened on a corner in 2014 with a wood-topped bar and a small menu that changes with the season. Everything is cooked to order; nothing leaves the pass we wouldn't eat ourselves.\n\nToday the room runs on the Restaurant Automation System — a live NimbusCMS demo.", + 'hours' => "Mon–Thu · 11:00–22:00\nFri–Sat · 11:00–23:00\nSunday · 10:00–21:00", + 'address' => "18 Copper Lane\nOld Town\nEC1 4RS", + 'phone' => '020 7946 0142', +], '2024-01-01 00:00:00'), null, null); +echo " homepage: 1 single collection + entry\n"; + +// --- 3c) Site settings: brand + render the homepage at the root ------------ // These are DB settings (nb_settings), which shadow config/site.php at runtime — -// so the guest-facing root ("/") shows the branded menu, not the bare Nimbus +// so the guest-facing root ("/") shows the branded homepage, not the bare Nimbus // placeholder. Seeded here so a from-scratch rebuild matches the golden restore. (new SettingsRepository($db))->setMany([ 'site.title' => 'The Copper Table', 'site.description' => 'A restaurant running on the Restaurant Automation System — a live NimbusCMS demo.', - 'site.home' => 'menu_items', + 'site.home' => 'home', ]); -echo " settings: home -> menu_items, brand -> The Copper Table\n"; +echo " settings: home -> home, brand -> The Copper Table\n"; // --- 4) Live floor / orders / reservations --------------------------------- $storage = static fn (): PluginStorage => new PluginStorage($db); diff --git a/docs/design/slice-b-homepage.md b/docs/design/slice-b-homepage.md new file mode 100644 index 0000000..305a650 --- /dev/null +++ b/docs/design/slice-b-homepage.md @@ -0,0 +1,140 @@ +# Slice B — Public homepage + a denser guest site + +**Status:** design (pre-build) · **Branch:** `nimbus-rebuild` · **Depends on:** the RAS +theme (Slice 8b), the menu collections, the ADR-0027 view-data hinge (core, already +shipped). + +## Why + +The 2014 original had **no public website** — it was a staff dashboard (RAS). The +guest-facing site is therefore new work, and today it is a single page: the menu at +`/`. Dan's ask: *"make the public-facing site more dense with a homepage direction +and all the normal stuff"*, keeping the uplifted RAS identity (dark `#1a1c20` + +gold `#d4a017`, serif display, printed-menu feel). + +So: a real restaurant homepage — hero, a short about, a few **featured dishes +pulled live from the menu**, hours & location, and a reservations call-to-action — +with the full menu remaining its own page. + +## Non-goals (this slice) + +- Online **ordering / payment** — that is Slice C2 (its own design + security pass). +- Online **reservations** — booking stays staff-side (the Reservations terminal). + The homepage CTA is *"call to reserve"* (a real `tel:` link), not a public form. + (A public booking form is a candidate future slice; it is a public write surface + and would need the same security treatment as ordering.) +- No new **core** capability. Everything here rides existing hinges. + +## Content model — the homepage is editable CMS content + +Model the homepage as a **`single`-kind collection `home`** (one entry, no index), +so the copy is editable in the admin and the slice also demonstrates Nimbus as a +CMS rather than hard-coding strings in a template. + +`home` fields (all optional; the template degrades gracefully when blank): + +| handle | type | purpose | +|--------|------|---------| +| `hero_kicker` | text | eyebrow, e.g. "Est. 2014 · Modern American" | +| `hero_title` | text | large display line, e.g. "The Copper Table" | +| `hero_tagline` | textarea | one or two sentences under the title | +| `about_title` | text | section heading | +| `about_body` | textarea | a short paragraph | +| `hours` | textarea | one "Day · time" per line | +| `address` | textarea | postal address, one line per row | +| `phone` | text | used for the `tel:` reservations CTA | + +Wire it with the existing home mechanism: set the **`site.home` setting** to +`home` (DB setting — see the deploy gotcha; seeded via `SettingsRepository`). The +router then renders the single entry through `entry-home.php` +(`specialize('entry','home')`), falling back to `entry.php` if the theme lacked it. + +> The menu stays at `/menu_items`. `site.home` moves from `menu_items` → `home`. + +## Live "featured dishes" — the plugin feeds the theme (ADR 0027) + +A restaurant homepage should show a few real dishes, and they must stay live as the +menu changes. The platform-honest way (no core change, no restaurant logic leaking +into core) is the **view-data hinge**: + +- The restaurant plugin registers a `ViewDataContributor` via + `PluginContext::viewData()`. +- Its `data(PageContext $page)` returns `[]` unless `$page->kind === 'home'`; on the + home page it returns `['featured' => [ {title, price, category, blurb}, … ]]` — a + **handful** of published menu items read through the plugin's existing + `ContentReader` (`$context->content()`), which is **published-only** and + **visitor-independent** (safe to bake into the by-path page cache). +- The result reaches the theme namespaced as + `contrib['danmat.restaurant']['featured']`. The theme **escapes every value on + render** (it is data, not HTML). + +Selection rule for "featured": the first *N* (=3) published menu items that have a +non-empty description (`body`), falling back to the first *N* items — deterministic, +cache-stable, no per-visitor state. + +Why not just query the menu in the template? Themes get only the current page's +view-model; cross-collection reads are a plugin concern. The hinge is exactly this +seam, and using it keeps the theme dumb and the data live. + +## Theme changes + +- **`templates/entry-home.php`** (new) — the homepage, sections in order: + 1. **Hero** — kicker, title, tagline; full-width dark band, gold rule, a "View + the menu" button (→ `/menu_items`) and a "Call to reserve" button (→ `tel:`). + 2. **About** — `about_title` + `about_body`, measure-width column. + 3. **Featured** — 2–3 cards from `contrib['danmat.restaurant']['featured']` + (name · price · one-line blurb), a "See the full menu →" link. + 4. **Visit** — a two-column block: **Hours** (from `hours`) and **Find us** + (`address` + a `tel:` phone), on one column at ≤ 640px. + 5. **Reserve** — a closing CTA band (call to reserve). +- **`templates/header.php`** — nav becomes **Home · Menu** (and later Order). The + brand already links to `/`. +- **`assets/app.css`** — sections styled in the existing token system (no new + colours beyond the current palette); hero, cards grid, the two-column Visit block, + responsive at `40rem`. Reuse `.wrap`, `.eyebrow`, serif headings, dotted leaders. +- **`theme.json`** — document the new template + `nav`. + +## Seed (`deploy/seed-demo.php`) + +- Create the `home` single collection + its fields. +- Save the one home entry with real copy for "The Copper Table". +- Set `site.home` → `home` (was `menu_items`); keep title/description. +- Golden re-dump on the box so the hourly reset serves it. + +## Three-hat review (proportionate — no core change) + +**🧑‍💼 Product** — Real problem (the demo needs a credible restaurant front page), +for the demo's guests; keeps the menu live. Not over-built (no booking/ordering +here). ✅ + +**🏗️ Architect** — Classification: **theme + plugin + seed** in the app repo; zero +core change. The one design choice — homepage as a `single` collection + featured +via the view-data hinge — reuses shipped seams (ADR 0027, single-kind home) exactly +as intended, and is the smallest thing that keeps featured dishes live. No new +capability, no API surface frozen. A reusable *pattern* (a plugin lighting up a +theme homepage) but implemented entirely with existing hooks. ✅ + +**👷 Principal engineer** — Correctness: template degrades when any field/contrib is +empty; featured selection is deterministic (cache-stable). Perf: featured is a +handful, read once per cached page; no N+1. Testability: the contributor is a pure +`PageContext → array` unit (test: returns `[]` off-home, returns ≤3 published items +on-home, never a draft). Mobile: verify at 375px (hero, cards, two-column Visit +collapse). ✅ + +**🔒 Security lens (light — public read surface only):** all output is +escape-on-render (data, not HTML, per the hinge contract); the contributor is +**visitor-independent** (no `$_COOKIE`/`$_SESSION`/user) so nothing per-visitor is +baked into the shared page cache; ContentReader is published-only (no draft leak); +`tel:` uses an admin-entered phone, escaped. No write surface, no new route. Nothing +to block. (The real security work lands in Slice C2, which *does* add a public +write.) + +## Definition of done + +- `entry-home.php` renders all five sections; blanks degrade gracefully. +- Featured dishes come **live** from the menu via the contributor; editing a menu + item is reflected on the homepage after the page-cache TTL. +- Header nav = Home · Menu; menu still at `/menu_items`. +- Verified at desktop **and 375px** (no horizontal scroll; Visit collapses). +- Plugin CI green (contributor unit test); theme CI green (`php -l`). +- Seed creates `home` + sets `site.home`; box golden re-dumped. diff --git a/plugin/src/HomeViewData.php b/plugin/src/HomeViewData.php new file mode 100644 index 0000000..4199d82 --- /dev/null +++ b/plugin/src/HomeViewData.php @@ -0,0 +1,36 @@ + */ + public function data(PageContext $page): array + { + if ($page->kind !== 'home') { + return []; + } + $featured = $this->menu->featured($this->limit); + return $featured === [] ? [] : ['featured' => $featured]; + } +} diff --git a/plugin/src/Menu.php b/plugin/src/Menu.php index 7445d1e..6d7b1be 100644 --- a/plugin/src/Menu.php +++ b/plugin/src/Menu.php @@ -45,6 +45,37 @@ public function items(): array return $out; } + /** + * A handful of items for the public homepage: name, price, category and a + * short blurb (the item's `body`). Items that HAVE a blurb come first (a + * homepage reads better with descriptions), then the rest, capped at $limit. + * Order within each group follows the collection read order, so the result is + * deterministic and visitor-independent — safe to bake into the page cache + * (ADR 0027). + * + * @return list + */ + public function featured(int $limit = 3): array + { + $blurbed = []; + $plain = []; + foreach (($this->reader)()->entries(self::COLLECTION, 500) as $entry) { + $fields = is_array($entry['fields'] ?? null) ? $entry['fields'] : []; + $row = [ + 'name' => (string) ($entry['title'] ?? ''), + 'price' => $this->price($entry), + 'category' => $this->category($entry), + 'blurb' => trim((string) ($fields['body'] ?? '')), + ]; + if ($row['blurb'] !== '') { + $blurbed[] = $row; + } else { + $plain[] = $row; + } + } + return array_slice(array_merge($blurbed, $plain), 0, max(0, $limit)); + } + /** * The name + unit price to snapshot onto an order line, for one menu item id, * or null if there is no such published item. diff --git a/plugin/src/MenuSource.php b/plugin/src/MenuSource.php index 36d5077..392b270 100644 --- a/plugin/src/MenuSource.php +++ b/plugin/src/MenuSource.php @@ -5,17 +5,26 @@ namespace DanMat\Restaurant; /** - * The menu as the order surfaces need it — a pickable list. A seam over {@see Menu} - * (which reads the menu collection through the core content-read capability), so the - * admin and MCP surfaces depend on this, not on core content, and a test can supply - * a canned menu without a collection. + * The menu as the app's surfaces need it. A seam over {@see Menu} (which reads the + * menu collection through the core content-read capability), so the admin, MCP and + * public surfaces depend on this, not on core content, and a test can supply a + * canned menu without a collection. */ interface MenuSource { /** - * The menu items available to order. + * The menu items available to order — a pickable list. * * @return list */ public function items(): array; + + /** + * A handful of items to feature on the public homepage — items with a + * description first, then others, capped at $limit. Deterministic and + * visitor-independent (safe for the page cache; see ADR 0027). + * + * @return list + */ + public function featured(int $limit = 3): array; } diff --git a/plugin/src/RestaurantPlugin.php b/plugin/src/RestaurantPlugin.php index 85f6eda..c7c04e1 100644 --- a/plugin/src/RestaurantPlugin.php +++ b/plugin/src/RestaurantPlugin.php @@ -57,6 +57,11 @@ public function register(PluginContext $context): void // The agent surface — every tool gates on danmat.restaurant:read|write (ADR 0016). $context->mcp()->register(new RestaurantToolset($tables, $orders, $menu, $reservations, $reports)); + // The public homepage's live "featured dishes" — the plugin feeds the theme + // a handful of menu items via the view-data hinge (ADR 0027). Data only, + // home page only, visitor-independent (cache-safe); the theme escapes it. + $context->viewData()->register(new HomeViewData($menu)); + // The floor board. A staff terminal is a capability-gated ADMIN PAGE, never a // public plugin route (routes carry no auth/CSRF). Gated on :write; the handler // gets the CSP nonce (2nd arg) and a CSRF token (3rd arg). diff --git a/plugin/tests/HomeViewDataTest.php b/plugin/tests/HomeViewDataTest.php new file mode 100644 index 0000000..433ed5b --- /dev/null +++ b/plugin/tests/HomeViewDataTest.php @@ -0,0 +1,82 @@ + $featured */ + private function menu(array $featured): MenuSource + { + return new class ($featured) implements MenuSource { + /** @param list $featured */ + public function __construct(private array $featured) + { + } + + public function items(): array + { + return []; + } + + public function featured(int $limit = 3): array + { + return array_slice($this->featured, 0, $limit); + } + }; + } + + private function context(string $kind): PageContext + { + return new PageContext($kind, 'https://x.test/', 'T', 'Site', 'nonce'); + } + + public function test_it_contributes_featured_dishes_on_the_home_page(): void + { + $menu = $this->menu([ + ['name' => 'Grilled Salmon', 'price' => '9.99', 'category' => 'Main Course', 'blurb' => 'Seasonal greens.'], + ]); + $data = (new HomeViewData($menu))->data($this->context('home')); + + self::assertArrayHasKey('featured', $data); + self::assertSame('Grilled Salmon', $data['featured'][0]['name']); + } + + public function test_it_contributes_nothing_off_the_home_page(): void + { + $menu = $this->menu([ + ['name' => 'Grilled Salmon', 'price' => '9.99', 'category' => 'Main Course', 'blurb' => 'x'], + ]); + $contributor = new HomeViewData($menu); + + self::assertSame([], $contributor->data($this->context('collection')), 'no data on a collection page'); + self::assertSame([], $contributor->data($this->context('entry')), 'no data on an entry page'); + } + + public function test_an_empty_menu_contributes_nothing_even_on_home(): void + { + $data = (new HomeViewData($this->menu([])))->data($this->context('home')); + self::assertSame([], $data, 'no featured key when there is nothing to feature'); + } + + public function test_the_limit_is_honoured(): void + { + $items = []; + foreach (['A', 'B', 'C', 'D', 'E'] as $n) { + $items[] = ['name' => $n, 'price' => '1.00', 'category' => null, 'blurb' => '']; + } + $data = (new HomeViewData($this->menu($items), 3))->data($this->context('home')); + self::assertCount(3, $data['featured']); + } +} diff --git a/plugin/tests/OrdersAdminTest.php b/plugin/tests/OrdersAdminTest.php index f19dcb9..663e820 100644 --- a/plugin/tests/OrdersAdminTest.php +++ b/plugin/tests/OrdersAdminTest.php @@ -49,6 +49,11 @@ public function items(): array { return [['id' => 101, 'name' => 'Margherita', 'price' => '12.50', 'category' => 'Mains']]; } + + public function featured(int $limit = 3): array + { + return []; + } }; $this->admin = new OrdersAdmin($this->orders, $this->tables, $menu); } diff --git a/theme/assets/app.css b/theme/assets/app.css index 67f00f2..ab640ea 100644 --- a/theme/assets/app.css +++ b/theme/assets/app.css @@ -59,6 +59,7 @@ img { max-width: 100%; height: auto; } padding: .3rem .5rem; border-radius: 5px; } .brand-name { font-family: var(--serif); font-size: 1.15rem; letter-spacing: .01em; } +.site-nav { display: flex; gap: 1.4rem; } .site-nav a { font-size: .95rem; letter-spacing: .04em; text-transform: uppercase; } /* Menu page */ @@ -87,6 +88,62 @@ img { max-width: 100%; height: auto; } .menu-desc { margin: .3rem 0 0; color: var(--muted); font-size: .92rem; max-width: 46ch; } .empty { color: var(--muted); text-align: center; padding: 2rem 0; } +/* Homepage */ +.btn { + display: inline-block; padding: .7rem 1.3rem; border-radius: 6px; + font-size: .82rem; letter-spacing: .12em; text-transform: uppercase; font-weight: 700; + border: 1px solid transparent; transition: background-color .15s, color .15s, border-color .15s; +} +.btn-gold { background: var(--gold); color: #1a1c20; } +.btn-gold:hover { background: var(--gold-bright); color: #1a1c20; } +.btn-ghost { border-color: var(--rule); color: var(--text); } +.btn-ghost:hover { border-color: var(--gold); color: var(--gold-bright); } + +.hero { + border-bottom: 1px solid var(--rule); + background: + radial-gradient(1200px 400px at 50% -120px, rgba(212, 160, 23, 0.10), transparent 70%), + var(--ground); +} +.hero-inner { text-align: center; padding: 5.5rem 0 4.5rem; } +.hero-title { font-family: var(--serif); font-weight: 400; font-size: 3.4rem; line-height: 1.05; margin: 0 0 1rem; text-wrap: balance; } +.hero-tagline { color: var(--muted); font-size: 1.15rem; max-width: 34rem; margin: 0 auto 1.9rem; } +.hero-actions { display: flex; gap: .8rem; justify-content: center; flex-wrap: wrap; margin: 0; } + +.home-section { padding: 3.75rem 0; border-bottom: 1px solid var(--rule); } +.section-head { text-align: center; margin-bottom: 2.25rem; } +.section-title { font-family: var(--serif); font-weight: 400; font-size: 1.9rem; margin: 0 0 1rem; } +.about { text-align: center; } +.about .section-title { margin-bottom: 1rem; } +.about-body { color: var(--muted); font-size: 1.08rem; margin: 0; } + +.dish-grid { + list-style: none; margin: 0; padding: 0; + display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.25rem; +} +.dish { background: var(--surface); border: 1px solid var(--rule); border-radius: 10px; padding: 1.4rem; } +.dish-cat { margin: 0 0 .5rem; font-size: .68rem; font-weight: 700; letter-spacing: .18em; text-transform: uppercase; color: var(--gold); } +.dish-row { display: flex; align-items: baseline; justify-content: space-between; gap: .6rem; } +.dish-name { font-family: var(--serif); font-size: 1.2rem; } +.dish-price { font-variant-numeric: tabular-nums; color: var(--gold); font-weight: 600; white-space: nowrap; } +.dish-blurb { margin: .55rem 0 0; color: var(--muted); font-size: .92rem; } +.section-more { text-align: center; margin: 2rem 0 0; } +.section-more a { letter-spacing: .04em; text-transform: uppercase; font-size: .9rem; } + +.visit-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 2.5rem; } +.visit-col .section-title { font-size: 1.5rem; } +.visit-body { color: var(--muted); margin: 0 0 .5rem; } + +.reserve { text-align: center; border-bottom: 0; } +.reserve-lede { color: var(--muted); margin: 0 0 1.6rem; } + +@media (max-width: 40rem) { + .hero-title { font-size: 2.5rem; } + .hero-inner { padding: 4rem 0 3.25rem; } + .dish-grid { grid-template-columns: 1fr; } + .visit-grid { grid-template-columns: 1fr; gap: 1.75rem; } +} + /* Generic page + 404 */ .page { padding: 3rem 0 4rem; } .page h1 { font-family: var(--serif); font-weight: 400; font-size: 2.2rem; margin: 0 0 1rem; } diff --git a/theme/templates/entry-home.php b/theme/templates/entry-home.php new file mode 100644 index 0000000..5eeaf5f --- /dev/null +++ b/theme/templates/entry-home.php @@ -0,0 +1,115 @@ + $entry the home entry view-model + * @var array> $contrib namespaced plugin view-data + * @var string $appName the site name + * @var callable $e escape a value for output + */ +$fields = is_array($entry['fields'] ?? null) ? $entry['fields'] : []; +$f = static fn (string $k): string => trim((string) ($fields[$k] ?? '')); + +$heroKicker = $f('hero_kicker'); +$heroTitle = $f('hero_title') !== '' ? $f('hero_title') : $appName; +$heroTagline = $f('hero_tagline'); +$aboutTitle = $f('about_title'); +$aboutBody = $f('about_body'); +$hours = $f('hours'); +$address = $f('address'); +$phone = $f('phone'); + +$featured = $contrib['danmat.restaurant']['featured'] ?? []; +$featured = is_array($featured) ? $featured : []; + +// tel: href — digits and a leading + only, so an admin-entered phone is a safe URL. +$telHref = $phone !== '' ? 'tel:' . preg_replace('/[^0-9+]/', '', $phone) : ''; +?> +
+
+

+

+

+

+ View the menu + Call to reserve +

+
+
+ + +
+
+

+

+
+
+ + + + + + + +
+
+ +
+

Hours

+

+
+ + +
+

Find us

+

+

+
+ +
+
+ + +
+
+

Join us

+

Walk-ins welcome. For a table.

+

+ Call to reserve + Browse the menu +

+
+
diff --git a/theme/templates/header.php b/theme/templates/header.php index 0c33a1b..b203f6e 100644 --- a/theme/templates/header.php +++ b/theme/templates/header.php @@ -14,6 +14,7 @@ diff --git a/theme/templates/layout.php b/theme/templates/layout.php index 3bcae66..a7de0dd 100644 --- a/theme/templates/layout.php +++ b/theme/templates/layout.php @@ -11,7 +11,9 @@ * @var array $meta * @var string $head extra HTML contributed by plugins (already-rendered, trusted) */ -$pageTitle = isset($title) && $title !== '' ? $title . ' · ' . $appName : $appName; +// Append the site name unless the page title already is it (the homepage entry is +// titled after the restaurant, so this avoids "Name · Name"). +$pageTitle = isset($title) && $title !== '' && $title !== $appName ? $title . ' · ' . $appName : $appName; $meta = $meta ?? []; $cssVer = substr((string) @hash_file('crc32b', __DIR__ . '/../assets/app.css'), 0, 8); ?> diff --git a/theme/theme.json b/theme/theme.json index d754a80..4babccd 100644 --- a/theme/theme.json +++ b/theme/theme.json @@ -2,15 +2,16 @@ "name": "RAS", "version": "0.1.0", "description": "The public face of the Restaurant Automation System — a dark, elegant guest menu carrying the RAS identity. Plain PHP templates, one stylesheet, no build step.", - "nav": ["menu_items"], + "nav": ["home", "menu_items"], "templates": { "layout": "HTML shell; includes header and footer.", - "header": "Site header with the RAS wordmark and the menu link.", + "header": "Site header with the RAS wordmark and the Home/Menu links.", "footer": "Footer with hours and contact.", + "entry-home": "The homepage: the `home` single-collection entry, rendered as a restaurant front page (hero, about, featured dishes, hours/location, reservations CTA).", "collection-menu_items": "The guest menu (the `menu_items` collection), grouped by category with prices.", "entry": "A single info page.", "404": "Themed not-found page." }, - "specialization": "collection-menu_items renders the `menu_items` collection provisioned by the restaurant app — each item has a `price` number and a `category` relation (expanded). Grouped by category name, priced, with dotted leaders.", + "specialization": "entry-home renders the `home` single collection (fields: hero_kicker/hero_title/hero_tagline, about_title/about_body, hours, address, phone) and shows live featured dishes contributed by the restaurant plugin under `contrib['danmat.restaurant']['featured']` (ADR 0027). collection-menu_items renders the `menu_items` collection — each item has a `price` number and a `category` relation (expanded), grouped by category name, priced, with dotted leaders.", "assets": "Files under assets/ are served at /theme/assets/ (e.g. assets/app.css -> /theme/assets/app.css)." }