diff --git a/docs/feature-plan.md b/docs/feature-plan.md index 3874b2e..1ba6e03 100644 --- a/docs/feature-plan.md +++ b/docs/feature-plan.md @@ -371,10 +371,12 @@ between features rather than a slot in a wave. **Wave 1 — the loop.** A1 freshness · A3 viewer CTA · C1 focus and announcements. The two ends of the share loop, plus the accessibility fix -that costs almost nothing and unblocks the most people. +that costs almost nothing and unblocks the most people. **Shipped.** **Wave 2 — the front door.** A2 demo compare · B1 backup card · B2 phrase entry. What a newcomer meets, and what keeps them from losing everything. +**Shipped.** B3 (confirm the edit phrase at hatch) was left in wave 3 as +planned; it now has somewhere to send people. **Wave 3 — the words.** C2 narrative panel · A4 core-tier milestone · B3 phrase confirmation. The copy-heavy work, once the structure around it is diff --git a/e2e/run-e2e.mjs b/e2e/run-e2e.mjs index 8b92ba1..6770915 100644 --- a/e2e/run-e2e.mjs +++ b/e2e/run-e2e.mjs @@ -258,6 +258,24 @@ try { } await shot(page, '01-landing-unconfigured.png'); + // The demo is the one page that must survive a missing server: it is what a + // newcomer is shown before they commit to anything, and it never fetches. + step = 'demo-unconfigured'; + await page.click('text=See a comparison first'); + await page.waitForSelector('text=brave-azure-otter', { timeout: 30000 }); + const demoBody = await page.textContent('body'); + for (const expected of ['Overall alignment', 'Mutual desires', 'Fit, each way']) { + if (!demoBody.includes(expected)) fail(`demo is missing "${expected}" with no server`); + } + // The dealbreaker alert and the mutual reveal are the two moments the demo + // exists to show; a demo that quietly lost them would still look fine. + if (!demoBody.includes('Alcohol')) fail('demo does not name the violated dealbreaker'); + if (!demoBody.includes('Cuddling')) fail('demo does not reveal the mutual desire'); + // Desires are mutual-only: a one-sided answer must not appear anywhere. + if (demoBody.includes('Massage')) fail('demo revealed a one-sided desire'); + await shot(page, '01b-demo-unconfigured.png'); + await page.goto(BASE); + step = 'landing-configure'; await page.fill('input[aria-label="Profile server URL"]', main.url); await page.click('text=Use this server'); @@ -421,6 +439,22 @@ try { step = 'edit-login-fresh-context'; const editor = await freshPage(); await editor.goto(`${BASE}#/edit`); + + // A typo must be caught before Argon2id charges seconds for it, and must + // say which word is wrong. Append a letter rather than dropping one: + // dropping a letter can land on another real EFF word, and the phrase would + // then be well-formed and go through the KDF on some runs and not others. + const typo = editPhrase.replace(/^(\S+)/, '$1q'); + await editor.fill('input[aria-label="Edit phrase"]', typo); + const beforeTypo = Date.now(); + await editor.click('text=Open my profile'); + await editor.waitForSelector('.notice-warn', { timeout: 10000 }); + const spent = Date.now() - beforeTypo; + const complaint = await editor.textContent('.notice-warn'); + if (!complaint.includes('word 1')) fail(`typo complaint does not locate the word: ${complaint}`); + // A derivation would take seconds; this path must never reach one. + if (spent > 5000) fail(`typo took ${spent}ms — it went through the KDF`); + await editor.fill('input[aria-label="Edit phrase"]', editPhrase); await editor.click('text=Open my profile'); await editor.waitForSelector('.profile-head', { timeout: 30000 }); diff --git a/libs/core/src/demo/demo-cast.spec.ts b/libs/core/src/demo/demo-cast.spec.ts new file mode 100644 index 0000000..e5d4572 --- /dev/null +++ b/libs/core/src/demo/demo-cast.spec.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { buildDemoCast } from './demo-cast'; +import { allItems } from '../schema/schema'; +import { pairScores } from '../match/scores'; +import { revealMutualDesires } from '../match/reveal'; +import { personaFromViewPhrase } from '../persona/persona'; + +/** + * A demo that rots is worse than none: it would show a newcomer a broken or + * dishonest version of the thing being sold. These tests fail the moment the + * schema moves under the cast, rather than letting it quietly degrade. + */ +describe('the demo cast', () => { + it('answers only ids the schema actually has', async () => { + const known = new Set(allItems().map(({ item }) => item.id)); + const cast = await buildDemoCast(); + for (const profile of cast) { + for (const id of Object.keys(profile.payload.a)) { + expect(known, `${profile.phrase} answers unknown item ${id}`).toContain(id); + } + } + }); + + it('carries phrases that mint real creatures', async () => { + for (const profile of await buildDemoCast()) { + const persona = await personaFromViewPhrase(profile.phrase); + expect(persona, `${profile.phrase} is not a valid view phrase`).not.toBeNull(); + expect(profile.phrase.split('-')).toHaveLength(6); + } + }); + + it('compares as a strong but imperfect fit', async () => { + const [otter, owl] = await buildDemoCast(); + const scores = pairScores(otter.payload, owl.payload); + // Two people who genuinely get on: high enough to be worth the survey, + // short of a suspicious 1.0. + expect(scores.overall).toBeGreaterThan(0.6); + expect(scores.overall).toBeLessThan(0.95); + expect(scores.coverage).toBeGreaterThan(10); + }); + + it('trips exactly one dealbreaker, in one direction', async () => { + const [otter, owl] = await buildDemoCast(); + const scores = pairScores(otter.payload, owl.payload); + // The otter drinks never and accepts rarely; the owl drinks socially. + expect(scores.fitA.alerts).toEqual(['ls.alcohol']); + // The owl set no dealbreakers, so nothing fires the other way — the + // demo shows a directional alert, not a symmetric verdict. + expect(scores.fitB.alerts).toEqual([]); + }); + + it('reveals only the desires both of them marked', async () => { + const cast = await buildDemoCast(); + const revealed = (await revealMutualDesires(cast.map((p) => p.payload))).map((r) => r.item.id); + expect(revealed).toContain('dp.cuddle'); + expect(revealed).toContain('dp.talk'); + // One-sided answers must stay invisible, or the demo would be teaching + // the wrong thing about how desires travel. + expect(revealed).not.toContain('dp.massage'); + expect(revealed).not.toContain('dp.dressup'); + expect(revealed).not.toContain('dp.aftercare'); + }); + + it('never puts a desire answer in the open payload', async () => { + for (const { payload } of await buildDemoCast()) { + expect(Object.keys(payload.a).some((id) => id.startsWith('dp.'))).toBe(false); + expect(payload.m?.length).toBeGreaterThan(0); + } + }); +}); diff --git a/libs/core/src/demo/demo-cast.ts b/libs/core/src/demo/demo-cast.ts new file mode 100644 index 0000000..53b85f0 --- /dev/null +++ b/libs/core/src/demo/demo-cast.ts @@ -0,0 +1,131 @@ +// Two fictional creatures, so the payoff can be seen before it is paid for. +// +// Everything else in Menagerie needs two finished profiles and a server +// before it will show you anything. That is a hard sell to someone deciding +// whether twenty minutes of survey is worth it, so this pair exists purely to +// be compared: real schema ids, real scoring, no network. +// +// The answers are hand-written rather than generated (the QA cast in `qa/` is +// generated, and reads like it — twins and exact inversions prove score +// bounds, they do not look like two people). These two are written to land +// three specific moments: a strong overall fit, one dealbreaker that needs a +// conversation, and a mutual desire neither of them could have seen alone. +import { buildSharePayload } from '../codec/codec'; +import { buildMatchTokens } from '../crypto/match-tokens'; +import type { Acceptable, Answers, ProfilePayload, Weights } from '../schema/types'; + +export interface DemoProfile { + /** A real, well-formed view phrase — the persona and its art derive from it. */ + readonly phrase: string; + readonly payload: ProfilePayload; +} + +interface DemoSource { + readonly phrase: string; + /** Fixed, because nothing here is secret — these profiles are fiction. */ + readonly salt: string; + readonly answers: Answers; + readonly weights: Weights; + readonly acceptable: Acceptable; +} + +const SOURCES: readonly DemoSource[] = [ + { + // Warm, plans ahead, sober by choice — and says so as a dealbreaker. + phrase: 'brave-azure-otter-mistwoven-emberlit-fernhollow', + salt: 'demo-otter', + answers: { + 'ab.pn': [2], + 'ab.age': 1, + 'sk.friend': 3, + 'sk.activity': 3, + 'sk.longterm': 3, + 'sk.poly': 2, + 'sk.qpr': 2, + 'sk.casual': 1, + 'sk.mono': 0, + 'sk.hookup': 0, + 'va.together': 4, + 'va.novelty': 4, + 'va.heart': 3, + 'va.express': 5, + 'va.social': 2, + 'va.plan': 4, + 'ls.alcohol': 0, + 'ls.smoke': 0, + 'ls.exercise': 2, + 'ls.sleep': 0, + // Gives time and touch; needs words and time. The mismatch with the + // owl below is the whole point of scoring care as an interlock. + 'cn.give': [1, 2], + 'cn.receive': [0, 1], + 'dp.cuddle': 3, + 'dp.talk': 3, + 'dp.massage': 2, + 'dp.aftercare': 3, + 'dp.rope': 0, + }, + weights: { 'ls.alcohol': 3, 'va.together': 2, 'sk.longterm': 2 }, + // Never or rarely. The owl drinks socially, which is the alert. + acceptable: { 'ls.alcohol': [0, 1] }, + }, + { + // Curious, spontaneous, drinks socially. Matches on almost everything + // that is not the one thing the otter marked a dealbreaker. + phrase: 'calm-bright-owl-moonlit-honeywarmed-willowbrook', + salt: 'demo-owl', + answers: { + 'ab.pn': [0], + 'ab.age': 1, + 'sk.friend': 3, + 'sk.activity': 2, + 'sk.longterm': 2, + 'sk.poly': 3, + 'sk.qpr': 1, + 'sk.casual': 1, + 'sk.mono': 0, + 'sk.hookup': 1, + 'va.together': 3, + 'va.novelty': 5, + 'va.heart': 4, + 'va.express': 4, + 'va.social': 3, + 'va.plan': 2, + 'ls.alcohol': 2, + 'ls.smoke': 0, + 'ls.exercise': 3, + 'ls.sleep': 1, + 'cn.give': [0, 1], + 'cn.receive': [2, 3], + // Cuddling and long talks are mutual; the massage is not, and the + // dress-up the otter never answered stays invisible either way. + 'dp.cuddle': 3, + 'dp.talk': 2, + 'dp.massage': 0, + 'dp.dressup': 2, + }, + weights: { 'va.novelty': 2, 'sk.poly': 2 }, + acceptable: {}, + }, +]; + +/** + * Build the demo pair. Async only because desire fingerprints are hashed the + * same way real ones are — this touches no network and needs no server, which + * is what lets the demo be the one page that still works when the profile + * server is unreachable. + */ +export async function buildDemoCast(): Promise { + return Promise.all( + SOURCES.map(async (source) => ({ + phrase: source.phrase, + payload: buildSharePayload( + source.answers, + await buildMatchTokens(source.answers, source.salt), + source.salt, + source.weights, + source.acceptable, + ), + })), + ); +} diff --git a/libs/core/src/hatch/phrase-check.spec.ts b/libs/core/src/hatch/phrase-check.spec.ts new file mode 100644 index 0000000..d4d8750 --- /dev/null +++ b/libs/core/src/hatch/phrase-check.spec.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; +import { describePhrase, diagnoseEditPhrase, diagnoseViewPhrase } from './phrase-check'; +import { mintEditPhrase, mintViewPhrase } from './phrases'; + +const GOOD_VIEW = 'brave-azure-otter-mistwoven-emberlit-fernhollow'; + +describe('diagnoseViewPhrase', () => { + it('passes a real phrase, however it was typed', () => { + for (const form of [ + GOOD_VIEW, + GOOD_VIEW.replace(/-/g, ' '), + ` ${GOOD_VIEW.toUpperCase()} `, + 'brave azure-otter mistwoven emberlit-fernhollow', + ]) { + expect(diagnoseViewPhrase(form).ok, form).toBe(true); + } + }); + + it('passes freshly minted phrases', async () => { + for (let i = 0; i < 5; i++) { + expect(diagnoseViewPhrase(await mintViewPhrase()).ok).toBe(true); + } + }); + + it('names the mistyped word and offers the correction', () => { + // One letter dropped from "otter". + const found = diagnoseViewPhrase('brave-azure-oter-mistwoven-emberlit-fernhollow'); + expect(found.ok).toBe(false); + expect(found.problems).toHaveLength(1); + expect(found.problems[0]).toMatchObject({ index: 2, word: 'oter', suggestion: 'otter' }); + }); + + it('knows which slot a word belongs to', () => { + // Both are real words, but swapped into each other's positions. + const found = diagnoseViewPhrase('otter-azure-brave-mistwoven-emberlit-fernhollow'); + expect(found.problems.map((p) => p.index)).toEqual([0, 2]); + }); + + it('offers no suggestion when the correction is ambiguous', () => { + // Not one edit from anything: a suggestion here would be invention. + const found = diagnoseViewPhrase('brave-azure-zzzzzzzz-mistwoven-emberlit-fernhollow'); + expect(found.problems[0].suggestion).toBeNull(); + }); + + it('diagnoses a phrase pasted as a link', () => { + const ok = diagnoseViewPhrase(`https://menagerie.love/#/view/${GOOD_VIEW}`); + expect(ok.ok).toBe(true); + const typo = diagnoseViewPhrase( + 'https://menagerie.love/#/view/brave-azure-oter-mistwoven-emberlit-fernhollow', + ); + expect(typo.problems[0].suggestion).toBe('otter'); + }); + + it('reports a wrong word count without hiding the bad words', () => { + const found = diagnoseViewPhrase('brave-azure-oter'); + expect(found.ok).toBe(false); + expect(found.actualWords).toBe(3); + expect(found.expectedWords).toBe(6); + // The typo is still named — fixing the length should not reveal a second + // round of complaints one at a time. + expect(found.problems[0].suggestion).toBe('otter'); + }); +}); + +describe('diagnoseEditPhrase', () => { + it('passes freshly minted phrases', async () => { + for (let i = 0; i < 3; i++) { + const diagnosis = await diagnoseEditPhrase(await mintEditPhrase()); + expect(diagnosis.ok).toBe(true); + } + }); + + it('catches a word that is not in the EFF list', async () => { + const phrase = await mintEditPhrase(); + const words = phrase.split(' '); + const found = await diagnoseEditPhrase(['zzzzzzzzz', ...words.slice(1)].join(' ')); + expect(found.ok).toBe(false); + expect(found.problems[0].index).toBe(0); + }); + + it('counts words', async () => { + const found = await diagnoseEditPhrase('one two three'); + expect(found.actualWords).toBe(3); + expect(found.expectedWords).toBe(5); + }); +}); + +describe('describePhrase', () => { + it('says nothing when nothing is wrong', () => { + expect(describePhrase(diagnoseViewPhrase(GOOD_VIEW), 'view phrase')).toBeNull(); + }); + + it('leads with the correction when there is one', () => { + const message = describePhrase( + diagnoseViewPhrase('brave-azure-oter-mistwoven-emberlit-fernhollow'), + 'view phrase', + ); + expect(message).toContain('oter'); + expect(message).toContain('otter'); + expect(message).toContain('word 3'); + }); + + it('explains a length mismatch in words a person can act on', () => { + const message = describePhrase(diagnoseViewPhrase(GOOD_VIEW + '-extra'), 'view phrase'); + expect(message).toContain('6 words'); + expect(message).toContain('extra word'); + }); +}); diff --git a/libs/core/src/hatch/phrase-check.ts b/libs/core/src/hatch/phrase-check.ts new file mode 100644 index 0000000..abfa7d9 --- /dev/null +++ b/libs/core/src/hatch/phrase-check.ts @@ -0,0 +1,159 @@ +// Telling someone their phrase is wrong, instantly, and where. +// +// Both phrases are drawn from fixed public wordlists, so a phrase containing +// a word that is not in them cannot possibly be right — and checking that +// costs microseconds while the Argon2id derivation it would otherwise reach +// costs seconds. Before this, a single mistyped letter bought a multi-second +// wait and "no profile answers to that phrase", which is indistinguishable +// from a profile that was deleted. +// +// Nothing here is a secrecy trade: these lists ship in the bundle already, +// and a view phrase's grammar is documented in-app. Guessing the phrase still +// costs what it always did. +import { normalizePassphrase } from '../crypto/phrase-kdf'; +import { ADJECTIVES_A, ADJECTIVES_B, ANIMALS } from '../persona/wordlists'; +import { TAIL_ADJECTIVES, TAIL_PLACES } from '../persona/tail-wordlists'; +import { EDIT_PHRASE_WORDS, VIEW_PHRASE_WORDS } from './phrases'; + +/** One word that is not in the list its position requires. */ +export interface WordProblem { + /** 0-based position in the phrase. */ + readonly index: number; + readonly word: string; + /** What the list holds one edit away, when exactly that is unambiguous. */ + readonly suggestion: string | null; + /** What this position must hold, for a message that can name it. */ + readonly expects: string; +} + +export interface PhraseDiagnosis { + readonly ok: boolean; + readonly expectedWords: number; + readonly actualWords: number; + readonly problems: readonly WordProblem[]; +} + +/** + * True when one insertion, deletion, or substitution turns `a` into `b`. + * Deliberately not a full edit distance: a two-edit "suggestion" is wrong + * often enough to be worse than none, and this runs over a 7,776-word list. + */ +function isOneEditApart(a: string, b: string): boolean { + const la = a.length; + const lb = b.length; + if (Math.abs(la - lb) > 1) return false; + if (a === b) return false; + let i = 0; + let j = 0; + let edits = 0; + while (i < la && j < lb) { + if (a[i] === b[j]) { + i++; + j++; + continue; + } + if (++edits > 1) return false; + if (la > lb) i++; + else if (lb > la) j++; + else { + i++; + j++; + } + } + if (i < la || j < lb) edits++; + return edits <= 1; +} + +/** The single one-edit neighbour, or null when there are none or several. */ +function suggestFrom(list: readonly string[], word: string): string | null { + let found: string | null = null; + for (const candidate of list) { + if (!isOneEditApart(word, candidate)) continue; + // Two plausible corrections is not a suggestion, it is a guess. + if (found) return null; + found = candidate; + } + return found; +} + +const ANIMAL_NAMES: readonly string[] = ANIMALS.map((a) => a.name); + +/** What each slot of a view phrase must hold, in order. */ +const VIEW_SLOTS: readonly { readonly list: readonly string[]; readonly expects: string }[] = [ + { list: ADJECTIVES_A, expects: 'an adjective' }, + { list: ADJECTIVES_B, expects: 'an adjective' }, + { list: ANIMAL_NAMES, expects: 'an animal' }, + { list: TAIL_ADJECTIVES, expects: 'a tail word' }, + { list: TAIL_ADJECTIVES, expects: 'a tail word' }, + { list: TAIL_PLACES, expects: 'a place' }, +]; + +/** + * Pull the phrase out of a pasted link, so a typo inside a URL is diagnosed + * as a typo rather than as six words of nonsense. Mirrors what the extractor + * accepts — a diagnostic that rejects inputs the real parser takes would send + * people chasing the wrong problem. + */ +function phraseCandidate(text: string): string { + const url = text.trim().match(/#\/(?:view|group)\/([A-Za-z-]+)/); + return url ? url[1] : text; +} + +function diagnose( + text: string, + expectedWords: number, + listAt: (index: number) => { list: readonly string[]; expects: string }, +): PhraseDiagnosis { + const words = normalizePassphrase(phraseCandidate(text)).split(' ').filter(Boolean); + const problems: WordProblem[] = []; + // Check the words that exist even when the count is wrong: a phrase that is + // both short and misspelled should say both, not make the person fix the + // count and come back to be told again. + for (let i = 0; i < Math.min(words.length, expectedWords); i++) { + const { list, expects } = listAt(i); + if (list.includes(words[i])) continue; + problems.push({ index: i, word: words[i], suggestion: suggestFrom(list, words[i]), expects }); + } + return { + ok: words.length === expectedWords && problems.length === 0, + expectedWords, + actualWords: words.length, + problems, + }; +} + +/** Instant check of a view phrase against the public grammar. */ +export function diagnoseViewPhrase(text: string): PhraseDiagnosis { + return diagnose(text, VIEW_PHRASE_WORDS, (i) => VIEW_SLOTS[i]); +} + +/** + * Instant check of an edit phrase against the EFF list. Async because that + * list is a lazily-imported ~78 KB chunk and must stay one — call this when a + * phrase is submitted, not on every keystroke of a page that may never see + * one. + */ +export async function diagnoseEditPhrase(text: string): Promise { + const { WORDS } = await import('../crypto/eff-wordlist'); + return diagnose(text, EDIT_PHRASE_WORDS, () => ({ list: WORDS, expects: 'a word' })); +} + +/** + * The diagnosis as one sentence for a person: what is wrong, where, and the + * correction when there is an unambiguous one. Null when nothing is wrong. + */ +export function describePhrase(diagnosis: PhraseDiagnosis, label: string): string | null { + if (diagnosis.ok) return null; + const { problems, expectedWords, actualWords } = diagnosis; + const first = problems[0]; + if (first) { + const where = `word ${first.index + 1}`; + const fix = first.suggestion ? ` Did you mean “${first.suggestion}”?` : ''; + return `“${first.word}” isn’t ${first.expects} Menagerie uses (${where}).${fix}`; + } + const short = actualWords < expectedWords; + return ( + `A ${label} is ${expectedWords} words — that’s ${actualWords}. ` + + (short ? 'Something may be missing.' : 'There may be an extra word.') + ); +} diff --git a/libs/core/src/index.ts b/libs/core/src/index.ts index 8127d6d..bbfea79 100644 --- a/libs/core/src/index.ts +++ b/libs/core/src/index.ts @@ -56,8 +56,12 @@ export { bannerStyleFor, type BannerStyle, type BannerPersonaLike } from './pers export * from './hatch/constants'; export * from './hatch/keys'; export * from './hatch/phrases'; +export * from './hatch/phrase-check'; export { encryptBlob, decryptBlob } from './hatch/blob'; export * from './hatch/priv-data'; + +// The fictional pair the demo comparison renders. +export { buildDemoCast, type DemoProfile } from './demo/demo-cast'; export * from './hatch/hatch-api'; export { HatchClient, diff --git a/libs/ui/src/styles/_base.scss b/libs/ui/src/styles/_base.scss index c23b083..2e1020f 100644 --- a/libs/ui/src/styles/_base.scss +++ b/libs/ui/src/styles/_base.scss @@ -75,6 +75,50 @@ a { outline: none; } +/* ---------- print ---------- */ + +/* Printing is a real output format here: the backup card exists to leave the + screen. Chrome to paper, or Chrome to PDF — both go through this. */ +@media print { + .app-header, + .app-footer, + .skip-link, + moxy-toast, + .no-print { + display: none !important; + } + + body { + background: #fff; + color: #000; + } + + /* The creature, its QR and the accent rules are the card's identity, not + decoration — browsers drop backgrounds when printing unless told. */ + * { + print-color-adjust: exact; + -webkit-print-color-adjust: exact; + } + + .card { + border: 1px solid #999; + box-shadow: none; + break-inside: avoid; + margin: 0; + } + + /* Never split a phrase across a page break — half a phrase is worthless. */ + .code-box, + .passphrase-box { + break-inside: avoid; + } + + /* The share button is a screen affordance; on paper it is a dead rectangle. */ + .qr-share { + display: none !important; + } +} + /* ---------- shell ---------- */ /* Six nav links plus the session chip, logout and theme is a full row; keep diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 4823ed9..dd76693 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -47,12 +47,28 @@ export const routes: Routes = [ title: 'Groups', loadComponent: () => import('./groups/groups.component').then((m) => m.GroupsComponent), }, + // Behind the session guard on purpose: this page prints the edit phrase, + // so a view-only visitor must never be able to reach it. + { + path: 'backup', + title: 'Backup card', + canActivate: [hatchSessionGuard], + loadComponent: () => + import('./backup/backup-card.component').then((m) => m.BackupCardComponent), + }, { path: 'settings', canActivate: [hatchSessionGuard], title: 'Settings', loadComponent: () => import('./settings/settings.component').then((m) => m.SettingsComponent), }, + // Deliberately guard-free and server-free: the whole point is that someone + // with no profile and no reachable server can still see the payoff. + { + path: 'demo', + title: 'Demo comparison', + loadComponent: () => import('./demo/demo.component').then((m) => m.DemoComponent), + }, { path: 'compare', title: 'Compare', diff --git a/src/app/backup/backup-card.component.spec.ts b/src/app/backup/backup-card.component.spec.ts new file mode 100644 index 0000000..7de8656 --- /dev/null +++ b/src/app/backup/backup-card.component.spec.ts @@ -0,0 +1,80 @@ +import { TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { BackupCardComponent } from './backup-card.component'; +import { routes } from '../app.routes'; +import { ProfileSessionStore } from '../stores/profile-session.store'; + +const VIEW = 'brave-azure-otter-mistwoven-emberlit-fernhollow'; +const EDIT = 'implosive widow buckskin earthy parted'; + +describe('the backup card', () => { + function render() { + const session = TestBed.inject(ProfileSessionStore); + session.active.set(true); + session.viewPhrase.set(VIEW); + session.editPhrase.set(EDIT); + session.persona.set({ + words: ['brave', 'azure', 'otter'], + name: 'brave-azure-otter', + emoji: '🦦', + color: '#0b5e8a', + color2: '#1e5f9e', + colorIndex: 11, + }); + const fixture = TestBed.createComponent(BackupCardComponent); + fixture.detectChanges(); + return fixture.nativeElement as HTMLElement; + } + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [BackupCardComponent], + providers: [provideRouter([])], + }).compileComponents(); + }); + + // The whole point of the card: if either phrase is missing, printing it is + // worse than useless — it looks like a backup and isn't one. + it('carries both phrases and the creature', () => { + const el = render(); + expect(el.querySelector('.code-box')?.textContent).toContain(VIEW); + expect(el.querySelector('.passphrase-box')?.textContent).toContain(EDIT); + expect(el.querySelector('moxy-creature-avatar')).not.toBeNull(); + expect(el.textContent).toContain('brave-azure-otter'); + }); + + it('renders the view QR, not a bare link', () => { + expect(render().querySelector('moxy-qr-code')).not.toBeNull(); + }); + + // Printing the edit phrase hands someone full control on paper. Saying so + // is not optional, and it must survive a copy edit of the surrounding page. + it('warns that the card carries full edit control', () => { + const warning = render().querySelector('.notice-warn'); + expect(warning?.textContent).toContain('edit phrase'); + expect(warning?.textContent?.toLowerCase()).toContain('delete this profile'); + }); + + // The card is the print target; the surrounding controls are not. + it('marks the screen-only controls as no-print', () => { + const el = render(); + const controls = el.querySelector('.no-print'); + expect(controls?.textContent).toContain('Print or save as PDF'); + expect(el.querySelector('.backup-card')?.classList.contains('no-print')).toBe(false); + }); + + // A view-only visitor must never reach a page that prints an edit phrase. + it('is reachable only behind the session guard', () => { + const route = routes.find((r) => r.path === 'backup'); + expect(route).toBeDefined(); + expect(route?.canActivate?.length).toBeGreaterThan(0); + }); + + it('says nothing about phrases when there is no session', () => { + const fixture = TestBed.createComponent(BackupCardComponent); + fixture.detectChanges(); + const el: HTMLElement = fixture.nativeElement; + expect(el.querySelector('.passphrase-box')).toBeNull(); + expect(el.textContent).toContain('No session'); + }); +}); diff --git a/src/app/backup/backup-card.component.ts b/src/app/backup/backup-card.component.ts new file mode 100644 index 0000000..32117f5 --- /dev/null +++ b/src/app/backup/backup-card.component.ts @@ -0,0 +1,113 @@ +import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { CreatureAvatarComponent, QrCodeComponent } from '@moxy/ui'; +import { ProfileSessionStore } from '../stores/profile-session.store'; + +/** + * One page you can put in a drawer. + * + * Losing the edit phrase is this product's only unrecoverable failure, and + * the sole mitigation so far has been telling people to write it down. This + * gives them something worth writing down: both phrases, the creature, the + * QR, and one line each on what they do — printable to paper, or to PDF for + * a password manager, using nothing but the browser's own print dialog. + * + * It carries the edit phrase in plain text, so it says so, loudly, and the + * route is behind the session guard: a view-only visitor can never reach it. + */ +@Component({ + selector: 'moxy-backup-card', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [RouterLink, CreatureAvatarComponent, QrCodeComponent], + template: ` +
+

Backup card

+

+ Print this and put it somewhere you'd keep a passport, or save it as a PDF into a password + manager. It is the difference between losing a phone and losing a profile. +

+
+ This card carries your edit phrase. Anyone holding it can edit or delete + this profile — printing it puts full control on a piece of paper. Don't leave it on a shared + printer, and don't photograph it onto a camera roll that syncs somewhere you wouldn't put a + password. +
+
+ + Back to settings +
+
+ + @if (viewPhrase(); as view) { +
+
+ @if (session.persona(); as persona) { + +
+

{{ persona.name }}

+

Menagerie profile · saved {{ today }}

+
+ } +
+ +

View phrase — share this

+

+ Read-only. Anyone with it can see your saved answers and compare against them. It can + never edit anything. +

+
{{ view }}
+ @if (session.viewUrl(); as url) { +
+ +
+ } + +

Edit phrase — keep this secret

+

+ The only way to change or delete this profile. There is no account and no reset: lose it + and this profile can never be edited again, by you or by anyone. +

+
{{ session.editPhrase() }}
+ +

+ Menagerie stores only ciphertext it can't read. These two phrases are the entire identity + of this profile — there is nothing else to recover it with. +

+
+ } @else { +

No session — log in to print a card.

+ } + `, + styles: ` + .backup-head { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 16px; + } + .backup-qr { + margin: 10px 0 18px; + } + .backup-foot { + margin-top: 18px; + padding-top: 10px; + border-top: 1px solid var(--hairline); + } + `, +}) +export class BackupCardComponent { + protected readonly session = inject(ProfileSessionStore); + + protected readonly viewPhrase = computed(() => this.session.viewPhrase()); + + /** Written on the card so a drawer full of them can be told apart. */ + protected readonly today = new Date().toLocaleDateString(undefined, { + year: 'numeric', + month: 'long', + day: 'numeric', + }); + + protected print(): void { + window.print(); + } +} diff --git a/src/app/compare/compare-panels.component.ts b/src/app/compare/compare-panels.component.ts new file mode 100644 index 0000000..d2fc9ac --- /dev/null +++ b/src/app/compare/compare-panels.component.ts @@ -0,0 +1,60 @@ +import { NgComponentOutlet } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + input, + signal, + type Type, +} from '@angular/core'; +import type { CompareModel } from './compare-model'; +import { + COMPARE_PANELS, + type ComparePanelComponent, + type ComparePanelDescriptor, +} from './compare-panels.token'; + +interface ResolvedPanel { + readonly descriptor: ComparePanelDescriptor; + readonly component: Type; +} + +/** + * The registered panels, resolved once and rendered in order against one + * model. Owning this here means the demo page shows exactly what the real + * compare page shows: a panel registered in app.config.ts appears in both, or + * neither, and there is no second list to keep in step. + */ +@Component({ + selector: 'moxy-compare-panels', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [NgComponentOutlet], + template: ` + @for (panel of visiblePanels(); track panel.descriptor.id) { + + } + `, +}) +export class ComparePanelsComponent { + readonly model = input.required(); + + private readonly resolved = signal([]); + + protected readonly visiblePanels = computed(() => { + const model = this.model(); + return this.resolved().filter((p) => !p.descriptor.visible || p.descriptor.visible(model)); + }); + + constructor() { + const descriptors = [...(inject(COMPARE_PANELS, { optional: true }) ?? [])].sort( + (a, b) => a.order - b.order, + ); + void Promise.all( + descriptors.map(async (descriptor) => ({ + descriptor, + component: await descriptor.loadComponent(), + })), + ).then((panels) => this.resolved.set(panels)); + } +} diff --git a/src/app/compare/compare.component.ts b/src/app/compare/compare.component.ts index 08d5888..e5e68ba 100644 --- a/src/app/compare/compare.component.ts +++ b/src/app/compare/compare.component.ts @@ -1,30 +1,13 @@ -import { NgComponentOutlet } from '@angular/common'; -import { - ChangeDetectionStrategy, - Component, - computed, - inject, - signal, - type Type, -} from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; import { ToastService, seriesVar } from '@moxy/ui'; import { CompareStore } from '../stores/compare.store'; import { ProfileSessionStore } from '../stores/profile-session.store'; -import { - COMPARE_PANELS, - type ComparePanelComponent, - type ComparePanelDescriptor, -} from './compare-panels.token'; - -interface ResolvedPanel { - readonly descriptor: ComparePanelDescriptor; - readonly component: Type; -} +import { ComparePanelsComponent } from './compare-panels.component'; @Component({ selector: 'moxy-compare', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [NgComponentOutlet], + imports: [ComparePanelsComponent], template: `

Compare profiles

@@ -93,9 +76,7 @@ interface ResolvedPanel { @if (store.model(); as m) { @if (m.payloads.length >= 2) { - @for (panel of visiblePanels(); track panel.descriptor.id) { - - } + } @else {

@@ -113,14 +94,6 @@ export class CompareComponent { private readonly toast = inject(ToastService); protected readonly color = seriesVar; - private readonly resolved = signal([]); - - protected readonly visiblePanels = computed(() => { - const m = this.store.model(); - if (!m) return []; - return this.resolved().filter((p) => !p.descriptor.visible || p.descriptor.visible(m)); - }); - protected readonly canAddMine = computed(() => { const mine = this.session.viewPhrase(); return ( @@ -130,18 +103,6 @@ export class CompareComponent { ); }); - constructor() { - const descriptors = [...(inject(COMPARE_PANELS, { optional: true }) ?? [])].sort( - (a, b) => a.order - b.order, - ); - void Promise.all( - descriptors.map(async (descriptor) => ({ - descriptor, - component: await descriptor.loadComponent(), - })), - ).then((panels) => this.resolved.set(panels)); - } - protected slotName(slotIndex: number): string { const m = this.store.model(); if (!m) return '…'; diff --git a/src/app/demo/demo.component.ts b/src/app/demo/demo.component.ts new file mode 100644 index 0000000..d21b7dc --- /dev/null +++ b/src/app/demo/demo.component.ts @@ -0,0 +1,135 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + resource, + signal, +} from '@angular/core'; +import { Router, RouterLink } from '@angular/router'; +import { buildDemoCast, personaFromViewPhrase } from '@moxy/core'; +import { CreatureAvatarComponent, ToastService } from '@moxy/ui'; +import { buildCompareModel, type CompareSlot } from '../compare/compare-model'; +import { ComparePanelsComponent } from '../compare/compare-panels.component'; +import { ProfileSessionStore } from '../stores/profile-session.store'; +import { ServerConfigStore } from '../stores/server-config.store'; + +/** + * What a comparison looks like, before you have one. + * + * Everything else here needs two finished profiles and a reachable server + * before it shows anything at all, which asks a newcomer to spend twenty + * minutes on faith. This renders the real panels, with real scoring, against + * a fictional pair — no network, no session, nothing stored. + */ +@Component({ + selector: 'moxy-demo', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [RouterLink, ComparePanelsComponent, CreatureAvatarComponent], + template: ` +

+

What a comparison looks like

+

+ Two profiles that don’t exist, compared for real. Every number below is computed by the same + code your own comparisons use — nothing here is a screenshot, and nothing here is stored or + sent anywhere. +

+
+ + + @if (demo.error()) { +
+

The demo didn’t build

+

That’s a bug in Menagerie, not in anything you did.

+ Go to the start +
+ } @else if (demo.value(); as model) { +
+
+ @for (persona of personas(); track persona.name) { + + + {{ persona.name }} + + } +
+

+ Invented for this page. They have opposite ideas about drinking, and one of them made that + a dealbreaker — which is the kind of thing worth knowing early, and the kind of thing this + survey exists to surface. +

+
+ + + +
+

Your turn

+

+ Hatching takes a second and needs no account, no email, and no name. Answer the core set, + share your phrase with one person, and you get this — about the two of you. +

+ @if (ready()) { + + } @else { + + Get started +

+ No profile server is configured yet, so this demo is all there is to see for now. +

+ } +
+ } @else { +

Building the comparison…

+ } + `, +}) +export class DemoComponent { + private readonly session = inject(ProfileSessionStore); + private readonly config = inject(ServerConfigStore); + private readonly router = inject(Router); + private readonly toast = inject(ToastService); + + protected readonly hatching = signal(false); + protected readonly ready = computed(() => this.config.state() === 'ready'); + + /** + * No params: the cast is fixed, so this runs once. Nothing in here reaches + * the network — the server config is consulted only to decide whether to + * offer hatching, never to build the comparison, which is what lets this be + * the one page that still works when the profile server is unreachable. + */ + protected readonly demo = resource({ + loader: async () => { + const cast = await buildDemoCast(); + const slots: CompareSlot[] = await Promise.all( + cast.map(async (profile) => ({ + ref: profile.phrase, + payload: profile.payload, + persona: await personaFromViewPhrase(profile.phrase), + })), + ); + return buildCompareModel(slots); + }, + }); + + protected readonly personas = computed(() => { + if (this.demo.error()) return []; + return (this.demo.value()?.slots ?? []) + .map((slot) => slot.persona) + .filter((persona) => persona != null); + }); + + protected async hatch(): Promise { + this.hatching.set(true); + try { + await this.session.hatch(); + await this.router.navigate(['/me']); + } catch (err) { + this.toast.error(err); + } finally { + this.hatching.set(false); + } + } +} diff --git a/src/app/edit-login/edit-login.component.ts b/src/app/edit-login/edit-login.component.ts index 2c371d2..75ebc70 100644 --- a/src/app/edit-login/edit-login.component.ts +++ b/src/app/edit-login/edit-login.component.ts @@ -1,5 +1,6 @@ import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; import { Router, RouterLink } from '@angular/router'; +import { describePhrase, diagnoseEditPhrase } from '@moxy/core'; import { ToastService } from '@moxy/ui'; import { ProfileSessionStore } from '../stores/profile-session.store'; @@ -26,7 +27,11 @@ import { ProfileSessionStore } from '../stores/profile-session.store'; autocomplete="off" aria-label="Edit phrase" [disabled]="busy()" + (input)="problem.set(null)" /> + @if (problem(); as message) { +

{{ message }}

+ }
@if (config.state() === 'unconfigured') { @@ -69,7 +80,11 @@ import { ServerConfigStore } from '../stores/server-config.store'; placeholder="correct horse battery staple luck" autocomplete="off" aria-label="Edit phrase" + (input)="editProblem.set(null)" /> + @if (editProblem(); as message) { +

{{ message }}

+ } @@ -81,10 +96,14 @@ import { ServerConfigStore } from '../stores/server-config.store'; + @if (viewProblem(); as message) { +

{{ message }}

+ } @@ -113,6 +132,9 @@ export class LandingComponent { private readonly toast = inject(ToastService); protected readonly hatching = signal(false); + /** Inline and per-field: a correction belongs beside the box it fixes. */ + protected readonly editProblem = signal(null); + protected readonly viewProblem = signal(null); protected async hatch(): Promise { this.hatching.set(true); @@ -128,6 +150,12 @@ export class LandingComponent { protected async edit(event: Event, input: HTMLInputElement): Promise { event.preventDefault(); + const message = describePhrase(await diagnoseEditPhrase(input.value), 'edit phrase'); + if (message) { + this.editProblem.set(message); + return; + } + this.editProblem.set(null); try { if (await this.session.login(input.value)) { await this.router.navigate(['/me']); @@ -143,9 +171,15 @@ export class LandingComponent { event.preventDefault(); const phrase = extractViewPhrase(input.value); if (!phrase) { - this.toast.show('That doesn’t look like a Menagerie view phrase or link.', 'error'); + // The grammar check already knows which word is wrong; "that doesn't + // look like a phrase" made the person hunt for it themselves. + this.viewProblem.set( + describePhrase(diagnoseViewPhrase(input.value), 'view phrase') ?? + 'That doesn’t look like a Menagerie view phrase or link.', + ); return; } + this.viewProblem.set(null); void this.router.navigate(['/view', phrase]); } diff --git a/src/app/settings/settings.component.ts b/src/app/settings/settings.component.ts index 8af99fd..78d30e2 100644 --- a/src/app/settings/settings.component.ts +++ b/src/app/settings/settings.component.ts @@ -39,7 +39,8 @@ import { MetricsStore } from '../stores/metrics.store';

Housekeeping: profiles with no saved answers are deleted after {{ gcEmpty }}; profiles untouched and unviewed for {{ gcIdle }} are deleted too. Saving anything, or anyone viewing - you, keeps yours alive. + you, keeps yours alive. There is no account and no reset, so the + backup card is worth printing while you still can.