Finish the rebrand, rework the comparison page, and close a phrase lockout - #33
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
normalizePassphrasesplits 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-loginandlandingbothreturnon any diagnosis message before the KDF is attempted. The phrase is refused at the door, andextractEditPhrasethen 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.tsis 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.
detail: trueon 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.<title>— no hover exists there, so the table is not its fallback, it is the panel.The rest of the rebrand
138 selector references and 118 alias imports across ~100 files:
moxy-→mng-,@moxy/*→@mng/*,moxyOptionGroup→mngOptionGroup, andangular.json'sprefix, 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.tsargument, 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.
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, sowarmPhraseKdf()fetches it on idle rather than trading an offline unlock for 28 kB.loadTranslationswas pulled eagerly for a call only reached when a catalogue is installed — never, for the source locale (−4 kB).@angular/formswas 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 —
captionwas 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, ande2eagainst 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