Layout / Box Model
ui.boxEine Abdeckungsmatrix für Boxmodell, Prozentwerte, absolute Positionierung, Flex / Grid und Clipping.
BEHAVIOR CONTRACT
Padding-Box, Content-Box, prozentuale Insets und Overflow-Clipping haben visuelle Hilfslinien.
RECORDED INTERACTION
Durchläuft jede Gruppe von Layout-Hilfslinien.
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;
}Der unveränderte Quellcode der Story aus dem Storybook (zenit 5f9add5+wip 2026-09-30). W steht für ui.widgets; col / row / label sind kleine Layout-Helfer des Storybooks.