@getvitops/generator 7.0.0 → 8.0.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,118 @@
1
1
  # @getvitops/generator
2
2
 
3
+ ## 8.0.0
4
+
5
+ ### Major Changes
6
+
7
+ - Rebuild the lightbox on one dialog + the existing carousel, fixing the flicker on
8
+ prev/next and the stutter on open.
9
+
10
+ **Breaking (`css`/`bricks` and `tailwind` formats — `.lightbox-dialog`'s markup
11
+ contract changed):**
12
+
13
+ - `<Gallery />` now emits ONE `<dialog class="lightbox-dialog">` per gallery,
14
+ wrapping a `<Carousel />`, instead of one dialog per image. A `.gallery__trigger`
15
+ is now a plain `<a href="#slide-id">`, not a `command="show-modal"` button.
16
+ - `.lightbox-dialog__content` / `__image` / `__nav` / `__nav-button` /
17
+ `__caption` are gone. The dialog's child is `.lightbox-dialog__track` (a
18
+ `.carousel`) — prev/next, dots, arrow keys and swipe are the carousel's,
19
+ reused rather than reimplemented. `.lightbox-dialog__close` is unchanged.
20
+ - `<wc-gallery>` no longer builds nav buttons or steps between images — the
21
+ nested `<wc-carousel>` does. It fires `gallery-open` (unchanged shape) and
22
+ re-emits the carousel's own stepping as `gallery-change`.
23
+ - `packages/core/src/web-components/utils/gallery.ts` dropped `keyToAction` and
24
+ `swipeAction` (the carousel's own key/swipe handling covers both now).
25
+ `stepIndex`/`preloadTargets` are unchanged.
26
+
27
+ **Why:** stepping used to close one `<dialog>` and open another, which made
28
+ `animation.css`'s top-layer driver play a full exit-then-entry transition on
29
+ every step — visible as a flicker on prev/next, by construction, not a bug to
30
+ tune away. Opening also awaited the full image's `decode()` before starting the
31
+ view-transition morph, so the dialog visibly stalled on a slow network. One
32
+ dialog containing one carousel makes the first failure mode unrepresentable
33
+ (stepping is a scroll, not a close/open pair) and removes the wait for the
34
+ second (the morph starts immediately, painting the thumbnail as a CSS
35
+ background placeholder until the full image arrives).
36
+
37
+ **Also improved, not just fixed:** the no-JS fallback is no longer a _stripped_
38
+ lightbox — it's a genuine one. Each trigger is ordinary fragment navigation
39
+ (`:has(:target)` in `lightbox.css` reveals the dialog), which needs no Invoker
40
+ Commands polyfill in any engine, and it now has working prev/next with no
41
+ JavaScript at all — the previous per-dialog fallback had no way to express that.
42
+
43
+ **Migration:** a consumer using `<Gallery />` needs no changes — the component's
44
+ props are unchanged. Anyone who hand-authored `.lightbox-dialog` markup (per
45
+ `docs/concepts/patterns.md` / `docs/css/classes.md`) must move to the new single-
46
+ dialog-wrapping-a-carousel shape; see `patterns.astro`'s "Gallery, hand-authored"
47
+ demo for the reference markup. `<wc-gallery>` consumers listening for
48
+ `gallery-change` keep the same event shape (`{ index, total }`).
49
+
50
+ ### Minor Changes
51
+
52
+ - Add Google Business Profile reviews: fetch, cache, render.
53
+
54
+ - **`vitops reviews`** (new CLI command) fetches a client's GBP reviews (legacy v4 API — the only
55
+ one that has them) and writes them to `.vitops/reviews.json`, meant to be committed so a build
56
+ never needs a live Google credential. `--dry` prints the plan; `--check` diffs against the cache
57
+ without writing. Requires the same `VITOPS_GOOGLE_CLIENT_ID`/`_CLIENT_SECRET`/`_REFRESH_TOKEN` (or
58
+ ADC login) `search setup` uses, scoped to `business.manage` — **and the calling GCP project must
59
+ be allowlisted by Google first**, a separate one-time approval from every other Google API here.
60
+ - **`site.reviews`** (config): which `organization.locations` to fetch, and per-location
61
+ `featured`/`minRating`/`sort`/`limit` selection. `schema` (JSON-LD) and `avatars` both default to
62
+ **off** — see the migration notes below.
63
+ - **`@getvitops/utils/reviews`** (new subpath): the pure plan/select/merge functions plus the GBP
64
+ fetcher and cache reader/writer, mirroring `./indexing`'s pure/IO split.
65
+ - **`<ReviewCard>`** (new Astro component, `@getvitops/astro`): one review card from a cache entry.
66
+ No web component — nothing here needs progressive enhancement. Composes the existing
67
+ `schemas/Review.astro` for JSON-LD only when both `schema` and `itemReviewed` are passed.
68
+ - **`<Marquee>`** (new Astro component): the tier-3 wrapper `wc-marquee`/`marquee.css` was missing.
69
+ - **`.review` pattern** (`css`/`bricks` and `tailwind` formats): a new config-generated
70
+ `patterns.items.review` entry plus `packages/core/css/patterns/review.css` for the structural
71
+ parts (rating, identity, body, reply, source link). Two new semantic icon names, `star` and
72
+ `star-half`, across every icon set.
73
+
74
+ **Migration:** no breaking change. A site adding `site.reviews` should read the "Google reviews"
75
+ section in AGENTS.md before enabling `schema` — Google's structured-data policy treats a business
76
+ marking up reviews of itself as ineligible for review rich results and risks a manual action, which
77
+ is why it defaults off.
78
+
79
+ - Repo-wide simplification pass: de-boilerplate the CLI, dedupe shared cache/plan
80
+ primitives in `@getvitops/utils`, drop dead code, and tighten dependency hygiene.
81
+
82
+ No breaking changes. (`vitops icons`'s `--site` flag was briefly going to be renamed to
83
+ `--input` for consistency with the other `Config`-anchored commands — deferred rather than
84
+ spending a major bump on a rename with no other breaking change riding along; it stays
85
+ `--site` for now.)
86
+
87
+ **Fixed:**
88
+
89
+ - `@getvitops/core`'s 12 runtime deps (`lit`, both `@oddbird` polyfills, both
90
+ `@webcomponents` packages, `colorjs.io`, etc.) were listed as `dependencies` despite
91
+ being bundled into `dist/elements.js`/`dist/polyfills.js`/`dist/deferred.js` — a
92
+ consumer installed them twice for nothing. Moved to `devDependencies`; no change to
93
+ what ships.
94
+ - Deleted `old-css-lib/` (59 files), superseded by `packages/core/css/` since the
95
+ in-memory bundling change — no consumer-facing effect, it was not part of any
96
+ published output.
97
+ - Deleted a dead Astro module (`packages/astro/src/scripts/deferred.ts`) that had
98
+ silently diverged from `packages/core/src/js/deferred.ts` and had zero importers.
99
+
100
+ **No consumer-facing change in `@getvitops/vite`** beyond the fixed-group version bump —
101
+ task-graph and caching fixes internal to this repo's own build.
102
+
103
+ Everything else (the `cli.ts` file split into `commands/{search,ads}.ts`, shared
104
+ `json-store.ts`/`plan-format.ts` helpers in `@getvitops/utils`, a `.github/actions/setup`
105
+ composite action, restatement-comment cleanup) is internal refactoring with no change to
106
+ any published API, CLI flag, or generated output.
107
+
108
+ ### Patch Changes
109
+
110
+ - Updated dependencies
111
+ - Updated dependencies
112
+ - Updated dependencies
113
+ - Updated dependencies [a84839b]
114
+ - @getvitops/utils@8.0.0
115
+
3
116
  ## 7.0.0
4
117
 
5
118
  ### Major Changes
@@ -1012,28 +1125,20 @@ hidden` would pull it out of the accessible-name computation along with the visu
1012
1125
  {
1013
1126
  "designSystem": {
1014
1127
  "themes": {
1015
- "default": {
1016
- /* … */
1017
- },
1128
+ "default": {/* … */},
1018
1129
  },
1019
1130
  },
1020
1131
  "organization": {
1021
1132
  "name": "Acme",
1022
1133
  "contact": "hq",
1023
1134
  "locations": {
1024
- "hq": {
1025
- /* … */
1026
- },
1135
+ "hq": {/* … */},
1027
1136
  },
1028
1137
  },
1029
1138
  "site": {
1030
1139
  "defaultLocale": "en",
1031
- "domains": {
1032
- /* … */
1033
- },
1034
- "analytics": {
1035
- /* … */
1036
- },
1140
+ "domains": {/* … */},
1141
+ "analytics": {/* … */},
1037
1142
  },
1038
1143
  }
1039
1144
  ```
@@ -1198,7 +1303,7 @@ open-fade-in"` composes slide and fade. Applied at zero specificity with identit
1198
1303
  Also new, and worth wiring at the same time — **`gitLastmod()` for real sitemap dates**:
1199
1304
 
1200
1305
  ```js
1201
- import vitops, { gitLastmod } from '@getvitops/astro';
1306
+ import vitops, { gitLastmod } from "@getvitops/astro";
1202
1307
  vitops({ sitemap: { serialize: await gitLastmod() } });
1203
1308
  ```
1204
1309
 
@@ -1383,8 +1488,8 @@ open-fade-in"` composes slide and fade. Applied at zero specificity with identit
1383
1488
 
1384
1489
  ```js
1385
1490
  vitops({
1386
- analytics: { googleAnalytics: 'G-XXXXXXXXXX', plausible: 'acme.com' },
1387
- consent: { policyUrl: '/legal/cookies' },
1491
+ analytics: { googleAnalytics: "G-XXXXXXXXXX", plausible: "acme.com" },
1492
+ consent: { policyUrl: "/legal/cookies" },
1388
1493
  });
1389
1494
  ```
1390
1495
 
@@ -1524,7 +1629,7 @@ inset(0 100% 0 0)`, and `clip-path` clips **hit-testing** as well as painting
1524
1629
 
1525
1630
  ```js
1526
1631
  // astro.config.mjs
1527
- getvitops({ css: { input: 'company.json', format: 'css' } });
1632
+ getvitops({ css: { input: "company.json", format: "css" } });
1528
1633
  ```
1529
1634
 
1530
1635
  ```
@@ -1575,11 +1680,11 @@ inset(0 100% 0 0)`, and `clip-path` clips **hit-testing** as well as painting
1575
1680
  getvitops({
1576
1681
  fonts: [
1577
1682
  {
1578
- name: 'League Spartan',
1579
- provider: 'fontsource',
1580
- cssVariable: '--font-league-spartan',
1581
- weights: ['100 900'],
1582
- subsets: ['latin'],
1683
+ name: "League Spartan",
1684
+ provider: "fontsource",
1685
+ cssVariable: "--font-league-spartan",
1686
+ weights: ["100 900"],
1687
+ subsets: ["latin"],
1583
1688
  preload: true,
1584
1689
  },
1585
1690
  ],
@@ -2450,7 +2555,7 @@ inset(0 100% 0 0)`, and `clip-path` clips **hit-testing** as well as painting
2450
2555
  ```js
2451
2556
  // astro.config.mjs
2452
2557
  getvitops({
2453
- css: { input: 'design-system.json', format: 'css', out: 'src/styles' },
2558
+ css: { input: "design-system.json", format: "css", out: "src/styles" },
2454
2559
  editor: true,
2455
2560
  });
2456
2561
  ```
@@ -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,79 @@ 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
+ max-block-size: 100%;
74
+
75
+ /* `.carousel__media`'s own rule reads this for its `object-fit` (default
76
+ `cover`, right for a thumbnail strip, wrong for a full image — nothing
77
+ here should be cropped). Nesting under `dialog.lightbox-dialog` gives
78
+ this whole block higher specificity than carousel.css's bare
79
+ `.carousel__media` rule regardless of import order, so it always wins. */
80
+ --carousel-media-fit: contain;
81
+
82
+ .carousel__track {
83
+ max-block-size: 100%;
84
+ }
85
+
86
+ /* Breathing room against the edge-to-edge dialog, on the SLIDE rather than
87
+ the media box — a slide is the grid item `align-items: stretch` fills to
88
+ the track's full height, so padding here is what keeps the image off the
89
+ glass instead of the media box's aspect-ratio doing it. */
90
+ .carousel__slide {
91
+ box-sizing: border-box;
92
+ padding: 5vh 5vw;
93
+ }
94
+
95
+ .carousel__media {
96
+ /* Full images vary wildly in ratio; let each fill its slide instead of
97
+ carousel.css's fixed 16/9 box (right for a thumbnail strip, wrong
98
+ here). */
99
+ aspect-ratio: unset;
100
+ block-size: 100%;
101
+
102
+ /* Painted the instant a slide is visible, whether or not the full image
103
+ has loaded yet — the morph target is never an empty box. Set per-slide
104
+ by `<Gallery />` from the thumbnail URL; declared as a var rather than
105
+ a bare property so a slide that omits a thumb (no placeholder) simply
106
+ paints nothing extra. */
107
+ background-image: var(--lightbox-placeholder, none);
108
+ background-size: contain;
109
+ background-position: center;
110
+ background-repeat: no-repeat;
111
+
112
+ /* `<wc-gallery>` assigns `view-transition-name: lightbox-active` to the
113
+ slide's `<img>` and the originating thumbnail at the moment it opens,
114
+ then clears it on close — that shared, runtime-assigned name is what
115
+ morphs one into the other. `match-element` (the auto-generated
116
+ per-element name) does NOT do this: it names each element uniquely,
117
+ so it can never morph a thumbnail into a different `<img>`. Do not put
118
+ a `view-transition-name` on the image here statically. */
119
+ }
120
+
121
+ .carousel__caption {
122
+ color: inherit;
123
+ }
63
124
  }
64
125
 
65
126
  .lightbox-dialog__close {
@@ -77,6 +138,9 @@ dialog.lightbox-dialog {
77
138
  display: flex;
78
139
  align-items: center;
79
140
  justify-content: center;
141
+ text-decoration: none;
142
+ font-size: 1.5rem;
143
+ line-height: 1;
80
144
  transition: background-color 0.15s ease;
81
145
  z-index: 10;
82
146
 
@@ -88,50 +152,4 @@ dialog.lightbox-dialog {
88
152
  outline-offset: 2px;
89
153
  }
90
154
  }
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
155
  }
@@ -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
+ }