7.4 KiB
Geometry — spacing, alignment, control layout
Principles for placing icons, buttons, and text inside controls. Apply when building tab strips, list items, buttons with affordances, or anything where actions sit next to content.
Constants live in
lib/widgets/src/spacing.dart— pull from there instead of inlining literals:
Concept Constant Hairline (2px) clideInsetHairlineTight inset (4px) clideInsetTightUniform icon margin (6) clideInsetIconStandard inset (8px) clideInsetStandardText-content inset (12) clideInsetTextTight / standard gap clideGapTight/clideGapStandardSection / major gap clideGapSection/clideGapMajorMicro icon (10) clideIconMicroStandard icon (14) clideIconStandardHit-target (16) clideIconHitTargetControl height (28) clideControlHeight
Uniform inner spacing rule
Icons inside control surfaces should have equal margin on every constrained side. The "constrained sides" are top, bottom, and the side opposite to where content flows in.
The remaining side — where the text or other content sits — gets a larger, content-appropriate breathing room.
Example: tab close button (16×16 inside a 28-tall tab):
top : 6 ┐
bottom : 6 ├─ uniform: (28 − 16) / 2 = 6
right : 6 ┘
left : 8 ── content gap (separates from title text)
The visual effect: the close button looks like a deliberate affordance with a calm, consistent border, not a glyph stuffed into the corner.
No double-edge padding
When a fixed-size action (icon button, close ×) sits at the edge of a padded parent, the parent's padding on that edge should not stack with the action's own internal margin. Pick one place to hold the breathing room.
Wrong:
Container(
padding: EdgeInsets.symmetric(horizontal: 12), // tab pad: 12 right
child: Row(children: [
Expanded(child: title),
SizedBox(width: 8), // gap: 8
Container(width: 28, alignment: Center, // close: 6 internal margin
child: Icon(close, size: 16)),
]),
)
// Visible margin from icon right to outer right = 12 + 6 = 18px → too much
Right:
Container(
padding: EdgeInsets.only(left: 12, right: 6), // pad matches icon margin
child: Row(children: [
Expanded(child: title),
SizedBox(width: 8),
Container(width: 16, height: 16, alignment: Center, // hit target = icon size
child: Icon(close, size: 10)),
]),
)
// Visible margin = 6 (parent right pad) ≈ 6 (top/bottom auto) → uniform
Two-column control pattern
For tab-shaped or row-shaped controls with a primary content area and a secondary action:
Row(children: [
Expanded(child: <content>), // takes remainder
if (action != null) ...[
SizedBox(width: 8), // standard gap
<fixed-size action>, // shrinks to content
],
])
- Left column:
Expanded, holds the primary content (title, label, description). Aligned to the start of its space by default. - Right column: fixed natural width, holds the action (close, status, indicator). Sized to the icon, not to artificial padding.
The parent container's padding sits flush against both columns (see "no double-edge padding").
Match perceived mass, not measured pixels
Glyphs vary in visual weight. A bold + looks heavier than a thin
× at the same point size. When eyeballing alignment, trust the
optical center over the geometric center.
In practice: if two icons measure to the same margin but one looks
crowded, give the heavier glyph slightly more breathing room and
trim the lighter one. For clide, this came up with the × close
glyph vs the + add glyph — both at 14pt, but + reads as denser
and is left in its 28-wide button without further padding, while
× sits in a 16×16 hit area with 6px symmetric margin.
Strip / row should fill the parent
Tab strips, status bars, and divider rows should span the full parent width, not size to their content. Without this, the strip looks like it floats inside the pane.
Column(
crossAxisAlignment: CrossAxisAlignment.stretch, // <-- this
children: [
_TabStrip(...),
Expanded(child: _body(...)),
],
)
Without stretch, Column gives loose width constraints and a
Container(height: tabHeight) child sizes to its child's natural
width — the strip ends mid-pane.
Anchor strips with a divider
Add a 1px bottom border (dividerColor) to tab strips and any
header strip that sits above content. Without it, the strip looks
disconnected from the body and the perceived alignment slips.
Container(
height: 28,
decoration: BoxDecoration(
color: tokens.tabBarBackground,
border: Border(bottom: BorderSide(color: tokens.dividerColor)),
),
child: ...,
)
Anti-patterns
- Centering a glyph inside a "hover background" that's larger than the natural icon size, then surrounding the whole thing with a padded parent — the icon ends up far inside the visible edge.
- Hardcoded
padding: EdgeInsets.symmetric(horizontal: 12)on every control regardless of whether the right edge has an action — see "no double-edge padding". - Tab strip inside
ColumnwithoutcrossAxisAlignment.stretch— the strip ends mid-pane. mainAxisSize.minon the tab strip's outer Row when you actually want it to fill parent width — only useminfor pill-shaped controls that should hug their content.- Eyeballing alignment without working back from a target margin in pixels. The math matters; see "uniform inner spacing".
Ultrawide — clide is an IDE, assume wide screens (T-239)
The default flutter_test surface is 800px, but clide runs on 3440/5120
ultrawide constantly. Two consequences:
- Right-align with
Expanded, not aSpacerthat fights a flex sibling. ARow[ left…, Flexible(flex:1), Spacer(), right… ]splits the free space between the loose flex item and theSpacer50/50 — so the right group is pushed only half the free space. The drift is proportional to width: invisible at 800–1200px, ~1500px adrift at 3440px. To pin a right group to the edge regardless of width, put the left group inExpanded(Row[...])(it absorbs all free space; flex items flex within it) and let the right group trail at intrinsic width. SeeStatusbarHostinlib/app.dart. - Test layout at ultrawide, not just the default surface. Width-sensitive
bugs hide at 800px. Drive the surface with
tester.view.physicalSize— a wideSizedBoxunder the default 800px surface is CLAMPED to 800, so it doesn't actually test wide. Pattern intest/app_statusbar_test.dart(pumpAt): setview.physicalSize, assert key positions at a normal AND an ultrawide width. Audit tracked in T-241.
Testing alignment
When iterating on a control's spacing:
- State the target margin (e.g. "6px around the close icon, all sides except left").
- Map every contributing source: parent padding, gap SizedBoxes, container alignment offsets, icon-to-container size differences.
- Sum them. Adjust until they hit the target.
- Verify visually — perceived mass may justify a 1–2px tweak.