@import "tailwindcss"; @config "../../tailwind.config.mts"; /* A finger is driving. The same query as `hooks/useCoarsePointer.ts`, so a device that gets the OS date wheels also gets the finger-sized controls. See "On touch, every control is 44px to a finger" in DECISIONS.md. */ @custom-variant touch (@media (hover: none) and (pointer: coarse)); /* A 44px hit area centred on a control smaller than that, drawn by `::after` so the control keeps its look. The element must be positioned, since the pseudo-element is placed against it. Used as `touch:tap-target`. */ @utility tap-target { &::after { content: ""; position: absolute; inset-block: min(0px, calc((100% - 2.75rem) / 2)); inset-inline: min(0px, calc((100% - 2.75rem) / 2)); } } /* Lifts text, or an icon, off whatever a backdrop shows beneath it, with a glow in `--backdrop-fade`: the colour a hero's map fades to, and a card's glass is tinted. The icon's is a filter because `text-shadow` stops at an SVG, and keeps to two close layers: more of them, or wider ones, drew each layer's edge as a visible ring - `drop-shadow`s chain, each blurring the last one's 8-bit output. The text's `text-shadow` layers do not chain. */ @utility glow-text { text-shadow: 0 0 1px var(--backdrop-fade), 0 0 8px var(--backdrop-fade); } @utility glow-icon { filter: drop-shadow(0 0 2px var(--backdrop-fade)) drop-shadow(0 0 5px var(--backdrop-fade)); } @layer base { :root { --background: 0 0% 100%; --foreground: 222.2 84% 4.9%; --card: 0 0% 100%; --card-foreground: 222.2 84% 4.9%; --popover: 0 0% 100%; --popover-foreground: 222.2 84% 4.9%; /* A deliberately dark overlay surface, for things that float *above* a card and have to read as detached from it - currently the air-consumption chart's hover card. Not --popover, which is the same color as --card in both themes (white here, 13% lightness under .dark) and so leans entirely on its 1px border to separate from whatever it's sitting on. Over a chart that's not enough: the card overlaps its own data points, and a surface that matches the panel underneath reads as part of it. Dark in *both* themes rather than inverting per theme - a near-black chip is the conventional chart tooltip, and it keeps the same weight against a white card as it does against a dark one. */ --tooltip: 222 47% 11%; --tooltip-foreground: 210 40% 98%; --primary: 240 2% 15%; --primary-foreground: 0 0% 100%; --secondary: 210 40% 96%; --secondary-foreground: 222.2 84% 4.9%; --muted: 210 40% 96%; /* Darkened from shadcn's default 46.9%. At that lightness it is 4.65:1 on --background (barely AA) and only 4.34:1 on --muted, so every muted line on a tinted surface - the footer, a card's description - failed. 40% clears AA on both (6.0:1 and 5.4:1) and is still visibly secondary to --foreground. */ --muted-foreground: 215 16% 40%; --accent: 210 40% 96%; --accent-foreground: 222.2 84% 4.9%; /* Errors and destructive actions are coral, not red. Hue 10 sits six degrees off the brand --coral's 16, in the same warm family as every other accent here; shadcn's default saturated hue-0 red would be the one colour in the app belonging to no other token, and against a palette built on coral and teal it reads as imported rather than chosen. Also darker than shadcn's red - 42% against 60.2% - and that is not a side effect of the hue. `text-destructive` is real body text in `FormMessage`, and 60.2% is 3.76:1 on --background: an AA failure on every validation message in the app. Hue 10 at 42% is 5.5:1 there, and 4.6:1 over its own /10 tint, which is the surface `StatusMessage` and the delete-account panel put it on. */ --destructive: 10 88% 42%; --destructive-foreground: 210 40% 98%; /* Same coral, darkened until the label on it clears WCAG AA - 5.6:1 under the light theme's near-white --destructive-foreground, 5.9:1 under the dark theme's pure white. --destructive is dark enough in *this* theme to carry white itself; the separate token is for the dark theme, where --destructive has to be light enough to read as text on a near-black background and so cannot sit under a white label. It is the palette's only per-role split: the brand accents are a single hue each, and the destructive pair is the exception because here the colour carries meaning rather than brand. --destructive for text, icons and tints; --destructive-solid for anything filled. */ --destructive-solid: 10 88% 40%; /* The counterpart to --destructive, for a confirmation that has to persist in place rather than in a toast (see `StatusMessage`). A token rather than a green-600/green-400 pair per component, so the pair is tuned once and stays consistent with --destructive's weight. Darker than the obvious green so it clears AA against --background as body text. */ --success: 142 72% 29%; --success-foreground: 0 0% 100%; /* Between --success and --destructive in weight: something to take seriously that is not a failure: caution text, the course status badge, and the terms page's safety notice, which is deliberately *not* one of the `bg-muted` informational callouts. A dive log telling people it is not a source of safety advice should not look like a footnote. */ --warning: 32 92% 27%; --warning-foreground: 0 0% 100%; --border: 214.3 31.8% 91.4%; --input: 214.3 31.8% 91.4%; --ring: 240 2% 15%; /* The two brand accents, and each is exactly its CSS named colour: --coral is `coral` (#FF7F50) and --teal is `teal` (#008080). That is the whole definition, deliberately - one hue per accent, not per-role variants such as --coral-solid, --coral-text and --teal-solid, each a contrast-tuned darkening for one job (a filled button, text on a light background), which leave every call site picking between coral-ish classes. Anything coral is `coral`; anything teal is `teal`. Both are constant across light/dark - not redeclared under .dark - so a brand colour reads the same in both themes. 65.7%, not 66%: the rounded value renders #FF8052, two units off coral on two channels. Imperceptible on its own and not imperceptible next to `icon.svg`, which hard-codes #FF7F50 for the favicon. 180 100% 25% needs no such correction; it lands on #008080 exactly. The cost is real and accepted. It falls differently on each of the three tuned variants, so the call sites are worth naming rather than summarising - DECISIONS.md carries the same three lists, and they are meant to stay identical: --coral-text (6.1:1 on white -> 2.5:1, light theme only; the dark theme loses nothing, since --coral-text would resolve to the brand coral there): the landing hero's accent word, the header's nav items active in both the desktop and the mobile menu and on hover in the desktop one, the Home page stat cell's label on group-hover, and the species card's hover border. --coral-solid (5.1:1 under white -> 2.5:1, in *both* themes - neither token is redeclared under .dark, so fill and white label are theme-constant and the pairing never involves --background): the sign-in button in the header, and AuthForm's submit button. This is where the single hue costs the dark theme, and it is easy to reason about backwards from --coral-text's dark behaviour, which does not apply here. --teal-solid (5.8:1 under white -> 4.8:1, both themes, still clearing AA): Button's `default` variant, so every filled button in the app, and the calendar's selected day. The exception that keeps its tuned pair is the one where colour carries meaning rather than brand: errors and destructive actions, --destructive / --destructive-solid above. */ --coral: 16 100% 65.7%; --teal: 180 100% 25%; /* Labels for anything *filled* with a brand accent, which the gear service badge is the first thing to do - everywhere else coral and teal are strokes or glyphs. Both are pure white, which puts the three service chips on one label colour in the dark theme and within a shade of it in the light one, where --destructive-foreground is the near-white 210 40% 98% rather than #FFF. Close enough to read as one treatment; not close enough to call them the same value. Neither is a second coral or a second teal, and so neither is what the collapse above removed: they are the colour that sits *on* the accent, the pairing --destructive, --success, --warning and every other filled token here already have. Constant across themes because the accents are - a per-theme pair would have nothing to adapt to. --coral-foreground is where this palette pays for the uniformity, and the cost is on the record rather than hidden: white on #FF7F50 is 2.50:1, under the 4.5:1 AA asks of the label, where a near-black would have been 7.23:1. Teal is the dark half of the pair and has no such problem - 4.77:1 under white, and only 3.8:1 under the near-black, so it wanted white anyway. See "The service scale is three brand fills, and `--warning`'s amber would be a fourth accent" in DECISIONS.md. */ --coral-foreground: 0 0% 100%; --teal-foreground: 0 0% 100%; /* Third chart accent, for the dive profile's tank-pressure curve. A neutral grey beside depth's teal and temperature's coral: the gauge reading is the supporting line on that plot, and grey keeps it there while staying separable from both by hue. Declared here and deliberately *not* redeclared under .dark, the same property that makes coral and teal hold their contrast in both themes: 52% lightness is 3.9:1 against a white card and 4.2:1 against the dark theme's 13% one, and still 3.3:1 under a gas-switch marker's resting 0.9 opacity on white. A per-theme pair would have to be tuned twice and would drift. */ --pressure: 220 6% 52%; /* Fourth chart accent, for the dive profile's deco ceiling. Red, which is what every dive computer and every other dive log uses for the ceiling, and the one mark on this chart that is a *limit* rather than a measurement - the region it bounds is water the diver may not enter. Declared once and deliberately not redeclared under .dark, the same call --pressure, --teal and --coral make. 55% lightness is 4.3:1 against the light card and 3.8:1 against the dark theme's 13% one, so it clears the 3:1 WCAG asks of a graphical object in both - which is the property a per-theme pair would have to be re-tuned twice to keep, and would drift. That is also why it isn't --destructive, which is a light/dark pair tuned for text and for white-on-fill, and not for a stroke over a chart. This sits closer to --coral's hue 16 than a chart accent normally should, and temperature is the channel most likely to be plotted beside a ceiling. What separates them is lightness and saturation rather than hue - coral is a pale salmon at 66%, this is a saturated red at 55% - plus the fact that the ceiling is the only mark on the chart drawn as a dashed line over a shaded region. Colour is doing the least of the work here, which is the condition that makes the overlap acceptable. */ --ceiling: 0 80% 55%; /* The heart-rate row's accent, the first row under the depth plot. A rose, because red is what every watch and dive computer draws a heart rate in, and pulled to 345 so it is not the ceiling's 0: the two never share a plot, and the ceiling is dashed over a shaded region, so as with --ceiling and --coral colour is doing the least of the work. Alone on its row, so it has nothing there to be told apart from. Declared once and never redeclared under .dark, like the accents below, and measured the same way: 4.18:1 against the light card, 3.90:1 against the dark one. */ --heart-rate: 345 80% 55%; /* Six accents for the rows under the depth plot that a dive computer computes rather than measures: the no-decompression clock, the time to surface, the ppO₂ it calculated, the CNS clock and the two gradient factors. Declared once and never redeclared under .dark, the same call --pressure, --ceiling, --teal and --coral make - and each is picked so that it clears the 3:1 WCAG asks of a graphical object against **both** cards, which is the property a per-theme pair would have to be re-tuned twice to keep. Measured against the light card's white and the dark theme's 13% one: --ndl 3.35:1 light, 4.86:1 dark --tts 3.48:1 light, 4.67:1 dark --ppo2 4.02:1 light, 4.05:1 dark --cns 3.79:1 light, 4.30:1 dark --gradient-factor 4.17:1 light, 3.91:1 dark --surface-gradient-factor 4.29:1 light, 3.79:1 dark **What has to be separable is what shares a row**, and the panel is one row per scale: heart rate, no-deco time, time to surface, ppO2, and the three percentages. Within that last row the hues are 90 degrees apart or more. Across rows they are not always - --surface-gradient-factor's 230 sits 25 degrees from --ppo2's 205 - and that is the same trade --ceiling already makes at 16 degrees from --coral, for the same reason: two curves that never share a plot are told apart by which plot they are in, and the legend and the crosshair both name them in words. Colour is doing the least of the work. Hues chosen around the three already spoken for (0 ceiling, 16 coral, 180 teal; --pressure is grey) rather than through them, so no new accent has to be told apart from an old one by lightness alone. */ --ndl: 150 65% 38%; --tts: 35 95% 40%; --ppo2: 205 85% 45%; --cns: 300 60% 55%; --gradient-factor: 95 55% 35%; --surface-gradient-factor: 230 70% 62%; /* A cylinder's gas, in the US medical-gas colours dive shops borrow: oxygen green, helium brown. See `components/icons/tank-icon.tsx`. */ --gas-oxygen: 144 100% 39%; --gas-helium: 25 100% 37%; /* A backdrop card's own colour and its hover, a step off the page's either way - see `BackdropCard`. */ --backdrop-surface: 240 4% 95.5%; --backdrop-surface-hover: 240 4% 92.5%; /* The shipped basemaps' water and the colour of the sea names drawn on it, for anything standing in for a map of open water - a dive card with no place yet. Whole colours rather than HSL triplets so they are exactly what `scripts/generate-basemaps.mjs` writes; `basemap.test.ts` fails when the two part. */ --map-water: rgb(163 206 205); --map-water-foreground: rgb(37 96 96); --radius: 0.5rem; /* The part of the page the browser is actually showing, for overlays that have to fit inside it - currently `ui/dialog.tsx`. `useVisualViewport` overwrites both from `window.visualViewport` while a dialog is open, and these are what a browser that never fires those events falls back to. `svh` rather than `vh`, because they differ on exactly the device this matters on: iOS measures `vh` against the viewport Safari would have with its toolbars retracted, so `100vh` is taller than the screen for as long as they are up. The `@supports` below keeps `vh` for anything that has never heard of `svh`, where an unsubstitutable variable would otherwise drop the whole declaration that used it. */ --visual-viewport-height: 100vh; --visual-viewport-top: 0px; /* The anchor of the backdrops behind a dialog and a picker sheet, in document coordinates. Unlike the pair above, this fallback is a floor rather than a correct value: page scroll has nothing to do with `visualViewport` support, so a browser without the API that opens either partway down a long page anchors the backdrop at the top of the document and misses the screen. Left as is deliberately - every browser the app supports has had `visualViewport` for years, so the only thing a better fallback would protect is a consumer that does not exist. Worth knowing before reusing this variable for anything else. */ --visual-viewport-doc-top: 0px; } @supports (height: 100svh) { :root { --visual-viewport-height: 100svh; } } /* macOS Dark Mode's actual system colors: windowBackgroundColor / systemGray6 for surfaces (a very slightly cool/blue-gray, not a perfectly neutral gray), labelColor for text, and macOS's "Graphite" system accent color (a desaturated gray, not blue) for primary/ring. Destructive is the exception: it is in the coral family, as in the light theme, for the reasons on the token itself below. */ .dark { --background: 240 4% 9%; --foreground: 240 6% 93%; --card: 240 4% 13%; --card-foreground: 240 6% 93%; --popover: 240 4% 13%; --popover-foreground: 240 6% 93%; /* Darker than both --card (13%) and --background (9%), so the hover card still reads as floating above the panel rather than cut out of it. Kept on the same cool gray hue as the rest of the dark palette rather than reusing the light theme's slightly blue near-black. */ --tooltip: 240 6% 4%; --tooltip-foreground: 240 6% 93%; --primary: 240 2% 45%; --primary-foreground: 0 0% 100%; /* Nine points above --card rather than shadcn's three. --secondary's whole job is to fill a chip, and every one of them in this app sits on a card: at 16% a secondary badge was 1.2:1 against the surface behind it and simply wasn't there. The light theme gets away with a 4-point gap because its --secondary carries a 40%-saturation blue tint that separates it by hue; this palette is deliberately near-neutral, so lightness is the only channel available. Deliberately NOT merged with --muted, which stays at 16% - that one is a large-area wash (the footer, callouts) where 22% would read as a panel. */ --secondary: 240 4% 22%; --secondary-foreground: 240 6% 93%; --muted: 240 4% 16%; --muted-foreground: 240 3% 60%; --accent: 240 4% 18%; --accent-foreground: 240 6% 93%; /* Same hue 10 as the light theme, lightened for the dark surface exactly as --success and --warning are: 6.9:1 on --background and 6.2:1 on --card, which it needs because this is the theme where --destructive is read as text. */ --destructive: 10 100% 68%; --destructive-foreground: 0 0% 100%; /* Constant across themes, like the brand accents: it is only ever under white label text, so it has nothing to adapt to. */ --destructive-solid: 10 88% 40%; /* Lightened for the dark surface, exactly as --destructive is. */ --success: 142 65% 55%; --success-foreground: 240 4% 9%; /* Lightened for the dark surface, exactly as --success is. */ --warning: 38 95% 62%; --warning-foreground: 240 4% 9%; --border: 240 4% 18%; --input: 240 4% 18%; --ring: 240 2% 55%; /* Helium a step lighter on the dark surface. */ --gas-helium: 30 100% 44%; --backdrop-surface: var(--card); --backdrop-surface-hover: var(--muted); --map-water: rgb(68 108 107); --map-water-foreground: rgb(178 226 226); } } @layer base { * { @apply border-border; } /* The header is `sticky top-0`, so it is painted over the top of whatever an anchor jump scrolls to: clicking Features in the nav put `#features` at `top: 0` and hid its first header-height behind it. `scroll-padding-top` moves the scroll container's snap line down instead of offsetting each target, so it covers `#features`, `#self-hosting`, `#get-started` and any anchor added later, plus the `scrollIntoView` of a focused element, which had the same problem. On the scroll container, hence `html` not `body`. The value has to be the header's height *exactly*, and the first attempt at this got the safe direction backwards. It used 5rem against a 69px header, reasoning that spare clearance is harmless - but the extra 11px is a strip of whatever sits immediately *above* the target, and above `#self-hosting` is the full-bleed `bg-primary` band, so jumping there showed an 11px black bar (grey in dark mode) welded under the header. Overshooting is the bad direction. Undershooting is the harmless one: every section on that page carries its own `py-20`, so a pixel or two short only ever eats empty padding. So this is derived from the header rather than measured off a screenshot: `py-4` twice, the `size="sm"` action button at `h-9`, and the 1px `border-b`. On touch it is `py-3` twice and the button's 44px floor, which is the same sum. It therefore tracks the root font size the same way the header does, which a px literal would not. `header.tsx`'s mobile menu and its backdrop start at this line too, so the header above them stays undimmed and its burger stays tappable. The combobox listboxes are unaffected either way: they `scrollIntoView({ block: "nearest" })` inside their own overflow container, which has its own (unset) scroll-padding that a rule on `html` cannot reach. */ :root { --header-height: calc(4.25rem + 1px); } /* A card section's padding: `p-6`, and 12px on a phone, where the page's gutter and the gap between cards are 10px. A variable rather than a `max-sm:` class beside `p-6`, so a caller's `p-0` or `print:p-0` replaces it outright - tailwind-merge only drops a class of the same variant. */ :root { --card-pad: 1.5rem; /* Half of what a `size="sm"` button's 36px exceeds a card title's line by: 24px, and 18px on a phone. On touch the button is 44px. */ --card-title-lift: 0.375rem; @variant touch { --card-title-lift: 0.625rem; } } @media (width < 40rem) { :root { --card-pad: 0.75rem; --card-title-lift: 0.5625rem; @variant touch { --card-title-lift: 0.8125rem; } } } html { scroll-padding-top: var(--header-height); } /* Where a row pinned to an edge draws its surface: faded rather than popped in, which matters most when one comes back as the keyboard closes. The dive form's row carries it on itself, a dialog's rows on a `::before` that reaches a gap's height into the content - there at rest too, so that the fade has a start. */ [data-dive-form-actions] { @apply transition-[background-color,box-shadow,backdrop-filter] duration-150 motion-reduce:transition-none; } /* The padding under the dive form's buttons. Installed to a phone's home screen, the page runs down into the screen's rounded corners, which a browser's toolbar otherwise covers, so there the pinned buttons sit as high as a dialog's do - its 1rem inset plus its footer's `pb-6` - or above the home indicator's inset, where one is reported, and the action button clears the curve. The same at rest, so nothing moves as the row sticks. */ :root { --dive-form-actions-pb: 0.75rem; @media (display-mode: standalone) { --dive-form-actions-pb: max(2.5rem, env(safe-area-inset-bottom, 0px)); } } [data-dive-form-actions] { padding-bottom: var(--dive-form-actions-pb); } /* The box each surface - and the header's cross - is placed against, pinned or not. */ [data-dialog-header], [data-dialog-footer] { position: relative; } [data-dialog-header]::before, [data-dialog-footer]::before { content: ""; position: absolute; z-index: -1; pointer-events: none; opacity: 0; @apply bg-background/90 transition-opacity duration-150 motion-reduce:transition-none; } [data-dialog-header]::before { inset: 0 0 -1rem; @apply shadow-[inset_0_-1px_0_hsl(var(--border))]; } /* Above the buttons, so the rule does not sit on them, without the footer itself growing at rest. */ [data-dialog-footer]::before { inset: -1rem 0 0; @apply shadow-[inset_0_1px_0_hsl(var(--border))]; } /* The dive form's Cancel/Save row sticks to the foot of the screen, and a dialog's title row and footer to its top and foot while the content scrolls between them - unless the visible height is short, which with the keyboard up it is: then each stays where it rests and the field being typed into keeps the room. `useShortViewport` sets the attribute, and takes it off only once the viewport has settled. A row resting off screen reads as stuck to its observer, so everything keyed on `data-stuck` sits under the same gate. The dialog offsets are measured from inside the content's `p-6`, so they cancel that padding to reach the edge, and go two pixels past it: pinned, and at rest at that end of the dialog - the same place, each row bleeding into the padding - then differ by a hidden sliver the row's observer can see. */ :root:not([data-visual-viewport-short]) { [data-dive-form-actions] { position: sticky; bottom: 0; } /* Stuck, it covers what scrolls under it. A shadow rather than a border, as on a stuck section heading: the rule takes no height, so nothing moves as the row sticks. */ [data-dive-form-actions][data-stuck] { @apply bg-background/90 shadow-[inset_0_1px_0_hsl(var(--border))] backdrop-blur; } /* The page's bottom scroll padding while that row is pinned: a field focused below it - the first refused one after a failed save - stops above the row rather than behind it. The row's height: `pt-3`, a default button's `h-10` (44px on touch) and the padding under it. Unlike at the top, overshooting costs nothing here. The refusal line the row can carry is not counted: a resolver's refusal, the usual one, never shows there. */ &:has([data-dive-form-actions][data-stuck]) { scroll-padding-bottom: calc(3.25rem + var(--dive-form-actions-pb)); @variant touch { scroll-padding-bottom: calc(3.5rem + var(--dive-form-actions-pb)); } } [data-dialog-header] { position: sticky; top: calc(-1.5rem - 2px); } [data-dialog-footer] { position: sticky; bottom: calc(-1.5rem - 2px); } [data-dialog-header][data-stuck]::before, [data-dialog-footer][data-stuck]::before { opacity: 1; @apply backdrop-blur; } /* The header's height, which `DialogHeader` measures, and that gap. */ [role="dialog"]:has([data-dialog-header][data-stuck]) { scroll-padding-top: calc(var(--dialog-header-height, 0px) + 1rem); } /* The footer's height, which `DialogFooter` measures, and that gap. */ [role="dialog"]:has([data-dialog-footer][data-stuck]) { scroll-padding-bottom: calc(var(--dialog-footer-height, 0px) + 1rem); } } body { @apply bg-background text-foreground; } /* Tailwind v4's Preflight dropped v3's `button { cursor: pointer }` to match the browser default. Restored here, because without it the app is visibly inconsistent along a line that has nothing to do with what a control does: A `Button` rendered as a `Link` gets a hand (it renders an anchor, and the UA styles those) while every other button - the same component, the same appearance - gets an arrow. The alternative is patching `cursor-pointer` onto components one at a time, which is what was happening. Base UI's menu and listbox items are unaffected: they carry an explicit `cursor-default` (native OS menus don't use a hand), and a utility always outranks this layer. Same for `disabled:cursor-not-allowed` on inputs. */ button:not(:disabled), [role="button"]:not(:disabled), select:not(:disabled), input:where([type="checkbox"], [type="file"]):not(:disabled) { cursor: pointer; } /* No double-tap zoom on a control, so tapping one twice in quick succession - a period arrow, a map zoom button, a star - is two taps. Pinch still zooms. A drag handle's inline `touch-action: none` (`useDragSort`) outranks this. */ a, button, [role="button"], input, label, select, summary, textarea { touch-action: manipulation; } /* Every date field is a native one on a touch device (`hooks/useCoarsePointer.ts`), and WebKit sizes those to the widget rather than to the box it was given: on an iPhone the date fields in a dialog come out wider than the text fields above them and overflow it. Dropping the native appearance is what lets `Input`'s own `h-10 w-full` hold the row, and `min-width: 0` is the same fight one level out, where the widget's intrinsic width would stop a grid or flex cell narrowing to a phone's. Alignment and the height of an empty field need nothing here, and a rule for either is worth checking on a device before adding: Preflight's own `::-webkit-date-and-time-value { text-align: inherit; min-height: 1lh }` already covers both on iOS. */ input[type="date"], input[type="time"] { appearance: none; min-width: 0; } } /* MapLibre's cooperative-gesture screen, hidden so the app can draw its own. `cooperativeGestures` is what gives the picker four behaviours at once - one finger scrolls the page, two drive the map, the wheel only zooms with ctrl/cmd, and `touchmove` is prevented from two touches up. It also renders a 40%-black overlay of its own, with MapLibre's wording, on *every* blocked gesture. That last part is a regression rather than a feature here: a plain wheel over this map is the diver scrolling down the dialog towards Save, and flashing a dark panel over the map every time they do it is worse than the silence it replaces. So the screen is hidden and `components/map/map-picker.tsx` listens for `cooperativegestureprevented` instead, showing the app's own hint for the one case that needs explaining. Outside `@layer base`, and two class names deep, because `maplibre-gl.css` is imported from a client component's own chunk rather than from here - so its rules may arrive after these, and only specificity can be relied on. And a stylesheet rather than `map.cooperativeGestures.disable()`, which is the obvious way to be rid of the screen and takes the behaviour with it: its `_destroyUI()` removes `maplibregl-cooperative-gestures` from the canvas container, and that class is what MapLibre's own `touch-action: pan-x pan-y` rule keys off. Hiding one element cannot touch a class on another, which is exactly why it is the right instrument here. */ .maplibregl-map .maplibregl-cooperative-gesture-screen { display: none; } /* The hero's reef accent is a CC0 silhouette (see `NOTICE.md`), carried as a 19 KB two-colour PNG rather than the 318 KB SVG it was traced into: the drawing's polyp stipple is thousands of tiny loops, which is why neither svgo nor WebP could shrink it, and why none of that detail survives being drawn at 25% opacity anyway. A mask rather than an ``, so the shape takes its colour from a token and follows the theme the way the rest of the page does. `mask-mode: luminance` is required, not decorative: the file has no alpha channel - the shape is white on black - and `match-source` would read a missing alpha as fully opaque and paint the whole box. Nothing is hand-prefixed for it, deliberately. WebKit never shipped a `-webkit-mask-mode`: the legacy spelling is `-webkit-mask-source-type`, and the build emits that on its own - `@tailwindcss/postcss` runs lightningcss against Safari 16.4 and friends, which prefixes the whole `mask-*` block. A hand-written `-webkit-mask-mode` sat here until a reviewer checked the built CSS and found it passed through as an unknown declaration, doing nothing. */ .hero-reef { -webkit-mask-image: url("/reef-mask.png"); mask-image: url("/reef-mask.png"); mask-mode: luminance; -webkit-mask-size: contain; mask-size: contain; -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat; -webkit-mask-position: center; mask-position: center; background-color: hsl(var(--teal)); aspect-ratio: 3516 / 5556; }