@usableapp/cardds 0.7.34 → 0.7.36
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 +10 -0
- package/README.md +5 -0
- package/dist/scaffold/Screen.d.ts +10 -3
- package/dist/scaffold/Screen.js +6 -4
- package/dist/scaffold/slideScreen.d.ts +1 -0
- package/dist/scaffold/slideScreen.js +22 -6
- package/dist/templates/CallSheet.d.ts +1 -1
- package/dist/templates/CallSheet.js +9 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
> For: a project that consumes `@usableapp/cardds` and its AI — what changed between versions, written as before → after, so a screen can be brought up to date without reading the source. Newest first. A consumer never edits cardds; if something here leaves you without a way to do what you did before, report the gap.
|
|
4
4
|
|
|
5
|
+
## 0.7.36 — 2026-10-01
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- **`Screen ready` — one outcome, drawn once** (Lh 2026-10-01). A screen that can come out more than one way (signed in or not, a list or its empty state, a redirect) showed one outcome and then turned into another. Now: `<Screen ready={known}>` — `false` while the app is still finding out. Nothing of the screen is drawn (no top, content, bottom; `data-pending`, `aria-busy`), and when it is reached through `slideScreen` or the back button (`installScreenSlide`) the screen BEFORE stays until it is ready — at most 3 s — then the right one slides in. Default `true`: nothing changes until a screen sets it. Every template passes it through (`Home`, `CallSheet`, `Walkthrough`). To do in an app: every screen that decides after its first paint (a check in an effect, a redirect) gets `ready`; on the first load hold `Splash done` until the first screen is known.
|
|
9
|
+
|
|
10
|
+
## 0.7.35 — 2026-10-01
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **`CallSheet`: a sheet on its way out keeps its body until it has slid away** (Lh 2026-10-01). Before, the body was taken out the moment `open` turned null (or another way was called), so an empty sheet slid down. Nothing to change in a screen.
|
|
14
|
+
|
|
5
15
|
## 0.7.34 — 2026-10-01
|
|
6
16
|
|
|
7
17
|
### Changed
|
package/README.md
CHANGED
|
@@ -355,6 +355,11 @@ between at 40% down the screen (see *Anatomy*, "Where the centre is"):
|
|
|
355
355
|
one height, the round pager turns them (`slideTo()`), the words slide like a page. No sheet there.
|
|
356
356
|
ONE SCREEN TO THE NEXT (a route change) slides the WHOLE screen the same way: `slideScreen(() => navigate(to), 'next' | 'back')`,
|
|
357
357
|
and `installScreenSlide()` once at app start for the back button (story `Step · WalksOn`). `data-slide="off"` on a Screen = no slide.
|
|
358
|
+
ONE OUTCOME, DRAWN ONCE (Lh 2026-10-01): a screen that can come out more than one way (signed in or not, a list or
|
|
359
|
+
its empty state, a redirect) is NOT drawn until the app knows which — `<Screen ready={known}>`. Not ready = nothing of it
|
|
360
|
+
is drawn; arrived at through `slideScreen` / the back button, the screen before stays until it is ready (at most 3 s), then
|
|
361
|
+
the right one slides in. Never one outcome first and another after (story `Step · ArrivesReady`). On a first load there is
|
|
362
|
+
no screen before: hold the `Splash` (`done`) until the first screen is known.
|
|
358
363
|
- *Sheets the app calls:* the base offers WAYS IN — sign in · new phone · join — as buttons (`.cta-pack`); each
|
|
359
364
|
way has a sheet that holds its WHOLE form and its own submit on its foot, starting `.sheet--away` (not on the
|
|
360
365
|
screen: slid out, hidden once gone, `inert`). **The wiring is the app's, not cardds's**: a `useState` of which
|
|
@@ -11,15 +11,21 @@ export interface ScreenProps extends ComponentProps<'div'> {
|
|
|
11
11
|
/** BOTTOM: an `ActionBar` (or a `FloatBar`) — a LAYER over the content's foot, as tall as its rows need.
|
|
12
12
|
* Never with a `Sheet` in the content: a sheet finishes on its own foot (warns once). */
|
|
13
13
|
bottom?: ReactNode;
|
|
14
|
+
/** `false` while the app does not yet know WHICH screen this is (signed in or not, a member with or without a request…):
|
|
15
|
+
* nothing of it is drawn — no top, content or bottom — so a screen never shows one outcome and then turns into another.
|
|
16
|
+
* Arrived at through `slideScreen`, the screen before stays until this turns true (at most 3 s). Default true. */
|
|
17
|
+
ready?: boolean;
|
|
14
18
|
}
|
|
15
19
|
/**
|
|
16
20
|
* Screen — the mobile screen in THREE parts: **top** and **content** (the children) in one flex column, the content down to the
|
|
17
21
|
* screen's foot; **bottom** a LAYER over it there (Lh 2026-09-23). Top = `TopBar` / `AppBar`, or `top="lead"` (no bar, the words start
|
|
18
22
|
* 15% down). Content = a box of components one after another — a `Step` (`Pages` of `Page`s / a `Page` [+ `Sheet`]), a `Centre`, a
|
|
19
|
-
* `SheetStack`… Bottom = an `ActionBar` (the walk's `PagerAt` its first row) or a `FloatBar`, covering the content's foot on purpose. Max
|
|
23
|
+
* `SheetStack`… Bottom = an `ActionBar` (the walk's `PagerAt` its first row) or a `FloatBar`, covering the content's foot on purpose. Max 30rem wide, the standard inset, no vertical padding. A plain Screen (no `fill`) scrolls.
|
|
24
|
+
* ONE OUTCOME, DRAWN ONCE (Lh 2026-10-01): a screen that can come out more than one way is not drawn until the app knows which —
|
|
25
|
+
* `ready={false}` until then.
|
|
20
26
|
* The slots set the order; children laid by hand in the same order still work.
|
|
21
27
|
*/
|
|
22
|
-
export declare function Screen({ fill, palette, as, top, bottom, className, children, ref, ...rest }: ScreenProps): import("react").DetailedReactHTMLElement<{
|
|
28
|
+
export declare function Screen({ fill, palette, as, top, bottom, ready, className, children, ref, ...rest }: ScreenProps): import("react").DetailedReactHTMLElement<{
|
|
23
29
|
key?: import("react").Key | null | undefined;
|
|
24
30
|
defaultChecked?: boolean | undefined;
|
|
25
31
|
defaultValue?: string | number | readonly string[] | undefined;
|
|
@@ -80,7 +86,7 @@ export declare function Screen({ fill, palette, as, top, bottom, className, chil
|
|
|
80
86
|
"aria-autocomplete"?: "none" | "inline" | "list" | "both" | undefined;
|
|
81
87
|
"aria-braillelabel"?: string | undefined;
|
|
82
88
|
"aria-brailleroledescription"?: string | undefined;
|
|
83
|
-
"aria-busy"
|
|
89
|
+
"aria-busy": (boolean | "true" | "false") | undefined;
|
|
84
90
|
"aria-checked"?: boolean | "false" | "mixed" | "true" | undefined;
|
|
85
91
|
"aria-colcount"?: number | undefined;
|
|
86
92
|
"aria-colindex"?: number | undefined;
|
|
@@ -301,5 +307,6 @@ export declare function Screen({ fill, palette, as, top, bottom, className, chil
|
|
|
301
307
|
onTransitionStartCapture?: import("react").TransitionEventHandler<HTMLDivElement> | undefined;
|
|
302
308
|
className: string;
|
|
303
309
|
'data-palette': Palette | undefined;
|
|
310
|
+
'data-pending': string | undefined;
|
|
304
311
|
ref: Ref<HTMLDivElement>;
|
|
305
312
|
}, HTMLDivElement>;
|
package/dist/scaffold/Screen.js
CHANGED
|
@@ -5,10 +5,12 @@ import { cx } from '../cx.js';
|
|
|
5
5
|
* Screen — the mobile screen in THREE parts: **top** and **content** (the children) in one flex column, the content down to the
|
|
6
6
|
* screen's foot; **bottom** a LAYER over it there (Lh 2026-09-23). Top = `TopBar` / `AppBar`, or `top="lead"` (no bar, the words start
|
|
7
7
|
* 15% down). Content = a box of components one after another — a `Step` (`Pages` of `Page`s / a `Page` [+ `Sheet`]), a `Centre`, a
|
|
8
|
-
* `SheetStack`… Bottom = an `ActionBar` (the walk's `PagerAt` its first row) or a `FloatBar`, covering the content's foot on purpose. Max
|
|
8
|
+
* `SheetStack`… Bottom = an `ActionBar` (the walk's `PagerAt` its first row) or a `FloatBar`, covering the content's foot on purpose. Max 30rem wide, the standard inset, no vertical padding. A plain Screen (no `fill`) scrolls.
|
|
9
|
+
* ONE OUTCOME, DRAWN ONCE (Lh 2026-10-01): a screen that can come out more than one way is not drawn until the app knows which —
|
|
10
|
+
* `ready={false}` until then.
|
|
9
11
|
* The slots set the order; children laid by hand in the same order still work.
|
|
10
12
|
*/
|
|
11
|
-
export function Screen({ fill, palette, as = 'div', top, bottom, className, children, ref, ...rest }) {
|
|
13
|
+
export function Screen({ fill, palette, as = 'div', top, bottom, ready = true, className, children, ref, ...rest }) {
|
|
12
14
|
const own = useRef(null);
|
|
13
15
|
useEffect(() => {
|
|
14
16
|
const el = own.current;
|
|
@@ -25,7 +27,7 @@ export function Screen({ fill, palette, as = 'div', top, bottom, className, chil
|
|
|
25
27
|
return;
|
|
26
28
|
}
|
|
27
29
|
return watchSize([bar], () => el.style.setProperty('--bottom-h', `${bar.offsetHeight}px`));
|
|
28
|
-
}, [bottom]);
|
|
30
|
+
}, [bottom, ready]);
|
|
29
31
|
const setRef = (node) => {
|
|
30
32
|
own.current = node;
|
|
31
33
|
if (typeof ref === 'function')
|
|
@@ -33,5 +35,5 @@ export function Screen({ fill, palette, as = 'div', top, bottom, className, chil
|
|
|
33
35
|
else if (ref)
|
|
34
36
|
ref.current = node;
|
|
35
37
|
};
|
|
36
|
-
return createElement(as, { className: cx('screen', fill && 'screen--fill', top === 'lead' && 'screen--lead', className), 'data-palette': palette, ref: setRef, ...rest }, top !== 'lead' ? top : null, children, bottom);
|
|
38
|
+
return createElement(as, { className: cx('screen', fill && 'screen--fill', top === 'lead' && 'screen--lead', className), 'data-palette': palette, 'data-pending': ready ? undefined : '', 'aria-busy': ready ? undefined : true, ref: setRef, ...rest }, ready && top !== 'lead' ? top : null, ready ? children : null, ready ? bottom : null);
|
|
37
39
|
}
|
|
@@ -3,6 +3,7 @@ import type { SlideDirection } from './Centre.js';
|
|
|
3
3
|
* slideScreen — go to the NEXT SCREEN (a route change) with the whole screen sliding, as a walk's pages do. Wrap the app's
|
|
4
4
|
* navigate in it: `slideScreen(() => navigate('/download'))` — `back` for a way back: `slideScreen(() => navigate(-1), 'back')`.
|
|
5
5
|
* The update may render at once or later (a route's chunk loading): the slide waits for the new Screen (at most 0.8 s).
|
|
6
|
+
* A new Screen that is not `ready` is waited for (at most 3 s) — the old screen stays until then.
|
|
6
7
|
* A Screen with `data-slide="off"` (or none at all) is not slid. `scope`: where the one Screen is (default the document — an
|
|
7
8
|
* app has one). Without view transitions (the browser floor) it just runs the update.
|
|
8
9
|
*/
|
|
@@ -3,16 +3,31 @@
|
|
|
3
3
|
transition named for the move only: the leaving `.screen` is `screen-out`, the arriving one `screen-in`. */
|
|
4
4
|
const screenIn = (scope) => scope.querySelector('.screen:not(.screen .screen)');
|
|
5
5
|
/** wait until the screen in `scope` CHANGED — another element, or new content in the same one (React may keep the element when
|
|
6
|
-
both routes render a Screen in the same place) — a route rendered, sync or not; at most `ms
|
|
7
|
-
|
|
6
|
+
both routes render a Screen in the same place) — a route rendered, sync or not; at most `ms`. A new Screen that is not
|
|
7
|
+
`ready` yet (`data-pending`) is waited for too, at most `hold`: the old screen stays until the new one knows what it is. */
|
|
8
|
+
function newScreen(scope, old, ms = 800, hold = 3000) {
|
|
8
9
|
return new Promise((done) => {
|
|
10
|
+
const pending = (el) => !!el?.hasAttribute('data-pending');
|
|
9
11
|
const now = screenIn(scope);
|
|
10
|
-
if (now !== old)
|
|
12
|
+
if (now !== old && !pending(now))
|
|
11
13
|
return done(now);
|
|
12
14
|
const root = scope instanceof Document ? scope.documentElement : scope;
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
const
|
|
15
|
+
const end = () => { mo.disconnect(); clearTimeout(t); done(screenIn(scope)); };
|
|
16
|
+
let held = false;
|
|
17
|
+
const look = () => {
|
|
18
|
+
if (!pending(screenIn(scope)))
|
|
19
|
+
return end();
|
|
20
|
+
if (!held) {
|
|
21
|
+
held = true;
|
|
22
|
+
clearTimeout(t);
|
|
23
|
+
t = setTimeout(end, hold);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
const mo = new MutationObserver(look);
|
|
27
|
+
mo.observe(root, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-pending'] });
|
|
28
|
+
let t = setTimeout(end, ms);
|
|
29
|
+
if (pending(now))
|
|
30
|
+
look();
|
|
16
31
|
});
|
|
17
32
|
}
|
|
18
33
|
let running = false;
|
|
@@ -20,6 +35,7 @@ let running = false;
|
|
|
20
35
|
* slideScreen — go to the NEXT SCREEN (a route change) with the whole screen sliding, as a walk's pages do. Wrap the app's
|
|
21
36
|
* navigate in it: `slideScreen(() => navigate('/download'))` — `back` for a way back: `slideScreen(() => navigate(-1), 'back')`.
|
|
22
37
|
* The update may render at once or later (a route's chunk loading): the slide waits for the new Screen (at most 0.8 s).
|
|
38
|
+
* A new Screen that is not `ready` is waited for (at most 3 s) — the old screen stays until then.
|
|
23
39
|
* A Screen with `data-slide="off"` (or none at all) is not slid. `scope`: where the one Screen is (default the document — an
|
|
24
40
|
* app has one). Without view transitions (the browser floor) it just runs the update.
|
|
25
41
|
*/
|
|
@@ -4,7 +4,7 @@ import { type SheetProps } from '../sheets/Sheet.js';
|
|
|
4
4
|
export interface CallSheetSheet {
|
|
5
5
|
/** the sheet head's title */
|
|
6
6
|
title: ReactNode;
|
|
7
|
-
/** the sheet body — rendered only while the sheet is up; a function is called then */
|
|
7
|
+
/** the sheet body — rendered only while the sheet is up (and while it slides away); a function is called then */
|
|
8
8
|
body: ReactNode | (() => ReactNode);
|
|
9
9
|
/** the sheet foot: the one button that finishes the sheet */
|
|
10
10
|
foot?: ReactNode;
|
|
@@ -21,8 +21,16 @@ export function CallSheet({ media, heading, body, action, sheet, ways, open: ope
|
|
|
21
21
|
const list = ways ?? (sheet ? [{ action, sheet }] : []);
|
|
22
22
|
const [on, set] = useControlled(openProp === undefined ? undefined : index(openProp), index(defaultOpen), onOpenChange && ((next) => onOpenChange(next !== null, next)));
|
|
23
23
|
const [at, setAt] = useState('full');
|
|
24
|
+
// a sheet on its way out keeps its body until it has slid away — an emptied sheet sliding down read as a broken one
|
|
25
|
+
const [leaving, setLeaving] = useState(null), [was, setWas] = useState(on);
|
|
26
|
+
if (was !== on) {
|
|
27
|
+
setWas(on);
|
|
28
|
+
if (was !== null)
|
|
29
|
+
setLeaving(was);
|
|
30
|
+
}
|
|
24
31
|
return (_jsxs(Screen, { fill: true, ...rest, children: [_jsxs(Step, { children: [_jsxs(Page, { children: [media, _jsx(CardHead, { level: "h1", heading: heading }), typeof body === 'string' ? _jsx(Text, { level: "body", children: body }) : body, _jsx(CtaPack, { children: list.map((w, i) => (_jsx(Btn, { primary: i === 0, quiet: i > 0, "aria-expanded": on === i, onClick: () => { setAt('full'); set(i); }, children: w.action }, i))) })] }), list.map(({ sheet: sh }, i) => (_jsxs(Sheet, { state: on === i ? at : 'away', onStateChange: (s) => { if (s === 'away')
|
|
25
32
|
set(null);
|
|
26
33
|
else
|
|
27
|
-
setAt(s); },
|
|
34
|
+
setAt(s); }, onSettled: (s) => { if (s === 'away' && leaving === i)
|
|
35
|
+
setLeaving(null); }, states: states, handle: sh.handle ?? handle, children: [_jsx(CardHead, { heading: sh.title }), _jsx(SheetBody, { children: on === i || leaving === i ? (typeof sh.body === 'function' ? sh.body() : sh.body) : null }), (sh.foot != null || sh.footLink != null) && _jsx(CardFoot, { link: sh.footLink, children: sh.foot })] }, i)))] }), children] }));
|
|
28
36
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@usableapp/cardds",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.36",
|
|
4
4
|
"license": "UNLICENSED",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "card-first mobile design system, React-first: the components in src/ are thin wrappers over the CSS contract (css/*.css stays the only truth); gallery/ shows every story live (npm run dev), tests/ measures the geometry.",
|