@sempods/apps 0.8.0 → 0.9.0-dev.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +166 -0
  2. package/README.md +13 -6
  3. package/bin/sempods-apps.mjs +1 -0
  4. package/instructions/app-workflow.md +145 -72
  5. package/instructions/publish.md +29 -11
  6. package/instructions/setup.md +2 -2
  7. package/instructions/visual-check.md +20 -2
  8. package/package.json +5 -3
  9. package/scripts/build-site.mjs +37 -9
  10. package/scripts/check-theme.mjs +398 -0
  11. package/scripts/check.mjs +16 -5
  12. package/scripts/icon.mjs +1 -1
  13. package/scripts/lib/apps.mjs +115 -27
  14. package/scripts/lib/generate.mjs +58 -28
  15. package/scripts/lib/json.mjs +6 -0
  16. package/scripts/lib/kit.mjs +81 -0
  17. package/scripts/lib/merge.mjs +12 -9
  18. package/scripts/lib/sdk.mjs +10 -2
  19. package/scripts/lib/themes.mjs +45 -19
  20. package/scripts/migrate.mjs +6 -1
  21. package/scripts/new-app.mjs +2 -7
  22. package/scripts/sdk-update.mjs +32 -10
  23. package/shared/files/{e13474647ebaf26d042d76f4917203d209a2b14c0d2af8d12139ea328530823e → 3b75b42e15713ac93916d9c2978c2826dfedef18ed5255d444482484bc4b3334} +8 -5
  24. package/shared/files/{843c078e84912158607ebb8ce0042589d676a742bcd3cf8b6c474d62ff3a6845 → 5f709dbc1d0ab5c918a1ff06c32342a3f95569b1570793bfd3a03642bb6a7e43} +4 -2
  25. package/shared/snapshot.json +3 -3
  26. package/skeleton/app/package.json +3 -0
  27. package/skeleton/app/src/App.tsx +53 -21
  28. package/skeleton/app/src/examples/ContextSwitch.tsx +131 -0
  29. package/skeleton/app/src/examples/ListScreen.tsx +154 -56
  30. package/skeleton/app/src/index.css +10 -110
  31. package/skeleton/app/src/main.tsx +1 -3
  32. package/skeleton/app/src/places.test.ts +128 -0
  33. package/skeleton/app/src/places.ts +146 -0
  34. package/skeleton/app/src/texts.ts +8 -0
  35. package/skeleton/app/src/themes/calm.css +22 -8
  36. package/skeleton/app/src/themes/fresh.css +22 -8
  37. package/skeleton/app/src/themes/playful.css +22 -8
  38. package/skeleton/app/src/themes/warm.css +22 -8
  39. package/skeleton/app/vite.config.ts +12 -2
  40. package/skills/update/SKILL.md +6 -1
  41. package/update-policy.json +1 -1
  42. package/skeleton/app/src/routes.test.ts +0 -19
  43. package/skeleton/app/src/routes.ts +0 -139
  44. package/skeleton/app/src/ui/Composer.tsx +0 -18
  45. package/skeleton/app/src/ui/ContextSwitch.tsx +0 -94
  46. package/skeleton/app/src/ui/Dialog.tsx +0 -120
  47. package/skeleton/app/src/ui/Menu.tsx +0 -118
  48. package/skeleton/app/src/ui/Tabs.tsx +0 -39
  49. package/skeleton/app/src/ui/dismiss.ts +0 -30
  50. package/skeleton/app/src/ui/icons.tsx +0 -53
  51. package/skeleton/app/src/ui/ui.css +0 -249
@@ -0,0 +1,146 @@
1
+ import type { AddressModel } from '@sempods/app-ui';
2
+ import { app } from './sempods.generated.ts';
3
+
4
+ // The app's addresses as places: the start page at the app's path, a view per
5
+ // route in apps.json `routes` (for example /<id>/bought), an item per
6
+ // resource address (/<id>/items/<id>), the sign-in callback and an explicit
7
+ // page for anything else. `useNav(places)` in App.tsx moves between them:
8
+ // links, bookmarks and Back work, and an unsaved edit asks first. The address
9
+ // model is the app's own; change places and addresses here.
10
+
11
+ /** A route listed in apps.json. */
12
+ export type Route = (typeof app.routes)[number];
13
+
14
+ export type Place =
15
+ | { readonly kind: 'start' }
16
+ | { readonly kind: 'view'; readonly route: Route }
17
+ /**
18
+ * One item of src/examples/ListScreen.tsx, at `items/<id>`. `context` is
19
+ * the source Context the address names; the item is edited there, never
20
+ * in another one. Rename it with the list (for example `tasks/:id`).
21
+ */
22
+ | {
23
+ readonly kind: 'item';
24
+ readonly subject: string;
25
+ readonly context?: string;
26
+ }
27
+ | { readonly kind: 'callback' }
28
+ | { readonly kind: 'not-found'; readonly reason: 'unknown' | 'invalid' };
29
+
30
+ /** The resource pattern of an item; apps.json `resources` declares it. */
31
+ export const ITEM = 'items/:id';
32
+
33
+ /** An absolute IRI, as the SDK accepts it: a scheme and a rest. */
34
+ const absolute = (value: string) => /^[A-Za-z][A-Za-z0-9+.-]*:\S+$/.test(value);
35
+
36
+ /** A subject IRI as one path segment: UTF-8 in base64url. */
37
+ export function encodeSubject(iri: string): string {
38
+ let binary = '';
39
+ for (const byte of new TextEncoder().encode(iri))
40
+ binary += String.fromCharCode(byte);
41
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
42
+ }
43
+
44
+ /** The subject IRI of a path segment; null when it names none. */
45
+ export function decodeSubject(id: string): string | null {
46
+ if (!/^[\w-]+$/.test(id)) return null;
47
+ try {
48
+ const binary = atob(id.replace(/-/g, '+').replace(/_/g, '/'));
49
+ const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
50
+ const iri = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
51
+ return absolute(iri) ? iri : null;
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+
57
+ /**
58
+ * The address model over the app's path, routes and resource patterns. An
59
+ * address the app does not declare in apps.json is not found, so the parser,
60
+ * the host and the installed app always agree.
61
+ */
62
+ export function addressModel(config: {
63
+ readonly basePath: string;
64
+ readonly routes: readonly string[];
65
+ readonly resources: readonly string[];
66
+ }): AddressModel<Place> {
67
+ const { basePath } = config;
68
+ const model: AddressModel<Place> = {
69
+ format(place) {
70
+ switch (place.kind) {
71
+ case 'start':
72
+ case 'not-found':
73
+ return basePath;
74
+ case 'view':
75
+ return basePath + place.route;
76
+ case 'callback':
77
+ return basePath + 'callback';
78
+ case 'item': {
79
+ const path = `${basePath}items/${encodeSubject(place.subject)}`;
80
+ return place.context
81
+ ? `${path}?context=${encodeURIComponent(place.context)}`
82
+ : path;
83
+ }
84
+ }
85
+ },
86
+ parse(pathname, search) {
87
+ const here = pathname + search;
88
+ // A known address in another form (a trailing slash) becomes its
89
+ // canonical one. Its query stays, so a deep link keeps it through
90
+ // sign-in; parameters the address sets itself come first.
91
+ const known = (place: Place) => {
92
+ const address = model.format(place);
93
+ const own = address.split('?')[1];
94
+ if (own === undefined) return { place, canonical: address + search };
95
+ const set = new URLSearchParams(own);
96
+ const rest = new URLSearchParams(
97
+ [...new URLSearchParams(search)].filter(([key]) => !set.has(key)),
98
+ ).toString();
99
+ return { place, canonical: rest ? `${address}&${rest}` : address };
100
+ };
101
+ // An unknown or invalid address keeps itself, so its page shows the
102
+ // address the person followed.
103
+ const missing = (reason: 'unknown' | 'invalid') => ({
104
+ place: { kind: 'not-found', reason } as const,
105
+ canonical: here,
106
+ });
107
+ if (!(pathname + '/').startsWith(basePath)) return missing('unknown');
108
+ const path = pathname.slice(basePath.length).replace(/\/$/, '');
109
+ // The runtime reads the callback's parameters: it keeps its address.
110
+ if (path === 'callback')
111
+ return { place: { kind: 'callback' }, canonical: here };
112
+ if (path === '') return known({ kind: 'start' });
113
+ // Literal routes win over resource patterns sharing their prefix.
114
+ const route = config.routes.find((r) => r === path);
115
+ if (route !== undefined)
116
+ return known({ kind: 'view', route: route as Route });
117
+ const item = /^items\/([^/]+)$/.exec(path);
118
+ if (item?.[1] && config.resources.includes(ITEM)) {
119
+ const subject = decodeSubject(item[1]);
120
+ const context = new URLSearchParams(search).get('context');
121
+ if (!subject || (context !== null && !absolute(context)))
122
+ return missing('invalid');
123
+ return known({
124
+ kind: 'item',
125
+ subject,
126
+ ...(context ? { context } : {}),
127
+ });
128
+ }
129
+ return missing('unknown');
130
+ },
131
+ };
132
+ return model;
133
+ }
134
+
135
+ /**
136
+ * The address a sign-in callback returns to, kept in the app. A callback
137
+ * without a matching sign-in, for example an expired one, returns to `/` when
138
+ * `returnTo` is a function, as the generated one is; that is the site's
139
+ * overview, so the app's start page takes its place.
140
+ */
141
+ export function inApp(url: string): string {
142
+ return url.startsWith(app.basePath) ? url : app.basePath;
143
+ }
144
+
145
+ /** The app's address model; pass it to `useNav` once, in the frame. */
146
+ export const places = addressModel(app);
@@ -22,6 +22,10 @@ const en = {
22
22
  newVersion:
23
23
  'A new version is ready. It is used once every window of this app has been closed.',
24
24
  menu: 'Menu',
25
+ notFound: 'Not found',
26
+ notFoundText: 'This address does not lead anywhere in this app.',
27
+ invalidLink: 'This link is not valid.',
28
+ toStart: 'To the start page',
25
29
  };
26
30
 
27
31
  export const texts: { readonly en: typeof en; readonly de: typeof en } = {
@@ -31,5 +35,9 @@ export const texts: { readonly en: typeof en; readonly de: typeof en } = {
31
35
  newVersion:
32
36
  'Eine neue Version ist bereit. Sie wird verwendet, sobald alle Fenster dieser App geschlossen sind.',
33
37
  menu: 'Menü',
38
+ notFound: 'Nicht gefunden',
39
+ notFoundText: 'Diese Adresse führt nirgendwohin in dieser App.',
40
+ invalidLink: 'Dieser Link ist ungültig.',
41
+ toStart: 'Zur Startseite',
34
42
  },
35
43
  };
@@ -1,11 +1,25 @@
1
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. */
2
+ Palette roles of the theme contract of @sempods/app-ui, in light and
3
+ dark mode. They colour the app's components and, through the kit's bridge,
4
+ the SDK's. Text pairs keep 4.5:1, field boundaries and the focus ring
5
+ 3:1; pnpm run check:theme checks every pair in both modes. */
4
6
  :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);
7
+ --background: light-dark(#ffffff, #111315);
8
+ --foreground: light-dark(#1f2933, #e5e7eb);
9
+ --card: light-dark(#ffffff, #181b1f);
10
+ --card-foreground: var(--foreground);
11
+ --popover: light-dark(#ffffff, #1b1e23);
12
+ --popover-foreground: var(--foreground);
13
+ --primary: light-dark(#1f2933, #e5e7eb);
14
+ --primary-foreground: light-dark(#ffffff, #111315);
15
+ --secondary: light-dark(#eef0f3, #23272e);
16
+ --secondary-foreground: var(--foreground);
17
+ --muted: light-dark(#f3f4f6, #1d2026);
18
+ --muted-foreground: light-dark(#5f6672, #a1a7b0);
19
+ --accent: light-dark(#eef0f3, #262a31);
20
+ --accent-foreground: var(--foreground);
21
+ --destructive: light-dark(#b42318, #ffbcb5);
22
+ --border: light-dark(#e5e7eb, #2d3138);
23
+ --input: light-dark(#7a818c, #6b7280);
24
+ --ring: light-dark(#1f2933, #e5e7eb);
11
25
  }
@@ -1,11 +1,25 @@
1
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. */
2
+ Palette roles of the theme contract of @sempods/app-ui, in light and
3
+ dark mode. They colour the app's components and, through the kit's bridge,
4
+ the SDK's. Text pairs keep 4.5:1, field boundaries and the focus ring
5
+ 3:1; pnpm run check:theme checks every pair in both modes. */
4
6
  :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);
7
+ --background: light-dark(#ffffff, #0e171d);
8
+ --foreground: light-dark(#11293a, #e1edf3);
9
+ --card: light-dark(#ffffff, #132029);
10
+ --card-foreground: var(--foreground);
11
+ --popover: light-dark(#ffffff, #16242e);
12
+ --popover-foreground: var(--foreground);
13
+ --primary: light-dark(#0a6680, #5ec6e4);
14
+ --primary-foreground: light-dark(#ffffff, #06212b);
15
+ --secondary: light-dark(#e4f1f6, #1a2d38);
16
+ --secondary-foreground: var(--foreground);
17
+ --muted: light-dark(#eff6f9, #15222b);
18
+ --muted-foreground: light-dark(#4f6070, #94a8b6);
19
+ --accent: light-dark(#e4f1f6, #1d313d);
20
+ --accent-foreground: var(--foreground);
21
+ --destructive: light-dark(#b42318, #ffbcb5);
22
+ --border: light-dark(#dce8ef, #22343f);
23
+ --input: light-dark(#6a7f8d, #5f7787);
24
+ --ring: light-dark(#0a6680, #5ec6e4);
11
25
  }
@@ -1,11 +1,25 @@
1
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. */
2
+ Palette roles of the theme contract of @sempods/app-ui, in light and
3
+ dark mode. They colour the app's components and, through the kit's bridge,
4
+ the SDK's. Text pairs keep 4.5:1, field boundaries and the focus ring
5
+ 3:1; pnpm run check:theme checks every pair in both modes. */
4
6
  :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);
7
+ --background: light-dark(#ffffff, #14111c);
8
+ --foreground: light-dark(#241a3a, #ece6f8);
9
+ --card: light-dark(#ffffff, #1b1725);
10
+ --card-foreground: var(--foreground);
11
+ --popover: light-dark(#ffffff, #1f1a2b);
12
+ --popover-foreground: var(--foreground);
13
+ --primary: light-dark(#6a3bd2, #b59cff);
14
+ --primary-foreground: light-dark(#ffffff, #1b1036);
15
+ --secondary: light-dark(#efeafb, #282138);
16
+ --secondary-foreground: var(--foreground);
17
+ --muted: light-dark(#f5f2fc, #1e1929);
18
+ --muted-foreground: light-dark(#5f5776, #a49cbc);
19
+ --accent: light-dark(#efeafb, #2c2540);
20
+ --accent-foreground: var(--foreground);
21
+ --destructive: light-dark(#b42318, #ffbcb5);
22
+ --border: light-dark(#e9e4f3, #2e2742);
23
+ --input: light-dark(#7d7596, #6f6789);
24
+ --ring: light-dark(#6a3bd2, #b59cff);
11
25
  }
@@ -1,11 +1,25 @@
1
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. */
2
+ Palette roles of the theme contract of @sempods/app-ui, in light and
3
+ dark mode. They colour the app's components and, through the kit's bridge,
4
+ the SDK's. Text pairs keep 4.5:1, field boundaries and the focus ring
5
+ 3:1; pnpm run check:theme checks every pair in both modes. */
4
6
  :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);
7
+ --background: light-dark(#fdfaf6, #1a1512);
8
+ --foreground: light-dark(#2b211b, #efe6dd);
9
+ --card: light-dark(#fffdfa, #211b17);
10
+ --card-foreground: var(--foreground);
11
+ --popover: light-dark(#fffdfa, #251e19);
12
+ --popover-foreground: var(--foreground);
13
+ --primary: light-dark(#9c4128, #f0a184);
14
+ --primary-foreground: light-dark(#ffffff, #2a120a);
15
+ --secondary: light-dark(#f4e9df, #30261f);
16
+ --secondary-foreground: var(--foreground);
17
+ --muted: light-dark(#f6efe7, #241d18);
18
+ --muted-foreground: light-dark(#6b5e53, #ab9d90);
19
+ --accent: light-dark(#f4e9df, #342a23);
20
+ --accent-foreground: var(--foreground);
21
+ --destructive: light-dark(#a8201a, #ffbcb5);
22
+ --border: light-dark(#ece2d8, #3a2f28);
23
+ --input: light-dark(#8a7b6e, #7a6b5f);
24
+ --ring: light-dark(#9c4128, #f0a184);
11
25
  }
@@ -1,13 +1,23 @@
1
+ import tailwindcss from '@tailwindcss/vite';
1
2
  import react from '@vitejs/plugin-react';
2
3
  import { defineConfig } from 'vite';
3
4
  import { VitePWA } from 'vite-plugin-pwa';
4
5
  import { base, pageTitle, pwa, server } from './vite.sempods.generated.ts';
5
6
 
6
7
  // 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
+ // through the generated file; Tailwind CSS styles the UI kit and the app. Add
9
+ // your own plugins and options here.
8
10
  export default defineConfig({
9
11
  base,
10
12
  server,
11
13
  preview: server,
12
- plugins: [react(), pageTitle, ...(pwa ? [VitePWA(pwa)] : [])],
14
+ // React, the SDK and the UI kit come to about 570 kB minified (180 kB
15
+ // gzip) in one chunk; Vite warns only well above that.
16
+ build: { chunkSizeWarningLimit: 1000 },
17
+ plugins: [
18
+ react(),
19
+ tailwindcss(),
20
+ pageTitle,
21
+ ...(pwa ? [VitePWA(pwa)] : []),
22
+ ],
13
23
  });
@@ -34,7 +34,12 @@ instead.
34
34
  `pnpm run update <version>`.
35
35
  3. **Read the upgrade notes** for every version after the old one, including
36
36
  skipped ones, in `node_modules/@sempods/apps/CHANGELOG.md`. They name what
37
- the migration cannot do alone, such as changes the owner's apps need.
37
+ the migration cannot do alone, such as changes the owner's apps need. The
38
+ migration report's notes name app code that waits for a decision, for
39
+ example an app that still has the copied UI kit: which of its files are as
40
+ shipped and which the owner changed. Propose such app changes to the owner
41
+ separately from the tooling update; never overwrite a file the owner
42
+ changed.
38
43
  4. **Decide the manual cases.** The migration applies only unambiguous
39
44
  changes. It lists everything else in `.sempods-migration/migration.json`,
40
45
  each with a reason, and leaves those files exactly as they are. Next to
@@ -31,5 +31,5 @@
31
31
  },
32
32
  "seed": ["apps.json", "apps/.gitkeep"],
33
33
  "rootManifest": "package.json",
34
- "sdk": ["@sempods/app-sdk", "@sempods/client-sdk"]
34
+ "sdk": ["@sempods/app-sdk", "@sempods/client-sdk", "@sempods/app-ui"]
35
35
  }
@@ -1,19 +0,0 @@
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
- });
@@ -1,139 +0,0 @@
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
- }
@@ -1,18 +0,0 @@
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
- }
@@ -1,94 +0,0 @@
1
- import type { ReactNode } from 'react';
2
- import { useContexts, useSdkLocale } from '@sempods/app-sdk/react';
3
- import { ChevronIcon } from './icons.tsx';
4
- import { Menu } from './Menu.tsx';
5
-
6
- /**
7
- * The everyday switch for an app that works in one of several contexts at a
8
- * time (for example lists): the app icon and the current context's name in
9
- * place of the header's `<h1>`, opening a menu with the readable contexts,
10
- * their access and a way to share more. The SDK's `useContexts` loads the
11
- * contexts, selects a sensible one and switches under the leave policy; this
12
- * component only shows them. Until a context is selected it shows the app's
13
- * icon and `title` as the `<h1>`; then the `<h1>` names the app for screen
14
- * readers only, outside the menu. Name things in the person's words: `label`
15
- * "List", `shareMore` "Share more lists …".
16
- */
17
- export function ContextSwitch({
18
- icon,
19
- title,
20
- label,
21
- texts,
22
- }: {
23
- readonly icon: ReactNode;
24
- /** The app's name. */
25
- readonly title: string;
26
- /** What a context is in this app, for example "List". */
27
- readonly label: string;
28
- readonly texts: { readonly shareMore: string };
29
- }) {
30
- const {
31
- contexts,
32
- selected,
33
- select,
34
- failed,
35
- retry,
36
- canShare,
37
- share,
38
- shareProblem,
39
- } = useContexts();
40
- const { messages } = useSdkLocale();
41
- if (!selected)
42
- return (
43
- <h1>
44
- {icon}
45
- {title}
46
- </h1>
47
- );
48
- // A failed reload keeps the known contexts and offers to try again; a
49
- // failed request to share says why below the switch.
50
- const actions = [
51
- ...(failed
52
- ? [{ label: messages.controls.retry, onSelect: () => void retry() }]
53
- : []),
54
- ...(canShare
55
- ? [{ label: texts.shareMore, onSelect: () => void share() }]
56
- : []),
57
- ];
58
- return (
59
- <div className="context-switch-wrap">
60
- <h1 className="visually-hidden">{title}</h1>
61
- <Menu
62
- className="context-switch"
63
- align="start"
64
- label={`${label}: ${selected.name}`}
65
- trigger={
66
- <>
67
- {icon}
68
- <span className="context-name">{selected.name}</span>
69
- <ChevronIcon />
70
- </>
71
- }
72
- items={[
73
- ...contexts.map((context) => ({
74
- label: context.name,
75
- // The SDK's own words, as in its management dialog.
76
- hint: context.writable
77
- ? messages.controls.canChange
78
- : messages.controls.canRead,
79
- checked: context.iri === selected.iri,
80
- onSelect: () => {
81
- if (context.iri !== selected.iri) void select(context.iri);
82
- },
83
- })),
84
- ...(actions.length > 0 ? [null, ...actions] : []),
85
- ]}
86
- />
87
- {shareProblem && (
88
- <p role="alert" className="context-switch-problem">
89
- {shareProblem}
90
- </p>
91
- )}
92
- </div>
93
- );
94
- }