Example project · read-only
Restaurant / local business
Menu, hours, gallery and table reservations for a restaurant, cafe or neighborhood shop. This is GridFlow's Restaurant starter, a complete project the builder starts from. Ember & Oak is a made-up kitchen. Click around: the menu filters, gallery and reservation form all work. Dashed boxes mark where a photo goes: in the builder you upload yours or have GridFlow make one.
Build your own — sign up
Describe your own in a sentence and GridFlow builds it. No card to start.
See the files (5) — read-only
package.json
{
"name": "ember-and-oak",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"vite": "^5.4.0"
}
}
index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Ember & Oak — a wood-fired neighborhood kitchen. Menu, hours, gallery and table reservations." />
<title>Ember & Oak — Wood-fired kitchen</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600&family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
</head>
<body>
<a class="skip-link" href="#menu">Skip to menu</a>
<header class="site-header" id="top">
<div class="wrap header-row">
<a class="brand" href="#top" aria-label="Ember and Oak, home">
<span class="brand-mark" aria-hidden="true"></span>
<span class="brand-name">Ember <em>&</em> Oak</span>
</a>
<nav class="site-nav" aria-label="Primary">
<a href="#menu">Menu</a>
<a href="#gallery">Gallery</a>
<a href="#reserve">Reserve</a>
<a href="#visit">Visit</a>
</nav>
<a class="btn btn-primary btn-sm" href="#reserve">Book a table</a>
</div>
</header>
<main>
<section class="hero" aria-labelledby="hero-title">
<div class="hero-photo gf-placeholder" data-placeholder="1" role="img" aria-label="Placeholder 1 — the hero photo behind the headline">
<span class="gf-ph-badge">Placeholder 1 · upload a photo, or ask Grid: “create an image for placeholder 1”</span>
</div>
<div class="wrap hero-grid">
<div class="hero-copy">
<p class="eyebrow">Wood-fired · Seasonal · Neighborhood</p>
<h1 id="hero-title">Slow fire.<br />Honest plates.</h1>
<p class="lede">A forty-seat kitchen built around one oak-burning oven. Sourdough, whole roasted vegetables, and cuts we break down ourselves.</p>
<div class="hero-actions">
<a class="btn btn-primary" href="#reserve">Reserve a table</a>
<a class="btn btn-ghost" href="#menu">See the menu</a>
</div>
</div>
<aside class="hero-card" aria-label="Hours and address">
<div class="status-row">
<span class="status-dot" id="status-dot" aria-hidden="true"></span>
<span id="status-text">Checking hours…</span>
</div>
<dl class="hero-meta">
<div>
<dt>Today</dt>
<dd id="today-hours">—</dd>
</div>
<div>
<dt>Address</dt>
<dd>412 Larkspur Street<br />Old Mill District, Portland</dd>
</div>
<div>
<dt>Phone</dt>
<dd><a href="tel:+15035550148">(503) 555-0148</a></dd>
</div>
</dl>
</aside>
</div>
</section>
<section class="section" id="menu" aria-labelledby="menu-title">
<div class="wrap">
<div class="section-head">
<p class="eyebrow">The menu</p>
<h2 id="menu-title">Cooked over oak, changed with the season</h2>
</div>
<div class="menu-controls">
<div class="tabs" role="tablist" aria-label="Menu categories" id="menu-tabs"></div>
<fieldset class="diet-filter" id="diet-filter">
<legend>Show only</legend>
</fieldset>
</div>
<div id="menu-panels"></div>
<p class="legend" id="diet-legend"></p>
</div>
</section>
<section class="section section-alt" id="gallery" aria-labelledby="gallery-title">
<div class="wrap">
<div class="section-head">
<p class="eyebrow">The room</p>
<h2 id="gallery-title">Forty seats, one fire</h2>
</div>
<ul class="gallery-grid" id="gallery-grid"></ul>
</div>
</section>
<section class="section" id="reserve" aria-labelledby="reserve-title">
<div class="wrap reserve-grid">
<div class="reserve-copy">
<p class="eyebrow">Reservations</p>
<h2 id="reserve-title">Save a seat by the oven</h2>
<p>Tables are held for 15 minutes past the booking time. Parties of nine or more, call us and we will set the long table.</p>
<ul class="reserve-notes">
<li>Walk-ins welcome at the counter</li>
<li>Full menu until close</li>
<li>Wheelchair accessible, step-free entry</li>
</ul>
</div>
<form class="card reserve-form" id="reserve-form" novalidate>
<div class="field-grid">
<div class="field">
<label for="r-name">Name</label>
<input id="r-name" name="name" type="text" autocomplete="name" required />
<p class="error" id="err-name" aria-live="polite"></p>
</div>
<div class="field">
<label for="r-email">Email</label>
<input id="r-email" name="email" type="email" autocomplete="email" required />
<p class="error" id="err-email" aria-live="polite"></p>
</div>
<div class="field">
<label for="r-phone">Phone</label>
<input id="r-phone" name="phone" type="tel" autocomplete="tel" inputmode="tel" placeholder="(503) 555-0100" />
<p class="error" id="err-phone" aria-live="polite"></p>
</div>
<div class="field">
<label for="r-date">Date</label>
<input id="r-date" name="date" type="date" required />
<p class="error" id="err-date" aria-live="polite"></p>
</div>
<div class="field">
<label for="r-time">Time</label>
<select id="r-time" name="time" required>
<option value="">Choose a date first</option>
</select>
<p class="error" id="err-time" aria-live="polite"></p>
</div>
<div class="field">
<label for="r-party">Party size</label>
<select id="r-party" name="party" required>
<option value="">Select</option>
<option value="1">1 guest</option>
<option value="2" selected>2 guests</option>
<option value="3">3 guests</option>
<option value="4">4 guests</option>
<option value="5">5 guests</option>
<option value="6">6 guests</option>
<option value="7">7 guests</option>
<option value="8">8 guests</option>
<option value="9">9+ (we will call you)</option>
</select>
<p class="error" id="err-party" aria-live="polite"></p>
</div>
<div class="field field-full">
<label for="r-notes">Anything we should know? <span class="optional">Optional</span></label>
<textarea id="r-notes" name="notes" rows="3" placeholder="Allergies, a birthday, a high chair…"></textarea>
</div>
</div>
<div class="form-actions">
<button class="btn btn-primary" type="submit">Request reservation</button>
<p class="form-hint">No payment needed. Saved on this device only.</p>
</div>
<p class="error form-error" id="form-error" role="alert"></p>
</form>
<div class="card confirmation" id="confirmation" hidden>
<div class="confirm-check" aria-hidden="true">✓</div>
<h3>You are on the list</h3>
<p class="confirm-lead" id="confirm-lead"></p>
<dl class="confirm-details" id="confirm-details"></dl>
<p class="confirm-ref">Reference <strong id="confirm-ref"></strong></p>
<div class="confirm-actions">
<button class="btn btn-ghost" type="button" id="confirm-new">Make another booking</button>
<button class="btn btn-danger" type="button" id="confirm-cancel">Cancel this booking</button>
</div>
</div>
</div>
</section>
<section class="section section-alt" id="visit" aria-labelledby="visit-title">
<div class="wrap visit-grid">
<div class="map-placeholder" id="map" role="img" aria-label="Map placeholder showing the restaurant location in the Old Mill District">
<div class="map-roads" aria-hidden="true"></div>
<div class="map-river" aria-hidden="true"></div>
<div class="map-pin" aria-hidden="true"><span></span></div>
<div class="map-label" aria-hidden="true">Ember & Oak</div>
</div>
<div class="visit-copy">
<p class="eyebrow">Find us</p>
<h2 id="visit-title">Two blocks from the river</h2>
<address>
412 Larkspur Street<br />
Old Mill District, Portland, OR 97209
</address>
<p>Street parking after 6pm. The Larkspur streetcar stop is at our corner. Bike racks under the awning.</p>
<a class="btn btn-ghost" href="https://www.google.com/maps/search/?api=1&query=412+Larkspur+Street+Portland" target="_blank" rel="noopener">Get directions</a>
</div>
</div>
</section>
</main>
<footer class="site-footer">
<div class="wrap footer-grid">
<div>
<p class="brand-name footer-brand">Ember <em>&</em> Oak</p>
<p class="muted">A wood-fired neighborhood kitchen. Est. 2019.</p>
<p class="muted"><a href="mailto:hello@emberandoak.example">hello@emberandoak.example</a><br /><a href="tel:+15035550148">(503) 555-0148</a></p>
</div>
<div>
<h3 class="footer-title">Hours</h3>
<table class="hours-table" id="hours-table">
<caption class="sr-only">Opening hours by day</caption>
<tbody></tbody>
</table>
</div>
<div>
<h3 class="footer-title">Visit</h3>
<p class="muted">412 Larkspur Street<br />Old Mill District<br />Portland, OR 97209</p>
<p class="muted">Kitchen closes 30 minutes before the room.</p>
</div>
</div>
<div class="wrap footer-bottom">
<p class="muted">Sample content for a template — menu, hours and contact details are placeholders.</p>
<a class="muted" href="#top">Back to top ↑</a>
</div>
</footer>
<div class="lightbox" id="lightbox" hidden>
<div class="lightbox-backdrop" data-close></div>
<div class="lightbox-dialog" role="dialog" aria-modal="true" aria-labelledby="lightbox-caption">
<button class="lightbox-close" type="button" data-close aria-label="Close gallery">×</button>
<div class="lightbox-photo" id="lightbox-photo"></div>
<div class="lightbox-bar">
<button class="lightbox-nav" type="button" id="lightbox-prev" aria-label="Previous photo">←</button>
<p id="lightbox-caption"></p>
<button class="lightbox-nav" type="button" id="lightbox-next" aria-label="Next photo">→</button>
</div>
</div>
</div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
src/main.js
import "./style.css";
import { business, hours, dietTags, menu, gallery } from "./data.js";
const STORAGE_KEY = "ember-oak.reservation";
const $ = (sel, root) => (root || document).querySelector(sel);
function el(tag, attrs, children) {
const node = document.createElement(tag);
if (attrs) {
for (const key of Object.keys(attrs)) {
const val = attrs[key];
if (val == null || val === false) continue;
if (key === "class") node.className = val;
else if (key === "text") node.textContent = val;
else if (key.startsWith("on")) node.addEventListener(key.slice(2), val);
else node.setAttribute(key, val === true ? "" : val);
}
}
(children || []).forEach((c) => node.append(c));
return node;
}
/* ---------- Hours + open status ---------- */
function toMinutes(hhmm) {
const parts = hhmm.split(":");
return Number(parts[0]) * 60 + Number(parts[1]);
}
function fmtTime(hhmm) {
const m = toMinutes(hhmm);
const h24 = Math.floor(m / 60);
const mins = m % 60;
const h12 = h24 % 12 === 0 ? 12 : h24 % 12;
const suffix = h24 < 12 ? "am" : "pm";
return h12 + (mins ? ":" + String(mins).padStart(2, "0") : "") + suffix;
}
function hoursLabel(day) {
return day.open ? fmtTime(day.open) + " – " + fmtTime(day.close) : "Closed";
}
function renderHours() {
const now = new Date();
const todayIdx = now.getDay();
const today = hours[todayIdx];
$("#today-hours").textContent = hoursLabel(today);
const dot = $("#status-dot");
const text = $("#status-text");
const nowMin = now.getHours() * 60 + now.getMinutes();
if (today.open && nowMin >= toMinutes(today.open) && nowMin < toMinutes(today.close)) {
dot.className = "status-dot is-open";
text.textContent = "Open now · closes " + fmtTime(today.close);
} else if (today.open && nowMin < toMinutes(today.open)) {
dot.className = "status-dot is-closed";
text.textContent = "Opens today at " + fmtTime(today.open);
} else {
let next = (todayIdx + 1) % 7;
while (!hours[next].open) next = (next + 1) % 7;
dot.className = "status-dot is-closed";
text.textContent = "Closed · opens " + hours[next].day + " " + fmtTime(hours[next].open);
}
// Footer table starts on Monday for readability.
const tbody = $("#hours-table tbody");
tbody.replaceChildren();
for (let i = 1; i <= 7; i++) {
const idx = i % 7;
const row = el("tr", { class: idx === todayIdx ? "is-today" : null }, [
el("td", { text: hours[idx].day }),
el("td", { text: hoursLabel(hours[idx]) }),
]);
tbody.append(row);
}
}
/* ---------- Menu tabs + dietary filter ---------- */
const activeDiets = new Set();
function renderMenu() {
const tabs = $("#menu-tabs");
const panels = $("#menu-panels");
tabs.replaceChildren();
panels.replaceChildren();
menu.forEach((cat, i) => {
const tab = el("button", {
class: "tab",
role: "tab",
id: "tab-" + cat.id,
"aria-selected": i === 0 ? "true" : "false",
"aria-controls": "panel-" + cat.id,
tabindex: i === 0 ? "0" : "-1",
text: cat.label,
onclick: () => selectTab(cat.id),
onkeydown: onTabKey,
});
tabs.append(tab);
const panel = el("div", {
class: "menu-panel",
role: "tabpanel",
id: "panel-" + cat.id,
"aria-labelledby": "tab-" + cat.id,
tabindex: "0",
hidden: i !== 0,
});
panels.append(panel);
});
const filter = $("#diet-filter");
Object.keys(dietTags).forEach((key) => {
const input = el("input", { type: "checkbox", value: key, onchange: onDietChange });
filter.append(el("label", { class: "chip" }, [input, el("span", { text: dietTags[key].label })]));
});
$("#diet-legend").textContent =
"Dietary key: " +
Object.keys(dietTags).map((k) => dietTags[k].short + " " + dietTags[k].label).join(" · ") +
". Prices are sample data.";
fillPanels();
}
function fillPanels() {
menu.forEach((cat) => {
const panel = $("#panel-" + cat.id);
panel.replaceChildren();
const list = el("ul", { class: "menu-list" });
const items = cat.items.filter((item) => [...activeDiets].every((d) => item.tags.includes(d)));
if (!items.length) {
list.append(el("li", { class: "menu-empty", text: "Nothing in " + cat.label.toLowerCase() + " matches every selected filter yet — ask us, the kitchen adapts most plates." }));
}
items.forEach((item) => {
const tags = el("div", { class: "tags" }, item.tags.map((t) => el("span", { class: "tag", title: dietTags[t].label, "aria-label": dietTags[t].label, text: dietTags[t].short })));
list.append(el("li", { class: "menu-item" }, [
el("span", { class: "name", text: item.name }),
el("span", { class: "price", text: "$" + item.price }),
el("span", { class: "desc", text: item.desc }),
tags,
]));
});
panel.append(list);
});
}
function selectTab(id) {
menu.forEach((cat) => {
const on = cat.id === id;
const tab = $("#tab-" + cat.id);
tab.setAttribute("aria-selected", on ? "true" : "false");
tab.tabIndex = on ? 0 : -1;
$("#panel-" + cat.id).hidden = !on;
if (on) tab.focus();
});
}
function onTabKey(e) {
const ids = menu.map((c) => c.id);
const current = ids.indexOf(e.currentTarget.id.replace("tab-", ""));
let next = null;
if (e.key === "ArrowRight") next = (current + 1) % ids.length;
if (e.key === "ArrowLeft") next = (current - 1 + ids.length) % ids.length;
if (e.key === "Home") next = 0;
if (e.key === "End") next = ids.length - 1;
if (next !== null) {
e.preventDefault();
selectTab(ids[next]);
}
}
function onDietChange(e) {
if (e.target.checked) activeDiets.add(e.target.value);
else activeDiets.delete(e.target.value);
fillPanels();
}
/* ---------- Gallery + lightbox ---------- */
let lightboxIndex = 0;
let lastFocus = null;
function renderGallery() {
const grid = $("#gallery-grid");
grid.replaceChildren();
gallery.forEach((photo, i) => {
const btn = el("button", { class: "gallery-btn", type: "button", "aria-label": "Open photo: " + photo.caption, onclick: () => openLightbox(i) }, [
el("div", { class: "photo photo-" + photo.hue, "aria-hidden": "true" }),
el("div", { class: "cap", text: photo.caption }),
]);
grid.append(el("li", null, [btn]));
});
const box = $("#lightbox");
box.querySelectorAll("[data-close]").forEach((n) => n.addEventListener("click", closeLightbox));
$("#lightbox-prev").addEventListener("click", () => showPhoto(lightboxIndex - 1));
$("#lightbox-next").addEventListener("click", () => showPhoto(lightboxIndex + 1));
box.addEventListener("keydown", (e) => {
if (e.key === "Escape") closeLightbox();
if (e.key === "ArrowLeft") showPhoto(lightboxIndex - 1);
if (e.key === "ArrowRight") showPhoto(lightboxIndex + 1);
if (e.key === "Tab") trapFocus(e, box);
});
}
function trapFocus(e, root) {
const focusables = root.querySelectorAll("button");
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
}
function showPhoto(i) {
lightboxIndex = (i + gallery.length) % gallery.length;
const photo = gallery[lightboxIndex];
$("#lightbox-photo").className = "lightbox-photo photo-" + photo.hue;
$("#lightbox-caption").textContent = (lightboxIndex + 1) + " / " + gallery.length + " — " + photo.caption;
}
function openLightbox(i) {
lastFocus = document.activeElement;
showPhoto(i);
$("#lightbox").hidden = false;
document.body.style.overflow = "hidden";
$("#lightbox-next").focus();
}
function closeLightbox() {
$("#lightbox").hidden = true;
document.body.style.overflow = "";
if (lastFocus) lastFocus.focus();
}
/* ---------- Reservation form ---------- */
function isoDate(d) {
return d.getFullYear() + "-" + String(d.getMonth() + 1).padStart(2, "0") + "-" + String(d.getDate()).padStart(2, "0");
}
function parseDate(str) {
const p = str.split("-").map(Number);
return new Date(p[0], p[1] - 1, p[2]);
}
function slotsFor(dateStr) {
const day = hours[parseDate(dateStr).getDay()];
if (!day.open) return [];
const slots = [];
const last = toMinutes(day.close) - business.lastSeatingOffset;
const isToday = dateStr === isoDate(new Date());
const now = new Date();
const nowMin = now.getHours() * 60 + now.getMinutes();
for (let m = toMinutes(day.open); m <= last; m += business.slotMinutes) {
if (isToday && m <= nowMin + 30) continue;
const hh = String(Math.floor(m / 60)).padStart(2, "0");
const mm = String(m % 60).padStart(2, "0");
slots.push(hh + ":" + mm);
}
return slots;
}
function populateTimes() {
const dateInput = $("#r-date");
const timeSelect = $("#r-time");
timeSelect.replaceChildren();
const value = dateInput.value;
if (!value) {
timeSelect.append(el("option", { value: "", text: "Choose a date first" }));
return;
}
const slots = slotsFor(value);
if (!slots.length) {
const day = hours[parseDate(value).getDay()];
timeSelect.append(el("option", { value: "", text: day.open ? "No seatings left that day" : "Closed on " + day.day + "s" }));
return;
}
timeSelect.append(el("option", { value: "", text: "Select a time" }));
slots.forEach((s) => timeSelect.append(el("option", { value: s, text: fmtTime(s) })));
}
function setError(field, message) {
const input = $("#r-" + field);
const err = $("#err-" + field);
err.textContent = message || "";
input.setAttribute("aria-invalid", message ? "true" : "false");
return !message;
}
function validate(data) {
let ok = true;
ok = setError("name", data.name.trim().length >= 2 ? "" : "Please tell us who the table is for.") && ok;
ok = setError("email", /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email.trim()) ? "" : "Enter a valid email so we can confirm.") && ok;
const digits = data.phone.replace(/\D/g, "");
ok = setError("phone", !data.phone.trim() || digits.length >= 10 ? "" : "Phone should have at least 10 digits.") && ok;
let dateMsg = "";
if (!data.date) dateMsg = "Pick a date.";
else if (data.date < isoDate(new Date())) dateMsg = "That date has already passed.";
else if (!hours[parseDate(data.date).getDay()].open) dateMsg = "We are closed that day. Try Tuesday through Sunday.";
ok = setError("date", dateMsg) && ok;
ok = setError("time", data.time ? "" : "Choose a seating time.") && ok;
let partyMsg = "";
if (!data.party) partyMsg = "How many guests?";
else if (Number(data.party) >= 9) partyMsg = "For nine or more, call " + business.phone + " and we will set the long table.";
ok = setError("party", partyMsg) && ok;
return ok;
}
function makeRef() {
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
let out = "EO-";
for (let i = 0; i < 6; i++) out += chars[Math.floor(Math.random() * chars.length)];
return out;
}
function longDate(dateStr) {
return parseDate(dateStr).toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric" });
}
function showConfirmation(res) {
$("#reserve-form").hidden = true;
const card = $("#confirmation");
card.hidden = false;
$("#confirm-lead").textContent = "Thanks, " + res.name.split(" ")[0] + ". We will confirm by email at " + res.email + ".";
const details = $("#confirm-details");
details.replaceChildren();
const rows = [
["When", longDate(res.date) + " · " + fmtTime(res.time)],
["Guests", res.party + (Number(res.party) === 1 ? " guest" : " guests")],
["Where", business.address],
["Notes", res.notes.trim() || "None"],
];
rows.forEach((r) => details.append(el("div", null, [el("dt", { text: r[0] }), el("dd", { text: r[1] })])));
$("#confirm-ref").textContent = res.ref;
card.focus({ preventScroll: true });
}
function loadSaved() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const res = JSON.parse(raw);
if (res.date < isoDate(new Date())) { localStorage.removeItem(STORAGE_KEY); return null; }
return res;
} catch (err) {
return null;
}
}
function saveReservation(res) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(res)); } catch (err) { /* storage unavailable */ }
}
function resetForm() {
const form = $("#reserve-form");
form.reset();
form.querySelectorAll("[aria-invalid]").forEach((n) => n.removeAttribute("aria-invalid"));
form.querySelectorAll(".error").forEach((n) => (n.textContent = ""));
$("#r-date").value = "";
populateTimes();
form.hidden = false;
$("#confirmation").hidden = true;
$("#r-name").focus();
}
function initReservation() {
const form = $("#reserve-form");
const dateInput = $("#r-date");
const today = new Date();
const max = new Date(today.getTime() + 60 * 24 * 60 * 60 * 1000);
dateInput.min = isoDate(today);
dateInput.max = isoDate(max);
dateInput.addEventListener("change", () => { populateTimes(); setError("date", ""); setError("time", ""); });
populateTimes();
["name", "email", "phone", "time", "party"].forEach((f) => {
$("#r-" + f).addEventListener("input", () => setError(f, ""));
});
form.addEventListener("submit", (e) => {
e.preventDefault();
const fd = new FormData(form);
const data = {
name: String(fd.get("name") || ""),
email: String(fd.get("email") || ""),
phone: String(fd.get("phone") || ""),
date: String(fd.get("date") || ""),
time: String(fd.get("time") || ""),
party: String(fd.get("party") || ""),
notes: String(fd.get("notes") || ""),
};
const formError = $("#form-error");
if (!validate(data)) {
formError.textContent = "A few fields need attention above.";
const firstBad = form.querySelector('[aria-invalid="true"]');
if (firstBad) firstBad.focus();
return;
}
formError.textContent = "";
const res = Object.assign({ ref: makeRef(), createdAt: new Date().toISOString() }, data);
saveReservation(res);
showConfirmation(res);
});
$("#confirm-new").addEventListener("click", resetForm);
$("#confirm-cancel").addEventListener("click", () => {
if (!window.confirm("Cancel this booking? You can always make a new one.")) return;
try { localStorage.removeItem(STORAGE_KEY); } catch (err) { /* ignore */ }
resetForm();
});
$("#confirmation").tabIndex = -1;
const saved = loadSaved();
if (saved) showConfirmation(saved);
}
/* ---------- Boot ---------- */
renderHours();
renderMenu();
renderGallery();
initReservation();
setInterval(renderHours, 60 * 1000);
src/style.css
:root {
--bg: #151312;
--bg-2: #1d1a18;
--bg-3: #262220;
--line: rgba(246, 239, 228, 0.12);
--line-strong: rgba(246, 239, 228, 0.24);
--ink: #f6efe4;
--ink-2: rgba(246, 239, 228, 0.72);
--ink-3: rgba(246, 239, 228, 0.6);
--accent: #e8a53a;
--accent-2: #f2bd66;
--accent-ink: #1a1408;
--danger: #e0644f;
--ok: #7fc38a;
--font-display: "Fraunces", "Iowan Old Style", "Palatino Linotype", Georgia, serif;
--font-body: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--radius: 12px;
--radius-lg: 20px;
--shadow: 0 24px 48px -24px rgba(0, 0, 0, 0.6);
--wrap: 1120px;
color-scheme: dark;
}
*, *::before, *::after { box-sizing: border-box; }
html { scroll-behavior: smooth; -webkit-text-size-adjust: 100%; }
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
*, *::before, *::after { transition: none !important; animation: none !important; }
}
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font-family: var(--font-body);
font-size: 16px;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
img { max-width: 100%; display: block; }
a { color: var(--accent-2); text-decoration: none; }
a:hover { text-decoration: underline; text-underline-offset: 3px; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 4px; }
button, input, select, textarea { font: inherit; color: inherit; }
button { cursor: pointer; }
h1, h2, h3 { font-family: var(--font-display); font-weight: 500; line-height: 1.1; margin: 0; letter-spacing: -0.01em; }
h1 { font-size: clamp(2.5rem, 6vw, 4.5rem); }
h2 { font-size: clamp(1.75rem, 3.5vw, 2.5rem); }
h3 { font-size: 1.375rem; }
p { margin: 0; }
.muted { color: var(--ink-2); }
.eyebrow {
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--accent);
margin-bottom: 16px;
}
.wrap { width: min(var(--wrap), calc(100% - 40px)); margin-inline: auto; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.skip-link {
position: absolute; left: 16px; top: -48px;
padding: 8px 16px; background: var(--accent); color: var(--accent-ink);
border-radius: 8px; z-index: 100; font-weight: 600;
}
.skip-link:focus { top: 16px; }
/* Buttons */
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
min-height: 48px; padding: 0 24px; border-radius: 999px;
font-weight: 600; font-size: 0.9375rem; border: 1px solid transparent;
transition: transform 0.15s ease, background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
text-decoration: none;
}
.btn:hover { text-decoration: none; transform: translateY(-1px); }
.btn:active { transform: translateY(0); }
.btn-sm { min-height: 40px; padding: 0 16px; font-size: 0.875rem; }
.btn-primary { background: var(--accent); color: var(--accent-ink); box-shadow: 0 8px 24px -12px rgba(232, 165, 58, 0.8); }
.btn-primary:hover { background: var(--accent-2); }
.btn-ghost { background: transparent; border-color: var(--line-strong); color: var(--ink); }
.btn-ghost:hover { border-color: var(--ink); background: rgba(246, 239, 228, 0.06); }
.btn-danger { background: transparent; border-color: rgba(224, 100, 79, 0.5); color: #f0907f; }
.btn-danger:hover { background: rgba(224, 100, 79, 0.12); border-color: var(--danger); }
/* Header */
.site-header {
position: sticky; top: 0; z-index: 50;
background: rgba(21, 19, 18, 0.82);
backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid var(--line);
}
.header-row { display: flex; align-items: center; gap: 24px; min-height: 72px; }
.brand { display: inline-flex; align-items: center; gap: 12px; color: var(--ink); }
.brand:hover { text-decoration: none; }
.brand-mark {
width: 32px; height: 32px; border-radius: 50%;
background: radial-gradient(circle at 40% 35%, #f8d38a 0%, var(--accent) 35%, #8a4a12 75%, #3b1d08 100%);
box-shadow: 0 0 0 4px rgba(232, 165, 58, 0.15);
}
.brand-name { font-family: var(--font-display); font-size: 1.25rem; font-weight: 600; letter-spacing: -0.01em; }
.brand-name em { font-style: italic; color: var(--accent); font-weight: 500; }
.site-nav { display: flex; gap: 8px; margin-left: auto; }
.site-nav a { color: var(--ink-2); padding: 8px 12px; border-radius: 8px; font-weight: 500; font-size: 0.9375rem; }
.site-nav a:hover { color: var(--ink); background: rgba(246, 239, 228, 0.06); text-decoration: none; }
/* Hero */
.hero { position: relative; padding: 80px 0 96px; overflow: hidden; isolation: isolate; }
/* The hero photo slot. The gradient stands in until a picture is placed; the badge says how. */
.gf-ph-badge {
position: absolute; top: 20px; right: 20px; z-index: 1; pointer-events: none;
font-size: 12px; line-height: 1.3; padding: 8px 12px; border-radius: 999px;
border: 1px dashed rgba(246, 239, 228, 0.38); color: rgba(246, 239, 228, 0.8); background: rgba(21, 19, 18, 0.55);
backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); max-width: min(60vw, 420px);
}
.hero-photo {
position: absolute; inset: 0; z-index: -1;
background:
radial-gradient(ellipse 60% 50% at 78% 30%, rgba(232, 165, 58, 0.45), transparent 70%),
radial-gradient(ellipse 40% 60% at 90% 80%, rgba(138, 74, 18, 0.6), transparent 70%),
radial-gradient(ellipse 70% 40% at 20% 100%, rgba(59, 29, 8, 0.8), transparent 70%),
linear-gradient(180deg, #1a1614 0%, var(--bg) 100%);
}
.hero-photo::before {
content: ""; position: absolute; right: -8%; top: -30%; width: 58vw; max-width: 760px; aspect-ratio: 1; border-radius: 50%;
background: radial-gradient(circle, transparent 54%, rgba(232, 165, 58, 0.22) 55%, rgba(232, 165, 58, 0.22) 56.5%, transparent 57.5%, transparent 66%, rgba(232, 165, 58, 0.08) 67%, transparent 68.5%);
animation: drift 18s ease-in-out infinite alternate;
}
@keyframes drift { from { transform: translate3d(0, 0, 0) scale(1); } to { transform: translate3d(-3%, 4%, 0) scale(1.05); } }
.hero-photo::after {
content: ""; position: absolute; inset: 0;
background-image: radial-gradient(rgba(246, 239, 228, 0.06) 1px, transparent 1px);
background-size: 24px 24px; opacity: 0.5;
mask-image: linear-gradient(180deg, transparent, #000 40%, transparent);
-webkit-mask-image: linear-gradient(180deg, transparent, #000 40%, transparent);
}
.hero-grid { display: grid; grid-template-columns: 1.4fr 1fr; gap: 48px; align-items: end; }
.hero .lede { font-size: 1.125rem; color: var(--ink-2); max-width: 46ch; margin-top: 24px; }
.hero-actions { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 32px; }
.hero-card {
background: rgba(29, 26, 24, 0.85); border: 1px solid var(--line);
border-radius: var(--radius-lg); padding: 24px; box-shadow: var(--shadow);
backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
}
.status-row { display: flex; align-items: center; gap: 10px; font-weight: 600; font-size: 0.9375rem; padding-bottom: 16px; border-bottom: 1px solid var(--line); }
.status-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--ink-3); box-shadow: 0 0 0 4px rgba(246, 239, 228, 0.08); }
.status-dot.is-open { background: var(--ok); box-shadow: 0 0 0 4px rgba(127, 195, 138, 0.2); }
.status-dot.is-closed { background: var(--danger); box-shadow: 0 0 0 4px rgba(224, 100, 79, 0.2); }
.hero-meta { margin: 0; display: grid; gap: 16px; padding-top: 16px; }
.hero-meta dt { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3); font-weight: 600; }
.hero-meta dd { margin: 4px 0 0; color: var(--ink); }
/* Sections */
.section { padding: 96px 0; }
.section-alt { background: var(--bg-2); border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
.section-head { max-width: 640px; margin-bottom: 40px; }
/* Menu */
.menu-controls { display: flex; flex-wrap: wrap; gap: 16px 32px; align-items: flex-end; justify-content: space-between; margin-bottom: 32px; }
.tabs { display: flex; flex-wrap: wrap; gap: 8px; }
.tab {
min-height: 44px; padding: 0 20px; border-radius: 999px;
background: transparent; border: 1px solid var(--line-strong); color: var(--ink-2); font-weight: 500;
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
.tab:hover { color: var(--ink); border-color: var(--ink); }
.tab[aria-selected="true"] { background: var(--ink); color: var(--bg); border-color: var(--ink); }
.diet-filter { border: 0; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.diet-filter legend { float: left; font-size: 0.8125rem; color: var(--ink-3); margin-right: 8px; padding: 0; }
.chip { position: relative; display: inline-flex; align-items: center; gap: 6px; cursor: pointer; }
.chip input { position: absolute; opacity: 0; width: 1px; height: 1px; }
.chip span {
display: inline-flex; align-items: center; min-height: 32px; padding: 0 12px; border-radius: 999px;
border: 1px solid var(--line-strong); font-size: 0.8125rem; font-weight: 600; color: var(--ink-2);
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
.chip:hover span { border-color: var(--ink); color: var(--ink); }
.chip input:checked + span { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); }
.chip input:focus-visible + span { outline: 2px solid var(--accent); outline-offset: 3px; }
.menu-panel[hidden] { display: none; }
.menu-list { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(2, 1fr); gap: 0 48px; }
.menu-item { display: grid; grid-template-columns: 1fr auto; gap: 4px 24px; padding: 20px 0; border-bottom: 1px solid var(--line); transition: padding-left 0.15s ease; }
.menu-item:hover { padding-left: 8px; }
.menu-item .name { font-family: var(--font-display); font-size: 1.25rem; font-weight: 500; }
.menu-item .price { font-weight: 600; color: var(--accent-2); font-variant-numeric: tabular-nums; }
.menu-item .desc { grid-column: 1; color: var(--ink-2); font-size: 0.9375rem; }
.menu-item .tags { grid-column: 1 / -1; display: flex; gap: 6px; margin-top: 4px; }
.tag {
font-size: 0.6875rem; font-weight: 700; letter-spacing: 0.06em; padding: 2px 8px; border-radius: 6px;
background: rgba(246, 239, 228, 0.08); color: var(--ink-2); border: 1px solid var(--line);
}
.menu-empty { padding: 40px 0; color: var(--ink-2); grid-column: 1 / -1; }
.legend { margin-top: 24px; font-size: 0.8125rem; color: var(--ink-3); }
/* Gallery */
.gallery-grid { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
.gallery-grid li:first-child { grid-column: span 2; grid-row: span 2; }
.gallery-btn {
display: block; width: 100%; padding: 0; border: 0; border-radius: var(--radius); overflow: hidden;
background: none; text-align: left; position: relative; aspect-ratio: 4 / 3;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.gallery-grid li:first-child .gallery-btn { aspect-ratio: auto; height: 100%; min-height: 280px; }
.gallery-btn:hover { transform: translateY(-2px); box-shadow: var(--shadow); }
.gallery-btn .photo, .lightbox-photo { position: absolute; inset: 0; transition: transform 0.4s ease; }
.gallery-btn:hover .photo { transform: scale(1.04); }
.gallery-btn .cap {
position: absolute; left: 0; right: 0; bottom: 0; padding: 40px 16px 16px;
background: linear-gradient(180deg, transparent, rgba(21, 19, 18, 0.85));
font-size: 0.875rem; font-weight: 500; color: var(--ink);
}
.photo-amber { background: radial-gradient(circle at 30% 40%, #f6c667, #c9772a 40%, #4a2410 85%); }
.photo-umber { background: linear-gradient(160deg, #6b4423, #2e1a0e 60%, #1a100a); }
.photo-wheat { background: radial-gradient(circle at 70% 30%, #f1d9a6, #b98b4a 45%, #5b3d1c 90%); }
.photo-ember { background: radial-gradient(circle at 50% 80%, #ff9a3c, #9a3b12 45%, #2a120a 85%); }
.photo-olive { background: linear-gradient(140deg, #8a8f4f, #4c5330 55%, #22261a); }
.photo-rust { background: radial-gradient(circle at 40% 60%, #d9683d, #8f3a1f 50%, #3a1a10 90%); }
.photo-amber::after, .photo-umber::after, .photo-wheat::after, .photo-ember::after, .photo-olive::after, .photo-rust::after {
content: ""; position: absolute; inset: 0;
background-image: radial-gradient(rgba(255, 255, 255, 0.08) 1px, transparent 1px);
background-size: 6px 6px; mix-blend-mode: overlay;
}
/* Reservation */
.reserve-grid { display: grid; grid-template-columns: 1fr 1.4fr; gap: 48px; align-items: start; }
.reserve-copy p { color: var(--ink-2); margin-top: 16px; }
.reserve-notes { margin: 24px 0 0; padding: 0; list-style: none; display: grid; gap: 12px; color: var(--ink-2); }
.reserve-notes li { padding-left: 24px; position: relative; }
.reserve-notes li::before { content: ""; position: absolute; left: 0; top: 10px; width: 12px; height: 2px; background: var(--accent); }
.card { background: var(--bg-2); border: 1px solid var(--line); border-radius: var(--radius-lg); padding: 32px; box-shadow: var(--shadow); }
.field-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 20px 16px; }
.field-full { grid-column: 1 / -1; }
.field label { display: block; font-size: 0.875rem; font-weight: 600; margin-bottom: 8px; }
.field .optional { color: var(--ink-3); font-weight: 400; }
.field input, .field select, .field textarea {
width: 100%; min-height: 48px; padding: 10px 14px;
background: var(--bg-3); border: 1px solid var(--line-strong); border-radius: 10px;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.field select {
appearance: none; -webkit-appearance: none; padding-right: 40px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16' fill='none' stroke='%23f6efe4' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 6l4 4 4-4'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 14px center;
}
.field textarea { resize: vertical; min-height: 88px; }
.field input:hover, .field select:hover, .field textarea:hover { border-color: var(--ink-3); }
.field input:focus, .field select:focus, .field textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(232, 165, 58, 0.25); }
.field input[aria-invalid="true"], .field select[aria-invalid="true"] { border-color: var(--danger); }
.field ::placeholder { color: var(--ink-3); }
.error { color: #f0907f; font-size: 0.8125rem; min-height: 0; margin-top: 6px; }
.error:empty { display: none; }
.form-error { margin-top: 16px; }
.form-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 16px; margin-top: 24px; }
.form-hint { font-size: 0.8125rem; color: var(--ink-3); }
.confirmation { grid-column: 2; text-align: left; animation: rise 0.35s ease; }
@keyframes rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
.confirm-check { width: 48px; height: 48px; border-radius: 50%; background: var(--accent); color: var(--accent-ink); display: grid; place-items: center; font-weight: 700; font-size: 1.25rem; margin-bottom: 16px; }
.confirm-lead { color: var(--ink-2); margin-top: 8px; }
.confirm-details { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; margin: 24px 0; padding: 20px 0; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
.confirm-details dt { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3); font-weight: 600; }
.confirm-details dd { margin: 4px 0 0; font-weight: 500; }
.confirm-ref { color: var(--ink-2); }
.confirm-ref strong { color: var(--accent-2); font-family: ui-monospace, "SF Mono", Menlo, monospace; letter-spacing: 0.08em; }
.confirm-actions { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 24px; }
/* Map */
.visit-grid { display: grid; grid-template-columns: 1.2fr 1fr; gap: 48px; align-items: center; }
.map-placeholder {
position: relative; aspect-ratio: 4 / 3; border-radius: var(--radius-lg); overflow: hidden;
background: #221e1b; border: 1px solid var(--line); box-shadow: var(--shadow);
}
.map-roads {
position: absolute; inset: 0;
background-image:
linear-gradient(rgba(246, 239, 228, 0.1) 2px, transparent 2px),
linear-gradient(90deg, rgba(246, 239, 228, 0.1) 2px, transparent 2px),
linear-gradient(rgba(246, 239, 228, 0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(246, 239, 228, 0.04) 1px, transparent 1px);
background-size: 120px 120px, 120px 120px, 30px 30px, 30px 30px;
transform: rotate(-8deg) scale(1.3);
}
.map-river {
position: absolute; left: -10%; right: -10%; top: 62%; height: 18%;
background: linear-gradient(90deg, rgba(64, 98, 120, 0.7), rgba(64, 98, 120, 0.5));
border-radius: 50% 40% 60% 50% / 100%; transform: rotate(-6deg);
}
.map-pin { position: absolute; left: 46%; top: 38%; width: 28px; height: 28px; }
.map-pin::before { content: ""; position: absolute; inset: 0; border-radius: 50% 50% 50% 0; transform: rotate(-45deg); background: var(--accent); box-shadow: 0 8px 16px -6px rgba(0, 0, 0, 0.8); }
.map-pin span { position: absolute; left: 8px; top: 8px; width: 12px; height: 12px; border-radius: 50%; background: var(--bg); }
.map-pin::after { content: ""; position: absolute; left: -14px; top: -14px; width: 56px; height: 56px; border-radius: 50%; border: 2px solid rgba(232, 165, 58, 0.5); animation: pulse 2.4s ease-out infinite; }
@keyframes pulse { 0% { transform: scale(0.5); opacity: 1; } 100% { transform: scale(1.6); opacity: 0; } }
.map-label { position: absolute; left: 46%; top: 30%; transform: translateX(-38%); background: var(--ink); color: var(--bg); font-size: 0.75rem; font-weight: 700; padding: 4px 10px; border-radius: 6px; white-space: nowrap; }
.visit-copy address { font-style: normal; font-family: var(--font-display); font-size: 1.25rem; margin: 16px 0; }
.visit-copy p { color: var(--ink-2); margin-bottom: 24px; }
/* Footer */
.site-footer { border-top: 1px solid var(--line); padding: 64px 0 32px; background: var(--bg); }
.footer-grid { display: grid; grid-template-columns: 1.2fr 1fr 1fr; gap: 40px; }
.footer-brand { font-size: 1.5rem; margin-bottom: 8px; }
.footer-title { font-family: var(--font-body); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.14em; color: var(--ink-3); margin-bottom: 16px; }
.hours-table { border-collapse: collapse; width: 100%; font-size: 0.9375rem; }
.hours-table td { padding: 6px 0; border-bottom: 1px solid var(--line); color: var(--ink-2); }
.hours-table td:last-child { text-align: right; font-variant-numeric: tabular-nums; }
.hours-table tr.is-today td { color: var(--ink); font-weight: 600; }
.hours-table tr.is-today td:first-child::after { content: " · today"; color: var(--accent); font-weight: 500; font-size: 0.8125rem; }
.footer-bottom { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 16px; margin-top: 48px; padding-top: 24px; border-top: 1px solid var(--line); font-size: 0.8125rem; }
/* Lightbox */
.lightbox { position: fixed; inset: 0; z-index: 90; display: grid; place-items: center; padding: 24px; }
.lightbox[hidden] { display: none; }
.lightbox-backdrop { position: absolute; inset: 0; background: rgba(0, 0, 0, 0.8); }
.lightbox-dialog { position: relative; width: min(920px, 100%); background: var(--bg-2); border: 1px solid var(--line); border-radius: var(--radius-lg); overflow: hidden; box-shadow: var(--shadow); }
.lightbox-photo { position: relative; aspect-ratio: 16 / 9; }
.lightbox-bar { display: flex; align-items: center; gap: 16px; padding: 16px 20px; }
.lightbox-bar p { flex: 1; text-align: center; font-size: 0.9375rem; }
.lightbox-nav, .lightbox-close {
width: 40px; height: 40px; border-radius: 50%; border: 1px solid var(--line-strong);
background: rgba(21, 19, 18, 0.6); color: var(--ink); font-size: 1.125rem; display: grid; place-items: center;
transition: background 0.15s ease, border-color 0.15s ease;
}
.lightbox-nav:hover, .lightbox-close:hover { background: var(--ink); color: var(--bg); border-color: var(--ink); }
.lightbox-close { position: absolute; top: 12px; right: 12px; z-index: 2; }
/* Responsive */
@media (max-width: 900px) {
.hero-grid, .reserve-grid, .visit-grid { grid-template-columns: 1fr; }
.hero { padding: 56px 0 64px; }
.confirmation { grid-column: auto; }
.footer-grid { grid-template-columns: 1fr 1fr; }
.gallery-grid { grid-template-columns: repeat(2, 1fr); }
.gallery-grid li:first-child { grid-column: span 2; grid-row: auto; }
.gallery-grid li:first-child .gallery-btn { aspect-ratio: 16 / 9; min-height: 0; }
}
@media (max-width: 640px) {
.header-row { flex-wrap: wrap; min-height: 56px; padding-top: 8px; gap: 12px; }
.site-nav {
order: 3; flex-basis: 100%; margin: -4px -8px 0; padding: 0 8px 8px; gap: 0;
overflow-x: auto; scrollbar-width: none; -webkit-overflow-scrolling: touch;
}
.site-nav::-webkit-scrollbar { display: none; }
.site-nav a { flex: 0 0 auto; padding: 6px 10px; font-size: 0.875rem; }
.header-row .btn { margin-left: auto; }
.section { padding: 64px 0; }
.menu-list { grid-template-columns: 1fr; }
.field-grid { grid-template-columns: 1fr; }
.card { padding: 24px 20px; }
.footer-grid { grid-template-columns: 1fr; }
.confirm-details { grid-template-columns: 1fr; }
.wrap { width: calc(100% - 32px); }
}
@media (max-width: 400px) {
.gallery-grid { grid-template-columns: 1fr; }
.gallery-grid li:first-child { grid-column: auto; }
.hero-actions .btn { width: 100%; }
}
src/data.js
// Sample data for the template. Replace with the real business details.
export const business = {
name: "Ember & Oak",
phone: "(503) 555-0148",
address: "412 Larkspur Street, Portland, OR 97209",
// Minutes booked ahead of close that we stop taking reservations.
lastSeatingOffset: 60,
slotMinutes: 30,
};
// Hours keyed by JS getDay(): 0 = Sunday … 6 = Saturday. null = closed.
// Times are 24h "HH:MM".
export const hours = [
{ day: "Sunday", open: "11:00", close: "21:00" },
{ day: "Monday", open: null, close: null },
{ day: "Tuesday", open: "17:00", close: "22:00" },
{ day: "Wednesday", open: "17:00", close: "22:00" },
{ day: "Thursday", open: "17:00", close: "22:00" },
{ day: "Friday", open: "12:00", close: "23:00" },
{ day: "Saturday", open: "12:00", close: "23:00" },
];
export const dietTags = {
v: { label: "Vegetarian", short: "V" },
vg: { label: "Vegan", short: "VG" },
gf: { label: "Gluten-free", short: "GF" },
df: { label: "Dairy-free", short: "DF" },
sp: { label: "Spicy", short: "🌶" },
};
export const menu = [
{
id: "starters",
label: "Starters",
items: [
{ name: "Oven sourdough", desc: "House loaf, cultured butter, flaky salt.", price: 6, tags: ["v"] },
{ name: "Charred leeks", desc: "Romesco, toasted hazelnuts, sherry vinegar.", price: 12, tags: ["vg", "gf", "df"] },
{ name: "Burrata & stone fruit", desc: "Grilled peaches, basil oil, aged balsamic.", price: 15, tags: ["v", "gf"] },
{ name: "Nduja flatbread", desc: "Blistered dough, whipped ricotta, hot honey.", price: 13, tags: ["sp"] },
{ name: "Smoked trout rillette", desc: "Dill, pickled shallot, rye crisps.", price: 14, tags: ["df"] },
],
},
{
id: "mains",
label: "Mains",
items: [
{ name: "Half chicken", desc: "Brined overnight, salsa verde, oven potatoes.", price: 28, tags: ["gf", "df"] },
{ name: "Whole roasted cauliflower", desc: "Tahini, pomegranate, crispy chickpeas.", price: 22, tags: ["vg", "gf", "df"] },
{ name: "Dry-aged ribeye, 14 oz", desc: "Bone marrow butter, watercress. For sharing.", price: 58, tags: ["gf"] },
{ name: "Ember-roasted salmon", desc: "Brown butter, charred lemon, fennel slaw.", price: 32, tags: ["gf"] },
{ name: "Mushroom & farro", desc: "Maitake, black garlic, pecorino, soft egg.", price: 24, tags: ["v"] },
{ name: "Harissa lamb shoulder", desc: "Slow-roasted, mint yogurt, flatbread. Serves two.", price: 64, tags: ["sp"] },
],
},
{
id: "desserts",
label: "Desserts",
items: [
{ name: "Burnt Basque cheesecake", desc: "Blackberry, olive oil.", price: 11, tags: ["v", "gf"] },
{ name: "Wood-oven apple tart", desc: "Vanilla cream, brown sugar crumb.", price: 10, tags: ["v"] },
{ name: "Dark chocolate pot", desc: "Sea salt, hazelnut praline.", price: 9, tags: ["vg", "gf", "df"] },
{ name: "Cheese board", desc: "Three local cheeses, quince, walnut bread.", price: 16, tags: ["v"] },
],
},
{
id: "drinks",
label: "Drinks",
items: [
{ name: "House red / white", desc: "Willamette Valley, by the glass.", price: 12, tags: ["vg", "gf"] },
{ name: "Smoked old fashioned", desc: "Rye, oak smoke, orange peel.", price: 15, tags: ["vg", "gf"] },
{ name: "Local pale ale", desc: "Rotating tap from the neighborhood.", price: 8, tags: ["vg"] },
{ name: "Fire-roasted lemonade", desc: "Charred lemons, thyme, soda. Zero proof.", price: 7, tags: ["vg", "gf", "df"] },
{ name: "Espresso", desc: "Single origin, roasted across the street.", price: 4, tags: ["vg", "gf", "df"] },
],
},
];
export const gallery = [
{ id: "oven", caption: "The oak-burning oven, lit at 3pm every day", hue: "amber" },
{ id: "room", caption: "The dining room just before service", hue: "umber" },
{ id: "bread", caption: "Sourdough, scored and ready", hue: "wheat" },
{ id: "bar", caption: "Eight seats at the counter", hue: "ember" },
{ id: "garden", caption: "The back patio in late summer", hue: "olive" },
{ id: "plate", caption: "Charred leeks with romesco", hue: "rust" },
];