feat: color a site from its address, and offer a failed web page to the system browser

This commit is contained in:
randogoth 2026-10-11 22:32:44 +03:00
parent 56db479df0
commit 9d9f09ebc7
24 changed files with 597 additions and 25 deletions

View file

@ -0,0 +1,90 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
/// OkLCH color math. Lightness and chroma are perceptual here, so a palette
/// can be moved to another hue without the per-hue lightness corrections that
/// rotating an HSL hue would need.
/// Builds a color from lightness (0..1), chroma and hue (degrees). Chroma is
/// reduced until the color fits in sRGB, so a hue that can't hold the asked
/// chroma comes back muted rather than clipped to a wrong hue.
Color oklch(double lightness, double chroma, double hue) {
var low = 0.0;
var high = chroma;
if (_fitsSrgb(_linearSrgb(lightness, chroma, hue))) {
return _color(_linearSrgb(lightness, chroma, hue));
}
for (var i = 0; i < 16; i++) {
final mid = (low + high) / 2;
if (_fitsSrgb(_linearSrgb(lightness, mid, hue))) {
low = mid;
} else {
high = mid;
}
}
return _color(_linearSrgb(lightness, low, hue));
}
/// The WCAG contrast ratio, from 1 (identical) to 21 (black on white).
double contrastRatio(Color a, Color b) {
final la = _relativeLuminance(a);
final lb = _relativeLuminance(b);
final lighter = math.max(la, lb);
final darker = math.min(la, lb);
return (lighter + 0.05) / (darker + 0.05);
}
/// How far a color sits from gray, in the same units [oklch] takes: the
/// bigger it is, the more vivid the color looks.
double chromaOf(Color color) {
double linear(double value) =>
value <= 0.04045 ? value / 12.92 : math.pow((value + 0.055) / 1.055, 2.4) as double;
final r = linear(color.r);
final g = linear(color.g);
final b = linear(color.b);
final l = math.pow(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b, 1 / 3) as double;
final m = math.pow(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b, 1 / 3) as double;
final s = math.pow(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b, 1 / 3) as double;
final a = 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s;
final bb = 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s;
return math.sqrt(a * a + bb * bb);
}
List<double> _linearSrgb(double lightness, double chroma, double hue) {
final radians = hue * math.pi / 180;
final a = chroma * math.cos(radians);
final b = chroma * math.sin(radians);
final l = math.pow(lightness + 0.3963377774 * a + 0.2158037573 * b, 3) as double;
final m = math.pow(lightness - 0.1055613458 * a - 0.0638541728 * b, 3) as double;
final s = math.pow(lightness - 0.0894841775 * a - 1.2914855480 * b, 3) as double;
return [
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
];
}
bool _fitsSrgb(List<double> rgb) =>
rgb.every((channel) => channel >= -0.0001 && channel <= 1.0001);
Color _color(List<double> rgb) => Color.fromARGB(
255,
_encode(rgb[0]),
_encode(rgb[1]),
_encode(rgb[2]),
);
int _encode(double channel) {
final clamped = channel.clamp(0.0, 1.0);
final encoded = clamped <= 0.0031308
? 12.92 * clamped
: 1.055 * math.pow(clamped, 1 / 2.4) - 0.055;
return (encoded * 255).round().clamp(0, 255);
}
double _relativeLuminance(Color color) {
double channel(double value) =>
value <= 0.04045 ? value / 12.92 : math.pow((value + 0.055) / 1.055, 2.4) as double;
return 0.2126 * channel(color.r) + 0.7152 * channel(color.g) + 0.0722 * channel(color.b);
}

View file

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import '../../mews/document.dart';
import 'site_palette.dart';
/// The client's own presentation, mirroring the palettes of the default
/// stylesheet mews-0.1.css (spec 5.1). Mews clients ignore author CSS and use
@ -23,11 +24,16 @@ class MewsPalette {
final Color preBackground;
}
/// Visited links keep one color across every palette, generated ones
/// included, so the mark of having been somewhere reads the same everywhere.
const visitedLight = Color(0xff5b2a86);
const visitedDark = Color(0xffc9a3f2);
const _plainLight = MewsPalette(
background: Color(0xfffaf8f3),
foreground: Color(0xff1f1f1f),
link: Color(0xff1a4f9c),
visited: Color(0xff5b2a86),
visited: visitedLight,
border: Color(0xffc9c4b8),
preBackground: Color(0xffefece4),
);
@ -36,7 +42,7 @@ const _plainDark = MewsPalette(
background: Color(0xff18191b),
foreground: Color(0xffe3e1dc),
link: Color(0xff8fb8f5),
visited: Color(0xffc9a3f2),
visited: visitedDark,
border: Color(0xff45464a),
preBackground: Color(0xff222326),
);
@ -45,7 +51,7 @@ const _warmLight = MewsPalette(
background: Color(0xfffbf4e8),
foreground: Color(0xff2a2420),
link: Color(0xff8a3d14),
visited: Color(0xff5b2a86),
visited: visitedLight,
border: Color(0xffc9c4b8),
preBackground: Color(0xffefece4),
);
@ -54,7 +60,7 @@ const _warmDark = MewsPalette(
background: Color(0xff1d1916),
foreground: Color(0xffe8dfd3),
link: Color(0xfff0b48a),
visited: Color(0xffc9a3f2),
visited: visitedDark,
border: Color(0xff45464a),
preBackground: Color(0xff222326),
);
@ -63,7 +69,7 @@ const _coolLight = MewsPalette(
background: Color(0xfff3f6fa),
foreground: Color(0xff1c2430),
link: Color(0xff1a4f9c),
visited: Color(0xff5b2a86),
visited: visitedLight,
border: Color(0xffc9c4b8),
preBackground: Color(0xffefece4),
);
@ -72,7 +78,7 @@ const _coolDark = MewsPalette(
background: Color(0xff161a20),
foreground: Color(0xffdde4ee),
link: Color(0xff8fb8f5),
visited: Color(0xffc9a3f2),
visited: visitedDark,
border: Color(0xff45464a),
preBackground: Color(0xff222326),
);
@ -81,7 +87,7 @@ const _greenLight = MewsPalette(
background: Color(0xfff2f6ef),
foreground: Color(0xff1f2a1f),
link: Color(0xff24502a),
visited: Color(0xff5b2a86),
visited: visitedLight,
border: Color(0xffc9c4b8),
preBackground: Color(0xffefece4),
);
@ -90,7 +96,7 @@ const _greenDark = MewsPalette(
background: Color(0xff161b16),
foreground: Color(0xffdbe6d8),
link: Color(0xff9fd39a),
visited: Color(0xffc9a3f2),
visited: visitedDark,
border: Color(0xff45464a),
preBackground: Color(0xff222326),
);
@ -99,7 +105,7 @@ const _monoLight = MewsPalette(
background: Color(0xffe8ebe1),
foreground: Color(0xff1e2219),
link: Color(0xff1e2219),
visited: Color(0xff5b2a86),
visited: visitedLight,
border: Color(0xffc9c4b8),
preBackground: Color(0xffefece4),
);
@ -108,14 +114,27 @@ const _monoDark = MewsPalette(
background: Color(0xff1b1d18),
foreground: Color(0xffc8d0b8),
link: Color(0xffc8d0b8),
visited: Color(0xffc9a3f2),
visited: visitedDark,
border: Color(0xff45464a),
preBackground: Color(0xff222326),
);
/// Resolves the palette: the reader's variant choice overrides the page's own
/// variant unless the choice is "site" (spec 5.3, 8.2).
MewsPalette paletteFor({required MewsVariant pageVariant, required String readerVariant, required bool dark}) {
/// variant unless the choice is "site" (spec 5.3, 8.2). The choice "host"
/// derives the palette from the address instead, and falls back to the plain
/// palette for an address with no host.
MewsPalette paletteFor({
required MewsVariant pageVariant,
required String readerVariant,
required bool dark,
String? url,
}) {
if (readerVariant == 'host' && url != null) {
final derived = sitePalette(url, dark: dark);
if (derived != null) {
return derived;
}
}
final variant = readerVariant == 'site' ? pageVariant : pageVariantFromName(readerVariant);
return switch ((variant, dark)) {
(MewsVariant.warm, false) => _warmLight,

View file

@ -1,5 +1,8 @@
import 'dart:io' show Process;
import 'package:flutter/foundation.dart'
show defaultTargetPlatform, TargetPlatform;
import 'package:url_launcher/url_launcher.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart' show ScrollDirection;
import 'package:flutter/services.dart' show LogicalKeyboardKey;
@ -114,6 +117,7 @@ class _ReaderScreenState extends State<ReaderScreen> {
pageVariant: page.variant,
readerVariant: deps.settings.variant.value,
dark: Theme.of(context).brightness == Brightness.dark,
url: page.url,
).background;
// The 40em column plus its padding: swipes are accepted
@ -235,6 +239,7 @@ class _ReaderScreenState extends State<ReaderScreen> {
pageVariant: page.variant,
readerVariant: variant,
dark: Theme.of(context).brightness == Brightness.dark,
url: page.url,
);
return MewsPageView(
key: ValueKey(page.url),
@ -263,6 +268,7 @@ class _ReaderScreenState extends State<ReaderScreen> {
pageVariant: page.variant,
readerVariant: settings.variant.value,
dark: Theme.of(context).brightness == Brightness.dark,
url: page.url,
);
return ColoredBox(
color: palette.background,
@ -389,10 +395,46 @@ class _ReaderScreenState extends State<ReaderScreen> {
);
}
/// Hands [url] to the system's web browser. The reader does not watch
/// what happens to it there, and a system with no browser answers nothing
/// rather than failing here.
///
/// The launcher asks GIO on Linux, which can fail to see the session's
/// browser from the environment the app runs in, and a binary built before
/// the launcher was added answers not at all; in both cases xdg-open asks
/// the session directly.
Future<void> _openOutside(String url) async {
final uri = Uri.tryParse(url);
if (uri == null) return;
var opened = false;
try {
opened = await launchUrl(uri, mode: LaunchMode.externalApplication);
} on Exception {
// No answer; the fallback below asks the session itself.
}
if (opened) return;
if (defaultTargetPlatform == TargetPlatform.linux) {
final result = await Process.run('xdg-open', [url]);
// A missing xdg-open or a session without a browser answers with an
// error; nothing more to do but stay where we are.
if (result.exitCode != 0) return;
}
}
Widget _errorPage(BuildContext context, String? brokenSource) {
final strings = AppScope.of(context).strings;
final deps = AppScope.of(context).deps;
// A web address can be shown by the system's browser, so a page that
// will not load here can leave; a protocol page has nowhere else to go,
// so only trying again is offered there.
final failed = deps.browser.currentAddress;
final failedScheme = Uri.tryParse(failed)?.scheme;
final canOpenOutside =
brokenSource == null && (failedScheme == 'http' || failedScheme == 'https');
Widget content;
if (brokenSource == null) {
content = Column(
@ -404,6 +446,13 @@ class _ReaderScreenState extends State<ReaderScreen> {
onPressed: () => deps.browser.reload(),
child: Text(strings.t('error-try-again')),
),
if (canOpenOutside) ...[
const SizedBox(height: 8),
OutlinedButton(
onPressed: () => _openOutside(failed),
child: Text(strings.t('error-open-in-browser')),
),
],
],
);
} else if (_showBrokenText) {

View 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;
}