Skip to content

Finish the rebrand, rework the comparison page, and close a phrase lockout - #33

Merged
Randolin merged 9 commits into
mainfrom
claude/product-strategy-positioning-u1vwig
Aug 30, 2026
Merged

Randolin merged 9 commits into
mainfrom
claude/product-strategy-positioning-u1vwig

Conversation

@Randolin

Copy link
Copy Markdown
Owner

Nine commits across four threads. The last one is a bug fix that matters more than the rest of this put together.

A lockout, found by a flaky test

normalizePassphrase splits on hyphens as well as spaces — it has to, so a view phrase normalizes identically written either way. Four of the EFF list's 7,776 words are hyphenated (drop-down, felt-tip, t-shirt, yo-yo), so a five-word edit phrase containing one measures as six words.

The KDF never notices — mint and login normalize the same, so the derivation would open the profile. What breaks is the guard in front of it: edit-login and landing both return on any diagnosis message before the KDF is attempted. The phrase is refused at the door, and extractEditPhrase then truncates a six-token input to its last five, which can be five real EFF words — so the answer degrades from "that's 6 words" to "no profile answers to that phrase". Roughly one profile in 390.

Fixed in minting, which is the only place it could go: not in normalizePassphrase (its splitting is a frozen KDF input), and not in the guards (they would have to guess where a hyphen used to be). Minting draws from the 7,772 words that survive normalization, costing ~0.004 bits across a five-word phrase against ~64.6. The vendored list is untouched. passphrase.spec.ts is a deterministic tripwire where the test that caught this was probabilistic.

Residual: a profile already minted with one of those four words still cannot be opened through the UI. Its data and its KDF input are fine — only the guard rejects it — but nothing here recovers one, because with no production profiles the only holders are dev fixtures.

The comparison page

Four commits, mostly subtraction. The page had grown to nine cards that all looked equally important, three of them saying the same thing in different shapes.

  • Deleted the radar (the strips already showed those scales, more legibly), the dumbbell (two numbers the narrative states in a sentence that also explains why they differ), two duplicate legends, and the three-to-five identical answer-grid cards.
  • A lead and a fold. Card chrome is emphasis now rather than the default — three cards on the page instead of nine. Everything past the answer sits behind one disclosure (detail: true on a panel registration), still in the DOM so find-in-page reaches it, opened automatically before printing. Desires moved up rather than down: it is a second answer to a second question, and the only thing on the page that is nowhere else.
  • Table twins for the last two hover-only charts. The agreement strip on a 390px screen is a row of four-pixel dots with its values in an SVG <title> — no hover exists there, so the table is not its fallback, it is the panel.
  • One left edge. Removing the card chrome exposed a misalignment the cards had hidden, and the first pass made it worse by giving panels the card's padding — putting every unboxed thing 28px right of the h1, which is also unboxed. Also fixed: an agreement strip that floated in a 190px gutter because a fixed height made its viewBox meet height-first and centre; an interlock flow starting 46px inside its own heading; prose with no measure; and two mobile-only ones (a meter label column wider than the bar it labelled, three stat tiles leaving the third stranded at half width).

The rest of the rebrand

138 selector references and 118 alias imports across ~100 files: moxy-mng-, @moxy/*@mng/*, moxyOptionGroupmngOptionGroup, and angular.json's prefix, which was stale at "app". deploy/ keeps the old names deliberately — they are instructions about a server that already exists.

That this was a find-and-replace with a green build at the end is the point of the crypto/domains.ts argument, demonstrated rather than asserted.

Bundle

The 500 kB budget had been warning on every build, which is how a build warning stops being read. Measured first: 557 kB raw, and the larger initial chunk carries no product copy at all.

  • Argon2 was imported at the top of phrase-kdf.ts, so every visitor downloaded a KDF to read the landing page (−28 kB). Deferring it needed a second half: the service worker caches what has been fetched, not what might be, so warmPhraseKdf() fetches it on idle rather than trading an offline unlock for 28 kB.
  • loadTranslations was pulled eagerly for a call only reached when a catalogue is installed — never, for the source locale (−4 kB).
  • @angular/forms was a dependency nothing imports.

526 kB / 134 kB transfer now, with the budget re-baselined to 550/650 and the measurement written into CONTRIBUTING.

Also

An edit-phrase clipboard guard, a person palette that had broken under simulated protanopia (orange↔amber at ΔE 2.5) with a CVD spec that now measures it, and three i18n holes closed — caption was not in the guarded attribute list, so a table caption had been shipping untranslatable.

Verification

Full ladder green: format:check, both typechecks, all four unit suites (26 core files / 6 ui / 13 app / 2 server), build, and e2e against the production build — which now includes a mobile-dark comparison shot, open and closed, that is what found the unreadable agreement strip.

Each new guard was verified by feeding it the bad input: the old palette, a planted interpolation literal, an untranslatable caption, a buried desires panel, an unburied answer grid, and the passphrase filter removed.


Generated by Claude Code

claude added 9 commits August 30, 2026 00:11
The two phrases are not different enough, and the failure that matters —
sharing the edit phrase in place of the view phrase — happens at the moment
of copying, in a chat window the app has no reach into. Every safeguard we
had stops at the app boundary: separate fields, a danger-styled card, five
words against six. None of them are present where the mistake is made.

The clipboard is. Copying the edit phrase now puts a warning line above it,
so the sender sees it in the compose field and the recipient sees it if the
sender didn't. `extractEditPhrase` takes it back off on the way in — working
on the word tail rather than on lines, because a single-line input flattens
the newline and a translated warning varies in length and shape. A prefix is
stripped only when the last five words are all real EFF words, so someone who
genuinely typed seven words gets the ordinary message instead of two words
silently discarded.

The entry side goes the other way: deliberately dumber.

Telling a recipient "that's an edit phrase, you could change or delete their
profile, please tell them" hands a capability disclosure to the one person
who now holds the capability. A savvy reader already knows — five words, no
hyphens, no animal — so the disclosure only ever reaches someone who had not
worked it out, which is exactly the wrong audience to inform. The neutral
line routes them back to the sender, which is the same alert path without
the invitation.

So describePhrase now reports length and ONLY length when the length is
wrong. Word-level help is meaningful once the shape is right and meaningless
before it, and enumerating which words are or aren't in Menagerie's lists is
a small oracle in its own right. A five-word edit phrase, a fragment, and
total junk all get one answer. Typo correction inside a well-formed six-word
phrase is untouched — that input is the reader's own and they can act on it.

Also documented at diagnoseViewPhrase, because it is the obvious future
"improvement": never sharpen that message by asking the server. Deriving the
edit locator to check whether the string opens something real would turn the
view box into an oracle confirming exactly that, for whoever pasted it. The
only locator that path may derive is the view one, which for anything but a
view phrase resolves to nothing.

Specs pin the silence rather than the words: an edit phrase, a fragment and
junk must all produce the identical line, and that line must not contain
"edit", "private", "control", "delete" or "five".

Ladder green including e2e.
Wave 4 darkened --series-3 and --series-4 to clear 3:1 contrast against the
page. contrast.spec.ts confirmed that and shipped. What nothing measured is
the other half of the job: whether the four slots are distinguishable FROM
EACH OTHER. They were not. Orange and amber landed 2.5 apart under simulated
protanopia — the same colour — and 12.1 apart for full colour vision, which
is hard too. A four-way comparison put two people's dots on one strip in
what was effectively one hue.

Measured, not eyeballed, against the visualisation reference's validator:

  before  orange↔amber   ΔE  2.5 protan   12.1 normal
  after   aqua↔orange    ΔE  9.7 protan   18.7 normal   (worst pair, light)
          purple↔blue    ΔE  8.5 deutan   17.1 normal   (worst pair, dark)

Slot 4 is purple now, not amber. That is not a preference: no lightness of
amber clears orange, in either mode — the search ran every four-subset of the
reference's eight hues and amber fails beside orange every time. Slot 3's
window turned out to be one step wide, #18a473 passing where #169b6c is
already a warning and #159265 a failure, which is a good illustration of why
this needs a number rather than an opinion.

The two-person case — nearly every comparison — was never affected and stays
at ΔE 24.7. It is now asserted separately at a deliberately wide margin, so
the common case cannot be quietly spent to buy room for the rare one.

series-cvd.spec.ts is the durable half. It simulates protanopia and
deuteranopia with the Machado (2009) transforms and measures OKLab ΔE over
ALL pairs, not adjacent ones: in a bar chart only neighbours touch, but here
four dots share one scale strip so any two can meet. Ported from the same
transforms and metric the reference tool uses, so the two cannot disagree —
verified by feeding it the old palette, which it rejects with the identical
numbers. contrast.spec.ts now points at it, since satisfying one and not the
other is exactly the mistake that got made.
The comparison page had grown to nine cards that all looked equally
important, three of them saying the same thing in different shapes. This
removes the duplicates rather than restyling them.

- The values radar goes. It plotted the same scales the strips already
  show, less legibly: a polygon answers "who is bigger overall", which is
  not a question this app asks, and its axis labels collide on a phone.
  Its accessible table went with it, so that spec now asserts against the
  strips, which carry the same numbers.
- One person legend, once. It was drawn in three panels; the headline is
  ungated, so it is the one that keeps it.
- The answer grid was three to five identical cards holding one or two
  rows each. Now one card with quiet section subheads — a document with a
  shape rather than a stack of equals.
- The dumbbell goes. Directional fit is real and worth stating, but it is
  two numbers, and the narrative already says them in a sentence that
  explains why they differ. A chart of two dots is a stat tile at best.

Nothing measurable is lost: every number these carried is still on the
page, in the panel that shows it best. The e2e needles move with the
copy, and the directional-fit assertion now reads the prose.
Two changes to one problem — the page presented nine surfaces as equals,
so nothing on it was emphasised and the answer had to be hunted for.

Card chrome is now emphasis rather than default. The headline keeps it,
because it is the answer and it carries the dealbreaker alerts; the
picker and the share-back offer keep it, because they are controls. Every
other panel becomes a `.panel` — same inset so headings share a left edge,
told apart by its heading and the space around it, which is what headings
are for. Three cards on the page instead of nine.

Everything past the answer moves behind one disclosure. `detail: true` on
a panel registration is the whole mechanism: the lead is the headline, the
sentences, and the mutual-desires reveal; the evidence for them is one
click away, still in the DOM so find-in-page reaches it, and opened
automatically before printing since a printed comparison is the whole
comparison.

Desires moves up rather than down. It is not evidence for the headline
number — it is a second answer to a second question, and the only thing on
the page that is nowhere else. On the demo especially, it is the moment a
newcomer is there to see.

The e2e now drives the disclosure instead of reading through it: a closed
`<details>` still yields its text to `textContent`, so the needle list
alone could not tell "on the page" from "buried". It opens it, reads,
closes it, and checks both that the evidence went away and that the lead
did not. Verified by burying the desires panel (demo assertion fails) and
unburying the answer grid (closed-state assertion fails).
The last of the old name, and the least interesting: 138 selector
references and 118 alias imports across ~100 files, none of which anything
derives from. `moxy-` becomes `mng-`, `@moxy/*` becomes `@mng/*`, the
`moxyOptionGroup` directive attribute becomes `mngOptionGroup`, and
angular.json's `prefix` — stale at "app", so `ng generate` had been
producing the wrong prefix all along — becomes "mng".

Two same-shaped strings went with them because they were only ever
internal: the service worker's cache name (its activate handler deletes
every key that is not the current one, so the rename is self-healing and
costs one shell re-fetch) and the e2e's temp database filenames.

Three places keep the old name deliberately:

- `deploy/` — `moxy-sync`, `moxy-update.timer`, `moxy-sync-data` are the
  names on a server that already exists. They are migration instructions,
  not identifiers, and rewriting them would make the document lie.
- `crypto/domains.ts` and its spec — the comments explain that the salts
  used to spell the product's name and no longer do, and the spec's
  tripwire regex tests for exactly that word.

That the rest was a find-and-replace with a green build at the end is the
point of the domains.ts argument, demonstrated rather than asserted: this
is what a rename costs once nothing cryptographic is entangled with it.
Six months ago the same edit would have made every profile unopenable.
Two charts still put their values in an SVG `<title>`, which is a hover
tooltip — and a phone has no hover. The agreement strip is the worse of
the two: on a 390px screen it is a row of four-pixel dots and nothing
else, so the table is not its fallback, it is the panel. The interlock
flow's `role="img"` label counts covered needs, which says how well it
went without saying what happened; its table names each need and whether
the other person offers it, one table per direction since the heading
above each already says whose needs.

Found by shooting the compare page at 390px in dark mode, which nothing
covered before — the e2e's dark shot was the view page and its mobile shot
was the landing page. That step is now in the suite (open and closed), and
it is the shot that showed the dots were unreadable rather than merely
small.

Three i18n holes closed on the way, all the same shape as the ones
`i18n-copy.spec.ts` was written for and all of them past it: `caption` was
not in the guarded attribute list, so the values table's caption had been
shipping untranslatable, and two column headers were bare TS literals.
`caption` is guarded now, the headers use `$localize`, and the guard fails
on the old code.

Also: the disclosure's hover underline was covering its explanatory line
too, which made the explanation look like a second link.
The 500 kB initial budget had been warning on every build, which is how a
build warning stops being read. Measured before touching anything: 557 kB
raw, 144 kB over the wire, and the larger of the two initial chunks
carries no product copy at all — Angular and the router dominate it, and
500 kB was the framework's template default rather than a figure this app
was ever going to hit.

Two things genuinely did not belong on the critical path:

- **Argon2.** `hash-wasm` was imported at the top of `phrase-kdf.ts`, so
  every visitor downloaded a KDF to read the landing page. It is imported
  inside the derivation now, which is already an async function that takes
  seconds. −28 kB.
- **`loadTranslations`.** `i18n/locale.ts` pulled the `@angular/localize`
  runtime eagerly to call a function it only reaches when a catalogue is
  actually being installed — never, for the source locale. −4 kB.

Deferring Argon2 needed a second half. The service worker caches what has
been fetched, not what might be, so a chunk still unfetched when the
network goes is a profile that cannot be unlocked offline — a lockout
traded for 28 kB. `warmPhraseKdf()` fetches it on idle from `main.ts`: the
first paint stays light and the cache ends up where it was.

`@angular/forms` was a dependency nothing imports; dropped. An explicit
`provideZonelessChangeDetection()` was tried and reverted — the app is
already zoneless by default and it cost 130 bytes to say so.

Budget is now 550 kB warning / 650 kB error against a real 526 kB, so it
fires on a regression instead of on every build. CONTRIBUTING carries the
measurement and names both deferrals, so the next person to see the
warning looks for what got pulled eager rather than raising the number.
Removing the card chrome exposed a misalignment the cards had been hiding,
and I made it worse by papering over it: panels borrowed the card's
horizontal padding so their headings would line up with headings inside
cards. That put every unboxed thing 28px right of the h1 and the lede,
which are unboxed too — a ragged step down the page with nothing drawn to
explain it.

The rule is the other way round. A card's padding holds its contents off
its own border; a panel has no border, so it belongs in the page's text
column with the h1. Cards inset their own contents, and that inset now
reads as deliberate because there is a box making it.

Then the things that were never aligned to anything:

- **The agreement strip floated in the middle of its row.** A 420×26
  viewBox with a fixed 26px height meets that height first and centres what
  is left, so the track sat in a 190px gutter with its own "differ/aligned"
  legend nowhere near its ends. Scaling by width instead fills the row, and
  scales uniformly, so the dots stay round and grow into a real hit target.
  The track is drawn edge to edge now, because the legend is positioned in
  CSS and cannot know how the viewBox scaled.
- **The interlock flow started 46px inside its own heading**, because its
  left labels were right-aligned against a fixed 160-unit column and short
  labels left the gutter behind. They start at the figure's left edge now.
- **Prose had no measure.** Without a card to end them, the narrative's
  paragraphs ran the full 900px — about 110 characters. 68ch now; notices
  keep the full width, since a notice is a box and lines up with boxes.

And two mobile-only ones the 390px shot found:

- The meters' 130px label column was wider than the bar it labelled, which
  inverts the row — the bar carries the number. Label and value go on a
  line above and the bar takes the width. The agreement strip stacks the
  same way.
- Three stat tiles in two columns left the third stranded at half width
  beside a gap. It spans the row now. That one needed the grid items to be
  addressed as `.stat-row > *`: they are the component host elements, not
  the `.stat-tile` div inside them, so the obvious selector silently did
  nothing.
A flaky test turned out to be a lockout.

`normalizePassphrase` splits on hyphens as well as spaces — it has to, so a
view phrase normalizes identically whether it was written with hyphens or
spaces. Four of the EFF list's 7,776 words are hyphenated: `drop-down`,
`felt-tip`, `t-shirt`, `yo-yo`. A five-word edit phrase containing one of
them measures as SIX words the moment anything counts.

The derivation itself never notices — mint and login normalize the same, so
the KDF would open the profile. What breaks is the guard in front of it:
`edit-login` and `landing` both call `describePhrase(diagnoseEditPhrase(…))`
and `return` on any message, before the KDF is attempted. So the phrase is
refused at the door with "an edit phrase is 5 words — that's 6", and there
is no way past it. `extractEditPhrase` then makes it worse: with six tokens
it takes the last five, which can be five real EFF words, so the phrase is
silently truncated and the answer becomes "no profile answers to that
phrase" instead. Roughly one profile in 390.

The fix is in minting, and it could not be anywhere else. Not in
`normalizePassphrase`, whose splitting is a frozen KDF input — changing it
would change every derivation ever made. Not in the guards, which would
have to re-join adjacent tokens by guessing where a hyphen used to be.
Minting now draws from the 7,772 words that survive normalization intact,
which costs about 0.004 bits across a five-word phrase against ~64.6. The
vendored list is untouched; the filter is at selection time.

`passphrase.spec.ts` is the tripwire, and it is deterministic where the
test that caught this was not: it checks every mintable list for a word
normalization would split, and names the four so a future filter change
that lets them back in says which words and why. Verified by removing the
filter — it fails with `sublevel nerd slapping felt-tip uncoiled: expected
[ Array(6) ] to have a length of 5`.

Residual, stated plainly: a profile already minted with one of those four
words still cannot be opened through the UI. Its data is fine and its KDF
input is unchanged — only the guard rejects it — but nothing here recovers
one, because with no production profiles the only holders are dev fixtures.
@Randolin
Randolin merged commit 0188897 into main Aug 30, 2026
2 checks passed
@Randolin
Randolin deleted the claude/product-strategy-positioning-u1vwig branch August 30, 2026 05:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants