@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.
- package/CHANGELOG.md +201 -25
- package/README.md +13 -6
- package/bin/sempods-apps.mjs +1 -0
- package/instructions/app-workflow.md +268 -127
- package/instructions/publish.md +29 -11
- package/instructions/setup.md +13 -8
- 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/page.mjs +49 -1
- package/scripts/lib/sdk.mjs +10 -2
- package/scripts/lib/themes.mjs +45 -19
- package/scripts/migrate.mjs +15 -3
- 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/files/{f30ec6b7f9de16be54919382ab18cdcc33ee64938f7b907d855bcf6740e4c102 → e1c798ec8536cf8cff6c19caf622fa9076ae210b0d1733a6e82ea2c1f4104c43} +11 -2
- package/shared/snapshot.json +4 -4
- package/skeleton/app/NOTES.md +11 -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 +176 -69
- 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 -85
- package/skeleton/app/src/ui/Dialog.tsx +0 -103
- 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 -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,
|
|
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
|
-
|
|
23
|
+
useSdkLocale,
|
|
24
|
+
useWorkflowAccess,
|
|
19
25
|
} from '@sempods/app-sdk/react';
|
|
20
26
|
import { fields, flag, text } from '@sempods/client-sdk/edit';
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
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';
|
|
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
|
-
|
|
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 &&
|
|
84
|
-
|
|
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="
|
|
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="
|
|
119
|
+
<span className="sr-only">{t.done(row.data.name)}</span>
|
|
98
120
|
</label>
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
</
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
<
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
:
|
|
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
|
+
});
|