diff --git a/e2e/tag-shelf-resize.spec.mjs b/e2e/tag-shelf-resize.spec.mjs index c8a04c4..54752a6 100644 --- a/e2e/tag-shelf-resize.spec.mjs +++ b/e2e/tag-shelf-resize.spec.mjs @@ -18,32 +18,75 @@ import { const TAG_COUNT = 10 const RESTING_CHIPS = 8 // MIN_TAG_ROWS (2) × TAGS_PER_ROW (4) const DRAGGED_CHIPS = 12 // one row deeper +const OLD_CEILING_CHIPS = 48 // what twelve rows used to be worth +// var(--space-3) under the last chip row, matching the air over the shelf's +// separator: the strip's border used to sit 5px under the chips at every depth. +const BOTTOM_GAP = 10 +// Whatever the shelf is dragged to, the sections keep this much of the column. +const LIST_FLOOR = 150 +// MAX_TAGS is 20 per snippet, so a deep shelf takes several. +const PER_SNIPPET = 18 -async function seedTags(page) { +async function seedSnippet(page, name, tags) { await newSnippetButton(page).click() const editor = page.getByRole('dialog', { name: 'New Snippet' }) - await editor.getByPlaceholder('Snippet name…').fill('Tag farm') + await editor.getByPlaceholder('Snippet name…').fill(name) await editor.locator('.editor').click() await page.keyboard.type('seeded body') - for (let i = 1; i <= TAG_COUNT; i++) { - await editor.getByPlaceholder('add a tag…').fill(`tag-${String(i).padStart(2, '0')}`) + for (const tag of tags) { + await editor.getByPlaceholder('add a tag…').fill(tag) await editor.getByPlaceholder('add a tag…').press('Enter') } await editor.getByRole('button', { name: 'Save', exact: true }).click() await expect(editor).toBeHidden() } -async function dragShelfDownOneRow(page) { - const rowStep = (await page.locator('.usb-tag').first().boundingBox()).height + 4 +async function seedTags(page, count = TAG_COUNT) { + const names = Array.from({ length: count }, (_, i) => `tag-${String(i + 1).padStart(2, '0')}`) + for (let at = 0; at < names.length; at += PER_SNIPPET) { + await seedSnippet(page, `Tag farm ${at}`, names.slice(at, at + PER_SNIPPET)) + } +} + +async function dragShelf(page, toY) { const box = await page.locator('.usb-shelf-grip').boundingBox() const x = box.x + box.width / 2 const y = box.y + box.height / 2 await page.mouse.move(x, y) await page.mouse.down() - await page.mouse.move(x, y + rowStep, { steps: 6 }) + await page.mouse.move(x, toY(y), { steps: 6 }) await page.mouse.up() } +async function dragShelfDownOneRow(page) { + const rowStep = (await page.locator('.usb-tag').first().boundingBox()).height + 4 + await dragShelf(page, (y) => y + rowStep) +} + +// The air between the last chip row and the strip's border, which is the same +// number at every depth. +async function bottomGap(page) { + const tags = await page.locator('.usb-tags').boundingBox() + const strip = await page.locator('.usb-controls').boundingBox() + return strip.y + strip.height - (tags.y + tags.height) +} + +// The divider under the shelf is the ONE thing that looks draggable, so it has +// to be the thing that is: both the paint and the hit area straddle it. Adrift +// in the air above, the handle lights up somewhere the pointer is not and the +// seam itself grabs nothing. +async function expectHandleOnTheSeam(page) { + const strip = await page.locator('.usb-controls').boundingBox() + const grip = await page.locator('.usb-shelf-grip').boundingBox() + const seam = strip.y + strip.height + expect(Math.abs(grip.y + grip.height / 2 - seam)).toBeLessThanOrEqual(1) + const onSeam = await page.evaluate( + ([x, y]) => document.elementFromPoint(x, y)?.className ?? '', + [strip.x + strip.width / 2, seam - 0.5] + ) + expect(onSeam).toContain('usb-shelf-grip') +} + test('the shelf grip deepens the tag rows, and the depth survives a relaunch', async () => { test.setTimeout(90_000) const dir = freshUserDataDir() @@ -56,14 +99,9 @@ test('the shelf grip deepens the tag rows, and the depth survives a relaunch', a await expect(chips).toHaveCount(RESTING_CHIPS) await expect(page.locator('.usb-more')).toBeVisible() - // The handle IS the section's bottom edge: flush under the chips AND flush - // with the strip's own boundary — floating anywhere between, it reads as - // some other divider's handle. - const tagsBox = await page.locator('.usb-tags').boundingBox() - const gripBox = await page.locator('.usb-shelf-grip').boundingBox() - const stripBox = await page.locator('.usb-controls').boundingBox() - expect(gripBox.y - (tagsBox.y + tagsBox.height)).toBeLessThanOrEqual(1) - expect(stripBox.y + stripBox.height - (gripBox.y + gripBox.height)).toBeLessThanOrEqual(1) + await expectHandleOnTheSeam(page) + const restingGap = await bottomGap(page) + expect(restingGap).toBeGreaterThanOrEqual(BOTTOM_GAP) // "+6 more" opens the SIX that did not fit — not the whole registry with // the promised six lost inside it. @@ -81,6 +119,8 @@ test('the shelf grip deepens the tag rows, and the depth survives a relaunch', a // One row deeper: four more chips show and the overflow count falls in step. await expect(chips).toHaveCount(DRAGGED_CHIPS) await expect(page.locator('.usb-more')).toContainText('+2') + // And the air under the shelf is the air it had before the drag. + expect(await bottomGap(page)).toBeCloseTo(restingGap, 0) } finally { await app.close() } @@ -94,3 +134,26 @@ test('the shelf grip deepens the tag rows, and the depth survives a relaunch', a await app.close() } }) + +// Dragged to the floor of the window: the shelf goes far deeper than the dozen +// rows it used to stop at, and the sections it shares the column with keep a +// usable slice of it — squeezed out, they take the grip off the bottom edge with +// them and there is no way left to make the shelf small again. +test('the shelf deepens past the old ceiling without swallowing the sidebar', async ({ page }) => { + test.setTimeout(120_000) + await seedTags(page, 108) + await expect(page.locator('.usb-tag')).toHaveCount(RESTING_CHIPS) + + const floor = await page.evaluate(() => window.innerHeight - 4) + await dragShelf(page, () => floor) + + expect(await page.locator('.usb-tag').count()).toBeGreaterThan(OLD_CEILING_CHIPS) + + const aside = await page.locator('.saved').boundingBox() + const list = await page.locator('.usb-scroll').boundingBox() + const grip = await page.locator('.usb-shelf-grip').boundingBox() + expect(list.height).toBeGreaterThanOrEqual(LIST_FLOOR) + expect(grip.y + grip.height).toBeLessThanOrEqual(aside.y + aside.height) + expect(await bottomGap(page)).toBeGreaterThanOrEqual(BOTTOM_GAP) + await expectHandleOnTheSeam(page) +}) diff --git a/src/renderer/src/components/styles/SavedDiffs.css b/src/renderer/src/components/styles/SavedDiffs.css index 4179fb5..e3a2499 100644 --- a/src/renderer/src/components/styles/SavedDiffs.css +++ b/src/renderer/src/components/styles/SavedDiffs.css @@ -49,6 +49,7 @@ /* Fixed control strip: search + the segmented group filter. */ .usb-controls { + position: relative; /* the shelf grip pins to its bottom edge */ flex: none; flex-direction: column; align-items: stretch; @@ -58,6 +59,11 @@ padding: 0 var(--space-3) var(--space-2); border-bottom: 1px solid var(--border); background: var(--bg-elevated); + /* The sections keep this much of the column whatever the shelf is dragged to, + and whatever the window is resized to afterwards. Squeezed out they take + the grip off the bottom edge with them, and a shelf with no reachable grip + cannot be made small again. Past the cap the chips scroll instead. */ + max-height: calc(100% - 150px); } /* Section toggles — independent pills (MULTI-select), so several can be on at once. "On" fills accent-tinted; "off" is a quiet outline. All + ★ are helpers, @@ -65,6 +71,7 @@ /* The search field and its one modifier share a row, so the band keeps the height the rail lines up against. */ .usb-find { + flex: none; gap: var(--space-2); } .usb-find > :first-child { @@ -90,10 +97,14 @@ color: var(--pin-ink); border-color: color-mix(in srgb, var(--favorite) 40%, var(--border)); } +/* The one part of the strip that gives way when the shelf is deeper than the + column can hold: the chips scroll rather than pushing the sections out. */ .usb-tags { display: flex; flex-wrap: wrap; gap: 4px; + min-height: 0; + overflow-y: auto; margin-top: var(--space-1); margin-bottom: var(--space-1); } @@ -138,16 +149,21 @@ padding-bottom: var(--space-4); } -/* Drag-to-deepen: the width grip's horizontal twin, on the shelf's bottom - edge. Unmarked at rest; the depth's only affordance, persisted as - tagShelfRows. Full-bleed across the control strip so the seam is the - target, not a sliver of it. */ +/* Drag-to-deepen: the width grip's horizontal twin, straddling the strip's own + border. Unmarked at rest; the depth's only affordance, persisted as + tagShelfRows. Full-bleed so the seam is the target, not a sliver of it. + + OUT of the flow, which is the whole trick: in it, the handle can either hang + off the chips or sit on the border, and each attempt at one broke the other — + held to the chips it lights up in mid-air well above the divider, and welded + to the border it glues the last chip row to the edge. Pinned here it is the + divider, and the strip's bottom padding is free to be the air above it. */ .usb-shelf-grip { - height: 4px; - /* Pulled up over the column gap + the shelf's own margin so the handle sits - ON the chips' bottom edge — floating 10px below, it read as some other - divider's handle. */ - margin: calc(-1 * (var(--space-2) + var(--space-1))) calc(-1 * var(--space-3)) 0; + position: absolute; + left: 0; + right: 0; + bottom: -3px; /* centres the 5px band on the 1px border */ + height: 5px; cursor: row-resize; user-select: none; touch-action: none; @@ -156,11 +172,6 @@ .usb-shelf-grip.resizing { background: color-mix(in srgb, var(--accent) 45%, transparent); } -/* With no filter row after it, the grip IS the strip's boundary — it rides - down over the strip's bottom padding to sit on the border itself. */ -.usb-shelf-grip:last-child { - margin-bottom: calc(-1 * var(--space-2)); -} /* The overflow keeps the chip shape — it stands in for tags, so it belongs in their row — but carries no colour dot, because it is not one. */ @@ -177,6 +188,7 @@ selected tag whatever shape it took. It belongs on its own line, beside the thing it undoes: how many tags are on. */ .usb-filtering { + flex: none; display: flex; align-items: baseline; gap: var(--space-2); diff --git a/src/renderer/src/utils/settingsDefaults.js b/src/renderer/src/utils/settingsDefaults.js index af4df2e..8caf503 100644 --- a/src/renderer/src/utils/settingsDefaults.js +++ b/src/renderer/src/utils/settingsDefaults.js @@ -34,10 +34,12 @@ export const FILE_TYPE_LIMITS = { export const DEFAULT_MAX_SNIPPET_SIZE_KB = 512 // The sidebar tag shelf's depth. Two rows is the FLOOR as well as the default: // diffs and snippets are what the sidebar is for, and an unbounded tag wall -// pushed them into a sliver. A tall screen may spend up to twelve. +// pushed them into a sliver. The ceiling can afford to be this generous because +// the strip's max-height (SavedDiffs.css) keeps the sections a slice of the +// column whatever is asked for — past it the chips scroll. export const TAGS_PER_ROW = 4 export const MIN_TAG_ROWS = 2 -export const MAX_TAG_ROWS = 12 +export const MAX_TAG_ROWS = 24 export const MAX_SNIPPET_SIZE_KB_CAP = 8192