diff --git a/app/src/App.css b/app/src/App.css index 374a7609..5d7d3c95 100644 --- a/app/src/App.css +++ b/app/src/App.css @@ -462,10 +462,6 @@ table.ratio-table td.min-width { color: red; } -.action-button-spacing { - margin-top: 1em; -} - /* Form loading skeleton */ .form-skeleton-field { margin-bottom: 1.5em; diff --git a/app/src/components/Archive.js b/app/src/components/Archive.js index 5a1edfc1..ee5bca1f 100644 --- a/app/src/components/Archive.js +++ b/app/src/components/Archive.js @@ -174,7 +174,6 @@ function ArchivePage() { confirmButton='Update' onClick={localUpdateArchive} obeyWROLMode={true} - style={{marginTop: '0.5em'}} > Update ; @@ -193,7 +192,6 @@ function ArchivePage() { confirmButton='Generate' onClick={localGenerateScreenshot} obeyWROLMode={true} - style={{marginTop: '0.5em'}} > Generate Screenshot : null; @@ -275,12 +273,14 @@ function ArchivePage() { - {singlefileButton} - {readButton} - {updateButton} - {deleteButton} - {generateScreenshotButton} - +
+ {singlefileButton} + {readButton} + {updateButton} + {deleteButton} + {generateScreenshotButton} + +
@@ -648,7 +648,6 @@ export function DomainEditPage() { confirmHeader='Delete Domain?' onClick={handleDelete} obeyWROLMode={true} - style={{marginTop: '1em'}} >Delete; const refreshButton = domain?.directory ? ( @@ -657,7 +656,6 @@ export function DomainEditPage() { size='small' onClick={handleRefreshDomain} obeyWROLMode={true} - style={{marginTop: '1em'}} >Refresh ) : null; @@ -666,7 +664,6 @@ export function DomainEditPage() { size='small' onClick={() => setTagEditModalOpen(true)} color='violet' - style={{marginTop: '1em'}} >Tag; const reorganizeButton = ( @@ -675,7 +672,6 @@ export function DomainEditPage() { size='small' onClick={() => setReorganizeModalOpen(true)} obeyWROLMode={true} - style={{marginTop: '1em'}} >Reorganize Files ); diff --git a/app/src/components/Channels.js b/app/src/components/Channels.js index b31f3c00..fa7b4133 100644 --- a/app/src/components/Channels.js +++ b/app/src/components/Channels.js @@ -173,7 +173,6 @@ export function ChannelEditPage() { confirmHeader='Delete Channel?' onClick={handleDelete} obeyWROLMode={true} - style={{marginTop: '1em'}} >Delete; const refreshButton = Refresh; const tagButton = ; const reorganizeButton = ( @@ -198,7 +195,6 @@ export function ChannelEditPage() { size='small' onClick={() => setReorganizeModalOpen(true)} obeyWROLMode={true} - style={{marginTop: '1em'}} >Reorganize Files ); diff --git a/app/src/components/Common.test.js b/app/src/components/Common.test.js index ca20148f..f2824797 100644 --- a/app/src/components/Common.test.js +++ b/app/src/components/Common.test.js @@ -752,14 +752,8 @@ describe('PageContainer', () => { * naming components from other files, so the page cannot be resolved from one file's text. * That is `VideosTabLayout`, whose pages are clean today but are not covered here. */ - const src = path.join(__dirname, '..'); - const files = (dir) => fs.readdirSync(dir, {withFileTypes: true}).flatMap(entry => { - const full = path.join(dir, entry.name); - if (entry.isDirectory()) return files(full); - if (!/\.jsx?$/.test(entry.name)) return []; - if (/\.(test|cy)\.jsx?$/.test(entry.name)) return []; - return [full]; - }); + const src = SRC; + const files = () => sourceFiles(); const offenders = []; for (const file of files(src)) { @@ -785,6 +779,241 @@ 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 + * 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 + * made those two sit lower than the rest, because `vertical-align: middle` aligns the margin box. + * + * `.wrolpi-button-row` is a flex row with a gap, so it covers both axes at once and no button + * needs a margin of its own. ui-layout.cy.js measures that the row really spaces and aligns + * what it holds; these two scans are about the row being REACHED and left alone -- neither of + * which throws, and both of which is how the app got here. + */ + + /* + * `=>` is blanked before any tag scanning. An arrow function in a prop -- `onClick={() => + * handleDelete(false)}`, which most of these buttons have -- puts a `>` inside an opening tag, + * and a scanner reading `>` as "tag ends here" splits one button into two bogus tags. Two + * characters for two, so every index still points where it did in the original text. + */ + const scannable = (text) => text.replace(/=>/g, '=~'); + + const TAG = /<(\/?)([A-Za-z][\w.]*)([^>]*?)(\/?)>/g; + const tagsIn = (text) => [...scannable(text).matchAll(TAG)].map(match => ({ + start: match.index, + end: match.index + match[0].length, + attributes: match[3], + closing: match[1] === '/', + selfClosing: match[4] === '/', + })); + + /** + * The tag that encloses `index`: walk the tags before it backwards, letting a close push the + * depth up and an open bring it back down, and stop at the first open that is still unclosed. + */ + const enclosingTag = (tags, index) => { + let depth = 0; + for (const tag of [...tags].filter(tag => tag.end <= index).reverse()) { + if (tag.selfClosing) continue; + if (tag.closing) depth += 1; + else if (depth === 0) return tag; + else depth -= 1; + } + return null; + }; + + /** + * Every `const = ...` declared at any indentation, as button consts are. + * + * Every one, not the first: Archive.js declares a `deleteButton` in the archive page and + * another in the domain edit page, and a resolver that stopped at the first found the clean one + * and reported the page with the margin on it as clean too. + */ + const localBodiesOf = (text, name) => { + const pattern = new RegExp(`^[ \\t]*(?:const|let)\\s+${name}\\s*=`, 'gm'); + return [...text.matchAll(pattern)].map(found => { + const after = text.slice(found.index + found[0].length); + const next = after.search(/\n[ \t]*(?:const|let|function|return|\})[\s(]/); + return next === -1 ? after : after.slice(0, next); + }); + }; + + /** Every `const actionButtons = <>...` fragment in `text`: a button row under another name. */ + const actionFragments = (text) => { + const bodies = []; + const pattern = /^[ \t]*const\s+actionButtons\s*=\s*<>/gm; + for (const found of text.matchAll(pattern)) { + const from = found.index + found[0].length; + const end = text.indexOf('', from); + if (end !== -1) bodies.push(text.slice(from, end)); + } + return bodies; + }; + + const VERTICAL_MARGIN = /margin(?:Top|Bottom)\s*:|margin\s*:/; + + /* + * The classes that lay buttons out in a row. A row is a row whatever it is called -- the file + * preview's toolbar had one before this, at a tighter gap because its buttons are icons -- so + * the check below is "is this one of them", and the test after it is what keeps the list honest + * by reading each one out of the stylesheet and confirming it really is a flex row with a gap. + */ + const ROW_CLASSES = [ + 'wrolpi-button-row', 'wrolpi-modal-actions', 'wrolpi-confirm-actions', 'preview-toolbar-group', + ]; + const isRow = (tag) => !!tag && ROW_CLASSES.some(name => tag.attributes.includes(name)); + + it('lays every row out as a flex row with a gap', () => { + // Otherwise the list above is just a list of names the scans below happen to accept. + const stylesheets = ['components/ui/ui.css', 'App.css'] + .map(name => fs.readFileSync(path.join(SRC, name), 'utf8')).join('\n'); + + ROW_CLASSES.forEach(name => { + const found = new RegExp(`\\.${name}\\s*\\{([^}]*)\\}`).exec(stylesheets); + // Named in the failure rather than passed to `expect`, which takes no message. + expect(found ? name : `${name} is not declared`).toBe(name); + expect(found[1]).toMatch(/display:\s*flex/); + expect(found[1]).toMatch(/gap:/); + }); + }); + + it('never stamps a vertical margin on a button the row already spaces', () => { + /* + * The margin and the gap add up. Nine buttons across the three collection edit pages had + * `marginTop: 1em` each and sat 1em below the Save button beside them; the archive page's + * Update and Generate Screenshot had 0.5em and sat half that below their own neighbours. + */ + const offenders = []; + let rows = 0; + let resolved = 0; + + for (const file of sourceFiles()) { + const text = fs.readFileSync(file, 'utf8'); + const where = path.relative(SRC, file); + + const blocks = actionFragments(text); + for (const tag of tagsIn(text)) { + if (!tag.closing && tag.attributes.includes('wrolpi-button-row')) { + // A row holds buttons, not more divs, so its first `` is its own. + const closes = text.indexOf('', tag.end); + blocks.push(closes === -1 ? '' : text.slice(tag.end, closes)); + } + } + rows += blocks.length; + + for (const block of blocks) { + // A button declared inline in the row, style and all. + if (VERTICAL_MARGIN.test(block)) offenders.push(`${where}: inline in the row`); + // ...or interpolated by name, which is how most of them are written. + for (const name of new Set([...block.matchAll(/\{(\w*[Bb]utton\w*)\}/g)] + .map(match => match[1]))) { + for (const body of localBodiesOf(text, name)) { + resolved += 1; + if (VERTICAL_MARGIN.test(body)) offenders.push(`${where}: ${name}`); + } + } + } + } + + // A scan that found no rows, or resolved none of their buttons, reports a clean app. + expect(rows).toBeGreaterThan(3); + expect(resolved).toBeGreaterThan(8); + + expect(offenders).toEqual([]); + }); + + it('puts every row of buttons in a row', () => { + /* + * The other half: a page can have margin-free buttons and still list them as bare siblings, + * which is the touching this fixes. Every `{actionButtons}` is rendered somewhere, and the + * element it is rendered into is what has to carry the gap -- on the doc page that was a + * plain `
`, spacing the row from what was above it while + * leaving the buttons inside it edge to edge. + */ + const offenders = []; + let sites = 0; + + for (const file of sourceFiles()) { + const text = fs.readFileSync(file, 'utf8'); + if (!text.includes('{actionButtons}')) continue; + const tags = tagsIn(text); + + for (const found of text.matchAll(/\{actionButtons\}/g)) { + // `actionButtons={actionButtons}` is the prop being handed down, not a render. + if (text.slice(found.index - 14, found.index) === 'actionButtons=') continue; + sites += 1; + const parent = enclosingTag(tags, found.index); + if (!isRow(parent)) { + offenders.push(`${path.relative(SRC, file)}: ${ + parent ? parent.attributes.trim().slice(0, 40) : 'no enclosing tag'}`); + } + } + } + + /* + * Three: the shared collection edit form, which three pages hand a fragment to, and the doc + * page's own two responsive branches. A scan that counted the `actionButtons={...}` props + * instead would reach eight and never look at a render site at all. + */ + expect(sites).toBe(3); + + expect(offenders).toEqual([]); + }); + + it('never lists buttons as bare siblings', () => { + /* + * The touching itself, and the reported defect: the archive page interpolated six buttons + * one after another straight into its Panel, so every pair shared an edge. + * + * A run of adjacent button interpolations is the shape to look for. Runs inside an + * `actionButtons` fragment are blanked first -- a fragment is spread into its parent's row + * and has no business being one itself, which the previous test is what checks. Blanked + * rather than skipped so every index still points where it did. + */ + const offenders = []; + let runs = 0; + + for (const file of sourceFiles()) { + let text = fs.readFileSync(file, 'utf8'); + for (const fragment of actionFragments(text)) { + text = text.replace(fragment, ' '.repeat(fragment.length)); + } + const tags = tagsIn(text); + + for (const found of text.matchAll(/\{\w*[Bb]utton\w*\}(?:\s*\{\w*[Bb]utton\w*\})+/g)) { + runs += 1; + const parent = enclosingTag(tags, found.index); + if (!isRow(parent)) { + offenders.push(`${path.relative(SRC, file)}: ${found[0].replace(/\s+/g, '')}`); + } + } + } + + /* + * Four: the archive page's actions, the file preview's toolbar (which had a row of its own + * already), the downloads table's edit/restart pair, and the doc page's format picker + * beside its actions. Asserted exactly, because a run that stopped being recognised would + * leave this reporting a clean app. + */ + expect(runs).toBe(4); + + expect(offenders).toEqual([]); + }); +}); + +/** The app's own source, minus its tests: what a source scan is allowed to read. */ +const SRC = path.join(__dirname, '..'); +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 (!/\.jsx?$/.test(entry.name)) return []; + if (/\.(test|cy)\.jsx?$/.test(entry.name)) return []; + return [full]; +}); + /** * Every `...` in `text` whose enclosing `return` provides page chrome -- a * `PageContainer` or a `wrolpi-stack` wrapper. Those are the routers that have already spaced and diff --git a/app/src/components/Docs.js b/app/src/components/Docs.js index feff9329..ea9926d6 100644 --- a/app/src/components/Docs.js +++ b/app/src/components/Docs.js @@ -403,13 +403,16 @@ function DocPage() { return <> {formatButtons &&
{formatButtons}
} -
{actionButtons}
+
+ {actionButtons} +
-
- {formatButtons && - {formatButtons} - } + {/* The row's own `margin-top` separates it from what is above; the gap + inside it separates the format picker from the actions, so the span + that used to hold them apart with `margin-right` is gone. */} +
+ {formatButtons} {actionButtons}
diff --git a/app/src/components/Playlists.js b/app/src/components/Playlists.js index 2e325367..24c285b9 100644 --- a/app/src/components/Playlists.js +++ b/app/src/components/Playlists.js @@ -479,7 +479,6 @@ export function PlaylistEditPage() { confirmHeader='Delete Playlist?' onClick={handleDelete} obeyWROLMode={true} - style={{marginTop: '1em'}} >Delete; const tagButton = ; const actionButtons = <> diff --git a/app/src/components/ThemeSamplePage.js b/app/src/components/ThemeSamplePage.js index 9a1e22f7..9c587644 100644 --- a/app/src/components/ThemeSamplePage.js +++ b/app/src/components/ThemeSamplePage.js @@ -332,10 +332,31 @@ export function ThemeSamplePage() {
)}
+
+
A row of actions
+ {/* `.wrolpi-button-row` is how a page lists the actions on a thing -- the + archive page's View/Read/Update/Delete, the edit pages' action row. It + wraps, so the last button drops to a second line at this width instead of + overflowing, and the row gap keeps the two lines apart. */} +
+ + + + + +
+

In night mode Delete drops its fill for a dashed red outline; dashed means destructive or failed, and nothing else uses it.

+

+ 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 + margin-top got a button sitting lower than its neighbours instead, + because vertical-align: middle is measured on the margin box. +

An icon button is exactly as tall as a labelled one at every size, so a group of them lines up. Mantine's own scale for icon buttons is unrelated diff --git a/app/src/components/admin/Downloads.js b/app/src/components/admin/Downloads.js index c5c31d86..0a8975a6 100644 --- a/app/src/components/admin/Downloads.js +++ b/app/src/components/admin/Downloads.js @@ -329,10 +329,14 @@ function RecurringDownloadRow({download, fetchDownloads, onDelete}) { {download.progress && } {next} - - {error && !download.progress && errorTrigger} - {editButton} - {restartButton} + + {/* `justify-content` rather than the cell's `text-align: right`, which a flex row + does not answer to. These two are icon buttons and were sharing an edge. */} +

+ {error && !download.progress && errorTrigger} + {editButton} + {restartButton} +
{errorModal} {editModal} diff --git a/app/src/components/collections/CollectionEditForm.js b/app/src/components/collections/CollectionEditForm.js index f9c4a078..f4e69c95 100644 --- a/app/src/components/collections/CollectionEditForm.js +++ b/app/src/components/collections/CollectionEditForm.js @@ -60,14 +60,21 @@ export function CollectionEditForm({ {appliedTagName &&
} -
+ {/* + * The shared row. It was already a flex row at this gap, but it did not wrap, so + * the pages that hand it four action buttons ran out of width on a phone -- which + * is why each of those buttons, and the Cancel below, carried `margin-top: 1em`. + * That margin is what made them sit lower than Save, `vertical-align: middle` + * being measured on the margin box. Wrapping plus a row gap covers the case the + * margin was for, in the axis it actually happens in. + */} +
{actionButtons} {onCancel && } diff --git a/app/src/components/collections/CollectionEditForm.test.js b/app/src/components/collections/CollectionEditForm.test.js index 7cf2f7e2..6a8ec02d 100644 --- a/app/src/components/collections/CollectionEditForm.test.js +++ b/app/src/components/collections/CollectionEditForm.test.js @@ -253,18 +253,28 @@ describe('CollectionEditForm', () => { }); describe('CSS Classes', () => { - it('uses action-button-spacing class for cancel button', () => { + it('puts its actions in a button row, and leaves their margins alone', () => { + /* + * `action-button-spacing` was `margin-top: 1em` on Cancel alone, matching the same + * margin the three calling pages stamped on each of their action buttons -- so every + * button in this row sat 1em below the Save beside it, which has none. The row spaces + * and wraps them now, and nothing in it carries a margin of its own. + */ const form = createTestForm(mockCollection); const mockOnCancel = jest.fn(); - render( + const {container} = render(
Form content
); const cancelButton = screen.getByRole('button', {name: /cancel/i}); - expect(cancelButton).toHaveClass('action-button-spacing'); + const row = container.querySelector('.wrolpi-button-row'); + expect(row).toBeInTheDocument(); + expect(row).toContainElement(cancelButton); + expect(row).toContainElement(screen.getByRole('button', {name: /save/i})); + expect(cancelButton).not.toHaveClass('action-button-spacing'); }); }); }); diff --git a/app/src/components/ui/ui-layout.cy.js b/app/src/components/ui/ui-layout.cy.js index 86c1e21a..ae64ec47 100644 --- a/app/src/components/ui/ui-layout.cy.js +++ b/app/src/components/ui/ui-layout.cy.js @@ -277,6 +277,136 @@ describe('a page stacks its own blocks', () => { }); }); +describe('a row of buttons is spaced and aligned by the row', () => { + /* + * Semantic's Button carried `margin: 0 .25em .25em 0` of its own, so a page could list + * buttons as bare siblings and they arrived spaced. Nothing replaced that margin, and + * five pages still list them that way -- the archive page's six actions all shared edges. + * + * Two of those six also carried `marginTop: 0.5em`, which is what made "Update" sit lower + * than its neighbours: a Button is `display: inline-flex; vertical-align: middle`, and + * `vertical-align` aligns the MARGIN box, so half of any top margin pushes the visible + * button down. The margin was there to keep a wrapped row from touching vertically -- + * the right thing, solved in the wrong axis. A flex row with `gap` covers both axes and + * needs no per-button margin, so the alignment comes back for free. + */ + + const rowButtons = ($row) => [...$row[0].querySelectorAll('button')]; + + it('puts a gap between the buttons, horizontally and when wrapped', () => { + cy.mountUI(
+ + + +
); + + cy.get('.wrolpi-button-row').should(($row) => { + const row = $row[0]; + const styles = getComputedStyle(row); + const declared = parseFloat(styles.columnGap); + expect(declared, 'the row declares a gap').to.be.greaterThan(0); + expect(parseFloat(styles.rowGap), 'and the same one between wrapped lines') + .to.be.closeTo(declared, 0.5); + expect(styles.flexWrap, 'the row wraps rather than overflowing').to.equal('wrap'); + + /* + * Narrowed to exactly two buttons' worth, measured rather than guessed. A hardcoded + * 150px was a hidden assertion about both the font and the interface scale, and at 1.1 + * it fitted one button, not the two the assertions below are about. + */ + const widths = rowButtons($row).map(el => el.getBoundingClientRect().width); + expect(widths, 'three buttons measured').to.have.length(3); + row.style.width = `${widths[0] + declared + widths[1] + 1}px`; + + const rects = rowButtons($row).map(el => el.getBoundingClientRect()); + const top = Math.min(...rects.map(rect => rect.top)); + const first = rects.filter(rect => Math.round(rect.top) === Math.round(top)); + expect(first, 'two buttons on the first line').to.have.length(2); + expect(first[1].left - first[0].right, 'gap along the line').to.be.closeTo(declared, 0.5); + + // And the third dropped to a second line, held off the first by the same gap. + const wrapped = rects.find(rect => Math.round(rect.top) !== Math.round(top)); + expect(wrapped, 'the third button wrapped').to.not.be.undefined; + expect(wrapped.top - first[0].bottom, 'gap between the lines') + .to.be.closeTo(declared, 0.5); + }); + }); + + it('centres its buttons against a taller item beside them', () => { + /* + * A row is not always all buttons: the doc page puts its format picker beside its actions, + * and the downloads table an error trigger beside its two icon buttons. Whatever is + * tallest sets the line height, and `align-items: center` is what keeps the buttons reading + * as part of that line rather than hanging from the top of it. + * + * Written as tops first, which was a test with no teeth -- `stretch` stretches the WRAPPER + * and leaves the button at the top of it, so the tops agreed either way. Centres are what + * the property actually decides. + * + * The row is also mixed in the way the archive page's is: a button inside an ``, a bare + * one, and an APIButton. + */ + cy.mountUI(
+
Tall
+
+ + Promise.resolve()}>Update +
); + + cy.get('.wrolpi-button-row').should(($row) => { + const row = $row[0].getBoundingClientRect(); + expect(row.height, 'the tall item set the line height').to.be.at.least(80); + + const centers = rowButtons($row).map((el) => { + const rect = el.getBoundingClientRect(); + return rect.top + rect.height / 2; + }); + expect(centers, 'three buttons measured').to.have.length(3); + const middle = row.top + row.height / 2; + centers.forEach(center => expect(center, 'centred in the line').to.be.closeTo(middle, 1)); + }); + }); + + it('counts a confirmed action as one item in the row, not two', () => { + /* + * `APIButton` with `confirmContent` returns a fragment -- the button AND a ``. + * In a flex row those flatten into siblings, so a Confirm that rendered anything in + * place would take a gap of its own and open a hole between two buttons. It portals, + * and this is what says so; three of the archive page's six actions are confirmed. + */ + cy.mountUI(
+ + Promise.resolve()}>Delete + +
); + + cy.get('.wrolpi-button-row').should(($row) => { + const declared = parseFloat(getComputedStyle($row[0]).columnGap); + const rects = rowButtons($row).map(el => el.getBoundingClientRect()); + expect(rects, 'three buttons measured').to.have.length(3); + rects.slice(1).forEach((rect, index) => expect( + rect.left - rects[index].right, 'gap either side of the confirmed action', + ).to.be.closeTo(declared, 0.5)); + }); + }); + + it('grows its gap with the interface scale', () => { + cy.mountUI(
); + + cy.get('.wrolpi-button-row').should(($row) => { + const before = parseFloat(getComputedStyle($row[0]).columnGap); + const root = Cypress.$('html')[0]; + const scale = parseFloat(getComputedStyle(root).getPropertyValue('--ui-scale')) || 1; + root.style.setProperty('--ui-scale', String(scale * 2)); + const after = parseFloat(getComputedStyle($row[0]).columnGap); + root.style.removeProperty('--ui-scale'); + + expect(before, 'the gap exists at all').to.be.greaterThan(0); + expect(after / before, 'and doubles with the scale').to.be.closeTo(2, 0.05); + }); + }); +}); + /* `--panel` is authored as a hex; computed backgrounds come back as rgb(). */ const hexToRgb = (hex) => { const value = hex.replace('#', ''); diff --git a/app/src/components/ui/ui.css b/app/src/components/ui/ui.css index 2706a468..0ab395bc 100644 --- a/app/src/components/ui/ui.css +++ b/app/src/components/ui/ui.css @@ -1306,6 +1306,36 @@ html[data-theme="amber"] .wrolpi-progress-fill { gap: 1rem; } +/* + * 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 + * 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. + * + * 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 + * `vertical-align` aligns the MARGIN box, so half of any top margin shows up as the visible button + * dropping. The margin was solving the wrapped-row case in the only axis a margin can, and paying + * for it in the other one. A gap covers both at once, so no button needs a margin of its own; the + * scans in Common.test.js are what keep them from growing back. + * + * 0.625rem matches `.wrolpi-modal-actions`, which is the same row in a modal footer. + */ +.wrolpi-button-row { + display: flex; + flex-wrap: wrap; + /* + * Centred, not stretched. Rows here are not all buttons -- the doc page puts its format + * picker beside its actions, and the downloads table its error trigger -- and a taller item + * makes the line taller than a button. `stretch` would leave the buttons hanging from the + * top of it; centring keeps a row of unequal things reading as one line. + */ + align-items: center; + gap: 0.625rem; +} + /* ----------------------------------------------------------------- Panels */ .wrolpi-panel {