@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.
- package/CHANGELOG.md +190 -0
- package/README.md +6 -3
- package/bin/sempods-apps.mjs +1 -0
- package/instructions/app-workflow.md +254 -111
- package/instructions/publish.md +13 -7
- package/instructions/setup.md +33 -23
- package/instructions/visual-check.md +43 -0
- package/package.json +3 -3
- package/scripts/build-site.mjs +15 -3
- package/scripts/check.mjs +3 -1
- package/scripts/icon.mjs +47 -0
- package/scripts/lib/apps.mjs +56 -2
- package/scripts/lib/generate.mjs +55 -14
- package/scripts/lib/icon.mjs +192 -0
- package/scripts/lib/overview.mjs +5 -3
- package/scripts/lib/page.mjs +89 -0
- package/scripts/lib/themes.mjs +58 -0
- package/scripts/migrate.mjs +37 -14
- package/scripts/new-app.mjs +43 -10
- package/scripts/sdk-update.mjs +16 -1
- package/shared/files/{f2fb601d5ebc4ca72274ac9d3fcf7b7a96e27958bb92cd065a9165b501ee94d7 → 44c114d4d2c8b172f4757100f79e8a1a00d30c96ba2ad8f75c3373e52f6b849f} +2 -2
- package/shared/files/{31ede0d1a035d8531100ebfd751510c772ab0871309a0b9752a77fb96187eb53 → 843c078e84912158607ebb8ce0042589d676a742bcd3cf8b6c474d62ff3a6845} +3 -2
- package/shared/files/{1c29eed0a0297c742ca5814de9081ff8efce3b2da5d9e1bdf90a403873da4aa9 → f30ec6b7f9de16be54919382ab18cdcc33ee64938f7b907d855bcf6740e4c102} +8 -2
- package/shared/snapshot.json +4 -4
- package/skeleton/app/NOTES.md +11 -1
- package/skeleton/app/index.html +7 -3
- package/skeleton/app/package.json +6 -6
- package/skeleton/app/src/App.tsx +53 -21
- package/skeleton/app/src/NewVersionNotice.tsx +6 -11
- package/skeleton/app/src/examples/ListScreen.tsx +167 -0
- package/skeleton/app/src/index.css +86 -13
- package/skeleton/app/src/main.tsx +3 -0
- package/skeleton/app/src/pwa.ts +1 -1
- package/skeleton/app/src/routes.test.ts +19 -0
- package/skeleton/app/src/routes.ts +139 -0
- package/skeleton/app/src/texts.test.ts +28 -0
- package/skeleton/app/src/texts.ts +35 -0
- package/skeleton/app/src/themes/calm.css +11 -0
- package/skeleton/app/src/themes/fresh.css +11 -0
- package/skeleton/app/src/themes/playful.css +11 -0
- package/skeleton/app/src/themes/warm.css +11 -0
- package/skeleton/app/src/ui/Composer.tsx +18 -0
- package/skeleton/app/src/ui/ContextSwitch.tsx +85 -0
- package/skeleton/app/src/ui/Dialog.tsx +103 -0
- package/skeleton/app/src/ui/Menu.tsx +118 -0
- package/skeleton/app/src/ui/Tabs.tsx +39 -0
- package/skeleton/app/src/ui/dismiss.ts +30 -0
- package/skeleton/app/src/ui/icons.tsx +53 -0
- package/skeleton/app/src/ui/ui.css +240 -0
- package/skeleton/app/vite.config.ts +4 -4
- package/skills/update/SKILL.md +4 -2
- package/skeleton/app/public/favicon.svg +0 -1
- package/skeleton/app/public/icon-192.png +0 -0
- package/skeleton/app/public/icon-512.png +0 -0
- package/skeleton/app/src/start.test.ts +0 -15
- package/skeleton/app/src/start.ts +0 -9
|
@@ -0,0 +1,85 @@
|
|
|
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 title, 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. Shows `fallback` (the app's icon and name)
|
|
13
|
+
* until a context is selected. Name things in the person's words: `label`
|
|
14
|
+
* "List", `shareMore` "Share more lists …".
|
|
15
|
+
*/
|
|
16
|
+
export function ContextSwitch({
|
|
17
|
+
icon,
|
|
18
|
+
label,
|
|
19
|
+
texts,
|
|
20
|
+
fallback,
|
|
21
|
+
}: {
|
|
22
|
+
readonly icon: ReactNode;
|
|
23
|
+
readonly fallback: ReactNode;
|
|
24
|
+
/** What a context is in this app, for example "List". */
|
|
25
|
+
readonly label: string;
|
|
26
|
+
readonly texts: { readonly shareMore: string };
|
|
27
|
+
}) {
|
|
28
|
+
const {
|
|
29
|
+
contexts,
|
|
30
|
+
selected,
|
|
31
|
+
select,
|
|
32
|
+
failed,
|
|
33
|
+
retry,
|
|
34
|
+
canShare,
|
|
35
|
+
share,
|
|
36
|
+
shareProblem,
|
|
37
|
+
} = useContexts();
|
|
38
|
+
const { messages } = useSdkLocale();
|
|
39
|
+
if (!selected) return fallback;
|
|
40
|
+
// A failed reload keeps the known contexts and offers to try again; a
|
|
41
|
+
// failed request to share says why below the switch.
|
|
42
|
+
const actions = [
|
|
43
|
+
...(failed
|
|
44
|
+
? [{ label: messages.controls.retry, onSelect: () => void retry() }]
|
|
45
|
+
: []),
|
|
46
|
+
...(canShare
|
|
47
|
+
? [{ label: texts.shareMore, onSelect: () => void share() }]
|
|
48
|
+
: []),
|
|
49
|
+
];
|
|
50
|
+
return (
|
|
51
|
+
<div className="context-switch-wrap">
|
|
52
|
+
<Menu
|
|
53
|
+
className="context-switch"
|
|
54
|
+
align="start"
|
|
55
|
+
label={`${label}: ${selected.name}`}
|
|
56
|
+
trigger={
|
|
57
|
+
<>
|
|
58
|
+
{icon}
|
|
59
|
+
<span className="context-name">{selected.name}</span>
|
|
60
|
+
<ChevronIcon />
|
|
61
|
+
</>
|
|
62
|
+
}
|
|
63
|
+
items={[
|
|
64
|
+
...contexts.map((context) => ({
|
|
65
|
+
label: context.name,
|
|
66
|
+
// The SDK's own words, as in its management dialog.
|
|
67
|
+
hint: context.writable
|
|
68
|
+
? messages.controls.canChange
|
|
69
|
+
: messages.controls.canRead,
|
|
70
|
+
checked: context.iri === selected.iri,
|
|
71
|
+
onSelect: () => {
|
|
72
|
+
if (context.iri !== selected.iri) void select(context.iri);
|
|
73
|
+
},
|
|
74
|
+
})),
|
|
75
|
+
...(actions.length > 0 ? [null, ...actions] : []),
|
|
76
|
+
]}
|
|
77
|
+
/>
|
|
78
|
+
{shareProblem && (
|
|
79
|
+
<p role="alert" className="context-switch-problem">
|
|
80
|
+
{shareProblem}
|
|
81
|
+
</p>
|
|
82
|
+
)}
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useEffect,
|
|
3
|
+
useRef,
|
|
4
|
+
useState,
|
|
5
|
+
type MouseEvent,
|
|
6
|
+
type ReactNode,
|
|
7
|
+
} from 'react';
|
|
8
|
+
import { CloseIcon } from './icons.tsx';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A modal dialog, a bottom sheet on phones, for example around an item's
|
|
12
|
+
* `ResourceEditor`. Escape, the × button and a press beside the dialog (on
|
|
13
|
+
* its backdrop) ask the owner to close it; it
|
|
14
|
+
* only goes away when the owner unmounts it, so a declined close (unsaved
|
|
15
|
+
* changes) keeps it open. The SDK's leave confirmation shows above it.
|
|
16
|
+
* "Pods & sharing" needs none: `AppAccess` with `onClose` brings its own.
|
|
17
|
+
*/
|
|
18
|
+
export function Dialog({
|
|
19
|
+
label,
|
|
20
|
+
closeLabel,
|
|
21
|
+
onClose,
|
|
22
|
+
children,
|
|
23
|
+
}: {
|
|
24
|
+
readonly label: string;
|
|
25
|
+
readonly closeLabel: string;
|
|
26
|
+
/** Asked to close; unmount the dialog to close it, keep it to decline. */
|
|
27
|
+
readonly onClose: () => void | Promise<void>;
|
|
28
|
+
readonly children: ReactNode;
|
|
29
|
+
}) {
|
|
30
|
+
const dialog = useRef<HTMLDialogElement>(null);
|
|
31
|
+
// What had focus before the dialog opened, for example the item it shows.
|
|
32
|
+
const [opener] = useState(() => document.activeElement);
|
|
33
|
+
// Open as a modal once mounted; unmounting removes it. (Closing it in an
|
|
34
|
+
// effect cleanup would fire `close` under React's StrictMode re-run.)
|
|
35
|
+
// Removing an open dialog does not restore focus as close() would, so the
|
|
36
|
+
// cleanup returns it to the opener unless it has moved on elsewhere.
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
const element = dialog.current;
|
|
39
|
+
if (element && !element.open) element.showModal();
|
|
40
|
+
return () => {
|
|
41
|
+
const focused = document.activeElement;
|
|
42
|
+
if (
|
|
43
|
+
opener instanceof HTMLElement &&
|
|
44
|
+
opener.isConnected &&
|
|
45
|
+
(!focused || focused === document.body || element?.contains(focused))
|
|
46
|
+
)
|
|
47
|
+
opener.focus();
|
|
48
|
+
};
|
|
49
|
+
}, [opener]);
|
|
50
|
+
// A press on the backdrop targets the dialog element outside its box. Both
|
|
51
|
+
// ends of the press count, so selecting text inside and releasing beside it
|
|
52
|
+
// does not close.
|
|
53
|
+
const beside = (event: MouseEvent) => {
|
|
54
|
+
const box = dialog.current?.getBoundingClientRect();
|
|
55
|
+
return (
|
|
56
|
+
event.target === dialog.current &&
|
|
57
|
+
!!box &&
|
|
58
|
+
(event.clientX < box.left ||
|
|
59
|
+
event.clientX > box.right ||
|
|
60
|
+
event.clientY < box.top ||
|
|
61
|
+
event.clientY > box.bottom)
|
|
62
|
+
);
|
|
63
|
+
};
|
|
64
|
+
const pressedBeside = useRef(false);
|
|
65
|
+
// Ask the owner; if it keeps the dialog, make sure it is open again.
|
|
66
|
+
const request = async () => {
|
|
67
|
+
await onClose();
|
|
68
|
+
const element = dialog.current;
|
|
69
|
+
if (element && !element.open) element.showModal();
|
|
70
|
+
};
|
|
71
|
+
return (
|
|
72
|
+
<dialog
|
|
73
|
+
ref={dialog}
|
|
74
|
+
className="dialog"
|
|
75
|
+
aria-label={label}
|
|
76
|
+
onCancel={(event) => {
|
|
77
|
+
// Escape: the browser would close the dialog before the owner decides.
|
|
78
|
+
event.preventDefault();
|
|
79
|
+
void request();
|
|
80
|
+
}}
|
|
81
|
+
// Browsers may still close it (a repeated Escape without a user
|
|
82
|
+
// activation); then the owner decides as well.
|
|
83
|
+
onClose={() => void request()}
|
|
84
|
+
onPointerDown={(event) => {
|
|
85
|
+
pressedBeside.current = beside(event);
|
|
86
|
+
}}
|
|
87
|
+
onClick={(event) => {
|
|
88
|
+
if (pressedBeside.current && beside(event)) void request();
|
|
89
|
+
pressedBeside.current = false;
|
|
90
|
+
}}
|
|
91
|
+
>
|
|
92
|
+
<button
|
|
93
|
+
className="dialog-close"
|
|
94
|
+
aria-label={closeLabel}
|
|
95
|
+
title={closeLabel}
|
|
96
|
+
onClick={() => void request()}
|
|
97
|
+
>
|
|
98
|
+
<CloseIcon />
|
|
99
|
+
</button>
|
|
100
|
+
<div className="dialog-body">{children}</div>
|
|
101
|
+
</dialog>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useCallback,
|
|
3
|
+
useEffect,
|
|
4
|
+
useRef,
|
|
5
|
+
useState,
|
|
6
|
+
type KeyboardEvent,
|
|
7
|
+
type ReactNode,
|
|
8
|
+
type RefObject,
|
|
9
|
+
} from 'react';
|
|
10
|
+
import { useDismiss } from './dismiss.ts';
|
|
11
|
+
|
|
12
|
+
export type MenuItem = {
|
|
13
|
+
readonly label: string;
|
|
14
|
+
/** A second, muted line, for example the access to a context. */
|
|
15
|
+
readonly hint?: string;
|
|
16
|
+
/** For a choice among several: marks the current one. */
|
|
17
|
+
readonly checked?: boolean;
|
|
18
|
+
readonly onSelect: () => void;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A button that opens a short list of actions or choices. It closes on a
|
|
23
|
+
* choice, Escape or a press outside; a choice and Escape return focus to the
|
|
24
|
+
* button.
|
|
25
|
+
* `null` in `items` draws a separator.
|
|
26
|
+
*/
|
|
27
|
+
export function Menu({
|
|
28
|
+
label,
|
|
29
|
+
trigger,
|
|
30
|
+
items,
|
|
31
|
+
align = 'end',
|
|
32
|
+
button,
|
|
33
|
+
className,
|
|
34
|
+
}: {
|
|
35
|
+
/** The button's name for screen readers; also its tooltip. */
|
|
36
|
+
readonly label: string;
|
|
37
|
+
/** The button's content, usually an icon. */
|
|
38
|
+
readonly trigger: ReactNode;
|
|
39
|
+
readonly items: readonly (MenuItem | null)[];
|
|
40
|
+
/** Which edge of the button the list lines up with. */
|
|
41
|
+
readonly align?: 'start' | 'end';
|
|
42
|
+
/** The button, for example to return focus to it later. */
|
|
43
|
+
readonly button?: RefObject<HTMLButtonElement | null>;
|
|
44
|
+
readonly className?: string;
|
|
45
|
+
}) {
|
|
46
|
+
const [open, setOpen] = useState(false);
|
|
47
|
+
const container = useRef<HTMLDivElement>(null);
|
|
48
|
+
const own = useRef<HTMLButtonElement>(null);
|
|
49
|
+
const ref = button ?? own;
|
|
50
|
+
const close = useCallback(() => setOpen(false), []);
|
|
51
|
+
useDismiss(open, close, container, ref);
|
|
52
|
+
const list = useRef<HTMLDivElement>(null);
|
|
53
|
+
const choice = items.some((item) => item?.checked !== undefined);
|
|
54
|
+
// Focus moves into the list on opening; the arrow keys move within it.
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (open) list.current?.querySelector('button')?.focus();
|
|
57
|
+
}, [open]);
|
|
58
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
59
|
+
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
|
|
60
|
+
event.preventDefault();
|
|
61
|
+
const entries = [...(list.current?.querySelectorAll('button') ?? [])];
|
|
62
|
+
const at = entries.indexOf(document.activeElement as HTMLButtonElement);
|
|
63
|
+
const step = event.key === 'ArrowDown' ? 1 : -1;
|
|
64
|
+
entries[(at + step + entries.length) % entries.length]?.focus();
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div className={`menu ${className ?? ''}`} ref={container}>
|
|
69
|
+
<button
|
|
70
|
+
ref={ref}
|
|
71
|
+
aria-label={label}
|
|
72
|
+
title={label}
|
|
73
|
+
aria-haspopup="menu"
|
|
74
|
+
aria-expanded={open}
|
|
75
|
+
onClick={() => setOpen(!open)}
|
|
76
|
+
>
|
|
77
|
+
{trigger}
|
|
78
|
+
</button>
|
|
79
|
+
{open && (
|
|
80
|
+
<div
|
|
81
|
+
role="menu"
|
|
82
|
+
ref={list}
|
|
83
|
+
className={`menu-items menu-${align}`}
|
|
84
|
+
onKeyDown={onKeyDown}
|
|
85
|
+
>
|
|
86
|
+
{items.map((item, i) =>
|
|
87
|
+
item ? (
|
|
88
|
+
<button
|
|
89
|
+
key={i}
|
|
90
|
+
role={item.checked === undefined ? 'menuitem' : 'menuitemradio'}
|
|
91
|
+
aria-checked={item.checked}
|
|
92
|
+
onClick={() => {
|
|
93
|
+
close();
|
|
94
|
+
// Focus goes back to the button before the action, which
|
|
95
|
+
// may move it on, for example into a dialog it opens.
|
|
96
|
+
ref.current?.focus();
|
|
97
|
+
item.onSelect();
|
|
98
|
+
}}
|
|
99
|
+
>
|
|
100
|
+
{choice && (
|
|
101
|
+
<span className="menu-check" aria-hidden="true">
|
|
102
|
+
{item.checked ? '✓' : ''}
|
|
103
|
+
</span>
|
|
104
|
+
)}
|
|
105
|
+
<span>
|
|
106
|
+
{item.label}
|
|
107
|
+
{item.hint && <small>{item.hint}</small>}
|
|
108
|
+
</span>
|
|
109
|
+
</button>
|
|
110
|
+
) : (
|
|
111
|
+
<hr key={i} />
|
|
112
|
+
),
|
|
113
|
+
)}
|
|
114
|
+
</div>
|
|
115
|
+
)}
|
|
116
|
+
</div>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { useRoute, Route } from '../routes.ts';
|
|
2
|
+
|
|
3
|
+
export type View = {
|
|
4
|
+
/** The route; undefined for the start page. */
|
|
5
|
+
readonly route: Route | undefined;
|
|
6
|
+
readonly label: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The app's views as tabs on one line: links with their own addresses, so
|
|
11
|
+
* they open in a new tab or get bookmarked; a plain click switches in place
|
|
12
|
+
* under the SDK's leave policy. Pass `route` and `link` from `useRoute()`.
|
|
13
|
+
*/
|
|
14
|
+
export function Tabs({
|
|
15
|
+
label,
|
|
16
|
+
views,
|
|
17
|
+
current,
|
|
18
|
+
link,
|
|
19
|
+
}: {
|
|
20
|
+
/** The name of the navigation for screen readers. */
|
|
21
|
+
readonly label: string;
|
|
22
|
+
readonly views: readonly View[];
|
|
23
|
+
readonly current: Route | undefined;
|
|
24
|
+
readonly link: ReturnType<typeof useRoute>['link'];
|
|
25
|
+
}) {
|
|
26
|
+
return (
|
|
27
|
+
<nav aria-label={label} className="tabs">
|
|
28
|
+
{views.map(({ route, label }) => (
|
|
29
|
+
<a
|
|
30
|
+
key={route ?? ''}
|
|
31
|
+
{...link(route)}
|
|
32
|
+
aria-current={route === current ? 'page' : undefined}
|
|
33
|
+
>
|
|
34
|
+
{label}
|
|
35
|
+
</a>
|
|
36
|
+
))}
|
|
37
|
+
</nav>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { useEffect, type RefObject } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Closes a popup on a pointer press outside `container` or on Escape, and
|
|
5
|
+
* returns focus to `button` after Escape.
|
|
6
|
+
*/
|
|
7
|
+
export function useDismiss(
|
|
8
|
+
open: boolean,
|
|
9
|
+
close: () => void,
|
|
10
|
+
container: RefObject<HTMLElement | null>,
|
|
11
|
+
button: RefObject<HTMLElement | null>,
|
|
12
|
+
) {
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
if (!open) return;
|
|
15
|
+
const onPointer = (event: PointerEvent) => {
|
|
16
|
+
if (!container.current?.contains(event.target as Node)) close();
|
|
17
|
+
};
|
|
18
|
+
const onKey = (event: KeyboardEvent) => {
|
|
19
|
+
if (event.key !== 'Escape') return;
|
|
20
|
+
close();
|
|
21
|
+
button.current?.focus();
|
|
22
|
+
};
|
|
23
|
+
document.addEventListener('pointerdown', onPointer);
|
|
24
|
+
document.addEventListener('keydown', onKey);
|
|
25
|
+
return () => {
|
|
26
|
+
document.removeEventListener('pointerdown', onPointer);
|
|
27
|
+
document.removeEventListener('keydown', onKey);
|
|
28
|
+
};
|
|
29
|
+
}, [open, close, container, button]);
|
|
30
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Line icons for icon buttons, drawn in the text colour. Each is decorative:
|
|
2
|
+
// the button carries the name (aria-label and title).
|
|
3
|
+
import type { ReactNode } from 'react';
|
|
4
|
+
|
|
5
|
+
function Icon({ size = 22, children }: { size?: number; children: ReactNode }) {
|
|
6
|
+
return (
|
|
7
|
+
<svg
|
|
8
|
+
viewBox="0 0 24 24"
|
|
9
|
+
width={size}
|
|
10
|
+
height={size}
|
|
11
|
+
aria-hidden="true"
|
|
12
|
+
fill="none"
|
|
13
|
+
stroke="currentColor"
|
|
14
|
+
strokeWidth="2"
|
|
15
|
+
strokeLinecap="round"
|
|
16
|
+
strokeLinejoin="round"
|
|
17
|
+
>
|
|
18
|
+
{children}
|
|
19
|
+
</svg>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const MenuIcon = () => (
|
|
24
|
+
<Icon>
|
|
25
|
+
<circle cx="12" cy="5" r="1" />
|
|
26
|
+
<circle cx="12" cy="12" r="1" />
|
|
27
|
+
<circle cx="12" cy="19" r="1" />
|
|
28
|
+
</Icon>
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
export const ReloadIcon = () => (
|
|
32
|
+
<Icon>
|
|
33
|
+
<path d="M20 12a8 8 0 1 1-2.34-5.66M20 4v5h-5" />
|
|
34
|
+
</Icon>
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
export const AddIcon = () => (
|
|
38
|
+
<Icon>
|
|
39
|
+
<path d="M12 5v14M5 12h14" />
|
|
40
|
+
</Icon>
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
export const CloseIcon = () => (
|
|
44
|
+
<Icon size={20}>
|
|
45
|
+
<path d="M6 6l12 12M18 6L6 18" />
|
|
46
|
+
</Icon>
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
export const ChevronIcon = () => (
|
|
50
|
+
<Icon size={16}>
|
|
51
|
+
<path d="M6 9l6 6 6-6" />
|
|
52
|
+
</Icon>
|
|
53
|
+
);
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/* The small UI kit: header, menu, context switch, tabs, bottom bar and
|
|
2
|
+
dialog, on the --sempods-* tokens of index.css. Calm and flat: one line per
|
|
3
|
+
edge, no shadows. */
|
|
4
|
+
|
|
5
|
+
/* Header: the app (or the context switch) at the start, icon actions and the
|
|
6
|
+
menu at the end, on one line at every width. A long name shrinks and wraps
|
|
7
|
+
inside the title, so the menu stays on screen. */
|
|
8
|
+
main > header {
|
|
9
|
+
flex-wrap: nowrap;
|
|
10
|
+
}
|
|
11
|
+
main > header h1 {
|
|
12
|
+
min-width: 0;
|
|
13
|
+
overflow-wrap: anywhere;
|
|
14
|
+
}
|
|
15
|
+
.header-actions {
|
|
16
|
+
display: flex;
|
|
17
|
+
align-items: center;
|
|
18
|
+
flex: none;
|
|
19
|
+
}
|
|
20
|
+
.header-actions button {
|
|
21
|
+
padding: 10px;
|
|
22
|
+
color: var(--sempods-text);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/* Keeps a heading for screen readers without showing it. */
|
|
26
|
+
.visually-hidden {
|
|
27
|
+
position: absolute;
|
|
28
|
+
width: 1px;
|
|
29
|
+
height: 1px;
|
|
30
|
+
overflow: hidden;
|
|
31
|
+
clip-path: inset(50%);
|
|
32
|
+
white-space: nowrap;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/* Menu: a button and a short list below it. */
|
|
36
|
+
.menu {
|
|
37
|
+
position: relative;
|
|
38
|
+
min-width: 0;
|
|
39
|
+
}
|
|
40
|
+
.menu-items {
|
|
41
|
+
position: absolute;
|
|
42
|
+
top: 100%;
|
|
43
|
+
z-index: 10;
|
|
44
|
+
display: grid;
|
|
45
|
+
min-width: 14rem;
|
|
46
|
+
max-width: calc(100vw - 32px);
|
|
47
|
+
padding: 4px;
|
|
48
|
+
border: 1px solid var(--sempods-line);
|
|
49
|
+
border-radius: 8px;
|
|
50
|
+
background: var(--sempods-bg);
|
|
51
|
+
}
|
|
52
|
+
.menu-end {
|
|
53
|
+
right: 0;
|
|
54
|
+
}
|
|
55
|
+
.menu-start {
|
|
56
|
+
left: 0;
|
|
57
|
+
}
|
|
58
|
+
.menu-items button {
|
|
59
|
+
display: flex;
|
|
60
|
+
align-items: flex-start;
|
|
61
|
+
gap: 8px;
|
|
62
|
+
text-align: start;
|
|
63
|
+
color: var(--sempods-text);
|
|
64
|
+
font-weight: 400;
|
|
65
|
+
}
|
|
66
|
+
.menu-items small {
|
|
67
|
+
display: block;
|
|
68
|
+
color: var(--sempods-muted);
|
|
69
|
+
font-size: 0.8rem;
|
|
70
|
+
}
|
|
71
|
+
.menu-check {
|
|
72
|
+
flex: none;
|
|
73
|
+
width: 1rem;
|
|
74
|
+
}
|
|
75
|
+
.menu-items hr {
|
|
76
|
+
width: 100%;
|
|
77
|
+
margin: 4px 0;
|
|
78
|
+
border: 0;
|
|
79
|
+
border-top: 1px solid var(--sempods-line);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/* Context switch: app icon and the current context, as the header's title;
|
|
83
|
+
a failed request to share more shows below it. */
|
|
84
|
+
.context-switch-wrap {
|
|
85
|
+
min-width: 0;
|
|
86
|
+
}
|
|
87
|
+
.context-switch > button {
|
|
88
|
+
display: flex;
|
|
89
|
+
align-items: center;
|
|
90
|
+
gap: 8px;
|
|
91
|
+
max-width: 100%;
|
|
92
|
+
padding: 8px 8px 8px 0;
|
|
93
|
+
color: var(--sempods-text);
|
|
94
|
+
font-size: 1.15rem;
|
|
95
|
+
}
|
|
96
|
+
.context-switch > button img {
|
|
97
|
+
flex: none;
|
|
98
|
+
width: 28px;
|
|
99
|
+
height: 28px;
|
|
100
|
+
}
|
|
101
|
+
.context-name {
|
|
102
|
+
overflow: hidden;
|
|
103
|
+
text-overflow: ellipsis;
|
|
104
|
+
white-space: nowrap;
|
|
105
|
+
}
|
|
106
|
+
.context-switch-problem {
|
|
107
|
+
margin: 0;
|
|
108
|
+
color: var(--sempods-muted);
|
|
109
|
+
font-size: 0.875rem;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/* Lists of items: a row with its checkbox, its name (opens the item) and its
|
|
113
|
+
actions; the empty state is a quiet line. */
|
|
114
|
+
.row {
|
|
115
|
+
display: flex;
|
|
116
|
+
flex-wrap: wrap;
|
|
117
|
+
align-items: center;
|
|
118
|
+
gap: 4px;
|
|
119
|
+
border-bottom: 1px solid var(--sempods-line);
|
|
120
|
+
}
|
|
121
|
+
.row-name {
|
|
122
|
+
flex: 1;
|
|
123
|
+
min-width: 0;
|
|
124
|
+
text-align: start;
|
|
125
|
+
color: var(--sempods-text);
|
|
126
|
+
font-weight: 400;
|
|
127
|
+
}
|
|
128
|
+
/* A row's notice (a problem or an open outcome) takes its own line. */
|
|
129
|
+
.row > [data-sempods-ui='notice'] {
|
|
130
|
+
flex-basis: 100%;
|
|
131
|
+
}
|
|
132
|
+
.empty {
|
|
133
|
+
color: var(--sempods-muted);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/* Tabs: the views as links on one line; the current one is underlined on
|
|
137
|
+
the row's only line. */
|
|
138
|
+
.tabs {
|
|
139
|
+
display: flex;
|
|
140
|
+
gap: 1.25rem;
|
|
141
|
+
margin: 4px 0 8px;
|
|
142
|
+
border-bottom: 1px solid var(--sempods-line);
|
|
143
|
+
}
|
|
144
|
+
.tabs a {
|
|
145
|
+
margin-bottom: -1px;
|
|
146
|
+
padding: 10px 0;
|
|
147
|
+
border-bottom: 2px solid transparent;
|
|
148
|
+
color: var(--sempods-muted);
|
|
149
|
+
text-decoration: none;
|
|
150
|
+
white-space: nowrap;
|
|
151
|
+
}
|
|
152
|
+
.tabs a:hover {
|
|
153
|
+
color: var(--sempods-text);
|
|
154
|
+
}
|
|
155
|
+
.tabs a[aria-current='page'] {
|
|
156
|
+
border-bottom-color: var(--sempods-accent);
|
|
157
|
+
color: var(--sempods-text);
|
|
158
|
+
font-weight: 500;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/* Bottom bar: fixed in thumb reach, above the home indicator; the page
|
|
162
|
+
leaves room for it. */
|
|
163
|
+
.composer {
|
|
164
|
+
position: fixed;
|
|
165
|
+
right: 0;
|
|
166
|
+
bottom: 0;
|
|
167
|
+
left: 0;
|
|
168
|
+
z-index: 5;
|
|
169
|
+
padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
|
|
170
|
+
border-top: 1px solid var(--sempods-line);
|
|
171
|
+
background: var(--sempods-bg);
|
|
172
|
+
}
|
|
173
|
+
.composer-content {
|
|
174
|
+
display: flex;
|
|
175
|
+
align-items: center;
|
|
176
|
+
gap: 8px;
|
|
177
|
+
max-width: 728px;
|
|
178
|
+
margin-inline: auto;
|
|
179
|
+
}
|
|
180
|
+
.composer-content > form {
|
|
181
|
+
display: flex;
|
|
182
|
+
flex: 1;
|
|
183
|
+
align-items: center;
|
|
184
|
+
gap: 8px;
|
|
185
|
+
}
|
|
186
|
+
.composer-content input {
|
|
187
|
+
flex: 1;
|
|
188
|
+
min-width: 0;
|
|
189
|
+
border: 0;
|
|
190
|
+
background: transparent;
|
|
191
|
+
}
|
|
192
|
+
/* The bar's top line frames the input; the caret shows the focus. */
|
|
193
|
+
.composer-content input:focus-visible {
|
|
194
|
+
outline: none;
|
|
195
|
+
}
|
|
196
|
+
main:has(.composer) {
|
|
197
|
+
padding-bottom: calc(88px + env(safe-area-inset-bottom));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/* Dialog: centred, a bottom sheet on phones. */
|
|
201
|
+
.dialog {
|
|
202
|
+
width: min(28rem, calc(100vw - 2rem));
|
|
203
|
+
max-height: calc(100dvh - 2rem);
|
|
204
|
+
padding: 0;
|
|
205
|
+
border: 1px solid var(--sempods-line);
|
|
206
|
+
border-radius: 12px;
|
|
207
|
+
background: var(--sempods-bg);
|
|
208
|
+
color: var(--sempods-text);
|
|
209
|
+
}
|
|
210
|
+
.dialog::backdrop {
|
|
211
|
+
background: color-mix(in srgb, #000 35%, transparent);
|
|
212
|
+
}
|
|
213
|
+
.dialog-close {
|
|
214
|
+
float: right;
|
|
215
|
+
margin: 4px;
|
|
216
|
+
color: var(--sempods-muted);
|
|
217
|
+
}
|
|
218
|
+
.dialog-body {
|
|
219
|
+
clear: both;
|
|
220
|
+
padding: 0 20px 20px;
|
|
221
|
+
}
|
|
222
|
+
@media (max-width: 30rem) {
|
|
223
|
+
.dialog {
|
|
224
|
+
width: 100vw;
|
|
225
|
+
max-width: 100vw;
|
|
226
|
+
margin: auto 0 0;
|
|
227
|
+
border-radius: 12px 12px 0 0;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/* A primary action: filled, for the one main step of a dialog or form. */
|
|
232
|
+
button.primary {
|
|
233
|
+
width: 100%;
|
|
234
|
+
background: var(--sempods-accent);
|
|
235
|
+
color: var(--sempods-on-accent);
|
|
236
|
+
}
|
|
237
|
+
button.primary:hover:not(:disabled) {
|
|
238
|
+
background: var(--sempods-accent);
|
|
239
|
+
opacity: 0.9;
|
|
240
|
+
}
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import react from '@vitejs/plugin-react';
|
|
2
2
|
import { defineConfig } from 'vite';
|
|
3
3
|
import { VitePWA } from 'vite-plugin-pwa';
|
|
4
|
-
import { base, pwa, server } from './vite.sempods.generated.ts';
|
|
4
|
+
import { base, pageTitle, pwa, server } from './vite.sempods.generated.ts';
|
|
5
5
|
|
|
6
|
-
// Base path, development port and PWA settings come from apps.json
|
|
7
|
-
// generated file; add your own plugins and options here.
|
|
6
|
+
// 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
8
|
export default defineConfig({
|
|
9
9
|
base,
|
|
10
10
|
server,
|
|
11
11
|
preview: server,
|
|
12
|
-
plugins: [react(), ...(pwa ? [VitePWA(pwa)] : [])],
|
|
12
|
+
plugins: [react(), pageTitle, ...(pwa ? [VitePWA(pwa)] : [])],
|
|
13
13
|
});
|
package/skills/update/SKILL.md
CHANGED
|
@@ -72,5 +72,7 @@ instead.
|
|
|
72
72
|
request's branch, or open a pull request "Update the tooling to <version>",
|
|
73
73
|
with the migration report, each manual case and how it was decided, and
|
|
74
74
|
what to try. Tell the owner what changed in plain words and
|
|
75
|
-
what they should try. App
|
|
76
|
-
|
|
75
|
+
what they should try. App notes and the owner's entries in `package.json`
|
|
76
|
+
are never rewritten by the migration; of the apps' files it only
|
|
77
|
+
regenerates the generated configuration and moves files still unchanged
|
|
78
|
+
from the skeleton where a release's upgrade notes say so.
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="#176354"/></svg>
|
|
Binary file
|