feat: color a site from its address, and offer a failed web page to the system browser
This commit is contained in:
parent
56db479df0
commit
9d9f09ebc7
24 changed files with 597 additions and 25 deletions
189
app/lib/presentation/reader/site_palette.dart
Normal file
189
app/lib/presentation/reader/site_palette.dart
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue