@sempods/apps 0.8.0 → 0.9.0-dev.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +166 -0
  2. package/README.md +13 -6
  3. package/bin/sempods-apps.mjs +1 -0
  4. package/instructions/app-workflow.md +145 -72
  5. package/instructions/publish.md +29 -11
  6. package/instructions/setup.md +2 -2
  7. package/instructions/visual-check.md +20 -2
  8. package/package.json +5 -3
  9. package/scripts/build-site.mjs +37 -9
  10. package/scripts/check-theme.mjs +398 -0
  11. package/scripts/check.mjs +16 -5
  12. package/scripts/icon.mjs +1 -1
  13. package/scripts/lib/apps.mjs +115 -27
  14. package/scripts/lib/generate.mjs +58 -28
  15. package/scripts/lib/json.mjs +6 -0
  16. package/scripts/lib/kit.mjs +81 -0
  17. package/scripts/lib/merge.mjs +12 -9
  18. package/scripts/lib/sdk.mjs +10 -2
  19. package/scripts/lib/themes.mjs +45 -19
  20. package/scripts/migrate.mjs +6 -1
  21. package/scripts/new-app.mjs +2 -7
  22. package/scripts/sdk-update.mjs +32 -10
  23. package/shared/files/{e13474647ebaf26d042d76f4917203d209a2b14c0d2af8d12139ea328530823e → 3b75b42e15713ac93916d9c2978c2826dfedef18ed5255d444482484bc4b3334} +8 -5
  24. package/shared/files/{843c078e84912158607ebb8ce0042589d676a742bcd3cf8b6c474d62ff3a6845 → 5f709dbc1d0ab5c918a1ff06c32342a3f95569b1570793bfd3a03642bb6a7e43} +4 -2
  25. package/shared/snapshot.json +3 -3
  26. package/skeleton/app/package.json +3 -0
  27. package/skeleton/app/src/App.tsx +53 -21
  28. package/skeleton/app/src/examples/ContextSwitch.tsx +131 -0
  29. package/skeleton/app/src/examples/ListScreen.tsx +154 -56
  30. package/skeleton/app/src/index.css +10 -110
  31. package/skeleton/app/src/main.tsx +1 -3
  32. package/skeleton/app/src/places.test.ts +128 -0
  33. package/skeleton/app/src/places.ts +146 -0
  34. package/skeleton/app/src/texts.ts +8 -0
  35. package/skeleton/app/src/themes/calm.css +22 -8
  36. package/skeleton/app/src/themes/fresh.css +22 -8
  37. package/skeleton/app/src/themes/playful.css +22 -8
  38. package/skeleton/app/src/themes/warm.css +22 -8
  39. package/skeleton/app/vite.config.ts +12 -2
  40. package/skills/update/SKILL.md +6 -1
  41. package/update-policy.json +1 -1
  42. package/skeleton/app/src/routes.test.ts +0 -19
  43. package/skeleton/app/src/routes.ts +0 -139
  44. package/skeleton/app/src/ui/Composer.tsx +0 -18
  45. package/skeleton/app/src/ui/ContextSwitch.tsx +0 -94
  46. package/skeleton/app/src/ui/Dialog.tsx +0 -120
  47. package/skeleton/app/src/ui/Menu.tsx +0 -118
  48. package/skeleton/app/src/ui/Tabs.tsx +0 -39
  49. package/skeleton/app/src/ui/dismiss.ts +0 -30
  50. package/skeleton/app/src/ui/icons.tsx +0 -53
  51. package/skeleton/app/src/ui/ui.css +0 -249
@@ -1,27 +1,32 @@
1
1
  // An example, not mounted: the default screen of a list app, built from the
2
- // SDK's quickstart screen with the kit. For a list app, copy it next to
3
- // App.tsx, render <ListScreen /> in place of <Start />, and rename the type,
4
- // fields, collection and texts to the app's own. Keep App.tsx's runtime,
2
+ // SDK's quickstart screen with the UI kit (@sempods/app-ui). For a list app,
3
+ // copy it next to App.tsx, change its import of '../places.ts' to
4
+ // './places.ts', render <ListScreen nav={nav} /> in place of <Start />,
5
+ // declare the item address in apps.json (`"resources": ["items/:id"]`) and
6
+ // run pnpm run regenerate; then rename the type, fields, collection, address
7
+ // and texts to the app's own. Keep App.tsx's runtime,
5
8
  // provider and frame; take behaviour from the SDK's examples, never their
6
9
  // runtime, provider or styles.
10
+ import { useEffect, useRef } from 'react';
7
11
  import {
8
12
  LoadNotice,
9
13
  RemoveButton,
10
14
  ResourceEditor,
11
15
  UnresolvedNotices,
12
16
  UpdateNotice,
17
+ useAppState,
18
+ useContextEditor,
13
19
  useCreation,
14
20
  useFieldUpdate,
15
21
  useList,
16
22
  useMessages,
17
- useResourceEditor,
18
- useSelection,
23
+ useSdkLocale,
19
24
  useWorkflowAccess,
20
25
  } from '@sempods/app-sdk/react';
21
26
  import { fields, flag, text } from '@sempods/client-sdk/edit';
22
- import { Composer } from '../ui/Composer.tsx';
23
- import { Dialog } from '../ui/Dialog.tsx';
24
- import { AddIcon } from '../ui/icons.tsx';
27
+ import { ActionButton, Input, ScreenDialog, type Nav } from '@sempods/app-ui';
28
+ import { PlusIcon } from 'lucide-react';
29
+ import type { Place } from '../places.ts';
25
30
 
26
31
  const schema = 'https://schema.org/';
27
32
 
@@ -44,7 +49,6 @@ const en = {
44
49
  done: (name: string) => `Done: ${name}`,
45
50
  remove: (name: string) => `Delete: ${name}`,
46
51
  edit: 'Edit item',
47
- close: 'Close',
48
52
  name: 'Name',
49
53
  newItem: 'New item',
50
54
  add: 'Add',
@@ -57,58 +61,71 @@ const listTexts: { readonly en: typeof en; readonly de: typeof en } = {
57
61
  done: (name) => `Erledigt: ${name}`,
58
62
  remove: (name) => `Löschen: ${name}`,
59
63
  edit: 'Eintrag bearbeiten',
60
- close: 'Schließen',
61
64
  name: 'Name',
62
65
  newItem: 'Neuer Eintrag',
63
66
  add: 'Hinzufügen',
64
67
  },
65
68
  };
66
69
 
67
- export function ListScreen() {
70
+ export function ListScreen({ nav }: { readonly nav: Nav<Place> }) {
68
71
  const t = useMessages(listTexts);
69
72
  // A list that can only be read gets no input and no invitation to add.
70
73
  const { write } = useWorkflowAccess();
74
+ const { view } = useAppState();
71
75
  const list = useList(item);
72
76
  const items = list.data?.items;
73
77
  // One hook for the list; each row gets its part with `mutate.for(row)`.
74
78
  const mutate = useFieldUpdate(item);
75
- const { selected, select } = useSelection();
76
- const editor = useResourceEditor(selected, item);
77
79
  const creation = useCreation(item, {
78
80
  initial: { name: '', done: false },
79
81
  collection: 'items',
80
82
  });
83
+ const input = useRef<HTMLInputElement>(null);
84
+ if (!view) return null;
85
+ const { place } = nav;
86
+ // A link names the Context the list reads, so the address keeps its source.
87
+ const itemPlace = (subject: string): Place => ({
88
+ kind: 'item',
89
+ subject,
90
+ context: view.contextIri,
91
+ });
81
92
 
82
93
  return (
83
- <>
94
+ // Room below the rows for the bar fixed at the bottom.
95
+ <div className="mt-4 pb-28">
84
96
  {/* Loading, a failed read with "Try again", never a failure as empty. */}
85
97
  <LoadNotice {...list} />
86
98
  {(list.state.kind === 'ready' || list.state.kind === 'loading') &&
87
99
  items?.length === 0 &&
88
100
  list.data?.skipped === 0 && (
89
- <p className="empty">{write ? t.empty : t.emptyReadOnly}</p>
101
+ <p className="py-6 text-muted-foreground">
102
+ {write ? t.empty : t.emptyReadOnly}
103
+ </p>
90
104
  )}
91
- <ul className="rows">
105
+ <ul className="divide-y">
92
106
  {items?.map((entry) => {
93
107
  const row = mutate.for(entry);
94
108
  return (
95
- <li key={entry.iri} className="row">
109
+ <li key={entry.iri} className="flex min-h-14 items-center gap-1">
96
110
  {/* The label is the checkbox's 44px touch target. */}
97
- <label>
111
+ <label className="flex size-11 shrink-0 cursor-pointer items-center justify-center">
98
112
  <input
99
113
  type="checkbox"
114
+ className="size-6 accent-primary"
100
115
  checked={row.data.done}
101
116
  disabled={!row.canMutate}
102
117
  onChange={() => void row.update({ done: !row.data.done })}
103
118
  />
104
- <span className="visually-hidden">{t.done(row.data.name)}</span>
119
+ <span className="sr-only">{t.done(row.data.name)}</span>
105
120
  </label>
106
- <button
107
- className="row-name"
108
- onClick={() => void select(entry.iri)}
121
+ {/* The item's own address: a plain click opens its dialog, a
122
+ modified click a new tab. */}
123
+ <a
124
+ {...nav.link(itemPlace(entry.iri))}
125
+ className="flex min-h-11 flex-1 items-center rounded-md px-2 hover:bg-muted"
109
126
  >
110
127
  {row.data.name}
111
- </button>
128
+ </a>
112
129
  {/* Asks in the SDK's dialog before it deletes. */}
113
130
  <RemoveButton
114
131
  disabled={!row.canMutate}
@@ -123,54 +140,135 @@ export function ListScreen() {
123
140
  </ul>
124
141
  {/* Recovery for a row with an open write that is no longer listed. */}
125
142
  <UnresolvedNotices of={mutate} shown={items} />
126
- {/* Outside the rows, so the draft stays reachable if its row goes.
127
- The SDK's Escape in a field cancels like Cancel and discards the
128
- draft; × and a click beside the dialog ask first. */}
129
- {selected && (
130
- <Dialog
131
- label={t.edit}
132
- closeLabel={t.close}
133
- onClose={() => void select(null)}
134
- >
135
- <ResourceEditor editor={editor} onClose={() => select(null)}>
136
- {(draft, change) => (
137
- <label>
138
- {t.name}
139
- <input
140
- value={draft.name}
141
- onChange={(event) => change({ name: event.target.value })}
142
- />
143
- </label>
144
- )}
145
- </ResourceEditor>
146
- </Dialog>
147
- )}
143
+ <ItemDialog
144
+ subject={place.kind === 'item' ? place.subject : null}
145
+ // The Context the address names; only an address without one uses
146
+ // the selected Context.
147
+ context={(place.kind === 'item' && place.context) || view.contextIri}
148
+ title={t.edit}
149
+ label={t.name}
150
+ // Back to where it was opened from, after a direct entry to the
151
+ // start; the list refreshes on its own time, so a slow read never
152
+ // keeps the dialog open.
153
+ leave={() => {
154
+ nav.close({ kind: 'start' });
155
+ void list.reload();
156
+ }}
157
+ />
148
158
  <UpdateNotice {...creation.notice} quiet />
149
159
  {write && (
150
- <Composer>
160
+ // A bar fixed at the bottom, in thumb reach, above the home indicator.
161
+ // Render it outside views that switch: switching does not ask about
162
+ // an unsent creation draft, and unmounting loses it.
163
+ <div className="fixed inset-x-0 bottom-0 z-10 border-t bg-background px-4 pt-2.5 pb-[calc(0.625rem+env(safe-area-inset-bottom))]">
151
164
  <form
165
+ className="mx-auto flex max-w-3xl items-center gap-2"
152
166
  onSubmit={(event) => {
153
167
  event.preventDefault();
168
+ // The field keeps the focus, also after a press on Add, so the
169
+ // keyboard stays for the next item. Only this press moves it.
170
+ input.current?.focus();
154
171
  void creation.create();
155
172
  }}
156
173
  >
157
- <input
174
+ <Input
175
+ ref={input}
176
+ className="h-11 flex-1"
158
177
  aria-label={t.newItem}
159
178
  placeholder={t.newItem}
160
179
  value={creation.draft.name}
161
- disabled={!creation.canEdit}
180
+ // While a creation is pending the field stays focused and
181
+ // cannot change; a confirmed creation empties it.
182
+ readOnly={creation.busy}
183
+ disabled={!creation.canEdit && !creation.busy}
162
184
  onChange={(event) => creation.change({ name: event.target.value })}
163
185
  />
164
- <button
165
- aria-label={t.add}
166
- title={t.add}
167
- disabled={!creation.canCreate}
186
+ <ActionButton
187
+ type="submit"
188
+ shape="round-sm"
189
+ label={t.add}
190
+ isDisabled={!creation.canCreate}
168
191
  >
169
- <AddIcon />
170
- </button>
192
+ <PlusIcon />
193
+ </ActionButton>
171
194
  </form>
172
- </Composer>
195
+ </div>
196
+ )}
197
+ </div>
198
+ );
199
+ }
200
+
201
+ /**
202
+ * The item's screen: a dialog that follows the address, with the SDK's
203
+ * editor for the Context the address names. Escape, Close and a press beside
204
+ * it ask before an unsaved edit is discarded; Back does too.
205
+ */
206
+ function ItemDialog({
207
+ subject,
208
+ context,
209
+ title,
210
+ label,
211
+ leave,
212
+ }: {
213
+ readonly subject: string | null;
214
+ readonly context: string;
215
+ readonly title: string;
216
+ readonly label: string;
217
+ readonly leave: () => void;
218
+ }) {
219
+ const { messages } = useSdkLocale();
220
+ const edit = useContextEditor(item);
221
+ const { open, close } = edit;
222
+ // While the SDK changes the target (`changing`), it opens no editor; the
223
+ // effect runs again once the change has settled.
224
+ const { changing } = useAppState();
225
+ useEffect(() => {
226
+ if (changing) return;
227
+ if (subject) void open({ subject, context });
228
+ else void close();
229
+ }, [subject, context, changing, open, close]);
230
+ // An editor still showing another target is not this dialog's.
231
+ const editor =
232
+ subject &&
233
+ edit.target?.subject === subject &&
234
+ edit.target.context === context
235
+ ? edit.editor
236
+ : null;
237
+ // The SDK asks before a dirty draft is discarded; leaving the address does
238
+ // not wait for the list to reload.
239
+ const requestClose = async () => {
240
+ if (await edit.close()) leave();
241
+ };
242
+ return (
243
+ <ScreenDialog
244
+ title={title}
245
+ open={subject !== null}
246
+ onRequestClose={() => void requestClose()}
247
+ >
248
+ {edit.phase === 'unavailable' && edit.problem ? (
249
+ // A source Context the app cannot read: no editor, no other Context.
250
+ <p role="alert">{messages.contextProblems?.[edit.problem]}</p>
251
+ ) : (
252
+ <ResourceEditor
253
+ editor={editor}
254
+ quiet
255
+ onClose={() => void requestClose()}
256
+ labels={{ name: label }}
257
+ >
258
+ {(draft, change) => (
259
+ <label className="grid gap-1.5">
260
+ {label}
261
+ <Input
262
+ value={draft.name}
263
+ onChange={(event) =>
264
+ change({ ...draft, name: event.target.value })
265
+ }
266
+ required
267
+ />
268
+ </label>
269
+ )}
270
+ </ResourceEditor>
173
271
  )}
174
- </>
272
+ </ScreenDialog>
175
273
  );
176
274
  }
@@ -1,110 +1,10 @@
1
- :root {
2
- /* Follow the system's light or dark mode. The colours are the --sempods-*
3
- tokens of the app's theme (src/themes/, imported in main.tsx), which
4
- colour the app and the SDK components alike. */
5
- color-scheme: light dark;
6
- font-family: system-ui, sans-serif;
7
- color: var(--sempods-text);
8
- background: var(--sempods-bg);
9
- }
10
- body {
11
- margin: 0;
12
- }
13
- main {
14
- max-width: 760px;
15
- margin: auto;
16
- padding: 16px;
17
- }
18
- /* The app's own header; AppAccess brings its own. */
19
- main > header {
20
- display: flex;
21
- flex-wrap: wrap;
22
- align-items: center;
23
- justify-content: space-between;
24
- gap: 0.5rem;
25
- }
26
- main > header h1 {
27
- display: flex;
28
- align-items: center;
29
- gap: 0.5rem;
30
- margin: 0;
31
- font-size: 1.4rem;
32
- }
33
- main > header h1 img {
34
- width: 28px;
35
- height: 28px;
36
- }
37
-
38
- /* The rules below style the app's own markup only. SDK components
39
- ([data-sempods-ui]) bring their own styles and are left alone. :where()
40
- keeps these rules at zero specificity, so any class overrides them. */
41
-
42
- /* Actions read as text, like the SDK's secondary actions: accent colour, no
43
- frame, a calm hover and a visible focus, with 44px touch targets. */
44
- :where(button):not(:where([data-sempods-ui] *, [data-sempods-button])) {
45
- min-width: 44px;
46
- min-height: 44px;
47
- padding: 10px 12px;
48
- border: 0;
49
- border-radius: 6px;
50
- background: transparent;
51
- color: var(--sempods-accent);
52
- font: inherit;
53
- font-weight: 500;
54
- cursor: pointer;
55
- }
56
- :where(button):not(:where([data-sempods-ui] *, [data-sempods-button])):where(
57
- :hover:not(:disabled)
58
- ) {
59
- background: color-mix(in srgb, var(--sempods-accent) 8%, transparent);
60
- }
61
- :where(button):not(:where([data-sempods-ui] *, [data-sempods-button])):where(
62
- :disabled
63
- ) {
64
- color: var(--sempods-muted);
65
- cursor: default;
66
- }
67
- :where(input, select, textarea):not(
68
- :where([data-sempods-ui] *, [type='checkbox'], [type='radio'])
69
- ) {
70
- box-sizing: border-box;
71
- min-height: 44px;
72
- padding: 8px 10px;
73
- border: 1px solid var(--sempods-line);
74
- border-radius: 6px;
75
- background: var(--sempods-bg);
76
- color: var(--sempods-text);
77
- font: inherit;
78
- }
79
- /* A compact box; its wrapping <label> is the 44px touch target, as in the
80
- SDK. Margins are not clickable, so wrap every checkbox in its label. */
81
- :where(input[type='checkbox'], input[type='radio']):not(
82
- :where([data-sempods-ui] *)
83
- ) {
84
- flex: none;
85
- width: 24px;
86
- height: 24px;
87
- margin: 0;
88
- margin-inline-end: 10px;
89
- accent-color: var(--sempods-accent);
90
- }
91
- :where(label:has(input[type='checkbox'], input[type='radio'])):not(
92
- :where([data-sempods-ui] *)
93
- ) {
94
- display: inline-flex;
95
- align-items: center;
96
- min-width: 44px;
97
- min-height: 44px;
98
- cursor: pointer;
99
- }
100
- :where(button, input, select, textarea, a):not(
101
- :where([data-sempods-ui] *, [data-sempods-button])
102
- ):where(:focus-visible) {
103
- outline: 2px solid var(--sempods-accent);
104
- outline-offset: 2px;
105
- }
106
- /* Lists of items, without the browser's indentation and bullets. */
107
- :where(ul):not(:where([data-sempods-ui] *)) {
108
- padding-inline-start: 0;
109
- list-style: none;
110
- }
1
+ /* The app's stylesheet. The layer order comes first: Tailwind's reset (base)
2
+ stays below the SDK's styles (sempods), and the app's classes (components,
3
+ utilities) apply on and inside SDK surfaces. Then Tailwind, the UI kit with
4
+ its theme contract, and the app's theme. Keep unlayered element rules
5
+ (`button { … }`) out of this file: they would override every SDK rule. */
6
+ @layer theme, base, sempods, components, utilities;
7
+ @import 'tailwindcss';
8
+ @import '@sempods/app-ui/styles.css';
9
+ /* The app's theme: one of src/themes/; switch by changing this line. */
10
+ @import './themes/__APP_THEME__.css';
@@ -1,9 +1,7 @@
1
1
  import { StrictMode } from 'react';
2
2
  import { createRoot } from 'react-dom/client';
3
- // The app's theme: one of src/themes/; switch by changing this line.
4
- import './themes/__APP_THEME__.css';
3
+ // Tailwind, the UI kit and the app's theme.
5
4
  import './index.css';
6
- import './ui/ui.css';
7
5
  import App from './App.tsx';
8
6
  import { registerAppWorker } from './pwa.ts';
9
7
  import { app } from './sempods.generated.ts';
@@ -0,0 +1,128 @@
1
+ import { createBrowserRuntime } from '@sempods/app-sdk';
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
+ import {
4
+ addressModel,
5
+ decodeSubject,
6
+ encodeSubject,
7
+ inApp,
8
+ ITEM,
9
+ } from './places.ts';
10
+ import { app, runtimeOptions } from './sempods.generated.ts';
11
+
12
+ afterEach(() => vi.unstubAllGlobals());
13
+
14
+ describe('places', () => {
15
+ const model = addressModel({
16
+ basePath: '/konsum/',
17
+ routes: ['bought'],
18
+ resources: [ITEM],
19
+ });
20
+ const subject = 'https://pod.example/data/items/milk#it';
21
+ const id = encodeSubject(subject);
22
+
23
+ it('reads the place of an address under the app path', () => {
24
+ expect(model.parse('/konsum/', '').place).toEqual({ kind: 'start' });
25
+ expect(model.parse('/konsum/bought/', '')).toEqual({
26
+ place: { kind: 'view', route: 'bought' },
27
+ canonical: '/konsum/bought',
28
+ });
29
+ expect(model.parse(`/konsum/items/${id}`, '').place).toEqual({
30
+ kind: 'item',
31
+ subject,
32
+ });
33
+ expect(model.parse('/konsum/callback', '?code=x').canonical).toBe(
34
+ '/konsum/callback?code=x',
35
+ );
36
+ });
37
+
38
+ it('keeps the query of a view while it normalises the address', () => {
39
+ expect(model.parse('/konsum/bought/', '?filter=open').canonical).toBe(
40
+ '/konsum/bought?filter=open',
41
+ );
42
+ expect(model.parse('/konsum/', '?task=x').canonical).toBe('/konsum/?task=x');
43
+ });
44
+
45
+ it('keeps the rest of an item query after its context', () => {
46
+ const search = '?tab=history&context=urn%3Actx%3A1';
47
+ expect(model.parse(`/konsum/items/${id}/`, search)).toEqual({
48
+ place: { kind: 'item', subject, context: 'urn:ctx:1' },
49
+ canonical: `/konsum/items/${id}?context=urn%3Actx%3A1&tab=history`,
50
+ });
51
+ expect(model.parse(`/konsum/items/${id}`, '?tab=history').canonical).toBe(
52
+ `/konsum/items/${id}?tab=history`,
53
+ );
54
+ });
55
+
56
+ it('keeps the source Context the address names', () => {
57
+ const context = 'https://pod.example/contexts/home';
58
+ const place = { kind: 'item', subject, context } as const;
59
+ const address = model.format(place);
60
+ const [path, query] = address.split('?');
61
+ expect(model.parse(path!, `?${query}`).place).toEqual(place);
62
+ });
63
+
64
+ it('shows unknown and invalid addresses as not found, keeping them', () => {
65
+ expect(model.parse('/konsum/other', '')).toEqual({
66
+ place: { kind: 'not-found', reason: 'unknown' },
67
+ canonical: '/konsum/other',
68
+ });
69
+ expect(model.parse('/konsum/items/!!', '').place).toEqual({
70
+ kind: 'not-found',
71
+ reason: 'invalid',
72
+ });
73
+ expect(model.parse(`/konsum/items/${id}`, '?context=x').place).toEqual({
74
+ kind: 'not-found',
75
+ reason: 'invalid',
76
+ });
77
+ expect(model.parse('/notes/', '').place.kind).toBe('not-found');
78
+ });
79
+
80
+ it('finds no item an app has not declared', () => {
81
+ const plain = addressModel({
82
+ basePath: '/konsum/',
83
+ routes: [],
84
+ resources: [],
85
+ });
86
+ expect(plain.parse(`/konsum/items/${id}`, '').place).toEqual({
87
+ kind: 'not-found',
88
+ reason: 'unknown',
89
+ });
90
+ });
91
+
92
+ it('returns a callback without a matching sign-in to the start page', async () => {
93
+ // An expired sign-in in a browser with nothing saved: the runtime gets
94
+ // `/` and the app keeps the person in it.
95
+ const callback = new URL(
96
+ `http://127.0.0.1:5180${app.basePath}callback?code=unused&state=expired`,
97
+ );
98
+ vi.stubGlobal('location', callback);
99
+ const seen: string[] = [];
100
+ const runtime = createBrowserRuntime({
101
+ ...runtimeOptions,
102
+ location: () => callback.href,
103
+ // A free lock, so the runtime reads the empty store.
104
+ locks: { request: (name, _options, granted) => granted({ name }) },
105
+ openStore: async () => ({
106
+ read: async () => undefined,
107
+ list: async () => ({ records: [], unreadable: [] }),
108
+ commit: async () => {
109
+ throw new Error('nothing to commit');
110
+ },
111
+ close() {},
112
+ }),
113
+ replaceUrl: (url) => seen.push(inApp(url)),
114
+ });
115
+ const report = await runtime.initialize();
116
+ expect(report).toMatchObject({ storage: 'durable', problem: 'attempt' });
117
+ expect(seen).toEqual([app.basePath]);
118
+ expect(inApp(`${app.basePath}bought?filter=open`)).toBe(
119
+ `${app.basePath}bought?filter=open`,
120
+ );
121
+ });
122
+
123
+ it('encodes a subject as one path segment', () => {
124
+ expect(id).toMatch(/^[A-Za-z0-9_-]+$/);
125
+ expect(decodeSubject(id)).toBe(subject);
126
+ expect(decodeSubject('bm90IGFuIElSSQ')).toBeNull();
127
+ });
128
+ });