Group the message features by what somebody is trying to do (relay#44) #152

Merged
paulhitt merged 1 commits from feat/features-grouped-by-intent into main 2026-09-08 11:55:38 +00:00
3 changed files with 110 additions and 48 deletions
+15
View File
@@ -267,6 +267,21 @@ a { color: inherit; }
.cards-3 .card:hover { box-shadow: var(--shadow-lg); }
.cards-3 .card-icon { margin-left: 0; }
.card h3 { margin: 0 0 10px; font-size: 17.5px; letter-spacing: -.015em; font-weight: 700; }
/* A card inside a group: the group owns the h3, so the card's title is an h4
and has to keep the same look. Styled together so the two cannot drift. */
.card h4 { margin: 0 0 10px; font-size: 17.5px; letter-spacing: -.015em; font-weight: 700; }
/* Groups exist so the page can gain features without gaining top-level
sections (relay#44). Quieter than an h2 band head, louder than a card. */
.group-head {
margin: 46px 0 22px;
font-size: 15px;
font-weight: 700;
letter-spacing: .06em;
text-transform: uppercase;
color: var(--ink-2);
text-align: center;
}
.group-head:first-of-type { margin-top: 30px; }
.card p { margin: 0; color: var(--ink-2); font-size: 15px; line-height: 1.6; }
.card-icon {
display: flex; align-items: center; justify-content: center;
+56 -48
View File
@@ -26,10 +26,11 @@
accidentally end up in.
</p>
<h3 class="group-head">Saying something</h3>
<div class="cards">
<article class="card">
<span class="card-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 5h16v10H9l-5 4z"/></svg></span>
<h3>One-to-one and groups</h3>
<h4>One-to-one and groups</h4>
<p>
Text, photos and voice notes, on your own or with everybody. Mention
someone and they'll hear about it even if they've muted the group —
@@ -38,7 +39,7 @@
</article>
<article class="card">
<span class="card-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 4l4 1 1 4-2 2a12 12 0 0 0 5 5l2-2 4 1 1 4a2 2 0 0 1-2 2A17 17 0 0 1 3 6a2 2 0 0 1 2-2"/></svg></span>
<h3>Voice calls</h3>
<h4>Voice calls</h4>
<p>
Ring someone directly. The audio is encrypted between the two phones,
and the check that you're talking to the right person rides inside the
@@ -47,60 +48,28 @@
</article>
<article class="card">
<span class="card-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9" stroke-dasharray="3 3"/></svg></span>
<h3>Stories</h3>
<h4>Stories</h4>
<p>
Post a picture, a few words or a link, for as long as you choose —
an hour, or a week. Only your contacts see it, and the server is never
told how long it's meant to last.
</p>
</article>
<article class="card">
<span class="card-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg></span>
<h3>Chats that clear themselves</h3>
<p>
Set a conversation to tidy up after an hour, a day or a week. It
applies to <strong>both</strong> phones, and the server isn't told a
conversation is timed, let alone for how long.
</p>
</article>
<article class="card">
<span class="card-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20v-1a6 6 0 0 1 6-6h1"/><circle cx="10.5" cy="7" r="3.5"/><path d="M17 14v4"/><path d="M17 21h.01"/></svg></span>
<h3>No phone number</h3>
<p>
Sign up with a username and an email. People find you by the name they
already call you, and your number stays out of it entirely.
</p>
</article>
<article class="card">
<span class="card-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l7 4v5c0 4-3 7-7 9-4-2-7-5-7-9V7z"/><path d="M9 12l2 2 4-4"/></svg></span>
<h3>You'll know if a key changes</h3>
<p>
If someone's phone is replaced — or somebody tries to stand in for
them — the app stops and tells you, with one thing you can do about
it. There's no way to wave it away, because there shouldn't be.
</p>
</article>
<article class="card">
<span class="card-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20h4l10-10a2.8 2.8 0 0 0-4-4L4 16z"/><path d="M13.5 6.5l4 4"/></svg></span>
<h3>Fix the typo</h3>
<h4>Fix the typo</h4>
<p>
Change a message after you've sent it. It turns up on the other phone
as the same message, marked edited — not as a second one nobody can
tell apart from the first.
</p>
</article>
<article class="card">
<span class="card-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="6"/><path d="M20 20l-4.5-4.5"/></svg></span>
<h3>Find what you said</h3>
<p>
Search across every conversation you have. It happens on your phone,
because the server has never been able to read a word of it — which
is also why nobody can search it but you.
</p>
</article>
</div>
<h3 class="group-head">On your own time</h3>
<div class="cards">
<article class="card">
<span class="card-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="16" rx="2"/><path d="M8 3v4M16 3v4M3 10h18"/><path d="M12 14v3l2 1"/></svg></span>
<h3>Write now, send later</h3>
<h4>Write now, send later</h4>
<p>
Say it while you're thinking of it and let it arrive at a decent
hour. The message waits <strong>on your phone</strong>, not on the
@@ -109,7 +78,7 @@
</article>
<article class="card">
<span class="card-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9a6 6 0 0 1 12 0c0 5 2 6 2 6H4s2-1 2-6"/><path d="M10 19a2 2 0 0 0 4 0"/></svg></span>
<h3>Nudge yourself, quiet a thread</h3>
<h4>Nudge yourself, quiet a thread</h4>
<p>
Ask to be reminded to reply, or put a conversation to sleep for the
afternoon. Both are kept on your phone — the server has no idea a
@@ -117,17 +86,46 @@
</p>
</article>
<article class="card">
<span class="card-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a4 4 0 0 0 6 .5l2-2a4 4 0 0 0-6-6l-1 1"/><path d="M14 11a4 4 0 0 0-6-.5l-2 2a4 4 0 0 0 6 6l1-1"/></svg></span>
<h3>Link previews, made at your end</h3>
<span class="card-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg></span>
<h4>Chats that clear themselves</h4>
<p>
When you share a link, <strong>your</strong> phone builds the little
card — so the site never hears from the person you sent it to. It
does hear from you, which is why there's a switch to turn it off.
Set a conversation to tidy up after an hour, a day or a week. It
applies to <strong>both</strong> phones, and the server isn't told a
conversation is timed, let alone for how long.
</p>
</article>
<article class="card">
<span class="card-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="6"/><path d="M20 20l-4.5-4.5"/></svg></span>
<h4>Find what you said</h4>
<p>
Search across every conversation you have. It happens on your phone,
because the server has never been able to read a word of it — which
is also why nobody can search it but you.
</p>
</article>
</div>
<h3 class="group-head">Knowing it stays yours</h3>
<div class="cards">
<article class="card">
<span class="card-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20v-1a6 6 0 0 1 6-6h1"/><circle cx="10.5" cy="7" r="3.5"/><path d="M17 14v4"/><path d="M17 21h.01"/></svg></span>
<h4>No phone number</h4>
<p>
Sign up with a username and an email. People find you by the name they
already call you, and your number stays out of it entirely.
</p>
</article>
<article class="card">
<span class="card-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l7 4v5c0 4-3 7-7 9-4-2-7-5-7-9V7z"/><path d="M9 12l2 2 4-4"/></svg></span>
<h4>You'll know if a key changes</h4>
<p>
If someone's phone is replaced — or somebody tries to stand in for
them — the app stops and tells you, with one thing you can do about
it. There's no way to wave it away, because there shouldn't be.
</p>
</article>
<article class="card">
<span class="card-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 21s-6-5.2-6-10a6 6 0 0 1 12 0c0 1.4-.5 2.8-1.3 4.2"/><circle cx="12" cy="11" r="2"/><path d="M4 4l16 16"/></svg></span>
<h3>Photos don't say where you were</h3>
<h4>Photos don't say where you were</h4>
<p>
Pictures and video you send from the photo button are rebuilt on the
way out, so the coordinates your camera wrote in don't travel with
@@ -136,9 +134,18 @@
which is rather the point of a file, so check what's in it first.
</p>
</article>
<article class="card">
<span class="card-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a4 4 0 0 0 6 .5l2-2a4 4 0 0 0-6-6l-1 1"/><path d="M14 11a4 4 0 0 0-6-.5l-2 2a4 4 0 0 0 6 6l1-1"/></svg></span>
<h4>Link previews, made at your end</h4>
<p>
When you share a link, <strong>your</strong> phone builds the little
card — so the site never hears from the person you sent it to. It
does hear from you, which is why there's a switch to turn it off.
</p>
</article>
<article class="card">
<span class="card-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="10" width="16" height="11" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/><path d="M12 14v3"/></svg></span>
<h3>Lock it behind your face</h3>
<h4>Lock it behind your face</h4>
<p>
Your phone is the one place your messages are readable, so it's the
one place worth locking. Face, fingerprint or your usual PIN, and it
@@ -147,6 +154,7 @@
</p>
</article>
</div>
</div>
</div>
</section>
@@ -228,6 +228,45 @@ final class PublicPagesTests: XCTestCase {
"the page no longer states that the size it keeps is exact")
}
/// 🔴 **The shape has to survive the next fifty features (relay#44).**
///
/// The landing page says what the product IS in three cards and sends the
/// detail to `/messages`, `/files`, `/wallet`. That split already happened.
/// What it did not come with was anything that KEEPS it — and `/messages`
/// had quietly reached thirteen cards in one flat list, with 52 unstarted
/// ideas still to land. A flat list of a hundred is a specification, and
/// nobody reads it.
///
/// So features group by what a person is trying to do: the count inside a
/// group grows freely, the number of groups does not. This test fails when
/// a page goes back to a flat list long enough to stop being skimmable,
/// which is the moment to add a group rather than a fourteenth card.
func testFeaturePagesStayGroupedRatherThanFlat() throws {
// Below this, a flat list is still fine — grouping four cards is noise.
let flatCeiling = 8
for name in ["landing", "messages", "files", "wallet"] {
let page = try String(contentsOf: views.appendingPathComponent("\(name).leaf"),
encoding: .utf8)
let cards = page.components(separatedBy: "<article class=\"card\">").count - 1
let groups = page.components(separatedBy: "class=\"group-head\"").count - 1
if cards > flatCeiling {
XCTAssertGreaterThan(
groups, 0,
"\(name).leaf has \(cards) cards in a flat list. Group them by what "
+ "somebody is trying to do — see relay#44 — rather than adding "
+ "another card to the pile.")
}
// A group per card is the same problem wearing a hat.
if groups > 0 {
XCTAssertLessThanOrEqual(
groups, 5,
"\(name).leaf has \(groups) groups. The point is that the TOP level "
+ "stays small; put the feature inside an existing group.")
}
}
}
func testTheSafetyPageCarriesAReachableContact() throws {
let page = try String(contentsOf: views.appendingPathComponent("safety.leaf"),
encoding: .utf8)