@getvitops/generator 7.0.0 → 8.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,124 @@
1
1
  # @getvitops/generator
2
2
 
3
+ ## 8.0.1
4
+
5
+ ### Patch Changes
6
+
7
+ - @getvitops/utils@8.0.1
8
+
9
+ ## 8.0.0
10
+
11
+ ### Major Changes
12
+
13
+ - Rebuild the lightbox on one dialog + the existing carousel, fixing the flicker on
14
+ prev/next and the stutter on open.
15
+
16
+ **Breaking (`css`/`bricks` and `tailwind` formats — `.lightbox-dialog`'s markup
17
+ contract changed):**
18
+
19
+ - `<Gallery />` now emits ONE `<dialog class="lightbox-dialog">` per gallery,
20
+ wrapping a `<Carousel />`, instead of one dialog per image. A `.gallery__trigger`
21
+ is now a plain `<a href="#slide-id">`, not a `command="show-modal"` button.
22
+ - `.lightbox-dialog__content` / `__image` / `__nav` / `__nav-button` /
23
+ `__caption` are gone. The dialog's child is `.lightbox-dialog__track` (a
24
+ `.carousel`) — prev/next, dots, arrow keys and swipe are the carousel's,
25
+ reused rather than reimplemented. `.lightbox-dialog__close` is unchanged.
26
+ - `<wc-gallery>` no longer builds nav buttons or steps between images — the
27
+ nested `<wc-carousel>` does. It fires `gallery-open` (unchanged shape) and
28
+ re-emits the carousel's own stepping as `gallery-change`.
29
+ - `packages/core/src/web-components/utils/gallery.ts` dropped `keyToAction` and
30
+ `swipeAction` (the carousel's own key/swipe handling covers both now).
31
+ `stepIndex`/`preloadTargets` are unchanged.
32
+
33
+ **Why:** stepping used to close one `<dialog>` and open another, which made
34
+ `animation.css`'s top-layer driver play a full exit-then-entry transition on
35
+ every step — visible as a flicker on prev/next, by construction, not a bug to
36
+ tune away. Opening also awaited the full image's `decode()` before starting the
37
+ view-transition morph, so the dialog visibly stalled on a slow network. One
38
+ dialog containing one carousel makes the first failure mode unrepresentable
39
+ (stepping is a scroll, not a close/open pair) and removes the wait for the
40
+ second (the morph starts immediately, painting the thumbnail as a CSS
41
+ background placeholder until the full image arrives).
42
+
43
+ **Also improved, not just fixed:** the no-JS fallback is no longer a _stripped_
44
+ lightbox — it's a genuine one. Each trigger is ordinary fragment navigation
45
+ (`:has(:target)` in `lightbox.css` reveals the dialog), which needs no Invoker
46
+ Commands polyfill in any engine, and it now has working prev/next with no
47
+ JavaScript at all — the previous per-dialog fallback had no way to express that.
48
+
49
+ **Migration:** a consumer using `<Gallery />` needs no changes — the component's
50
+ props are unchanged. Anyone who hand-authored `.lightbox-dialog` markup (per
51
+ `docs/concepts/patterns.md` / `docs/css/classes.md`) must move to the new single-
52
+ dialog-wrapping-a-carousel shape; see `patterns.astro`'s "Gallery, hand-authored"
53
+ demo for the reference markup. `<wc-gallery>` consumers listening for
54
+ `gallery-change` keep the same event shape (`{ index, total }`).
55
+
56
+ ### Minor Changes
57
+
58
+ - Add Google Business Profile reviews: fetch, cache, render.
59
+
60
+ - **`vitops reviews`** (new CLI command) fetches a client's GBP reviews (legacy v4 API — the only
61
+ one that has them) and writes them to `.vitops/reviews.json`, meant to be committed so a build
62
+ never needs a live Google credential. `--dry` prints the plan; `--check` diffs against the cache
63
+ without writing. Requires the same `VITOPS_GOOGLE_CLIENT_ID`/`_CLIENT_SECRET`/`_REFRESH_TOKEN` (or
64
+ ADC login) `search setup` uses, scoped to `business.manage` — **and the calling GCP project must
65
+ be allowlisted by Google first**, a separate one-time approval from every other Google API here.
66
+ - **`site.reviews`** (config): which `organization.locations` to fetch, and per-location
67
+ `featured`/`minRating`/`sort`/`limit` selection. `schema` (JSON-LD) and `avatars` both default to
68
+ **off** — see the migration notes below.
69
+ - **`@getvitops/utils/reviews`** (new subpath): the pure plan/select/merge functions plus the GBP
70
+ fetcher and cache reader/writer, mirroring `./indexing`'s pure/IO split.
71
+ - **`<ReviewCard>`** (new Astro component, `@getvitops/astro`): one review card from a cache entry.
72
+ No web component — nothing here needs progressive enhancement. Composes the existing
73
+ `schemas/Review.astro` for JSON-LD only when both `schema` and `itemReviewed` are passed.
74
+ - **`<Marquee>`** (new Astro component): the tier-3 wrapper `wc-marquee`/`marquee.css` was missing.
75
+ - **`.review` pattern** (`css`/`bricks` and `tailwind` formats): a new config-generated
76
+ `patterns.items.review` entry plus `packages/core/css/patterns/review.css` for the structural
77
+ parts (rating, identity, body, reply, source link). Two new semantic icon names, `star` and
78
+ `star-half`, across every icon set.
79
+
80
+ **Migration:** no breaking change. A site adding `site.reviews` should read the "Google reviews"
81
+ section in AGENTS.md before enabling `schema` — Google's structured-data policy treats a business
82
+ marking up reviews of itself as ineligible for review rich results and risks a manual action, which
83
+ is why it defaults off.
84
+
85
+ - Repo-wide simplification pass: de-boilerplate the CLI, dedupe shared cache/plan
86
+ primitives in `@getvitops/utils`, drop dead code, and tighten dependency hygiene.
87
+
88
+ No breaking changes. (`vitops icons`'s `--site` flag was briefly going to be renamed to
89
+ `--input` for consistency with the other `Config`-anchored commands — deferred rather than
90
+ spending a major bump on a rename with no other breaking change riding along; it stays
91
+ `--site` for now.)
92
+
93
+ **Fixed:**
94
+
95
+ - `@getvitops/core`'s 12 runtime deps (`lit`, both `@oddbird` polyfills, both
96
+ `@webcomponents` packages, `colorjs.io`, etc.) were listed as `dependencies` despite
97
+ being bundled into `dist/elements.js`/`dist/polyfills.js`/`dist/deferred.js` — a
98
+ consumer installed them twice for nothing. Moved to `devDependencies`; no change to
99
+ what ships.
100
+ - Deleted `old-css-lib/` (59 files), superseded by `packages/core/css/` since the
101
+ in-memory bundling change — no consumer-facing effect, it was not part of any
102
+ published output.
103
+ - Deleted a dead Astro module (`packages/astro/src/scripts/deferred.ts`) that had
104
+ silently diverged from `packages/core/src/js/deferred.ts` and had zero importers.
105
+
106
+ **No consumer-facing change in `@getvitops/vite`** beyond the fixed-group version bump —
107
+ task-graph and caching fixes internal to this repo's own build.
108
+
109
+ Everything else (the `cli.ts` file split into `commands/{search,ads}.ts`, shared
110
+ `json-store.ts`/`plan-format.ts` helpers in `@getvitops/utils`, a `.github/actions/setup`
111
+ composite action, restatement-comment cleanup) is internal refactoring with no change to
112
+ any published API, CLI flag, or generated output.
113
+
114
+ ### Patch Changes
115
+
116
+ - Updated dependencies
117
+ - Updated dependencies
118
+ - Updated dependencies
119
+ - Updated dependencies [a84839b]
120
+ - @getvitops/utils@8.0.0
121
+
3
122
  ## 7.0.0
4
123
 
5
124
  ### Major Changes
@@ -1012,28 +1131,20 @@ hidden` would pull it out of the accessible-name computation along with the visu
1012
1131
  {
1013
1132
  "designSystem": {
1014
1133
  "themes": {
1015
- "default": {
1016
- /* … */
1017
- },
1134
+ "default": {/* … */},
1018
1135
  },
1019
1136
  },
1020
1137
  "organization": {
1021
1138
  "name": "Acme",
1022
1139
  "contact": "hq",
1023
1140
  "locations": {
1024
- "hq": {
1025
- /* … */
1026
- },
1141
+ "hq": {/* … */},
1027
1142
  },
1028
1143
  },
1029
1144
  "site": {
1030
1145
  "defaultLocale": "en",
1031
- "domains": {
1032
- /* … */
1033
- },
1034
- "analytics": {
1035
- /* … */
1036
- },
1146
+ "domains": {/* … */},
1147
+ "analytics": {/* … */},
1037
1148
  },
1038
1149
  }
1039
1150
  ```
@@ -1198,7 +1309,7 @@ open-fade-in"` composes slide and fade. Applied at zero specificity with identit
1198
1309
  Also new, and worth wiring at the same time — **`gitLastmod()` for real sitemap dates**:
1199
1310
 
1200
1311
  ```js
1201
- import vitops, { gitLastmod } from '@getvitops/astro';
1312
+ import vitops, { gitLastmod } from "@getvitops/astro";
1202
1313
  vitops({ sitemap: { serialize: await gitLastmod() } });
1203
1314
  ```
1204
1315
 
@@ -1383,8 +1494,8 @@ open-fade-in"` composes slide and fade. Applied at zero specificity with identit
1383
1494
 
1384
1495
  ```js
1385
1496
  vitops({
1386
- analytics: { googleAnalytics: 'G-XXXXXXXXXX', plausible: 'acme.com' },
1387
- consent: { policyUrl: '/legal/cookies' },
1497
+ analytics: { googleAnalytics: "G-XXXXXXXXXX", plausible: "acme.com" },
1498
+ consent: { policyUrl: "/legal/cookies" },
1388
1499
  });
1389
1500
  ```
1390
1501
 
@@ -1524,7 +1635,7 @@ inset(0 100% 0 0)`, and `clip-path` clips **hit-testing** as well as painting
1524
1635
 
1525
1636
  ```js
1526
1637
  // astro.config.mjs
1527
- getvitops({ css: { input: 'company.json', format: 'css' } });
1638
+ getvitops({ css: { input: "company.json", format: "css" } });
1528
1639
  ```
1529
1640
 
1530
1641
  ```
@@ -1575,11 +1686,11 @@ inset(0 100% 0 0)`, and `clip-path` clips **hit-testing** as well as painting
1575
1686
  getvitops({
1576
1687
  fonts: [
1577
1688
  {
1578
- name: 'League Spartan',
1579
- provider: 'fontsource',
1580
- cssVariable: '--font-league-spartan',
1581
- weights: ['100 900'],
1582
- subsets: ['latin'],
1689
+ name: "League Spartan",
1690
+ provider: "fontsource",
1691
+ cssVariable: "--font-league-spartan",
1692
+ weights: ["100 900"],
1693
+ subsets: ["latin"],
1583
1694
  preload: true,
1584
1695
  },
1585
1696
  ],
@@ -2450,7 +2561,7 @@ inset(0 100% 0 0)`, and `clip-path` clips **hit-testing** as well as painting
2450
2561
  ```js
2451
2562
  // astro.config.mjs
2452
2563
  getvitops({
2453
- css: { input: 'design-system.json', format: 'css', out: 'src/styles' },
2564
+ css: { input: "design-system.json", format: "css", out: "src/styles" },
2454
2565
  editor: true,
2455
2566
  });
2456
2567
  ```
@@ -75,3 +75,31 @@ body {
75
75
  scroll-padding-block-start: var(--top-offset, 0);
76
76
  }
77
77
  }
78
+
79
+ /* The focus ring must survive Windows High Contrast Mode, and as generated it
80
+ does not. Every pattern's `ring: true` state (`generate.ts`) emits `outline:
81
+ none; box-shadow: 0 0 0 3px <color>;` — a deliberate choice the rest of the
82
+ time, since a box-shadow ring can follow a border-radius where a native
83
+ outline cannot. But `forced-colors: active` forces `box-shadow` to `none`
84
+ unconditionally (CSS Color Adjust Module), and this framework already asked
85
+ for no native outline either — so a keyboard user in forced-colors mode gets
86
+ NO focus indicator at all, on every `:focus-visible` element in every
87
+ format. This is the single most severe instance of the class of bug
88
+ `forced-colors`/`prefers-contrast` coverage exists to catch (AGENTS.md's
89
+ design-system-gaps notes on `--surface-glass`/`--overlay`/role solids are
90
+ the same class, decorative rather than safety-critical).
91
+
92
+ `!important`, deliberately, for the same reason `:root`'s `font-size` above
93
+ gets it: this must win regardless of which pattern's `vitops.components`
94
+ rule set `outline: none`, and `!important` outranks a normal-priority
95
+ declaration regardless of cascade layer. The colour is the token every
96
+ other ring already uses (`--color-border-focus`, from `ui-primary`,
97
+ contrast-checked as a non-text boundary) — forced-colors mode remaps
98
+ `outline-color` to a system colour automatically, so this is really only
99
+ asserting THAT an outline renders, not which colour it renders in. */
100
+ @media (forced-colors: active) {
101
+ :focus-visible {
102
+ outline: 2px solid var(--color-border-focus) !important;
103
+ outline-offset: 2px !important;
104
+ }
105
+ }
@@ -6,6 +6,7 @@
6
6
  @import './generated/type-tokens.css';
7
7
  @import './generated/tokens.css';
8
8
  @import './generated/typography.css';
9
+ @import './generated/typography-headings.css';
9
10
  @import './layout.css';
10
11
  /* Must precede utilities.css: both are `vitops.utilities`, and utilities.css
11
12
  re-emits `.<bp>-flex` AFTER its own `.hidden` so `class="hidden md-flex"`
@@ -53,6 +54,7 @@
53
54
  @import './patterns/table.css';
54
55
  @import './patterns/details.css';
55
56
  @import './patterns/comment.css';
57
+ @import './patterns/review.css';
56
58
  @import './patterns/banner.css';
57
59
  @import './patterns/consent.css';
58
60
  @import './patterns/list.css';
@@ -6,10 +6,13 @@
6
6
  * concerns — "a grid of images" and "view one full-screen" — and only the dialog
7
7
  * half needs the `lightbox` name; the grid never was one.
8
8
  *
9
- * `<wc-gallery>` wraps this and augments the per-image `.lightbox-dialog`s it finds
10
- * with prev/next, arrow keys, swipe and a counter. With no JS each `.gallery__trigger`
11
- * is a real `command="show-modal" commandfor="…"` invoker for its own dialog, so the
12
- * grid + dialogs already form a working modal lightbox before any script runs.
9
+ * `<wc-gallery>` wraps this and the single `.lightbox-dialog` it opens, running the
10
+ * thumbnail → full-image view-transition morph; stepping between images is a
11
+ * `<wc-carousel>` scroll inside that dialog, not a second dialog. With no JS each
12
+ * `.gallery__trigger` is a real `<a href="#slide-id">` into the carousel's own
13
+ * `<li id="slide-id">`, and `:has(:target)` in lightbox.css is what reveals the
14
+ * dialog — so the grid + dialog already form a working modal lightbox, with
15
+ * prev/next, before any script runs.
13
16
  */
14
17
 
15
18
  wc-gallery {
@@ -40,6 +43,7 @@ wc-gallery {
40
43
  padding: 0;
41
44
  background: none;
42
45
  border: none;
46
+ text-decoration: none;
43
47
  cursor: pointer;
44
48
 
45
49
  &:focus-visible {
@@ -2,10 +2,17 @@
2
2
  * Lightbox — the full-screen `<dialog>` a gallery trigger opens.
3
3
  * Consumes the generated token cascade (src/css/generated/tokens.css).
4
4
  *
5
- * The thumbnail grid that drives this moved to `patterns/gallery.css`
5
+ * The thumbnail grid that drives this lives in `patterns/gallery.css`
6
6
  * (`.gallery` / `.gallery__item` / `.gallery__trigger` / `.gallery__thumb`) — this
7
7
  * partial owns only the dialog a trigger opens. `<Gallery />` emits both; this
8
8
  * file has no opinion on what triggers it.
9
+ *
10
+ * **One dialog, one carousel** — not one dialog per image. Stepping between
11
+ * images is a scroll inside an already-open dialog (`.carousel`, patterns/
12
+ * carousel.css), so there is no close-one/open-another sequence for the top-layer
13
+ * driver in animation.css to double-animate. `<wc-gallery>` only opens/closes;
14
+ * `<wc-carousel>` (nested inside, `<Gallery />` emits both tags) owns prev/next,
15
+ * dots, arrow keys and swipe — reused rather than re-implemented.
9
16
  */
10
17
 
11
18
  /* ── Lightbox dialog — full-screen, w/ view transitions ─────────────────────── */
@@ -22,7 +29,7 @@ dialog.lightbox-dialog {
22
29
  background: var(--lightbox-dialog-bg, oklch(0 0 0 / 0.92));
23
30
  color: #fff;
24
31
  display: grid;
25
- place-items: center;
32
+ place-items: stretch;
26
33
 
27
34
  /* No scrim. Stated through popover.css's token rather than overriding its
28
35
  ::backdrop rule, so it keeps the shared fade timing. */
@@ -31,7 +38,8 @@ dialog.lightbox-dialog {
31
38
  /* The entry, as effect vars rather than a keyframe — same approach as
32
39
  dialog.css, and for the same reason: the top-layer driver in animation.css
33
40
  then animates this OUT too, which an `animation:` on `[open]` never does
34
- (it only ever plays once, on open). */
41
+ (it only ever plays once, on open). Runs on real open/close ONLY — stepping
42
+ between slides never opens or closes this dialog, so it can never fire here. */
35
43
  --opacity-from: 0;
36
44
  --scale-x-from: 0.95;
37
45
  --scale-y-from: 0.95;
@@ -40,26 +48,138 @@ dialog.lightbox-dialog {
40
48
  display: grid;
41
49
  }
42
50
 
43
- .lightbox-dialog__content {
44
- position: relative;
45
- max-inline-size: 90vw;
46
- max-block-size: 90vh;
47
- display: flex;
48
- align-items: center;
49
- justify-content: center;
51
+ /* ── No-JS fallback: `:target` + `:has()` ──────────────────────────────────
52
+ A `.gallery__trigger` is a real `<a href="#slide-id">`. Following it makes
53
+ that slide's `<li>` match `:target`, which this rule alone is what reveals
54
+ the dialog — no Invoker Commands, no polyfill, in every engine that has
55
+ `:has()` (Safari 15.4+, Chromium 105+, Firefox 121+). The browser's own
56
+ fragment-navigation scroll then lands the carousel on the right slide,
57
+ because the target lives inside `.carousel__track`.
58
+
59
+ `:not([open])` is load-bearing: once `<wc-gallery>` opens this for real via
60
+ `showModal()`, `[open]` is present and this rule must yield to it — a stale
61
+ `:target` from a previous no-JS visit must not fight the real modal, and
62
+ must not keep the dialog "open" by this rule alone once JS is stepping
63
+ between slides (which never changes the URL fragment). */
64
+ &:not([open]):has(:target) {
65
+ display: grid;
66
+ position: fixed;
67
+ inset: 0;
68
+ z-index: var(--lightbox-z, 100);
50
69
  }
51
70
 
52
- .lightbox-dialog__image {
71
+ .lightbox-dialog__track {
53
72
  max-inline-size: 100%;
54
- max-block-size: 90vh;
55
- object-fit: contain;
56
-
57
- /* `<wc-gallery>` assigns `view-transition-name: lightbox-active` to this and
58
- the originating thumbnail at the moment it opens, then clears it on close —
59
- that shared, runtime-assigned name is what morphs one into the other.
60
- `match-element` (the auto-generated per-element name) does NOT do this: it
61
- names each element uniquely, so it can never morph a thumbnail into a
62
- different `<img>`. Do not put a `view-transition-name` here statically. */
73
+ /* Inert: `.carousel`'s implicit grid row is `auto`, so this percentage is
74
+ cyclic (CSS Sizing 3 §5.2) — it resolves against the row's own
75
+ content-derived size, i.e. against itself, and can never actually clamp
76
+ it. Kept only to state intent; the `<img>`/`<picture>` cap below is what
77
+ does the real work by capping the thing with an intrinsic size instead
78
+ of trying to cap a box that is sized BY that intrinsic size. */
79
+ max-block-size: 100%;
80
+
81
+ /* One source of truth for the slide's breathing room: the padding below
82
+ and the image's block cap both read these, so they cannot drift apart. */
83
+ --lightbox-pad-block: 5dvh;
84
+ --lightbox-pad-inline: 5dvw;
85
+
86
+ /* `.carousel__media`'s own rule reads this for its `object-fit` (default
87
+ `cover`, right for a thumbnail strip, wrong for a full image — nothing
88
+ here should be cropped). Nesting under `dialog.lightbox-dialog` gives
89
+ this whole block higher specificity than carousel.css's bare
90
+ `.carousel__media` rule regardless of import order, so it always wins.
91
+ Belt-and-braces only now: the image rule below sizes the box to the
92
+ image, so there's normally nothing left for `object-fit` to do. */
93
+ --carousel-media-fit: contain;
94
+
95
+ /* No `.carousel__track { max-block-size: 100% }` override here — same
96
+ cyclic-percentage trap as above, against `.carousel`'s own implicit
97
+ `auto` row. It has never applied. */
98
+
99
+ /* Breathing room against the edge-to-edge dialog, on the SLIDE rather than
100
+ the media box — a slide is the grid item `align-items: stretch` fills to
101
+ the track's full height, so padding here is what keeps the image off the
102
+ glass instead of the media box's aspect-ratio doing it. */
103
+ .carousel__slide {
104
+ box-sizing: border-box;
105
+ padding: var(--lightbox-pad-block) var(--lightbox-pad-inline);
106
+
107
+ /* carousel.css gives a slide `align-content: start` so a `cover`ed media
108
+ box (which fills the row) hugs the top. Here the media box is
109
+ deliberately SMALLER than the slide — it's sized to the image, not the
110
+ other way around — so it must be centred instead, or a 16/9 image
111
+ would hang from the top of a row sized by a 9/16 neighbour. */
112
+ align-content: center;
113
+ }
114
+
115
+ .carousel__media {
116
+ /* Full images vary wildly in ratio; drop carousel.css's fixed 16/9 box
117
+ (right for a thumbnail strip, wrong here) and let the image below
118
+ determine the box's size. carousel.css's `block-size: 100%` is
119
+ deliberately NOT reinstated here: against this slide's content-sized
120
+ `auto` row it's the same cyclic percentage as the caps above, and it
121
+ only ever resolved back to the content height it was meant to cap. */
122
+ aspect-ratio: unset;
123
+
124
+ /* Painted the instant a slide is visible, whether or not the full image
125
+ has loaded yet — the morph target is never an empty box. Set per-slide
126
+ by `<Gallery />` from the thumbnail URL; declared as a var rather than
127
+ a bare property so a slide that omits a thumb (no placeholder) simply
128
+ paints nothing extra. `contain` + `center` lands on the same rect the
129
+ image below resolves to, so the placeholder doesn't jump on load. */
130
+ background-image: var(--lightbox-placeholder, none);
131
+ background-size: contain;
132
+ background-position: center;
133
+ background-repeat: no-repeat;
134
+
135
+ /* `<wc-gallery>` assigns `view-transition-name: lightbox-active` to the
136
+ slide's `<img>` and the originating thumbnail at the moment it opens,
137
+ then clears it on close — that shared, runtime-assigned name is what
138
+ morphs one into the other. `match-element` (the auto-generated
139
+ per-element name) does NOT do this: it names each element uniquely,
140
+ so it can never morph a thumbnail into a different `<img>`. Do not put
141
+ a `view-transition-name` on the image here statically. */
142
+ }
143
+
144
+ /* THE FIX. carousel.css sizes the media to fill its box
145
+ (`inline-size`/`block-size: 100%`) so `object-fit: cover` has something
146
+ to crop — right for a thumbnail strip, backwards for a lightbox, where
147
+ the BOX should instead be sized to the image. With both axes `auto` and
148
+ both maxes set, the browser's replaced-element min/max algorithm
149
+ (CSS 2.1 §10.4) does a ratio-preserving clamp against whichever axis
150
+ binds — so the element's border box IS the visible image, with no
151
+ cyclic percentages and no dependency on how many rows the grid above
152
+ ends up with. That also matters for two things beyond sizing: it's the
153
+ `view-transition-name` target (WCGallery.ts), so the morph must not
154
+ land on an oversized box; and it keeps the letterbox area click-through
155
+ for the dialog's `closedby="any"`.
156
+
157
+ A DESCENDANT selector, not a direct-child `>`: an `ImageMetadata` src
158
+ renders `<picture><img></picture>` (ResponsiveImage.astro), and
159
+ carousel.css's own rule only reaches the non-replaced `<picture>` via
160
+ `> :is(img, picture, …)` — `object-fit` is a no-op there, and the real
161
+ `<img>` inside gets no sizing at all. This selector covers both the
162
+ bare-`<img>` demo and the `<picture>`-wrapped case. */
163
+ .carousel__media :is(img, picture) {
164
+ /* Undo carousel.css's `inline-size/block-size: 100%` — a fixed size on
165
+ either axis would defeat the ratio-preserving clamp below. */
166
+ inline-size: auto;
167
+ block-size: auto;
168
+ max-inline-size: 100%; /* of the padded slide, i.e. ~90dvw */
169
+ /* The one cap that actually binds (not a percentage, so not cyclic).
170
+ `--lightbox-caption-space` is 0 by default — nothing here uses a
171
+ caption today, but `<figcaption>` stacks below the image inside this
172
+ same box (Gallery.astro), so a captioned gallery must set it to
173
+ reserve room or the slide can exceed the viewport by that height. */
174
+ max-block-size: calc(
175
+ 100dvh - 2 * var(--lightbox-pad-block) - var(--lightbox-caption-space, 0px)
176
+ );
177
+ margin-inline: auto; /* centre in the full-width figure; vertical centring is the slide's align-content */
178
+ }
179
+
180
+ .carousel__caption {
181
+ color: inherit;
182
+ }
63
183
  }
64
184
 
65
185
  .lightbox-dialog__close {
@@ -77,6 +197,9 @@ dialog.lightbox-dialog {
77
197
  display: flex;
78
198
  align-items: center;
79
199
  justify-content: center;
200
+ text-decoration: none;
201
+ font-size: 1.5rem;
202
+ line-height: 1;
80
203
  transition: background-color 0.15s ease;
81
204
  z-index: 10;
82
205
 
@@ -88,50 +211,4 @@ dialog.lightbox-dialog {
88
211
  outline-offset: 2px;
89
212
  }
90
213
  }
91
-
92
- .lightbox-dialog__nav {
93
- position: absolute;
94
- inset-block: 0;
95
- display: flex;
96
- align-items: center;
97
- padding: 1rem;
98
-
99
- &[data-direction='prev'] {
100
- inset-inline-start: 0;
101
- }
102
- &[data-direction='next'] {
103
- inset-inline-end: 0;
104
- }
105
- }
106
-
107
- .lightbox-dialog__nav-button {
108
- inline-size: 3rem;
109
- block-size: 3rem;
110
- padding: 0;
111
- background: var(--lightbox-nav-bg, transparent);
112
- border: none;
113
- border-radius: 50%;
114
- color: inherit;
115
- cursor: pointer;
116
- display: flex;
117
- align-items: center;
118
- justify-content: center;
119
- transition: background-color 0.15s ease;
120
-
121
- &:hover {
122
- background: var(--lightbox-nav-hover-bg, oklch(1 0 0 / 0.15));
123
- }
124
- &:disabled {
125
- opacity: 0.3;
126
- cursor: not-allowed;
127
- }
128
- }
129
-
130
- .lightbox-dialog__caption {
131
- position: absolute;
132
- inset-block-end: 0;
133
- inset-inline: 0;
134
- padding: 1rem 2rem;
135
- text-align: center;
136
- }
137
214
  }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Review — the `.review` card's structure only.
3
+ *
4
+ * Box chrome (border/padding/background/shadow) is CONFIG-GENERATED
5
+ * (`patterns.items.review` in design-system.json), the same split `card.css`
6
+ * documents. This partial holds what the framework owns: the internal layout —
7
+ * rating, identity, body, reply, source link.
8
+ */
9
+
10
+ .review {
11
+ .review__header {
12
+ display: flex;
13
+ flex-wrap: wrap;
14
+ align-items: center;
15
+ gap: 0.75rem;
16
+ }
17
+
18
+ .review__avatar {
19
+ inline-size: 2.5rem;
20
+ block-size: 2.5rem;
21
+ border-radius: 50%;
22
+ object-fit: cover;
23
+ }
24
+
25
+ /* Shown instead of `.review__avatar` when `avatars` is off (the default) — see
26
+ the config-generated pattern's rationale in AGENTS.md's Reviews section. */
27
+ .review__initials {
28
+ display: inline-flex;
29
+ align-items: center;
30
+ justify-content: center;
31
+ inline-size: 2.5rem;
32
+ block-size: 2.5rem;
33
+ border-radius: 50%;
34
+ background: var(--color-bg-neutral-muted);
35
+ color: var(--color-text-neutral);
36
+ font-weight: 600;
37
+ font-size: 0.875em;
38
+ }
39
+
40
+ .review__identity {
41
+ display: flex;
42
+ flex-direction: column;
43
+ gap: 0.125rem;
44
+ }
45
+
46
+ .review__author {
47
+ font-weight: 600;
48
+ }
49
+
50
+ .review__date {
51
+ font-size: 0.875em;
52
+ opacity: 0.7;
53
+ }
54
+
55
+ .review__stars {
56
+ display: inline-flex;
57
+ align-items: center;
58
+ gap: 0.125rem;
59
+ margin-inline-start: auto;
60
+ }
61
+
62
+ .review__star--empty {
63
+ opacity: 0.35;
64
+ }
65
+
66
+ .review__body {
67
+ margin: 0;
68
+ }
69
+
70
+ .review__reply {
71
+ margin-inline-start: 1.5rem;
72
+ padding-inline-start: 1rem;
73
+ border-inline-start: 2px solid var(--color-bg-surface-muted);
74
+ }
75
+
76
+ .review__reply-author {
77
+ font-weight: 600;
78
+ font-size: 0.875em;
79
+ }
80
+
81
+ .review__source {
82
+ font-size: 0.875em;
83
+ align-self: start;
84
+ }
85
+ }
86
+
87
+ .review-list {
88
+ display: flex;
89
+ flex-direction: column;
90
+ gap: 1.5rem;
91
+ }