From 1e4a8f3f2f7aca4505e6f404f33f8c913d293aa7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 06:07:37 +0000 Subject: [PATCH 1/4] A demo comparison, so the payoff can be seen before it is paid for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everything else here needs two finished profiles and a reachable server before it shows anything, which asks a newcomer to spend twenty minutes on faith. /demo renders the real panels, with real scoring, against a fictional pair — and reaches the network for none of it, which makes it the one page that still works when the profile server is down. The e2e proves exactly that: it drives the demo on the unconfigured landing page, before any server exists. The cast is hand-written, not 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 the three moments worth showing: a strong overall fit, one dealbreaker that needs a conversation, and a mutual desire neither of them could have seen alone. Their phrases are real, well-formed view phrases, so the personas and their art derive the way everyone else's do. A demo that rots is worse than none, so a core spec holds it to the schema: every answered id must exist, the phrases must still mint creatures, the fit stays in a believable band, exactly one dealbreaker trips and only in one direction, and one-sided desires stay hidden — which is the claim the demo is making about how desires travel. Panel rendering moves into a shared ComparePanelsComponent so the demo shows exactly what /compare shows: a panel registered in app.config.ts appears in both or neither, with no second list to keep in step. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UaDanzXTm6kVSgbNh1eNrs --- e2e/run-e2e.mjs | 18 +++ libs/core/src/demo/demo-cast.spec.ts | 70 ++++++++++ libs/core/src/demo/demo-cast.ts | 131 +++++++++++++++++++ libs/core/src/index.ts | 3 + src/app/app.routes.ts | 7 + src/app/compare/compare-panels.component.ts | 60 +++++++++ src/app/compare/compare.component.ts | 47 +------ src/app/demo/demo.component.ts | 135 ++++++++++++++++++++ src/app/landing/landing.component.ts | 6 + 9 files changed, 434 insertions(+), 43 deletions(-) create mode 100644 libs/core/src/demo/demo-cast.spec.ts create mode 100644 libs/core/src/demo/demo-cast.ts create mode 100644 src/app/compare/compare-panels.component.ts create mode 100644 src/app/demo/demo.component.ts diff --git a/e2e/run-e2e.mjs b/e2e/run-e2e.mjs index 8b92ba1..b0e063c 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'); 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/index.ts b/libs/core/src/index.ts index 8127d6d..e36e5bb 100644 --- a/libs/core/src/index.ts +++ b/libs/core/src/index.ts @@ -58,6 +58,9 @@ export * from './hatch/keys'; export * from './hatch/phrases'; 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/src/app/app.routes.ts b/src/app/app.routes.ts index 4823ed9..69dbbfc 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -53,6 +53,13 @@ export const routes: Routes = [ 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/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/landing/landing.component.ts b/src/app/landing/landing.component.ts index 7e6c94b..57b0041 100644 --- a/src/app/landing/landing.component.ts +++ b/src/app/landing/landing.component.ts @@ -18,6 +18,12 @@ import { ServerConfigStore } from '../stores/server-config.store'; reveals overlap — and intimate interests only when they’re mutual. No accounts, no email, no names required. How it works

+ + 🔍 See a comparison first
@if (config.state() === 'unconfigured') { From e72466d2204b96ab7e94d6a15e66e2d160dc5dc2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 06:10:20 +0000 Subject: [PATCH 2/4] A backup card, because losing the edit phrase is unrecoverable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The worst thing that can happen to a profile has had exactly one mitigation: telling people to write the phrase down. This gives them something worth writing down — both phrases, the creature, the view QR, the date, and one line each on what they do. Print it to paper for a drawer, or to PDF for a password manager; the browser's own print dialog does the export, so no dependency and no canvas rasterizing. The card prints the edit phrase in plain text, so it says so in a warning that a spec pins, and the route sits behind the session guard — a view-only visitor must never reach a page that prints edit control. A spec pins that too, since the guard is one line someone could drop while refactoring routes and nothing else would notice. Printing is a real output format here rather than an afterthought: the print block hides the shell, the toast, the skip link and the QR's share button, keeps colour (browsers drop backgrounds unless told, and the creature and QR are identity, not decoration), and refuses to split a phrase across a page break — half a phrase is worthless. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UaDanzXTm6kVSgbNh1eNrs --- libs/ui/src/styles/_base.scss | 44 ++++++++ src/app/app.routes.ts | 9 ++ src/app/backup/backup-card.component.spec.ts | 80 +++++++++++++ src/app/backup/backup-card.component.ts | 113 +++++++++++++++++++ src/app/settings/settings.component.ts | 4 +- 5 files changed, 249 insertions(+), 1 deletion(-) create mode 100644 src/app/backup/backup-card.component.spec.ts create mode 100644 src/app/backup/backup-card.component.ts 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 69dbbfc..dd76693 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -47,6 +47,15 @@ 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], 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/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.