Travel — City Guide
A warm, walkable city guide for the fictional coastal town of Porto Lumera, organised by neighbourhood. A full-bleed CSS-and-SVG hero opens onto a neighbourhood chip selector and a category filter that together narrow a list of point-of-interest cards — each with a gradient photo, star rating, price tier and best-time badge. A stylised SVG map repositions and highlights its pins to match the active neighbourhood, while a live top-five rail and a heart-driven trip planner keep the whole guide practical and editorial.
MCP
Code
:root {
--bg: #fbf7f1;
--card: #ffffff;
--ink: #241f1a;
--muted: #6b6259;
--teal: #1f8a8a;
--teal-dk: #166a6a;
--coral: #e8623f;
--sand: #e7d8c3;
--gold: #d9a441;
--line: rgba(36, 31, 26, .12);
--line-2: rgba(36, 31, 26, .08);
--shadow: 0 10px 30px rgba(36, 31, 26, .10);
--shadow-sm: 0 4px 14px rgba(36, 31, 26, .08);
--radius: 16px;
--serif: "Fraunces", Georgia, "Times New Roman", serif;
--sans: "Work Sans", system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font-family: var(--sans);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1, h2, h3 { margin: 0; line-height: 1.15; }
a { color: var(--teal-dk); }
button { font-family: inherit; }
:focus-visible {
outline: 3px solid var(--teal);
outline-offset: 2px;
border-radius: 8px;
}
.skip-link {
position: absolute;
left: -999px;
top: 8px;
background: var(--ink);
color: #fff;
padding: 10px 16px;
border-radius: 10px;
z-index: 50;
text-decoration: none;
}
.skip-link:focus { left: 12px; }
/* ---------- Hero ---------- */
.hero {
position: relative;
min-height: clamp(360px, 56vh, 520px);
display: flex;
align-items: flex-end;
overflow: hidden;
isolation: isolate;
color: #fff;
}
.hero__scene { position: absolute; inset: 0; z-index: -2; }
.hero__sky {
position: absolute; inset: 0;
background: linear-gradient(180deg, #f6c89a 0%, #f0a374 32%, #d97a57 62%, #9c5a52 100%);
}
.hero__art { position: absolute; inset: 0; width: 100%; height: 100%; }
.hero::after {
content: "";
position: absolute; inset: 0; z-index: -1;
background: linear-gradient(180deg, rgba(36, 31, 26, 0) 40%, rgba(36, 31, 26, .68) 100%);
}
.hero__inner {
width: min(1080px, 92vw);
margin: 0 auto;
padding: 0 4px 38px;
}
.hero__eyebrow {
margin: 0 0 8px;
font-weight: 600;
letter-spacing: .14em;
text-transform: uppercase;
font-size: .76rem;
color: #ffe8c9;
}
.hero__title {
font-family: var(--serif);
font-weight: 600;
font-size: clamp(2.6rem, 9vw, 5rem);
letter-spacing: -.01em;
text-shadow: 0 2px 18px rgba(0, 0, 0, .35);
}
.hero__lede {
max-width: 54ch;
margin: 14px 0 0;
font-size: clamp(1rem, 2.3vw, 1.16rem);
color: #f6ede0;
}
.hero__meta {
list-style: none;
display: flex;
flex-wrap: wrap;
gap: 10px 18px;
margin: 20px 0 0;
padding: 0;
font-weight: 600;
font-size: .92rem;
}
.hero__meta li {
background: rgba(255, 255, 255, .16);
backdrop-filter: blur(2px);
padding: 7px 14px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, .28);
}
/* ---------- Layout ---------- */
.wrap {
width: min(1080px, 92vw);
margin: 0 auto;
padding: 30px 0 64px;
}
/* ---------- Controls ---------- */
.controls {
background: var(--card);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
padding: 18px 20px;
margin-bottom: 26px;
}
.controls__row { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
.controls__row--cats { margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--line-2); }
.controls__label {
font-family: var(--serif);
font-size: .92rem;
font-weight: 600;
color: var(--muted);
min-width: 116px;
}
.chips, .cats { display: flex; flex-wrap: wrap; gap: 8px; }
.chip {
appearance: none;
border: 1px solid var(--line);
background: var(--bg);
color: var(--ink);
font-weight: 600;
font-size: .9rem;
padding: 8px 15px;
border-radius: 999px;
cursor: pointer;
transition: transform .12s ease, background .15s ease, color .15s ease, border-color .15s ease, box-shadow .15s ease;
}
.chip:hover { background: #fff; border-color: var(--teal); transform: translateY(-1px); }
.chip[aria-selected="true"] {
background: var(--teal);
border-color: var(--teal);
color: #fff;
box-shadow: 0 6px 16px rgba(31, 138, 138, .28);
}
.cat {
appearance: none;
border: 1px solid var(--line);
background: #fff;
color: var(--muted);
font-weight: 600;
font-size: .84rem;
padding: 6px 13px;
border-radius: 10px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
transition: background .15s ease, color .15s ease, border-color .15s ease;
}
.cat:hover { border-color: var(--coral); color: var(--coral); }
.cat[aria-pressed="true"] {
background: var(--coral);
border-color: var(--coral);
color: #fff;
}
.layout {
display: grid;
grid-template-columns: 1.6fr 1fr;
gap: 28px;
align-items: start;
}
/* ---------- POI ---------- */
.poi__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.poi__title { font-family: var(--serif); font-size: 1.6rem; font-weight: 600; }
.poi__count { color: var(--muted); font-size: .9rem; font-weight: 600; }
.poi__list { list-style: none; margin: 0; padding: 0; display: grid; gap: 18px; }
.card {
display: grid;
grid-template-columns: 130px 1fr;
background: var(--card);
border: 1px solid var(--line);
border-radius: var(--radius);
overflow: hidden;
box-shadow: var(--shadow-sm);
transition: transform .16s ease, box-shadow .16s ease;
}
.card:hover { transform: translateY(-3px); box-shadow: var(--shadow); }
.card__photo {
position: relative;
min-height: 100%;
background-size: cover;
}
.card__cat {
position: absolute;
top: 8px; left: 8px;
background: rgba(36, 31, 26, .72);
color: #fff;
font-size: .68rem;
font-weight: 700;
letter-spacing: .04em;
text-transform: uppercase;
padding: 4px 8px;
border-radius: 999px;
}
.card__body { padding: 14px 16px 15px; min-width: 0; }
.card__top { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
.card__name { font-family: var(--serif); font-size: 1.16rem; font-weight: 600; line-height: 1.2; }
.card__hood { color: var(--teal-dk); font-weight: 600; font-size: .8rem; margin-top: 2px; }
.card__desc { color: var(--muted); font-size: .92rem; margin: 9px 0 12px; }
.card__meta { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 14px; font-size: .85rem; }
.rating { font-weight: 700; color: var(--ink); display: inline-flex; align-items: center; gap: 4px; }
.rating .star { color: var(--gold); }
.price { font-weight: 700; color: var(--teal-dk); letter-spacing: .04em; }
.price .off { color: var(--line); }
.badge-time {
background: #f3ead9;
color: #8a6d2f;
font-weight: 700;
font-size: .74rem;
padding: 3px 9px;
border-radius: 999px;
}
.card__maplink {
margin-left: auto;
font-size: .82rem;
font-weight: 700;
color: var(--teal-dk);
background: none;
border: 0;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
padding: 2px;
}
.card__maplink:hover { color: var(--coral); }
.heart {
appearance: none;
border: 1px solid var(--line);
background: #fff;
width: 36px; height: 36px;
border-radius: 999px;
cursor: pointer;
font-size: 1rem;
line-height: 1;
display: grid;
place-items: center;
flex: none;
transition: transform .14s ease, background .14s ease, border-color .14s ease;
}
.heart:hover { transform: scale(1.08); border-color: var(--coral); }
.heart[aria-pressed="true"] { background: var(--coral); border-color: var(--coral); }
.heart[aria-pressed="true"]::after { content: "♥"; color: #fff; }
.heart::after { content: "♡"; color: var(--coral); }
.poi__empty {
background: #fff;
border: 1px dashed var(--line);
border-radius: var(--radius);
padding: 28px;
text-align: center;
color: var(--muted);
font-weight: 600;
}
/* ---------- Side ---------- */
.side { display: grid; gap: 22px; position: sticky; top: 16px; }
.map {
margin: 0;
background: var(--card);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 12px;
box-shadow: var(--shadow-sm);
}
.map__svg { width: 100%; height: auto; border-radius: 10px; display: block; }
.pin { cursor: pointer; transition: transform .2s ease; transform-origin: center; transform-box: fill-box; }
.pin circle { transition: r .2s ease, fill .2s ease, stroke-width .2s ease; }
.pin text { font: 700 11px var(--sans); fill: #fff; pointer-events: none; }
.pin.dim { opacity: .28; }
.pin.active circle { fill: var(--coral); stroke: #fff; stroke-width: 2.5; }
.pin.active { transform: scale(1.18); }
.map__cap {
display: flex;
gap: 16px;
flex-wrap: wrap;
padding: 10px 4px 2px;
font-size: .78rem;
color: var(--muted);
font-weight: 600;
}
.map__legend { display: inline-flex; align-items: center; gap: 6px; }
.dot { width: 12px; height: 4px; border-radius: 4px; display: inline-block; }
.dot--tram { background: var(--coral); }
.dot--sea { background: var(--teal); height: 10px; width: 10px; border-radius: 50%; }
.rail, .trip {
background: var(--card);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 16px 18px;
box-shadow: var(--shadow-sm);
}
.rail__title, .trip__title {
font-family: var(--serif);
font-size: 1.16rem;
font-weight: 600;
margin-bottom: 12px;
}
.rail__list { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; counter-reset: rank; }
.rail__item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 6px;
border-radius: 10px;
border: 0;
background: none;
width: 100%;
text-align: left;
cursor: pointer;
transition: background .14s ease;
}
.rail__item:hover { background: var(--bg); }
.rail__rank {
counter-increment: rank;
font-family: var(--serif);
font-weight: 700;
font-size: 1.1rem;
color: var(--coral);
min-width: 22px;
}
.rail__rank::before { content: counter(rank); }
.rail__info { min-width: 0; }
.rail__name { font-weight: 600; font-size: .94rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.rail__sub { font-size: .78rem; color: var(--muted); }
.rail__score { margin-left: auto; font-weight: 700; font-size: .84rem; color: var(--gold); white-space: nowrap; }
.trip__empty { color: var(--muted); font-size: .9rem; margin: 0; }
.trip__list { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
.trip__row {
display: flex;
align-items: center;
gap: 10px;
background: var(--bg);
border: 1px solid var(--line-2);
border-radius: 10px;
padding: 8px 10px;
font-size: .9rem;
}
.trip__row .nm { font-weight: 600; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.trip__row .hd { color: var(--muted); font-size: .76rem; margin-left: auto; white-space: nowrap; }
.trip__remove {
appearance: none;
border: 0;
background: none;
color: var(--muted);
cursor: pointer;
font-size: 1.1rem;
line-height: 1;
padding: 2px 4px;
border-radius: 6px;
}
.trip__remove:hover { color: var(--coral); }
/* ---------- Footer ---------- */
.foot {
border-top: 1px solid var(--line);
background: #fff;
}
.foot p {
width: min(1080px, 92vw);
margin: 0 auto;
padding: 22px 0;
color: var(--muted);
font-size: .86rem;
}
/* ---------- Toast ---------- */
.toast {
position: fixed;
left: 50%;
bottom: 24px;
transform: translate(-50%, 18px);
background: var(--ink);
color: #fff;
padding: 12px 20px;
border-radius: 999px;
font-weight: 600;
font-size: .9rem;
box-shadow: var(--shadow);
opacity: 0;
pointer-events: none;
transition: opacity .22s ease, transform .22s ease;
z-index: 60;
max-width: 90vw;
text-align: center;
}
.toast.show { opacity: 1; transform: translate(-50%, 0); }
/* ---------- Responsive ---------- */
@media (max-width: 880px) {
.layout { grid-template-columns: 1fr; }
.side { position: static; order: -1; }
}
@media (max-width: 560px) {
.controls__row { align-items: flex-start; }
.controls__label { min-width: 0; }
.card { grid-template-columns: 1fr; }
.card__photo { min-height: 120px; }
.poi__head { flex-direction: column; align-items: flex-start; gap: 4px; }
}
@media (max-width: 380px) {
.hero__inner { padding-bottom: 28px; }
.card__maplink { margin-left: 0; }
}
@media (prefers-reduced-motion: reduce) {
* { transition-duration: .01ms !important; animation-duration: .01ms !important; }
}(function () {
"use strict";
/* ---------- Data (fictional) ---------- */
var HOODS = [
{ id: "all", name: "All Porto Lumera" },
{ id: "marina", name: "Old Marina", x: 56, y: 230 },
{ id: "azulejo", name: "Azulejo Quarter", x: 150, y: 150 },
{ id: "miradouro", name: "Miradouro Heights", x: 262, y: 80 },
{ id: "mercado", name: "Mercado Sul", x: 110, y: 70 },
{ id: "praia", name: "Praia Dourada", x: 300, y: 270 }
];
var CATS = [
{ id: "eat", label: "Eat", icon: "🍽" },
{ id: "drink", label: "Drink", icon: "🍷" },
{ id: "see", label: "See", icon: "🏛" },
{ id: "stay", label: "Stay", icon: "🛏" },
{ id: "shop", label: "Shop", icon: "🛍" }
];
var SPOTS = [
{ id: "s1", name: "Casa Sardinha", hood: "marina", cat: "eat", rating: 4.8, price: 2, time: "Dinner", grad: "linear-gradient(135deg,#e8623f,#9c5a52)", desc: "Charcoal-grilled sardines on a quay that smells of salt and citrus." },
{ id: "s2", name: "Doca Vermelha", hood: "marina", cat: "drink", rating: 4.6, price: 2, time: "Sunset", grad: "linear-gradient(135deg,#f0a374,#d97a57)", desc: "Vermouth bar in a former net store; harbour light pours through the doors." },
{ id: "s3", name: "Farol Velho", hood: "marina", cat: "see", rating: 4.5, price: 1, time: "Morning", grad: "linear-gradient(135deg,#1f8a8a,#166a6a)", desc: "Climb the old lighthouse for the cleanest view of the working docks." },
{ id: "s4", name: "Tile & Lime Atelier", hood: "azulejo", cat: "shop", rating: 4.7, price: 2, time: "Afternoon", grad: "linear-gradient(135deg,#1aa0a0,#1f8a8a)", desc: "Hand-painted azulejo tiles fired on-site; ship a single square home." },
{ id: "s5", name: "Pátio das Letras", hood: "azulejo", cat: "drink", rating: 4.4, price: 1, time: "Anytime", grad: "linear-gradient(135deg,#caa477,#9c7a4f)", desc: "Leafy courtyard café for galão and almond cake between bookshops." },
{ id: "s6", name: "Igreja do Sal", hood: "azulejo", cat: "see", rating: 4.9, price: 1, time: "Morning", grad: "linear-gradient(135deg,#6b6259,#241f1a)", desc: "A blue-tiled nave where every wall tells a sailor's story." },
{ id: "s7", name: "Mirador do Vento", hood: "miradouro", cat: "see", rating: 5.0, price: 1, time: "Sunset", grad: "linear-gradient(135deg,#f6c89a,#e8623f)", desc: "The terrace locals swear by — the whole bay turns copper at dusk." },
{ id: "s8", name: "Refúgio Heights", hood: "miradouro", cat: "stay", rating: 4.8, price: 3, time: "Stay", grad: "linear-gradient(135deg,#9c7a4f,#caa477)", desc: "Seven cliff rooms with shutters that frame the lighthouse beam." },
{ id: "s9", name: "Vinho & Vista", hood: "miradouro", cat: "drink", rating: 4.6, price: 2, time: "Sunset", grad: "linear-gradient(135deg,#d97a57,#9c5a52)", desc: "Rooftop wine room pouring crisp greens from the inland hills." },
{ id: "s10", name: "Mercado Sul Hall", hood: "mercado", cat: "eat", rating: 4.7, price: 1, time: "Lunch", grad: "linear-gradient(135deg,#e8623f,#d9a441)", desc: "Twelve stalls under iron arches — start with the octopus rice." },
{ id: "s11", name: "Forno da Avó", hood: "mercado", cat: "eat", rating: 4.9, price: 1, time: "Morning", grad: "linear-gradient(135deg,#d9a441,#caa477)", desc: "Wood-fired bakery; the custard tarts sell out by ten sharp." },
{ id: "s12", name: "Praça Verde", hood: "mercado", cat: "shop", rating: 4.3, price: 1, time: "Afternoon", grad: "linear-gradient(135deg,#a9cf9b,#1f8a8a)", desc: "Saturday flower-and-spice market spilling across the green square." },
{ id: "s13", name: "Areia Beach Club", hood: "praia", cat: "drink", rating: 4.5, price: 2, time: "Afternoon", grad: "linear-gradient(135deg,#bfe3df,#1aa0a0)", desc: "Striped loungers, cold ginja, and a DJ who reads the tide." },
{ id: "s14", name: "Dunas Guesthouse", hood: "praia", cat: "stay", rating: 4.6, price: 2, time: "Stay", grad: "linear-gradient(135deg,#f6c89a,#caa477)", desc: "Whitewashed rooms a barefoot walk from the golden strand." },
{ id: "s15", name: "Cabana do Polvo", hood: "praia", cat: "eat", rating: 4.8, price: 2, time: "Lunch", grad: "linear-gradient(135deg,#1f8a8a,#e8623f)", desc: "Sand-floor shack serving the day's catch with charred lemon." }
];
/* ---------- State ---------- */
var state = { hood: "all", cat: "all", trip: [] };
/* ---------- Helpers ---------- */
function $(sel, root) { return (root || document).querySelector(sel); }
function el(tag, cls, html) {
var n = document.createElement(tag);
if (cls) n.className = cls;
if (html != null) n.innerHTML = html;
return n;
}
function hoodName(id) {
for (var i = 0; i < HOODS.length; i++) if (HOODS[i].id === id) return HOODS[i].name;
return id;
}
function priceMark(n) {
var s = "";
for (var i = 1; i <= 3; i++) s += '<span class="' + (i <= n ? "" : "off") + '">€</span>';
return s;
}
var toastTimer;
function toast(msg) {
var t = $("#toast");
t.textContent = msg;
t.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () { t.classList.remove("show"); }, 2200);
}
function filtered() {
return SPOTS.filter(function (s) {
var okHood = state.hood === "all" || s.hood === state.hood;
var okCat = state.cat === "all" || s.cat === state.cat;
return okHood && okCat;
});
}
/* ---------- Build chips ---------- */
function buildHoods() {
var box = $("#hoods");
HOODS.forEach(function (h) {
var b = el("button", "chip");
b.type = "button";
b.textContent = h.name;
b.setAttribute("role", "tab");
b.setAttribute("aria-selected", h.id === state.hood ? "true" : "false");
b.dataset.hood = h.id;
b.addEventListener("click", function () { setHood(h.id); });
box.appendChild(b);
});
}
function buildCats() {
var box = $("#cats");
var all = el("button", "cat");
all.type = "button";
all.innerHTML = "All";
all.dataset.cat = "all";
all.setAttribute("aria-pressed", "true");
all.addEventListener("click", function () { setCat("all"); });
box.appendChild(all);
CATS.forEach(function (c) {
var b = el("button", "cat");
b.type = "button";
b.innerHTML = '<span aria-hidden="true">' + c.icon + "</span> " + c.label;
b.dataset.cat = c.id;
b.setAttribute("aria-pressed", "false");
b.addEventListener("click", function () { setCat(c.id); });
box.appendChild(b);
});
}
/* ---------- Build map pins ---------- */
function buildPins() {
var g = $("#pins");
HOODS.filter(function (h) { return h.id !== "all"; }).forEach(function (h) {
var pg = document.createElementNS("http://www.w3.org/2000/svg", "g");
pg.setAttribute("class", "pin");
pg.dataset.hood = h.id;
pg.setAttribute("tabindex", "0");
pg.setAttribute("role", "button");
pg.setAttribute("aria-label", h.name);
var c = document.createElementNS("http://www.w3.org/2000/svg", "circle");
c.setAttribute("cx", h.x);
c.setAttribute("cy", h.y);
c.setAttribute("r", "10");
c.setAttribute("fill", "#1f8a8a");
c.setAttribute("stroke", "#fff");
c.setAttribute("stroke-width", "2");
var t = document.createElementNS("http://www.w3.org/2000/svg", "text");
t.setAttribute("x", h.x);
t.setAttribute("y", h.y + 4);
t.setAttribute("text-anchor", "middle");
t.textContent = String(SPOTS.filter(function (s) { return s.hood === h.id; }).length);
pg.appendChild(c);
pg.appendChild(t);
function activate() { setHood(state.hood === h.id ? "all" : h.id); }
pg.addEventListener("click", activate);
pg.addEventListener("keydown", function (e) {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); activate(); }
});
g.appendChild(pg);
});
}
/* ---------- Render ---------- */
function renderChips() {
Array.prototype.forEach.call($("#hoods").children, function (b) {
b.setAttribute("aria-selected", b.dataset.hood === state.hood ? "true" : "false");
});
Array.prototype.forEach.call($("#cats").children, function (b) {
b.setAttribute("aria-pressed", b.dataset.cat === state.cat ? "true" : "false");
});
}
function renderPins() {
Array.prototype.forEach.call($("#pins").children, function (p) {
var on = state.hood === "all" || p.dataset.hood === state.hood;
p.classList.toggle("active", state.hood !== "all" && p.dataset.hood === state.hood);
p.classList.toggle("dim", state.hood !== "all" && p.dataset.hood !== state.hood);
p.setAttribute("aria-pressed", state.hood === p.dataset.hood ? "true" : "false");
void on;
});
}
function spotCard(s) {
var li = el("li");
var card = el("article", "card");
var photo = el("div", "card__photo");
photo.style.background = s.grad;
photo.appendChild(el("span", "card__cat", catLabel(s.cat)));
card.appendChild(photo);
var body = el("div", "card__body");
var top = el("div", "card__top");
var nameWrap = el("div");
nameWrap.appendChild(el("h3", "card__name", s.name));
nameWrap.appendChild(el("p", "card__hood", "📍 " + hoodName(s.hood)));
top.appendChild(nameWrap);
var heart = el("button", "heart");
heart.type = "button";
var saved = state.trip.indexOf(s.id) > -1;
heart.setAttribute("aria-pressed", saved ? "true" : "false");
heart.setAttribute("aria-label", (saved ? "Remove " : "Add ") + s.name + " to your trip");
heart.addEventListener("click", function () { toggleTrip(s.id); });
top.appendChild(heart);
body.appendChild(top);
body.appendChild(el("p", "card__desc", s.desc));
var meta = el("div", "card__meta");
meta.appendChild(el("span", "rating", '<span class="star" aria-hidden="true">★</span> ' + s.rating.toFixed(1)));
meta.appendChild(el("span", "price", priceMark(s.price)));
meta.appendChild(el("span", "badge-time", s.time));
var mapBtn = el("button", "card__maplink", "View on map →");
mapBtn.type = "button";
mapBtn.addEventListener("click", function () {
setHood(s.hood);
pulsePin(s.hood);
toast("Highlighted " + hoodName(s.hood) + " on the map");
var mapEl = $(".map");
if (mapEl && mapEl.scrollIntoView) mapEl.scrollIntoView({ behavior: "smooth", block: "center" });
});
meta.appendChild(mapBtn);
body.appendChild(meta);
card.appendChild(body);
li.appendChild(card);
return li;
}
function catLabel(id) {
for (var i = 0; i < CATS.length; i++) if (CATS[i].id === id) return CATS[i].label;
return id;
}
function pulsePin(hood) {
var p = $('#pins [data-hood="' + hood + '"]');
if (!p) return;
p.classList.add("active");
}
function renderList() {
var list = $("#poiList");
list.innerHTML = "";
var rows = filtered();
$("#count").textContent = rows.length + (rows.length === 1 ? " spot" : " spots");
$("#empty").hidden = rows.length > 0;
rows.forEach(function (s) { list.appendChild(spotCard(s)); });
}
function renderRail() {
var rail = $("#rail");
rail.innerHTML = "";
var rows = filtered().slice().sort(function (a, b) { return b.rating - a.rating; }).slice(0, 5);
if (!rows.length) {
rail.appendChild(el("li", "rail__sub", "Nothing to rank here yet."));
return;
}
rows.forEach(function (s) {
var li = el("li");
var b = el("button", "rail__item");
b.type = "button";
b.innerHTML =
'<span class="rail__rank" aria-hidden="true"></span>' +
'<span class="rail__info">' +
'<span class="rail__name">' + s.name + "</span>" +
'<span class="rail__sub">' + hoodName(s.hood) + " · " + catLabel(s.cat) + "</span>" +
"</span>" +
'<span class="rail__score">★ ' + s.rating.toFixed(1) + "</span>";
b.setAttribute("aria-label", s.name + ", " + s.rating.toFixed(1) + " stars, view on map");
b.addEventListener("click", function () { setHood(s.hood); pulsePin(s.hood); });
li.appendChild(b);
rail.appendChild(li);
});
}
function renderTrip() {
var list = $("#tripList");
var empty = $("#tripEmpty");
list.innerHTML = "";
empty.hidden = state.trip.length > 0;
state.trip.forEach(function (id) {
var s = SPOTS.filter(function (x) { return x.id === id; })[0];
if (!s) return;
var li = el("li", "trip__row");
li.innerHTML =
'<span class="nm">' + s.name + "</span>" +
'<span class="hd">' + hoodName(s.hood) + "</span>";
var rm = el("button", "trip__remove", "✕");
rm.type = "button";
rm.setAttribute("aria-label", "Remove " + s.name + " from your trip");
rm.addEventListener("click", function () { toggleTrip(id); });
li.appendChild(rm);
list.appendChild(li);
});
}
/* ---------- Actions ---------- */
function setHood(id) {
state.hood = id;
renderChips();
renderPins();
renderList();
renderRail();
}
function setCat(id) {
state.cat = id;
renderChips();
renderList();
renderRail();
}
function toggleTrip(id) {
var i = state.trip.indexOf(id);
var s = SPOTS.filter(function (x) { return x.id === id; })[0];
if (i > -1) {
state.trip.splice(i, 1);
toast("Removed " + (s ? s.name : "spot") + " from your trip");
} else {
state.trip.push(id);
toast("Added " + (s ? s.name : "spot") + " to your trip");
}
renderList();
renderTrip();
}
/* ---------- Init ---------- */
buildHoods();
buildCats();
buildPins();
renderChips();
renderPins();
renderList();
renderRail();
renderTrip();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Porto Lumera — City Guide</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;9..144,700&family=Work+Sans:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<a class="skip-link" href="#guide">Skip to guide</a>
<header class="hero" role="banner">
<div class="hero__scene" aria-hidden="true">
<div class="hero__sky"></div>
<svg class="hero__art" viewBox="0 0 1200 520" preserveAspectRatio="xMidYMax slice" role="img" aria-label="">
<defs>
<linearGradient id="sea" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#1f8a8a"/>
<stop offset="1" stop-color="#13616a"/>
</linearGradient>
<linearGradient id="hill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#caa477"/>
<stop offset="1" stop-color="#9c7a4f"/>
</linearGradient>
</defs>
<path d="M0 360 Q300 320 600 350 T1200 340 V520 H0 Z" fill="url(#sea)"/>
<path d="M0 360 Q200 300 460 330 T1200 320 V380 H0 Z" fill="#1aa0a0" opacity=".55"/>
<g fill="url(#hill)">
<path d="M0 360 L120 250 L240 330 L360 230 L520 350 L660 240 L820 350 L980 250 L1120 340 L1200 300 V360 Z"/>
</g>
<g fill="#f4ede2" opacity=".95">
<rect x="150" y="288" width="34" height="58" rx="3"/>
<rect x="196" y="300" width="26" height="46" rx="3"/>
<rect x="300" y="276" width="30" height="70" rx="3"/>
<rect x="338" y="296" width="22" height="50" rx="3"/>
<rect x="470" y="290" width="34" height="56" rx="3"/>
<rect x="610" y="272" width="28" height="74" rx="3"/>
<rect x="648" y="294" width="24" height="52" rx="3"/>
<rect x="800" y="288" width="32" height="58" rx="3"/>
<rect x="940" y="280" width="26" height="66" rx="3"/>
<rect x="980" y="298" width="22" height="48" rx="3"/>
</g>
<g fill="#e8623f">
<polygon points="317,250 333,250 325,234"/>
<polygon points="623,246 639,246 631,228"/>
<polygon points="953,256 969,256 961,238"/>
</g>
<circle cx="1040" cy="120" r="46" fill="#ffe8b0" opacity=".9"/>
</svg>
</div>
<div class="hero__inner">
<p class="hero__eyebrow">Coastal Portugal · Travelogue</p>
<h1 class="hero__title">Porto Lumera</h1>
<p class="hero__lede">A sun-bleached harbour city stitched from six walkable neighbourhoods —
tiled lanes, market squares, cliff-edge miradouros and a tram that hums to the sea.</p>
<ul class="hero__meta">
<li><span aria-hidden="true">☀</span> Best Apr–Oct</li>
<li><span aria-hidden="true">🚋</span> Walk & tram</li>
<li><span aria-hidden="true">⏱</span> 3–4 day trip</li>
</ul>
</div>
</header>
<main id="guide" class="wrap">
<section class="controls" aria-label="Filter the guide">
<div class="controls__row">
<h2 class="controls__label" id="hood-label">Neighbourhood</h2>
<div class="chips" role="tablist" aria-labelledby="hood-label" id="hoods"></div>
</div>
<div class="controls__row controls__row--cats">
<h2 class="controls__label" id="cat-label">Category</h2>
<div class="cats" role="group" aria-labelledby="cat-label" id="cats"></div>
</div>
</section>
<div class="layout">
<section class="poi" aria-labelledby="poi-head">
<div class="poi__head">
<h2 id="poi-head" class="poi__title">Top spots</h2>
<p class="poi__count" id="count" aria-live="polite"></p>
</div>
<ul class="poi__list" id="poiList"></ul>
<p class="poi__empty" id="empty" hidden>No spots match those filters — try another category.</p>
</section>
<aside class="side" aria-label="Map and highlights">
<figure class="map" aria-labelledby="map-cap">
<svg class="map__svg" viewBox="0 0 360 360" role="img" aria-labelledby="map-title">
<title id="map-title">Stylised neighbourhood map of Porto Lumera with location pins</title>
<rect width="360" height="360" fill="#f0e7d8"/>
<path d="M0 250 Q120 230 220 270 T360 250 V360 H0 Z" fill="#bfe3df"/>
<path d="M0 262 Q120 244 220 282 T360 262" fill="none" stroke="#1f8a8a" stroke-width="2" opacity=".5"/>
<g stroke="#d9c6a8" stroke-width="6" fill="none" stroke-linecap="round">
<path d="M40 30 L40 250"/>
<path d="M150 20 L150 260"/>
<path d="M260 24 L260 250"/>
<path d="M20 80 L330 70"/>
<path d="M20 160 L330 150"/>
</g>
<path id="tram" d="M40 250 L150 160 L260 90 L330 70" fill="none"
stroke="#e8623f" stroke-width="3" stroke-dasharray="6 6" opacity=".8"/>
<g class="park" fill="#a9cf9b" opacity=".7">
<circle cx="300" cy="200" r="34"/>
</g>
<g id="pins"></g>
</svg>
<figcaption id="map-cap" class="map__cap">
<span class="map__legend"><i class="dot dot--tram"></i> Tram line</span>
<span class="map__legend"><i class="dot dot--sea"></i> Harbour</span>
</figcaption>
</figure>
<section class="rail" aria-labelledby="rail-head">
<h2 id="rail-head" class="rail__title">Top 5 right now</h2>
<ol class="rail__list" id="rail"></ol>
</section>
<section class="trip" aria-labelledby="trip-head">
<h2 id="trip-head" class="trip__title">Your trip</h2>
<p class="trip__empty" id="tripEmpty">Tap the heart on a spot to start planning.</p>
<ul class="trip__list" id="tripList"></ul>
</section>
</aside>
</div>
</main>
<footer class="foot" role="contentinfo">
<p>Porto Lumera Guide · A fictional travel companion. Prices, hours and maps are illustrative.</p>
</footer>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>City Guide
An editorial city guide for the invented harbour town of Porto Lumera, built to feel warm and walkable. The hero is a full-bleed sunset scene rendered entirely from CSS gradients and a layered inline SVG skyline, sitting above a chip-based neighbourhood selector and a small category filter for Eat, Drink, See, Stay and Shop. Choosing a neighbourhood — or any category — instantly narrows the point-of-interest list, where each card pairs a gradient photo, a star rating, a price tier and a best-time badge with a save affordance.
The neighbourhood selector is wired to a first-class map. A stylised SVG plan of the city carries one numbered pin per district along a coral tram line; selecting a chip highlights that district’s pin and dims the rest, and the View on map link on any card jumps the guide straight to it. The pins are keyboard-operable buttons, so the map and the chips stay in sync no matter which control you reach for. A live Top 5 right now rail re-ranks the visible spots by rating as you filter.
Saving works throughout: tap the heart on a card to drop a spot into the Your trip panel, remove it from either place, and a toast confirms each change. The layout is a two-column guide on the desktop that collapses to a single stacked column — map first — on narrow screens, staying legible and tappable down to about 360px.
Illustrative travel UI only — fictional destinations, prices, and maps.