202 lines
7.4 KiB
Markdown
202 lines
7.4 KiB
Markdown
# 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:
|
||
|
||
```dart
|
||
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:
|
||
|
||
```dart
|
||
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:
|
||
|
||
```dart
|
||
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.
|
||
|
||
```dart
|
||
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.
|
||
|
||
```dart
|
||
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 800–1200px, ~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 1–2px tweak.
|