nandi/frqpublic Fork
⑂ 535605d
Commits
⬇ Clone ▾
git clone https://git.rickub.com/nandi/frq.git
git clone ssh://git@rickub.com/nandi/frq.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

An overview you can skim on a phone

Each entry was [room, sender, text, →] in one wrapping row. On a phone
that is three lines: the sender, then the text, then the arrow alone
on a row of its own -- a full-height button saying nothing the row had
not already said, and the only part of the entry that could be
pressed. Eight of them filled the screen with furniture.

So the arrow goes and the card takes the press. A `card` with an
`onClick` is both the larger target and the smaller thing to draw, and
it is the pattern the Chats list already uses. What is left is what a
reader skims: who said it and where, then what they said.

The text is clamped to two lines rather than to 48 characters. How
much fits on a line is a fact about the font and the width and a
character count knows neither -- the same 96 characters are two lines
on a phone and five in a widget test, which is why tuning the cap
against the test font was measuring the wrong thing. `summarise` still
bounds what crosses the wire; the renderer bounds what is seen.

Measured, and the measuring found something else: `frq_ui_demo` made
exactly one room, so the overview in every layout test was "Nothing
has happened anywhere else." The test that laid it out was laying out
an empty box. There is a second room now, with a long bot line and a
short one -- awkward on purpose, like the rest of that fixture. An
entry went from 167 points to 91, and 186 with the clamp reverted.

Two older tests were resting on position and are pinned to content
instead: one took `find.text('Open').first` from a list that now has
two rooms in it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-20T14:49:08-07:00 Browse files
535605d parent: df9fc99
modified flutter/lib/nim_renderer.dart +24 -2
@@ -507,7 +507,7 @@ class _NimAppState extends State<NimApp> {
507507
508508 // Container::Card in the Clojure: padding 12, fills its width.
509509 case 'card':
510- return Container(
510+ final card = Container(
511511 width: double.infinity,
512512 margin: const EdgeInsets.symmetric(vertical: t.spaceXxxs),
513513 padding: const EdgeInsets.all(t.spaceXs),
@@ -520,6 +520,20 @@ class _NimAppState extends State<NimApp> {
520520 children: _spaced(kids, spacing > 0 ? spacing : t.spaceXxs,
521521 vertical: true)),
522522 );
523+ // A card that is itself the control. Where every line in a list goes
524+ // to the same place, a button on each one is a small target beside a
525+ // large inert thing -- and on a phone the button is what wraps onto
526+ // a row of its own. The whole card takes the press instead.
527+ final tap = n.prop('onClick', '');
528+ if (tap.isEmpty) return card;
529+ return Material(
530+ color: Colors.transparent,
531+ child: InkWell(
532+ onTap: () => _send(tap),
533+ borderRadius: BorderRadius.circular(t.radiusS),
534+ child: card,
535+ ),
536+ );
523537
524538 case 'title':
525539 final shrinks = n.prop('shrink', false);
@@ -553,7 +567,15 @@ class _NimAppState extends State<NimApp> {
553567 /// wraps. Kept apart from `label` because a wrapping label in a row
554568 /// lays out against the row's width rather than the column's.
555569 case 'text':
556- return Text(n.prop('text', ''), style: _style(t.textBody, t.onBg));
570+ // `lines` clamps to that many and ellipsises. A character count
571+ // cannot do this job: how much fits on a line is a fact about the
572+ // font and the width, and the two disagree — in a widget test the
573+ // same 96 characters take five lines where a phone gives them two.
574+ final lines = n.prop('lines', 0);
575+ return Text(n.prop('text', ''),
576+ maxLines: lines > 0 ? lines : null,
577+ overflow: lines > 0 ? TextOverflow.ellipsis : TextOverflow.clip,
578+ style: _style(t.textBody, t.onBg));
557579
558580 case 'link':
559581 {
@@ -507,7 +507,7 @@ class _NimAppState extends State<NimApp> {
507 507
508 // Container::Card in the Clojure: padding 12, fills its width.508 // Container::Card in the Clojure: padding 12, fills its width.
509 case 'card':509 case 'card':
510- return Container(510+ final card = Container(
511 width: double.infinity,511 width: double.infinity,
512 margin: const EdgeInsets.symmetric(vertical: t.spaceXxxs),512 margin: const EdgeInsets.symmetric(vertical: t.spaceXxxs),
513 padding: const EdgeInsets.all(t.spaceXs),513 padding: const EdgeInsets.all(t.spaceXs),
@@ -520,6 +520,20 @@ class _NimAppState extends State<NimApp> {
520 children: _spaced(kids, spacing > 0 ? spacing : t.spaceXxs,520 children: _spaced(kids, spacing > 0 ? spacing : t.spaceXxs,
521 vertical: true)),521 vertical: true)),
522 );522 );
523+ // A card that is itself the control. Where every line in a list goes
524+ // to the same place, a button on each one is a small target beside a
525+ // large inert thing -- and on a phone the button is what wraps onto
526+ // a row of its own. The whole card takes the press instead.
527+ final tap = n.prop('onClick', '');
528+ if (tap.isEmpty) return card;
529+ return Material(
530+ color: Colors.transparent,
531+ child: InkWell(
532+ onTap: () => _send(tap),
533+ borderRadius: BorderRadius.circular(t.radiusS),
534+ child: card,
535+ ),
536+ );
523 537
524 case 'title':538 case 'title':
525 final shrinks = n.prop('shrink', false);539 final shrinks = n.prop('shrink', false);
@@ -553,7 +567,15 @@ class _NimAppState extends State<NimApp> {
553 /// wraps. Kept apart from `label` because a wrapping label in a row567 /// wraps. Kept apart from `label` because a wrapping label in a row
554 /// lays out against the row's width rather than the column's.568 /// lays out against the row's width rather than the column's.
555 case 'text':569 case 'text':
556- return Text(n.prop('text', ''), style: _style(t.textBody, t.onBg));570+ // `lines` clamps to that many and ellipsises. A character count
571+ // cannot do this job: how much fits on a line is a fact about the
572+ // font and the width, and the two disagree — in a widget test the
573+ // same 96 characters take five lines where a phone gives them two.
574+ final lines = n.prop('lines', 0);
575+ return Text(n.prop('text', ''),
576+ maxLines: lines > 0 ? lines : null,
577+ overflow: lines > 0 ? TextOverflow.ellipsis : TextOverflow.clip,
578+ style: _style(t.textBody, t.onBg));
557 579
558 case 'link':580 case 'link':
559 {581 {
modified flutter/test/nim_layout_test.dart +63 -2
@@ -232,8 +232,14 @@ void main() {
232232 core.demoUi();
233233 core.dispatch('screen.chats');
234234 await layOut(tester, sizes['desktop']!);
235- // The row's "Open", not its name: the name is a label in this list.
236- await tester.tap(find.text('Open').first);
235+ // The row's "Open", not its name: the name is a label in this list —
236+ // and the Open belonging to #test, not whichever card is first. There
237+ // is more than one room in the demo now, and `.first` was a fact about
238+ // the order they happen to come back in.
239+ final card = find
240+ .ancestor(of: find.text('#test'), matching: find.byType(Container))
241+ .last;
242+ await tester.tap(find.descendant(of: card, matching: find.text('Open')));
237243 await tester.pump();
238244 expect(find.text('hello there'), findsWidgets,
239245 reason: 'tapping the room did not open it');
@@ -533,4 +539,59 @@ void main() {
533539 expectLaidOut(tester, 'a long room name');
534540 });
535541 });
542+
543+ group('the overview on a phone', () {
544+ testWidgets('an entry is two lines, not three', (tester) async {
545+ // The complaint, measured. Each entry was [room, sender, text, →] in a
546+ // Wrap: on a phone the arrow wrapped to a row of its own, so an entry
547+ // stood about three lines tall and most of that was a button. Counting
548+ // rows is unreliable across themes; the height of one entry is not.
549+ core.demoUi();
550+ core.dispatch('overview.toggle');
551+ await layOut(tester, sizes['phone']!);
552+
553+ // The entry carrying a known line, found by what is in it rather than
554+ // by position among every InkWell on the screen.
555+ final entry = find
556+ .ancestor(
557+ of: find.textContaining('sandbox-01'),
558+ matching: find.byType(InkWell))
559+ .first;
560+ expect(entry, findsOneWidget, reason: 'the entry is not tappable');
561+
562+ final h = tester.getRect(entry).height;
563+ expect(h, lessThan(96),
564+ reason: 'an overview entry is $h tall — three lines of furniture');
565+
566+ // The long one, which is what the complaint was actually about: a bot
567+ // line runs for paragraphs and a summary of it must still be an entry
568+ // in a list rather than a page of its own.
569+ final long = find
570+ .ancestor(
571+ of: find.textContaining('Result'), matching: find.byType(InkWell))
572+ .first;
573+ final lh = tester.getRect(long).height;
574+ // 167 before the text was clamped, and the clamp is what holds this:
575+ // a character cap cannot, because it is the font and the width that
576+ // decide how many lines 96 characters become.
577+ expect(lh, lessThan(110),
578+ reason: 'a long line makes a $lh-tall entry');
579+ expectLaidOut(tester, 'the overview on a phone');
580+ });
581+
582+ testWidgets('and the whole entry is the control, not an arrow',
583+ (tester) async {
584+ core.demoUi();
585+ core.dispatch('overview.toggle');
586+ await layOut(tester, sizes['phone']!);
587+ // Scoped to the overview: a `→` also jumps to the line a reply
588+ // answers, up in the backlog, and that one is not this one.
589+ final card = find
590+ .ancestor(
591+ of: find.textContaining('sandbox-01'), matching: find.byType(InkWell))
592+ .first;
593+ expect(find.descendant(of: card, matching: find.text('→')), findsNothing,
594+ reason: 'the arrow button is back');
595+ });
596+ });
536597 }
@@ -232,8 +232,14 @@ void main() {
232 core.demoUi();232 core.demoUi();
233 core.dispatch('screen.chats');233 core.dispatch('screen.chats');
234 await layOut(tester, sizes['desktop']!);234 await layOut(tester, sizes['desktop']!);
235- // The row's "Open", not its name: the name is a label in this list.235+ // The row's "Open", not its name: the name is a label in this list —
236- await tester.tap(find.text('Open').first);236+ // and the Open belonging to #test, not whichever card is first. There
237+ // is more than one room in the demo now, and `.first` was a fact about
238+ // the order they happen to come back in.
239+ final card = find
240+ .ancestor(of: find.text('#test'), matching: find.byType(Container))
241+ .last;
242+ await tester.tap(find.descendant(of: card, matching: find.text('Open')));
237 await tester.pump();243 await tester.pump();
238 expect(find.text('hello there'), findsWidgets,244 expect(find.text('hello there'), findsWidgets,
239 reason: 'tapping the room did not open it');245 reason: 'tapping the room did not open it');
@@ -533,4 +539,59 @@ void main() {
533 expectLaidOut(tester, 'a long room name');539 expectLaidOut(tester, 'a long room name');
534 });540 });
535 });541 });
542+
543+ group('the overview on a phone', () {
544+ testWidgets('an entry is two lines, not three', (tester) async {
545+ // The complaint, measured. Each entry was [room, sender, text, →] in a
546+ // Wrap: on a phone the arrow wrapped to a row of its own, so an entry
547+ // stood about three lines tall and most of that was a button. Counting
548+ // rows is unreliable across themes; the height of one entry is not.
549+ core.demoUi();
550+ core.dispatch('overview.toggle');
551+ await layOut(tester, sizes['phone']!);
552+
553+ // The entry carrying a known line, found by what is in it rather than
554+ // by position among every InkWell on the screen.
555+ final entry = find
556+ .ancestor(
557+ of: find.textContaining('sandbox-01'),
558+ matching: find.byType(InkWell))
559+ .first;
560+ expect(entry, findsOneWidget, reason: 'the entry is not tappable');
561+
562+ final h = tester.getRect(entry).height;
563+ expect(h, lessThan(96),
564+ reason: 'an overview entry is $h tall — three lines of furniture');
565+
566+ // The long one, which is what the complaint was actually about: a bot
567+ // line runs for paragraphs and a summary of it must still be an entry
568+ // in a list rather than a page of its own.
569+ final long = find
570+ .ancestor(
571+ of: find.textContaining('Result'), matching: find.byType(InkWell))
572+ .first;
573+ final lh = tester.getRect(long).height;
574+ // 167 before the text was clamped, and the clamp is what holds this:
575+ // a character cap cannot, because it is the font and the width that
576+ // decide how many lines 96 characters become.
577+ expect(lh, lessThan(110),
578+ reason: 'a long line makes a $lh-tall entry');
579+ expectLaidOut(tester, 'the overview on a phone');
580+ });
581+
582+ testWidgets('and the whole entry is the control, not an arrow',
583+ (tester) async {
584+ core.demoUi();
585+ core.dispatch('overview.toggle');
586+ await layOut(tester, sizes['phone']!);
587+ // Scoped to the overview: a `→` also jumps to the line a reply
588+ // answers, up in the backlog, and that one is not this one.
589+ final card = find
590+ .ancestor(
591+ of: find.textContaining('sandbox-01'), matching: find.byType(InkWell))
592+ .first;
593+ expect(find.descendant(of: card, matching: find.text('→')), findsNothing,
594+ reason: 'the arrow button is back');
595+ });
596+ });
536 }597 }
modified nim/src/frq/screens/chat.nim +23 -8
@@ -286,22 +286,37 @@ proc overviewPane(s: State): Node =
286286 ## `rooms.recentEverywhere`. Taking the newest hundred would be the strip
287287 ## answering about whichever room is busiest, which is the one already on
288288 ## screen.
289- result = card(title2("Overview"))
290- var body = vbox(%*{"spacing": 4})
289+ # One card a line, and the card is the control.
290+ #
291+ # This was a single card holding rows of [room, sender, text, →]. In a
292+ # Wrap on a phone that is three lines an entry: the sender on the first,
293+ # the text on the second, and the arrow alone on a third -- a full-height
294+ # button carrying no more meaning than the row it had been pushed off.
295+ # Eight entries filled the screen with mostly furniture.
296+ #
297+ # So the arrow is gone and the whole card takes the press, which is both
298+ # the larger target and the smaller thing to draw. What is left is what a
299+ # reader actually skims: who said it and where, then what they said.
300+ var body = vbox(%*{"spacing": 4}, title2("Overview"))
291301 var n = 0
292302 for m in recentEverywhere(s.rooms, s.current):
293303 if n >= overviewLines: break
294304 n += 1
295305 # Each line carries the room it was said in, since that is the one thing a
296306 # line taken out of its own conversation no longer says for itself.
297- body.children.add hbox(%*{"spacing": 6},
298- dimLabel(m.room),
299- label(m.frm & ":"),
300- text(summarise(m.text, 48)),
301- button("→", "overview.goto:" & m.room & ":" & rowId(m)))
307+ body.children.add card(
308+ %*{"onClick": "overview.goto:" & m.room & ":" & rowId(m),
309+ "spacing": 2},
310+ hbox(%*{"spacing": 6}, dimLabel(m.room), label(m.frm)),
311+ # Two lines, and the cap is in lines rather than characters. How much
312+ # fits on one is a fact about the font and the width, and a character
313+ # count knows neither: 96 of them are two lines on a phone and five in
314+ # a widget test. `summarise` still bounds what crosses the wire, but
315+ # what a reader sees is bounded by the thing they are looking at.
316+ text(summarise(m.text, 160), lines = 2))
302317 if n == 0:
303318 body.children.add dimLabel("Nothing has happened anywhere else.")
304- result.children.add body
319+ result = body
305320
306321 proc lightboxPane(s: State): Node =
307322 ## The picture being looked at, as large as the window allows.
@@ -286,22 +286,37 @@ proc overviewPane(s: State): Node =
286 ## `rooms.recentEverywhere`. Taking the newest hundred would be the strip286 ## `rooms.recentEverywhere`. Taking the newest hundred would be the strip
287 ## answering about whichever room is busiest, which is the one already on287 ## answering about whichever room is busiest, which is the one already on
288 ## screen.288 ## screen.
289- result = card(title2("Overview"))289+ # One card a line, and the card is the control.
290- var body = vbox(%*{"spacing": 4})290+ #
291+ # This was a single card holding rows of [room, sender, text, →]. In a
292+ # Wrap on a phone that is three lines an entry: the sender on the first,
293+ # the text on the second, and the arrow alone on a third -- a full-height
294+ # button carrying no more meaning than the row it had been pushed off.
295+ # Eight entries filled the screen with mostly furniture.
296+ #
297+ # So the arrow is gone and the whole card takes the press, which is both
298+ # the larger target and the smaller thing to draw. What is left is what a
299+ # reader actually skims: who said it and where, then what they said.
300+ var body = vbox(%*{"spacing": 4}, title2("Overview"))
291 var n = 0301 var n = 0
292 for m in recentEverywhere(s.rooms, s.current):302 for m in recentEverywhere(s.rooms, s.current):
293 if n >= overviewLines: break303 if n >= overviewLines: break
294 n += 1304 n += 1
295 # Each line carries the room it was said in, since that is the one thing a305 # Each line carries the room it was said in, since that is the one thing a
296 # line taken out of its own conversation no longer says for itself.306 # line taken out of its own conversation no longer says for itself.
297- body.children.add hbox(%*{"spacing": 6},307+ body.children.add card(
298- dimLabel(m.room),308+ %*{"onClick": "overview.goto:" & m.room & ":" & rowId(m),
299- label(m.frm & ":"),309+ "spacing": 2},
300- text(summarise(m.text, 48)),310+ hbox(%*{"spacing": 6}, dimLabel(m.room), label(m.frm)),
301- button("→", "overview.goto:" & m.room & ":" & rowId(m)))311+ # Two lines, and the cap is in lines rather than characters. How much
312+ # fits on one is a fact about the font and the width, and a character
313+ # count knows neither: 96 of them are two lines on a phone and five in
314+ # a widget test. `summarise` still bounds what crosses the wire, but
315+ # what a reader sees is bounded by the thing they are looking at.
316+ text(summarise(m.text, 160), lines = 2))
302 if n == 0:317 if n == 0:
303 body.children.add dimLabel("Nothing has happened anywhere else.")318 body.children.add dimLabel("Nothing has happened anywhere else.")
304- result.children.add body319+ result = body
305 320
306 proc lightboxPane(s: State): Node =321 proc lightboxPane(s: State): Node =
307 ## The picture being looked at, as large as the window allows.322 ## The picture being looked at, as large as the window allows.
modified nim/src/frq/ui.nim +11 -2
@@ -56,6 +56,10 @@ func hbox*(props: JsonNode, children: varargs[Node]): Node =
5656 n("hbox", props, @children)
5757 func card*(children: varargs[Node]): Node =
5858 n("card", newJObject(), @children)
59+func card*(props: JsonNode, children: varargs[Node]): Node =
60+ ## A card with props — `onClick` makes the whole card the control, for a
61+ ## list whose every line goes somewhere.
62+ n("card", props, @children)
5963 func page*(props: JsonNode, children: varargs[Node]): Node =
6064 n("page", props, @children)
6165
@@ -118,9 +122,14 @@ func paragraph*(children: varargs[Node]): Node =
118122 ## contradiction before every row it drew.
119123 n("paragraph", newJObject(), @children)
120124
121-func text*(body: string): Node =
125+func text*(body: string, lines = 0): Node =
122126 ## Prose, as opposed to a `label`: wraps, and is the thing a message is.
123- n("text", %*{"text": body})
127+ ##
128+ ## `lines` caps how many it may take, ellipsising after. Left at 0 it says
129+ ## all of itself, which is what a message in a conversation does.
130+ var p = %*{"text": body}
131+ if lines > 0: p["lines"] = %lines
132+ n("text", p)
124133
125134 func link*(label, url: string): Node =
126135 n("link", %*{"label": label, "url": url})
@@ -56,6 +56,10 @@ func hbox*(props: JsonNode, children: varargs[Node]): Node =
56 n("hbox", props, @children)56 n("hbox", props, @children)
57 func card*(children: varargs[Node]): Node =57 func card*(children: varargs[Node]): Node =
58 n("card", newJObject(), @children)58 n("card", newJObject(), @children)
59+func card*(props: JsonNode, children: varargs[Node]): Node =
60+ ## A card with props — `onClick` makes the whole card the control, for a
61+ ## list whose every line goes somewhere.
62+ n("card", props, @children)
59 func page*(props: JsonNode, children: varargs[Node]): Node =63 func page*(props: JsonNode, children: varargs[Node]): Node =
60 n("page", props, @children)64 n("page", props, @children)
61 65
@@ -118,9 +122,14 @@ func paragraph*(children: varargs[Node]): Node =
118 ## contradiction before every row it drew.122 ## contradiction before every row it drew.
119 n("paragraph", newJObject(), @children)123 n("paragraph", newJObject(), @children)
120 124
121-func text*(body: string): Node =125+func text*(body: string, lines = 0): Node =
122 ## Prose, as opposed to a `label`: wraps, and is the thing a message is.126 ## Prose, as opposed to a `label`: wraps, and is the thing a message is.
123- n("text", %*{"text": body})127+ ##
128+ ## `lines` caps how many it may take, ellipsising after. Left at 0 it says
129+ ## all of itself, which is what a message in a conversation does.
130+ var p = %*{"text": body}
131+ if lines > 0: p["lines"] = %lines
132+ n("text", p)
124 133
125 func link*(label, url: string): Node =134 func link*(label, url: string): Node =
126 n("link", %*{"label": label, "url": url})135 n("link", %*{"label": label, "url": url})
modified nim/src/frq_core.nim +18 -0
@@ -247,6 +247,24 @@ proc frq_ui_demo*() {.exportc, dynlib.} =
247247 # A different day, so a heading has to land between them.
248248 Message(id: "8", frm: "alice", text: "next day", at: t0 + 200_000_000)]
249249 app.rooms["#test"] = r
250+
251+ # Somewhere else, because the overview is about every room but this one and
252+ # with a single room there was nothing for it to show. The test that laid
253+ # the overview out was laying out "Nothing has happened anywhere else."
254+ #
255+ # Awkward on purpose, like the rest: a long bot line that has to be cut,
256+ # and a short one that must not look different for it.
257+ app.rooms.ensureRoom("#tasks")
258+ var other = app.rooms["#tasks"]
259+ other.joined = true
260+ other.messages = @[
261+ Message(id: "o1", frm: "freeq-bot", text: "sandbox-01: I am sandbox 1 of 50.",
262+ at: t0 + 100_000),
263+ Message(id: "o2", frm: "freeq-bot",
264+ text: "**Result**: 50/50 announcements collected in ~12s, and the rest of a sentence that has to be cut somewhere sensible.",
265+ at: t0 + 200_000)]
266+ app.rooms["#tasks"] = other
267+
250268 app.current = "#test"
251269 app.screen = scChat
252270 app.status = "Connected as me"
@@ -247,6 +247,24 @@ proc frq_ui_demo*() {.exportc, dynlib.} =
247 # A different day, so a heading has to land between them.247 # A different day, so a heading has to land between them.
248 Message(id: "8", frm: "alice", text: "next day", at: t0 + 200_000_000)]248 Message(id: "8", frm: "alice", text: "next day", at: t0 + 200_000_000)]
249 app.rooms["#test"] = r249 app.rooms["#test"] = r
250+
251+ # Somewhere else, because the overview is about every room but this one and
252+ # with a single room there was nothing for it to show. The test that laid
253+ # the overview out was laying out "Nothing has happened anywhere else."
254+ #
255+ # Awkward on purpose, like the rest: a long bot line that has to be cut,
256+ # and a short one that must not look different for it.
257+ app.rooms.ensureRoom("#tasks")
258+ var other = app.rooms["#tasks"]
259+ other.joined = true
260+ other.messages = @[
261+ Message(id: "o1", frm: "freeq-bot", text: "sandbox-01: I am sandbox 1 of 50.",
262+ at: t0 + 100_000),
263+ Message(id: "o2", frm: "freeq-bot",
264+ text: "**Result**: 50/50 announcements collected in ~12s, and the rest of a sentence that has to be cut somewhere sensible.",
265+ at: t0 + 200_000)]
266+ app.rooms["#tasks"] = other
267+
250 app.current = "#test"268 app.current = "#test"
251 app.screen = scChat269 app.screen = scChat
252 app.status = "Connected as me"270 app.status = "Connected as me"
modified nim/tests/tchat.nim +12 -0
@@ -266,6 +266,18 @@ suite "the overview":
266266 .anyIt("hello" in it.props{"text"}.getStr() and
267267 it.props{"text"}.getStr() != "hello")
268268
269+ test "each line is a card that is itself the way there":
270+ # It used to be a row ending in a "→" button. In a Wrap on a phone the
271+ # button landed on a line of its own, so every entry cost three rows and
272+ # the smallest thing on screen was the only part that could be pressed.
273+ app.overview = true
274+ let t = cs.chatScreen(app, true)
275+ check "→" notin t.labels("button")
276+ let cards = t.find("card").filterIt(
277+ it.props{"onClick"}.getStr().startsWith("overview.goto:"))
278+ check cards.len == 1
279+ check cards[0].props{"onClick"}.getStr() == "overview.goto:#other:o1"
280+
269281 test "an empty one says so":
270282 var lonely = withRoom()
271283 lonely.overview = true
@@ -266,6 +266,18 @@ suite "the overview":
266 .anyIt("hello" in it.props{"text"}.getStr() and266 .anyIt("hello" in it.props{"text"}.getStr() and
267 it.props{"text"}.getStr() != "hello")267 it.props{"text"}.getStr() != "hello")
268 268
269+ test "each line is a card that is itself the way there":
270+ # It used to be a row ending in a "→" button. In a Wrap on a phone the
271+ # button landed on a line of its own, so every entry cost three rows and
272+ # the smallest thing on screen was the only part that could be pressed.
273+ app.overview = true
274+ let t = cs.chatScreen(app, true)
275+ check "→" notin t.labels("button")
276+ let cards = t.find("card").filterIt(
277+ it.props{"onClick"}.getStr().startsWith("overview.goto:"))
278+ check cards.len == 1
279+ check cards[0].props{"onClick"}.getStr() == "overview.goto:#other:o1"
280+
269 test "an empty one says so":281 test "an empty one says so":
270 var lonely = withRoom()282 var lonely = withRoom()
271 lonely.overview = true283 lonely.overview = true