@scalewing/react 1.49.0 → 1.50.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # @scalewing/react
2
2
 
3
+ ## 1.50.0
4
+
5
+ ### Minor Changes
6
+
7
+ - d707587: `ScreenPreview`: a drawn stand-in for an app's screen on a public page, a `figure` whose screen is one image (`role="img"`) named by `label`, with an optional `eyebrow`, `title` and visible `caption`. `frame="card"` (default) or `"device"`, a 20rem phone from `md` and the card below it. Its content is `PreviewGroup`s of `PreviewRow`s (`kind` `plain`, `total`, `note`, `filled`, `alert`) and `PreviewTiles` (three to a row on a phone, six from `md`). New classes `sw-screen-preview*`, `sw-preview-*`, with `screenPreviewFrames` and `previewRowKinds`; a blank label, an unknown frame or kind, or bad tiles throw a `RangeError`. No new tokens or dependencies. Request: `docs/requests/teisoro-screen-preview.md`.
8
+ - 7a647d3: `ThemeProvider` takes `frame` (`none` by default, or `site`): `site` writes `data-frame="site"` and makes the themed root a column at least the window's height whose last child (the footer) is pushed to its foot, so a short page's footer meets the window's foot. `themeFrames` and `ThemeFrame` list the frames; an unknown one throws a `RangeError`. No new tokens or dependencies. Request: `docs/requests/teisoro-site-frame.md`.
9
+
3
10
  ## 1.49.0
4
11
 
5
12
  ### Minor Changes
package/README.md CHANGED
@@ -36,6 +36,10 @@ import {
36
36
  Separator,
37
37
  Band,
38
38
  Hero,
39
+ ScreenPreview,
40
+ PreviewGroup,
41
+ PreviewRow,
42
+ PreviewTiles,
39
43
  SiteFooter,
40
44
  SiteHeader,
41
45
  DescriptionList,
@@ -81,6 +85,16 @@ The accent does not keep 4.5:1 on the grey in every palette, so keep links and a
81
85
  </ThemeProvider>
82
86
  ```
83
87
 
88
+ `frame="site"` (opt-in; `themeFrames`, default `"none"`) makes the themed node a public site's page: a column at least the window's height (the canvas already keeps that height) whose last child is pushed to its foot, so a page shorter than the window still has its footer at the window's foot. It writes `data-frame="site"` on the themed node. Put the header, `main` and footer as the provider's direct children, the footer last: anything rendered after it as a direct child (a live region, a toast host) would take the footer's place at the foot, so put those inside `main`. An unknown frame throws a `RangeError`.
89
+
90
+ ```tsx
91
+ <ThemeProvider frame="site" colorScheme="light">
92
+ <SiteHeader … />
93
+ <main>…</main>
94
+ <SiteFooter … />
95
+ </ThemeProvider>
96
+ ```
97
+
84
98
  ```html
85
99
  <div class="sw-padding-top-4 sw-padding-x-4"></div>
86
100
  ```
@@ -1050,6 +1064,23 @@ const { headings, omissions } = inspectMarkdown(page.body, {
1050
1064
  />
1051
1065
  ```
1052
1066
 
1067
+ `ScreenPreview` is a drawn stand-in for an app's screen on a public page, until a real screenshot replaces it: a `figure` whose screen is one image (`role="img"`) named by `label`, its alternative text, so a screen reader hears that sentence instead of the rows; an optional `caption` shows under it as the `figcaption`. An optional `eyebrow` and `title` head the screen. Its content is `PreviewGroup`s of `PreviewRow`s (`label` at the start, `value` at the end in tabular numerals; `kind` `plain`, `total` in bold past a hairline, `note` a small label over its words, `filled` on the quiet fill, or `alert` on the danger tint) and `PreviewTiles` (`items` of `{ id, label, value }`, three to a row on a phone, six from `md`). `frame="card"` (default) is a surface card whose groups are parted by hairlines; `frame="device"` draws a 20rem phone from `md` (a bezel in the text colour round a quiet screen with a surface header and each group a card) and the card below `md`. A blank `label`, an unknown `frame` or `kind`, no tiles, or a blank or repeated tile id throws a `RangeError`.
1068
+
1069
+ ```tsx
1070
+ <ScreenPreview
1071
+ frame="device"
1072
+ label="Today's census: 14 herons, 6 egrets, 20 birds in all"
1073
+ eyebrow="Census · Today"
1074
+ title="Birds on the marsh"
1075
+ caption="A photo of the real board goes here."
1076
+ >
1077
+ <PreviewGroup>
1078
+ <PreviewRow label="Grey herons" value="14" />
1079
+ <PreviewRow kind="total" label="All birds" value="20" />
1080
+ </PreviewGroup>
1081
+ </ScreenPreview>
1082
+ ```
1083
+
1053
1084
  `SiteFooter` is a public site's footer on the dark inverse band (`Band tone="inverse"`, so a brand moves it with a theme overlay of `inverse`). Supply `brand` (such as a logo for a dark background), an optional `tagline`, and `groups`: each `{ id, label, links }` is a `nav` named by its label, which shows above its links (hidden from assistive technology, since the `nav` already carries it). Each link is `{ id, label, href, current?, lang?, hrefLang? }`; `lang` and `hrefLang` pass through to the link unchecked; `current` sets `aria-current="page"` and draws it in the band's words at label weight, the others in its muted words, underlined on hover and focus. `legal` and `end` fill the bottom row past a hairline, such as the copyright and a language switch. Below `md` the brand takes the full width and the groups two columns; from `md` the brand and the groups share one spread row. Links get the 44 px target on a coarse pointer. Put it last in the page's `body` (outside a `section` or `article`) so it is the page's `contentinfo` landmark. No groups, a group with no links, blank or repeated ids, blank labels or a blank `href` throw a `RangeError`.
1054
1085
 
1055
1086
  ```tsx
@@ -0,0 +1,25 @@
1
+ import { type HTMLAttributes, type ReactNode } from 'react';
2
+ import { type ScreenPreviewFrame, screenPreviewFrames } from '../css/css-screen-preview.js';
3
+ export { screenPreviewFrames, type ScreenPreviewFrame };
4
+ export type ScreenPreviewProps = Omit<HTMLAttributes<HTMLElement>, 'children' | 'role' | 'style' | 'title'> & {
5
+ /** What the drawn screen shows, said in words: its alternative text. */
6
+ label: string;
7
+ /** `card` (default), or `device`, a phone's bezel from `md`. */
8
+ frame?: ScreenPreviewFrame;
9
+ /** A small line above the screen's title, such as where in the app it is. */
10
+ eyebrow?: ReactNode;
11
+ /** The screen's own title. */
12
+ title?: ReactNode;
13
+ /** A visible caption under the screen, such as what will replace it. */
14
+ caption?: ReactNode;
15
+ /** `PreviewGroup`s, `PreviewRow`s and `PreviewTiles`. */
16
+ children: ReactNode;
17
+ };
18
+ /**
19
+ * A drawn stand-in for an app's screen on a public page: a `figure` whose
20
+ * screen is one image (`role="img"`) named by `label`, so its rows are
21
+ * read as that one sentence, with an optional visible `caption`. Its
22
+ * content is `PreviewGroup`s of `PreviewRow`s and `PreviewTiles`. A blank
23
+ * label or an unknown frame throws a `RangeError`.
24
+ */
25
+ export declare function ScreenPreview({ caption, children, className, eyebrow, frame, label, title, ...rest }: ScreenPreviewProps): import("react").JSX.Element;
@@ -0,0 +1,20 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cx } from '../class-names.js';
3
+ import { screenPreviewFrameClass, screenPreviewFrames, } from '../css/css-screen-preview.js';
4
+ import { isOneOf } from '../is-one-of.js';
5
+ export { screenPreviewFrames };
6
+ /**
7
+ * A drawn stand-in for an app's screen on a public page: a `figure` whose
8
+ * screen is one image (`role="img"`) named by `label`, so its rows are
9
+ * read as that one sentence, with an optional visible `caption`. Its
10
+ * content is `PreviewGroup`s of `PreviewRow`s and `PreviewTiles`. A blank
11
+ * label or an unknown frame throws a `RangeError`.
12
+ */
13
+ export function ScreenPreview({ caption, children, className, eyebrow, frame = 'card', label, title, ...rest }) {
14
+ if (!label.trim())
15
+ throw new RangeError('A ScreenPreview needs a label, its alternative text');
16
+ if (!isOneOf(screenPreviewFrames, frame))
17
+ throw new RangeError(`Unknown ScreenPreview frame: ${String(frame)}`);
18
+ const header = eyebrow || title ? (_jsxs("div", { className: "sw-screen-preview-header", children: [eyebrow ? (_jsx("span", { className: "sw-screen-preview-eyebrow", children: eyebrow })) : null, title ? (_jsx("span", { className: "sw-screen-preview-title", children: title })) : null] })) : null;
19
+ return (_jsxs("figure", { ...rest, className: cx('sw-screen-preview', screenPreviewFrameClass(frame), className), children: [_jsx("div", { className: "sw-screen-preview-bezel", children: _jsxs("div", { "aria-label": label, className: "sw-screen-preview-screen", role: "img", children: [header, _jsx("div", { className: "sw-screen-preview-body", children: children })] }) }), caption ? (_jsx("figcaption", { className: "sw-screen-preview-caption", children: caption })) : null] }));
20
+ }
@@ -0,0 +1,8 @@
1
+ import { type ReactNode } from 'react';
2
+ /**
3
+ * Rows of a `ScreenPreview` that belong together: parted from the next
4
+ * group by a hairline on a card, each a card of its own in a device frame.
5
+ */
6
+ export declare function PreviewGroup({ children }: {
7
+ children: ReactNode;
8
+ }): import("react").JSX.Element;
@@ -0,0 +1,8 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * Rows of a `ScreenPreview` that belong together: parted from the next
4
+ * group by a hairline on a card, each a card of its own in a device frame.
5
+ */
6
+ export function PreviewGroup({ children }) {
7
+ return _jsx("div", { className: "sw-preview-group", children: children });
8
+ }
@@ -0,0 +1,15 @@
1
+ import { type ReactNode } from 'react';
2
+ import { type PreviewRowKind, previewRowKinds } from '../../css/css-screen-preview.js';
3
+ export { previewRowKinds, type PreviewRowKind };
4
+ export type PreviewRowProps = {
5
+ label: ReactNode;
6
+ value: ReactNode;
7
+ /**
8
+ * `plain` (default) a label and its figure; `total` the same in bold past
9
+ * a hairline; `note` a small label over its words; `filled` on the quiet
10
+ * fill; `alert` on the danger tint.
11
+ */
12
+ kind?: PreviewRowKind;
13
+ };
14
+ /** One row of a `ScreenPreview`: a label at its start and a value at its end. */
15
+ export declare function PreviewRow({ kind, label, value }: PreviewRowProps): import("react").JSX.Element;
@@ -0,0 +1,11 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cx } from '../../class-names.js';
3
+ import { previewRowKindClass, previewRowKinds, } from '../../css/css-screen-preview.js';
4
+ import { isOneOf } from '../../is-one-of.js';
5
+ export { previewRowKinds };
6
+ /** One row of a `ScreenPreview`: a label at its start and a value at its end. */
7
+ export function PreviewRow({ kind = 'plain', label, value }) {
8
+ if (!isOneOf(previewRowKinds, kind))
9
+ throw new RangeError(`Unknown PreviewRow kind: ${String(kind)}`);
10
+ return (_jsxs("div", { className: cx('sw-preview-row', previewRowKindClass(kind)), children: [_jsx("span", { className: "sw-preview-row-label", children: label }), _jsx("span", { className: "sw-preview-row-value", children: value })] }));
11
+ }
@@ -0,0 +1,16 @@
1
+ import { type ReactNode } from 'react';
2
+ /** One tile: `id` keys it and is never shown; `label` sits over `value`. */
3
+ export type PreviewTile = {
4
+ id: string;
5
+ label: ReactNode;
6
+ value: ReactNode;
7
+ };
8
+ export type PreviewTilesProps = {
9
+ items: readonly PreviewTile[];
10
+ };
11
+ /**
12
+ * A row of small tiles in a `ScreenPreview`, each a label over its value,
13
+ * such as counts by kind: three to a row on a phone, six from `md`. No
14
+ * items, or a blank or repeated id, throws a `RangeError`.
15
+ */
16
+ export declare function PreviewTiles({ items }: PreviewTilesProps): import("react").JSX.Element;
@@ -0,0 +1,15 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * A row of small tiles in a `ScreenPreview`, each a label over its value,
4
+ * such as counts by kind: three to a row on a phone, six from `md`. No
5
+ * items, or a blank or repeated id, throws a `RangeError`.
6
+ */
7
+ export function PreviewTiles({ items }) {
8
+ if (items.length === 0)
9
+ throw new RangeError('PreviewTiles needs an item');
10
+ if (items.some((item) => !item.id.trim()))
11
+ throw new RangeError('PreviewTiles items need nonempty ids');
12
+ if (new Set(items.map((item) => item.id)).size !== items.length)
13
+ throw new RangeError('PreviewTiles items need unique ids');
14
+ return (_jsx("div", { className: "sw-preview-tiles", children: items.map((item) => (_jsxs("div", { className: "sw-preview-tile", children: [_jsx("span", { className: "sw-preview-tile-label", children: item.label }), _jsx("span", { className: "sw-preview-tile-value", children: item.value })] }, item.id))) }));
15
+ }
@@ -52,6 +52,7 @@ import { cssTooltipClasses } from './css-tooltip.js';
52
52
  import { cssInfoTipClasses } from './css-info-tip.js';
53
53
  import { cssSeparatorClasses } from './css-separator.js';
54
54
  import { cssBandClasses } from './css-band.js';
55
+ import { cssScreenPreviewClasses } from './css-screen-preview.js';
55
56
  import { cssHeroClasses } from './css-hero.js';
56
57
  import { cssSiteFooterClasses } from './css-site-footer.js';
57
58
  import { cssSiteHeaderClasses } from './css-site-header.js';
@@ -202,6 +203,8 @@ ${cssSectionNavClasses()}
202
203
 
203
204
  ${cssBandClasses()}
204
205
 
206
+ ${cssScreenPreviewClasses()}
207
+
205
208
  ${cssHeroClasses()}
206
209
 
207
210
  ${cssSiteFooterClasses()}
@@ -1,6 +1,7 @@
1
1
  import { typographyVariants } from '@scalewing/tokens';
2
2
  import { cssBoxLinkClasses } from './css-box-link.js';
3
3
  import { cssCanvasGroupedRules } from './css-canvas-grouped.js';
4
+ import { cssThemeFrameRules } from './css-theme-frame.js';
4
5
  const body = typographyVariants.body;
5
6
  /** The native controls that take typed text: the canvas's text controls. */
6
7
  export const typedInputs = [
@@ -87,6 +88,8 @@ body {
87
88
 
88
89
  ${cssCanvasGroupedRules()}
89
90
 
91
+ ${cssThemeFrameRules()}
92
+
90
93
  [data-theme] a {
91
94
  color: var(--sw-color-accent);
92
95
  text-decoration: none;
@@ -0,0 +1,10 @@
1
+ /** How a drawn screen is framed: a plain card, or a phone from `md`. */
2
+ export declare const screenPreviewFrames: readonly ["card", "device"];
3
+ export type ScreenPreviewFrame = (typeof screenPreviewFrames)[number];
4
+ /** What a drawn row shows: a figure, a total, a note, a filled or an alert row. */
5
+ export declare const previewRowKinds: readonly ["plain", "total", "note", "filled", "alert"];
6
+ export type PreviewRowKind = (typeof previewRowKinds)[number];
7
+ export declare function screenPreviewFrameClass(frame: ScreenPreviewFrame): string;
8
+ export declare function previewRowKindClass(kind: PreviewRowKind): string;
9
+ export declare function cssScreenPreviewClasses(): string;
10
+ export declare function screenPreviewClassCatalog(): string[];
@@ -0,0 +1,232 @@
1
+ import { typographyVariants } from '@scalewing/tokens';
2
+ import { breakpointQuery } from './breakpoints.js';
3
+ import { toneTintFill, toneTintInk } from './tone-tint.js';
4
+ const { caption, label, title } = typographyVariants;
5
+ /** How a drawn screen is framed: a plain card, or a phone from `md`. */
6
+ export const screenPreviewFrames = ['card', 'device'];
7
+ /** What a drawn row shows: a figure, a total, a note, a filled or an alert row. */
8
+ export const previewRowKinds = [
9
+ 'plain',
10
+ 'total',
11
+ 'note',
12
+ 'filled',
13
+ 'alert',
14
+ ];
15
+ export function screenPreviewFrameClass(frame) {
16
+ return `sw-screen-preview-${frame}`;
17
+ }
18
+ export function previewRowKindClass(kind) {
19
+ return `sw-preview-row-${kind}`;
20
+ }
21
+ /*
22
+ * The card frame, and the device frame below `md`: one surface card whose
23
+ * groups are parted by hairlines. Rows set their label at the start and
24
+ * their figure at the end in tabular numerals.
25
+ */
26
+ function cardRules() {
27
+ return `.sw-screen-preview {
28
+ display: flex;
29
+ flex-direction: column;
30
+ gap: var(--sw-space-2);
31
+ margin: 0;
32
+ max-width: 100%;
33
+ min-width: 0;
34
+ }
35
+
36
+ .sw-screen-preview-screen {
37
+ background: var(--sw-color-surface);
38
+ border-radius: var(--sw-radius-md);
39
+ color: var(--sw-color-text);
40
+ display: flex;
41
+ flex-direction: column;
42
+ font-family: var(--sw-font-sans);
43
+ font-size: ${label.fontSize}px;
44
+ font-variant-numeric: tabular-nums;
45
+ gap: var(--sw-space-3);
46
+ line-height: ${label.lineHeight}px;
47
+ padding: var(--sw-space-4);
48
+ }
49
+
50
+ .sw-screen-preview-header {
51
+ display: flex;
52
+ flex-direction: column;
53
+ }
54
+
55
+ .sw-screen-preview-eyebrow {
56
+ color: var(--sw-color-muted);
57
+ font-size: ${caption.fontSize}px;
58
+ line-height: ${caption.lineHeight}px;
59
+ }
60
+
61
+ .sw-screen-preview-title {
62
+ font-size: ${title.fontSize}px;
63
+ font-weight: ${title.fontWeight};
64
+ line-height: ${title.lineHeight}px;
65
+ }
66
+
67
+ .sw-screen-preview-body {
68
+ display: flex;
69
+ flex-direction: column;
70
+ gap: var(--sw-space-2);
71
+ }
72
+
73
+ .sw-preview-group {
74
+ display: flex;
75
+ flex-direction: column;
76
+ gap: var(--sw-space-2);
77
+ }
78
+
79
+ .sw-preview-group + .sw-preview-group {
80
+ border-top: 1px solid var(--sw-color-border);
81
+ padding-top: var(--sw-space-2);
82
+ }
83
+
84
+ .sw-screen-preview-caption {
85
+ color: var(--sw-color-muted);
86
+ font-size: ${caption.fontSize}px;
87
+ line-height: ${caption.lineHeight}px;
88
+ }`;
89
+ }
90
+ function rowRules() {
91
+ return `.sw-preview-row {
92
+ display: flex;
93
+ gap: var(--sw-space-3);
94
+ justify-content: space-between;
95
+ min-width: 0;
96
+ }
97
+
98
+ .sw-preview-row-value {
99
+ text-align: end;
100
+ }
101
+
102
+ .${previewRowKindClass('total')} {
103
+ border-top: 1px solid var(--sw-color-border);
104
+ font-weight: ${label.fontWeight};
105
+ padding-top: var(--sw-space-2);
106
+ }
107
+
108
+ .${previewRowKindClass('note')} {
109
+ flex-direction: column;
110
+ gap: 0;
111
+ }
112
+
113
+ .${previewRowKindClass('note')} .sw-preview-row-label {
114
+ color: var(--sw-color-muted);
115
+ font-size: ${caption.fontSize}px;
116
+ line-height: ${caption.lineHeight}px;
117
+ }
118
+
119
+ .${previewRowKindClass('note')} .sw-preview-row-value {
120
+ text-align: start;
121
+ }
122
+
123
+ .${previewRowKindClass('filled')},
124
+ .${previewRowKindClass('alert')} {
125
+ border-radius: var(--sw-radius-sm);
126
+ padding: var(--sw-space-2) var(--sw-space-3);
127
+ }
128
+
129
+ .${previewRowKindClass('filled')} {
130
+ background: var(--sw-color-subtle);
131
+ }
132
+
133
+ .${previewRowKindClass('alert')} {
134
+ background: ${toneTintFill('danger')};
135
+ color: ${toneTintInk('danger')};
136
+ }
137
+
138
+ .sw-preview-tiles {
139
+ display: grid;
140
+ gap: var(--sw-space-2);
141
+ grid-template-columns: repeat(3, minmax(0, 1fr));
142
+ }
143
+
144
+ .sw-preview-tile {
145
+ align-items: center;
146
+ background: var(--sw-color-subtle);
147
+ border-radius: var(--sw-radius-sm);
148
+ display: flex;
149
+ flex-direction: column;
150
+ min-width: 0;
151
+ padding: var(--sw-space-2);
152
+ }
153
+
154
+ .sw-preview-tile-label {
155
+ color: var(--sw-color-muted);
156
+ font-size: ${caption.fontSize}px;
157
+ line-height: ${caption.lineHeight}px;
158
+ }`;
159
+ }
160
+ /*
161
+ * From `md` the device frame draws a phone: a dark bezel (the text colour)
162
+ * round a quiet screen whose header is a surface strip and whose groups
163
+ * are surface cards. Below `md` it is the card, since the page is already
164
+ * a phone.
165
+ */
166
+ function wideRules() {
167
+ const device = `.${screenPreviewFrameClass('device')}`;
168
+ return `@media ${breakpointQuery('from', 'md')} {
169
+ .${screenPreviewFrameClass('card')} .sw-screen-preview-screen { padding: var(--sw-space-5); }
170
+ .sw-preview-tiles { grid-template-columns: repeat(6, minmax(0, 1fr)); }
171
+ ${device} { width: 20rem; }
172
+ ${device} .sw-screen-preview-bezel {
173
+ background: var(--sw-color-text);
174
+ border-radius: calc(var(--sw-radius-lg) + var(--sw-space-3));
175
+ padding: var(--sw-space-3);
176
+ }
177
+ ${device} .sw-screen-preview-screen {
178
+ background: var(--sw-color-subtle);
179
+ border-radius: var(--sw-radius-lg);
180
+ gap: 0;
181
+ overflow: hidden;
182
+ padding: 0;
183
+ }
184
+ ${device} .sw-screen-preview-header {
185
+ background: var(--sw-color-surface);
186
+ border-bottom: 1px solid var(--sw-color-border);
187
+ padding: var(--sw-space-4);
188
+ }
189
+ ${device} .sw-screen-preview-body { gap: var(--sw-space-3); padding: var(--sw-space-3); }
190
+ ${device} .sw-preview-group {
191
+ background: var(--sw-color-surface);
192
+ border-radius: var(--sw-radius-md);
193
+ padding: var(--sw-space-3);
194
+ }
195
+ ${device} .sw-preview-group + .sw-preview-group { border-top: 0; }
196
+ ${device} .sw-screen-preview-caption { text-align: center; }
197
+ }
198
+
199
+ @media (forced-colors: active) {
200
+ .sw-screen-preview-screen { border: 1px solid CanvasText; }
201
+ }
202
+
203
+ @media (forced-colors: active) and ${breakpointQuery('from', 'md')} {
204
+ .${screenPreviewFrameClass('device')} .sw-screen-preview-screen { border: 0; }
205
+ .${screenPreviewFrameClass('device')} .sw-screen-preview-bezel { border: 1px solid CanvasText; }
206
+ }`;
207
+ }
208
+ export function cssScreenPreviewClasses() {
209
+ return [cardRules(), rowRules(), wideRules()].join('\n\n');
210
+ }
211
+ export function screenPreviewClassCatalog() {
212
+ return [
213
+ 'sw-screen-preview',
214
+ ...screenPreviewFrames.map(screenPreviewFrameClass),
215
+ 'sw-screen-preview-bezel',
216
+ 'sw-screen-preview-screen',
217
+ 'sw-screen-preview-header',
218
+ 'sw-screen-preview-eyebrow',
219
+ 'sw-screen-preview-title',
220
+ 'sw-screen-preview-body',
221
+ 'sw-screen-preview-caption',
222
+ 'sw-preview-group',
223
+ 'sw-preview-row',
224
+ ...previewRowKinds.map(previewRowKindClass),
225
+ 'sw-preview-row-label',
226
+ 'sw-preview-row-value',
227
+ 'sw-preview-tiles',
228
+ 'sw-preview-tile',
229
+ 'sw-preview-tile-label',
230
+ 'sw-preview-tile-value',
231
+ ];
232
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * How the themed root lays out its children: `none` (default) leaves them
3
+ * in the document's flow; `site` is a public site's page, a column at least
4
+ * the window's height whose last child (the footer) is pushed to its foot,
5
+ * so a short page's footer still meets the window's foot.
6
+ */
7
+ export declare const themeFrames: readonly ["none", "site"];
8
+ export type ThemeFrame = (typeof themeFrames)[number];
9
+ export declare function assertThemeFrame(frame: unknown): asserts frame is ThemeFrame;
10
+ export declare function cssThemeFrameRules(): string;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * How the themed root lays out its children: `none` (default) leaves them
3
+ * in the document's flow; `site` is a public site's page, a column at least
4
+ * the window's height whose last child (the footer) is pushed to its foot,
5
+ * so a short page's footer still meets the window's foot.
6
+ */
7
+ export const themeFrames = ['none', 'site'];
8
+ export function assertThemeFrame(frame) {
9
+ if (!themeFrames.includes(frame))
10
+ throw new RangeError(`Unknown ThemeProvider frame: ${String(frame)}`);
11
+ }
12
+ /*
13
+ * The themed root already keeps the window's height (the document canvas);
14
+ * the site frame makes it a column and gives its last child the space the
15
+ * content leaves above it.
16
+ */
17
+ export function cssThemeFrameRules() {
18
+ return `[data-theme][data-frame='site'] {
19
+ display: flex;
20
+ flex-direction: column;
21
+ }
22
+
23
+ [data-theme][data-frame='site'] > :last-child {
24
+ margin-top: auto;
25
+ }`;
26
+ }
@@ -28,6 +28,7 @@ import { tooltipClassCatalog } from './css-tooltip.js';
28
28
  import { infoTipClassCatalog } from './css-info-tip.js';
29
29
  import { separatorClassCatalog } from './css-separator.js';
30
30
  import { bandClassCatalog } from './css-band.js';
31
+ import { screenPreviewClassCatalog } from './css-screen-preview.js';
31
32
  import { heroClassCatalog } from './css-hero.js';
32
33
  import { siteFooterClassCatalog } from './css-site-footer.js';
33
34
  import { siteHeaderClassCatalog } from './css-site-header.js';
@@ -142,6 +143,7 @@ export function utilityClassCatalog() {
142
143
  ...infoTipClassCatalog(),
143
144
  ...separatorClassCatalog(),
144
145
  ...bandClassCatalog(),
146
+ ...screenPreviewClassCatalog(),
145
147
  ...heroClassCatalog(),
146
148
  ...siteFooterClassCatalog(),
147
149
  ...siteHeaderClassCatalog(),
package/dist/index.d.ts CHANGED
@@ -72,6 +72,7 @@ export { Table, TableBody, TableCell, TableFooter, TableHeader, TableRow, tableC
72
72
  export { Text, type TextAlign, type TextElement, type TextProps, } from './components/Text.js';
73
73
  export { ThemeProvider, useTheme, type ThemeProviderProps, } from './theme/ThemeProvider.js';
74
74
  export { themeCanvases, type ThemeCanvas } from './css/css-canvas-grouped.js';
75
+ export { themeFrames, type ThemeFrame } from './css/css-theme-frame.js';
75
76
  export { utilityClassCatalog } from './css/stylesheet.js';
76
77
  export type { VisibilityProps } from './visibility-classes.js';
77
78
  export type { Breakpoint, BreakpointDirection, VisibilityBreakpoint, } from './css/breakpoints.js';
@@ -80,6 +81,13 @@ export { Band, bandElements, bandTones, bandWidths, } from './components/Band.js
80
81
  export type { BandElement, BandProps, BandTone, BandWidth, } from './components/Band.js';
81
82
  export { SiteHeader } from './components/SiteHeader.js';
82
83
  export type { SiteHeaderItem, SiteHeaderLabels, SiteHeaderProps, } from './components/SiteHeader.js';
84
+ export { ScreenPreview, screenPreviewFrames, } from './components/ScreenPreview.js';
85
+ export type { ScreenPreviewFrame, ScreenPreviewProps, } from './components/ScreenPreview.js';
86
+ export { PreviewGroup } from './components/screen-preview/PreviewGroup.js';
87
+ export { PreviewRow, previewRowKinds, } from './components/screen-preview/PreviewRow.js';
88
+ export type { PreviewRowKind, PreviewRowProps, } from './components/screen-preview/PreviewRow.js';
89
+ export { PreviewTiles } from './components/screen-preview/PreviewTiles.js';
90
+ export type { PreviewTile, PreviewTilesProps, } from './components/screen-preview/PreviewTiles.js';
83
91
  export { Hero } from './components/Hero.js';
84
92
  export type { HeroProps } from './components/Hero.js';
85
93
  export { SiteFooter } from './components/SiteFooter.js';
package/dist/index.js CHANGED
@@ -68,10 +68,15 @@ export { Table, TableBody, TableCell, TableFooter, TableHeader, TableRow, tableC
68
68
  export { Text, } from './components/Text.js';
69
69
  export { ThemeProvider, useTheme, } from './theme/ThemeProvider.js';
70
70
  export { themeCanvases } from './css/css-canvas-grouped.js';
71
+ export { themeFrames } from './css/css-theme-frame.js';
71
72
  export { utilityClassCatalog } from './css/stylesheet.js';
72
73
  export { breakpointQuery } from './css/breakpoints.js';
73
74
  export { Band, bandElements, bandTones, bandWidths, } from './components/Band.js';
74
75
  export { SiteHeader } from './components/SiteHeader.js';
76
+ export { ScreenPreview, screenPreviewFrames, } from './components/ScreenPreview.js';
77
+ export { PreviewGroup } from './components/screen-preview/PreviewGroup.js';
78
+ export { PreviewRow, previewRowKinds, } from './components/screen-preview/PreviewRow.js';
79
+ export { PreviewTiles } from './components/screen-preview/PreviewTiles.js';
75
80
  export { Hero } from './components/Hero.js';
76
81
  export { SiteFooter } from './components/SiteFooter.js';
77
82
  export { Separator } from './components/Separator.js';
package/dist/styles.css CHANGED
@@ -632,6 +632,15 @@ body {
632
632
  background-color: var(--sw-canvas-grouped);
633
633
  }
634
634
 
635
+ [data-theme][data-frame='site'] {
636
+ display: flex;
637
+ flex-direction: column;
638
+ }
639
+
640
+ [data-theme][data-frame='site'] > :last-child {
641
+ margin-top: auto;
642
+ }
643
+
635
644
  [data-theme] a {
636
645
  color: var(--sw-color-accent);
637
646
  text-decoration: none;
@@ -4606,6 +4615,178 @@ body {
4606
4615
  .sw-band-inverse { border-block: 1px solid CanvasText; }
4607
4616
  }
4608
4617
 
4618
+ .sw-screen-preview {
4619
+ display: flex;
4620
+ flex-direction: column;
4621
+ gap: var(--sw-space-2);
4622
+ margin: 0;
4623
+ max-width: 100%;
4624
+ min-width: 0;
4625
+ }
4626
+
4627
+ .sw-screen-preview-screen {
4628
+ background: var(--sw-color-surface);
4629
+ border-radius: var(--sw-radius-md);
4630
+ color: var(--sw-color-text);
4631
+ display: flex;
4632
+ flex-direction: column;
4633
+ font-family: var(--sw-font-sans);
4634
+ font-size: 15px;
4635
+ font-variant-numeric: tabular-nums;
4636
+ gap: var(--sw-space-3);
4637
+ line-height: 20px;
4638
+ padding: var(--sw-space-4);
4639
+ }
4640
+
4641
+ .sw-screen-preview-header {
4642
+ display: flex;
4643
+ flex-direction: column;
4644
+ }
4645
+
4646
+ .sw-screen-preview-eyebrow {
4647
+ color: var(--sw-color-muted);
4648
+ font-size: 13px;
4649
+ line-height: 18px;
4650
+ }
4651
+
4652
+ .sw-screen-preview-title {
4653
+ font-size: 20px;
4654
+ font-weight: 600;
4655
+ line-height: 28px;
4656
+ }
4657
+
4658
+ .sw-screen-preview-body {
4659
+ display: flex;
4660
+ flex-direction: column;
4661
+ gap: var(--sw-space-2);
4662
+ }
4663
+
4664
+ .sw-preview-group {
4665
+ display: flex;
4666
+ flex-direction: column;
4667
+ gap: var(--sw-space-2);
4668
+ }
4669
+
4670
+ .sw-preview-group + .sw-preview-group {
4671
+ border-top: 1px solid var(--sw-color-border);
4672
+ padding-top: var(--sw-space-2);
4673
+ }
4674
+
4675
+ .sw-screen-preview-caption {
4676
+ color: var(--sw-color-muted);
4677
+ font-size: 13px;
4678
+ line-height: 18px;
4679
+ }
4680
+
4681
+ .sw-preview-row {
4682
+ display: flex;
4683
+ gap: var(--sw-space-3);
4684
+ justify-content: space-between;
4685
+ min-width: 0;
4686
+ }
4687
+
4688
+ .sw-preview-row-value {
4689
+ text-align: end;
4690
+ }
4691
+
4692
+ .sw-preview-row-total {
4693
+ border-top: 1px solid var(--sw-color-border);
4694
+ font-weight: 600;
4695
+ padding-top: var(--sw-space-2);
4696
+ }
4697
+
4698
+ .sw-preview-row-note {
4699
+ flex-direction: column;
4700
+ gap: 0;
4701
+ }
4702
+
4703
+ .sw-preview-row-note .sw-preview-row-label {
4704
+ color: var(--sw-color-muted);
4705
+ font-size: 13px;
4706
+ line-height: 18px;
4707
+ }
4708
+
4709
+ .sw-preview-row-note .sw-preview-row-value {
4710
+ text-align: start;
4711
+ }
4712
+
4713
+ .sw-preview-row-filled,
4714
+ .sw-preview-row-alert {
4715
+ border-radius: var(--sw-radius-sm);
4716
+ padding: var(--sw-space-2) var(--sw-space-3);
4717
+ }
4718
+
4719
+ .sw-preview-row-filled {
4720
+ background: var(--sw-color-subtle);
4721
+ }
4722
+
4723
+ .sw-preview-row-alert {
4724
+ background: color-mix(in srgb, var(--sw-color-danger) 10%, var(--sw-color-surface));
4725
+ color: color-mix(in srgb, var(--sw-color-danger) 70%, var(--sw-color-text));
4726
+ }
4727
+
4728
+ .sw-preview-tiles {
4729
+ display: grid;
4730
+ gap: var(--sw-space-2);
4731
+ grid-template-columns: repeat(3, minmax(0, 1fr));
4732
+ }
4733
+
4734
+ .sw-preview-tile {
4735
+ align-items: center;
4736
+ background: var(--sw-color-subtle);
4737
+ border-radius: var(--sw-radius-sm);
4738
+ display: flex;
4739
+ flex-direction: column;
4740
+ min-width: 0;
4741
+ padding: var(--sw-space-2);
4742
+ }
4743
+
4744
+ .sw-preview-tile-label {
4745
+ color: var(--sw-color-muted);
4746
+ font-size: 13px;
4747
+ line-height: 18px;
4748
+ }
4749
+
4750
+ @media (min-width: 48rem) {
4751
+ .sw-screen-preview-card .sw-screen-preview-screen { padding: var(--sw-space-5); }
4752
+ .sw-preview-tiles { grid-template-columns: repeat(6, minmax(0, 1fr)); }
4753
+ .sw-screen-preview-device { width: 20rem; }
4754
+ .sw-screen-preview-device .sw-screen-preview-bezel {
4755
+ background: var(--sw-color-text);
4756
+ border-radius: calc(var(--sw-radius-lg) + var(--sw-space-3));
4757
+ padding: var(--sw-space-3);
4758
+ }
4759
+ .sw-screen-preview-device .sw-screen-preview-screen {
4760
+ background: var(--sw-color-subtle);
4761
+ border-radius: var(--sw-radius-lg);
4762
+ gap: 0;
4763
+ overflow: hidden;
4764
+ padding: 0;
4765
+ }
4766
+ .sw-screen-preview-device .sw-screen-preview-header {
4767
+ background: var(--sw-color-surface);
4768
+ border-bottom: 1px solid var(--sw-color-border);
4769
+ padding: var(--sw-space-4);
4770
+ }
4771
+ .sw-screen-preview-device .sw-screen-preview-body { gap: var(--sw-space-3); padding: var(--sw-space-3); }
4772
+ .sw-screen-preview-device .sw-preview-group {
4773
+ background: var(--sw-color-surface);
4774
+ border-radius: var(--sw-radius-md);
4775
+ padding: var(--sw-space-3);
4776
+ }
4777
+ .sw-screen-preview-device .sw-preview-group + .sw-preview-group { border-top: 0; }
4778
+ .sw-screen-preview-device .sw-screen-preview-caption { text-align: center; }
4779
+ }
4780
+
4781
+ @media (forced-colors: active) {
4782
+ .sw-screen-preview-screen { border: 1px solid CanvasText; }
4783
+ }
4784
+
4785
+ @media (forced-colors: active) and (min-width: 48rem) {
4786
+ .sw-screen-preview-device .sw-screen-preview-screen { border: 0; }
4787
+ .sw-screen-preview-device .sw-screen-preview-bezel { border: 1px solid CanvasText; }
4788
+ }
4789
+
4609
4790
  .sw-hero {
4610
4791
  align-items: center;
4611
4792
  display: grid;
@@ -1,6 +1,7 @@
1
1
  import { type ReactNode } from 'react';
2
2
  import { type ColorScheme, type PaletteId, type Theme, type ThemeColors } from '@scalewing/tokens';
3
3
  import { type ThemeCanvas } from '../css/css-canvas-grouped.js';
4
+ import { type ThemeFrame } from '../css/css-theme-frame.js';
4
5
  export type ThemeProviderProps = {
5
6
  colorScheme?: ColorScheme | 'system';
6
7
  palette?: PaletteId;
@@ -14,7 +15,15 @@ export type ThemeProviderProps = {
14
15
  * colours keep it. An unknown value throws a `RangeError`.
15
16
  */
16
17
  canvas?: ThemeCanvas;
18
+ /**
19
+ * `none` (default) leaves the children in the document's flow; `site`
20
+ * (opt-in) makes the themed root a public site's page: a column at least
21
+ * the window's height whose last child (the footer) sits at its foot, so
22
+ * a short page's footer meets the window's foot. An unknown value throws
23
+ * a `RangeError`.
24
+ */
25
+ frame?: ThemeFrame;
17
26
  children: ReactNode;
18
27
  };
19
- export declare function ThemeProvider({ colorScheme, palette, colors, canvas, children, }: ThemeProviderProps): import("react").JSX.Element;
28
+ export declare function ThemeProvider({ colorScheme, palette, colors, canvas, frame, children, }: ThemeProviderProps): import("react").JSX.Element;
20
29
  export declare function useTheme(): Theme;
@@ -2,6 +2,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { createContext, useContext, useEffect, useMemo, useState, } from 'react';
3
3
  import { createTheme, secondaryActionBorder, semanticColorKeys, } from '@scalewing/tokens';
4
4
  import { assertThemeCanvas, } from '../css/css-canvas-grouped.js';
5
+ import { assertThemeFrame } from '../css/css-theme-frame.js';
5
6
  const ThemeContext = createContext(null);
6
7
  function schemeFromMedia() {
7
8
  if (typeof window === 'undefined' ||
@@ -25,8 +26,9 @@ function themeCssVars(theme) {
25
26
  vars['--sw-glass-specular'] = theme.glass.specular;
26
27
  return vars;
27
28
  }
28
- export function ThemeProvider({ colorScheme = 'system', palette, colors, canvas = 'default', children, }) {
29
+ export function ThemeProvider({ colorScheme = 'system', palette, colors, canvas = 'default', frame = 'none', children, }) {
29
30
  assertThemeCanvas(canvas);
31
+ assertThemeFrame(frame);
30
32
  const [systemScheme, setSystemScheme] = useState(schemeFromMedia);
31
33
  useEffect(() => {
32
34
  if (colorScheme !== 'system' || typeof window.matchMedia !== 'function') {
@@ -42,7 +44,7 @@ export function ThemeProvider({ colorScheme = 'system', palette, colors, canvas
42
44
  }, [colorScheme]);
43
45
  const resolvedScheme = colorScheme === 'system' ? systemScheme : colorScheme;
44
46
  const theme = useMemo(() => createTheme({ colorScheme: resolvedScheme, palette, colors }), [colors, palette, resolvedScheme]);
45
- return (_jsx(ThemeContext.Provider, { value: theme, children: _jsx("div", { "data-canvas": canvas === 'default' ? undefined : canvas, "data-palette": palette, "data-theme": theme.colorScheme, style: themeCssVars(theme), children: children }) }));
47
+ return (_jsx(ThemeContext.Provider, { value: theme, children: _jsx("div", { "data-canvas": canvas === 'default' ? undefined : canvas, "data-frame": frame === 'none' ? undefined : frame, "data-palette": palette, "data-theme": theme.colorScheme, style: themeCssVars(theme), children: children }) }));
46
48
  }
47
49
  export function useTheme() {
48
50
  const theme = useContext(ThemeContext);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scalewing/react",
3
- "version": "1.49.0",
3
+ "version": "1.50.0",
4
4
  "description": "Scalewing DOM components and package-owned CSS utilities.",
5
5
  "type": "module",
6
6
  "license": "MIT",