wrolpi/app/src/components/ui/ui.css
Roland ef5a3a1dbc
ui: a progress bar's track is its own edge, not a border
Take the 1px outline off the bar without changing its size.  `box-sizing:
border-box` is global and the declared 1.375rem / 7.5rem were always the outer
box, so nothing moves.

What the border was quietly doing was giving the empty half of the track an
edge, so the track now has to carry that itself: `--head` rather than `--bg`.
A bar sits on a Panel as often as on the page -- Status and Downloads put them
in Panels and table rows, uploads in modals -- and `--bg` is invisible against
one of those two by definition, which is also why light mode needed its own
`--panel` override.  `--head` is off both surfaces in all four themes, and
keeps the label's contrast against the empty track above 4.5:1 everywhere,
which a lighter track (`--border`, 3.4:1 in night) would not.

Asserted per theme in the component spec: no border on any edge, the height
and min-width measured against `rem` rather than px, and the track distinct
from the Panel beneath it.  Putting the border back fails all four.
2026-08-08 18:05:53 -06:00

1807 lines
64 KiB
CSS

/*
* Styles for the token-driven component library.
*
* Rules here encode design decisions Mantine's variables cannot express on their
* own — chiefly the two that change *form* rather than color between themes:
* the dashed danger treatment and status-by-brightness. See ui-design.md.
*
* Nothing in this file hardcodes a color; every value comes from a token.
*/
/* ------------------------------------------------------------------ Icons */
.wrolpi-icon-spin {
animation: wrolpi-icon-spin 0.9s linear infinite;
}
@keyframes wrolpi-icon-spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.wrolpi-icon-spin {
animation: none;
}
}
.wrolpi-icon-stack {
position: relative;
display: inline-flex;
line-height: 0;
}
/*
* The corner glyph sits on the main one, so it carries the surface color behind
* it — without that the two sets of strokes cross and neither reads.
*
* Which surface that is depends on where the stack was placed, and only the call
* site knows: the file browser puts one inside a filled blue button, where a disc
* of the page background is a hole punched in the button. `--icon-stack-bg`
* defaults to the page, and a call site anywhere else sets it.
*/
.wrolpi-icon-stack-corner {
position: absolute;
/* rem, so the badge keeps its corner as the glyph it sits on grows. */
right: -0.1875rem;
bottom: -0.1875rem;
display: flex;
background: var(--icon-stack-bg, var(--bg));
border-radius: 50%;
padding: 1px;
}
/*
* A stack inside a button is the one case the call site should not have to describe,
* because the surface changes underneath it: Mantine paints a filled button from
* `--button-bg`, and the disc has to follow it into whatever state the button is in.
* The file browser's New Folder button is disabled in WROL mode, so a disc pinned to the
* enabled fill would be a blue chip on a grey button -- the original hole, inverted.
*
* Only `filled` gets the first rule: an outline or subtle button sets `--button-bg` to
* `transparent`, and there the surface behind the button is still the right answer.
* `:not([data-variant])` is that same case -- Mantine omits the attribute entirely when a
* call site names a color but no variant, which is how every filled button here is
* written, and keying on the attribute alone matched none of them. `--button-bg` itself
* is absent when a button names neither, hence the fallback chain.
*/
.mantine-Button-root:not([data-variant]) .wrolpi-icon-stack-corner,
.mantine-Button-root[data-variant="filled"] .wrolpi-icon-stack-corner {
background: var(--button-bg, var(--icon-stack-bg, var(--bg)));
}
/*
* There is deliberately no `:disabled` override here any more. There used to be one pinning
* the disc to `--mantine-color-disabled`, because Mantine repainted a disabled button grey
* and a disc left on `--blue` became a blue chip on a grey surface. Disabled buttons now
* keep their own fill (see the disabled-button rule further down), so `--button-bg` is
* correct in both states and the rule above covers them.
*/
/* ---------------------------------------------------------------- Buttons */
/*
* Dashed borders mean "destructive or failed", and nothing else may use them.
* In night mode a destructive button drops its fill entirely: 1.5px keeps the
* dashes legible at low brightness, where a 1px hairline dash disappears.
*/
/*
* In light, dark, and amber the button stays filled, which `color="red"` already
* does. Night is the exception, and it cannot be expressed by overriding
* Mantine's `--button-*` variables: Mantine writes those as inline styles on the
* element, which no stylesheet rule can outrank. Setting the real properties
* works instead — this selector (0,2,0 plus an element) outranks the Mantine
* class rule that reads the variables.
*/
html[data-theme="night"] .wrolpi-button-danger {
background: transparent;
color: var(--danger);
border: 1.5px dashed var(--danger);
}
html[data-theme="night"] .wrolpi-button-danger:hover:not(:disabled) {
background: var(--panel);
}
/* The icon inside inherits currentColor, so it follows without another rule. */
/*
* An icon button is the same height as a labelled one, so a group of them lines up.
*
* Mantine gives ActionIcon a size scale unrelated to Button's -- `sm` is 22px against a
* button's 36px -- which left the Details/Open pair on every archive card ragged, and any
* other toolbar that mixes the two. It already ships the aligned scale as
* `--ai-size-input-*`, for an ActionIcon standing beside an Input; those five values are
* identical to `--button-height-*`. Pointing one at the other fixes every size at once,
* including ones no call site uses yet.
*
* The `html` prefix is load-bearing. Mantine declares this scale on ActionIcon's own
* hashed class, which has the same specificity as `.mantine-ActionIcon-root` and would win
* on source order.
*/
html .mantine-ActionIcon-root {
--ai-size-xs: var(--ai-size-input-xs);
--ai-size-sm: var(--ai-size-input-sm);
--ai-size-md: var(--ai-size-input-md);
--ai-size-lg: var(--ai-size-input-lg);
--ai-size-xl: var(--ai-size-input-xl);
}
/* ----------------------------------------------------------- Theme picker */
.wrolpi-theme-picker {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(14.375rem, 1fr));
gap: 0.625rem;
}
.wrolpi-theme-option {
display: flex;
flex-direction: column;
gap: 0.25rem;
align-items: flex-start;
text-align: left;
padding: 0.625rem 0.75rem;
background: var(--panel);
color: var(--text);
border: 1px solid var(--border);
font: inherit;
cursor: pointer;
}
.wrolpi-theme-option:hover {
background: var(--head);
}
/* The selected theme is marked by a heavier border as well as color, so the
selection survives in monochrome themes. */
.wrolpi-theme-option-active {
border: 2px solid var(--blue);
padding: 0.5625rem 0.6875rem;
}
html[data-theme="night"] .wrolpi-theme-option-active,
html[data-theme="amber"] .wrolpi-theme-option-active {
border-color: var(--text);
}
.wrolpi-theme-option:focus-visible {
outline: 2px solid var(--blue);
outline-offset: 1px;
}
.wrolpi-theme-option-name {
display: inline-flex;
align-items: center;
gap: 0.4375rem;
font-weight: 600;
font-size: 0.8125rem;
}
.wrolpi-theme-option-description {
font-size: 0.75rem;
color: var(--muted);
line-height: 1.4;
}
/* Separated from the theme grid: it configures the chosen theme, it is not a choice. */
.wrolpi-media-filter-setting {
margin-top: 0.875rem;
padding-top: 0.75rem;
border-top: 1px solid var(--border);
}
/* ------------------------------------------------------------ Pagination */
/*
* The pager is centred here rather than by whoever renders it. Three call sites wrapped it
* in `<center>` or `text-align: center` and it still sat hard against the left edge.
*
* Mantine builds a Pagination as a BLOCK root -- full width, so those wrappers had nothing
* to centre -- holding a flex Group that carries the controls and lays them out at
* `flex-start`. `justify-content` on the root therefore does nothing at all; it has to
* reach the Group.
*
* Setting `--group-justify` on the root does not work either: Group defaults that prop and
* writes the variable INLINE on its own element, where an inherited value cannot reach it.
* The property itself is fair game, though -- Mantine only ever reads the variable from a
* hashed class -- so this rule at (0,2,0) is what actually lands.
*
* The Group already wraps by default (`--group-wrap` falls back to `wrap`), which matters
* once the row is centred: centred overflow spills off BOTH edges, and the first page would
* be unreachable rather than merely off-screen to the right.
*/
.wrolpi-pagination .mantine-Group-root {
justify-content: center;
}
.wrolpi-pagination button {
background: var(--panel);
color: var(--text);
border: 1px solid var(--border);
}
.wrolpi-pagination button[data-active] {
background: var(--blue);
color: var(--btn-text);
border-color: var(--blue);
}
/* Night and amber have no second hue to spend, so the current page is marked by a
heavier border and the panel surface instead of a filled block of color. */
html[data-theme="night"] .wrolpi-pagination button[data-active],
html[data-theme="amber"] .wrolpi-pagination button[data-active] {
background: var(--panel);
color: var(--text);
border: 2px solid var(--text);
}
.wrolpi-pagination button:disabled {
color: var(--muted);
background: var(--bg);
}
/* ---------------------------------------------------------------- Tab bar */
.wrolpi-tabs {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 0.75rem;
border-bottom: 1px solid var(--border);
/*
* No `margin-bottom`. A tab bar is always the first block of a page, inside the page's own
* stack, so the 0.875rem it used to carry was added to the stack's gap and put the bar 33px
* from the content it labels where every other pair of blocks sat 17.6px apart.
*/
}
.wrolpi-tabs-links {
display: flex;
flex-wrap: wrap;
}
.wrolpi-tabs-right {
padding-bottom: 0.375rem;
}
.wrolpi-tab {
display: inline-block;
padding: 0.5rem 0.875rem;
color: var(--muted);
text-decoration: none;
font-size: 0.8125rem;
/*
* A tab is a NavLink in the app's nav bars but a <button> on Inventory and in the
* gallery, and a button carries the UA's own `buttonface` background -- rgb(239,239,239).
* Only the active tab set a background, so every inactive one was a near-white block in
* dark, night and amber. The bar's own surface has to show through instead.
*/
background: transparent;
border: 1px solid transparent;
/* Sits on the bar's own rule, so the active tab can erase it and read as
connected to the panel below. */
margin-bottom: -1px;
}
.wrolpi-tab:hover {
color: var(--text);
}
.wrolpi-tab-active {
color: var(--text);
background: var(--panel);
border-color: var(--border);
border-bottom-color: var(--panel);
font-weight: 600;
}
.wrolpi-tab:focus-visible {
outline: 2px solid var(--blue);
outline-offset: -2px;
}
/* ------------------------------------------------------------- Search box */
.wrolpi-searchbox {
position: relative;
width: 100%;
}
.wrolpi-searchbox-control {
display: flex;
align-items: center;
/*
* One inset for the padding, the gap between children, and the negative margin that
* welds the clear button to the edge. They were three separate 8px literals that had to
* stay in lockstep: changing the padding alone would have pushed the clear button past
* the border, and changing the gap alone would have re-inset it.
*/
--searchbox-inset: 0.5rem;
gap: var(--searchbox-inset);
background: var(--panel);
border: 1px solid var(--border);
padding: 0 var(--searchbox-inset);
}
.wrolpi-searchbox-control:focus-within {
border-color: var(--blue);
}
html[data-theme="night"] .wrolpi-searchbox-control:focus-within,
html[data-theme="amber"] .wrolpi-searchbox-control:focus-within {
border-color: var(--text);
}
.wrolpi-searchbox-icon {
display: flex;
color: var(--muted);
}
.wrolpi-searchbox-input {
flex: 1;
min-width: 0;
padding: 0.5rem 0;
background: none;
border: none;
outline: none;
color: var(--text);
font: inherit;
font-size: 0.8125rem;
}
.wrolpi-searchbox-input::placeholder {
color: var(--muted);
}
.wrolpi-searchbox-loading,
.wrolpi-searchbox-clear {
flex: none;
}
/*
* The clear button is attached to the field, not floating inside it.
*
* The control is a bordered box with `padding: 0 8px`, so the button sat 9px short of the
* right edge and 2px short of the full height -- reading as a glyph dropped inside the input
* rather than as a control of its own. Semantic rendered this as an action button welded to
* the field: measured on the QA Pi, zero gap to the input, zero to the outer edge, and the
* full 44px height.
*
* A negative right margin rather than removing the control's padding: the padding is what
* holds the search icon and the text off the border, and only this one child should escape
* it. The gap to its LEFT is deliberately left alone -- cancelling that too welded the
* loading spinner to the divider whenever a field was both `loading` and `clearable`, which
* SearchResultsInput does, and the spinner is not part of this control.
*
* `html` because Mantine sets ActionIcon's height from `--ai-size` on its own single class;
* at equal specificity the winner is whichever stylesheet loaded last, which is not a thing
* to rely on. `min-height` has to go with it: stretch cannot shrink a box below its own
* minimum, so leaving that at `--ai-size` would mean this rule only half-owned the height it
* claims to set -- true today only because the field happens to be the taller of the two.
*
* The corner radius needs no rule: the theme zeroes every `--mantine-radius-*`, so there are
* no rounded corners to leave panel-colored wedges against the square field.
*/
html .wrolpi-searchbox-clear {
align-self: stretch;
height: auto;
min-height: unset;
margin-right: calc(-1 * var(--searchbox-inset));
border-left: 1px solid var(--border);
}
/*
* The suggestion list overlays the page rather than pushing it down — a search
* that reflows the results underneath it is unusable while typing.
*/
.wrolpi-searchbox-results {
position: absolute;
z-index: 20;
top: 100%;
left: 0;
right: 0;
/*
* Tall enough for a full set of grouped suggestions -- directories, channels and
* domains together run past 320px, and the last group was being clipped -- but
* still capped so a long list cannot cover the page.
*
* 70vh, raised from 60. The rem ceiling scales with the interface but a fraction of
* the VIEWPORT does not, so when the type grew 10% the rows grew with it and the cap
* did not: the full set now needs about 420px, and 60vh of a 660px-tall window is
* 396px, so the last group was clipped again -- the exact defect this comment already
* describes. 70vh is 462px there, and the 30rem ceiling still bounds it on a tall
* window, so it cannot cover the page either way.
*
* `directory-search.cy.js` is the regression test: it fails at 660px of viewport
* height and passes from 700px up, which is how the shortfall was measured.
*/
max-height: min(70vh, 30rem);
overflow-y: auto;
background: var(--panel);
border: 1px solid var(--border);
border-top: none;
}
/*
* Inside the global search modal the suggestions ARE the panel's content, so they sit in
* flow instead of floating over the page.
*
* Overlaying is right everywhere else -- a list that reflows the page while you type is
* unusable -- but in a modal it fails twice. An absolutely positioned list contributes no
* height, so the panel sized itself to the search box alone (114px), and the panel's own
* `overflow: auto` then clipped the list at that edge: the modal showed an input and the
* first line of the first group. In flow, the panel grows to fit and its own max-height
* does the scrolling.
*/
.wrolpi-search-modal .wrolpi-searchbox-results {
position: static;
max-height: min(50vh, 26.25rem);
}
.wrolpi-searchbox-group-name {
padding: 0.375rem 0.625rem;
font-size: 0.6875rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
background: var(--head);
}
.wrolpi-searchbox-result {
padding: 0.4375rem 0.625rem;
cursor: pointer;
border-top: 1px solid var(--border);
}
.wrolpi-searchbox-result-active {
background: var(--head);
}
.wrolpi-searchbox-result-title {
font-size: 0.8125rem;
color: var(--text);
overflow-wrap: anywhere;
}
.wrolpi-searchbox-result-description {
font-size: 0.6875rem;
color: var(--muted);
}
.wrolpi-searchbox-empty {
padding: 0.625rem;
font-size: 0.75rem;
color: var(--muted);
}
/* ---------------------------------------------------------------- Modals */
/*
* Actions sit below the body, separated by a hairline rather than a raised bar.
* Right-aligned, so the confirming action lands where the eye finishes reading.
*/
.wrolpi-modal-actions {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: 0.625rem;
/* Bleed back out through the modal body's own padding so the rule spans the
full width; -1 * whatever Mantine padded the body with. */
margin: 1.125rem calc(var(--mantine-spacing-md) * -1) calc(var(--mantine-spacing-md) * -1);
padding: 0.75rem var(--mantine-spacing-md);
border-top: 1px solid var(--border);
}
/* A confirmation's buttons: under the question, no separating rule. See Confirm. */
.wrolpi-confirm-actions {
display: flex;
justify-content: flex-end;
gap: 0.625rem;
margin-top: 1.125rem;
}
/* A stack of form rows, replacing Semantic's `ui stackable grid` in edit forms. */
.wrolpi-form-rows {
display: flex;
flex-direction: column;
gap: 0.875rem;
}
/* ------------------------------------------------------------- Form fields */
/*
* A Pill is what MultiSelect renders for each chosen option, and it is the one place
* Mantine does not honour the radius theme. MultiSelect leaves `radius` undefined, so
* Pill emits no `--pill-radius` at all and its stylesheet falls back to
* `border-radius: var(--pill-radius, 1000rem)` -- a literal in Mantine's own CSS, which
* zeroing `--mantine-radius-xl` never touches. Defining the variable is what stops the
* fallback from ever applying. Everything else in the library has hard corners.
*/
.mantine-Pill-root {
--pill-radius: 0;
border-radius: 0;
}
/*
* `hooks/useForm.js` renders its own <label> and native <input> — it always did, with
* Semantic's Form.Input only wrapping them for field styling. These rules supply that
* styling from tokens instead, so a native input matches the library's own controls.
*/
.wrolpi-form-field input {
width: 100%;
padding: 0.4375rem 0.5625rem;
background: var(--panel);
color: var(--text);
border: 1px solid var(--border);
font: inherit;
font-size: 0.8125rem;
}
.wrolpi-form-field input::placeholder {
color: var(--muted);
}
.wrolpi-form-field input:focus {
outline: none;
border-color: var(--blue);
}
html[data-theme="night"] .wrolpi-form-field input:focus,
html[data-theme="amber"] .wrolpi-form-field input:focus {
border-color: var(--text);
}
/* An invalid field is dashed, matching every other failure state in the interface. */
.wrolpi-form-field[data-error] input {
border: 1.5px dashed var(--danger);
}
.wrolpi-form-field input:disabled {
color: var(--muted);
background: var(--bg);
}
/* --------------------------------------------------------------- Path input */
/*
* Prefix and input are siblings in a flex row sharing one outline, so the prefix cannot
* print over the value -- the failure that Mantine's absolutely-positioned `leftSection`
* allowed when its reserved width was wrong. The prefix takes its natural width, so any
* media directory fits without a number being maintained anywhere.
*/
.wrolpi-path-input-label {
display: block;
font-weight: 600;
font-size: 0.8125rem;
margin-bottom: 0.25rem;
}
.wrolpi-path-input-control {
display: flex;
align-items: stretch;
background: var(--panel);
border: 1px solid var(--border);
}
.wrolpi-path-input-control:focus-within {
border-color: var(--blue);
}
html[data-theme="night"] .wrolpi-path-input-control:focus-within,
html[data-theme="amber"] .wrolpi-path-input-control:focus-within {
border-color: var(--text);
}
.wrolpi-path-input-control[data-error] {
border: 1.5px dashed var(--danger);
}
.wrolpi-path-input-control[data-disabled] {
background: var(--bg);
}
/*
* Muted and on the header surface: it reads as context, not as something to edit.
*
* It shrinks and ellipsises rather than holding its full width. The prefix is whatever the
* API reports as the media directory -- `/media/wrolpi` on a Pi, but a development box or an
* unusual Docker mount can report a much longer path -- and at `flex: none` a long one took
* the whole row and left the input 18px wide, a field nobody could type into. Capping it at
* half the control keeps the editable part editable; the full path stays available through
* `title` and is read out in full by the accessible description either way.
*/
.wrolpi-path-input-prefix {
display: flex;
align-items: center;
flex: 0 1 auto;
min-width: 3ch;
max-width: 50%;
overflow: hidden;
text-overflow: ellipsis;
padding: 0 0.375rem 0 0.5625rem;
background: var(--head);
border-right: 1px solid var(--border);
color: var(--muted);
font-size: 0.75rem;
white-space: nowrap;
user-select: none;
}
.wrolpi-path-input-control input {
flex: 1;
min-width: 0;
padding: 0.4375rem 0.5625rem;
background: none;
border: none;
outline: none;
color: var(--text);
font: inherit;
font-size: 0.8125rem;
}
.wrolpi-path-input-control input::placeholder {
color: var(--muted);
}
.wrolpi-path-input-control input:disabled {
color: var(--muted);
}
.wrolpi-path-input-description {
font-size: 0.75rem;
color: var(--muted);
margin-top: 0.1875rem;
}
.wrolpi-path-input-error {
font-size: 0.75rem;
color: var(--danger);
margin-top: 0.1875rem;
}
/* ---------------------------------------------------------- Action inputs */
/*
* Input and button share one outline: the input gives up its right border and
* the button's left border becomes the seam between them.
*/
.wrolpi-action-input {
display: flex;
align-items: stretch;
width: 100%;
}
.wrolpi-action-input > .mantine-TextInput-root {
flex: 1;
min-width: 0;
}
.wrolpi-action-input .mantine-TextInput-input {
border-right-width: 0;
}
.wrolpi-action-input > :not(.mantine-TextInput-root) {
flex: none;
}
/* A colored label beside an input, unlike ActionInput's shared outline: they stay two
controls, so the gap between them is real and scales with the type. */
.wrolpi-colored-input {
display: flex;
align-items: stretch;
gap: 0.375rem;
}
/* ------------------------------------------------- Loading & placeholders */
/*
* A centered loader with a caption, and the skeleton stack that stands in for text.
* Both were inline px in Feedback.tsx, which put them outside the interface scale.
*/
.wrolpi-loading {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.625rem;
}
.wrolpi-loading-caption {
font-size: 0.8125rem;
color: var(--muted);
}
.wrolpi-placeholder {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
/* --------------------------------------------------------------- Switches */
/*
* Mantine's unchecked track is a gray from its own palette, which would put a
* non-red pixel on screen in night mode. Scoped to `:not(:checked)` so the
* checked track keeps coming from the `color` prop.
*/
/*
* Semantic's inverted stylesheet colors *every* descendant of `.ui.inverted.segment`
* (rgba(255,255,255,.9), and #dddddd on labels). Our components are mounted inside
* unmigrated pages that still use those wrappers, so an inherited color puts white
* pixels on screen in night mode. Restating from tokens is the fix; the rule can go
* when the last Semantic wrapper does.
*/
.wrolpi-switch .mantine-Switch-label {
color: var(--text);
}
.wrolpi-switch .mantine-Switch-description {
color: var(--muted);
}
.wrolpi-switch input:disabled + * .mantine-Switch-label,
.wrolpi-switch input:disabled ~ * .mantine-Switch-label {
color: var(--muted);
}
.wrolpi-switch input:not(:checked) + .mantine-Switch-track {
--switch-bg: var(--border);
}
/*
* The disabled thumb has the same problem: Mantine sets it from its gray palette,
* on the thumb element itself, which is a near-white pixel in night mode.
*/
.wrolpi-switch input:disabled + * > .mantine-Switch-thumb,
.wrolpi-switch input[data-disabled] + * > .mantine-Switch-thumb {
--switch-thumb-bg-disabled: var(--muted);
}
/* ----------------------------------------------------------------- Labels */
/*
* Sized to Semantic's label (12px text, ~7px of vertical padding), which these replaced.
* The first pass came out at 11.5px with 3px of padding and read as noticeably smaller than
* what users had before -- a tag is a hit target as well as a word.
*/
.wrolpi-label {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.3125rem 0.625rem;
font-size: 0.78125rem;
font-weight: 500;
line-height: 1.3;
background: var(--label-color);
/*
* `--label-text` is set per-tag by Tags.js, which calculates black or light text against
* the user's chosen fill. The fallback covers every label whose fill is a theme token.
* It is a variable rather than an inline `color` so that a theme which discards the fill
* can also discard the text color calculated for it.
*/
color: var(--label-text, var(--btn-text));
border: 1px solid transparent;
}
/* ------------------------------------------------------------------- Tags */
/*
* A tag is a label with a physical shape: a pointed left edge and an eyelet, like a tag
* tied to a garment. The point is a square rotated 45 degrees behind the left edge -- its
* right half sits under the body in the same color and merges, leaving the left half
* showing as the point. At 21px a side its diagonal is 29.7px, within half a pixel of the
* body's 30.2px height, so the two edges of the point meet the body at its corners. (Design
* px throughout: the interface scale multiplies both, so the match holds at any size.)
*
* The eyelet is drawn in `currentColor`, so it reads as a hole punched through the tag on
* any surface, filled or outlined, without needing to know what is behind it.
*/
.wrolpi-tag {
position: relative;
/*
* Larger than a plain label, which is the one place the two part company. A label
* annotates something else -- the count beside a tab's name, the page number in the CBZ
* viewer -- and wants to sit under the text it belongs to. A tag is the whole content of
* its chip and the thing a wall of them is scanned for, so at 78% of body text it read as
* a footnote on the dashboard, where the tags are the page.
*/
font-size: 0.875rem;
/*
* Room for the point, which is drawn outside the body: half its diagonal (14.85px), plus
* the 1px it is nudged left, plus the 1px border it is positioned inside of, rounded up.
* This is the one length here that grew: 16px covered the smaller point, and the larger
* one overhung it by a third of a pixel, which is enough to clip against the tag before it.
*/
margin-left: 1.0625rem;
/* Tags wrap, and a wrapped row was sitting on the row above it. */
margin-bottom: 0.3125rem;
/*
* The point is centred on the left edge, so its inner half covers the body's first 10.5px
* -- and a positioned pseudo-element paints above the parent's text, not behind it, so
* without this the first letter is hidden under the point. Semantic's tag label carried
* the same asymmetry (`padding-left: 1.5em`) for the same reason.
*/
padding-left: 0.8125rem;
}
.wrolpi-tag::before {
content: '';
position: absolute;
top: 50%;
right: 100%;
/*
* Tied to the body's height, not chosen: the diagonal has to equal it or the point's two
* edges meet the body short of its corners. So this grew with the font -- 21px a side for
* the 30.2px body, where 20px served the 28.2px body the tag had at 12.5px text.
*/
width: 1.3125rem;
height: 1.3125rem;
/*
* Nudged 1px left of where `right: 100%` puts it. That property positions against the
* containing block's *padding* box, and the body carries a 1px border, so a square centred
* there sits a pixel inside the visible left edge -- and the point read as set slightly
* into the tag rather than growing out of it. This is exactly that border: 2px opened a
* visible gap on the inner side, because the point's edges then meet the body's left edge
* short of its corners and leave the difference showing.
*/
transform: translate(calc(50% - 1px), -50%) rotate(45deg);
background: var(--label-color);
}
/*
* The eyelet sits in the point, where a real tag's hole is -- inside the body it reads as a
* bullet in front of the word. It is outside the element's own box, over the ::before, and
* paints on top of it by being the later pseudo-element.
*/
.wrolpi-tag::after {
content: '';
position: absolute;
top: 50%;
left: -0.4375rem;
width: 0.3125rem;
height: 0.3125rem;
/*
* Centred by translation rather than by a negative margin of half its own height.
* The margin was -2.5px against a 0.3125rem dot: once the dot scaled and the margin
* did not, the hole drifted off the point's centre. A percentage cannot drift.
*/
transform: translateY(-50%);
border-radius: 50%;
background: currentColor;
/* A hole, not a dot: it should read as absence rather than as a bullet. */
opacity: 0.45;
}
/*
* White and black labels would vanish into the surface they sit on. They set the text
* color through `--label-text` rather than `color` for the same reason the per-tag color
* does: setting `color` here outranked the base rule, so amber -- which replaces every fill
* with its own and has no `color` rule of its own -- was left painting `--white` on `--text`,
* light orange on orange.
*/
.wrolpi-label-white {
--label-text: var(--black);
border-color: var(--border);
}
.wrolpi-label-black {
--label-text: var(--white);
border-color: var(--border);
}
/*
* Night mode: a filled label is a bright patch, and a wall of tags would be a
* wall of light. Every label becomes an outline instead.
*/
html[data-theme="night"] .wrolpi-label {
background: transparent;
color: var(--label-color);
border-color: var(--label-color);
}
/*
* Night draws the tag as an outline, so the point has to be two strokes rather than a solid
* diamond, and the body's own left border has to go -- otherwise a line closes across the
* base of the point and it reads as a chip with a triangle stuck to it.
*/
html[data-theme="night"] .wrolpi-tag {
border-left-color: transparent;
}
html[data-theme="night"] .wrolpi-tag::before {
background: transparent;
border-left: 1px solid var(--label-color);
border-bottom: 1px solid var(--label-color);
}
/* ---------------------------------------------------------------- Headers */
.wrolpi-header {
margin: 0 0 0.625rem;
}
/*
* A heading opens a section, so it needs more air above it than below — it belongs to
* what follows, not to what came before. With no top margin at all a heading sat flush
* against the panel above it, which read as part of that panel rather than as the start
* of the next thing (visible on /more/statistics, where "Tags" touched the panel above).
*
* `* +` rather than a blanket margin: a heading that opens a Panel or a page is already
* where it should be, and pushing it down would just dent the top of its own container.
*/
* + .wrolpi-header {
margin-top: 1.375rem;
}
/* The heading and its trailing control share one row. */
.wrolpi-header-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
/*
* Trailing content keeps the heading's row but not its size: a help button rendered at
* heading scale is a 26px glyph next to a 17px word.
*
* `flex-shrink: 0` states the intent -- a control is not the thing that should give way,
* a long title should wrap instead. Declared, but NOT covered by a test: with the row's
* only other item being a heading, the heading's automatic minimum size absorbs the
* pressure and no width could be found where removing this changes what is painted. It
* is here for the day the slot holds something beside a heading that can shrink, not
* because a case exists today.
*/
.wrolpi-header-after {
display: inline-flex;
align-items: center;
flex-shrink: 0;
font-size: 0.8125rem;
font-weight: 400;
}
.wrolpi-header-dividing {
border-bottom: 1px solid var(--border);
padding-bottom: 0.375rem;
}
.wrolpi-header-text {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
/*
* Stated explicitly because semantic.min.css sets `h1,h2,h3,h4,h5 { font-family:
* Lato }` and loads after Mantine's element rule, so headings would ignore the
* theme's body face — visible in amber, where the body is mono and headings were
* not. A class outranks the element selector. Can go once Semantic is gone.
*/
font-family: var(--font-body);
font-weight: 600;
line-height: 1.25;
color: var(--text);
text-wrap: balance;
}
/* The scale lives here so no call site invents a size of its own. */
.wrolpi-header-h1 {
font-size: 1.625rem;
}
.wrolpi-header-h2 {
font-size: 1.3125rem;
}
.wrolpi-header-h3 {
font-size: 1.0625rem;
}
.wrolpi-header-h4 {
font-size: 0.875rem;
}
.wrolpi-header-h5 {
font-size: 0.8125rem;
letter-spacing: 0.02em;
}
.wrolpi-header-sub {
font-size: 0.75rem;
color: var(--muted);
margin-top: 0.1875rem;
}
/*
* Night and amber are single-hue by definition, and a call site may legitimately want
* many distinguishable colors — the Flasher's chip-family badges set `--label-color`
* inline from a twelve-color palette that no four tokens can express.
*
* In those themes the palette is overridden back to the theme's own text color, so a
* label cannot put a green or blue pixel on a night-adapted screen. `!important` is
* required because the palette arrives as an inline custom property, which a normal
* rule cannot outrank. The labels stop being distinguishable by color, which is the
* correct trade: the theme's whole purpose is that no other hue reaches the eye.
*/
html[data-theme="night"] .wrolpi-label,
html[data-theme="amber"] .wrolpi-label {
--label-color: var(--text) !important;
/*
* The per-tag text color goes with the fill it was calculated against. Tags.js works
* out black-or-light from the user's chosen hex, and once that hex is discarded the
* answer is not just useless but wrong: amber was painting `#dddddd` on `#ff9500`, about
* 1.9:1 and a neutral grey in a theme whose whole point is that nothing else is. Night,
* which is an outline over a near-black page, was painting that black text on nothing.
*
* There is no contrast to calculate here, which is why this is a token and not a second
* version of `contrastingColor`: the fill is the theme's own, so the answer is fixed.
*/
--label-text: var(--btn-text) !important;
}
/* ----------------------------------------------------------------- Toasts */
/*
* Notifications take their colors from our tokens rather than from Mantine's color scheme.
*
* Mantine paints a dark-scheme notification title with `--mantine-color-white`, and the
* bridge aliases that to `--btn-text` -- deliberately, because it is the text drawn on a
* filled button, which is near-black in dark, night and amber. So the title came out at
* rgb(28, 31, 33) on a rgb(38, 41, 43) surface: about 1.1:1, present and invisible. Fixing
* the alias is not an option; it is correct for the buttons it exists for.
*
* The `html` prefix is only there to outrank Mantine's single-class rules. One set of values
* covers every theme, because the tokens are what differ.
*/
html .mantine-Notification-root {
background-color: var(--panel);
border-color: var(--border);
}
html .mantine-Notification-title {
color: var(--text);
}
html .mantine-Notification-description {
color: var(--muted);
}
/*
* The description is the message itself, so it has to be read, and `--muted` is too dim to
* read on a panel in the two monochrome themes -- 2.0:1 in night, 3.1:1 in amber, against
* 5.1:1 and 5.7:1 in dark and light. Those two drop the muting and let the title's weight
* carry the hierarchy instead, which is the same brightness-over-hue trade the rest of night
* makes.
*/
html[data-theme="night"] .mantine-Notification-description,
html[data-theme="amber"] .mantine-Notification-description {
color: var(--text);
}
html .mantine-Notification-closeButton {
color: var(--muted);
}
html .mantine-Notification-closeButton:hover {
background-color: var(--head);
color: var(--text);
}
/* --------------------------------------------------------------- Messages */
.wrolpi-message {
display: flex;
align-items: flex-start;
gap: 0.625rem;
border: 1px solid var(--border);
border-left: 3px solid var(--message-color, var(--border));
background: var(--panel);
padding: 0.625rem 0.875rem;
font-size: 0.8125rem;
}
.wrolpi-message-content {
flex: 1;
min-width: 0;
}
/* The icon repeats what the border color already says, so it takes that color. */
.wrolpi-message-icon {
color: var(--message-color, var(--text));
display: flex;
padding-top: 1px;
}
.wrolpi-message-dismiss {
background: none;
border: none;
padding: 0;
color: var(--muted);
cursor: pointer;
display: flex;
/* Widen the tap target past the glyph without moving it. */
margin: -0.375rem -0.375rem -0.375rem 0;
padding: 0.375rem;
}
.wrolpi-message-dismiss:hover {
color: var(--text);
}
.wrolpi-message-dismiss:focus-visible {
outline: 2px solid var(--blue);
outline-offset: -2px;
}
.wrolpi-message-title {
font-weight: 600;
margin-bottom: 0.125rem;
}
.wrolpi-message-body {
color: var(--muted);
}
/* Errors take the danger treatment in every theme, so "dashed" stays one rule. */
/*
* The dashed frame tracks the role the message was given, exactly as its accent and
* icon already do. Naming a hue here meant the frame and the accent could disagree --
* invisible in night, where they are nearly the same pixel, but still wrong.
*/
.wrolpi-message-error {
border: 1.5px dashed var(--message-color);
}
/* --------------------------------------------------------------- Progress */
/*
* Tall enough for the label inside it. At 16px a 14px line box left half a pixel of air
* above and below the text, which on the Status page is 44 bars of cramped 11px bold --
* legible only if you already knew what it said.
*
* `overflow: hidden` is what keeps the indeterminate band inside the bar. Without it the
* band slides from `margin-left: -35%` to `100%` in full view, so the animation ran from
* outside the left edge to outside the right one, painting over whatever sat beside it.
*
* No border: the track is drawn by its own fill instead, so `--head` rather than `--bg`.
* A bar sits on a Panel as often as on the page, and `--bg` is invisible against one of
* those two by definition -- `--head` is off both in every theme. Height and min-width
* are the outer box (`box-sizing: border-box` is global), so dropping the 1px border left
* the bar exactly the size it was.
*/
.wrolpi-progress {
position: relative;
height: 1.375rem;
min-width: 7.5rem;
overflow: hidden;
background: var(--head);
}
/*
* Bars stacked directly on top of each other need a gap; bars that a container already
* spaces do not. On the Status page the CPU and RAM bars are bare siblings in a Panel and
* were touching, while the drive-bandwidth bars each sit alone in a Grid column and get
* their spacing from the Grid.
*
* `* +` distinguishes those two cases by itself: it fires only when a bar actually follows
* something, so an only-child bar in a column is untouched.
*/
* + .wrolpi-progress {
margin-top: 0.5rem;
}
.wrolpi-progress-fill {
height: 100%;
background: var(--progress-color, var(--blue));
transition: width 0.2s linear;
}
@media (prefers-reduced-motion: reduce) {
.wrolpi-progress-fill {
transition: none;
}
}
/*
* Indeterminate: a sliding band, for work whose size is not known yet. Semantic called
* this `indicating`; without it an upload that has not reported its size sits at 0% and
* reads as stalled.
*/
.wrolpi-progress-indeterminate .wrolpi-progress-fill {
width: 35%;
animation: wrolpi-progress-slide 1.4s ease-in-out infinite;
}
@keyframes wrolpi-progress-slide {
from {
margin-left: -35%;
}
to {
margin-left: 100%;
}
}
/* Motion is the only signal here, so when it is suppressed the bar fills instead. */
@media (prefers-reduced-motion: reduce) {
.wrolpi-progress-indeterminate .wrolpi-progress-fill {
animation: none;
width: 100%;
opacity: 0.5;
}
}
.wrolpi-progress-text {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
/* 12px rather than 11: these labels carry units and device names, not just a number. */
font-size: 0.75rem;
font-weight: 700;
color: var(--text);
font-variant-numeric: tabular-nums;
/*
* A long label is a real case, not a hypothetical: uploads label with a filename
* (Upload.js, FileBrowser.js) and a failed refresh labels with the error string.
* Without `nowrap` it wraps to a second line inside a 22px bar and the clip bisects
* it; with a hard clip and no ellipsis, `Complete: very-long-archive-name.tar.gz`
* ends mid-glyph and reads as broken rather than truncated.
*/
padding: 0 0.375rem;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
/*
* The percent text sits on both the filled and empty halves of the bar, so the
* fill has to stay light enough in light mode for dark text to read over it.
*
* MEASURED, and only light actually clears it. The label against the fill is
* 5.49:1 in light (the brightness filter below is what buys that) but 2.35 in
* dark, 2.48 in night and 2.74 in amber; against the empty track it is 5.2-15:1
* everywhere. Reaching AA over the fill needs the label drawn twice, clipped to
* the fill boundary, so each half gets its own color -- and in night not even
* that would do it, since `--text` is only ~4:1 against pure black and the fill
* is lighter than black by definition. Left as it is, deliberately, rather than
* asserted at a threshold chosen to pass.
*/
html[data-theme="light"] .wrolpi-progress-fill {
filter: brightness(1.75) saturate(0.55);
}
/* Night: a mostly-full bar must not become a large bright surface. */
html[data-theme="night"] .wrolpi-progress-fill,
html[data-theme="amber"] .wrolpi-progress-fill {
background: var(--muted);
}
/* ----------------------------------------------------------------- Stacks */
/*
* The space between stacked blocks, owned by whatever stacks them rather than by the blocks.
*
* `PageContainer` puts this on every page, so a page's own blocks are spaced without asking. A
* component that stacks blocks one level deeper -- Settings holds its six panels in a wrapper of
* its own -- puts it on that wrapper. Mantine's `Stack` is the same thing with the same 1rem gap
* and is equally correct where a call site already reaches for it.
*
* `gap`, not the `> * + * { margin-top }` this was written as first. The margin version is the
* older recommendation and it is quietly broken here: the responsive `Media` component injects
* `.fresnel-container { margin: 0; padding: 0 }` into the document at runtime, which has the same
* specificity as `.wrolpi-stack > * + *` and comes later in source order -- so every block a
* page wraps in `Media`, which on Status is the one carrying the CPU and RAM bars, threw the
* margin away and went on touching its neighbour. A gap belongs to the parent and a child cannot
* zero it.
*
* It also handles `Media`'s hidden half for free: the branch for the other viewport is
* `display: none`, so it is not a flex item and contributes no gap, where `* + *` would have
* spaced around something invisible.
*
* `align-items: stretch` is the default and is what keeps every block full-width, as it was as a
* block element. Heights are unaffected: the column is auto-height, so nothing shrinks, and the
* map's 85vh box and the CBZ viewer's full-height page measure the same as before.
*/
.wrolpi-stack {
display: flex;
flex-direction: column;
gap: 1rem;
}
/*
* The same idea along the other axis: a row of buttons, spaced by the row.
*
* Semantic's Button carried `margin: 0 .25em .25em 0`, so a page could list buttons as bare
* siblings and they arrived both spaced and, when the row ran out of width, separated from the line
* below. Nothing replaced that margin -- the archive page's six actions shared edges, and the
* downloads table's two icon buttons did too.
*
* Two of the archive page's six had `marginTop: 0.5em` instead, which is where the "Update sits
* lower than the rest" came from: a Button is `display: inline-flex; vertical-align: middle`, and
* `vertical-align` aligns the MARGIN box, so half of any top margin shows up as the visible button
* dropping. The margin was solving the wrapped-row case in the only axis a margin can, and paying
* for it in the other one. A gap covers both at once, so no button needs a margin of its own; the
* scans in Common.test.js are what keep them from growing back.
*
* 0.625rem matches `.wrolpi-modal-actions`, which is the same row in a modal footer.
*/
.wrolpi-button-row {
display: flex;
flex-wrap: wrap;
/*
* Centred, not stretched. Rows here are not all buttons -- the doc page puts its format
* picker beside its actions, and the downloads table its error trigger -- and a taller item
* makes the line taller than a button. `stretch` would leave the buttons hanging from the
* top of it; centring keeps a row of unequal things reading as one line.
*/
align-items: center;
gap: 0.625rem;
}
/* ----------------------------------------------------------------- Panels */
.wrolpi-panel {
background: var(--panel);
border: 1px solid var(--border);
padding: 0.875rem 1rem;
/*
* Deliberately NO outer margin. A panel is dropped into pages, modals, Stacks and grids, and
* how far it sits from its neighbour is a property of that relationship rather than of the
* panel -- so the space between stacked panels is owned by whatever stacks them
* (`.wrolpi-stack` for a page, Mantine's `Stack` gap inside a modal).
*
* This was tried the other way first, as `margin-bottom: 1rem` here, and it went wrong in
* both of the documented ways within the hour: it compounded with the gap of every `Stack`
* that already held panels (four of them, spacing those lists at 35.2px against the app's
* 17.6px), and the `:last-child` reset that would have kept the trailing space off the bottom
* of a container silently did nothing, because WROLPi wraps panels in the responsive `Media`
* component and a panel is frequently the only child of its own wrapper.
*/
}
/* Danger zones are dashed in every theme; it doubles as a colorblind-safe cue. */
.wrolpi-panel-danger {
border: 1.5px dashed var(--danger);
}
/* ----------------------------------------------------------- Card posters */
/*
* The icon shown when a file has no poster, and the corner mark for a tagged
* file. Both live here rather than in Common.js so the card surface and the
* poster area agree in every theme.
*/
/*
* A poster is bounded by the card and keeps whatever ratio the source image has. It used
* to carry a fixed 290x163 box, which is wider than a card in a default grid (~233px at
* desktop widths), so `overflow: hidden` on the card cropped the sides off and the poster
* read as the wrong aspect ratio.
*
* Both caps are needed. Bounding the width alone lets a square thumbnail stand as tall as
* the card is wide, and a portrait book cover taller still, so the media band dwarfs the
* title it belongs to. `width` and `height` stay `auto` so the pair behaves as "contain":
* whichever cap bites first, the other axis comes down with it and the ratio survives.
*/
:root {
--card-poster-max-height: 12.5rem;
/* How far a disabled control fades. Semantic used 0.45 and it reads correctly against
every theme's panel; the tests read this rather than repeating the number. */
--disabled-opacity: 0.45;
}
/*
* The band is the full width of the card and centres the poster in it. The frame inside
* shrink-wraps the painted image, and is what corner chrome -- the tagged mark, a video's
* duration -- is positioned against. Pinning that chrome to the BAND puts it in the
* letterbox space beside a height-capped poster: 39px adrift on a book cover, 60px on a
* vertical video.
*
* The frame's own width falls out of the caps below. Its intrinsic contribution is the
* image's natural width already reduced by `max-height`, so a portrait cover contributes
* 155px and gets 155px; a 16:9 poster contributes 1280px and is flex-shrunk to the band,
* at which point `max-width: 100%` resolves against a width that is finally definite.
*/
.wrolpi-card-poster {
display: flex;
justify-content: center;
/*
* The band is the query container for the cap below, so the cap can be a fraction of the
* card's own width. Containment is safe here: the band's width comes from the card, never
* from the image inside it, and it has no fixed-position descendants.
*/
container-type: inline-size;
}
/*
* The card's text block.
*
* These lived as inline px in Card's JSX, which put them outside the interface scale --
* `fontSize: 13` is 13px whatever the root font-size is, so a card's title would have
* stayed put while the prose around it grew. Rules now, for the same reason every other
* size is one: a single place to retune, and ui-scale.test.js can see them.
*/
.wrolpi-card-body {
display: flex;
flex-direction: column;
gap: 0.25rem;
flex: 1;
padding: 0.625rem 0.75rem 0.75rem;
}
.wrolpi-card-title {
font-size: 0.8125rem;
font-weight: 600;
line-height: 1.35;
}
.wrolpi-card-meta {
font-size: 0.75rem;
color: var(--muted);
}
/* The foot: `auto` pushes the actions down so a row of cards lines its buttons up
however tall the titles above them got. ui-layout.cy.js measures that. */
.wrolpi-card-actions {
margin-top: auto;
padding-top: 0.5rem;
}
/* A grid of cards. The track width is the caller's `minWidth`, converted to rem in
CardGroup so a column scales with the type -- which is what returns a phone to one
card per row, as the pre-migration build had it. */
.wrolpi-card-group {
display: grid;
gap: 0.875rem;
}
.wrolpi-card-poster-frame {
position: relative;
display: block;
min-width: 0;
}
.wrolpi-card-poster img {
display: block;
width: auto;
height: auto;
max-width: 100%;
/*
* The cap grows with the card, so a landscape poster can reach the card's edges.
*
* A fixed height stops scaling: held to 220px, a 16:9 thumbnail is 391px wide, so any card
* wider than that centred it with empty panel on both sides. That is every video card at
* one column -- a phone, or any window under about 456px of content -- which is where the
* whole library is browsed on a phone. The cases below all used a 233px card, so it went
* unseen for the entire migration.
*
* `56.25cqw` is nine sixteenths of the band: exactly the height of a 16:9 image that fills
* the width, and the same number states the rule for every other ratio -- a poster may be
* as tall as a 16:9 slice of this card and no taller. So a book cover still reads as a
* band rather than a column, and now grows with the card instead of stopping at 220px.
*
* `max()`, not `min()`: on a narrow card the fraction is the smaller value and the floor
* has to win, or the cap would tighten as cards got smaller and crop a portrait cover.
*
* Declared twice on purpose. `cqw` needs container queries (Chrome 105, Safari 16), and an
* engine that does not know the unit throws out the WHOLE declaration -- which would leave
* the poster with no height cap at all, so a portrait cover would stand as tall as the card
* is wide. The plain cap first means such an engine still clamps, just without the
* card-relative growth; anything newer takes the second and ignores the first.
*/
max-height: var(--card-poster-max-height);
max-height: max(var(--card-poster-max-height), 56.25cqw);
}
.wrolpi-card-icon {
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-height: 7.5rem;
background: var(--head);
}
.wrolpi-card-tag {
position: absolute;
top: 0;
left: 0;
display: flex;
padding: 0.1875rem 0.25rem;
background: var(--green);
color: var(--btn-text);
}
/* Night and amber have no green to spend; the mark reads as an outline instead. */
html[data-theme="night"] .wrolpi-card-tag,
html[data-theme="amber"] .wrolpi-card-tag {
background: var(--panel);
color: var(--text);
border-right: 1px solid var(--border);
border-bottom: 1px solid var(--border);
}
/* ------------------------------------------------------------- Statistics */
/*
* A statistic carries its own spacing rather than relying on a parent to supply
* it, so that one dropped on a page by itself still sits clear of its
* neighbours. Semantic's `Statistic.Group` only spaced elements carrying
* Semantic's own classes, so without this the labels ran together into one
* unreadable line.
*/
.wrolpi-statistic {
display: inline-block;
vertical-align: top;
margin: 0 1.625rem 0.625rem 0;
}
/*
* The group is spacing and nothing else: no frame, no rules between cells, and
* no surface of its own, so the statistics sit on whatever they are dropped onto
* -- the page in one place, a Panel in another -- and pick up its color.
*
* That also settles a question this went round twice on. Ruling the cells needs
* a technique that survives wrapping, because `auto-fit` decides the number of
* rows at paint time: a `border-right` per cell rules columns but not rows, and
* a border-colored background behind a 1px gap rules both but paints every
* track no cell landed in. With no rules at all, neither problem exists.
*
* The gap is wider across than down. Two statistics side by side have nothing
* between them but air, so they need enough of it not to read as one column of
* text; stacked ones are already separated by the label's line.
*/
.wrolpi-statistic-group {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(9.375rem, 1fr));
gap: 1.125rem 1.875rem;
}
/*
* With the frame gone there is nothing holding the group off whatever precedes it, and on
* most pages that is a Header -- the first row of values sat directly against the heading.
*
* Only when something does precede it, though. Apps opens each of its Panels with a group,
* and a blanket `margin-top` there pushed the values down off the panel's own top padding
* while leaving the bottom where it was, so the whole panel read as lopsided.
*/
* + .wrolpi-statistic-group {
margin-top: 1.125rem;
}
/*
* A statistic that renders nothing -- Status omits the fan reading on a device
* with no fan connector -- must not hold a track open. The group cannot know in
* advance, since it would have to render the child to find out, so the cell is
* emitted either way and this takes the empty ones back out. It is also why the
* cells carry no inline style: there is nothing here for it to outrank.
*/
.wrolpi-statistic-cell:empty {
display: none;
}
/* Inside our own group the grid does the spacing, so the intrinsic margin goes. */
.wrolpi-statistic-cell .wrolpi-statistic {
display: block;
margin: 0;
}
.wrolpi-statistic-value {
font-size: 1.5rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
line-height: 1.15;
}
.wrolpi-statistic-label {
font-size: 0.6875rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
margin-top: 0.125rem;
}
/* ---------------------------------------------------------------- Status */
/*
* One rule for four states in four themes. The color is the role the component
* chose, and the role already knows what it looks like here -- which is what
* replaced the three `html[data-theme="night"]` overrides that used to live below.
*/
.wrolpi-status {
font-size: 0.78125rem;
font-weight: 500;
display: inline-flex;
align-items: center;
gap: 0.3125rem;
color: var(--status-color);
}
/*
* Failure is not left to color alone. Night and amber distinguish the roles by
* brightness, and brightness is the first thing lost on a dim screen or to a user
* who cannot separate these hues, so the loudest state also carries weight.
*/
.wrolpi-status-failed {
font-weight: 600;
}
/* ----------------------------------------------------------------- Tables */
/*
* The header is a surface, not just bold text.
*
* Mantine gives `thead` no background at all, so the header showed whatever was
* behind the table -- which is exactly what the unstriped body rows show. Bold
* type was the only thing separating the two, and against a striped body the
* header read as one more stripe. `--head` is the token for a recessed surface
* and is distinct from both the panel and the stripe in all four themes.
*/
.mantine-Table-thead .mantine-Table-th {
background: var(--head);
}
/*
* A full-strength rule under the header, because that is the one division in a
* table that carries meaning: everything above it names, everything below it is
* data. The rules between body rows are `--table-line` and much fainter -- see
* the note there. Applied to the last header row so a grouped header does not
* get a hard rule through the middle of itself.
*/
.mantine-Table-thead .mantine-Table-tr:last-child .mantine-Table-th {
border-bottom: 2px solid var(--border);
}
/*
* Row rules are quieter than any other border in the interface. They only have
* to let the eye track across a row; at `--border` strength, with a rule under
* every row, a long table turns into a stack of boxes.
*
* `html` for specificity, not decoration: Mantine declares this variable on its
* own hashed class, which ties with `.mantine-Table-table` and wins on source
* order because its stylesheet is imported after this one.
*/
html .mantine-Table-table {
--table-border-color: var(--table-line);
}
/*
* The outline around the whole table is not a row rule and keeps full strength;
* Mantine draws both from the same variable, so it has to be restated.
*/
html .mantine-Table-table[data-with-table-border] {
border-color: var(--border);
}
/* A sortable header: a real button, so the column is reachable by keyboard. */
.wrolpi-th-sort {
display: inline-flex;
align-items: center;
gap: 0.3125rem;
background: none;
border: none;
padding: 0;
margin: 0;
font: inherit;
color: inherit;
cursor: pointer;
white-space: nowrap;
}
/* The unsorted columns show a faint arrow: the affordance is visible, but it does
not compete with the column actually doing the sorting. */
.wrolpi-th-sort-idle {
opacity: 0.35;
}
.wrolpi-th-sort:hover .wrolpi-th-sort-idle {
opacity: 0.7;
}
.wrolpi-th-sort:focus-visible {
outline: 2px solid var(--blue);
outline-offset: 2px;
}
html[data-theme="night"] .wrolpi-th-sort:focus-visible,
html[data-theme="amber"] .wrolpi-th-sort:focus-visible {
outline-color: var(--text);
}
/* A failed row carries the danger border, matching failed messages. */
.wrolpi-row-failed > td {
border-top: 1.5px dashed var(--danger);
border-bottom: 1.5px dashed var(--danger);
}
.wrolpi-row-failed > td:first-child {
border-left: 1.5px dashed var(--danger);
}
.wrolpi-row-failed > td:last-child {
border-right: 1.5px dashed var(--danger);
}
/* Wide tables scroll inside their own container; the page never scrolls sideways. */
.wrolpi-table-scroll {
overflow-x: auto;
}
/*
* A disabled button keeps its own color.
*
* Mantine repaints one flat grey -- background, text and border all replaced with
* `--mantine-color-disabled`. Semantic kept the color and dropped the whole control to
* `opacity: 0.45`, and that is the better signal: it says "not available" without also
* saying "no longer the delete button".
*
* The file browser is where the difference shows. Its footer is eight buttons, six of them
* disabled until something is selected, so the toolbar a user meets on arriving at /files was
* an undifferentiated grey row -- Delete, Rename, Move, Ignore and Tag all the same pixel.
* Color is how those are told apart at a glance.
*
* Mantine leaves the variables alone and overrides only the painted properties, so the color
* the call site asked for is still in `--button-bg` and can simply be reinstated. `--button-bd`
* is a whole border shorthand, not a color, so it is set with `border`.
*
* `:not([data-loading])` mirrors Mantine, and is not decoration. Mantine renders a loading
* button as `disabled={disabled || loading}`, so every busy control in the app is `:disabled`
* -- every APIButton mid-request, the refresh button, the flasher's connect. Its own grey
* repaint is careful to exclude them; without the same exclusion here, "working..." fades to
* 0.45 and reads as unavailable rather than busy.
*
* On specificity: Mantine's disabled paint is
* `.m_hash:where(:disabled:not([data-loading]), ...)`, and `:where()` contributes nothing, so
* it scores (0,1,0) -- a single class. The `html` prefix is not needed to beat THAT. It is
* needed to stay level with the app's other `html ...` overrides, and the night danger rule
* below shows why that matters: at equal specificity the later rule wins, which is how this
* one silently repainted night's dashed danger buttons until it was told not to.
*/
html .mantine-Button-root:disabled:not([data-loading]),
html .mantine-Button-root[data-disabled]:not([data-loading]) {
background: var(--button-bg);
color: var(--button-color);
border: var(--button-bd);
opacity: var(--disabled-opacity);
}
html .mantine-ActionIcon-root:disabled:not([data-loading]),
html .mantine-ActionIcon-root[data-disabled]:not([data-loading]) {
background: var(--ai-bg);
color: var(--ai-color);
border: var(--ai-bd);
opacity: var(--disabled-opacity);
}
/*
* Night keeps its dashed danger treatment when disabled.
*
* Night has no second hue to spend, so danger is encoded by a dashed border on a transparent
* fill rather than by a red fill. The general rule above repaints from `--button-bg`, which
* is the filled red Mantine put in the variable, and at (0,2,1) it tied with the night rule
* and won on source order -- turning the file browser's disabled Delete into a solid red chip
* in the one theme where a solid red chip is not what danger looks like.
*
* (0,3,1), so it does not depend on where it sits in the file. No `background`/`color`
* shorthand games: it restates the night treatment, and the fade comes from the rule above.
*/
html[data-theme="night"] .wrolpi-button-danger:disabled:not([data-loading]),
html[data-theme="night"] .wrolpi-button-danger[data-disabled]:not([data-loading]) {
background: transparent;
color: var(--danger);
border: 1.5px dashed var(--danger);
}
/*
* A button whose only content is an icon is square, like the ActionIcon it sits beside.
*
* A Button is as wide as its content plus its horizontal padding; an ActionIcon is square by
* construction. So a row mixing the two at one nominal size came out at different widths --
* the map's pin actions are edit (Button), add-to-playlist (IconButton) and delete (Button),
* and measured 46, 30 and 46 wide. Their heights already agree, because ActionIcon's scale
* was remapped onto Button's; this is the other axis of the same alignment.
*
* `--button-height` is Mantine's own per-size height, so the two stay in step through every
* size without a table here to keep current.
*/
html .mantine-Button-root[data-icon-only] {
width: var(--button-height);
padding: 0;
}