@sempods/apps 0.7.1 → 0.8.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 +190 -0
- package/README.md +6 -3
- package/bin/sempods-apps.mjs +1 -0
- package/instructions/app-workflow.md +254 -111
- package/instructions/publish.md +13 -7
- package/instructions/setup.md +33 -23
- package/instructions/visual-check.md +43 -0
- package/package.json +3 -3
- package/scripts/build-site.mjs +15 -3
- package/scripts/check.mjs +3 -1
- package/scripts/icon.mjs +47 -0
- package/scripts/lib/apps.mjs +56 -2
- package/scripts/lib/generate.mjs +55 -14
- package/scripts/lib/icon.mjs +192 -0
- package/scripts/lib/overview.mjs +5 -3
- package/scripts/lib/page.mjs +89 -0
- package/scripts/lib/themes.mjs +58 -0
- package/scripts/migrate.mjs +37 -14
- package/scripts/new-app.mjs +43 -10
- package/scripts/sdk-update.mjs +16 -1
- package/shared/files/{f2fb601d5ebc4ca72274ac9d3fcf7b7a96e27958bb92cd065a9165b501ee94d7 → 44c114d4d2c8b172f4757100f79e8a1a00d30c96ba2ad8f75c3373e52f6b849f} +2 -2
- package/shared/files/{31ede0d1a035d8531100ebfd751510c772ab0871309a0b9752a77fb96187eb53 → 843c078e84912158607ebb8ce0042589d676a742bcd3cf8b6c474d62ff3a6845} +3 -2
- package/shared/files/{1c29eed0a0297c742ca5814de9081ff8efce3b2da5d9e1bdf90a403873da4aa9 → f30ec6b7f9de16be54919382ab18cdcc33ee64938f7b907d855bcf6740e4c102} +8 -2
- package/shared/snapshot.json +4 -4
- package/skeleton/app/NOTES.md +11 -1
- package/skeleton/app/index.html +7 -3
- package/skeleton/app/package.json +6 -6
- package/skeleton/app/src/App.tsx +53 -21
- package/skeleton/app/src/NewVersionNotice.tsx +6 -11
- package/skeleton/app/src/examples/ListScreen.tsx +167 -0
- package/skeleton/app/src/index.css +86 -13
- package/skeleton/app/src/main.tsx +3 -0
- package/skeleton/app/src/pwa.ts +1 -1
- package/skeleton/app/src/routes.test.ts +19 -0
- package/skeleton/app/src/routes.ts +139 -0
- package/skeleton/app/src/texts.test.ts +28 -0
- package/skeleton/app/src/texts.ts +35 -0
- package/skeleton/app/src/themes/calm.css +11 -0
- package/skeleton/app/src/themes/fresh.css +11 -0
- package/skeleton/app/src/themes/playful.css +11 -0
- package/skeleton/app/src/themes/warm.css +11 -0
- package/skeleton/app/src/ui/Composer.tsx +18 -0
- package/skeleton/app/src/ui/ContextSwitch.tsx +85 -0
- package/skeleton/app/src/ui/Dialog.tsx +103 -0
- package/skeleton/app/src/ui/Menu.tsx +118 -0
- package/skeleton/app/src/ui/Tabs.tsx +39 -0
- package/skeleton/app/src/ui/dismiss.ts +30 -0
- package/skeleton/app/src/ui/icons.tsx +53 -0
- package/skeleton/app/src/ui/ui.css +240 -0
- package/skeleton/app/vite.config.ts +4 -4
- package/skills/update/SKILL.md +4 -2
- package/skeleton/app/public/favicon.svg +0 -1
- package/skeleton/app/public/icon-192.png +0 -0
- package/skeleton/app/public/icon-512.png +0 -0
- package/skeleton/app/src/start.test.ts +0 -15
- package/skeleton/app/src/start.ts +0 -9
|
@@ -0,0 +1,167 @@
|
|
|
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,
|
|
5
|
+
// provider and frame; take behaviour from the SDK's examples, never their
|
|
6
|
+
// runtime, provider or styles.
|
|
7
|
+
import {
|
|
8
|
+
LoadNotice,
|
|
9
|
+
RemoveButton,
|
|
10
|
+
ResourceEditor,
|
|
11
|
+
UnresolvedNotices,
|
|
12
|
+
UpdateNotice,
|
|
13
|
+
useCreation,
|
|
14
|
+
useFieldUpdate,
|
|
15
|
+
useList,
|
|
16
|
+
useMessages,
|
|
17
|
+
useResourceEditor,
|
|
18
|
+
useSelection,
|
|
19
|
+
} from '@sempods/app-sdk/react';
|
|
20
|
+
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';
|
|
24
|
+
|
|
25
|
+
const schema = 'https://schema.org/';
|
|
26
|
+
|
|
27
|
+
/** What one row is: a name and whether it is done. */
|
|
28
|
+
export const item = fields(
|
|
29
|
+
{
|
|
30
|
+
name: text(`${schema}name`, { language: null, trim: true }),
|
|
31
|
+
done: flag(`${schema}actionStatus`, {
|
|
32
|
+
on: `${schema}CompletedActionStatus`,
|
|
33
|
+
off: `${schema}PotentialActionStatus`,
|
|
34
|
+
}),
|
|
35
|
+
},
|
|
36
|
+
{ type: `${schema}Action` },
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
// In an app these belong in src/texts.ts.
|
|
40
|
+
const en = {
|
|
41
|
+
empty: 'Nothing here yet. Add the first item below.',
|
|
42
|
+
done: (name: string) => `Done: ${name}`,
|
|
43
|
+
remove: (name: string) => `Delete: ${name}`,
|
|
44
|
+
edit: 'Edit item',
|
|
45
|
+
close: 'Close',
|
|
46
|
+
name: 'Name',
|
|
47
|
+
newItem: 'New item',
|
|
48
|
+
add: 'Add',
|
|
49
|
+
};
|
|
50
|
+
const listTexts: { readonly en: typeof en; readonly de: typeof en } = {
|
|
51
|
+
en,
|
|
52
|
+
de: {
|
|
53
|
+
empty: 'Noch nichts hier. Füge unten den ersten Eintrag hinzu.',
|
|
54
|
+
done: (name) => `Erledigt: ${name}`,
|
|
55
|
+
remove: (name) => `Löschen: ${name}`,
|
|
56
|
+
edit: 'Eintrag bearbeiten',
|
|
57
|
+
close: 'Schließen',
|
|
58
|
+
name: 'Name',
|
|
59
|
+
newItem: 'Neuer Eintrag',
|
|
60
|
+
add: 'Hinzufügen',
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export function ListScreen() {
|
|
65
|
+
const t = useMessages(listTexts);
|
|
66
|
+
const list = useList(item);
|
|
67
|
+
const items = list.data?.items;
|
|
68
|
+
// One hook for the list; each row gets its part with `mutate.for(row)`.
|
|
69
|
+
const mutate = useFieldUpdate(item);
|
|
70
|
+
const { selected, select } = useSelection();
|
|
71
|
+
const editor = useResourceEditor(selected, item);
|
|
72
|
+
const creation = useCreation(item, {
|
|
73
|
+
initial: { name: '', done: false },
|
|
74
|
+
collection: 'items',
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<>
|
|
79
|
+
{/* Loading, a failed read with "Try again", never a failure as empty. */}
|
|
80
|
+
<LoadNotice {...list} />
|
|
81
|
+
{(list.state.kind === 'ready' || list.state.kind === 'loading') &&
|
|
82
|
+
items?.length === 0 &&
|
|
83
|
+
list.data?.skipped === 0 && <p className="empty">{t.empty}</p>}
|
|
84
|
+
<ul className="rows">
|
|
85
|
+
{items?.map((entry) => {
|
|
86
|
+
const row = mutate.for(entry);
|
|
87
|
+
return (
|
|
88
|
+
<li key={entry.iri} className="row">
|
|
89
|
+
{/* The label is the checkbox's 44px touch target. */}
|
|
90
|
+
<label>
|
|
91
|
+
<input
|
|
92
|
+
type="checkbox"
|
|
93
|
+
checked={row.data.done}
|
|
94
|
+
disabled={!row.canMutate}
|
|
95
|
+
onChange={() => void row.update({ done: !row.data.done })}
|
|
96
|
+
/>
|
|
97
|
+
<span className="visually-hidden">{t.done(row.data.name)}</span>
|
|
98
|
+
</label>
|
|
99
|
+
<button
|
|
100
|
+
className="row-name"
|
|
101
|
+
onClick={() => void select(entry.iri)}
|
|
102
|
+
>
|
|
103
|
+
{row.data.name}
|
|
104
|
+
</button>
|
|
105
|
+
{/* Asks in the SDK's dialog before it deletes. */}
|
|
106
|
+
<RemoveButton
|
|
107
|
+
disabled={!row.canMutate}
|
|
108
|
+
onRemove={row.remove}
|
|
109
|
+
label={t.remove(row.data.name)}
|
|
110
|
+
/>
|
|
111
|
+
{/* The changed row is the confirmation; problems show here. */}
|
|
112
|
+
<UpdateNotice {...row.notice} quiet />
|
|
113
|
+
</li>
|
|
114
|
+
);
|
|
115
|
+
})}
|
|
116
|
+
</ul>
|
|
117
|
+
{/* Recovery for a row with an open write that is no longer listed. */}
|
|
118
|
+
<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
|
+
)}
|
|
141
|
+
<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}
|
|
160
|
+
>
|
|
161
|
+
<AddIcon />
|
|
162
|
+
</button>
|
|
163
|
+
</form>
|
|
164
|
+
</Composer>
|
|
165
|
+
</>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
:root {
|
|
2
|
-
/* Follow the system's light or dark mode
|
|
3
|
-
|
|
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. */
|
|
4
5
|
color-scheme: light dark;
|
|
5
6
|
font-family: system-ui, sans-serif;
|
|
6
|
-
color:
|
|
7
|
-
background:
|
|
7
|
+
color: var(--sempods-text);
|
|
8
|
+
background: var(--sempods-bg);
|
|
8
9
|
}
|
|
9
10
|
body {
|
|
10
11
|
margin: 0;
|
|
@@ -14,24 +15,96 @@ main {
|
|
|
14
15
|
margin: auto;
|
|
15
16
|
padding: 16px;
|
|
16
17
|
}
|
|
17
|
-
header
|
|
18
|
+
/* The app's own header; AppAccess brings its own. */
|
|
19
|
+
main > header {
|
|
18
20
|
display: flex;
|
|
19
21
|
flex-wrap: wrap;
|
|
20
22
|
align-items: center;
|
|
21
23
|
justify-content: space-between;
|
|
22
24
|
gap: 0.5rem;
|
|
23
25
|
}
|
|
24
|
-
header h1 {
|
|
26
|
+
main > header h1 {
|
|
27
|
+
display: flex;
|
|
28
|
+
align-items: center;
|
|
29
|
+
gap: 0.5rem;
|
|
25
30
|
margin: 0;
|
|
26
31
|
font-size: 1.4rem;
|
|
27
32
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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);
|
|
31
52
|
font: inherit;
|
|
32
|
-
|
|
33
|
-
|
|
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;
|
|
34
105
|
}
|
|
35
|
-
|
|
36
|
-
|
|
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;
|
|
37
110
|
}
|
|
@@ -1,6 +1,9 @@
|
|
|
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
5
|
import './index.css';
|
|
6
|
+
import './ui/ui.css';
|
|
4
7
|
import App from './App.tsx';
|
|
5
8
|
import { registerAppWorker } from './pwa.ts';
|
|
6
9
|
import { app } from './sempods.generated.ts';
|
package/skeleton/app/src/pwa.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Adapted from the sempods TypeScript SDK v0.
|
|
1
|
+
// Adapted from the sempods TypeScript SDK v0.6.0 recipe
|
|
2
2
|
// examples/todo/pwa/register.tsx, licensed under Apache-2.0; see
|
|
3
3
|
// the LICENSE and NOTICE of the @sempods/app-sdk package. Changes: this header; the notice
|
|
4
4
|
// component moved to NewVersionNotice.tsx.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { pathOf, routeOf } from './routes.ts';
|
|
3
|
+
|
|
4
|
+
describe('routes', () => {
|
|
5
|
+
const routes = ['open', 'bought'] as const;
|
|
6
|
+
|
|
7
|
+
it('reads the route of an address under the app path', () => {
|
|
8
|
+
expect(routeOf('/konsum/bought', routes, '/konsum/')).toBe('bought');
|
|
9
|
+
expect(routeOf('/konsum/bought/', routes, '/konsum/')).toBe('bought');
|
|
10
|
+
expect(routeOf('/konsum/', routes, '/konsum/')).toBeUndefined();
|
|
11
|
+
expect(routeOf('/konsum/other', routes, '/konsum/')).toBeUndefined();
|
|
12
|
+
expect(routeOf('/notes/bought', routes, '/konsum/')).toBeUndefined();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('writes the address of a route', () => {
|
|
16
|
+
expect(pathOf('bought', '/konsum/')).toBe('/konsum/bought');
|
|
17
|
+
expect(pathOf(undefined, '/konsum/')).toBe('/konsum/');
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useCallback,
|
|
3
|
+
useEffect,
|
|
4
|
+
useSyncExternalStore,
|
|
5
|
+
type MouseEvent,
|
|
6
|
+
} from 'react';
|
|
7
|
+
import { useApp } from '@sempods/app-sdk/react';
|
|
8
|
+
import { app } from './sempods.generated.ts';
|
|
9
|
+
|
|
10
|
+
// The app's views as addresses: the start page at the app's path and one
|
|
11
|
+
// route per entry in apps.json `routes` (for example /<id>/bought). Links,
|
|
12
|
+
// bookmarks and the back button work, and the published site answers every
|
|
13
|
+
// route without rewrites.
|
|
14
|
+
|
|
15
|
+
/** A route listed in apps.json; undefined is the start page. */
|
|
16
|
+
export type Route = (typeof app.routes)[number];
|
|
17
|
+
|
|
18
|
+
/** The route of an address; undefined for the start page and unknown paths. */
|
|
19
|
+
export function routeOf<R extends string>(
|
|
20
|
+
path: string,
|
|
21
|
+
routes: readonly R[],
|
|
22
|
+
base: string,
|
|
23
|
+
): R | undefined {
|
|
24
|
+
if (!path.startsWith(base)) return undefined;
|
|
25
|
+
const segment = path.slice(base.length).replace(/\/$/, '');
|
|
26
|
+
return routes.find((route) => route === segment);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The address of a route; the start page is the app's path. */
|
|
30
|
+
export function pathOf(route: string | undefined, base: string): string {
|
|
31
|
+
return route === undefined ? base : `${base}${route}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// The address the app shows. It changes only through the functions below, so
|
|
35
|
+
// a declined navigation keeps the current view.
|
|
36
|
+
let shown = typeof location === 'object' ? location.pathname : '';
|
|
37
|
+
// The position of the shown entry in the session history, kept in its state,
|
|
38
|
+
// so a declined back or forward can return to it without a new entry.
|
|
39
|
+
let position: number =
|
|
40
|
+
typeof history === 'object' ? (history.state?.position ?? 0) : 0;
|
|
41
|
+
// Set while the router itself undoes a step, whose popstate it ignores.
|
|
42
|
+
let undoing = false;
|
|
43
|
+
const listeners = new Set<() => void>();
|
|
44
|
+
function show(path: string) {
|
|
45
|
+
shown = path;
|
|
46
|
+
for (const listener of listeners) listener();
|
|
47
|
+
}
|
|
48
|
+
function subscribe(listener: () => void) {
|
|
49
|
+
listeners.add(listener);
|
|
50
|
+
return () => void listeners.delete(listener);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Replaces the address without a new history entry. Pass it to the runtime
|
|
55
|
+
* (`replaceUrl`), so the return to the original route after a sign-in shows
|
|
56
|
+
* that route.
|
|
57
|
+
*/
|
|
58
|
+
export function replaceUrl(url: string) {
|
|
59
|
+
history.replaceState({ position }, '', url);
|
|
60
|
+
show(location.pathname);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The current route, `go` to switch it and `link(route)`, the props of an
|
|
65
|
+
* `<a>` that switches to it. Switching, back and forward run
|
|
66
|
+
* under the SDK's leave policy: an unsaved edit asks first, and staying keeps
|
|
67
|
+
* the view, its address and the history. An unsent creation draft is not
|
|
68
|
+
* asked about; keep such input (a Composer) outside the switched views. Call it once, in the app's frame, and pass the
|
|
69
|
+
* route down.
|
|
70
|
+
*/
|
|
71
|
+
export function useRoute() {
|
|
72
|
+
const path = useSyncExternalStore(subscribe, () => shown);
|
|
73
|
+
const route: Route | undefined = routeOf(path, app.routes, app.basePath);
|
|
74
|
+
const { navigate } = useApp();
|
|
75
|
+
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
const onPop = () => {
|
|
78
|
+
if (undoing) {
|
|
79
|
+
undoing = false;
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const to = location.pathname;
|
|
83
|
+
const target: number = history.state?.position ?? 0;
|
|
84
|
+
void navigate(() => {
|
|
85
|
+
position = target;
|
|
86
|
+
show(to);
|
|
87
|
+
}).then((left) => {
|
|
88
|
+
// Staying: step back to the shown entry; the history stays intact.
|
|
89
|
+
if (left || target === position) return;
|
|
90
|
+
undoing = true;
|
|
91
|
+
history.go(position - target);
|
|
92
|
+
});
|
|
93
|
+
};
|
|
94
|
+
addEventListener('popstate', onPop);
|
|
95
|
+
return () => removeEventListener('popstate', onPop);
|
|
96
|
+
}, [navigate]);
|
|
97
|
+
|
|
98
|
+
// One address per view: unknown paths show the start page, and a trailing
|
|
99
|
+
// slash goes. The callback keeps its address until the runtime has read it.
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
const normal = pathOf(route, app.basePath);
|
|
102
|
+
if (path !== normal && path !== `${app.basePath}callback`)
|
|
103
|
+
replaceUrl(normal + location.search + location.hash);
|
|
104
|
+
}, [path, route]);
|
|
105
|
+
|
|
106
|
+
const go = useCallback(
|
|
107
|
+
(next: Route | undefined) => {
|
|
108
|
+
if (next === route) return;
|
|
109
|
+
void navigate(() => {
|
|
110
|
+
position += 1;
|
|
111
|
+
history.pushState({ position }, '', pathOf(next, app.basePath));
|
|
112
|
+
show(location.pathname);
|
|
113
|
+
});
|
|
114
|
+
},
|
|
115
|
+
[navigate, route],
|
|
116
|
+
);
|
|
117
|
+
// Props for a link to a route: its own address, so it opens in a new tab or
|
|
118
|
+
// gets bookmarked, while a plain click switches in place through `go`
|
|
119
|
+
// instead of loading the page past the leave policy.
|
|
120
|
+
const link = useCallback(
|
|
121
|
+
(next: Route | undefined) => ({
|
|
122
|
+
href: pathOf(next, app.basePath),
|
|
123
|
+
onClick: (event: MouseEvent) => {
|
|
124
|
+
if (
|
|
125
|
+
event.button !== 0 ||
|
|
126
|
+
event.metaKey ||
|
|
127
|
+
event.ctrlKey ||
|
|
128
|
+
event.shiftKey ||
|
|
129
|
+
event.altKey
|
|
130
|
+
)
|
|
131
|
+
return;
|
|
132
|
+
event.preventDefault();
|
|
133
|
+
go(next);
|
|
134
|
+
},
|
|
135
|
+
}),
|
|
136
|
+
[go],
|
|
137
|
+
);
|
|
138
|
+
return { route, go, link };
|
|
139
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { app } from './sempods.generated.ts';
|
|
3
|
+
import { texts, uiLocale } from './texts.ts';
|
|
4
|
+
|
|
5
|
+
describe('texts', () => {
|
|
6
|
+
it('has every text in every language', () => {
|
|
7
|
+
const keys = Object.keys(texts.en);
|
|
8
|
+
for (const catalogue of [texts.en, texts.de]) {
|
|
9
|
+
expect(Object.keys(catalogue)).toEqual(keys);
|
|
10
|
+
for (const text of Object.values(catalogue)) expect(text).not.toBe('');
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('takes the language from the browser unless one is configured', () => {
|
|
15
|
+
expect(uiLocale('auto', ['de-AT', 'en'])).toEqual({
|
|
16
|
+
locale: 'de-AT',
|
|
17
|
+
language: 'de',
|
|
18
|
+
});
|
|
19
|
+
// An unsupported language keeps its regional formats with English texts.
|
|
20
|
+
expect(uiLocale('auto', ['fr-FR'])).toEqual({
|
|
21
|
+
locale: 'fr-FR',
|
|
22
|
+
language: 'en',
|
|
23
|
+
});
|
|
24
|
+
expect(uiLocale('de', ['en-US']).language).toBe('de');
|
|
25
|
+
// The generated configuration imports without a browser.
|
|
26
|
+
expect(['en', 'de']).toContain(uiLocale(app.language, []).language);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { resolveLocale, type Language } from '@sempods/app-sdk';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The UI language and the regional formats. With "auto" in apps.json they
|
|
5
|
+
* come from the browser: German or English, otherwise English, with the
|
|
6
|
+
* browser's region for dates and numbers. A fixed language keeps its own.
|
|
7
|
+
*/
|
|
8
|
+
export function uiLocale(
|
|
9
|
+
configured: string,
|
|
10
|
+
browser: readonly string[],
|
|
11
|
+
): { readonly locale: string; readonly language: Language } {
|
|
12
|
+
return resolveLocale(configured === 'auto' ? undefined : configured, browser);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// The app's own texts in English and German, read in components with the
|
|
16
|
+
// SDK's useMessages(texts); SDK components bring their own, and SDK texts are
|
|
17
|
+
// reused where they fit (useSdkLocale().messages.controls). Both languages
|
|
18
|
+
// have every key, so TypeScript reports a missing translation. Add the app's
|
|
19
|
+
// texts here, never inline in components.
|
|
20
|
+
const en = {
|
|
21
|
+
start: 'Connected. Tell your assistant what this app should do.',
|
|
22
|
+
newVersion:
|
|
23
|
+
'A new version is ready. It is used once every window of this app has been closed.',
|
|
24
|
+
menu: 'Menu',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const texts: { readonly en: typeof en; readonly de: typeof en } = {
|
|
28
|
+
en,
|
|
29
|
+
de: {
|
|
30
|
+
start: 'Verbunden. Beschreibe deinem Assistenten, was diese App tun soll.',
|
|
31
|
+
newVersion:
|
|
32
|
+
'Eine neue Version ist bereit. Sie wird verwendet, sobald alle Fenster dieser App geschlossen sind.',
|
|
33
|
+
menu: 'Menü',
|
|
34
|
+
},
|
|
35
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/* Calm: graphite on white, quiet and neutral; the default.
|
|
2
|
+
The --sempods-* tokens colour the app and the SDK components alike, in
|
|
3
|
+
light and dark mode; each pair keeps at least 4.5:1 contrast. */
|
|
4
|
+
:root {
|
|
5
|
+
--sempods-bg: light-dark(#ffffff, #111315);
|
|
6
|
+
--sempods-text: light-dark(#1f2933, #e5e7eb);
|
|
7
|
+
--sempods-muted: light-dark(#6b7280, #9ca3af);
|
|
8
|
+
--sempods-line: light-dark(#e5e7eb, #2d3138);
|
|
9
|
+
--sempods-accent: light-dark(#1f2933, #e5e7eb);
|
|
10
|
+
--sempods-on-accent: light-dark(#ffffff, #111315);
|
|
11
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/* Fresh: clear teal and cool greys, light and open.
|
|
2
|
+
The --sempods-* tokens colour the app and the SDK components alike, in
|
|
3
|
+
light and dark mode; each pair keeps at least 4.5:1 contrast. */
|
|
4
|
+
:root {
|
|
5
|
+
--sempods-bg: light-dark(#ffffff, #0e171d);
|
|
6
|
+
--sempods-text: light-dark(#11293a, #e1edf3);
|
|
7
|
+
--sempods-muted: light-dark(#58697a, #94a8b6);
|
|
8
|
+
--sempods-line: light-dark(#dce8ef, #22343f);
|
|
9
|
+
--sempods-accent: light-dark(#0a6680, #5ec6e4);
|
|
10
|
+
--sempods-on-accent: light-dark(#ffffff, #06212b);
|
|
11
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/* Playful: violet and soft lilac, lively and friendly.
|
|
2
|
+
The --sempods-* tokens colour the app and the SDK components alike, in
|
|
3
|
+
light and dark mode; each pair keeps at least 4.5:1 contrast. */
|
|
4
|
+
:root {
|
|
5
|
+
--sempods-bg: light-dark(#ffffff, #14111c);
|
|
6
|
+
--sempods-text: light-dark(#241a3a, #ece6f8);
|
|
7
|
+
--sempods-muted: light-dark(#665e7c, #a49cbc);
|
|
8
|
+
--sempods-line: light-dark(#e9e4f3, #2e2742);
|
|
9
|
+
--sempods-accent: light-dark(#6a3bd2, #b59cff);
|
|
10
|
+
--sempods-on-accent: light-dark(#ffffff, #1b1036);
|
|
11
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/* Warm: terracotta on cream, cosy and homely.
|
|
2
|
+
The --sempods-* tokens colour the app and the SDK components alike, in
|
|
3
|
+
light and dark mode; each pair keeps at least 4.5:1 contrast. */
|
|
4
|
+
:root {
|
|
5
|
+
--sempods-bg: light-dark(#fdfaf6, #1a1512);
|
|
6
|
+
--sempods-text: light-dark(#2b211b, #efe6dd);
|
|
7
|
+
--sempods-muted: light-dark(#74675c, #ab9d90);
|
|
8
|
+
--sempods-line: light-dark(#ece2d8, #3a2f28);
|
|
9
|
+
--sempods-accent: light-dark(#9c4128, #f0a184);
|
|
10
|
+
--sempods-on-accent: light-dark(#ffffff, #2a120a);
|
|
11
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A bar fixed at the bottom of the screen, in thumb reach, for the input that
|
|
5
|
+
* is always there (for example "add an item"). The page leaves room for it,
|
|
6
|
+
* it sits above the home indicator, and with the skeleton's viewport setting
|
|
7
|
+
* (interactive-widget=resizes-content) it stays above the on-screen keyboard
|
|
8
|
+
* in browsers that support that. iOS Safari overlays the keyboard instead.
|
|
9
|
+
* Render it in the frame, outside views that tabs switch: switching views
|
|
10
|
+
* does not ask about an unsent creation draft, and unmounting loses it.
|
|
11
|
+
*/
|
|
12
|
+
export function Composer({ children }: { readonly children: ReactNode }) {
|
|
13
|
+
return (
|
|
14
|
+
<div className="composer">
|
|
15
|
+
<div className="composer-content">{children}</div>
|
|
16
|
+
</div>
|
|
17
|
+
);
|
|
18
|
+
}
|