Skip to content

Commit e3dec81

Browse files
benfaceclaude
andcommitted
A host filling its column to the pixel narrows with it; stories hold on Linux
- The width spacer is a grid track, minmax(columns − 1/32px, columns): the columns stay a shrink-to-fit host's width, and a flex-1 column the host fills exactly still shrinks when a sibling grows, its resize laying the host out (the min-width spacer held it at the host's width). - Own Width fills its column to the pixel on every platform. - Glyph Properties checks font smoothing where the browser has it (Firefox on macOS only); Line Breaks spaces each CJK character from its own advance. - pnpm 12.8.1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 79a3b4d commit e3dec81

7 files changed

Lines changed: 139 additions & 94 deletions

File tree

‎.agents/specs/cell-model.md‎

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -217,9 +217,16 @@ is caught by observing the host's parent (a growing container), its
217217
siblings (a flex or grid slot that grows because a sibling shrank), and
218218
the window. A host whose width is its content's (`w-fit`,
219219
`inline-block`, a float, an absolute box, a flex row's item) takes it
220-
from a spacer, as its height does: the shadow viewport's `min-width`,
221-
the columns laid out, lifted while the engine reads so the content
222-
measures anew.
220+
from a spacer, as its height does: a shadow grid track,
221+
`minmax(<columns> − 1/32px, <columns>)`, lifted while the engine reads
222+
so the content measures anew. The columns laid out are its max-content
223+
width, the host's width where it has none of its own; its min-content
224+
width yields a thirty-second of a pixel, so a container sized off the
225+
host (a `flex-1` column the host fills to the pixel, which an aside
226+
beside it squeezes) still shrinks natively, and its resize lays the
227+
host out. A thirty-second is past a layout unit in every engine and
228+
within the resize tolerance, so a flex item held at that floor is laid
229+
out once.
223230

224231
The height is CSS's too. The host keeps `height: auto`; its content
225232
lies out of its flow (the shadow's slot is positioned, outside the
@@ -1643,13 +1650,13 @@ For "Aspect ratio":
16431650
For "Host sizing":
16441651

16451652
- shadow.css: the slot out of the host's flow but under
1646-
`:host([measuring])`, and the viewport, whose `min-height` and
1647-
`min-width` are the spacer, the width lifted under
1653+
`:host([measuring])`, the viewport, whose `min-height` is the
1654+
spacer's height, and `#spacer`, its width's grid track, lifted under
16481655
`:host([measuring])`.
16491656
- element.ts: the height read before `measuring` (`sizedHeight`), the
16501657
rows it gives the root where it is not the spacer's (`sizedRows`),
16511658
the spacer written from the root's natural content rows
1652-
(`#spacerHeight`) and the columns laid out (the viewport's
1653-
`min-width`), and `#laidOutSize`, the height the resize
1659+
(`#spacerHeight`) and the columns laid out (`#spacerWidth`,
1660+
`SPACER_YIELD` under them), and `#laidOutSize`, the size the resize
16541661
observer compares.
16551662
- layout.ts: `layoutRoot`'s `rows`, the root's forced height.

‎apps/storybook/stories/host.stories.ts‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -591,8 +591,8 @@ export const OwnHeight: StoryObj = {
591591
* its chrome (specs/cell-model.md "Host sizing"): `w-fit`,
592592
* `inline-block`, `float-left`, and a flex row's item, whose sibling
593593
* starts past its right edge, the row narrowed laying it out narrower;
594-
* and a host in a `flex-1` column narrows as the aside beside it
595-
* grows. */
594+
* and a host filling its `flex-1` column to the pixel narrows as the
595+
* aside beside it grows. */
596596
export const OwnWidth: StoryObj = {
597597
render: () => html`
598598
<div class="flex flex-col gap-4">
@@ -636,8 +636,15 @@ export const OwnWidth: StoryObj = {
636636
expect(box("sibling").right).toBeLessThanOrEqual(box("row").right + 0.5);
637637
});
638638
layouts.stop();
639+
// Room for forty whole columns, which the host fills to the pixel,
640+
// its layout settled before the aside grows.
641+
const cell = cellSize(hooks("column")).width;
642+
hooks("shell").style.width = `${60 * cell}px`;
643+
hooks("aside").style.width = `${20 * cell}px`;
644+
await waitFor(() => expect(box("column").width).toBeCloseTo(40 * cell, 1));
645+
await frames(2);
639646
// An inline width: a new utility's stylesheet would lay every host out.
640-
hooks("aside").style.width = "30rem";
647+
hooks("aside").style.width = `${50 * cell}px`;
641648
await waitFor(() => {
642649
expect(box("column").right).toBeLessThanOrEqual(box("shell").right + 0.5);
643650
expect(rowsOf(hooks("column")).filter((row) => row.trim()).length).toBeGreaterThan(1);

‎apps/storybook/stories/typography.stories.ts‎

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -255,12 +255,15 @@ export const GlyphProperties: StoryObj = {
255255
const style = getComputedStyle(paintedSpan(host, text)!);
256256
const smoothing = style.getPropertyValue("-webkit-font-smoothing");
257257
// Firefox's alias reads its own `grayscale`.
258-
return `${style.fontWeight} ${style.fontStyle} ${smoothing === "grayscale" ? "antialiased" : smoothing}`;
258+
return `${style.fontWeight} ${style.fontStyle} ${smoothing === "grayscale" ? "antialiased" : smoothing}`.trimEnd();
259259
};
260-
expect(font(styledHost!, "host")).toBe("700 italic antialiased");
261-
expect(font(styledHost!, "upright")).toBe("400 normal auto");
262-
expect(font(plainHost!, "plain")).toBe("400 normal auto");
263-
expect(font(plainHost!, "styled")).toBe("700 italic antialiased");
260+
// Firefox smooths fonts on macOS only.
261+
const smoothed = (face: string, smoothing: string) =>
262+
CSS.supports("-webkit-font-smoothing", smoothing) ? `${face} ${smoothing}` : face;
263+
expect(font(styledHost!, "host")).toBe(smoothed("700 italic", "antialiased"));
264+
expect(font(styledHost!, "upright")).toBe(smoothed("400 normal", "auto"));
265+
expect(font(plainHost!, "plain")).toBe(smoothed("400 normal", "auto"));
266+
expect(font(plainHost!, "styled")).toBe(smoothed("700 italic", "antialiased"));
264267
const zero = getComputedStyle(paintedSpan(plainHost!, "zero")!);
265268
expect(zero.fontVariantNumeric).toBe("slashed-zero");
266269
expect(zero.textShadow).not.toBe("none");
@@ -602,19 +605,30 @@ const lineBreakCases = (u: Unit): NativeCase[] =>
602605
departs,
603606
}));
604607

605-
/** Each case's rows as the browser breaks them, a CJK copy spaced to
606-
* the grid's two cells a character by its first (its fallback font
607-
* draws them narrower), its spaces kept a cell. */
608+
/** Each case's rows as the browser breaks them, each character of a
609+
* CJK copy spaced to its cells on the grid, two a character and one a
610+
* space, from its own advance (fallback fonts draw them narrower, and
611+
* not all alike). */
608612
export const LineBreaks: StoryObj = {
609613
tags: ["!dev", "!golden"],
610614
...besideNative(lineBreakCases, async (host, native, i) => {
611615
if (lineBreaks[i]!.cjk) {
612-
const probe = native.appendChild(document.createElement("span"));
613-
probe.textContent = native.textContent!.charAt(0);
614-
const advance = probe.getBoundingClientRect().width;
615-
probe.remove();
616-
const spacing = 2 * cellSize(host).width - advance;
617-
Object.assign(native.style, { letterSpacing: `${spacing}px`, wordSpacing: `${-spacing}px` });
616+
const cell = cellSize(host).width;
617+
const paragraph = native.firstElementChild as HTMLElement;
618+
const chars = [...paragraph.textContent!].map((char) => {
619+
const span = document.createElement("span");
620+
span.textContent = char;
621+
return span;
622+
});
623+
paragraph.replaceChildren(...chars);
624+
paragraph.style.letterSpacing = "0px";
625+
const advances = chars.map((span) => span.getBoundingClientRect().width);
626+
// A hair under its cells, so a full line fits however the engine
627+
// rounds the spacing.
628+
chars.forEach((span, j) => {
629+
const cells = span.textContent === " " ? 1 : 2;
630+
span.style.letterSpacing = `${cells * cell - advances[j]! - 1 / 32}px`;
631+
});
618632
await frames(1);
619633
}
620634
expectLinesAsNative(host, native);

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,5 +35,5 @@
3535
"oxfmt"
3636
]
3737
},
38-
"packageManager": "pnpm@12.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec"
38+
"packageManager": "pnpm@12.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45"
3939
}

‎packages/core/src/element.ts‎

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ const SHADE_RULES = [...SHADES]
8888
const SHADOW_TEMPLATE = `
8989
<style>${shadowCss}${SHADE_RULES}</style>
9090
<div id="viewport">
91+
<div id="spacer"></div>
9192
<pre id="grid" class="grid" aria-hidden="true"></pre>
9293
<div id="layers"></div>
9394
<slot></slot>
@@ -263,6 +264,11 @@ const DYNAMIC_RELAYOUT_EVENTS = [
263264
* 1/60 in Firefox). */
264265
const SIZE_TOLERANCE = 0.05;
265266

267+
/** How far the width spacer yields under the columns laid out
268+
* (specs/cell-model.md "Host sizing"), in px: past a layout unit in
269+
* every engine, within SIZE_TOLERANCE. */
270+
const SPACER_YIELD = 1 / 32;
271+
266272
/** Whether a lock's snap back could start a transition on an element:
267273
* a non-zero duration or delay in any entry of its lists. */
268274
const mayTransition = (style: CSSStyleDeclaration): boolean =>
@@ -448,9 +454,11 @@ export class MonoWindElement extends HTMLElementBase {
448454
#grid: HTMLElement;
449455
#layers: HTMLElement;
450456
#viewport: HTMLElement;
451-
/** The spacer's height in px, the content's rows at the last layout
452-
* (specs/cell-model.md "Host sizing"). */
457+
#spacer: HTMLElement;
458+
/** The spacer's size in px, the content's rows and the columns at the
459+
* last layout (specs/cell-model.md "Host sizing"). */
453460
#spacerHeight = 0;
461+
#spacerWidth = 0;
454462
#probe: HTMLElement;
455463
/** Resolves colors where the grid's spans inherit them
456464
* (specs/cell-model.md "Opacity and translucency"): the ink, the
@@ -605,6 +613,7 @@ export class MonoWindElement extends HTMLElementBase {
605613
this.#grid = shadow.querySelector<HTMLElement>("#grid")!;
606614
this.#layers = shadow.querySelector<HTMLElement>("#layers")!;
607615
this.#viewport = shadow.querySelector<HTMLElement>("#viewport")!;
616+
this.#spacer = shadow.querySelector<HTMLElement>("#spacer")!;
608617
this.#colorProbe = shadow.querySelector<HTMLElement>("#color-probe")!;
609618
this.#colorProbeStyle = getComputedStyle(this.#colorProbe);
610619
// The cell-metrics probe (measureCellMetrics), in the LIGHT DOM so it
@@ -2995,9 +3004,11 @@ export class MonoWindElement extends HTMLElementBase {
29953004
this.#viewport.style.minHeight = `${spacerHeight}px`;
29963005
this.#spacerHeight = spacerHeight;
29973006
}
2998-
const spacerWidth = `${availableCols * metrics.width}px`;
2999-
if (this.#viewport.style.minWidth !== spacerWidth)
3000-
this.#viewport.style.minWidth = spacerWidth;
3007+
const spacerWidth = availableCols * metrics.width;
3008+
if (spacerWidth !== this.#spacerWidth) {
3009+
this.#spacer.style.gridTemplateColumns = `minmax(${spacerWidth - SPACER_YIELD}px, ${spacerWidth}px)`;
3010+
this.#spacerWidth = spacerWidth;
3011+
}
30013012
// Cap the width to the columns laid out (specs/cell-model.md "Host
30023013
// sizing"); the companion applies it outside measuring.
30033014
setVar(this, "--mw-host-w", `${availableCols * metrics.width + chromeX}px`);

‎packages/core/src/shadow.css‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
/* Positioned, so the light DOM paints over the grid from its origin; a
1616
* block formatting context, so a flow child's top margin stays where
1717
* the engine put it (specs/cell-model.md "Inline content"). Out of the
18-
* host's flow, so the viewport's `min-height` and `min-width` alone give
18+
* host's flow, so the viewport's `min-height` and the spacer alone give
1919
* it its size ("Host sizing"), except while the engine reads a
2020
* shrink-to-fit host's width off its content. */
2121
slot {
@@ -28,8 +28,14 @@ slot {
2828
:host([measuring]) slot {
2929
position: relative;
3030
}
31-
:host([measuring]) #viewport {
32-
min-width: 0 !important;
31+
/* A track preferring the columns laid out and yielding a hair under
32+
* them, so a container sized off the host still shrinks natively
33+
* (specs/cell-model.md "Host sizing"). */
34+
#spacer {
35+
display: grid;
36+
}
37+
:host([measuring]) #spacer {
38+
display: none;
3339
}
3440
/* Sized to the ink extent, the host's background carried past the host
3541
* (specs/cell-model.md "Overflow"); the text fill undoes the host's

0 commit comments

Comments
 (0)