ui: token-driven component library on Mantine

Adds src/components/ui: buttons, messages, labels, progress, status, tables,
form controls, panels, cards, statistics, modals, tooltips, tabs, and icons.
Everything resolves its colors through theme tokens, so no component branches
on the current theme, and Semantic UI keeps rendering the app meanwhile.

Mantine is bridged rather than restyled: its own CSS variables are pointed at
our tokens. Four details were not obvious, and each caused a visible defect
in night mode before being fixed:

  - Structural colors must go in the resolver's light/dark blocks. Mantine
    emits its values under [data-mantine-color-scheme], which outranks the
    :root block that `variables` becomes, so its grays were winning.
  - The dark and gray palettes are mapped shade by shade to tokens. Mantine
    reads them directly for table borders, zebra stripes and input chrome;
    they can be neither flattened nor left alone.
  - Mantine writes --button-* as inline styles, which no stylesheet can
    outrank, so the dashed danger treatment sets real properties instead.
  - Switch colors are written onto inner elements, so they need scoped CSS
    rather than a variable on the root. Adds a --knob token for control
    handles, because Mantine's default thumb follows theme.white, which is
    near-black in the dark-based themes.

An audit of every painted pixel in the /theme-sample gallery now finds no
non-red channel in night mode.

Icons come from @tabler/icons-react with the 102 Semantic names the app uses
mapped to components; they stroke with currentColor, so they follow the theme
without any rule of their own. Toasts keep the react-semantic-toasts-2
signature so those call sites migrate by changing an import.

TypeScript 5 is required: Mantine's declarations use `const` type parameters,
which 4.9 cannot parse (Mantine 7 is the same, so downgrading is no escape).
That needs two npm overrides to keep clean installs working, both verified
against a Docker build: react-scripts declares peerOptional typescript ^3||^4,
and fork-ts-checker 6.5.2 assigns typescript.performance.mark, which TS 5
made getter-only - crashing the dev server's type checker on every rebuild.

semantic-baseline.test.js ratchets the removal: it fails if a file not already
listed imports Semantic UI. An ESLint rule would have printed 75 warnings per
rebuild, and CI unsets CI= for its build step, so it would guard nothing there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Roland 2026-07-29 19:22:03 -06:00
parent 925b02dccf
commit 1bf8ea3da8
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
20 changed files with 2916 additions and 407 deletions

1384
app/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -6,6 +6,10 @@
"@acalcutt/maplibre-contour-pmtiles": "^0.1.2",
"@artsy/fresnel": "^8.5.0",
"@cypress/react18": "^2.0.1",
"@mantine/core": "^8.3.18",
"@mantine/hooks": "^8.3.18",
"@mantine/notifications": "^8.3.18",
"@tabler/icons-react": "^3.46.0",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"cypress": "^13.15.2",
@ -62,7 +66,7 @@
"@types/node": "^26.0.1",
"@types/react": "^18.3.17",
"@types/react-dom": "^18.3.5",
"typescript": "^4.9.5"
"typescript": "^5.9.3"
},
"jest": {
"transformIgnorePatterns": [
@ -70,21 +74,9 @@
]
},
"overrides": {
"@babel/code-frame": "7.29.0",
"@babel/compat-data": "7.20.14",
"@babel/core": "7.20.12",
"@babel/generator": "7.29.1",
"@babel/helper-compilation-targets": "7.20.7",
"@babel/helper-globals": "7.28.0",
"@babel/helper-module-imports": "7.28.6",
"@babel/helper-module-transforms": "7.28.6",
"@babel/helper-string-parser": "7.27.1",
"@babel/helper-validator-identifier": "7.28.5",
"@babel/helper-validator-option": "7.18.6",
"@babel/helpers": "7.27.0",
"@babel/parser": "7.29.3",
"@babel/template": "7.28.6",
"@babel/traverse": "7.29.0",
"@babel/types": "7.29.0"
"react-scripts": {
"typescript": "$typescript"
},
"fork-ts-checker-webpack-plugin": "^6.5.3"
}
}

View file

@ -6,7 +6,9 @@ import {VideosTabLayout, VideosPage, VideosSettingsPage, VideosStatistics, Video
import AdminRoute from "./components/admin/AdminRoute";
import {Container} from "semantic-ui-react";
import 'semantic-ui-offline/semantic.min.css';
// Theme tokens load after Semantic so the shell's colors win over Semantic's defaults.
import '@mantine/core/styles.css';
import '@mantine/notifications/styles.css';
// Theme tokens load last so they win over both component libraries.
import './themes/fonts.css';
import './themes/tokens.css';
import {MediaFilterDefs} from "./themes/MediaFilterDefs";
@ -22,6 +24,7 @@ import {MediaContextProvider, mediaStyles, StatusContext, ThemeContext} from "./
import {Header, ThemeProvider} from "./components/Theme";
import {DashboardPage} from "./DashboardPage";
import {DonatePage} from "./components/DonatePage";
import {ThemeSamplePage} from "./components/ThemeSamplePage";
import {useEventsInterval} from "./Events";
import {SemanticToastContainer} from "react-semantic-toasts-2";
import {FilePreviewProvider} from "./components/FilePreview";
@ -92,6 +95,10 @@ const router = createBrowserRouter(createRoutesFromElements(<Route
<Route index element={<ErrorBoundary><DashboardPage/></ErrorBoundary>}/>
<Route path='search/*' element={<ErrorBoundary><DashboardPage/></ErrorBoundary>}/>
<Route path='donate' element={<DonatePage/>}/>
{/* Component gallery for reviewing the design in each theme. Unlinked, and left out
of production builds so it cannot ship to users. */}
{process.env.NODE_ENV === 'development' &&
<Route path='theme-sample' element={<ErrorBoundary><ThemeSamplePage/></ErrorBoundary>}/>}
<Route path='videos'>
<Route element={<ErrorBoundary><VideosTabLayout/></ErrorBoundary>}>
<Route index element={<ErrorBoundary><VideosPage/></ErrorBoundary>}/>

View file

@ -94,6 +94,9 @@ import {
TabPaneProps,
TextAreaProps,
} from "semantic-ui-react";
import {MantineProvider} from "@mantine/core";
import {Notifications} from "@mantine/notifications";
import {cssVariablesResolver, mantineTheme} from "../themes/mantine";
import {ColorToSemanticHexColor} from "./Vars";
import _ from "lodash";
import {ThemeContextValue, ThemeName, SavedThemeName} from "../types/theme";
@ -248,8 +251,18 @@ export function ThemeProvider({children, ...props}: ThemeProviderProps) {
cycleSavedTheme,
};
// Mantine's own scheme decides the surfaces its internals use (overlays, scrollbars,
// focus rings). Night and amber are neither of its two schemes, so they ride on dark
// and take their actual colors from our tokens. This is the theme `base` flag.
return <ThemeContext.Provider value={themeValue}>
{children}
<MantineProvider
theme={mantineTheme}
cssVariablesResolver={cssVariablesResolver}
forceColorScheme={dark ? 'dark' : 'light'}
>
<Notifications position='bottom-right' limit={5}/>
{children}
</MantineProvider>
</ThemeContext.Provider>
}

View file

@ -0,0 +1,297 @@
import React, {useContext, useState} from 'react';
import {ThemeContext} from '../contexts/contexts';
import {
Button,
Card,
CardGroup,
Confirm,
Icon,
IconButton,
Label,
Loader,
Message,
Panel,
Placeholder,
Progress,
Select,
Statistic,
StatisticGroup,
Status,
Table,
TextInput,
Textarea,
Checkbox,
Toggle,
Tooltip,
toast,
} from './ui';
import {semanticColorNames} from '../themes/mantine';
/*
* A gallery of every component in the new library, in the current theme.
*
* Reachable at /theme-sample. This exists to review the design and to catch a
* component that looks wrong in one of the four themes; it is not linked from
* the navigation.
*/
const Section = ({label, children}) => <section style={{marginBottom: 34}}>
<p style={{
fontSize: 11, fontWeight: 600, letterSpacing: '0.12em', textTransform: 'uppercase',
color: 'var(--muted)', margin: '0 0 10px',
}}>{label}</p>
{children}
</section>;
const Row = ({children}) => <div style={{display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'center'}}>
{children}
</div>;
export function ThemeSamplePage() {
const {theme, setTheme} = useContext(ThemeContext);
const [confirmOpen, setConfirmOpen] = useState(false);
const [comments, setComments] = useState(true);
const [hotspot, setHotspot] = useState(true);
return <div style={{maxWidth: 1060, margin: '0 auto', padding: '20px 16px 60px', color: 'var(--text)'}}>
<h1 style={{fontSize: 21, fontWeight: 600, margin: '8px 0 4px'}}>Component gallery</h1>
<p style={{color: 'var(--muted)', fontSize: 13, marginTop: 0}}>
Every component in the new library, rendered in the <strong>{theme}</strong> theme.
Switch themes here or from the navigation bar.
</p>
<Section label='Themes'>
<Row>
{['light', 'dark', 'night', 'amber'].map(name => <Button
key={name}
variant={theme === name ? 'filled' : 'default'}
onClick={() => setTheme(name)}
>
{name}
</Button>)}
</Row>
</Section>
<Section label='Statistics'>
<StatisticGroup>
<Statistic value='1,432' label='Videos'/>
<Statistic value='896' label='Archives'/>
<Statistic value='12,904' label='Files'/>
<Statistic value='87.4 GiB' label='Free space'/>
<Statistic value='14' label='Zim files'/>
</StatisticGroup>
</Section>
<Section label='Buttons'>
<Panel>
<Row>
<Button role='primary' icon='download'>Download</Button>
<Button role='save' icon='save'>Save</Button>
<Button role='retry' icon='refresh'>Retry</Button>
<Button role='danger' icon='trash' onClick={() => setConfirmOpen(true)}>Delete</Button>
<Button role='cancel' icon='x'>Cancel</Button>
<Button role='primary' loading>Working</Button>
<Button role='primary' disabled>Disabled</Button>
</Row>
<div style={{marginTop: 12}}>
<Row>
<IconButton icon='settings' label='Settings'/>
<IconButton icon='search' label='Search'/>
<IconButton icon='trash' role='danger' label='Delete'
onClick={() => setConfirmOpen(true)}/>
<Tooltip label='Tooltips replace Semantic popups'>
<Button role='cancel'>Hover me</Button>
</Tooltip>
<Button role='cancel' onClick={() => toast({
type: 'success', title: 'Saved', description: 'Channel settings written.',
})}>Show a toast</Button>
</Row>
</div>
<p style={{fontSize: 12, color: 'var(--muted)', marginBottom: 0}}>
In night mode Delete drops its fill for a dashed red outline; dashed means
destructive or failed, and nothing else uses it.
</p>
</Panel>
</Section>
<Section label='Messages'>
<div style={{display: 'flex', flexDirection: 'column', gap: 10}}>
<Message kind='info' title='Refresh started'>
Indexing the media directory. This can take several minutes.
</Message>
<Message kind='success' title='Config saved'>
Channel settings were written to config/channels.yaml.
</Message>
<Message kind='warning' title='Daily download limit reached'>
Downloads will resume tomorrow at 00:00.
</Message>
<Message kind='error' title='Download failed'>
HTTP 403 from the remote server after 3 attempts. Retry, or check the URL.
</Message>
</div>
</Section>
<Section label='Downloads table'>
<Table>
<Table.Header>
<Table.Row>
<Table.HeaderCell>URL</Table.HeaderCell>
<Table.HeaderCell>Downloader</Table.HeaderCell>
<Table.HeaderCell style={{width: 150}}>Progress</Table.HeaderCell>
<Table.HeaderCell>Status</Table.HeaderCell>
<Table.HeaderCell>Size</Table.HeaderCell>
<Table.HeaderCell>Actions</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
<Table.Row>
<Table.Cell>youtube.com/watch?v=hT6Rkfahyg</Table.Cell>
<Table.Cell>video</Table.Cell>
<Table.Cell><Progress percent={100}/></Table.Cell>
<Table.Cell><Status kind='complete'>complete</Status></Table.Cell>
<Table.Cell numeric>312 MiB</Table.Cell>
<Table.Cell><Button role='cancel' size='xs'>Details</Button></Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>youtube.com/watch?v=Zk91nq2rrE</Table.Cell>
<Table.Cell>video</Table.Cell>
<Table.Cell><Progress percent={62}/></Table.Cell>
<Table.Cell><Status kind='active'>downloading</Status></Table.Cell>
<Table.Cell numeric>198 MiB</Table.Cell>
<Table.Cell><Button role='cancel' size='xs'>Stop</Button></Table.Cell>
</Table.Row>
<Table.Row failed>
<Table.Cell>example.org/prepping/water-storage.html</Table.Cell>
<Table.Cell>archive</Table.Cell>
<Table.Cell><Progress percent={0} showPercent={false}/></Table.Cell>
<Table.Cell><Status kind='failed'>failed</Status></Table.Cell>
<Table.Cell numeric>&mdash;</Table.Cell>
<Table.Cell>
<Row>
<Button role='retry' size='xs' icon='refresh'>Retry</Button>
<Button role='danger' size='xs' icon='trash'
onClick={() => setConfirmOpen(true)}>Delete</Button>
</Row>
</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>wikipedia_en_all_maxi_2026-06.zim</Table.Cell>
<Table.Cell>zim</Table.Cell>
<Table.Cell><Progress percent={0}/></Table.Cell>
<Table.Cell><Status kind='pending'>pending</Status></Table.Cell>
<Table.Cell numeric>104 GiB</Table.Cell>
<Table.Cell><Button role='cancel' size='xs'>Cancel</Button></Table.Cell>
</Table.Row>
</Table.Body>
</Table>
</Section>
<Section label='Form controls'>
<Panel>
<div style={{display: 'grid', gridTemplateColumns: '2fr 1fr 1fr', gap: 12}}>
<TextInput label='URLs' defaultValue='https://www.youtube.com/watch?v=hT6Rkfahyg'
description='One URL per line.'/>
<Select label='Downloader' defaultValue='Videos'
data={['Videos', 'Archive', 'File', 'Scrape']}/>
<TextInput label='Destination' defaultValue='videos/'/>
</div>
<div style={{marginTop: 12}}>
<Textarea label='Notes' placeholder='Notes about this download…' minRows={2}/>
</div>
<div style={{marginTop: 14, display: 'flex', flexDirection: 'column', gap: 10}}>
<Checkbox label='Download comments' checked={comments}
onChange={e => setComments(e.currentTarget.checked)}/>
<Toggle label='Hotspot' description='Broadcast the WROLPi WiFi network.'
checked={hotspot} onChange={e => setHotspot(e.currentTarget.checked)}/>
<Toggle label='WROL Mode' description='Read-only: disables downloads.'/>
<Toggle label='Bluetooth' description='No adapter detected.' disabled/>
</div>
</Panel>
</Section>
<Section label='Labels'>
<Panel>
<Row>
{semanticColorNames.map(color => <Label key={color} color={color}>{color}</Label>)}
<Label color='black'>black</Label>
<Label color='white'>white</Label>
<Label color='blue' icon='tag'>tagged</Label>
</Row>
</Panel>
</Section>
<Section label='Icons'>
<Panel>
<Row>
{['film', 'archive', 'file text', 'map outline', 'book', 'settings', 'search',
'download', 'trash', 'warning sign'].map(name => <Icon key={name} name={name} size='large'/>)}
</Row>
<div style={{marginTop: 14}}>
<Row>
<Loader/>
<Icon name='circle notch' loading/>
<span style={{fontSize: 12, color: 'var(--muted)'}}>
Icons inherit currentColor, so they follow every theme.
</span>
</Row>
</div>
</Panel>
</Section>
<Section label='Cards'>
<CardGroup>
{[
['How To Sharpen An Axe The Right Way', 'Wranglerstar · 2024-11-03'],
['Pressure Canning Basics', 'RoseRed Homestead · 2025-02-17'],
['Solar Panel Wiring: Series vs Parallel', 'DIY Solar Power · 2025-08-21'],
['Ham Radio General License, Part 1', 'Ham Radio Crash Course · 2024-05-09'],
].map(([title, meta]) => <Card
key={title}
title={title}
meta={meta}
media={<div style={{
aspectRatio: '16/9', background: 'var(--head)', display: 'flex',
alignItems: 'center', justifyContent: 'center', color: 'var(--muted)',
}}><Icon name='film' size={34}/></div>}
/>)}
</CardGroup>
</Section>
<Section label='Loading placeholders'>
<Panel><Placeholder lines={4}/></Panel>
</Section>
<Section label='Danger zone'>
<Panel danger>
<h2 style={{color: 'var(--red)', fontSize: 14, margin: '0 0 4px'}}>Danger zone</h2>
<p style={{color: 'var(--muted)', fontSize: 13, margin: '0 0 12px'}}>
These actions are destructive and cannot be undone.
</p>
<Row>
<Button role='danger' icon='trash' onClick={() => setConfirmOpen(true)}>
Delete channel
</Button>
<Button role='danger' icon='trash' onClick={() => setConfirmOpen(true)}>
Wipe download history
</Button>
</Row>
</Panel>
</Section>
<Confirm
open={confirmOpen}
destructive
title='Delete channel?'
confirmLabel='Delete'
onConfirm={() => setConfirmOpen(false)}
onCancel={() => setConfirmOpen(false)}
>
<p style={{marginTop: 0}}>
The channel <strong>Wranglerstar</strong> and its download rules will be removed.
</p>
<p style={{color: 'var(--muted)', marginBottom: 0}}>
videos/Wranglerstar/ (312 files) will not be deleted.
</p>
</Confirm>
</div>
}

View file

@ -0,0 +1,87 @@
import React, {forwardRef} from 'react';
import {ActionIcon, ActionIconProps, Button as MButton, ButtonProps as MButtonProps} from '@mantine/core';
import {Icon} from './Icon';
/*
* Buttons.
*
* Roles carry meaning (Semantic UI heritage): blue = primary/download,
* green = save/confirm, red = destructive, amber = retry/warning, neutral
* outline = cancel. Prefer `role` over naming a color, so a theme that remaps
* a color keeps the meaning.
*/
export type ButtonRole = 'primary' | 'save' | 'retry' | 'danger' | 'cancel';
const roleProps: Record<ButtonRole, {color?: string; variant: string; className?: string}> = {
primary: {color: 'blue', variant: 'filled'},
save: {color: 'green', variant: 'filled'},
retry: {color: 'yellow', variant: 'filled'},
// Danger resolves differently per theme (filled in light/dark, dashed outline in
// night). The branching lives in ui.css, never in a call site.
danger: {color: 'red', variant: 'filled', className: 'wrolpi-button-danger'},
cancel: {variant: 'default'},
};
export interface ButtonProps extends Omit<MButtonProps, 'leftSection' | 'rightSection'> {
role?: ButtonRole;
/** Semantic icon name or a Tabler component, rendered before the label. */
icon?: string | React.ComponentType<any>;
iconAfter?: string | React.ComponentType<any>;
onClick?: React.MouseEventHandler<HTMLButtonElement>;
type?: 'button' | 'submit' | 'reset';
href?: string;
target?: string;
}
const renderIcon = (icon?: string | React.ComponentType<any>) => {
if (!icon) return undefined;
return typeof icon === 'string' ? <Icon name={icon}/> : <Icon component={icon}/>;
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>((
{role, icon, iconAfter, className, ...props}, ref
) => {
const fromRole = role ? roleProps[role] : undefined;
return <MButton
ref={ref}
// An explicit color/variant still wins, so a call site can deviate when it must.
color={props.color ?? fromRole?.color}
variant={props.variant ?? fromRole?.variant}
className={[fromRole?.className, className].filter(Boolean).join(' ') || undefined}
leftSection={renderIcon(icon)}
rightSection={renderIcon(iconAfter)}
{...props}
/>
});
Button.displayName = 'Button';
export interface IconButtonProps extends Omit<ActionIconProps, 'children'> {
role?: ButtonRole;
icon: string | React.ComponentType<any>;
/** Required: an icon-only button has no visible name. */
label: string;
onClick?: React.MouseEventHandler<HTMLButtonElement>;
type?: 'button' | 'submit' | 'reset';
}
/** A square, icon-only button. */
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>((
{role, icon, label, className, ...props}, ref
) => {
const fromRole = role ? roleProps[role] : undefined;
return <ActionIcon
ref={ref}
aria-label={label}
title={label}
color={props.color ?? fromRole?.color}
variant={props.variant ?? fromRole?.variant ?? 'default'}
className={[fromRole?.className, className].filter(Boolean).join(' ') || undefined}
{...props}
>
{renderIcon(icon)}
</ActionIcon>
});
IconButton.displayName = 'IconButton';
export {ButtonGroup} from '@mantine/core';

View file

@ -0,0 +1,72 @@
import React from 'react';
import {Table as MTable, TableProps as MTableProps} from '@mantine/core';
/*
* Tables.
*
* Borders do structural work here: full 1px cell borders, a `--head` header row,
* and zebra striping. Wide tables scroll in their own container so the page
* body never scrolls sideways.
*
* The compound names mirror Semantic's (Header/Body/Row/Cell/HeaderCell/Footer)
* so migrating a call site is a rename, not a rewrite.
*/
export interface TableProps extends MTableProps {
/** Wrap in a horizontally scrollable container. On by default. */
scrollable?: boolean;
}
function TableBase({scrollable = true, ...props}: TableProps) {
const table = <MTable
withTableBorder
withColumnBorders
striped
highlightOnHover={false}
verticalSpacing={7}
horizontalSpacing={10}
{...props}
/>;
return scrollable ? <div className='wrolpi-table-scroll'>{table}</div> : table;
}
export interface RowProps extends React.ComponentPropsWithoutRef<'tr'> {
/** A failed row: takes the dashed danger border, in every theme. */
failed?: boolean;
}
function Row({failed, className, ...props}: RowProps) {
return <MTable.Tr
className={[failed ? 'wrolpi-row-failed' : '', className].filter(Boolean).join(' ') || undefined}
{...props}
/>
}
export interface CellProps extends React.ComponentPropsWithoutRef<'td'> {
/** Right-align and use tabular figures, so digits line up in the column. */
numeric?: boolean;
}
function Cell({numeric, style, ...props}: CellProps) {
return <MTable.Td
style={numeric ? {textAlign: 'right', fontVariantNumeric: 'tabular-nums', ...style} : style}
{...props}
/>
}
export const Table = Object.assign(TableBase, {
Header: MTable.Thead,
Body: MTable.Tbody,
Footer: MTable.Tfoot,
Row,
Cell,
HeaderCell: MTable.Th,
// Mantine's own names, for new code.
Thead: MTable.Thead,
Tbody: MTable.Tbody,
Tfoot: MTable.Tfoot,
Tr: Row,
Td: Cell,
Th: MTable.Th,
ScrollContainer: MTable.ScrollContainer,
});

View file

@ -0,0 +1,154 @@
import React from 'react';
import {Loader as MLoader, Skeleton} from '@mantine/core';
import {Icon} from './Icon';
import {SemanticColorName} from '../../themes/mantine';
/*
* Feedback: messages, labels, progress, status text, loaders.
*
* These carry the design rules that change form (not just color) between
* themes, so their markup is ours and their values come from tokens.
*/
// ------------------------------------------------------------------ Messages
export type MessageKind = 'info' | 'success' | 'warning' | 'error';
const messageColors: Record<MessageKind, string> = {
info: 'var(--blue)',
success: 'var(--green)',
warning: 'var(--amber)',
error: 'var(--red)',
};
export interface MessageProps {
kind?: MessageKind;
title?: React.ReactNode;
children?: React.ReactNode;
className?: string;
}
export function Message({kind = 'info', title, children, className}: MessageProps) {
return <div
// Errors and warnings interrupt; info and success are announced politely.
role={kind === 'error' ? 'alert' : 'status'}
className={['wrolpi-message', kind === 'error' ? 'wrolpi-message-error' : '', className]
.filter(Boolean).join(' ')}
style={{['--message-color' as string]: messageColors[kind]}}
>
{title && <div className='wrolpi-message-title'>{title}</div>}
{children && <div className='wrolpi-message-body'>{children}</div>}
</div>
}
// -------------------------------------------------------------------- Labels
export interface LabelProps {
color?: SemanticColorName | 'black' | 'white';
icon?: string | React.ComponentType<any>;
children?: React.ReactNode;
onClick?: React.MouseEventHandler<HTMLSpanElement>;
className?: string;
}
/** A tag/chip. Filled in light and dark; an outline in night, where a filled
* patch would be a bright surface. */
export function Label({color = 'grey', icon, children, onClick, className}: LabelProps) {
return <span
className={['wrolpi-label', `wrolpi-label-${color}`, className].filter(Boolean).join(' ')}
style={{['--label-color' as string]: `var(--${color})`, cursor: onClick ? 'pointer' : undefined}}
onClick={onClick}
>
{icon && (typeof icon === 'string' ? <Icon name={icon} size={14}/> : <Icon component={icon} size={14}/>)}
{children}
</span>
}
// ------------------------------------------------------------------ Progress
export interface ProgressProps {
/** 0-100. Values outside the range are clamped. */
percent?: number;
/** Show the percentage inside the bar. */
showPercent?: boolean;
color?: SemanticColorName;
/** Replaces the percentage with arbitrary text (e.g. "2.1 GB / 5.3 GB"). */
label?: React.ReactNode;
className?: string;
}
export function Progress({percent = 0, showPercent = true, color = 'blue', label, className}: ProgressProps) {
const clamped = Math.min(100, Math.max(0, Number.isFinite(percent) ? percent : 0));
return <div
className={['wrolpi-progress', className].filter(Boolean).join(' ')}
role='progressbar'
aria-valuenow={Math.round(clamped)}
aria-valuemin={0}
aria-valuemax={100}
>
<div
className='wrolpi-progress-fill'
style={{width: `${clamped}%`, ['--progress-color' as string]: `var(--${color})`}}
/>
{(showPercent || label) && <span className='wrolpi-progress-text'>
{label ?? `${Math.round(clamped)}%`}
</span>}
</div>
}
// -------------------------------------------------------------------- Status
export type StatusKind = 'complete' | 'active' | 'pending' | 'failed';
const statusIcons: Record<StatusKind, string> = {
complete: 'check',
active: 'circle notch',
pending: 'circle',
failed: 'x',
};
export interface StatusProps {
kind: StatusKind;
children?: React.ReactNode;
/** Hide the leading icon. */
plain?: boolean;
}
/**
* Status text. Encoded by hue in light and dark; night mode has no hue to
* spend, so brightness carries it (see ui.css). Callers name the state, never
* the color.
*/
export function Status({kind, children, plain}: StatusProps) {
return <span className={`wrolpi-status wrolpi-status-${kind}`}>
{!plain && <Icon name={statusIcons[kind]} size={14} loading={kind === 'active'}/>}
{children}
</span>
}
// ------------------------------------------------------------------- Loaders
export interface LoaderProps {
size?: number | 'xs' | 'sm' | 'md' | 'lg' | 'xl';
label?: string;
}
export function Loader({size = 'sm', label}: LoaderProps) {
return <MLoader size={size} color='var(--blue)' aria-label={label ?? 'Loading'}/>
}
/** Placeholder for content that has not arrived. Replaces Semantic's Placeholder. */
export function Placeholder({lines = 3, height = 12}: {lines?: number; height?: number}) {
return <div style={{display: 'flex', flexDirection: 'column', gap: 8}}>
{Array.from({length: lines}, (_, index) => <Skeleton
key={index}
height={height}
// A ragged last line reads as text rather than a block.
width={index === lines - 1 ? '60%' : '100%'}
radius={0}
animate
/>)}
</div>
}
export {Skeleton};

View file

@ -0,0 +1,174 @@
import React from 'react';
import * as Tabler from '@tabler/icons-react';
/*
* Icons.
*
* Semantic UI shipped an icon font; we use Tabler's bundled SVG components, which
* are tree-shaken at build time and need nothing from the network.
*
* Icons never name a color. They stroke with `currentColor`, inheriting from the
* button or text that wraps them, so status color and every theme come for free.
*/
/** 16px inline (default), 20px emphasis, 24px page-level. */
export type IconSize = 'small' | 'medium' | 'large';
const sizes: Record<IconSize, number> = {small: 16, medium: 20, large: 24};
/**
* Semantic icon name -> Tabler component name.
*
* Covers every name the app renders today so call sites can migrate without also
* choosing new icons. New code should import the Tabler component directly and
* pass it as `component`; this map exists for the migration, not forever.
*/
const semanticNames: Record<string, keyof typeof Tabler> = {
'add': 'IconPlus',
'archive': 'IconArchive',
'arrow alternate circle up': 'IconCircleArrowUp',
'arrow down': 'IconArrowDown',
'arrow right': 'IconArrowRight',
'arrow up': 'IconArrowUp',
'balance scale': 'IconScale',
'book': 'IconBook',
'bug': 'IconBug',
'calculator': 'IconCalculator',
'calendar': 'IconCalendar',
'charset': 'IconLanguage',
'check': 'IconCheck',
'check circle': 'IconCircleCheck',
'checkmark': 'IconCheck',
'chevron left': 'IconChevronLeft',
'chevron right': 'IconChevronRight',
'chrome': 'IconBrandChrome',
'circle': 'IconCircle',
'circle notch': 'IconLoader2',
'circle notched': 'IconLoader2',
'close': 'IconX',
'columns': 'IconColumns',
'copy': 'IconCopy',
'cube': 'IconCube',
'directory': 'IconFolder',
'disk': 'IconDatabase',
'download': 'IconDownload',
'dropdown': 'IconChevronDown',
'edit': 'IconEdit',
'exclamation': 'IconExclamationMark',
'exclamation triangle': 'IconAlertTriangle',
'expand arrows alternate': 'IconArrowsMaximize',
'external': 'IconExternalLink',
'eye': 'IconEye',
'eye slash': 'IconEyeOff',
'file': 'IconFile',
'file alternate': 'IconFileDescription',
'file alternate outline': 'IconFileDescription',
'file code': 'IconFileCode',
'file pdf outline': 'IconFileTypePdf',
'file text': 'IconFileText',
'file video': 'IconFileSmile',
'film': 'IconMovie',
'filter': 'IconFilter',
'firefox': 'IconBrandFirefox',
'folder': 'IconFolder',
'folder open': 'IconFolderOpen',
'folder open outline': 'IconFolderOpen',
'folder outline': 'IconFolder',
'globe': 'IconWorld',
'hand point right': 'IconHandFinger',
'hdd': 'IconDeviceSdCard',
'heart': 'IconHeart',
'heartbeat': 'IconHeartRateMonitor',
'history': 'IconHistory',
'image': 'IconPhoto',
'lightning': 'IconBolt',
'linkify': 'IconLink',
'list': 'IconList',
'lock': 'IconLock',
'map outline': 'IconMap',
'microchip': 'IconCpu',
'minus': 'IconMinus',
'play': 'IconPlayerPlay',
'plug': 'IconPlug',
'plus': 'IconPlus',
'print': 'IconPrinter',
'puzzle piece': 'IconPuzzle',
'qrcode': 'IconQrcode',
'question': 'IconHelp',
'redo': 'IconArrowForwardUp',
'refresh': 'IconRefresh',
'rss': 'IconRss',
'save': 'IconDeviceFloppy',
'search': 'IconSearch',
'server': 'IconServer',
'settings': 'IconSettings',
'share': 'IconShare',
'shield': 'IconShield',
'spinner': 'IconLoader2',
'star': 'IconStarFilled',
'star outline': 'IconStar',
'stop': 'IconPlayerStop',
'sync': 'IconRefresh',
'tachometer alternate': 'IconGauge',
'tag': 'IconTag',
'terminal': 'IconTerminal2',
'th': 'IconLayoutGrid',
'thumbs up': 'IconThumbUp',
'trash': 'IconTrash',
'undo': 'IconArrowBackUp',
'unlock': 'IconLockOpen',
'upload': 'IconUpload',
'usb': 'IconUsb',
'volume up': 'IconVolume',
'warning': 'IconAlertTriangle',
'warning circle': 'IconAlertCircle',
'warning sign': 'IconAlertTriangle',
'wifi': 'IconWifi',
'wrench': 'IconTool',
'x': 'IconX',
};
export interface IconProps extends Omit<React.SVGProps<SVGSVGElement>, 'name' | 'ref'> {
/** A Semantic UI icon name, for migrated call sites. */
name?: string;
/** A Tabler component, for new code. */
component?: React.ComponentType<any>;
size?: IconSize | number;
/** Rotate continuously; for spinners and in-progress states. */
loading?: boolean;
/** Accessible name. Omit for decorative icons, which are hidden instead. */
label?: string;
}
/** Resolve a Semantic icon name to its Tabler component, or undefined if unmapped. */
export const resolveIconName = (name: string): React.ComponentType<any> | undefined => {
const taberName = semanticNames[name.trim().toLowerCase()];
return taberName ? (Tabler[taberName] as React.ComponentType<any>) : undefined;
}
export function Icon({name, component, size = 'small', loading, label, ...props}: IconProps) {
let Component = component;
if (!Component && name) {
Component = resolveIconName(name);
if (!Component) {
// Loud on purpose: a silently missing icon leaves a hole in the interface.
console.error(`No icon for Semantic name "${name}"; pass a Tabler component instead.`);
Component = Tabler.IconHelpCircle;
}
}
if (!Component) {
console.error('Icon requires `name` or `component`.');
return null;
}
const pixels = typeof size === 'number' ? size : sizes[size];
return <Component
size={pixels}
stroke={2}
className={loading ? 'wrolpi-icon-spin' : undefined}
aria-hidden={label ? undefined : 'true'}
aria-label={label}
role={label ? 'img' : undefined}
{...props}
/>
}

View file

@ -0,0 +1,68 @@
import React from 'react';
import {
Checkbox as MCheckbox,
MultiSelect,
NumberInput,
Radio,
Select as MSelect,
Switch as MSwitch,
Textarea as MTextarea,
TextInput as MTextInput,
} from '@mantine/core';
/*
* Form controls.
*
* All of these are Mantine components, deliberately: every one renders its own
* markup rather than a native control. Native checkboxes, radios, and select
* arrows are drawn by the browser in white, which cannot be restyled and would
* put white pixels on screen in night mode. Mantine draws SVG we can color
* from tokens.
*/
// Mantine reads these per-component variables; pointing them at tokens keeps the
// controls correct in all four themes without any per-theme branching.
const checkboxStyles = {
'--checkbox-color': 'var(--blue)',
'--checkbox-icon-color': 'var(--btn-text)',
} as React.CSSProperties;
/*
* `--switch-color` is the *checked* track and comes from the `color` prop, so it is
* deliberately not set here. The thumb must be a token: Mantine's default follows
* `theme.white`, which is our `--btn-text` — near-black on the dark-based themes.
*
* The unchecked track is set in ui.css instead of here. Mantine writes `--switch-bg`
* onto the track element itself, so a variable on this root would never reach it.
*/
const switchStyles = {
'--switch-thumb-bg': 'var(--knob)',
'--switch-disabled-color': 'var(--head)',
} as React.CSSProperties;
export type CheckboxProps = React.ComponentProps<typeof MCheckbox>;
export function Checkbox({style, ...props}: CheckboxProps) {
return <MCheckbox style={{...checkboxStyles, ...style}} {...props}/>
}
export type ToggleProps = React.ComponentProps<typeof MSwitch>;
/** An on/off switch. Replaces Semantic's `Radio toggle`. */
export function Toggle({style, className, ...props}: ToggleProps) {
return <MSwitch
className={['wrolpi-switch', className].filter(Boolean).join(' ')}
style={{...switchStyles, ...style}}
color='blue'
{...props}
/>
}
export {
MTextInput as TextInput,
MTextarea as Textarea,
MSelect as Select,
MultiSelect,
NumberInput,
Radio,
};

View file

@ -0,0 +1,63 @@
import React from 'react';
import {Menu, Modal as MModal, Tooltip as MTooltip} from '@mantine/core';
import {Button} from './Button';
/*
* Overlays: modals, confirmations, tooltips, menus.
*/
export type ModalProps = React.ComponentProps<typeof MModal>;
export function Modal(props: ModalProps) {
return <MModal
centered
// Flat surfaces: the overlay separates the modal from the page, not a shadow.
overlayProps={{backgroundOpacity: 0.55, blur: 0}}
{...props}
/>
}
export interface ConfirmProps {
open: boolean;
title?: React.ReactNode;
children?: React.ReactNode;
/** Label for the confirming action. Name the action ("Delete"), not "OK". */
confirmLabel?: string;
cancelLabel?: string;
/** Style the confirming button as destructive. */
destructive?: boolean;
loading?: boolean;
onConfirm?: () => void;
onCancel?: () => void;
}
export function Confirm({
open,
title,
children,
confirmLabel = 'OK',
cancelLabel = 'Cancel',
destructive,
loading,
onConfirm,
onCancel,
}: ConfirmProps) {
return <Modal opened={open} onClose={() => onCancel?.()} title={title} size='sm'>
{children}
<div style={{display: 'flex', justifyContent: 'flex-end', gap: 10, marginTop: 18}}>
<Button role='cancel' onClick={onCancel}>{cancelLabel}</Button>
<Button role={destructive ? 'danger' : 'save'} loading={loading} onClick={onConfirm}>
{confirmLabel}
</Button>
</div>
</Modal>
}
export type TooltipProps = React.ComponentProps<typeof MTooltip>;
/** Replaces Semantic's Popup. */
export function Tooltip(props: TooltipProps) {
return <MTooltip withArrow={false} openDelay={200} {...props}/>
}
export {Menu};

View file

@ -0,0 +1,111 @@
import React from 'react';
import {Card as MCard, Tabs as MTabs, Accordion as MAccordion, Breadcrumbs} from '@mantine/core';
/*
* Surfaces and structure: panels, cards, statistics, tabs, accordions,
* breadcrumbs. Elevation is expressed with borders and background steps —
* never a shadow (shadows are zeroed in the Mantine bridge).
*/
// -------------------------------------------------------------------- Panels
export interface PanelProps extends React.HTMLAttributes<HTMLDivElement> {
/** A danger zone: dashed red border, for destructive groupings. */
danger?: boolean;
}
/** A bordered surface. Replaces Semantic's Segment. */
export function Panel({danger, className, children, ...props}: PanelProps) {
return <div
className={['wrolpi-panel', danger ? 'wrolpi-panel-danger' : '', className]
.filter(Boolean).join(' ')}
{...props}
>
{children}
</div>
}
// ---------------------------------------------------------------- Statistics
export interface StatisticProps {
value: React.ReactNode;
label: React.ReactNode;
color?: string;
}
export function Statistic({value, label, color}: StatisticProps) {
return <div>
<div className='wrolpi-statistic-value' style={{color: color ? `var(--${color})` : undefined}}>
{value}
</div>
<div className='wrolpi-statistic-label'>{label}</div>
</div>
}
/** A row of statistics separated by hairlines, sharing one outer border. */
export function StatisticGroup({children}: {children: React.ReactNode}) {
const items = React.Children.toArray(children);
return <div style={{
display: 'grid',
gridTemplateColumns: `repeat(auto-fit, minmax(150px, 1fr))`,
border: '1px solid var(--border)',
}}>
{items.map((child, index) => <div
key={index}
style={{
background: 'var(--panel)',
padding: '14px 16px',
borderRight: index === items.length - 1 ? undefined : '1px solid var(--border)',
}}
>
{child}
</div>)}
</div>
}
// --------------------------------------------------------------------- Cards
export interface CardProps {
/** Image or poster area above the body. */
media?: React.ReactNode;
title?: React.ReactNode;
meta?: React.ReactNode;
children?: React.ReactNode;
onClick?: React.MouseEventHandler<HTMLDivElement>;
className?: string;
}
export function Card({media, title, meta, children, onClick, className}: CardProps) {
return <MCard
withBorder
padding={0}
className={className}
onClick={onClick}
style={{
background: 'var(--panel)',
borderColor: 'var(--border)',
cursor: onClick ? 'pointer' : undefined,
display: 'flex',
flexDirection: 'column',
}}
>
{media && <div style={{borderBottom: '1px solid var(--border)'}}>{media}</div>}
<div style={{padding: '10px 12px 12px', display: 'flex', flexDirection: 'column', gap: 4, flex: 1}}>
{title && <div style={{fontSize: 13, fontWeight: 600, lineHeight: 1.35}}>{title}</div>}
{children}
{meta && <div style={{fontSize: 12, color: 'var(--muted)', marginTop: 'auto'}}>{meta}</div>}
</div>
</MCard>
}
export function CardGroup({children, minWidth = 200}: {children: React.ReactNode; minWidth?: number}) {
return <div style={{
display: 'grid',
gridTemplateColumns: `repeat(auto-fill, minmax(${minWidth}px, 1fr))`,
gap: 14,
}}>
{children}
</div>
}
export {MTabs as Tabs, MAccordion as Accordion, Breadcrumbs};

View file

@ -0,0 +1,24 @@
/*
* The WROLPi component library: token-driven components built on Mantine.
*
* Import from here, not from `@mantine/core` or `semantic-ui-react`:
* import {Button, Table, Message} from '../components/ui';
*
* Everything resolves its colors through theme tokens, so no component branches
* on the current theme. See ui-design.md for the rules and
* ui-migration-plan.md for how call sites move here.
*/
import './ui.css';
export * from './Icon';
export * from './Button';
export * from './Feedback';
export * from './Surfaces';
export * from './DataTable';
export * from './Inputs';
export * from './Overlays';
export * from './toast';
// Layout primitives are used as-is; they carry no colors of their own.
export {Anchor, Box, Center, Divider, Flex, Grid, Group, Space, Stack, Text, Title} from '@mantine/core';

View file

@ -0,0 +1,40 @@
import {notifications} from '@mantine/notifications';
/*
* Toasts.
*
* Keeps the react-semantic-toasts-2 call signature so the ~19 call sites migrate
* by changing their import, not their code. The container is mounted by
* ThemeProvider.
*/
export type ToastType = 'info' | 'success' | 'warning' | 'error';
export interface ToastOptions {
type?: ToastType;
title?: string;
description?: string;
/** Milliseconds before auto-dismiss. 0 keeps it until dismissed. */
time?: number;
}
const toastColors: Record<ToastType, string> = {
info: 'blue',
success: 'green',
warning: 'yellow',
error: 'red',
};
export function toast({type = 'info', title, description, time = 5000}: ToastOptions = {}) {
notifications.show({
title,
message: description,
color: toastColors[type],
// Semantic treated 0 as "stay up"; Mantine wants false for that.
autoClose: time === 0 ? false : time,
withBorder: true,
});
}
/** Dismiss every visible toast. Useful when navigating away from a failed action. */
export const clearToasts = () => notifications.clean();

View file

@ -0,0 +1,280 @@
/*
* 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;
}
}
/* ---------------------------------------------------------------- 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. */
/* --------------------------------------------------------------- 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);
}
/* --------------------------------------------------------------- Messages */
.wrolpi-message {
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-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 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;
}

View file

@ -0,0 +1,252 @@
import React from 'react';
import {render, screen} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {MantineProvider} from '@mantine/core';
import {IconMoodSmile} from '@tabler/icons-react';
import {cssVariablesResolver, mantineTheme, semanticColorNames} from '../../themes/mantine';
import {
Button,
Confirm,
Icon,
IconButton,
Label,
Message,
Panel,
Progress,
resolveIconName,
Status,
Table,
Toggle,
} from './index';
// The components need Mantine's provider, exactly as ThemeProvider supplies it.
const renderUI = (ui) => render(
<MantineProvider theme={mantineTheme} cssVariablesResolver={cssVariablesResolver}>
{ui}
</MantineProvider>
);
describe('Icon', () => {
it('renders a Semantic name as an SVG', () => {
const {container} = renderUI(<Icon name='trash'/>);
expect(container.querySelector('svg')).toBeInTheDocument();
});
it('accepts a Tabler component directly', () => {
const {container} = renderUI(<Icon component={IconMoodSmile}/>);
expect(container.querySelector('svg')).toBeInTheDocument();
});
it('maps every Semantic icon name the app uses', () => {
// A name that falls through renders a fallback glyph, leaving a hole in the UI.
const names = ['trash', 'warning sign', 'download', 'refresh', 'plus', 'edit', 'wifi',
'search', 'lock', 'check', 'upload', 'play', 'folder', 'filter', 'file text',
'external', 'disk', 'circle notched', 'terminal', 'eye'];
const unmapped = names.filter(name => !resolveIconName(name));
expect(unmapped).toEqual([]);
});
it('hides decorative icons from assistive technology but names labelled ones', () => {
const {container} = renderUI(<><Icon name='trash'/><Icon name='trash' label='Delete'/></>);
const [decorative, labelled] = [...container.querySelectorAll('svg')];
expect(decorative).toHaveAttribute('aria-hidden', 'true');
expect(labelled).toHaveAttribute('aria-label', 'Delete');
expect(labelled).not.toHaveAttribute('aria-hidden');
});
it('reports an unknown name instead of failing silently', () => {
const error = jest.spyOn(console, 'error').mockImplementation(() => {});
renderUI(<Icon name='not a real icon'/>);
expect(error).toHaveBeenCalledWith(expect.stringContaining('not a real icon'));
error.mockRestore();
});
});
describe('Button', () => {
it('renders a role as a themed button', () => {
renderUI(<Button role='primary'>Download</Button>);
expect(screen.getByRole('button', {name: 'Download'})).toBeInTheDocument();
});
it('marks destructive buttons so night mode can restyle them', () => {
// The dashed-outline treatment lives in CSS keyed on this class; without it a
// Delete button would stay filled red in night mode.
renderUI(<Button role='danger'>Delete</Button>);
expect(screen.getByRole('button', {name: 'Delete'})).toHaveClass('wrolpi-button-danger');
});
it('lets a call site override the role colors', () => {
renderUI(<Button role='primary' variant='outline'>Odd one out</Button>);
expect(screen.getByRole('button', {name: 'Odd one out'})).toHaveAttribute('data-variant', 'outline');
});
it('renders an icon alongside the label', () => {
const {container} = renderUI(<Button role='save' icon='save'>Save</Button>);
expect(container.querySelector('svg')).toBeInTheDocument();
expect(screen.getByRole('button', {name: 'Save'})).toBeInTheDocument();
});
it('gives icon-only buttons an accessible name', () => {
renderUI(<IconButton icon='trash' label='Delete channel'/>);
expect(screen.getByRole('button', {name: 'Delete channel'})).toBeInTheDocument();
});
});
describe('Message', () => {
it('announces errors assertively and other kinds politely', () => {
renderUI(<><Message kind='error' title='Failed'/><Message kind='info' title='Working'/></>);
expect(screen.getByRole('alert')).toHaveTextContent('Failed');
expect(screen.getByRole('status')).toHaveTextContent('Working');
});
it('gives errors the dashed danger border in every theme', () => {
renderUI(<Message kind='error' title='Failed'>Try again</Message>);
expect(screen.getByRole('alert')).toHaveClass('wrolpi-message-error');
});
});
describe('Progress', () => {
it('reports its value to assistive technology', () => {
renderUI(<Progress percent={62}/>);
const bar = screen.getByRole('progressbar');
expect(bar).toHaveAttribute('aria-valuenow', '62');
expect(bar).toHaveTextContent('62%');
});
it('clamps values outside 0-100', () => {
// A download reporting 103% must not overflow its container.
renderUI(<><Progress percent={150}/><Progress percent={-20}/></>);
const [high, low] = screen.getAllByRole('progressbar');
expect(high).toHaveAttribute('aria-valuenow', '100');
expect(low).toHaveAttribute('aria-valuenow', '0');
});
it('survives a missing percentage', () => {
renderUI(<Progress percent={undefined}/>);
expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '0');
});
it('shows arbitrary label text instead of the percentage', () => {
renderUI(<Progress percent={40} label='2.1 GB / 5.3 GB'/>);
expect(screen.getByRole('progressbar')).toHaveTextContent('2.1 GB / 5.3 GB');
});
});
describe('Status', () => {
it('names the state in a class so themes can encode it differently', () => {
// Night mode has no hue to spend, so it encodes status by brightness.
const {container} = renderUI(<Status kind='failed'>failed</Status>);
expect(container.querySelector('.wrolpi-status-failed')).toBeInTheDocument();
});
});
describe('Label', () => {
it('supports every Semantic color', () => {
const {container} = renderUI(<>
{semanticColorNames.map(color => <Label key={color} color={color}>{color}</Label>)}
</>);
expect(container.querySelectorAll('.wrolpi-label')).toHaveLength(semanticColorNames.length);
});
});
describe('Table', () => {
it('renders a Semantic-shaped compound table', () => {
renderUI(<Table>
<Table.Header>
<Table.Row><Table.HeaderCell>URL</Table.HeaderCell></Table.Row>
</Table.Header>
<Table.Body>
<Table.Row><Table.Cell>example.com</Table.Cell></Table.Row>
</Table.Body>
</Table>);
expect(screen.getByRole('columnheader', {name: 'URL'})).toBeInTheDocument();
expect(screen.getByRole('cell', {name: 'example.com'})).toBeInTheDocument();
});
it('marks failed rows for the danger border', () => {
const {container} = renderUI(<Table>
<Table.Body>
<Table.Row failed><Table.Cell>broken</Table.Cell></Table.Row>
</Table.Body>
</Table>);
expect(container.querySelector('.wrolpi-row-failed')).toBeInTheDocument();
});
it('keeps wide tables from scrolling the page sideways', () => {
const {container} = renderUI(<Table><Table.Body>
<Table.Row><Table.Cell>wide</Table.Cell></Table.Row>
</Table.Body></Table>);
expect(container.querySelector('.wrolpi-table-scroll')).toBeInTheDocument();
});
});
describe('Panel', () => {
it('marks danger zones', () => {
const {container} = renderUI(<Panel danger>Destructive actions</Panel>);
expect(container.querySelector('.wrolpi-panel-danger')).toBeInTheDocument();
});
});
describe('Toggle', () => {
it('carries the class its unchecked-track rule needs', () => {
// Mantine writes --switch-bg onto the track element, so the token override has to
// be a CSS rule scoped to this class rather than a style on the root. Without it
// the unchecked track falls back to Mantine's gray — a non-red pixel in night mode.
const {container} = renderUI(<Toggle label='Hotspot'/>);
expect(container.querySelector('.wrolpi-switch')).toBeInTheDocument();
});
it('exposes the switch role and reports its state', async () => {
const onChange = jest.fn();
renderUI(<Toggle label='Hotspot' checked={false} onChange={onChange}/>);
const toggle = screen.getByRole('switch', {name: 'Hotspot'});
expect(toggle).not.toBeChecked();
await userEvent.click(toggle);
expect(onChange).toHaveBeenCalled();
});
});
describe('Confirm', () => {
it('names the action on its confirming button', async () => {
const onConfirm = jest.fn();
renderUI(<Confirm open destructive title='Delete channel?' confirmLabel='Delete'
onConfirm={onConfirm}/>);
await userEvent.click(screen.getByRole('button', {name: 'Delete'}));
expect(onConfirm).toHaveBeenCalled();
});
it('styles a destructive confirmation as destructive', () => {
renderUI(<Confirm open destructive confirmLabel='Wipe' title='Wipe history?'/>);
expect(screen.getByRole('button', {name: 'Wipe'})).toHaveClass('wrolpi-button-danger');
});
});

View file

@ -0,0 +1,77 @@
[
"src/App.js",
"src/DashboardPage.js",
"src/Events.js",
"src/Tags.js",
"src/api.js",
"src/components/AddToPlaylist.js",
"src/components/Apps.js",
"src/components/Archive.js",
"src/components/ArchivePreview.js",
"src/components/BulkTagModal.js",
"src/components/CbzViewer.js",
"src/components/Channels.js",
"src/components/Common.js",
"src/components/DatesSelector.js",
"src/components/Docs.js",
"src/components/DonatePage.js",
"src/components/Download.js",
"src/components/ErrorBoundary.js",
"src/components/FileBrowser.js",
"src/components/FilePreview.js",
"src/components/Files.js",
"src/components/Flasher.js",
"src/components/Headline.js",
"src/components/HelpModal.js",
"src/components/Map.js",
"src/components/MapSearchView.js",
"src/components/MapViewer.js",
"src/components/Nav.js",
"src/components/Placeholder.js",
"src/components/Playlists.js",
"src/components/Search.js",
"src/components/Share.js",
"src/components/SortableTable.js",
"src/components/Tables.js",
"src/components/TaggedDeleteConfirmModal.js",
"src/components/Theme.tsx",
"src/components/Upload.js",
"src/components/VideoPlayer.js",
"src/components/Videos.js",
"src/components/Zim.js",
"src/components/admin/Configs.js",
"src/components/admin/ControllerPage.js",
"src/components/admin/Downloads.js",
"src/components/admin/ExtensionInstallSuggestion.js",
"src/components/admin/ExtensionPage.js",
"src/components/admin/Settings.js",
"src/components/admin/Status.js",
"src/components/calculators/DriveCalculator.js",
"src/components/calculators/ElectricalCalculators.js",
"src/components/calculators/FoodStorageCalculator.js",
"src/components/calculators/HamCalculators.js",
"src/components/calculators/OneTimePadCalculator.js",
"src/components/calculators/QRCodeCalculator.js",
"src/components/calculators/RatioCalculator.js",
"src/components/calculators/RationCalculator.js",
"src/components/calculators/TemperatureCalculator.js",
"src/components/calculators/VinDecoderCalculator.js",
"src/components/calculators/WaterCalculator.js",
"src/components/collections/BatchReorganizeModal.js",
"src/components/collections/CollectionEditForm.js",
"src/components/collections/CollectionReorganizeModal.js",
"src/components/collections/CollectionTable.js",
"src/components/collections/CollectionTagModal.js",
"src/components/collections/ConflictResolutionModal.js",
"src/components/inventory/CatalogEditor.js",
"src/components/inventory/FieldCell.js",
"src/components/inventory/FieldSchemaEditor.js",
"src/components/inventory/InventoryExportPanel.js",
"src/components/inventory/InventoryImportModal.js",
"src/components/inventory/InventoryItemsMobile.js",
"src/components/inventory/InventoryRoute.js",
"src/components/inventory/InventorySummary.js",
"src/components/inventory/InventoryTable.js",
"src/hooks/customHooks.js",
"src/hooks/useForm.js"
]

View file

@ -0,0 +1,57 @@
import fs from 'fs';
import path from 'path';
import baseline from './semantic-baseline.json';
/*
* A ratchet for the Semantic UI removal.
*
* semantic-baseline.json lists every file that still imports Semantic UI (or the
* Semantic-based toast library). This test fails if a file not on that list
* starts importing them, and asks for the list to shrink when a file stops.
*
* It replaces an ESLint rule deliberately: `no-restricted-imports` would either
* print 75 warnings on every dev rebuild, or fail the build outright, and CI
* already unsets CI= for the build step so warnings there guard nothing.
*
* When the list reaches zero, delete this test, the JSON, and the dependencies.
*/
const SRC = path.join(__dirname);
const LIBRARIES = /from ['"](semantic-ui-react|semantic-ui-react\/|react-semantic-toasts-2)/;
/** Every source file under src/, excluding tests. */
const sourceFiles = (dir = SRC) => fs.readdirSync(dir, {withFileTypes: true}).flatMap(entry => {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) return sourceFiles(full);
if (!/\.(js|jsx|ts|tsx)$/.test(entry.name)) return [];
if (/\.(test|cy)\.(js|jsx|ts|tsx)$/.test(entry.name)) return [];
return [path.relative(path.join(SRC, '..'), full)];
});
const importers = () => sourceFiles()
.filter(file => LIBRARIES.test(fs.readFileSync(path.join(SRC, '..', file), 'utf8')))
.sort();
describe('Semantic UI removal', () => {
it('has no new files importing Semantic UI', () => {
const added = importers().filter(file => !baseline.includes(file));
// New code must import from src/components/ui instead. See ui-migration-plan.md.
expect(added).toEqual([]);
});
it('keeps the baseline free of files that no longer import Semantic UI', () => {
const current = importers();
const stale = baseline.filter(file => !current.includes(file));
// Migrating a file should also delete its line here, so the count means something.
expect(stale).toEqual([]);
});
it('reports how much of the migration is left', () => {
// Not an assertion so much as a progress readout in the test output.
const remaining = importers().length;
expect(remaining).toBeLessThanOrEqual(baseline.length);
console.log(`Semantic UI: ${remaining} files remaining to migrate.`);
});
});

127
app/src/themes/mantine.ts Normal file
View file

@ -0,0 +1,127 @@
import {createTheme, CSSVariablesResolver, MantineColorsTuple} from '@mantine/core';
/*
* Bridge between Mantine and the WROLPi theme tokens.
*
* Mantine components read Mantine's own CSS variables, so rather than styling
* components one by one we point those variables at our tokens. A component
* therefore never branches on the current theme, and a new theme is still just
* a table of token values. See tokens.css and ui-design.md.
*/
/**
* A Mantine palette whose every shade is the same token.
*
* Mantine picks a shade per variant (filled uses 6, light uses 1, and so on).
* We have one value per color, and the flat design has no shade ramp to honor,
* so every shade resolves to the token and `color="teal"` follows the theme.
*/
const tokenColor = (token: string): MantineColorsTuple =>
Array(10).fill(`var(--${token})`) as unknown as MantineColorsTuple;
/** The Semantic UI color names WROLPi uses. Each maps to a token of the same name. */
export const semanticColorNames = [
'red', 'orange', 'yellow', 'olive', 'green', 'teal', 'blue', 'violet', 'purple', 'pink',
'brown', 'grey',
] as const;
export type SemanticColorName = typeof semanticColorNames[number];
const semanticColors = Object.fromEntries(
semanticColorNames.map(name => [name, tokenColor(name)])
);
export const mantineTheme = createTheme({
// Hard corners everywhere. The radius variables are zeroed below as well, so a
// component that hardcodes `radius="md"` cannot reintroduce a rounded corner.
defaultRadius: 0,
fontFamily: 'var(--font-body)',
fontFamilyMonospace: 'var(--font-mono)',
headings: {fontFamily: 'var(--font-body)', fontWeight: '600'},
// Text drawn on filled buttons. Mantine reads `white` for that, and our tokens
// already define the correct value per theme (white in light, near-black in dark).
white: 'var(--btn-text)',
black: 'var(--text)',
primaryColor: 'blue',
// Every shade is identical, so the shade only decides which variable name is read.
primaryShade: 6,
// Mantine cannot compute contrast from a `var()`, so it must not try.
autoContrast: false,
colors: {
...semanticColors,
/*
* Mantine reads `dark` and `gray` shades directly for surfaces, borders, and
* secondary text — table borders, zebra stripes, input chrome, disabled states.
* They cannot be flattened (the steps carry meaning) and they cannot be left
* alone either, or those pixels stay neutral gray, which night mode forbids.
* So each shade is mapped to the token that plays its role.
*/
dark: [
'var(--text)', 'var(--text)', // 0-1: primary text
'var(--muted)', 'var(--muted)', // 2-3: secondary text
'var(--border)', 'var(--border)', // 4-5: borders and subtle surfaces
'var(--panel)', // 6: default surface
'var(--bg)', // 7: body
'var(--head)', 'var(--head)', // 8-9: recessed surfaces
] as unknown as MantineColorsTuple,
gray: [
'var(--panel)', // 0: lightest surface
'var(--bg)', // 1: page
'var(--head)', 'var(--head)', // 2-3: recessed surfaces
'var(--border)', 'var(--border)', // 4-5: borders
'var(--muted)', 'var(--muted)', // 6-7: secondary text
'var(--text)', 'var(--text)', // 8-9: primary text
] as unknown as MantineColorsTuple,
},
});
/**
* Colors Mantine defines per color scheme. These MUST go in both `light` and
* `dark` rather than `variables`: Mantine emits its own values under
* `[data-mantine-color-scheme=...]`, which outranks the `:root` block that
* `variables` becomes. Put them in `variables` and Mantine's grays win — which
* in night mode means neutral gray pixels, defeating the whole point of it.
*/
const schemeColors = {
// Surfaces and text.
'--mantine-color-body': 'var(--bg)',
'--mantine-color-text': 'var(--text)',
'--mantine-color-bright': 'var(--text)',
'--mantine-color-dimmed': 'var(--muted)',
'--mantine-color-placeholder': 'var(--muted)',
'--mantine-color-anchor': 'var(--blue)',
'--mantine-color-error': 'var(--red)',
// The "default" variant: inputs, outline buttons, plain surfaces.
'--mantine-color-default': 'var(--panel)',
'--mantine-color-default-hover': 'var(--head)',
'--mantine-color-default-color': 'var(--text)',
'--mantine-color-default-border': 'var(--border)',
'--mantine-color-disabled': 'var(--head)',
'--mantine-color-disabled-color': 'var(--muted)',
'--mantine-color-disabled-border': 'var(--border)',
};
/** Point Mantine's own variables at our tokens. */
export const cssVariablesResolver: CSSVariablesResolver = () => ({
// Not scheme-specific, so `:root` is enough.
variables: {
// Hard corners.
'--mantine-radius-default': '0',
'--mantine-radius-xs': '0',
'--mantine-radius-sm': '0',
'--mantine-radius-md': '0',
'--mantine-radius-lg': '0',
'--mantine-radius-xl': '0',
// Flat surfaces: elevation is expressed with borders and background steps.
'--mantine-shadow-xs': 'none',
'--mantine-shadow-sm': 'none',
'--mantine-shadow-md': 'none',
'--mantine-shadow-lg': 'none',
'--mantine-shadow-xl': 'none',
},
light: schemeColors,
dark: schemeColors,
});

View file

@ -45,6 +45,9 @@ html[data-theme="light"] {
/* Text drawn on top of a filled color button. */
--btn-text: #ffffff;
/* Switch thumbs and slider handles. */
--knob: #ffffff;
}
/*
@ -83,6 +86,7 @@ html[data-theme="dark"] {
--white: #e6e8e6;
--btn-text: #1c1f21;
--knob: var(--text);
}
/*
@ -115,6 +119,7 @@ html[data-theme="night"] {
--white: #ff8a8a;
--btn-text: #0a0000;
--knob: var(--text);
}
/* Amber: a monochrome amber terminal palette, same flat style as the rest. */
@ -143,6 +148,7 @@ html[data-theme="amber"] {
--white: #ffc875;
--btn-text: #0d0600;
--knob: var(--text);
/* Amber assigns the mono face to the body role; that is its character. */
--font-body: var(--font-system-mono);