wrolpi/app/src/hooks/useForm.js

506 lines
17 KiB
JavaScript
Raw Normal View History

import React from "react";
import _ from "lodash";
import {InfoPopup, RequiredAsterisk, Toggle, validURL, validURLs} from "../components/Common";
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.
2026-07-30 13:41:05 -06:00
import {Icon, Message, Textarea} from "../components/ui";
async function asyncNoOp() {
}
export function useForm({
fetcher,
submitter,
defaultFormData,
emptyFormData,
onSuccess = asyncNoOp,
onFailure = asyncNoOp,
onDirty = _.noop,
clearOnSuccess = false,
fetchOnSuccess = false,
}) {
const [formData, setFormData] = React.useState(defaultFormData || {});
const [disabled, setDisabled] = React.useState(false);
const [loading, setLoading] = React.useState(false);
const [ready, setReady] = React.useState(false);
const [dirty, setDirty] = React.useState(false);
// Form-level error (e.g. initial fetch failed). Distinct from field-level `errors`.
const [error, setError] = React.useState(null);
const [errors, setErrors] = React.useState({});
const [validators, setValidators] = React.useState({});
const [validValues, setValidValues] = React.useState({});
const [requires, setRequires] = React.useState({});
const memoizedFormData = React.useMemo(() => formData, [formData]);
const memoizedErrors = React.useMemo(() => errors, [errors]);
const memoizedValidValues = React.useMemo(() => validValues, [validValues]);
const memoizedRequires = React.useMemo(() => requires, [requires]);
if (clearOnSuccess && fetchOnSuccess) {
console.error('Cannot use both clearOnSuccess and fetchOnSuccess!');
}
if (fetchOnSuccess && !fetcher) {
console.error('Cannot fetchOnSuccess without fetcher!');
}
React.useEffect(() => {
// Form is dirty if it has deviated from the default.
setDirty(!_.isEqual(defaultFormData, memoizedFormData));
// Fetch/load failures keep the form not ready so pages can show error UI.
if (error) {
console.debug('Form invalid because of form-level error');
setReady(false);
return
}
const errorValues = Object.values(errors).filter(i => !!i);
if (errorValues.length > 0) {
console.debug('Form invalid because it has errors');
setReady(false);
return
}
const invalidValues = Object.values(memoizedValidValues).filter(i => i !== true);
if (invalidValues.length > 0) {
console.debug('Form invalid because it failed validators');
setReady(false);
return
}
const missingValues = Object.keys(memoizedRequires).filter(i => !_.get(formData, i));
if (missingValues.length > 0) {
console.debug(`Form invalid because it is missing required value: ${missingValues[0]}`);
setReady(false);
return
}
setReady(true);
}, [loading, memoizedFormData, memoizedErrors, memoizedValidValues, memoizedRequires, error]);
const localFetch = async () => {
if (fetcher) {
try {
const result = await fetcher();
setFormData(result);
setError(null);
return result;
} catch (e) {
// Catch so callers (useEffect, page refresh) do not get an unhandled rejection
// (which would show the webpack-dev-server error overlay and break Cypress).
console.error('Failed to fetch form data:', e);
setError(e?.message || String(e) || 'Failed to load');
}
}
}
React.useEffect(() => {
localFetch();
}, []);
React.useEffect(() => {
if (dirty) {
onDirty();
}
}, [dirty, onDirty]);
const patchFormData = (newFormData) => {
newFormData = {...formData, ...newFormData};
// console.debug('useForm.patchFormData', newFormData);
setFormData(newFormData);
};
const reset = () => {
setFormData(emptyFormData || defaultFormData);
}
const onSubmit = async () => {
if (!ready) {
console.error('Refusing to submit form because it is not ready');
return;
}
setDisabled(true);
setLoading(true);
setReady(false);
try {
await submitter(formData);
if (fetchOnSuccess && fetcher) {
const result = await fetcher();
setFormData(result);
} else if (clearOnSuccess) {
reset();
}
await onSuccess();
} catch (error) {
await onFailure(error);
throw error;
} finally {
setLoading(false);
setDisabled(false);
}
}
// Using useCallback with dependencies to ensure the debounce function doesn't change on every render
const debouncedValidate = React.useCallback(_.debounce((path, value) => {
const validator = validators[path];
let error = null;
if (validator) {
try {
error = validator(value);
} catch (e) {
console.error(`Failed to validate ${path}`);
}
}
console.debug('useForm.debouncedValidate', path, value, 'error=', error);
setValidValues(prev => ({...prev, [path]: !error}));
setErrors(prev => {
if (error) {
return {...prev, [path]: error};
} else {
const {[path]: _, ...newErrors} = prev;
return newErrors;
}
});
}, 300), [validators]);
const handleInputEvent = (e) => {
if (e) e.preventDefault();
let {type, value} = e.target;
let {path} = e.target.dataset;
path = path || e.target.name;
if (type === 'number' && value !== '' && !isNaN(value)) {
// Use parseFloat (not parseInt) so decimal values (sleep_requests=0.75, durations, etc.)
// coming from <input type="number" step="0.25"> are preserved as floats.
// The explicit empty-string check prevents isNaN('') === false from producing NaN in state.
value = parseFloat(value);
}
console.debug('handleInputEvent', 'path=', path, 'type=', type, 'value=', value);
// Change value in state first, this part happens immediately
const newFormData = _.set(formData, path.split('.'), value);
patchFormData(newFormData);
// Trigger validation after a delay
debouncedValidate(path, value);
return value;
}
const addValidator = (path, validator) => {
if (path && !!validator && !(path in validators)) {
setValidators({...validators, [path]: validator});
}
}
const addRequires = (name) => {
if (name && !(name in requires)) {
setRequires({...requires, [name]: false});
}
}
const setValue = (path, newValue) => {
patchFormData(_.set(formData, path, newValue));
}
const getCustomProps = ({name, validator, type = 'text', path, required = false, afterChange}) => {
// Props for any other elements.
path = path || name;
addValidator(path, validator);
if (required) {
addRequires(path);
}
const value = _.get(formData, path); // Get a path, separated by .'s
if (value === undefined) {
patchFormData(_.set(formData, path, type === 'array' ? [] : null));
}
// Allow bypass of `handleInputEvent` which only handles an input event.
const localSetValue = newValue => {
setValue(path, newValue);
debouncedValidate(path, newValue);
}
// Props for <input/>, etc.
const inputProps = {
type,
disabled,
value,
onChange: (newValue) => {
setValue(path, newValue);
if (afterChange) {
afterChange(newValue);
}
},
'data-path': path,
}
// Attributes about the input, but should not be passed as properties.
const inputAttrs = {
valid: validValues[path],
path,
localSetValue,
};
return [inputProps, inputAttrs]
}
const getInputProps = ({
name,
validator,
path,
required = false,
type = 'text',
onChange = null,
afterChange = null
}) => {
// Props for <input/>
path = path || name;
// Use generic validators if no validator is provided.
if (!validator && type === 'url') {
validator = validURL;
}
const [customProps, inputAttrs] = getCustomProps({name, validator, path, type, required, afterChange});
const localHandleInputEvent = async (e) => {
if (e) e.preventDefault();
const value = handleInputEvent(e);
if (onChange) {
await onChange(value);
}
}
// Attributes that should be passed as properties to the input.
const inputProps = {
...customProps,
name: name,
onChange: localHandleInputEvent,
error: errors[path] || null,
required: required ? null : undefined,
}
return [inputProps, inputAttrs]
}
const getSelectionProps = ({name, validator, path, type, required = false, afterChange = null}) => {
// Props for Dropdowns.
path = path || name;
const [customProps, inputAttrs] = getCustomProps({name, validator, type, path, required, afterChange});
customProps.onChange = (e, {value}) => {
patchFormData(_.set(formData, path, value));
if (afterChange) {
afterChange(value);
}
};
return [customProps, inputAttrs]
}
return {
dirty,
disabled,
error,
fetcher: localFetch,
formData,
getCustomProps,
getInputProps,
getSelectionProps,
handleInputEvent,
setValue,
loading,
onSubmit,
ready,
reset,
}
}
export const commaSeparatedValidator = (value) => {
if (typeof value !== 'string') {
return 'Expected a string';
}
if (value.endsWith(',')) {
return 'Cannot end with comma';
}
if (value.startsWith(',')) {
return 'Cannot start with comma';
}
}
export function InputForm({
form,
required = false,
name,
path,
type = 'text',
validator,
placeholder = '',
label,
helpContent = null,
helpPosition = 'top',
extraInputProps = {},
disabled = false,
onChange = null,
message = null,
}) {
const [inputProps, inputAttrs] = form.getInputProps({name, path, validator, type, required, onChange});
inputProps.disabled = disabled || inputProps.disabled;
ui: thirty-five files off Semantic, leaving fifteen Phase 2, part 4. Baseline 50 -> 15. Migrated: all eight calculators, the rest of inventory and collections, the Map/Zim/Docs cluster, Videos, Files, Search, Archive, DashboardPage, DatesSelector, HelpModal, Events, Tags, Flasher's neighbours, admin Settings and Status, Download, and App. Also the three shared modules everything imports: api.js and customHooks.js swap the Semantic toast library for the library's shim (same call shape), and useForm.js renders the library's Message. useForm accepts both the old `{content, header, positive|negative}` shape and the new `{kind, title}`, because the pages building those objects migrate separately. Two library gaps closed, both found by call sites needing them: ActionInput is a forwardRef now. Its ref reaches the input rather than the wrapper, because callers use it to focus or select the field -- a ref to the surrounding div would do neither, which is why one call site had resorted to hand-copying the component's markup. Header takes a `color` token name. Three separate call sites had reached for an inline style to colour a heading that carries meaning ("3 items to add" against "2 to remove"), which is how a hex ends up in markup. Two files needed repair after being interrupted mid-edit: MapViewer had a Segment left in its error branch, and VideoPlayer had lost the useContext import its own code still used. Neither jest nor tsc noticed; only `npm run build` did, which is the whole reason that check exists. Fifteen files remain: Archive, BulkTagModal, Channels, Docs, FileBrowser, FilePreview, Flasher, Nav, Playlists, Theme.tsx, Upload, VideoPlayer, Zim, ControllerPage, admin/Downloads. Theme.tsx is deliberately last -- the unmigrated files still import its Semantic-backed wrappers, so it can only go once they have.
2026-07-30 12:21:30 -06:00
/*
ui: the last of Semantic UI, in name and in dead CSS The library has been uninstalled for some time, but the codebase still read as though it were there: identifiers named after it, rules written against its class names, and about 180 comments explaining a rule in terms of a thing that is no longer installed. Renamed, keeping the vocabulary the app actually uses: semanticUIColorMap -> navColorHexMap, semanticColorNames -> paletteColorNames, semanticNames -> iconNameAliases, semanticSizes -> sizeAliases, and SemanticUIColors -> NavColors in wrolpi/schema.py, which is what validates nav_color on the way in. ColorToSemanticHexColor had no callers at all and is gone. The baseline ratchet has done its job -- the list reached zero, so the JSON is deleted and the guard keeps only the checks with teeth: no package, no import, no `className="ui "`, no toast library. Its Theme.tsx check went with the wrappers it was watching for. It also asserts its own walk found files, so a clean result cannot mean it scanned nothing. Deleted from App.css: every rule requiring `.ui`, `.inverted`, `.segment` or `.modal`, none of which any component has set since the stylesheet stopped loading, plus the form skeletons, ui-alerts and progress-text rules that nothing renders. Two of them were doing harm rather than nothing: the drag tints had a dark-theme variant under `.inverted` that stopped applying, leaving 15% of a fixed blue over a dark row, and now mix a token instead. Two selectors naming markup that no longer exists were live bugs. The file browser's filter had `.ui.input.file-browser-filter`, so it rendered at the browser default width on a bar laid out to give it 34em -- now qualified by its container, because `.wrolpi-action-input` sets `width: 100%` at the same specificity and is loaded later. And SearchBox takes named props rather than a rest spread, so the `id` DestinationForm hands it was dropped in silence and the label it renders pointed at nothing; the download forms' Destination field has had no associated label since the migration. "Semantic roles" survives in four places, where it means what it says. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 19:15:57 -06:00
* Callers build these as {content, header, positive|negative} --
ui: thirty-five files off Semantic, leaving fifteen Phase 2, part 4. Baseline 50 -> 15. Migrated: all eight calculators, the rest of inventory and collections, the Map/Zim/Docs cluster, Videos, Files, Search, Archive, DashboardPage, DatesSelector, HelpModal, Events, Tags, Flasher's neighbours, admin Settings and Status, Download, and App. Also the three shared modules everything imports: api.js and customHooks.js swap the Semantic toast library for the library's shim (same call shape), and useForm.js renders the library's Message. useForm accepts both the old `{content, header, positive|negative}` shape and the new `{kind, title}`, because the pages building those objects migrate separately. Two library gaps closed, both found by call sites needing them: ActionInput is a forwardRef now. Its ref reaches the input rather than the wrapper, because callers use it to focus or select the field -- a ref to the surrounding div would do neither, which is why one call site had resorted to hand-copying the component's markup. Header takes a `color` token name. Three separate call sites had reached for an inline style to colour a heading that carries meaning ("3 items to add" against "2 to remove"), which is how a hex ends up in markup. Two files needed repair after being interrupted mid-edit: MapViewer had a Segment left in its error branch, and VideoPlayer had lost the useContext import its own code still used. Neither jest nor tsc noticed; only `npm run build` did, which is the whole reason that check exists. Fifteen files remain: Archive, BulkTagModal, Channels, Docs, FileBrowser, FilePreview, Flasher, Nav, Playlists, Theme.tsx, Upload, VideoPlayer, Zim, ControllerPage, admin/Downloads. Theme.tsx is deliberately last -- the unmigrated files still import its Semantic-backed wrappers, so it can only go once they have.
2026-07-30 12:21:30 -06:00
* and the pages that build them are migrating separately, so both spellings are
* accepted until they have all moved to {kind, title}.
*/
let messageElm = null;
ui: thirty-five files off Semantic, leaving fifteen Phase 2, part 4. Baseline 50 -> 15. Migrated: all eight calculators, the rest of inventory and collections, the Map/Zim/Docs cluster, Videos, Files, Search, Archive, DashboardPage, DatesSelector, HelpModal, Events, Tags, Flasher's neighbours, admin Settings and Status, Download, and App. Also the three shared modules everything imports: api.js and customHooks.js swap the Semantic toast library for the library's shim (same call shape), and useForm.js renders the library's Message. useForm accepts both the old `{content, header, positive|negative}` shape and the new `{kind, title}`, because the pages building those objects migrate separately. Two library gaps closed, both found by call sites needing them: ActionInput is a forwardRef now. Its ref reaches the input rather than the wrapper, because callers use it to focus or select the field -- a ref to the surrounding div would do neither, which is why one call site had resorted to hand-copying the component's markup. Header takes a `color` token name. Three separate call sites had reached for an inline style to colour a heading that carries meaning ("3 items to add" against "2 to remove"), which is how a hex ends up in markup. Two files needed repair after being interrupted mid-edit: MapViewer had a Segment left in its error branch, and VideoPlayer had lost the useContext import its own code still used. Neither jest nor tsc noticed; only `npm run build` did, which is the whole reason that check exists. Fifteen files remain: Archive, BulkTagModal, Channels, Docs, FileBrowser, FilePreview, Flasher, Nav, Playlists, Theme.tsx, Upload, VideoPlayer, Zim, ControllerPage, admin/Downloads. Theme.tsx is deliberately last -- the unmigrated files still import its Semantic-backed wrappers, so it can only go once they have.
2026-07-30 12:21:30 -06:00
if (message) {
const kind = message.kind
|| (message.positive && 'success')
|| (message.negative && 'error')
|| 'info';
messageElm = <Message kind={kind} title={message.title ?? message.header}>
{message.children ?? message.content}
</Message>;
}
/*
* One element, not a fragment: every caller that puts an InputForm in a grid or flex
* container was getting the label and the input laid out as two separate items -- the
* input beside its own label rather than under it. The label is block with the same
* 4px gap the migrated fields (Download.js FieldLabel) use, so an InputForm and a
* DestinationForm side by side line their inputs up at the same height.
*/
return <div>
<label htmlFor={`${name}_input`} style={{display: 'block', marginBottom: 4}}>
<b>{label} {required && <RequiredAsterisk/>}</b>
{helpContent &&
<InfoPopup
content={helpContent}
position={helpPosition}
/>
}
</label>
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.
2026-07-30 13:41:05 -06:00
<div className='wrolpi-form-field' data-error={inputProps.error ? true : undefined}>
<input
id={`${name}_input`}
placeholder={placeholder}
{...extraInputProps}
{...inputProps}
/>
</div>
{messageElm}
</div>
}
export function NumberInputForm({
form,
required,
name,
path,
validator,
placeholder = '',
label,
helpContent,
helpPosition,
min = 1,
max = 9999999999,
}) {
// Default to positive number.
validator = validator || ((value) => {
value = _.isNumber(value) ? value : parseFloat(value);
if (value < 0) {
return 'Number must be positive'
}
});
return <InputForm
form={form}
required={required}
name={name}
path={path}
type='number'
validator={validator}
placeholder={placeholder}
label={label}
helpContent={helpContent}
helpPosition={helpPosition}
extraInputProps={{min, max}}
/>
}
export function UrlInput({form, required = true, name = 'url', path = 'url', disabled = false}) {
const validator = (i) => {
return validURL(i) ? null : 'Invalid URL';
};
return <InputForm
form={form}
type='url'
label='URL'
required={required}
name={name}
path={path}
validator={validator}
disabled={disabled}
/>
}
export function UrlsTextarea({name = 'urls', required, form}) {
required = required !== undefined;
// Memoize input props to prevent recomputation during render
const [inputProps, inputAttrs] = React.useMemo(() => {
const validator = (value) => {
if (!validURLs(value)) {
return 'Invalid URLs';
}
};
return form.getInputProps({name, validator, required});
}, [form, name, required]);
const handleDrop = (e) => {
if (e) e.preventDefault();
const droppedUrl = e.dataTransfer.getData('text');
let urls = (inputProps.value || '').split('\n');
urls = [...urls, droppedUrl];
urls = urls.filter(i => !!i).join('\n');
inputAttrs.localSetValue(`${urls}\n`);
};
const handleKeyDown = (event) => {
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
event.preventDefault();
form.onSubmit();
}
};
return (
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.
2026-07-30 13:41:05 -06:00
<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})
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.
2026-07-30 13:41:05 -06:00
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>
}