wrolpi/app/src/components/ui/ui.css
Roland 02aed6d7f5
ui: raise the suggestion list's cap, which the scale outgrew
CI's cypress-e2e job went red on the interface scale: `directory-search.cy.js` failed
`expected '<div.wrolpi-searchbox-result-title>' to be 'visible'`.

The suggestion list is capped at `min(60vh, 30rem)`.  The rem ceiling scales with the
interface but a fraction of the VIEWPORT does not, so when the type grew 10% the rows grew
and the cap did not: the full set of grouped suggestions now needs about 420px, and 60vh of
a 660px-tall window -- Cypress's default -- is 396px.  The last group was clipped again,
which is the exact defect the comment on that rule already describes.

70vh is 462px there, and the 30rem ceiling still bounds it on a tall window, so the list
cannot cover the page either way.

Measured rather than guessed: the spec fails at 660px of viewport height and passes from
700px up, so the shortfall was about 24px.

Verified: all 50 e2e specs pass locally, 248 ui-layout component tests, 1141 jest tests.
2026-08-08 18:05:51 -06:00

1698 lines
57 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;
right: -3px;
bottom: -3px;
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);
margin-bottom: 0.875rem;
}
.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 20px a side its diagonal is 28px, which is the body's height,
* so the two edges of the point meet the body exactly at its corners.
*
* 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;
/*
* Room for the point, which is drawn outside the body: half its diagonal (14.1px) plus
* the 1px it is nudged left, rounded up.
*/
margin-left: 1rem;
/* 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 10px
* -- 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%;
width: 1.25rem;
height: 1.25rem;
/*
* 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: -7px;
width: 0.3125rem;
height: 0.3125rem;
margin-top: -2.5px;
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: -6px -6px -6px 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.
*/
.wrolpi-progress {
position: relative;
height: 1.375rem;
min-width: 7.5rem;
overflow: hidden;
background: var(--bg);
border: 1px solid var(--border);
}
/*
* 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 {
background: var(--panel);
}
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);
}
/* ----------------------------------------------------------------- Panels */
.wrolpi-panel {
background: var(--panel);
border: 1px solid var(--border);
padding: 0.875rem 1rem;
}
/* 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.
*/
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;
}