wrolpi/app/src/App.css
lrnselfreliance 4fea50943b
map: clicking a POI shows its kind, detail, elevation and coordinates (#738)
The Protomaps basemap keeps a few attributes per POI beyond the name the
map already labels: kind, kind_detail and elevation.  Clicking a POI now
opens a popup with those, plus the feature's coordinates (or the click
location when the feature has no point).  The same POI appears at several
min_zoom levels within one tile, so features are deduped by name and kind.

Map popups (this one and the existing pin popup) now follow the active
theme's tokens instead of MapLibre's fixed white, covering light, dark,
night and amber.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-27 08:23:03 -06:00

759 lines
24 KiB
CSS

:root {
/* Shared with the toast offset below, so the two cannot drift apart. */
--navbar-height: 3.4em;
}
/* Nav bar: a hand-rolled flexbox bar. The user-chosen background and the foreground
measured against it are set inline (see themes/navColors); this stylesheet only
supplies structure and hover affordances. */
.wrolpi-navbar {
display: flex;
align-items: stretch;
/*
* Wraps rather than clipping. With this many items the bar overflows a 1000px
* viewport, and `nowrap` cut "Admin" off the right edge -- the one link a user needs
* to reach the settings that fix a broken WROLPi.
*/
flex-wrap: wrap;
min-height: var(--navbar-height);
padding: 0 0.25em;
}
/*
* Toasts start below the navigation bar instead of on top of it.
*
* They are fixed at `top: var(--mantine-spacing-md)` -- 16px -- while the bar occupies the
* first 54px of the page, so every toast landed over the bar's right-hand corner: the search
* glyph, the hamburger, Help and Admin. For as long as the toast was up those controls could
* not be clicked, and an error toast is exactly when a user reaches for Admin.
*
* It went unnoticed until the search glyph was given a proper hit area: at 18px it sat in the
* gap under the toast, and growing it to the height of the bar moved it into the overlap.
* The toast was always covering that corner; there was just nothing there to hit.
*
* `:where()` gives Mantine's own rule zero specificity, so one class is enough to win, but
* the attribute is matched too -- only the top-anchored positions need moving, and a future
* switch to `bottom-right` should not silently inherit a top offset.
*/
html .mantine-Notifications-root[data-position^='top'] {
top: calc(var(--navbar-height) + var(--mantine-spacing-md, 1rem));
}
.wrolpi-navbar-right {
display: flex;
align-items: center;
margin-left: auto;
gap: 0.2em;
}
.wrolpi-navbar-link,
.wrolpi-navbar-link-button {
display: flex;
align-items: center;
/* Tightened from 1.1em: the bar has eleven links plus nine indicators. */
padding: 0 0.8em;
color: inherit;
text-decoration: none;
white-space: nowrap;
background: none;
border: none;
font: inherit;
cursor: pointer;
}
.wrolpi-navbar-link:hover,
.wrolpi-navbar-link-button:hover {
/* A translucent black works on every navbar color, light or dark. */
background: rgba(0, 0, 0, 0.12);
}
/*
* The tab for the section the user is in.
*
* react-router marks it with `active` and `aria-current` on its own, and always did -- the
* mark reached the DOM and nothing here drew it, so every tab looked identical and clicking
* Videos appeared to leave the bar untouched.
*
* The underline is a pseudo-element in `currentColor`, not a border: `currentColor` follows
* the foreground the bar computed for whatever navbar color the user picked, so this works
* on all twelve of them (see `navColorNames`) and in all four themes without naming a color;
* and a pseudo-element adds no height, where a border would jog the whole row as the mark
* moved between tabs. The darker wash is the hover treatment deepened, so the two read as
* the same idea.
*/
.wrolpi-navbar-link.active {
position: relative;
background: rgba(0, 0, 0, 0.2);
}
.wrolpi-navbar-link.active::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: 0;
/* rem, not px, so the mark grows with the --ui-scale setting like the text above it. */
height: 0.1875rem;
background: currentColor;
}
/*
* The same tabs inside the overflow "More" menu and the mobile hamburger, which are the
* only place most of them appear on a phone. Those render in a portal, outside the bar,
* so they take theme tokens rather than the bar's foreground.
*/
.mantine-Menu-item.active {
background: var(--head);
font-weight: 600;
}
/*
* Everything in the bar takes the bar's foreground.
*
* `color: inherit` on the links alone was not enough, because two of the things in the
* corner do not inherit at all and both came out blue on every bar in every theme:
*
* - An anchor takes `a {color: var(--blue)}` from tokens.css, which outranks an inherited
* value however the bar sets it. The search glyph is an anchor, and so is every status
* icon that links to /admin -- those only looked right because they carry an inline
* color of their own, which still wins here and is meant to.
* - A Mantine ActionIcon paints `--ai-color`, its own themed blue. The hamburger and the
* theme toggle are ActionIcons. The variable is no use as a hook: Mantine writes it
* INLINE on the element, so a stylesheet declaring it loses every time (the same trap
* as `--group-justify` on Pagination). The painted property has to be set instead, and
* `html` lifts this over Mantine's own single-class rule.
*
* Measuring the foreground against the bar is worth nothing if the icons opt out of it.
*/
.wrolpi-navbar a,
html .wrolpi-navbar .mantine-ActionIcon-root {
color: inherit;
}
/* The bar's own hover, rather than a blue tint from Mantine's palette. */
html .wrolpi-navbar .mantine-ActionIcon-root:hover {
background: rgba(0, 0, 0, 0.12);
color: inherit;
}
/*
* One status indicator's slot. Laid out rather than nudged: see NavIconWrapper in Nav.js
* for the 0.8em top margin this replaces and why it looked correct on half the indicators.
*/
.wrolpi-navbar-icon {
display: flex;
align-items: center;
/* A sliver only: the corner's gap and the slots' own padding already separate the
icons, and anything more has to fit a 320px phone times the number of slots. */
margin-left: 0.25em;
/* Full bar height, so the anchor inside has somewhere to stretch to. */
align-self: stretch;
}
/*
* An icon-only trigger in the corner gets a link's hit area, not its glyph's.
*
* The share glyph already computed `cursor: pointer` -- it is an `<a href>` -- but the
* target was the 24px box the icon occupies, so the pointer appeared only if you found it
* exactly, and there was no hover highlight to aim at. Stretched and padded, it behaves
* like Help and Admin beside it.
*
* `align-self` rather than `align-items: stretch` on the wrapper: the IconButtons in the
* corner have an explicit height, and a stretch container places an item it cannot stretch
* at the start -- which would push the hotspot and theme glyphs back off centre.
*/
.wrolpi-navbar-icon > a {
align-self: stretch;
padding: 0 0.5em;
cursor: pointer;
}
.wrolpi-navbar-icon > a:hover {
background: rgba(0, 0, 0, 0.12);
}
/*
* An anchor in the corner hugs its glyph instead of a text line box.
*
* A line box reserves descender space for text that is not there, which leaves an icon
* sitting about 3px above the centre of the box being centred -- the share and search
* glyphs, but not the hotspot or theme picker, which are square ActionIcons that centre
* their own glyph. That difference is what made a single top margin look right on some
* indicators and wrong on others.
*/
.wrolpi-navbar-right a {
display: flex;
align-items: center;
}
/*
* A link in the corner fills the bar's height, as one outside it does.
*
* Help and Admin highlighted only a box around their text while Videos and Statistics
* highlighted the full height. Same class and same rules; the difference is the parent.
* The bar stretches its children, but the corner is a nested flex container that centres
* its own, so a link inside it was sized to its content. The hover background is painted
* on the link box, so this is the hover affordance itself and not just its outline.
*/
.wrolpi-navbar-right .wrolpi-navbar-link,
.wrolpi-navbar-right .wrolpi-navbar-link-button {
align-self: stretch;
}
::-ms-clear {
display: none;
}
pre.wrap-text {
white-space: pre-wrap; /* Since CSS 2.1 */
white-space: -moz-pre-wrap; /* Mozilla, since 1999 */
word-wrap: break-word; /* Internet Explorer 5.5+ */
}
/*
* A video's length, pinned to the corner of its poster.
*
* Tokens, not a literal white-on-black. The poster under it is filtered by night mode -- it is a
* `.media` leaf -- but this chip is drawn beside the image rather than inside it, so it was never
* filtered: a white tile on a card whose picture had gone dim red, which is the brightest thing on
* the videos page in the theme meant to have nothing bright on it.
*
* Opaque, because it sits on an arbitrary poster and has to be legible over any of them.
*/
.duration-overlay {
position: absolute;
top: 0.25rem;
right: 0.25rem;
color: var(--text);
background: var(--panel);
border: 1px solid var(--border);
padding: 0px 0.25rem;
border-radius: 0.1875rem;
}
.no-link-underscore {
text-decoration: none !important;
}
/*
* A card's title and its author/channel/domain line are links, but they are the card's
* text -- they take the body color rather than the link color, so a grid of cards reads
* as titles and not as a wall of blue. This was a literal `black` until the themes
* arrived, which left every title at about 1.1:1 on the dark, night and amber panels.
*/
.card-link {
color: var(--text);
}
/*
* The hand-rolled `.toggle` / `.slider` switch is gone.
*
* Nothing referenced either class: `Toggle` is a Mantine Switch, and the only remaining
* matches for the words were the `classList.toggle` DOM method and the controller's own
* templates, which do not load this stylesheet.
*
* It is deleted rather than finished because it was already half-converted and broken: the
* track and knob became rem while the checked-state travel stayed `translateX(26px)`, so on a
* 54px track the thumb would have stopped ~4px short. Dead CSS that is also wrong is a trap
* for whoever revives it, and it also hardcoded #6435C9 and white, which no theme can reach.
*/
/* File preview Modal.Actions toolbar.
Single layout that works at every viewport — buttons keep their order at any width.
On narrow screens, the tags selector wraps to its own line while the button groups stay together. */
.preview-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5em;
/* Fill the actions row: `.wrolpi-modal-actions` packs its children to the right, which
is where a confirming button belongs but strands this toolbar. At full width the
toolbar's own layout wins and everything reads from the left. */
flex: 1 1 100%;
}
.preview-toolbar-group {
display: flex;
align-items: center;
gap: 0.25em;
flex: 0 0 auto;
}
.preview-toolbar-tags {
/* rem so the basis grows with the tags it holds. */
flex: 1 1 12.5rem;
min-width: 0;
}
/* On a phone the tags selector always gets its own line rather than squeezing in beside
the buttons. 700px is the app's `tablet` breakpoint (`AppMedia` in contexts.js). */
@media (max-width: 699px) {
.preview-toolbar-tags {
flex-basis: 100%;
}
}
/* Caps preview content to fit inside the file-preview Modal.
300px reserves room for pathContent <pre>, Modal.Header, Modal.Actions row,
inner content (e.g. STL dimensions banner), and modal padding.
Use 100dvh so mobile browser chrome (URL bar) is accounted for. */
.preview-fit {
width: 100%;
height: calc(100dvh - 18.75rem);
max-height: calc(100dvh - 18.75rem);
min-height: 15rem;
overflow: hidden;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
/* Fresnel (Media) wraps iframes in a div which would otherwise shrink to the
default iframe size; make it fill the preview so the iframe's 100% works. */
.preview-fit .fresnel-container {
width: 100%;
height: 100%;
}
td.file-path {
cursor: pointer;
}
/*
* The row is an icon followed by a filename, as inline siblings. An inline SVG
* sits on the text baseline by default, so a 24px glyph next to 13px text hangs
* well above it and the name looks like it is resting on the icon's floor.
* `middle` centres the glyph against the text instead.
*
* The margin is the gap that was never there: the folder icon touched the first
* letter of its own name, and two icons (a folder and its ignored marker) touched
* each other.
*/
td.file-path > svg {
vertical-align: middle;
margin-right: 0.35em;
}
textarea.otp {
width: 100%;
color: #333;
}
.card-icon {
min-height: 10.1875rem;
text-align: center;
padding-top: 3.3125rem;
}
.clickable {
cursor: pointer;
}
/*
* A table that truncates to the width it has rather than growing past it: the columns divide
* the table's width, and `column-ellipsis` gives up what it cannot show. A column that must
* fit something exact states a width on its header cell; the rest is the ellipsis column's.
*
* `table.`, not the bare class: Mantine sets `table-layout` on its own `.mantine-Table-table`
* at the same specificity and later in the cascade, so the bare class loses -- silently, with
* every other property here still applying.
*/
table.table-ellipsis {
width: 100%;
table-layout: fixed;
}
.table-ellipsis td.column-ellipsis {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/*
* Column widths for the downloads tables. Phone first, and the phone is 375px: below the
* breakpoint each column is cut to what it must hold -- its header's longest word, the label
* wrapping, or its buttons -- and the URL takes what is left, which is little. Above it the
* labels get their own line back and the URL gets the rest.
*
* 700px is the app's `tablet` breakpoint (`AppMedia` in contexts.js). `em` here is the
* table's own font (~15.4px), not the root's.
*/
/*
* A header too long for a phone carries both forms and shows one. The short word is what the
* mobile column widths below are sized for; the full words return at the breakpoint.
*/
.table-ellipsis th .label-long {
display: none;
}
@media (min-width: 700px) {
.table-ellipsis th .label-long {
display: inline;
}
.table-ellipsis th .label-short {
display: none;
}
}
.table-ellipsis th.col-select {
/* 22px of checkbox plus 11px of cell padding either side. */
width: 3em;
}
.table-ellipsis th.col-frequency {
width: 4.2em;
}
.table-ellipsis th.col-completed {
width: 5.5em;
}
.table-ellipsis th.col-completed-plain {
width: 4.2em;
}
.table-ellipsis th.col-next {
width: 3.9em;
}
/* A `ButtonGroup` cannot wrap -- its buttons are joined -- so this holds all three of them
(delete, edit, retry on a failed download) at every width. */
.table-ellipsis th.col-control-group {
width: 9.5em;
}
/* A `.wrolpi-button-row` wraps, so on a phone this holds one button and the second drops
beneath it. It is sized by the word "Control" rather than by the button, which is wider. */
.table-ellipsis th.col-control-row {
width: 5.2em;
}
@media (min-width: 700px) {
.table-ellipsis th.col-frequency {
width: 12em;
}
.table-ellipsis th.col-completed {
width: 9.5em;
}
.table-ellipsis th.col-completed-plain {
width: 8.5em;
}
.table-ellipsis th.col-next {
width: 4em;
}
.table-ellipsis th.col-control-row {
width: 7.5em;
}
}
/*
* A header wraps instead of running into the next column: `.wrolpi-th-sort` is nowrap, which a
* fixed column cannot widen for, so a long label printed over its neighbour.
*
* `min-width: 0` as well as the wrap -- the label is an anonymous flex item whose automatic
* minimum is its longest word, and without this it still refuses to go below that word. A
* column narrower than its longest word is a width to fix at the call site.
*/
.table-ellipsis th .wrolpi-th-sort {
display: flex;
white-space: normal;
min-width: 0;
text-align: left;
}
/*
* A header is clipped rather than painted over its neighbour. Wrapping cannot save a
* single-word label in the elastic column, which may be narrower than the word itself.
*/
.table-ellipsis th {
overflow: hidden;
}
/* `ButtonGroup` is a nowrap flex row whose children shrink; `col-control-group` is sized for
the whole group, so the buttons in it must not. */
.table-ellipsis td .mantine-ButtonGroup-group > * {
flex-shrink: 0;
}
.card-title-ellipsis {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.search-container {
display: flex;
justify-content: flex-start;
}
.search-input {
flex-grow: 1;
}
.video-player {
display: block;
margin: 0 auto; /* Center the video horizontally */
}
.audio-poster-container {
position: relative;
display: block;
text-align: center;
}
.audio-poster-overlay {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: rgba(255, 255, 255, 0.85);
text-shadow: 0 0 20px rgba(0, 0, 0, 0.6);
pointer-events: none;
}
table.ratio-table td.equal-width {
width: 48%;
}
table.ratio-table td.min-width {
width: auto;
}
/*
* Sticky footer for the file browser.
*
* The surface is a token. It was two hardcoded greys with `.inverted` selecting the dark
* one, and once nothing set `.inverted` any more the footer stayed at near-white `#f9fafb`
* in dark, night and amber. In night mode a white bar pinned to the bottom of the screen
* costs the user the dark adaptation the whole theme exists to protect. A token has no
* such failure mode: there is one rule and each theme fills it in.
*/
.sticky-footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 0.5em 1em;
background-color: var(--panel);
border-top: 1px solid var(--border);
z-index: 100;
/*
* A row with gaps, like every other group of buttons in the app.
*
* This was a plain block, so the buttons -- inline-block elements with no whitespace
* between them, because JSX drops whitespace-only lines -- sat flush against each other
* in one unbroken slab. Everywhere else a group of buttons is a flex row with a gap;
* the file browser's footer is hand-rolled and never got one. It wraps because the bar
* carries eight controls and a phone cannot hold them.
*/
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5em;
}
/*
* Drag selection visual feedback for FileBrowser: a blue tint over the rows being taken,
* red over the rows being dropped.
*
* Each had a second, stronger rule under `.inverted` for the dark themes. Nothing has set
* that class since ThemeProvider stopped supplying it, so only the light tint has been
* drawn for some time -- and 15% of a fixed blue over a dark row is close to invisible,
* which is the one moment the user needs to see what they have hold of.
*
* A token mixed with transparent instead: the hue follows the theme, and the strength can
* go up without pinning it to a literal that only suits one background.
*/
tr.drag-selecting td {
background-color: color-mix(in srgb, var(--blue) 22%, transparent) !important;
}
tr.drag-deselecting td {
background-color: color-mix(in srgb, var(--red) 22%, transparent) !important;
}
/* Prevent text selection during drag */
.file-browser-dragging {
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
/* Filter input and collapse-all button: right-aligned on desktop, full width on mobile (below). */
.file-browser-filter-container {
display: flex;
justify-content: flex-end;
/* The gap is the container's. The collapse-all button carried `margin-left: 0.5em` for it,
which is the same thing one element to the right of where it belongs. */
gap: 0.5em;
/* No `margin-bottom`: this is a top-level block of the Files page, and the page's stack
spaces it (see `.wrolpi-stack`). Its own 0.5em was added to that. */
}
/*
* Double the default input width on desktop.
*
* The selector was `.ui.input.file-browser-filter`, naming a wrapper the old input
* component rendered around the field. Nothing carries `ui input` any more, so this rule
* and the mobile one below it had been matching nothing at all: the filter was rendering
* at the browser's default width on a bar laid out to give it 34em.
*
* The class lands on ActionInput's wrapper, which is the field and its clear button
* together -- that pair is the control being sized, not the text box alone.
*
* Qualified by the container so it carries two classes. `.wrolpi-action-input` sets
* `width: 100%` in ui.css, which is one class like `.file-browser-filter` is, and ui.css
* is loaded after this file -- so a bare class selector here loses the tie and the field
* fills the row anyway. Specificity, not source order, is what should decide it.
*/
.file-browser-filter-container .file-browser-filter {
width: 34em;
max-width: 100%;
}
@media only screen and (max-width: 700px) {
.hide-on-mobile {
display: none !important;
}
.file-browser-filter-container {
justify-content: flex-start;
}
/* Fill the row, but stay on one line with the collapse-all button. */
.file-browser-filter-container .file-browser-filter {
flex: 1 1 auto;
width: auto;
}
}
/* Inventory "PDF" export: the printable table is hidden on screen and becomes the only
visible content when printing, so a user can use the browser dialog to Save as PDF. */
.inventory-print {
display: none;
}
@media print {
body * {
visibility: hidden;
}
.inventory-print,
.inventory-print * {
visibility: visible;
}
.inventory-print {
display: block;
position: absolute;
top: 0;
left: 0;
width: 100%;
color: #000;
}
.inventory-print h1 {
font-size: 18pt;
margin: 0 0 0.25em;
}
.inventory-print h2 {
font-size: 13pt;
margin: 1.25em 0 0.5em;
}
.inventory-print-meta {
font-size: 9pt;
color: #444;
margin: 0 0 0.75em;
}
.inventory-print table {
width: 100%;
border-collapse: collapse;
font-size: 10pt;
}
.inventory-print th,
.inventory-print td {
border: 1px solid #999;
padding: 0.25rem 0.375rem;
text-align: left;
vertical-align: top;
}
/* Repeat the header row at the top of each printed page. */
.inventory-print thead {
display: table-header-group;
}
.inventory-print tr {
page-break-inside: avoid;
}
/* The supply-plan shopping list reuses the .inventory-print element styling (h1/table/etc.) but is a separate
printable block. By default (e.g. the Export tab's whole-inventory PDF) it is hidden; it becomes the ONLY
printed block when the supply plan toggles `body.printing-shopping` just before window.print(). */
.shopping-print,
.shopping-print * {
visibility: hidden;
}
.shopping-print {
display: none;
}
body.printing-shopping .inventory-print:not(.shopping-print),
body.printing-shopping .inventory-print:not(.shopping-print) * {
visibility: hidden;
}
body.printing-shopping .inventory-print:not(.shopping-print) {
display: none;
}
body.printing-shopping .shopping-print,
body.printing-shopping .shopping-print * {
visibility: visible;
}
body.printing-shopping .shopping-print {
display: block;
}
}
/* Map popups (POI details and pins) follow the active theme's tokens. */
/* Scoped under .maplibregl-popup to outrank maplibre-gl.css, which loads after this file. */
.maplibregl-popup .maplibregl-popup-content {
background: var(--panel);
color: var(--text);
border: 1px solid var(--border);
border-radius: 0.25rem;
}
.maplibregl-popup .maplibregl-popup-close-button {
color: var(--muted);
}
.maplibregl-popup .maplibregl-popup-close-button:hover {
background: var(--head);
color: var(--text);
}
.maplibregl-popup.maplibregl-popup-anchor-top .maplibregl-popup-tip,
.maplibregl-popup.maplibregl-popup-anchor-top-left .maplibregl-popup-tip,
.maplibregl-popup.maplibregl-popup-anchor-top-right .maplibregl-popup-tip {
border-bottom-color: var(--panel);
}
.maplibregl-popup.maplibregl-popup-anchor-bottom .maplibregl-popup-tip,
.maplibregl-popup.maplibregl-popup-anchor-bottom-left .maplibregl-popup-tip,
.maplibregl-popup.maplibregl-popup-anchor-bottom-right .maplibregl-popup-tip {
border-top-color: var(--panel);
}
.maplibregl-popup.maplibregl-popup-anchor-left .maplibregl-popup-tip {
border-right-color: var(--panel);
}
.maplibregl-popup.maplibregl-popup-anchor-right .maplibregl-popup-tip {
border-left-color: var(--panel);
}