@sempods/apps 0.7.1 → 0.8.0
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 +200 -0
- package/README.md +6 -3
- package/bin/sempods-apps.mjs +1 -0
- package/instructions/app-workflow.md +322 -111
- package/instructions/publish.md +13 -7
- package/instructions/setup.md +38 -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 +137 -0
- package/scripts/lib/themes.mjs +58 -0
- package/scripts/migrate.mjs +44 -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 → e1c798ec8536cf8cff6c19caf622fa9076ae210b0d1733a6e82ea2c1f4104c43} +17 -2
- package/shared/snapshot.json +4 -4
- package/skeleton/app/NOTES.md +20 -2
- 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 +176 -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 +94 -0
- package/skeleton/app/src/ui/Dialog.tsx +120 -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 +249 -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,30 @@
|
|
|
1
|
+
import { useEffect, type RefObject } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Closes a popup on a pointer press outside `container` or on Escape, and
|
|
5
|
+
* returns focus to `button` after Escape.
|
|
6
|
+
*/
|
|
7
|
+
export function useDismiss(
|
|
8
|
+
open: boolean,
|
|
9
|
+
close: () => void,
|
|
10
|
+
container: RefObject<HTMLElement | null>,
|
|
11
|
+
button: RefObject<HTMLElement | null>,
|
|
12
|
+
) {
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
if (!open) return;
|
|
15
|
+
const onPointer = (event: PointerEvent) => {
|
|
16
|
+
if (!container.current?.contains(event.target as Node)) close();
|
|
17
|
+
};
|
|
18
|
+
const onKey = (event: KeyboardEvent) => {
|
|
19
|
+
if (event.key !== 'Escape') return;
|
|
20
|
+
close();
|
|
21
|
+
button.current?.focus();
|
|
22
|
+
};
|
|
23
|
+
document.addEventListener('pointerdown', onPointer);
|
|
24
|
+
document.addEventListener('keydown', onKey);
|
|
25
|
+
return () => {
|
|
26
|
+
document.removeEventListener('pointerdown', onPointer);
|
|
27
|
+
document.removeEventListener('keydown', onKey);
|
|
28
|
+
};
|
|
29
|
+
}, [open, close, container, button]);
|
|
30
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Line icons for icon buttons, drawn in the text colour. Each is decorative:
|
|
2
|
+
// the button carries the name (aria-label and title).
|
|
3
|
+
import type { ReactNode } from 'react';
|
|
4
|
+
|
|
5
|
+
function Icon({ size = 22, children }: { size?: number; children: ReactNode }) {
|
|
6
|
+
return (
|
|
7
|
+
<svg
|
|
8
|
+
viewBox="0 0 24 24"
|
|
9
|
+
width={size}
|
|
10
|
+
height={size}
|
|
11
|
+
aria-hidden="true"
|
|
12
|
+
fill="none"
|
|
13
|
+
stroke="currentColor"
|
|
14
|
+
strokeWidth="2"
|
|
15
|
+
strokeLinecap="round"
|
|
16
|
+
strokeLinejoin="round"
|
|
17
|
+
>
|
|
18
|
+
{children}
|
|
19
|
+
</svg>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const MenuIcon = () => (
|
|
24
|
+
<Icon>
|
|
25
|
+
<circle cx="12" cy="5" r="1" />
|
|
26
|
+
<circle cx="12" cy="12" r="1" />
|
|
27
|
+
<circle cx="12" cy="19" r="1" />
|
|
28
|
+
</Icon>
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
export const ReloadIcon = () => (
|
|
32
|
+
<Icon>
|
|
33
|
+
<path d="M20 12a8 8 0 1 1-2.34-5.66M20 4v5h-5" />
|
|
34
|
+
</Icon>
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
export const AddIcon = () => (
|
|
38
|
+
<Icon>
|
|
39
|
+
<path d="M12 5v14M5 12h14" />
|
|
40
|
+
</Icon>
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
export const CloseIcon = () => (
|
|
44
|
+
<Icon size={20}>
|
|
45
|
+
<path d="M6 6l12 12M18 6L6 18" />
|
|
46
|
+
</Icon>
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
export const ChevronIcon = () => (
|
|
50
|
+
<Icon size={16}>
|
|
51
|
+
<path d="M6 9l6 6 6-6" />
|
|
52
|
+
</Icon>
|
|
53
|
+
);
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/* The small UI kit: header, menu, context switch, tabs, bottom bar and
|
|
2
|
+
dialog, on the --sempods-* tokens of index.css. Calm and flat: one line per
|
|
3
|
+
edge, no shadows. */
|
|
4
|
+
|
|
5
|
+
/* Header: the app (or the context switch) at the start, icon actions and the
|
|
6
|
+
menu at the end, on one line at every width. A long name shrinks and wraps
|
|
7
|
+
inside the title, so the menu stays on screen. */
|
|
8
|
+
main > header {
|
|
9
|
+
flex-wrap: nowrap;
|
|
10
|
+
}
|
|
11
|
+
main > header h1 {
|
|
12
|
+
min-width: 0;
|
|
13
|
+
overflow-wrap: anywhere;
|
|
14
|
+
}
|
|
15
|
+
.header-actions {
|
|
16
|
+
display: flex;
|
|
17
|
+
align-items: center;
|
|
18
|
+
flex: none;
|
|
19
|
+
}
|
|
20
|
+
.header-actions button {
|
|
21
|
+
padding: 10px;
|
|
22
|
+
color: var(--sempods-text);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/* Keeps a heading for screen readers without showing it. */
|
|
26
|
+
.visually-hidden {
|
|
27
|
+
position: absolute;
|
|
28
|
+
width: 1px;
|
|
29
|
+
height: 1px;
|
|
30
|
+
overflow: hidden;
|
|
31
|
+
clip-path: inset(50%);
|
|
32
|
+
white-space: nowrap;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/* Menu: a button and a short list below it in body text, as wide as its
|
|
36
|
+
longest item up to the screen's width. */
|
|
37
|
+
.menu {
|
|
38
|
+
position: relative;
|
|
39
|
+
min-width: 0;
|
|
40
|
+
}
|
|
41
|
+
.menu-items {
|
|
42
|
+
position: absolute;
|
|
43
|
+
top: 100%;
|
|
44
|
+
z-index: 10;
|
|
45
|
+
display: grid;
|
|
46
|
+
width: max-content;
|
|
47
|
+
min-width: 14rem;
|
|
48
|
+
max-width: min(24rem, calc(100vw - 32px));
|
|
49
|
+
font-size: 1rem;
|
|
50
|
+
font-weight: 400;
|
|
51
|
+
/* A long name without spaces wraps inside the menu. */
|
|
52
|
+
overflow-wrap: anywhere;
|
|
53
|
+
padding: 4px;
|
|
54
|
+
border: 1px solid var(--sempods-line);
|
|
55
|
+
border-radius: 8px;
|
|
56
|
+
background: var(--sempods-bg);
|
|
57
|
+
}
|
|
58
|
+
.menu-end {
|
|
59
|
+
right: 0;
|
|
60
|
+
}
|
|
61
|
+
.menu-start {
|
|
62
|
+
left: 0;
|
|
63
|
+
}
|
|
64
|
+
.menu-items button {
|
|
65
|
+
display: flex;
|
|
66
|
+
align-items: flex-start;
|
|
67
|
+
gap: 8px;
|
|
68
|
+
text-align: start;
|
|
69
|
+
color: var(--sempods-text);
|
|
70
|
+
font-weight: 400;
|
|
71
|
+
}
|
|
72
|
+
.menu-items button > span {
|
|
73
|
+
min-width: 0;
|
|
74
|
+
}
|
|
75
|
+
.menu-items small {
|
|
76
|
+
display: block;
|
|
77
|
+
color: var(--sempods-muted);
|
|
78
|
+
font-size: 0.8rem;
|
|
79
|
+
}
|
|
80
|
+
.menu-check {
|
|
81
|
+
flex: none;
|
|
82
|
+
width: 1rem;
|
|
83
|
+
}
|
|
84
|
+
.menu-items hr {
|
|
85
|
+
width: 100%;
|
|
86
|
+
margin: 4px 0;
|
|
87
|
+
border: 0;
|
|
88
|
+
border-top: 1px solid var(--sempods-line);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/* Context switch: app icon and the current context, as the header's title;
|
|
92
|
+
a failed request to share more shows below it. */
|
|
93
|
+
.context-switch-wrap {
|
|
94
|
+
min-width: 0;
|
|
95
|
+
}
|
|
96
|
+
.context-switch > button {
|
|
97
|
+
display: flex;
|
|
98
|
+
align-items: center;
|
|
99
|
+
gap: 8px;
|
|
100
|
+
max-width: 100%;
|
|
101
|
+
padding: 8px 8px 8px 0;
|
|
102
|
+
color: var(--sempods-text);
|
|
103
|
+
font-size: 1.15rem;
|
|
104
|
+
}
|
|
105
|
+
.context-switch > button img {
|
|
106
|
+
flex: none;
|
|
107
|
+
width: 28px;
|
|
108
|
+
height: 28px;
|
|
109
|
+
}
|
|
110
|
+
.context-name {
|
|
111
|
+
overflow: hidden;
|
|
112
|
+
text-overflow: ellipsis;
|
|
113
|
+
white-space: nowrap;
|
|
114
|
+
}
|
|
115
|
+
.context-switch-problem {
|
|
116
|
+
margin: 0;
|
|
117
|
+
color: var(--sempods-muted);
|
|
118
|
+
font-size: 0.875rem;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/* Lists of items: a row with its checkbox, its name (opens the item) and its
|
|
122
|
+
actions; the empty state is a quiet line. */
|
|
123
|
+
.row {
|
|
124
|
+
display: flex;
|
|
125
|
+
flex-wrap: wrap;
|
|
126
|
+
align-items: center;
|
|
127
|
+
gap: 4px;
|
|
128
|
+
border-bottom: 1px solid var(--sempods-line);
|
|
129
|
+
}
|
|
130
|
+
.row-name {
|
|
131
|
+
flex: 1;
|
|
132
|
+
min-width: 0;
|
|
133
|
+
text-align: start;
|
|
134
|
+
color: var(--sempods-text);
|
|
135
|
+
font-weight: 400;
|
|
136
|
+
}
|
|
137
|
+
/* A row's notice (a problem or an open outcome) takes its own line. */
|
|
138
|
+
.row > [data-sempods-ui='notice'] {
|
|
139
|
+
flex-basis: 100%;
|
|
140
|
+
}
|
|
141
|
+
.empty {
|
|
142
|
+
color: var(--sempods-muted);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/* Tabs: the views as links on one line; the current one is underlined on
|
|
146
|
+
the row's only line. */
|
|
147
|
+
.tabs {
|
|
148
|
+
display: flex;
|
|
149
|
+
gap: 1.25rem;
|
|
150
|
+
margin: 4px 0 8px;
|
|
151
|
+
border-bottom: 1px solid var(--sempods-line);
|
|
152
|
+
}
|
|
153
|
+
.tabs a {
|
|
154
|
+
margin-bottom: -1px;
|
|
155
|
+
padding: 10px 0;
|
|
156
|
+
border-bottom: 2px solid transparent;
|
|
157
|
+
color: var(--sempods-muted);
|
|
158
|
+
text-decoration: none;
|
|
159
|
+
white-space: nowrap;
|
|
160
|
+
}
|
|
161
|
+
.tabs a:hover {
|
|
162
|
+
color: var(--sempods-text);
|
|
163
|
+
}
|
|
164
|
+
.tabs a[aria-current='page'] {
|
|
165
|
+
border-bottom-color: var(--sempods-accent);
|
|
166
|
+
color: var(--sempods-text);
|
|
167
|
+
font-weight: 500;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/* Bottom bar: fixed in thumb reach, above the home indicator; the page
|
|
171
|
+
leaves room for it. */
|
|
172
|
+
.composer {
|
|
173
|
+
position: fixed;
|
|
174
|
+
right: 0;
|
|
175
|
+
bottom: 0;
|
|
176
|
+
left: 0;
|
|
177
|
+
z-index: 5;
|
|
178
|
+
padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
|
|
179
|
+
border-top: 1px solid var(--sempods-line);
|
|
180
|
+
background: var(--sempods-bg);
|
|
181
|
+
}
|
|
182
|
+
.composer-content {
|
|
183
|
+
display: flex;
|
|
184
|
+
align-items: center;
|
|
185
|
+
gap: 8px;
|
|
186
|
+
max-width: 728px;
|
|
187
|
+
margin-inline: auto;
|
|
188
|
+
}
|
|
189
|
+
.composer-content > form {
|
|
190
|
+
display: flex;
|
|
191
|
+
flex: 1;
|
|
192
|
+
align-items: center;
|
|
193
|
+
gap: 8px;
|
|
194
|
+
}
|
|
195
|
+
.composer-content input {
|
|
196
|
+
flex: 1;
|
|
197
|
+
min-width: 0;
|
|
198
|
+
border: 0;
|
|
199
|
+
background: transparent;
|
|
200
|
+
}
|
|
201
|
+
/* The bar's top line frames the input; the caret shows the focus. */
|
|
202
|
+
.composer-content input:focus-visible {
|
|
203
|
+
outline: none;
|
|
204
|
+
}
|
|
205
|
+
main:has(.composer) {
|
|
206
|
+
padding-bottom: calc(88px + env(safe-area-inset-bottom));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/* Dialog: centred, a bottom sheet on phones. */
|
|
210
|
+
.dialog {
|
|
211
|
+
width: min(28rem, calc(100vw - 2rem));
|
|
212
|
+
max-height: calc(100dvh - 2rem);
|
|
213
|
+
padding: 0;
|
|
214
|
+
border: 1px solid var(--sempods-line);
|
|
215
|
+
border-radius: 12px;
|
|
216
|
+
background: var(--sempods-bg);
|
|
217
|
+
color: var(--sempods-text);
|
|
218
|
+
}
|
|
219
|
+
.dialog::backdrop {
|
|
220
|
+
background: color-mix(in srgb, #000 35%, transparent);
|
|
221
|
+
}
|
|
222
|
+
.dialog-close {
|
|
223
|
+
float: right;
|
|
224
|
+
margin: 4px;
|
|
225
|
+
color: var(--sempods-muted);
|
|
226
|
+
}
|
|
227
|
+
.dialog-body {
|
|
228
|
+
clear: both;
|
|
229
|
+
padding: 0 20px 20px;
|
|
230
|
+
}
|
|
231
|
+
@media (max-width: 30rem) {
|
|
232
|
+
.dialog {
|
|
233
|
+
width: 100vw;
|
|
234
|
+
max-width: 100vw;
|
|
235
|
+
margin: auto 0 0;
|
|
236
|
+
border-radius: 12px 12px 0 0;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/* A primary action: filled, for the one main step of a dialog or form. */
|
|
241
|
+
button.primary {
|
|
242
|
+
width: 100%;
|
|
243
|
+
background: var(--sempods-accent);
|
|
244
|
+
color: var(--sempods-on-accent);
|
|
245
|
+
}
|
|
246
|
+
button.primary:hover:not(:disabled) {
|
|
247
|
+
background: var(--sempods-accent);
|
|
248
|
+
opacity: 0.9;
|
|
249
|
+
}
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import react from '@vitejs/plugin-react';
|
|
2
2
|
import { defineConfig } from 'vite';
|
|
3
3
|
import { VitePWA } from 'vite-plugin-pwa';
|
|
4
|
-
import { base, pwa, server } from './vite.sempods.generated.ts';
|
|
4
|
+
import { base, pageTitle, pwa, server } from './vite.sempods.generated.ts';
|
|
5
5
|
|
|
6
|
-
// Base path, development port and PWA settings come from apps.json
|
|
7
|
-
// generated file; add your own plugins and options here.
|
|
6
|
+
// Base path, development port, page title and PWA settings come from apps.json
|
|
7
|
+
// through the generated file; add your own plugins and options here.
|
|
8
8
|
export default defineConfig({
|
|
9
9
|
base,
|
|
10
10
|
server,
|
|
11
11
|
preview: server,
|
|
12
|
-
plugins: [react(), ...(pwa ? [VitePWA(pwa)] : [])],
|
|
12
|
+
plugins: [react(), pageTitle, ...(pwa ? [VitePWA(pwa)] : [])],
|
|
13
13
|
});
|
package/skills/update/SKILL.md
CHANGED
|
@@ -72,5 +72,7 @@ instead.
|
|
|
72
72
|
request's branch, or open a pull request "Update the tooling to <version>",
|
|
73
73
|
with the migration report, each manual case and how it was decided, and
|
|
74
74
|
what to try. Tell the owner what changed in plain words and
|
|
75
|
-
what they should try. App
|
|
76
|
-
|
|
75
|
+
what they should try. App notes and the owner's entries in `package.json`
|
|
76
|
+
are never rewritten by the migration; of the apps' files it only
|
|
77
|
+
regenerates the generated configuration and moves files still unchanged
|
|
78
|
+
from the skeleton where a release's upgrade notes say so.
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="#176354"/></svg>
|
|
Binary file
|
|
Binary file
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { app } from './sempods.generated.ts';
|
|
3
|
-
import { startMessage } from './start.ts';
|
|
4
|
-
|
|
5
|
-
describe('startMessage', () => {
|
|
6
|
-
it('speaks the app language', () => {
|
|
7
|
-
expect(startMessage('de')).toMatch(/^Verbunden\./);
|
|
8
|
-
expect(startMessage('en')).toMatch(/^Connected\./);
|
|
9
|
-
});
|
|
10
|
-
|
|
11
|
-
it('has a text for the configured language', () => {
|
|
12
|
-
// The generated configuration imports without a browser.
|
|
13
|
-
expect(startMessage(app.language)).not.toBe('');
|
|
14
|
-
});
|
|
15
|
-
});
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
import type { Language } from '@sempods/app-sdk';
|
|
2
|
-
|
|
3
|
-
// Replace this with your app's own domain logic. Pure functions like this one
|
|
4
|
-
// are tested in src/start.test.ts without a browser or a Pod.
|
|
5
|
-
export function startMessage(language: Language): string {
|
|
6
|
-
return language === 'de'
|
|
7
|
-
? 'Verbunden. Beschreibe deinem Assistenten, was diese App tun soll.'
|
|
8
|
-
: 'Connected. Tell your assistant what this app should do.';
|
|
9
|
-
}
|