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
563 lines
22 KiB
Dart
563 lines
22 KiB
Dart
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();
|
|
}
|