Skip to content

Commit 64bc7fa

Browse files
fix(ui): the phone record header title and path labels fit their row instead of running off the edge (#27093)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ8XDtEhogeJTkWotrnBZbuqC)_ Fixes #26897 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:手机宽度下,记录页标题过长时改为在行内以省略号截断(与桌面一致);路径(record:path)各阶段平分一行,放不下的名称以省略号截断并在 title 里保留全名,终态阶段名换行。 - 为什么改:390 宽的手机上标题和阶段名直接冲出屏幕右边、被裁在半个词上("Login issues after platfo"、"Resolu"),用户看不出自己在哪条记录、哪个阶段。 - 风险与回滚:只改两处 class(page:header 标题列加 `w-full sm:w-auto`;路径手机行的阶段与标签 class),桌面布局不变;回滚即 revert 本 PR。 - 先看:`containers.tsx` 记录头标题列那一行;`record-path.tsx` 的 `PHONE_STAGE_CLASS` / `PHONE_LABEL_CLASS` 与手机行;`ui/e2e/record-path-phone-row.spec.ts`。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/plugin-detail/src/renderers/__tests__/record-path.phoneRow-26897.test.tsx packages/components/src/__tests__/page-header-title-width.test.tsx`,预期 11 个测试全绿。 - 需要你做的:无,席位落地。 ## What changes - **`page:header` record title column** (`ui/packages/components/src/renderers/layout/containers.tsx`) gains `w-full sm:w-auto`. Below `sm` the header is `flex-col items-start`; a column child's WIDTH is its cross size and `items-start` makes it fit-content, so the column took the h1's max-content width (measured in Chromium: **1015.48px inside a 390px row**) and the console's content pane, which clips sideways overflow, cut the title mid-word with no ellipsis. `min-w-0` was already on the column and is a floor, not a ceiling, so it was not the missing piece; this is the same defect objectui#9119 fixed on DetailView's header, fixed the same way. `sm:w-auto` hands the row back to the objectui#7244 arbitration unchanged. - **`record:path` phone row** (`ui/packages/plugin-detail/src/renderers/record-path.tsx`): the stages were `shrink-0` content-width with `whitespace-nowrap` labels inside a sideways scroller whose scrollbar is hidden, so the stage at the right edge read "Resolu" with nothing to say the row scrolled. Each phone stage now takes an equal share (`flex-1 min-w-10`, new `PHONE_STAGE_CLASS`); an ordinary label is `text-center truncate` (`PHONE_LABEL_CLASS`) and carries its full text in `title`; a terminal label wraps with `TERMINAL_LABEL_CLASS`, as on the desktop rail (#25895). The 2.5rem floor stops a stage collapsing to a bare ellipsis: a path too long for every stage to keep 2.5rem still scrolls (the row keeps `overflow-x-auto`). The row's own class string is unchanged. - The desktop rail and the desktop header markup are unchanged (`renderStage` gains an optional `labelTitle`, passed only by the phone row). - `RecordTitleChip.tsx` is not touched: once its container has a definite width, its h1 `truncate` fires as it does on desktop. ## Tests - `ui/e2e/record-header-title-width.spec.ts` (Chromium geometry, real Tailwind compiled from the class strings read out of `containers.tsx` and `RecordTitleChip.tsx`): new `page:header` block, 4 phone widths (320/375/390/639) assert the title column fits its row, no page overflow, and the h1 is clipped (ellipsis fires); a CONTROL at 1440px asserts the title unclipped and the tail on the same line. On `origin/main` the four phone cases were red (title column 1015.48px); with the fix 14/14 green. - New `ui/e2e/record-path-phone-row.spec.ts` (Chromium geometry, class strings read out of `record-path.tsx`): the case path fits its row at 320/375/390 with equal stage widths and every cut label ellipsised (and at least one label really cut, so the ellipsis check is not vacuous); a terminal label wraps instead of being cut; a 10-stage path at 320px keeps 40px stages and scrolls. 5/5 green. - `page-header-title-width.test.tsx`: new class-shape pin for `w-full` + `sm:w-auto` on the title column. - New `record-path.phoneRow-26897.test.tsx` (4 tests): phone stages `flex-1 min-w-10`, not `shrink-0`; ordinary phone labels `truncate` with `title` = full label; terminal phone labels wrap; CONTROL: the desktop label still truncates and carries no `title`. - `record-path.terminalWidth-25895.test.tsx`: doc sentence "The mobile row scrolls and is unchanged" replaced (it became false); no assertion changed. - Ablations (one-off, via `scripts/ablation-replace.mjs`, each anchor hit 1 → 0, restored blob equal to HEAD, `git diff HEAD` empty): - `w-full sm:w-auto` removed: the page:header e2e block goes 4 failed / 1 passed (the four phone widths red, the 1440 control green); the vitest pin goes 1 failed / 6 passed. - `PHONE_STAGE_CLASS` = `shrink-0 min-w-10`: the path e2e goes 3 failed / 2 passed ("row holds 479px of stages in 320px / 375px / 390px"). - `labelTitle: stage.label` deleted: the phoneRow vitest goes 1 failed / 3 passed. ## Acceptance notes - The phone header title ellipsises, as on desktop, rather than wrapping; the card allowed either. Wrapping to two lines (`line-clamp-2` below `sm`) would show more of a long name on a phone; that is a product call left open, not taken here. - A path longer than fits at 2.5rem per stage (about seven stages on a 390px phone) still scrolls sideways with a hidden scrollbar, so its last visible stage can be cut at the edge; below that length every stage fits. - The desktop rail's ordinary stage labels truncate without a `title` (unchanged, out of this card's scope). - `title` reaches mouse and pen users; a phone has no hover, and the full label also reaches assistive tech through each stage's existing `aria-label`. ## Verification Gates derived by `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, run on head `6fa87db5`: 34 derived plus extras (`check-sdui-manifest` is among the derived ones; extras: `type-check` of `@object-ui/components` and `@object-ui/plugin-detail`, `pnpm --dir ui run type-check:e2e`, eslint on the touched files, `check:changeset-fixed`, `check:new-line-citations`, both Chromium specs), every one exit 0, exits captured before any pipe. Whole `packages/components/` + `packages/plugin-detail/` suites on the same head: 762 files passed, 2 skipped (7258 tests). Details in the os-dev report on the card. --- _Generated by [Claude Code](https://claude.ai/code/session_01JQwxbXctE6WABPKWup61H3)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 14ab711 commit 64bc7fa

8 files changed

Lines changed: 446 additions & 9 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@object-ui/components': patch
3+
'@object-ui/plugin-detail': patch
4+
---
5+
6+
Clause-②: no
7+
Metadata-protocol: none
8+
9+
On a phone, the record header title and the record path's stage labels no longer run off the right edge. Below the `sm` breakpoint, `page:header`'s record title column now takes the header's width, so a long title ellipsises as it does on desktop instead of being cut mid-word. The `record:path` phone row now gives every stage an equal share of the row (with a 2.5rem floor, past which it still scrolls): an ordinary stage label ellipsises and keeps its full text in `title`, and a terminal stage label wraps, as on the desktop rail.

‎ui/e2e/record-header-title-width.spec.ts‎

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -305,7 +305,7 @@ test.beforeAll(async () => {
305305
// this callback runs long after module evaluation, so its utilities are
306306
// resolvable here — and they MUST be compiled, or the console pane below
307307
// would be an unstyled block and its reading meaningless.
308-
css = await tailwindFor([...ALL_CANDIDATES, ...CONSOLE_MAIN.split(' ')]);
308+
css = await tailwindFor([...ALL_CANDIDATES, ...CONSOLE_MAIN.split(' '), ...PAGE_HEADER_CANDIDATES]);
309309
});
310310

311311
/* ════════════════════════════════════════════════════════════════════════════
@@ -546,3 +546,93 @@ test.describe('record header title width arbitration (objectui#7281)', () => {
546546
expect(DETAIL.tail.split(/\s+/)).toContain('shrink-0');
547547
});
548548
});
549+
550+
/* ════════════════════════════════════════════════════════════════════════════
551+
* objectstack#26897 — `page:header`'s record header BELOW the `sm` breakpoint.
552+
*
553+
* The same cross-axis defect objectui#9119 fixed on DetailView, on the third
554+
* record header: `page:header` (`packages/components/src/renderers/layout/
555+
* containers.tsx`) draws its record title through `RecordTitleChip`, inside a
556+
* row that is `flex-col items-start` below `sm`, so its title column took the
557+
* h1's max-content width and the console's content pane clipped the title
558+
* mid-word with no ellipsis ("CASE-00001 · Login issues after platfo" at
559+
* 390×844 on the HotCRM seed). Every class string below is read out of the
560+
* two real sources; only the action tail's content is a fixture.
561+
* ════════════════════════════════════════════════════════════════════════════ */
562+
563+
const CONTAINERS_FILE = 'packages/components/src/renderers/layout/containers.tsx';
564+
const CHIP_FILE = 'packages/components/src/custom/RecordTitleChip.tsx';
565+
const containersSrc = read(CONTAINERS_FILE);
566+
const chipSrc = read(CHIP_FILE);
567+
568+
const PAGE_HEADER = {
569+
// The record branch's `<header>` comes first in the file; the non-record
570+
// fallback's carries no `sm:flex-row`, so a drift onto it throws.
571+
row: classByPattern(containersSrc, CONTAINERS_FILE, /<header\s+className=\{cn\(\s*'([^']+)'/, [
572+
'flex-col',
573+
'items-start',
574+
'sm:flex-row',
575+
]),
576+
titleColumn: classBefore(containersSrc, CONTAINERS_FILE, '<RecordTitleChip', ['flex-col', 'min-w-0']),
577+
// The tail as it renders when the host wires `refresh` (the console does).
578+
tail: classAt(containersSrc, CONTAINERS_FILE, 'if (!refreshButton) return actions;', 1, ['shrink-0']),
579+
chip: classByPattern(chipSrc, CHIP_FILE, /<div className=\{cn\('([^']+)', className\)\}>/, ['min-w-0']),
580+
chipRow: classBefore(chipSrc, CHIP_FILE, '{icon && (', ['flex-wrap']),
581+
// #1 after the anchor is the icon's own span; #2 is the h1, #3 the object label.
582+
h1: classAt(chipSrc, CHIP_FILE, '{icon && (', 2, ['truncate']),
583+
objectLabel: classAt(chipSrc, CHIP_FILE, '{icon && (', 3, ['shrink-0']),
584+
};
585+
586+
/** The mobile tail: one inline action, the `⋯` trigger and the `⟳` chrome. */
587+
const PAGE_HEADER_TAIL_BOX = 'h-9 w-12 shrink-0';
588+
589+
const pageHeaderFixture = (title: string) =>
590+
box(
591+
PAGE_HEADER.row,
592+
box(
593+
PAGE_HEADER.titleColumn,
594+
box(
595+
PAGE_HEADER.chip,
596+
box(
597+
PAGE_HEADER.chipRow,
598+
`<h1 class="${PAGE_HEADER.h1}">${title}</h1><span class="${PAGE_HEADER.objectLabel}">Case</span>`,
599+
),
600+
),
601+
) + box(PAGE_HEADER.tail, Array.from({ length: 3 }, () => box(PAGE_HEADER_TAIL_BOX)).join('')),
602+
);
603+
604+
const PAGE_HEADER_CANDIDATES = [...Object.values(PAGE_HEADER), PAGE_HEADER_TAIL_BOX]
605+
.join(' ')
606+
.split(/\s+/)
607+
.filter(Boolean);
608+
609+
test.describe('page:header record title below the sm breakpoint (objectstack#26897)', () => {
610+
// 390 is the card's phone; the other three are objectui#9119's.
611+
for (const width of [320, 375, 390, 639]) {
612+
test(`the title column cannot outgrow its row at ${width}px`, async ({ page }) => {
613+
const r = await readGeometry(page, pageHeaderFixture(LONG_TITLE), css, width);
614+
expect(
615+
r.titleColumnWidth,
616+
`title column ${r.titleColumnWidth}px inside a ${r.rowWidth}px row — it was sized to the ` +
617+
'title, not to the row, so the h1 `truncate` cannot bind',
618+
).toBeLessThanOrEqual(r.rowWidth + 0.5);
619+
expect(r.documentOverflowX, 'the record header pushed the page sideways').toBe(0);
620+
// A title cut off with no ellipsis is the defect, not the fix: the
621+
// ellipsis has to fire.
622+
expect(
623+
r.h1Clipped,
624+
`h1 is ${r.h1Width}px wide for a ${r.h1ScrollWidth}px title but reports no clipping`,
625+
).toBe(true);
626+
});
627+
}
628+
629+
test('CONTROL: at 1440px the title column is content-sized and the tail shares its line', async ({ page }) => {
630+
// `sm:w-auto` has to hand the row back to the `sm:flex-row` arbitration
631+
// objectui#7244 settled: a short title, unclipped, with the tail beside it.
632+
const r = await readGeometry(page, pageHeaderFixture(TITLE), css, ROOMY);
633+
expect(r.h1Clipped, `h1 ${r.h1Width}px vs scrollWidth ${r.h1ScrollWidth}px`).toBe(false);
634+
expect(r.tailOnOwnLine, 'the tail wrapped at a viewport where it fits').toBe(false);
635+
expect(r.titleColumnWidth).toBeLessThan(r.rowWidth);
636+
expect(r.documentOverflowX).toBe(0);
637+
});
638+
});
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import { test, expect, type Page } from '@playwright/test';
2+
import { compile } from 'tailwindcss';
3+
import { readFileSync } from 'node:fs';
4+
import { fileURLToPath } from 'node:url';
5+
import path from 'node:path';
6+
import { uiRoot } from '../scripts/ui-root.mjs';
7+
8+
/**
9+
* GEOMETRY pin for `record:path`'s phone row (objectstack#26897).
10+
*
11+
* At 390×844 on the HotCRM seed the path's row below `sm` cut the stage at its
12+
* right edge mid-word ("Resolu"): the stages were content-width and
13+
* `whitespace-nowrap` inside a sideways scroller whose scrollbar is hidden, so
14+
* nothing said the row scrolled. The fix gives every stage an equal share with
15+
* a 2.5rem floor, truncates an ordinary label with an ellipsis and wraps a
16+
* terminal one, as the desktop rail does.
17+
*
18+
* ⚠️ A Playwright spec and not a vitest test for the reason
19+
* `record-header-title-width.spec.ts` gives: jsdom has no layout engine, so a
20+
* width read there is 0 and an assertion on it is vacuous. The class-shape
21+
* half (and the `title` that keeps a cut label readable) is pinned beside the
22+
* renderer in `record-path.phoneRow-26897.test.tsx`.
23+
*
24+
* REAL, read out of `record-path.tsx` at run time: the phone row's class
25+
* string, the stage, rail and label base strings `renderStage` composes, and
26+
* the three named label/stage constants — plus real Tailwind compiled from the
27+
* installed `tailwindcss` for exactly those candidates. FIXTURE: the stage
28+
* labels, and the element tree, transcribed from `renderStage`.
29+
*/
30+
31+
const REPO_ROOT = uiRoot();
32+
const PATH_FILE = 'packages/plugin-detail/src/renderers/record-path.tsx';
33+
const pathSrc = readFileSync(path.join(REPO_ROOT, PATH_FILE), 'utf8');
34+
35+
/** One string out of the source, asserting a token so a drifted match throws. */
36+
function grab(re: RegExp, mustContain: string[]): string {
37+
const m = pathSrc.match(re);
38+
if (!m) throw new Error(`${PATH_FILE}: nothing matched ${re}`);
39+
const cls = m[1];
40+
for (const token of mustContain) {
41+
if (!cls.split(/\s+/).includes(token)) {
42+
throw new Error(
43+
`${PATH_FILE}: ${re} matched "${cls}", which does not carry "${token}" — the extraction ` +
44+
'walked onto the wrong string, so this measurement would be meaningless.',
45+
);
46+
}
47+
}
48+
return cls;
49+
}
50+
51+
const CLS = {
52+
row: grab(/\{\/\* Mobile:[^\n]*\*\/\}\s*<div\s+className="([^"]+)"/, ['sm:hidden', 'overflow-x-auto']),
53+
stage: grab(/className=\{cn\('([^']+)', o\.className\)\}/, ['flex-col']),
54+
rail: grab(/const railClass = [\s\S]*?cn\(\s*'([^']+)'/, ['w-full']),
55+
label: grab(/const labelClass = [\s\S]*?cn\(\s*'([^']+)'/, ['min-w-0']),
56+
phoneStage: grab(/const PHONE_STAGE_CLASS = '([^']+)'/, ['min-w-10']),
57+
phoneLabel: grab(/const PHONE_LABEL_CLASS = '([^']+)'/, ['truncate']),
58+
terminalLabel: grab(/const TERMINAL_LABEL_CLASS = '([^']+)'/, ['[overflow-wrap:anywhere]']),
59+
};
60+
61+
type Stage = { label: string; terminal?: boolean };
62+
63+
/** The card's case path, as the HotCRM seed draws it. */
64+
const CASE_STAGES: Stage[] = [
65+
{ label: 'New' },
66+
{ label: 'In Progress' },
67+
{ label: 'Waiting on Customer' },
68+
{ label: 'Resolution Proposed' },
69+
{ label: 'Resolved' },
70+
{ label: 'Closed', terminal: true },
71+
];
72+
73+
/** A long path: ten stages, so 2.5rem floors cannot all fit a 320px row. */
74+
const LONG_STAGES: Stage[] = Array.from({ length: 10 }, (_, i) => ({ label: `Stage number ${i + 1}` }));
75+
76+
const fixture = (stages: Stage[]) =>
77+
`<div id="row" role="list" class="${CLS.row}">` +
78+
stages
79+
.map(
80+
(s) =>
81+
`<div role="listitem" class="${CLS.stage} ${CLS.phoneStage}">` +
82+
`<span class="${CLS.rail} bg-muted"></span>` +
83+
`<span class="${CLS.label} ${s.terminal ? CLS.terminalLabel : CLS.phoneLabel}">${s.label}</span>` +
84+
'</div>',
85+
)
86+
.join('') +
87+
'</div>';
88+
89+
let css = '';
90+
test.beforeAll(async () => {
91+
const twEntry = fileURLToPath(import.meta.resolve('tailwindcss/index.css'));
92+
const compiler = await compile('@import "tailwindcss";', {
93+
base: path.dirname(twEntry),
94+
loadStylesheet: async (id: string, base: string) => {
95+
const file = id === 'tailwindcss' ? twEntry : path.resolve(base, id);
96+
return { path: file, base: path.dirname(file), content: readFileSync(file, 'utf8') };
97+
},
98+
});
99+
css = compiler.build([...Object.values(CLS), 'bg-muted'].join(' ').split(/\s+/).filter(Boolean));
100+
if (!css.includes('@media')) {
101+
throw new Error('Tailwind produced no media queries — `sm:hidden` would be inert.');
102+
}
103+
});
104+
105+
type Reading = {
106+
rowClientWidth: number;
107+
rowScrollWidth: number;
108+
stages: { label: string; left: number; right: number; width: number; clipped: boolean; ellipsis: boolean }[];
109+
};
110+
111+
async function readRow(page: Page, stages: Stage[], width: number): Promise<Reading> {
112+
await page.setViewportSize({ width, height: 844 });
113+
await page.setContent(`<style>html,body{margin:0}${css}</style>${fixture(stages)}`, { waitUntil: 'load' });
114+
// Wait for the row's layout to settle across two frames, then for fonts.
115+
await page.waitForFunction(
116+
() => {
117+
const row = document.getElementById('row');
118+
if (!row || row.getBoundingClientRect().width <= 0) return false;
119+
const w = row.scrollWidth;
120+
const prev = (window as unknown as { __w?: number }).__w;
121+
(window as unknown as { __w?: number }).__w = w;
122+
return prev !== undefined && prev === w;
123+
},
124+
null,
125+
{ polling: 'raf', timeout: 10_000 },
126+
);
127+
await page.evaluate(() => document.fonts?.ready);
128+
return page.evaluate(() => {
129+
const round = (n: number) => Math.round(n * 100) / 100;
130+
const row = document.getElementById('row')!;
131+
return {
132+
rowClientWidth: row.clientWidth,
133+
rowScrollWidth: row.scrollWidth,
134+
stages: Array.from(row.children).map((item) => {
135+
const label = item.querySelector('span:not(.bg-muted)') as HTMLElement;
136+
const r = item.getBoundingClientRect();
137+
return {
138+
label: label.textContent ?? '',
139+
left: round(r.left),
140+
right: round(r.right),
141+
width: round(r.width),
142+
clipped: label.scrollWidth > label.clientWidth,
143+
ellipsis: getComputedStyle(label).textOverflow === 'ellipsis',
144+
};
145+
}),
146+
};
147+
});
148+
}
149+
150+
test.describe('record:path phone row (objectstack#26897)', () => {
151+
for (const width of [320, 375, 390]) {
152+
test(`the case path fits its row at ${width}px and every cut label ellipsises`, async ({ page }) => {
153+
const r = await readRow(page, CASE_STAGES, width);
154+
// THE assertion: nothing hides past the row's edge, where the hidden
155+
// scrollbar gave no sign that it was there.
156+
expect(
157+
r.rowScrollWidth,
158+
`row holds ${r.rowScrollWidth}px of stages in ${r.rowClientWidth}px — the last stage is cut at the edge`,
159+
).toBeLessThanOrEqual(r.rowClientWidth);
160+
for (const s of r.stages) {
161+
expect(s.right, `stage "${s.label}" ends past the row`).toBeLessThanOrEqual(width + 0.5);
162+
// A label that does not fit is cut WITH an ellipsis, never bare.
163+
if (s.clipped) expect(s.ellipsis, `stage "${s.label}" is cut with no ellipsis`).toBe(true);
164+
}
165+
// Not vacuous: at these widths the long labels really are cut.
166+
expect(r.stages.some((s) => s.clipped), 'no label was cut, so the ellipsis went unmeasured').toBe(true);
167+
// Equal shares: every stage is one width.
168+
const widths = new Set(r.stages.map((s) => Math.round(s.width)));
169+
expect(widths.size, `stage widths ${[...widths].join(', ')}`).toBe(1);
170+
});
171+
}
172+
173+
test('a terminal label wraps instead of being cut, as on the desktop rail', async ({ page }) => {
174+
const r = await readRow(page, [...CASE_STAGES.slice(0, 5), { label: 'Closed — Duplicate', terminal: true }], 320);
175+
const terminal = r.stages[r.stages.length - 1];
176+
expect(terminal.clipped, 'the terminal stage label is cut').toBe(false);
177+
});
178+
179+
test('past the floor the stages keep 2.5rem and the row scrolls instead', async ({ page }) => {
180+
const r = await readRow(page, LONG_STAGES, 320);
181+
for (const s of r.stages) expect(s.width).toBeGreaterThanOrEqual(40);
182+
expect(r.rowScrollWidth).toBeGreaterThan(r.rowClientWidth);
183+
});
184+
});

‎ui/packages/components/src/__tests__/page-header-title-width.test.tsx‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,21 @@ describe('PageHeaderRenderer — record title width arbitration (#7244)', () =>
101101
expect(titleColumn.className).toContain('min-w-0');
102102
});
103103

104+
it('sizes the title column to the row below sm, so the h1 can ellipsise on a phone (objectstack#26897)', () => {
105+
const { header, titleColumn } = renderRecordHeader();
106+
// Below `sm` the header is `flex-col items-start`: a child's WIDTH is its
107+
// cross size there and `items-start` makes it fit-content, so without a
108+
// definite width this column took the h1's max-content width (1015px in a
109+
// 390px row) and the console's content pane clipped the title mid-word.
110+
// `min-w-0` above is a floor and cannot bound it. The geometry itself is
111+
// pinned in Chromium by `e2e/record-header-title-width.spec.ts`.
112+
expect(header.className.split(/\s+/)).toContain('items-start');
113+
const tokens = titleColumn.className.split(/\s+/);
114+
expect(tokens).toContain('w-full');
115+
// …and `sm:w-auto` hands the row back to the arbitration above.
116+
expect(tokens).toContain('sm:w-auto');
117+
});
118+
104119
it('still truncates rather than wrapping the title text itself', () => {
105120
const { h1 } = renderRecordHeader();
106121
expect(h1.className).toContain('truncate');

‎ui/packages/components/src/renderers/layout/containers.tsx‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2942,6 +2942,18 @@ const PageHeaderRenderer: React.FC<any> = ({ schema, className, ...props }) => {
29422942
// Headers whose tail already fits are unaffected — an Account record
29432943
// (edit + `⋯` + `⟳`) needs 192 + 16 + ~180px of a 687px header, so it stays
29442944
// on one line exactly as before.
2945+
//
2946+
// Below `sm` the header is a COLUMN, and there the title column carries
2947+
// `w-full sm:w-auto` (objectstack#26897, the defect objectui#9119 fixed
2948+
// on DetailView's header). In a column a child's WIDTH is its cross size,
2949+
// and `items-start` sizes it to fit-content, so the column took the h1's
2950+
// max-content width: 1015px inside a 390px row in Chromium, and the
2951+
// console's content pane, which clips sideways overflow, cut the title
2952+
// mid-word with no ellipsis. `min-w-0` is a floor and `flex-1` acts on the
2953+
// main axis (height here), so neither can bound it; only a definite cross
2954+
// size lets the h1's `truncate` fire. `sm:w-auto` hands the row back to
2955+
// the arbitration above unchanged. Geometry pinned in Chromium by
2956+
// `e2e/record-header-title-width.spec.ts`.
29452957
return (
29462958
<header
29472959
className={cn(
@@ -2951,7 +2963,7 @@ const PageHeaderRenderer: React.FC<any> = ({ schema, className, ...props }) => {
29512963
{...designer}
29522964
{...headerAria}
29532965
>
2954-
<div className="flex flex-col min-w-0 sm:min-w-48 flex-1">
2966+
<div className="flex flex-col min-w-0 sm:min-w-48 flex-1 w-full sm:w-auto">
29552967
<RecordTitleChip
29562968
title={resolvedTitle}
29572969
titleNode={declaredFace}

0 commit comments

Comments
 (0)