Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/feature-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions e2e/run-e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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 });
Expand Down
70 changes: 70 additions & 0 deletions libs/core/src/demo/demo-cast.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
131 changes: 131 additions & 0 deletions libs/core/src/demo/demo-cast.ts
Original file line number Diff line number Diff line change
@@ -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<readonly DemoProfile[]> {
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,
),
})),
);
}
108 changes: 108 additions & 0 deletions libs/core/src/hatch/phrase-check.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading