@bygghammar/cms-platform 0.92.2 → 0.94.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.
@@ -139,8 +139,8 @@ export function ReuploadOverwrite({
139
139
  <strong className="font-medium text-ink">Re-upload &amp; overwrite</strong> — pick the
140
140
  original source files again. Matching library files are regenerated at the current quality
141
141
  (480/768/1200/1920/2560, q0.90) and written over the same filenames, so nothing needs
142
- re-linking. Transparent PNGs/WebPs keep alpha and regenerate their PNG fallback.
143
- Superadmin only.
142
+ re-linking. Transparent PNGs/WebPs keep alpha in the WebP files; their PNG copy is
143
+ regenerated too, but responsive page images use the WebP files only. Superadmin only.
144
144
  </div>
145
145
  <label className="inline-flex items-center gap-2 text-[0.7rem] text-ink/70">
146
146
  <input
@@ -30,8 +30,10 @@ export type ReviewImageUploadProps = {
30
30
  };
31
31
 
32
32
  /**
33
- * Uploads a review image: cover-cropped to 16:10 and re-encoded to WebP at
34
- * 480/768/1200/1920 in the browser, then re-validated server-side before storage.
33
+ * Uploads a review image: re-encoded in the browser to WebP at 480/768/1200/1920,
34
+ * usually with its proportions kept (a variant taller than the 2400px stored-height
35
+ * limit is center-cropped vertically; pages crop around the focus point when
36
+ * shown), then re-validated server-side before storage.
35
37
  *
36
38
  * Host-agnostic: storage happens through the injected `upload` function.
37
39
  */
@@ -96,8 +98,10 @@ export function ReviewImageUpload({
96
98
  {busy ? "Optimizing…" : "Upload image"}
97
99
  </button>
98
100
  <p className="mt-1.5 text-[0.7rem] text-ink/55">
99
- Auto-cropped to 16:10 and compressed to WebP (480/768/1200/1920). Max 15 MB. PNGs with
100
- transparency keep their alpha channel and receive a PNG fallback.
101
+ Usually keeps its proportions, but a very tall image may be cropped at the top and
102
+ bottom to the 2400px height limit; pages crop it around the focus point. Compressed
103
+ to WebP (480/768/1200/1920). Max 15 MB. PNGs with
104
+ transparency keep their alpha channel in the WebP files; no PNG copy is made.
101
105
  </p>
102
106
  </div>
103
107
  );
@@ -26,7 +26,7 @@ import {
26
26
  Users,
27
27
  } from "lucide-react";
28
28
  import { useQuery, useQueryClient } from "@tanstack/react-query";
29
- import { useCallback, useRef } from "react";
29
+ import { useCallback, useRef, useState } from "react";
30
30
  import { useServerFn } from "@tanstack/react-start";
31
31
 
32
32
  import { MISSING_CREATED_ID } from "../../tracking/injections";
@@ -48,13 +48,16 @@ import { SitemapView, type SitemapCheck } from "../SitemapView";
48
48
  import { SiteSettingsView } from "../SiteSettingsView";
49
49
  import { TrackingView, type TrackingDeps } from "../TrackingView";
50
50
  import { UsersView } from "../UsersView";
51
- import { VersionView } from "../VersionView";
51
+ import { VersionView, type PlatformBuildInfo, type PlatformRelease } from "../VersionView";
52
+ import { moduleInventory, type ModuleSettingsRead } from "../../admin/module-inventory";
53
+ import type { VersionReport } from "../../admin/version-report";
52
54
  import { parseEffectiveAdminHost } from "../../site/locale-domains";
53
55
  import {
54
56
  useFeatureEnabled,
55
57
  useFeatureGate,
56
58
  useModuleGate,
57
59
  useMyRoles,
60
+ useResolvedSiteConfig,
58
61
  } from "../shell/use-resolved-config";
59
62
  import { backofficeHost, backofficePaths } from "./host";
60
63
 
@@ -127,14 +130,96 @@ export function UsersRoute() {
127
130
  );
128
131
  }
129
132
 
130
- /** CMS build and platform version screen. */
133
+ type SystemVersionPayload = {
134
+ build?: PlatformBuildInfo;
135
+ releases?: PlatformRelease[];
136
+ /** Present from hosts that build it with `buildVersionReport`. */
137
+ report?: VersionReport;
138
+ };
139
+
140
+ const SYSTEM_VERSION_QUERY_KEY = ["admin", "system-version"] as const;
141
+
142
+ /**
143
+ * Release, package and module screen.
144
+ *
145
+ * Read-only: it shows the module switches, it never changes them. The version
146
+ * read is not polled — no refetch on focus, a five-minute stale time — and a
147
+ * manual check goes through the server, which applies its own minimum
148
+ * interval before asking the registry again.
149
+ */
131
150
  export function VersionRoute() {
132
- const { fns } = backofficeHost();
133
- const fetchVersion = useServerFn(fns.getSystemVersion);
134
- const q = useQuery({ queryKey: ["admin", "system-version"], queryFn: () => fetchVersion() });
151
+ const host = backofficeHost();
152
+ const paths = backofficePaths(host);
153
+ const fetchVersion = useServerFn(host.fns.getSystemVersion);
154
+ const queryClient = useQueryClient();
155
+ const q = useQuery({
156
+ queryKey: SYSTEM_VERSION_QUERY_KEY,
157
+ queryFn: () => fetchVersion({ data: { refresh: false } }) as Promise<SystemVersionPayload>,
158
+ staleTime: 5 * 60_000,
159
+ refetchOnWindowFocus: false,
160
+ retry: 1,
161
+ });
162
+ const refreshing = useRef(false);
163
+ const [isRefreshing, setRefreshing] = useState(false);
164
+ // The `dataUpdatedAt` of the answer that was on screen when a "Check again"
165
+ // failed. The failure notice shows while that same answer is still shown, so
166
+ // any later successful answer (a retry or a re-read) clears it.
167
+ const [refreshFailedFor, setRefreshFailedFor] = useState<number | null>(null);
168
+ const refresh = useCallback(async () => {
169
+ if (refreshing.current) return;
170
+ refreshing.current = true;
171
+ setRefreshing(true);
172
+ try {
173
+ const next = (await fetchVersion({ data: { refresh: true } })) as SystemVersionPayload;
174
+ queryClient.setQueryData(SYSTEM_VERSION_QUERY_KEY, next);
175
+ setRefreshFailedFor(null);
176
+ } catch {
177
+ // Keep the last answer on screen, but say the check failed. The error
178
+ // itself (an expired session, a network failure) is not shown.
179
+ setRefreshFailedFor(queryClient.getQueryState(SYSTEM_VERSION_QUERY_KEY)?.dataUpdatedAt ?? 0);
180
+ } finally {
181
+ refreshing.current = false;
182
+ setRefreshing(false);
183
+ }
184
+ }, [fetchVersion, queryClient]);
185
+
186
+ const settings = useResolvedSiteConfig();
187
+ const settingsRead: ModuleSettingsRead =
188
+ settings.status === "loading"
189
+ ? { state: "loading" }
190
+ : settings.status === "error"
191
+ ? { state: "error" }
192
+ : { state: "ready", features: settings.config.features, stale: settings.status === "stale" };
193
+ const report = q.data?.report && q.data.report.schemaVersion === 1 ? q.data.report : null;
194
+ const cmsVersion =
195
+ report?.packages.find((p) => p.role === "cms")?.installed ?? q.data?.build?.platformVersion ?? null;
196
+ // The latest request failed while an earlier answer is still on screen:
197
+ // a failed "Check again", or a failed re-read of cached data.
198
+ const latestCheckFailed =
199
+ q.data !== undefined &&
200
+ ((refreshFailedFor !== null && refreshFailedFor === q.dataUpdatedAt) || q.isError);
201
+
135
202
  return (
136
203
  <VersionView
137
- deps={{ build: q.data?.build, releases: q.data?.releases ?? [], loading: q.isLoading }}
204
+ deps={{
205
+ build: q.data?.build,
206
+ releases: q.data?.releases ?? [],
207
+ loading: q.isLoading,
208
+ error: q.isError && !q.data,
209
+ latestCheckFailed,
210
+ onRetry: () => void q.refetch(),
211
+ report,
212
+ modules: moduleInventory(settingsRead, report?.packages, q.isLoading ? null : cmsVersion),
213
+ onRefresh: report ? () => void refresh() : undefined,
214
+ refreshing: isRefreshing,
215
+ siteName: host.siteConfig.name,
216
+ settingsHref: paths.settings,
217
+ renderLink: ({ href, className, children }) => (
218
+ <Link to={href} className={className}>
219
+ {children}
220
+ </Link>
221
+ ),
222
+ }}
138
223
  />
139
224
  );
140
225
  }
@@ -466,8 +551,8 @@ export function AdminOverviewRoute() {
466
551
  },
467
552
  {
468
553
  to: paths.version,
469
- title: "CMS & platform version",
470
- body: "Current CMS build, shared platform package version and release history with dates.",
554
+ title: "Version & modules",
555
+ body: "The live site release, installed CMS and module packages, module status and available updates.",
471
556
  icon: Package,
472
557
  },
473
558
  ...(isSuper && host.siteConfig.features.backup
@@ -21,11 +21,27 @@ import { backofficeNav, type BackofficeNavItem, type BackofficeNavOptions } from
21
21
  /** Query key shared with the site-settings screen. */
22
22
  export const SITE_SETTINGS_QUERY_KEY = ["admin", "site-settings"] as const;
23
23
 
24
+ /**
25
+ * Where the resolved configuration came from:
26
+ *
27
+ * - `loading`: no answer yet; `config` is the compiled code config.
28
+ * - `error`: the read failed (or answered without settings) and no earlier
29
+ * answer is held; `config` is the compiled code config, not the live one.
30
+ * - `ready`: `config` includes the saved overrides.
31
+ * - `stale`: a re-read failed; `config` is from the last successful read.
32
+ */
33
+ export type SiteSettingsStatus = "loading" | "error" | "ready" | "stale";
34
+
24
35
  /**
25
36
  * Code config merged with the stored overrides. While loading, the code config
26
- * is returned so the shell renders without a flash of empty navigation.
37
+ * is returned so the shell renders without a flash of empty navigation; a
38
+ * caller that must not present that fallback as the live answer reads `status`.
27
39
  */
28
- export function useResolvedSiteConfig(): { config: SiteConfig; isLoading: boolean } {
40
+ export function useResolvedSiteConfig(): {
41
+ config: SiteConfig;
42
+ isLoading: boolean;
43
+ status: SiteSettingsStatus;
44
+ } {
29
45
  const host = backofficeHost();
30
46
  const load = useServerFn(host.fns.getSiteSettings);
31
47
  const q = useQuery({
@@ -34,13 +50,22 @@ export function useResolvedSiteConfig(): { config: SiteConfig; isLoading: boolea
34
50
  staleTime: 30_000,
35
51
  });
36
52
 
37
- const resolved =
53
+ const fromSettings =
38
54
  (q.data?.config as SiteConfig | undefined) ??
39
55
  (q.data?.overrides
40
56
  ? resolveSiteConfig(host.siteConfig, q.data.overrides as never)
41
- : host.siteConfig);
57
+ : undefined);
58
+ const resolved = fromSettings ?? host.siteConfig;
59
+
60
+ const status: SiteSettingsStatus = q.isLoading
61
+ ? "loading"
62
+ : !fromSettings
63
+ ? "error"
64
+ : q.isError
65
+ ? "stale"
66
+ : "ready";
42
67
 
43
- return { config: resolved, isLoading: q.isLoading };
68
+ return { config: resolved, isLoading: q.isLoading, status };
44
69
  }
45
70
 
46
71
  /** Sidebar model built from the live (override-aware) configuration. */
@@ -23,6 +23,8 @@ import {
23
23
  MessageSquareQuote,
24
24
  } from "lucide-react";
25
25
 
26
+ import { POPUP_PACKAGE } from "../popups/package-name";
27
+
26
28
  /**
27
29
  * Every module a site can switch on.
28
30
  *
@@ -90,6 +92,14 @@ export type CmsModuleDefinition = {
90
92
  * enabled, so a disabled module mounts nothing.
91
93
  */
92
94
  readonly hasPublicRuntime?: boolean;
95
+ /**
96
+ * A separately published first-party package the module's runtime is built
97
+ * on, beyond the CMS package itself. The version screen reports its installed
98
+ * and published versions; every other module ships inside the CMS package and
99
+ * updates with it. Naming a package here also allows the server to look it up
100
+ * in the public registry, so only first-party packages belong here.
101
+ */
102
+ readonly package?: `@bygghammar/${string}`;
93
103
  };
94
104
 
95
105
  /**
@@ -145,6 +155,7 @@ export const CMS_MODULES = [
145
155
  nav: { path: "/popups", label: "Popups", icon: MessageSquare },
146
156
  requiresRole: "staff",
147
157
  hasPublicRuntime: true,
158
+ package: POPUP_PACKAGE,
148
159
  },
149
160
  ] as const satisfies readonly CmsModuleDefinition[];
150
161
 
@@ -103,9 +103,12 @@ export type FocalImageProps = Omit<ImgHTMLAttributes<HTMLImageElement>, "src"> &
103
103
  * `decoding="async"`, so an image nobody asked to prioritize can never compete
104
104
  * with the first screen. The one image that should compete says `priority`.
105
105
  *
106
- * When the source is a responsive WebP variant with a `-fallback.png` sibling,
107
- * renders a `<picture>` so browsers that do not support WebP (or need explicit
108
- * alpha handling) still receive a transparent PNG.
106
+ * A responsive WebP variant renders as one `<img>` whose `src` is the stored URL
107
+ * and whose `srcSet` holds the sibling widths. Only art direction adds a
108
+ * `<picture>`. Display is WebP-only: no `-fallback.png` is ever referenced,
109
+ * even for a transparent upload whose optimizer run wrote one, because nothing
110
+ * stored says which uploads have one. A browser without WebP support gets no
111
+ * PNG from this markup.
109
112
  */
110
113
  export function FocalImage({
111
114
  src,
@@ -158,17 +161,6 @@ export function FocalImage({
158
161
  fetchPriority: fetchPriorityAttr,
159
162
  };
160
163
 
161
- // Only wrap in <picture> when a fallback PNG is advertised. The fallback URL
162
- // is always derived, but it only exists for transparent uploads; using it for
163
- // every image would cause unnecessary 404s on opaque assets.
164
- const hasFallback =
165
- "fallbackSrc" in img &&
166
- img.fallbackSrc &&
167
- // A missing fallback is indistinguishable from an opaque asset, so we only
168
- // render the <source> when the primary URL is definitely a responsive WebP.
169
- img.type === "image/webp" &&
170
- img.srcSet;
171
-
172
164
  const artSources =
173
165
  responsive && !srcSetFailed
174
166
  ? (artDirected ?? [])
@@ -176,7 +168,11 @@ export function FocalImage({
176
168
  .filter((a) => a.img.src)
177
169
  : [];
178
170
 
179
- if (artSources.length || (hasFallback && "fallbackSrc" in img)) {
171
+ // Only art direction needs a <picture>. Its <img src> is the stored URL, the
172
+ // one file known to exist: a `-fallback.png` sibling exists only for
173
+ // transparent uploads, so pointing at it broke every opaque image for
174
+ // crawlers and for any browser that falls back to `src`.
175
+ if (artSources.length) {
180
176
  // The <picture> is only a source selector: it must not take the layout or
181
177
  // focus classes, or the inner <img> loses `object-position` and the crop
182
178
  // ignores the editor-chosen focus point.
@@ -195,7 +191,7 @@ export function FocalImage({
195
191
  <img
196
192
  {...imgProps}
197
193
  className={className}
198
- src={hasFallback && "fallbackSrc" in img ? img.fallbackSrc : img.src}
194
+ src={img.src}
199
195
  alt={rest.alt}
200
196
  style={merged}
201
197
  onError={(event) => {
@@ -2,8 +2,9 @@
2
2
  * Client-side media optimization run at upload time in the backoffice.
3
3
  * - Images → WebP variants, quality 0.90, max width 2560 (preserves aspect).
4
4
  * PNG/WebP/AVIF sources with an alpha channel keep their transparency in
5
- * every generated WebP file, and a single `-fallback.png` is produced so
6
- * older browsers still see a transparent image.
5
+ * every generated WebP file. They also get a single `-fallback.png` copy,
6
+ * but responsive page markup never references it: those images display as
7
+ * WebP only, so a browser without WebP support does not receive the PNG.
7
8
  * - Videos → original file + a JPG poster grabbed from ~10% into the clip.
8
9
  * - Everything else → passthrough.
9
10
  *
@@ -18,7 +19,7 @@ export type OptimizedFile = {
18
19
 
19
20
  export type OptimizedBundle = {
20
21
  primary: OptimizedFile;
21
- /** Optional sibling files uploaded alongside (e.g. video poster, PNG fallback). */
22
+ /** Optional sibling files uploaded alongside (e.g. video poster, transparent PNG copy). */
22
23
  extras: OptimizedFile[];
23
24
  };
24
25
 
@@ -96,9 +97,10 @@ export async function optimizeForUpload(
96
97
  const primary = files.find((f) => f.name.endsWith("-2560.webp")) ?? files[files.length - 1];
97
98
  const extras = files.filter((f) => f !== primary);
98
99
 
99
- // Transparent sources also get a single PNG fallback at the largest
100
- // width. It is stored as an extra so the primary WebP URL stays stable
101
- // and every existing page reference remains valid.
100
+ // Transparent sources also get a single PNG copy at the largest width,
101
+ // stored as an extra so the primary WebP URL stays stable. Responsive
102
+ // markup does not reference it (nothing stored says which uploads have
103
+ // one), so no page serves it as a fallback.
102
104
  if (transparent) {
103
105
  const fallback = await encodePngAt(bmp, largestW, largestH);
104
106
  extras.push({ name: `${base}-fallback.png`, blob: fallback, contentType: "image/png" });
@@ -1,19 +1,33 @@
1
1
  /**
2
2
  * Client-side preparation of review (testimonial) images.
3
3
  *
4
- * Uploaded review images keep their original aspect ratio (no crop at upload
5
- * time — cropping happens at display time around the editor-chosen focus point)
6
- * and are re-encoded to WebP at the four standard responsive widths so review
7
- * cards stay fast. The server re-validates every produced variant
8
- * (see `review-image.server.ts`) — this step is an optimisation, not a trust boundary.
4
+ * Uploaded review images usually keep their original aspect ratio. The exception
5
+ * is a source tall enough that a variant would exceed `MAX_STORED_HEIGHT`: that
6
+ * variant is center-cropped vertically to the limit. Display may crop again,
7
+ * around the editor-chosen focus point. Variants are re-encoded to WebP at the
8
+ * four review widths (`REVIEW_IMAGE_WIDTHS`)
9
+ * so review cards stay fast. The server re-validates every produced variant
10
+ * (see `server/review-image-validate.ts`) — this step is an optimisation, not a
11
+ * trust boundary.
9
12
  *
10
13
  * Browser-only (uses Canvas). Do NOT import from server code.
11
14
  */
12
15
 
13
- import { RESPONSIVE_WIDTHS } from "./srcset";
16
+ /**
17
+ * The review-image upload contract: exactly these widths, in this order. The
18
+ * server accepts nothing else (`mediaSchemas.reviewImage` requires four
19
+ * variants and `validateReviewVariant` checks each width against
20
+ * `REVIEW_WIDTHS`) and stores the 1920 file as the primary. Deliberately not
21
+ * the general `RESPONSIVE_WIDTHS`, which added 2560 for page media: five
22
+ * variants fail the server schema, so no review image could be stored.
23
+ */
24
+ export const REVIEW_IMAGE_WIDTHS = [480, 768, 1200, 1920] as const;
14
25
 
15
26
  export const REVIEW_ASPECT = 16 / 10;
16
- /** Safety ceiling for the stored image height (very tall panoramas). */
27
+ /**
28
+ * Height ceiling for every stored variant. When a variant's proportional height
29
+ * would exceed it, `encodeCoverWebp` center-crops the image vertically to fit.
30
+ */
17
31
  export const MAX_STORED_HEIGHT = 2400;
18
32
  export const REVIEW_QUALITY = 0.8;
19
33
  /** Hard client-side guard before we even decode the file. */
@@ -63,7 +77,7 @@ export async function prepareReviewImage(file: File): Promise<PreparedReviewImag
63
77
  }
64
78
  const variants: PreparedVariant[] = [];
65
79
  let optimizedBytes = 0;
66
- for (const targetW of RESPONSIVE_WIDTHS) {
80
+ for (const targetW of REVIEW_IMAGE_WIDTHS) {
67
81
  const width = Math.min(targetW, Math.max(320, bmp.width));
68
82
  let height = Math.max(1, Math.round((width * bmp.height) / bmp.width));
69
83
  if (height > MAX_STORED_HEIGHT) height = MAX_STORED_HEIGHT;
@@ -2,12 +2,13 @@
2
2
  * Responsive image helpers. Works with files produced by `optimizeForUpload`,
3
3
  * which uploads a base slug plus WebP variants: `-480`, `-768`, `-1200`, `-1920`
4
4
  * and (for assets uploaded after the HiDPI change) `-2560`. Transparent uploads
5
- * also produce a single `-fallback.png` for older browsers.
5
+ * also produce a single `-fallback.png`, but only those: nothing a page stores
6
+ * says which uploads were transparent, so that file is never advertised here
7
+ * and responsive images display as WebP only.
6
8
  *
7
9
  * Given the largest variant URL (`…-1920.webp`), derives sibling URLs and returns
8
- * a ready-to-use `srcSet`/`sizes` pair plus a PNG fallback when one exists. For
9
- * any other URL shape, returns the original `src` so legacy assets keep working
10
- * with no responsive scaling.
10
+ * a ready-to-use `srcSet`/`sizes` pair. For any other URL shape, returns the
11
+ * original `src` so legacy assets keep working with no responsive scaling.
11
12
  */
12
13
 
13
14
  import { resolveMediaSrc } from "./urls";
@@ -23,7 +24,14 @@ export type ResponsiveImage = {
23
24
  sizes?: string;
24
25
  /** MIME type of the responsive srcSet (`image/webp`). */
25
26
  type?: string;
26
- /** PNG fallback URL for browsers that do not support WebP or need alpha. */
27
+ /**
28
+ * @deprecated Never set. It used to be derived from the WebP name, but the
29
+ * optimizer writes `-fallback.png` only for transparent uploads, so for every
30
+ * opaque one the derived URL was a missing object rendered as `<img src>`.
31
+ * Kept so existing readers still compile; they now take their non-fallback
32
+ * path, and a transparent upload's PNG is no longer offered to browsers
33
+ * without WebP support.
34
+ */
27
35
  fallbackSrc?: string;
28
36
  };
29
37
 
@@ -53,7 +61,6 @@ export function responsiveImage(input: string | undefined, sizes?: string): Resp
53
61
  srcSet: parts.join(", "),
54
62
  sizes: sizes ?? "(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 1200px",
55
63
  type: "image/webp",
56
- fallbackSrc: `${base}-fallback.png${query}`,
57
64
  };
58
65
  }
59
66
 
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The separately published package the Popups module is built on.
3
+ *
4
+ * Named here, inside the module's own directory, so the module table can
5
+ * declare it without the rest of the CMS mentioning the popup package. This
6
+ * file holds only the name: importing it loads nothing from the package.
7
+ */
8
+ export const POPUP_PACKAGE = "@bygghammar/lightweight-popup" as const;
@@ -393,9 +393,11 @@ export async function uploadMediaBundleCore(
393
393
  /**
394
394
  * Upload a review (testimonial) image.
395
395
  *
396
- * The browser crops and re-encodes to WebP at the four responsive widths; every
397
- * variant is independently validated here (magic number, real dimensions,
398
- * aspect, compression ratio, byte ceiling) before anything is stored.
396
+ * The browser re-encodes to WebP at the four review widths, keeping proportions
397
+ * unless a variant would exceed the 2400px stored-height limit, which it
398
+ * center-crops vertically. Every variant is independently validated here (magic
399
+ * number, real dimensions, aspect, compression ratio, byte ceiling) before
400
+ * anything is stored.
399
401
  */
400
402
  export async function uploadReviewImageCore(
401
403
  deps: MediaLibraryDeps,
@@ -5,8 +5,9 @@
5
5
  * never trusts that: every variant is decoded here, sniffed for a real WebP magic
6
6
  * number, measured, and checked against the canonical review constraints
7
7
  * (expected width, sane aspect bounds, height ceiling, byte ceiling) before
8
- * anything is written to storage. Images keep their original aspect ratio;
9
- * cropping is done at display time around the saved focus point.
8
+ * anything is written to storage. Images usually keep their original aspect
9
+ * ratio: the browser center-crops a variant only when it would exceed the height
10
+ * ceiling, and display crops around the saved focus point.
10
11
  */
11
12
 
12
13
  export const REVIEW_WIDTHS = [480, 768, 1200, 1920] as const;