wrolpi/app/src/components/ui/ui.css
Roland 3454e042f9
ui: nine leaf files off Semantic, and the components they needed
Phase 2, part 2.  Baseline 72 -> 63.

Migrated: Tables, SortableTable, DonatePage, Share, Headline, ErrorBoundary,
and the QR code, VIN decoder, and temperature calculators.

New in the library, all in the gallery and tested:

  SearchBox     Semantic's `<Search category>`, keeping its grouped `results`
                shape because four call sites already build it.  A real
                combobox: arrows move, Enter takes the highlighted suggestion
                or submits the raw text, Escape closes without clearing.
  Pagination    Semantic's prop names, but onPageChange gets the page number
                rather than an (event, data) pair.
  TabBar        the strip and its house style only; routing stays at the call
                site, which is what decides what is active.
  IconStack     two glyphs composed into one symbol, named once for assistive
                technology (Semantic's IconGroup).
  Table.HeaderCell gains `sorted` and `onSort`.

That last one is a fix, not just a port.  Semantic's `sorted` prop set
`aria-sort`; migrating a table by hand dropped it, leaving a clickable <th>
that no keyboard could reach and no screen reader could describe.  Putting it
in the library means the next table gets it right without anyone remembering.

Night mode holes closed along the way: the QR codes on the donate, share, and
calculator pages are white by necessity — a scanner needs the contrast — and
they render as SVG, which the media filter does not target.  In night mode that
was a lit panel.  They now carry `.media`, so the filter remaps the block as a
unit and the contrast survives the mapping.

Also: `paypal` joins the icon map rather than being imported ad hoc in a page,
and FileBrowser stops passing Semantic-only table props into a DOM element.
2026-08-08 18:04:00 -06:00

717 lines
17 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.
*/
.wrolpi-icon-stack-corner {
position: absolute;
right: -3px;
bottom: -3px;
display: flex;
background: var(--bg);
border-radius: 50%;
padding: 1px;
}
/* ---------------------------------------------------------------- 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(--red);
border: 1.5px dashed var(--red);
}
html[data-theme="night"] .wrolpi-button-danger:hover:not(:disabled) {
background: var(--panel);
}
/* The icon inside inherits currentColor, so it follows without another rule. */
/* ----------------------------------------------------------- Theme picker */
.wrolpi-theme-picker {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
gap: 10px;
}
.wrolpi-theme-option {
display: flex;
flex-direction: column;
gap: 4px;
align-items: flex-start;
text-align: left;
padding: 10px 12px;
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: 9px 11px;
}
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: 7px;
font-weight: 600;
font-size: 13px;
}
.wrolpi-theme-option-description {
font-size: 12px;
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: 14px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
/* ------------------------------------------------------------ Pagination */
.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: 12px;
border-bottom: 1px solid var(--border);
margin-bottom: 14px;
}
.wrolpi-tabs-links {
display: flex;
flex-wrap: wrap;
}
.wrolpi-tabs-right {
padding-bottom: 6px;
}
.wrolpi-tab {
display: inline-block;
padding: 8px 14px;
color: var(--muted);
text-decoration: none;
font-size: 13px;
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;
gap: 8px;
background: var(--panel);
border: 1px solid var(--border);
padding: 0 8px;
}
.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: 8px 0;
background: none;
border: none;
outline: none;
color: var(--text);
font: inherit;
font-size: 13px;
}
.wrolpi-searchbox-input::placeholder {
color: var(--muted);
}
.wrolpi-searchbox-loading,
.wrolpi-searchbox-clear {
flex: none;
}
/*
* 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;
max-height: 320px;
overflow-y: auto;
background: var(--panel);
border: 1px solid var(--border);
border-top: none;
}
.wrolpi-searchbox-group-name {
padding: 6px 10px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
background: var(--head);
}
.wrolpi-searchbox-result {
padding: 7px 10px;
cursor: pointer;
border-top: 1px solid var(--border);
}
.wrolpi-searchbox-result-active {
background: var(--head);
}
.wrolpi-searchbox-result-title {
font-size: 13px;
color: var(--text);
overflow-wrap: anywhere;
}
.wrolpi-searchbox-result-description {
font-size: 11px;
color: var(--muted);
}
.wrolpi-searchbox-empty {
padding: 10px;
font-size: 12px;
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: 10px;
/* 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: 18px calc(var(--mantine-spacing-md) * -1) calc(var(--mantine-spacing-md) * -1);
padding: 12px var(--mantine-spacing-md);
border-top: 1px solid var(--border);
}
/* ---------------------------------------------------------- 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;
}
/* --------------------------------------------------------------- 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.
*/
.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 */
.wrolpi-label {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 9px;
font-size: 11.5px;
font-weight: 500;
line-height: 1.3;
background: var(--label-color);
color: var(--btn-text);
border: 1px solid transparent;
}
/* White and black labels would vanish into the surface they sit on. */
.wrolpi-label-white {
color: var(--black);
border-color: var(--border);
}
.wrolpi-label-black {
color: 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);
}
/* ---------------------------------------------------------------- Headers */
.wrolpi-header {
margin: 0 0 10px;
}
.wrolpi-header-dividing {
border-bottom: 1px solid var(--border);
padding-bottom: 6px;
}
.wrolpi-header-text {
display: flex;
align-items: center;
gap: 8px;
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: 26px;
}
.wrolpi-header-h2 {
font-size: 21px;
}
.wrolpi-header-h3 {
font-size: 17px;
}
.wrolpi-header-h4 {
font-size: 14px;
}
.wrolpi-header-h5 {
font-size: 13px;
letter-spacing: 0.02em;
}
.wrolpi-header-sub {
font-size: 12px;
color: var(--muted);
margin-top: 3px;
}
/* --------------------------------------------------------------- Messages */
.wrolpi-message {
display: flex;
align-items: flex-start;
gap: 10px;
border: 1px solid var(--border);
border-left: 3px solid var(--message-color, var(--border));
background: var(--panel);
padding: 10px 14px;
font-size: 13px;
}
.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: 6px;
}
.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: 2px;
}
.wrolpi-message-body {
color: var(--muted);
}
/* Errors take the danger treatment in every theme, so "dashed" stays one rule. */
.wrolpi-message-error {
border: 1.5px dashed var(--red);
}
/* --------------------------------------------------------------- Progress */
.wrolpi-progress {
position: relative;
height: 16px;
min-width: 120px;
background: var(--bg);
border: 1px solid var(--border);
}
.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;
}
}
.wrolpi-progress-text {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
color: var(--text);
font-variant-numeric: tabular-nums;
}
/*
* 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.
*/
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: 14px 16px;
}
/* Danger zones are dashed in every theme; it doubles as a colorblind-safe cue. */
.wrolpi-panel-danger {
border: 1.5px dashed var(--red);
}
/* ------------------------------------------------------------- Statistics */
.wrolpi-statistic-value {
font-size: 24px;
font-weight: 600;
font-variant-numeric: tabular-nums;
line-height: 1.15;
}
.wrolpi-statistic-label {
font-size: 11px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
margin-top: 2px;
}
/* ---------------------------------------------------------------- Status */
/*
* Status is encoded by hue in light/dark. In night mode hue is unavailable, so
* brightness carries it instead: dim = done, mid = active, brightest + bold =
* failed. Callers use these classes rather than naming a color.
*/
.wrolpi-status {
font-size: 12.5px;
font-weight: 500;
display: inline-flex;
align-items: center;
gap: 5px;
}
.wrolpi-status-complete {
color: var(--green);
}
.wrolpi-status-active {
color: var(--blue);
}
.wrolpi-status-pending {
color: var(--muted);
}
.wrolpi-status-failed {
color: var(--red);
}
html[data-theme="night"] .wrolpi-status-complete {
color: var(--muted);
}
html[data-theme="night"] .wrolpi-status-active {
color: var(--text);
}
html[data-theme="night"] .wrolpi-status-failed {
color: var(--red);
font-weight: 500;
}
/* ----------------------------------------------------------------- Tables */
/* A sortable header: a real button, so the column is reachable by keyboard. */
.wrolpi-th-sort {
display: inline-flex;
align-items: center;
gap: 5px;
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(--red);
border-bottom: 1.5px dashed var(--red);
}
.wrolpi-row-failed > td:first-child {
border-left: 1.5px dashed var(--red);
}
.wrolpi-row-failed > td:last-child {
border-right: 1.5px dashed var(--red);
}
/* Wide tables scroll inside their own container; the page never scrolls sideways. */
.wrolpi-table-scroll {
overflow-x: auto;
}