From 10a5ae227124ccf73c04a8fbcfbbebf5755e3711 Mon Sep 17 00:00:00 2001 From: xarmian Date: Sun, 26 Jul 2026 01:11:44 -0400 Subject: [PATCH] feat(web): dissolve the item action bar into a new .tab-strip wrapper (TASK-2328) (#1033) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 2 of PLAN-2326 (DR-4, DR-9) — the core of IDEA-2299. The `.meta-actions` band is gone; its five controls are right-aligned into the tab row. `.tab-strip` (flex, align-items:center) wraps the UNCHANGED `.pane-tabs` tablist plus a new `.strip-actions` sibling holding the star, QuickActionsMenu (its `{#key itemSlug}` wrapper intact), both jump badges, and the `.menu-anchor` wrapper — moved whole, since it is the `position: relative` containing block the anchored Menu positions against. `.strip-actions` is a SIBLING of `.pane-tabs`, never a child: `role="tablist"` is on `.pane-tabs` itself, so nesting the actions inside would put non-tab children in a tablist and in range of the arrow-key handler's `querySelectorAll('[role="tab"]')` walk (DR-4). DR-9 width allocation: the actions never shrink (`flex: 0 0 auto`); the tab list scrolls (`min-width: 0; overflow-x: auto`) rather than wrapping or crushing them. The scroll rule is on `.pane-tabs` ONLY — an `overflow` value on the shared `.tab-strip` ancestor would clip both anchored popovers. For the same reason the wrapper carries no `contain` / `clip-path` / `transform` / `filter` / `will-change`. `container-type: inline-size` is safe (layout/style/inline-size containment, no paint containment) and is what TASK-2329's tier rule queries; verified in Chromium that neither the anchored panels nor the mobile BottomSheet's `position: fixed` overlay are affected. Both badges split their single text node into `.badge-icon` + `.badge-count` (DR-9) so TASK-2329 can hide the icon and keep the count. `title` / `aria-label` and the literal space between the spans are preserved, so the computed accessible names are byte-identical. Also here: - `.pane-tabs` gains `padding-bottom: 1px; margin-bottom: -1px`. `overflow-x: auto` computes `overflow-y` to `auto`, which would otherwise clip `.pane-tab`'s `margin-bottom: -1px` and leave the active tab a 1px accent on 1px of divider instead of a solid 2px underline (measured, then re-measured after the fix: pixel-identical to before). - The divider moves from `.pane-tabs` to `.tab-strip` so it spans the full strip rather than stopping where the tabs end. - `.action-btn`'s `min-width: 70px` is overridden under `.strip-actions` only — the base rule stays for the graph-drawer controls. - Explicit print hide for `.tab-strip` / `.strip-actions`; the old rule targeted `.pane-tabs` and `.meta-actions` by name, and the new wrapper inherits neither. Header stack: 222.8px -> 180.8px on the full page at 1440px (-42px), measured on the same item and viewport across both builds. Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT --- .../lib/components/items/ItemDetail.svelte | 663 ++++++++++-------- 1 file changed, 382 insertions(+), 281 deletions(-) diff --git a/web/src/lib/components/items/ItemDetail.svelte b/web/src/lib/components/items/ItemDetail.svelte index dcc6f4cc..b60e4df6 100644 --- a/web/src/lib/components/items/ItemDetail.svelte +++ b/web/src/lib/components/items/ItemDetail.svelte @@ -4353,281 +4353,310 @@ {/if} - -
- - - - {#if collection && (quickActions.length > 0 || isOwner)} - - {#key itemSlug} - { - editCollectionSection = 'actions'; - editCollectionOpen = true; + {#each PANE_TABS as t (t.id)} + + - {/if} - {#if backlinksCount > 0} - - - {/if} - - -
- -
{ - if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return; - const tabs = Array.from( - (e.currentTarget as HTMLElement).querySelectorAll('[role="tab"]') - ); - const idx = tabs.indexOf(document.activeElement as HTMLElement); - if (idx === -1) return; - e.preventDefault(); - const next = e.key === 'ArrowRight' - ? (idx + 1) % tabs.length - : (idx - 1 + tabs.length) % tabs.length; - // Automatic activation: panels are display-toggles (always - // mounted), so activating on arrow-focus is free and keeps the - // roving tabindex consistent (tabindex derives from activeTab). - activeTab = PANE_TABS[next].id; - tabs[next].focus(); - }} - > - {#each PANE_TABS as t (t.id)} - + +
+ - {/each} + + {#if collection && (quickActions.length > 0 || isOwner)} + + {#key itemSlug} + { + editCollectionSection = 'actions'; + editCollectionOpen = true; + }} + oncollectionupdated={(updated) => { + // Switch-safety note (BUG-2280 — investigated, NOT a live + // bug; do NOT add a template-side {@const keyedSlug = + // itemSlug} "snapshot" fence here). A quick-action save can + // resolve AFTER an A->B switch and fire this callback from a + // destroyed {#key} instance, but it's already safe by two + // independent layers, so no parent fence is needed: + // 1. QuickActionsMenu's OWN child-side guard + // (`collection?.id !== baseCollection.id`, captured + // pre-await) drops the callback on a CROSS-collection + // switch: a destroyed instance's `collection` prop reads + // the LIVE parent value (B's collection), not a frozen + // A — so the guard fails and oncollectionupdated is + // never invoked. (A {@const} snapshot would NOT freeze + // in Svelte 5 — it's a lazily-pulled derived that reads + // the current itemSlug — so a keyedSlug fence here is a + // no-op: the literal BUG-2129 trap. Verified empirically.) + // 2. On a SAME-collection switch the callback DOES fire, + // but `updated` is that same collection, so assigning it + // is correct; and loadData's collection write + // (`collGen === collectionGen || collection?.id !== + // collData.id`) forces the right collection regardless + // of the collectionGen bump below. + // Bump the unified fence so an in-flight stale load/refresh + // can't revert this fresh write (Codex). + collectionGen++; + collection = updated; + }} + /> + {/key} + {/if} + {#if childTotal > 0} + + + {/if} + {#if backlinksCount > 0} + + + {/if} + + +
@@ -5515,12 +5544,80 @@ color: var(--text-muted); } + /* ── Tab strip (PLAN-2326 / TASK-2328) ──────────────────────────────── + One band: the `.pane-tabs` tablist plus the right-aligned + `.strip-actions` group, which before TASK-2328 was its own + `.meta-actions` row above. The divider that used to belong to + `.pane-tabs` lives here now so it runs the full width of the strip + rather than stopping where the tabs do. + + `container-type: inline-size` makes this the query container for + TASK-2329's tier rule. It has to be here rather than on an ancestor: + the docked pane's width is user-dragged and persisted, decoupled from + the viewport, so a media query cannot see it (DR-2). + + Deliberately NO `overflow`, `contain`, `clip-path`, `transform`, + `filter` or `will-change` on this element — it is an ancestor of both + `.menu-anchor` and QuickActionsMenu's anchor, and any of those would + clip the anchored panels they position. DR-9's scroll rule goes on + `.pane-tabs`, which is their SIBLING. (`container-type` itself is + safe: it applies layout/style/inline-size containment but not PAINT + containment, so the panels still escape — DR-3, re-verified in + Chromium against this markup, including the mobile BottomSheet's + `position: fixed` overlay, which still resolves against the + viewport.) */ + .tab-strip { + display: flex; + align-items: center; + gap: var(--space-3); + container-type: inline-size; + container-name: item-tab-strip; + border-bottom: 1px solid var(--border-subtle); + margin: var(--space-4) 0; + } + /* ── Pane tabs (PLAN-2290 Phase 4) ────────────────────────────────── */ .pane-tabs { display: flex; gap: 2px; - border-bottom: 1px solid var(--border-subtle); - margin: var(--space-4) 0 var(--space-4); + /* DR-9 width allocation: `.strip-actions` never shrinks, so the tab + list scrolls horizontally instead of wrapping to a second row or + crushing the actions. `min-width: 0` is what lets a flex item + shrink below its content width at all. */ + min-width: 0; + overflow-x: auto; + scrollbar-width: none; + /* `overflow-x: auto` computes `overflow-y` to `auto` too, which would + clip `.pane-tab`'s `margin-bottom: -1px` overlap and leave the + active tab with a 1px accent sitting on 1px of divider instead of a + solid 2px underline (measured). Extending the padding box by the + same 1px keeps the overlap inside the scrollport; the negative + margin pulls the outer box back so the strip's height and the + divider's position are unchanged. */ + padding-bottom: 1px; + margin-bottom: -1px; + } + + .pane-tabs::-webkit-scrollbar { + display: none; + } + + /* `margin-left: auto` right-aligns the group however few tabs there are; + `flex: 0 0 auto` is DR-9's other half — the actions hold their size and + the tab list gives way. */ + .strip-actions { + display: flex; + align-items: center; + gap: var(--space-2); + margin-left: auto; + flex: 0 0 auto; + } + + /* `.action-btn`'s 70px floor exists for the standalone graph-drawer + controls, which are text buttons. In the strip these are icon-sized, so + the floor is overridden HERE rather than removed from the base rule. */ + .strip-actions .action-btn { + min-width: 0; } .pane-tab { @@ -5557,7 +5654,14 @@ .tab-hidden { display: block !important; } - .pane-tabs { + /* All three named explicitly. Before TASK-2328 this rule hid + `.pane-tabs` and the print block further down hid `.meta-actions`; + the new `.tab-strip` wrapper inherits NEITHER, so hiding it is not + free (DR-5). Its children are listed alongside it so the strip + stays hidden even if the wrapper is later restyled. */ + .tab-strip, + .pane-tabs, + .strip-actions { display: none !important; } } @@ -6421,14 +6525,9 @@ border-top: 1px solid var(--border); } - /* History */ - .meta-actions { - display: flex; - align-items: center; - gap: var(--space-2); - margin-bottom: var(--space-6); - flex-wrap: wrap; - } + /* History. `.action-btn` is generic — the graph-drawer controls below use + it too, which is why `.strip-actions` overrides `min-width` rather than + this rule dropping it. */ .action-btn { padding: var(--space-1) var(--space-3); min-width: 70px; @@ -6563,10 +6662,12 @@ padding: 0; } - /* Hide interactive / screen-only chrome inside the item page. */ + /* Hide interactive / screen-only chrome inside the item page. + `.meta-actions` is gone (TASK-2328) — its contents now print-hide + with `.tab-strip` / `.strip-actions` in the tab-strip print block + above. */ .sticky-header, .pane-header, - .meta-actions, .editor-mode-toggle, .add-relationship-section, .save-status,