189 lines
5.9 KiB
Dart
189 lines
5.9 KiB
Dart
import 'package:flutter/material.dart';
|
|
|
|
import 'oklch.dart';
|
|
import 'palettes.dart';
|
|
|
|
/// Derives a palette from the address, so a site is recognizable by its color
|
|
/// before a word of it is read (spec 5.3 lets clients do this instead of
|
|
/// honoring body variants).
|
|
///
|
|
/// The fixed palettes in palettes.dart are one structure in five hues: their
|
|
/// lightness and chroma per role are near-identical and only the hue differs.
|
|
/// So a site's address picks a hue, and the structure supplies the rest. Only
|
|
/// the hue moves, which keeps every generated palette inside the lightness and
|
|
/// chroma envelope of the fixed ones: no site can come out more saturated than
|
|
/// Cool. Three background tint strengths and a tinted or neutral foreground
|
|
/// give 144 looks, none of them louder than the others.
|
|
|
|
/// The seed the hue comes from: the host, or `user@host` on a personal page,
|
|
/// so everyone on a shared host gets their own color. Empty when the address
|
|
/// has no host, as internal `mews:` pages don't.
|
|
String siteSeed(String url) {
|
|
final uri = Uri.tryParse(url);
|
|
final host = uri?.host.toLowerCase() ?? '';
|
|
if (host.isEmpty) {
|
|
return '';
|
|
}
|
|
final user = _userPattern.firstMatch(uri!.path)?.group(2);
|
|
return user == null ? host : '${user.toLowerCase()}@$host';
|
|
}
|
|
|
|
/// Matches the two conventions for a personal page under a shared host.
|
|
final _userPattern = RegExp(r'^/(~|users/)([^/?]+)', caseSensitive: false);
|
|
|
|
/// The palette for an address, or null when it has no host to derive one from.
|
|
MewsPalette? sitePalette(String url, {required bool dark}) {
|
|
final seed = siteSeed(url);
|
|
if (seed.isEmpty) {
|
|
return null;
|
|
}
|
|
return _cache.putIfAbsent('$seed/$dark', () => _generate(seed, dark));
|
|
}
|
|
|
|
/// One palette per site and mode, each built once.
|
|
final _cache = <String, MewsPalette>{};
|
|
|
|
MewsPalette _generate(String seed, bool dark) {
|
|
final hash = _hash(seed);
|
|
final hue = (hash % 24) * 15.0;
|
|
final tint = _tints[(hash >> 5) % _tints.length];
|
|
final skeleton = dark ? _darkSkeleton : _lightSkeleton;
|
|
final isForegroundTinted = (hash >> 9) & 1 == 1;
|
|
|
|
final background = oklch(
|
|
skeleton.backgroundLightness + tint.lightnessOffset,
|
|
skeleton.backgroundChroma * tint.chroma,
|
|
hue,
|
|
);
|
|
return MewsPalette(
|
|
background: background,
|
|
foreground: oklch(
|
|
skeleton.foregroundLightness,
|
|
isForegroundTinted ? skeleton.foregroundChroma : 0,
|
|
hue,
|
|
),
|
|
link: oklch(
|
|
_linkLightness(background, skeleton, hue),
|
|
skeleton.linkChroma,
|
|
hue,
|
|
),
|
|
visited: dark ? visitedDark : visitedLight,
|
|
border: oklch(
|
|
skeleton.borderLightness,
|
|
skeleton.borderChroma * tint.chroma,
|
|
hue,
|
|
),
|
|
preBackground: oklch(
|
|
skeleton.preLightness + tint.lightnessOffset,
|
|
skeleton.preChroma * tint.chroma,
|
|
hue,
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Walks the link's lightness away from the background until it clears the
|
|
/// contrast the spec asks of links. A flat hue rotation leaves it between 6.8
|
|
/// and 7.8 to 1 in light mode, so the greens need this; dark mode clears it
|
|
/// everywhere and the walk stops at once.
|
|
double _linkLightness(Color background, _Skeleton skeleton, double hue) {
|
|
final step = skeleton.isDark ? 0.005 : -0.005;
|
|
var lightness = skeleton.linkLightness;
|
|
for (var i = 0; i < 60; i++) {
|
|
final link = oklch(lightness, skeleton.linkChroma, hue);
|
|
if (contrastRatio(background, link) >= _minLinkContrast) {
|
|
break;
|
|
}
|
|
lightness += step;
|
|
}
|
|
return lightness;
|
|
}
|
|
|
|
/// Above the 7:1 the spec requires of links, leaving room for the rounding to
|
|
/// 8-bit channels.
|
|
const _minLinkContrast = 7.5;
|
|
|
|
/// How strongly the background carries the hue. The middle step is what the
|
|
/// fixed palettes use.
|
|
const _tints = [
|
|
_Tint(chroma: 0.4, lightnessOffset: -0.02),
|
|
_Tint(chroma: 1.0, lightnessOffset: 0.0),
|
|
_Tint(chroma: 2.0, lightnessOffset: 0.015),
|
|
];
|
|
|
|
class _Tint {
|
|
const _Tint({required this.chroma, required this.lightnessOffset});
|
|
|
|
final double chroma;
|
|
final double lightnessOffset;
|
|
}
|
|
|
|
/// The lightness and chroma the fixed palettes share, measured from them. The
|
|
/// link chroma sits a hair under the most saturated fixed link so that
|
|
/// rounding to 8-bit channels can't carry a generated palette past it. A
|
|
/// dark skeleton offsets its lightness upward instead of downward, so the
|
|
/// contrast walk knows which way to go.
|
|
class _Skeleton {
|
|
const _Skeleton({
|
|
required this.isDark,
|
|
required this.backgroundLightness,
|
|
required this.backgroundChroma,
|
|
required this.foregroundLightness,
|
|
required this.foregroundChroma,
|
|
required this.linkLightness,
|
|
required this.linkChroma,
|
|
required this.borderLightness,
|
|
required this.borderChroma,
|
|
required this.preLightness,
|
|
required this.preChroma,
|
|
});
|
|
|
|
final bool isDark;
|
|
final double backgroundLightness;
|
|
final double backgroundChroma;
|
|
final double foregroundLightness;
|
|
final double foregroundChroma;
|
|
final double linkLightness;
|
|
final double linkChroma;
|
|
final double borderLightness;
|
|
final double borderChroma;
|
|
final double preLightness;
|
|
final double preChroma;
|
|
}
|
|
|
|
const _lightSkeleton = _Skeleton(
|
|
isDark: false,
|
|
backgroundLightness: 0.972,
|
|
backgroundChroma: 0.010,
|
|
foregroundLightness: 0.258,
|
|
foregroundChroma: 0.022,
|
|
linkLightness: 0.438,
|
|
linkChroma: 0.135,
|
|
borderLightness: 0.821,
|
|
borderChroma: 0.017,
|
|
preLightness: 0.943,
|
|
preChroma: 0.011,
|
|
);
|
|
|
|
const _darkSkeleton = _Skeleton(
|
|
isDark: true,
|
|
backgroundLightness: 0.215,
|
|
backgroundChroma: 0.011,
|
|
foregroundLightness: 0.913,
|
|
foregroundChroma: 0.018,
|
|
linkLightness: 0.780,
|
|
linkChroma: 0.096,
|
|
borderLightness: 0.395,
|
|
borderChroma: 0.007,
|
|
preLightness: 0.256,
|
|
preChroma: 0.006,
|
|
);
|
|
|
|
/// FNV-1a, 32-bit. Any stable hash would do; this one is four lines and gives
|
|
/// the same palette on every platform and release.
|
|
int _hash(String seed) {
|
|
var hash = 0x811c9dc5;
|
|
for (final byte in seed.codeUnits) {
|
|
hash = ((hash ^ byte) * 0x01000193) & 0xffffffff;
|
|
}
|
|
return hash;
|
|
}
|