@import url("tokens.css");

*, *::before, *::after { box-sizing: border-box; }

body {
  margin: 0;
  background: var(--cw-cream);
  color: var(--cw-ink);
  font-family: var(--cw-font-body);
  font-size: 17px;
  line-height: 1.6;
}

a { color: var(--cw-green); }
a:hover { text-decoration: none; }

.wrap { max-width: 1080px; margin: 0 auto; padding: 0 1.25rem; }

/* Header: dark bar, non-negotiable while the logo is a white-only export. */
/* Positioned and stacked so the nav's dropdown panel paints over the page
   rather than behind the first thing below the header. */
.site-header { background: var(--cw-green); color: #fff;
  position: relative; z-index: 5; }
.site-header a { color: #fff; text-decoration: none; }
/* The header alone escapes the 1080px column: the twelve-item nav cannot
   share a line with the logo inside it, so the bar uses the full viewport -
   logo hard left, nav hard right. Body content stays on the column. */
.site-header .wrap { display: flex; flex-wrap: wrap; gap: 1rem;
  max-width: none;
  align-items: center; justify-content: space-between; padding-block: 1rem; }
/* The logo is a white-only export with coloured category icons, so it needs a
   dark bar behind it. The palette's green works - white reads at 11.55:1 and
   the icons stay legible from about 44px up - so no black plate is needed;
   a black box inside a green header would read as a pasted-in asset. */
.brand { display: block; line-height: 0; }
.brand img { height: 48px; width: auto; display: block; }
@media (max-width: 640px) { .brand img { height: 38px; } }
.site-nav { display: flex; flex-wrap: wrap; gap: 1rem; font-size: .95rem; }
.site-nav a { border-bottom: 1px solid transparent; padding-bottom: 2px; }
/* Gold is legible on the dark green (4.92:1) - this is the one place it is
 * allowed near text. */
.site-nav a:hover { border-bottom-color: var(--cw-gold); }

/* Directory / Map / Walk, grouped.
 *
 * The DEFAULT is all three visible - `display: contents` lets the wrapper
 * spans dissolve so the links are ordinary flex items of .site-nav. The
 * dropdown is added on top of that, and only where a dropdown can actually be
 * operated. Written the other way round, a device that matched neither branch
 * would be left with two links it could never reach.
 */
.nav-group, .nav-menu { display: contents; }

@media (min-width: 761px) and (hover: hover) {
  /* `hover: hover` is the load-bearing half of this query. On a touchscreen
     the parent is a LINK to /directory/, so a tap navigates and the panel is
     never seen - there the group stays three links abreast. */
  .nav-group { display: block; position: relative; }
  .nav-group-top::after {
    content: ""; display: inline-block; margin-left: .45em;
    border: 4px solid transparent; border-top-color: currentColor;
    transform: translateY(2px);
  }
  .nav-menu {
    display: block; position: absolute; top: 100%; left: 0;
    min-width: 100%; padding: .25rem 0;
    background: var(--cw-green-deep);
    /* The hairline is an INSET shadow, not a border. A real border runs the
       full height of the box, so its left and right edges would draw two thin
       lines up through the transparent bridge and across the green header.
       An inset shadow paints inside the padding edge, which is exactly the
       painted panel. */
    box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .16);
    /* The bridge. A plain margin would leave a dead strip between the link and
       the panel, and the pointer crossing it closes the panel - the same 8px
       gap that made the map's marker cards unusable. A transparent top border
       plus padding-box clipping puts the hit area there without painting it.
       1.65rem is the measured distance from the nav link's box to the bottom
       of the header, so the panel's painted edge starts exactly where the
       green bar ends instead of sitting a dark rectangle on top of it. Only
       the seam depends on that number - the hover bridge works at anything
       from the gap upward. */
    border-top: 1.65rem solid transparent; background-clip: padding-box;
    /* Hidden but still FOCUSABLE. display:none and visibility:hidden both take
       the links out of the tab order, and then :focus-within can never fire -
       a keyboard user tabbing off Directory would skip straight past Map and
       Walk with no way to reach either. */
    opacity: 0; pointer-events: none; clip-path: inset(50%);
  }
  .nav-group:hover .nav-menu,
  .nav-group:focus-within .nav-menu {
    opacity: 1; pointer-events: auto; clip-path: none;
  }
  .nav-menu a {
    display: block; white-space: nowrap;
    padding: .5rem .9rem; border-bottom: 0;
  }
  .nav-menu a:hover, .nav-menu a:focus-visible {
    background: rgba(255, 255, 255, .1); border-bottom: 0;
  }
}

/* Mobile nav.
 *
 * The nav is OPEN by default and the button is display:none above the
 * breakpoint, so a no-JS visitor keeps the wrapped row of links that has
 * always been there. nav.js reveals the button and adds .js-nav; only then
 * does the collapsed state exist at all. Collapsing unconditionally would hide
 * the whole navigation from anyone whose JS did not run. */
.nav-toggle {
  display: none; align-items: center; gap: .5rem;
  min-height: 44px; padding: .4rem .7rem;
  background: none; color: #fff; font: inherit; font-size: .95rem;
  border: 1px solid rgba(255, 255, 255, .45); border-radius: var(--cw-radius);
  cursor: pointer;
}
.nav-toggle:focus-visible { outline: 3px solid var(--cw-gold); outline-offset: 2px; }
/* Three bars drawn from one box, so there is no icon font and no extra markup.
   aria-hidden on the span - the button already says "Menu". */
.nav-bars, .nav-bars::before, .nav-bars::after {
  display: block; width: 20px; height: 2px; background: #fff; content: "";
}
.nav-bars { position: relative; }
.nav-bars::before { position: absolute; top: -6px; }
.nav-bars::after { position: absolute; top: 6px; }
[aria-expanded="true"] .nav-bars { background: transparent; }
[aria-expanded="true"] .nav-bars::before { top: 0; transform: rotate(45deg); }
[aria-expanded="true"] .nav-bars::after { top: 0; transform: rotate(-45deg); }

@media (max-width: 760px) {
  .nav-toggle { display: inline-flex; }
  /* .js-nav gates the collapse: without the script the button never appears
     and the nav is never hidden. */
  .js-nav .site-nav { display: none; width: 100%; }
  .js-nav .site-nav.is-open { display: flex; }
  .site-nav {
    flex-direction: column; gap: 0; padding-bottom: .5rem;
  }
  .site-nav a {
    /* Full-width rows at the coarse-pointer floor, rather than a wrapped
       ribbon of small targets. */
    display: block; min-height: 44px; line-height: 44px;
    border-bottom: 1px solid rgba(255, 255, 255, .18); padding-bottom: 0;
  }
  .site-nav a:hover { border-bottom-color: var(--cw-gold); }
  /* Indented under Directory, so the grouping survives the collapse into a
     column even though the panel does not. */
  .nav-menu a { padding-left: 1.25rem; }
}

main { padding-block: 2.5rem 4rem; }

h1 { font-family: var(--cw-font-display); font-size: clamp(1.9rem, 4vw, 2.6rem);
  line-height: 1.15; margin: 0 0 .75rem; }
h2 { font-family: var(--cw-font-display); font-size: 1.4rem; margin: 2rem 0 .75rem; }
h3 { font-family: var(--cw-font-display); font-size: 1.15rem; margin: 0 0 .35rem; }
p { max-width: var(--cw-measure); }
.lede { font-size: 1.1rem; color: var(--cw-ink-soft); max-width: var(--cw-measure); }

.crumbs { font-size: .9rem; color: var(--cw-ink-soft); margin-bottom: .75rem; }

/* Listings */
.cards { list-style: none; margin: 0; padding: 0; display: grid; gap: 1rem;
  grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); }
.card { background: var(--cw-cream-warm); border: 1px solid var(--cw-rule);
  border-radius: var(--cw-radius); padding: 1.1rem; }
.card h3 a { text-decoration: none; }
.card h3 a:hover { text-decoration: underline; }
.card-type { font-size: .8rem; text-transform: uppercase; letter-spacing: .08em;
  color: var(--cw-ink-soft); margin: 0 0 .5rem; }
.card-desc { margin: 0 0 .6rem; font-size: .95rem; }
.card-meta { margin: 0; font-size: .9rem; color: var(--cw-ink-soft); }

/* Hero. The empty state is the NORMAL path - 48 of 48 records launch with no
 * photograph - so it is designed, not a fallback. */
.hero { display: block; width: 100%; max-width: 720px; height: auto;
  border-radius: var(--cw-radius); margin: 1.25rem 0; }
.hero-empty { display: grid; place-items: center; aspect-ratio: 16 / 7;
  background: var(--cw-green); color: #fff; }
.hero-empty span { font-family: var(--cw-font-display); font-size: 1.5rem;
  letter-spacing: .12em; text-transform: uppercase; opacity: .85; }

.facts { display: grid; grid-template-columns: max-content 1fr; gap: .4rem 1.25rem;
  margin: 1.5rem 0; max-width: var(--cw-measure); }
.facts dt { font-weight: 600; color: var(--cw-ink-soft); }
.facts dd { margin: 0; }

.hours { border-collapse: collapse; margin: .5rem 0 1rem; }
.hours th { text-align: left; font-weight: 600; padding: .3rem 1.5rem .3rem 0;
  color: var(--cw-ink-soft); }
.hours td { padding: .3rem 0; }
.hours-note { font-size: .92rem; color: var(--cw-ink-soft); font-style: italic; }

/* Where the facts above came from, on the 32 listings the business has never
   confirmed. It reports OUR provenance; it does not accuse anyone, so there is
   no red and no warning colour - it sits at the same weight as .hours-note
   directly above it. Not .notice: check_inactive_notices fails any ACTIVE page
   containing that class. */
.unconfirmed { font-size: .92rem; color: var(--cw-ink-soft);
  max-width: var(--cw-measure); margin: 0 0 1rem;
  padding-left: .85rem; border-left: 2px solid var(--cw-rule); }

.notice { background: var(--cw-cream-warm); border-left: 3px solid var(--cw-gold);
  padding: .85rem 1rem; max-width: var(--cw-measure); }

/* The two runtime surfaces. Both sections ship `hidden` and are unhidden by
   their own script only once there is real content, so nothing here is ever
   seen in the empty state - there is no empty state to style. Using [hidden]
   works because these are HTML elements; the SVG layers on the map cannot use
   it and set display directly instead. */
.specials { background: var(--cw-cream-warm); border: 1px solid var(--cw-rule);
  border-radius: var(--cw-radius); padding: 1rem 1.15rem; margin: 1.5rem 0;
  max-width: var(--cw-measure); }
.specials h2 { margin: 0 0 .6rem; font-size: 1.15rem; }
.specials-list { list-style: none; margin: 0; padding: 0; }
.specials-list li { display: flex; justify-content: space-between; gap: 1rem;
  padding: .35rem 0; border-bottom: 1px solid var(--cw-rule); }
.specials-list li:last-child { border-bottom: 0; }
.special-name { font-weight: 600; }
.special-price { color: var(--cw-ink-soft); white-space: nowrap; }

.reviews { margin: 1.5rem 0; max-width: var(--cw-measure); }
.reviews h2 { font-size: 1.15rem; margin: 0 0 .5rem; }
.review-badge { display: flex; align-items: baseline; gap: .5rem; margin: 0 0 .4rem; }
/* Gold is decorative only and fails the body-text floor on cream - see
   tokens.css. It is legitimate here because the stars are aria-hidden and the
   score beside them carries the same value in ink. */
.review-stars { color: var(--cw-gold); letter-spacing: .05em; }
.review-score { font-weight: 700; font-size: 1.1rem; }
.review-count { color: var(--cw-ink-soft); font-size: .92rem; }
.review-link { font-size: .92rem; }

/* The owner invitation, last thing in the member body. Addressed to one reader
   in a hundred, so it reads as a rule and a paragraph rather than a panel: a
   filled box competes with the business's own phone number for every other
   visitor, and would look near-identical to .notice, which means the opposite
   thing on the same template.
   No gold. It is 3.11:1 on cream and fails the body floor (tokens.css), and
   this page's two legitimate uses of it are already spent on .notice's rule and
   the review stars. The action reuses .btn rather than a bespoke class because
   the focus ring below is an explicit selector list ending in .btn:focus-visible
   - a new class would silently ship a link with no visible focus. */
.claim { max-width: var(--cw-measure); margin: 2.75rem 0 0;
  padding-top: 1.25rem; border-top: 1px solid var(--cw-rule); }
.claim h2 { font-size: 1.05rem; margin: 0 0 .4rem; }
.claim p { font-size: .95rem; color: var(--cw-ink-soft); margin: 0 0 .9rem; }
.claim .btn { background: var(--cw-green); color: #fff; }  /* 11.55:1 */

.site-footer { background: var(--cw-green-deep); color: var(--cw-cream);
  padding-block: 2.5rem 1.5rem; margin-top: 3rem; font-size: .92rem; }
.site-footer a { color: var(--cw-cream); text-decoration: none;
  border-bottom: 1px solid transparent; }
.site-footer a:hover { border-bottom-color: var(--cw-gold); }
.footer-grid { display: grid; gap: 2rem;
  grid-template-columns: repeat(auto-fit, minmax(min(220px, 100%), 1fr)); }
.footer-name { font-family: var(--cw-font-display); font-size: 1.2rem;
  margin: 0 0 .6rem; }
.footer-id p { margin: 0 0 .35rem; }
.footer-head { font-size: .72rem; letter-spacing: .18em;
  text-transform: uppercase; color: var(--cw-gold); margin: 0 0 .7rem; }
.footer-links { list-style: none; margin: 0; padding: 0;
  display: grid; gap: .4rem; }
/* The footer's sign-up. Same .signup rules as the homepage panel, re-coloured
   for the deep green: cream field, gold button (gold on green is 4.92:1, the
   one place gold is allowed to carry text - see tokens.css). The notice
   colours are the light-panel ones, which read fine as a card on the green. */
.footer-signup > p { margin: 0 0 .7rem; }
.footer-signup .signup input { background: var(--cw-cream); }
.footer-signup .signup label { color: rgba(245, 239, 233, .75); }
.footer-signup .signup button { background: var(--cw-gold); color: var(--cw-green-deep); }
.footer-signup .signup button:hover { background: #fff; }
.footer-signup .signup input:focus-visible,
.footer-signup .signup button:focus-visible { outline-color: #fff; }
.signup .form-notice { flex: 1 1 100%; margin: .5rem 0 0; }
.footer-base { margin-top: 2.25rem; padding-top: 1.1rem;
  border-top: 1px solid rgba(245, 239, 233, .18);
  color: rgba(245, 239, 233, .65); font-size: .85rem; }
.footer-base p { margin: 0; }

/* /about/: the etched window full-bleed, the district's own description on a
   deep-green panel over it. The image is absolute inside the wash-frame so
   the frame's height can come from the grid overlay, not the photo. */
.about-hero {
  display: grid; margin: 0 calc(50% - 50vw) 2.5rem; width: 100vw;
}
.about-hero .wash-frame, .about-panel { grid-area: 1 / 1; }
.about-hero .wash-frame { min-height: clamp(420px, 62vh, 660px); }
.about-hero .wash-frame img {
  position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover;
}
.about-panel {
  position: relative; align-self: center; justify-self: start;
  margin: clamp(1.25rem, 5vw, 4rem); max-width: 44rem;
  padding: clamp(1.4rem, 3.5vw, 2.5rem);
  background: rgba(34, 48, 31, .9); color: var(--cw-cream);
}
.about-panel h1 { margin-bottom: 1rem; }
.about-panel p { margin: 0 0 .9rem; }
.about-panel p:last-child { margin-bottom: 0; }
@media (max-width: 640px) {
  /* Overlaying a hundred words on a phone-width photo buries both; the
     panel becomes the next block instead, same green, zero seam. */
  .about-hero { display: block; }
  .about-hero .wash-frame { min-height: 240px; }
  .about-panel { margin: 0; max-width: none; }
}

/* /contact/: the form and the office, side by side where there is room. */
.contact-grid {
  display: grid; gap: 2.5rem; align-items: start;
  grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr));
  margin-top: 1.5rem;
}
.contact-form-block .field { margin-bottom: 1rem; }
.contact-form-block label { display: block; font-weight: 600;
  font-size: .92rem; margin-bottom: .3rem; }
.contact-form-block .optional { font-weight: 400; font-size: .85em;
  color: var(--cw-ink-soft); }
.contact-form-block input, .contact-form-block textarea {
  width: 100%; font: inherit; color: var(--cw-ink);
  background: var(--cw-cream-warm); border: 1px solid var(--cw-rule);
  border-radius: var(--cw-radius); padding: .6rem .7rem;
}
.contact-form-block input:focus-visible,
.contact-form-block textarea:focus-visible {
  outline: 3px solid var(--cw-gold); outline-offset: 1px;
}
/* The honeypot. Removed from sight and from the accessibility tree, never
   display:none - some spam scripts skip fields they can see are hidden. */
.hp { position: absolute; width: 1px; height: 1px; overflow: hidden;
  clip-path: inset(50%); white-space: nowrap; }
.form-notice { padding: .7rem .9rem; border-radius: var(--cw-radius);
  border: 1px solid; margin: 0 0 1rem; max-width: none; }
.form-notice.is-ok { background: #eef2e6; border-color: #b9c7a5;
  color: #2c4222; }
.form-notice.is-bad { background: #f7ebe8; border-color: #d9b3a8;
  color: #6e2f1e; }
.contact-office h2 { margin-top: 0; }
.contact-office > p { margin: 0 0 .35rem; }
.office-map { width: 100%; height: auto; margin: 1rem 0 .75rem;
  border: 1px solid var(--cw-rule); border-radius: var(--cw-radius);
  background: #f3efe8; }
.map-office circle { fill: var(--cw-green); stroke: #fff; stroke-width: 2; }
.map-office .map-office-dot { fill: var(--cw-gold); stroke: none; }

@media (max-width: 640px) {
  .facts { grid-template-columns: 1fr; gap: .1rem 0; }
  .facts dd { margin-bottom: .6rem; }
}

/* ============================================================
   Homepage
   ============================================================ */

.visually-hidden {
  position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}

/* ===== Homepage: the marquee concept (2026-08-24 redesign) =====
   Appetite-led, one live element. Typographic hero, washed film-strip, the
   ticker at display scale, then the verb tiles. Direction and non-negotiables:
   ../chapelwest/ticker-post-schema-v1.md. The ticker speaks in the mono face
   for a reason - see the type note in tokens.css. */

.home-hero { text-align: center; padding: 2.5rem 0 2.75rem; }
.home-hero h1 {
  font-family: var(--cw-font-display); font-weight: 600;
  font-size: clamp(2.7rem, 7.5vw, 5.5rem); line-height: 1.02;
  margin: 0 auto; max-width: 16ch; text-wrap: balance;
}
.home-hero h1 em { font-style: italic; color: var(--cw-green); }
.home-hero-sub {
  margin: 1.2rem auto 0; max-width: 52ch;
  color: var(--cw-ink-soft); font-size: 1.05rem;
}
/* The verb nav is generated from categories.json - a fifth category brings
   its own verb (asserted upstream), same rule as the nav and the tiles. */
.verb-line {
  margin-top: 1.8rem; display: flex; justify-content: center;
  flex-wrap: wrap; gap: .8rem 2rem;
}
.verb-line a {
  font-size: .82rem; letter-spacing: .2em; text-transform: uppercase;
  font-weight: 600; color: var(--cw-green); text-decoration: none;
  border-bottom: 2px solid var(--cw-gold); padding-bottom: .25rem;
}
.verb-line a:hover { color: var(--cw-ink); }

/* The shared colour wash. One treatment over every photograph is what lets
   mixed photography (hero shoot, scraped category pages, member uploads)
   cohere as one street - the Smorgasburg lesson from the design survey.
   A wrapper + overlay rather than a filter alone: the green `color` blend
   pulls every image toward the palette, the filter softens the source.
   pointer-events: none keeps tile links clickable under the overlay. */
.wash-frame {
  position: relative; isolation: isolate; overflow: hidden;
  background: var(--cw-green-deep);
  /* display:block matters: the spotlight uses a <span> wrapper inside its
     link, and an inline box gives the ::after overlay nothing to cover. */
  display: block;
}
.wash-frame img {
  filter: saturate(.78) sepia(.08) contrast(1.05) brightness(.98);
}
.wash-frame::after {
  content: ""; position: absolute; inset: 0; pointer-events: none;
  background: var(--cw-green); mix-blend-mode: color; opacity: .2;
}

/* Film strip. Full-bleed out of .wrap, same idiom the old slideshow used. */
.home-strip {
  display: grid; grid-template-columns: repeat(5, 1fr); gap: 4px;
  margin: 0 calc(50% - 50vw); width: 100vw; padding: 0 4px;
}
.home-strip .wash-frame { height: clamp(120px, 18vw, 210px); }
.home-strip img, .verb-tiles img {
  width: 100%; height: 100%; object-fit: cover; display: block;
}
/* The strip's phone layout is in the "Homepage on a phone" block below - it
   scrolls there rather than dropping frames. */

/* The ticker. Deep green band, mono voice, gold names - visually a different
   instrument from everything around it, so live text reads as live and a
   stale feed cannot contaminate the brand layer.

   Three states, least capable first:
   - No JS: the track is a wrapped static list. Every post readable, no motion.
   - JS + prefers-reduced-motion: ticker.js sees the media query and leaves
     the static layout alone.
   - JS + motion: .js-ready switches the track to a single animated row;
     ticker.js appends an aria-hidden duplicate so translateX(-50%) loops
     seamlessly, and unhides the Pause button. */
.ticker {
  margin: 0 calc(50% - 50vw); width: 100vw;
  background: var(--cw-green-deep); color: var(--cw-cream);
  font-family: var(--cw-font-live);
  border-top: 1px solid rgba(201, 164, 92, .35);
  border-bottom: 1px solid rgba(201, 164, 92, .35);
  display: flex; align-items: stretch; overflow: hidden;
}
.tk-tag {
  /* margin:0 because this is a <p>: its UA margin-block, not its content, was
     setting the whole band's height (59px for 36px of tag). */
  flex: none; margin: 0; display: flex; align-items: center; gap: .5rem;
  padding: .55rem 1.1rem; align-self: stretch;
  border-right: 1px solid rgba(201, 164, 92, .35);
  font-size: .72rem; letter-spacing: .18em; text-transform: uppercase;
  color: var(--cw-gold); white-space: nowrap;
}
.tk-dot {
  width: 7px; height: 7px; border-radius: 50%; background: var(--cw-gold);
  animation: cw-pulse 2s ease-in-out infinite;
}
@keyframes cw-pulse { 50% { opacity: .25; } }
.tk-win { flex: 1; overflow: hidden; display: flex; align-items: center; }
.tk-track {
  list-style: none; margin: 0; padding: .5rem 1rem;
  display: flex; flex-wrap: wrap; align-items: baseline; gap: .1rem 1.6rem;
}
.ticker.js-ready .tk-track {
  flex-wrap: nowrap; padding: 0; white-space: nowrap; width: max-content;
  animation: cw-tk var(--tk-dur, 60s) linear infinite;
  will-change: transform;
}
.ticker.is-paused .tk-track { animation-play-state: paused; }
@keyframes cw-tk { to { transform: translateX(-50%); } }
/* The vertical band's travel. .is-vert is set by ticker.js from the SAME
   media query the vertical layout below is written under - if the two ever
   disagree the band animates along an axis it is not laid out on, which looks
   like the ticker simply not moving. */
@keyframes cw-tk-y { to { transform: translateY(-50%); } }
.ticker.js-ready.is-vert .tk-track { animation-name: cw-tk-y; }
.tk-i {
  display: inline-flex; align-items: baseline; gap: .8rem;
  font-size: clamp(1.05rem, 2vw, 1.4rem); padding-block: .9rem;
  white-space: nowrap;
}
/* The static state is a designed state, not a degradation - but nowrap made
   it a broken one. Each post is one unbroken 600-900px line, and .tk-win
   clips at its own width, so with JS off or prefers-reduced-motion set every
   post was truncated mid-sentence: at 320px a reader saw about eight words of
   each. Only the animated single row needs nowrap, and .js-ready already sets
   it on the track, so it is lifted here rather than in the base rule.
   flex-wrap on the item lets the name, text and timestamp stack; max-width
   keeps the item inside the track instead of setting its own width. */
.ticker:not(.js-ready) .tk-i {
  white-space: normal; flex-wrap: wrap; max-width: 100%;
  /* Display scale is for text that is MOVING - a marquee needs big type to be
     caught in passing. Standing still it is a list, and at 1.05rem+ nine posts
     ran to 3197px on a phone, which buries the rest of the homepage under the
     band for exactly the visitors who asked for less motion. */
  font-size: 1rem; padding-block: .45rem;
}
.tk-n {
  color: var(--cw-gold); font-weight: 600; letter-spacing: .06em;
  text-transform: uppercase; font-size: .72em;
}
/* House posts: district-authored floor content. Name in cream plus a badge,
   so district notices read differently from member posts. */
.tk-i.is-house .tk-n { color: var(--cw-cream); }
.tk-i.is-house .tk-n::after {
  content: "district"; margin-left: .5rem; font-size: .72em;
  letter-spacing: .12em; text-transform: lowercase;
  border: 1px solid rgba(245, 239, 233, .4); border-radius: var(--cw-radius);
  padding: .05em .35em; color: rgba(245, 239, 233, .7);
}
.tk-m { color: rgba(245, 239, 233, .55); font-size: .68em; }
/* "View all" - the band's own route to /whats-happening/. It sits in the LIVE
   cluster rather than beside Pause and the speed dial on purpose: this is
   NAVIGATION and those are widget controls, and one run of four segments doing
   four unrelated things reads as a toolbar nobody can parse. Cream, not the
   controls' gold, for the same reason - a link is not a button.

   It carries NO count, deliberately. The band shows a 7-day window and the
   archive shows everything, so those are two different numbers, and ticker.js
   filters the band client-side against a stale deploy - a baked count can
   contradict what is visibly on screen. They happen to agree today, which is
   exactly how a wrong one would ship unnoticed. */
.tk-all {
  flex: none; align-self: stretch; display: flex; align-items: center;
  padding: 0 1.1rem; white-space: nowrap;
  border-right: 1px solid rgba(201, 164, 92, .35);
  font-size: .72rem; letter-spacing: .18em; text-transform: uppercase;
  color: var(--cw-cream); text-decoration: none;
}
.tk-all:hover { background: rgba(201, 164, 92, .12); color: var(--cw-gold); }
.tk-pause, .tk-speed {
  flex: none; font: inherit; font-size: .72rem; letter-spacing: .1em;
  text-transform: uppercase; cursor: pointer;
  background: none; border: none;
  border-left: 1px solid rgba(201, 164, 92, .35);
  color: var(--cw-gold); padding: 0 1rem;
}
/* The speed label must not resize the button as it cycles (1x / ½x / 3x),
   or the Pause button next to it walks. */
.tk-speed { min-width: 3.6rem; }
.tk-pause:hover, .tk-speed:hover { background: rgba(201, 164, 92, .12); }
@media (prefers-reduced-motion: reduce) {
  .tk-dot { animation: none; }
}

/* Verb tiles. Iterates categories.json - auto-fit, never a fixed four, so a
   fifth category gets a tile instead of breaking the row (the district's own
   logo already shows five icons). */
.verb-tiles {
  display: grid; gap: 4px;
  grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 1fr));
  /* 4px, not a section margin: the tiles sit directly under the ticker and
     continue the photographic stack, so the seam matches the 4px gutters
     inside the strip and the grid. */
  margin: 4px calc(50% - 50vw) 0; width: 100vw; padding: 0 4px;
  list-style: none;
}
.verb-tiles a {
  position: relative; display: block; text-decoration: none;
  height: clamp(220px, 28vw, 340px);
}
.tile-label {
  position: absolute; inset: auto 0 0 0; z-index: 1;
  padding: 2.4rem 1.2rem 1.1rem;
  background: linear-gradient(to top,
                              rgba(34, 48, 31, .82) 0,
                              rgba(34, 48, 31, .55) 55%,
                              rgba(34, 48, 31, 0) 100%);
  color: var(--cw-cream);
  font-family: var(--cw-font-display); font-weight: 600;
  font-size: clamp(1.5rem, 2.6vw, 2.1rem); line-height: 1.1;
}
.tile-label small {
  display: block; margin-top: .2rem;
  font-family: var(--cw-font-body); font-weight: 400; font-size: .72rem;
  letter-spacing: .16em; text-transform: uppercase; color: var(--cw-gold);
}
.verb-tiles a:hover .tile-label { color: var(--cw-gold); }

/* What's Happening - the static page behind the ticker. The ticker is never
   the sole access to a post (schema rule), so every post also renders here as
   a stacked board, same mono voice, on the deep green ground. */
.board {
  list-style: none; margin: 2rem 0 0; padding: 1.5rem 1.6rem;
  background: var(--cw-green-deep); color: var(--cw-cream);
  font-family: var(--cw-font-live); border-radius: var(--cw-radius);
  display: grid; gap: 1rem;
}
.board li {
  display: grid; grid-template-columns: 1fr auto; gap: .15rem 1rem;
  border-bottom: 1px dashed rgba(201, 164, 92, .3); padding-bottom: .9rem;
}
.board li:last-child { border-bottom: none; padding-bottom: 0; }
.board .tk-n { grid-column: 1; font-size: .78rem; }
.board .tk-m { grid-column: 2; text-align: right; font-size: .75rem; }
.board .board-txt {
  grid-column: 1 / -1; font-size: .95rem; line-height: 1.5;
  color: rgba(245, 239, 233, .92); margin: 0;
}
.board .is-house .tk-n { color: var(--cw-cream); }
.board .is-house .tk-n::after {
  content: "district"; margin-left: .5rem; font-size: .72em;
  letter-spacing: .12em; text-transform: lowercase;
  border: 1px solid rgba(245, 239, 233, .4); border-radius: var(--cw-radius);
  padding: .05em .35em; color: rgba(245, 239, 233, .7);
}

.btn {
  display: inline-block; background: var(--cw-cream); color: var(--cw-green);
  font-weight: 600; text-decoration: none; padding: .6rem 1.15rem;
  border-radius: var(--cw-radius); border: 1px solid transparent;
}
.btn:hover { background: #fff; }
.panel .btn { background: var(--cw-green); color: #fff; }
/* The green variants (.panel, .claim) keep the shared white hover, so their
   text has to swap to green with it - it shipped white-on-white. */
.panel .btn:hover, .claim .btn:hover { color: var(--cw-green); }

/* Never remove the focus ring. The ticker pause and the verb links are
   keyboard controls like any other. */
.tk-pause:focus-visible, .tk-speed:focus-visible, .tk-all:focus-visible,
.verb-line a:focus-visible, .verb-tiles a:focus-visible,
.site-nav a:focus-visible, .btn:focus-visible {
  outline: 3px solid var(--cw-gold); outline-offset: 2px;
}

/* Yale at Chapel West */
.schools {
  list-style: none; margin: 2rem 0 0; padding: 0; display: grid; gap: 2rem;
  /* Two per row, fixed rather than auto-fit. Four cards divide evenly into two
     rows, and auto-fit made it three across on a wide screen, which left the
     fourth stranded alone on a second row. */
  grid-template-columns: repeat(2, 1fr);
}
@media (max-width: 700px) {
  .schools { grid-template-columns: 1fr; }
}
.school img {
  /* height:auto for the same reason as .spot img - the width/height attributes
     would otherwise beat aspect-ratio and the row would go ragged. */
  width: 100%; height: auto; aspect-ratio: 3 / 2; object-fit: cover;
  border-radius: var(--cw-radius); display: block; margin-bottom: .8rem;
}
.school h2 { margin: 0 0 .2rem; }
.school-meta { margin: 0 0 .6rem; color: var(--cw-ink-soft); font-size: .95rem; }
.school p:last-child { margin: 0; }

/* Rotating spotlight */
.spotlight { margin-block: 3rem; }
.spots {
  list-style: none; margin: 0; padding: 0; display: grid; gap: 1.25rem;
  grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
}
.spot a { display: block; text-decoration: none; color: inherit; }
.spot img {
  /* height:auto is load-bearing. Every img carries width/height attributes to
     reserve layout space, and without this the attribute height wins over
     aspect-ratio - the cards render at each photo's native ratio and the row
     falls apart. */
  width: 100%; height: auto; aspect-ratio: 4 / 3; object-fit: cover;
  border-radius: var(--cw-radius); display: block; margin-bottom: .6rem;
}

/* Cards are top-aligned so the captions line up in a row rather than floating
   at whatever height each image ends at. */
.spots { align-items: start; }
.spot-cat {
  display: block; font-size: .75rem; text-transform: uppercase;
  letter-spacing: .1em; color: var(--cw-ink-soft);
}
.spot-name {
  display: block; font-family: var(--cw-font-display); font-size: 1.15rem;
  margin-bottom: .3rem;
}
.spot a:hover .spot-name { text-decoration: underline; }
.spot p { font-size: .93rem; color: var(--cw-ink-soft); margin: 0; }

.happening { margin-block: 3rem; }

/* Map teaser + email sign-up */
.two-up {
  display: grid; gap: 1.25rem; margin-block: 3rem;
  grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
}
.panel {
  background: var(--cw-cream-warm); border: 1px solid var(--cw-rule);
  border-radius: var(--cw-radius); padding: 1.5rem;
}
.panel h2 { margin-top: 0; }
.panel-map { background: var(--cw-green); color: #fff; border-color: transparent; }
.panel-map h2, .panel-map p { color: #fff; }
.panel-map .btn { background: var(--cw-cream); color: var(--cw-green); }
/* A body link inside the dark panel. Without this it inherits the sitewide
   green-on-cream link colour and disappears into the green panel entirely -
   which is exactly how the walk link first shipped here. Cream rather than
   gold: gold clears the contrast floor on this green but is decorative only,
   and this is a sentence a visitor has to read. */
.panel-map a { color: var(--cw-cream); }
.panel-map .btn:hover { background: #fff; }

.signup { display: flex; flex-wrap: wrap; gap: .5rem; align-items: end; }
.signup label { flex: 1 1 100%; font-size: .85rem; color: var(--cw-ink-soft); }
.signup input {
  flex: 1 1 12rem; font: inherit; padding: .55rem .7rem;
  border: 1px solid var(--cw-rule); border-radius: var(--cw-radius);
  background: #fff; color: var(--cw-ink);
}
.signup button {
  font: inherit; font-weight: 600; padding: .55rem 1.1rem; cursor: pointer;
  background: var(--cw-green); color: #fff; border: 0;
  border-radius: var(--cw-radius);
}
.signup input:focus-visible, .signup button:focus-visible {
  outline: 3px solid var(--cw-gold); outline-offset: 2px;
}

/* ===== Homepage on a phone =====
   Measured against the built page at 320px (SE) and 390px (iPhone 14). The
   layout was never broken - no horizontal overflow at any width - so most of
   what is corrected here is density, plus the tap targets in the
   pointer: coarse block below. The ticker was the one genuine failure. */

@media (max-width: 700px) {

  /* The ticker stacks, and this is the load-bearing fix.

     In one row the band's own chrome crowds out the thing the band is for:
     the LIVE tag (87px) plus the speed and pause buttons (131px) is 218px of
     a 390px viewport, leaving the track 172px - and 102px at 320px, which is
     about eight characters. The site's ONE live element, specified at display
     scale, was 44% visible on a phone.

     So the tag and its controls take the first row and the track takes the
     whole width of the second. ticker.js needs no change and must not get
     one: it reads win.offsetWidth at run time to decide how many times to
     repeat the items before cloning the half, so a wider window simply
     repeats fewer times. It does not listen for resize, so a rotation across
     this breakpoint keeps the duplication it computed on load - which is the
     behaviour it already had, not something stacking introduces. */
  .ticker { flex-wrap: wrap; }
  /* "View all" takes the spacer role, so the row reads LIVE | View all hard
     left and the controls hard right, with the gap BETWEEN the two clusters
     rather than swelling the tag. Its own border-right goes: on this row it is
     followed by empty band, and a rule dangling in space is just untidy. */
  .tk-tag { flex: none; }
  .tk-all { margin-right: auto; border-right: 0; min-height: 44px; }

  /* All four segments keep their place on a phone; the row is made to FIT
     them rather than shed one.

     It used to come to a fixed 325px whatever the viewport, so anything
     narrower wrapped Pause onto a line of its own - a real iPhone did exactly
     that while Chrome at 390px reported 65px of slack and looked fine. 17% is
     not slack when letter-spacing and mono metrics differ between engines.

     So the chrome is trimmed here instead: tighter tracking and padding, and
     the speed dial drops `min-width: 3.6rem` for a 44px touch floor. That
     reserve existed so the label could not resize the button as it cycles,
     but every label is two characters in a MONOSPACE face, so the widths were
     already equal and the reserve was buying nothing. */
  .tk-tag, .tk-all { padding-inline: .7rem; letter-spacing: .1em; }
  .tk-speed { min-width: 44px; padding-inline: .55rem; }
  .tk-pause { padding-inline: .7rem; }

  /* The WORD "Live" goes on a phone; the pulsing DOT stays. The dot is what
     carries the signal - live is a thing the band does, not a caption - and
     dropping the label buys 45px for the controls, which are what a thumb
     actually needs. Chris's call: lose the label before losing a control.

     Visually hidden, never `display: none`, so a screen reader still reaches
     the word. It needed its own span in build.py before CSS could target it
     at all; a bare text node cannot be styled.

     Keyed to the same 700px breakpoint as everything else in this block
     rather than its own narrow cutoff - a second breakpoint here is a second
     thing to drift, and a previous pass proved that by leaving a 359px rule
     hiding the speed dial long after the row had been trimmed to fit. */
  .tk-live {
    position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
    overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
  }
  /* No text beside it, so the flex gap has nothing left to separate. */
  .tk-tag { gap: 0; }
  /* These took their height from a band sized by the message. The message is
     no longer beside them, so the coarse-pointer floor has to be stated. */
  .tk-speed, .tk-pause { min-height: 44px; }
  /* basis 100% is what forces the wrap - grow alone would not, since wrapping
     is decided on basis before free space is distributed. */
  .tk-win {
    order: 3; flex: 1 0 100%;
    border-top: 1px solid rgba(201, 164, 92, .35);
  }

  /* ...and on a phone it runs DOWNWARDS.

     Stacking bought the message the full width, but not enough of it: a
     140-character post is about 38 characters of a 390px line, so you could
     read the words and still never take in a post - you chased it. Going
     down, each line holds still horizontally while you read it, and a post
     is on screen whole. Desktop keeps the sideways band, which is what a
     ~1060px window was designed for and where it works.

     The band's DEPTH is the dial - --tk-band, below - not a post count.
     "Four at a time" is a desktop number: out here a post is three or four
     lines, so four of them is a ~460px band, over half the viewport, sitting
     permanently between the hero and the verb tiles. Height fixed, however
     many fit, fit. */
  .ticker.is-vert .tk-win {
    /* ~2 posts and the edges of the ones either side, which is what reads as a
       scroll rather than a list that happens to move. Raise it to see more at
       once - see the note above on why this is a depth, not a post count. */
    --tk-band: 216px;
    height: var(--tk-band); align-items: flex-start;
    /* Feather the two cut edges. Sideways the clipped item leaves at the
       viewport edge and nobody reads it as damage; going down, a post sliced
       flat against the control row above it just looks broken. The mask is
       decoration only - it hides nothing that the band is not about to scroll
       into view anyway, and every post is on /whats-happening/ regardless. */
    -webkit-mask-image: linear-gradient(to bottom, transparent 0,
      #000 18px, #000 calc(100% - 18px), transparent 100%);
    mask-image: linear-gradient(to bottom, transparent 0,
      #000 18px, #000 calc(100% - 18px), transparent 100%);
  }
  .ticker.js-ready.is-vert .tk-track {
    flex-direction: column; flex-wrap: nowrap;
    width: 100%; height: max-content;
    white-space: normal; gap: 0; padding: 0;
  }
  .ticker.is-vert .tk-i {
    display: flex; flex-wrap: wrap; align-items: baseline; width: 100%;
    white-space: normal; max-width: none;
    /* Body scale, like the static state: display scale is for text caught in
       passing. Standing still long enough to be read, it is a list. */
    font-size: 1rem; line-height: 1.45;
    padding: .55rem 1rem; gap: 0 .8rem;
    border-bottom: 1px dashed rgba(201, 164, 92, .28);
  }
  /* Name and timestamp share the first line, the post takes the second -
     the same shape /whats-happening/ uses, so the band and the archive read
     as one thing. order + a 100% basis rather than markup: the item order is
     name, text, time, and build.py emits that for the sideways band. */
  .ticker.is-vert .tk-txt { order: 2; flex: 1 0 100%; }
  .ticker.is-vert .tk-m { margin-left: auto; }

  /* The film strip shows all five frames again.

     It used to hide frames 4 and 5, so the weekly rotation that exists so
     "no business is left out" showed 60% of its window to phone visitors -
     and always the same 60%, since the window is ordered, not shuffled. Five
     across a 320px screen is 61px a frame, which is not a photograph. So the
     strip becomes what it is named: a strip you push. The partial frame at
     the right edge is the affordance; nothing here is the only route to
     anything, so a missed swipe costs nothing. */
  .home-strip {
    grid-template-columns: none;
    grid-auto-flow: column; grid-auto-columns: 38vw;
    overflow-x: auto; overscroll-behavior-x: contain;
    /* scroll-padding matches the strip's own gutter. Without it the first
       frame snaps flush to the viewport edge on load, scrolling the 4px
       gutter away and making the strip look mis-cropped rather than pushable. */
    scroll-snap-type: x proximity; scroll-padding-inline: 4px;
    scrollbar-width: none;
  }
  .home-strip::-webkit-scrollbar { display: none; }
  .home-strip .wash-frame {
    scroll-snap-align: start; height: clamp(150px, 40vw, 210px);
  }

  /* The spotlight becomes four rows rather than four full-width cards.

     Stacked at 373px each it ran to 1600px - nearly two screens of "this
     week" standing between the tiles and the map. As a media object each row
     is about 165px. The description stays full width underneath rather than
     being squeezed into the right-hand column: at 350px a two-column card
     gives it a 215px measure, and these are three-line sentences. */
  .spots { grid-template-columns: 1fr; gap: 1.1rem; }
  .spot {
    border-bottom: 1px solid var(--cw-rule); padding-bottom: 1.1rem;
  }
  .spot:last-child { border-bottom: 0; padding-bottom: 0; }
  .spot a {
    display: grid; grid-template-columns: 116px 1fr;
    column-gap: 1rem; align-content: center;
  }
  /* Placed explicitly. Auto-placement puts the frame at 1/1 and the category
     at 2/1, which drops the name onto the second row of the FIRST column,
     under the photograph. */
  .spot .wash-frame { grid-column: 1; grid-row: 1 / span 2; }
  .spot-cat  { grid-column: 2; grid-row: 1; align-self: end; }
  .spot-name { grid-column: 2; grid-row: 2; align-self: start; margin-bottom: 0; }
  /* The .6rem was the gap to the caption below it on the desktop card. Inside
     the frame, beside the caption, it is just green showing through. */
  .spot img { margin-bottom: 0; }
  .spot p { margin-top: .5rem; }

  /* 80px of empty cream sat above the h1 - main's 2.5rem plus the hero's own -
     which is a fifth of a phone screen spent before the first word. main
     keeps its padding, since every other page wants it. */
  .home-hero { padding: .5rem 0 2rem; }

  /* The sign-up sits at the foot of a long scroll and is the only form on the
     page; its input and button were 35px tall. */
  .signup input, .signup button { min-height: 44px; }
}

/* Tap targets that a mouse never made anyone think about. Keyed on
   pointer: coarse, not on width - the footer is every page's, and a thumb on
   a big phone is still a thumb. Padding on the anchor rather than min-height
   on the row: the anchor IS the target, so it is the box that has to grow.

   Measured at 320px before this: the verb nav 27px, the footer's phone number
   17px, footer links 22px. */
@media (pointer: coarse) {
  /* Asymmetric, and deliberately so: the gold rule under each verb is the
     design, so the height is taken from the top and the underline stays where
     it sits relative to the word. */
  .verb-line a { padding-top: .9rem; padding-bottom: .5rem; }
  /* The one tel: link on the page most likely to be tapped from a phone. */
  .footer-id a { display: inline-block; padding-block: .6rem; }
  .footer-links a { display: inline-block; padding-block: .7rem; }
  .footer-links { gap: 0; }
}

/* ------------------------------------------------------------------ *
 * District map (/map/)
 *
 * The per-category marker colours are NOT here. They are generated by
 * build.py from categories.json, because four hard-coded selectors would
 * leave a fifth category unstyled. See build_map_page().
 * ------------------------------------------------------------------ */

.map-tools { margin-block: 1.5rem; }
.chips { display: flex; flex-wrap: wrap; gap: .5rem; margin-bottom: .85rem; }
.chip {
  font: inherit; font-size: .9rem; cursor: pointer;
  display: inline-flex; align-items: center; gap: .45rem;
  padding: .4rem .85rem; border-radius: 2rem;
  border: 1px solid var(--cw-rule); background: var(--cw-cream-warm);
  color: var(--cw-ink);
}
.chip::before {
  content: ""; width: .6rem; height: .6rem; border-radius: 50%;
  background: var(--cw-ink-soft);
}
.chip[data-cat="all"]::before { display: none; }
/* The parking toggle is a layer, not a category - give it a P rather than a
 * colour swatch so it does not read as a fifth kind of business. */
.chip-layer::before {
  content: "P"; background: none; width: auto; height: auto;
  font-size: .72rem; font-weight: 700; color: var(--cw-ink-soft);
  border: 1px solid var(--cw-rule); border-radius: 2px; padding: 0 .2rem;
}
.chip-layer.is-on::before { color: #fff; border-color: rgba(255,255,255,.6); }
.chip.is-on { background: var(--cw-green); color: #fff; border-color: var(--cw-green); }
.chip:focus-visible { outline: 3px solid var(--cw-gold); outline-offset: 2px; }

.map-actions { display: flex; flex-wrap: wrap; gap: .6rem; }
.btn-quiet { background: var(--cw-cream-warm); color: var(--cw-green);
             border: 1px solid var(--cw-rule); }
.map-status { min-height: 1.4em; margin: .6rem 0 0; font-size: .9rem;
              color: var(--cw-ink-soft); }

/* Spin the Wheel.
 *
 * Two presentations, one widget: a native <dialog> on /map/, where the map
 * already fills the viewport, and inline on the wheel category's own page,
 * where nothing competes with it. Everything shared is on `.wheel`; only the
 * framing differs.
 *
 * The dialog is native so the focus trap, Escape, backdrop inertness and focus
 * restoration are the platform's job rather than ours.
 *
 * There is no .is-spinning rule: the spin is drawn on the canvas, so there is
 * nothing here for a reduced-motion query to switch off. wheel.js skips the
 * animation and lands directly on a result instead. */
.wheel { color: var(--cw-ink); }
.wheel-title { margin: 0 0 1rem; text-align: center; }

.wheel-modal {
  border: 1px solid var(--cw-rule); border-radius: var(--cw-radius);
  background: var(--cw-cream);
  padding: 1.5rem; max-width: 26rem; width: calc(100% - 2rem);
}
.wheel-modal::backdrop { background: rgba(34, 48, 31, .55); }
.wheel-close-form { float: right; margin: -.5rem -.5rem 0 0; }
.wheel-close {
  font: inherit; font-size: 1.4rem; line-height: 1; cursor: pointer;
  background: none; border: 0; color: var(--cw-ink-soft); padding: .2rem .4rem;
}
.wheel-close:hover { color: var(--cw-ink); }

/* Inline, the wheel is a band across the page rather than a card in the middle
 * of one: the canvas sits beside its legend on a wide screen so it does not
 * push the member grid a whole viewport down the page. */
.wheel-inline {
  display: block; margin: 0 0 2.5rem; padding: 1.5rem;
  background: var(--cw-cream-warm); border: 1px solid var(--cw-rule);
  border-radius: var(--cw-radius);
}
.wheel-inline .wheel-title { text-align: left; }

/* Wide enough for two columns: the wheel on the left, everything you read on
 * the right. Stacked, the legend and the result would push the member grid a
 * whole viewport down the page, which is the wrong trade on the one page whose
 * job is to list restaurants. */
@media (min-width: 760px) {
  .wheel-inline {
    display: grid; gap: .25rem 2rem; align-content: start;
    grid-template-columns: auto minmax(0, 1fr);
    grid-template-rows: auto auto auto 1fr;
    grid-template-areas:
      "title  title"
      "stage  legend"
      "stage  spin"
      "stage  result";
  }
  .wheel-inline .wheel-title  { grid-area: title; }
  .wheel-inline .wheel-stage  { grid-area: stage; align-self: center; margin: 0; }
  .wheel-inline .wheel-legend { grid-area: legend; margin-top: 0; align-self: end; }
  .wheel-inline .wheel-spin   { grid-area: spin; margin: 1rem 0 0;
                                justify-self: start; min-width: 9rem; }
  .wheel-inline .wheel-result { grid-area: result; align-self: start; }
  .wheel-inline .wres-card    { margin-top: 1rem; text-align: left; }
  .wheel-inline .wres-acts    { justify-content: flex-start; }
  .wheel-inline .wres-alt      { text-align: left; }
}

.wheel-stage { position: relative; width: max-content; margin: 0 auto; }
/* The pointer overhangs the rim so it reads as pointing INTO the slice rather
 * than sitting on top of one. */
.wheel-pointer {
  position: absolute; top: -10px; left: 50%; transform: translateX(-50%);
  width: 0; height: 0; z-index: 1;
  border-left: 9px solid transparent; border-right: 9px solid transparent;
  border-top: 15px solid var(--cw-green);
}
.wheel-canvas {
  display: block; width: 300px; height: 300px; max-width: 100%;
  border-radius: 50%; box-shadow: 0 0 0 5px var(--cw-gold);
}
.wheel-spin { display: block; margin: 1.25rem auto 0; cursor: pointer;
              font: inherit; background: var(--cw-green); color: #fff; }
.wheel-spin:hover { background: var(--cw-green-deep); }
.wheel-spin.is-stopping { background: #8a3a4e; }

.wheel-result:empty { display: none; }
.wres-card {
  margin-top: 1.25rem; padding: .9rem 1rem; text-align: center;
  background: var(--cw-cream); border: 1px solid var(--cw-rule);
  border-radius: var(--cw-radius);
}
.wheel-modal .wres-card { background: var(--cw-cream-warm); }
.wres-name { margin: 0; font-family: var(--cw-font-display); font-size: 1.2rem; }
.wres-meta { margin: .2rem 0 .6rem; font-size: .85rem; color: var(--cw-ink-soft); }
.wres-acts { display: flex; justify-content: center; gap: 1rem; margin: 0;
             font-size: .9rem; }
.wres-again { margin-top: .8rem; font: inherit; cursor: pointer; }

/* The rest of the cuisine, under the one the wheel chose - and rendered the
 * same way, phone and Directions included. Someone deciding between four
 * restaurants needs to be able to call or walk to any of them, not only the
 * one the wheel stopped on. */
.wres-alts { margin-top: .9rem; padding-top: .7rem;
             border-top: 1px solid var(--cw-rule); }
.wres-alts-h { margin: 0 0 .5rem; font-size: .72rem; letter-spacing: .06em;
               text-transform: uppercase; color: var(--cw-ink-soft); }
.wres-alt-list { list-style: none; margin: 0; padding: 0;
                 display: grid; gap: .55rem; }
.wres-alt { padding: .5rem .7rem; border: 1px solid var(--cw-rule);
            border-radius: var(--cw-radius); background: rgba(255, 255, 255, .45); }
/* A shade smaller than the pick, so the wheel's own answer still reads first. */
.wres-alt .wres-name { font-size: 1rem; }
.wres-alt .wres-meta { margin: .1rem 0 .35rem; }
.wres-alt .wres-acts { font-size: .85rem; }

/* The legend is the canvas's text alternative, the crawlable copy of the
 * cuisine list, the no-JavaScript rendering, and the source wheel.js reads
 * slugs and colours from. Never display:none it. */
.wheel-legend {
  list-style: none; margin: 1.25rem 0 0; padding: 0;
  display: flex; flex-wrap: wrap; gap: .3rem .8rem;
  font-size: .8rem; color: var(--cw-ink-soft);
}
.wseg { display: flex; align-items: center; gap: .35rem; }
.wseg::before {
  content: ""; width: .7rem; height: .7rem; border-radius: 2px;
  background: var(--seg); flex: none;
}
.wseg-n { color: var(--cw-ink-soft); opacity: .7; }

/* Where the wheel has no map to pan, the landed business is marked in the grid
 * instead. A left rule and a lift, matching how .stop.is-active reads under the
 * map, so the two pages agree on what "this is your pick" looks like. */
.card.is-picked {
  border-left: 4px solid var(--cw-gold);
  box-shadow: 0 2px 10px rgba(34, 48, 31, .16);
}
@media (prefers-reduced-motion: no-preference) {
  .card.is-picked { transition: box-shadow .3s ease, border-color .3s ease; }
}

.map-figure { margin: 0 0 2rem; }
.map-svg {
  display: block; width: 100%; height: auto; aspect-ratio: 1 / 1;
  max-height: 78vh; background: var(--cw-cream-warm);
  border: 1px solid var(--cw-rule); border-radius: var(--cw-radius);
  touch-action: none; --map-zoom: 1;
}
.map-svg.is-live { cursor: grab; }
.map-svg.is-panning { cursor: grabbing; }

/* On a touchscreen the map gives vertical drags back to the page. It stands up
 * to 68vh tall, so `touch-action: none` here meant a thumb landing anywhere on
 * it could not scroll past - two thirds of the screen was a dead zone. `pan-y`
 * hands the browser vertical panning only: a sideways drag still pans the map,
 * and pinch is still ours because pinch-zoom is not in the list. Two fingers
 * move the map in any direction, which is what the caption tells people. */
@media (pointer: coarse) {
  .map-svg { touch-action: pan-y; }
}

/* The gesture differs by device, so the caption has to as well. */
.hint-coarse { display: none; }
@media (pointer: coarse) {
  .hint-fine { display: none; }
  .hint-coarse { display: inline; }
}

.map-green    { fill: #dfe3d4; stroke: none; }
/* Opacity on the GROUP, not on each stroke. The corridor is drawn as many
 * overlapping wide strokes; per-stroke alpha compounds where they overlap and
 * the district reads as a string of mottled blobs. Group opacity flattens the
 * children first, then fades the result once. */
.map-district { fill: none; stroke: var(--cw-green); stroke-opacity: 1;
                stroke-linejoin: round; stroke-linecap: round; opacity: .11; }
.map-buildings{ fill: #e6ded4; stroke: #d8cec1; stroke-width: .6; }
.map-roads    { fill: none; stroke: #fbf8f4; stroke-width: 11;
                stroke-linejoin: round; stroke-linecap: round; }
.map-street {
  font-family: var(--cw-font-body); font-size: 11px; fill: var(--cw-ink-soft);
  text-anchor: middle; paint-order: stroke;
  stroke: var(--cw-cream-warm); stroke-width: 3px; stroke-linejoin: round;
  /* Counter-scale so labels stay legible rather than ballooning on zoom. */
  transform-box: fill-box; transform-origin: center;
}

/* Markers counter-scale with the zoom so that zooming in separates them
 * instead of magnifying the whole crowded cluster together. */
.map-marker { cursor: pointer; }
.map-marker > * { transform: scale(calc(1 / var(--map-zoom)));
                  transform-origin: center; transform-box: fill-box; }
.map-dot { fill: var(--cw-green); stroke: #fff; stroke-width: 3; }
.map-dot-many { stroke-width: 3.5; }
.map-count { font-family: var(--cw-font-body); font-size: 15px; font-weight: 700;
             fill: #fff; text-anchor: middle; }
.map-marker.is-off { opacity: .12; pointer-events: none; }
.map-marker.is-active .map-dot { stroke: var(--cw-gold); stroke-width: 5; }
.map-marker:focus-visible .map-dot { stroke: var(--cw-gold); stroke-width: 5; }

.map-lot rect { fill: #fff; stroke: var(--cw-ink-soft); stroke-width: 2; }
.map-lot text { font-family: var(--cw-font-body); font-size: 14px;
                font-weight: 700; text-anchor: middle; fill: var(--cw-ink); }

.map-cap { display: flex; flex-wrap: wrap; gap: .5rem 1.25rem;
           justify-content: space-between; padding-top: .5rem; }
.map-hint, .map-credit { font-size: .8rem; color: var(--cw-ink-soft); margin: 0; }
/* The ODbL credit sits at the foot of the page, after the list, rather than in
 * the caption under the map - it is a licence condition, not a caption, and
 * directly under the map it competed with the line telling you how to use one.
 * Scoped to that position so it stays a plain caption anywhere else. */
.stops + .map-credit { margin-top: 2.25rem; padding-top: 1rem;
                       border-top: 1px solid var(--cw-rule); }
.map-listnote { font-size: .9rem; color: var(--cw-ink-soft); }

.stops { list-style: none; padding: 0; margin: 1rem 0 0;
         display: grid; gap: .85rem; }
.stop {
  background: var(--cw-cream-warm); border: 1px solid var(--cw-rule);
  border-left: 4px solid var(--cw-rule);
  border-radius: var(--cw-radius); padding: .9rem 1.1rem;
}
.stop.is-off { display: none; }
.stop.is-active { border-left-color: var(--cw-gold); }
.stop-head { margin: 0 0 .5rem; font-size: 1rem; }
.stop-count { font-weight: 400; font-size: .82rem; color: var(--cw-ink-soft); }
.stop-list { list-style: none; padding: 0; margin: 0; display: grid; gap: .55rem; }
.stop-biz.is-off { display: none; }
.stop-biz { display: flex; flex-wrap: wrap; gap: .25rem .9rem; align-items: baseline; }
.stop-name { font-weight: 600; display: inline-flex; align-items: baseline; gap: .45rem; }
.stop-name::before {
  content: ""; width: .55rem; height: .55rem; border-radius: 50%;
  background: var(--cw-ink-soft); transform: translateY(-1px);
}
.stop-meta { font-size: .85rem; color: var(--cw-ink-soft); }
.stop-biz .tel, .stop-dir { font-size: .85rem; }
.tel-none { color: var(--cw-ink-soft); font-style: italic; }

@media (max-width: 700px) {
  .map-svg { max-height: 68vh; }
  .map-street { font-size: 13px; }
  .wheel-canvas { width: 260px; height: 260px; }
  .wheel-modal, .wheel-inline { padding: 1.1rem; }
}

/* Directory search - also the proposal's phone lookup. Ships hidden and is
 * revealed by assets/js/directory.js, so there is no dead box without JS. */
.dir-search { margin: 1.25rem 0 2rem; max-width: 32rem; }
.dir-search label { display: block; font-size: .85rem; color: var(--cw-ink-soft);
                    margin-bottom: .3rem; }
.dir-search input {
  width: 100%; font: inherit; padding: .6rem .8rem;
  border: 1px solid var(--cw-rule); border-radius: var(--cw-radius);
  background: #fff; color: var(--cw-ink);
}
.dir-search input:focus-visible { outline: 3px solid var(--cw-gold); outline-offset: 2px; }
.dir-count { min-height: 1.3em; margin: .45rem 0 0; font-size: .85rem;
             color: var(--cw-ink-soft); }
.map-outside { flex: 1 1 100%; font-size: .8rem; color: var(--cw-ink-soft); margin: 0; }
.map-outside a { color: inherit; }

/* Marker popups. Positioned by assets/js/tools.js over .map-stage, which is why
 * the stage is the positioning context and the layer itself is click-through. */
.map-stage { position: relative; }
.map-pops { position: absolute; inset: 0; pointer-events: none; }

/* Zoom controls. Above the cards, so a card that happens to sit under them
 * cannot swallow the click. */
.map-zoom {
  position: absolute; top: 10px; right: 10px; z-index: 3;
  display: grid; border-radius: 6px; overflow: hidden;
  border: 1px solid var(--cw-rule); background: var(--cw-cream-warm);
  box-shadow: 0 2px 8px rgba(0, 0, 0, .14);
}
.map-zoom button {
  width: 38px; height: 38px; font: inherit; font-size: 1.3rem; line-height: 1;
  border: 0; background: none; color: var(--cw-ink); cursor: pointer;
  display: grid; place-items: center;
}
.map-zoom button + button { border-top: 1px solid var(--cw-rule); }
/* Guarded, or a tap leaves the button looking held down until something else is
 * touched - a touchscreen has nowhere to move the pointer to. */
@media (hover: hover) {
  .map-zoom button:hover:not(:disabled) { background: rgba(0, 0, 0, .07); }
}
/* 38px is a comfortable mouse target and an uncomfortable thumb. Pinch is the
 * primary gesture on a touchscreen, but these are still the only way to zoom
 * back out in one move, so they have to be hittable. */
@media (pointer: coarse) {
  .map-zoom button { width: 44px; height: 44px; font-size: 1.45rem; }
}
.map-zoom button:disabled { opacity: .35; cursor: default; }
.map-zoom button:focus-visible { outline: 3px solid var(--cw-gold); outline-offset: -3px; }

.pop {
  position: absolute; pointer-events: auto; z-index: 2;
  width: 264px; max-width: calc(100% - 12px);
  background: var(--cw-cream-warm); color: var(--cw-ink);
  border: 1px solid var(--cw-rule); border-radius: 6px;
  box-shadow: 0 6px 22px rgba(0, 0, 0, .22);
  padding: .6rem .7rem;
}
/* Bridge the dead zone between the marker and the card. Measured at 8px, and
 * crossing it is where a hover card gets lost: the pointer leaves the marker,
 * the hide timer starts, and on a map this crowded it can pass over a
 * neighbouring marker on the way. The bridge is transparent and only exists
 * while the card is open. */
.pop::before {
  content: ""; position: absolute; left: 0; right: 0;
  top: 100%; height: 22px;
}
.pop.is-below::before { top: auto; bottom: 100%; }

.pop::after {           /* tail, aimed at the marker via --tail */
  content: ""; position: absolute; left: var(--tail, 50%); margin-left: -7px;
  border: 7px solid transparent; border-top-color: var(--cw-cream-warm);
  top: 100%;
}
.pop.is-below::after { top: auto; bottom: 100%;
  border-top-color: transparent; border-bottom-color: var(--cw-cream-warm); }

.pop-close {
  position: absolute; top: 2px; right: 2px; z-index: 1;
  width: 26px; height: 26px; line-height: 1; font-size: 1.15rem;
  border: 0; background: none; color: var(--cw-ink-soft); cursor: pointer;
  border-radius: 4px;
}
.pop-close:hover { color: var(--cw-ink); background: rgba(0, 0, 0, .06); }
.pop-close:focus-visible { outline: 3px solid var(--cw-gold); outline-offset: -2px; }
/* Only offered once the card is pinned - before that it closes by itself and a
 * close button would just be one more thing to aim at. */
.pop:not(.is-pinned) .pop-close { display: none; }
.pop.is-pinned { box-shadow: 0 8px 26px rgba(0, 0, 0, .3); }
.pop.is-pinned .pop-head, .pop.is-pinned .pop-list { padding-right: 1.4rem; }
.pop:not(.pop-many).is-pinned .pop-name { padding-right: 1.4rem; }

.pop-head { margin: 0 0 .45rem; font-size: .82rem; font-weight: 600; }
.pop-head span { font-weight: 400; color: var(--cw-ink-soft); }
.pop-list { list-style: none; margin: 0; padding: 0; display: grid; gap: .55rem; }
.pop-biz { display: grid; gap: .1rem .55rem; }
.pop-many .pop-biz { grid-template-columns: 56px 1fr; align-items: start; }
.pop-many .pop-biz + .pop-biz { border-top: 1px solid var(--cw-rule); padding-top: .55rem; }
.pop-txt { display: grid; gap: .08rem; min-width: 0; }

.pop-shot img { display: block; width: 100%; height: auto; border-radius: 4px; }
.pop-shot { border-radius: 4px; overflow: hidden; background: var(--cw-rule); }
.pop-many .pop-shot { width: 56px; aspect-ratio: 1; }
.pop-many .pop-shot img { height: 100%; object-fit: cover; }
.pop-shot-none { aspect-ratio: 16 / 9; background: var(--cw-green); opacity: .12; }

.pop-name { font-weight: 700; font-size: .95rem; line-height: 1.25; }
.pop-many .pop-name { font-size: .88rem; }
.pop-type { font-size: .76rem; color: var(--cw-ink-soft);
            text-transform: uppercase; letter-spacing: .04em; }
.pop-tel { font-size: .85rem; }
.pop-tel-none { color: var(--cw-ink-soft); font-style: italic; font-size: .8rem; }
.pop-dir { font-size: .8rem; justify-self: start; }

@media (max-width: 700px) { .pop { width: 240px; } }

/* Newsletters. A grid of front pages, because eleven PDFs listed as eleven
 * lines of blue text is what the district already has and it tells a visitor
 * nothing about which one they want. The cover IS the summary. */
.nl-head {
  font-size: 1rem; letter-spacing: .08em; text-transform: uppercase;
  color: var(--cw-ink-soft); font-family: var(--cw-font-body); font-weight: 600;
  margin: 2.5rem 0 1rem; padding-bottom: .4rem;
  border-bottom: 1px solid var(--cw-rule);
}
.nl-grid {
  list-style: none; margin: 0; padding: 0; display: grid; gap: 1.75rem;
  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
}
/* The two binders are one artefact each rather than one issue each, and they
 * carry a sentence of explanation the issues do not. Wider cells give that
 * sentence somewhere to sit instead of stacking it six words to a line. */
.nl-grid-wide { grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
.nl-cover {
  display: block; margin-bottom: .7rem; overflow: hidden;
  background: var(--cw-cream-warm);
  border: 1px solid var(--cw-rule); border-radius: var(--cw-radius);
}
/* No forced aspect-ratio and no object-fit. Every one of these documents is
 * printed on the same tall page, so the natural ratios already line the rows
 * up - and cropping to a ratio cost the archive its map, which is the one
 * cover where the edges carry the information. */
.nl-cover img { display: block; width: 100%; height: auto; }

/* The binders read as one artefact each rather than one issue each, and they
 * carry a sentence the issues do not. Cover beside the text, so the sentence
 * has a column instead of six words to a line. */
.nl-grid-wide .nl {
  display: grid; grid-template-columns: 120px 1fr; column-gap: 1.1rem;
  align-content: start;
}
.nl-grid-wide .nl-cover { grid-row: span 3; margin-bottom: 0; }

.nl-title { margin: 0 0 .15rem; font-size: 1.05rem; line-height: 1.25; }
.nl-meta { margin: 0; font-size: .8rem; color: var(--cw-ink-soft); }
.nl-note { margin: .45rem 0 0; font-size: .85rem; color: var(--cw-ink-soft); }

/* Two covers a row on a phone, not one. auto-fill cannot express this - a
 * 200px floor resolves to a single column at 343px of content and hands each
 * issue a 560px-tall card, which turns nine issues into a very long scroll. */
@media (max-width: 560px) {
  .nl-grid { grid-template-columns: 1fr 1fr; gap: 1.25rem; }
  .nl-grid-wide { grid-template-columns: 1fr; }
  .nl-title { font-size: .95rem; }
}

/* Footer. One row of links under the address, kept as a list so it reads as a
 * set of links rather than a run-on sentence with a screen reader. */
.footer-links {
  list-style: none; margin: .9rem 0 0; padding: 0;
  display: flex; flex-wrap: wrap; gap: 1.1rem; font-size: .9rem;
}

/* News archive. There was no prose block style on this site before - member
 * pages emit class="copy" and the stylesheet has never had a rule for it - so
 * the migrated posts bring their own measure and rhythm rather than inheriting
 * one that does not exist. */
.news-year {
  font-size: 1rem; letter-spacing: .08em; text-transform: uppercase;
  color: var(--cw-ink-soft); font-family: var(--cw-font-body); font-weight: 600;
  border-bottom: 1px solid var(--cw-rule); padding-bottom: .4rem;
  margin: 2.5rem 0 0;
}
.news-list { list-style: none; padding: 0; margin: 0; display: grid; }
.news-item {
  display: grid; grid-template-columns: 120px 1fr; gap: 1.1rem;
  align-items: start; padding: 1.25rem 0;
  border-bottom: 1px solid var(--cw-rule);
}
.news-item:last-child { border-bottom: 0; }
.news-thumb img {
  width: 120px; height: 90px; object-fit: cover; display: block;
  border-radius: var(--cw-radius); background: var(--cw-cream-warm);
}
.news-text h3 { margin: 0 0 .2rem; font-size: 1.15rem; }
.news-meta { margin: 0; font-size: .82rem; color: var(--cw-ink-soft); }
.news-sum { margin: .4rem 0 0; color: var(--cw-ink-soft); max-width: 60ch; }
/* A post with no image must not leave a 120px hole where the thumbnail was. */
.news-item:not(:has(.news-thumb)) { grid-template-columns: 1fr; }
@media (max-width: 560px) {
  .news-item { grid-template-columns: 84px 1fr; gap: .8rem; }
  .news-thumb img { width: 84px; height: 63px; }
}

.crumb { font-size: .85rem; margin: 0 0 .5rem; }
.post { max-width: var(--cw-measure); }
.post-meta { margin: 0 0 1.5rem; font-size: .85rem; color: var(--cw-ink-soft); }
.post-body > *:first-child { margin-top: 0; }
.post-body p { margin: 0 0 1.1rem; }
.post-body h2 { font-size: 1.3rem; margin: 2rem 0 .6rem; }
.post-body h3 { font-size: 1.1rem; margin: 1.75rem 0 .5rem; }
.post-body ul, .post-body ol { margin: 0 0 1.1rem; padding-left: 1.3rem; }
.post-body li { margin-bottom: .35rem; }
.post-body blockquote {
  margin: 1.5rem 0; padding-left: 1rem;
  border-left: 3px solid var(--cw-gold); color: var(--cw-ink-soft);
}
.post-figure { margin: 1.5rem 0; }
.post-figure img { width: 100%; height: auto; display: block;
                   border-radius: var(--cw-radius); }
.post-figure figcaption {
  font-size: .82rem; color: var(--cw-ink-soft); padding-top: .45rem;
}
.post-nav {
  display: flex; flex-wrap: wrap; gap: 1rem; justify-content: space-between;
  max-width: var(--cw-measure); margin-top: 2.5rem; padding-top: 1rem;
  border-top: 1px solid var(--cw-rule); font-size: .9rem;
}

/* The walking tour, /walk/. The directory is a list and the map is a picture;
   this is the same records with a DIRECTION, so the page is built around one
   continuous line running down the left. The line is the street: leg headings
   sit on it as corners, stops as doors along it.

   No JavaScript and no motion here on purpose. This is the page a scroll-driven
   version degrades to, so it has to be worth reading on its own first - the
   same least-capable-state-first discipline the ticker follows. */
.walk, .walk-stops { list-style: none; margin: 0; padding: 0; }
.walk { margin-top: 2rem; }
.walk-leg { position: relative; padding-left: 1.75rem; }
/* The line is drawn on the LEG rather than on the stop list so it runs
   unbroken through the heading and the turn instruction. Two rules, one per
   list, would leave a gap at every corner - which is exactly where a walker
   most needs the line to be continuous. */
.walk-leg::before {
  content: ""; position: absolute; left: 5px; top: .55rem; bottom: 0;
  border-left: 2px solid var(--cw-rule);
}
/* The line runs the full height of every leg including the last. Stopping it
   under the final heading left the last stop's dot floating with no street
   through it, which reads as a rendering fault rather than as an ending. */
.walk-leg > h2 {
  position: relative; font-size: 1.45rem; margin: 2.5rem 0 .35rem;
  display: flex; flex-wrap: wrap; align-items: baseline; gap: .25rem .9rem;
}
.walk-leg:first-child > h2 { margin-top: 0; }
/* A square at the corner, a dot at each door. Different shapes rather than
   different sizes: at 12px a bigger circle just reads as a nearer one. */
.walk-leg > h2::before {
  content: ""; position: absolute; left: -1.75rem; top: .45em;
  width: 12px; height: 12px; background: var(--cw-green);
}
.leg-span {
  font-family: var(--cw-font-body); font-size: .82rem; font-weight: 600;
  letter-spacing: .1em; text-transform: uppercase; color: var(--cw-ink-soft);
}
.leg-turn { max-width: var(--cw-measure); color: var(--cw-ink-soft);
  margin: 0 0 1.75rem; }

.walk-stop {
  position: relative; display: grid; gap: .3rem 1.75rem;
  grid-template-columns: minmax(0, 520px) minmax(0, 1fr);
  align-items: start; margin-bottom: 2rem;
}
.walk-stop::before {
  content: ""; position: absolute; left: -1.75rem; top: .55rem;
  width: 12px; height: 12px; border-radius: 50%;
  background: var(--cw-cream); border: 2px solid var(--cw-gold);
}
/* The frame link duplicates the heading link, so it is taken out of the tab
   order and hidden from screen readers rather than announced twice. */
.stop-frame { display: block; aspect-ratio: 3 / 2; }
.stop-frame img { width: 100%; height: 100%; object-fit: cover; display: block; }
/* Nine active businesses have no photograph. A plate carrying the street
   number reads as a door rather than as a hole, and it is the one thing we
   know is true about the spot without a picture of it. */
.stop-plate {
  display: grid; place-content: center; justify-items: center; gap: .5rem;
  width: 100%; height: 100%; text-align: center;
  color: var(--cw-cream);
}
/* Number over street, not a bare number. At photo scale a lone numeral is a
   green void; the street under it makes the panel a door plate, which is the
   one thing we know about the spot without a picture of it. */
.stop-plate b {
  font-family: var(--cw-font-display); font-weight: 400;
  font-size: clamp(2rem, 5vw, 3rem); line-height: 1; letter-spacing: .04em;
  opacity: .9;
}
.stop-plate i {
  font-style: normal; font-size: .72rem; font-weight: 600;
  letter-spacing: .22em; text-transform: uppercase; opacity: .62;
}
.stop-text h3 { font-size: 1.15rem; margin: 0 0 .2rem; }
.stop-text h3 a { text-decoration: none; }
.stop-text h3 a:hover { text-decoration: underline; }
.stop-type { margin: 0 0 .35rem; font-size: .82rem; font-weight: 600;
  letter-spacing: .1em; text-transform: uppercase; color: var(--cw-ink-soft); }
.stop-desc { margin: 0 0 .75rem; max-width: 34ch; }
.stop-meta { margin: 0; font-size: .92rem; }
.walk-tail { margin-top: 3rem; }
.panel-alt { font-size: .92rem; margin: .85rem 0 0; }

@media (max-width: 700px) {
  /* Photo over text, and the line pulled in tight. The two-column stop needs
     about 560px before the text column stops being a gutter, and Chris's
     phone is under 360. */
  .walk-leg { padding-left: 1.25rem; }
  .walk-leg::before { left: 4px; }
  .walk-leg > h2 { font-size: 1.25rem; }
  .walk-leg > h2::before,
  .walk-stop::before { left: -1.25rem; width: 10px; height: 10px; }
  .walk-stop { grid-template-columns: 1fr; gap: .6rem; margin-bottom: 2.25rem; }
}

/* The route index. A 48-stop walk is about 20,000px on a phone, so the page
   needs a way in that is not "scroll until you recognise something". Generated
   from the same legs the page renders, so it cannot name a block that is no
   longer on the route. */
.walk-index {
  max-width: var(--cw-measure); margin: 1.75rem 0 0;
  border-top: 1px solid var(--cw-rule);
  border-bottom: 1px solid var(--cw-rule); padding: 1rem 0;
}
.walk-index-head {
  margin: 0 0 .6rem; font-size: .78rem; font-weight: 600;
  letter-spacing: .16em; text-transform: uppercase; color: var(--cw-ink-soft);
}
.walk-index ol {
  list-style: none; margin: 0; padding: 0;
  display: flex; flex-wrap: wrap; gap: .35rem 1.5rem;
}
/* Nowrap on the whole entry, so a leg breaks between items rather than inside
   one. "Chapel / Street" split over two lines reads as two legs. */
.walk-index a { text-decoration: none; font-weight: 600; white-space: nowrap; }
.walk-index a:hover { text-decoration: underline; }
.walk-index a span {
  font-weight: 400; color: var(--cw-ink-soft); font-size: .88rem;
  margin-left: .4rem;
}
@media (max-width: 700px) {
  /* One leg a line, extent underneath. Eight entries on a 360px screen cannot
     share rows without breaking mid-name, and the extent is the half that can
     afford its own line. */
  .walk-index ol { flex-direction: column; gap: .7rem; }
  .walk-index a { white-space: normal; }
  .walk-index a span { display: block; margin-left: 0; }
}
/* Anchored headings land under nothing (the header does not stick), but a
   little air above the target reads as arriving at the corner rather than at
   the top edge of the word. */
.walk-leg > h2 { scroll-margin-top: 1.5rem; }

/* The archive's door out of What's Happening. */
.board-more { margin-top: 2rem; color: var(--cw-ink-soft); }

/* /contact/sent/ and /contact/error/ - where a JavaScript-free submit lands.
   Short pages with one thing to say, so the measure is tight and the type is
   a size up from body copy. */
.landing { max-width: var(--cw-measure); font-size: 1.1rem; }
.landing p { margin: 0 0 1rem; }
.landing-do {
  padding: 1rem 1.25rem; background: var(--cw-cream-warm);
  border-left: 3px solid var(--cw-gold);
}
.landing-back { margin-top: 2rem; color: var(--cw-ink-soft); font-size: .95rem; }

/* /notables/ - the banner series.

   Every entry is a ROW, never a card in a grid, and that is forced by the
   artwork rather than chosen: 7 of the 31 banners are portrait and 24 are
   landscape, so a uniform grid would have to crop, and cropping a banner cuts
   off somebody's face or the end of their bio. A row lets each keep its own
   proportions. `render.img_tag` writes width and height from the recorded
   aspect, so nothing shifts as they load.

   This is the case the auto-fit/minmax grid used everywhere else is explicitly
   wrong for. Do not "tidy" it into one. */
.nb-index {
  max-width: 100%; margin: 1.75rem 0 0;
  border-top: 1px solid var(--cw-rule);
  border-bottom: 1px solid var(--cw-rule); padding: 1rem 0;
}
.nb-index ul {
  list-style: none; margin: 0; padding: 0;
  display: flex; flex-wrap: wrap; gap: .35rem 1.5rem;
}
/* Nowrap per name, so the list breaks between people rather than inside one.
   "Constance Baker / Motley" over two lines reads as two people. */
.nb-index a { text-decoration: none; font-weight: 600; white-space: nowrap; }
.nb-index a:hover { text-decoration: underline; }

.notables { margin-top: 2.5rem; }
.nb {
  display: grid; gap: 1rem 2rem; align-items: start;
  /* The image column sizes to the ARTWORK, not to a fixed width. With a fixed
     620px column the seven portrait banners sat in it with 300px of dead space
     beside them, because a portrait capped by height is narrower than a
     landscape one capped by width. `auto` lets each row be as wide as its own
     banner needs and hands the remainder to the text. */
  grid-template-columns: minmax(0, auto) minmax(0, 1fr);
  padding-block-end: 2.5rem; margin-block-end: 2.5rem;
  border-bottom: 1px solid var(--cw-rule);
  /* The index links here, and a heading flush to the viewport edge reads as
     the top of the page rather than as arrival. */
  scroll-margin-top: 1.5rem;
}
.nb:last-child { border-bottom: 0; margin-block-end: 0; }
/* The height cap is expressed as a WIDTH, computed from the banner's own
   aspect, which build.py writes onto .nb-art as --nb-aspect.

   Width alone is not enough: banner 10 is two portraits stacked, aspect 0.49,
   so a flat 620px made it 1265px tall - one entry taller than the viewport
   with its text stranded at the top. Capping both axes gives every entry
   roughly the same area whatever its shape.

   It has to be done this way round. `width: auto; max-height: 620px` on the
   image renders identically once loaded and is WRONG: an image that has not
   arrived has no intrinsic width, so `auto` computes to 0, the aspect-ratio
   box collapses with it, and 30 of the 31 lazy banners reserve no space at
   all - the exact layout shift img_tag's width/height attributes exist to
   prevent. A definite width on the container keeps the reservation. */
.nb-art { width: min(620px, calc(620px * var(--nb-aspect, 1.5))); }
/* No aspect-ratio and no object-fit anywhere near this image. The banner IS
   the composition; cropping one cuts off a face or the end of a bio. */
.nb-banner {
  display: block; width: 100%; height: auto;
  border-radius: var(--cw-radius);
}
.nb-text > :last-child { margin-bottom: 0; }
.nb-text h2 {
  margin: 0 0 .75rem; font-size: 1.45rem; line-height: 1.2;
  scroll-margin-top: 1.5rem;
}
/* The artwork's own number. It is the only thing tying a banner on this page
   to a banner on a printed sheet, so it is kept - but small, in the live
   voice, and aria-hidden in the markup, because "eleven" announced before a
   name is noise to someone listening rather than looking. */
.nb-num {
  font-family: var(--cw-font-live); font-size: .72rem; font-weight: 400;
  color: var(--cw-ink-soft); margin-right: .6rem;
  vertical-align: .35em;
}
.nb-text p { margin: 0 0 .9rem; }
.nb-quote {
  margin: 1.1rem 0 .9rem; padding: .1rem 0 .1rem 1.1rem;
  border-left: 3px solid var(--cw-gold);
}
.nb-quote p {
  margin: 0 0 .4rem; font-family: var(--cw-font-display);
  font-size: 1.05rem; line-height: 1.45;
}
.nb-quote footer { font-size: .88rem; color: var(--cw-ink-soft); }
.nb-quote cite { font-style: italic; }
/* Banner 14 carries a statistic where the others carry a quote. Same slot,
   deliberately not the same voice - it is a fact about a career, not
   something anybody said. */
.nb-stat { font-size: .95rem; color: var(--cw-ink-soft); }
.nb-credit {
  font-size: .82rem; color: var(--cw-ink-soft);
  padding-top: .3rem;
}
.nb-colophon {
  max-width: var(--cw-measure); margin-top: 3rem; padding-top: 1.25rem;
  border-top: 1px solid var(--cw-rule);
  font-size: .95rem; color: var(--cw-ink-soft);
}

@media (max-width: 800px) {
  /* One column, artwork first. A banner squeezed into half of a 390px screen
     is a picture of type nobody can read, which defeats showing it at all. */
  .nb { grid-template-columns: 1fr; gap: .9rem; }
  .nb-text h2 { font-size: 1.3rem; }
  /* The cap comes off here. Stacked, the banner has the full column and
     nothing is competing for the space, so a tall one should use it - the
     alternative is shrinking the only legible copy of the artwork on the
     narrowest screen, which is backwards. */
  .nb-art { width: 100%; }
}

/* /about/: the district's printed map, and the Board. The map is NOT in a
   wash-frame - the wash makes mixed photography cohere, and a green wash
   over a green-shaded map costs legibility for nothing. The link is the
   whole image, and the caption under it says what a click does. */
.district-map { margin: 0 0 2.5rem; }
.district-map-link { display: block; max-width: 720px; }
.district-map-link img {
  width: 100%; height: auto; display: block;
  border: 1px solid var(--cw-rule); border-radius: var(--cw-radius);
  background: #fff;
}
.district-map-link:focus-visible img { outline: 3px solid var(--cw-gold); }
.district-map .post-meta { margin-top: .6rem; }
.board-section { margin: 0 0 2.5rem; }
.board-list {
  list-style: none; margin: 0; padding: 0;
  display: grid; gap: .5rem 2rem;
  grid-template-columns: repeat(auto-fill, minmax(min(260px, 100%), 1fr));
  max-width: var(--cw-measure);
}
.board-list li { display: flex; flex-direction: column; }
.board-name { font-family: var(--cw-font-display); font-size: 1.1rem; }
.board-role { font-size: .85rem; color: var(--cw-ink-soft); }
/* The Evelyn Schatz Way sign: a 3.5:1 panorama, so it runs the full column
   and the caption sits under it. It IS a photograph, so it takes the wash. */
.about-sign { margin: 0 0 2.5rem; }
.about-sign .wash-frame img { width: 100%; height: auto; display: block; }
.about-sign figcaption {
  max-width: var(--cw-measure); padding-top: .6rem;
  font-size: .95rem; color: var(--cw-ink-soft);
}
