@sempods/apps 0.8.0-dev.1 → 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 (54) hide show
  1. package/CHANGELOG.md +201 -25
  2. package/README.md +13 -6
  3. package/bin/sempods-apps.mjs +1 -0
  4. package/instructions/app-workflow.md +268 -127
  5. package/instructions/publish.md +29 -11
  6. package/instructions/setup.md +13 -8
  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/page.mjs +49 -1
  19. package/scripts/lib/sdk.mjs +10 -2
  20. package/scripts/lib/themes.mjs +45 -19
  21. package/scripts/migrate.mjs +15 -3
  22. package/scripts/new-app.mjs +2 -7
  23. package/scripts/sdk-update.mjs +32 -10
  24. package/shared/files/{e13474647ebaf26d042d76f4917203d209a2b14c0d2af8d12139ea328530823e → 3b75b42e15713ac93916d9c2978c2826dfedef18ed5255d444482484bc4b3334} +8 -5
  25. package/shared/files/{843c078e84912158607ebb8ce0042589d676a742bcd3cf8b6c474d62ff3a6845 → 5f709dbc1d0ab5c918a1ff06c32342a3f95569b1570793bfd3a03642bb6a7e43} +4 -2
  26. package/shared/files/{f30ec6b7f9de16be54919382ab18cdcc33ee64938f7b907d855bcf6740e4c102 → e1c798ec8536cf8cff6c19caf622fa9076ae210b0d1733a6e82ea2c1f4104c43} +11 -2
  27. package/shared/snapshot.json +4 -4
  28. package/skeleton/app/NOTES.md +11 -3
  29. package/skeleton/app/package.json +3 -0
  30. package/skeleton/app/src/App.tsx +53 -21
  31. package/skeleton/app/src/examples/ContextSwitch.tsx +131 -0
  32. package/skeleton/app/src/examples/ListScreen.tsx +176 -69
  33. package/skeleton/app/src/index.css +10 -110
  34. package/skeleton/app/src/main.tsx +1 -3
  35. package/skeleton/app/src/places.test.ts +128 -0
  36. package/skeleton/app/src/places.ts +146 -0
  37. package/skeleton/app/src/texts.ts +8 -0
  38. package/skeleton/app/src/themes/calm.css +22 -8
  39. package/skeleton/app/src/themes/fresh.css +22 -8
  40. package/skeleton/app/src/themes/playful.css +22 -8
  41. package/skeleton/app/src/themes/warm.css +22 -8
  42. package/skeleton/app/vite.config.ts +12 -2
  43. package/skills/update/SKILL.md +6 -1
  44. package/update-policy.json +1 -1
  45. package/skeleton/app/src/routes.test.ts +0 -19
  46. package/skeleton/app/src/routes.ts +0 -139
  47. package/skeleton/app/src/ui/Composer.tsx +0 -18
  48. package/skeleton/app/src/ui/ContextSwitch.tsx +0 -85
  49. package/skeleton/app/src/ui/Dialog.tsx +0 -103
  50. package/skeleton/app/src/ui/Menu.tsx +0 -118
  51. package/skeleton/app/src/ui/Tabs.tsx +0 -39
  52. package/skeleton/app/src/ui/dismiss.ts +0 -30
  53. package/skeleton/app/src/ui/icons.tsx +0 -53
  54. package/skeleton/app/src/ui/ui.css +0 -240
@@ -1,31 +1,37 @@
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,
24
+ useWorkflowAccess,
19
25
  } from '@sempods/app-sdk/react';
20
26
  import { fields, flag, text } from '@sempods/client-sdk/edit';
21
- import { Composer } from '../ui/Composer.tsx';
22
- import { Dialog } from '../ui/Dialog.tsx';
23
- 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';
24
30
 
25
31
  const schema = 'https://schema.org/';
26
32
 
27
33
  /** What one row is: a name and whether it is done. */
28
- export const item = fields(
34
+ const item = fields(
29
35
  {
30
36
  name: text(`${schema}name`, { language: null, trim: true }),
31
37
  done: flag(`${schema}actionStatus`, {
@@ -39,10 +45,10 @@ export const item = fields(
39
45
  // In an app these belong in src/texts.ts.
40
46
  const en = {
41
47
  empty: 'Nothing here yet. Add the first item below.',
48
+ emptyReadOnly: 'Nothing in this list.',
42
49
  done: (name: string) => `Done: ${name}`,
43
50
  remove: (name: string) => `Delete: ${name}`,
44
51
  edit: 'Edit item',
45
- close: 'Close',
46
52
  name: 'Name',
47
53
  newItem: 'New item',
48
54
  add: 'Add',
@@ -51,57 +57,75 @@ const listTexts: { readonly en: typeof en; readonly de: typeof en } = {
51
57
  en,
52
58
  de: {
53
59
  empty: 'Noch nichts hier. Füge unten den ersten Eintrag hinzu.',
60
+ emptyReadOnly: 'Nichts in dieser Liste.',
54
61
  done: (name) => `Erledigt: ${name}`,
55
62
  remove: (name) => `Löschen: ${name}`,
56
63
  edit: 'Eintrag bearbeiten',
57
- close: 'Schließen',
58
64
  name: 'Name',
59
65
  newItem: 'Neuer Eintrag',
60
66
  add: 'Hinzufügen',
61
67
  },
62
68
  };
63
69
 
64
- export function ListScreen() {
70
+ export function ListScreen({ nav }: { readonly nav: Nav<Place> }) {
65
71
  const t = useMessages(listTexts);
72
+ // A list that can only be read gets no input and no invitation to add.
73
+ const { write } = useWorkflowAccess();
74
+ const { view } = useAppState();
66
75
  const list = useList(item);
67
76
  const items = list.data?.items;
68
77
  // One hook for the list; each row gets its part with `mutate.for(row)`.
69
78
  const mutate = useFieldUpdate(item);
70
- const { selected, select } = useSelection();
71
- const editor = useResourceEditor(selected, item);
72
79
  const creation = useCreation(item, {
73
80
  initial: { name: '', done: false },
74
81
  collection: 'items',
75
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
+ });
76
92
 
77
93
  return (
78
- <>
94
+ // Room below the rows for the bar fixed at the bottom.
95
+ <div className="mt-4 pb-28">
79
96
  {/* Loading, a failed read with "Try again", never a failure as empty. */}
80
97
  <LoadNotice {...list} />
81
98
  {(list.state.kind === 'ready' || list.state.kind === 'loading') &&
82
99
  items?.length === 0 &&
83
- list.data?.skipped === 0 && <p className="empty">{t.empty}</p>}
84
- <ul className="rows">
100
+ list.data?.skipped === 0 && (
101
+ <p className="py-6 text-muted-foreground">
102
+ {write ? t.empty : t.emptyReadOnly}
103
+ </p>
104
+ )}
105
+ <ul className="divide-y">
85
106
  {items?.map((entry) => {
86
107
  const row = mutate.for(entry);
87
108
  return (
88
- <li key={entry.iri} className="row">
109
+ <li key={entry.iri} className="flex min-h-14 items-center gap-1">
89
110
  {/* The label is the checkbox's 44px touch target. */}
90
- <label>
111
+ <label className="flex size-11 shrink-0 cursor-pointer items-center justify-center">
91
112
  <input
92
113
  type="checkbox"
114
+ className="size-6 accent-primary"
93
115
  checked={row.data.done}
94
116
  disabled={!row.canMutate}
95
117
  onChange={() => void row.update({ done: !row.data.done })}
96
118
  />
97
- <span className="visually-hidden">{t.done(row.data.name)}</span>
119
+ <span className="sr-only">{t.done(row.data.name)}</span>
98
120
  </label>
99
- <button
100
- className="row-name"
101
- 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"
102
126
  >
103
127
  {row.data.name}
104
- </button>
128
+ </a>
105
129
  {/* Asks in the SDK's dialog before it deletes. */}
106
130
  <RemoveButton
107
131
  disabled={!row.canMutate}
@@ -116,52 +140,135 @@ export function ListScreen() {
116
140
  </ul>
117
141
  {/* Recovery for a row with an open write that is no longer listed. */}
118
142
  <UnresolvedNotices of={mutate} shown={items} />
119
- {/* Outside the rows, so the draft stays reachable if its row goes.
120
- The SDK's Escape in a field cancels like Cancel and discards the
121
- draft; × and a click beside the dialog ask first. */}
122
- {selected && (
123
- <Dialog
124
- label={t.edit}
125
- closeLabel={t.close}
126
- onClose={() => void select(null)}
127
- >
128
- <ResourceEditor editor={editor} onClose={() => select(null)}>
129
- {(draft, change) => (
130
- <label>
131
- {t.name}
132
- <input
133
- value={draft.name}
134
- onChange={(event) => change({ name: event.target.value })}
135
- />
136
- </label>
137
- )}
138
- </ResourceEditor>
139
- </Dialog>
140
- )}
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
+ />
141
158
  <UpdateNotice {...creation.notice} quiet />
142
- <Composer>
143
- <form
144
- onSubmit={(event) => {
145
- event.preventDefault();
146
- void creation.create();
147
- }}
148
- >
149
- <input
150
- aria-label={t.newItem}
151
- placeholder={t.newItem}
152
- value={creation.draft.name}
153
- disabled={!creation.canEdit}
154
- onChange={(event) => creation.change({ name: event.target.value })}
155
- />
156
- <button
157
- aria-label={t.add}
158
- title={t.add}
159
- disabled={!creation.canCreate}
159
+ {write && (
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))]">
164
+ <form
165
+ className="mx-auto flex max-w-3xl items-center gap-2"
166
+ onSubmit={(event) => {
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();
171
+ void creation.create();
172
+ }}
160
173
  >
161
- <AddIcon />
162
- </button>
163
- </form>
164
- </Composer>
165
- </>
174
+ <Input
175
+ ref={input}
176
+ className="h-11 flex-1"
177
+ aria-label={t.newItem}
178
+ placeholder={t.newItem}
179
+ value={creation.draft.name}
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}
184
+ onChange={(event) => creation.change({ name: event.target.value })}
185
+ />
186
+ <ActionButton
187
+ type="submit"
188
+ shape="round-sm"
189
+ label={t.add}
190
+ isDisabled={!creation.canCreate}
191
+ >
192
+ <PlusIcon />
193
+ </ActionButton>
194
+ </form>
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>
271
+ )}
272
+ </ScreenDialog>
166
273
  );
167
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
+ });