Shell: one-shot #spec-<payload> link consumed at startup

This commit is contained in:
2026-07-04 19:42:42 +03:00
parent e4ff442219
commit 194177c0c5
5 changed files with 86 additions and 28 deletions
+10 -8
View File
@@ -51,14 +51,16 @@ picks a dataset itself when any exist, so the derived view immediately
self-corrects to the `dataset-build` form. self-corrects to the `dataset-build` form.
**One-shot action links are not view states.** A hash form that _requests an **One-shot action links are not view states.** A hash form that _requests an
action_ (`#example-<id>`: add that gallery example and open it — spec §01E) action_ `#example-<id>` (add that gallery example) and `#spec-<payload>` (add
stays out of the `ViewState` union: it is parsed by its own function in the spec carried in the payload; `@core/spec-link` owns the base64url encoding,
`url-hash.ts`, consumed once at startup (`orchestration/startup.ts`, after shared with the learn pages that build such links) — stays out of the
persistence wiring so the created record write-throughs, before `startRouting`), `ViewState` union: each is parsed by its own function in `url-hash.ts`,
and then routing's settle step replaces the hash with the resulting view. It consumed once at startup (`orchestration/startup.ts`, after persistence wiring
never serializes back and never participates in Back/Forward; `parseHash` so the created record write-throughs, before `startRouting`), and then
degrades it like any unknown hash. Any future action link (e.g. a spec-payload routing's settle step replaces the hash with the resulting view. Action links
link) follows the same shape rather than growing the view-state union. never serialize back and never participate in Back/Forward; `parseHash`
degrades them like any unknown hash. Any future action link follows the same
shape rather than growing the view-state union.
### 1.2 The adapter: `infrastructure/url-hash.ts` ### 1.2 The adapter: `infrastructure/url-hash.ts`
+4 -1
View File
@@ -102,7 +102,10 @@ Behavior:
- On load, the app reads the hash and restores the corresponding state (selected snippet, Datasets list, a specific dataset, the new-dataset form, or the Chart Builder). - On load, the app reads the hash and restores the corresponding state (selected snippet, Datasets list, a specific dataset, the new-dataset form, or the Chart Builder).
- An empty/absent hash opens the default snippets view with no modal. - An empty/absent hash opens the default snippets view with no modal.
**One-shot action link.** `#example-<id>` is not a view state but a request: on load, the app adds the matching gallery example (see _Snippet Library → First-Run & Empty Workspace_) as an ordinary snippet and opens it, then replaces the hash with the created snippet's view — so reloading does not re-add it, and the link never appears in Back/Forward history. Landing at it with an empty library skips the onboarding canvas and lays the workspace out at the same default split leaving the canvas would. An unknown id is ignored and the hash degrades to the default view. Each visit to such a link deliberately creates a new copy (same as pressing an example's **Add**). The landing uses these links (the hero's "Open in Astrolabe") to hand a visitor into the app carrying the chart they were just looking at. **One-shot action links.** Two hash forms are not view states but requests, consumed on load: the app adds a snippet, opens it, then replaces the hash with the created snippet's view — so reloading does not re-add it, and the link never appears in Back/Forward history. Landing at one with an empty library skips the onboarding canvas and lays the workspace out at the same default split leaving the canvas would. Each visit deliberately creates a new copy.
- `#example-<id>` adds the matching gallery example (see _Snippet Library → First-Run & Empty Workspace_), named as in the gallery (same as pressing its **Add**). An unknown id is ignored and the hash degrades to the default view. The landing uses these links (the hero's "Open in Astrolabe") to hand a visitor into the app carrying the chart they were just looking at.
- `#spec-<payload>` carries a spec's own text (base64url-encoded), so any sender — a lesson stage's "Open in Astrolabe", a shared link — can hand a self-contained spec into the app. The snippet's name derives from the spec (its `title`, else a "Mark chart of y by x" phrase, else "Shared spec"), like a pasted spec. A malformed payload is ignored and the hash degrades to the default view.
## F. Toast Notifications ## F. Toast Notifications
+18
View File
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach } from 'vitest';
import { import {
parseHash, parseHash,
parseExampleHash, parseExampleHash,
parseSpecHash,
serializeHash, serializeHash,
readView, readView,
replaceView, replaceView,
@@ -94,6 +95,23 @@ describe('parseExampleHash', () => {
}); });
}); });
describe('parseSpecHash', () => {
it('extracts the payload from the one-shot spec link', () => {
expect(parseSpecHash('#spec-eyJtYXJrIjoiYmFyIn0')).toBe('eyJtYXJrIjoiYmFyIn0');
expect(parseSpecHash('spec-AAAA')).toBe('AAAA'); // leading "#" optional
});
it('returns null for anything that is not a spec link', () => {
expect(parseSpecHash('')).toBeNull();
expect(parseSpecHash('#spec-')).toBeNull(); // empty payload
expect(parseSpecHash('#example-brush')).toBeNull();
});
it('is not a ViewState: parseHash degrades a spec link to the default view', () => {
expect(parseHash('#spec-eyJtYXJrIjoiYmFyIn0')).toEqual({ kind: 'snippets' });
});
});
describe('round-trip identity', () => { describe('round-trip identity', () => {
it('parseHash(serializeHash(v)) deep-equals v for every variant', () => { it('parseHash(serializeHash(v)) deep-equals v for every variant', () => {
for (const v of ALL_VIEWS) { for (const v of ALL_VIEWS) {
+18
View File
@@ -107,6 +107,24 @@ export function readExampleId(): string | null {
return parseExampleHash(window.location.hash); return parseExampleHash(window.location.hash);
} }
/**
* The second action link: `#spec-<payload>` carries a spec's text itself
* (base64url — `@core/spec-link` owns the encoding), so a lesson stage or any
* sender can hand a self-contained spec into the app. Same one-shot contract
* as `#example-<id>`. The base64url alphabet has no percent-escapes, so
* reading `location.hash` is safe even in Firefox (which returns the hash
* percent-decoded).
*/
export function parseSpecHash(rawHash: string): string | null {
const m = /^spec-(.+)$/.exec(rawHash.replace(/^#/, ''));
return m ? m[1] : null;
}
/** Read the one-shot spec payload from the current URL, if any. */
export function readSpecPayload(): string | null {
return parseSpecHash(window.location.hash);
}
/** Read the current view from `window.location.hash`. */ /** Read the current view from `window.location.hash`. */
export function readView(): ViewState { export function readView(): ViewState {
return parseHash(window.location.hash); return parseHash(window.location.hash);
+33 -16
View File
@@ -13,7 +13,9 @@ import type { Dataset } from '@core/dataset';
import type { CustomTheme } from '@core/custom-theme'; import type { CustomTheme } from '@core/custom-theme';
import type { FontAsset } from '@core/font-asset'; import type { FontAsset } from '@core/font-asset';
import { CHART_EXAMPLES, exampleSpecText } from '@core/examples'; import { CHART_EXAMPLES, exampleSpecText } from '@core/examples';
import { readExampleId } from '../infrastructure/url-hash'; import { deriveSnippetName } from '@core/snippet';
import { decodeSpecPayload } from '@core/spec-link';
import { readExampleId, readSpecPayload } from '../infrastructure/url-hash';
import { loadSnippets } from '../infrastructure/snippet-store'; import { loadSnippets } from '../infrastructure/snippet-store';
import { loadDatasets } from '../infrastructure/dataset-store'; import { loadDatasets } from '../infrastructure/dataset-store';
import { loadCustomThemes } from '../infrastructure/theme-store'; import { loadCustomThemes } from '../infrastructure/theme-store';
@@ -98,29 +100,44 @@ export async function initApp(): Promise<void> {
wireThemePersistence(); wireThemePersistence();
wireFontPersistence(); wireFontPersistence();
// One-shot deep link (spec §01E): `#example-<id>` the landing's hand-off // One-shot action links (spec §01E): `#example-<id>` (the landing's hand-off
// links adds that gallery example as an ordinary snippet and opens it. It // links) adds that gallery example; `#spec-<payload>` (lesson stages, shared
// runs after persistence wiring (so the new snippet write-throughs; anything // links) carries the spec text itself. Both add an ordinary snippet and open
// created before wiring would be treated as loaded baseline and never saved) // it. They run after persistence wiring (so the new snippet write-throughs;
// and before routing (whose settle step below replaces the hash with the // anything created before wiring would be treated as loaded baseline and
// created snippet's view, so a reload doesn't re-add it). An unknown id is // never saved) and before routing (whose settle step below replaces the hash
// ignored and the hash degrades to the default view. // with the created snippet's view, so a reload doesn't re-add it). An unknown
// id or malformed payload is ignored and the hash degrades to the default view.
// TODO: the ordering constraints above (after wiring, before routing) have no // TODO: the ordering constraints above (after wiring, before routing) have no
// automated coverage — an initApp integration test (fake-indexeddb + stubbed // automated coverage — an initApp integration test (fake-indexeddb + stubbed
// location.hash) would catch a reorder silently breaking the marketing links. // location.hash) would catch a reorder silently breaking the marketing links.
const exampleId = readExampleId(); const addLinkedSnippet = (options: {
if (exampleId) { name: string;
const example = CHART_EXAMPLES.find((e) => e.id === exampleId); spec: string;
if (example) { nameSource?: 'auto' | 'user';
}): void => {
const firstSnippet = useSnippetStore.getState().snippets.length === 0; const firstSnippet = useSnippetStore.getState().snippets.length === 0;
useSnippetStore.getState().createSnippet({ useSnippetStore.getState().createSnippet(options);
name: example.name,
spec: exampleSpecText(example),
});
// Entering the workspace directly, the onboarding canvas never shows — so // Entering the workspace directly, the onboarding canvas never shows — so
// give a first chart the same generous default split leaving it would // give a first chart the same generous default split leaving it would
// (spec §02 → First-Run & Empty Workspace). // (spec §02 → First-Run & Empty Workspace).
if (firstSnippet) usePanesStore.getState().applyOnboardingSplit(window.innerWidth); if (firstSnippet) usePanesStore.getState().applyOnboardingSplit(window.innerWidth);
};
const exampleId = readExampleId();
const example = exampleId ? CHART_EXAMPLES.find((e) => e.id === exampleId) : undefined;
if (example) {
// Same semantics as the gallery's Add: a curated name the user chose.
addLinkedSnippet({ name: example.name, spec: exampleSpecText(example) });
} else {
const payload = readSpecPayload();
const specText = payload !== null ? decodeSpecPayload(payload) : null;
if (specText !== null) {
// Same semantics as the paste door: derived name that tracks the spec.
addLinkedSnippet({
name: deriveSnippetName(specText) ?? 'Shared spec',
nameSource: 'auto',
spec: specText,
});
} }
} }