feat: lay our own pages out as interface rather than as articles

This commit is contained in:
randogoth 2026-10-11 14:03:44 +03:00
parent e7a8e91334
commit dfba1cfb16
8 changed files with 162 additions and 55 deletions

View file

@ -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/<verb>` 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`.

View file

@ -29,15 +29,12 @@ Future<MewsPage> 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([

View file

@ -37,15 +37,13 @@ Future<MewsPage> 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([

View file

@ -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<MewsBlock> 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<Choice> chips = const [],
List<Choice> 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<MewsBlock> linkLine(List<Choice> 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<MewsListItem> items) =>
ListBlock(ordered: false, start: 1, items: items);

View file

@ -30,15 +30,12 @@ Future<MewsPage> 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([

View file

@ -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(

View file

@ -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<String, String> 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<MewsPageView> createState() => _MewsPageViewState();
}
@ -86,8 +93,10 @@ class _MewsPageViewState extends State<MewsPageView> {
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<MewsPageView> {
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<MewsPageView> {
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<MewsPageView> {
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<MewsPageView> {
),
),
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<MewsPageView> {
form: block,
foreground: foreground,
onFormSubmit: widget.onFormSubmit,
ownPage: widget.ownPage,
),
),
};
@ -311,6 +361,10 @@ class _MewsPageViewState extends State<MewsPageView> {
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<MewsPageView> {
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<String, String> 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<MewsSubmitField>())
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<MewsSubmitField>())
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),
),
],
),

View file

@ -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<ReaderScreen> {
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<ReaderScreen> {
color: palette.background,
child: MewsPageView(
page: page,
ownPage: MewsUrls.isInternal(page.url),
palette: palette,
fontFamily: settings.font.value,
fontScale: settings.fontScale.value,