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 = {}; 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; }