@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.
Files changed (56) hide show
  1. package/CHANGELOG.md +200 -0
  2. package/README.md +6 -3
  3. package/bin/sempods-apps.mjs +1 -0
  4. package/instructions/app-workflow.md +322 -111
  5. package/instructions/publish.md +13 -7
  6. package/instructions/setup.md +38 -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 +137 -0
  17. package/scripts/lib/themes.mjs +58 -0
  18. package/scripts/migrate.mjs +44 -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 → e1c798ec8536cf8cff6c19caf622fa9076ae210b0d1733a6e82ea2c1f4104c43} +17 -2
  24. package/shared/snapshot.json +4 -4
  25. package/skeleton/app/NOTES.md +20 -2
  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 +176 -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 +94 -0
  44. package/skeleton/app/src/ui/Dialog.tsx +120 -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 +249 -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
@@ -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,10 +14,20 @@ 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 proposes a fitting look. It asks a few
18
+ short questions about what the app is for, whether people use their own data
19
+ or work on the same data, and where new entries and changes should go. You can
20
+ describe these choices as lists or shared areas; no technical terms are needed.
18
21
  Review rules and automatic updates can wait until you have tried the first
19
22
  version.
20
23
 
24
+ Already have data for this app? If you want, connect your Pod to the assistant
25
+ through MCP, its connection to external tools and data. Agree which data it
26
+ may inspect so it can make the app fit your existing tasks, contacts or events.
27
+ This optional look only reads; it does not change your records. You can also
28
+ provide a made-up example or start without it. You still sign in to the app
29
+ separately when trying it.
30
+
21
31
  ## Change
22
32
 
23
33
  Ask for the next small improvement: “Let me mark an item as bought.” Keep trying
@@ -29,7 +39,12 @@ Your existing apps stay in the same repository.
29
39
  Use a test context with made-up data in your Pod. Sign in on the Pod's page,
30
40
  then choose that context in the app. Ask your assistant to check a save and a
31
41
  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.
42
+ into chat. Without a Pod you only see the sign-in screen; the app's own
43
+ screens need a test context.
44
+
45
+ Before your assistant hands over a screen, it shows you a screenshot from a
46
+ phone-sized window and asks what you think. Say what you like and what feels
47
+ off; that is how the app gets its look.
33
48
 
34
49
  ## Keep apps up to date
35
50
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "format": 1,
3
- "revision": "b9a807367932b1331df3f586df6cd8144ca3db006bd58dd4e4b92cc412dd3ccd",
3
+ "revision": "b39743fd5d824bb8bd37eaa9298b23ffe281dfd41799d652b5295f96191fdc02",
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": "e1c798ec8536cf8cff6c19caf622fa9076ae210b0d1733a6e82ea2c1f4104c43",
22
+ "package.json": "843c078e84912158607ebb8ce0042589d676a742bcd3cf8b6c474d62ff3a6845",
23
23
  "pnpm-workspace.yaml": "4660ca1887261ae658c7f587190e2b71ac91d15b1b6c4cf04dec823fdd0da654"
24
24
  }
25
25
  }
@@ -6,9 +6,27 @@ 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
+ Who uses their own data or shared data; who reads, writes and deletes, and
17
+ which limits the Pod can enforce. Which contexts are read, where new records
18
+ go, and where edits to existing records go (the selected context or each
19
+ row's source). What deletion means for other apps using the same records.
20
+ If existing data was inspected, the agreed Pod, contexts and read scope;
21
+ otherwise, what remains unknown. No credentials or private sample values.
22
+
9
23
  ## Vocabulary
10
24
 
11
- Types and properties (full IRIs) the app reads and writes.
25
+ Existing data or standard vocabulary it follows, types and properties (full
26
+ IRIs), text languages and status values. How subject identities and unknown
27
+ fields survive other apps' edits; the reason for any custom extension.
28
+ Which mappings were observed through optional MCP inspection or a supplied
29
+ example, and which are assumptions still to verify.
12
30
 
13
31
  ## Decisions
14
32
 
@@ -16,4 +34,4 @@ Choices the owner made, with the date.
16
34
 
17
35
  ## Tested
18
36
 
19
- What was tried, against which Pod and context, and what is still untested.
37
+ 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
+ // h1.
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
  }
@@ -0,0 +1,176 @@
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
+ useWorkflowAccess,
20
+ } from '@sempods/app-sdk/react';
21
+ import { fields, flag, text } from '@sempods/client-sdk/edit';
22
+ import { Composer } from '../ui/Composer.tsx';
23
+ import { Dialog } from '../ui/Dialog.tsx';
24
+ import { AddIcon } from '../ui/icons.tsx';
25
+
26
+ const schema = 'https://schema.org/';
27
+
28
+ /** What one row is: a name and whether it is done. */
29
+ const item = fields(
30
+ {
31
+ name: text(`${schema}name`, { language: null, trim: true }),
32
+ done: flag(`${schema}actionStatus`, {
33
+ on: `${schema}CompletedActionStatus`,
34
+ off: `${schema}PotentialActionStatus`,
35
+ }),
36
+ },
37
+ { type: `${schema}Action` },
38
+ );
39
+
40
+ // In an app these belong in src/texts.ts.
41
+ const en = {
42
+ empty: 'Nothing here yet. Add the first item below.',
43
+ emptyReadOnly: 'Nothing in this list.',
44
+ done: (name: string) => `Done: ${name}`,
45
+ remove: (name: string) => `Delete: ${name}`,
46
+ edit: 'Edit item',
47
+ close: 'Close',
48
+ name: 'Name',
49
+ newItem: 'New item',
50
+ add: 'Add',
51
+ };
52
+ const listTexts: { readonly en: typeof en; readonly de: typeof en } = {
53
+ en,
54
+ de: {
55
+ empty: 'Noch nichts hier. Füge unten den ersten Eintrag hinzu.',
56
+ emptyReadOnly: 'Nichts in dieser Liste.',
57
+ done: (name) => `Erledigt: ${name}`,
58
+ remove: (name) => `Löschen: ${name}`,
59
+ edit: 'Eintrag bearbeiten',
60
+ close: 'Schließen',
61
+ name: 'Name',
62
+ newItem: 'Neuer Eintrag',
63
+ add: 'Hinzufügen',
64
+ },
65
+ };
66
+
67
+ export function ListScreen() {
68
+ const t = useMessages(listTexts);
69
+ // A list that can only be read gets no input and no invitation to add.
70
+ const { write } = useWorkflowAccess();
71
+ const list = useList(item);
72
+ const items = list.data?.items;
73
+ // One hook for the list; each row gets its part with `mutate.for(row)`.
74
+ const mutate = useFieldUpdate(item);
75
+ const { selected, select } = useSelection();
76
+ const editor = useResourceEditor(selected, item);
77
+ const creation = useCreation(item, {
78
+ initial: { name: '', done: false },
79
+ collection: 'items',
80
+ });
81
+
82
+ return (
83
+ <>
84
+ {/* Loading, a failed read with "Try again", never a failure as empty. */}
85
+ <LoadNotice {...list} />
86
+ {(list.state.kind === 'ready' || list.state.kind === 'loading') &&
87
+ items?.length === 0 &&
88
+ list.data?.skipped === 0 && (
89
+ <p className="empty">{write ? t.empty : t.emptyReadOnly}</p>
90
+ )}
91
+ <ul className="rows">
92
+ {items?.map((entry) => {
93
+ const row = mutate.for(entry);
94
+ return (
95
+ <li key={entry.iri} className="row">
96
+ {/* The label is the checkbox's 44px touch target. */}
97
+ <label>
98
+ <input
99
+ type="checkbox"
100
+ checked={row.data.done}
101
+ disabled={!row.canMutate}
102
+ onChange={() => void row.update({ done: !row.data.done })}
103
+ />
104
+ <span className="visually-hidden">{t.done(row.data.name)}</span>
105
+ </label>
106
+ <button
107
+ className="row-name"
108
+ onClick={() => void select(entry.iri)}
109
+ >
110
+ {row.data.name}
111
+ </button>
112
+ {/* Asks in the SDK's dialog before it deletes. */}
113
+ <RemoveButton
114
+ disabled={!row.canMutate}
115
+ onRemove={row.remove}
116
+ label={t.remove(row.data.name)}
117
+ />
118
+ {/* The changed row is the confirmation; problems show here. */}
119
+ <UpdateNotice {...row.notice} quiet />
120
+ </li>
121
+ );
122
+ })}
123
+ </ul>
124
+ {/* Recovery for a row with an open write that is no longer listed. */}
125
+ <UnresolvedNotices of={mutate} shown={items} />
126
+ {/* Outside the rows, so the draft stays reachable if its row goes.
127
+ The SDK's Escape in a field cancels like Cancel and discards the
128
+ draft; × and a click beside the dialog ask first. */}
129
+ {selected && (
130
+ <Dialog
131
+ label={t.edit}
132
+ closeLabel={t.close}
133
+ onClose={() => void select(null)}
134
+ >
135
+ <ResourceEditor editor={editor} onClose={() => select(null)}>
136
+ {(draft, change) => (
137
+ <label>
138
+ {t.name}
139
+ <input
140
+ value={draft.name}
141
+ onChange={(event) => change({ name: event.target.value })}
142
+ />
143
+ </label>
144
+ )}
145
+ </ResourceEditor>
146
+ </Dialog>
147
+ )}
148
+ <UpdateNotice {...creation.notice} quiet />
149
+ {write && (
150
+ <Composer>
151
+ <form
152
+ onSubmit={(event) => {
153
+ event.preventDefault();
154
+ void creation.create();
155
+ }}
156
+ >
157
+ <input
158
+ aria-label={t.newItem}
159
+ placeholder={t.newItem}
160
+ value={creation.draft.name}
161
+ disabled={!creation.canEdit}
162
+ onChange={(event) => creation.change({ name: event.target.value })}
163
+ />
164
+ <button
165
+ aria-label={t.add}
166
+ title={t.add}
167
+ disabled={!creation.canCreate}
168
+ >
169
+ <AddIcon />
170
+ </button>
171
+ </form>
172
+ </Composer>
173
+ )}
174
+ </>
175
+ );
176
+ }
@@ -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
  }