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
2
SPEC.md
2
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.
|
||||
|
|
|
|||
|
|
@ -114,6 +114,10 @@ class BrowserController {
|
|||
final backPage = signal<MewsPage?>(null);
|
||||
final forwardPage = signal<MewsPage?>(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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
90
app/lib/presentation/reader/oklch.dart
Normal file
90
app/lib/presentation/reader/oklch.dart
Normal 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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
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;
|
||||
}
|
||||
|
|
@ -6,6 +6,10 @@
|
|||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
url_launcher_linux
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = הטקסט של הדף
|
||||
|
|
|
|||
|
|
@ -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 = ページのテキスト
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = Текст страницы
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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<LoadFailed>());
|
||||
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<LoadFailed>());
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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<LoadFailed>());
|
||||
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.'},
|
||||
|
|
|
|||
85
app/test/site_palette_test.dart
Normal file
85
app/test/site_palette_test.dart
Normal file
|
|
@ -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<MewsPalette> _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));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue