mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-25 18:40:06 -04: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>
This commit is contained in:
parent
72228ed67f
commit
e00f0439da
58 changed files with 387 additions and 618 deletions
|
|
@ -102,7 +102,7 @@ describe('Domain Editing Workflow', () => {
|
|||
// Set directory - DirectorySearch component uses input inside field
|
||||
cy.contains('label', 'Directory').parent().find('input').clear().type('archive/example.com');
|
||||
|
||||
// Set description - Semantic UI TextArea
|
||||
// Set description
|
||||
cy.contains('label', 'Description').parent().find('textarea').clear().type('Updated example domain');
|
||||
|
||||
// Mock tags endpoint
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ describe('Domains List Page', () => {
|
|||
|
||||
it('Edit control is a link to that domain\'s edit page', () => {
|
||||
/*
|
||||
* This used to assert Semantic's `ui button secondary` classes. Framework class
|
||||
* This used to assert the old `ui button secondary` classes. Framework class
|
||||
* names say nothing a user can perceive and they broke the moment the button
|
||||
* changed libraries, so it now checks what actually matters: the control is a real
|
||||
* link, and it points at the edit route rather than relying on a click handler.
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
* Usage: node scripts/generate-favicons.js
|
||||
*
|
||||
* This script reads the base icon.svg and generates a colored version
|
||||
* for each color in semanticUIColorMap (from src/components/Vars.js).
|
||||
* for each color in navColorHexMap (from src/components/Vars.js).
|
||||
* The favicons are saved to public/favicon-{colorname}.svg
|
||||
*/
|
||||
|
||||
|
|
@ -17,14 +17,14 @@ const publicDir = path.join(__dirname, '..', 'public');
|
|||
const varsPath = path.join(srcDir, 'Vars.js');
|
||||
const iconPath = path.join(publicDir, 'icon.svg');
|
||||
|
||||
// Parse semanticUIColorMap from Vars.js (single source of truth)
|
||||
// Parse navColorHexMap from Vars.js (single source of truth)
|
||||
function parseColorMapFromVars() {
|
||||
const varsContent = fs.readFileSync(varsPath, 'utf8');
|
||||
|
||||
// Extract the semanticUIColorMap object using regex
|
||||
const match = varsContent.match(/export const semanticUIColorMap\s*=\s*\{([^}]+)\}/);
|
||||
// Extract the navColorHexMap object using regex
|
||||
const match = varsContent.match(/export const navColorHexMap\s*=\s*\{([^}]+)\}/);
|
||||
if (!match) {
|
||||
throw new Error('Could not find semanticUIColorMap in Vars.js');
|
||||
throw new Error('Could not find navColorHexMap in Vars.js');
|
||||
}
|
||||
|
||||
const colorMap = {};
|
||||
|
|
@ -35,15 +35,15 @@ function parseColorMapFromVars() {
|
|||
}
|
||||
|
||||
if (Object.keys(colorMap).length === 0) {
|
||||
throw new Error('No colors found in semanticUIColorMap');
|
||||
throw new Error('No colors found in navColorHexMap');
|
||||
}
|
||||
|
||||
return colorMap;
|
||||
}
|
||||
|
||||
// Parse colors from Vars.js
|
||||
const semanticUIColorMap = parseColorMapFromVars();
|
||||
console.log(`Found ${Object.keys(semanticUIColorMap).length} colors in Vars.js\n`);
|
||||
const navColorHexMap = parseColorMapFromVars();
|
||||
console.log(`Found ${Object.keys(navColorHexMap).length} colors in Vars.js\n`);
|
||||
|
||||
// Colors that conflict with white stroke - use black stroke instead
|
||||
// Must match Nav.js conflictingColors
|
||||
|
|
@ -55,7 +55,7 @@ const iconSvg = fs.readFileSync(iconPath, 'utf8');
|
|||
console.log('Generating colored favicons from icon.svg...\n');
|
||||
|
||||
// Generate a favicon for each color
|
||||
for (const [colorName, hexColor] of Object.entries(semanticUIColorMap)) {
|
||||
for (const [colorName, hexColor] of Object.entries(navColorHexMap)) {
|
||||
// Replace the dark fill color (#1b1c1d) with the nav color
|
||||
let coloredSvg = iconSvg.replace(/fill:#1b1c1d/g, `fill:${hexColor}`);
|
||||
|
||||
|
|
@ -72,4 +72,4 @@ for (const [colorName, hexColor] of Object.entries(semanticUIColorMap)) {
|
|||
console.log(` Created favicon-${colorName}.svg (${hexColor}${strokeInfo})`);
|
||||
}
|
||||
|
||||
console.log(`\nGenerated ${Object.keys(semanticUIColorMap).length} favicon files in public/`);
|
||||
console.log(`\nGenerated ${Object.keys(navColorHexMap).length} favicon files in public/`);
|
||||
|
|
|
|||
279
app/src/App.css
279
app/src/App.css
|
|
@ -3,16 +3,16 @@
|
|||
--navbar-height: 3.4em;
|
||||
}
|
||||
|
||||
/* Nav bar: a hand-rolled flexbox bar (was a Semantic Menu). The user-chosen background
|
||||
and the foreground measured against it are set inline (see themes/navColors); this
|
||||
stylesheet only supplies structure and hover affordances. */
|
||||
/* Nav bar: a hand-rolled flexbox bar. The user-chosen background and the foreground
|
||||
measured against it are set inline (see themes/navColors); this stylesheet only
|
||||
supplies structure and hover affordances. */
|
||||
.wrolpi-navbar {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
/*
|
||||
* 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.
|
||||
* Wraps rather than clipping. With this many items the bar overflows a 1000px
|
||||
* viewport, and `nowrap` cut "Admin" off the right edge -- the one link a user needs
|
||||
* to reach the settings that fix a broken WROLPi.
|
||||
*/
|
||||
flex-wrap: wrap;
|
||||
min-height: var(--navbar-height);
|
||||
|
|
@ -158,26 +158,10 @@ html .wrolpi-navbar .mantine-ActionIcon-root:hover {
|
|||
align-self: stretch;
|
||||
}
|
||||
|
||||
.channel-edit {
|
||||
border-radius: 0.1875rem;
|
||||
padding: 0.75em;
|
||||
}
|
||||
|
||||
.channel-edit:hover {
|
||||
background-color: #7abaff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
::-ms-clear {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.form-control-clear {
|
||||
z-index: 10;
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
pre.wrap-text {
|
||||
white-space: pre-wrap; /* Since CSS 2.1 */
|
||||
white-space: -moz-pre-wrap; /* Mozilla, since 1999 */
|
||||
|
|
@ -219,14 +203,6 @@ pre.wrap-text {
|
|||
color: var(--text);
|
||||
}
|
||||
|
||||
.center-me {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
div.inverted {
|
||||
background-color: #1B1C1D;
|
||||
}
|
||||
|
||||
/*
|
||||
* The hand-rolled `.toggle` / `.slider` switch is gone.
|
||||
*
|
||||
|
|
@ -240,17 +216,6 @@ div.inverted {
|
|||
* for whoever revives it, and it also hardcoded #6435C9 and white, which no theme can reach.
|
||||
*/
|
||||
|
||||
.inverted-progress-text.ui.indicating.progress .bar > .progress,
|
||||
.inverted-progress-text.ui.progress > .label {
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
/* Light bar colors (yellow, olive) need dark text on the bar — must override inverted-progress-text. */
|
||||
.light-bar-progress-text.ui.indicating.progress .bar > .progress,
|
||||
.light-bar-progress-text.ui.progress .bar > .progress {
|
||||
color: rgba(0, 0, 0, 0.85) !important;
|
||||
}
|
||||
|
||||
/* File preview Modal.Actions toolbar.
|
||||
Single layout that works at every viewport — buttons keep their order at any width.
|
||||
On narrow screens, the tags selector wraps to its own line while the button groups stay together. */
|
||||
|
|
@ -316,18 +281,6 @@ td.file-path > svg {
|
|||
margin-right: 0.35em;
|
||||
}
|
||||
|
||||
/* Necessary until react-semantic-toasts is upgraded to Node 18 */
|
||||
.ui-alerts {
|
||||
position: fixed;
|
||||
z-index: 2060;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.ui-alerts.top-right {
|
||||
top: 1.25rem;
|
||||
right: 1.25rem;
|
||||
}
|
||||
|
||||
textarea.otp {
|
||||
width: 100%;
|
||||
color: #333;
|
||||
|
|
@ -339,69 +292,10 @@ textarea.otp {
|
|||
padding-top: 3.3125rem;
|
||||
}
|
||||
|
||||
.card-icon.inverted {
|
||||
background-color: #222222;
|
||||
}
|
||||
|
||||
.modal .inverted {
|
||||
border-color: #888888;
|
||||
}
|
||||
|
||||
.modal .header.inverted {
|
||||
color: #DDDDDD !important;
|
||||
background-color: #171616 !important;
|
||||
}
|
||||
|
||||
.modal .content.inverted {
|
||||
color: #DDDDDD !important;
|
||||
border-color: #888888 !important;
|
||||
background-color: #171616 !important;
|
||||
}
|
||||
|
||||
.modal .actions.inverted {
|
||||
color: #DDDDDD !important;
|
||||
border-color: #888888 !important;
|
||||
background-color: #171616 !important;
|
||||
}
|
||||
|
||||
.modal.inverted i.close {
|
||||
color: #DDDDDD !important;
|
||||
}
|
||||
|
||||
/* The accordion has no background of its own; it inherits whatever container it sits in (Modal
|
||||
content #171616, Segment #1B1C1D, the page body, etc.). This matches how the light theme already
|
||||
renders accordions (transparent), and avoids a darker band when the parent isn't #171616.
|
||||
Direct children only, so nested components that use the `content`/`title` class names (e.g. a
|
||||
Message's Message.Content) are not affected. */
|
||||
.ui.inverted.accordion,
|
||||
.ui.inverted.accordion > .title,
|
||||
.ui.inverted.accordion > .content {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
/* Semantic UI paints a definition table's empty corner cell white (background on the inverted
|
||||
variant, white box-shadows on the celled/plain variants) to fake a "cut-out" corner on a white
|
||||
page. Our dark theme has a dark page, so the corner must be transparent with no shadow. */
|
||||
.ui.inverted.definition.table thead:not(.full-width) th:first-child,
|
||||
.ui.inverted.definition.table tfoot:not(.full-width) th:first-child,
|
||||
.ui.inverted.celled.definition.table thead:not(.full-width) th:first-child,
|
||||
.ui.inverted.celled.definition.table tfoot:not(.full-width) th:first-child {
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.segment.inverted {
|
||||
border: 1px solid #ffffff22 !important;
|
||||
}
|
||||
|
||||
.inverted label {
|
||||
color: #DDDDDD !important;
|
||||
}
|
||||
|
||||
.table-ellipsis td.column-ellipsis {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
|
|
@ -431,14 +325,6 @@ textarea.otp {
|
|||
margin: 0.625rem;
|
||||
}
|
||||
|
||||
.icon.divider {
|
||||
color: rgb(0, 0, 0, 0.4) !important;
|
||||
}
|
||||
|
||||
.divider.inverted {
|
||||
color: rgb(255, 255, 255, 0.6) !important;
|
||||
}
|
||||
|
||||
.video-player {
|
||||
display: block;
|
||||
margin: 0 auto; /* Center the video horizontally */
|
||||
|
|
@ -468,80 +354,14 @@ table.ratio-table td.min-width {
|
|||
width: auto;
|
||||
}
|
||||
|
||||
/* CollectionEditForm styles */
|
||||
.required-indicator {
|
||||
color: red;
|
||||
}
|
||||
|
||||
/* Form loading skeleton */
|
||||
.form-skeleton-field {
|
||||
margin-bottom: 1.5em;
|
||||
}
|
||||
|
||||
.form-skeleton-label {
|
||||
height: 0.875rem;
|
||||
width: 5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.5s infinite;
|
||||
border-radius: 0.1875rem;
|
||||
}
|
||||
|
||||
.form-skeleton-input {
|
||||
height: 2.375rem;
|
||||
width: 100%;
|
||||
background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.5s infinite;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.form-skeleton-textarea {
|
||||
height: 5rem;
|
||||
width: 100%;
|
||||
background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.5s infinite;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.form-skeleton-button {
|
||||
height: 2.625rem;
|
||||
width: 6.25rem;
|
||||
background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.5s infinite;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
@keyframes skeleton-shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode skeleton */
|
||||
.inverted .form-skeleton-label,
|
||||
.inverted .form-skeleton-input,
|
||||
.inverted .form-skeleton-textarea,
|
||||
.inverted .form-skeleton-button {
|
||||
background: linear-gradient(90deg, #333 25%, #444 50%, #333 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
/*
|
||||
* Sticky footer for the file browser.
|
||||
*
|
||||
* These were two hardcoded Semantic greys with `.inverted` selecting the dark one, but
|
||||
* nothing has set `.inverted` since ThemeProvider stopped supplying it -- so the footer stayed
|
||||
* at near-white `#f9fafb` in dark, night and amber. In night mode a white bar pinned to the
|
||||
* bottom of the screen costs the user the dark adaptation the whole theme exists to protect.
|
||||
* Tokens have no such failure mode: there is one rule and each theme fills it in.
|
||||
* The surface is a token. It was two hardcoded greys with `.inverted` selecting the dark
|
||||
* one, and once nothing set `.inverted` any more the footer stayed at near-white `#f9fafb`
|
||||
* in dark, night and amber. In night mode a white bar pinned to the bottom of the screen
|
||||
* costs the user the dark adaptation the whole theme exists to protect. A token has no
|
||||
* such failure mode: there is one rule and each theme fills it in.
|
||||
*/
|
||||
.sticky-footer {
|
||||
position: fixed;
|
||||
|
|
@ -557,9 +377,9 @@ table.ratio-table td.min-width {
|
|||
*
|
||||
* This was a plain block, so the buttons -- inline-block elements with no whitespace
|
||||
* between them, because JSX drops whitespace-only lines -- sat flush against each other
|
||||
* in one unbroken slab. Everywhere else buttons are laid out with Mantine's `Group`,
|
||||
* which is a flex row with a gap; the file browser's footer is hand-rolled and never got
|
||||
* one. It wraps because the bar carries eight controls and a phone cannot hold them.
|
||||
* in one unbroken slab. Everywhere else a group of buttons is a flex row with a gap;
|
||||
* the file browser's footer is hand-rolled and never got one. It wraps because the bar
|
||||
* carries eight controls and a phone cannot hold them.
|
||||
*/
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -567,22 +387,24 @@ table.ratio-table td.min-width {
|
|||
gap: 0.5em;
|
||||
}
|
||||
|
||||
/* Drag selection visual feedback for FileBrowser */
|
||||
/*
|
||||
* Drag selection visual feedback for FileBrowser: a blue tint over the rows being taken,
|
||||
* red over the rows being dropped.
|
||||
*
|
||||
* Each had a second, stronger rule under `.inverted` for the dark themes. Nothing has set
|
||||
* that class since ThemeProvider stopped supplying it, so only the light tint has been
|
||||
* drawn for some time -- and 15% of a fixed blue over a dark row is close to invisible,
|
||||
* which is the one moment the user needs to see what they have hold of.
|
||||
*
|
||||
* A token mixed with transparent instead: the hue follows the theme, and the strength can
|
||||
* go up without pinning it to a literal that only suits one background.
|
||||
*/
|
||||
tr.drag-selecting td {
|
||||
background-color: rgba(33, 133, 208, 0.15) !important;
|
||||
background-color: color-mix(in srgb, var(--blue) 22%, transparent) !important;
|
||||
}
|
||||
|
||||
.inverted tr.drag-selecting td {
|
||||
background-color: rgba(33, 133, 208, 0.25) !important;
|
||||
}
|
||||
|
||||
/* Drag deselection visual feedback (red tint) */
|
||||
tr.drag-deselecting td {
|
||||
background-color: rgba(219, 40, 40, 0.15) !important;
|
||||
}
|
||||
|
||||
.inverted tr.drag-deselecting td {
|
||||
background-color: rgba(219, 40, 40, 0.25) !important;
|
||||
background-color: color-mix(in srgb, var(--red) 22%, transparent) !important;
|
||||
}
|
||||
|
||||
/* Prevent text selection during drag */
|
||||
|
|
@ -604,8 +426,23 @@ tr.drag-deselecting td {
|
|||
spaces it (see `.wrolpi-stack`). Its own 0.5em was added to that. */
|
||||
}
|
||||
|
||||
/* Double the default input width on desktop. */
|
||||
.ui.input.file-browser-filter {
|
||||
/*
|
||||
* Double the default input width on desktop.
|
||||
*
|
||||
* The selector was `.ui.input.file-browser-filter`, naming a wrapper the old input
|
||||
* component rendered around the field. Nothing carries `ui input` any more, so this rule
|
||||
* and the mobile one below it had been matching nothing at all: the filter was rendering
|
||||
* at the browser's default width on a bar laid out to give it 34em.
|
||||
*
|
||||
* The class lands on ActionInput's wrapper, which is the field and its clear button
|
||||
* together -- that pair is the control being sized, not the text box alone.
|
||||
*
|
||||
* Qualified by the container so it carries two classes. `.wrolpi-action-input` sets
|
||||
* `width: 100%` in ui.css, which is one class like `.file-browser-filter` is, and ui.css
|
||||
* is loaded after this file -- so a bare class selector here loses the tie and the field
|
||||
* fills the row anyway. Specificity, not source order, is what should decide it.
|
||||
*/
|
||||
.file-browser-filter-container .file-browser-filter {
|
||||
width: 34em;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
|
@ -620,36 +457,12 @@ tr.drag-deselecting td {
|
|||
}
|
||||
|
||||
/* Fill the row, but stay on one line with the collapse-all button. */
|
||||
.ui.input.file-browser-filter {
|
||||
.file-browser-filter-container .file-browser-filter {
|
||||
flex: 1 1 auto;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ensure step icons are always visible, even when disabled */
|
||||
.ui.steps .step.disabled > .icon,
|
||||
.ui.steps .step.disabled > i.icon,
|
||||
.ui.steps .disabled.step > .icon,
|
||||
.ui.steps .disabled.step > i.icon {
|
||||
opacity: 0.45 !important;
|
||||
display: inline-block !important;
|
||||
visibility: visible !important;
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
color: rgba(0, 0, 0, 0.87) !important;
|
||||
}
|
||||
|
||||
/* Ensure the icon glyph (::before) is visible for disabled steps */
|
||||
.ui.steps .step.disabled > .icon:before,
|
||||
.ui.steps .step.disabled > i.icon:before,
|
||||
.ui.steps .disabled.step > .icon:before,
|
||||
.ui.steps .disabled.step > i.icon:before {
|
||||
opacity: 0.45 !important;
|
||||
visibility: visible !important;
|
||||
color: rgba(0, 0, 0, 0.87) !important;
|
||||
}
|
||||
|
||||
|
||||
/* Inventory "PDF" export: the printable table is hidden on screen and becomes the only
|
||||
visible content when printing, so a user can use the browser dialog to Save as PDF. */
|
||||
.inventory-print {
|
||||
|
|
|
|||
|
|
@ -144,7 +144,7 @@ export function useTags() {
|
|||
|
||||
/*
|
||||
* The link carries no margin of its own. It had `0.3em` either side, from when these were
|
||||
* Semantic labels that had none -- but a tag already reserves a `margin-left` in ui.css for
|
||||
* labels that had none -- but a tag already reserves a `margin-left` in ui.css for
|
||||
* the point drawn outside its body, so that was 0.3em twice on top of the group's gap and
|
||||
* the point's own room. Two tags sat 34.7px apart, of which the point needs 18.7px.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ describe('a row of tags', () => {
|
|||
|
||||
it('adds no margin of its own around a linked tag', async () => {
|
||||
/*
|
||||
* The link wrapper carried `0.3em` either side, from when these were Semantic labels
|
||||
* The link wrapper carried `0.3em` either side, from when these were framework labels
|
||||
* that had no margin of their own. It is 0.3em twice, plus the group's gap, plus the
|
||||
* structural margin -- so two tags sat 34.7px apart where the point needs 18.7px, and
|
||||
* a wall of tags on the dashboard read as loose.
|
||||
|
|
|
|||
|
|
@ -709,10 +709,8 @@ export function DomainEditPage() {
|
|||
appliedTagName={domain?.tag_name}
|
||||
>
|
||||
{/*
|
||||
* CollectionEditForm still wraps its children in a `ui stackable grid` div (Semantic's
|
||||
* grid CSS, not a component) because it is shared with unmigrated callers (Channels.js,
|
||||
* Playlists.js). Plain "row"/"column" divs are that CSS's own class names, not a
|
||||
* semantic-ui-react import.
|
||||
* CollectionEditForm lays its children out as a grid, and "row"/"column" are that
|
||||
* grid's own class names -- plain divs, not components.
|
||||
*/}
|
||||
<div className="row">
|
||||
<div className="column">
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import {VinDecoderCalculator} from "./calculators/VinDecoderCalculator";
|
|||
// `group` matching one of these names; any calculator whose group is missing/unknown falls
|
||||
// into a trailing "Other" section so a new calculator can never silently disappear.
|
||||
//
|
||||
// `color` is a Semantic UI named color (colorblind-friendly palette) used to tint the group's
|
||||
// `color` is one of the named palette colors (see `paletteColorNames`) used to tint the group's
|
||||
// buttons. Violet is intentionally avoided — it is the default navbar color and would blend in.
|
||||
export const CALCULATOR_GROUPS = [
|
||||
{name: 'Preparedness & Storage', color: 'red'},
|
||||
|
|
|
|||
|
|
@ -492,7 +492,7 @@ export function SearchResultsInput({
|
|||
|
||||
const localHandleResultSelect = (result) => {
|
||||
if (handleResultSelect) {
|
||||
// Semantic handed its callers `{result}`, and they destructure it -- passing the
|
||||
// Callers destructure `{result}` from this -- passing the
|
||||
// bare result silently broke navigation from the search suggestions. The shape
|
||||
// stays until the call sites are ready to change together.
|
||||
handleResultSelect({result});
|
||||
|
|
@ -1626,7 +1626,7 @@ export const TAG_TEXT_LIGHT = '#dddddd';
|
|||
* Dark or light text, whichever a user's chosen tag color actually reads better against.
|
||||
*
|
||||
* Measures both instead of comparing a brightness figure to a threshold. The threshold
|
||||
* version put light text on Semantic's blue (`#2185d0`) at 2.9:1 when dark text on the same
|
||||
* version put light text on the palette blue (`#2185d0`) at 2.9:1 when dark text on the same
|
||||
* fill gives 5.3:1 -- it had picked the worse of the only two options. Every mid-tone blue,
|
||||
* teal and purple a user might choose sat in that band.
|
||||
*/
|
||||
|
|
@ -1823,7 +1823,7 @@ export function useAPIButton(
|
|||
}
|
||||
}
|
||||
|
||||
// `color` was a Semantic color name; a role carries the meaning instead, and a
|
||||
// `color` was a bare color name; a role carries the meaning instead, and a
|
||||
// caller that really wants a color can still pass one through `props`.
|
||||
const buttonArgs = {
|
||||
onClick: localOnClick, disabled, loading, type, size,
|
||||
|
|
@ -2118,7 +2118,7 @@ export function Breadcrumbs({crumbs, size = undefined}) {
|
|||
return <span key={index}>{text}</span>
|
||||
}
|
||||
|
||||
// The separator is a chevron rather than Semantic's slash, matching the icon set.
|
||||
// The separator is a chevron rather than a slash, matching the icon set.
|
||||
return <UIBreadcrumbs separator={<Icon name='chevron right' size={14}/>}>
|
||||
{crumbs.map(getSection)}
|
||||
</UIBreadcrumbs>
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@ describe('DirectorySearch', () => {
|
|||
it('renders the dropdown without crashing when directoryName is null', async () => {
|
||||
// An unset form field (e.g. the new Channel page) passes a null value. With
|
||||
// isDir=false a "New Directory" result is built from directoryName; if that title is
|
||||
// null, Semantic UI's Search crashes reading null.length when the menu opens.
|
||||
// null, the old Search crashed reading null.length when the menu opened.
|
||||
setMockHookState({
|
||||
directories: [],
|
||||
channelDirectories: [],
|
||||
|
|
@ -135,7 +135,7 @@ describe('DirectorySearch', () => {
|
|||
<DirectorySearch onSelect={mockOnSelect} value="" required/>
|
||||
);
|
||||
|
||||
// Unlike Semantic UI's Search, the SearchBox forwards `required` straight
|
||||
// Unlike the old Search, the SearchBox forwards `required` straight
|
||||
// onto the input.
|
||||
const input = screen.getByPlaceholderText(/search directory names/i);
|
||||
expect(input).toBeRequired();
|
||||
|
|
@ -495,7 +495,7 @@ describe('contrastingColor', () => {
|
|||
*/
|
||||
|
||||
it('picks whichever of the two options actually reads better', () => {
|
||||
// Semantic's blue. The old threshold check chose light text here, at 2.9:1, when dark
|
||||
// The palette blue. The old threshold check chose light text here, at 2.9:1, when dark
|
||||
// text on the same fill gives 5.3:1 -- the worse of the only two available answers.
|
||||
// Every mid-tone blue, teal and purple a user might choose sat in that band.
|
||||
const blue = '#2185d0';
|
||||
|
|
@ -781,7 +781,7 @@ describe('PageContainer', () => {
|
|||
|
||||
describe('a row of buttons', () => {
|
||||
/*
|
||||
* Semantic's Button brought `margin: 0 .25em .25em 0` with it, so a page could list buttons as
|
||||
* Buttons used to bring `margin: 0 .25em .25em 0` with them, so a page could list buttons as
|
||||
* bare siblings and they arrived both spaced and, when the row wrapped, separated. Nothing
|
||||
* replaced that margin. The archive page's six actions shared edges, and two of them carried
|
||||
* `marginTop: 0.5em` -- put there to keep a wrapped row from touching vertically, which instead
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ describe('DomainEditPage', () => {
|
|||
|
||||
render(<DomainEditPage/>);
|
||||
|
||||
// Should show Semantic UI Loader with text
|
||||
// Should show the Loading component with text
|
||||
expect(screen.getByText(/loading domain/i)).toBeInTheDocument();
|
||||
|
||||
// Form should not be visible during initial load
|
||||
|
|
|
|||
|
|
@ -37,11 +37,11 @@ import {
|
|||
} from "./Vars";
|
||||
|
||||
/*
|
||||
* Download.js was built around Semantic UI's <Form> family (Form.Input,
|
||||
* Download.js was built around the old <Form> family (Form.Input,
|
||||
* FormDropdown, TextArea) via `hooks/useForm.js`'s JSX helpers (InputForm,
|
||||
* NumberInputForm, ToggleForm, UrlInput, UrlsTextarea). `hooks/useForm.js` is
|
||||
* off-limits for this migration and its JSX helpers still render through
|
||||
* `./Theme` (Semantic UI). Rather than use those helpers, the field
|
||||
* `./Theme`. Rather than use those helpers, the field
|
||||
* components below call `useForm`'s plain data getters (getInputProps,
|
||||
* getSelectionProps, getCustomProps) directly and render our own markup with
|
||||
* the token-driven `./ui` library. See the migration report for what this
|
||||
|
|
@ -110,7 +110,7 @@ function NumberField({
|
|||
</div>
|
||||
}
|
||||
|
||||
/** A single-select field. `options` are Semantic-shaped ({key, text, value}); Mantine's Select
|
||||
/** A single-select field. `options` are {key, text, value}; Mantine's Select
|
||||
* requires string data values, so values are stringified for display and mapped back on change. */
|
||||
function SelectField({form, name, path, label, options, required = false, placeholder, disabled = false, afterChange}) {
|
||||
const [inputProps] = form.getSelectionProps({name, path, required, afterChange});
|
||||
|
|
@ -1547,7 +1547,7 @@ export function EditScrapeFilesDownloadForm({download, onDelete, onCancel, onSuc
|
|||
/>
|
||||
}
|
||||
|
||||
/** URL field for a single URL (Semantic's `UrlInput`). */
|
||||
/** URL field for a single URL. */
|
||||
function UrlField({form, required = true, name = 'url', path = 'url', disabled = false}) {
|
||||
const validator = (i) => validURL(i) ? null : 'Invalid URL';
|
||||
return <TextField
|
||||
|
|
@ -1562,7 +1562,7 @@ function UrlField({form, required = true, name = 'url', path = 'url', disabled =
|
|||
/>
|
||||
}
|
||||
|
||||
/** Multiline URLs field (Semantic's `UrlsTextarea`). */
|
||||
/** Multiline URLs field: one URL per line. */
|
||||
function UrlsField({name = 'urls', required, form}) {
|
||||
required = required !== undefined;
|
||||
|
||||
|
|
|
|||
|
|
@ -984,7 +984,7 @@ const REFRESH_PHASES = [
|
|||
{key: 5, icon: 'check circle', title: 'Cleanup', description: 'Finalizing'},
|
||||
];
|
||||
|
||||
// Replaces Semantic's Step.Group/Step: a row of phase markers, brightness/weight (via the active
|
||||
// A row of phase markers, brightness/weight (via the active
|
||||
// border) carrying the current stage rather than a bespoke component from the shared library.
|
||||
function RefreshStepItem({phase, isActive, isCompleted, isDisabled, description}) {
|
||||
return <div style={{
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ function SearchModal({open, onClose}) {
|
|||
*
|
||||
* `autoFocus` on the input below is what actually does it: the modal mounts its
|
||||
* content on a later tick, so this effect runs while `inputRef.current` is still
|
||||
* null. Semantic's modal mounted synchronously, which is why focusing here used
|
||||
* null. The modal this replaced mounted synchronously, which is why focusing here used
|
||||
* to be enough. The ref call is kept for the case where the modal is already
|
||||
* mounted and merely re-opened.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -17,14 +17,14 @@ import {
|
|||
} from "../hooks/customHooks";
|
||||
import {useReorganizationStatus} from "../contexts/FileWorkerStatusContext";
|
||||
import {SearchIconButton} from "./Search";
|
||||
import {HELP_VIEWER_URI, NAME, semanticUIColorMap} from "./Vars";
|
||||
import {HELP_VIEWER_URI, NAME, navColorHexMap} from "./Vars";
|
||||
import {useOverflowNav} from "../hooks/useOverflowNav";
|
||||
import {defaultNavColor, navBarStyle, useNavColors} from "../themes/navColors";
|
||||
import _ from "lodash";
|
||||
|
||||
function updateFavicon(colorName) {
|
||||
// Fall back to violet if invalid color
|
||||
const safeColor = colorName in semanticUIColorMap ? colorName : 'violet';
|
||||
const safeColor = colorName in navColorHexMap ? colorName : 'violet';
|
||||
const faviconPath = `/favicon-${safeColor}.svg`;
|
||||
|
||||
// Update all favicon links
|
||||
|
|
@ -40,7 +40,7 @@ function updateFavicon(colorName) {
|
|||
if (icon16) icon16.href = faviconPath;
|
||||
|
||||
// Update theme-color meta tag for iOS/Android status bar
|
||||
const hexColor = semanticUIColorMap[safeColor];
|
||||
const hexColor = navColorHexMap[safeColor];
|
||||
const themeColorMeta = document.querySelector('meta[name="theme-color"]');
|
||||
if (themeColorMeta) themeColorMeta.content = hexColor;
|
||||
}
|
||||
|
|
@ -88,8 +88,8 @@ function DropdownMenuItem({link}) {
|
|||
// "More" overflow menu.
|
||||
if (link.links) {
|
||||
// Mantine's Menu does not nest submenus without extra plumbing; a labelled,
|
||||
// indented group stands in for Semantic's nested Dropdown. No current data
|
||||
// uses this path (no allLinks entry has its own `links`).
|
||||
// indented group stands in for one. No current data uses this path (no
|
||||
// allLinks entry has its own `links`).
|
||||
return <React.Fragment>
|
||||
<Menu.Label>{link.text}</Menu.Label>
|
||||
{link.links.map(l => <DropdownMenuItem key={l.key} link={l}/>)}
|
||||
|
|
@ -158,8 +158,8 @@ function MobileMenu({links}) {
|
|||
/*
|
||||
* One status indicator's slot in the right-hand corner.
|
||||
*
|
||||
* This used to carry `marginTop: 0.8em`, a nudge left over from Semantic's menu. The corner
|
||||
* centres its children, and a top margin is part of the margin box being centred, so the
|
||||
* This used to carry `marginTop: 0.8em`, a nudge inherited from the bar this replaced. The
|
||||
* corner centres its children, and a top margin is part of the margin box being centred, so the
|
||||
* nudge pushed every indicator down by half of it -- about 6px.
|
||||
*
|
||||
* That read as an error on some and not others, which is why it survived: the bare-icon
|
||||
|
|
|
|||
|
|
@ -247,7 +247,7 @@ export function useSearchSuggestions(defaultSearchStr, defaultTagNames, anyTag)
|
|||
const noResults = [{title: 'No results'}];
|
||||
|
||||
const normalizeSuggestionsResults = (newSuggestions) => {
|
||||
// Convert the suggestions from the Backend to what the Semantic <Search> expects.
|
||||
// Convert the suggestions from the Backend to what SearchBox expects.
|
||||
const lowerSearchStr = searchStr ? searchStr.toLowerCase() : '';
|
||||
|
||||
let results = {};
|
||||
|
|
@ -516,8 +516,8 @@ export function SearchIconButton() {
|
|||
/*
|
||||
* A single button which opens the search modal via keyboard shortcuts context.
|
||||
*
|
||||
* `wrolpi-navbar-link`, not Semantic's leftover `item`, which carried no rules at all
|
||||
* once Semantic was removed: no pointer cursor, no hover highlight, and a hit area of
|
||||
* `wrolpi-navbar-link`, not the leftover `item` class, which carries no rules at all
|
||||
* now that the stylesheet defining it is gone: no pointer cursor, no hover highlight, and a hit area of
|
||||
* whatever the 18px glyph occupied rather than the height of the bar. It is the same
|
||||
* kind of thing as Help and Admin and now has the same affordance.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -226,15 +226,12 @@ export function ThemeProvider({children, ...props}: ThemeProviderProps) {
|
|||
</ThemeContext.Provider>
|
||||
}
|
||||
/*
|
||||
* The Semantic UI wrappers that used to live below this line are gone.
|
||||
* This module is the theme provider and nothing else: it resolves the chosen theme, stamps
|
||||
* `data-theme` and `data-media-filter` on <html>, persists both, and hands Mantine our
|
||||
* tokens.
|
||||
*
|
||||
* 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.
|
||||
* It used to re-export a set of themed component wrappers as well, along with the
|
||||
* dark-mode compatibility props they consumed (`i`, `s`, `t`, `inverted`). Those carried
|
||||
* hardcoded greys, which is precisely what the token themes replaced; components come from
|
||||
* `src/components/ui` instead.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ import {
|
|||
clearToasts,
|
||||
toast,
|
||||
} from './ui';
|
||||
import {semanticColorNames} from '../themes/mantine';
|
||||
import {paletteColorNames} from '../themes/mantine';
|
||||
import {contrastingColor} from './Common';
|
||||
import {navBarStyle, navColorNames, useNavColors} from '../themes/navColors';
|
||||
import {IconMenu2} from '@tabler/icons-react';
|
||||
|
|
@ -315,7 +315,7 @@ export function ThemeSamplePage() {
|
|||
<IconButton icon='search' label='Search'/>
|
||||
<IconButton icon='trash' role='danger' label='Delete'
|
||||
onClick={() => setConfirmOpen(true)}/>
|
||||
<Tooltip label='Tooltips replace Semantic popups'>
|
||||
<Tooltip label='A tooltip, on hover or focus'>
|
||||
<Button role='cancel'>Hover me</Button>
|
||||
</Tooltip>
|
||||
<Button role='cancel' onClick={() => toast({
|
||||
|
|
@ -351,11 +351,10 @@ export function ThemeSamplePage() {
|
|||
destructive or failed, and nothing else uses it.
|
||||
</p>
|
||||
<p style={{fontSize: '0.75rem', color: 'var(--muted)', marginBottom: 0}}>
|
||||
Buttons in a row are spaced by the row, never by margins of their own. Semantic's
|
||||
Button brought its own margin and nothing replaced it, so pages that listed
|
||||
buttons as bare siblings had them sharing edges — and the ones that answered with
|
||||
<code>margin-top</code> got a button sitting lower than its neighbours instead,
|
||||
because <code>vertical-align: middle</code> is measured on the margin box.
|
||||
Buttons in a row are spaced by the row, never by margins of their own. A button
|
||||
that carries its own <code>margin-top</code> sits lower than its neighbours,
|
||||
because <code>vertical-align: middle</code> is measured on the margin box —
|
||||
which is why the spacing belongs to the row and not to the buttons in it.
|
||||
</p>
|
||||
<p style={{fontSize: '0.75rem', color: 'var(--muted)', marginBottom: 0}}>
|
||||
An icon button is exactly as tall as a labelled one at every size, so a
|
||||
|
|
@ -368,9 +367,8 @@ export function ThemeSamplePage() {
|
|||
|
||||
<Section label='Two choices of equal weight'>
|
||||
<Panel>
|
||||
{/* Semantic's `Segment placeholder` with a vertical Divider, which is what the
|
||||
dashboard's Download/Upload panel is. The halves are a grid so they stay equal
|
||||
whatever the labels say, and the rule falls at the panel's true middle. */}
|
||||
{/* The dashboard's Download/Upload panel. The halves are a grid so they stay
|
||||
equal whatever the labels say, and the rule falls at the panel's true middle. */}
|
||||
<div className='wrolpi-or-split'>
|
||||
<div><Button role='primary' icon='download'>Download</Button></div>
|
||||
<div><Button role='save' icon='upload'>Upload</Button></div>
|
||||
|
|
@ -389,7 +387,7 @@ export function ThemeSamplePage() {
|
|||
<Panel>
|
||||
{/*
|
||||
* `toast` has thirteen call sites and no test, and it has already failed once
|
||||
* app-wide: App.js dropped Semantic's toast container while seven pages were
|
||||
* app-wide: App.js dropped the old toast container while seven pages were
|
||||
* still importing the old helper, and every notification silently vanished.
|
||||
* A toast is also the one component you cannot see by loading a page -- it has
|
||||
* to be provoked -- which is why it needs buttons here rather than a sample.
|
||||
|
|
@ -464,7 +462,7 @@ export function ThemeSamplePage() {
|
|||
</Panel>
|
||||
</Section>
|
||||
|
||||
<Section label='Semantic roles'>
|
||||
<Section label='Roles'>
|
||||
<Panel>
|
||||
{/*
|
||||
The roles side by side, which is the only way to see the thing that
|
||||
|
|
@ -765,7 +763,7 @@ export function ThemeSamplePage() {
|
|||
<Section label='Labels'>
|
||||
<Panel>
|
||||
<Row>
|
||||
{semanticColorNames.map(color => <Label key={color} color={color}>{color}</Label>)}
|
||||
{paletteColorNames.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>
|
||||
|
|
@ -938,8 +936,8 @@ export function ThemeSamplePage() {
|
|||
<Button role='cancel' icon='eye' onClick={() => setModalOpen(true)}>Open a modal</Button>
|
||||
</Row>
|
||||
<p style={{fontSize: '0.75rem', color: 'var(--muted)', marginBottom: 0, marginTop: 10}}>
|
||||
Semantic's compound shape is kept — Modal.Header, Modal.Content,
|
||||
Modal.Actions — so the 34 call sites written against it migrate by import.
|
||||
A modal is compound — Modal.Header, Modal.Content, Modal.Actions — so the
|
||||
markup at a call site reads the way the dialog is laid out.
|
||||
</p>
|
||||
<Modal open={modalOpen} onClose={() => setModalOpen(false)} size='small'>
|
||||
<Modal.Header>Restore Backup: channels.yaml</Modal.Header>
|
||||
|
|
|
|||
|
|
@ -77,7 +77,18 @@ export const allFrequencyOptions = {
|
|||
[days180Option.value]: days180Option,
|
||||
};
|
||||
|
||||
export const semanticUIColorMap = {
|
||||
/*
|
||||
* The named colors a user can pick for the navigation bar, and the fixed hex each one is.
|
||||
*
|
||||
* Fixed, and deliberately not tokens: these are the colors of the favicon files in
|
||||
* `public/favicon-<color>.svg`, which `scripts/generate-favicons.js` writes from this very
|
||||
* object, and of the `theme-color` meta tag the mobile browser paints its own chrome with.
|
||||
* Neither can read a CSS variable, so both need a literal that cannot drift from the bar.
|
||||
*
|
||||
* The palette is a colorblind-friendly one and is shared with `themes/navColors.ts`, which
|
||||
* measures a legible foreground against each.
|
||||
*/
|
||||
export const navColorHexMap = {
|
||||
red: '#db2828',
|
||||
orange: '#f2711c',
|
||||
yellow: '#fbbd08',
|
||||
|
|
@ -92,10 +103,6 @@ export const semanticUIColorMap = {
|
|||
grey: '#767676',
|
||||
}
|
||||
|
||||
export const ColorToSemanticHexColor = (color) => {
|
||||
return semanticUIColorMap[color] || null;
|
||||
}
|
||||
|
||||
export const validUrlRegex = /^(http|https):\/\/[^ "]+$/;
|
||||
|
||||
export const downloadOrderOptions = [
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ const NoComments = ({video}) => {
|
|||
}
|
||||
}
|
||||
|
||||
// Semantic's <Comment>/<CommentGroup> have no equivalent in the token library -- this is a nested
|
||||
// The token library has no comment-thread component -- this is a nested
|
||||
// comment thread, not a reusable widget, so it is built here from plain elements and tokens.
|
||||
const VideoComment = ({comment, children}) => {
|
||||
const {is_favorited, like_count, author, timestamp, text, author_is_uploader} = comment;
|
||||
|
|
|
|||
|
|
@ -214,7 +214,7 @@ describe('ControllerPage', () => {
|
|||
});
|
||||
expect(screen.getByText('Hotspot Protocol')).toBeInTheDocument();
|
||||
// The saved protocol is selected; WPA3 is offered because the device supports it.
|
||||
// Semantic UI renders the selected value as both the dropdown text and an option.
|
||||
// The select renders its chosen value as both the field's text and an option.
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('WPA2 (most compatible)').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('WPA3').length).toBeGreaterThan(0);
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ describe('ExtensionPage', () => {
|
|||
|
||||
it('shows the not-yet-installed warning when no binaries are available', async () => {
|
||||
// This test exists specifically to render the <Message warning> block,
|
||||
// which catches missing imports from the Theme/Semantic-UI module.
|
||||
// which catches missing imports from the component library.
|
||||
setUserAgent(CHROME_UA);
|
||||
global.fetch = mockFetch({
|
||||
files: {
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import {
|
|||
} from "../Common";
|
||||
import {useConfigs, useDockerized} from "../../hooks/customHooks";
|
||||
import {ConfigsTable} from "./Configs";
|
||||
import {semanticUIColorMap} from "../Vars";
|
||||
import {navColorHexMap} from "../Vars";
|
||||
import {Link} from "react-router";
|
||||
import {
|
||||
Button,
|
||||
|
|
@ -506,7 +506,7 @@ export function SettingsPage() {
|
|||
{value: tz, label: tz}
|
||||
));
|
||||
|
||||
const navColorOptions = Object.keys(semanticUIColorMap).map(i => {
|
||||
const navColorOptions = Object.keys(navColorHexMap).map(i => {
|
||||
return {value: i, label: i.charAt(0).toUpperCase() + i.slice(1)}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -46,12 +46,7 @@ export function CollectionEditForm({
|
|||
</Message>}
|
||||
|
||||
<form onSubmit={handleSubmit} autoComplete="off">
|
||||
{/*
|
||||
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.
|
||||
*/}
|
||||
{/* Every caller passes library components, so this is a plain flex column. */}
|
||||
<div
|
||||
className='wrolpi-form-rows'
|
||||
style={form.loading ? {opacity: 0.6, pointerEvents: 'none'} : undefined}
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ function getCollectionSearchLink(collection, routes, primaryKey) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Convert a Semantic-style relative column width (1-16) into a CSS percentage.
|
||||
* Convert a relative column width (1-16, sixteenths) into a CSS percentage.
|
||||
* @param {number} width - Relative width, 1-16
|
||||
* @returns {Object|undefined} A style object, or undefined when no width is configured
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ export function FieldCell({field, value, unitValue, onChange, onUnitChange, onEn
|
|||
return;
|
||||
}
|
||||
// Our inputs forward their ref straight to the underlying <input> DOM node, so focus + select work
|
||||
// directly on it (unlike Semantic's Input, which wrapped the node behind `.inputRef`).
|
||||
// directly on it (the old Input wrapped the node behind `.inputRef`).
|
||||
const node = localRef.current;
|
||||
if (node && node.focus) {
|
||||
node.focus();
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@ export function InventoryTable({slug, fields, items, locations, catalog, search,
|
|||
const visibleItems = useMemo(() => filterItems(displayItems, fields, search), [displayItems, fields, search]);
|
||||
|
||||
const focusFirst = () => {
|
||||
// Our inputs forward their ref straight to the underlying <input> DOM node (unlike Semantic's Input,
|
||||
// Our inputs forward their ref straight to the underlying <input> DOM node (unlike the old Input,
|
||||
// which wrapped it behind `.inputRef`).
|
||||
const node = firstInputRef.current;
|
||||
if (node && node.focus) {
|
||||
|
|
|
|||
|
|
@ -5,10 +5,9 @@ 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.
|
||||
* Roles carry meaning: 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';
|
||||
|
|
@ -25,7 +24,7 @@ const roleProps: Record<ButtonRole, {color?: string; variant: string; className?
|
|||
|
||||
export interface ButtonProps extends Omit<MButtonProps, 'leftSection' | 'rightSection'> {
|
||||
role?: ButtonRole;
|
||||
/** Semantic icon name or a Tabler component, rendered before the label. */
|
||||
/** An icon name (see Icon.tsx) or a Tabler component, rendered before the label. */
|
||||
icon?: string | React.ComponentType<any>;
|
||||
iconAfter?: string | React.ComponentType<any>;
|
||||
onClick?: React.MouseEventHandler<HTMLButtonElement>;
|
||||
|
|
@ -41,14 +40,15 @@ const renderIcon = (icon?: string | React.ComponentType<any>) => {
|
|||
}
|
||||
|
||||
/*
|
||||
* Semantic's size names, mapped onto Mantine's scale.
|
||||
* WROLPi's size names, mapped onto Mantine's scale.
|
||||
*
|
||||
* Unmigrated call sites still pass `size='tiny'`, and Mantine silently ignores a
|
||||
* size it does not know — the button renders at the default size and nobody sees
|
||||
* a warning. Translating here keeps those call sites looking right until they
|
||||
* migrate, and costs nothing once they have.
|
||||
* Call sites across the app say `size='tiny'` or `size='huge'`, and Mantine silently
|
||||
* ignores a size it does not know — the button renders at the default size, with no
|
||||
* warning and nothing on screen to say a size was asked for at all. One table here
|
||||
* is cheaper and safer than rewriting the size on several hundred call sites, and it
|
||||
* keeps the vocabulary the rest of the app already reads in.
|
||||
*/
|
||||
const semanticSizes: Record<string, string> = {
|
||||
const sizeAliases: Record<string, string> = {
|
||||
mini: 'xs',
|
||||
tiny: 'xs',
|
||||
small: 'sm',
|
||||
|
|
@ -60,9 +60,9 @@ const semanticSizes: Record<string, string> = {
|
|||
};
|
||||
|
||||
// Generic so it passes each component's own size type straight through; only a
|
||||
// string that Semantic knew about is rewritten.
|
||||
// string in the table above is rewritten.
|
||||
export const resolveSize = <T, >(size: T): T =>
|
||||
typeof size === 'string' ? ((semanticSizes[size] ?? size) as T) : size;
|
||||
typeof size === 'string' ? ((sizeAliases[size] ?? size) as T) : size;
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>((
|
||||
{role, icon, iconAfter, className, children, ...props}, ref
|
||||
|
|
@ -105,9 +105,8 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>((
|
|||
ref={ref}
|
||||
/*
|
||||
* An `href` makes this an anchor. Mantine needs `component='a'` to render one, and
|
||||
* without it the href is accepted and silently dropped — the control looks like a
|
||||
* link and navigates nowhere. Semantic used `as='a'` for this, so every migrated
|
||||
* call site that carried `href` alone was quietly broken.
|
||||
* without it the href is accepted and silently dropped, so a call site carrying
|
||||
* `href` alone renders a control that looks like a link and navigates nowhere.
|
||||
*/
|
||||
component={props.component ?? (props.href ? 'a' : undefined)}
|
||||
// An explicit color/variant still wins, so a call site can deviate when it must.
|
||||
|
|
|
|||
|
|
@ -15,8 +15,8 @@ import {Icon} from './Icon';
|
|||
* 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.
|
||||
* Compound: Table.Header / Body / Row / Cell / HeaderCell / Footer, so the markup at
|
||||
* a call site reads the way the rendered table is structured.
|
||||
*/
|
||||
|
||||
export interface TableProps extends MTableProps {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import React from 'react';
|
|||
import {Loader as MLoader, Skeleton} from '@mantine/core';
|
||||
import {Icon} from './Icon';
|
||||
import {pxToRem} from './scale';
|
||||
import {RoleName, SemanticColorName} from '../../themes/mantine';
|
||||
import {RoleName, PaletteColorName} from '../../themes/mantine';
|
||||
|
||||
/*
|
||||
* Feedback: messages, labels, progress, status text, loaders.
|
||||
|
|
@ -33,7 +33,7 @@ export interface MessageProps {
|
|||
kind?: MessageKind;
|
||||
title?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
/** Semantic icon name or a Tabler component, shown to the left of the text. */
|
||||
/** An icon name (see Icon.tsx) or a Tabler component, shown to the left of the text. */
|
||||
icon?: string | React.ComponentType<any>;
|
||||
/** Renders a dismiss button. Omit for messages the user cannot clear. */
|
||||
onDismiss?: () => void;
|
||||
|
|
@ -69,7 +69,7 @@ export function Message({kind = 'info', title, children, icon, onDismiss, classN
|
|||
// -------------------------------------------------------------------- Labels
|
||||
|
||||
export interface LabelProps {
|
||||
color?: SemanticColorName | RoleName | 'black' | 'white';
|
||||
color?: PaletteColorName | RoleName | 'black' | 'white';
|
||||
icon?: string | React.ComponentType<any>;
|
||||
/**
|
||||
* Draw it as a physical tag — pointed left edge and an eyelet — rather than a plain
|
||||
|
|
@ -108,7 +108,7 @@ export interface ProgressProps {
|
|||
indeterminate?: boolean;
|
||||
/** Show the percentage inside the bar. */
|
||||
showPercent?: boolean;
|
||||
color?: SemanticColorName | RoleName;
|
||||
color?: PaletteColorName | RoleName;
|
||||
/** Replaces the percentage with arbitrary text (e.g. "2.1 GB / 5.3 GB"). */
|
||||
label?: React.ReactNode;
|
||||
className?: string;
|
||||
|
|
@ -208,8 +208,8 @@ export interface LoadingProps {
|
|||
|
||||
/**
|
||||
* A centered loader with a caption, for a region that has nothing to show yet.
|
||||
* Replaces Semantic's `<Loader active inline='centered'>`, which 19 call sites
|
||||
* used with their own wrapper div.
|
||||
* A centred spinner with optional text under it. The wrapper is the component's,
|
||||
* so the 19 call sites that show one do not each roll their own.
|
||||
*/
|
||||
export function Loading({children, size = 'sm', padding = '2em'}: LoadingProps) {
|
||||
// `padding` is the caller's, so it stays inline; the rest is in ui.css so it scales.
|
||||
|
|
@ -219,7 +219,7 @@ export function Loading({children, size = 'sm', padding = '2em'}: LoadingProps)
|
|||
</div>
|
||||
}
|
||||
|
||||
/** Placeholder for content that has not arrived. Replaces Semantic's Placeholder. */
|
||||
/** Placeholder for content that has not arrived: a few skeleton lines. */
|
||||
export function Placeholder({lines = 3, height = 12}: {lines?: number; height?: number}) {
|
||||
return <div className='wrolpi-placeholder'>
|
||||
{Array.from({length: lines}, (_, index) => <Skeleton
|
||||
|
|
|
|||
|
|
@ -4,8 +4,9 @@ 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.
|
||||
* Tabler's bundled SVG components, which are tree-shaken at build time and need
|
||||
* nothing from the network -- an icon font would have to be fetched, and WROLPi
|
||||
* has to draw its interface with no connection at all.
|
||||
*
|
||||
* 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.
|
||||
|
|
@ -17,13 +18,14 @@ export type IconSize = 'small' | 'medium' | 'large';
|
|||
const sizes: Record<IconSize, number> = {small: 16, medium: 20, large: 24};
|
||||
|
||||
/**
|
||||
* Semantic icon name -> Tabler component name.
|
||||
* WROLPi's icon names -> Tabler component names.
|
||||
*
|
||||
* 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.
|
||||
* The app names an icon by what it is for -- `icon='book'`, `icon='arrow left'` --
|
||||
* rather than by which component draws it, so hundreds of call sites are not coupled
|
||||
* to the icon set. Swapping Tabler for something else is then this table and nothing
|
||||
* else. New code may pass a Tabler component directly as `component` instead.
|
||||
*/
|
||||
const semanticNames: Record<string, keyof typeof Tabler> = {
|
||||
const iconNameAliases: Record<string, keyof typeof Tabler> = {
|
||||
'add': 'IconPlus',
|
||||
'apple': 'IconBrandApple',
|
||||
'archive': 'IconArchive',
|
||||
|
|
@ -178,7 +180,7 @@ export interface IconStackProps {
|
|||
|
||||
/**
|
||||
* Two icons composed into one symbol — a wifi glyph with a question mark on it,
|
||||
* say. Replaces Semantic's IconGroup.
|
||||
* say.
|
||||
*
|
||||
* The corner glyph gets a background matching the surface so it stays legible
|
||||
* over the icon beneath it; see `--icon-stack-bg` in ui.css.
|
||||
|
|
@ -196,7 +198,7 @@ export function IconStack({children, corner, label, style, className}: IconStack
|
|||
}
|
||||
|
||||
export interface IconProps extends Omit<React.SVGProps<SVGSVGElement>, 'name' | 'ref'> {
|
||||
/** A Semantic UI icon name, for migrated call sites. */
|
||||
/** One of the icon names in `iconNameAliases`. */
|
||||
name?: string;
|
||||
/** A Tabler component, for new code. */
|
||||
component?: React.ComponentType<any>;
|
||||
|
|
@ -207,9 +209,9 @@ export interface IconProps extends Omit<React.SVGProps<SVGSVGElement>, 'name' |
|
|||
label?: string;
|
||||
}
|
||||
|
||||
/** Resolve a Semantic icon name to its Tabler component, or undefined if unmapped. */
|
||||
/** Resolve an 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()];
|
||||
const taberName = iconNameAliases[name.trim().toLowerCase()];
|
||||
return taberName ? (Tabler[taberName] as React.ComponentType<any>) : undefined;
|
||||
}
|
||||
|
||||
|
|
@ -219,7 +221,7 @@ export function Icon({name, component, size = 'small', loading, label, ...props}
|
|||
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.`);
|
||||
console.error(`No icon named "${name}"; add it to iconNameAliases, or pass a Tabler component.`);
|
||||
Component = Tabler.IconHelpCircle;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ export function Checkbox({style, ...props}: CheckboxProps) {
|
|||
|
||||
export type ToggleProps = React.ComponentProps<typeof MSwitch>;
|
||||
|
||||
/** An on/off switch. Replaces Semantic's `Radio toggle`. */
|
||||
/** An on/off switch. */
|
||||
export function Toggle({style, className, ...props}: ToggleProps) {
|
||||
return <MSwitch
|
||||
className={['wrolpi-switch', className].filter(Boolean).join(' ')}
|
||||
|
|
@ -146,8 +146,8 @@ export interface ActionInputProps extends React.ComponentProps<typeof MTextInput
|
|||
}
|
||||
|
||||
/**
|
||||
* A text input with a button joined to its right edge. Replaces Semantic's
|
||||
* `<Input action={...}/>`.
|
||||
* A text input with a button joined to its right edge -- a search field and its
|
||||
* clear control, a path field and its browse control.
|
||||
*
|
||||
* The two share one outline rather than sitting side by side, so the pair reads
|
||||
* as a single control — the input's own right border is dropped and the button
|
||||
|
|
|
|||
|
|
@ -25,10 +25,8 @@ export interface PaginationProps {
|
|||
}
|
||||
|
||||
/**
|
||||
* Page controls. Replaces Semantic's Pagination, keeping its prop names
|
||||
* (`activePage`, `totalPages`, `onPageChange`) so call sites migrate as a
|
||||
* rename — but `onPageChange` receives the page number directly rather than
|
||||
* Semantic's `(event, data)` pair.
|
||||
* Page controls: `activePage`, `totalPages`, `onPageChange`. `onPageChange` receives
|
||||
* the page number directly.
|
||||
*/
|
||||
export function Pagination({
|
||||
activePage,
|
||||
|
|
|
|||
|
|
@ -9,10 +9,9 @@ import {Button} from './Button';
|
|||
/*
|
||||
* Modals.
|
||||
*
|
||||
* Semantic's compound shape (`Modal.Header` / `Modal.Content` / `Modal.Actions`)
|
||||
* is kept because 34 call sites are written against it. The subcomponents are
|
||||
* markers: Modal reads them out of its children and places each in the right
|
||||
* slot, so the children stay in source order at the call site.
|
||||
* Compound: `Modal.Header` / `Modal.Content` / `Modal.Actions`. The subcomponents are
|
||||
* markers -- Modal reads them out of its children and places each in the right slot, so
|
||||
* the children stay in source order at the call site.
|
||||
*/
|
||||
|
||||
const ModalHeader = ({children}: {children?: React.ReactNode}) => <>{children}</>;
|
||||
|
|
@ -24,7 +23,7 @@ ModalContent.displayName = 'Modal.Content';
|
|||
const ModalActions = ({children}: {children?: React.ReactNode}) => <>{children}</>;
|
||||
ModalActions.displayName = 'Modal.Actions';
|
||||
|
||||
// Semantic's size names, mapped onto Mantine's scale.
|
||||
// WROLPi's size names, mapped onto Mantine's scale. See `sizeAliases` in Button.tsx.
|
||||
const modalSizes: Record<string, string> = {
|
||||
mini: 'xs',
|
||||
tiny: 'sm',
|
||||
|
|
@ -34,12 +33,12 @@ const modalSizes: Record<string, string> = {
|
|||
};
|
||||
|
||||
export interface ModalProps extends Omit<React.ComponentProps<typeof MModal>, 'opened' | 'size'> {
|
||||
/** Semantic's name for `opened`. Either works. */
|
||||
/** An alias for `opened`. Either works. */
|
||||
open?: boolean;
|
||||
opened?: boolean;
|
||||
/** A title given directly, instead of a `Modal.Header` child. */
|
||||
title?: React.ReactNode;
|
||||
/** Semantic size name (mini/tiny/small/large/fullscreen) or a Mantine size. */
|
||||
/** A size name (mini/tiny/small/large/fullscreen) or a Mantine size. */
|
||||
size?: string | number;
|
||||
/** Accepted and ignored: the close button is always shown. */
|
||||
closeIcon?: boolean;
|
||||
|
|
@ -52,8 +51,7 @@ export interface ModalProps extends Omit<React.ComponentProps<typeof MModal>, 'o
|
|||
* modal is open -- there is no check for whether it is the one the user is looking at. Two
|
||||
* modals open meant two listeners, so a single Escape closed BOTH: opening the search modal
|
||||
* over the dashboard's download modal and pressing Escape once left you on the dashboard.
|
||||
* Semantic handled this; the migration lost it.
|
||||
*
|
||||
|
||||
* Mantine's own answer is `Modal.Stack` with a `stackId` per modal, which is the only thing
|
||||
* that gates `closeOnEscape`. It is not used here for two reasons: it requires every call
|
||||
* site to be rewritten to hoist its modal into a stack, and it sets `__hidden` on everything
|
||||
|
|
@ -246,7 +244,7 @@ export function Confirm({
|
|||
|
||||
export type TooltipProps = React.ComponentProps<typeof MTooltip>;
|
||||
|
||||
/** Replaces Semantic's Popup. */
|
||||
/** A hover/focus tooltip. */
|
||||
export function Tooltip(props: TooltipProps) {
|
||||
return <MTooltip withArrow={false} openDelay={200} {...props}/>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@ import {Loader} from './Feedback';
|
|||
/*
|
||||
* The search box, with optional grouped suggestions.
|
||||
*
|
||||
* Replaces Semantic's `<Search category>`, and deliberately keeps its `results`
|
||||
* shape — `{groupKey: {name, results: [{title, description}]}}` — because four
|
||||
* call sites already build that structure from the API.
|
||||
* Grouped search suggestions. `results` is
|
||||
* `{groupKey: {name, results: [{title, description}]}}`, which is the shape four call
|
||||
* sites already build from the API.
|
||||
*
|
||||
* Suggestions are a combobox: the input keeps focus and owns the keyboard, while
|
||||
* `aria-activedescendant` points at the highlighted option. Arrow keys move,
|
||||
|
|
@ -64,6 +64,16 @@ export interface SearchBoxProps {
|
|||
className?: string;
|
||||
autoFocus?: boolean;
|
||||
name?: string;
|
||||
/**
|
||||
* Set on the input, not the wrapper, so a caller's own `<label htmlFor>` reaches it.
|
||||
*
|
||||
* SearchBox takes named props rather than spreading a rest object, so an `id` handed
|
||||
* to it used to be dropped in silence. `DestinationForm` passes one and renders a
|
||||
* label pointing at it: the download forms' Destination field has therefore had a
|
||||
* label associated with nothing at all, which a pointer user never notices and a
|
||||
* screen reader user gets nothing from.
|
||||
*/
|
||||
id?: string;
|
||||
}
|
||||
|
||||
/** Flatten the groups into the order they are displayed, for keyboard movement. */
|
||||
|
|
@ -90,6 +100,7 @@ export function SearchBox({
|
|||
className,
|
||||
autoFocus,
|
||||
name,
|
||||
id,
|
||||
}: SearchBoxProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [highlighted, setHighlighted] = useState(-1);
|
||||
|
|
@ -179,6 +190,7 @@ export function SearchBox({
|
|||
<input
|
||||
ref={inputRef}
|
||||
type='text'
|
||||
id={id}
|
||||
name={name}
|
||||
className='wrolpi-searchbox-input'
|
||||
placeholder={placeholder}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ export type HeaderLevel = 'h1' | 'h2' | 'h3' | 'h4' | 'h5';
|
|||
export interface HeaderProps extends Omit<React.HTMLAttributes<HTMLHeadingElement>, 'color'> {
|
||||
/** Heading level. Also the visual size — pick it for the outline, not the look. */
|
||||
as?: HeaderLevel;
|
||||
/** Semantic icon name or a Tabler component, shown before the text. */
|
||||
/** An icon name (see Icon.tsx) or a Tabler component, shown before the text. */
|
||||
icon?: string | React.ComponentType<any>;
|
||||
/** Secondary line below, in muted text. */
|
||||
subheader?: React.ReactNode;
|
||||
|
|
@ -42,8 +42,8 @@ export interface HeaderProps extends Omit<React.HTMLAttributes<HTMLHeadingElemen
|
|||
}
|
||||
|
||||
/**
|
||||
* A section heading. Named and shaped like Semantic's Header (`as`, `icon`,
|
||||
* `subheader`, `dividing`) so the app's 47 call sites migrate by import.
|
||||
* A section heading: `as` picks the level, with optional `icon`, `subheader` and a
|
||||
* `dividing` rule underneath.
|
||||
*
|
||||
* Sizes come from ui.css, keyed on the level, rather than each call site
|
||||
* choosing — that is what keeps the type scale a scale.
|
||||
|
|
@ -96,7 +96,7 @@ export interface PanelProps extends React.HTMLAttributes<HTMLDivElement> {
|
|||
danger?: boolean;
|
||||
}
|
||||
|
||||
/** A bordered surface. Replaces Semantic's Segment. */
|
||||
/** A bordered surface: the panel most of the app's content sits on. */
|
||||
export function Panel({danger, className, children, ...props}: PanelProps) {
|
||||
return <div
|
||||
className={['wrolpi-panel', danger ? 'wrolpi-panel-danger' : '', className]
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/*
|
||||
* The WROLPi component library: token-driven components built on Mantine.
|
||||
*
|
||||
* Import from here, not from `@mantine/core` or `semantic-ui-react`:
|
||||
* Import from here, not from `@mantine/core` directly:
|
||||
* import {Button, Table, Message} from '../components/ui';
|
||||
*
|
||||
* Everything resolves its colors through theme tokens, so no component branches
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {clearToasts, toast} from './toast';
|
|||
* Tests for toasts.
|
||||
*
|
||||
* `toast` has thirteen call sites and had no test at all, having already failed once in the
|
||||
* way that is hardest to notice: App.js dropped Semantic's toast container while seven pages
|
||||
* way that is hardest to notice: App.js dropped the old toast container while seven pages
|
||||
* were still importing the old helper, so every notification in the app silently went
|
||||
* nowhere. Nothing threw, and nothing looked wrong until somebody expected a message.
|
||||
*
|
||||
|
|
@ -175,7 +175,7 @@ describe('toast', () => {
|
|||
|
||||
describe('a toast that can be clicked', () => {
|
||||
/*
|
||||
* Semantic's toast took an `onClick` in its options and made the whole toast activate it.
|
||||
* The old toast took an `onClick` in its options and made the whole toast activate it.
|
||||
* `Events.js` still passes one -- it did before the migration and was never changed -- for
|
||||
* three events that each open a URL: a shared page pushed from the browser extension, a
|
||||
* finished archive upload, and a generated screenshot. The migrated helper destructured
|
||||
|
|
@ -314,7 +314,7 @@ describe('how long a toast stays up', () => {
|
|||
|
||||
it('stays until dismissed when the caller asks for time 0', async () => {
|
||||
/*
|
||||
* Semantic treated `time: 0` as "stay up". Mantine wants `autoClose: false`: its
|
||||
* Callers spell "stay up" as `time: 0`. Mantine wants `autoClose: false`: its
|
||||
* `getAutoClose` returns any number as given, and the container then calls
|
||||
* `setTimeout(hide, 0)` -- so passing the 0 straight through dismisses the toast on the
|
||||
* next tick. Call sites use 0 for errors the user must actually see, so getting this
|
||||
|
|
|
|||
|
|
@ -5,9 +5,8 @@ import {RoleName} from '../../themes/mantine';
|
|||
/*
|
||||
* 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.
|
||||
* One call signature for the ~19 places that raise a toast. The container is mounted
|
||||
* by ThemeProvider, so a caller only says what happened.
|
||||
*/
|
||||
|
||||
/* `error` is the original spelling and stays; `danger` is the token's name for it. */
|
||||
|
|
@ -20,9 +19,9 @@ export interface ToastOptions {
|
|||
/** Milliseconds before auto-dismiss. 0 keeps it until dismissed. */
|
||||
time?: number;
|
||||
/**
|
||||
* Makes the whole toast activate this — Semantic's behaviour, which `Events.js` still
|
||||
* relies on for the three events that carry a URL: a page shared from the browser
|
||||
* extension, a finished archive upload, and a generated screenshot.
|
||||
* Makes the whole toast activate this. `Events.js` relies on it for the three events
|
||||
* that carry a URL: a page shared from the browser extension, a finished archive
|
||||
* upload, and a generated screenshot.
|
||||
*/
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
|
@ -74,7 +73,7 @@ export function toast({type = 'info', title, description, time = 5000, onClick}:
|
|||
title,
|
||||
message: description,
|
||||
color: toastRoles[type],
|
||||
// Semantic treated 0 as "stay up"; Mantine wants false for that. Its `getAutoClose`
|
||||
// Callers spell "stay up" as `time: 0`; Mantine wants `false`. Its `getAutoClose`
|
||||
// returns any number as given and the container then calls `setTimeout(hide, 0)`, so
|
||||
// passing the 0 through dismisses the toast on the next tick.
|
||||
autoClose: time === 0 ? false : time,
|
||||
|
|
|
|||
|
|
@ -365,9 +365,8 @@ html[data-theme="amber"] .wrolpi-searchbox-control:focus-within {
|
|||
*
|
||||
* The control is a bordered box with `padding: 0 8px`, so the button sat 9px short of the
|
||||
* right edge and 2px short of the full height -- reading as a glyph dropped inside the input
|
||||
* rather than as a control of its own. Semantic rendered this as an action button welded to
|
||||
* the field: measured on the QA Pi, zero gap to the input, zero to the outer edge, and the
|
||||
* full 44px height.
|
||||
* rather than as a control of its own. It should read as an action button welded to the
|
||||
* field: zero gap to the input, zero to the outer edge, and the field's full height.
|
||||
*
|
||||
* A negative right margin rather than removing the control's padding: the padding is what
|
||||
* holds the search icon and the text off the border, and only this one child should escape
|
||||
|
|
@ -503,7 +502,7 @@ html .wrolpi-searchbox-clear {
|
|||
margin-top: 1.125rem;
|
||||
}
|
||||
|
||||
/* A stack of form rows, replacing Semantic's `ui stackable grid` in edit forms. */
|
||||
/* A stack of form rows: the layout of every edit form. */
|
||||
.wrolpi-form-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -525,9 +524,9 @@ html .wrolpi-searchbox-clear {
|
|||
}
|
||||
|
||||
/*
|
||||
* `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.
|
||||
* `hooks/useForm.js` renders its own <label> and native <input> rather than a library
|
||||
* control. These rules dress a native input from the tokens, so one of those fields
|
||||
* matches the library's own controls beside it.
|
||||
*/
|
||||
|
||||
.wrolpi-form-field input {
|
||||
|
|
@ -725,11 +724,10 @@ html[data-theme="amber"] .wrolpi-path-input-control:focus-within {
|
|||
* checked track keeps coming from the `color` prop.
|
||||
*/
|
||||
/*
|
||||
* Semantic's inverted stylesheet colors *every* descendant of `.ui.inverted.segment`
|
||||
* (rgba(255,255,255,.9), and #dddddd on labels). Our components are mounted inside
|
||||
* unmigrated pages that still use those wrappers, so an inherited color puts white
|
||||
* pixels on screen in night mode. Restating from tokens is the fix; the rule can go
|
||||
* when the last Semantic wrapper does.
|
||||
* A switch's label and description are body text, stated from the tokens rather than
|
||||
* inherited. Mantine draws the description from its own gray palette, which is a
|
||||
* non-red pixel in night mode; and an inherited color is only ever as good as whatever
|
||||
* the switch happens to be mounted inside.
|
||||
*/
|
||||
.wrolpi-switch .mantine-Switch-label {
|
||||
color: var(--text);
|
||||
|
|
@ -760,9 +758,9 @@ html[data-theme="amber"] .wrolpi-path-input-control:focus-within {
|
|||
/* ----------------------------------------------------------------- Labels */
|
||||
|
||||
/*
|
||||
* Sized to Semantic's label (12px text, ~7px of vertical padding), which these replaced.
|
||||
* The first pass came out at 11.5px with 3px of padding and read as noticeably smaller than
|
||||
* what users had before -- a tag is a hit target as well as a word.
|
||||
* 12px text with ~7px of vertical padding. The first pass came out at 11.5px with 3px of
|
||||
* padding and read as noticeably smaller than what users had before -- a tag is a hit
|
||||
* target as well as a word, and a phone is where it is hardest to hit.
|
||||
*/
|
||||
.wrolpi-label {
|
||||
display: inline-flex;
|
||||
|
|
@ -818,8 +816,8 @@ html[data-theme="amber"] .wrolpi-path-input-control:focus-within {
|
|||
/*
|
||||
* The point is centred on the left edge, so its inner half covers the body's first 10.5px
|
||||
* -- and a positioned pseudo-element paints above the parent's text, not behind it, so
|
||||
* without this the first letter is hidden under the point. Semantic's tag label carried
|
||||
* the same asymmetry (`padding-left: 1.5em`) for the same reason.
|
||||
* without this the first letter is hidden under the point. The asymmetry is the point's
|
||||
* width, not a nudge.
|
||||
*/
|
||||
padding-left: 0.8125rem;
|
||||
}
|
||||
|
|
@ -970,10 +968,10 @@ html[data-theme="night"] .wrolpi-tag::before {
|
|||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
/*
|
||||
* Stated explicitly because semantic.min.css sets `h1,h2,h3,h4,h5 { font-family:
|
||||
* Lato }` and loads after Mantine's element rule, so headings would ignore the
|
||||
* theme's body face — visible in amber, where the body is mono and headings were
|
||||
* not. A class outranks the element selector. Can go once Semantic is gone.
|
||||
* The face is stated here rather than left to inheritance or to Mantine's heading
|
||||
* config, so a heading cannot quietly diverge from the theme's body face. Amber is
|
||||
* where that shows: its body is mono, and a heading that misses the token renders in
|
||||
* a proportional face beside it.
|
||||
*/
|
||||
font-family: var(--font-body);
|
||||
font-weight: 600;
|
||||
|
|
@ -1203,9 +1201,8 @@ html .mantine-Notification-closeButton:hover {
|
|||
}
|
||||
|
||||
/*
|
||||
* Indeterminate: a sliding band, for work whose size is not known yet. Semantic called
|
||||
* this `indicating`; without it an upload that has not reported its size sits at 0% and
|
||||
* reads as stalled.
|
||||
* Indeterminate: a sliding band, for work whose size is not known yet. Without it an
|
||||
* upload that has not reported its size sits at 0% and reads as stalled.
|
||||
*/
|
||||
.wrolpi-progress-indeterminate .wrolpi-progress-fill {
|
||||
width: 35%;
|
||||
|
|
@ -1327,10 +1324,10 @@ html[data-theme="amber"] .wrolpi-progress-fill {
|
|||
/*
|
||||
* The same idea along the other axis: a row of buttons, spaced by the row.
|
||||
*
|
||||
* Semantic's Button carried `margin: 0 .25em .25em 0`, so a page could list buttons as bare
|
||||
* Buttons used to carry `margin: 0 .25em .25em 0` of their own, so a page could list them as bare
|
||||
* siblings and they arrived both spaced and, when the row ran out of width, separated from the line
|
||||
* below. Nothing replaced that margin -- the archive page's six actions shared edges, and the
|
||||
* downloads table's two icon buttons did too.
|
||||
* below. Once that margin went nothing replaced it -- the archive page's six actions shared edges,
|
||||
* and the downloads table's two icon buttons did too.
|
||||
*
|
||||
* Two of the archive page's six had `marginTop: 0.5em` instead, which is where the "Update sits
|
||||
* lower than the rest" came from: a Button is `display: inline-flex; vertical-align: middle`, and
|
||||
|
|
@ -1380,10 +1377,9 @@ html[data-theme="amber"] .wrolpi-progress-fill {
|
|||
/* ------------------------------------------------------------- Or divider */
|
||||
|
||||
/*
|
||||
* Two choices of equal weight, split down the middle -- Semantic's `Segment placeholder` with a
|
||||
* vertical `Divider`, which the dashboard's Download/Upload panel was built as.
|
||||
* Two choices of equal weight, split down the middle: the dashboard's Download/Upload panel.
|
||||
*
|
||||
* The migrated version was a `Group` with a divider between two buttons, so the halves were sized
|
||||
* An earlier version was a flex row with a divider between two buttons, so the halves were sized
|
||||
* to their buttons rather than to the panel: the pair huddled in the centre with the rule wherever
|
||||
* they left it, instead of two equal fields with the rule at the true middle.
|
||||
*
|
||||
|
|
@ -1501,8 +1497,8 @@ html[data-theme="amber"] .wrolpi-progress-fill {
|
|||
*/
|
||||
:root {
|
||||
--card-poster-max-height: 12.5rem;
|
||||
/* How far a disabled control fades. Semantic used 0.45 and it reads correctly against
|
||||
every theme's panel; the tests read this rather than repeating the number. */
|
||||
/* How far a disabled control fades. 0.45 reads correctly against every theme's panel;
|
||||
the tests read this rather than repeating the number. */
|
||||
--disabled-opacity: 0.45;
|
||||
}
|
||||
|
||||
|
|
@ -1641,9 +1637,8 @@ html[data-theme="amber"] .wrolpi-card-tag {
|
|||
/*
|
||||
* A statistic carries its own spacing rather than relying on a parent to supply
|
||||
* it, so that one dropped on a page by itself still sits clear of its
|
||||
* neighbours. Semantic's `Statistic.Group` only spaced elements carrying
|
||||
* Semantic's own classes, so without this the labels ran together into one
|
||||
* unreadable line.
|
||||
* neighbours. Without it the labels run together into one unreadable line
|
||||
* wherever a statistic is used outside a group.
|
||||
*/
|
||||
|
||||
.wrolpi-statistic {
|
||||
|
|
@ -1846,9 +1841,9 @@ html[data-theme="amber"] .wrolpi-th-sort:focus-visible {
|
|||
* A disabled button keeps its own color.
|
||||
*
|
||||
* Mantine repaints one flat grey -- background, text and border all replaced with
|
||||
* `--mantine-color-disabled`. Semantic kept the color and dropped the whole control to
|
||||
* `opacity: 0.45`, and that is the better signal: it says "not available" without also
|
||||
* saying "no longer the delete button".
|
||||
* `--mantine-color-disabled`. Keeping the color and dropping the whole control to
|
||||
* `opacity: 0.45` is the better signal: it says "not available" without also saying
|
||||
* "no longer the delete button".
|
||||
*
|
||||
* The file browser is where the difference shows. Its footer is eight buttons, six of them
|
||||
* disabled until something is selected, so the toolbar a user meets on arriving at /files was
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import userEvent from '@testing-library/user-event';
|
|||
import {MantineProvider} from '@mantine/core';
|
||||
import {IconMoodSmile} from '@tabler/icons-react';
|
||||
import {ThemeContext} from '../../contexts/contexts';
|
||||
import {cssVariablesResolver, mantineTheme, semanticColorNames} from '../../themes/mantine';
|
||||
import {cssVariablesResolver, mantineTheme, paletteColorNames} from '../../themes/mantine';
|
||||
import {themeChoices} from '../../themes/names';
|
||||
import {
|
||||
ActionInput,
|
||||
|
|
@ -49,7 +49,7 @@ const renderUI = (ui) => render(
|
|||
);
|
||||
|
||||
describe('Icon', () => {
|
||||
it('renders a Semantic name as an SVG', () => {
|
||||
it('renders a named icon as an SVG', () => {
|
||||
const {container} = renderUI(<Icon name='trash'/>);
|
||||
|
||||
expect(container.querySelector('svg')).toBeInTheDocument();
|
||||
|
|
@ -61,7 +61,7 @@ describe('Icon', () => {
|
|||
expect(container.querySelector('svg')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('maps every Semantic icon name the app uses', () => {
|
||||
it('maps every 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',
|
||||
|
|
@ -212,7 +212,7 @@ describe('Button', () => {
|
|||
expect(screen.getByRole('button', {name: 'Save'})).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('translates Semantic size names, which Mantine would otherwise ignore', () => {
|
||||
it('translates our size names, which Mantine would otherwise ignore', () => {
|
||||
// Unmigrated call sites still pass size='tiny'. Mantine drops a size it does not
|
||||
// recognise without warning, so the button quietly renders at the default size.
|
||||
renderUI(<><Button size='tiny'>Old</Button><Button size='sm'>New</Button></>);
|
||||
|
|
@ -225,7 +225,7 @@ describe('Button', () => {
|
|||
it('renders an anchor when given an href, without being told twice', () => {
|
||||
/*
|
||||
* Mantine drops `href` unless it also gets `component='a'`, so a button carrying
|
||||
* only an href looked like a link and navigated nowhere. Semantic spelled this
|
||||
* only an href looked like a link and navigated nowhere. The old library spelled this
|
||||
* `as='a'`, so every migrated call site that kept just the href was silently
|
||||
* broken — a download button that downloads nothing.
|
||||
*/
|
||||
|
|
@ -242,7 +242,7 @@ describe('Button', () => {
|
|||
|
||||
it('forwards a ref to the underlying DOM element', () => {
|
||||
/*
|
||||
* Ported from the old Theme.test.js, which caught a real crash: Semantic's Button
|
||||
* Ported from the old Theme.test.js, which caught a real crash: the old 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
|
||||
|
|
@ -621,7 +621,7 @@ describe('Progress', () => {
|
|||
|
||||
it('keeps its percent text readable on every bar color', () => {
|
||||
/*
|
||||
* Ported from Theme.test.js, which asserted Semantic's `inverted-progress-text`
|
||||
* Ported from Theme.test.js, which asserted the old `inverted-progress-text`
|
||||
* class across all fourteen colors 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
|
||||
|
|
@ -649,7 +649,7 @@ describe('Progress', () => {
|
|||
});
|
||||
|
||||
it('reports no value when the size is unknown', () => {
|
||||
// Semantic called this `indicating`. An upload that has not reported its size
|
||||
// An upload that has not reported its size
|
||||
// would otherwise sit at 0% and read as stalled, and `aria-valuenow=0` would tell
|
||||
// a screen reader the same wrong thing.
|
||||
renderUI(<Progress indeterminate label='Uploading…'/>);
|
||||
|
|
@ -676,12 +676,12 @@ describe('Status', () => {
|
|||
});
|
||||
|
||||
describe('Label', () => {
|
||||
it('supports every Semantic color', () => {
|
||||
it('supports every palette color', () => {
|
||||
const {container} = renderUI(<>
|
||||
{semanticColorNames.map(color => <Label key={color} color={color}>{color}</Label>)}
|
||||
{paletteColorNames.map(color => <Label key={color} color={color}>{color}</Label>)}
|
||||
</>);
|
||||
|
||||
expect(container.querySelectorAll('.wrolpi-label')).toHaveLength(semanticColorNames.length);
|
||||
expect(container.querySelectorAll('.wrolpi-label')).toHaveLength(paletteColorNames.length);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -802,7 +802,7 @@ describe('nothing paints a label\'s text with an inline color', () => {
|
|||
});
|
||||
|
||||
describe('Table', () => {
|
||||
it('renders a Semantic-shaped compound table', () => {
|
||||
it('renders a compound table', () => {
|
||||
renderUI(<Table>
|
||||
<Table.Header>
|
||||
<Table.Row><Table.HeaderCell>URL</Table.HeaderCell></Table.Row>
|
||||
|
|
@ -882,7 +882,7 @@ describe('Table', () => {
|
|||
describe('Card', () => {
|
||||
it('draws a mimetype accent from a token, not a hex', () => {
|
||||
// File cards carry the mimetype's color on their top edge so a grid of results is
|
||||
// scannable by kind. Semantic did this with `<Card color='violet'>`; two migrated
|
||||
// scannable by kind. The old card did this with `<Card color='violet'>`; two migrated
|
||||
// files had dropped the accent because our Card had no equivalent.
|
||||
const {container} = renderUI(<Card title='Water Storage.pdf' color='red'/>);
|
||||
|
||||
|
|
@ -1520,7 +1520,7 @@ describe('modal sizes', () => {
|
|||
/*
|
||||
* The size table is the single thing that halved every modal in the migration.
|
||||
*
|
||||
* Semantic's names were measured on the pre-migration build: mini 360, tiny 540, small
|
||||
* The names were measured on the build users had before: mini 360, tiny 540, small
|
||||
* 720, large 1080, fullscreen 95%. They are mapped onto Mantine's much smaller scale, so
|
||||
* a modal that said `small` went from 720px to 440px and one that said nothing at all
|
||||
* went from 900px to 440px. That is deliberate now -- the sizes were audited call site
|
||||
|
|
@ -1543,7 +1543,7 @@ describe('modal sizes', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('maps every Semantic name onto that scale', () => {
|
||||
it('maps every size name onto that scale', () => {
|
||||
// The mapping the call sites are written against. `fullscreen` is the one that did
|
||||
// not shrink, and the one several audited modals were moved TO.
|
||||
const source = fs.readFileSync(path.join(__dirname, 'Overlays.tsx'), 'utf8');
|
||||
|
|
@ -1595,7 +1595,7 @@ describe('modal sizes', () => {
|
|||
/*
|
||||
* Two different hazards, and neither announces itself.
|
||||
*
|
||||
* A misspelled SEMANTIC name is not in the table, so it passes through untranslated,
|
||||
* A misspelled name is not in the table, so it passes through untranslated,
|
||||
* Mantine does not recognise it either, and the modal silently renders at the default
|
||||
* 440px. A raw MANTINE name -- `sm`, `md` -- is recognised and honoured: it works,
|
||||
* so nothing ever complains, and the call site quietly sits outside the vocabulary
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ export const useIsTouchDevice = () => {
|
|||
/** @type {React.Context<import('../types/theme').ThemeContextValue>} */
|
||||
/*
|
||||
* The default is the declared shape of this context, and test-fixtures.test.js holds the
|
||||
* fixtures to it key for key. It used to carry `i`, `s`, `t` and `inverted` -- Semantic
|
||||
* fixtures to it key for key. It used to carry `i`, `s`, `t` and `inverted` -- the old
|
||||
* compatibility props holding hardcoded greys -- long after ThemeProvider stopped supplying
|
||||
* them, so the context advertised four properties that were always undefined in a real tree.
|
||||
* FileBrowser was still reading one of them.
|
||||
|
|
|
|||
|
|
@ -264,13 +264,13 @@ describe('useDriveHealth', () => {
|
|||
|
||||
describe('Paginator rendering', () => {
|
||||
test('renders exactly totalPages numbered page links (no phantom trailing link)', () => {
|
||||
// 48 results at 24 per page -> 2 pages. If totalPages is 3, semantic-ui
|
||||
// 48 results at 24 per page -> 2 pages. If totalPages is 3, the pagination
|
||||
// will render an extra "3" link that fetches an empty page.
|
||||
render(
|
||||
<Paginator activePage={1} totalPages={2} onPageChange={() => {}}/>
|
||||
);
|
||||
|
||||
// Semantic UI renders page numbers as <a> elements with the page text.
|
||||
// Page numbers render as <a> elements carrying the page text.
|
||||
// Tablet + mobile variants both render here because Media is mocked to
|
||||
// pass children through, so each numeric page appears at least once.
|
||||
expect(screen.queryAllByText('1').length).toBeGreaterThan(0);
|
||||
|
|
|
|||
|
|
@ -341,7 +341,7 @@ export function InputForm({
|
|||
inputProps.disabled = disabled || inputProps.disabled;
|
||||
|
||||
/*
|
||||
* Callers build these with Semantic's shape -- {content, header, positive|negative} --
|
||||
* Callers build these as {content, header, positive|negative} --
|
||||
* and the pages that build them are migrating separately, so both spellings are
|
||||
* accepted until they have all moved to {kind, title}.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -774,7 +774,7 @@ describe('useForm', () => {
|
|||
const [selectionProps] = result.current.getSelectionProps({name: 'selected'});
|
||||
|
||||
act(() => {
|
||||
// Semantic UI dropdown passes (event, {value})
|
||||
// A dropdown passes (event, {value})
|
||||
selectionProps.onChange({}, {value: 'option2'});
|
||||
});
|
||||
|
||||
|
|
|
|||
81
app/src/no-semantic-ui.test.js
Normal file
81
app/src/no-semantic-ui.test.js
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
/*
|
||||
* Semantic UI is gone, and this is what keeps it gone.
|
||||
*
|
||||
* It began as a ratchet: a JSON baseline listing the 75 files that still imported the
|
||||
* library, which was only ever allowed to shrink. The list reached zero and the file has
|
||||
* been deleted with it; what is left is the part that still has teeth.
|
||||
*
|
||||
* An import of a missing module fails loudly on its own, so that is not really what this
|
||||
* guards. It guards the quiet ways the library comes back: reinstalling a package to
|
||||
* "just use a Semantic component for this one thing", which drags a 300kB stylesheet into
|
||||
* the bundle with it, or writing `className="ui stackable grid"` in the belief that those
|
||||
* rules are still loaded, which they have not been since Phase 4 -- markup like that does
|
||||
* not error, it simply has no effect, and the layout quietly comes out wrong.
|
||||
*
|
||||
* It is a test rather than an ESLint rule deliberately: `no-restricted-imports` would
|
||||
* either print warnings on every dev rebuild or fail the build outright, and CI unsets CI=
|
||||
* for its build step, so warnings there guard nothing.
|
||||
*
|
||||
* New UI code imports from `src/components/ui`.
|
||||
*/
|
||||
|
||||
const SRC = __dirname;
|
||||
const REPO = path.join(SRC, '..');
|
||||
|
||||
/** 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(REPO, full)];
|
||||
});
|
||||
|
||||
const filesMatching = (pattern) => sourceFiles()
|
||||
.filter(file => pattern.test(fs.readFileSync(path.join(REPO, file), 'utf8')))
|
||||
.sort();
|
||||
|
||||
describe('Semantic UI stays removed', () => {
|
||||
it('scans a source tree, so a clean result means something', () => {
|
||||
// Every assertion below is "this list is empty". Without this one they would all
|
||||
// pass just as well if the walk found no files at all.
|
||||
expect(sourceFiles().length).toBeGreaterThan(100);
|
||||
});
|
||||
|
||||
it('has no file importing Semantic UI', () => {
|
||||
expect(filesMatching(/from ['"](semantic-ui-react|semantic-ui-react\/)/)).toEqual([]);
|
||||
});
|
||||
|
||||
it('has nobody left calling the Semantic toast library', () => {
|
||||
/*
|
||||
* A toast raised through react-semantic-toasts-2 needs `<SemanticToastContainer/>`
|
||||
* mounted, and that came out of App.js when App.js migrated. So this import fails
|
||||
* in the way that is hardest to notice: no error, no warning, just a notification
|
||||
* the user is never shown. Import `toast` from `src/components/ui` instead; it
|
||||
* takes the same options.
|
||||
*/
|
||||
expect(filesMatching(/from ['"]react-semantic-toasts-2/)).toEqual([]);
|
||||
});
|
||||
|
||||
it('has no file leaning on the Semantic stylesheet', () => {
|
||||
/*
|
||||
* `className="ui stackable grid"` and friends. semantic.min.css is not loaded, so
|
||||
* a `ui ...` class name now styles nothing at all -- which is why this cannot be
|
||||
* left to the import check: nothing about it fails, the element simply loses the
|
||||
* layout its author expected.
|
||||
*/
|
||||
expect(filesMatching(/className=["']ui /)).toEqual([]);
|
||||
});
|
||||
|
||||
it('has no Semantic package installed', () => {
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(REPO, 'package.json'), 'utf8'));
|
||||
const named = [...Object.keys(pkg.dependencies || {}),
|
||||
...Object.keys(pkg.devDependencies || {})]
|
||||
.filter(name => /semantic/i.test(name));
|
||||
|
||||
expect(named).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -1 +0,0 @@
|
|||
[]
|
||||
|
|
@ -1,134 +0,0 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import baseline from './semantic-baseline.json';
|
||||
|
||||
/*
|
||||
* Semantic UI is gone, and this keeps it gone.
|
||||
*
|
||||
* It began as a ratchet: semantic-baseline.json listed the 75 files still importing
|
||||
* Semantic UI, the list only shrank, and adding a file to it failed the build. The list
|
||||
* reached zero, the three packages are uninstalled, and the baseline is now an empty
|
||||
* array kept only so a reintroduction has to delete a file rather than edit one.
|
||||
*
|
||||
* The assertions below are cheap and each one caught something real during the migration,
|
||||
* so they stay: an import would now fail to resolve, but a `className="ui button"` or a
|
||||
* reach back through Theme.tsx would not, and neither would reinstalling a package.
|
||||
*
|
||||
* It is a test rather than an ESLint rule deliberately: `no-restricted-imports` would
|
||||
* either print dozens of warnings on every dev rebuild or fail the build outright, and CI
|
||||
* unsets CI= for its build step, so warnings there guard nothing.
|
||||
*/
|
||||
|
||||
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('has nobody left calling the Semantic toast library', () => {
|
||||
/*
|
||||
* This one is a ratchet at zero, not a shrinking list.
|
||||
*
|
||||
* The Semantic toasts only appear if `<SemanticToastContainer/>` is mounted, and
|
||||
* that container came out of App.js when App.js migrated. Any file that goes back
|
||||
* to importing `toast` from react-semantic-toasts-2 would therefore raise
|
||||
* notifications that silently never appear — no error, no warning, just a delete
|
||||
* or a failure the user is never told about. Import `toast` from
|
||||
* `src/components/ui` instead; it takes the same options.
|
||||
*/
|
||||
const TOASTS = /from ['"]react-semantic-toasts-2/;
|
||||
const importers = sourceFiles()
|
||||
.filter(file => TOASTS.test(fs.readFileSync(path.join(SRC, '..', file), 'utf8')));
|
||||
|
||||
expect(importers).toEqual([]);
|
||||
});
|
||||
|
||||
it('declares every migrated file that still leans on Semantic CSS classes', () => {
|
||||
/*
|
||||
* A file can stop importing semantic-ui-react and still depend on its stylesheet
|
||||
* by writing `className="ui stackable grid"`. The import ratchet above cannot see
|
||||
* that, so when Phase 4 deletes semantic.min.css the layout would silently
|
||||
* collapse. Anything on this list is a deliberate bridge that must be revisited
|
||||
* then; anything NOT on it is an accident.
|
||||
*/
|
||||
// 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))
|
||||
.filter(file => /className=["']ui /.test(fs.readFileSync(path.join(SRC, '..', file), 'utf8')));
|
||||
|
||||
expect(bridges.sort()).toEqual(allowed.sort());
|
||||
});
|
||||
|
||||
it('lets no migrated file keep rendering Semantic through Theme.tsx', () => {
|
||||
/*
|
||||
* The import check above only sees `semantic-ui-react`. `Theme.tsx` re-exports
|
||||
* Semantic-backed wrappers under friendly names, so a file could drop its direct
|
||||
* imports, leave the baseline, and still put Semantic components on screen —
|
||||
* which is exactly how a Semantic modal survived in the search shortcut long
|
||||
* after that file looked migrated.
|
||||
*
|
||||
* Theme.tsx also re-exports theme *names* (`themeChoices`, `darkTheme`, ...),
|
||||
* which are data and carry no markup; those come from `themes/names` instead, so
|
||||
* any import from Theme in a migrated file is a component import and a mistake.
|
||||
*/
|
||||
const fromTheme = /from ['"](\.{1,2}\/)+Theme['"]/;
|
||||
const leaks = sourceFiles()
|
||||
.filter(file => !baseline.includes(file))
|
||||
.filter(file => !file.endsWith('components/Theme.tsx'))
|
||||
.filter(file => fromTheme.test(fs.readFileSync(path.join(SRC, '..', file), 'utf8')));
|
||||
|
||||
expect(leaks).toEqual([]);
|
||||
});
|
||||
|
||||
it('has no Semantic package installed', () => {
|
||||
/*
|
||||
* The three packages are uninstalled. An import of a missing module fails loudly,
|
||||
* so this guards the quieter mistake: someone reinstalling one -- to "just use a
|
||||
* Semantic component for this one thing" -- which would drag the whole library and
|
||||
* its 300kB stylesheet back into the bundle.
|
||||
*/
|
||||
const pkg = JSON.parse(
|
||||
fs.readFileSync(path.join(SRC, '..', 'package.json'), 'utf8'));
|
||||
const named = [...Object.keys(pkg.dependencies || {}),
|
||||
...Object.keys(pkg.devDependencies || {})]
|
||||
.filter(name => /semantic/i.test(name));
|
||||
|
||||
expect(named).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps the baseline empty', () => {
|
||||
// The migration is finished; the file stays as an empty list so that putting a
|
||||
// name back requires a deliberate edit.
|
||||
expect(baseline).toEqual([]);
|
||||
expect(importers()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -18,7 +18,7 @@ jest.mock('react-stl-viewer', () => ({
|
|||
StlViewer: () => null,
|
||||
}));
|
||||
|
||||
// Mock window.matchMedia (used by Semantic UI Media components)
|
||||
// Mock window.matchMedia (used by the responsive Media components)
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation(query => ({
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
* The second is drift, which is the worse of the two. A fixture that no longer matches the
|
||||
* shape it stands in for makes a test *confidently* wrong: it passes while the component
|
||||
* would break in the browser. The theme context fixture carried `i`, `s` and `t` with
|
||||
* hardcoded Semantic greys for months after ThemeProvider stopped supplying them, and one
|
||||
* hardcoded greys for months after ThemeProvider stopped supplying them, and one
|
||||
* component was still reading a prop that no longer existed. test-fixtures.test.js compares
|
||||
* these against the real contexts so that class of drift fails a test rather than surviving.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import {
|
|||
* and the fixture is the last place anyone looks.
|
||||
*
|
||||
* That is not hypothetical here. The theme fixture supplied `i`, `s` and `t` holding
|
||||
* hardcoded Semantic greys for months after ThemeProvider stopped supplying them, and
|
||||
* hardcoded greys for months after ThemeProvider stopped supplying them, and
|
||||
* FileBrowser was still reading `inverted` off the context and pasting it into a className --
|
||||
* so the tests agreed with the fixture, the fixture agreed with nothing, and the file
|
||||
* browser's footer stayed light in all three dark themes.
|
||||
|
|
@ -128,7 +128,7 @@ describe('specs still mocking a context module', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('the Semantic theme compatibility props are gone for good', () => {
|
||||
describe('the theme compatibility props are gone for good', () => {
|
||||
it('no context, fixture, or component carries i/s/t/inverted from the theme', () => {
|
||||
/*
|
||||
* These four props are why this whole file exists. They were removed from
|
||||
|
|
|
|||
|
|
@ -29,16 +29,22 @@ export const roleNames = ['neutral', 'info', 'success', 'warning', 'danger'] as
|
|||
|
||||
export type RoleName = typeof roleNames[number];
|
||||
|
||||
/** The Semantic UI color names WROLPi uses. Each maps to a token of the same name. */
|
||||
export const semanticColorNames = [
|
||||
/**
|
||||
* The named colors WROLPi uses, each mapping to a token of the same name.
|
||||
*
|
||||
* A hue, unlike a role: this is the palette a user picks a nav bar or a calculator group
|
||||
* from, where the point is telling one thing from another rather than saying how serious
|
||||
* it is. The same twelve as `navColorHexMap` in components/Vars.js.
|
||||
*/
|
||||
export const paletteColorNames = [
|
||||
'red', 'orange', 'yellow', 'olive', 'green', 'teal', 'blue', 'violet', 'purple', 'pink',
|
||||
'brown', 'grey',
|
||||
] as const;
|
||||
|
||||
export type SemanticColorName = typeof semanticColorNames[number];
|
||||
export type PaletteColorName = typeof paletteColorNames[number];
|
||||
|
||||
const semanticColors = Object.fromEntries(
|
||||
[...semanticColorNames, ...roleNames].map(name => [name, tokenColor(name)])
|
||||
const paletteColors = Object.fromEntries(
|
||||
[...paletteColorNames, ...roleNames].map(name => [name, tokenColor(name)])
|
||||
);
|
||||
|
||||
export const mantineTheme = createTheme({
|
||||
|
|
@ -58,7 +64,7 @@ export const mantineTheme = createTheme({
|
|||
// Mantine cannot compute contrast from a `var()`, so it must not try.
|
||||
autoContrast: false,
|
||||
colors: {
|
||||
...semanticColors,
|
||||
...paletteColors,
|
||||
/*
|
||||
* Mantine reads `dark` and `gray` shades directly for surfaces, borders, and
|
||||
* secondary text — table borders, zebra stripes, input chrome, disabled states.
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import {SavedThemeName, ThemeMediaFilter, ThemeName} from '../types/theme';
|
|||
* Theme names and the choices offered by the pickers.
|
||||
*
|
||||
* These live here rather than in components/Theme.tsx so the component library can
|
||||
* use them without importing that module, which still pulls in Semantic UI.
|
||||
* use them without importing that module, which pulls in the whole ThemeProvider.
|
||||
*/
|
||||
|
||||
export const darkTheme = 'dark';
|
||||
|
|
@ -18,7 +18,7 @@ export const themeSessionKey = 'color-scheme';
|
|||
/** Every theme a user can apply, in the order the pickers offer them. */
|
||||
export const themeNames: ThemeName[] = [lightTheme, darkTheme, nightTheme, amberTheme];
|
||||
|
||||
/** Themes built on a dark background. Semantic components are `inverted` in these. */
|
||||
/** Themes built on a dark background. */
|
||||
export const darkThemes: ThemeName[] = [darkTheme, nightTheme, amberTheme];
|
||||
|
||||
/** Themes a user must choose deliberately; `prefers-color-scheme` never picks them. */
|
||||
|
|
@ -146,7 +146,7 @@ export const resolveMediaFilter = (
|
|||
export interface ThemeChoice {
|
||||
value: SavedThemeName;
|
||||
text: string;
|
||||
/** Semantic icon name; resolved to a Tabler component by the Icon shim. */
|
||||
/** An icon name; resolved to a Tabler component by Icon. */
|
||||
icon: string;
|
||||
description: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import path from 'path';
|
|||
import {contrastRatio, isMeasurable} from './contrast';
|
||||
import {defaultNavColor, navColorNames, navColorsFrom} from './navColors';
|
||||
import {themeNames} from './names';
|
||||
import {semanticUIColorMap} from '../components/Vars';
|
||||
import {navColorHexMap} from '../components/Vars';
|
||||
|
||||
/*
|
||||
* The navbar foreground, measured against the palette that actually ships.
|
||||
|
|
@ -53,12 +53,12 @@ describe('the navbar palette', () => {
|
|||
|
||||
it('offers exactly the colors the favicon has an icon for', () => {
|
||||
/*
|
||||
* `semanticUIColorMap` supplies the fixed hexes for `/favicon-<color>.svg` and the
|
||||
* `navColorHexMap` supplies the fixed hexes for `/favicon-<color>.svg` and the
|
||||
* `theme-color` meta tag -- a file on disk and OS chrome, neither of which can take a
|
||||
* token. A color offered here with no icon there falls back to violet, so the bar
|
||||
* and the browser tab would disagree.
|
||||
*/
|
||||
expect([...navColorNames].sort()).toEqual(Object.keys(semanticUIColorMap).sort());
|
||||
expect([...navColorNames].sort()).toEqual(Object.keys(navColorHexMap).sort());
|
||||
});
|
||||
|
||||
it('defaults to a color it offers', () => {
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ import {bestForeground, contrastRatio, isMeasurable} from './contrast';
|
|||
/**
|
||||
* The colors offered for the bar, in picker order.
|
||||
*
|
||||
* The same keys as `semanticUIColorMap` in components/Vars.js, which maps them to the fixed
|
||||
* The same keys as `navColorHexMap` in components/Vars.js, which maps them to the fixed
|
||||
* hexes used for the favicon and the mobile status bar (those are files and OS chrome, so
|
||||
* they cannot take a token). navColors.test.js holds the two lists together.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -236,10 +236,10 @@ html[data-theme="amber"] {
|
|||
/*
|
||||
* Base element styling.
|
||||
*
|
||||
* semantic.min.css used to supply all of this, and removing it took the lot with it --
|
||||
* links lost their color and gained the browser's underline and visited purple, and
|
||||
* A framework stylesheet used to supply all of this, and removing it took the lot with
|
||||
* it -- links lost their color 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.
|
||||
* None of it belonged to a framework; it belongs here, expressed in tokens.
|
||||
*/
|
||||
|
||||
/* ------------------------------------------------------------------------- */
|
||||
|
|
@ -248,15 +248,15 @@ html[data-theme="amber"] {
|
|||
/*
|
||||
* How large the interface is, overall.
|
||||
*
|
||||
* Measured against the pre-migration Semantic build at 1280px, the drift was not one
|
||||
* number: its headings were h1 28 / h2 24 / h3 18 / h4 15 / h5 14 against our
|
||||
* Measured against the build users had before this library, at 1280px, the drift was not
|
||||
* one number: its headings were h1 28 / h2 24 / h3 18 / h4 15 / h5 14 against our
|
||||
* 26 / 21 / 17 / 14 / 13, and its video card a fixed 290px with an 18px title against
|
||||
* our 238px with a 13px one -- while our BODY text and chrome went the other way,
|
||||
* a 16px base against Semantic's 14 and 42px buttons against 36. No per-component
|
||||
* nudge reproduces what a user comparing the two sees, which is the whole page.
|
||||
* a 16px base against its 14 and 42px buttons against 36. No per-component nudge
|
||||
* reproduces what a user comparing the two sees, which is the whole page.
|
||||
*
|
||||
* Ten percent is not a fudge factor: our heading scale times 1.1 is
|
||||
* 28.6 / 23.1 / 18.7 / 15.4 / 14.3, which is Semantic's scale to within a pixel at
|
||||
* 28.6 / 23.1 / 18.7 / 15.4 / 14.3, which is the old scale to within a pixel at
|
||||
* every step.
|
||||
*
|
||||
* The lever is the root font-size, and everything that should grow with it is written
|
||||
|
|
|
|||
|
|
@ -4,15 +4,15 @@ import path from 'path';
|
|||
/*
|
||||
* The size of the interface.
|
||||
*
|
||||
* Measured against the pre-migration Semantic build on the QA Pi at 1280px, the drift was not
|
||||
* Measured on the QA Pi at 1280px against the build users had before, the drift was not
|
||||
* one number: its headings were h1 28 / h2 24 / h3 18 / h4 15 / h5 14 against our 26 / 21 / 17
|
||||
* / 14 / 13, and its video card a fixed 290px with an 18px title against our 238px with a 13px
|
||||
* one -- while our BODY text and chrome went the other way, 16px base against Semantic's 14 and
|
||||
* one -- while our BODY text and chrome went the other way, 16px base against its 14 and
|
||||
* 42px buttons against 36. So no per-component nudge reproduces what a user comparing the two
|
||||
* actually sees, which is the whole page at once.
|
||||
*
|
||||
* Ten percent is not a fudge factor. Our heading scale multiplied by 1.1 is 28.6 / 23.1 / 18.7
|
||||
* / 15.4 / 14.3 -- Semantic's numbers, to within a pixel at every step.
|
||||
* / 15.4 / 14.3 -- the old numbers, to within a pixel at every step.
|
||||
*
|
||||
* The lever is the root font-size, and everything that should grow with it is written in `rem`.
|
||||
* That is Mantine's own mechanism: its 644 size declarations are `calc(Xrem * var(--mantine-
|
||||
|
|
|
|||
|
|
@ -57,7 +57,13 @@ class SettingsResponse:
|
|||
zims_destination: str = 'zims'
|
||||
|
||||
|
||||
class SemanticUIColors(enum.StrEnum):
|
||||
class NavColors(enum.StrEnum):
|
||||
"""The colors a user may choose for the navigation bar.
|
||||
|
||||
The same twelve as `navColorHexMap` in app/src/components/Vars.js, which holds the hex
|
||||
each one is drawn as, and as `navColorNames` in app/src/themes/navColors.ts. A color
|
||||
not on this list has no favicon file to go with it.
|
||||
"""
|
||||
red = enum.auto()
|
||||
orange = enum.auto()
|
||||
yellow = enum.auto()
|
||||
|
|
@ -107,7 +113,7 @@ class SettingsRequest:
|
|||
|
||||
def __post_init__(self):
|
||||
self.nav_color = self.nav_color.lower() if self.nav_color else None
|
||||
if self.nav_color and self.nav_color not in SemanticUIColors.__members__:
|
||||
if self.nav_color and self.nav_color not in NavColors.__members__:
|
||||
raise ValidationError('Nav color is invalid')
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue