From dfba1cfb1683ee833bc1c6e1eaa7a81352530db8 Mon Sep 17 00:00:00 2001 From: randogoth Date: Sun, 11 Oct 2026 14:03:44 +0300 Subject: [PATCH] feat: lay our own pages out as interface rather than as articles --- SPEC.md | 3 +- app/lib/application/pages/bookmarks_page.dart | 13 +- app/lib/application/pages/history_page.dart | 14 +-- app/lib/application/pages/page_blocks.dart | 38 ++++-- .../application/pages/subscriptions_page.dart | 13 +- app/lib/presentation/app.dart | 14 ++- .../presentation/reader/mews_page_view.dart | 119 +++++++++++++++--- .../presentation/reader/reader_screen.dart | 3 + 8 files changed, 162 insertions(+), 55 deletions(-) diff --git a/SPEC.md b/SPEC.md index 557f370..aff0467 100644 --- a/SPEC.md +++ b/SPEC.md @@ -72,4 +72,5 @@ The v1 client presents one reader screen: - The reader can tag bookmarks and sites. A tag exists only while something carries it, so adding a tag and making one are the same act; `mews:tags` lists every tag in use and what carries it, and following a tag narrows the list it belongs to. There is no renaming and no empty tag. - Each library page carries a search box that narrows it by title or address, and each row carries its own actions: remove a bookmark, leave a site or check it now, forget a page. History lists one row per address, headed by the most recent visit, rather than repeating a page once per visit. Clearing history takes a second step on a page the reader can simply leave, since it cannot be undone. - A change is a link too: following `mews:do/` applies it and rerenders the page in place without adding a history entry. Only a link followed from one of the client's own pages is honored, so a fetched page cannot clear history or change a setting on the reader's behalf (spec 8.1). A form on one of these pages is routed the same way rather than sent to the network. -- Fonts are bundled, and v1 ships Atkinson Hyperlegible. Adding a font is one TTF in `app/assets/fonts/`, one `fonts` entry in `app/pubspec.yaml`, one option on the settings page, and one label in `app/locales/en-US/main.ftl`. +- Our own pages are built from the same blocks as a page but are interface, not an article, so the renderer lays them out differently: wider before they stop growing, denser, rows divided by a line instead of bulleted, section headings quieter, a note under a row set smaller and dimmer, links carried by weight rather than an underline, and a one-field form on a single line. +- Fonts are bundled, and v1 ships Atkinson Hyperlegible. The reader's typeface applies to the whole app, chrome included, so the address bar and the page below it are one face. Adding a font is one TTF in `app/assets/fonts/`, one `fonts` entry in `app/pubspec.yaml`, one option on the settings page, and one label in `app/locales/en-US/main.ftl`. diff --git a/app/lib/application/pages/bookmarks_page.dart b/app/lib/application/pages/bookmarks_page.dart index 55df896..1f727ee 100644 --- a/app/lib/application/pages/bookmarks_page.dart +++ b/app/lib/application/pages/bookmarks_page.dart @@ -29,15 +29,12 @@ Future buildBookmarksPage( field: 'q', value: query, ), - ParagraphBlock([ - if (query.isNotEmpty) ...[ - MewsLink(MewsUrls.page(url, {'q': null}), - [MewsText(strings.t('action-clear-search'))]), - const MewsText(actionSeparator), - ], + ...linkLine([ + if (query.isNotEmpty) + action(MewsUrls.page(url, {'q': null}), + strings.t('action-clear-search')), if (tag != null && tag.isNotEmpty) - MewsLink(MewsUrls.page(url, {'tag': null}), - [MewsText(strings.t('bookmarks-show-all'))]), + action(MewsUrls.page(url, {'tag': null}), strings.t('bookmarks-show-all')), ]), if (tag != null && tag.isNotEmpty) ParagraphBlock([ diff --git a/app/lib/application/pages/history_page.dart b/app/lib/application/pages/history_page.dart index 7d774a3..3a1715d 100644 --- a/app/lib/application/pages/history_page.dart +++ b/app/lib/application/pages/history_page.dart @@ -37,15 +37,13 @@ Future buildHistoryPage( MewsLink(MewsUrls.history, [MewsText(strings.t('history-clear-no'))]), ]), ] else - ParagraphBlock([ - if (query.isNotEmpty) ...[ - MewsLink(MewsUrls.page(url, {'q': null}), - [MewsText(strings.t('action-clear-search'))]), - const MewsText(actionSeparator), - ], + ...linkLine([ + if (query.isNotEmpty) + action(MewsUrls.page(url, {'q': null}), + strings.t('action-clear-search')), if (pages.isNotEmpty) - MewsLink(MewsUrls.page(url, {'confirm': 'clear'}), - [MewsText(strings.t('history-clear'))]), + action(MewsUrls.page(url, {'confirm': 'clear'}), + strings.t('history-clear')), ]), if (pages.isEmpty) ParagraphBlock([ diff --git a/app/lib/application/pages/page_blocks.dart b/app/lib/application/pages/page_blocks.dart index 2f95002..7f395a8 100644 --- a/app/lib/application/pages/page_blocks.dart +++ b/app/lib/application/pages/page_blocks.dart @@ -9,7 +9,7 @@ import 'mews_urls.dart'; /// Separates independent labels on one line. A separator is layout, so it is a /// constant here rather than a message. -const actionSeparator = ' '; +const actionSeparator = ' ยท '; /// A link with its label: a choice in a group, or an action on a row. For an /// action [current] is always false. @@ -59,10 +59,9 @@ List chrome({ ]; } -/// One row in a list: the item, then an optional note on its own line. A date -/// goes inside the link, the way a Mews page writes its own dated links (spec -/// 6.1), so the whole row is one target. One paragraph per row keeps rows -/// close together, since each paragraph carries its own bottom padding. +/// One row in a list: the item as a link, then its note and date on a second, +/// quieter line, then its chips and actions. One paragraph per row keeps the +/// lines together, since each paragraph carries its own bottom padding. MewsListItem itemRow({ required String href, required String label, @@ -72,23 +71,28 @@ MewsListItem itemRow({ List chips = const [], List actions = const [], }) { - final text = date == null ? label : '$date $label'; + final meta = [?note, ?date]; return MewsListItem([ ParagraphBlock([ MewsLink(href, [ if (strong) - MewsEmphasis(EmphasisStyle.strong, [MewsText(text)]) + MewsEmphasis(EmphasisStyle.strong, [MewsText(label)]) else - MewsText(text), + MewsText(label), ]), - if (note != null) ...[ + if (meta.isNotEmpty) ...[ const MewsLineBreak(), - MewsEmphasis(EmphasisStyle.emphasis, [MewsText(note)]), + MewsEmphasis( + EmphasisStyle.emphasis, [MewsText(meta.join(actionSeparator))]), ], if (chips.isNotEmpty) ...[ const MewsLineBreak(), - ..._separated( - [for (final chip in chips) MewsLink(chip.href, [MewsText(chip.label)])]), + // A leading hash tells a tag apart from the actions beside it, which + // links alone give no way to distinguish. + ..._separated([ + for (final chip in chips) + MewsLink(chip.href, [MewsText('#${chip.label}')]), + ]), ], if (actions.isNotEmpty) ...[ const MewsLineBreak(), @@ -175,6 +179,16 @@ FormBlock textForm({ ); } +/// A line of links with the separator between them, never trailing. Returns +/// nothing when there is no link to show, so a page never holds an empty line. +List linkLine(List links) { + if (links.isEmpty) return const []; + return [ + ParagraphBlock(_separated( + [for (final link in links) MewsLink(link.href, [MewsText(link.label)])])), + ]; +} + /// A plain list of links, used for menus and library lists alike. ListBlock itemList(List items) => ListBlock(ordered: false, start: 1, items: items); diff --git a/app/lib/application/pages/subscriptions_page.dart b/app/lib/application/pages/subscriptions_page.dart index c7fdf5a..89156cf 100644 --- a/app/lib/application/pages/subscriptions_page.dart +++ b/app/lib/application/pages/subscriptions_page.dart @@ -30,15 +30,12 @@ Future buildSubscriptionsPage( field: 'q', value: query, ), - ParagraphBlock([ - if (query.isNotEmpty) ...[ - MewsLink(MewsUrls.page(url, {'q': null}), - [MewsText(strings.t('action-clear-search'))]), - const MewsText(actionSeparator), - ], + ...linkLine([ + if (query.isNotEmpty) + action(MewsUrls.page(url, {'q': null}), + strings.t('action-clear-search')), if (tag != null && tag.isNotEmpty) - MewsLink(MewsUrls.page(url, {'tag': null}), - [MewsText(strings.t('subscriptions-show-all'))]), + action(MewsUrls.page(url, {'tag': null}), strings.t('subscriptions-show-all')), ]), if (tag != null && tag.isNotEmpty) ParagraphBlock([ diff --git a/app/lib/presentation/app.dart b/app/lib/presentation/app.dart index ea123a1..584ce00 100644 --- a/app/lib/presentation/app.dart +++ b/app/lib/presentation/app.dart @@ -127,6 +127,7 @@ class _ThemedApp extends StatelessWidget { _ => ThemeMode.system, }; final settings = deps.settings; + final font = settings.font.value; final page = deps.browser.currentPage.value; final variant = settings.variant.value == 'site' ? (page?.variant ?? MewsVariant.plain) @@ -135,14 +136,18 @@ class _ThemedApp extends StatelessWidget { return MaterialApp( title: 'Mews', themeMode: mode, - theme: _themeFor(paletteFor(pageVariant: variant, readerVariant: 'site', dark: false)), - darkTheme: _themeFor(paletteFor(pageVariant: variant, readerVariant: 'site', dark: true)), + theme: _themeFor( + paletteFor(pageVariant: variant, readerVariant: 'site', dark: false), + font), + darkTheme: _themeFor( + paletteFor(pageVariant: variant, readerVariant: 'site', dark: true), + font), home: const ReaderScreen(), ); }); } - ThemeData _themeFor(MewsPalette palette) { + ThemeData _themeFor(MewsPalette palette, String fontFamily) { final dark = palette.background.computeLuminance() < 0.5; final scheme = ColorScheme( brightness: dark ? Brightness.dark : Brightness.light, @@ -163,6 +168,9 @@ class _ThemedApp extends StatelessWidget { return ThemeData( useMaterial3: true, colorScheme: scheme, + // The chrome reads in the typeface the reader chose for pages, so the + // address bar and the page below it are not two different faces. + fontFamily: fontFamily, scaffoldBackgroundColor: palette.background, // Selection highlights and the caret follow the palette too. textSelectionTheme: TextSelectionThemeData( diff --git a/app/lib/presentation/reader/mews_page_view.dart b/app/lib/presentation/reader/mews_page_view.dart index a344a4b..d50830f 100644 --- a/app/lib/presentation/reader/mews_page_view.dart +++ b/app/lib/presentation/reader/mews_page_view.dart @@ -20,6 +20,7 @@ class MewsPageView extends StatefulWidget { required this.onLinkTap, required this.onFormSubmit, this.scrollController, + this.ownPage = false, super.key, }); @@ -32,6 +33,12 @@ class MewsPageView extends StatefulWidget { final void Function(FormBlock form, Map values) onFormSubmit; final ScrollController? scrollController; + /// Whether this is one of the client's own pages. Those are built from the + /// same blocks, but they are interface rather than an article, so they are + /// laid out denser and wider, rows are divided instead of bulleted, and + /// links read as controls rather than prose. + final bool ownPage; + @override State createState() => _MewsPageViewState(); } @@ -86,8 +93,10 @@ class _MewsPageViewState extends State { children: [ Center( child: ConstrainedBox( - // max-width: 40em - constraints: BoxConstraints(maxWidth: 40 * base), + // max-width: 40em for an article; our own pages are lists, so + // they get more room before they stop growing. + constraints: + BoxConstraints(maxWidth: (widget.ownPage ? 56 : 40) * base), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: blocks, @@ -105,7 +114,11 @@ class _MewsPageViewState extends State { padding: EdgeInsets.only(bottom: base), child: Text( text, - style: TextStyle(color: palette.foreground.withValues(alpha: 0.7), fontSize: base * 0.85), + style: TextStyle( + color: palette.foreground.withValues(alpha: 0.7), + fontFamily: widget.fontFamily, + fontSize: base * 0.85, + ), ), ); @@ -113,12 +126,19 @@ class _MewsPageViewState extends State { Widget _block(BuildContext context, MewsBlock block, double base) { final palette = widget.palette; - final foreground = TextStyle(color: palette.foreground, fontSize: base, height: 1.5); + final foreground = TextStyle( + color: palette.foreground, + fontFamily: widget.fontFamily, + fontSize: base, + height: 1.5, + ); return switch (block) { HeadingBlock(:final level, :final inline) => Padding( padding: EdgeInsets.only( - top: level == 1 ? base * 0.5 : base * 1.6, + top: level == 1 + ? base * 0.5 + : (widget.ownPage ? base : base * 1.6), bottom: base * 0.5, ), child: Text.rich( @@ -127,11 +147,16 @@ class _MewsPageViewState extends State { fontWeight: FontWeight.bold, height: 1.25, fontSize: base * const [1.6, 1.3, 1.1, 1.0, 1.0, 1.0][level - 1], + // A section label on our own pages, not a document heading. + color: widget.ownPage && level > 1 + ? palette.foreground.withValues(alpha: 0.7) + : null, + letterSpacing: widget.ownPage && level > 1 ? 0.4 : null, ), ), ), ParagraphBlock(:final inline) => Padding( - padding: EdgeInsets.only(bottom: base), + padding: EdgeInsets.only(bottom: widget.ownPage ? base * 0.35 : base), child: Text.rich(TextSpan(children: _inline(inline)), style: foreground), ), AddressBlock(:final inline) => Padding( @@ -168,9 +193,33 @@ class _MewsPageViewState extends State { ), ), RuleBlock() => Padding( - padding: EdgeInsets.symmetric(vertical: base * 2), + padding: EdgeInsets.symmetric( + vertical: widget.ownPage ? base * 0.6 : base * 2), child: Divider(color: palette.border, height: 1), ), + // Rows on our own pages: separated by a line rather than marked with a + // bullet, so a list of bookmarks or settings reads as a list of rows. + ListBlock(:final items) when widget.ownPage => Padding( + padding: EdgeInsets.only(bottom: base * 0.35), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var i = 0; i < items.length; i++) ...[ + if (i > 0) Divider(color: palette.border, height: 1), + Padding( + padding: EdgeInsets.only(top: base * 0.45), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final child in items[i].blocks) + _block(context, child, base), + ], + ), + ), + ], + ], + ), + ), ListBlock(:final ordered, :final start, :final items) => Padding( padding: EdgeInsets.only(bottom: base), child: Column( @@ -274,6 +323,7 @@ class _MewsPageViewState extends State { form: block, foreground: foreground, onFormSubmit: widget.onFormSubmit, + ownPage: widget.ownPage, ), ), }; @@ -311,6 +361,10 @@ class _MewsPageViewState extends State { MewsEmphasis(:final style, :final children) => TextSpan( style: switch (style) { EmphasisStyle.strong => const TextStyle(fontWeight: FontWeight.bold), + EmphasisStyle.emphasis when widget.ownPage => TextStyle( + color: palette.foreground.withValues(alpha: 0.65), + fontSize: 15.0 * widget.fontScale, + ), EmphasisStyle.emphasis => const TextStyle(fontStyle: FontStyle.italic), EmphasisStyle.term => const TextStyle(fontStyle: FontStyle.italic), EmphasisStyle.quote => const TextStyle(fontStyle: FontStyle.italic), @@ -320,7 +374,11 @@ class _MewsPageViewState extends State { MewsLink(:final href, :final children) => TextSpan( style: TextStyle( color: widget.visitedUrls.contains(href) ? palette.visited : palette.link, - decoration: TextDecoration.underline, + // Underlines mark links in prose; on our own pages they are + // controls, so weight carries them instead. + decoration: + widget.ownPage ? TextDecoration.none : TextDecoration.underline, + fontWeight: widget.ownPage ? FontWeight.w500 : null, ), recognizer: _recognizer(node), children: _inline(children, _recognizer(node))), @@ -342,12 +400,17 @@ class _MewsForm extends StatefulWidget { required this.form, required this.foreground, required this.onFormSubmit, + this.ownPage = false, }); final FormBlock form; final TextStyle foreground; final void Function(FormBlock form, Map values) onFormSubmit; + /// On our own pages a form is a search box or a one-field prompt, so it sits + /// on one line instead of filling the column like a page's own form. + final bool ownPage; + @override State<_MewsForm> createState() => _MewsFormState(); } @@ -370,6 +433,38 @@ class _MewsFormState extends State<_MewsForm> { final form = widget.form; final style = widget.foreground; + Widget button(MewsSubmitField field) => FilledButton( + onPressed: () => field.reset ? _reset(form) : _submit(form), + child: Text( + field.label ?? + (field.reset + ? strings.t('form-reset-default') + : strings.t('form-submit-default')), + style: TextStyle(fontFamily: style.fontFamily), + ), + ); + + if (widget.ownPage) { + final inputs = form.fields.where((f) => f is! MewsSubmitField).toList(); + return Form( + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + for (final field in inputs) + if (field is! MewsHiddenField) + Expanded(child: _field(field, style)) + else + _field(field, style), + for (final field in form.fields.whereType()) + Padding( + padding: const EdgeInsetsDirectional.only(start: 8), + child: button(field), + ), + ], + ), + ); + } + return Form( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -378,13 +473,7 @@ class _MewsFormState extends State<_MewsForm> { for (final field in form.fields.whereType()) Padding( padding: const EdgeInsets.only(top: 8), - child: FilledButton( - onPressed: () => field.reset ? _reset(form) : _submit(form), - child: Text( - field.label ?? - (field.reset ? strings.t('form-reset-default') : strings.t('form-submit-default')), - ), - ), + child: button(field), ), ], ), diff --git a/app/lib/presentation/reader/reader_screen.dart b/app/lib/presentation/reader/reader_screen.dart index 26e7f0d..7a06c62 100644 --- a/app/lib/presentation/reader/reader_screen.dart +++ b/app/lib/presentation/reader/reader_screen.dart @@ -4,6 +4,7 @@ import 'package:signals/signals_flutter.dart' show SignalBuilder; import '../../application/controllers/browser_controller.dart' show BrokenPage, LoadFailed; +import '../../application/pages/mews_urls.dart'; import '../../mews/document.dart'; import '../../mews/parser.dart' show mewsPlainText; import '../app.dart'; @@ -159,6 +160,7 @@ class _ReaderScreenState extends State { return MewsPageView( key: ValueKey(page.url), page: page, + ownPage: MewsUrls.isInternal(page.url), palette: palette, fontFamily: font, fontScale: fontScale, @@ -186,6 +188,7 @@ class _ReaderScreenState extends State { color: palette.background, child: MewsPageView( page: page, + ownPage: MewsUrls.isInternal(page.url), palette: palette, fontFamily: settings.font.value, fontScale: settings.fontScale.value,