codify UI spacing constants (T-86)

Adds lib/widgets/src/spacing.dart with three categories of named
constants — insets (clideInsetHairline / Tight / Icon / Standard /
Text), gaps (clideGapTight / Standard / Section / SectionLarge /
Major / Column), and sizes (clideIconMicro / Caption / Standard /
HitTarget / Emphatic, clideControlHeight).

Migrates MultitabPane to consume the constants and updates the
ui-design geometry reference to point at them. Inline pixel
literals in widget code were drifting (12 here, 6 there, 28
elsewhere) — pulling them through named symbols makes the
"uniform inner spacing" rule enforceable instead of eyeballed.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-05-06 15:45:07 +02:00
co-authored by Claude
parent 29ac9b3caf
commit e329f1cc6f
7 changed files with 151 additions and 26 deletions
@@ -4,11 +4,22 @@ Principles for placing icons, buttons, and text inside controls.
Apply when building tab strips, list items, buttons with affordances, Apply when building tab strips, list items, buttons with affordances,
or anything where actions sit next to content. or anything where actions sit next to content.
> Numeric values in this doc will move to `ClideSpacing` constants > Constants live in `lib/widgets/src/spacing.dart` — pull from there
> (`lib/widgets/src/spacing.dart`) — see T-86. Until then, the > instead of inlining literals:
> constants used in the codebase: `12` (text inset), `6` (icon >
> breathing), `8` (standard gap), `4` (tight gap), `16` (icon hit > | Concept | Constant |
> area), `28` (button / row height). > |-------------------------|-------------------------|
> | 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 ## Uniform inner spacing rule
+19 -5
View File
@@ -1,5 +1,5 @@
{ {
"exported_at": "2026-05-06T13:38:19Z", "exported_at": "2026-05-06T13:45:07Z",
"decisions": [ "decisions": [
{ {
"id": "D-1", "id": "D-1",
@@ -2696,19 +2696,19 @@
"id": "T-85", "id": "T-85",
"type": "task", "type": "task",
"title": "consolidate theme-ui into ui-design skill with theme/surface/geometry/icons references", "title": "consolidate theme-ui into ui-design skill with theme/surface/geometry/icons references",
"status": "in_progress", "status": "done",
"priority": "medium", "priority": "medium",
"created_at": "2026-05-06 13:28:58", "created_at": "2026-05-06 13:28:58",
"updated_at": "2026-05-06 13:29:05" "updated_at": "2026-05-06 13:38:38"
}, },
{ {
"id": "T-86", "id": "T-86",
"type": "task", "type": "task",
"title": "codify UI spacing constants (gaps, paddings, control sizes)", "title": "codify UI spacing constants (gaps, paddings, control sizes)",
"status": "backlog", "status": "in_progress",
"priority": "medium", "priority": "medium",
"created_at": "2026-05-06 13:28:58", "created_at": "2026-05-06 13:28:58",
"updated_at": "2026-05-06 13:28:58" "updated_at": "2026-05-06 13:38:44"
} }
], ],
"ticket_deps": null, "ticket_deps": null,
@@ -4097,6 +4097,20 @@
"old_value": "backlog", "old_value": "backlog",
"new_value": "in_progress", "new_value": "in_progress",
"changed_at": "2026-05-06 13:29:05" "changed_at": "2026-05-06 13:29:05"
},
{
"ticket_id": "T-85",
"field": "status",
"old_value": "in_progress",
"new_value": "done",
"changed_at": "2026-05-06 13:38:38"
},
{
"ticket_id": "T-86",
"field": "status",
"old_value": "backlog",
"new_value": "in_progress",
"changed_at": "2026-05-06 13:38:44"
} }
] ]
} }
+4
View File
@@ -36,6 +36,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
- Claude pane uses `MultitabPane` for primary + secondaries — drops - Claude pane uses `MultitabPane` for primary + secondaries — drops
~100 lines of bespoke tab-strip code, gains drag-to-reorder. ~100 lines of bespoke tab-strip code, gains drag-to-reorder.
- UI spacing constants live in `lib/widgets/src/spacing.dart` —
`clideInset*` for paddings, `clideGap*` for sibling distances,
`clideIcon*` / `clideControlHeight` for control sizes. Inline
pixel literals replaced where they were repeated.
### Changed ### Changed
+14 -13
View File
@@ -4,6 +4,7 @@ import 'package:clide/widgets/src/clide_tappable.dart';
import 'package:clide/widgets/src/clide_text.dart'; import 'package:clide/widgets/src/clide_text.dart';
import 'package:clide/widgets/src/icons/x.dart'; import 'package:clide/widgets/src/icons/x.dart';
import 'package:clide/widgets/src/multitab_controller.dart'; import 'package:clide/widgets/src/multitab_controller.dart';
import 'package:clide/widgets/src/spacing.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
typedef MultitabBuilder<T> = Widget Function(BuildContext context, MultitabEntry<T> entry); typedef MultitabBuilder<T> = Widget Function(BuildContext context, MultitabEntry<T> entry);
@@ -27,7 +28,7 @@ class MultitabPane<T> extends StatelessWidget {
this.onAddRequested, this.onAddRequested,
this.allowReorder = true, this.allowReorder = true,
this.keepAlive = false, this.keepAlive = false,
this.tabHeight = 28, this.tabHeight = clideControlHeight,
}); });
final MultitabController<T> controller; final MultitabController<T> controller;
@@ -321,13 +322,13 @@ class _TabState<T> extends State<_Tab<T>> {
builder: (context, _, __) => Container( builder: (context, _, __) => Container(
constraints: BoxConstraints(minWidth: 96, maxWidth: 200), constraints: BoxConstraints(minWidth: 96, maxWidth: 200),
height: widget.tabHeight, height: widget.tabHeight,
// Left margin stays at 12 (text breathing room). // Left: text inset (title breathing room).
// Right margin matches the close button's vertical // Right: when there's a close button, match its uniform
// breathing room ((tabHeight − iconSize) / 2 ≈ 6) so the // icon margin so top/bottom/right are equal — see the
// gap around the icon is uniform on top, bottom, and right. // ui-design `geometry.md` "no double-edge padding" rule.
padding: EdgeInsets.only( padding: EdgeInsets.only(
left: 12, left: clideInsetText,
right: widget.onClose != null ? 6 : 12, right: widget.onClose != null ? clideInsetIcon : clideInsetText,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: bg, color: bg,
@@ -351,14 +352,14 @@ class _TabState<T> extends State<_Tab<T>> {
), ),
// Right column: close icon, fixed natural width. // Right column: close icon, fixed natural width.
if (widget.onClose != null) ...[ if (widget.onClose != null) ...[
const SizedBox(width: 8), const SizedBox(width: clideGapStandard),
Opacity( Opacity(
opacity: _hovered || widget.active ? 1.0 : 0.0, opacity: _hovered || widget.active ? 1.0 : 0.0,
child: ClideTappable( child: ClideTappable(
onTap: widget.onClose, onTap: widget.onClose,
builder: (context, hovered, _) => Container( builder: (context, hovered, _) => Container(
width: 16, width: clideIconHitTarget,
height: 16, height: clideIconHitTarget,
alignment: Alignment.center, alignment: Alignment.center,
decoration: BoxDecoration( decoration: BoxDecoration(
color: hovered ? tokens.listItemHoverBackground : null, color: hovered ? tokens.listItemHoverBackground : null,
@@ -366,7 +367,7 @@ class _TabState<T> extends State<_Tab<T>> {
), ),
child: ClideIcon( child: ClideIcon(
const CloseIcon(), const CloseIcon(),
size: 10, size: clideIconMicro,
color: hovered ? tokens.globalForeground : tokens.globalTextMuted, color: hovered ? tokens.globalForeground : tokens.globalTextMuted,
), ),
), ),
@@ -397,14 +398,14 @@ class _AddButton extends StatelessWidget {
child: ClideTappable( child: ClideTappable(
onTap: onTap, onTap: onTap,
builder: (context, hovered, _) => Container( builder: (context, hovered, _) => Container(
width: 28, width: clideControlHeight,
height: tabHeight, height: tabHeight,
alignment: Alignment.center, alignment: Alignment.center,
decoration: BoxDecoration( decoration: BoxDecoration(
color: hovered ? tokens.listItemHoverBackground : null, color: hovered ? tokens.listItemHoverBackground : null,
), ),
child: ClideText('+', child: ClideText('+',
fontSize: 14, fontSize: clideIconStandard,
color: hovered ? tokens.globalForeground : tokens.globalTextMuted), color: hovered ? tokens.globalForeground : tokens.globalTextMuted),
), ),
), ),
+89
View File
@@ -0,0 +1,89 @@
/// Spacing constants shared across widgets.
///
/// Three categories — every widget that picks a literal pixel value
/// for layout should pull it from one of these instead:
///
/// 1. **Insets** — padding inside containers around content
/// (text breathing room, icon micro-margin).
/// 2. **Gaps** — distance between siblings in a Row or Column.
/// 3. **Sizes** — control dimensions (icon glyph size, hit-target
/// outer size, button/row/tab height).
///
/// Values are paired with the geometry rules under
/// `.claude/skills/ui-design/references/geometry.md` (uniform inner
/// spacing, no double-edge padding, two-column control pattern).
/// Adjust here only — never inline literals.
library;
// ---------------------------------------------------------------------------
// Insets — padding inside containers
// ---------------------------------------------------------------------------
/// Hairline. Used for divider thickness and the terminal cell padding
/// where 1px would clip glyphs.
const double clideInsetHairline = 2;
/// Tight inset for compact controls (sidebar item gutter).
const double clideInsetTight = 4;
/// Uniform breathing room around an icon inside a control. Pair with
/// a 16x16 hit target inside a 28-tall control: top/bottom auto-fall
/// to (28 − 16) / 2 = 6, so a matching right padding of 6 yields a
/// uniform border on the constrained sides.
const double clideInsetIcon = 6;
/// Standard gap between adjacent inline elements in a row (icon → text,
/// text → action). Used as the in-row SizedBox between a title and its
/// trailing close button.
const double clideInsetStandard = 8;
/// Text-content padding. Used as the leading horizontal padding of
/// text-bearing controls (tab title left padding, list-item gutter).
const double clideInsetText = 12;
// ---------------------------------------------------------------------------
// Gaps — distance between siblings
// ---------------------------------------------------------------------------
/// Tight gap between row segments inside a control.
const double clideGapTight = 4;
/// Standard gap between sibling controls (between a title and an
/// action icon, between two list items in a flex row).
const double clideGapStandard = 8;
/// Section internal gap (between a section header and its first row).
const double clideGapSection = 14;
/// Section gap (between two sections in a stack).
const double clideGapSectionLarge = 20;
/// Major gap between large blocks (between two columns in a row,
/// between header and content card).
const double clideGapMajor = 24;
/// Welcome-screen-style gap between the two centered columns.
const double clideGapColumn = 56;
// ---------------------------------------------------------------------------
// Sizes — control dimensions
// ---------------------------------------------------------------------------
/// Micro icon glyph (close ×, dropdown chevron when paired with text).
const double clideIconMicro = 10;
/// Caption-row icon (status bar, sidebar inline icons).
const double clideIconCaption = 13;
/// Standard inline icon (icon rail, action buttons).
const double clideIconStandard = 14;
/// Hit-target outer container around a micro icon. Provides hover
/// background and a comfortable click area; the icon centers inside.
const double clideIconHitTarget = 16;
/// Emphatic icon (standalone affordances, primary action glyphs).
const double clideIconEmphatic = 18;
/// Standard control height (tab, button, list row).
const double clideControlHeight = 28;
+1
View File
@@ -28,6 +28,7 @@ export 'src/multitab_pane.dart';
export 'src/clide_tappable.dart'; export 'src/clide_tappable.dart';
export 'src/clide_text.dart'; export 'src/clide_text.dart';
export 'src/clide_tooltip.dart'; export 'src/clide_tooltip.dart';
export 'src/spacing.dart';
export 'src/typography.dart'; export 'src/typography.dart';
export 'src/icons/check.dart'; export 'src/icons/check.dart';
export 'src/icons/chevron.dart'; export 'src/icons/chevron.dart';
+8 -3
View File
@@ -1,3 +1,4 @@
import 'package:clide/widgets/src/icons/x.dart';
import 'package:clide/widgets/widgets.dart'; import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
@@ -5,6 +6,10 @@ import 'package:flutter_test/flutter_test.dart';
import '../helpers/kernel_fixture.dart'; import '../helpers/kernel_fixture.dart';
import '../helpers/widget_harness.dart'; import '../helpers/widget_harness.dart';
Finder _closeIcons() => find.byWidgetPredicate(
(w) => w is ClideIcon && w.painter is CloseIcon,
);
MultitabEntry<String> entry(String id, {bool closeable = true, bool reorderable = true}) { MultitabEntry<String> entry(String id, {bool closeable = true, bool reorderable = true}) {
return MultitabEntry<String>( return MultitabEntry<String>(
id: id, id: id,
@@ -119,7 +124,7 @@ void main() {
// A pinned tab has no close target; a closeable one does (it's // A pinned tab has no close target; a closeable one does (it's
// hidden via Opacity until hover, but still in the tree). // hidden via Opacity until hover, but still in the tree).
// Two tabs total, one × glyph for 's'. // Two tabs total, one × glyph for 's'.
expect(find.text('×'), findsOneWidget); expect(_closeIcons(), findsOneWidget);
}); });
testWidgets('default close behavior removes the entry', (tester) async { testWidgets('default close behavior removes the entry', (tester) async {
@@ -130,7 +135,7 @@ void main() {
harness(f, MultitabPane<String>(controller: c, bodyBuilder: body)), harness(f, MultitabPane<String>(controller: c, bodyBuilder: body)),
); );
await tester.tap(find.text('×')); await tester.tap(_closeIcons());
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(c.entries.map((e) => e.id), ['p']); expect(c.entries.map((e) => e.id), ['p']);
@@ -151,7 +156,7 @@ void main() {
); );
// Both tabs are closeable; tap the first × encountered. // Both tabs are closeable; tap the first × encountered.
await tester.tap(find.text('×').first); await tester.tap(_closeIcons().first);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(closed, isNotNull); expect(closed, isNotNull);