@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.
- package/CHANGELOG.md +166 -0
- package/README.md +13 -6
- package/bin/sempods-apps.mjs +1 -0
- package/instructions/app-workflow.md +145 -72
- package/instructions/publish.md +29 -11
- package/instructions/setup.md +2 -2
- package/instructions/visual-check.md +20 -2
- package/package.json +5 -3
- package/scripts/build-site.mjs +37 -9
- package/scripts/check-theme.mjs +398 -0
- package/scripts/check.mjs +16 -5
- package/scripts/icon.mjs +1 -1
- package/scripts/lib/apps.mjs +115 -27
- package/scripts/lib/generate.mjs +58 -28
- package/scripts/lib/json.mjs +6 -0
- package/scripts/lib/kit.mjs +81 -0
- package/scripts/lib/merge.mjs +12 -9
- package/scripts/lib/sdk.mjs +10 -2
- package/scripts/lib/themes.mjs +45 -19
- package/scripts/migrate.mjs +6 -1
- package/scripts/new-app.mjs +2 -7
- package/scripts/sdk-update.mjs +32 -10
- package/shared/files/{e13474647ebaf26d042d76f4917203d209a2b14c0d2af8d12139ea328530823e → 3b75b42e15713ac93916d9c2978c2826dfedef18ed5255d444482484bc4b3334} +8 -5
- package/shared/files/{843c078e84912158607ebb8ce0042589d676a742bcd3cf8b6c474d62ff3a6845 → 5f709dbc1d0ab5c918a1ff06c32342a3f95569b1570793bfd3a03642bb6a7e43} +4 -2
- package/shared/snapshot.json +3 -3
- package/skeleton/app/package.json +3 -0
- package/skeleton/app/src/App.tsx +53 -21
- package/skeleton/app/src/examples/ContextSwitch.tsx +131 -0
- package/skeleton/app/src/examples/ListScreen.tsx +154 -56
- package/skeleton/app/src/index.css +10 -110
- package/skeleton/app/src/main.tsx +1 -3
- package/skeleton/app/src/places.test.ts +128 -0
- package/skeleton/app/src/places.ts +146 -0
- package/skeleton/app/src/texts.ts +8 -0
- package/skeleton/app/src/themes/calm.css +22 -8
- package/skeleton/app/src/themes/fresh.css +22 -8
- package/skeleton/app/src/themes/playful.css +22 -8
- package/skeleton/app/src/themes/warm.css +22 -8
- package/skeleton/app/vite.config.ts +12 -2
- package/skills/update/SKILL.md +6 -1
- package/update-policy.json +1 -1
- package/skeleton/app/src/routes.test.ts +0 -19
- package/skeleton/app/src/routes.ts +0 -139
- package/skeleton/app/src/ui/Composer.tsx +0 -18
- package/skeleton/app/src/ui/ContextSwitch.tsx +0 -94
- package/skeleton/app/src/ui/Dialog.tsx +0 -120
- package/skeleton/app/src/ui/Menu.tsx +0 -118
- package/skeleton/app/src/ui/Tabs.tsx +0 -39
- package/skeleton/app/src/ui/dismiss.ts +0 -30
- package/skeleton/app/src/ui/icons.tsx +0 -53
- 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,
|
|
3
|
-
// App.tsx,
|
|
4
|
-
//
|
|
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
|
-
|
|
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 {
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
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="
|
|
101
|
+
<p className="py-6 text-muted-foreground">
|
|
102
|
+
{write ? t.empty : t.emptyReadOnly}
|
|
103
|
+
</p>
|
|
90
104
|
)}
|
|
91
|
-
<ul className="
|
|
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="
|
|
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="
|
|
119
|
+
<span className="sr-only">{t.done(row.data.name)}</span>
|
|
105
120
|
</label>
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
</
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
<
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
186
|
+
<ActionButton
|
|
187
|
+
type="submit"
|
|
188
|
+
shape="round-sm"
|
|
189
|
+
label={t.add}
|
|
190
|
+
isDisabled={!creation.canCreate}
|
|
168
191
|
>
|
|
169
|
-
<
|
|
170
|
-
</
|
|
192
|
+
<PlusIcon />
|
|
193
|
+
</ActionButton>
|
|
171
194
|
</form>
|
|
172
|
-
</
|
|
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
|
-
:
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
//
|
|
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
|
+
});
|