ui: the last Semantic UI file, and the stylesheet with it

ControllerPage was the final call site; Theme.tsx then had nothing left to wrap,
so it drops from 861 lines to 338 -- the provider that resolves the theme, stamps
`data-theme` and `data-media-filter`, persists both and hands Mantine our tokens.
Everything below that was Semantic wrappers.

The dark-mode compatibility props went with them.  `i`, `s`, `t` and `inverted`
existed so unmigrated components could borrow Semantic's dark treatment, and
they carried hardcoded greys -- #1B1C1D, #dddddd, #eeeeee -- which is exactly
what the token themes replaced.  Three call sites in Common.js were still
injecting the #eeeeee one into links.

Then `semantic-ui-offline/semantic.min.css` came out of App.js.  That took more
with it than expected, and looking at the running app is the only reason it was
caught:

  Every link lost its colour and gained the browser's underline and visited
  purple.  The dashboard's load averages and section headings were underlined
  and purple.  Links now take `--blue`, which remaps per theme, and underline
  only on hover -- many of them wrap cards, posters and icons.

  `box-sizing: border-box` disappeared, which silently changes the size of every
  padded element in the app.  Semantic's reset had been providing it.

  The navigation bar overflowed a 1000px viewport with `nowrap` and cut "Admin"
  off the right edge -- the one link a user needs to reach the settings that fix
  a broken WROLPi.  Semantic's menu wrapped; ours does now too.

The gzipped CSS bundle is 47.5 kB, and one call site was still leaning on
Semantic's grid classes -- now a flex column, since all three of its callers
pass library components.

The ratchet stays rather than being deleted.  At zero it asserts four things: no
file imports Semantic UI, none calls its toast library, none leans on its
stylesheet, and no file reaches back through Theme.tsx.  It can go when the
packages are uninstalled.

Theme.test.js is deleted, but not before porting the two behaviours it
protected: that Button forwards a ref to the real DOM element (a class-component
ref once crashed the Control page, because portals call `node.contains`), and
that the progress percentage stays readable on every bar colour.
This commit is contained in:
Roland 2026-07-30 13:41:05 -06:00
parent 739ceccb9f
commit 96d84dfab8
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
14 changed files with 238 additions and 846 deletions

View file

@ -4,7 +4,12 @@
.wrolpi-navbar {
display: flex;
align-items: stretch;
flex-wrap: nowrap;
/*
* Wraps rather than clipping. Semantic's menu wrapped, and with this many items the
* bar overflows a 1000px viewport -- `nowrap` cut "Admin" off the right edge, which
* is the one link a user needs to reach the settings that fix a broken WROLPi.
*/
flex-wrap: wrap;
min-height: 3.4em;
padding: 0 0.25em;
}
@ -20,7 +25,8 @@
.wrolpi-navbar-link-button {
display: flex;
align-items: center;
padding: 0 1.1em;
/* Tightened from 1.1em: the bar has eleven links plus nine indicators. */
padding: 0 0.8em;
color: inherit;
text-decoration: none;
white-space: nowrap;
@ -32,7 +38,8 @@
.wrolpi-navbar-link:hover,
.wrolpi-navbar-link-button:hover {
background: rgba(0, 0, 0, 0.08);
/* A translucent black works on every navbar colour, light or dark. */
background: rgba(0, 0, 0, 0.12);
}
.channel-edit {

View file

@ -5,7 +5,6 @@ import {createBrowserRouter, createRoutesFromElements, Link, Outlet, Route, Rout
import {VideosTabLayout, VideosPage, VideosSettingsPage, VideosStatistics, VideoWrapper} from "./components/Videos";
import AdminRoute from "./components/admin/AdminRoute";
import {Box, Header} from "./components/ui";
import 'semantic-ui-offline/semantic.min.css';
import '@mantine/core/styles.css';
import '@mantine/notifications/styles.css';
// Theme tokens load last so they win over both component libraries.

View file

@ -175,9 +175,6 @@ export function isoDatetimeToString(dt, time = false) {
}
export function CardLink({to, newTab = false, ...props}) {
const {t} = useContext(ThemeContext);
props = {...props, ...t};
props = newTab === true ? {...props, target: '_blank', rel: 'noopener noreferrer'} : props;
return <Link to={to} className="no-link-underscore card-link" {...props}>
{props.children}
@ -185,13 +182,11 @@ export function CardLink({to, newTab = false, ...props}) {
}
export function ExternalCardLink({to, children, ...props}) {
const {t} = useContext(ThemeContext);
return <a
href={to}
target='_blank'
rel='noopener noreferrer'
className='no-link-underscore card-link'
{...t}
{...props}
>
{children}
@ -199,10 +194,9 @@ export function ExternalCardLink({to, children, ...props}) {
}
export function PreviewLink({file, children, className, ...props}) {
const {t} = useContext(ThemeContext);
const {setPreviewFile} = React.useContext(FilePreviewContext);
className = className ? `clickable ${className}` : `clickable `;
return <span className={className} onClick={() => setPreviewFile(file)} {...props} {...t}>
return <span className={className} onClick={() => setPreviewFile(file)} {...props}>
{children}
</span>
}

View file

@ -1,109 +0,0 @@
import React from 'react';
import {Button, Modal, Progress} from './Theme';
import {renderInDarkMode, renderInLightMode} from '../test-utils';
// All semantic-ui colors.
const allColors = ['red', 'orange', 'yellow', 'olive', 'green', 'teal', 'blue', 'violet', 'purple', 'pink', 'brown', 'grey'];
// Light bar colors that always need dark percent text (light bars in any theme).
const lightBarColors = ['yellow', 'olive', 'teal'];
// Bar colors that need dark percent text only in dark mode (inverted makes bar lighter).
const darkModeLightBarColors = ['grey', 'blue'];
describe('Button ref forwarding', () => {
// Regression: SButton is a class component, so forwarding a ref straight to it resolves to the class instance
// (no `.contains`). Semantic UI's Modal/Popup Portal calls `node.contains(...)` on the trigger's ref during
// handleDocumentClick, throwing "node.contains is not a function". The forwarded ref must be the DOM <button>.
it('resolves a forwarded ref to the underlying DOM element', () => {
const ref = React.createRef();
renderInLightMode(<Button ref={ref} content='Click'/>);
expect(ref.current).toBeInstanceOf(HTMLElement);
expect(typeof ref.current.contains).toBe('function');
expect(ref.current.tagName).toBe('BUTTON');
});
it('does not throw when a Button is used as a controlled Modal trigger and the document is clicked', () => {
// Reproduces the Control page crash: an open Modal whose trigger is a themed Button. A document click
// invokes the Portal's handleDocumentClick -> doesNodeContainClick(triggerRef.current, e).
renderInLightMode(
<Modal open trigger={<Button content='Open'/>}>
<Modal.Content>content</Modal.Content>
</Modal>
);
expect(() => {
document.body.dispatchEvent(new MouseEvent('click', {bubbles: true}));
}).not.toThrow();
});
});
describe('Progress component theme support', () => {
describe('dark mode', () => {
it('adds inverted-progress-text class for label readability', () => {
const {container} = renderInDarkMode(
<Progress percent={39} progress indicating color='violet'>
2.1 GB / 5.3 GB
</Progress>
);
const progressEl = container.querySelector('.ui.progress');
expect(progressEl).toHaveClass('inverted-progress-text');
const progressLabel = container.querySelector('.bar > .progress');
expect(progressLabel).toHaveTextContent('39%');
const label = container.querySelector('.ui.progress > .label');
expect(label).toHaveTextContent('2.1 GB / 5.3 GB');
});
it('does NOT add inverted-progress-text in light mode', () => {
const {container} = renderInLightMode(
<Progress percent={39} progress indicating color='violet'>
2.1 GB / 5.3 GB
</Progress>
);
const progressEl = container.querySelector('.ui.progress');
expect(progressEl).not.toHaveClass('inverted-progress-text');
});
});
describe('percent text contrast per color', () => {
allColors.forEach(color => {
it(`${color} bar has readable percent text in light mode`, () => {
const {container} = renderInLightMode(
<Progress percent={50} progress color={color}/>
);
const progressLabel = container.querySelector('.bar > .progress');
expect(progressLabel).toBeInTheDocument();
expect(progressLabel).toHaveTextContent('50%');
const progressEl = container.querySelector('.ui.progress');
if (lightBarColors.includes(color)) {
expect(progressEl).toHaveClass('light-bar-progress-text');
} else {
expect(progressEl).not.toHaveClass('light-bar-progress-text');
}
});
it(`${color} bar has readable percent text in dark mode`, () => {
const {container} = renderInDarkMode(
<Progress percent={50} progress color={color}/>
);
const progressLabel = container.querySelector('.bar > .progress');
expect(progressLabel).toBeInTheDocument();
expect(progressLabel).toHaveTextContent('50%');
const progressEl = container.querySelector('.ui.progress');
if (lightBarColors.includes(color) || darkModeLightBarColors.includes(color)) {
expect(progressEl).toHaveClass('light-bar-progress-text');
} else {
expect(progressEl).not.toHaveClass('light-bar-progress-text');
}
});
});
});
});

View file

@ -1,99 +1,5 @@
import React, {useContext, useEffect, useState, forwardRef, HTMLAttributes, CSSProperties} from 'react';
import React, {useEffect, useState} from 'react';
import {ThemeContext} from "../contexts/contexts";
import {
Accordion as SAccordion,
AccordionContent as SAccordionContent,
AccordionTitle as SAccordionTitle,
Breadcrumb as SBreadcrumb,
Button as SButton,
ButtonGroup as SButtonGroup,
Card as SCard,
Divider as SDivider,
Form as SForm,
FormField as SFormField,
FormGroup as SFormGroup,
FormInput as SFormInput,
Header as SHeader,
Icon as SIcon,
List as SList,
ListContent as SListContent,
ListDescription as SListDescription,
ListHeader as SListHeader,
ListIcon as SListIcon,
ListItem as SListItem,
Loader as SLoader,
Menu as SMenu,
MenuItem as SMenuItem,
MenuHeader as SMenuHeader,
MenuMenu as SMenuMenu,
Modal as SModal,
ModalActions as SModalActions,
ModalContent as SModalContent,
ModalDescription as SModalDescription,
ModalHeader as SModalHeader,
Placeholder as SPlaceholder,
Popup as SPopup,
Progress as SProgress,
Ref as SRef,
Segment as SSegment,
Statistic as SStatistic,
StatisticGroup as SStatisticGroup,
Tab as STab,
Table as STable,
TableBody as STableBody,
TableCell as STableCell,
TableFooter as STableFooter,
TableHeader as STableHeader,
TableHeaderCell as STableHeaderCell,
TableRow as STableRow,
TabPane as STabPane,
TextArea as STextArea,
AccordionContentProps,
AccordionProps,
AccordionTitleProps,
BreadcrumbProps,
ButtonProps,
ButtonGroupProps,
CardProps as SCardProps,
DividerProps,
FormProps,
FormFieldProps,
FormGroupProps,
FormInputProps,
HeaderProps,
IconProps,
ListProps,
ListItemProps,
ListIconProps,
ListContentProps,
ListHeaderProps,
ListDescriptionProps,
LoaderProps,
MenuProps,
MenuItemProps,
MenuMenuProps,
ModalProps,
ModalActionsProps,
ModalContentProps,
ModalDescriptionProps,
ModalHeaderProps,
PlaceholderProps,
PopupProps,
ProgressProps,
SegmentProps,
StatisticProps,
StatisticGroupProps,
TabProps,
TableProps,
TableBodyProps,
TableCellProps,
TableFooterProps,
TableHeaderProps,
TableHeaderCellProps,
TableRowProps,
TabPaneProps,
TextAreaProps,
} from "semantic-ui-react";
import {MantineProvider} from "@mantine/core";
import {Notifications} from "@mantine/notifications";
import {cssVariablesResolver, mantineTheme} from "../themes/mantine";
@ -101,7 +7,6 @@ import {
amberTheme,
darkTheme,
defaultTheme,
explicitOnlyThemes,
isDarkTheme,
isThemeName,
lightTheme,
@ -114,13 +19,11 @@ import {
themeNames,
themeSessionKey,
} from "../themes/names";
import {ColorToSemanticHexColor} from "./Vars";
import _ from "lodash";
import {ThemeContextValue, ThemeName, SavedThemeName} from "../types/theme";
// Theme names and picker choices live in themes/names so the component library can use
// them without importing this module (which still pulls in Semantic UI). Re-exported
// here because most call sites already import them from Theme.
// Theme names and picker choices live in themes/names, and are re-exported here because
// most call sites already import them from Theme.
export {
amberTheme,
darkTheme,
@ -289,26 +192,7 @@ export function ThemeProvider({children, ...props}: ThemeProviderProps) {
const dark = isDarkTheme(theme);
// Compatibility layer for components which have not been migrated to tokens yet. Semantic
// has no concept of the night/amber themes, so they reuse its dark treatment while pulling
// their colors from the tokens. These values disappear with the last Semantic component.
const i = dark ? {inverted: true} : {inverted: undefined};
const inverted = dark ? 'inverted' : '';
let s: {style?: CSSProperties} = {};
let t: {style?: CSSProperties} = {};
if (theme === darkTheme) {
s = {style: {backgroundColor: '#1B1C1D', color: '#dddddd'}};
t = {style: {color: '#eeeeee'}};
} else if (explicitOnlyThemes.includes(theme)) {
s = {style: {backgroundColor: 'var(--panel)', color: 'var(--text)'}};
t = {style: {color: 'var(--text)'}};
}
const themeValue: ThemeContextValue = {
i, // Used for Semantic elements which support "inverted".
s, // Used to invert the style some elements.
t, // Used to invert text.
inverted, // Used to add "invert" to className.
theme,
savedTheme,
isDark: dark,
@ -336,526 +220,16 @@ export function ThemeProvider({children, ...props}: ThemeProviderProps) {
</MantineProvider>
</ThemeContext.Provider>
}
// Simple wrappers for Semantic UI elements to use the current theme.
const invertedNull = <T extends {inverted?: boolean | null}>(props: T): T => {
if (props['inverted'] === true) {
return {...props, inverted: null};
}
return props;
}
const defaultGrey = <T extends object>(props: T, inverted: boolean): T => {
// Some elements look softer when grey, use grey only if another color is not provided.
if (inverted) {
return {color: 'grey', ...props} as T;
}
return props;
}
// ----------------------------------------------------------------------------
// Accordion Compound Component
// ----------------------------------------------------------------------------
type AccordionTitleComponent = React.FC<AccordionTitleProps>;
type AccordionContentComponent = React.FC<AccordionContentProps>;
interface AccordionComponent extends React.FC<AccordionProps> {
Title: AccordionTitleComponent;
Content: AccordionContentComponent;
}
const AccordionTitle: AccordionTitleComponent = (props) => {
return <SAccordionTitle {...props}/>
};
const AccordionContent: AccordionContentComponent = (props) => {
return <SAccordionContent {...props}/>
};
const AccordionBase: React.FC<AccordionProps> = (props) => {
const {i, inverted} = useContext(ThemeContext);
const mergedProps = defaultGrey({...i, ...props}, !!inverted);
return <SAccordion {...mergedProps}/>
};
export const Accordion: AccordionComponent = Object.assign(AccordionBase, {
Title: AccordionTitle,
Content: AccordionContent,
});
// ----------------------------------------------------------------------------
// Button Compound Component
// ----------------------------------------------------------------------------
type ButtonGroupComponent = React.FC<ButtonGroupProps>;
interface ButtonComponent extends React.ForwardRefExoticComponent<ButtonProps & React.RefAttributes<any>> {
Group: ButtonGroupComponent;
}
const ButtonGroup: ButtonGroupComponent = (props) => {
return <SButtonGroup {...props}/>
};
const ButtonBase = forwardRef<any, ButtonProps>((props, ref) => {
const {i, inverted} = useContext(ThemeContext);
const mergedProps = defaultGrey({...i, ...props}, !!inverted);
// SButton is a class component, so a forwarded `ref` would resolve to the class instance rather than the DOM
// node. Semantic UI's Modal/Popup use the trigger's ref with `node.contains(...)`, which throws on a non-DOM
// value. Wrap in Ref (findDOMNode) so the forwarded ref always resolves to the underlying <button> element.
if (ref) {
return <SRef innerRef={ref}><SButton {...mergedProps}/></SRef>
}
return <SButton {...mergedProps}/>
});
ButtonBase.displayName = 'ButtonBase';
export const Button: ButtonComponent = Object.assign(ButtonBase, {
Group: ButtonGroup,
});
export function Divider(props: DividerProps) {
const {i} = useContext(ThemeContext);
return <SDivider {...i} {...props}/>
}
export function Header(props: HeaderProps) {
const {t} = useContext(ThemeContext);
// Merge the caller's style on top of the theme's text style; a bare `{...props}`
// would let an incoming `style` prop replace (and drop) the theme's text color.
return <SHeader {...t} {...props} style={{...t.style, ...props.style}}/>
}
export function Icon(props: IconProps) {
const {i} = useContext(ThemeContext);
return <SIcon {...i} {...props}/>
}
export function Loader(props: LoaderProps) {
const {i} = useContext(ThemeContext);
return <SLoader {...i} {...props}/>
}
// Menu is a compound component (Menu.Item / Menu.Header / Menu.Menu) so callers get the same API as Semantic's
// Menu while inheriting the theme. The parent menu is `inverted` in dark mode so item text stays readable.
const MenuBase: React.FC<MenuProps> = (props) => {
const {i} = useContext(ThemeContext);
return <SMenu {...i} {...props}/>;
};
const MenuItem: React.FC<MenuItemProps> = (props) => <SMenuItem {...props}/>;
const MenuHeader: React.FC<MenuItemProps> = (props) => <SMenuHeader {...props}/>;
const MenuMenu: React.FC<MenuMenuProps> = (props) => <SMenuMenu {...props}/>;
interface MenuComponent extends React.FC<MenuProps> {
Item: React.FC<MenuItemProps>;
Header: React.FC<MenuItemProps>;
Menu: React.FC<MenuMenuProps>;
}
export const Menu: MenuComponent = Object.assign(MenuBase, {
Item: MenuItem,
Header: MenuHeader,
Menu: MenuMenu,
});
export function Placeholder(props: PlaceholderProps) {
const {i} = useContext(ThemeContext);
return <SPlaceholder {...i} {...props}/>
}
export function Popup(props: PopupProps) {
const {i} = useContext(ThemeContext);
return <SPopup {...i} style={{border: '1px solid grey'}} {...props}/>
}
// Bar colors that always need dark percent text (light bars in any theme).
const PROGRESS_BAR_COLORS = ['yellow', 'olive', 'teal'];
// Bar colors that need dark percent text only in dark mode (inverted makes bar lighter).
const DARK_MODE_PROGRESS_BAR_COLORS = ['grey', 'blue'];
export function Progress(props: ProgressProps) {
const {i, inverted} = useContext(ThemeContext);
const classes = [props.className || ''];
if (inverted) classes.push('inverted-progress-text');
const color = props.color as string;
if (PROGRESS_BAR_COLORS.includes(color) || (inverted && DARK_MODE_PROGRESS_BAR_COLORS.includes(color))) {
classes.push('light-bar-progress-text');
}
return <SProgress {...i} {...props} className={classes.join(' ').trim()}/>
}
export function Segment(props: SegmentProps) {
const {i} = useContext(ThemeContext);
return <SSegment {...i} {...props}/>
}
// Table is now a compound component - see COMPOUND COMPONENTS section below
export function TextArea(props: TextAreaProps) {
const {i} = useContext(ThemeContext);
const mergedProps = invertedNull({...i, ...props});
return <STextArea {...mergedProps}/>
}
// ============================================================================
// COMPOUND COMPONENTS
// ============================================================================
// ----------------------------------------------------------------------------
// Modal Compound Component
// ----------------------------------------------------------------------------
type ModalActionsComponent = React.FC<ModalActionsProps>;
type ModalContentComponent = React.FC<ModalContentProps>;
type ModalDescriptionComponent = React.FC<ModalDescriptionProps>;
type ModalHeaderComponent = React.FC<ModalHeaderProps>;
interface ModalComponent extends React.FC<ModalProps> {
Actions: ModalActionsComponent;
Content: ModalContentComponent;
Description: ModalDescriptionComponent;
Header: ModalHeaderComponent;
}
const ModalActions: ModalActionsComponent = (props) => {
const {inverted} = useContext(ThemeContext);
return <SModalActions {...props} className={`${inverted}`}/>
};
const ModalContent: ModalContentComponent = (props) => {
const {inverted} = useContext(ThemeContext);
return <SModalContent {...props} className={`${inverted}`}/>
};
const ModalDescription: ModalDescriptionComponent = (props) => {
const {inverted} = useContext(ThemeContext);
return <SModalDescription {...props} className={`${inverted}`}/>
};
const ModalHeader: ModalHeaderComponent = (props) => {
const {inverted} = useContext(ThemeContext);
return <SModalHeader {...props} className={`${inverted}`}>
{props.children}
</SModalHeader>
};
const ModalBase: React.FC<ModalProps> = (props) => {
const {inverted} = useContext(ThemeContext);
return <SModal {...props} className={`${inverted}`}/>
};
export const Modal: ModalComponent = Object.assign(ModalBase, {
Actions: ModalActions,
Content: ModalContent,
Description: ModalDescription,
Header: ModalHeader,
});
// ----------------------------------------------------------------------------
// List Compound Component
// ----------------------------------------------------------------------------
// Semantic's List supports `inverted`; the inverted class on the parent handles the item text,
// but List.Icon must also receive `inverted` so its color flips in dark mode (the parent's
// inverted class alone does not restyle the icon).
interface ListComponent extends React.FC<ListProps> {
Item: React.FC<ListItemProps>;
Icon: React.FC<ListIconProps>;
Content: React.FC<ListContentProps>;
Header: React.FC<ListHeaderProps>;
Description: React.FC<ListDescriptionProps>;
}
const ListBase: React.FC<ListProps> = (props) => {
const {i} = useContext(ThemeContext);
return <SList {...i} {...props}/>
};
const ListIcon: React.FC<ListIconProps> = (props) => {
const {i} = useContext(ThemeContext);
return <SListIcon {...i} {...props}/>
};
export const List: ListComponent = Object.assign(ListBase, {
Item: SListItem,
Icon: ListIcon,
Content: SListContent,
Header: SListHeader,
Description: SListDescription,
});
// ----------------------------------------------------------------------------
// Confirm Component
// ----------------------------------------------------------------------------
export interface ConfirmProps {
open: boolean;
header?: string;
content?: string | React.ReactNode;
confirmButton?: string;
cancelButton?: string;
onConfirm?: () => void;
onCancel?: () => void;
onClose?: () => void;
size?: 'mini' | 'tiny' | 'small' | 'large' | 'fullscreen';
}
export const Confirm: React.FC<ConfirmProps> = ({
open,
header,
content,
confirmButton = 'OK',
cancelButton = 'Cancel',
onConfirm,
onCancel,
onClose,
size = 'tiny',
}) => {
const {i} = useContext(ThemeContext);
return (
<Modal open={open} onClose={onClose} size={size} className="themed-confirm">
{header && <Modal.Header>{header}</Modal.Header>}
<Modal.Content>
{typeof content === 'string' ? <p>{content}</p> : content}
</Modal.Content>
<Modal.Actions>
<Button onClick={onCancel} {...i}>{cancelButton}</Button>
<Button onClick={onConfirm} color="green" {...i}>{confirmButton}</Button>
</Modal.Actions>
</Modal>
);
};
// ----------------------------------------------------------------------------
// Form Compound Component
// ----------------------------------------------------------------------------
type FormFieldComponent = React.FC<FormFieldProps>;
type FormGroupComponent = React.FC<FormGroupProps>;
type FormInputComponent = React.FC<FormInputProps>;
interface FormComponent extends React.FC<FormProps> {
Field: FormFieldComponent;
Group: FormGroupComponent;
Input: FormInputComponent;
}
const FormField: FormFieldComponent = (props) => {
const {i} = useContext(ThemeContext);
const mergedProps = invertedNull({...i, ...props});
return <SFormField {...mergedProps}/>
};
const FormGroup: FormGroupComponent = (props) => {
const {i} = useContext(ThemeContext);
const mergedProps = invertedNull({...i, ...props});
return <SFormGroup {...mergedProps}/>
};
const FormInput: FormInputComponent = (props) => {
const {i} = useContext(ThemeContext);
return <SFormInput {...i} {...props}/>
};
const FormBase: React.FC<FormProps> = (props) => {
const {i} = useContext(ThemeContext);
return <SForm {...i} {...props}/>
};
export const Form: FormComponent = Object.assign(FormBase, {
Field: FormField,
Group: FormGroup,
Input: FormInput,
});
// ----------------------------------------------------------------------------
// Card Compound Component
// ----------------------------------------------------------------------------
interface CardIconProps extends HTMLAttributes<HTMLDivElement> {
onClick?: () => void;
children?: React.ReactNode;
}
type CardIconComponent = React.FC<CardIconProps>;
interface CardProps extends Omit<SCardProps, 'color'> {
color?: string;
}
interface CardComponent extends React.FC<CardProps> {
Icon: CardIconComponent;
}
const CardIcon: CardIconComponent = ({onClick, children, ...props}) => {
const {inverted} = useContext(ThemeContext);
const cardIcon = <center className={`card-icon ${inverted}`} {...props}>{children}</center>;
if (onClick) {
return <div onClick={onClick} className='clickable'>
{cardIcon}
</div>
} else {
return cardIcon;
}
};
const CardBase: React.FC<CardProps> = ({color, ...props}) => {
const {inverted} = useContext(ThemeContext);
const style: CSSProperties = props.style || {};
const emphasisColor = ColorToSemanticHexColor(color);
if (emphasisColor) {
// Increase drop shadow to emphasize color.
const borderColor = inverted ? '#888' : '#ddd';
style.boxShadow = `0 0 0 2px ${borderColor}, 0 5px 0 0 ${emphasisColor}, 0 0px 3px 0 #d4d4d5`;
}
return <SCard {...props} style={style}/>
};
export const Card: CardComponent = Object.assign(CardBase, {
Icon: CardIcon,
});
// ----------------------------------------------------------------------------
// Tab Compound Component
// ----------------------------------------------------------------------------
type TabPaneComponent = React.FC<TabPaneProps>;
interface TabComponent extends React.FC<TabProps> {
Pane: TabPaneComponent;
}
const TabPane: TabPaneComponent = (props) => {
const {i} = useContext(ThemeContext);
return <STabPane {...i} {...props}/>
};
const TabBase: React.FC<TabProps> = (props) => {
const {i} = useContext(ThemeContext);
const mergedProps = invertedNull({...i, ...props});
return <STab {...mergedProps}/>
};
export const Tab: TabComponent = Object.assign(TabBase, {
Pane: TabPane,
});
// ----------------------------------------------------------------------------
// Statistic Compound Component
// ----------------------------------------------------------------------------
type StatisticGroupComponent = React.FC<StatisticGroupProps>;
interface StatisticComponent extends React.FC<StatisticProps> {
Group: StatisticGroupComponent;
}
const StatisticGroup: StatisticGroupComponent = (props) => {
const {i} = useContext(ThemeContext);
const style: CSSProperties = {...props.style, marginLeft: 0, marginRight: 0};
return <SStatisticGroup {...i} {...props} style={style}/>
};
const StatisticBase: React.FC<StatisticProps> = (props) => {
const {i, inverted} = useContext(ThemeContext);
const mergedProps = defaultGrey({...i, ...props}, !!inverted);
return <SStatistic {...mergedProps}/>
};
export const Statistic: StatisticComponent = Object.assign(StatisticBase, {
Group: StatisticGroup,
});
// ----------------------------------------------------------------------------
// Breadcrumb Compound Component
// ----------------------------------------------------------------------------
interface BreadcrumbDividerProps {
icon?: string;
}
type BreadcrumbDividerComponent = React.FC<BreadcrumbDividerProps>;
interface BreadcrumbComponent extends React.FC<BreadcrumbProps> {
Divider: BreadcrumbDividerComponent;
}
const BreadcrumbDivider: BreadcrumbDividerComponent = ({icon, ...props}) => {
const {inverted} = useContext(ThemeContext);
// TODO this only handles icons for now.
const className = `divider icon ${inverted} ${icon || ''}`;
return <i aria-hidden="true" className={className}></i>
};
const BreadcrumbBase: React.FC<BreadcrumbProps> = (props) => {
const {t} = useContext(ThemeContext);
return <SBreadcrumb {...props} {...t}/>
};
export const Breadcrumb: BreadcrumbComponent = Object.assign(BreadcrumbBase, {
Divider: BreadcrumbDivider,
});
// ----------------------------------------------------------------------------
// Table Compound Component
// ----------------------------------------------------------------------------
type TableBodyComponent = React.FC<TableBodyProps>;
type TableCellComponent = React.FC<TableCellProps>;
type TableFooterComponent = React.FC<TableFooterProps>;
type TableHeaderComponent = React.FC<TableHeaderProps>;
type TableHeaderCellComponent = React.FC<TableHeaderCellProps>;
type TableRowComponent = React.FC<TableRowProps>;
interface TableComponent extends React.FC<TableProps> {
Body: TableBodyComponent;
Cell: TableCellComponent;
Footer: TableFooterComponent;
Header: TableHeaderComponent;
HeaderCell: TableHeaderCellComponent;
Row: TableRowComponent;
}
const TableBody: TableBodyComponent = (props) => {
return <STableBody {...props}/>
};
const TableCell: TableCellComponent = (props) => {
return <STableCell {...props}/>
};
const TableFooter: TableFooterComponent = (props) => {
return <STableFooter {...props}/>
};
const TableHeader: TableHeaderComponent = (props) => {
return <STableHeader {...props}/>
};
const TableHeaderCell: TableHeaderCellComponent = (props) => {
return <STableHeaderCell {...props}/>
};
const TableRow: TableRowComponent = (props) => {
return <STableRow {...props}/>
};
const TableBase: React.FC<TableProps> = (props) => {
const {i} = useContext(ThemeContext);
return <STable {...i} {...props}/>
};
export const Table: TableComponent = Object.assign(TableBase, {
Body: TableBody,
Cell: TableCell,
Footer: TableFooter,
Header: TableHeader,
HeaderCell: TableHeaderCell,
Row: TableRow,
});
/*
* The Semantic UI wrappers that used to live below this line are gone.
*
* They existed so call sites could get a themed Button, Segment, Modal and so on while
* the app still rendered Semantic components; every one of those call sites now imports
* from `src/components/ui` instead. The dark-mode compatibility props they consumed --
* `i`, `s`, `t`, `inverted` -- went with them: those carried hardcoded greys, which is
* precisely what the token themes replaced.
*
* What remains is the part that was never about Semantic: the provider that resolves the
* chosen theme, stamps `data-theme` and `data-media-filter` on <html>, persists both, and
* hands Mantine our tokens.
*/

View file

@ -47,50 +47,39 @@ export function CollectionEditForm({
<form onSubmit={handleSubmit} autoComplete="off">
{/*
* `ui stackable grid` replicates Semantic's own Grid CSS classes (still loaded
* globally via semantic-ui-offline while other call sites remain unmigrated).
* Callers of this component (Channels.js, Archive.js, Playlists.js) still pass
* Semantic <Grid.Row>/<Grid.Column> children -- they have not migrated to the
* component library yet. This div keeps their layout without importing
* semantic-ui-react here. Replace with the real `Grid`/`Grid.Col` once every
* caller has migrated.
*/}
Every caller now passes library components rather than Semantic Grid rows, so
this is a plain flex column instead of the `ui stackable grid` bridge it used
to be. That bridge was the last thing in the app depending on Semantic's
stylesheet.
*/}
<div
className="ui stackable grid"
className='wrolpi-form-rows'
style={form.loading ? {opacity: 0.6, pointerEvents: 'none'} : undefined}
>
{children}
{appliedTagName && <div className="row">
<div className="column">
<SingleTag name={appliedTagName}/>
</div>
</div>}
{appliedTagName && <div><SingleTag name={appliedTagName}/></div>}
<div className="two column row">
<div className="column">
{actionButtons}
{onCancel && <Button
type='button'
role='cancel'
onClick={onCancel}
disabled={form.disabled}
className="action-button-spacing"
>
Cancel
</Button>}
</div>
<div className="column">
<Button
type='submit'
role='save'
size='lg'
style={{float: 'right'}}
disabled={form.disabled}
>
Save
</Button>
</div>
<div style={{display: 'flex', alignItems: 'center', gap: 10}}>
{actionButtons}
{onCancel && <Button
type='button'
role='cancel'
onClick={onCancel}
disabled={form.disabled}
className="action-button-spacing"
>
Cancel
</Button>}
<Button
type='submit'
role='save'
size='lg'
style={{marginLeft: 'auto'}}
disabled={form.disabled}
>
Save
</Button>
</div>
</div>
</form>

View file

@ -342,6 +342,54 @@ html[data-theme="amber"] .wrolpi-searchbox-control:focus-within {
border-top: 1px solid var(--border);
}
/* A stack of form rows, replacing Semantic's `ui stackable grid` in edit forms. */
.wrolpi-form-rows {
display: flex;
flex-direction: column;
gap: 14px;
}
/* ------------------------------------------------------------- Form fields */
/*
* `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: 7px 9px;
background: var(--panel);
color: var(--text);
border: 1px solid var(--border);
font: inherit;
font-size: 13px;
}
.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(--red);
}
.wrolpi-form-field input:disabled {
color: var(--muted);
background: var(--bg);
}
/* ---------------------------------------------------------- Action inputs */
/*
* Input and button share one outline: the input gives up its right border and

View file

@ -22,6 +22,7 @@ import {
IconButton,
Label,
Message,
Modal,
Panel,
Progress,
resolveIconName,
@ -170,6 +171,34 @@ describe('Button', () => {
expect(screen.getByRole('link', {name: 'Save file'}).tagName).toBe('A');
});
it('forwards a ref to the underlying DOM element', () => {
/*
* Ported from the old Theme.test.js, which caught a real crash: Semantic's Button
* was a class component, so a forwarded ref resolved to the class instance, and
* anything that called `node.contains(...)` on the trigger -- a Modal or Popup
* portal checking whether a click landed inside -- threw "contains is not a
* function". Our Tooltip and Menu targets rely on this ref too.
*/
const ref = React.createRef();
renderUI(<Button ref={ref}>Click</Button>);
expect(ref.current).toBeInstanceOf(HTMLElement);
expect(typeof ref.current.contains).toBe('function');
expect(ref.current.tagName).toBe('BUTTON');
});
it('survives a document click while used as a modal trigger', () => {
// The other half of that regression: the Control page crashed when the document
// was clicked with a modal open whose trigger was a themed Button.
renderUI(<>
<Button>Open</Button>
<Modal open onClose={jest.fn()}><Modal.Content>content</Modal.Content></Modal>
</>);
expect(() => document.body.dispatchEvent(new MouseEvent('click', {bubbles: true})))
.not.toThrow();
});
it('is still a button when there is no href', () => {
renderUI(<Button onClick={jest.fn()}>Save</Button>);
@ -279,6 +308,20 @@ describe('Progress', () => {
expect(bar).toHaveTextContent('62%');
});
it('keeps its percent text readable on every bar colour', () => {
/*
* Ported from Theme.test.js, which asserted Semantic's `inverted-progress-text`
* class across all fourteen colours and both modes. That class is gone; the
* mechanism now is that the text always takes `--text` while light mode lightens
* the fill beneath it, because the text sits across both the filled and unfilled
* halves of the bar.
*/
const css = fs.readFileSync(path.join(__dirname, 'ui.css'), 'utf8');
expect(css).toMatch(/\.wrolpi-progress-text\s*{[^}]*color:\s*var\(--text\)/);
expect(css).toMatch(/html\[data-theme="light"] \.wrolpi-progress-fill\s*{[^}]*brightness/);
});
it('clamps values outside 0-100', () => {
// A download reporting 103% must not overflow its container.
renderUI(<><Progress percent={150}/><Progress percent={-20}/></>);

View file

@ -1,8 +1,7 @@
import React from "react";
import _ from "lodash";
import {InfoPopup, RequiredAsterisk, Toggle, validURL, validURLs} from "../components/Common";
import {Form, Icon, TextArea} from "../components/Theme";
import {Message} from "../components/ui";
import {Icon, Message, Textarea} from "../components/ui";
async function asyncNoOp() {
@ -367,13 +366,14 @@ export function InputForm({
/>
}
</label>
<Form.Input
id={`${name}_input`}
placeholder={placeholder}
error={inputProps.error}
>
<input {...extraInputProps} {...inputProps}/>
</Form.Input>
<div className='wrolpi-form-field' data-error={inputProps.error ? true : undefined}>
<input
id={`${name}_input`}
placeholder={placeholder}
{...extraInputProps}
{...inputProps}
/>
</div>
{messageElm}
</>
}
@ -463,31 +463,36 @@ export function UrlsTextarea({name = 'urls', required, form}) {
};
return (
<Form.Input required error={inputProps.error} label="URLs">
<TextArea
id="urls_textarea"
placeholder="Enter one URL per line"
name="urls"
onDrop={handleDrop}
onKeyDown={handleKeyDown}
{...inputProps}
/>
</Form.Input>
<Textarea
id="urls_textarea"
label="URLs"
required
error={inputProps.error}
placeholder="Enter one URL per line"
name="urls"
onDrop={handleDrop}
onKeyDown={handleKeyDown}
autosize
minRows={3}
{...inputProps}
/>
);
}
export function ToggleForm({form, name, label, path, icon = null, iconSize = 'big', disabled = false}) {
const [inputProps, inputAttrs] = form.getCustomProps({name, path})
const iconElm = icon ? <Icon size={iconSize} name={icon}/> : null;
return <Form.Input
label={label}>
{iconElm}
<Toggle
disabled={disabled || form.disabled}
name={name}
checked={inputProps.value}
onChange={inputProps.onChange}
/>
</Form.Input>
const iconElm = icon ? <Icon size={iconSize === 'big' ? 'large' : iconSize} name={icon}/> : null;
return <div className='wrolpi-form-field'>
<label><b>{label}</b></label>
<div style={{display: 'flex', alignItems: 'center', gap: 8}}>
{iconElm}
<Toggle
disabled={disabled || form.disabled}
name={name}
checked={inputProps.value}
onChange={inputProps.onChange}
/>
</div>
</div>
}

View file

@ -1,8 +1,6 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
/* The font comes from the theme's `--font-body` role token; see themes/tokens.css. */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

View file

@ -1,3 +1 @@
[
"src/components/Theme.tsx"
]
[]

View file

@ -13,7 +13,10 @@ import baseline from './semantic-baseline.json';
* 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.
* The list has reached zero. The test stays as a guard rather than being deleted: it now
* asserts that nothing imports Semantic UI, nothing calls its toast library, nothing leans
* on its stylesheet, and no migrated file reaches back through Theme.tsx. It can go when
* the packages themselves are uninstalled.
*/
const SRC = path.join(__dirname);
@ -74,7 +77,9 @@ describe('Semantic UI removal', () => {
* collapse. Anything on this list is a deliberate bridge that must be revisited
* then; anything NOT on it is an accident.
*/
const allowed = ['src/components/collections/CollectionEditForm.js'];
// Empty, and it should stay that way: Semantic's stylesheet is no longer loaded,
// so a `ui ...` class name now styles nothing at all.
const allowed = [];
const bridges = sourceFiles()
.filter(file => !baseline.includes(file))

View file

@ -158,12 +158,61 @@ html[data-theme="amber"] {
/* Shell */
/* ------------------------------------------------------------------------- */
/*
* Base element styling.
*
* semantic.min.css used to supply all of this, and removing it took the lot with it --
* links lost their colour and gained the browser's underline and visited purple, and
* every padded element silently changed size when `box-sizing: border-box` went away.
* None of it belonged to Semantic; it belongs here, expressed in tokens.
*/
html {
box-sizing: border-box;
}
*,
*::before,
*::after {
box-sizing: inherit;
}
body {
background: var(--bg);
color: var(--text);
font-family: var(--font-body);
}
/*
* Links take the primary token rather than the browser's blue, and carry no underline
* until hovered -- a great many of them wrap cards, posters, statistics and icons, where
* a permanent underline is noise. `--blue` remaps per theme, so night gets a dim red
* link and amber an amber one without another rule.
*/
a {
color: var(--blue);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
/* A link wrapping a card, poster or icon carries no text of its own to underline. */
a:has(img, svg, .wrolpi-card-icon):hover {
text-decoration: none;
}
a:focus-visible {
outline: 2px solid var(--blue);
outline-offset: 2px;
}
html[data-theme="night"] a:focus-visible,
html[data-theme="amber"] a:focus-visible {
outline-color: var(--text);
}
/* ------------------------------------------------------------------------- */
/* Media filtering */
/* ------------------------------------------------------------------------- */

View file

@ -31,14 +31,6 @@ export interface StyleProps {
// ThemeContext value type
export interface ThemeContextValue {
/** Props for Semantic elements that support "inverted" */
i: InvertedProps;
/** Props to invert styles on elements */
s: StyleProps;
/** Props to invert text color */
t: StyleProps;
/** Class name string: 'inverted' or '' */
inverted: string;
/** Current applied theme */
theme: ThemeName;
/** User's saved theme preference */