mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Shell: one-shot #example-<id> deep link consumed at startup
This commit is contained in:
@@ -50,6 +50,16 @@ serializes the builder's no-datasets state only: an un-targeted builder open
|
||||
picks a dataset itself when any exist, so the derived view immediately
|
||||
self-corrects to the `dataset-build` form.
|
||||
|
||||
**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)
|
||||
stays out of the `ViewState` union: it is parsed by its own function in
|
||||
`url-hash.ts`, consumed once at startup (`orchestration/startup.ts`, after
|
||||
persistence wiring so the created record write-throughs, before `startRouting`),
|
||||
and then routing's settle step replaces the hash with the resulting view. It
|
||||
never serializes back and never participates in Back/Forward; `parseHash`
|
||||
degrades it like any unknown hash. Any future action link (e.g. a spec-payload
|
||||
link) follows the same shape rather than growing the view-state union.
|
||||
|
||||
### 1.2 The adapter: `infrastructure/url-hash.ts`
|
||||
|
||||
This is the only file that reads or writes `window.location` / `history`. It
|
||||
|
||||
@@ -102,6 +102,8 @@ 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).
|
||||
- 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.
|
||||
|
||||
## F. Toast Notifications
|
||||
|
||||
Transient toast messages appear in a corner of the screen to confirm actions or report problems, without interrupting the workflow.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user