@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,58 @@
1
+ // The skeleton's themes: one CSS file per theme in src/themes/, which sets the
2
+ // --sempods-* tokens in light and dark mode. An app imports one of them in its
3
+ // src/main.tsx; its icon takes that theme's accent.
4
+ import { readdirSync, readFileSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { ifPresent } from './json.mjs';
7
+ import { SKELETON } from './paths.mjs';
8
+
9
+ export const DEFAULT_THEME = 'calm';
10
+ const THEMES_DIR = join('src', 'themes');
11
+ const IMPORT = /^import '\.\/themes\/([a-z-]+)\.css';$/m;
12
+
13
+ /** The theme names the skeleton ships, the default first. */
14
+ export function themeNames(skeleton = SKELETON) {
15
+ const names = readdirSync(join(skeleton, THEMES_DIR))
16
+ .filter((file) => file.endsWith('.css'))
17
+ .map((file) => file.slice(0, -'.css'.length))
18
+ .sort();
19
+ return [DEFAULT_THEME, ...names.filter((name) => name !== DEFAULT_THEME)];
20
+ }
21
+
22
+ /** A token's light and dark colour, from `--name: light-dark(#light, #dark)`. */
23
+ export function tokenColours(css, token) {
24
+ const match = css.match(
25
+ new RegExp(
26
+ `--sempods-${token}:\\s*light-dark\\((#[0-9a-f]{6}),\\s*(#[0-9a-f]{6})\\)`,
27
+ 'i',
28
+ ),
29
+ );
30
+ if (!match) throw new Error(`the theme sets no --sempods-${token}`);
31
+ return { light: match[1].toLowerCase(), dark: match[2].toLowerCase() };
32
+ }
33
+
34
+ /**
35
+ * The icon's colours for a theme file: the accent as the square and the text
36
+ * on the accent as the letter, in light and dark mode.
37
+ */
38
+ export function iconColours(css) {
39
+ const accent = tokenColours(css, 'accent');
40
+ const on = tokenColours(css, 'on-accent');
41
+ return {
42
+ light: { background: accent.light, letter: on.light },
43
+ dark: { background: accent.dark, letter: on.dark },
44
+ };
45
+ }
46
+
47
+ /** The theme an app imports in src/main.tsx, if it imports one of these. */
48
+ export function appTheme(appDir) {
49
+ const main = ifPresent(() =>
50
+ readFileSync(join(appDir, 'src', 'main.tsx'), 'utf8'),
51
+ );
52
+ return main?.match(IMPORT)?.[1];
53
+ }
54
+
55
+ /** The CSS of a theme file in an app or in the skeleton. */
56
+ export function themeCss(dir, name) {
57
+ return readFileSync(join(dir, THEMES_DIR, `${name}.css`), 'utf8');
58
+ }
@@ -32,6 +32,7 @@ import {
32
32
  replaceFile,
33
33
  } from './lib/files.mjs';
34
34
  import { GENERATED_FILES } from './lib/generate.mjs';
35
+ import { adoptPageTitle, PAGE, VITE_CONFIG } from './lib/page.mjs';
35
36
  import { formatJson, ifPresent, readJson, replaceJson } from './lib/json.mjs';
36
37
  import { mergeManifest, mergeText, withSection } from './lib/merge.mjs';
37
38
  import { repositoryRoot, TOOLING } from './lib/paths.mjs';
@@ -73,21 +74,23 @@ export function refuseUnfinishedSdkUpdate(root) {
73
74
  );
74
75
  }
75
76
 
77
+ // A broken apps.json names no apps here; regenerating reports it later.
78
+ function appsOf(root) {
79
+ try {
80
+ return readApps(root).apps;
81
+ } catch {
82
+ return [];
83
+ }
84
+ }
85
+
76
86
  /**
77
87
  * Symbolic links where an update writes: the command's own files, the apps'
78
- * generated configuration and the given starter paths, each with its
79
- * ancestors, and anything inside the migration folder.
88
+ * generated configuration, page and Vite configuration and the given starter
89
+ * paths, each with its ancestors, and anything inside the migration folder.
80
90
  */
81
91
  export function linkedPaths(root, files = []) {
82
92
  const found = new Set();
83
- // A broken apps.json names no apps here; regenerating reports it later.
84
- const apps = (() => {
85
- try {
86
- return readApps(root).apps;
87
- } catch {
88
- return [];
89
- }
90
- })();
93
+ const apps = appsOf(root);
91
94
  for (const path of [
92
95
  'package.json',
93
96
  'pnpm-lock.yaml',
@@ -95,7 +98,9 @@ export function linkedPaths(root, files = []) {
95
98
  BASELINE_FILE,
96
99
  MIGRATION_DIR,
97
100
  ...apps.flatMap(({ id }) =>
98
- GENERATED_FILES.map((file) => `apps/${id}/${file}`),
101
+ [...GENERATED_FILES, PAGE, VITE_CONFIG].map(
102
+ (file) => `apps/${id}/${file}`,
103
+ ),
99
104
  ),
100
105
  ...files,
101
106
  ]) {
@@ -372,6 +377,8 @@ export function migrate(
372
377
  if (baseline.revision === target.snapshot.revision) {
373
378
  // A leftover of a completed migration.
374
379
  rmSync(work, { recursive: true, force: true });
380
+ // The tooling may still have changed what it generates for apps.
381
+ updateApps(root, report);
375
382
  return { ...report, unchanged: true };
376
383
  }
377
384
  }
@@ -479,7 +486,7 @@ export function migrate(
479
486
  throw new Error(
480
487
  `${unresolved.map(({ file }) => file).join(', ')} still contain an unresolved suggestion; resolve them, then run pnpm run migrate --done again`,
481
488
  );
482
- report.regenerated = regenerate(root);
489
+ updateApps(root, report);
483
490
  // A new starter can change the manifest or the workspace settings, so the
484
491
  // lockfile and node_modules follow before the migration counts as done.
485
492
  if (!install(root))
@@ -497,11 +504,27 @@ export function migrate(
497
504
  return report;
498
505
  }
499
506
 
507
+ /**
508
+ * The apps' part of a migration: files a release moves where that is
509
+ * unambiguous, then the generated configuration.
510
+ */
511
+ function updateApps(root, report) {
512
+ for (const app of appsOf(root)) {
513
+ const dir = join(root, 'apps', app.id);
514
+ if (existsSync(dir)) report.updated.push(...adoptPageTitle(dir, app));
515
+ }
516
+ report.regenerated = regenerate(root);
517
+ }
518
+
500
519
  export function formatMigration(report) {
501
- if (report.unchanged)
502
- return `The shared files already match ${PACKAGE} ${report.to}; nothing to migrate.`;
503
520
  const list = (title, items) =>
504
521
  items.length ? [`${title}:`, ...items.map((item) => ` ${item}`)] : [];
522
+ if (report.unchanged)
523
+ return [
524
+ `The shared files already match ${PACKAGE} ${report.to}; nothing to migrate.`,
525
+ ...list('Updated', report.updated),
526
+ ...list('Regenerated', report.regenerated),
527
+ ].join('\n');
505
528
  const lines = [
506
529
  `Migration from ${PACKAGE} ${report.from} to ${report.to}${report.unfinished ? ' is open' : ' is complete'}.`,
507
530
  ...list('Updated', report.updated),
@@ -1,10 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  // Creates apps/<id> from the skeleton with the root's SDK version and adds it
3
3
  // to apps.json.
4
- // Usage: pnpm run new-app <id> [--title "<title>"] [--language <de|en>] [--no-pwa]
4
+ // Usage: pnpm run new-app <id> [--title "<title>"] [--description "<text>"]
5
+ // [--language <auto|de|en>] [--theme <name>] [--no-pwa]
5
6
  import {
6
7
  cpSync,
7
8
  existsSync,
9
+ mkdirSync,
8
10
  readdirSync,
9
11
  readFileSync,
10
12
  rmSync,
@@ -14,14 +16,23 @@ import { extname, join, resolve } from 'node:path';
14
16
  import { fileURLToPath } from 'node:url';
15
17
  import { parseArgs } from 'node:util';
16
18
  import {
19
+ APP_LANGUAGES,
20
+ AUTO,
21
+ invalidDescription,
17
22
  invalidId,
18
- LANGUAGES,
19
23
  nextDevPort,
20
24
  readApps,
21
25
  writeApps,
22
26
  } from './lib/apps.mjs';
23
27
  import { writeGenerated } from './lib/generate.mjs';
24
28
  import { escapeHtml } from './lib/html.mjs';
29
+ import { writeAppIcons } from './lib/icon.mjs';
30
+ import {
31
+ DEFAULT_THEME,
32
+ iconColours,
33
+ themeCss,
34
+ themeNames,
35
+ } from './lib/themes.mjs';
25
36
  import { readJson, writeJson } from './lib/json.mjs';
26
37
  import { repositoryRoot, SKELETON } from './lib/paths.mjs';
27
38
  import { INSTALL, pnpm, scriptArgs } from './lib/pnpm.mjs';
@@ -29,14 +40,14 @@ import { EXACT_VERSION, SDK } from './lib/sdk.mjs';
29
40
 
30
41
  const TEXT = new Set(['.json', '.html', '.md', '.ts', '.tsx', '.css']);
31
42
 
32
- function fillTokens(dir, app) {
43
+ function fillTokens(dir, app, theme) {
33
44
  // Directory entries carry their type, so no separate stat precedes the read
34
45
  // and write of the same path.
35
46
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
36
47
  const { name } = entry;
37
48
  const path = join(dir, name);
38
49
  if (entry.isDirectory()) {
39
- fillTokens(path, app);
50
+ fillTokens(path, app, theme);
40
51
  continue;
41
52
  }
42
53
  if (!TEXT.has(extname(name))) continue;
@@ -44,8 +55,13 @@ function fillTokens(dir, app) {
44
55
  const source = readFileSync(path, 'utf8');
45
56
  const filled = source
46
57
  .replaceAll('__APP_ID__', app.id)
58
+ .replaceAll('__APP_THEME__', theme)
47
59
  .replaceAll('__APP_TITLE__', title)
48
- .replaceAll('__APP_LANGUAGE__', app.language);
60
+ // The page's language until the app sets the one it resolved.
61
+ .replaceAll(
62
+ '__APP_LANGUAGE__',
63
+ app.language === AUTO ? 'en' : app.language,
64
+ );
49
65
  if (filled !== source) writeFileSync(path, filled);
50
66
  }
51
67
  }
@@ -74,12 +90,23 @@ function rootSdk(root) {
74
90
  */
75
91
  export function createApp(
76
92
  root,
77
- { id, title = id, language = 'en', pwa = true },
93
+ {
94
+ id,
95
+ title = id,
96
+ description,
97
+ language = AUTO,
98
+ theme = DEFAULT_THEME,
99
+ pwa = true,
100
+ },
78
101
  ) {
79
102
  const reason = invalidId(id);
80
103
  if (reason) throw new Error(reason);
81
- if (!LANGUAGES.includes(language))
82
- throw new Error(`language must be one of ${LANGUAGES.join(', ')}`);
104
+ if (!themeNames().includes(theme))
105
+ throw new Error(`theme must be one of ${themeNames().join(', ')}`);
106
+ const problem = invalidDescription(description);
107
+ if (problem) throw new Error(problem);
108
+ if (!APP_LANGUAGES.includes(language))
109
+ throw new Error(`language must be one of ${APP_LANGUAGES.join(', ')}`);
83
110
  if (typeof title !== 'string' || title.trim() === '')
84
111
  throw new Error('the title must not be empty');
85
112
  const manifest = readApps(root);
@@ -91,6 +118,7 @@ export function createApp(
91
118
  const app = {
92
119
  id,
93
120
  title: title.trim(),
121
+ ...(description === undefined ? {} : { description: description.trim() }),
94
122
  language,
95
123
  path: `/${id}/`,
96
124
  devPort: nextDevPort(manifest),
@@ -98,7 +126,8 @@ export function createApp(
98
126
  };
99
127
  try {
100
128
  cpSync(SKELETON, appDir, { recursive: true, errorOnExist: true });
101
- fillTokens(appDir, app);
129
+ fillTokens(appDir, app, theme);
130
+ writeAppIcons(appDir, app.title, iconColours(themeCss(appDir, theme)));
102
131
  const pkgFile = join(appDir, 'package.json');
103
132
  const pkg = readJson(pkgFile);
104
133
  // Package managers keep dependency lists sorted.
@@ -123,14 +152,16 @@ function main() {
123
152
  allowPositionals: true,
124
153
  options: {
125
154
  title: { type: 'string' },
155
+ description: { type: 'string' },
126
156
  language: { type: 'string' },
157
+ theme: { type: 'string' },
127
158
  'no-pwa': { type: 'boolean' },
128
159
  'skip-install': { type: 'boolean' },
129
160
  },
130
161
  });
131
162
  if (positionals.length !== 1) {
132
163
  console.error(
133
- 'Usage: pnpm run new-app <id> [--title "<title>"] [--language <de|en>] [--no-pwa]',
164
+ 'Usage: pnpm run new-app <id> [--title "<title>"] [--description "<text>"] [--language <auto|de|en>] [--theme <name>] [--no-pwa]',
134
165
  );
135
166
  process.exit(2);
136
167
  }
@@ -140,6 +171,8 @@ function main() {
140
171
  app = createApp(root, {
141
172
  id: positionals[0],
142
173
  title: values.title,
174
+ description: values.description,
175
+ theme: values.theme,
143
176
  language: values.language,
144
177
  pwa: !values['no-pwa'],
145
178
  });
@@ -55,6 +55,20 @@ export function releaseLinks(version) {
55
55
  };
56
56
  }
57
57
 
58
+ // Since 0.6.0 the SDK ships its release notes and migration guide; read the
59
+ // installed ones, which match the installed code.
60
+ const INSTALLED_DOCS = join('node_modules', '@sempods', 'app-sdk', 'docs');
61
+
62
+ /**
63
+ * Where to read about `version` after it is installed: the installed notes
64
+ * and guide when the SDK ships them, otherwise the published ones.
65
+ */
66
+ export function releaseDocs(root, version) {
67
+ const notes = join(INSTALLED_DOCS, 'releases', `${version}.md`);
68
+ if (!existsSync(join(root, notes))) return releaseLinks(version);
69
+ return { release: notes, migration: join(INSTALLED_DOCS, 'migration.md') };
70
+ }
71
+
58
72
  /** Plan everything before writing. Registry injection keeps tests offline. */
59
73
  export function planUpdate(
60
74
  root,
@@ -202,8 +216,9 @@ function main() {
202
216
  ? `Updated SDK to ${plan.version}.`
203
217
  : `Already at SDK ${plan.version}; nothing changed.`,
204
218
  );
219
+ const docs = releaseDocs(root, plan.version);
205
220
  console.log(
206
- `SDK release: ${plan.release}\nMigration guide: ${plan.migration}`,
221
+ `SDK release notes: ${docs.release}\nMigration guide: ${docs.migration}`,
207
222
  );
208
223
  }
209
224
 
@@ -47,9 +47,9 @@ it. Keep it free of credentials and private data.
47
47
  - Created from (`.sempods-baseline.json`): not recorded
48
48
  - SDK version at setup (`@sempods/app-sdk` in the root `package.json`): not recorded
49
49
  - Owner and review identity: not recorded
50
- - UI language: not selected
50
+ - Owner's language for the app texts: not recorded
51
51
  - First app ID and idea: not selected
52
- - Test Pod/context: not selected (optional; synthetic data only)
52
+ - Test Pod and contexts: not selected (optional; synthetic data only)
53
53
  - sempods version PRs (Dependabot): not chosen
54
54
  - Local URL and check result: not checked
55
55
  - Pod exercise and remaining gaps: not tested
@@ -8,6 +8,7 @@
8
8
  },
9
9
  "scripts": {
10
10
  "new-app": "sempods-apps new-app",
11
+ "icon": "sempods-apps icon",
11
12
  "dev": "sempods-apps dev",
12
13
  "regenerate": "sempods-apps regenerate",
13
14
  "configure-site": "sempods-apps configure-site",
@@ -18,8 +19,8 @@
18
19
  "migrate": "sempods-apps migrate"
19
20
  },
20
21
  "devDependencies": {
21
- "@sempods/app-sdk": "0.5.0",
22
+ "@sempods/app-sdk": "0.6.0",
22
23
  "@sempods/apps": "0.0.0",
23
- "@sempods/client-sdk": "0.5.0"
24
+ "@sempods/client-sdk": "0.6.0"
24
25
  }
25
26
  }
@@ -14,7 +14,8 @@ to it by itself.
14
14
  [Open this repository in your assistant and paste the starter prompt](../README.md#start-with-your-idea).
15
15
  Describe one thing you want to do: “Add something to my shopping list.” Your
16
16
  assistant sets up the app and gives you a local link to open.
17
- It chooses the technical details and asks you about what the app should do.
17
+ It chooses the technical details and asks you a few short questions: what the
18
+ app is for, which mood it should have, its name and icon.
18
19
  Review rules and automatic updates can wait until you have tried the first
19
20
  version.
20
21
 
@@ -29,7 +30,12 @@ Your existing apps stay in the same repository.
29
30
  Use a test context with made-up data in your Pod. Sign in on the Pod's page,
30
31
  then choose that context in the app. Ask your assistant to check a save and a
31
32
  reload, and to tell you what it has not tested. Never paste passwords or tokens
32
- into chat. Without a Pod, you can still try the screen.
33
+ into chat. Without a Pod you only see the sign-in screen; the app's own
34
+ screens need a test context.
35
+
36
+ Before your assistant hands over a screen, it shows you a screenshot from a
37
+ phone-sized window and asks what you think. Say what you like and what feels
38
+ off; that is how the app gets its look.
33
39
 
34
40
  ## Keep apps up to date
35
41
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "format": 1,
3
- "revision": "b9a807367932b1331df3f586df6cd8144ca3db006bd58dd4e4b92cc412dd3ccd",
3
+ "revision": "1dd60bc5a3e7e0ab995d0d6941e78f96239ee8979a6c1d3a529ae4f79b680e1b",
4
4
  "files": {
5
5
  ".agents/skills/app-workflow/SKILL.md": "0221b70340152ba3cc615342254603b29b89c4d7d944d7b3b3e647873bdcefa0",
6
6
  ".agents/skills/update/SKILL.md": "d3ca3a13c6524a2ee9382977ddc0539790813225f366306024a58c957a4716ee",
@@ -14,12 +14,12 @@
14
14
  ".node-version": "b911698dc3cf3227d34d0ad6727f1c4408791203c9665de99ab959331a1fb07d",
15
15
  "AGENTS.md": "393b09ecd18889d9e1fdf2a6df12d1f6c193fc7fcd89ba808ad94df521bad8db",
16
16
  "CLAUDE.md": "f9e2544290ee8418484e47194444ee58c2ea675682871ae8a8727d1edb17978e",
17
- "INIT.md": "f2fb601d5ebc4ca72274ac9d3fcf7b7a96e27958bb92cd065a9165b501ee94d7",
17
+ "INIT.md": "44c114d4d2c8b172f4757100f79e8a1a00d30c96ba2ad8f75c3373e52f6b849f",
18
18
  "README.md": "2818c884d46d97ec14fe4921dbdc147769d29846c08a92b1d3225c5297cfed93",
19
19
  "apps.json": "9945f9bd573e5af8aad2e65cc8b4284fbb1d7e8b96c4efdd42eeb49132fb4327",
20
20
  "apps/.gitkeep": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
21
- "docs/start.md": "1c29eed0a0297c742ca5814de9081ff8efce3b2da5d9e1bdf90a403873da4aa9",
22
- "package.json": "31ede0d1a035d8531100ebfd751510c772ab0871309a0b9752a77fb96187eb53",
21
+ "docs/start.md": "f30ec6b7f9de16be54919382ab18cdcc33ee64938f7b907d855bcf6740e4c102",
22
+ "package.json": "843c078e84912158607ebb8ce0042589d676a742bcd3cf8b6c474d62ff3a6845",
23
23
  "pnpm-workspace.yaml": "4660ca1887261ae658c7f587190e2b71ac91d15b1b6c4cf04dec823fdd0da654"
24
24
  }
25
25
  }
@@ -6,6 +6,16 @@ Your assistant keeps these notes up to date so the next session can continue.
6
6
 
7
7
  What the app is for and its first useful interaction.
8
8
 
9
+ ## Design
10
+
11
+ Purpose and who uses it, the theme and how the icon was chosen, with later
12
+ changes the owner asked for.
13
+
14
+ ## Data
15
+
16
+ How the app uses contexts (one, several one at a time, read all and write in
17
+ one, or write into several) and which contexts it reads and writes.
18
+
9
19
  ## Vocabulary
10
20
 
11
21
  Types and properties (full IRIs) the app reads and writes.
@@ -16,4 +26,4 @@ Choices the owner made, with the date.
16
26
 
17
27
  ## Tested
18
28
 
19
- What was tried, against which Pod and context, and what is still untested.
29
+ What was tried, against which Pod and contexts, and what is still untested.
@@ -3,11 +3,15 @@
3
3
  <head>
4
4
  <meta charset="UTF-8" />
5
5
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
6
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
- <meta name="theme-color" content="#176354" />
6
+ <!-- The layout resizes with the on-screen keyboard and reaches the edges. -->
7
+ <meta
8
+ name="viewport"
9
+ content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content"
10
+ />
11
+ <meta name="theme-color" media="(prefers-color-scheme: light)" content="#ffffff" />
12
+ <meta name="theme-color" media="(prefers-color-scheme: dark)" content="#111315" />
8
13
  <!-- The callback page must not send its code and state in Referer. -->
9
14
  <meta name="referrer" content="strict-origin" />
10
- <title>__APP_TITLE__</title>
11
15
  </head>
12
16
  <body>
13
17
  <div id="root"></div>
@@ -11,18 +11,18 @@
11
11
  "preview": "vite preview"
12
12
  },
13
13
  "dependencies": {
14
- "react": "19.3.0",
15
- "react-dom": "19.3.0"
14
+ "react": "19.3.1",
15
+ "react-dom": "19.3.1"
16
16
  },
17
17
  "devDependencies": {
18
- "@types/node": "24.19.1",
18
+ "@types/node": "24.19.2",
19
19
  "@types/react": "19.3.0",
20
20
  "@types/react-dom": "19.3.0",
21
21
  "@vitejs/plugin-react": "6.1.2",
22
- "oxlint": "1.87.0",
22
+ "oxlint": "1.89.0",
23
23
  "typescript": "7.0.2",
24
- "vite": "8.3.3",
24
+ "vite": "8.3.5",
25
25
  "vite-plugin-pwa": "2.0.0",
26
- "vitest": "5.0.3"
26
+ "vitest": "5.0.5"
27
27
  }
28
28
  }
@@ -1,64 +1,96 @@
1
1
  import { useRef, useState } from 'react';
2
2
  import { createBrowserRuntime } from '@sempods/app-sdk';
3
3
  import {
4
+ AccessNotice,
4
5
  AppAccess,
5
6
  SempodsProvider,
6
7
  TargetScreen,
7
8
  useAppState,
9
+ useMessages,
8
10
  useSdkLocale,
9
11
  } from '@sempods/app-sdk/react';
10
12
  import { NewVersionNotice } from './NewVersionNotice.tsx';
13
+ import { replaceUrl } from './routes.ts';
11
14
  import { app, runtimeOptions } from './sempods.generated.ts';
12
- import { startMessage } from './start.ts';
15
+ import { texts, uiLocale } from './texts.ts';
16
+ import { MenuIcon } from './ui/icons.tsx';
17
+ import { Menu } from './ui/Menu.tsx';
13
18
 
14
- // One runtime for the app's lifetime, created outside rendering.
15
- const runtime = createBrowserRuntime(runtimeOptions);
19
+ // One runtime for the app's lifetime, created outside rendering. Its return
20
+ // after a sign-in goes through the router, which then shows that route.
21
+ const runtime = createBrowserRuntime({ ...runtimeOptions, replaceUrl });
22
+
23
+ // One UI language for the app's texts, the SDK components and the page.
24
+ const { locale, language } = uiLocale(app.language, navigator.languages);
25
+ document.documentElement.lang = language;
16
26
 
17
27
  // Vite hot replacement ends this module's runtime lifetime.
18
28
  if (import.meta.hot) import.meta.hot.dispose(() => runtime.dispose());
19
29
 
30
+ // The app icon (public/favicon.svg), decorative next to the app's name.
31
+ const icon = <img src={`${app.basePath}favicon.svg`} alt="" />;
32
+
20
33
  export default function App({
21
34
  registration,
22
35
  }: {
23
36
  readonly registration: Promise<ServiceWorkerRegistration | undefined>;
24
37
  }) {
25
38
  return (
26
- <SempodsProvider runtime={runtime} language={app.language}>
39
+ <SempodsProvider runtime={runtime} language={language} locale={locale}>
27
40
  <NewVersionNotice registration={registration} />
28
41
  <Frame />
29
42
  </SempodsProvider>
30
43
  );
31
44
  }
32
45
 
33
- // The app owns its layout. AppAccess shows sign-in and recovery beside the
34
- // content and hides once a context is readable; the button reopens it.
46
+ // The app owns its layout: a header with the app's icon and name, its icon
47
+ // actions and a menu; AppAccess beside the content for sign-in and recovery,
48
+ // and as a dialog for managing Pods and sharing from the menu. An app that
49
+ // switches between contexts (lists) puts ui/ContextSwitch in place of the
50
+ // name.
35
51
  function Frame() {
36
- const [open, setOpen] = useState(false);
37
- const focusTarget = useRef<HTMLButtonElement>(null);
52
+ const [managing, setManaging] = useState(false);
53
+ const menuButton = useRef<HTMLButtonElement>(null);
38
54
  const { connections } = useAppState();
39
55
  const { messages } = useSdkLocale();
56
+ const t = useMessages(texts);
40
57
  const header = connections.length > 0;
41
58
  return (
42
59
  <main>
43
60
  {header && (
44
61
  <header>
45
- <h1>{app.title}</h1>
46
- <button
47
- ref={focusTarget}
48
- aria-expanded={open}
49
- onClick={() => setOpen(!open)}
50
- >
51
- {messages.controls.dataAccess}
52
- </button>
62
+ <h1>
63
+ {icon}
64
+ {app.title}
65
+ </h1>
66
+ <div className="header-actions">
67
+ {/* The app's own icon actions go here. */}
68
+ <Menu
69
+ label={t.menu}
70
+ trigger={<MenuIcon />}
71
+ button={menuButton}
72
+ items={[
73
+ {
74
+ label: messages.controls.dataAccess,
75
+ onSelect: () => setManaging(true),
76
+ },
77
+ ]}
78
+ />
79
+ </div>
53
80
  </header>
54
81
  )}
55
- {/* Below the header's h1, AppAccess uses h2. */}
82
+ {/* Below the header's h1, AppAccess uses h2. It explains sign-in and
83
+ lost access; AccessNotice adds only that a context is read-only. */}
56
84
  <AppAccess
57
85
  appName={app.title}
86
+ description={app.description}
87
+ icon={icon}
58
88
  headingLevel={header ? 2 : 1}
59
- open={open}
60
- focusTarget={focusTarget}
89
+ open={managing}
90
+ onClose={() => setManaging(false)}
91
+ focusTarget={menuButton}
61
92
  />
93
+ <AccessNotice only="write" />
62
94
  <TargetScreen>
63
95
  <Start />
64
96
  </TargetScreen>
@@ -68,6 +100,6 @@ function Frame() {
68
100
 
69
101
  // Replace this screen with your app. It renders once a Pod context is chosen.
70
102
  function Start() {
71
- const { language } = useSdkLocale();
72
- return <p>{startMessage(language)}</p>;
103
+ const t = useMessages(texts);
104
+ return <p>{t.start}</p>;
73
105
  }
@@ -1,9 +1,10 @@
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
4
- // registration function moved to pwa.ts.
4
+ // registration function moved to pwa.ts; the text comes from texts.ts.
5
5
  import { useEffect, useState } from 'react';
6
- import { useSdkLocale } from '@sempods/app-sdk/react';
6
+ import { useMessages } from '@sempods/app-sdk/react';
7
+ import { texts } from './texts.ts';
7
8
 
8
9
  /**
9
10
  * Render beneath the app's actual locale provider so notices follow its language.
@@ -18,7 +19,7 @@ export function NewVersionNotice({
18
19
  readonly registration: Promise<ServiceWorkerRegistration | undefined>;
19
20
  }) {
20
21
  const [ready, setReady] = useState(false);
21
- const { language } = useSdkLocale();
22
+ const t = useMessages(texts);
22
23
  useEffect(() => {
23
24
  let active = true;
24
25
  let stop = () => {};
@@ -53,11 +54,5 @@ export function NewVersionNotice({
53
54
  };
54
55
  }, [registration]);
55
56
  if (!ready) return null;
56
- return (
57
- <p role="status">
58
- {language === 'de'
59
- ? 'Eine neue Version ist bereit. Sie wird verwendet, sobald alle Fenster dieser App geschlossen sind.'
60
- : 'A new version is ready. It is used once every window of this app has been closed.'}
61
- </p>
62
- );
57
+ return <p role="status">{t.newVersion}</p>;
63
58
  }