The Garage SDKs, in the open

garage_auth, garage_entitlements, garage_iap and garage_ui, moved out of
Garage-Services and Metro-Map-Maker into one public repo. MIT, one readme,
docs under docs/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013F4NWNvYcdeSgqbWMT1VQ7
This commit is contained in:
ImBenji
2026-09-23 18:49:21 +01:00
co-authored by Claude Opus 5.5
commit b269201919
117 changed files with 26944 additions and 0 deletions
+562
View File
@@ -0,0 +1,562 @@
import "dart:math" as math;
import "dart:ui" show Color, lerpDouble;
import "package:flutter/painting.dart" show HSLColor;
import "package:flutter/widgets.dart" show Brightness;
// The apps own colour scheme. Used to be a subclass of shadcns ColorScheme which
// meant it kept getting flattened back to a plain base scheme every frame by the
// theme lerp - now its just our own first class type, nothing above it.
//
// Holds the usual semantic slots (background/foreground/primary/...) plus the
// app specific ones (chrome, panel borders, the control family) as equals.
//
// ── On the names ──────────────────────────────────────────────────────────
// The control family used to be called input*. That was a lie measured
// against its own call sites: of the eighteen reads of `inputBorder` exactly
// one was a text field, and the rest were outline buttons, selects, date
// inputs, checkboxes, radios, the colour swatch and toast. Anything with a
// stroke around it took the "input" token because that was the only stroke
// on offer. They're control* now, which is what they always were.
//
// `surfaceSunken` has the same history from the other end: it was
// explorerRowEven, a zebra stripe, and every consumer bar one was using it as
// "one step below the ground" for a whole pane. It's a rung on the ladder,
// not a row colour, so it gets a rung's name.
class ColourScheme {
const ColourScheme({
required this.brightness,
required this.background,
required this.foreground,
required this.card,
required this.popover,
required this.popoverBorder,
required this.tooltipBackground,
required this.tooltipBorder,
required this.primary,
required this.primaryHovered,
required this.primaryForeground,
required this.secondary,
required this.secondaryHovered,
required this.secondaryForeground,
required this.muted,
required this.mutedForeground,
required this.destructive,
required this.border,
required this.divider,
required this.surfaceSunken,
required this.controlFill,
required this.controlFillHovered,
required this.controlFillFocused,
required this.controlBorder,
required this.switchTrackInactive,
required this.ring,
required this.chart1,
required this.chart2,
required this.chart3,
required this.chart4,
required this.chart5,
required this.chrome,
required this.panelBorder,
required this.panelBorderHighlighted,
required this.rowHovered,
required this.rowText,
required this.popoverItemHovered,
required this.propertiesSectionBorder,
});
/// Builds a complete scheme from the handful of colours an app actually
/// chooses, deriving the rest.
///
/// ── How the derivation works ──────────────────────────────────────────
///
/// This used to be a table of HSL lightness offsets. HSL lightness is not
/// perceptually uniform and, worse, an offset that runs off the end of the
/// scale was *reflected*: it kept its size and lost only its direction. On
/// a ground already near the floor that turned "recess this by twelve" into
/// "raise it by twelve", and A&As carbon ended up with a canvas backdrop
/// LIGHTER than the paper sat in front of it.
///
/// Measuring the ten hand authored schemes in CIE L* showed what they
/// actually have in common, and its four rules rather than one table:
///
/// 1. Raised rungs are an absolute L* step off the ground. Zinc and carbon
/// agree here to within 1-3 points despite sitting sixteen points apart,
/// so this part was never the problem.
///
/// 2. Recessed rungs are the same idea until the ground runs out of room
/// underneath, and then they all squeeze by the same factor. Zinc has
/// 19.9 L* of basement and spends 12.6 of it on chrome. Carbon has 3.6
/// and spends all of it. Squeezing keeps their ORDER, which is the thing
/// that actually matters - reflecting destroyed it.
///
/// 3. A stroke is measured against the surface it outlines, not against the
/// ground. Every scheme puts its popover border about ten points over
/// its popover; none of them puts it ten points over the background.
/// (This is the fix that landed for propertiesSectionBorder alone in
/// 38a206f, generalised - it was never a one slot problem.)
///
/// 4. Text is a fraction of the background->foreground span, not an
/// absolute shift. mutedForeground is 60.7% of the way in zinc and
/// 60.7% in carbon, to one decimal, on spans of 71 and 92 points.
///
/// Anything you want to pin exactly is still an argument - every derived
/// slot has an override. Hand authored schemes keep using the const
/// constructor and are untouched.
factory ColourScheme.derive({
required Brightness brightness,
required Color background,
required Color foreground,
required Color primary,
Color? primaryForeground,
Color? destructive,
Color? ring,
// --- escape hatches: pin any derived slot ---
Color? chrome,
Color? card,
Color? popover,
Color? muted,
Color? tooltipBackground,
Color? secondary,
Color? border,
Color? surfaceSunken,
Color? controlFill,
Color? controlFillHovered,
Color? controlFillFocused,
Color? controlBorder,
Color? switchTrackInactive,
Color? mutedForeground,
Color? rowText,
Color? chart1,
Color? chart2,
Color? chart3,
Color? chart4,
Color? chart5,
}) {
final groundL = _lstar(background);
final span = _lstar(foreground) - groundL;
// How much of what the recessed rungs WANT this ground can actually give
// them. chrome is the deepest slot the shared scheme has (the canvas
// backdrop goes deeper, but thats an app side slot now), so it sets the
// scale: if theres room for it nothing squeezes at all.
final basement = brightness == Brightness.dark
? groundL
: math.max(groundL, _deepestSink);
final squeeze = basement >= _deepestSink ? 1.0 : basement / _deepestSink;
Color raise(double points) => _atLstar(background, groundL + points);
Color sink(double points) =>
_atLstar(background, groundL - points * squeeze);
// a stroke sits N points off the surface it outlines. reflects if theres
// no headroom that way - a border that cant get lighter than its own fill
// gets darker by the same amount, which is what youd have picked anyway.
Color edge(Color surface, double points) {
final l = _lstar(surface);
final wanted = l + points;
return _atLstar(
surface,
(wanted > 100 || wanted < 0) ? l - points : wanted,
);
}
Color text(double fraction) =>
_atLstar(foreground, groundL + span * fraction);
final resolvedChrome = chrome ?? sink(12.6);
final resolvedCard = card ?? raise(5.9);
final resolvedPopover = popover ?? sink(11.6);
final resolvedMuted = muted ?? sink(9.1);
final resolvedFill = controlFill ?? sink(9.1);
final resolvedTooltip = tooltipBackground ?? sink(9.1);
return ColourScheme(
brightness: brightness,
background: background,
foreground: foreground,
card: resolvedCard,
popover: resolvedPopover,
popoverBorder: edge(resolvedPopover, 9.9),
tooltipBackground: resolvedTooltip,
tooltipBorder: edge(resolvedTooltip, 9.1),
primary: primary,
// +11 L*, CLAMPED rather than reflected. A stroke that cant get lighter
// than its surface has to go the other way or it disappears, but a hover
// has no such problem - it just wants to be brighter, and a near white
// primary should hover to white, not turn round and dim to grey.
primaryHovered: _atLstar(primary, math.min(100, _lstar(primary) + 11.0)),
primaryForeground: primaryForeground ?? _contrastColor(primary),
secondary: secondary ?? raise(16.3),
secondaryHovered: raise(23.9),
// nine of the ten hand authored schemes put this exactly on foreground.
secondaryForeground: foreground,
muted: resolvedMuted,
mutedForeground: mutedForeground ?? text(0.607),
destructive:
destructive ??
(brightness == Brightness.dark
? const Color(0xffa9575f)
: const Color(0xffb3474f)),
// both reference schemes put the outermost line on the floor with
// chrome - its the gutter between panels, not a stroke on a surface.
border: border ?? resolvedChrome,
// 4.1 points off the ground is a line you have to go looking for. The
// shell has drawn its own rules with panelBorder (9.6) since forever
// and nobody has ever called those loud, so a divider sits just under
// that - visible, still subordinate to the edge of a panel.
divider: edge(background, 9.0),
surfaceSunken: surfaceSunken ?? sink(3.8),
controlFill: resolvedFill,
controlFillHovered: controlFillHovered ?? sink(6.5),
controlFillFocused: controlFillFocused ?? sink(11.0),
controlBorder: controlBorder ?? edge(resolvedFill, 11.8),
// the off state track of a Switch. was called `input`, which told you
// nothing and collided with muted in every derived scheme.
switchTrackInactive: switchTrackInactive ?? resolvedMuted,
ring: ring ?? _brightenForSelectionRing(primary),
chart1: chart1 ?? const Color(0xffff3352),
chart2: chart2 ?? const Color(0xff8bdc00),
chart3: chart3 ?? const Color(0xff2890ff),
chart4: chart4 ?? const Color(0xffedba18),
chart5: chart5 ?? const Color(0xffed5700),
chrome: resolvedChrome,
panelBorder: edge(background, 9.6),
panelBorderHighlighted: raise(20.8),
rowHovered: raise(10.0),
rowText: rowText ?? text(0.814),
popoverItemHovered: raise(8.0),
// +6.2 over `card`, which is what a properties section is filled with -
// NOT off the ground. Measured off the background it sat under a point
// above its own fill and vanished into it.
propertiesSectionBorder: edge(resolvedCard, 6.2),
);
}
final Brightness brightness;
final Color background;
final Color foreground;
/// Raised surface. Cards, and the fill behind a properties section.
final Color card;
final Color popover;
final Color popoverBorder;
// tooltips get their own pair rather than riding popover's - they sit on
// top of everything and want more contrast than a panel-level surface.
final Color tooltipBackground;
final Color tooltipBorder;
final Color primary;
final Color primaryHovered;
final Color primaryForeground;
final Color secondary;
// secondary fill, hovered. used to be one hardcoded grey duplicated in
// button.dart and select.dart, which meant the crimson/light schemes both
// hovered to the same dark grey.
final Color secondaryHovered;
final Color secondaryForeground;
final Color muted;
final Color mutedForeground;
final Color destructive;
final Color border;
final Color divider;
/// One step below the ground. Side panes, list backgrounds, anything
/// recessed into the surface its sat on rather than lifted off it.
final Color surfaceSunken;
// The control family: the fill and stroke every control shares - text
// fields, selects, date inputs, outline and ghost buttons, checkboxes,
// radios, the colour swatch. NOT text-field-only, whatever the old names
// claimed.
final Color controlFill;
final Color controlFillHovered;
final Color controlFillFocused;
final Color controlBorder;
/// A Switch's track while its off. Its own slot because nothing else wants
/// this colour and it used to squat on `input`.
final Color switchTrackInactive;
final Color ring;
final Color chart1;
final Color chart2;
final Color chart3;
final Color chart4;
final Color chart5;
// header/footer chrome, nudged off background so it reads as chrome.
final Color chrome;
// borders of a content panel. The panel FILL is just `background` - all ten
// hand authored schemes had them identical, so theres no slot for it.
final Color panelBorder;
final Color panelBorderHighlighted;
/// Hovered row, in a list or a menu. One slot: menuItemHovered and
/// explorerRowHovered were the same colour in all ten schemes.
final Color rowHovered;
/// Resting label/icon colour for a row. Was explorerRowText, and
/// propertiesSectionLabel was the same colour in all ten schemes.
final Color rowText;
// Interactive rows inside popovers. Kept separate from row hover while the
// two interaction colours are being evaluated.
final Color popoverItemHovered;
/// Outline of an object properties section. Measured off `card`, which is
/// what fills one.
final Color propertiesSectionBorder;
ColourScheme copyWith({
Brightness? brightness,
Color? background,
Color? foreground,
Color? card,
Color? popover,
Color? popoverBorder,
Color? tooltipBackground,
Color? tooltipBorder,
Color? primary,
Color? primaryHovered,
Color? primaryForeground,
Color? secondary,
Color? secondaryHovered,
Color? secondaryForeground,
Color? muted,
Color? mutedForeground,
Color? destructive,
Color? border,
Color? divider,
Color? surfaceSunken,
Color? controlFill,
Color? controlFillHovered,
Color? controlFillFocused,
Color? controlBorder,
Color? switchTrackInactive,
Color? ring,
Color? chart1,
Color? chart2,
Color? chart3,
Color? chart4,
Color? chart5,
Color? chrome,
Color? panelBorder,
Color? panelBorderHighlighted,
Color? rowHovered,
Color? rowText,
Color? popoverItemHovered,
Color? propertiesSectionBorder,
}) {
return ColourScheme(
brightness: brightness ?? this.brightness,
background: background ?? this.background,
foreground: foreground ?? this.foreground,
card: card ?? this.card,
popover: popover ?? this.popover,
popoverBorder: popoverBorder ?? this.popoverBorder,
tooltipBackground: tooltipBackground ?? this.tooltipBackground,
tooltipBorder: tooltipBorder ?? this.tooltipBorder,
primary: primary ?? this.primary,
primaryHovered: primaryHovered ?? this.primaryHovered,
primaryForeground: primaryForeground ?? this.primaryForeground,
secondary: secondary ?? this.secondary,
secondaryHovered: secondaryHovered ?? this.secondaryHovered,
secondaryForeground: secondaryForeground ?? this.secondaryForeground,
muted: muted ?? this.muted,
mutedForeground: mutedForeground ?? this.mutedForeground,
destructive: destructive ?? this.destructive,
border: border ?? this.border,
divider: divider ?? this.divider,
surfaceSunken: surfaceSunken ?? this.surfaceSunken,
controlFill: controlFill ?? this.controlFill,
controlFillHovered: controlFillHovered ?? this.controlFillHovered,
controlFillFocused: controlFillFocused ?? this.controlFillFocused,
controlBorder: controlBorder ?? this.controlBorder,
switchTrackInactive: switchTrackInactive ?? this.switchTrackInactive,
ring: ring ?? this.ring,
chart1: chart1 ?? this.chart1,
chart2: chart2 ?? this.chart2,
chart3: chart3 ?? this.chart3,
chart4: chart4 ?? this.chart4,
chart5: chart5 ?? this.chart5,
chrome: chrome ?? this.chrome,
panelBorder: panelBorder ?? this.panelBorder,
panelBorderHighlighted:
panelBorderHighlighted ?? this.panelBorderHighlighted,
rowHovered: rowHovered ?? this.rowHovered,
rowText: rowText ?? this.rowText,
popoverItemHovered: popoverItemHovered ?? this.popoverItemHovered,
propertiesSectionBorder:
propertiesSectionBorder ?? this.propertiesSectionBorder,
);
}
// Accent override - swaps primary/ring to the given accent colour, and picks a
// readable foreground for it. Mirrors what shadcns recolor() did. Pass null
// (the "none" accent) to leave the scheme untouched.
ColourScheme withAccent(Color? accent) {
if (accent == null) return this;
return copyWith(
primary: accent,
primaryHovered: accent,
primaryForeground: _contrastColor(accent),
ring: accent,
);
}
static ColourScheme lerp(ColourScheme a, ColourScheme b, double t) {
if (t <= 0) return a;
if (t >= 1) return b;
Color c(Color x, Color y) => Color.lerp(x, y, t)!;
return ColourScheme(
brightness: t < 0.5 ? a.brightness : b.brightness,
background: c(a.background, b.background),
foreground: c(a.foreground, b.foreground),
card: c(a.card, b.card),
popover: c(a.popover, b.popover),
popoverBorder: c(a.popoverBorder, b.popoverBorder),
tooltipBackground: c(a.tooltipBackground, b.tooltipBackground),
tooltipBorder: c(a.tooltipBorder, b.tooltipBorder),
primary: c(a.primary, b.primary),
primaryHovered: c(a.primaryHovered, b.primaryHovered),
primaryForeground: c(a.primaryForeground, b.primaryForeground),
secondary: c(a.secondary, b.secondary),
secondaryHovered: c(a.secondaryHovered, b.secondaryHovered),
secondaryForeground: c(a.secondaryForeground, b.secondaryForeground),
muted: c(a.muted, b.muted),
mutedForeground: c(a.mutedForeground, b.mutedForeground),
destructive: c(a.destructive, b.destructive),
border: c(a.border, b.border),
divider: c(a.divider, b.divider),
surfaceSunken: c(a.surfaceSunken, b.surfaceSunken),
controlFill: c(a.controlFill, b.controlFill),
controlFillHovered: c(a.controlFillHovered, b.controlFillHovered),
controlFillFocused: c(a.controlFillFocused, b.controlFillFocused),
controlBorder: c(a.controlBorder, b.controlBorder),
switchTrackInactive: c(a.switchTrackInactive, b.switchTrackInactive),
ring: c(a.ring, b.ring),
chart1: c(a.chart1, b.chart1),
chart2: c(a.chart2, b.chart2),
chart3: c(a.chart3, b.chart3),
chart4: c(a.chart4, b.chart4),
chart5: c(a.chart5, b.chart5),
chrome: c(a.chrome, b.chrome),
panelBorder: c(a.panelBorder, b.panelBorder),
panelBorderHighlighted: c(
a.panelBorderHighlighted,
b.panelBorderHighlighted,
),
rowHovered: c(a.rowHovered, b.rowHovered),
rowText: c(a.rowText, b.rowText),
popoverItemHovered: c(a.popoverItemHovered, b.popoverItemHovered),
propertiesSectionBorder: c(
a.propertiesSectionBorder,
b.propertiesSectionBorder,
),
);
}
}
/// [base]'s lightness in CIE L*, 0 (black) to 100 (white).
double lstarOf(Color base) => _lstar(base);
/// [base] moved [points] in CIE L*, keeping its hue and saturation.
///
/// Reflects rather than clamps when theres no room that way: a colour that
/// cant get [points] lighter gets [points] darker instead, keeping the size of
/// the step and losing only its direction. A clamp doesnt shorten a step, it
/// deletes it - two slots asking for +6 and +11 against a near-white ground
/// both land on white and a distinction that exists in every other scheme is
/// gone.
///
/// This is the same step [ColourScheme.derive] is built out of, exposed so an
/// app deriving extra slots of its own (Arcs & Angles' canvas colours) walks
/// the identical ladder rather than reinventing a near-miss of it.
Color shiftLstar(Color base, double points) {
final l = _lstar(base);
final wanted = l + points;
return _atLstar(base, (wanted > 100 || wanted < 0) ? l - points : wanted);
}
// chrome is the deepest recessed slot the shared scheme has, so its want sets
// the squeeze factor for every other one.
const double _deepestSink = 12.6;
// ── CIE L* ───────────────────────────────────────────────────────────────
// The offsets above are all in L*, which is perceptually uniform - five
// points looks like the same step whether youre near black or near white.
// HSL lightness, which this used to use, very much does not.
double _channel(double v) =>
v <= 0.04045 ? v / 12.92 : math.pow((v + 0.055) / 1.055, 2.4).toDouble();
double _lstar(Color c) {
final y =
0.2126 * _channel(c.r) + 0.7152 * _channel(c.g) + 0.0722 * _channel(c.b);
return y > 0.008856 ? 116 * math.pow(y, 1 / 3).toDouble() - 16 : 903.3 * y;
}
// [base]'s hue and saturation at the given L*.
//
// Theres no closed form for this that keeps HSL saturation fixed, so it
// bisects on HSL lightness instead - which is fine, its monotonic in
// luminance and this runs once per scheme at startup, not per frame.
Color _atLstar(Color base, double target) {
final hsl = HSLColor.fromColor(base);
final want = target.clamp(0.0, 100.0);
var lo = 0.0;
var hi = 1.0;
for (var i = 0; i < 18; i++) {
final mid = (lo + hi) / 2;
if (_lstar(hsl.withLightness(mid).toColor()) < want) {
lo = mid;
} else {
hi = mid;
}
}
return hsl.withLightness((lo + hi) / 2).toColor();
}
// flips lightness to get a readable foreground on a given colour. same idea as
// shadcns getContrastColor (full luminance contrast).
Color _contrastColor(Color on) {
final hsl = HSLColor.fromColor(on);
final l = hsl.lightness;
final target = l >= 0.5 ? 0.0 : 1.0;
// nudge toward the target rather than pure black/white so it doesnt look harsh
final mixed = lerpDouble(l, target, 1.0)!;
return hsl.withLightness(mixed.clamp(0.0, 1.0)).toColor();
}
// a focus ring wants to read punchier than a resting "primary" swatch -
// lighter and more saturated, so it pops against whatevers behind it instead
// of just matching a button colour.
Color _brightenForSelectionRing(Color colour) {
final hsl = HSLColor.fromColor(colour);
return hsl
.withSaturation((hsl.saturation + 0.16).clamp(0.0, 1.0))
.withLightness((hsl.lightness + 0.13).clamp(0.0, 0.78))
.toColor();
}
+90
View File
@@ -0,0 +1,90 @@
import "package:flutter/widgets.dart";
import "package:google_fonts/google_fonts.dart";
import "package:garage_ui/theme/theme_data.dart";
export "package:garage_ui/theme/theme_data.dart";
export "package:garage_ui/theme/colour_scheme.dart";
export "package:garage_ui/theme/typography.dart";
// The apps theme. Hands the theme data down via a plain inherited scope (no
// animated wrapper, no per frame ColorScheme.lerp flattening the whole thing -
// this is the bit that used to feel second class), AND establishes a sane
// DefaultTextStyle + IconTheme so bare Text/Icon dont fall back to flutter's
// yellow-underline "unstyled" debug look (MaterialApp only styles text inside a
// Material, and our panels arent Material).
class GarageTheme extends StatelessWidget {
const GarageTheme({super.key, required this.data, required this.child});
final ThemeData data;
final Widget child;
static ThemeData of(BuildContext context) {
final t = maybeOf(context);
assert(
t != null,
"No GarageTheme found in context. Wrap the app in an GarageTheme.",
);
return t!;
}
static ThemeData? maybeOf(BuildContext context) {
return context
.dependOnInheritedWidgetOfExactType<_GarageThemeScope>()
?.data;
}
@override
Widget build(BuildContext context) {
final cs = data.colorScheme;
return _GarageThemeScope(
data: data,
child: DefaultTextStyle(
// the one app-wide UI font, declared right here at the top of the tree.
// everything below that doesnt override fontFamily inherits Geist.
// (canvas-painted text - station labels, watermark - sits outside the
// widget tree so it isnt touched by this.)
style: GoogleFonts.geist(
color: cs.foreground,
// off the density, not a literal times scaling. this was the third
// independent source of text size in the package - controls read
// density.fontSize, the .xSmall()/.small()/.large() ladder now reads
// it too, and this floated free at 11 * scaling. Retune fontSize and
// page copy no longer stays behind while the controls move.
//
// fontSize, NOT textXs. The ladder above the control font is a set
// of MULTIPLIERS (xs 1.1x, sm 1.4x, lg 1.8x), so what textXs means
// depends on the tier: at A&A's 10 its 11, a hair over the control
// font and harmless, and at the product tier's 16 its 18 - body copy
// rendering BIGGER than the text inside the buttons and fields next
// to it. Thats backwards, and its what made the product surfaces
// read as oversized even with their control geometry correct.
//
// Body copy is the control font. A sentence and the text in the box
// under it are the same size, at every tier, by construction.
fontSize: data.density.fontSize,
// the important bit - kills the yellow underline.
decoration: TextDecoration.none,
fontWeight: FontWeight.w300,
),
child: IconTheme(
data: IconThemeData(
color: cs.foreground,
size: data.iconTheme.medium.size,
),
child: child,
),
),
);
}
}
class _GarageThemeScope extends InheritedWidget {
const _GarageThemeScope({required this.data, required super.child});
final ThemeData data;
@override
bool updateShouldNotify(_GarageThemeScope oldWidget) =>
oldWidget.data != data;
}
+88
View File
@@ -0,0 +1,88 @@
import "dart:ui" show Color;
import "package:flutter/widgets.dart" show Brightness;
import "package:garage_ui/theme/colour_scheme.dart";
/// Ready-made schemes, so a new app can look like a Garage app on line one.
///
/// Standing one up used to mean authoring 54 colours by hand before writing
/// any app code. These are the neutral pair, built through
/// [ColourScheme.derive] from four colours each — which is also the worked
/// example of how to make your own.
///
/// ```dart
/// GarageApp.router(
/// theme: ThemeData(colorScheme: GarageSchemes.dark),
/// ...
/// )
/// ```
///
/// Want your own hue? Change [ColourScheme.derive]'s `primary` and leave the
/// rest. Want one slot exact? Every derived slot has an override.
abstract final class GarageSchemes {
/// The neutral dark scheme. Same core Arcs & Angles' zinc is built on.
static final ColourScheme dark = ColourScheme.derive(
brightness: Brightness.dark,
background: const Color(0xff303030),
foreground: const Color(0xffe6e6e6),
primary: const Color(0xff4772b3),
);
/// The neutral light scheme — the same blue on a faintly cool near-white.
static final ColourScheme light = ColourScheme.derive(
brightness: Brightness.light,
background: const Color(0xfff2f2f4),
foreground: const Color(0xff1a1a1c),
primary: const Color(0xff3a63a1),
);
/// Carbon. True black, no chroma at all - built for oled panels, and the
/// scheme the Garage apps actually run.
///
/// It lives here rather than in each app because it didnt: the hub and Arcs
/// & Angles each kept their own copy and they drifted apart in twenty five
/// of fifty two slots before anyone noticed. One definition cant.
///
/// Carbon has 3.6 L* of room under its ground, against zinc's 19.9, so every
/// recessed rung squeezes into what there is - which is why chrome lands on
/// the floor without being told to. The pins are where carbon wants
/// something other than what the ladder gives it.
static final ColourScheme carbon = ColourScheme.derive(
brightness: Brightness.dark,
background: const Color(0xff0d0d0d),
foreground: const Color(0xfff2f2f2),
// no accent hue anywhere - primary is just a near white fill
primary: const Color(0xffe8e8e8),
primaryForeground: const Color(0xff000000),
ring: const Color(0xffffffff),
destructive: const Color(0xffc43333),
// the ladder puts these a shade off the floor. carbon wants the floor -
// the gutter between panels is the whole look.
popover: const Color(0xff000000),
controlFillFocused: const Color(0xff000000),
// above the ground rather than below it, which is what carbon has always
// done with muted. theres barely any "below" left to use.
muted: const Color(0xff0f0f0f),
controlFill: const Color(0xff080808),
tooltipBackground: const Color(0xff080808),
// NOT the ladder's +11.8 over the fill. Carbon cant recess a field far
// enough to read as recessed, so the stroke does all the separating and
// has to be the strongest thing on the control, not the weakest.
controlBorder: const Color(0xff2a2a2a),
chart1: const Color(0xfff45b69),
chart2: const Color(0xff86d957),
chart3: const Color(0xff62a7ff),
chart4: const Color(0xffffc857),
chart5: const Color(0xffff8c42),
);
/// Whichever of the pair matches [brightness].
static ColourScheme of(Brightness brightness) =>
brightness == Brightness.dark ? dark : light;
}
+169
View File
@@ -0,0 +1,169 @@
import "dart:ui" show Color, ImageFilter;
import "package:flutter/painting.dart" show HSLColor;
import "package:flutter/widgets.dart";
import "package:garage_ui/theme/garage_theme.dart";
// small pile of helpers the GarageUI widgets used to pull off the shadcn barrel.
// theyre generic, nothing shadcn specific, so we just keep our own copies.
// shadcns Color helpers that the widgets lean on.
extension ColorExtension on Color {
// scale the alpha channel by [factor] (0..1). shadcns scaleAlpha.
Color scaleAlpha(double factor) {
return withValues(alpha: (a * factor).clamp(0.0, 1.0));
}
// a readable foreground for this colour - flips lightness. shadcns getContrastColor.
Color getContrastColor([double luminanceContrast = 1]) {
final hsl = HSLColor.fromColor(this);
final l = hsl.lightness;
final target = l >= 0.5
? l - (l * luminanceContrast)
: l + ((1 - l) * luminanceContrast);
return hsl.withLightness(target.clamp(0.0, 1.0)).toColor();
}
}
// default anim duration shadcn used all over (hover/colour transitions etc).
const Duration kDefaultDuration = Duration(milliseconds: 150);
// widget value wins, then theme value, then the hard default. shadcns styleValue.
T styleValue<T>({T? widgetValue, T? themeValue, required T defaultValue}) {
return widgetValue ?? themeValue ?? defaultValue;
}
// we dropped shadcns density-insets system, so this is now just a passthrough -
// the GarageUI widgets only ever hand it plain EdgeInsets anyway.
EdgeInsetsGeometry resolveEdgeInsets(
EdgeInsetsGeometry padding,
double basePadding,
) {
return padding;
}
// shrinks a border radius by the border width so an inner surface tucks neatly
// inside its border. shadcns subtractByBorder.
BorderRadius subtractByBorder(BorderRadius radius, double borderWidth) {
Radius sub(Radius r) => Radius.elliptical(
(r.x - borderWidth).clamp(0.0, double.infinity),
(r.y - borderWidth).clamp(0.0, double.infinity),
);
return BorderRadius.only(
topLeft: sub(radius.topLeft),
topRight: sub(radius.topRight),
bottomLeft: sub(radius.bottomLeft),
bottomRight: sub(radius.bottomRight),
);
}
// used to be a density-aware Padding in shadcn. our padding is already resolved
// by the time it gets here, so this is just a Padding.
class DensityContainerPadding extends StatelessWidget {
const DensityContainerPadding({
super.key,
required this.padding,
required this.child,
});
final EdgeInsetsGeometry padding;
final Widget child;
@override
Widget build(BuildContext context) => Padding(padding: padding, child: child);
}
// shadcn used this to tell whether a surface was being rendered inside a sheet
// so it could drop its own rounding/border. the app doesnt use sheets, so its
// always false.
class SheetOverlayHandler {
const SheetOverlayHandler._();
static bool isSheetOverlay(BuildContext context) => false;
}
// draws a focus ring around a child when [focused]. shadcns FocusOutline, cut
// down to what the text field needs.
class FocusOutline extends StatelessWidget {
const FocusOutline({
super.key,
required this.child,
required this.focused,
this.borderRadius,
this.color,
this.width = 1.0,
});
final Widget child;
final bool focused;
final BorderRadiusGeometry? borderRadius;
final Color? color;
final double width;
@override
Widget build(BuildContext context) {
if (!focused) return child;
final ring = color ?? GarageTheme.of(context).colorScheme.ring;
return Container(
decoration: BoxDecoration(
borderRadius: borderRadius,
// draw the ring OUTSIDE the box so it doesnt inset the child and
// change the field size when focus comes and goes.
border: Border.all(
color: ring,
width: width,
strokeAlign: BorderSide.strokeAlignOutside,
),
),
child: child,
);
}
}
// blurs whatever is behind the child (glassmorphism on popovers/cards). ported
// straight from shadcns outlined_container.dart.
class SurfaceBlur extends StatefulWidget {
const SurfaceBlur({
super.key,
required this.child,
this.surfaceBlur,
this.borderRadius,
});
final Widget child;
final double? surfaceBlur;
final BorderRadiusGeometry? borderRadius;
@override
State<SurfaceBlur> createState() => _SurfaceBlurState();
}
class _SurfaceBlurState extends State<SurfaceBlur> {
final GlobalKey _mainContainerKey = GlobalKey();
@override
Widget build(BuildContext context) {
if (widget.surfaceBlur == null || widget.surfaceBlur! <= 0) {
return KeyedSubtree(key: _mainContainerKey, child: widget.child);
}
return Stack(
fit: StackFit.passthrough,
children: [
Positioned.fill(
child: ClipRRect(
borderRadius: widget.borderRadius ?? BorderRadius.zero,
child: BackdropFilter(
filter: ImageFilter.blur(
sigmaX: widget.surfaceBlur!,
sigmaY: widget.surfaceBlur!,
),
// needs a child or it wont actually blur anything
child: const SizedBox(),
),
),
),
KeyedSubtree(key: _mainContainerKey, child: widget.child),
],
);
}
}
+573
View File
@@ -0,0 +1,573 @@
import "package:flutter/foundation.dart"
show TargetPlatform, defaultTargetPlatform;
import "package:flutter/widgets.dart";
import "package:garage_ui/theme/colour_scheme.dart";
import "package:garage_ui/theme/typography.dart";
/// The two control tiers. One type for both levels: hand it to [ThemeData] to
/// set the app-wide density, or to any control to override that control.
///
/// Replaces the pair this used to be - a `DensityMode` enum on the theme and a
/// separate `ControlDensity` class on widgets - which modelled the same two
/// values twice, in two shapes, and named the widget-level one after a single
/// widget despite Select, Menubar, MenuButton, MenuPopup, TextField and
/// TabView all taking it.
enum ControlDensity {
compact,
normal;
bool get isCompact => this == ControlDensity.compact;
/// The canonical token set for this tier, ignoring any theme tuning.
Density get canonical =>
isCompact ? const Density.compact() : const Density.normal();
/// The tier the theme is currently set to.
static ControlDensity of(ThemeData theme) => theme.density.control;
/// The token set a control on this tier should read.
///
/// When the tier matches the theme's, the theme's own [Density] is used, so
/// a tuned one survives. When a control asks for the other tier, it falls
/// back to that tier's canonical tokens.
Density tokens(ThemeData theme) =>
this == theme.density.control ? theme.density : canonical;
EdgeInsets resolve(ThemeData theme) => tokens(theme).buttonPadding;
/// Padding for an icon-only control, collapsed to its smallest side so the
/// control comes out square rather than inheriting the asymmetric
/// horizontal padding.
EdgeInsets resolveIcon(ThemeData theme) => _squarePadding(resolve(theme));
}
EdgeInsets _squarePadding(EdgeInsets p) {
final side = p.vertical < p.horizontal ? p.vertical / 2 : p.horizontal / 2;
return EdgeInsets.all(side);
}
/// The app-wide UI density.
///
/// ONE RULE: the fields on this class are the only hand-set numbers in the
/// control system. Everything else - line box, icon size, every vertical
/// padding, every control height - is a getter derived from them. If you find
/// yourself typing a pixel height anywhere else, it belongs here instead.
///
/// The heights are the configurable part. You say how tall a control should be
/// and the padding falls out of it:
///
/// lineBox = fontSize * lineHeight
/// controlPaddingY = (controlHeight - lineBox) / 2
///
/// NOT the other way round. Tuning padding until a height came out right is
/// what produced four different control heights, a `Transform.translate` in two
/// widgets, and 23/27 re-typed as literals in the explorer.
///
/// `lineBox` needs no font metrics: when a TextStyle carries an explicit
/// `height`, Flutter sizes the line box to exactly `fontSize * height` and does
/// not consult the font. That is what makes this whole chain deterministic and
/// testable - a style with a null `height` measures 1.0x in the test host and
/// ~1.25x in the app, which is how a ~26px TextField once passed a suite of
/// tests that all asserted 23.
class Density {
const Density({
this.control = ControlDensity.compact,
// ---- type ----
this.fontSize = 10.0,
this.lineHeight = 1.1,
// ---- height targets (configure these; paddings derive from them) ----
this.controlHeight = 23.0,
this.menuRowHeight = 20.0,
this.popupRowHeight = 20.0,
this.popupMaxRows = 12,
this.explorerRowHeight = 22.0,
// ---- horizontal + misc primitives ----
this.buttonPaddingX = 10.0,
this.controlGap = 4.0,
this.controlBorderWidth = 1.0,
this.explorerRowBasePadding = 4.0,
this.explorerRowEndPadding = 10.0,
this.listRowIndent = 6.0,
// ---- container-level spacing (panels, popovers, dialogs) ----
this.containerGap = 8.0,
this.containerPadding = 16.0,
// ---- optical ----
this.controlTextOffset = 0.0,
});
/// The compact tier, said out loud. Same as the unnamed constructor's
/// defaults - which is exactly why it exists, because `Density()` silently
/// meaning "compact" was a trap.
const Density.compact() : this();
const Density.normal()
: control = ControlDensity.normal,
fontSize = 10.0,
lineHeight = 1.1,
controlHeight = 27.0,
menuRowHeight = 26.0,
popupRowHeight = 26.0,
popupMaxRows = 12,
explorerRowHeight = 30.0,
buttonPaddingX = 12.0,
controlGap = 5.0,
controlBorderWidth = 1.0,
explorerRowBasePadding = 8.0,
explorerRowEndPadding = 14.0,
listRowIndent = 12.0,
containerGap = 10.0,
containerPadding = 20.0,
controlTextOffset = 0.0;
/// The product tier - for the Garage web apps rather than for A&A.
///
/// compact and normal are both answers to "a properties inspector has to sit
/// beside a viewport without eating it", which is why they share a 10px font
/// and differ only in control geometry. An app with no canvas inherits that
/// economy for nothing, so this tier scales the TYPE as well: 12px control
/// font, 33px controls, and a gap ladder off a base of 5.
///
/// `control` stays [ControlDensity.normal] deliberately. That enum is the
/// two-way geometry switch widgets branch on (`isCompact`), not a name for
/// the tier, and A&A switches over it exhaustively in its settings UI.
///
/// 12 * 1.25 = 15 line box, (33 - 15) / 2 = 9 padding. both whole, so the
/// control geometry stays exact - see [lineBox].
const Density.product()
: control = ControlDensity.normal,
fontSize = 12.0,
lineHeight = 1.25,
controlHeight = 33.0,
menuRowHeight = 33.0,
popupRowHeight = 33.0,
popupMaxRows = 10,
explorerRowHeight = 29.0,
buttonPaddingX = 10.0,
controlGap = 5.0,
controlBorderWidth = 1.0,
explorerRowBasePadding = 8.0,
explorerRowEndPadding = 12.0,
listRowIndent = 10.0,
containerGap = 10.0,
containerPadding = 20.0,
controlTextOffset = 0.0;
/// Which tier this token set represents.
final ControlDensity control;
// ---- type ----
/// Control font size. The app is a fixed compact desktop tool, so these are
/// final pixel values and are deliberately NOT multiplied by `scaling`.
final double fontSize;
/// Line box as a multiple of [fontSize]. Pinning this is what makes control
/// geometry font-independent - see the class doc.
final double lineHeight;
// ---- height targets ----
/// Button / text field / select trigger / icon button.
final double controlHeight;
/// Menu rows (MenuButton and friends).
///
/// Happens to equal [popupRowHeight] in both densities today - menus and
/// select popups are the same kind of surface. Kept as two tokens anyway so
/// one can move without dragging the other along.
final double menuRowHeight;
/// Rows inside a select popup.
final double popupRowHeight;
/// How many rows a select popup shows before it starts scrolling. A count,
/// not a pixel value - the height falls out of it via [popupMaxHeight], so a
/// denser popup gets shorter rather than showing more of them.
final int popupMaxRows;
/// Explorer tree rows.
final double explorerRowHeight;
// ---- horizontal + misc ----
final double buttonPaddingX;
/// Gap between a control's leading/trailing icon and its label. Sits INSIDE
/// the control, so it is tighter than the outer padding on purpose.
final double controlGap;
final double controlBorderWidth;
/// Leading indent applied per depth level in the explorer tree.
final double explorerRowBasePadding;
/// Trailing inset on an explorer row, so the hide toggle isnt sat right
/// under the scrollbar thumb.
final double explorerRowEndPadding;
/// Leading inset on a flat list row, before its icon. Distinct from
/// [explorerRowBasePadding], which is a per-depth indent in a tree.
///
/// Authored from the values the Lines slots list was already branching on by
/// hand. It and the explorer disagree about this inset (6/12 vs 4/8) and
/// always have - reconciling them is a visual decision, not a refactor.
final double listRowIndent;
// ---- container-level ----
/// Spacing between elements in a panel / popover / dialog. This is layout
/// spacing, NOT control-internal spacing - reach for [controlGap] inside a
/// control. (Replaces the old shadcn-inherited `baseGap`.)
final double containerGap;
/// Padding inside a panel / popover / dialog. (Replaces `baseContentPadding`
/// and `baseContainerPadding`, which always held the same value.)
final double containerPadding;
// ---- optical ----
/// Downward nudge applied to control content so it sits on its optical
/// centre rather than its geometric one. Negative moves it up.
///
/// This is the one value the maths cannot settle on its own - the line box is
/// exact, but where the ink sits inside it depends on the font's
/// ascent/descent split. So it is a judgement made by eye, once, here. It
/// used to be `Offset(0, 1)` hardcoded in Button and `Offset(0, 2)` in
/// Select, which is two judgements that disagreed.
///
/// Currently 0 - i.e. the geometric centre is what looks right in Geist at
/// this size. Keep the token even so: it is the knob, and a zero here costs
/// nothing because the controls skip the transform entirely when it is 0.
final double controlTextOffset;
// =========================================================================
// derived - do not hand-set any of these, and do not re-derive them at a
// call site
// =========================================================================
/// Height of one line of control text: `fontSize * lineHeight`, rounded to a
/// whole pixel.
///
/// Measured, not assumed. Against Georgia and Andale Mono (both natural ratio
/// 1.10) and the test host's fallback (1.00): when the product is a whole
/// number the engine lays the line box out at exactly that in all three, so
/// the font's own ascent/descent genuinely do not participate. That is what
/// makes control geometry font-independent and testable.
///
/// When the product is fractional the engine rounds it, and at a .5 tie the
/// direction is FONT-DEPENDENT (13.5 came out 14 in Andale, 13 in Georgia).
/// So keep `fontSize * lineHeight` on a whole number - 10 * 1.1 = 11 does -
/// and every height below is exact. The rounding here keeps the derivation
/// honest for other configs rather than quietly missing the target by a
/// fraction of a pixel.
double get lineBox => (fontSize * lineHeight).roundToDouble();
/// Control icons, sized against the TEXT rather than the line box.
///
/// This used to be `lineBox` - 15px at product - on the reasoning that an
/// icon-only control and a text control then come out the same height for
/// free. That holds, but it isnt what the eye measures: lucide glyphs fill
/// their box nearly edge to edge while a 12px font has a cap height around
/// 8.5px, so a line-box icon reads about 70% taller than the letters beside
/// it and every button with an icon in it looked slightly wrong.
///
/// Level with the font. 1.1x was the first attempt at the optical match and
/// still read a shade heavy next to the label beside it. Control HEIGHT is
/// unaffected either way: that comes from controlHeight, not from whats
/// inside.
double get iconSize => fontSize;
double get controlPaddingY => (controlHeight - lineBox) / 2;
double get menuRowPaddingY => (menuRowHeight - lineBox) / 2;
double get popupRowPaddingY => (popupRowHeight - lineBox) / 2;
double get explorerRowPaddingY => (explorerRowHeight - lineBox) / 2;
/// Height of a chrome bar - an editor's header or footer.
///
/// One [controlGap] above the control and one below, which is [gapMd] all
/// told. A bar sized any tighter than that isnt giving its contents a
/// margin so much as a haircut: the old hardcoded 30 left 1.5px over a
/// normal-tier control and would have been SHORTER than a product-tier one,
/// so a button in the header didnt fit the header.
///
/// Matching the vertical margin to the horizontal gap is the whole point -
/// a row of buttons then sits in an even field instead of one thats
/// generous side to side and tight top to bottom.
///
/// compact 23 + 8 = 31, normal 27 + 10 = 37, product 33 + 10 = 43
double get chromeBarHeight => controlHeight + gapMd;
/// Cap on a select popup's list. Counts rows only - the list's own padding
/// (and a search field, when there is one) sits on top, so the last row
/// clips slightly rather than landing flush. Thats deliberate: a half row
/// showing is the cheapest "theres more below" hint there is.
///
/// Was `kDefaultSelectMaxHeight = 240.0` in select.dart, hand-typed and then
/// multiplied by `scaling`, so it never moved with density at all.
double get popupMaxHeight => popupRowHeight * popupMaxRows;
/// Padding for a borderless control (ghost/primary button, etc).
EdgeInsets get buttonPadding => EdgeInsets.symmetric(
horizontal: buttonPaddingX,
vertical: controlPaddingY,
);
/// Padding for a bordered control. A BoxDecoration border adds its width to
/// the Container's layout, so the stroke comes out of the padding and the
/// outer height stays [controlHeight] either way.
EdgeInsets get borderedControlPadding => EdgeInsets.symmetric(
horizontal: buttonPaddingX - controlBorderWidth,
vertical: controlPaddingY - controlBorderWidth,
);
/// Text fields are bordered controls.
EdgeInsets get textFieldPadding => borderedControlPadding;
// ---- text ----
//
// The body text ladder. [fontSize] is the control font - what a button
// label, a field's text and a select trigger render at - and these are the
// sizes for text that ISN'T inside a control: dialog copy, headings, hints.
//
// Dialog copy reading a step larger than the button labels beneath it is
// deliberate - content and controls are different things. The bug was never
// that they differed, it's that they were UNLINKED: these were hardcoded
// 12/14/18 times `scaling`, while the control font comes off this class and
// is deliberately not scaled. So the intended 12-vs-10 held at scaling 1.0
// and drifted to 14.4-vs-10 at 1.2. The ratio moved with scaling, which is
// the actual defect.
//
// Deriving them from [fontSize] pins the ratio. At both canonical densities
// fontSize is 10, so these come out 12/14/18 - exactly the numbers they
// replaced, so nothing shifts at scaling 1.0.
/// The one step BELOW the control font - a caption sat under something,
/// not beside it. A property row's subtitle and description, and the same
/// tier anything else that explains a control rather than labelling it
/// should reach for.
///
/// This rung didn't exist. The ladder only ever went UP from [fontSize],
/// because in A&A the control font IS the smallest thing on screen - so a
/// page needing a caption had nowhere to go and hardcoded one. x0.875 lands
/// on 9 against a 10px control font, and 14 against a 16px one.
double get textXxs => (fontSize * 0.875).roundToDouble();
/// Content that sits near controls without being one - dialog copy, page
/// headings, hints.
///
/// x1.1, which lands on the ambient body size: 11 against a 10px control
/// font. It was x1.2 (12), matching the literal the shorthands used to
/// hardcode - but that put dialog copy and every page heading a fifth above
/// the controls beneath them and a pixel above ordinary body text, which
/// read as oversized rather than as hierarchy. Weight and colour carry the
/// emphasis instead.
double get textXs => (fontSize * 1.1).roundToDouble();
/// Geist's own line box, as a multiple of font size - hhea ascender 1005,
/// descender -295, lineGap 0 over a 1000 upem. The package hardcodes Geist
/// (see GarageTheme), and a label sets no explicit `height`, so this is the
/// ratio its line actually lays out at. Flutter rounds each line to a whole
/// pixel, which is why the derivations below round rather than ceil.
static const double geistLineRatio = 1.3;
/// Height of a labelled row's label column - the label on [textXs] with a
/// subtitle under it on [textXxs], both on the font's own line box.
///
/// Worth having as a token because it OUTGROWS [controlHeight] at the small
/// tiers: 26 against a 23px compact control. A row sized on controlHeight
/// alone therefore got its height from the label rather than from the
/// control, which is backwards and moves with the font.
double get labelColumnHeight =>
(textXs * geistLineRatio).roundToDouble() +
(textXxs * geistLineRatio).roundToDouble();
/// Minimum height of a [PropertyRow]. Clears whichever of the control and
/// the label column is taller, plus a step, so neither one is the thing
/// setting the row height and a row is the same height with or without a
/// subtitle.
double get propertyRowHeight =>
(labelColumnHeight > controlHeight ? labelColumnHeight : controlHeight) +
gapXxs;
/// Body copy that wants to read a step above the controls.
double get textSm => (fontSize * 1.4).roundToDouble();
/// Headings.
double get textLg => (fontSize * 1.8).roundToDouble();
// ---- layout spacing ----
//
// The gap scale. Layout spacing BETWEEN widgets - what a call site reaches
// for when it puts a `Gap` between two things. Distinct from [controlGap],
// which is spacing INSIDE a control, and which is the base unit here.
//
// Before this existed, call sites picked `Gap(n)` literals by eye. That was
// documented rather than derived, and the apps drifted off it - the two
// Garage web frontends between them had sixteen distinct gap values,
// including a 3, a 5 and ten 14s that no scale would have produced.
//
// The steps are multiples of [controlGap], which makes [gapMd] equal to
// [containerGap] and [gapXl] equal to [containerPadding] at both densities.
// That agreement isn't arranged, it's what those two tokens already were -
// which is the evidence the base unit is right.
//
// Rounded because the normal density's base is 5, and a 1.5x step off it
// lands on 7.5. A fractional gap isn't wrong the way a fractional line box
// is (nothing derives from it), but a whole pixel won't seam on a fractional
// device ratio, so it's free to keep them whole.
/// Hairline separation - a label sat directly above its value.
double get gapXxs => (controlGap * 0.5).roundToDouble();
/// Tight. Icon-adjacent, or items that read as one unit.
double get gapXs => controlGap;
/// Snug, between [gapXs] and the default.
double get gapSm => (controlGap * 1.5).roundToDouble();
/// The default. "These two things are related but distinct." When a call
/// site has no particular reason to pick another step, it wants this one.
double get gapMd => controlGap * 2;
/// Section-level: separates groups within a panel or form.
double get gapLg => controlGap * 3;
/// Between major blocks of a layout.
double get gapXl => controlGap * 4;
/// The largest step - page-level separation, above the panel scale.
double get gapXxl => controlGap * 6;
}
/// Icon sizes. `small` is the control icon and is derived from the density's
/// line box so it can never drift from the text beside it.
class IconThemeTokens {
const IconThemeTokens({
required this.small,
required this.medium,
required this.large,
});
final IconThemeData small;
final IconThemeData medium;
final IconThemeData large;
static IconThemeTokens forDensity(
Density density,
double scaling,
Color color,
) => IconThemeTokens(
small: IconThemeData(size: density.iconSize, color: color),
medium: IconThemeData(size: 20 * scaling, color: color),
large: IconThemeData(size: 24 * scaling, color: color),
);
}
// The apps first class theme data. Everything the widgets used to pull off
// shadcns ThemeData (colours, the global scaling, radius tokens, icon sizes,
// the optional surface glass) now lives here as our own thing.
class ThemeData {
ThemeData({
required this.colorScheme,
this.scaling = 1.0,
this.radius = 0.5,
this.surfaceOpacity,
this.surfaceBlur,
this.enableFeedback,
this.panelRadius = 10,
this.panelGap = 5,
Density density = const Density(),
Typography? typography,
IconThemeTokens? iconTheme,
}) : density = density,
// typography and icon sizes are derived from the density so the control
// type, the control icon and the control height all move together.
typography = typography ?? Typography.forDensity(density),
iconTheme =
iconTheme ??
IconThemeTokens.forDensity(density, scaling, colorScheme.foreground);
final ColourScheme colorScheme;
// chrome panel layout - corner radius of the docked panels, and the gap
// around + between them in the shell.
final double panelRadius;
final double panelGap;
final Typography typography;
final Density density;
// haptic/click feedback toggle. null = decide by platform (mobile on).
final bool? enableFeedback;
// used by controls that behave differently on touch platforms.
TargetPlatform get platform => defaultTargetPlatform;
// global ui scale. was shadcns AdaptiveScaling(0.75). widgets multiply their
// paddings/sizes by this to stay the size they always were.
final double scaling;
// base radius (rem-ish). the tokens below are derived from it exactly the way
// shadcn derived theirs (radius * step).
final double radius;
final IconThemeTokens iconTheme;
// optional surface glassmorphism - null means opaque / no blur, same defaults
// shadcn shipped.
final double? surfaceOpacity;
final double? surfaceBlur;
double get radiusXs => radius * 4;
double get radiusSm => radius * 8;
double get radiusMd => radius * 12;
double get radiusLg => radius * 16;
double get radiusXl => radius * 20;
double get radiusXxl => radius * 24;
BorderRadius get borderRadiusXs => BorderRadius.circular(radiusXs);
BorderRadius get borderRadiusSm => BorderRadius.circular(radiusSm);
BorderRadius get borderRadiusMd => BorderRadius.circular(radiusMd);
BorderRadius get borderRadiusLg => BorderRadius.circular(radiusLg);
BorderRadius get borderRadiusXl => BorderRadius.circular(radiusXl);
BorderRadius get borderRadiusXxl => BorderRadius.circular(radiusXxl);
Radius get radiusMdRadius => Radius.circular(radiusMd);
Radius get radiusLgRadius => Radius.circular(radiusLg);
Radius get radiusXlRadius => Radius.circular(radiusXl);
ThemeData copyWith({
ColourScheme? colorScheme,
double? scaling,
double? radius,
double? surfaceOpacity,
double? surfaceBlur,
Typography? typography,
Density? density,
bool? enableFeedback,
double? panelRadius,
double? panelGap,
IconThemeTokens? iconTheme,
}) {
return ThemeData(
colorScheme: colorScheme ?? this.colorScheme,
scaling: scaling ?? this.scaling,
radius: radius ?? this.radius,
surfaceOpacity: surfaceOpacity ?? this.surfaceOpacity,
surfaceBlur: surfaceBlur ?? this.surfaceBlur,
typography: typography ?? this.typography,
density: density ?? this.density,
enableFeedback: enableFeedback ?? this.enableFeedback,
panelRadius: panelRadius ?? this.panelRadius,
panelGap: panelGap ?? this.panelGap,
iconTheme: iconTheme ?? this.iconTheme,
);
}
}
+105
View File
@@ -0,0 +1,105 @@
import "package:flutter/widgets.dart";
import "package:google_fonts/google_fonts.dart";
import "package:garage_ui/theme/garage_theme.dart";
// The apps type scale. shadcn exposed a big Typography object; the GarageUI widgets
// only reach for the small set below. `small` is a size style, `medium`/`normal`
// are weight styles, and `mono` swaps the font family while inheriting the
// ambient size/weight unless a caller overrides them.
class Typography {
const Typography({
this.normal = const TextStyle(fontWeight: FontWeight.w400),
this.medium = const TextStyle(fontWeight: FontWeight.w500),
this.semiBold = const TextStyle(fontWeight: FontWeight.w600),
// `small` is the shared control font used by buttons, fields and selects.
// Prefer [Typography.forDensity] over setting this by hand - the size
// and line height belong to the density, and the whole control geometry
// chain hangs off them.
this.small = const TextStyle(
fontSize: 10,
height: 1.1,
fontWeight: FontWeight.w400,
),
});
/// Builds the control type from the density, so `small` can never drift from
/// the line box the control heights are derived from.
factory Typography.forDensity(Density density) => Typography(
small: TextStyle(
fontSize: density.fontSize,
height: density.lineHeight,
fontWeight: FontWeight.w400,
),
);
final TextStyle normal;
final TextStyle medium;
/// Emphasis above [medium] - section labels, a dialog's title. Was a
/// FontWeight.w600 literal in menu.dart, properties.dart and toast.dart.
final TextStyle semiBold;
final TextStyle small;
TextStyle sansStyle(TextStyle style) =>
GoogleFonts.geist(textStyle: style, fontWeight: style.fontWeight);
TextStyle get mono => GoogleFonts.geistMono();
TextStyle monoStyle(TextStyle style) =>
GoogleFonts.geistMono(textStyle: style, fontWeight: style.fontWeight);
}
// shadcn hung these little text helpers off every widget (usually a Text). they
// merge a style change over whatever DefaultTextStyle is in scope. sizes are
// multiplied by the theme scaling so they track the rest of the ui.
extension TextStyleExtension on Widget {
// sizes come off the density's text ladder, NOT a literal times scaling -
// see Density.textXs for why. scaling is left out on purpose: Density isn't
// scaled, and these have to stay in step with the control font.
Widget xSmall() => _StyledText(
child: this,
style: (t) => TextStyle(fontSize: t.density.textXs),
);
Widget small() => _StyledText(
child: this,
style: (t) => TextStyle(fontSize: t.density.textSm),
);
Widget large() => _StyledText(
child: this,
style: (t) => TextStyle(fontSize: t.density.textLg),
);
Widget medium() => _StyledText(
child: this,
style: (t) => const TextStyle(fontWeight: FontWeight.w500),
);
Widget semiBold() => _StyledText(
child: this,
style: (t) => const TextStyle(fontWeight: FontWeight.w600),
);
Widget bold() => _StyledText(
child: this,
style: (t) => const TextStyle(fontWeight: FontWeight.w700),
);
Widget muted() => _StyledText(
child: this,
style: (t) => TextStyle(color: t.colorScheme.mutedForeground),
);
}
typedef _StyleFromTheme = TextStyle Function(ThemeData theme);
class _StyledText extends StatelessWidget {
const _StyledText({required this.child, required this.style});
final Widget child;
final _StyleFromTheme style;
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
return DefaultTextStyle.merge(style: style(theme), child: child);
}
}