From 064cdad18e8bee5328613b0181acaf61c97afe62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mindaugas=20Kasparavic=CC=8Cius?= Date: Tue, 11 Aug 2026 20:51:40 +0300 Subject: [PATCH] feat(sidebar): the tag shelf goes twice as deep, and its handle is the seam again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MAX_TAG_ROWS 12 → 24. Raising it alone squeezed the sections from 316px to 56.5px of an 800px window; squeezed out they take the grip off the bottom edge with them, and a shelf whose grip cannot be reached cannot be made small again. So the control strip is capped at calc(100% - 150px) and the chips scroll past it — which also holds when the window is resized after the drag. The last chip row sat 5px off the strip's border at every depth, because the handle rode down over the strip's bottom padding to sit on the border. The padding is the air under the shelf now. The handle is pinned to the strip's border instead of living in the flow, where it could hang off the chips or sit on the border but never both: held to the chips it lit up 9px above the divider it looks like, and welded to the border it glued the last chip row to the edge. Both had shipped, twice. e2e asserts the gap is the same at rest, one row deeper and at the new maximum; that the sections keep 150px however far the shelf is dragged; and that the handle's centre AND its hit area straddle the seam. Co-Authored-By: Claude Opus 5 --- e2e/tag-shelf-resize.spec.mjs | 93 ++++++++++++++++--- .../src/components/styles/SavedDiffs.css | 40 +++++--- src/renderer/src/utils/settingsDefaults.js | 6 +- 3 files changed, 108 insertions(+), 31 deletions(-) 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