@c9up/nebula 0.1.5 → 0.1.7

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 (42) hide show
  1. package/dist/adapters/tailwind.d.ts +7 -0
  2. package/dist/adapters/tailwind.js +11 -1
  3. package/dist/adapters/unocss.js +11 -2
  4. package/dist/atoms/Image.d.ts +106 -0
  5. package/dist/atoms/Image.js +139 -0
  6. package/dist/atoms/NativeSelect.js +27 -2
  7. package/dist/atoms/index.d.ts +1 -0
  8. package/dist/atoms/index.js +1 -0
  9. package/dist/lib/image.d.ts +213 -0
  10. package/dist/lib/image.js +331 -0
  11. package/dist/lib/index.d.ts +1 -0
  12. package/dist/lib/index.js +1 -0
  13. package/dist/lib/motion.d.ts +25 -16
  14. package/dist/lib/motion.js +30 -24
  15. package/dist/molecules/Picture.d.ts +46 -0
  16. package/dist/molecules/Picture.js +62 -0
  17. package/dist/molecules/index.d.ts +1 -0
  18. package/dist/molecules/index.js +1 -0
  19. package/dist/organisms/Dialog.d.ts +11 -4
  20. package/dist/organisms/Dialog.js +2 -2
  21. package/dist/organisms/Sidebar.d.ts +9 -0
  22. package/dist/organisms/Sidebar.js +5 -2
  23. package/dist/primitives/floatingSurface.js +17 -8
  24. package/dist/primitives/presence.js +401 -5
  25. package/nebula.css +1 -1
  26. package/package.json +7 -6
  27. package/registry.json +25 -0
  28. package/src/adapters/tailwind.ts +11 -1
  29. package/src/adapters/unocss.ts +11 -2
  30. package/src/atoms/Image.ts +226 -0
  31. package/src/atoms/NativeSelect.ts +27 -2
  32. package/src/atoms/index.ts +10 -0
  33. package/src/lib/image.ts +470 -0
  34. package/src/lib/index.ts +25 -0
  35. package/src/lib/motion.ts +30 -26
  36. package/src/molecules/Picture.ts +92 -0
  37. package/src/molecules/index.ts +1 -0
  38. package/src/organisms/Dialog.ts +13 -6
  39. package/src/organisms/Sidebar.ts +15 -2
  40. package/src/primitives/floatingSurface.ts +18 -9
  41. package/src/primitives/presence.ts +428 -5
  42. package/theme.css +0 -35
@@ -0,0 +1,331 @@
1
+ /**
2
+ * Responsive image plumbing — the arithmetic behind `Image` and `Picture`.
3
+ *
4
+ * Everything here is pure: widths in, strings out. It lives apart from the
5
+ * components because the interesting part of a responsive image is not the
6
+ * markup, it is deciding *which* variants to offer the browser, and that
7
+ * decision is worth testing without a DOM.
8
+ *
9
+ * **Where the bytes come from is not nebula's business.** A component that
10
+ * imported an image processor would drag a native module into a package whose
11
+ * only peer is Aurora, and would pin every app to one way of serving images.
12
+ * So the URL is produced by a resolver the app installs once
13
+ * ({@link setImageResolver}). The default one points at `/__image`, the
14
+ * endpoint `@c9up/prism` registers; pointing it at a CDN instead is a
15
+ * one-line change and no component notices.
16
+ *
17
+ * The resolver asks for a width, a format and a quality — never a height and
18
+ * never a crop. Those three axes are the ones the server can safely allow-list,
19
+ * which is what keeps an open transformation endpoint from becoming a cache
20
+ * bomb. Cropping is `object-fit` on the element, where it costs nothing.
21
+ */
22
+ /**
23
+ * Widths for elements that are not the width of a screen.
24
+ *
25
+ * Avatars, icons, thumbnails. Without them a 48px avatar's nearest offer is
26
+ * 640 — thirteen times the pixels it can show — because every other ladder
27
+ * starts at a phone's viewport.
28
+ */
29
+ export const IMAGE_SIZES = [
30
+ 16, 32, 48, 64, 96, 128, 256, 384,
31
+ ];
32
+ /**
33
+ * The widths worth generating, from real device pixel counts.
34
+ *
35
+ * Astro's ladder, close to the one Next.js settled on independently — which is
36
+ * the reason to reuse it rather than invent one. They are device widths, not
37
+ * round numbers: 828 is the iPhone XR, 1668 is an iPad. A tidier
38
+ * `[400, 800, 1200, 1600]` misses every one of them and ships each device a
39
+ * slightly-too-large image.
40
+ *
41
+ * Density is what makes the ladder cheap to round up to. The nearest rung
42
+ * above 2400 here is 2560, six percent over; on a sparser ladder it would be
43
+ * 3840.
44
+ */
45
+ export const DEFAULT_RESOLUTIONS = [
46
+ 640, // older and lower-end phones
47
+ 750, // iPhone 6-8
48
+ 828, // iPhone XR/11
49
+ 960, // older horizontal phones
50
+ 1080, // iPhone 6-8 Plus
51
+ 1280, // 720p
52
+ 1668, // various iPads
53
+ 1920, // 1080p
54
+ 2048, // QXGA
55
+ 2560, // WQXGA
56
+ 3200, // QHD+
57
+ 3840, // 4K
58
+ 4480, // 4.5K
59
+ 5120, // 5K
60
+ 6016, // 6K
61
+ ];
62
+ /**
63
+ * The ladder without the sizes only a desktop display asks for.
64
+ *
65
+ * Worth choosing deliberately for a full-width image: the top of
66
+ * {@link DEFAULT_RESOLUTIONS} is eleven extra variants to store and warm, for
67
+ * screens most sites see rarely.
68
+ */
69
+ export const LIMITED_RESOLUTIONS = [
70
+ 640, 750, 828, 1080, 1280, 1668, 2048, 2560,
71
+ ];
72
+ /**
73
+ * Every width a component may ask for, ascending.
74
+ *
75
+ * This list and the endpoint's allow-list are the same list, and that is the
76
+ * contract between the two halves: a component that emits a width the endpoint
77
+ * does not serve produces a `srcset` where every entry is a 400, and the page
78
+ * silently falls back to the one `src`.
79
+ *
80
+ * It is also why a declared width is never emitted literally. `width: 1200`
81
+ * asking for 1200 and 2400 would be two widths no allow-list contains, and
82
+ * allow-listing whatever an author happens to type is not an allow-list.
83
+ */
84
+ export function allSizes(breakpoints) {
85
+ return [...new Set([...IMAGE_SIZES, ...breakpoints])].sort((a, b) => a - b);
86
+ }
87
+ /**
88
+ * The smallest offered width that still covers `target`.
89
+ *
90
+ * Upwards, never downwards. Rounding down is how a 2x screen is handed an
91
+ * image with fewer pixels than it can show, and the result is soft in a way
92
+ * that is hard to attribute later. Falls back to the largest rung, which the
93
+ * endpoint clamps to the source anyway.
94
+ */
95
+ function snapUp(target, sizes) {
96
+ return sizes.find((size) => size >= target) ?? sizes[sizes.length - 1] ?? 0;
97
+ }
98
+ /**
99
+ * The width to actually request for a declared one.
100
+ *
101
+ * The `src` attribute and a density entry are single URLs rather than ladders,
102
+ * and they are subject to the same contract: a literal declared width is a
103
+ * width no allow-list contains.
104
+ */
105
+ export function offeredWidth(width, breakpoints = DEFAULT_RESOLUTIONS) {
106
+ return snapUp(width, allSizes(breakpoints));
107
+ }
108
+ /**
109
+ * The widths to offer, ascending.
110
+ *
111
+ * Every value comes off {@link allSizes} — see there for why a declared width
112
+ * is never emitted literally. 1x and 2x are always represented, rounded up, so
113
+ * the common case of a dense screen at the declared width is never served
114
+ * fewer pixels than it can show.
115
+ */
116
+ export function imageWidths(options) {
117
+ const { width, layout, breakpoints = DEFAULT_RESOLUTIONS, originalWidth, } = options;
118
+ const every = allSizes(breakpoints);
119
+ /**
120
+ * Drop what the source cannot fill, but never return nothing.
121
+ *
122
+ * A source smaller than every rung still has to be offered at some width,
123
+ * and the endpoint clamps to the source rather than enlarging it.
124
+ */
125
+ const withinSource = (candidates) => {
126
+ if (originalWidth === undefined)
127
+ return candidates;
128
+ const kept = candidates.filter((candidate) => candidate <= originalWidth);
129
+ if (kept.length > 0)
130
+ return kept;
131
+ const smallest = candidates[0];
132
+ return smallest === undefined ? [] : [smallest];
133
+ };
134
+ if (layout === "full-width") {
135
+ return withinSource([...breakpoints]);
136
+ }
137
+ if (width === undefined || width <= 0)
138
+ return [];
139
+ const oneX = snapUp(width, every);
140
+ const twoX = snapUp(width * 2, every);
141
+ if (layout === "fixed") {
142
+ return withinSource([...new Set([oneX, twoX])].sort((a, b) => a - b));
143
+ }
144
+ if (layout === "constrained") {
145
+ // Below its declared width a constrained image is the width of the
146
+ // viewport, so the viewport rungs under the cap earn their place. The
147
+ // floor is the smallest device width: nothing narrower is a screen,
148
+ // and offering 32w to an image that is at least 640 wide is noise in
149
+ // every `srcset` on the page.
150
+ const floor = breakpoints[0] ?? 0;
151
+ const intermediate = every.filter((size) => size >= floor && size <= twoX);
152
+ return withinSource([...new Set([oneX, twoX, ...intermediate])].sort((a, b) => a - b));
153
+ }
154
+ return [];
155
+ }
156
+ /**
157
+ * The `sizes` attribute for a layout.
158
+ *
159
+ * Without it the browser assumes the image is the full width of the viewport
160
+ * and picks the largest variant on every screen — which is the failure mode
161
+ * where adding a `srcset` makes a page slower, not faster.
162
+ */
163
+ export function imageSizes(options) {
164
+ const { width, layout } = options;
165
+ if (width === undefined || width <= 0) {
166
+ return layout === "full-width" ? "100vw" : undefined;
167
+ }
168
+ switch (layout) {
169
+ case "constrained":
170
+ // Wider screen than the cap: the image stops at the cap. Narrower:
171
+ // it is the screen.
172
+ return `(min-width: ${width}px) ${width}px, 100vw`;
173
+ case "fixed":
174
+ return `${width}px`;
175
+ case "full-width":
176
+ return "100vw";
177
+ default:
178
+ return undefined;
179
+ }
180
+ }
181
+ /** A `srcset` with `w` descriptors, or `undefined` when there is nothing to offer. */
182
+ export function imageSrcSet(options) {
183
+ const { src, widths, format, quality, resolve = imageUrl } = options;
184
+ if (widths.length === 0)
185
+ return undefined;
186
+ return widths
187
+ .map((width) => `${resolve(src, { width, format, quality })} ${width}w`)
188
+ .join(", ");
189
+ }
190
+ /**
191
+ * A `srcset` with `x` descriptors.
192
+ *
193
+ * The alternative to widths, never a companion to them: a `srcset` mixing `w`
194
+ * and `x` descriptors is invalid, and browsers handle the mixture by ignoring
195
+ * the whole attribute.
196
+ */
197
+ export function densitySrcSet(options) {
198
+ const { src, width, densities, format, quality, originalWidth, breakpoints = DEFAULT_RESOLUTIONS, resolve = imageUrl, } = options;
199
+ if (width <= 0 || densities.length === 0)
200
+ return undefined;
201
+ const seen = new Set();
202
+ const entries = [];
203
+ for (const density of [...densities].sort((a, b) => a - b)) {
204
+ if (density <= 0)
205
+ continue;
206
+ const target = offeredWidth(Math.round(width * density), breakpoints);
207
+ if (originalWidth !== undefined && target > originalWidth)
208
+ continue;
209
+ if (seen.has(target))
210
+ continue;
211
+ seen.add(target);
212
+ entries.push(`${resolve(src, { width: target, format, quality })} ${density}x`);
213
+ }
214
+ return entries.length === 0 ? undefined : entries.join(", ");
215
+ }
216
+ /**
217
+ * Where the default resolver sends its requests.
218
+ *
219
+ * `__`-prefixed because it is the framework's route rather than the
220
+ * application's, the same convention as `__assets` and `__relay`.
221
+ */
222
+ let endpoint = "/__image";
223
+ /** Point the built-in resolver at a different path. */
224
+ export function setImageEndpoint(path) {
225
+ endpoint = path;
226
+ }
227
+ /**
228
+ * The resolver used when an application installs none.
229
+ *
230
+ * Parameter names are one letter because they end up in every `srcset` entry
231
+ * of every image on the page, and a `srcset` with fifteen entries repeats them
232
+ * fifteen times.
233
+ */
234
+ export const defaultImageResolver = (src, transform) => {
235
+ const params = new URLSearchParams({
236
+ src,
237
+ w: String(Math.round(transform.width)),
238
+ });
239
+ if (transform.format !== undefined)
240
+ params.set("f", transform.format);
241
+ if (transform.quality !== undefined) {
242
+ params.set("q", String(Math.round(transform.quality)));
243
+ }
244
+ return `${endpoint}?${params.toString()}`;
245
+ };
246
+ let resolver = defaultImageResolver;
247
+ /**
248
+ * Install the resolver every image uses.
249
+ *
250
+ * Call it once at startup — from the app's entry on the client, and from the
251
+ * same module on the server so SSR and hydration agree. They must agree: an
252
+ * `src` that differs between the two makes the browser discard the image the
253
+ * server already started fetching and request another.
254
+ *
255
+ * Passing `null` restores {@link defaultImageResolver}.
256
+ */
257
+ export function setImageResolver(next) {
258
+ resolver = next ?? defaultImageResolver;
259
+ }
260
+ /** The resolver currently installed. */
261
+ export function getImageResolver() {
262
+ return resolver;
263
+ }
264
+ /** Build one image URL through the installed resolver. */
265
+ export function imageUrl(src, transform) {
266
+ return resolver(src, transform);
267
+ }
268
+ /** Tailwind classes for a layout. Height is always `auto` so the ratio holds. */
269
+ export function layoutClasses(layout) {
270
+ switch (layout) {
271
+ case "full-width":
272
+ return "h-auto w-full";
273
+ case "constrained":
274
+ return "h-auto max-w-full";
275
+ default:
276
+ return "h-auto";
277
+ }
278
+ }
279
+ const FIT_CLASSES = {
280
+ cover: "object-cover",
281
+ contain: "object-contain",
282
+ fill: "object-fill",
283
+ none: "object-none",
284
+ "scale-down": "object-scale-down",
285
+ };
286
+ const POSITION_CLASSES = {
287
+ center: "object-center",
288
+ top: "object-top",
289
+ bottom: "object-bottom",
290
+ left: "object-left",
291
+ right: "object-right",
292
+ "top left": "object-left-top",
293
+ "top right": "object-right-top",
294
+ "bottom left": "object-left-bottom",
295
+ "bottom right": "object-right-bottom",
296
+ };
297
+ /** The `object-fit` class for a fit value. */
298
+ export function fitClass(fit) {
299
+ return fit === undefined ? undefined : FIT_CLASSES[fit];
300
+ }
301
+ /** The `object-position` class for a position value. */
302
+ export function positionClass(position) {
303
+ return position === undefined ? undefined : POSITION_CLASSES[position];
304
+ }
305
+ /** The MIME type a `<source type>` needs for a format. */
306
+ export function mimeType(format) {
307
+ return `image/${format}`;
308
+ }
309
+ const EXTENSIONS = {
310
+ avif: "avif",
311
+ jpeg: "jpeg",
312
+ jpg: "jpeg",
313
+ png: "png",
314
+ webp: "webp",
315
+ };
316
+ /**
317
+ * The format a source's file name claims, if any.
318
+ *
319
+ * Used to pick the fallback of a `<picture>`: converting a PNG with
320
+ * transparency to JPEG puts a black box behind it, so the fallback follows the
321
+ * source rather than a fixed default. The extension is a claim, not proof —
322
+ * only the server knows what the bytes really are, and it re-reads them.
323
+ */
324
+ export function formatFromSource(src) {
325
+ // Cut the query and fragment first, or `photo.png?v=2` has no extension.
326
+ const path = src.split(/[?#]/, 1)[0] ?? "";
327
+ const dot = path.lastIndexOf(".");
328
+ if (dot === -1)
329
+ return undefined;
330
+ return EXTENSIONS[path.slice(dot + 1).toLowerCase()];
331
+ }
@@ -9,4 +9,5 @@
9
9
  export { type ClassValue, clsx, cn, twMerge } from "./cn.js";
10
10
  export { type ClassOverrides, type CompoundVariant, cva, type VariantConfig, type VariantProps, type VariantSelection, type VariantShape, } from "./cva.js";
11
11
  export { byId, resetIds, uid } from "./id.js";
12
+ export { allSizes, DEFAULT_RESOLUTIONS, defaultImageResolver, densitySrcSet, fitClass, formatFromSource, getImageResolver, type ImageFit, type ImageFormat, type ImageLayout, type ImagePosition, type ImageTransform, type ImageUrlResolver, imageSizes, imageSrcSet, imageUrl, imageWidths, LIMITED_RESOLUTIONS, layoutClasses, mimeType, positionClass, setImageEndpoint, setImageResolver, } from "./image.js";
12
13
  export { accessor, callHandler, type Reactive, read, readOr } from "./props.js";
package/dist/lib/index.js CHANGED
@@ -9,4 +9,5 @@
9
9
  export { clsx, cn, twMerge } from "./cn.js";
10
10
  export { cva, } from "./cva.js";
11
11
  export { byId, resetIds, uid } from "./id.js";
12
+ export { allSizes, DEFAULT_RESOLUTIONS, defaultImageResolver, densitySrcSet, fitClass, formatFromSource, getImageResolver, imageSizes, imageSrcSet, imageUrl, imageWidths, LIMITED_RESOLUTIONS, layoutClasses, mimeType, positionClass, setImageEndpoint, setImageResolver, } from "./image.js";
12
13
  export { accessor, callHandler, read, readOr } from "./props.js";
@@ -1,27 +1,36 @@
1
1
  /**
2
2
  * Enter and exit animations for overlays.
3
3
  *
4
- * shadcn drives these with `animate-in` / `animate-out` from `tw-animate-css`
5
- * a Tailwind plugin, and a dependency. nebula declares the four keyframes it
6
- * needs in `theme.css` and references them through arbitrary animation values,
7
- * which every one of the three adapters supports and none of them needs a
8
- * plugin for.
4
+ * These are shadcn's own class strings, verbatim, and they come from the same
5
+ * place shadcn's do: `tw-animate-css`, which registers `animate-in` /
6
+ * `animate-out` and the `fade-*` / `zoom-*` / `slide-*` modifiers through
7
+ * `@theme inline` and `@utility`.
9
8
  *
10
- * The durations are asymmetric on purpose: entering is slower than leaving.
11
- * A surface appearing wants to be noticed, a surface dismissed wants to be out
12
- * of the way matching them makes closing feel sluggish.
9
+ * nebula used to declare four bespoke keyframes and reference them through
10
+ * ARBITRARY animation values (`animate-[nebula-zoom-out_120ms_ease-in]`), to
11
+ * avoid the plugin. That is what made a missing stylesheet fatal rather than
12
+ * cosmetic: Tailwind compiles an arbitrary value unconditionally, so the
13
+ * `animation` property was always set while the keyframes behind it might exist
14
+ * nowhere — the browser then never fires `animationend` and every closed
15
+ * overlay stays in the document. With a theme-registered utility the class
16
+ * simply is not emitted when the theme is absent, the element animates not at
17
+ * all, and closing is instant. That is the whole reason upstream registers
18
+ * rather than inlines, and it is why the deviation is gone.
13
19
  *
14
- * Every string ends in `motion-reduce:animate-none`. That is not only a
15
- * courtesy: `onExitFinished` reads the computed style to decide whether to
16
- * wait, sees no animation, and removes the node immediately. Reduced motion
17
- * therefore gets an instant close rather than a delayed one, with no branch in
18
- * the component.
20
+ * The durations are shadcn's too, which means asymmetric by way of the sheet
21
+ * variants only; everything else takes `tw-animate-css`'s defaults.
19
22
  */
20
23
  import type { Side } from "../primitives/floating.js";
21
- /** Popovers, menus, selects — scale up from the anchor. */
22
- export declare const zoomInOut = "data-[state=open]:animate-[nebula-zoom-in_150ms_ease-out] data-[state=closed]:animate-[nebula-zoom-out_120ms_ease-in] motion-reduce:animate-none";
24
+ /**
25
+ * Popovers, menus, selects scale up from the anchor.
26
+ *
27
+ * shadcn's dialog and popover string. `motion-reduce:animate-none` is ours and
28
+ * stays: it also makes `onExitFinished` see no animation and remove the node at
29
+ * once, so reduced motion gets an instant close with no branch in the component.
30
+ */
31
+ export declare const zoomInOut = "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0 data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95 motion-reduce:animate-none";
23
32
  /** Backdrops and tooltips — no movement, just opacity. */
24
- export declare const fadeInOut = "data-[state=open]:animate-[nebula-fade-in_150ms_ease-out] data-[state=closed]:animate-[nebula-fade-out_120ms_ease-in] motion-reduce:animate-none";
33
+ export declare const fadeInOut = "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0 motion-reduce:animate-none";
25
34
  /**
26
35
  * Panels that slide in from an edge — Sheet, Drawer.
27
36
  *
@@ -1,26 +1,35 @@
1
1
  /**
2
2
  * Enter and exit animations for overlays.
3
3
  *
4
- * shadcn drives these with `animate-in` / `animate-out` from `tw-animate-css`
5
- * a Tailwind plugin, and a dependency. nebula declares the four keyframes it
6
- * needs in `theme.css` and references them through arbitrary animation values,
7
- * which every one of the three adapters supports and none of them needs a
8
- * plugin for.
4
+ * These are shadcn's own class strings, verbatim, and they come from the same
5
+ * place shadcn's do: `tw-animate-css`, which registers `animate-in` /
6
+ * `animate-out` and the `fade-*` / `zoom-*` / `slide-*` modifiers through
7
+ * `@theme inline` and `@utility`.
9
8
  *
10
- * The durations are asymmetric on purpose: entering is slower than leaving.
11
- * A surface appearing wants to be noticed, a surface dismissed wants to be out
12
- * of the way matching them makes closing feel sluggish.
9
+ * nebula used to declare four bespoke keyframes and reference them through
10
+ * ARBITRARY animation values (`animate-[nebula-zoom-out_120ms_ease-in]`), to
11
+ * avoid the plugin. That is what made a missing stylesheet fatal rather than
12
+ * cosmetic: Tailwind compiles an arbitrary value unconditionally, so the
13
+ * `animation` property was always set while the keyframes behind it might exist
14
+ * nowhere — the browser then never fires `animationend` and every closed
15
+ * overlay stays in the document. With a theme-registered utility the class
16
+ * simply is not emitted when the theme is absent, the element animates not at
17
+ * all, and closing is instant. That is the whole reason upstream registers
18
+ * rather than inlines, and it is why the deviation is gone.
13
19
  *
14
- * Every string ends in `motion-reduce:animate-none`. That is not only a
15
- * courtesy: `onExitFinished` reads the computed style to decide whether to
16
- * wait, sees no animation, and removes the node immediately. Reduced motion
17
- * therefore gets an instant close rather than a delayed one, with no branch in
18
- * the component.
20
+ * The durations are shadcn's too, which means asymmetric by way of the sheet
21
+ * variants only; everything else takes `tw-animate-css`'s defaults.
19
22
  */
20
- /** Popovers, menus, selects — scale up from the anchor. */
21
- export const zoomInOut = "data-[state=open]:animate-[nebula-zoom-in_150ms_ease-out] data-[state=closed]:animate-[nebula-zoom-out_120ms_ease-in] motion-reduce:animate-none";
23
+ /**
24
+ * Popovers, menus, selects scale up from the anchor.
25
+ *
26
+ * shadcn's dialog and popover string. `motion-reduce:animate-none` is ours and
27
+ * stays: it also makes `onExitFinished` see no animation and remove the node at
28
+ * once, so reduced motion gets an instant close with no branch in the component.
29
+ */
30
+ export const zoomInOut = "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0 data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95 motion-reduce:animate-none";
22
31
  /** Backdrops and tooltips — no movement, just opacity. */
23
- export const fadeInOut = "data-[state=open]:animate-[nebula-fade-in_150ms_ease-out] data-[state=closed]:animate-[nebula-fade-out_120ms_ease-in] motion-reduce:animate-none";
32
+ export const fadeInOut = "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0 motion-reduce:animate-none";
24
33
  /**
25
34
  * Panels that slide in from an edge — Sheet, Drawer.
26
35
  *
@@ -28,12 +37,9 @@ export const fadeInOut = "data-[state=open]:animate-[nebula-fade-in_150ms_ease-o
28
37
  * depends on the panel's own size and only `translate-x-full` knows that.
29
38
  */
30
39
  export function slideFrom(side) {
31
- const axis = side === "left"
32
- ? "data-[state=closed]:-translate-x-full"
33
- : side === "right"
34
- ? "data-[state=closed]:translate-x-full"
35
- : side === "top"
36
- ? "data-[state=closed]:-translate-y-full"
37
- : "data-[state=closed]:translate-y-full";
38
- return `transition-transform duration-300 ease-in-out data-[state=open]:translate-x-0 data-[state=open]:translate-y-0 ${axis} motion-reduce:transition-none`;
40
+ // shadcn's sheet: a slide utility per side, and its asymmetric durations —
41
+ // entering is slower than leaving, because a surface appearing wants to be
42
+ // noticed while one dismissed wants to be out of the way.
43
+ const slide = `data-[state=open]:slide-in-from-${side} data-[state=closed]:slide-out-to-${side}`;
44
+ return `data-[state=open]:animate-in data-[state=closed]:animate-out ${slide} data-[state=open]:duration-500 data-[state=closed]:duration-300 motion-reduce:animate-none`;
39
45
  }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Picture — the same image in several formats, browser picks the first it can.
3
+ *
4
+ * `Image` asks for one format and trusts it to be supported. That is fine for
5
+ * WebP, which every current browser reads. It is not fine for AVIF, which is
6
+ * 20-30% smaller again and still not universal — so AVIF can only be offered
7
+ * *alongside* a fallback, which is what `<picture>` is for.
8
+ *
9
+ * ```ts
10
+ * Picture({ src: "/photos/hero.jpg", alt: "…", width: 1200, height: 800 })
11
+ * ```
12
+ *
13
+ * renders a `<source type="image/avif">`, a `<source type="image/webp">` and
14
+ * the original as the `<img>` underneath. The browser takes the first `type`
15
+ * it understands; one that understands none takes the `<img>`, which is also
16
+ * what a crawler reads.
17
+ *
18
+ * The `<img>` is `Image` itself, with every prop forwarded. That is what makes
19
+ * this a molecule rather than an atom, and it is deliberate: the `srcset`
20
+ * arithmetic, the `sizes`, the loading attributes and the layout classes have
21
+ * exactly one implementation, so a `<source>` can never end up offering a
22
+ * different ladder than the `<img>` beneath it.
23
+ */
24
+ import { type ImageProps } from "../atoms/Image.js";
25
+ import { type ImageFormat } from "../lib/image.js";
26
+ import { type Reactive } from "../lib/props.js";
27
+ export interface PictureProps extends ImageProps {
28
+ /**
29
+ * Formats to offer above the fallback, best first.
30
+ *
31
+ * Order is the whole contract: a browser takes the first `type` it
32
+ * supports without comparing sizes, so listing WebP before AVIF means no
33
+ * browser ever receives the smaller file.
34
+ */
35
+ formats?: Reactive<readonly ImageFormat[]>;
36
+ /**
37
+ * Format of the `<img>` underneath.
38
+ *
39
+ * Defaults to what the source's extension claims, falling back to JPEG.
40
+ * Following the source matters for PNG: a transparent logo flattened to
41
+ * JPEG arrives with a black background, and the fallback is exactly the
42
+ * path taken by the browsers least likely to be checked.
43
+ */
44
+ fallbackFormat?: Reactive<ImageFormat | undefined>;
45
+ }
46
+ export declare const Picture: (props?: PictureProps | undefined) => import("@c9up/aurora").TemplateResult;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Picture — the same image in several formats, browser picks the first it can.
3
+ *
4
+ * `Image` asks for one format and trusts it to be supported. That is fine for
5
+ * WebP, which every current browser reads. It is not fine for AVIF, which is
6
+ * 20-30% smaller again and still not universal — so AVIF can only be offered
7
+ * *alongside* a fallback, which is what `<picture>` is for.
8
+ *
9
+ * ```ts
10
+ * Picture({ src: "/photos/hero.jpg", alt: "…", width: 1200, height: 800 })
11
+ * ```
12
+ *
13
+ * renders a `<source type="image/avif">`, a `<source type="image/webp">` and
14
+ * the original as the `<img>` underneath. The browser takes the first `type`
15
+ * it understands; one that understands none takes the `<img>`, which is also
16
+ * what a crawler reads.
17
+ *
18
+ * The `<img>` is `Image` itself, with every prop forwarded. That is what makes
19
+ * this a molecule rather than an atom, and it is deliberate: the `srcset`
20
+ * arithmetic, the `sizes`, the loading attributes and the layout classes have
21
+ * exactly one implementation, so a `<source>` can never end up offering a
22
+ * different ladder than the `<img>` beneath it.
23
+ */
24
+ import { component, html } from "@c9up/aurora";
25
+ import { Image, resolvedSizes, resolvedSrcSet, } from "../atoms/Image.js";
26
+ import { formatFromSource, mimeType } from "../lib/image.js";
27
+ import { read, readOr } from "../lib/props.js";
28
+ const DEFAULT_FORMATS = ["avif", "webp"];
29
+ /**
30
+ * The format for the `<img>`, never one of the modern ones.
31
+ *
32
+ * A fallback in a format the `<source>` elements already cover is not a
33
+ * fallback — a browser that cannot read AVIF or WebP would be handed WebP.
34
+ */
35
+ function fallbackOf(props) {
36
+ const explicit = read(props.fallbackFormat);
37
+ if (explicit !== undefined)
38
+ return explicit;
39
+ const claimed = formatFromSource(read(props.src));
40
+ return claimed === "png" ? "png" : "jpeg";
41
+ }
42
+ export const Picture = component((props) => {
43
+ const formats = () => readOr(props.formats, DEFAULT_FORMATS);
44
+ return html `<picture data-slot="picture">
45
+ ${() => formats().map((format) => {
46
+ const srcset = resolvedSrcSet(props, format);
47
+ // A layout that generates no ladder (`none`, or a `fixed`
48
+ // image with no width) leaves nothing for a `<source>` to
49
+ // point at, and an empty `srcset` makes the browser pick that
50
+ // source and render nothing.
51
+ if (srcset === undefined)
52
+ return null;
53
+ return html `<source
54
+ data-slot="picture-source"
55
+ type="${mimeType(format)}"
56
+ srcset="${srcset}"
57
+ sizes="${() => resolvedSizes(props)}"
58
+ />`;
59
+ })}
60
+ ${() => Image({ ...props, format: fallbackOf(props) })}
61
+ </picture>`;
62
+ });
@@ -29,6 +29,7 @@ export { InputOTP, type InputOTPProps } from "./InputOTP.js";
29
29
  export { Item, ItemActions, ItemContent, ItemDescription, ItemGroup, ItemMedia, type ItemProps, ItemSeparator, ItemTitle, type ItemVariants, itemVariants, } from "./Item.js";
30
30
  export { Message, type MessageProps } from "./Message.js";
31
31
  export { type PageSlot, Pagination, type PaginationProps, pageWindow, } from "./Pagination.js";
32
+ export { Picture, type PictureProps } from "./Picture.js";
32
33
  export { RadioGroup, type RadioGroupProps, type RadioOption, } from "./RadioGroup.js";
33
34
  export { Resizable, type ResizableProps } from "./Resizable.js";
34
35
  export { Table, TableBody, TableCaption, TableCell, type TableCellProps, TableFooter, TableHead, TableHeader, type TableProps, TableRow, type TableRowProps, } from "./Table.js";
@@ -29,6 +29,7 @@ export { InputOTP } from "./InputOTP.js";
29
29
  export { Item, ItemActions, ItemContent, ItemDescription, ItemGroup, ItemMedia, ItemSeparator, ItemTitle, itemVariants, } from "./Item.js";
30
30
  export { Message } from "./Message.js";
31
31
  export { Pagination, pageWindow, } from "./Pagination.js";
32
+ export { Picture } from "./Picture.js";
32
33
  export { RadioGroup, } from "./RadioGroup.js";
33
34
  export { Resizable } from "./Resizable.js";
34
35
  export { Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow, } from "./Table.js";
@@ -16,7 +16,6 @@
16
16
  * required rather than optional — `srOnlyTitle` is there for designs that show
17
17
  * no visible heading.
18
18
  */
19
- import type { Child } from "../lib/children.js";
20
19
  import { type Slot } from "../lib/children.js";
21
20
  import { type Reactive } from "../lib/props.js";
22
21
  export declare const dialogBackdropClasses = "fixed inset-0 z-50 bg-black/50";
@@ -24,9 +23,17 @@ export declare const dialogPanelClasses = "bg-background fixed top-1/2 left-1/2
24
23
  export interface DialogProps {
25
24
  /** Rendered inside the trigger button. Omit to drive `open` yourself. */
26
25
  trigger?: Slot;
27
- /** Announced on open. Hide it visually with `srOnlyTitle`. */
28
- title: Child;
29
- description?: Child;
26
+ /**
27
+ * Announced on open. Hide it visually with `srOnlyTitle`.
28
+ *
29
+ * A `Slot`, so it may be an accessor: one Dialog driven between "create" and
30
+ * "edit" needs a title that follows. It already behaved that way — the
31
+ * renderer binds whatever it is given — while the type said `Child` and
32
+ * refused the function, so the working call did not compile and the type
33
+ * disagreed with `children` beside it for no reason.
34
+ */
35
+ title: Slot;
36
+ description?: Slot;
30
37
  children?: Slot;
31
38
  /** Actions, laid out bottom-right. */
32
39
  footer?: Slot;
@@ -59,14 +59,14 @@ export const Dialog = component((props) => {
59
59
  id="${titleId}"
60
60
  data-slot="dialog-title"
61
61
  class="${cn("text-lg leading-none font-semibold", props.srOnlyTitle === true ? "sr-only" : "")}"
62
- >${props.title}</h2>
62
+ >${slot(props.title)}</h2>
63
63
  ${props.description === undefined
64
64
  ? null
65
65
  : html `<p
66
66
  id="${descriptionId}"
67
67
  data-slot="dialog-description"
68
68
  class="text-muted-foreground text-sm"
69
- >${props.description}</p>`}
69
+ >${slot(props.description)}</p>`}
70
70
  </div>
71
71
  ${slot(props.children)}
72
72
  ${props.footer === undefined