/* styles.css -- every colour, size and motion curve for the page, in one file.
 *
 * Sections: tokens · THE INK LAYER · base · brand · map & pins · search card & results ·
 * over the map (Nearest here + note) · recenter · THE FILTER BAR · legend · bottom sheet ·
 * sheet header, banner, skeleton, first run, body states · list rows (detail.js) ·
 * detail view (detail.js) · about sheet (about.js) · desktop · utilities. detail.js, search.js, legend.js,
 * about.js and brand.js emit class names but own no CSS; the `cp-*`, `results-*`,
 * `legend__*`, `about__*` and `brand__*` rules here are theirs to render into (their
 * headers list them).
 *
 * The type scale is 11 / 12 / 13 / 15 / 17 / 22, plus two display sizes that only ever
 * carry a number: --fs-num (a row's lots count) and --fs-hero (the detail's price).
 * Everything else is one of the six. Numbers are tabular everywhere (see `body` and the
 * one opt-out list under it), so a column of counts, distances and prices lines up
 * without anyone remembering to ask.
 *
 * THE ONE RULE THE 2026-09-25 PASS ADDED, and the one to break last: the character --
 * the hand rules, the swipe, the underline, the hand face -- lives where elements are
 * LARGE. Every one of them is a LINE or a wordmark; none of them sets a name, a price,
 * a distance or a count. A 15 px name that looks hand-drawn does not read as a person,
 * it reads as a font that failed to load, and this app is read at a junction with the
 * engine running. See "the ink layer" below for the budget that goes with it.
 */

/* ---------------------------------------------------------------- tokens */

:root {
  color-scheme: light;

  /* Paper, not screen (2026-09-25). The whole app moved off a cool neutral grey onto a
   * warm paper white with a pencil-grey hairline, because the character layer below is
   * ink on paper and a #FFFFFF surface under a hand-ruled line reads as a print bug
   * rather than as a page. The step from --bg to --surface is deliberately small: the
   * sheet is a sheet of paper on a desk, not a card floating in space. Measured on
   * --surface: --text 16.8:1, --text-2 6.6:1, every -ink colour >= 4.8:1. */
  --bg: #EFEDE6;
  --surface: #FCFBF7;
  --surface-2: #EDEAE1;
  --text: #1A1A18;
  --text-2: #5C5B55;
  --line: #DED9CD;
  /* --accent is a FILL (a button, the user's dot, the current tariff's bar) and keeps a
   * blue saturated enough to survive on a map tile. --accent-ink is the same idea as
   * --ok-ink below: the colour that blue becomes when it is a WORD on a surface. They
   * are equal in light; in dark the fill stays and the word lifts to 7.6:1, which the
   * old single token could not do -- every accent-coloured label in the dark scheme was
   * #2563EB on #181B21, 2.6:1. */
  --accent: #2563EB;
  --accent-ink: #2563EB;
  /* The pressed/hovered accent, which was the literal #1D4ED8 in three unrelated rules
   * (the primary button, "Nearest here", Directions). Three copies of one decision. */
  --accent-hover: #1D4ED8;
  --on-accent: #FFFFFF;
  /* The ground the map paints itself on, for the half-second before tiles decode. It is
   * the MEAN RGB of a real tile -- the least surprising single colour to show in a
   * tile's place -- measured on 17/103381/65043: Grey #DBE0DD (mean luminance 222.9),
   * Night #283F3F (58.1). It is a token of its own rather than --bg because the two do
   * not always agree: --bg is the scheme's page colour and the basemap is the scheme's
   * MAP, and between 2026-09-25's two owner decisions they were briefly opposites. */
  --map-bg: #DBE0DD;
  /* The masthead band (About). Always the inverse of the ground -- see .about__head --
   * so the mark it carries has to invert with it: --band-pin/--band-sweep are the
   * --mark-* pair for the OTHER scheme's ground. "Glance" is set in --band-sweep and is
   * real text, so it is the one of the two that has to clear 4.5:1 (6.19 here, 5.37 in
   * dark); the pin is artwork and clears the 3:1 that graphics ask for. */
  --band-bg: #1F1E1B;
  --band-fg: #FAF8F2;
  --band-pin: #3DBE7C;
  --band-sweep: #6E9BFF;

  /* Availability (plan D4). Same in both schemes: tuned for contrast on map tiles.
   * --ok is darker than the plan's #1E9E5A: white 11 px digits on that green measured
   * 3.4:1, under the 4.5:1 the plan itself asks for; #15803D gives ~4.8:1 and is still
   * unmistakably "go" beside the amber and red. */
  --ok: #15803D;
  --low: #E0A100;
  --full: #D93B3B;
  /* The two glyph pins (plan 2026-09-23 §1.4): "P" on slate for a carpark with no live
   * count, "S" on plum for season-only. Filled discs the same size as the live pins, so
   * a mall with no feed is as much a carpark on the map as an HDB block with one. */
  --nofeed: #5B6270;
  --season: #7C5CBF;
  /* Private residential (owner decision 2026-09-25). The one kind whose pin is not an
   * offer of parking, told apart by colour rather than by the hollow ring it replaced.
   * Earth brown is the only family not already spoken for: green/amber/red mean a live
   * count, blue is the user's dot, plum is season-only, slate is "no live count". Its
   * nearest reserved colour is slate at dE76 45.7 -- wider than the app's own closest
   * pair (accent vs season, 26.5) -- and it carries white at 5.87:1. */
  --resi: #8A5A2B;
  --unknown: #8A919E; /* neutral grey for icons and notes that are about *us* not knowing */
  /* The same three hues as *text* on --surface. Amber at #E0A100 is 2.1:1 on white,
   * so anywhere a word or number is coloured it uses the -ink variant and stays >= 4.5:1;
   * the fills above are only ever behind white glyphs or beside a number. */
  --ok-ink: #17804A;
  --low-ink: #8A5F00;
  --full-ink: #C42B2B;
  --season-ink: #6A4DAF; /* plum as text: 5.6:1 on white */

  /* The mark (brand.js, and the copy inlined in index.html). Not --ok/--accent directly:
   * the pin's #15803D on the dark sheet is 1.9:1 and sinks into it, so the dark scheme
   * lightens both halves. The asset files keep the light hex, which is what a favicon,
   * an OS icon and a link preview all render on. */
  --mark-pin: #15803D;
  --mark-sweep: #2563EB;

  --shadow: 0 4px 24px rgba(26, 26, 24, .13);
  --shadow-sm: 0 1px 3px rgba(26, 26, 24, .18);
  --surface-glass: rgba(252, 251, 247, .88);
  --scrim: rgba(26, 26, 24, .06);

  /* FOUR radii and no others (2026-09-25 consistency pass). The app had sixteen
   * distinct border-radius values -- 2, 4, 6, 8, 9, 10, 12, 14, 16, 18, 999 and 50 %,
   * each picked locally and none of them wrong on its own. That is what "parts of the
   * app are not consistent" actually measures as: not missing ornament, but a corner
   * that is 6 px on a tariff band, 8 px on a chip, 9 px on a count pill and 10 px on the
   * button beside it, so nothing quite rhymes with anything.
   *
   *   --radius      16  the big surfaces: the sheet, the search card, the dropdown
   *   --radius-sm   12  a card or a block inside one: the filter panel, a button
   *   --radius-xs    8  a small control or a chip that sits inside a card
   *   --radius-pill 999 anything whose shape IS a pill: counts, tags, "Nearest here"
   *
   * 50 % survives for the two things that are genuinely circles (the FAB, the user's
   * dot) and 0 for the rows, which are full-bleed and must not look like cards. */
  --radius: 16px;
  --radius-sm: 12px;
  --radius-xs: 8px;
  --radius-pill: 999px;
  /* Motion. --ease is a hard deceleration with no tail-off at the start: a sheet, a pin
   * or a dropdown leaves immediately and *settles* into place instead of easing out of
   * both ends, which reads as a pop. --ease-out stays the gentler curve for things that
   * only change colour. Durations: --dur-fast for a hover or a press, --dur for most
   * things, --dur-slow for the two big travels (the sheet's snap, the About sheet). */
  --dur-fast: 140ms;
  --dur: 220ms;
  --dur-slow: 320ms;
  --ease: cubic-bezier(.32, .72, 0, 1);
  --ease-out: cubic-bezier(.2, .8, .2, 1);

  --fs-2xs: 11px; /* eyebrows and captions: "RATE NOW", "lots", "TODAY" */
  --fs-xs: 12px;
  --fs-sm: 13px;
  --fs-md: 15px;
  --fs-lg: 17px;
  --fs-xl: 22px;
  --fs-num: 24px; /* a list row's lots count -- the one thing read from arm's length */
  --fs-hero: 30px; /* the detail view's price */
  --lh: 1.35;
  --font: Inter, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  /* The hand. Shantell Sans (Shantell Martin / Arrow Type / Anya Danilova, SIL Open Font
   * License 1.1 -- free for commercial use), loaded at 700 only.
   *
   * WHERE IT IS ALLOWED: the wordmark, and nothing else. It never sets a number, a
   * carpark name, a price, a distance or a button. The rule behind that is the one rule
   * this whole treatment obeys -- CHARACTER LIVES WHERE THINGS ARE LARGE. A wobble in a
   * 15 px name or an 11 px count does not read as a person, it reads as a font failing
   * to load, and this app is read at a junction with the engine running. */
  --font-hand: "Shantell Sans", "Comic Sans MS", var(--font);
  /* Inter sets loose at display sizes; every number that is set big gets this. */
  --tracking-tight: -.02em;

  /* One row is three lines now (name+count · price · distance · kind), so the peek snap
   * -- which app.js measures as handle + header + one row -- grew with it. */
  --row-h: 84px;
  --panel-w: 380px;
  --search-inset: 12px;
  --search-h: 52px;
  --fab: 44px;

  /* Height of the bottom sheet. app.js sets this in px on every snap and while dragging;
   * the recenter button and the map attribution ride on the same value, so the three
   * can never drift apart. */
  --sheet-h: 50vh;
}

/* The dark scheme is the CHROME's, never the basemap's (owner: "no dark mode", said of
 * the map). So this is a dark page holding a light map, and the two things that used to
 * be tied to the scheme are not any more: --map-bg stays paper, and --accent-ink lifts
 * so that every accent-coloured WORD clears 4.5:1 -- it did not before. */
@media (prefers-color-scheme: dark) {
  :root {
    color-scheme: dark;
    --bg: #0E0E0F;
    --surface: #18181A;
    --surface-2: #232327;
    --text: #EAE8E2;
    --text-2: #9C9A92;
    --line: #2F2F33;
    --accent-ink: #7FA8FF;
    --map-bg: #283F3F;
    --band-bg: #E9E6DD;
    --band-fg: #1A1A18;
    --band-pin: #15803D;
    --band-sweep: #1D4ED8;
    --ok-ink: #3DBE7C;
    --low-ink: #F0B429;
    --full-ink: #F26B6B;
    --season-ink: #B49CF0;
    --mark-pin: #3DBE7C;
    --mark-sweep: #6E9BFF;
    --shadow: 0 4px 24px rgba(0, 0, 0, .5);
    --shadow-sm: 0 1px 3px rgba(0, 0, 0, .6);
    /* .95, not the light scheme's .88, and the reason is the WORST pixel rather than the
     * average one. The attribution pill and the map note float over whatever the tile
     * happens to draw under them, so their contrast has to hold over a tile's brightest
     * pixel -- a white road casing or a label halo, which both OneMap themes have. With
     * 11 px --text-2 on the blend: .88 over white = 4.43:1 (under the 4.5:1 this app
     * holds itself to), .92 = 5.05:1, .95 = 5.53:1. The light scheme has the room the
     * dark one does not: .88 over a tile's BLACKEST pixel is still 5.01:1, because the
     * pill's own colour is already near the top of the range.
     *
     * First written when Grey was the only basemap; re-measured when Night came back,
     * and the answer did not move, because it never depended on which theme was under
     * the pill -- only on how bright one pixel of it can be. */
    --surface-glass: rgba(24, 24, 26, .95);
    --scrim: rgba(255, 255, 255, .06);
  }
}

/* ---------------------------------------------------------------- the ink layer

 * Three authored objects, drawn once as constants and never randomised. A rule that
 * re-wobbles on every render shimmers while the list scrolls, which is the fastest way
 * to turn "a person made this" into "something is broken".
 *
 * Each is an inline SVG used as a MASK, not as a background image: a mask takes its
 * colour from `background-color`, so one constant serves both schemes and every token
 * that lands on it. (A background-image would have the hex baked in and would need a
 * second copy under prefers-color-scheme.)
 *
 * THE BUDGET, and it is the whole discipline: at most three ink MOTIFS on any one
 * screen, and the ink never touches type under 17 px. The list screen shows one (the
 * rule under the filter bar). The detail screen shows three (rule, swipe, underline).
 * The About sheet shows two (masthead, rules). A fourth -- the inked frame around the
 * map that the design exploration drew -- is deliberately NOT here: it does no
 * informational work, and it measured as invisible (#1A1A18 at 62 % over an ink ground)
 * the moment the surface under it went dark.
 */

:root {
  /* 1. THE HAND RULE. A 216 px tile, repeated: repeating a fixed tile keeps the stroke
   * width and the wavelength constant at any width, where stretching one long path
   * would thin the line on a desktop panel and fatten it on a phone. Both ends sit at
   * y = 3.2 so the joins do not show. */
  --ink-rule: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 216 6'%3E%3Cpath d='M0 3.2C12 1.6 24 4.5 36 3.3S58 4.2 70 3C82 1.8 94 4.8 108 3.4S132 4.4 144 3.1 168 4.6 180 3.3 204 4.3 216 3.2' fill='none' stroke='%23000' stroke-width='1.2' stroke-linecap='round'/%3E%3C/svg%3E");
  /* 2. THE MARKER SWIPE. Stretched, not tiled: a highlighter really is one pass that
   * gets longer, and its ends are the only part that has to stay put. It sits behind
   * the ONE tariff that is live right now -- the single fact this app exists to
   * deliver -- which is the only reason the ink is allowed on that row at all. */
  --ink-swipe: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 240 40' preserveAspectRatio='none'%3E%3Cpath d='M3 7.5C44 4.2 96 9.6 140 5.8S216 7.4 237 5l-.5 28.5C196 37.4 142 31.6 96 35.4S24 33.2 3.6 35.8z' fill='%23000'/%3E%3C/svg%3E");
  /* 3. THE UNDERLINE. Two passes, because one pass is a border and two is a person
   * going over it again. Only ever under an eyebrow that labels a hero. */
  --ink-underline: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 7' preserveAspectRatio='none'%3E%3Cg fill='none' stroke='%23000' stroke-width='1.3' stroke-linecap='round'%3E%3Cpath d='M1.5 2.9C12 1.2 30 4.5 44 2.4 52 1.2 58 3.3 62.5 2.2'/%3E%3Cpath d='M4 5.4C16 4 34 6.4 48 4.8'/%3E%3C/g%3E%3C/svg%3E");
  /* 4. THE PIN. An inked teardrop, 30 x 38, tip at the bottom centre, head centred on
   * (15, 15) -- map.js holds the same box and the same head, and says so.
   *
   * TWO paths, not one, and the second is the whole reason the shape survives a raster
   * basemap it does not control. Measured on real tiles at 17/103381/65043:
   *
   *              pin fill vs ground      white keyline vs ground
   *   Grey  #F4F4F4    4.1-5.6 : 1              1.10 : 1
   *   Night #1C2C2C    1.7-3.2 : 1             14.52 : 1
   *
   * The two basemaps separate a pin through OPPOSITE channels -- on paper it is the
   * fill, on ink it is the keyline -- so an object carrying only one of them dissolves
   * into one of the two. That is the measured form of NOTES.md's "drawn silhouettes are
   * not treatment-proof": a line drawing has no fill and dies on Grey. This drop is an
   * opaque plate with a drawn edge, so it carries both.
   *
   * --pin-edge is the outer silhouette (drawn in --surface, the keyline) and --pin-body
   * the same drop inset ~2.2 px (drawn in the availability colour). Hand-drawn, which at
   * 28 px means the curve is uneven rather than wobbly -- a drop is a SHAPE, and a shape
   * reads as drawn where a 1 px line at this size would read as broken. */
  --pin-edge: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 38'%3E%3Cpath d='M15 37.2C13.2 32.3 9.2 27.5 6 23.5 2.5 19.1 1.2 15.5 1.7 11.5 2.7 4.8 8.1.9 15.1 1c7.1.1 12.3 4.4 13 10.9.4 4-1.1 7.6-4.5 11.8-3.2 4-6.9 8.6-8.6 13.5z' fill='%23000'/%3E%3C/svg%3E");
  --pin-body: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 38'%3E%3Cpath d='M15 33.4C13.6 29.6 10.6 25.9 7.9 22.5 5 18.9 3.9 15.9 4.3 12.6 5 7 9.4 3.7 15.1 3.8c5.8.1 10 3.7 10.6 9.1.3 3.3-.9 6.3-3.7 9.8-2.7 3.4-5.7 7-7 10.7z' fill='%23000'/%3E%3C/svg%3E");
  /* THE KEYLINE IS NOT A SCHEME TOKEN, and the first draft getting that wrong is the
   * clearest thing the measurement caught. It was --surface, which is paper in light and
   * near-black in dark -- and in dark the basemap is OneMap Night, so a near-black edge
   * round a mid-toned drop on a near-black ground deletes the ONLY channel that
   * separates a pin there (white keyline vs Night's ground: 14.52 : 1; the fill:
   * 1.7-3.2 : 1). It stays paper in both schemes: redundant on Grey, where the fill does
   * the separating at 4.1-5.6 : 1, and load-bearing on Night. */
  --pin-keyline: #FCFBF7;
  /* 4b. THE PRIVATE RESIDENTIAL MARKER -- treatment A of the signed-off icon set
   * (docs/design/directions/icons.html, 2026-09-25). The marker IS a house; there is no
   * glyph inside it, so the meaning rides on a ~20 px silhouette instead of a 15 px mark
   * within a shape. It replaced an interim "plate with a tail" that still carried a
   * glyph, which is why this is not a path swap: pins.js had to gain a marker (see
   * MARKERS there) before the shape could stop having an inside.
   *
   * THE DOOR IS THE REASON THERE ARE TWO DIFFERENT OUTLINES rather than one path used
   * twice. --house-body is the drawing as the set ships it, door notched out of the
   * bottom edge. --house-edge is the same house WITHOUT the door, fattened by a 4.4-wide
   * stroke on its own fill (a mask only cares about coverage, so fill+stroke is an
   * expand-by-2.2 that needs no hand-offset path). If the edge kept the door, the
   * stroke would close the 6.2-unit opening to 1.8 and the door would render as a
   * transparent slot onto the map instead of a paper-coloured cut.
   *
   * Both share one viewBox, so the two layers cannot drift out of register.
   *
   * It keeps brown (--resi #8A5A2B) and it keeps the two-layer edge+body construction,
   * so it survives Grey and Night by the same two measured channels as the drop. */
  --house-edge: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='-2.3 -2.5 30.6 35'%3E%3Cpath d='M12.7 1.6Q13.8 1.6 14.6 2.5L24.2 12.1Q25.8 13.7 23.6 13.9L22 14.1V28.6H4.4V14.1L2.8 13.9Q0.6 13.7 2.2 12.1L11.8 2.5Q12.6 1.6 12.7 1.6Z' fill='%23000' stroke='%23000' stroke-width='4.4' stroke-linejoin='round' stroke-linecap='round'/%3E%3C/svg%3E");
  --house-body: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='-2.3 -2.5 30.6 35'%3E%3Cpath d='M12.7 1.6Q13.8 1.6 14.6 2.5L24.2 12.1Q25.8 13.7 23.6 13.9L22 14.1V28.6H16.4V21.8Q16.4 18.8 13.3 18.8Q10.2 18.8 10.2 21.8V28.6H4.4V14.1L2.8 13.9Q0.6 13.7 2.2 12.1L11.8 2.5Q12.6 1.6 12.7 1.6Z' fill='%23000'/%3E%3C/svg%3E");
  /* 5. THE DESTINATION RING -- a loose ellipse, the gesture a person makes to a place on
   * a paper map. Lifted from the exploration's authored `ink-circle`, which overshoots
   * its own start: that overshoot is the tell that a hand drew it. */
  --ink-ring: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 84 44'%3E%3Cpath d='M44 4.4C22 2.6 5.4 9.8 4.2 20.4 3.1 30.8 19.4 39.8 41.6 40.4 63.2 41 79.4 33.6 80.2 23.2 81 13.1 66.4 5.2 45.6 4.2 33.4 3.6 22.6 6.1 16.2 10.4' fill='none' stroke='%23000' stroke-width='2.6' stroke-linecap='round'/%3E%3C/svg%3E");
  /* The ink is a pencil-grey, not the text colour: a hand rule at full --text weight
   * competes with the words it separates. Tuned to sit just above --line's 1.36:1. */
  --ink: #A9A296;
  /* The swipe's own fill. A token rather than a literal because the amount of blue a
   * wash needs to read differs by scheme: 16 % of a saturated blue is plenty on paper
   * and nearly invisible on ink. */
  --ink-swipe-fill: color-mix(in srgb, var(--accent) 16%, transparent);
}
@media (prefers-color-scheme: dark) {
  :root {
    --ink: #56555A;
    --ink-swipe-fill: color-mix(in srgb, var(--accent-ink) 22%, transparent);
  }
}

/* A structural seam. `.rule` is an element of its own (the sheet header's), `.has-rule`
 * draws the same line on the bottom edge of a block that already exists (a detail
 * section). Both are 6 px tall and neither takes a tap. */
.rule, .has-rule::after {
  display: block;
  height: 6px;
  background-color: var(--ink);
  -webkit-mask: var(--ink-rule) repeat-x left center / 216px 6px;
  mask: var(--ink-rule) repeat-x left center / 216px 6px;
  pointer-events: none;
}
.has-rule { position: relative; }
.has-rule::after { content: ""; position: absolute; left: 0; right: 0; bottom: -3px; }

/* The one place the ink is allowed to be loud: behind the tariff that is running now. */
.ink-swipe { position: relative; }
.ink-swipe > * { position: relative; }
.ink-swipe::before {
  content: "";
  position: absolute;
  inset: 0;
  background-color: var(--ink-swipe-fill);
  -webkit-mask: var(--ink-swipe) no-repeat center / 100% 100%;
  mask: var(--ink-swipe) no-repeat center / 100% 100%;
  pointer-events: none;
}

/* An eyebrow gone over twice. The LABEL stays a crisp 11 px uppercase Inter; only the
 * line under it is drawn, which is the large/small split this whole treatment turns on. */
.ink-underline { position: relative; padding-bottom: 9px; }
.ink-underline::after {
  content: "";
  position: absolute;
  left: 0;
  bottom: 0;
  width: 100%;
  max-width: 96px;
  height: 7px;
  background-color: var(--accent-ink);
  opacity: .75;
  -webkit-mask: var(--ink-underline) no-repeat left bottom / 100% 7px;
  mask: var(--ink-underline) no-repeat left bottom / 100% 7px;
}

/* The wordmark in the hand, and the only text in the app set in it. */
.hand { font-family: var(--font-hand); font-weight: 700; }

/* ---------------------------------------------------------------- base */

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

html, body {
  height: 100%;
  margin: 0;
  /* The map is the page; the body never scrolls, the sheet does. */
  overflow: hidden;
  overscroll-behavior: none;
}

body {
  background: var(--bg);
  color: var(--text);
  font-family: var(--font);
  font-size: var(--fs-md);
  line-height: var(--lh);
  /* Every figure on the page is one a reader compares with the figure above it -- lots,
   * metres, dollars, clock times. Set once here rather than on a dozen selectors, so a
   * number added later is tabular by default instead of by memory. */
  font-variant-numeric: tabular-nums;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
}

/* ...with one opt-out. Inter's `tnum` also swaps the hyphen for a figure-width one, so
 * "HDB multi-storey" and "Short-term parking" open a visible gap at the hyphen. These
 * are the places a hyphen can appear, and none of them is a column anyone compares down
 * the page: they are sentences and names that happen to contain digits. */
.cp-row__name, .results-name, .cp-detail__name,
.cp-chip--kind, .results-chip,
.cp-row__sub, .results-sub, .cp-hours, .cp-note, .cp-note__sub,
.about__list, .about__kind-name, .about__note, .state__text, .first-run__text,
.legend__text {
  font-variant-numeric: normal;
}

button, input, select {
  font: inherit;
  color: inherit;
}

button {
  cursor: pointer;
  border: 0;
  background: none;
  padding: 0;
  touch-action: manipulation;
}

a { color: var(--accent-ink); }

:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

svg { display: block; }

/* Line icons share one stroke so the clear ×, the chevron and the crosshair match. */
.search__clear svg, .lot__icon, .lot__chevron, .fab svg, .state__icon {
  fill: none;
  stroke: currentColor;
  stroke-width: 1.8;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* ---------------------------------------------------------------- brand */

/* The mark is never a link, never a button and never large. It appears in exactly three
 * places: the search card's leading slot (index.html, inline), the About sheet's heading
 * and the first-run line (both brand.js). All three draw with --mark-pin / --mark-sweep,
 * so the mark belongs to the scheme rather than sitting on top of it. */
.search__mark, .brand__mark, .first-run__mark { flex: none; }

/* The lockup: mark, a gap, then the wordmark. Both are em-based, so the whole thing
 * scales from one font-size exactly as the SVG lockup's 14/64 gap does. */
.brand--lockup {
  display: inline-flex;
  align-items: center;
  gap: .3em;
  line-height: 1;
}
/* 1.1em against Inter's cap height; the hand face in the About masthead sets smaller for
 * its point size, so the lockup there takes a slightly larger mark (see .about__title). */
.brand--lockup .brand__mark { width: 1.1em; height: 1.1em; }
.brand__word { letter-spacing: var(--tracking-tight); }
.brand__word-a { font-weight: 700; color: var(--text); }
.brand__word-b { font-weight: 600; color: var(--mark-sweep); }

/* ---------------------------------------------------------------- map & pins */

/* --map-bg, not --bg: the basemap is OneMap Grey in both schemes (map.js), so the frame
 * the user sees for the half-second before tiles decode has to be the tile's own paper.
 * Under the dark scheme --bg is #0E0E0F, and a black flash behind a light map is the
 * only thing about the basemap a dark-scheme user would ever notice. */
.map {
  position: fixed;
  inset: 0;
  background: var(--map-bg);
  z-index: 0;
}

.leaflet-container {
  background: var(--map-bg);
  font-family: var(--font);
}

/* Attribution sits just above the sheet's edge, bottom-left; the FAB owns bottom-right. */
.leaflet-bottom {
  bottom: var(--sheet-h);
  transition: bottom var(--dur-slow) var(--ease);
}
/* Every floating object over the map is the same thing: a scrap of paper with a
 * hairline edge. The hairline is what makes it survive OneMap Grey's busy ground -- a
 * shadow alone disappears over a hatched building outline. */
.leaflet-container .leaflet-control-attribution {
  margin: 0 0 8px 8px;
  padding: 3px 8px;
  border: 1px solid var(--line);
  border-radius: var(--radius-xs);
  background: var(--surface-glass);
  backdrop-filter: blur(8px);
  color: var(--text-2);
  font-size: 11px;
  line-height: 20px;
  box-shadow: var(--shadow-sm);
}
.leaflet-control-attribution a { color: var(--text-2); }
.leaflet-control-attribution img { vertical-align: middle; }

/* Zoom control (desktop only; map.js adds it there). Rounded, tokenised, no Leaflet grey. */
.leaflet-touch .leaflet-bar {
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  box-shadow: var(--shadow);
  overflow: hidden;
}
.leaflet-touch .leaflet-bar a {
  width: 40px;
  height: 40px;
  line-height: 40px;
  background: var(--surface);
  color: var(--text);
  border-bottom-color: var(--line);
  font-weight: 400;
}
.leaflet-touch .leaflet-bar a:hover { background: var(--surface-2); }
.leaflet-top.leaflet-right { top: 16px; right: 16px; }

/* ---- one mark, one colour, one place it is decided ---------------------------------
 *
 * A carpark is drawn three times -- as a pin on the map, as a dot on its list row, as a
 * sample in the legend -- and the only thing that has ever gone wrong between the three
 * is the CASCADE. `.cp-dot--resi` rides alongside `.cp-dot--nofeed` at equal
 * specificity, so whichever came last in this file won, and for one release a condo's
 * list dot was slate while its map pin was brown.
 *
 * So the three no longer paint themselves. Each modifier sets ONE custom property and
 * nothing else, and the shape rules below read it. There is exactly one declaration per
 * colour, shared by all three components, so they cannot disagree -- and `--resi` stays
 * last for the same reason it always had to, except that now "last" is a single line
 * rather than three lines in three sections.
 */
/* SHAPE and SIZE travel the same way COLOUR does: custom properties, set once per mark,
 * read by the map pin, the list-row dot and the legend sample alike. There is no second
 * copy of the teardrop, no second copy of the house, and no component that decides its
 * own silhouette -- which is what makes "the map, the list and the legend agree" a
 * property of the stylesheet rather than something to remember.
 *
 * The house is 0.9 of the drop at every one of the four sizes, not just on the map: the
 * decision is "this is the quieter object", and it would be a strange decision to make
 * only where the objects are dense. */
:root { --mark-edge: var(--pin-edge); --mark-body: var(--pin-body); }
.pin            { --mark-w: 30px; --mark-h: 38px; }
.cp-dot         { --mark-w: 11px; --mark-h: 14px; }
/* 24 x 31, not the 17 x 22 this started at. The signed-off set's note 5 asks for a 13 px
 * glyph in the legend (at 11 the void deck's three heads land near 2.2 px and the
 * camera's lens closes), and says to resize the SAMPLE rather than simplify the marks.
 * A drop's head is 78 % of its box width and a mark sits at 68 % of the head, so a 13 px
 * mark needs a 24 px sample -- at 17 px the marks overflowed the head and were clipped by
 * the silhouette, which is the legend showing something the map does not draw.
 * The switch rows use the same size: that card is where most people meet the set. */
.legend__sample { --mark-w: 24px; --mark-h: 31px; }
/* After the four above, so it wins at equal specificity -- the same ordering rule the
 * fills below obey, for the same reason. */
.pin--resi, .cp-dot--resi, .legend__sample--resi {
  --mark-edge: var(--house-edge);
  --mark-body: var(--house-body);
}
.pin--resi            { --mark-w: 27px; --mark-h: 31px; }
.cp-dot--resi         { --mark-w: 10px; --mark-h: 12px; }
.legend__sample--resi { --mark-w: 22px; --mark-h: 25px; }

.pin--ok,      .cp-dot--ok,      .legend__sample--ok      { --mark-fill: var(--ok); }
.pin--low,     .cp-dot--low,     .legend__sample--low     { --mark-fill: var(--low); }
.pin--full,    .cp-dot--full,    .legend__sample--full    { --mark-fill: var(--full); }
.pin--nofeed,  .cp-dot--nofeed,  .legend__sample--nofeed,
                                 .legend__sample--kind    { --mark-fill: var(--nofeed); }
.pin--season,  .cp-dot--season,  .legend__sample--season  { --mark-fill: var(--season); }
/* Last, and it has to be: private residential wears its own class ALONGSIDE the nofeed
 * one (it is still "no live count" on the availability scale) and must win the fill. */
.pin--resi,    .cp-dot--resi,    .legend__sample--resi    { --mark-fill: var(--resi); }

/* Pins. An inked teardrop, 30 x 38, tip at the bottom centre (map.js sets the box and
 * anchors on that tip). The head's centre is (15, 15) of the box and every number below
 * that positions something on the head repeats those two numbers.
 *
 * WHY A MASK AND NOT AN <svg>: the fill has to be an availability TOKEN (--ok, --low,
 * --resi ...), and those live in this file. A mask takes its colour from
 * `background-color`, so one authored path serves six fills and both schemes, and a
 * colour changed here changes the map, the list dot and the legend sample together --
 * which is the fix for the class of bug that once made a condo's list dot slate while
 * its map pin was brown.
 *
 * THE ORDER OF THE TWO LAYERS IS THE DESIGN: --pin-edge (the keyline, in --surface) is
 * the element's own background; --pin-body (the same drop inset 2.2 px, in the
 * availability colour) is ::before on top of it. See the measurement beside those two
 * tokens for why a drop needs both. */
.pin {
  display: block;
  width: var(--mark-w);
  height: var(--mark-h);
  background: none;
  border: 0;
  outline: none;
}
.pin__dot {
  position: relative;
  display: block;
  width: var(--mark-w);
  height: var(--mark-h);
  /* The keyline: --pin-keyline, which is paper in BOTH schemes. See that token. */
  background-color: var(--pin-keyline);
  -webkit-mask: var(--mark-edge) no-repeat center / var(--mark-w) var(--mark-h);
  mask: var(--mark-edge) no-repeat center / var(--mark-w) var(--mark-h);
  /* Grows from the TIP, so the point it marks does not move when it is selected. */
  transform-origin: 50% 100%;
  transition: transform var(--dur) var(--ease);
  will-change: transform;
}
.pin__dot::before {
  content: "";
  position: absolute;
  inset: 0;
  /* The fallback is the whole point of the fallback: an unclassified carpark, or a kind
   * the API invents tomorrow, draws a neutral drop rather than no drop at all. */
  background-color: var(--mark-fill, var(--unknown));
  -webkit-mask: var(--mark-body) no-repeat center / var(--mark-w) var(--mark-h);
  mask: var(--mark-body) no-repeat center / var(--mark-w) var(--mark-h);
}
/* The count or the glyph, sitting on the head's centre. It is a SIBLING of the two
 * masks, not a child: a mask clips everything inside the element it is on, so a digit
 * inside .pin__dot would be cut to the drop's outline. */
/* top: 14, not 15. The head's geometric centre is (15, 15) -- the selected ring below
 * still uses that -- but a drop's mass hangs BELOW its head, so a mark centred on the
 * geometry sits optically low. The icon set lifts by 0.8 of its 26-unit box; 1 px here
 * is the same move at this size. */
.pin__face {
  position: absolute;
  top: 14px;
  left: 15px;
  transform: translate(-50%, -50%);
  display: grid;
  place-items: center;
  color: #fff;
  font-size: 11px;
  font-weight: 600;
  line-height: 1;
  font-variant-numeric: tabular-nums;
  pointer-events: none;
}
/* A drop is not symmetrical about its head, so the drop shadow goes on the silhouette
 * rather than on a box: `filter` follows the mask, `box-shadow` would draw a rectangle. */
.pin { filter: drop-shadow(0 1px 2px rgba(0, 0, 0, .34)); }
/* "1.2k": four characters in a head sized for three. One step down and a hair of
 * negative tracking keeps it inside the drop; three digits keep the full 11 px, because
 * they are what almost every live pin actually shows. */
.pin--wide .pin__face { font-size: 10px; letter-spacing: -.035em; }
/* Amber is the one fill light enough to need dark digits on it. */
.pin--low .pin__face { color: #15181E; }
/* Glyph pins: one bold letter where the count would be. 12/700 rather than the digits'
 * 11/600 because a single letter has to carry the meaning a number carries by being one. */
.pin--nofeed .pin__face, .pin--season .pin__face { font-size: 12px; font-weight: 700; }
/* The icon half of the glyph set (pins.js GLYPHS): a 24 x 24 path filled with the dot's
 * own colour. 15 px rather than the letters' 12 px cap height -- a silhouette needs more
 * box than a letter to carry the same weight, and 15 px still clears the 2 px stroke.
 * `overflow: visible` is off by default on svg, which is what keeps a path that strays
 * outside the viewBox from spilling over the disc's edge. */
.pin__glyph { width: 15px; height: 15px; display: block; fill: currentColor; }
/* private_residential (owner decision 2026-09-25): a solid brown disc, the same 26 px
 * shape as every other pin, carrying the house glyph. It replaced a hollow slate ring,
 * which read as a broken pin and as a smaller target; the owner's rule is that the pin
 * looks like a pin and the FILTER is how you make it go away. It comes after the five
 * fills on purpose -- the class rides alongside `pin--nofeed` (same specificity) and
 * must win the background. Colour cannot say "we are not sure you may park here", so
 * the legend row, the tooltip and the detail pane all say it in words. */

/* Selected: the drop grows from its tip and a ring + halo fade in around the HEAD (not
 * around the box -- a drop's box centre is not its head). The ring is on a pseudo of
 * .pin rather than a box-shadow on .pin__dot for two reasons: the dot is scaled by a
 * transform, which would scale a 4 px ring to 5 px and blur it; and the dot is masked,
 * and a mask clips a box-shadow away entirely. */
.pin::after {
  content: "";
  position: absolute;
  top: 15px; /* the head's centre, same two numbers as .pin__face */
  left: 15px;
  width: 30px;
  height: 30px;
  margin: -15px 0 0 -15px;
  border-radius: 50%;
  opacity: 0;
  transform: scale(.8);
  box-shadow:
    0 0 0 3px var(--surface),
    0 0 0 9px color-mix(in srgb, var(--accent) 35%, transparent);
  transition: transform var(--dur) var(--ease), opacity var(--dur) var(--ease);
  pointer-events: none;
}
.pin { position: relative; }
.pin--selected .pin__dot { transform: scale(1.22); }
.pin--selected::after { opacity: 1; transform: none; }
/* Focus and hover cannot be a box-shadow on a masked element (the mask clips it), so
 * they are the same ring pseudo, shown at a smaller spread. Keyboard focus is an accent
 * ring, which is the app's focus language everywhere else. */
.pin:focus-visible::after {
  opacity: 1;
  transform: none;
  box-shadow: 0 0 0 3px var(--surface), 0 0 0 6px var(--accent);
}
.pin:hover:not(.pin--selected) { filter: drop-shadow(0 3px 6px rgba(0, 0, 0, .4)); }

/* The user: a 12 px accent dot with a breathing 30 % halo. */
.you {
  display: grid;
  place-items: center;
  background: none;
  border: 0;
}
.you__dot {
  position: relative;
  width: 12px;
  height: 12px;
  border-radius: 50%;
  background: var(--accent);
  /* The same keyline as a carpark pin, for the same reason: it is what holds the dot
   * against OneMap Night. It was a literal #fff, which happened to agree. */
  border: 2px solid var(--pin-keyline);
  box-shadow: 0 1px 4px rgba(0, 0, 0, .35);
}
.you__dot::before {
  content: "";
  position: absolute;
  inset: -14px;
  border-radius: 50%;
  background: var(--accent);
  opacity: .3;
  animation: you-pulse 2.6s var(--ease-out) infinite;
}
@keyframes you-pulse {
  0%   { transform: scale(.55); opacity: .45; }
  70%  { transform: scale(1.25); opacity: 0; }
  100% { transform: scale(1.25); opacity: 0; }
}

/* The destination (map.js setDestination): a hand-drawn RING around the place, with its
 * name in a chip above. It was a teardrop until the carparks became teardrops on
 * 2026-09-25 -- the exploration's own finding 6 was that Marginalia drew every pin as a
 * drop and lost "the place you searched for" among "somewhere to park". Three
 * silhouettes now, told apart without colour: carpark = drop, destination = ring,
 * you = pulsing disc.
 *
 * It also sits AROUND the point instead of above it, so it never occupies the same
 * pixels as a carpark pin standing on the same spot. */
.dest {
  background: none;
  border: 0;
}
.dest__box {
  position: relative;
  display: block;
  width: 56px;
  height: 34px;
  animation: dest-drop var(--dur) var(--ease);
}
.dest__ring {
  display: block;
  width: 56px;
  height: 34px;
  background-color: var(--accent);
  -webkit-mask: var(--ink-ring) no-repeat center / 56px 34px;
  mask: var(--ink-ring) no-repeat center / 56px 34px;
  /* A ring is a LINE, and a line is the one thing NOTES.md says does not survive a
   * raster basemap on its own. The white/paper halo underneath is what carries it over
   * OneMap Night's label blue and Grey's near-white ground alike. */
  filter: drop-shadow(0 0 1.5px var(--surface)) drop-shadow(0 0 1.5px var(--surface)) drop-shadow(0 1px 2px rgba(0, 0, 0, .35));
}
.dest__label {
  position: absolute;
  bottom: calc(100% + 4px);
  left: 50%;
  transform: translateX(-50%);
  max-width: 180px;
  padding: 3px 8px;
  border-radius: var(--radius-xs);
  background: var(--surface);
  color: var(--text);
  font-size: var(--fs-xs);
  font-weight: 600;
  line-height: 16px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  box-shadow: var(--shadow-sm);
}
/* Drawn on, rather than dropped in: a ring is a gesture, so it arrives by growing from
 * the point it encircles. Reduced motion cuts it to nothing. */
@keyframes dest-drop {
  from { transform: scale(.82); opacity: 0; }
  to   { transform: none; opacity: 1; }
}

/* ---------------------------------------------------------------- search card */

.search {
  position: fixed;
  top: calc(var(--search-inset) + env(safe-area-inset-top));
  left: calc(var(--search-inset) + env(safe-area-inset-left));
  right: calc(var(--search-inset) + env(safe-area-inset-right));
  z-index: 1100;
}
/* The search card is the app's masthead: the widest, tallest, highest object on the
 * screen, and the one place that says whose map this is. It gets the paper treatment in
 * full -- warm ground, a hairline edge, a soft shadow -- and no ink, because a 17 px
 * input and a 26 px mark are both below the size where a drawn line reads as drawn. */
.search__card {
  display: flex;
  align-items: center;
  gap: 10px;
  height: var(--search-h);
  padding: 0 6px 0 13px;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--surface);
  box-shadow: var(--shadow);
  transition: box-shadow var(--dur) var(--ease-out);
}
.search__card:focus-within { box-shadow: var(--shadow), 0 0 0 2px var(--accent); border-color: transparent; }
/* 24 px: the same optical weight the magnifying glass had at 20, since the mark's own
 * bounding box leaves air on three sides. */
.search__mark { width: 26px; height: 26px; }
.search__input {
  flex: 1;
  min-width: 0;
  height: 100%;
  border: 0;
  background: none;
  outline: none;
  /* 17 px, not 15: iOS zooms the whole page when a focused input is under 16 px. The
   * placeholder was shortened instead, which the card had no room for either way once
   * the mark took the leading slot. */
  font-size: var(--fs-lg);
  font-weight: 500;
  letter-spacing: -.008em;
  padding: 0;
  -webkit-appearance: none;
  appearance: none;
}
.search__input::placeholder { color: var(--text-2); font-weight: 400; }
.search__input::-webkit-search-cancel-button, .search__input::-webkit-search-decoration { display: none; }
.search__clear {
  flex: none;
  width: 32px;
  height: 32px;
  border-radius: 50%;
  color: var(--text-2);
  display: grid;
  place-items: center;
}
.search__clear[hidden] { display: none; } /* display:grid above would otherwise beat [hidden] */
.search__clear svg { width: 16px; height: 16px; }
.search__clear:hover { background: var(--surface-2); color: var(--text); }
/* ⓘ About. The same quiet circle as the clear button; it sits between the text and the
 * vehicle chip so the eye reads "search · info · vehicle" left to right. */
.search__about {
  flex: none;
  width: 32px;
  height: 32px;
  border-radius: 50%;
  color: var(--text-2);
  display: grid;
  place-items: center;
}
.search__about svg { width: 20px; height: 20px; }
.search__about:hover, .search__about[aria-expanded="true"] { background: var(--surface-2); color: var(--text); }
.search__about svg { fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; }

/* Vehicle chip over an invisible native <select>. */
.lot {
  position: relative;
  flex: none;
  height: 40px;
}
.lot__chip {
  height: 100%;
  display: inline-flex;
  align-items: center;
  gap: 4px;
  padding: 0 8px 0 10px;
  border-radius: var(--radius-sm);
  background: var(--surface-2);
  color: var(--text);
  font-weight: 600;
  font-size: var(--fs-sm);
  transition: background var(--dur-fast) var(--ease-out);
}
.lot:hover .lot__chip { background: var(--line); }
.lot:focus-within .lot__chip { outline: 2px solid var(--accent); outline-offset: 2px; }
.lot__icon { width: 20px; height: 20px; }
.lot__icon[hidden] { display: none; }
.lot__chevron { width: 14px; height: 14px; color: var(--text-2); }
.lot__select {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  opacity: 0;
  cursor: pointer;
  -webkit-appearance: none;
  appearance: none;
}

/* Results dropdown (rendered by search.js) */
.results {
  margin-top: 8px;
  border: 1px solid var(--line);
  /* dvh, not vh: on iOS the keyboard shrinks the dynamic viewport but not vh, and a
   * 60vh dropdown under an open keyboard has its last rows behind the keys. */
  max-height: min(60dvh, 480px);
  overflow-y: auto;
  overscroll-behavior: contain;
  border-radius: var(--radius);
  background: var(--surface);
  box-shadow: var(--shadow);
  padding: 6px 0;
  /* Grows out of the card's bottom edge rather than dropping in from above it: the
   * transform-origin is the seam the two share, so the panel unfolds from the input. */
  transform-origin: 50% 0;
  animation: unfold var(--dur) var(--ease);
}
.results[hidden] { display: none; }
/* `rise` is the page's one entrance: a short travel with no scale, for things that
 * arrive in place (the banner, the map pills, the detail view). `unfold` is for the two
 * panels that belong to something above them. */
@keyframes rise {
  from { opacity: 0; transform: translateY(6px); }
  to   { opacity: 1; transform: none; }
}
@keyframes unfold {
  from { opacity: 0; transform: translateY(-4px) scaleY(.97); }
  to   { opacity: 1; transform: none; }
}
.results-group + .results-group { border-top: 1px solid var(--line); margin-top: 6px; padding-top: 6px; }
.results-heading {
  padding: 8px 16px 6px;
  font-size: var(--fs-2xs);
  font-weight: 600;
  letter-spacing: .08em;
  text-transform: uppercase;
  color: var(--text-2);
}
.results-row {
  display: grid;
  grid-template-columns: 1fr auto;
  align-items: center;
  gap: 12px;
  width: 100%;
  min-height: 54px;
  padding: 9px 16px;
  text-align: left;
  border-radius: 0;
  transition: background var(--dur-fast) var(--ease-out);
}
.results-row:hover { background: var(--scrim); }
.results-row:active { background: var(--surface-2); }
.results-row:focus-visible { outline-offset: -2px; }
.results-body { min-width: 0; display: grid; gap: 2px; }
.results-name {
  font-size: var(--fs-md);
  font-weight: 600;
  letter-spacing: -.006em;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.results-sub {
  min-width: 0;
  font-size: var(--fs-sm);
  color: var(--text-2);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
/* A carpark row's second line: what it is, then the price -- the same two facts in the
 * same order as a list row's third and second lines, and with the same weight. */
.results-line {
  display: flex;
  align-items: baseline;
  gap: 6px;
  min-width: 0;
}
.results-chip { flex: none; }
/* "Rates as of Nov 2018": the row's third line, quieter than the price and never dropped. */
.results-asof {
  font-size: var(--fs-xs);
  color: var(--text-2);
  white-space: nowrap;
}
.results-go { color: var(--text-2); font-size: var(--fs-lg); }
.results-note, .results-empty {
  padding: 10px 16px 12px;
  font-size: var(--fs-sm);
  color: var(--text-2);
}

/* ---------------------------------------------------------------- over the map: "Nearest here" + note */

/* A centred column under the search card. Nothing in it takes a tap unless it is shown;
 * the column itself is click-through so the map underneath still pans. */
.map-top {
  position: fixed;
  top: calc(var(--search-inset) + env(safe-area-inset-top) + var(--search-h) + 10px);
  left: 0;
  right: 0;
  z-index: 900; /* over the map and its pins, under the search card's dropdown (1100) */
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
  pointer-events: none;
}
.map-top > * { pointer-events: auto; }
/* "Nearest here": an accent pill, like the primary button, because it is the one action
 * on the map that changes the sheet. Appears when the map centre has moved > 500 m from
 * the list's anchor (viewport.js REANCHOR_M); app.js hides it again once tapped. */
.here {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  min-height: 36px;
  padding: 0 14px 0 10px;
  border-radius: var(--radius-pill);
  background: var(--accent);
  color: var(--on-accent);
  font-size: var(--fs-sm);
  font-weight: 600;
  box-shadow: var(--shadow);
  animation: rise var(--dur) var(--ease);
  transition: transform var(--dur) var(--ease), background var(--dur-fast) var(--ease-out);
}
.here[hidden] { display: none; }
.here svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.8; }
.here:hover { background: var(--accent-hover); }
.here:active { transform: scale(.96); }
/* The note: a quiet glass pill in the attribution's voice. Two strings only (viewport.js
 * NOTES): "Zoom in to see carparks" when the pins are hidden below zoom 14, "Showing the
 * 200 nearest" when the viewport held more than the cap. */
.map-note {
  margin: 0;
  padding: 5px 12px;
  border: 1px solid var(--line);
  border-radius: var(--radius-pill);
  background: var(--surface-glass);
  backdrop-filter: blur(8px);
  color: var(--text-2);
  font-size: var(--fs-xs);
  font-weight: 500;
  line-height: 18px;
  box-shadow: var(--shadow-sm);
  animation: rise var(--dur) var(--ease);
}
.map-note[hidden] { display: none; }
/* Same rule as the FABs: gone at the full snap and under the About sheet (phones). */
html[data-snap="full"] .map-top, html[data-about="open"] .map-top { opacity: 0; pointer-events: none; }

/* ---------------------------------------------------------------- recenter */

.fab {
  position: fixed;
  right: calc(16px + env(safe-area-inset-right));
  /* Above the attribution strip (26 px + 8 px margin), which spans most of a 360 px phone. */
  bottom: calc(var(--sheet-h) + 46px);
  z-index: 1000;
  width: var(--fab);
  height: var(--fab);
  border: 1px solid var(--line);
  border-radius: 50%;
  background: var(--surface);
  color: var(--accent-ink);
  box-shadow: var(--shadow);
  display: grid;
  place-items: center;
  transition: bottom var(--dur-slow) var(--ease), opacity var(--dur) var(--ease-out), transform var(--dur-fast) var(--ease-out);
}
.fab svg { width: 22px; height: 22px; }
.fab:hover { background: var(--surface-2); }
.fab:active { transform: scale(.94); }
.fab.is-locating svg { animation: spin 1.2s linear infinite; }
/* At full height the sheet's top edge meets the search card; nowhere sensible remains.
 * app.js mirrors the sheet's snap onto <html> so this can be a plain descendant rule. */
html[data-snap="full"] .fab { opacity: 0; pointer-events: none; }
@keyframes spin { to { transform: rotate(360deg); } }

/* ---------------------------------------------------------------- the filter bar */

/* WHAT THIS REPLACED, and why. The filter used to be an unlabelled hamburger stacked
 * above the recenter crosshair, bottom-right -- the furthest point on the screen from
 * the search box -- opening a popover that, holding fifteen switches, covered the entire
 * map on a 390 px phone anyway. Shut, the only sign that anything was filtered was an
 * 8 px dot on that button: it said SOMETHING was off and never WHAT.
 *
 * The bar is the same control turned inside out. It sits on the seam between the sheet
 * header ("10 nearest to you") and the list, it is labelled, and its left half is a
 * sentence naming what is hidden. The state is carried three ways -- words, a count
 * pill, and a tint -- so colour is never the only channel.
 *
 * It costs the map nothing: it is inside the sheet, which was already there.
 */
.filterbar {
  flex: none;
  padding: 0 12px 0;
}
.filterbar .rule { margin: 0 4px; color: var(--ink); }
.filterbar__btn {
  display: flex;
  align-items: center;
  gap: 8px;
  width: 100%;
  min-height: 38px;
  padding: 0 8px 0 10px;
  border-radius: var(--radius-sm);
  font-size: var(--fs-sm);
  color: var(--text-2);
  text-align: left;
  transition: background var(--dur-fast) var(--ease-out), color var(--dur-fast) var(--ease-out);
}
.filterbar__btn:hover { background: var(--surface-2); }
.filterbar__btn:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.filterbar__icon {
  flex: none;
  width: 17px;
  height: 17px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.7;
  stroke-linejoin: round;
}
/* The sentence. It is the reason this bar exists, so it takes the slack and is the only
 * thing that ellipsizes -- the label and the chevron are fixed and always legible. */
.filterbar__text {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-variant-numeric: normal;
}
.filterbar__label { flex: none; font-weight: 600; color: var(--text); }
.filterbar__chevron {
  flex: none;
  width: 15px;
  height: 15px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.8;
  stroke-linecap: round;
  stroke-linejoin: round;
  transition: transform var(--dur) var(--ease);
}
.filterbar__btn[aria-expanded="true"] .filterbar__chevron { transform: rotate(180deg); }
/* How many rows are off. Not the only carrier -- the words beside it name the first two
 * -- but the one a reader can count at a glance from arm's length. */
.filterbar__count {
  flex: none;
  min-width: 18px;
  height: 18px;
  padding: 0 5px;
  border-radius: var(--radius-pill);
  background: var(--accent);
  color: var(--on-accent);
  font-size: var(--fs-2xs);
  font-weight: 700;
  line-height: 18px;
  text-align: center;
}
.filterbar__count[hidden] { display: none; }
.filterbar.is-filtered .filterbar__btn { color: var(--accent-ink); }
.filterbar.is-filtered .filterbar__icon { fill: color-mix(in srgb, var(--accent) 22%, transparent); }
.filterbar.is-filtered .filterbar__text { font-weight: 500; }
/* The detail view is about one carpark; a filter over the list it came from is noise
 * there, and the Back button is the way out. */
.sheet[data-view="detail"] .filterbar { display: none; }

/* ---------------------------------------------------------------- legend */

/* The panel, opening inside the sheet above the rows it governs. It is a block in the
 * scroll flow rather than a fixed popover, which deletes the whole positioning problem
 * the popover had (a max-height computed from four other tokens, a right edge computed
 * from the FAB's width) and makes the desktop case free: in the 380 px left panel it is
 * simply the first thing in the panel. */
.legend {
  margin: 8px 12px 12px;
  padding: 10px 12px 10px 10px;
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  background: var(--surface-2);
  outline: none;
  animation: unfold var(--dur) var(--ease);
  transform-origin: 50% 0;
}
.legend[hidden] { display: none; }
.legend__list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: 2px;
}
.legend__item {
  display: grid;
  grid-template-columns: 28px 1fr;
  align-items: center;
  column-gap: 10px;
  min-height: 34px;
  font-size: var(--fs-sm);
  white-space: nowrap;
}
/* A sample IS the pin: the same --pin-edge drop, the same --mark-fill, at 17 x 22. The
 * legend is the map's own artwork at another size, never a second drawing of it.
 *
 * The drop is painted on ::before rather than on the element, because the element has to
 * hold a glyph or a letter and a mask clips everything inside the box it is on -- the
 * same reason .pin__face is a sibling of .pin__dot's masks.
 *
 * `padding-bottom: 5px` is what puts the glyph on the HEAD instead of the middle of the
 * box: it shortens the content box to 17 px, so centred content lands at 8.5 px, which
 * is 22 x (15/38) -- the head's centre, the same ratio map.js and .pin__face use. */
/* `isolation: isolate` is load-bearing and was missing on the first build, which is a
 * good example of why this work gets looked at rather than asserted: the drop below is a
 * ::before at `z-index: -1` (so the glyph paints on top of it), and without a stacking
 * context of its own that -1 sends it behind the nearest ancestor that HAS one -- which
 * is the panel. Every sample rendered as a bare white letter floating on the panel with
 * no drop behind it, in a card whose entire job is to show what the marks look like. */
.legend__sample {
  position: relative;
  isolation: isolate;
  justify-self: center;
  width: var(--mark-w);
  height: var(--mark-h);
  /* 23 % of the height: shortens the content box so centred content lands on the HEAD
   * rather than in the middle of a drop, which hangs below its head. Same ratio as
   * .pin__face's lift, expressed the way a grid can use it. */
  padding-bottom: 23%;
  box-sizing: border-box;
  display: grid;
  place-items: center;
  color: #fff;
  font-size: 12px;
  font-weight: 700;
  line-height: 1;
}
.legend__sample::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  background-color: var(--mark-fill, var(--nofeed));
  -webkit-mask: var(--mark-edge) no-repeat center / var(--mark-w) var(--mark-h);
  mask: var(--mark-edge) no-repeat center / var(--mark-w) var(--mark-h);
}
/* 13 px, not 11 (icon set note 5). At 11 the void deck's three heads land near 2.2 px and
 * the camera's lens closes up; the answer is a bigger sample, not simpler marks -- the
 * legend is where people learn the set, so it is the last place to show a degraded one. */
.legend__sample .pin__glyph { width: 13px; height: 13px; }
/* The two marks that are not carparks, and so are not drops. The destination is the
 * hand-drawn ring the map draws round a searched place; `you` is the pulsing disc. Both
 * override the drop's box, and neither takes a --mark-fill. */
.legend__sample--dest {
  width: 22px;
  height: 14px;
  padding: 0;
  background-color: var(--accent);
  -webkit-mask: var(--ink-ring) no-repeat center / 22px 14px;
  mask: var(--ink-ring) no-repeat center / 22px 14px;
}
.legend__sample--dest::before { content: none; }
.legend__sample--you {
  width: 12px;
  height: 12px;
  padding: 0;
  border-radius: 50%;
  background: var(--accent);
  box-shadow: 0 0 0 5px color-mix(in srgb, var(--accent) 30%, transparent);
}
.legend__sample--you::before { content: none; }
.legend__text { color: var(--text); }
/* Inside the About sheet the same list runs full width with a little more air. */
/* Inside the About sheet the same list runs full width, in its three headed groups. A
 * sixteen-row legend would not scan as one column, so the headings are what make it
 * readable -- and they are the same three words the filter card uses, so the page that
 * explains the map and the card that filters it teach each other. `align-items: start`
 * because "Private residential — a development, not a confirmed carpark" wraps to two
 * lines and its disc belongs beside the first of them. */
.about__legend .legend__item { min-height: 30px; white-space: normal; align-items: start; }
.about__legend .legend__sample { margin-top: 5px; }
.about__legend .legend__panel { gap: 14px; }

/* --- the legend as the filter (legend.renderFilterLegend) -------------------------
 * Same samples, same words, but every row is a switch with a count. Two groups of
 * switches, then the two marks that are facts rather than carparks, then Reset. */
.legend__panel { display: grid; gap: 10px; }
.legend__group { display: grid; gap: 2px; }
.legend__heading {
  margin: 0 0 2px;
  font-size: var(--fs-2xs);
  font-weight: 600;
  letter-spacing: .08em;
  text-transform: uppercase;
  color: var(--text-2);
  font-variant-numeric: normal;
}
/* The sample track was sized for a 20 px disc; a drop is narrower and taller. */
/* The row that holds a switch is a plain box: `.legend__item` above is a two-column grid
 * built for the read-only legend, and a button dropped into it lands in the 26 px sample
 * track and spills out of the card. The switch below brings its own grid. */
.legend__item--switch {
  display: block;
  grid-template-columns: none;
  min-height: 0;
  white-space: normal;
}
/* The switch fills the row, so the whole line is the target -- a 20 px swatch would be a
 * thumb's worth of miss on a phone. The grid is the read-only row's plus a count column. */
.legend__toggle {
  display: grid;
  grid-template-columns: 26px 1fr auto;
  align-items: center;
  column-gap: 10px;
  width: 100%;
  min-height: 34px;
  padding: 3px 6px 3px 0;
  margin: 0;
  border: 0;
  border-radius: var(--radius-xs);
  background: none;
  font: inherit;
  font-size: var(--fs-sm);
  line-height: 1.25;
  color: var(--text);
  text-align: left;
  /* The read-only legend keeps every row on one line; here the count column takes the
   * width that gave it, and "No live count — check on arrival" wraps rather than being
   * cut off at the card's edge. `min-width: 0` is what lets a grid cell wrap at all. */
  white-space: normal;
  cursor: pointer;
  transition: background var(--dur-fast) var(--ease-out), opacity var(--dur-fast) var(--ease-out);
}
.legend__toggle:hover { background: var(--surface); }
.legend__toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
/* Off is said three ways, because colour alone is never the carrier: the sample drains,
 * the words grey and go through, and aria-checked says it out loud. */
.legend__toggle[aria-checked="false"] { opacity: .55; }
.legend__toggle[aria-checked="false"] .legend__text { text-decoration: line-through; color: var(--text-2); }
.legend__toggle[aria-checked="false"] .legend__sample { filter: grayscale(1); }
.legend__toggle .legend__sample { font-size: 12px; align-self: start; margin-top: 1px; }
.legend__toggle .legend__sample--dest { width: 20px; height: 13px; padding: 0; -webkit-mask-size: 20px 13px; mask-size: 20px 13px; }
.legend__toggle .legend__text { min-width: 0; }
/* The count: the reason to look at the row. Tabular so the column can be read down. */
.legend__count {
  justify-self: end;
  min-width: 2.4em;
  text-align: right;
  font-size: var(--fs-xs);
  color: var(--text-2);
  font-variant-numeric: tabular-nums;
}
.legend__toggle.is-empty .legend__count { opacity: .45; }
/* Every kind now has a mark of its own (pins.js GLYPHS), so the sample cell in a kind
 * row is a real pin rather than a spacer -- which is what makes the card a legend for
 * the glyphs as well as a set of switches, and why the About sheet's list did not have
 * to grow a second block of nine rows.
 *
 * "Other" is the exception: an unclassified carpark draws a plain slate disc, because
 * the contract's `kind: null` means "not classified yet" and a glyph there would be a
 * claim. Its sample is the same plain disc -- correct, if quiet. */
.legend__sample--kind .pin__glyph { fill: currentColor; }
/* The switch rows draw the marks at the same size the read-only legend does. They used
 * to shrink; see the 13 px note above -- these marks have load-bearing detail at 2-3 px
 * and the filter card is where most people meet the set. */
.legend__list--marks .legend__item { padding-left: 2px; }
.legend__foot { display: flex; justify-content: flex-end; }
.legend__foot [hidden] { display: none; }
.legend__reset {
  padding: 6px 12px;
  border: 1px solid var(--line);
  border-radius: var(--radius-pill);
  background: var(--surface);
  font: inherit;
  font-size: var(--fs-xs);
  font-weight: 600;
  color: var(--accent-ink);
  cursor: pointer;
  transition: background var(--dur-fast) var(--ease-out);
}
.legend__reset:hover { background: var(--surface-2); }
.legend__reset:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }

/* ---------------------------------------------------------------- bottom sheet */

.sheet {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 1050;
  height: var(--sheet-h);
  display: flex;
  flex-direction: column;
  background: var(--surface);
  /* A hairline along the top edge as well as the shadow: over OneMap Grey's hatched
   * building outlines a shadow alone does not settle the sheet's edge, and the edge is
   * the one line that tells you where the map stops and the paper starts. */
  border-top: 1px solid var(--line);
  border-radius: var(--radius) var(--radius) 0 0;
  box-shadow: var(--shadow);
  padding-bottom: env(safe-area-inset-bottom);
  transition: height var(--dur-slow) var(--ease);
  /* Drag on the handle/header, not the whole sheet: the list must still scroll. */
}
html.is-dragging .sheet, html.is-dragging .fab, html.is-dragging .leaflet-bottom { transition: none; }

.sheet__handle {
  flex: none;
  width: 100%;
  height: 24px;
  display: grid;
  place-items: center;
  cursor: grab;
  touch-action: none;
  border-radius: var(--radius) var(--radius) 0 0;
}
.sheet__handle:focus-visible { outline-offset: -3px; }
.sheet__grip {
  width: 36px;
  height: 4px;
  border-radius: 2px;
  background: var(--line);
  transition: background var(--dur-fast) var(--ease-out);
}
.sheet__handle:hover .sheet__grip, html.is-dragging .sheet__grip { background: var(--text-2); }

.sheet__header {
  flex: none;
  padding: 2px 16px 12px;
  touch-action: none;
  cursor: grab;
}

.sheet__body {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
  -webkit-overflow-scrolling: touch;
}
.list[hidden], .detail[hidden] { display: none; }
.list { padding-bottom: 12px; }

/* ---------------------------------------------------------------- sheet header (detail.js) */

.cp-header__title {
  margin: 0;
  font-size: var(--fs-md);
  font-weight: 600;
  display: flex;
  align-items: center;
  gap: 8px;
  min-height: 22px;
}
/* "10 nearest to Tampines Mall (Shopping Centre)" can outrun a 360 px sheet; the lead
 * gives way with an ellipsis and the age stays whole, since the age is the part that
 * changes and the name is already in the search box above. */
.cp-header__lead {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.cp-header__sep { flex: none; color: var(--text-2); font-weight: 400; }
.cp-header__age {
  flex: none;
  font-weight: 400;
  color: var(--text-2);
  font-variant-numeric: tabular-nums;
}
/* With a named anchor the age takes the second line and the dot goes: "10 nearest to
 * Tampines Mall" over "checked 3 min ago" reads as a title and its subtitle, which is
 * what it is. The lead still ellipsizes if the place name alone outruns the sheet. */
.cp-header--stacked .cp-header__title { flex-wrap: wrap; row-gap: 0; }
.cp-header--stacked .cp-header__lead { flex: 0 1 100%; }
.cp-header--stacked .cp-header__sep { display: none; }
.cp-header--stacked .cp-header__age { flex-basis: 100%; font-size: var(--fs-sm); }
/* Stale (> 15 min): the age itself turns amber; the count stays black. */
.cp-header.is-stale .cp-header__age { color: var(--low-ink); font-weight: 500; }
/* Waiting states carry a small ring so "Finding you…" visibly is still finding. */
.cp-header--cold .cp-header__title::before,
.cp-header--locating .cp-header__title::before,
.cp-header--loading .cp-header__title::before {
  content: "";
  width: 14px;
  height: 14px;
  border-radius: 50%;
  border: 2px solid var(--line);
  border-top-color: var(--accent);
  animation: spin .9s linear infinite;
  flex: none;
}
.cp-header--cold .cp-header__title,
.cp-header--locating .cp-header__title,
.cp-header--loading .cp-header__title { color: var(--text-2); font-weight: 500; }

/* Refetch with rows already on screen (lot type switched, tab came back). The rows and
 * "checked 2 min ago" stay -- they are still true of what is shown -- and a 2 px bar
 * slides under the handle instead of swapping the age for "updating…": no words change
 * under the reader, nothing shifts, and it is visible from the detail view too. It
 * appears only after 200 ms, so a Redis-fast answer never flashes it. app.js toggles
 * .is-updating on the sheet. */
.sheet.is-updating::after {
  content: "";
  position: absolute;
  top: 24px; /* the handle's height: the bar sits on the seam between grip and content */
  left: 16px;
  right: 16px;
  height: 2px;
  border-radius: 1px;
  pointer-events: none;
  opacity: 0;
  background: linear-gradient(90deg, transparent, var(--accent), transparent) no-repeat;
  background-size: 40% 100%;
  animation: updating-in 1ms 200ms both, updating-slide 1.1s linear 200ms infinite;
}
@keyframes updating-in { to { opacity: 1; } }
/* With a 40 %-wide image, position p% offsets it by p × 60 % of the track, so -70 % and
 * 170 % are just past fully-off either end: the sweep enters and leaves cleanly. */
@keyframes updating-slide {
  from { background-position: -70% 0; }
  to   { background-position: 170% 0; }
}
.cp-header--denied .cp-header__title { font-weight: 500; }
.cp-header--error .cp-header__title { color: var(--full-ink); }
.cp-header--error {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}
.cp-retry, .banner__retry {
  flex: none;
  min-height: 32px;
  padding: 0 12px;
  border-radius: var(--radius-xs);
  background: var(--surface-2);
  font-size: var(--fs-sm);
  font-weight: 600;
}
.cp-retry:hover, .banner__retry:hover { background: var(--line); }

/* Banner: the server is unreachable but the last good pins are still on the map. */
.banner {
  flex: none;
  display: flex;
  align-items: center;
  gap: 12px;
  margin: 0 12px 10px;
  padding: 10px 12px;
  border-radius: var(--radius-sm);
  background: rgba(217, 59, 59, .10);
  color: var(--text);
  font-size: var(--fs-sm);
  font-weight: 500;
  animation: rise var(--dur) var(--ease);
}
.banner[hidden] { display: none; }
.banner__text { flex: 1; }
.banner__text::before {
  content: "";
  display: inline-block;
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--full);
  margin-right: 8px;
  vertical-align: 1px;
}
.banner--info { background: var(--surface-2); }
.banner--info .banner__text::before { background: var(--unknown); }

/* Skeleton rows: the shape of the answer while the answer is on its way -- so they are
 * the shape of the row above, three lines and a numeral on the right. */
.skel-row {
  display: grid;
  grid-template-columns: 10px 1fr 46px;
  grid-template-areas: "dot name lots" ". sub lots" ". meta .";
  column-gap: 12px;
  row-gap: 9px;
  align-items: start;
  min-height: var(--row-h);
  padding: 15px 16px;
}
.skel {
  border-radius: var(--radius-xs);
  background: linear-gradient(90deg, var(--surface-2) 25%, var(--line) 50%, var(--surface-2) 75%);
  background-size: 200% 100%;
  animation: shimmer 1.4s linear infinite;
}
.skel--dot { grid-area: dot; width: 10px; height: 10px; border-radius: 50%; margin-top: 4px; }
.skel--name { grid-area: name; height: 15px; width: 62%; }
.skel--sub { grid-area: sub; height: 12px; width: 52%; }
.skel--meta { grid-area: meta; height: 10px; width: 34%; }
.skel--lots { grid-area: lots; height: 22px; width: 42px; justify-self: end; }
.skel-row:nth-child(2) .skel--name { width: 74%; }
.skel-row:nth-child(3) .skel--name { width: 52%; }
.skel-row:nth-child(2) .skel--sub { width: 40%; }
.skel-row:nth-child(3) .skel--meta { width: 26%; }
@keyframes shimmer { to { background-position: -200% 0; } }
.list.is-dim .skel { animation-play-state: paused; opacity: .6; }

/* The first paint, while the browser is still finding you (app.js firstRunBlock). The
 * mark, one line, and nothing to press: three shimmering skeleton rows were a promise
 * about an answer nobody had asked for yet. It is replaced the instant rows arrive, and
 * only ever shows once per page -- a later wait has rows behind it and gets the
 * skeleton, which is then an honest picture of what is coming back. */
.first-run {
  display: grid;
  justify-items: center;
  text-align: center;
  gap: 16px;
  padding: 30px 24px 24px;
}
.first-run__mark {
  width: 44px;
  height: 44px;
}
/* The other place the app introduces itself, and the second (and last) home of the hand
 * face: the first paint, while the browser is still finding you. */
.first-run__word {
  margin: -8px 0 0; /* the mark and the wordmark are one lockup, not two stacked objects */
  font-family: var(--font-hand);
  font-weight: 700;
  font-size: var(--fs-xl);
  line-height: 1;
  letter-spacing: 0;
  color: var(--text);
}
.first-run__word b { font-weight: 700; color: var(--mark-sweep); }
.first-run__text {
  margin: 0;
  max-width: 24ch;
  font-size: var(--fs-md);
  color: var(--text-2);
}

/* Body states without rows (denied, empty). */
.state {
  display: grid;
  justify-items: center;
  text-align: center;
  gap: 10px;
  padding: 28px 24px 24px;
  color: var(--text-2);
  font-size: var(--fs-sm);
}
.state__icon {
  width: 40px;
  height: 40px;
  color: var(--unknown);
  stroke-width: 1.5;
}
.state__text { margin: 0; max-width: 28ch; }
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  min-height: 40px;
  padding: 0 16px;
  border-radius: var(--radius-sm);
  font-weight: 600;
  font-size: var(--fs-sm);
  color: var(--text);
  background: var(--surface-2);
  text-decoration: none;
  transition: background var(--dur-fast) var(--ease-out), transform var(--dur) var(--ease);
}
.btn:hover { background: var(--line); }
.btn:active { transform: scale(.98); }
.btn--primary { background: var(--accent); color: var(--on-accent); }
.btn--primary:hover { background: var(--accent-hover); }

/* ---------------------------------------------------------------- list rows (detail.js) */

.cp-list {
  list-style: none;
  margin: 0;
  padding: 0;
}
/* A row answers four questions in one glance, and the layout ranks them:
 *
 *     ●  Blk 149/151 Tampines Street 12          85
 *        $0.60 / 30 min · until 22:30          lots
 *        265 m · HDB surface
 *
 * The NAME is the anchor (15/600, full width, one line). The COUNT is the hero numeral
 * (24 px tabular, hard right, with a caption so a figure is never bare). The PRICE is
 * the second line at reading weight. Distance and kind share a quiet third line, where
 * distance leads so the metres form a scannable column down the left of it.
 *
 * What this replaced: four type sizes competing on two lines, with a tinted "HDB
 * multi-storey" chip -- the loudest object in a row whose subject is a number -- sitting
 * between the name and the price. */
.cp-row__btn {
  display: grid;
  /* col 2 is sized by the distance, so the metres form a true column down the list;
   * col 3 takes the slack; col 4 is the count and its caption. */
  grid-template-columns: 10px auto minmax(0, 1fr) auto;
  grid-template-areas:
    "dot name name lots"
    ".   sub  sub  unit"
    ".   dist kind unit"
    ".   asof asof asof";
  column-gap: 12px;
  row-gap: 2px;
  align-items: start;
  width: 100%;
  min-height: var(--row-h);
  padding: 13px 16px 14px;
  text-align: left;
  border-radius: 0;
  transition: background var(--dur-fast) var(--ease-out);
}
.cp-row__btn:hover { background: var(--scrim); }
.cp-row__btn:active, .cp-row.is-selected .cp-row__btn { background: var(--surface-2); }
.cp-row.is-selected .cp-row__btn { box-shadow: inset 3px 0 0 var(--accent); }
.cp-row__btn:focus-visible { outline-offset: -2px; }
.cp-row + .cp-row .cp-row__btn { border-top: 1px solid var(--line); }

/* The same drop as the pin, at 11 x 14: the row and the map show one carpark as one
 * shape, not a circle in one place and a drop in the other. It is drawn from the same
 * --pin-edge path and painted from the same --mark-fill, so neither can drift.
 *
 * At 11 px this is still a SHAPE rather than a line -- which is the large/small rule
 * holding: a solid silhouette shrinks honestly, a 1 px hand-drawn outline at 11 px would
 * read as a rendering fault. */
.cp-dot {
  grid-area: dot;
  width: var(--mark-w);
  height: var(--mark-h);
  margin-top: 5px; /* optical centre of the 24 px name line, for a shape taller than wide */
  background-color: var(--mark-fill, var(--unknown));
  -webkit-mask: var(--mark-edge) no-repeat center / var(--mark-w) var(--mark-h);
  mask: var(--mark-edge) no-repeat center / var(--mark-w) var(--mark-h);
}

.cp-row__name {
  grid-area: name;
  align-self: baseline; /* shares a baseline with the count beside it */
  font-size: var(--fs-md);
  font-weight: 600;
  line-height: 24px;
  letter-spacing: -.008em;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
/* The count and its caption are one object in two grid cells -- they cannot be one
 * element, because the numeral carries the aria-label ("352 lots") and the caption must
 * not be read a second time. */
.cp-row__lots {
  grid-area: lots;
  justify-self: end;
  align-self: baseline;
  min-width: 2.4ch;
  text-align: right;
  font-size: var(--fs-num);
  font-weight: 600;
  line-height: 24px;
  letter-spacing: var(--tracking-tight);
}
/* Green is not spent here: nine rows out of ten are "ok" and a column of green numerals
 * would be decoration. The dot carries the scale; the numeral turns colour only when
 * the reading is the exception -- amber under 20 free, red under 5 -- and greys out when
 * there is no reading at all. */
.cp-row__lots--low { color: var(--low-ink); }
.cp-row__lots--full { color: var(--full-ink); }
.cp-row__lots--nofeed, .cp-row__lots--season {
  color: var(--text-2);
  font-weight: 400;
  font-size: var(--fs-lg); /* "—" set at 24 px reads as a redaction bar */
}
.cp-row__unit {
  grid-area: unit;
  justify-self: end;
  align-self: start;
  margin-top: 1px;
  font-size: var(--fs-2xs);
  font-weight: 500;
  line-height: 16px;
  letter-spacing: .03em;
  color: var(--text-2);
  white-space: nowrap;
}
.cp-row__sub {
  grid-area: sub;
  font-size: var(--fs-sm);
  line-height: 18px;
  color: var(--text);
  /* Two lines, not one with an ellipsis: "$0.60 / 30 min · until 22:30 · 7am–10.30pm
   * only" is the whole answer, and the hours suffix is the part a single line cut off.
   * Rows grow past --row-h when they need to; the min height still holds the rhythm. */
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  white-space: normal;
  overflow: hidden;
}
/* The third line: distance first (a column of metres the eye can run down), then what
 * the carpark is. Both quiet -- these are the facts you check after you have decided
 * which row you are reading. */
.cp-row__dist {
  grid-area: dist;
  font-size: var(--fs-xs);
  line-height: 16px;
  color: var(--text-2);
  white-space: nowrap;
}
/* Still a `.cp-chip` in the DOM (the detail head and the dropdown share the element and
 * its data-kind tints), but in a row it is a word, not a badge. */
.cp-chip.cp-row__kind {
  grid-area: kind;
  justify-self: start;
  align-self: start;
  padding: 0;
  background: none;
  color: var(--text-2);
  font-size: var(--fs-xs);
  font-weight: 400;
  line-height: 16px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  max-width: 100%;
}
/* No separator between the two: the distance column is sized by the widest distance in
 * the list, so every kind starts at the same x and the third line reads as two aligned
 * columns. A middot there would be a mark doing what the alignment already does. */
/* "Rates as of Nov 2018": a fourth line, only when the wire carries the month (today
 * format.SHOW_RATES_AS_OF is off, so never). */
.cp-row__asof {
  grid-area: asof;
  font-size: var(--fs-xs);
  color: var(--text-2);
}

/* ---------------------------------------------------------------- detail view (detail.js) */

/* One card, not a stack of boxes. Every section is separated by a hairline and a
 * consistent 16 px of air; nothing has a fill of its own except the current tariff and
 * the Directions button. The reading order is: what this is (name, kind, distance) ·
 * how many lots and how fresh · WHAT IT COSTS (the hero) · today's tariffs · which
 * vehicles · go. */
.cp-detail {
  padding: 0 16px calc(24px + env(safe-area-inset-bottom));
  animation: rise var(--dur) var(--ease);
}
.cp-detail__head { display: grid; gap: 4px; padding-bottom: 12px; }
.cp-back {
  justify-self: start;
  display: inline-flex;
  align-items: center;
  gap: 6px;
  min-height: 36px;
  margin: 0 0 4px -8px;
  padding: 0 10px 0 8px;
  border-radius: var(--radius-xs);
  color: var(--accent-ink);
  font-weight: 600;
  font-size: var(--fs-sm);
  transition: background var(--dur-fast) var(--ease-out);
}
.cp-back:hover { background: var(--surface-2); }
.cp-back__icon { font-size: var(--fs-lg); line-height: 1; }
.cp-detail__name {
  margin: 0;
  font-size: var(--fs-xl);
  font-weight: 600;
  line-height: 1.18;
  letter-spacing: var(--tracking-tight);
}
/* "HDB multi-storey · 443 m" -- one quiet line, the same two facts in the same order as
 * a list row's third line, so the row and the detail read as one document. */
.cp-detail__where {
  margin: 0;
  font-size: var(--fs-sm);
  line-height: 18px;
  color: var(--text-2);
}
.cp-detail__where > * + *::before {
  content: "·";
  margin: 0 7px;
  opacity: .55;
}
.cp-detail__area, .cp-detail__dist { white-space: nowrap; }
/* The private-residential caveat, under the chip and above every number on the page.
 * Bordered rather than tinted so it reads as an aside and not as a warning: the carpark
 * may well be open, we simply have no source that says so. Brown, because that is the
 * colour of the pin the reader just tapped. */
.cp-detail__caveat {
  margin: 8px 0 0;
  padding-left: 10px;
  border-left: 3px solid var(--resi);
  font-size: var(--fs-sm);
  line-height: var(--lh);
  color: var(--text-2);
}

.cp-chip, .results-chip {
  display: inline-block;
  padding: 1px 6px;
  border-radius: var(--radius-xs);
  background: var(--surface-2);
  color: var(--text-2);
  font-size: 11px;
  font-weight: 600;
  letter-spacing: .04em;
  text-transform: uppercase;
  line-height: 16px;
  vertical-align: middle;
}
/* What the carpark *is*. It used to be a tinted badge -- HDB blue, public green -- which
 * made the least important fact in the row the most visible object in it, and turned a
 * list of ten into a colour chart of agencies nobody asked about. It is now a plain
 * muted word wherever it appears beside a name, in the same voice as the distance it
 * sits next to. `data-kind` stays on the element: it is the wire value, and it is what a
 * future treatment would key off. Chips proper survive only for `--type` (the lot-type
 * counts), which really are little data objects. */
.cp-chip--kind, .results-chip {
  padding: 0;
  background: none;
  color: var(--text-2);
  text-transform: none;
  letter-spacing: 0;
  font-size: var(--fs-xs);
  font-weight: 400;
  line-height: inherit;
  white-space: nowrap;
}
.cp-chip--type {
  text-transform: none;
  letter-spacing: 0;
  font-size: var(--fs-sm);
  font-weight: 500;
  padding: 4px 10px;
  border-radius: var(--radius-xs);
  color: var(--text);
  font-variant-numeric: tabular-nums;
}

/* Lots and freshness: one line under the name, above the first hairline. The 34 px hero
 * number this replaced was the right call while the detail had no other focus; now the
 * price is the focus (the reader has already chosen this carpark -- what is left to
 * learn is the cost), and the count reads as a fact about it. */
/* The two seams in the detail view that divide one KIND of fact from another -- what we
 * counted, what it costs, and how to get there -- are hand-ruled. The hairlines BETWEEN
 * tariff bands and between list rows are not: a repeated wobble is a rash, and the whole
 * treatment lives or dies on that distinction. */
.cp-lots {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 2px 10px;
  padding: 2px 0 17px;
  position: relative;
}
.cp-lots::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 6px;
  background-color: var(--ink);
  -webkit-mask: var(--ink-rule) repeat-x left center / 216px 6px;
  mask: var(--ink-rule) repeat-x left center / 216px 6px;
}
.cp-lots__number {
  font-size: var(--fs-xl);
  font-weight: 600;
  line-height: 24px;
  letter-spacing: var(--tracking-tight);
  min-width: 1.6ch;
}
.cp-lots--ok { color: var(--ok-ink); }
.cp-lots--low { color: var(--low-ink); }
.cp-lots--full { color: var(--full-ink); }
.cp-lots--nofeed { color: var(--text-2); }
.cp-lots--season { color: var(--season-ink); }
.cp-lots__status { margin: 0; font-size: var(--fs-sm); font-weight: 500; }
/* Ends the line, hard right: the age belongs to the count, and putting it anywhere else
 * made it look like a fact about the carpark rather than about our reading of it. */
.cp-lots__checked { margin: 0 0 0 auto; font-size: var(--fs-sm); color: var(--text-2); }
.cp-lots__checked.is-stale { color: var(--low-ink); font-weight: 500; }
/* The lots_status sentence when "30 lots" took the status line: its own line, quieter. */
.cp-lots__note { flex-basis: 100%; margin: 2px 0 0; font-size: var(--fs-sm); color: var(--text-2); }

.cp-rates { display: grid; gap: 12px; padding: 18px 0; position: relative; }
.cp-rates::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 6px;
  background-color: var(--ink);
  -webkit-mask: var(--ink-rule) repeat-x left center / 216px 6px;
  mask: var(--ink-rule) repeat-x left center / 216px 6px;
}
/* "No short-term parking": the one thing on the page, so it keeps a fill. */
.cp-note {
  margin: 0;
  padding: 12px 14px;
  border-radius: var(--radius-sm);
  background: var(--surface-2);
  box-shadow: inset 3px 0 0 var(--season);
  font-weight: 600;
}
.cp-note__sub { margin: -6px 0 0; font-size: var(--fs-sm); color: var(--text-2); padding: 0 14px; }
/* The hero: eyebrow, then the price at 30 px with the window it applies to on its
 * baseline. No border, no fill -- size and space are what make it the headline. */
/* The price is the hero, so it gets the whole line. Side-by-side with the window
 * ("$0.60 / 30 min"  "until 07:00 tomorrow") the two only fit on a wide phone; at
 * 360 px the hero wrapped mid-phrase to "$0.60 / 30 / min", which is worse than
 * either. Stacking is unconditional rather than a media query: the longest price
 * strings ("$0.60 / 30 min after 1 h") wrap at every phone width. */
.cp-rate-now {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  grid-template-areas: "label" "price" "applies";
  row-gap: 2px;
}
.cp-rate-now__label, .cp-rates__title, .cp-types__label {
  margin: 0;
  font-size: var(--fs-2xs);
  font-weight: 600;
  letter-spacing: .08em;
  text-transform: uppercase;
  color: var(--text-2);
}
/* The one eyebrow in the app that is gone over twice. It labels the price, which is the
 * single fact this app exists to deliver; the LABEL itself stays crisp 11 px uppercase
 * Inter and only the line under it is drawn, which is the large/small split the whole
 * treatment turns on. */
.cp-rate-now__label {
  grid-area: label;
  position: relative;
  margin-bottom: 10px;
  padding-bottom: 9px;
}
.cp-rate-now__label::after {
  content: "";
  position: absolute;
  left: 0;
  bottom: 0;
  width: 100%;
  max-width: 88px;
  height: 7px;
  background-color: var(--accent-ink);
  opacity: .8;
  -webkit-mask: var(--ink-underline) no-repeat left bottom / 100% 7px;
  mask: var(--ink-underline) no-repeat left bottom / 100% 7px;
}
.cp-rate-now__price {
  grid-area: price;
  margin: 0;
  font-size: var(--fs-hero);
  font-weight: 600;
  line-height: 34px;
  letter-spacing: var(--tracking-tight);
  /* A price is one phrase; never break "30 min" across lines. */
  text-wrap: balance;
  overflow-wrap: normal;
}
.cp-rate-now__price.is-unavailable {
  font-size: var(--fs-md);
  line-height: 24px;
  font-weight: 500;
  color: var(--text-2);
}
.cp-rate-now__applies {
  grid-area: applies;
  margin: 0;
  font-size: var(--fs-sm);
  color: var(--text-2);
}
/* "Rates as of Nov 2018", straight under the price block: muted, never hidden. */
.cp-rates__asof { margin: -8px 0 0; font-size: var(--fs-xs); color: var(--text-2); }
.cp-rates__title { margin: 6px 0 -6px; }
/* Today: a clean list, not a boxed table. Hairlines between tariffs, no outer border,
 * and the one that is running now gets the accent bar and the faintest wash of it. */
.cp-bands {
  list-style: none;
  margin: 0;
  padding: 0;
}
/* A row is a tariff: price left, window right, and under them -- indented, quieter --
 * the cap that limits it. Caps used to be rows of their own and read as charges. */
.cp-band {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  grid-template-areas: "label applies" "cap cap";
  column-gap: 12px;
  padding: 10px 2px 10px 12px;
  font-size: var(--fs-sm);
  border-radius: var(--radius-xs);
}
.cp-band + .cp-band { border-top: 1px solid var(--line); border-radius: 0 0 var(--radius-xs) var(--radius-xs); }
/* THE ONE PIECE OF INK DOING INFORMATIONAL WORK. The tariff that is running right now
 * gets a highlighter swipe, not a tinted box with a bar down its left edge: a swipe is
 * what a person does to the line on the timetable that applies to them, and it is the
 * one object on this screen whose meaning IS its drawnness.
 *
 * `position: relative` on the children keeps them above the mask; the grid areas are
 * unaffected by it. The swipe is `--accent` at 16 %, which leaves the 13 px text at its
 * full --text contrast rather than sitting it on a tint. */
.cp-band.is-current {
  position: relative;
  font-weight: 600;
}
.cp-band.is-current > * { position: relative; }
.cp-band.is-current::before {
  content: "";
  position: absolute;
  inset: 2px -2px;
  background-color: var(--ink-swipe-fill);
  -webkit-mask: var(--ink-swipe) no-repeat center / 100% 100%;
  mask: var(--ink-swipe) no-repeat center / 100% 100%;
  pointer-events: none;
}
.cp-band.is-current + .cp-band { border-top-color: transparent; }
.cp-band__label { grid-area: label; }
.cp-band__applies { grid-area: applies; color: var(--text-2); font-weight: 400; white-space: nowrap; }
.cp-band__cap {
  grid-area: cap;
  margin-top: 2px;
  font-size: var(--fs-xs);
  font-weight: 400;
  color: var(--text-2);
}
/* A cap that belongs to no row: one sentence under the list, in the same voice as the
 * hours line below it. */
.cp-bands__note, .cp-hours {
  margin: 0;
  font-size: var(--fs-sm);
  color: var(--text-2);
  padding-left: 12px;
}
.cp-bands__note { margin-top: -6px; }

.cp-types { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding: 16px 0 20px; }
.cp-types__label { width: 100%; margin-bottom: 2px; }

.cp-btn {
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 48px;
  padding: 0 16px;
  border-radius: var(--radius-sm);
  background: var(--accent);
  color: var(--on-accent);
  font-weight: 600;
  letter-spacing: -.006em;
  text-decoration: none;
  box-shadow: 0 1px 2px color-mix(in srgb, var(--accent) 40%, transparent);
  transition: background var(--dur-fast) var(--ease-out), transform var(--dur) var(--ease);
}
/* Nothing precedes it when the rates section is the last thing above (it has its own
 * rule); a lot-type row does the spacing instead. */
.cp-rates + .cp-btn { margin-top: 20px; }
.cp-btn:hover { background: var(--accent-hover); }
.cp-btn:active { transform: scale(.98); }
/* "Source: HDB" -- the last line, for the reader who wants provenance. */
.cp-detail__source {
  margin: 14px 0 0;
  text-align: center;
  font-size: var(--fs-xs);
  color: var(--text-2);
}

/* ---------------------------------------------------------------- about sheet (about.js) */

/* On a phone the About sheet is the whole screen: the map, search card and bottom sheet
 * are behind it. It slides up like the results dropdown and scrolls on its own. */
.about {
  position: fixed;
  inset: 0;
  z-index: 1200;
  background: var(--surface);
  overflow-y: auto;
  overscroll-behavior: contain;
  -webkit-overflow-scrolling: touch;
  padding: calc(8px + env(safe-area-inset-top)) 0 calc(24px + env(safe-area-inset-bottom));
  animation: about-in var(--dur-slow) var(--ease);
}
.about[hidden] { display: none; }
@keyframes about-in {
  from { opacity: 0; transform: translateY(20px); }
  to   { opacity: 1; transform: none; }
}
/* While it is open the map controls under it must not take a stray tap or a Tab stop. */
html[data-about="open"] .fab { opacity: 0; pointer-events: none; }
.about__panel { padding: 0 16px; }

/* THE MASTHEAD. The About sheet is the one screen in this app that is a document rather
 * than a glance, so it is the one screen that can carry a banner -- and the banner is
 * where the hand face is allowed, because a wordmark is the largest single word on the
 * page and the only text here that is not data.
 *
 * The rule the design exploration arrived at and this keeps: THE MASTHEAD IS ALWAYS THE
 * INVERSE OF THE GROUND. Ink band on the paper scheme, paper band on the ink scheme. A
 * black bar on a black page is not a masthead, it is a gap, and the wordmark floats in
 * it.
 *
 * Full bleed: the negative margins undo `.about__panel`'s 16 px and `.about`'s top
 * padding, including the safe-area inset, so the band runs into the notch the way a
 * printed masthead runs to the trim. */
.about__head {
  display: grid;
  justify-items: start;
  gap: 6px;
  margin: calc(-8px - env(safe-area-inset-top)) -16px 22px;
  padding: calc(14px + env(safe-area-inset-top)) 16px 16px;
  background: var(--band-bg);
  color: var(--band-fg);
  /* The mark is drawn with --mark-*, which are tuned for the SCHEME's ground; the band
   * is the scheme's ground inverted, so the mark's pair inverts with it. Redeclared here
   * rather than in brand.js, so the mark stays one piece of artwork that belongs to
   * whatever surface it lands on. */
  --mark-pin: var(--band-pin);
  --mark-sweep: var(--band-sweep);
}
.about__head .cp-back { color: var(--band-fg); opacity: .78; margin-bottom: 2px; }
.about__head .cp-back:hover { background: rgba(255, 255, 255, .12); opacity: 1; }
/* The heading IS the lockup (brand.js): a sheet that explains the map opens by saying
 * whose map it is, and that costs one line instead of two. */
.about__title {
  margin: 0;
  font-size: 27px;
  line-height: 1.05;
}
/* The one string in the app set in the hand. Shantell Sans runs small and tight for its
 * point size, hence the 27 px above against the old 24, and it is set at 700 in both
 * halves -- the face is only loaded at 700, and "Glance" is already told apart by
 * colour. Tracking goes back to normal: a hand face has its own spacing and Inter's
 * -.02em display tracking collapses it. */
.about__title .brand__word { font-family: var(--font-hand); font-weight: 700; letter-spacing: 0; }
/* The hand face's cap height is well under Inter's at the same point size, so the mark
 * has to grow to stay the wordmark's equal rather than a bullet in front of it. */
.about__title .brand__mark { width: 1.3em; height: 1.3em; margin-bottom: .08em; }
.about__title .brand__word-a { color: var(--band-fg); }
.about__title .brand__word-b { color: var(--mark-sweep); }
.about__tagline {
  margin: 0;
  font-size: var(--fs-sm);
  color: var(--band-fg);
  opacity: .8;
}
.about__body { display: grid; gap: 24px; }
.about__section { display: grid; gap: 10px; }
/* The three section heads. They and the legend's group heads under them were the same
 * 11 px uppercase, so the page read as one flat list of eyebrows; a hand rule under the
 * section head is what separates the two levels -- and it is the About sheet's share of
 * the ink budget, alongside the masthead. */
.about__heading {
  position: relative;
  margin: 0;
  padding-bottom: 11px;
  font-size: var(--fs-2xs);
  font-weight: 600;
  letter-spacing: .08em;
  text-transform: uppercase;
  color: var(--text);
}
.about__heading::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 6px;
  background-color: var(--ink);
  -webkit-mask: var(--ink-rule) repeat-x left center / 216px 6px;
  mask: var(--ink-rule) repeat-x left center / 216px 6px;
}
.about__p, .about__stat, .about__total, .about__attribution { margin: 0; font-size: var(--fs-sm); color: var(--text-2); }
.about__total { font-size: var(--fs-lg); font-weight: 600; color: var(--text); }
.about__count { font-variant-numeric: tabular-nums; }
.about__stat .about__count { font-weight: 600; color: var(--text); }
/* Counts by kind: name · number rows in a bordered block like the bands. One column on a
 * phone ("HDB multi-storey 1,130" does not fit half of 360 px), two from 480 px. */
.about__kinds {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  grid-template-columns: 1fr;
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  overflow: hidden;
}
.about__kind {
  display: flex;
  justify-content: space-between;
  gap: 8px;
  padding: 7px 12px;
  font-size: var(--fs-sm);
  border-top: 1px solid var(--line);
  white-space: nowrap;
}
.about__kind:first-child { border-top: 0; }
@media (min-width: 480px) {
  .about__kinds { grid-template-columns: 1fr 1fr; }
  .about__kind:nth-child(2) { border-top: 0; }
  .about__kind:nth-child(odd) { border-right: 1px solid var(--line); }
}
.about__kind-name { color: var(--text-2); }
.about__kind-count { font-weight: 600; }
.about__list {
  margin: 0;
  padding-left: 18px;
  display: grid;
  gap: 8px;
  font-size: var(--fs-sm);
  color: var(--text-2);
  line-height: 1.45;
}
.about__list strong { color: var(--text); font-weight: 600; }
.about__list code {
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 12px;
  padding: 0 4px;
  border-radius: 6px;
  background: var(--surface-2);
}
.about__list a, .about__attribution a { color: var(--accent-ink); text-decoration: none; }
.about__list a:hover, .about__attribution a:hover { text-decoration: underline; }
/* The private-residential caveat: not a bullet (it is about something that IS on the
 * map), so it sits under the list with a rule of its own to separate the two claims. */
.about__note {
  margin: 0;
  padding-top: 12px;
  border-top: 1px solid var(--line);
  font-size: var(--fs-sm);
  color: var(--text-2);
  line-height: 1.45;
}
.about__attribution { padding-top: 4px; border-top: 1px solid var(--line); }

/* Detail-view loading placeholder (app.js) */
.detail-skel { padding: 12px 16px; display: grid; gap: 12px; }
.detail-skel .skel { height: 14px; }
.detail-skel .skel--title { height: 24px; width: 70%; }
.detail-skel .skel--block { height: 64px; border-radius: var(--radius-sm); width: 100%; }
.detail-skel .skel--line { width: 50%; }

/* ---------------------------------------------------------------- desktop: left panel */

@media (min-width: 900px) {
  :root { --sheet-h: 0px; }

  .map { left: var(--panel-w); }

  .sheet {
    top: 0;
    right: auto;
    width: var(--panel-w);
    height: auto;
    border-radius: 0;
    box-shadow: 1px 0 0 var(--line), var(--shadow);
    /* Room for the search card, which floats over the panel's top on desktop. */
    padding-top: calc(var(--search-h) + 32px);
  }
  .sheet__handle { display: none; }
  .sheet__header, .sheet__header * { cursor: default; touch-action: auto; }
  /* No handle here; the bar sits on the seam under the search card instead. */
  .sheet.is-updating::after { top: calc(var(--search-h) + 30px); }

  .search {
    left: 16px;
    right: auto;
    width: calc(var(--panel-w) - 32px);
    top: 16px;
  }
  .search__card { box-shadow: none; border: 1px solid var(--line); background: var(--surface-2); }
  .search__card:focus-within { background: var(--surface); box-shadow: 0 0 0 2px var(--accent); border-color: transparent; }

  .fab { right: 24px; bottom: 24px; }
  .leaflet-bottom { bottom: 0; }
  .leaflet-container .leaflet-control-attribution { margin: 0 0 12px 12px; }

  /* The search card is in the panel here, so the strip centres over the map itself. */
  .map-top { top: 16px; left: var(--panel-w); }
  html[data-about="open"] .map-top { opacity: 1; pointer-events: none; }
  html[data-about="open"] .map-top > * { pointer-events: auto; }

  /* The About sheet takes the left panel's place; the map and its controls stay. */
  .about {
    inset: 0 auto 0 0;
    width: var(--panel-w);
    box-shadow: 1px 0 0 var(--line), var(--shadow);
    padding-top: 0;
  }
  .about__head { margin-top: 0; padding-top: 16px; }
  html[data-about="open"] .fab { opacity: 1; pointer-events: auto; }
  html[data-about="open"] .search { visibility: hidden; }
}

/* ---------------------------------------------------------------- utilities */

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

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: .01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: .01ms !important;
  }
  /* Keep the halo as a still ring so "you are here" is not lost with the pulse. */
  .you__dot::before { animation: none; transform: scale(.9); opacity: .3; }
  .cp-header--cold .cp-header__title::before,
  .cp-header--locating .cp-header__title::before,
  .cp-header--loading .cp-header__title::before {
    animation: none;
    border-color: var(--accent);
    border-top-color: var(--line);
  }
  /* A still accent line says "busy" without the sweep. */
  .sheet.is-updating::after { animation: none; opacity: .5; background: var(--accent); }
}
