@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
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  // One SDK release for the root and every registered app: the manifests name
3
- // it, pnpm installs it, and the update counts only once node_modules has it
4
- // and check passes. Usage: pnpm run sdk-update <version|latest>
3
+ // it for both SDK packages and the UI kit, pnpm installs it, and the update
4
+ // counts only once node_modules has it and check passes.
5
+ // Usage: pnpm run sdk-update <version|latest>
5
6
  import {
6
7
  existsSync,
7
8
  readFileSync,
@@ -11,17 +12,20 @@ import {
11
12
  import { join, resolve } from 'node:path';
12
13
  import { fileURLToPath } from 'node:url';
13
14
  import { parseArgs } from 'node:util';
15
+ import semver from 'semver';
14
16
  import { readApps } from './lib/apps.mjs';
15
- import { formatJson, ifPresent, readJson } from './lib/json.mjs';
17
+ import { formatJson, ifPresent, readJson, sortedKeys } from './lib/json.mjs';
16
18
  import { migrationState } from './migrate.mjs';
17
19
  import { repositoryRoot } from './lib/paths.mjs';
18
20
  import { INSTALL, pnpm, pnpmJson, scriptArgs } from './lib/pnpm.mjs';
19
21
  import {
22
+ appSdk,
20
23
  compareVersions,
21
24
  EXACT_VERSION,
22
25
  PENDING_UPDATE,
23
26
  REFERENCE,
24
27
  SDK,
28
+ UI_KIT,
25
29
  } from './lib/sdk.mjs';
26
30
 
27
31
 
@@ -39,6 +43,7 @@ export function registryLookup(root, name, version) {
39
43
  `${name}@${version}`,
40
44
  'version',
41
45
  'dependencies',
46
+ 'peerDependencies',
42
47
  '--json',
43
48
  ]);
44
49
  } catch (error) {
@@ -100,21 +105,37 @@ export function planUpdate(
100
105
  throw new Error(
101
106
  `${SDK[0]}@${version} must depend on exactly ${SDK[1]}@${version}; found ${JSON.stringify(app.dependencies?.[SDK[1]])}`,
102
107
  );
108
+ // The UI kit is released with the SDK, at the same version.
109
+ const kit = lookup(UI_KIT, version);
110
+ if (kit?.version !== version)
111
+ throw new Error(
112
+ `${UI_KIT}@${version} is missing or returned an unexpected version; the UI kit is released with the SDK since 0.7.0`,
113
+ );
114
+ const peer = kit.peerDependencies?.[SDK[0]];
115
+ if (!peer || !semver.satisfies(version, peer, { includePrerelease: true }))
116
+ throw new Error(
117
+ `${UI_KIT}@${version} must support ${SDK[0]}@${version}; it asks for ${JSON.stringify(peer)}`,
118
+ );
103
119
 
120
+ // The root declares every SDK package and gains the UI kit when it lacks
121
+ // it; an app moves only what it declares.
104
122
  const entries = [
105
- ['package.json', 'devDependencies'],
123
+ ['package.json', 'devDependencies', true],
106
124
  ...readApps(root).apps.map(({ id }) => [
107
125
  `apps/${id}/package.json`,
108
126
  'dependencies',
127
+ false,
109
128
  ]),
110
129
  ];
111
130
  const changes = [];
112
- for (const [file, section] of entries) {
131
+ for (const [file, section, isRoot] of entries) {
113
132
  const path = join(root, file);
114
133
  const original = readFileSync(path, 'utf8');
115
134
  const manifest = JSON.parse(original);
116
- for (const name of SDK) {
135
+ const names = isRoot ? SDK : appSdk(manifest[section]);
136
+ for (const name of names) {
117
137
  const current = manifest[section]?.[name];
138
+ if (name === UI_KIT && current === undefined) continue;
118
139
  if (!EXACT_VERSION.test(current ?? ''))
119
140
  throw new Error(
120
141
  `${file}: ${section}.${name} must already be an exact semver`,
@@ -124,8 +145,9 @@ export function planUpdate(
124
145
  `${file}: downgrade from ${current} to ${version} requires --allow-downgrade`,
125
146
  );
126
147
  }
127
- if (SDK.every((name) => manifest[section][name] === version)) continue;
128
- for (const name of SDK) manifest[section][name] = version;
148
+ if (names.every((name) => manifest[section]?.[name] === version)) continue;
149
+ for (const name of names) manifest[section][name] = version;
150
+ manifest[section] = sortedKeys(manifest[section]);
129
151
  changes.push({
130
152
  path,
131
153
  original,
@@ -136,7 +158,7 @@ export function planUpdate(
136
158
  }
137
159
 
138
160
 
139
- /** Whether node_modules has both SDK packages at `version` and the reference. */
161
+ /** Whether node_modules has the SDK packages at `version` and the reference. */
140
162
  export function installedStateComplete(root, version) {
141
163
  try {
142
164
  return (
@@ -187,7 +209,7 @@ export function applyUpdate(
187
209
  run(INSTALL);
188
210
  if (!installed())
189
211
  throw new Error(
190
- `node_modules does not have both SDK packages at ${plan.version} with ${REFERENCE}; fix the cause and run the same command again`,
212
+ `node_modules does not have ${SDK.join(', ')} at ${plan.version} with ${REFERENCE}; fix the cause and run the same command again`,
191
213
  );
192
214
  run(['run', 'check']);
193
215
  rmSync(pending);
@@ -50,20 +50,23 @@ updates:
50
50
  - dependency-name: '@types/node'
51
51
  update-types:
52
52
  - 'version-update:semver-major'
53
- # A dependency joins the first group it matches. Both SDK packages move
54
- # together in one PR; the tooling has its own, since it brings a starter
55
- # migration (update skill). Security updates move only affected packages:
56
- # their SDK group joins advisories for both, and a PR that moves one is
57
- # completed with sdk-update (Update the SDK in the app workflow).
53
+ # A dependency joins the first group it matches. The SDK packages and the
54
+ # UI kit move together in one PR; the tooling has its own, since it brings
55
+ # a starter migration (update skill). Security updates move only affected
56
+ # packages: their SDK group joins advisories for several, and a PR that
57
+ # moves only some is completed with sdk-update (Update the SDK in the app
58
+ # workflow).
58
59
  groups:
59
60
  sempods-sdk:
60
61
  patterns:
61
62
  - '@sempods/app-sdk'
63
+ - '@sempods/app-ui'
62
64
  - '@sempods/client-sdk'
63
65
  sempods-sdk-security:
64
66
  applies-to: 'security-updates'
65
67
  patterns:
66
68
  - '@sempods/app-sdk'
69
+ - '@sempods/app-ui'
67
70
  - '@sempods/client-sdk'
68
71
  sempods-apps:
69
72
  patterns:
@@ -14,13 +14,15 @@
14
14
  "configure-site": "sempods-apps configure-site",
15
15
  "build-site": "sempods-apps build-site",
16
16
  "check": "sempods-apps check",
17
+ "check:theme": "sempods-apps check-theme",
17
18
  "sdk-update": "sempods-apps sdk-update",
18
19
  "update": "sempods-apps update",
19
20
  "migrate": "sempods-apps migrate"
20
21
  },
21
22
  "devDependencies": {
22
- "@sempods/app-sdk": "0.6.0",
23
+ "@sempods/app-sdk": "0.7.0-dev.2",
24
+ "@sempods/app-ui": "0.7.0-dev.2",
23
25
  "@sempods/apps": "0.0.0",
24
- "@sempods/client-sdk": "0.6.0"
26
+ "@sempods/client-sdk": "0.7.0-dev.2"
25
27
  }
26
28
  }
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "format": 1,
3
- "revision": "b39743fd5d824bb8bd37eaa9298b23ffe281dfd41799d652b5295f96191fdc02",
3
+ "revision": "e49f59f05c9dbe752680cd6986a428b544398e8bef7fe3d555111f0054cb46a5",
4
4
  "files": {
5
5
  ".agents/skills/app-workflow/SKILL.md": "0221b70340152ba3cc615342254603b29b89c4d7d944d7b3b3e647873bdcefa0",
6
6
  ".agents/skills/update/SKILL.md": "d3ca3a13c6524a2ee9382977ddc0539790813225f366306024a58c957a4716ee",
7
7
  ".claude/skills/app-workflow/SKILL.md": "0221b70340152ba3cc615342254603b29b89c4d7d944d7b3b3e647873bdcefa0",
8
8
  ".claude/skills/update/SKILL.md": "d3ca3a13c6524a2ee9382977ddc0539790813225f366306024a58c957a4716ee",
9
9
  ".github/codeql/codeql-config.yml": "528d7544990228d6d66fa62423775338fe1716326fc68371fb2665aee86e39b0",
10
- ".github/dependabot.yml": "e13474647ebaf26d042d76f4917203d209a2b14c0d2af8d12139ea328530823e",
10
+ ".github/dependabot.yml": "3b75b42e15713ac93916d9c2978c2826dfedef18ed5255d444482484bc4b3334",
11
11
  ".github/workflows/check.yml": "8b7567319e18694aeca8722107281a760e3100239c6d0f548acb71e1806fa172",
12
12
  ".github/workflows/codeql.yml": "7a7b7c820db3a2946fd2b3148972ada8073bcc5752f03793ea2069f5b7a3f260",
13
13
  ".gitignore": "c2a4b026a87f6c8e7473e7fc456c6020b63c7e675087f6b58013ed5163c2f6ee",
@@ -19,7 +19,7 @@
19
19
  "apps.json": "9945f9bd573e5af8aad2e65cc8b4284fbb1d7e8b96c4efdd42eeb49132fb4327",
20
20
  "apps/.gitkeep": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
21
21
  "docs/start.md": "e1c798ec8536cf8cff6c19caf622fa9076ae210b0d1733a6e82ea2c1f4104c43",
22
- "package.json": "843c078e84912158607ebb8ce0042589d676a742bcd3cf8b6c474d62ff3a6845",
22
+ "package.json": "5f709dbc1d0ab5c918a1ff06c32342a3f95569b1570793bfd3a03642bb6a7e43",
23
23
  "pnpm-workspace.yaml": "4660ca1887261ae658c7f587190e2b71ac91d15b1b6c4cf04dec823fdd0da654"
24
24
  }
25
25
  }
@@ -11,15 +11,18 @@
11
11
  "preview": "vite preview"
12
12
  },
13
13
  "dependencies": {
14
+ "lucide-react": "1.65.0",
14
15
  "react": "19.3.1",
15
16
  "react-dom": "19.3.1"
16
17
  },
17
18
  "devDependencies": {
19
+ "@tailwindcss/vite": "4.3.3",
18
20
  "@types/node": "24.19.2",
19
21
  "@types/react": "19.3.0",
20
22
  "@types/react-dom": "19.3.0",
21
23
  "@vitejs/plugin-react": "6.1.2",
22
24
  "oxlint": "1.89.0",
25
+ "tailwindcss": "4.3.3",
23
26
  "typescript": "7.0.2",
24
27
  "vite": "8.3.5",
25
28
  "vite-plugin-pwa": "2.0.0",
@@ -1,4 +1,4 @@
1
- import { useRef, useState } from 'react';
1
+ import { useState } from 'react';
2
2
  import { createBrowserRuntime } from '@sempods/app-sdk';
3
3
  import {
4
4
  AccessNotice,
@@ -9,16 +9,25 @@ import {
9
9
  useMessages,
10
10
  useSdkLocale,
11
11
  } from '@sempods/app-sdk/react';
12
+ import {
13
+ ActionButton,
14
+ HeaderMenu,
15
+ ScreenDialog,
16
+ replaceUrl,
17
+ useNav,
18
+ } from '@sempods/app-ui';
12
19
  import { NewVersionNotice } from './NewVersionNotice.tsx';
13
- import { replaceUrl } from './routes.ts';
20
+ import { inApp, places } from './places.ts';
14
21
  import { app, runtimeOptions } from './sempods.generated.ts';
15
22
  import { texts, uiLocale } from './texts.ts';
16
- import { MenuIcon } from './ui/icons.tsx';
17
- import { Menu } from './ui/Menu.tsx';
18
23
 
19
24
  // 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 });
25
+ // after a sign-in goes through the router, which then shows that address,
26
+ // always one in the app.
27
+ const runtime = createBrowserRuntime({
28
+ ...runtimeOptions,
29
+ replaceUrl: (url) => replaceUrl(inApp(url)),
30
+ });
22
31
 
23
32
  // One UI language for the app's texts, the SDK components and the page.
24
33
  const { locale, language } = uiLocale(app.language, navigator.languages);
@@ -28,7 +37,7 @@ document.documentElement.lang = language;
28
37
  if (import.meta.hot) import.meta.hot.dispose(() => runtime.dispose());
29
38
 
30
39
  // The app icon (public/favicon.svg), decorative next to the app's name.
31
- const icon = <img src={`${app.basePath}favicon.svg`} alt="" />;
40
+ const icon = <img src={`${app.basePath}favicon.svg`} alt="" className="size-7" />;
32
41
 
33
42
  export default function App({
34
43
  registration,
@@ -46,29 +55,34 @@ export default function App({
46
55
  // The app owns its layout: a header with the app's icon and name, its icon
47
56
  // actions and a menu; AppAccess beside the content for sign-in and recovery,
48
57
  // 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.
58
+ // switches between contexts (lists) puts src/examples/ContextSwitch.tsx in
59
+ // place of the h1.
51
60
  function Frame() {
61
+ // The shown place of the app's address model (places.ts). Call it once,
62
+ // here, and pass `nav` to the screens that link or switch.
63
+ const nav = useNav(places);
52
64
  const [managing, setManaging] = useState(false);
53
- const menuButton = useRef<HTMLButtonElement>(null);
54
- const { connections } = useAppState();
65
+ // `pod` is the active signed-in Pod; a saved connection that needs a new
66
+ // sign-in has none.
67
+ const { connections, pod } = useAppState();
55
68
  const { messages } = useSdkLocale();
56
69
  const t = useMessages(texts);
57
70
  const header = connections.length > 0;
71
+ const { place } = nav;
72
+ const toStart = () => nav.go({ kind: 'start' }, { replace: true });
58
73
  return (
59
- <main>
74
+ <main className="mx-auto max-w-3xl p-4">
60
75
  {header && (
61
- <header>
62
- <h1>
76
+ <header className="flex flex-wrap items-center justify-between gap-2">
77
+ <h1 className="flex items-center gap-2 text-2xl font-semibold">
63
78
  {icon}
64
79
  {app.title}
65
80
  </h1>
66
- <div className="header-actions">
67
- {/* The app's own icon actions go here. */}
68
- <Menu
81
+ <div className="flex items-center gap-2">
82
+ {/* The app's own icon actions go here, as
83
+ <ActionButton variant="ghost" shape="round-sm" label="…">. */}
84
+ <HeaderMenu
69
85
  label={t.menu}
70
- trigger={<MenuIcon />}
71
- button={menuButton}
72
86
  items={[
73
87
  {
74
88
  label: messages.controls.dataAccess,
@@ -88,12 +102,30 @@ function Frame() {
88
102
  headingLevel={header ? 2 : 1}
89
103
  open={managing}
90
104
  onClose={() => setManaging(false)}
91
- focusTarget={menuButton}
92
105
  />
93
106
  <AccessNotice only="write" />
94
107
  <TargetScreen>
95
108
  <Start />
96
109
  </TargetScreen>
110
+ {/* An explicit page once the person is signed in; a modal before that
111
+ would cover the sign-in, and closing it would lose the link. */}
112
+ {place.kind === 'not-found' && pod && (
113
+ <ScreenDialog
114
+ title={t.notFound}
115
+ open
116
+ onRequestClose={toStart}
117
+ >
118
+ <div className="grid gap-4">
119
+ <p>{place.reason === 'invalid' ? t.invalidLink : t.notFoundText}</p>
120
+ <ActionButton
121
+ shape="pill"
122
+ onPress={toStart}
123
+ >
124
+ {t.toStart}
125
+ </ActionButton>
126
+ </div>
127
+ </ScreenDialog>
128
+ )}
97
129
  </main>
98
130
  );
99
131
  }
@@ -101,5 +133,5 @@ function Frame() {
101
133
  // Replace this screen with your app. It renders once a Pod context is chosen.
102
134
  function Start() {
103
135
  const t = useMessages(texts);
104
- return <p>{t.start}</p>;
136
+ return <p className="mt-6">{t.start}</p>;
105
137
  }
@@ -0,0 +1,131 @@
1
+ // An example, not mounted: the everyday switch for an app that works in one
2
+ // of several contexts at a time (for example lists), built with the UI kit's
3
+ // menu parts. Copy it next to App.tsx and render it in place of the header's
4
+ // <h1>, with the app's icon and title.
5
+ import type { ReactNode } from 'react';
6
+ import { useContexts, useSdkLocale } from '@sempods/app-sdk/react';
7
+ import {
8
+ Button,
9
+ DropdownMenu,
10
+ DropdownMenuGroup,
11
+ DropdownMenuItem,
12
+ DropdownMenuSeparator,
13
+ DropdownMenuTrigger,
14
+ } from '@sempods/app-ui';
15
+ import { ChevronDownIcon } from 'lucide-react';
16
+
17
+ /**
18
+ * The app icon and the current context's name in place of the header's
19
+ * `<h1>`, opening a menu with the readable contexts, their access and a way
20
+ * to share more. The SDK's `useContexts` loads the contexts, selects a
21
+ * sensible one and switches under the leave policy; this component only shows
22
+ * them. Until a context is selected it shows the app's icon and `title` as the
23
+ * `<h1>`; then the `<h1>` names the app for screen readers only, outside the
24
+ * menu. Name things in the person's words: `label` "List", `shareMore`
25
+ * "Share more lists …".
26
+ */
27
+ export function ContextSwitch({
28
+ icon,
29
+ title,
30
+ label,
31
+ texts,
32
+ }: {
33
+ readonly icon: ReactNode;
34
+ /** The app's name. */
35
+ readonly title: string;
36
+ /** What a context is in this app, for example "List". */
37
+ readonly label: string;
38
+ readonly texts: { readonly shareMore: string };
39
+ }) {
40
+ const {
41
+ contexts,
42
+ selected,
43
+ select,
44
+ failed,
45
+ retry,
46
+ canShare,
47
+ share,
48
+ shareProblem,
49
+ } = useContexts();
50
+ const { messages } = useSdkLocale();
51
+ if (!selected)
52
+ return (
53
+ <h1 className="flex items-center gap-2 text-2xl font-semibold">
54
+ {icon}
55
+ {title}
56
+ </h1>
57
+ );
58
+ return (
59
+ <div className="grid min-w-0 gap-1">
60
+ <h1 className="sr-only">{title}</h1>
61
+ <DropdownMenuTrigger>
62
+ <Button
63
+ variant="ghost"
64
+ className="h-11 max-w-full justify-start gap-2 px-2 text-xl font-semibold"
65
+ aria-label={`${label}: ${selected.name}`}
66
+ >
67
+ {icon}
68
+ <span className="truncate">{selected.name}</span>
69
+ <ChevronDownIcon />
70
+ </Button>
71
+ <DropdownMenu placement="bottom start" className="min-w-56">
72
+ {/* The contexts as checked choices; the actions below are not. */}
73
+ <DropdownMenuGroup
74
+ aria-label={label}
75
+ selectionMode="single"
76
+ selectedKeys={[selected.iri]}
77
+ >
78
+ {contexts.map((context) => (
79
+ <DropdownMenuItem
80
+ key={context.iri}
81
+ id={context.iri}
82
+ className="min-h-11"
83
+ textValue={context.name}
84
+ onAction={() => {
85
+ if (context.iri !== selected.iri) void select(context.iri);
86
+ }}
87
+ >
88
+ <span className="grid">
89
+ {context.name}
90
+ {/* The SDK's own words, as in its management dialog. */}
91
+ <span className="text-sm text-muted-foreground">
92
+ {context.writable
93
+ ? messages.controls.canChange
94
+ : messages.controls.canRead}
95
+ </span>
96
+ </span>
97
+ </DropdownMenuItem>
98
+ ))}
99
+ </DropdownMenuGroup>
100
+ {(failed || canShare) && <DropdownMenuSeparator />}
101
+ {/* A failed reload keeps the known contexts and offers to try
102
+ again. */}
103
+ {failed && (
104
+ <DropdownMenuItem
105
+ id="retry"
106
+ className="min-h-11"
107
+ onAction={() => void retry()}
108
+ >
109
+ {messages.controls.retry}
110
+ </DropdownMenuItem>
111
+ )}
112
+ {canShare && (
113
+ <DropdownMenuItem
114
+ id="share"
115
+ className="min-h-11"
116
+ onAction={() => void share()}
117
+ >
118
+ {texts.shareMore}
119
+ </DropdownMenuItem>
120
+ )}
121
+ </DropdownMenu>
122
+ </DropdownMenuTrigger>
123
+ {/* A failed request to share says why below the switch. */}
124
+ {shareProblem && (
125
+ <p role="alert" className="text-sm text-destructive">
126
+ {shareProblem}
127
+ </p>
128
+ )}
129
+ </div>
130
+ );
131
+ }