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();
}