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
419 changes: 419 additions & 0 deletions docs/feature-plan.md

Large diffs are not rendered by default.

46 changes: 46 additions & 0 deletions e2e/run-e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,52 @@ try {
if (!(await pageB.locator('a', { hasText: 'Their profile' }).count())) {
fail('reply view-phrase attachment missing');
}
// --- freshness: B keeps A, A answers something, B is told ----------------
// The whole point of keeping a creature: noticing it changed without having
// to ask its owner out of band.
step = 'menagerie-keep';
await pageB.goto(viewUrl);
await pageB.waitForSelector(`text=${personaName}`, { timeout: 30000 });
await pageB.click('text=💾 Add to my menagerie');
await pageB.waitForSelector('text=joined your menagerie', { timeout: 30000 });
await pageB.goto(`${BASE}#/menagerie`);
await pageB.waitForSelector(`text=${personaName}`, { timeout: 60000 });
await pageB.waitForSelector('text=Check for updates', { timeout: 60000 });
if ((await pageB.textContent('body')).includes('new answers')) {
fail('a creature kept a moment ago is reported as having new answers');
}

step = 'menagerie-updated';
await page.goto(`${BASE}#/me`);
await page.waitForSelector('.profile-head');
await editCategory(page, 'What I value', async (card) => {
await card.locator('.q-row', { hasText: 'Togetherness' }).locator('.pip-scale').nth(5).click();
});
// reload(), not goto(): B is already on this URL and the browser treats a
// same-fragment goto as nothing at all. Reloading also proves the point —
// the baseline came back from the server, not from a signal still in memory.
// Two waits, because the page has to finish an Argon2id session restore
// before the refresh it kicks off can say anything.
await pageB.reload();
await pageB.waitForSelector(`text=${personaName}`, { timeout: 60000 });
await pageB.waitForSelector('text=new answers', { timeout: 60000 });

step = 'menagerie-seen';
// Reading the profile is what clears it — and it stays cleared across a
// reload, which is the part that only works because the baseline reached
// the server. Wait for the page to stop talking before reloading, or the
// reload races the very write being asserted.
await pageB.click('a:has-text("View")');
await pageB.waitForSelector(`text=${personaName}`, { timeout: 60000 });
await pageB.waitForLoadState('networkidle');
await pageB.goto(`${BASE}#/menagerie`);
await pageB.reload();
await pageB.waitForSelector(`text=${personaName}`, { timeout: 60000 });
await pageB.waitForSelector('text=Check for updates', { timeout: 60000 });
if ((await pageB.textContent('body')).includes('new answers')) {
fail('the badge came back after the profile was looked at');
}

// Park B on A's (still-current) profile page: after A regenerates, this
// stale page's boop attempt must be turned away.
await pageB.goto(viewUrl);
Expand Down
50 changes: 44 additions & 6 deletions libs/core/src/hatch/hatch-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,17 +370,55 @@ export class HatchClient {
}
}

/** Everything one view fetch learned, for callers that want more than answers. */
export interface FetchedView {
readonly payload: ProfilePayload;
/** The derived locator — worth keeping, it cost an Argon2id pass. */
readonly viewLocator: string;
/** Save count, the same number freshness checks compare against. */
readonly version: number;
}

/**
* Fetch and decrypt the open payload a view phrase points at — the one
* derive→fetch→decrypt→migrate pipeline every viewer shares. Null when the
* server has no record (deleted, expired, or re-minted).
* The one derive→fetch→decrypt→migrate pipeline every viewer shares. Null
* when the server has no record (deleted, expired, or re-minted).
*/
export async function fetchViewPayload(
export async function fetchView(
client: HatchClient,
viewPhrase: string,
): Promise<ProfilePayload | null> {
): Promise<FetchedView | null> {
const { viewLocator, viewKey } = await deriveViewKeys(viewPhrase);
const record = await client.getView(viewLocator);
if (!record) return null;
return migrateToCurrent(await decryptBlob(record.blob_view, viewKey));
return {
payload: migrateToCurrent(await decryptBlob(record.blob_view, viewKey)),
viewLocator,
version: record.version,
};
}

/** Just the answers, for the callers that want nothing else. */
export async function fetchViewPayload(
client: HatchClient,
viewPhrase: string,
): Promise<ProfilePayload | null> {
return (await fetchView(client, viewPhrase))?.payload ?? null;
}

/**
* How many times the profile behind a locator has been saved, or null when
* nothing answers to it (deleted, expired, or re-minted). No key needed: the
* version is metadata beside the ciphertext, so a viewer can tell that a
* profile changed without being able to read a word of it.
*
* Takes a locator rather than a phrase so callers holding a cached one skip
* the Argon2id derivation. The read still transfers the whole blob — the API
* has no metadata-only route — so this is cheap in CPU, not in bytes.
*/
export async function fetchViewVersion(
client: HatchClient,
viewLocator: string,
): Promise<number | null> {
const record = await client.getView(viewLocator);
return record ? record.version : null;
}
79 changes: 79 additions & 0 deletions libs/core/src/hatch/priv-data.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest';
import { connectionFreshness, migratePrivData, type PrivData } from './priv-data';

describe('connectionFreshness', () => {
it('is current while the version has not moved', () => {
expect(connectionFreshness({ lastSeenVersion: 4 }, 4)).toBe('current');
});

it('is updated once the profile has been saved again', () => {
expect(connectionFreshness({ lastSeenVersion: 4 }, 7)).toBe('updated');
});

// A profile can only ever be re-keyed forward, but a stale baseline read
// from another device must never render as a negative "update".
it('is current when the server is somehow behind the baseline', () => {
expect(connectionFreshness({ lastSeenVersion: 9 }, 4)).toBe('current');
});

it('is gone when nothing answers to the locator', () => {
expect(connectionFreshness({ lastSeenVersion: 4 }, null)).toBe('gone');
});

// The badge is for changes you missed. A creature you have never opened —
// one kept before freshness checks existed — has no missed changes.
it('adopts the current version as the baseline when there is none', () => {
expect(connectionFreshness({}, 12)).toBe('current');
expect(connectionFreshness({ lastSeenVersion: undefined }, 12)).toBe('current');
});

it('still reports a never-opened creature as gone', () => {
expect(connectionFreshness({}, null)).toBe('gone');
});
});

describe('migratePrivData and the connection fields', () => {
function blobWith(connection: Record<string, unknown>): unknown {
return {
v: 1,
viewPhrase: 'mellow-verdant-lobster-mistwoven-emberlit-fernhollow',
answers: {},
desiresSalt: null,
connections: [connection],
};
}

// The whole reason both fields are optional: a blob written before they
// existed has to keep opening, with no version bump and no upgrader.
it('opens a connection saved before freshness existed', () => {
const legacy = blobWith({
id: 'a',
label: 'kestrel',
viewPhrase: 'x-y-z-a-b-c',
notes: '',
addedAt: 1,
updatedAt: 1,
});
const priv: PrivData = migratePrivData(legacy);
expect(priv.connections[0].viewLocator).toBeUndefined();
expect(priv.connections[0].lastSeenVersion).toBeUndefined();
expect(connectionFreshness(priv.connections[0], 3)).toBe('current');
});

it('round-trips the cached locator and the baseline', () => {
const priv: PrivData = migratePrivData(
blobWith({
id: 'a',
label: 'kestrel',
viewPhrase: 'x-y-z-a-b-c',
notes: '',
addedAt: 1,
updatedAt: 1,
viewLocator: 'ff00',
lastSeenVersion: 2,
}),
);
expect(priv.connections[0].viewLocator).toBe('ff00');
expect(connectionFreshness(priv.connections[0], 3)).toBe('updated');
});
});
39 changes: 39 additions & 0 deletions libs/core/src/hatch/priv-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,45 @@ export interface SavedConnection {
notes: string;
addedAt: number;
updatedAt: number;
/**
* The view locator derived from `viewPhrase`, cached because deriving it
* costs a full Argon2id pass — a menagerie of eight would otherwise spend
* half a minute just working out what to ask the server about. It is no
* more secret than the phrase sitting beside it in this same blob.
*
* Optional: connections saved before freshness checks existed have none,
* and `migratePrivData` fills absent fields rather than versioning them.
*/
viewLocator?: string;
/**
* The profile's version the last time this person actually looked at it.
* The server bumps a profile's version on every save, so anything higher
* means new answers since. Absent means never looked — which reads as
* "nothing new", not "everything is new".
*/
lastSeenVersion?: number;
}

/** Where a kept creature stands relative to the last time you looked. */
export type ConnectionFreshnessState = 'current' | 'updated' | 'gone';

/**
* Compare a kept connection against what the server holds now.
*
* `currentVersion` is null only for a profile that genuinely answers to
* nothing — deleted, expired, or re-minted. A failed request is not that, and
* callers must not collapse the two: an unreachable server would otherwise
* report every creature you know as gone.
*/
export function connectionFreshness(
connection: Pick<SavedConnection, 'lastSeenVersion'>,
currentVersion: number | null,
): ConnectionFreshnessState {
if (currentVersion === null) return 'gone';
// No baseline means it has never been opened from here, and a change you
// were never shown is not a change you missed.
const seen = connection.lastSeenVersion ?? currentVersion;
return currentVersion > seen ? 'updated' : 'current';
}

/**
Expand Down
10 changes: 9 additions & 1 deletion libs/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,14 @@ export * from './hatch/phrases';
export { encryptBlob, decryptBlob } from './hatch/blob';
export * from './hatch/priv-data';
export * from './hatch/hatch-api';
export { HatchClient, HatchError, fetchViewPayload, type HatchFailure } from './hatch/hatch-client';
export {
HatchClient,
HatchError,
fetchView,
fetchViewPayload,
fetchViewVersion,
type FetchedView,
type HatchFailure,
} from './hatch/hatch-client';

export * from './storage/storage';
50 changes: 50 additions & 0 deletions libs/ui/src/styles/_base.scss
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,56 @@ a {
color: var(--accent);
}

/* ---------- accessibility primitives ---------- */

/* One visible focus ring for everything keyboard-reachable. Before this only
text inputs and .q-mark had any focus styling, so tabbing through the app
was invisible. :focus-visible keeps it off mouse clicks. */
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: var(--radius-sm);
}

/* Present to a screen reader, absent to everything else. */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}

/* The skip link rides above the sticky header (z-index 20) when focused. */
.skip-link {
position: absolute;
left: 8px;
top: -100px;
z-index: 40;
padding: 10px 14px;
border: 0;
border-radius: var(--radius-pill);
background: var(--accent);
color: var(--accent-ink);
font: inherit;
font-weight: 600;
cursor: pointer;
transition: top 0.12s ease-out;
}
.skip-link:focus {
top: 8px;
}

/* #view takes focus on every navigation so the next Tab starts at the new
page — but it is a focus target, not a control, so it shows no ring. */
#view:focus {
outline: none;
}

/* ---------- shell ---------- */

/* Six nav links plus the session chip, logout and theme is a full row; keep
Expand Down
7 changes: 7 additions & 0 deletions src/app/about/about.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,13 @@ import { GC_EMPTY_HUMAN, GC_IDLE_HUMAN } from '@moxy/core';
phrases and tiny QR codes possible. You can self-host it — one dependency-free file in the
repository.
</p>
<p class="sub">
Your menagerie adds one read per kept creature when you open that page, so it can tell you
which of them have new answers. The server can’t read a word of what comes back — it
compares a save counter — but those reads look exactly like views, and they say that someone
is still interested in that profile. That’s why nothing checks in the background: the
requests happen when you open the page, and never while you’re away.
</p>
</div>

<div class="card">
Expand Down
7 changes: 6 additions & 1 deletion src/app/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ import {
provideAppInitializer,
provideBrowserGlobalErrorListeners,
} from '@angular/core';
import { provideRouter, withHashLocation } from '@angular/router';
import { provideRouter, TitleStrategy, withHashLocation } from '@angular/router';
import { getSection } from '@moxy/core';
import { routes } from './app.routes';
import { provideComparePanel } from './compare/compare-panels.token';
import { PageTitleStrategy } from './page-title.strategy';
import { ServerConfigStore } from './stores/server-config.store';

export const appConfig: ApplicationConfig = {
Expand All @@ -17,6 +18,10 @@ export const appConfig: ApplicationConfig = {
// rewrite rules — #/view/<phrase> works from a QR scan anywhere.
provideRouter(routes, withHashLocation()),

// Names every route once: browser tab, history, and the shell's live
// region all read the same string.
{ provide: TitleStrategy, useExisting: PageTitleStrategy },

// Resolve the profile server address before anything routes.
provideAppInitializer(() => inject(ServerConfigStore).init()),

Expand Down
13 changes: 12 additions & 1 deletion src/app/app.html
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
<!-- A button, not an <a href="#view">: hash routing owns the fragment, so a
real anchor would navigate to the "view" route instead of jumping. -->
<button class="skip-link" type="button" (click)="skipToContent()">Skip to content</button>

<header class="app-header">
<a class="brand" routerLink="/">
<span class="brand-mark" aria-hidden="true">
Expand Down Expand Up @@ -58,10 +62,17 @@
</button>
</header>

<main id="view">
<!-- tabindex="-1" makes this focusable programmatically (never by Tab), which
is what lets a navigation land here instead of stranding focus on the
link that was just activated. -->
<main id="view" tabindex="-1">
<router-outlet />
</main>

<!-- The browser announces a page change on a real navigation; a hash-routed
SPA has to say it itself. -->
<p class="sr-only" role="status" aria-live="polite">{{ announcement() }}</p>

<footer class="app-footer">
<p>
No accounts, no names, no analytics — the server stores only ciphertext it can’t read.
Expand Down
Loading