Files
clide/.claude/skills/ui-design/references/geometry.md
T

7.4 KiB
Raw Blame History

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) clideInsetHairline
Tight inset (4px) clideInsetTight
Uniform icon margin (6) clideInsetIcon
Standard inset (8px) clideInsetStandard
Text-content inset (12) clideInsetText
Tight / standard gap clideGapTight / clideGapStandard
Section / major gap clideGapSection / clideGapMajor
Micro icon (10) clideIconMicro
Standard icon (14) clideIconStandard
Hit-target (16) clideIconHitTarget
Control 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 Column without crossAxisAlignment.stretch — the strip ends mid-pane.
  • mainAxisSize.min on the tab strip's outer Row when you actually want it to fill parent width — only use min for 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 a Spacer that fights a flex sibling. A Row[ left…, Flexible(flex:1), Spacer(), right… ] splits the free space between the loose flex item and the Spacer 50/50 — so the right group is pushed only half the free space. The drift is proportional to width: invisible at 8001200px, ~1500px adrift at 3440px. To pin a right group to the edge regardless of width, put the left group in Expanded(Row[...]) (it absorbs all free space; flex items flex within it) and let the right group trail at intrinsic width. See StatusbarHost in lib/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 wide SizedBox under the default 800px surface is CLAMPED to 800, so it doesn't actually test wide. Pattern in test/app_statusbar_test.dart (pumpAt): set view.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:

  1. State the target margin (e.g. "6px around the close icon, all sides except left").
  2. Map every contributing source: parent padding, gap SizedBoxes, container alignment offsets, icon-to-container size differences.
  3. Sum them. Adjust until they hit the target.
  4. Verify visually — perceived mass may justify a 12px tweak.