fix(tabs): state rows start under the brand, labels left-aligned (#426)

In the desktop header strip grouped by state, the brand leaves the flow
and sits over the strip's top-left corner, so every row after the first
starts at the left edge under "Codeman" instead of leaving that corner
empty. Labels are left-aligned in the measured column; the first row's
heading takes its natural width beside the brand (--tab-triage-brand,
kept by a ResizeObserver so no render pass reads layout for it).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-06 06:25:26 +02:00
parent bebf0db792
commit bb5fd5ee97
5 changed files with 95 additions and 14 deletions
+47 -6
View File
@@ -4890,6 +4890,9 @@ class CodemanApp {
}
const count = String(group.count);
if (head.lastElementChild.textContent !== count) head.lastElementChild.textContent = count;
// The first row's heading is the one that starts beside the brand in the
// header strip (styles.css); every later row starts under it.
head.classList.toggle('tab-triage-head--lead', group === triage.groups[0]);
const headOrder = String(group.headOrder);
if (head.style.order !== headOrder) head.style.order = headOrder;
const brk = ensure('tab-triage-break', group.key);
@@ -4905,20 +4908,34 @@ class CodemanApp {
}
/**
* Size the header strip's label column to the widest label on screen, so no
* row carries a fixed gutter's worth of empty space before its tabs (labels
* are right-aligned in it, styles.css). Measured only when the label text
* changes (a group appears, goes, or its count gains a digit), and once more
* when the web fonts finish loading, since that changes every width. The
* vertical lists do not use the gutter, so they are never measured.
* Size the header strip's two measured lengths (styles.css, "Header strip,
* wrapping"): `--tab-triage-gutter`, the label column, as wide as the widest
* label on screen so a row never carries a fixed gutter's worth of empty
* space; and `--tab-triage-brand`, the brand's width, because the brand sits
* over the strip's top-left corner and only the FIRST row starts beside it,
* every later row starting under it.
*
* The labels are measured only when their text changes (a group appears,
* goes, or its count gains a digit) and once more when the web fonts finish
* loading. The brand is watched by a ResizeObserver (a display-name change,
* the sidebar toggle appearing), so a render pass never forces a layout read
* for it. The vertical lists use neither length and are never measured.
*/
_sizeTabTriageGutter(container, triage) {
const inHeader = !!container.parentElement?.classList.contains('session-tabs-host');
if (!triage || !inHeader) {
if (container.style.getPropertyValue('--tab-triage-gutter')) container.style.removeProperty('--tab-triage-gutter');
if (container.style.getPropertyValue('--tab-triage-brand')) container.style.removeProperty('--tab-triage-brand');
this._tabTriageGutterKey = null;
return;
}
this._watchTabTriageBrand(container);
if (Number.isFinite(this._tabTriageBrandWidth)) {
const brand = `${this._tabTriageBrandWidth}px`;
if (container.style.getPropertyValue('--tab-triage-brand') !== brand) {
container.style.setProperty('--tab-triage-brand', brand);
}
}
const key = triage.groups.map((g) => `${g.key}:${g.count}`).join('|');
if (key === this._tabTriageGutterKey) return;
let widest = 0;
@@ -4940,6 +4957,30 @@ class CodemanApp {
}
}
/**
* Keep `_tabTriageBrandWidth` (the header brand plus the gap after it) in
* step with the brand, once per page. The first observation arrives right
* after `observe()`, so the width is known from the first frame on.
*/
_watchTabTriageBrand(container) {
if (this._tabTriageBrandObserver !== undefined) return;
const brand = container.closest('.header')?.querySelector(':scope > .header-brand');
if (!brand || typeof ResizeObserver !== 'function') {
this._tabTriageBrandObserver = null;
return;
}
const gap = 8;
this._tabTriageBrandWidth = Math.ceil(brand.getBoundingClientRect().width + gap);
this._tabTriageBrandObserver = new ResizeObserver((entries) => {
const box = entries[0]?.borderBoxSize?.[0];
const width = Math.ceil((box ? box.inlineSize : brand.getBoundingClientRect().width) + gap);
if (width === this._tabTriageBrandWidth) return;
this._tabTriageBrandWidth = width;
this._sizeTabTriageGutter(this.$('sessionTabs'), this._lastTabTriage);
});
this._tabTriageBrandObserver.observe(brand);
}
/**
* A drag in a grouped strip (by state or by case) may only reorder WITHIN a
* group. Inside a group the rows sit in tab order, so a drop there moves the
+25 -6
View File
@@ -19848,21 +19848,40 @@ html .toolbar .btn-git-status[aria-expanded='true'] {
/* Header strip, wrapping (desktop): one row per state. Each heading sits in a
left gutter, so a group too long for one line continues under its own tabs
rather than under its heading, and each row ends with a full-width break. */
/* The gutter is as wide as the widest label on screen (app.js measures it into
--tab-triage-gutter whenever the labels change; 92px is only the first-paint
fallback), and every label is right-aligned in it, so each one sits right
next to its own tabs while the rows still start on one line. */
/* The label column is as wide as the widest label on screen (app.js measures
it into --tab-triage-gutter whenever the labels change; 92px is only the
first-paint fallback) and the labels are left-aligned in it.
The brand leaves the flow and sits over the strip's top-left corner, so the
strip starts at the header's left edge: the first row's heading (the lead)
is pushed past the brand by --tab-triage-brand, and every later row starts
at the edge, under "Codeman", instead of leaving that corner empty. The
rows under the first one line up with each other in the label column. */
.header:has(> .session-tabs-host > .session-tabs.tabs-triage:is(.tabs-auto-wrap, .tabs-two-rows)) > .header-brand {
position: absolute;
top: 0.35rem;
left: 0.75rem;
z-index: 1;
}
.session-tabs-host > .session-tabs.tabs-triage:is(.tabs-auto-wrap, .tabs-two-rows) {
padding-left: calc(0.25rem + var(--tab-triage-gutter, 92px));
padding-left: var(--tab-triage-gutter, 92px);
}
.session-tabs-host > .session-tabs.tabs-triage:is(.tabs-auto-wrap, .tabs-two-rows) > .tab-triage-head {
justify-content: flex-end;
justify-content: flex-start;
width: var(--tab-triage-gutter, 92px);
margin-left: calc(-1 * var(--tab-triage-gutter, 92px));
padding: 0 8px 0 0;
}
/* The first row starts beside the brand and lines up with nothing else, so its
label takes its own width and its tabs follow it directly. */
.session-tabs-host > .session-tabs.tabs-triage:is(.tabs-auto-wrap, .tabs-two-rows) > .tab-triage-head--lead {
width: auto;
margin-left: calc(var(--tab-triage-brand, 100px) - var(--tab-triage-gutter, 92px));
}
.session-tabs-host > .session-tabs.tabs-triage:is(.tabs-auto-wrap, .tabs-two-rows) > .tab-triage-break {
display: block;
flex: 0 0 100%;