@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.
Files changed (56) hide show
  1. package/CHANGELOG.md +190 -0
  2. package/README.md +6 -3
  3. package/bin/sempods-apps.mjs +1 -0
  4. package/instructions/app-workflow.md +254 -111
  5. package/instructions/publish.md +13 -7
  6. package/instructions/setup.md +33 -23
  7. package/instructions/visual-check.md +43 -0
  8. package/package.json +3 -3
  9. package/scripts/build-site.mjs +15 -3
  10. package/scripts/check.mjs +3 -1
  11. package/scripts/icon.mjs +47 -0
  12. package/scripts/lib/apps.mjs +56 -2
  13. package/scripts/lib/generate.mjs +55 -14
  14. package/scripts/lib/icon.mjs +192 -0
  15. package/scripts/lib/overview.mjs +5 -3
  16. package/scripts/lib/page.mjs +89 -0
  17. package/scripts/lib/themes.mjs +58 -0
  18. package/scripts/migrate.mjs +37 -14
  19. package/scripts/new-app.mjs +43 -10
  20. package/scripts/sdk-update.mjs +16 -1
  21. package/shared/files/{f2fb601d5ebc4ca72274ac9d3fcf7b7a96e27958bb92cd065a9165b501ee94d7 → 44c114d4d2c8b172f4757100f79e8a1a00d30c96ba2ad8f75c3373e52f6b849f} +2 -2
  22. package/shared/files/{31ede0d1a035d8531100ebfd751510c772ab0871309a0b9752a77fb96187eb53 → 843c078e84912158607ebb8ce0042589d676a742bcd3cf8b6c474d62ff3a6845} +3 -2
  23. package/shared/files/{1c29eed0a0297c742ca5814de9081ff8efce3b2da5d9e1bdf90a403873da4aa9 → f30ec6b7f9de16be54919382ab18cdcc33ee64938f7b907d855bcf6740e4c102} +8 -2
  24. package/shared/snapshot.json +4 -4
  25. package/skeleton/app/NOTES.md +11 -1
  26. package/skeleton/app/index.html +7 -3
  27. package/skeleton/app/package.json +6 -6
  28. package/skeleton/app/src/App.tsx +53 -21
  29. package/skeleton/app/src/NewVersionNotice.tsx +6 -11
  30. package/skeleton/app/src/examples/ListScreen.tsx +167 -0
  31. package/skeleton/app/src/index.css +86 -13
  32. package/skeleton/app/src/main.tsx +3 -0
  33. package/skeleton/app/src/pwa.ts +1 -1
  34. package/skeleton/app/src/routes.test.ts +19 -0
  35. package/skeleton/app/src/routes.ts +139 -0
  36. package/skeleton/app/src/texts.test.ts +28 -0
  37. package/skeleton/app/src/texts.ts +35 -0
  38. package/skeleton/app/src/themes/calm.css +11 -0
  39. package/skeleton/app/src/themes/fresh.css +11 -0
  40. package/skeleton/app/src/themes/playful.css +11 -0
  41. package/skeleton/app/src/themes/warm.css +11 -0
  42. package/skeleton/app/src/ui/Composer.tsx +18 -0
  43. package/skeleton/app/src/ui/ContextSwitch.tsx +85 -0
  44. package/skeleton/app/src/ui/Dialog.tsx +103 -0
  45. package/skeleton/app/src/ui/Menu.tsx +118 -0
  46. package/skeleton/app/src/ui/Tabs.tsx +39 -0
  47. package/skeleton/app/src/ui/dismiss.ts +30 -0
  48. package/skeleton/app/src/ui/icons.tsx +53 -0
  49. package/skeleton/app/src/ui/ui.css +240 -0
  50. package/skeleton/app/vite.config.ts +4 -4
  51. package/skills/update/SKILL.md +4 -2
  52. package/skeleton/app/public/favicon.svg +0 -1
  53. package/skeleton/app/public/icon-192.png +0 -0
  54. package/skeleton/app/public/icon-512.png +0 -0
  55. package/skeleton/app/src/start.test.ts +0 -15
  56. 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; the SDK components follow the page.
3
- The colours match the SDK defaults, so AppAccess sits seamlessly on the page. */
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: light-dark(#202923, #e8eee9);
7
- background: light-dark(#fff, #191b1a);
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
- button,
29
- input,
30
- select {
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
- margin: 0.25rem;
33
- padding: 0.4rem;
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
- input[type='checkbox'] {
36
- accent-color: light-dark(#226044, #8acda6);
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';
@@ -1,4 +1,4 @@
1
- // Adapted from the sempods TypeScript SDK v0.2.0 recipe
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
+ }