From 9d9f09ebc761984055ee7e1af1fb5f30a54d8655 Mon Sep 17 00:00:00 2001 From: randogoth Date: Sun, 11 Oct 2026 22:32:44 +0300 Subject: [PATCH] feat: color a site from its address, and offer a failed web page to the system browser --- SPEC.md | 2 + .../controllers/browser_controller.dart | 14 +- .../reader_settings_controller.dart | 2 +- app/lib/application/pages/settings_page.dart | 1 + app/lib/presentation/app.dart | 23 ++- app/lib/presentation/reader/oklch.dart | 90 +++++++++ app/lib/presentation/reader/palettes.dart | 43 ++-- .../presentation/reader/reader_screen.dart | 49 +++++ app/lib/presentation/reader/site_palette.dart | 189 ++++++++++++++++++ .../flutter/generated_plugin_registrant.cc | 4 + app/linux/flutter/generated_plugins.cmake | 1 + app/locales/de/main.ftl | 2 + app/locales/en-US/main.ftl | 2 + app/locales/es/main.ftl | 2 + app/locales/fr/main.ftl | 2 + app/locales/he/main.ftl | 2 + app/locales/ja/main.ftl | 2 + app/locales/pt/main.ftl | 2 + app/locales/ru/main.ftl | 2 + app/pubspec.lock | 66 +++++- app/pubspec.yaml | 1 + app/test/app_widget_test.dart | 25 +++ app/test/browser_controller_test.dart | 11 + app/test/site_palette_test.dart | 85 ++++++++ 24 files changed, 597 insertions(+), 25 deletions(-) create mode 100644 app/lib/presentation/reader/oklch.dart create mode 100644 app/lib/presentation/reader/site_palette.dart create mode 100644 app/test/site_palette_test.dart diff --git a/SPEC.md b/SPEC.md index 08ba2e4..3447027 100644 --- a/SPEC.md +++ b/SPEC.md @@ -78,6 +78,7 @@ Use the `actors` package only where Dart Isolate isolation is genuinely useful: The v1 client presents one reader screen: - An address field pinned at the top; on touch devices it hides when the reader scrolls down and returns when they scroll up or reach the top, and on pointer hosts it stays on screen, since the keyboard reaches it whenever it is needed. Its leading icons open the hub and, on pointer platforms, go back and forward. Its trailing icons reload the page, subscribe to the site, and bookmark the page; subscribing is offered only on a page that carries dated links or declares an Atom feed. +- A web page that cannot be loaded offers to open in the reader's system web browser, since the web can be shown outside; a page over gemini or spartan has nowhere else to go, so its failures offer only trying again. - On pointer hosts the keyboard drives the reader: Ctrl or Cmd+L focuses the address field with its text selected, Ctrl or Cmd+R reloads the page, and the walk goes back and forward with Alt+Left and Alt+Right, or on a Mac with Cmd+Left, Cmd+Right, Cmd+[ and Cmd+]. Touch devices navigate with gestures instead, and none of these shortcuts are offered there. The links on a page are reachable by keyboard as well: the page is one stop in the focus order, and once it holds the focus, Tab highlights its links in reading order and Enter opens the highlighted one; the page scrolls to keep the highlighted link in view, Shift+Tab walks back, Escape puts the highlight away, and a Tab past either end hands the focus back to the chrome. - A swipe right goes back and a swipe left goes forward through the history stack. The whole empty margin beside the text column accepts the swipe, with a 40 px floor, so the gesture stays clear of selecting text. Touch devices navigate this way; pointer platforms get the address bar's buttons as well. - The client's own pages live under the `mews:` scheme. `mews:hub` names them and says what each one holds; `mews:posts`, `mews:bookmarks`, `mews:subscriptions`, `mews:history` and `mews:settings` each carry a strip linking to the others. Bare `mews:` is the hub, and the hub is where a session with no history to return to starts. Each page is built as an ordinary Mews document and rendered by the same renderer as a fetched page, so it joins the history stack and the swipe transitions, and none of them is recorded in history or offered for subscription. @@ -92,5 +93,6 @@ The v1 client presents one reader screen: - Hebrew mirrors the whole interface. Flutter's own widgets follow the same language, and our own pages take their direction from it; a fetched page keeps declaring its own, so an English interface still renders a Hebrew page right to left. - Plurals are separate messages rather than a rule inside one, and the choice is made in code from the language's own rules: Russian separates two to four from the rest, Hebrew has a form for two, French and Portuguese count zero as singular, and Japanese has one form. A language needing a form English lacks names it with the same id and that suffix. - Fonts are bundled, never fetched. The reader picks between two sans faces, Atkinson Hyperlegible and Luciole, and two serifs, Literata and Charis SIL; all four carry regular, italic, bold and bold italic, which the renderer needs for emphasis. The chosen face applies to the whole app, chrome included, so the address bar and the page below it are one face. +- Colors are the client's own, never the page's stylesheet: the five palettes of the default stylesheet in light and dark, or a palette derived from the address (spec 5.3). A derived palette is seeded by the host, and on a `/~user` or `/users/user` page by the user and host together, so everyone on a shared host gets their own color. The seed picks one of 24 hues, three background tint strengths and a tinted or plain foreground, and the lightness and chroma measured from the fixed palettes supply the rest: only the hue moves, so a site is recognizable by its color without any site coming out louder than the fixed palettes. Links reach the 7:1 contrast the spec asks for by moving their lightness until they meet it, so no hue needs a correction of its own. - A character the chosen face has no glyph for is looked for in that face's own fallbacks before the platform is asked. Hebrew always comes from a bundled Hebrew face matched to the shape of the chosen one, so it never depends on what the device has installed. The serifs also cover Greek, Cyrillic and a wide Latin range and fall back to each other; the two sans faces are narrow and have no bundled sans to turn to, so for those the platform answers, and it answers a sans with a sans. Scripts nothing bundled covers, such as Arabic and the CJK languages, always come from the platform. - Adding a font is four TTFs in `app/assets/fonts/` with its licence beside them, one `fonts` entry in `app/pubspec.yaml`, one entry in `app/lib/application/fonts.dart`, and one label in `app/locales/en-US/main.ftl`. A family named in the registry but missing from pubspec would render in the platform's font without saying so, so `app/test/fonts_test.dart` checks that the two agree. diff --git a/app/lib/application/controllers/browser_controller.dart b/app/lib/application/controllers/browser_controller.dart index d41b3f4..f524f0d 100644 --- a/app/lib/application/controllers/browser_controller.dart +++ b/app/lib/application/controllers/browser_controller.dart @@ -114,6 +114,10 @@ class BrowserController { final backPage = signal(null); final forwardPage = signal(null); + /// The address the reader is standing on, which an error page speaks about. + /// Empty when there is nowhere to be yet. + String get currentAddress => _stackIndex >= 0 ? _stack[_stackIndex] : ''; + bool get canGoBack => _stackIndex > 0; bool get canGoForward => _stackIndex >= 0 && _stack.length - 1 > _stackIndex; @@ -176,10 +180,16 @@ class BrowserController { _stack[_stackIndex] = candidate; _updateNeighbors(); await _loadCurrent(); - // The address as typed is the closer match; keep its error when the - // wap. attempt does not work either. + // The address as followed is the closer match: when the wap. + // attempt does not work either, its error is the one to show and the + // stack goes back to the address the reader followed, so the + // address bar and anything acting on the failure (like opening the + // page in a web browser) speak of the real address, not the guess. if (error.value != null && firstError != null) { error.value = firstError; + candidate = url; + _stack[_stackIndex] = url; + _updateNeighbors(); } } } diff --git a/app/lib/application/controllers/reader_settings_controller.dart b/app/lib/application/controllers/reader_settings_controller.dart index 1ab97e9..7891049 100644 --- a/app/lib/application/controllers/reader_settings_controller.dart +++ b/app/lib/application/controllers/reader_settings_controller.dart @@ -19,7 +19,7 @@ class ReaderSettingsController { /// setting arrives as a link the reader can also type, so a value outside /// these lists is refused rather than stored. static const themeModes = ['light', 'dark', 'system']; - static const variants = ['site', 'warm', 'cool', 'green', 'mono']; + static const variants = ['site', 'host', 'warm', 'cool', 'green', 'mono']; final font = signal(defaultFontFamily); final fontScale = signal(1.0); diff --git a/app/lib/application/pages/settings_page.dart b/app/lib/application/pages/settings_page.dart index 03a897a..139b763 100644 --- a/app/lib/application/pages/settings_page.dart +++ b/app/lib/application/pages/settings_page.dart @@ -17,6 +17,7 @@ const _themeLabels = { const _variantLabels = { 'site': 'settings-variant-site', + 'host': 'settings-variant-host', 'warm': 'settings-variant-warm', 'cool': 'settings-variant-cool', 'green': 'settings-variant-green', diff --git a/app/lib/presentation/app.dart b/app/lib/presentation/app.dart index ee67e7e..b63b3e4 100644 --- a/app/lib/presentation/app.dart +++ b/app/lib/presentation/app.dart @@ -166,9 +166,6 @@ class _ThemedApp extends StatelessWidget { // Read so the whole app rebuilds when the reader changes language. settings.language.value; final page = deps.browser.currentPage.value; - final variant = settings.variant.value == 'site' - ? (page?.variant ?? MewsVariant.plain) - : pageVariantFromName(settings.variant.value); // Flutter's own widgets and the text direction follow the same // language, so a right-to-left interface mirrors without us asking. @@ -188,17 +185,25 @@ class _ThemedApp extends StatelessWidget { GlobalCupertinoLocalizations.delegate, ], themeMode: mode, - theme: _themeFor( - paletteFor(pageVariant: variant, readerVariant: 'site', dark: false), - font), - darkTheme: _themeFor( - paletteFor(pageVariant: variant, readerVariant: 'site', dark: true), - font), + theme: _themeFor(_chromePalette(settings, page, dark: false), font), + darkTheme: _themeFor(_chromePalette(settings, page, dark: true), font), home: const ReaderScreen(), ); }); } + /// The interface wears the current page's palette, so the chrome around a + /// page matches it. + MewsPalette _chromePalette(ReaderSettingsController settings, MewsPage? page, + {required bool dark}) { + return paletteFor( + pageVariant: page?.variant ?? MewsVariant.plain, + readerVariant: settings.variant.value, + dark: dark, + url: page?.url, + ); + } + ThemeData _themeFor(MewsPalette palette, String fontFamily) { final dark = palette.background.computeLuminance() < 0.5; final scheme = ColorScheme( diff --git a/app/lib/presentation/reader/oklch.dart b/app/lib/presentation/reader/oklch.dart new file mode 100644 index 0000000..f67cab6 --- /dev/null +++ b/app/lib/presentation/reader/oklch.dart @@ -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 _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 rgb) => + rgb.every((channel) => channel >= -0.0001 && channel <= 1.0001); + +Color _color(List 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); +} diff --git a/app/lib/presentation/reader/palettes.dart b/app/lib/presentation/reader/palettes.dart index 2641f6d..4490d4b 100644 --- a/app/lib/presentation/reader/palettes.dart +++ b/app/lib/presentation/reader/palettes.dart @@ -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, diff --git a/app/lib/presentation/reader/reader_screen.dart b/app/lib/presentation/reader/reader_screen.dart index 66ddf51..c9d8c1e 100644 --- a/app/lib/presentation/reader/reader_screen.dart +++ b/app/lib/presentation/reader/reader_screen.dart @@ -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 { 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 { 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 { 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 { ); } + /// 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 _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 { 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) { diff --git a/app/lib/presentation/reader/site_palette.dart b/app/lib/presentation/reader/site_palette.dart new file mode 100644 index 0000000..9683bcc --- /dev/null +++ b/app/lib/presentation/reader/site_palette.dart @@ -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 = {}; + +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; +} diff --git a/app/linux/flutter/generated_plugin_registrant.cc b/app/linux/flutter/generated_plugin_registrant.cc index e71a16d..f6f23bf 100644 --- a/app/linux/flutter/generated_plugin_registrant.cc +++ b/app/linux/flutter/generated_plugin_registrant.cc @@ -6,6 +6,10 @@ #include "generated_plugin_registrant.h" +#include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); } diff --git a/app/linux/flutter/generated_plugins.cmake b/app/linux/flutter/generated_plugins.cmake index be1ee3e..df8d2f7 100644 --- a/app/linux/flutter/generated_plugins.cmake +++ b/app/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + url_launcher_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/app/locales/de/main.ftl b/app/locales/de/main.ftl index f11f182..75b87cd 100644 --- a/app/locales/de/main.ftl +++ b/app/locales/de/main.ftl @@ -134,6 +134,7 @@ settings-theme-dark = Dunkel settings-theme-system = Systemeinstellung folgen settings-variant-heading = Farben settings-variant-site = Website +settings-variant-host = Nach Adresse settings-variant-warm = Warm settings-variant-cool = Kühl settings-variant-green = Grün @@ -186,6 +187,7 @@ form-reset-default = Zurücksetzen error-load-failed = Die Seite konnte nicht geladen werden. Prüfe die Adresse und deine Verbindung und versuche es erneut. error-try-again = Erneut versuchen +error-open-in-browser = In einem Webbrowser öffnen error-broken-page = Diese Seite hat einen Auszeichnungsfehler und kann deshalb nicht angezeigt werden. error-broken-page-show-text = Seitentext anzeigen error-broken-page-text-title = Seitentext diff --git a/app/locales/en-US/main.ftl b/app/locales/en-US/main.ftl index 298a0f2..b4a7bf5 100644 --- a/app/locales/en-US/main.ftl +++ b/app/locales/en-US/main.ftl @@ -134,6 +134,7 @@ settings-theme-dark = Dark settings-theme-system = Follow system settings-variant-heading = Colors settings-variant-site = Site +settings-variant-host = By address settings-variant-warm = Warm settings-variant-cool = Cool settings-variant-green = Green @@ -189,6 +190,7 @@ form-reset-default = Reset error-load-failed = The page could not be loaded. Check the address and your connection, then try again. error-try-again = Try again +error-open-in-browser = Open in a web browser error-broken-page = This page has a markup error, so it cannot be shown. error-broken-page-show-text = Show page text error-broken-page-text-title = Page text diff --git a/app/locales/es/main.ftl b/app/locales/es/main.ftl index cb5aa19..e61c915 100644 --- a/app/locales/es/main.ftl +++ b/app/locales/es/main.ftl @@ -134,6 +134,7 @@ settings-theme-dark = Oscuro settings-theme-system = Seguir al sistema settings-variant-heading = Colores settings-variant-site = Sitio +settings-variant-host = Por dirección settings-variant-warm = Cálido settings-variant-cool = Frío settings-variant-green = Verde @@ -186,6 +187,7 @@ form-reset-default = Restablecer error-load-failed = No se ha podido cargar la página. Comprueba la dirección y tu conexión, y vuelve a intentarlo. error-try-again = Volver a intentarlo +error-open-in-browser = Abrir en un navegador web error-broken-page = Esta página tiene un error de marcado, así que no se puede mostrar. error-broken-page-show-text = Mostrar el texto de la página error-broken-page-text-title = Texto de la página diff --git a/app/locales/fr/main.ftl b/app/locales/fr/main.ftl index 12d2545..9bebd3f 100644 --- a/app/locales/fr/main.ftl +++ b/app/locales/fr/main.ftl @@ -134,6 +134,7 @@ settings-theme-dark = Sombre settings-theme-system = Suivre le système settings-variant-heading = Couleurs settings-variant-site = Site +settings-variant-host = Par adresse settings-variant-warm = Chaud settings-variant-cool = Froid settings-variant-green = Vert @@ -186,6 +187,7 @@ form-reset-default = Réinitialiser error-load-failed = La page n'a pas pu être chargée. Vérifie l'adresse et ta connexion, puis réessaie. error-try-again = Réessayer +error-open-in-browser = Ouvrir dans un navigateur web error-broken-page = Cette page comporte une erreur de balisage et ne peut donc pas être affichée. error-broken-page-show-text = Afficher le texte de la page error-broken-page-text-title = Texte de la page diff --git a/app/locales/he/main.ftl b/app/locales/he/main.ftl index c62a22e..fb54b7c 100644 --- a/app/locales/he/main.ftl +++ b/app/locales/he/main.ftl @@ -139,6 +139,7 @@ settings-theme-dark = כהה settings-theme-system = לפי המערכת settings-variant-heading = צבעים settings-variant-site = של האתר +settings-variant-host = לפי כתובת settings-variant-warm = חמים settings-variant-cool = קרים settings-variant-green = ירוקים @@ -191,6 +192,7 @@ form-reset-default = איפוס error-load-failed = לא ניתן לטעון את הדף. בדקו את הכתובת ואת החיבור ונסו שוב. error-try-again = ניסיון חוזר +error-open-in-browser = פתיחה בדפדפן אינטרנט error-broken-page = בדף הזה יש שגיאת סימון, ולכן אי אפשר להציג אותו. error-broken-page-show-text = הצגת הטקסט של הדף error-broken-page-text-title = הטקסט של הדף diff --git a/app/locales/ja/main.ftl b/app/locales/ja/main.ftl index 26e5ce9..025a96c 100644 --- a/app/locales/ja/main.ftl +++ b/app/locales/ja/main.ftl @@ -134,6 +134,7 @@ settings-theme-dark = ダーク settings-theme-system = システムに合わせる settings-variant-heading = 配色 settings-variant-site = サイト +settings-variant-host = アドレス別 settings-variant-warm = 暖色 settings-variant-cool = 寒色 settings-variant-green = グリーン @@ -186,6 +187,7 @@ form-reset-default = リセット error-load-failed = ページを読み込めませんでした。アドレスと接続を確認して、もう一度お試しください。 error-try-again = もう一度試す +error-open-in-browser = ウェブブラウザで開く error-broken-page = このページにはマークアップの誤りがあるため、表示できません。 error-broken-page-show-text = ページのテキストを表示 error-broken-page-text-title = ページのテキスト diff --git a/app/locales/pt/main.ftl b/app/locales/pt/main.ftl index 62ef4a9..8b1672c 100644 --- a/app/locales/pt/main.ftl +++ b/app/locales/pt/main.ftl @@ -134,6 +134,7 @@ settings-theme-dark = Escuro settings-theme-system = Seguir o sistema settings-variant-heading = Cores settings-variant-site = Site +settings-variant-host = Por endereço settings-variant-warm = Quente settings-variant-cool = Frio settings-variant-green = Verde @@ -186,6 +187,7 @@ form-reset-default = Repor error-load-failed = Não foi possível carregar a página. Verifica o endereço e a tua ligação e tenta de novo. error-try-again = Tentar de novo +error-open-in-browser = Abrir num navegador web error-broken-page = Esta página tem um erro de marcação, por isso não pode ser mostrada. error-broken-page-show-text = Mostrar o texto da página error-broken-page-text-title = Texto da página diff --git a/app/locales/ru/main.ftl b/app/locales/ru/main.ftl index 9a2afc6..131dfe8 100644 --- a/app/locales/ru/main.ftl +++ b/app/locales/ru/main.ftl @@ -139,6 +139,7 @@ settings-theme-dark = Тёмное settings-theme-system = Как в системе settings-variant-heading = Цвета settings-variant-site = Сайта +settings-variant-host = По адресу settings-variant-warm = Тёплые settings-variant-cool = Холодные settings-variant-green = Зелёные @@ -191,6 +192,7 @@ form-reset-default = Сбросить error-load-failed = Не удалось загрузить страницу. Проверьте адрес и подключение, затем повторите попытку. error-try-again = Повторить попытку +error-open-in-browser = Открыть в веб-браузере error-broken-page = На этой странице ошибка разметки, поэтому показать её нельзя. error-broken-page-show-text = Показать текст страницы error-broken-page-text-title = Текст страницы diff --git a/app/pubspec.lock b/app/pubspec.lock index c443565..2b1d7b7 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -810,6 +810,70 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: e8cc955c97e06df9879140a1fe301f3d493f01b7c62dec7f46834be31d7e9fda + url: "https://pub.dev" + source: hosted + version: "6.3.3" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c" + url: "https://pub.dev" + source: hosted + version: "6.3.30" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "8faa1aab294f1ab4040b43660c887b0418d5fa4f0cffef76a484e6aa1092eb4a" + url: "https://pub.dev" + source: hosted + version: "6.4.2" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: "10f86fef4c2c43563fa6c211ff9cf757adf4d3ab762c56bd430664a947d70cd0" + url: "https://pub.dev" + source: hosted + version: "3.2.3" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "5e835a3b869c2d70325349c81c5a45c28e20791265b67b2669da6b08c5cd5201" + url: "https://pub.dev" + source: hosted + version: "3.2.6" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "6c5ad3f22cd4c38e089b81963b3cd7bb83b111b2df5dce008bb066162f42e429" + url: "https://pub.dev" + source: hosted + version: "3.1.6" vector_math: dependency: transitive description: @@ -900,4 +964,4 @@ packages: version: "3.1.4" sdks: dart: ">=3.11.5 <4.0.0" - flutter: ">=3.38.4" + flutter: ">=3.41.0" diff --git a/app/pubspec.yaml b/app/pubspec.yaml index a460a98..6d47b10 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -24,6 +24,7 @@ dependencies: path_provider: ^2.1.6 crypto: ^3.0.7 file_picker: ^13.1.0 + url_launcher: ^6.3.3 dev_dependencies: flutter_test: diff --git a/app/test/app_widget_test.dart b/app/test/app_widget_test.dart index 13db159..8257ae2 100644 --- a/app/test/app_widget_test.dart +++ b/app/test/app_widget_test.dart @@ -133,6 +133,31 @@ void main() { expect(find.textContaining('Try again'), findsOneWidget); }); + testWidgets('a web page that will not load can open in a web browser', + (tester) async { + final stubDeps = AppDependencies( + database: MewsDatabase.test(NativeDatabase.memory()), + fetch: FakeFetch(const {}), + strings: strings, + ); + await tester.pumpWidget(MewsApp(deps: stubDeps, strings: strings)); + await tester.pumpAndSettle(); + + // A web address that answers nothing: the error offers the way out, + // since the web can be shown outside this reader. + await stubDeps.browser.open('https://missing.example.org/'); + await tester.pumpAndSettle(); + expect(stubDeps.browser.error.value, isA()); + expect(find.text('Open in a web browser'), findsOneWidget); + + // A protocol page has nowhere else to go, so only trying again is + // offered there. + await stubDeps.browser.open('gemini://missing.example.org/'); + await tester.pumpAndSettle(); + expect(stubDeps.browser.error.value, isA()); + expect(find.text('Open in a web browser'), findsNothing); + }); + testWidgets('the hub opens the settings page, which applies a choice in place', (tester) async { await pumpApp(tester); diff --git a/app/test/browser_controller_test.dart b/app/test/browser_controller_test.dart index ba7c4d5..f58b551 100644 --- a/app/test/browser_controller_test.dart +++ b/app/test/browser_controller_test.dart @@ -77,6 +77,17 @@ void main() { expect(await db.allPins(), {'capsule.example:1965': presented}); }); + test('a failed load keeps the address the reader followed', () async { + final controller = browser(FakeFetch(const {})); + + // Neither the address nor its wap. subdomain answers; the wap. guess + // is not left standing in for what the reader followed. + await controller.open('https://capsule.example/post'); + + expect(controller.error.value, isA()); + expect(controller.currentAddress, 'https://capsule.example/post'); + }); + test('a certificate request is answered by binding an identity', () async { final controller = browser(FakeFetch( {'gemini://capsule.example': '# Hello\n\nText.'}, diff --git a/app/test/site_palette_test.dart b/app/test/site_palette_test.dart new file mode 100644 index 0000000..3c34f88 --- /dev/null +++ b/app/test/site_palette_test.dart @@ -0,0 +1,85 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mews/mews/document.dart'; +import 'package:mews/presentation/reader/oklch.dart'; +import 'package:mews/presentation/reader/palettes.dart'; +import 'package:mews/presentation/reader/site_palette.dart'; + +/// A wide sample of what the generator can produce: enough hosts to cover all +/// 24 hues, 3 background tints and both foregrounds. +Iterable _allPalettes(bool dark) => [ + for (var i = 0; i < 2000; i++) sitePalette('gemini://h$i.example/', dark: dark)!, + ]; + +void main() { + group('seed', () { + test('comes from the host', () { + expect(siteSeed('gemini://example.org/page'), 'example.org'); + expect(siteSeed('gemini://Example.ORG/page'), 'example.org'); + }); + + test('names the user on a personal page', () { + expect(siteSeed('gemini://example.org/~alice/'), 'alice@example.org'); + expect(siteSeed('gemini://example.org/users/alice/x.gmi'), 'alice@example.org'); + expect(siteSeed('gemini://example.org/Users/Alice'), 'alice@example.org'); + }); + + test('is empty without a host', () { + expect(siteSeed('mews:settings'), ''); + expect(siteSeed('not a url'), ''); + }); + }); + + group('palette', () { + test('is the same every time for one address', () { + expect(sitePalette('gemini://example.org/a', dark: false), + same(sitePalette('gemini://example.org/b', dark: false))); + }); + + test('differs between two users on one host', () { + final alice = sitePalette('gemini://example.org/~alice/', dark: false)!; + final bob = sitePalette('gemini://example.org/~bob/', dark: false)!; + expect(alice.link, isNot(bob.link)); + }); + + test('is null for an address with no host', () { + expect(sitePalette('mews:settings', dark: false), isNull); + }); + + test('falls back to plain when the reader asks for host colors', () { + expect( + paletteFor( + pageVariant: MewsVariant.plain, + readerVariant: 'host', + dark: false, + url: 'mews:settings'), + paletteFor(pageVariant: MewsVariant.plain, readerVariant: 'plain', dark: false), + ); + }); + + // Spec 5.1: every text and link color meets 7:1 against its background. + for (final dark in [false, true]) { + test('meets 7:1 contrast in ${dark ? 'dark' : 'light'} mode', () { + for (final palette in _allPalettes(dark)) { + expect(contrastRatio(palette.background, palette.foreground), + greaterThanOrEqualTo(7.0)); + expect(contrastRatio(palette.background, palette.link), + greaterThanOrEqualTo(7.0)); + expect(contrastRatio(palette.background, palette.visited), + greaterThanOrEqualTo(7.0)); + } + }); + + test('stays no louder than the fixed palettes in ${dark ? 'dark' : 'light'} mode', () { + // Cool holds the most saturated link of the fixed palettes, so no + // generated color may pass it. + final cool = paletteFor( + pageVariant: MewsVariant.cool, readerVariant: 'cool', dark: dark); + final limit = chromaOf(cool.link); + for (final palette in _allPalettes(dark)) { + expect(chromaOf(palette.link), lessThanOrEqualTo(limit)); + expect(chromaOf(palette.background), lessThanOrEqualTo(0.03)); + } + }); + } + }); +}