.homeView { width: 100%; } /* ── Page rhythm ─────────────────────────────────────────────────────────── The landing page owns the vertical gap between its bands; no band sets its own top or bottom margin. Previously each one did, and the gaps came out as 24 / 32 / 24 / 16 / 48 / 32 / 16px — no scale, which is most of the reason a designed page read as a stack of unrelated strips. If you add a band here, give it padding and a ground, never a margin. */ .landing { display: flex; flex-direction: column; gap: var(--section-gap); } /* The hero and the four reasons under it are one thought, so they sit closer to each other than to the next band. This is the only place that overrides the page gap, and it does it by grouping rather than by re-spacing. */ .heroGroup { display: flex; flex-direction: column; gap: 2rem; } /* ── Section header ──────────────────────────────────────────────────────── One pattern for every band: kicker, heading, optional aside. */ .sectionHead { display: grid; grid-template-columns: 1fr auto; align-items: baseline; column-gap: 1.5rem; margin-bottom: var(--section-head-gap); } .sectionKicker { grid-column: 1 / -1; font-size: var(--step--2); font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: var(--brand-strong); margin: 0 0 0.35rem; } .sectionHeading { font-family: var(--font-display); font-size: var(--step-3); font-weight: 700; line-height: 1.2; letter-spacing: -0.02em; color: var(--text-primary); margin: 0; max-width: 24ch; text-wrap: balance; } .sectionAside { font-size: var(--step--1); color: var(--text-muted); margin: 0; text-align: right; } @media (max-width: 768px) { .sectionHead { grid-template-columns: 1fr; } .sectionHeading { font-size: var(--step-2); max-width: none; } .sectionAside { text-align: left; margin-top: 0.4rem; } } /* ── Hero ────────────────────────────────────────────────────────────────── The artwork fills the panel and the proposition sits on top of it, in the empty cream area the illustration reserves on its left. It is not a two-column grid: splitting the panel would crop that reserved area off and shrink the scene into a thumbnail, which is the one thing the artwork is composed not to be. Below the one-column breakpoint the artwork leaves the background and becomes a band under the search — see the 860px block. */ .heroPanel { position: relative; isolation: isolate; background: var(--hero-ground); border-radius: var(--radius-xl); /* * Deliberately NOT overflow: hidden. * * It used to be, to clip the artwork and the scrim to the rounded corners — * and it also clipped the search box's suggestion dropdown, which is 320px * tall against 145px of panel below the input. Roughly half the list was cut * off with no indication anything was missing. * * The two things that actually needed clipping round themselves instead, so * the panel can let a dropdown out. Anything absolutely positioned inside * this panel and taller than the space below it depends on this. */ } .heroContent { position: relative; z-index: 2; padding: 3.5rem 1.5rem 3.5rem 3rem; /* Held inside the artwork's cream area. Beyond roughly this width the text runs onto the hillside, where the scrim below is doing all the work. */ max-width: min(56%, 40rem); min-width: 0; } .heroArt { position: absolute; inset: 0; z-index: 0; /* Rounds itself, because the panel no longer clips it. inset: 0 makes this exactly the panel's own corners. */ border-radius: inherit; overflow: hidden; } .heroArt picture, .heroArt img { display: block; width: 100%; height: 100%; } .heroArt img { object-fit: cover; /* Centre keeps the schoolhouse in frame at every desktop panel ratio: the panel runs about 2.1:1 to 2.7:1 against a 1.78:1 image, so cover crops height only, and the school sits well inside the surviving band. */ object-position: center; } /* * Contrast guarantee. * * The artwork's cream area is generous but it is a fixed image on a fluid * panel — as the panel widens the text block and the cream drift apart, and at * some width the headline would sit on hillside green. Rather than tune the * text width per breakpoint and hope, the panel fades its own ground across * the left of the artwork, so the copy always has a known surface under it. * * The gradient starts from --hero-ground, which is sampled from the artwork * itself, so in the light theme this is invisible — it reads as the * illustration, not as an overlay on it. */ .heroPanel::before { content: ''; position: absolute; inset: 0; z-index: 1; pointer-events: none; /* Same reason as .heroArt: the panel stopped clipping, so the scrim keeps its own corners rather than squaring off over the panel's. */ border-radius: inherit; background: linear-gradient( to right, var(--hero-ground) 0%, rgba(var(--hero-ground-rgb), 0.97) 26%, rgba(var(--hero-ground-rgb), 0.72) 44%, rgba(var(--hero-ground-rgb), 0) 62% ); } /* * Dark theme. * * The artwork is a fixed raster, so unlike the drawn hero it cannot be * re-graded token by token — but the failure it would otherwise reproduce is * the same one: a bright illustration is the brightest object on a near-black * page and out-shouts the H1 and the search box. Dimming it in CSS is the * whole treatment, and the scrim then fades it into the dark panel instead of * into cream. * * Raising the brightness raises the background under light dark-theme text, so * this value has a contrast floor, not just a taste range. Measured off * rendered pixels, sampling background up to 120px past each line: * * brightness title body * 0.52 11.01:1 6.44:1 * 0.75 9.48:1 5.21:1 ← current * * Body text is the binding one. Going much above 0.75 walks it toward the * 4.5:1 floor, and at that point the scrim needs to carry further right rather * than the artwork being dimmed less. */ @media (prefers-color-scheme: dark) { .heroArt img { filter: brightness(0.75) saturate(0.72) contrast(1.02); } .heroPanel::before { background: linear-gradient( to right, var(--hero-ground) 0%, rgba(var(--hero-ground-rgb), 0.96) 30%, rgba(var(--hero-ground-rgb), 0.66) 50%, rgba(var(--hero-ground-rgb), 0.15) 78% ); } } /* * The one line in the first person, so it is set apart from the marketing copy * around it without shouting: display face, a step down in size, and a short * brand rule instead of a bullet or an emoji. * * No italic — Manrope ships no italic in the loaded weights, so font-style * would be synthesised into a slant. Same reason .heroEmph sets font-style * back to normal. */ .heroByline { display: flex; align-items: center; gap: 0.55rem; margin: 1.25rem 0 0; font-family: var(--font-display); font-size: var(--step--1); font-weight: 500; font-style: normal; color: var(--text-secondary); } .heroByline::before { content: ''; flex: 0 0 auto; width: 1.25rem; height: 2px; border-radius: 1px; background: var(--brand); } @media (max-width: 640px) { .heroByline { margin-top: 1rem; font-size: var(--step--2); } } .heroEyebrow { display: inline-flex; align-items: center; gap: 0.4rem; font-size: 0.72rem; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; /* --brand on its own 10% tint over Sand measures 4.19:1 — the tint darkens the ground under the label, and the token was specced against the ground, not against the tint. --brand-strong on the same composite is 5.85:1. */ color: var(--brand-strong); background: rgba(var(--brand-rgb), 0.1); padding: 0.3rem 0.7rem; border-radius: 999px; margin-bottom: 1rem; } .heroEyebrowDot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--brand); } .heroTitle { font-size: var(--step-4); font-weight: 700; color: var(--text-primary); margin-bottom: 0.75rem; line-height: 1.12; letter-spacing: -0.03em; font-family: var(--font-display); max-width: 18ch; } /* The em is a colour change, not an emphasis change — Manrope has no italic in the loaded weights, so leaving font-style alone would synthesise a slant. */ .heroEmph { color: var(--brand); font-style: normal; } .heroDescription { font-size: var(--step-1); color: var(--text-secondary); margin: 0 0 1.5rem; max-width: 46ch; line-height: 1.55; } .heroDescription strong { color: var(--text-primary); font-weight: 600; } @media (max-width: 1024px) { .heroContent { padding: 2.75rem 1.5rem 2.75rem 2rem; max-width: min(64%, 34rem); } } /* * One column: the artwork stops being a background and becomes a band under * the search. * * Overlaying at this width does not work — the panel is too narrow for the * copy to stay inside the artwork's cream area, so the scrim would have to * cover nearly the whole image, which is just a tinted rectangle with a * picture faintly behind it. Below the band, has already switched * to the pre-cropped file (see Illustration.tsx); this is the layout half of * the same decision. */ @media (max-width: 860px) { /* * Artwork above the copy, not below it. * * The DOM keeps .heroContent first so the desktop overlay does not depend on * source order, which left the band stranded under the search on phones — * reading as a strip stuck on the end rather than as a hero image. `order` * moves it visually only; it is decorative and aria-hidden, so there is no * reading order to disturb. * * It costs about 136px above the search. Measured on a 667px viewport (the * shortest phone still in use) the search still lands near 400px, well * inside the fold. */ .heroPanel { display: flex; flex-direction: column; } .heroContent { padding: 1.75rem 1.5rem 1.75rem; max-width: none; } /* 13rem, not 11: at 860px an 11rem band is 4.5:1, and squeezing the 3.2:1 crop into that throws away enough height to cut the flag off the roof and the base off the building. 13rem holds it at about 3.8:1, where the building stays whole. */ .heroArt { position: static; order: -1; height: 13rem; /* Top corners only. Here the artwork is a band flush with the top of the panel, not a layer covering it — inheriting all four would leave it floating with rounded bottom corners against the copy below. */ border-radius: var(--radius-xl) var(--radius-xl) 0 0; } /* The band crop puts the schoolhouse at 73% across — reported by scripts/build-hero-images.js, which derives it from the crop box rather than leaving the two to drift. Squeezing narrower then crops the emptier left side rather than the subject. */ .heroArt img { object-position: 73% center; } /* The scrim exists to protect text sitting on the artwork. Nothing sits on it here, and leaving it would wash the band out. */ .heroPanel::before { display: none; } .heroTitle { font-size: var(--step-3); max-width: none; } .heroDescription { font-size: var(--step-0); max-width: none; } } /* Above the fold on phones, every line costs. Drop the eyebrow tag and the long coverage sentence, leaving the proposition and the search — the data provenance is carried by the "Official & trusted" prop immediately below the hero (audit P1.7, feeds the 46% home exit rate). */ @media (max-width: 640px) { .heroPanel { border-radius: var(--radius-lg); } .heroContent { padding: 1.25rem 1.1rem 1.5rem; } .heroEyebrow, .heroDescriptionFull { display: none; } .heroDescription { font-size: 0.9rem; line-height: 1.45; margin-bottom: 1rem; } .heroTitle { font-size: 1.75rem; margin-bottom: 0.5rem; } /* 10rem, not 8.5: the band is widest-per-height right at this breakpoint — at 640px an 8.5rem band is 4.2:1, worse than anything above it, because the height steps down here while the width does not. 10rem keeps it near 3.6:1, in line with the rest of the range. */ .heroArt { height: 10rem; } } /* ── Value props ─────────────────────────────────────────────────────────── Four reasons, straight from the guideline. The fourth is ours rather than theirs — see the comment on VALUE_PROPS in HomeView.tsx. */ .valueProps { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1.25rem; list-style: none; padding: 0; margin: 0; } .valueProp { display: flex; gap: 0.8rem; align-items: flex-start; } .propIcon { display: flex; align-items: center; justify-content: center; flex: 0 0 2.6rem; width: 2.6rem; height: 2.6rem; border-radius: var(--radius-md); } .propIconTrust { background: var(--brand-bg); color: var(--brand); } .propIconCompare { background: rgba(var(--sage-rgb), 0.42); color: var(--status-above); } .propIconContext { background: rgba(242, 201, 76, 0.26); color: var(--mustard-ink); } .propIconDeadline { background: rgba(221, 216, 245, 0.6); color: var(--phase-nursery); } .propTitle { font-family: var(--font-display); font-size: var(--step-0); font-weight: 700; color: var(--text-primary); margin: 0 0 0.15rem; letter-spacing: -0.01em; } .propBody { font-size: var(--step--1); color: var(--text-muted); line-height: 1.5; margin: 0; } @media (max-width: 900px) { .valueProps { grid-template-columns: repeat(2, 1fr); gap: 1rem 1.25rem; } } @media (max-width: 560px) { .valueProps { grid-template-columns: 1fr; gap: 0.85rem; } } /* ── Results toolbar ─────────────────────────────────────────────────────── Search, filters and the List/Map switch in one card, pinned under the header. It sits above Leaflet's controls (z-index 1000), which scroll up beneath it, and below the comparison toast and the map's own fullscreen overlay. */ .resultsToolbar { position: sticky; top: calc(64px + 0.5rem); z-index: 1001; display: flex; align-items: flex-start; gap: 0.75rem; margin-bottom: 1.25rem; padding: 0.875rem 1rem; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-lg); box-shadow: var(--shadow-soft); } .resultsToolbar > :first-child { flex: 1; min-width: 0; } /* The one filled control in the toolbar: which view you are in is the first thing a results page should answer. */ .viewSwitch { flex-shrink: 0; display: flex; gap: 2px; padding: 1px; background: var(--bg-card); border: 1.5px solid var(--brand); border-radius: var(--radius-md); } .viewSwitchBtn { display: inline-flex; align-items: center; gap: 0.5rem; min-height: 2.5rem; padding: 0 1.125rem; background: transparent; border: 0; border-radius: calc(var(--radius-md) - 3px); font-family: var(--font-ui); font-size: var(--step--1); font-weight: 700; color: var(--brand-strong); cursor: pointer; transition: background-color var(--transition), color var(--transition); } .viewSwitchBtn:hover { background: var(--brand-bg); } .viewSwitchBtn[aria-pressed='true'] { background: var(--brand); color: var(--brand-on); } /* Phones: a full-bleed band flush under the header, and the switch gives way to the floating button in .mobileDock. */ @media (max-width: 640px) { .resultsToolbar { top: 56px; margin: -1rem -1rem 1rem; padding: 0.75rem 1rem; border-width: 0 0 1px; border-radius: 0; box-shadow: 0 2px 8px rgba(var(--shadow-rgb), 0.06); } .viewSwitch { display: none; } } .results { margin-top: 1rem; } .mapViewResults { margin-top: 0; } /* Map View Layout */ .mapViewContainer { display: grid; grid-template-columns: 1fr 340px; gap: 1rem; height: calc(100dvh - 280px); min-height: 520px; max-height: 800px; } .mapContainer { border-radius: 10px; overflow: hidden; border: 1px solid var(--border); height: 100%; } .compactList { display: flex; flex-direction: column; gap: 0.5rem; overflow-y: auto; height: 100%; padding-right: 0.375rem; } .compactList::-webkit-scrollbar { width: 6px; } .compactList::-webkit-scrollbar-track { background: var(--bg-secondary); border-radius: 3px; } .compactList::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } .compactList::-webkit-scrollbar-thumb:hover { background: var(--text-muted); } /* Compact School Item */ .compactItem { display: flex; justify-content: space-between; align-items: center; gap: 0.75rem; padding: 0.625rem 0.75rem; background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; transition: all 0.2s ease; } .compactItem:hover { border-color: var(--brand); box-shadow: 0 2px 6px rgba(var(--shadow-rgb), 0.05); } .compactItemContent { flex: 1; min-width: 0; } .compactItemHeader { display: flex; align-items: center; gap: 0.375rem; margin-bottom: 0.125rem; } .compactItemName { font-weight: 600; font-size: 0.8125rem; color: var(--text-primary); text-decoration: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .compactItemName:hover { color: var(--brand-strong); } .distanceBadge { flex-shrink: 0; padding: 0.0625rem 0.375rem; font-size: 0.6875rem; font-weight: 600; background: var(--brand); color: var(--brand-on); border-radius: 3px; } .compactItemMeta { display: flex; gap: 0.375rem; font-size: 0.6875rem; color: var(--text-secondary); margin-bottom: 0.25rem; } .compactItemMeta span:not(:last-child)::after { content: '·'; margin-left: 0.375rem; color: var(--text-muted); } .compactItemStats { display: flex; gap: 0.75rem; font-size: 0.6875rem; color: var(--text-secondary); } .compactStat strong { color: var(--text-primary); } .compactItemActions { display: flex; flex-direction: column; gap: 0.25rem; flex-shrink: 0; } .sectionHeader { margin-bottom: 1rem; } .sectionHeader h2 { font-size: 1.5rem; font-weight: 700; margin-bottom: 0.5rem; color: var(--text-primary); font-family: var(--font-display); } /* Decorative coral bar under section headings */ .sectionHeader h2::after { content: ''; display: block; width: 50px; height: 2px; background: var(--brand); border-radius: 1px; margin-top: 0.5rem; } .sectionDescription { font-size: 0.9375rem; color: var(--text-secondary); margin: 0; line-height: 1.5; } .schoolList { display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 1.25rem; } /* Staggered fade-in for rows */ .schoolList > *:nth-child(1) { animation-delay: 0ms; } .schoolList > *:nth-child(2) { animation-delay: 30ms; } .schoolList > *:nth-child(3) { animation-delay: 60ms; } .schoolList > *:nth-child(4) { animation-delay: 90ms; } .schoolList > *:nth-child(5) { animation-delay: 120ms; } .schoolList > *:nth-child(6) { animation-delay: 150ms; } .schoolList > *:nth-child(7) { animation-delay: 180ms; } .schoolList > *:nth-child(8) { animation-delay: 210ms; } .schoolList > *:nth-child(9) { animation-delay: 240ms; } .schoolList > *:nth-child(n+10) { animation-delay: 270ms; } .emptyState { text-align: center; padding: 2.5rem 1.5rem; background: var(--bg-card); border: 1px solid var(--border); border-radius: 10px; } .emptyStateTitle { font-size: 1.25rem; font-weight: 600; color: var(--text-primary); margin-bottom: 0.375rem; font-family: var(--font-display); } .emptyStateDescription { font-size: 0.9375rem; color: var(--text-secondary); max-width: 380px; margin: 0 auto; } @media (max-width: 768px) { .resultsHeader { flex-direction: column; align-items: flex-start; } .resultsHeaderActions { width: 100%; justify-content: space-between; } /* The sort