Layout / Box Model
ui.boxUne matrice de couverture du modèle de boîte, des pourcentages, du positionnement absolu, de Flex / Grid et du rognage.
BEHAVIOR CONTRACT
Padding box, content box, insets en pourcentage et rognage par overflow sont accompagnés de repères visuels.
RECORDED INTERACTION
Parcourt chaque groupe de repères de mise en page.
pub fn buildLayoutBoxModel(scope: *ui.Scope, cx: *ui.Cx) anyerror!*ui.Node {
_ = scope;
const a = cx.allocator;
const c = try col(cx, 22);
c.style.width = .{ .px = 720 };
// 覆盖矩阵先写在页首,截图本身即可充当人工验收 checklist。
const coverage = try ui.box(cx, .{
.width = .{ .px = 700 },
.direction = .column,
.gap = 4,
.padding = Padding.all(14),
.background = layout_col.surface,
.border = .{ .width = 1, .color = layout_col.line, .radius = 8 },
}, .{});
try coverage.appendChild(a, try ui.text(cx, "Coverage matrix", .{
.font_size = 13,
.font_weight = 700,
.color = light.color.fg_primary,
}));
try coverage.appendChild(a, try label(cx, "padding / border / content · px / fit / percent / grow · margin / gap"));
try coverage.appendChild(a, try label(cx, "row / justify / align · Grid px + fr · absolute inset / percent / stretch · overflow clip"));
try c.appendChild(a, coverage);
// 1) CSS padding-box anatomy. Parent 700×180, asymmetric padding
// L32/R44/T28/B20. Absolute right:0/bottom:0 must touch the padding edge
// (Zenit 的 border 为 paint-only,所以该 edge 与本地 rect edge 重合)。
try c.appendChild(a, try layoutSection(cx, "1. Padding-box anatomy + absolute edges", "amber = padding box · blue = content box · red = right:0 / bottom:0 · navy = content edge"));
const anatomy = try layoutArena(cx, 700, 180, .{ .left = 32, .right = 44, .top = 28, .bottom = 20 });
anatomy.meta.ownership.meta.test_id = "story.layoutbox.absolute.parent";
const content_guide = try ui.box(cx, .{
.position = .absolute,
.width = .{ .grow = .{} },
.height = .{ .grow = .{} },
.justify = .center,
.align_items = .center,
.background = layout_col.content,
.border = .{ .width = 1, .color = ui.Color.rgba(96, 165, 250, 255), .radius = 4 },
}, .{});
(try content_guide.style.ensureExtFallible(a)).inset = .{
.left = .{ .px = 32 },
.right = .{ .px = 44 },
.top = .{ .px = 28 },
.bottom = .{ .px = 20 },
};
content_guide.meta.ownership.meta.test_id = "story.layoutbox.absolute.content-guide";
try content_guide.appendChild(a, try ui.text(cx, "content 624 × 132", .{
.font_size = 12,
.font_weight = 650,
.color = ui.Color.rgba(30, 64, 175, 255),
}));
try anatomy.appendChild(a, content_guide);
const origin = try layoutBadge(cx, "0,0", 38, 26, layout_col.stretch);
origin.style.position = .absolute;
(try origin.style.ensureExtFallible(a)).inset = .{ .left = .{ .px = 0 }, .top = .{ .px = 0 } };
origin.meta.ownership.meta.test_id = "story.layoutbox.absolute.origin";
try anatomy.appendChild(a, origin);
const outer_edge = try layoutBadge(cx, "edge", 44, 30, layout_col.absolute);
outer_edge.style.position = .absolute;
(try outer_edge.style.ensureExtFallible(a)).inset = .{ .right = .{ .px = 0 }, .bottom = .{ .px = 0 } };
outer_edge.meta.ownership.meta.test_id = "story.layoutbox.absolute.outer-edge";
try anatomy.appendChild(a, outer_edge);
const content_edge = try layoutBadge(cx, "content", 58, 30, layout_col.flow);
content_edge.style.position = .absolute;
(try content_edge.style.ensureExtFallible(a)).inset = .{ .right = .{ .px = 44 }, .bottom = .{ .px = 20 } };
content_edge.meta.ownership.meta.test_id = "story.layoutbox.absolute.content-edge";
try anatomy.appendChild(a, content_edge);
try c.appendChild(a, anatomy);
try c.appendChild(a, try label(cx, "Expected: content=(32,28 624×132), red right/bottom gap=0, navy right gap=44 bottom gap=20"));
// 2) Percentage resolution and double-sided grow stretch.
try c.appendChild(a, try layoutSection(cx, "2. Percentage inset + double-sided stretch", "Percentages resolve against the full padding box; left+right/top+bottom constrain grow size."));
const inset_row = try row(cx, 16);
const percent_col = try col(cx, 6);
try percent_col.appendChild(a, try label(cx, "left:25% · top:50% → (85.5, 75)"));
const percent_parent = try layoutArena(cx, 342, 150, Padding.all(20));
percent_parent.meta.ownership.meta.test_id = "story.layoutbox.percent.parent";
const vguide = try ui.box(cx, .{
.position = .absolute,
.width = .{ .px = 1 },
.height = .{ .grow = .{} },
.background = layout_col.guide,
}, .{});
(try vguide.style.ensureExtFallible(a)).inset = .{ .left = .{ .px = 85.5 } };
try percent_parent.appendChild(a, vguide);
const hguide = try ui.box(cx, .{
.position = .absolute,
.width = .{ .grow = .{} },
.height = .{ .px = 1 },
.background = layout_col.guide,
}, .{});
(try hguide.style.ensureExtFallible(a)).inset = .{ .top = .{ .px = 75 } };
try percent_parent.appendChild(a, hguide);
const percent_marker = try layoutBadge(cx, "25/50", 52, 28, layout_col.absolute);
percent_marker.style.position = .absolute;
(try percent_marker.style.ensureExtFallible(a)).inset = .{
.left = .{ .percent = 25 },
.top = .{ .percent = 50 },
};
percent_marker.meta.ownership.meta.test_id = "story.layoutbox.percent.marker";
try percent_parent.appendChild(a, percent_marker);
try percent_col.appendChild(a, percent_parent);
try inset_row.appendChild(a, percent_col);
const stretch_col = try col(cx, 6);
try stretch_col.appendChild(a, try label(cx, "L14 / R22 / T18 / B12 + grow"));
const stretch_parent = try layoutArena(cx, 342, 150, .{ .left = 26, .right = 12, .top = 10, .bottom = 22 });
stretch_parent.meta.ownership.meta.test_id = "story.layoutbox.stretch.parent";
const stretch_child = try ui.box(cx, .{
.position = .absolute,
.width = .{ .grow = .{} },
.height = .{ .grow = .{} },
.justify = .center,
.align_items = .center,
.background = layout_col.stretch,
.border = .{ .radius = 5 },
}, .{});
(try stretch_child.style.ensureExtFallible(a)).inset = .{
.left = .{ .px = 14 },
.right = .{ .px = 22 },
.top = .{ .px = 18 },
.bottom = .{ .px = 12 },
};
stretch_child.meta.ownership.meta.test_id = "story.layoutbox.stretch.child";
try stretch_child.appendChild(a, try ui.text(cx, "306 × 120", .{
.font_size = 12,
.font_weight = 650,
.color = layout_col.ink_on_color,
}));
try stretch_parent.appendChild(a, stretch_child);
try stretch_col.appendChild(a, stretch_parent);
try inset_row.appendChild(a, stretch_col);
try c.appendChild(a, inset_row);
// 3) Flex and Grid both keep absolute children out of normal placement,
// while using the same padding-box containing block for inset resolution.
try c.appendChild(a, try layoutSection(cx, "3. Flex vs Grid: in-flow content and out-of-flow absolute", "Flow items start at padding.left; the red absolute marker consumes no gap, track, or flex space."));
const engines = try row(cx, 16);
const flex_col = try col(cx, 6);
try flex_col.appendChild(a, try label(cx, "Flex row · padding 20 · gap 12 · margins on B"));
const flex_parent = try layoutArena(cx, 342, 170, Padding.all(20));
flex_parent.style.direction = .row;
flex_parent.style.gap = 12;
flex_parent.style.align_items = .center;
flex_parent.meta.ownership.meta.test_id = "story.layoutbox.flex.parent";
const flow_a = try layoutBadge(cx, "A 64", 64, 56, layout_col.flow);
flow_a.meta.ownership.meta.test_id = "story.layoutbox.flex.a";
try flex_parent.appendChild(a, flow_a);
const flow_b = try layoutBadge(cx, "B +m", 64, 56, layout_col.flow_alt);
flow_b.style.margin = .{ .left = 8, .right = 6, .top = 0, .bottom = 0 };
flow_b.meta.ownership.meta.test_id = "story.layoutbox.flex.b";
try flex_parent.appendChild(a, flow_b);
const flow_grow = try layoutBadge(cx, "grow", 10, 56, ui.Color.rgba(124, 58, 237, 255));
flow_grow.style.width = .{ .grow = .{} };
flow_grow.style.margin.right = 10;
flow_grow.meta.ownership.meta.test_id = "story.layoutbox.flex.grow";
try flex_parent.appendChild(a, flow_grow);
const flex_abs = try layoutBadge(cx, "abs", 34, 34, layout_col.absolute);
flex_abs.style.position = .absolute;
(try flex_abs.style.ensureExtFallible(a)).inset = .{ .right = .{ .px = 0 }, .top = .{ .px = 0 } };
flex_abs.meta.ownership.meta.test_id = "story.layoutbox.flex.absolute";
try flex_parent.appendChild(a, flex_abs);
try flex_col.appendChild(a, flex_parent);
try engines.appendChild(a, flex_col);
const grid_col = try col(cx, 6);
try grid_col.appendChild(a, try label(cx, "Grid · 70px / 1fr / 2fr · gap 8"));
const grid_a = try layoutBadge(cx, "70px", 10, 52, layout_col.flow);
grid_a.style.width = .{ .grow = .{} };
grid_a.meta.ownership.meta.test_id = "story.layoutbox.grid.a";
const grid_b = try layoutBadge(cx, "1fr", 10, 52, layout_col.flow_alt);
grid_b.style.width = .{ .grow = .{} };
grid_b.meta.ownership.meta.test_id = "story.layoutbox.grid.b";
const grid_c = try layoutBadge(cx, "2fr", 10, 52, ui.Color.rgba(124, 58, 237, 255));
grid_c.style.width = .{ .grow = .{} };
grid_c.meta.ownership.meta.test_id = "story.layoutbox.grid.c";
const grid_abs = try layoutBadge(cx, "abs", 34, 34, layout_col.absolute);
grid_abs.style.position = .absolute;
(try grid_abs.style.ensureExtFallible(a)).inset = .{ .right = .{ .px = 0 }, .bottom = .{ .px = 0 } };
grid_abs.meta.ownership.meta.test_id = "story.layoutbox.grid.absolute";
const grid_parent = try ui.grid(cx, .{
.columns = &.{ .{ .px = 70 }, .{ .fr = 1 }, .{ .fr = 2 } },
.rows = &.{.{ .px = 52 }},
.column_gap = 8,
.width = .{ .px = 342 },
.height = .{ .px = 170 },
.padding = Padding.all(20),
.background = layout_col.padding,
.border = .{ .width = 1, .color = layout_col.line, .radius = 8 },
.align_items = .center,
}, .{ grid_a, grid_b, grid_c, grid_abs });
grid_parent.meta.ownership.meta.test_id = "story.layoutbox.grid.parent";
try grid_col.appendChild(a, grid_parent);
try engines.appendChild(a, grid_col);
try c.appendChild(a, engines);
// 4) Sizing modes share the parent's content width, while the parent still
// owns padding. This row makes px/percent/fit/grow contributions visible.
try c.appendChild(a, try layoutSection(cx, "4. Sizing modes inside one Flex row", "Parent 700 wide, padding 20 → content 660. The 20% item is 132 wide; grow receives the remainder."));
const sizing_parent = try ui.box(cx, .{
.width = .{ .px = 700 },
.height = .{ .px = 100 },
.direction = .row,
.align_items = .center,
.gap = 10,
.padding = Padding.all(20),
.background = layout_col.content,
.border = .{ .width = 1, .color = layout_col.line, .radius = 8 },
}, .{});
sizing_parent.meta.ownership.meta.test_id = "story.layoutbox.sizing.parent";
const sizing_px = try layoutBadge(cx, "px 80", 80, 44, layout_col.flow);
sizing_px.meta.ownership.meta.test_id = "story.layoutbox.sizing.px";
try sizing_parent.appendChild(a, sizing_px);
const sizing_percent = try layoutBadge(cx, "20%", 10, 44, layout_col.flow_alt);
sizing_percent.style.width = .{ .percent = 20 };
sizing_percent.meta.ownership.meta.test_id = "story.layoutbox.sizing.percent";
try sizing_parent.appendChild(a, sizing_percent);
const sizing_fit = try layoutBadge(cx, "fit content", 10, 44, ui.Color.rgba(124, 58, 237, 255));
sizing_fit.style.width = .{ .fit = .{} };
sizing_fit.meta.ownership.meta.test_id = "story.layoutbox.sizing.fit";
try sizing_parent.appendChild(a, sizing_fit);
const sizing_grow = try layoutBadge(cx, "grow", 10, 44, layout_col.stretch);
sizing_grow.style.width = .{ .grow = .{} };
sizing_grow.meta.ownership.meta.test_id = "story.layoutbox.sizing.grow";
try sizing_parent.appendChild(a, sizing_grow);
try c.appendChild(a, sizing_parent);
// 5) Main-axis distribution and cross-axis alignment. Each sample uses the
// same content width, so start/center/end drift is immediately visible.
try c.appendChild(a, try layoutSection(cx, "5. Flex distribution: justify + align + gap", "Three identical 220×92 parents; only justify changes. Cross axis stays centered."));
const justify_row = try row(cx, 16);
inline for (.{ ui.JustifyContent.start, .center, .end }, .{ "start", "center", "end" }) |justify, name| {
const sample_col = try col(cx, 5);
try sample_col.appendChild(a, try label(cx, name));
const sample = try ui.box(cx, .{
.width = .{ .px = 220 },
.height = .{ .px = 92 },
.direction = .row,
.justify = justify,
.align_items = .center,
.gap = 8,
.padding = Padding.all(12),
.background = layout_col.content,
.border = .{ .width = 1, .color = layout_col.line, .radius = 8 },
}, .{});
try sample.appendChild(a, try layoutBadge(cx, "1", 36, 30, layout_col.flow));
try sample.appendChild(a, try layoutBadge(cx, "2", 36, 30, layout_col.flow_alt));
try sample_col.appendChild(a, sample);
try justify_row.appendChild(a, sample_col);
}
try c.appendChild(a, justify_row);
// 6) Overflow is part of the box model contract. Same overflowing absolute
// child in both arenas; only the left parent clips at its padding-box edge.
try c.appendChild(a, try layoutSection(cx, "6. Overflow clipping", "The rose block starts at (280,55), size 100×60. Left clips; right remains visible."));
const overflow_row = try row(cx, 16);
inline for (.{ true, false }, .{ "overflow_hidden", "overflow_visible" }) |clipped, name| {
const sample_col = try col(cx, 5);
try sample_col.appendChild(a, try label(cx, name));
const sample = try ui.box(cx, .{
.width = .{ .px = 342 },
.height = .{ .px = 110 },
.position = .relative,
.padding = Padding.all(12),
.background = layout_col.surface,
.border = .{ .width = 1, .color = layout_col.line, .radius = 8 },
.overflow_hidden = clipped,
}, .{});
const overflow_child = try layoutBadge(cx, "100 × 60", 100, 60, layout_col.absolute);
overflow_child.style.position = .absolute;
(try overflow_child.style.ensureExtFallible(a)).inset = .{ .left = .{ .px = 280 }, .top = .{ .px = 55 } };
try sample.appendChild(a, overflow_child);
try sample_col.appendChild(a, sample);
try overflow_row.appendChild(a, sample_col);
}
try c.appendChild(a, overflow_row);
return c;
}Le code source tel quel de la story dans le Storybook (zenit 5f9add5+wip 2026-09-30). W désigne ui.widgets ; col / row / label sont de petits helpers de mise en page du Storybook.