the-mews/app/lib/presentation/reader/site_palette.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;
}