Shell: one-shot #example-<id> deep link consumed at startup

This commit is contained in:
2026-07-04 16:40:56 +03:00
parent db258a4e54
commit 5ec7c3aa77
5 changed files with 79 additions and 0 deletions
+19
View File
@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach } from 'vitest';
import {
parseHash,
parseExampleHash,
serializeHash,
readView,
replaceView,
@@ -75,6 +76,24 @@ describe('parseHash', () => {
});
});
describe('parseExampleHash', () => {
it('extracts the example id from the one-shot action link', () => {
expect(parseExampleHash('#example-brush')).toBe('brush');
expect(parseExampleHash('example-bar')).toBe('bar'); // leading "#" optional
});
it('returns null for anything that is not an example link', () => {
expect(parseExampleHash('')).toBeNull();
expect(parseExampleHash('#example-')).toBeNull(); // empty id
expect(parseExampleHash('#snippet-abc')).toBeNull();
expect(parseExampleHash('#build')).toBeNull();
});
it('is not a ViewState: parseHash degrades an example link to the default view', () => {
expect(parseHash('#example-brush')).toEqual({ kind: 'snippets' });
});
});
describe('round-trip identity', () => {
it('parseHash(serializeHash(v)) deep-equals v for every variant', () => {
for (const v of ALL_VIEWS) {
+19
View File
@@ -88,6 +88,25 @@ export function serializeHash(view: ViewState): string {
}
}
/**
* One-shot action link, parsed separately from `ViewState`: `#example-<id>`
* asks the app to add that gallery example (`@core/examples`) as a snippet at
* startup and open it. It is consumed once — routing's settle step then
* replaces the hash with the created snippet's view — so it never serializes
* back and never participates in Back/Forward. `parseHash` degrades it (like
* any unknown hash) to the default view, which is exactly the fallback for an
* id that no longer exists.
*/
export function parseExampleHash(rawHash: string): string | null {
const m = /^example-(.+)$/.exec(rawHash.replace(/^#/, ''));
return m ? m[1] : null;
}
/** Read the one-shot example id from the current URL, if any. */
export function readExampleId(): string | null {
return parseExampleHash(window.location.hash);
}
/** Read the current view from `window.location.hash`. */
export function readView(): ViewState {
return parseHash(window.location.hash);
+29
View File
@@ -12,6 +12,8 @@ import type { Snippet } from '@core/snippet';
import type { Dataset } from '@core/dataset';
import type { CustomTheme } from '@core/custom-theme';
import type { FontAsset } from '@core/font-asset';
import { CHART_EXAMPLES, exampleSpecText } from '@core/examples';
import { readExampleId } from '../infrastructure/url-hash';
import { loadSnippets } from '../infrastructure/snippet-store';
import { loadDatasets } from '../infrastructure/dataset-store';
import { loadCustomThemes } from '../infrastructure/theme-store';
@@ -23,6 +25,7 @@ import {
} from '../services/storage-errors';
import { notify } from '../stores/NotificationStore';
import { useSnippetStore } from '../stores/SnippetStore';
import { usePanesStore } from '../stores/PanesStore';
import { useDatasetStore } from '../stores/DatasetStore';
import { useCustomThemeStore } from '../stores/CustomThemeStore';
import { useFontStore } from '../stores/FontStore';
@@ -95,6 +98,32 @@ export async function initApp(): Promise<void> {
wireThemePersistence();
wireFontPersistence();
// One-shot deep link (spec §01E): `#example-<id>` — the landing's hand-off
// links — adds that gallery example as an ordinary snippet and opens it. It
// runs after persistence wiring (so the new snippet write-throughs; anything
// created before wiring would be treated as loaded baseline and never saved)
// and before routing (whose settle step below replaces the hash with the
// created snippet's view, so a reload doesn't re-add it). An unknown id is
// ignored and the hash degrades to the default view.
// TODO: the ordering constraints above (after wiring, before routing) have no
// automated coverage — an initApp integration test (fake-indexeddb + stubbed
// location.hash) would catch a reorder silently breaking the marketing links.
const exampleId = readExampleId();
if (exampleId) {
const example = CHART_EXAMPLES.find((e) => e.id === exampleId);
if (example) {
const firstSnippet = useSnippetStore.getState().snippets.length === 0;
useSnippetStore.getState().createSnippet({
name: example.name,
spec: exampleSpecText(example),
});
// Entering the workspace directly, the onboarding canvas never shows — so
// give a first chart the same generous default split leaving it would
// (spec §02 → First-Run & Empty Workspace).
if (firstSnippet) usePanesStore.getState().applyOnboardingSplit(window.innerWidth);
}
}
// Routing starts AFTER hydrate so the on-load hash restore can resolve snippet
// / dataset ids against the loaded stores (spec §01E, docs/architecture/04).
startRouting();