@owlmeans/web-consent 0.1.18-rc.47 → 0.1.18-rc.49

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/README.md CHANGED
@@ -14,7 +14,7 @@ The `web-consent` skill covers the components, the `consent` skill the model beh
14
14
  ## Installation
15
15
 
16
16
  ```bash
17
- bun add @owlmeans/web-consent@^0.1.18-rc.47
17
+ bun add @owlmeans/web-consent@^0.1.18-rc.49
18
18
  ```
19
19
 
20
20
  Peer dependencies the app provides: `react`, `tailwindcss`, `tailwind-merge`, `clsx`, `lucide-react`.
@@ -230,7 +230,7 @@ This package ships embedded agent skills under `agent-meta/`. After installing y
230
230
  your project's skill store (`.agents/skills/`):
231
231
 
232
232
  ```sh
233
- npx @owlmeans/agent-skills@^0.1.18-rc.56
233
+ npx @owlmeans/agent-skills@^0.1.18-rc.57
234
234
  ```
235
235
 
236
236
  The embedded files are version-matched to this package release. Do not edit them
@@ -1,8 +1,8 @@
1
1
  {
2
- "schemaVersion": 2,
2
+ "schemaVersion": 3,
3
3
  "package": "@owlmeans/web-consent",
4
- "version": "0.1.18-rc.47",
5
- "generatedAt": "2026-10-17T00:00:32.711Z",
4
+ "version": "0.1.18-rc.49",
5
+ "generatedAt": "2026-10-22T17:25:04.161Z",
6
6
  "canonicalRepo": "https://github.com/owlmeans/common",
7
7
  "entries": [
8
8
  {
@@ -10,7 +10,95 @@
10
10
  "name": "web-consent",
11
11
  "category": "package-specific",
12
12
  "file": "skills/web-consent/SKILL.md",
13
- "canonicalPath": ".agents/skills/web-consent/SKILL.md"
13
+ "canonicalPath": ".agents/skills/web-consent/SKILL.md",
14
+ "parts": [
15
+ {
16
+ "name": "dialog",
17
+ "file": "skills/web-consent/parts/dialog.md",
18
+ "description": "Mounting CookieConsent once — its surfaces (locating spinner, bar, preferences window), its props, the re-open button, the linker line, the bar, focus, and the login-raised window.",
19
+ "purposes": [
20
+ "consent",
21
+ "dialog",
22
+ "layout"
23
+ ],
24
+ "mentions": [
25
+ "CookieConsent",
26
+ "CookieConsentProps",
27
+ "ConsentDisplayMode",
28
+ "CONSENT_DEFAULT_MODE",
29
+ "noReopenButton",
30
+ "policyHref",
31
+ "ConsentGeoOptions",
32
+ "ConsentLinkerOptions",
33
+ "barClassName",
34
+ "data-consent-dialog",
35
+ "data-consent-bar",
36
+ "data-consent-reopen"
37
+ ]
38
+ },
39
+ {
40
+ "name": "look",
41
+ "file": "skills/web-consent/parts/look.md",
42
+ "description": "The flat look from the host's tokens — the class table, focus outlines, switches named by reference, and the axe and harness checks that pin them.",
43
+ "purposes": [
44
+ "consent",
45
+ "theme"
46
+ ],
47
+ "mentions": [
48
+ "barClassName",
49
+ "ConsentToggle",
50
+ "data-consent-overlay",
51
+ "a11y.spec",
52
+ "outline-ring"
53
+ ]
54
+ },
55
+ {
56
+ "name": "menu",
57
+ "file": "skills/web-consent/parts/menu.md",
58
+ "description": "ConsentMenuWidget, a plain row a host's own menu renders to reopen the dialog, and where its presence signal must come from.",
59
+ "purposes": [
60
+ "consent",
61
+ "navigation"
62
+ ],
63
+ "mentions": [
64
+ "ConsentMenuWidget",
65
+ "useConsentMenuPresence",
66
+ "consentStore.open"
67
+ ]
68
+ },
69
+ {
70
+ "name": "policy-page",
71
+ "file": "skills/web-consent/parts/policy-page.md",
72
+ "description": "The generated CookiePolicy page — what it states, the regional rule, the services disclosure, the linker domain list and its heading structure.",
73
+ "purposes": [
74
+ "consent",
75
+ "screen"
76
+ ],
77
+ "mentions": [
78
+ "CookiePolicy",
79
+ "CookiePolicyProps",
80
+ "ConsentService",
81
+ "googleTagServices",
82
+ "data-cookie-policy"
83
+ ]
84
+ },
85
+ {
86
+ "name": "setup",
87
+ "file": "skills/web-consent/parts/setup.md",
88
+ "description": "The Tailwind @source line pointing at src, and the packaged i18n bundle with the translate fall-through.",
89
+ "purposes": [
90
+ "consent",
91
+ "config"
92
+ ],
93
+ "mentions": [
94
+ "defaultConsentTranslate",
95
+ "consentI18nHelper",
96
+ "CONSENT_LOCALES",
97
+ "web-consent/src",
98
+ "@source"
99
+ ]
100
+ }
101
+ ]
14
102
  }
15
103
  ]
16
104
  }
@@ -8,7 +8,7 @@ user-invocable: false
8
8
  # @owlmeans/web-consent
9
9
 
10
10
  **Layer:** Web (React)
11
- **Install:** `"@owlmeans/web-consent": "^0.1.18-rc.47"` in `dependencies`
11
+ **Install:** `"@owlmeans/web-consent": "^0.1.18-rc.49"` in `dependencies`
12
12
 
13
13
  The browser components of the consent set. The model — categories, storage, migration, the store,
14
14
  Consent Mode signalling — is `@owlmeans/consent`, and this package re-exports a **named selection**
@@ -28,7 +28,7 @@ no such alias, where that would simply fail to build.
28
28
 
29
29
  For the same reason it is not part of `@owlmeans/web-panel`: that would drag ~30 packages onto a
30
30
  cookie notice. `@owlmeans/web-panel/consent` is the thin binding to OwlMeans i18n on top of these
31
- components — see below.
31
+ components — see the i18n section of [parts/setup.md](parts/setup.md).
32
32
 
33
33
  ## Key Exports
34
34
 
@@ -36,7 +36,7 @@ components — see below.
36
36
  |--------|-------------|
37
37
  | `CookieConsent` | The consent UI — the bar, the preferences window, the locating spinner — **and** the persistent re-open button |
38
38
  | `ConsentDisplayMode` / `CONSENT_DEFAULT_MODE` | `'bar'` (the default) or `'window'` — how a first-time visitor is asked |
39
- | `ConsentMenuWidget` | A plain row a HOST'S OWN menu renders to reopen the dialog, in place of (or beside) `CookieConsent`'s own floating button — see below |
39
+ | `ConsentMenuWidget` | A plain row a HOST'S OWN menu renders to reopen the dialog, in place of (or beside) `CookieConsent`'s own floating button — see [parts/menu.md](parts/menu.md) |
40
40
  | `CookiePolicy` | The cookie-policy page, generated from the configuration in force |
41
41
  | `ConsentToggle` | One category row — locked and labelled when the category is required |
42
42
  | `useConsent(opts?)` | This document's consent state and the actions over it (`UseConsentModel`) — **it also initialises the store on mount**, see below |
@@ -44,166 +44,6 @@ components — see below.
44
44
  | `CookieConsentProps` / `CookiePolicyProps` / `ConsentLink` | The component props |
45
45
  | Re-exports from `@owlmeans/consent` | The complete list: `consentStore`; `consentStorageHelper` (`readConsent`, `writeConsent`, `clearConsent`, `migrateConsent`); `consentModeHelper` (`applyConsent`, `pushConsentDefaults`, `consentBootstrapScript`, `consentDefaults`, `consentUpdate`, `gtagConsent`, `trackingGranted`, `consentGateScript`); `consentI18nHelper` (`defaultConsentTranslate`, `interpolate`, `normalizeLocale`); `DEFAULT_CONSENT_CATEGORIES`, `DEFAULT_CONSENT_MESSAGES`, `CONSENT_KEY`, `CONSENT_COOKIE_DAYS`, `CONSENT_SCHEMA_VERSION`, `CONSENT_LOCALES`, `CONSENT_ESSENTIAL` / `CONSENT_ANALYTICS` / `CONSENT_MARKETING`, the plugin seam (`consentPluginHelper`: `registerConsentPlugin`, `unregisterConsentPlugin`, `consentPlugins`, `decorateConsentUrl`, `consentDomains`, `adoptConsent`, `startConsentPlugins`, `adoptConsentLanguage`, `locateConsent`), the geo gate (`consentGeoHelper`; `CONSENT_REQUIRED_COUNTRIES`, `CONSENT_COUNTRIES_GDPR` / `_ALIGNED` / `_OPT_IN`, `CONSENT_GEO_UNKNOWN`, `CONSENT_TRACE_PATH`, `CONSENT_GEO_TIMEOUT`, `CONSENT_AUTO_MAX_AGE`, `CONSENT_IDLE_STATE`, `CONSENT_STATE_ATTRIBUTE`; the types `ConsentGeoOptions`, `ConsentGeoPlugin`, `ConsentGeoLocation`, `ConsentCloudflareOptions`, `ConsentLocating`, `ConsentGeoHelper`, `ConsentGeoVerdict`, `ConsentAutoState`), the linker (`consentLinkHelper`: `consentLinker`, `encodeConsentLink`, `decodeConsentLink`, `stripConsentLinkParam`, `consentLinkerScript`, `writeConsentLanguage`; `CONSENT_LANGUAGE_KEY`, `CONSENT_EVENT`, `CONSENT_LINK_PARAM`, `CONSENT_LINK_MAX_AGE`, `CONSENT_LINK_SKEW`), and the types `ConsentCategory`, `ConsentOptions`, `ConsentReason`, `ConsentRecord`, `ConsentService`, `ConsentSignal`, `ConsentState`, `ConsentStore`, `ConsentLocale`, `ConsentLinkerOptions`, `ConsentLinkerLanguage`, `ConsentPlugin`, `ConsentLinkPayload` and the five helper interfaces |
46
46
 
47
- ## Mounting the dialog
48
-
49
- `CookieConsent` is mounted **once**, at the application root or in the layout. It opens itself when
50
- no decision is stored, renders nothing but the re-open button once one is, and needs no state from
51
- the caller. One surface at a time:
52
-
53
- - **while a first-time visitor is located** (`geo`): a transparent overlay that blocks the page,
54
- and a spinner (`[data-consent-locating="first"]`, `[data-consent-spinner]` after
55
- `CONSENT_LOCATING_DELAY`, 300 ms) — never the bar;
56
- - **the first ask** (`reason: 'initial'`) in `bar` mode, the default: the bar;
57
- - **every other opening** — the bar's own "Cookie preferences" (`'preferences'`), a footer link or
58
- the corner button (`'reopen'`), the sign-in gate (`'login'`) — and the first ask in `window`
59
- mode: the preferences window.
60
-
61
- `[data-consent-dialog]` (with `role="dialog"`, `aria-modal`) and `[data-consent-accept-all]` sit on
62
- whichever surface is up, so a test or a host keys on them without knowing the mode;
63
- `data-consent-mode="bar|window"` and `[data-consent-bar]` tell them apart. "Cookie preferences"
64
- REPLACES the bar with the window — exactly one of each exists at a time.
65
-
66
- ```tsx
67
- import { CookieConsent } from '@owlmeans/web-consent'
68
-
69
- <CookieConsent
70
- policyHref="/legal/cookies"
71
- links={[{ href: '/legal/privacy', labelKey: 'privacy', defaultLabel: 'Privacy Policy' }]}
72
- />
73
- ```
74
-
75
- - **The category set the dialog renders is the set it saves.** Pass `categories` here and pass the
76
- same set to whatever stamps the head snippet, or the two disagree about what was asked. The same
77
- goes for `storageKey`, `cookieDays` and `cookieDomain`.
78
- - `locale` picks the packaged language; leave it unset and English is used, so an app with a
79
- language of its own passes it (or mounts `PanelCookieConsent`, which does). A region tag is
80
- reduced to its base (`pl-PL` → `pl`), and anything outside `CONSENT_LOCALES` falls back to English.
81
- - `policyHref` is a plain string, so an app-resolved path, a framework route and a raw href all
82
- work — the component must not know how its host does routing.
83
- - `noReopenButton` hides the floating button for an app that offers a footer link instead; that link
84
- calls `consentStore.open('reopen')`.
85
- - **`mode`** (`'bar'` | `'window'`, default `CONSENT_DEFAULT_MODE`) picks the first ask's surface.
86
- - **`geo`** (`ConsentGeoOptions`, plain data — an Astro island can pass it) turns on the geo gate:
87
- `geo={{ cloudflare: true }}` on a Cloudflare-proxied host. It is passed through to
88
- `consentStore.init` like `linker`; the `consent` skill's "Where consent is asked" is the model. A
89
- locator plugin is code and is registered, not passed. Pass the same `geo` to `CookiePolicy`.
90
- - **The re-open button is a bare icon in the very corner (`bottom-1 left-1`), not a card.** No
91
- filled background, no border, no shadow, no hover-scale — `bg-transparent`, dimmed
92
- (`opacity-70`) at rest and picked out on hover/focus — because it sits on every page of a site
93
- for as long as a visitor stays and must read as a small fixture rather than compete with the
94
- page's own controls. The pictogram is 20px (`h-5 w-5`) inside an invisible 44px hit area
95
- (`h-11 w-11`). `[data-consent-reopen]` is what any test or CSS override keys on.
96
- - **`linker` (`ConsentLinkerOptions`)** turns on cross-domain consent (see the `consent` skill's
97
- plugin-seam section): `<CookieConsent linker={{ domains: ['owlmeans.com', 'owlmeans.pl'] }} />`
98
- passes it straight to `useConsent`, which registers `consentLinkHelper.consentLinker` on mount. With it set and
99
- more than just the current host to disclose, a domain line renders right after the description
100
- (`[data-consent-domains]`, added to the dialog's `aria-describedby`) — computed DIRECTLY from
101
- `linker.domains` plus the current host, not through the plugin registry, so it never depends on
102
- registration having finished yet. Omit `linker` and nothing here changes at all. The same object
103
- carries `language` (`{}` to send the page's language, `{ supported }` to adopt one — the `consent`
104
- skill's language section): the dialog only forwards it to the store and discloses nothing extra.
105
- - **The interface language is not a category.** The default set renders the required row and the
106
- analytics and marketing toggles only; the language is strictly necessary storage that no toggle
107
- governs (the `consent` skill). The built-in bundle (8 languages) describes it in the required row.
108
-
109
- ## The bar
110
-
111
- A tall bar across the bottom of a **dimmed** overlay (`[data-consent-overlay]`,
112
- `fixed inset-0 bg-black/70`): the page stays in sight and scrolls, but nothing on it can be
113
- pressed until the visitor answers. The bar (`fixed inset-x-0 bottom-0 max-h-[85vh] border-t
114
- bg-background`) holds, left to right on a wide screen and stacked on a phone:
115
-
116
- - the cookie icon and `barTitle`;
117
- - `barDescription` — what the essential cookies do, the optional categories in force as a list
118
- after a colon (`{{categories}}`, so no language has to decline a label), that they stay off until
119
- allowed, and that the choice can be changed or withdrawn at any time; `barDescriptionEssential`
120
- when nothing is optional;
121
- - the cross-domain line (`[data-consent-domains]`) when `linker` names more than this host;
122
- - the links row (`[data-consent-links]`: `policyHref` then `links`, 44px targets, new tab);
123
- - three answers: `[data-consent-preferences]` "Cookie preferences" (outlined), then
124
- `[data-consent-mandatory]` "Accept only mandatory" and `[data-consent-accept-all]` "Accept All" —
125
- the two answers carry the SAME accent class: refusing is exactly as prominent as accepting, and
126
- one click away. With no optional category there is nothing to refuse, so "mandatory" is absent.
127
-
128
- `barClassName` styles the bar; `className` still styles the window's card.
129
-
130
- **Focus.** On open, focus moves to the surface itself (`tabIndex={-1}`) — never to an answer, which
131
- would be a nudge; Tab and Shift+Tab cycle inside it; on close focus returns where it was. Nothing
132
- pulls focus back on `focusin` (a script focusing a field is not a keyboard user escaping). The
133
- spinner overlay takes no focus.
134
-
135
- ## The look — flat, from the host's tokens
136
-
137
- The bar and the window are the first thing every new visitor of a generated app sees, so they
138
- follow the flat rule those apps are held to: **no gradient, shadow, glow, glass or
139
- `backdrop-filter`** anywhere. Everything is a theme token, so light and dark follow the host:
140
-
141
- | Part | Classes |
142
- |---|---|
143
- | Bar overlay | `bg-black/70`, matching the preferences window — the page is dimmed beneath the opaque bar |
144
- | Locating overlay | `bg-transparent` — a quick region lookup flashes no backdrop |
145
- | Bar | `border-t border-border bg-background text-foreground`, no shadow |
146
- | Bar answers | "Accept only mandatory" and "Accept All" both the accent pill; "Cookie preferences" outlined |
147
- | Spinner | `text-muted-foreground motion-safe:animate-spin` |
148
- | Window overlay | `bg-black/70`, flat — no blur |
149
- | Card | `rounded-3xl border border-border bg-background text-foreground`, no shadow or ring |
150
- | Icon | `rounded-full bg-muted text-primary` |
151
- | Category row | `rounded-2xl border border-border`; *Required* is plain uppercase muted text, never a badge |
152
- | Switch | `bg-primary` on / `bg-muted-foreground/40` off, white knob; 44px hit area around the 24×44 track |
153
- | Accept | the one accent pill — `rounded-full bg-primary text-primary-foreground min-h-11` |
154
- | Save, policy *Manage preferences* | outlined pill — `rounded-full border-[1.5px] border-foreground bg-transparent`, `hover:bg-muted` |
155
- | Links | muted, underlined at rest, `min-h-11` |
156
-
157
- Every control shows a 3px `outline-ring` focus outline 3px off the element (`outline-3
158
- outline-offset-3`; the switch draws it on its track through `peer-focus-visible`). An outline, not
159
- a ring, so a host that zeroes shadows keeps it. Never reintroduce a decorative class here — the
160
- package tests fail on `bg-gradient`, `backdrop-`, `blur`, `shadow` or `ring-primary` in the dialog.
161
-
162
- **Each switch is named by reference.** The `<label>` around a switch's checkbox holds only the
163
- drawn track, and the category's words sit in a sibling column it cannot reach, so the checkbox
164
- carries `aria-labelledby="<id>-label"` (the category label) and `aria-describedby` pointing at
165
- `<id>-required` and `<id>-desc`. Drop those and axe reports three unnamed checkboxes (`label`,
166
- critical). A required category stays `disabled` and keeps its name.
167
-
168
- `tests/a11y.spec.ts` runs axe-core (a dev dependency) against the open bar and the open window in
169
- both schemes, the locating overlay, the re-open button, and the policy page with services and the
170
- regional rule, and fails on any serious or critical violation. It loads the harness with `?styled=1` (plus `&theme=dark`), which compiles the package
171
- sources with a generated app's neutral tokens — contrast cannot be judged on the unstyled harness
172
- the other specs need. The harness also takes `?mode=bar|window`, `?categories=essential`,
173
- `?geo=<CC>|fail|html|hang` (its server answers `/cdn-cgi/trace` from the page's own query, so every
174
- case is still chosen by URL; `&geoDelay=<ms>`, `&geoTimeout=<ms>`), `?geoPlugin=<CC>|fail` and
175
- `?gpc=1` — `tests/bar.spec.ts` and `tests/geo.spec.ts` drive them.
176
- - `silent` skips every `dataLayer` and global write. It is for tests and for an app that runs no
177
- tags at all.
178
- - The window's switches are **seeded from storage every time it opens** — it is mounted only while
179
- open — never from the last render: a visitor reopening preferences must see the answer they gave.
180
- An automatic decision past its age seeds nothing.
181
-
182
- **When the window was raised by something waiting on it** — `reason === 'login'`, which the sign-in
183
- precondition in `@owlmeans/client-iam` raises — it says so and relabels the primary action *Accept
184
- & continue*. The sign-in gate always gets the window, never the bar. An automatic decision is said
185
- to be one when the window opens over it (`[data-consent-auto]`, `autoReason`). That is what makes the interruption legible instead of looking like the page asking
186
- twice. Word that path as an acknowledgement, never as "you must consent to essential cookies": a
187
- required category is disclosure, not a question.
188
-
189
- ## A menu row for a host that already has one
190
-
191
- `ConsentMenuWidget` renders one row — icon, translated label, `onClick` reopening the dialog
192
- (`consentStore.open('reopen')` by default, or a caller's own `onSelect`) — for a host whose own
193
- navigation already carries a settings menu and would rather offer cookie preferences there than as
194
- a second floating button. It calls `consentStore.open` directly, never `useConsent()`, for the same
195
- init-on-mount reason as everywhere else in this document.
196
-
197
- **It carries no presence signalling of its own.** A host that wants `CookieConsent`'s floating
198
- button to hide while this row is reachable needs to know that itself, from something that stays
199
- mounted for as long as the row is reachable — never from the row's own mount. A row placed inside a
200
- host menu's lazily-rendered content (Radix's `DropdownMenuContent`, and most headless menu
201
- primitives, only mount their children while the menu is actually OPEN) reports "present" for a
202
- fraction of the time the menu itself is on screen, and the floating button reappears the instant
203
- the menu closes. `@owlmeans/web-panel/consent`'s `useConsentMenuPresence()` is the fix for a
204
- context-aware host: call it from the menu's own always-mounted shell component, not from inside the
205
- row. A context-free host (an Astro island with its own menu) owns that judgement itself.
206
-
207
47
  ## Reading the decision
208
48
 
209
49
  ```tsx
@@ -233,109 +73,13 @@ a component that only wanted to read has to plan for:
233
73
  gets there first declares the `consent/default` signals from *its* categories, and every later
234
74
  call returns immediately. A bare `useConsent()` mounting before `CookieConsent` therefore declares
235
75
  `DEFAULT_CONSENT_CATEGORIES`' signals and the app's own `categories` never declare theirs — which
236
- silently breaks the parity rule above. Pass the app's `opts` wherever `useConsent` is called, or
76
+ silently breaks the parity rule (the category set the dialog renders is the set it saves —
77
+ [parts/dialog.md](parts/dialog.md)). Pass the app's `opts` wherever `useConsent` is called, or
237
78
  do not call it outside the dialog.
238
79
 
239
80
  `opts` are read on the mounting pass only (the effect's dependency list is empty), so changing them
240
81
  in a later render has no effect on that mount.
241
82
 
242
- ## The policy page
243
-
244
- `CookiePolicy` states only what the widget provably does — the categories in force, the storage key,
245
- the dual storage, the retention — all read from the same configuration the dialog renders. That is
246
- why it is generated rather than written: a hand-written policy drifts the first time a category
247
- changes, and nobody notices because nobody reads it until it matters.
248
-
249
- ```tsx
250
- import { googleTagHelper } from '@owlmeans/web-gtm'
251
-
252
- <CookiePolicy
253
- operator="Example Sp. z o.o." privacyHref="/legal/privacy" termsHref="/legal/terms"
254
- services={googleTag !== '' ? googleTagHelper.googleTagServices(googleTag) : undefined}
255
- />
256
- ```
257
-
258
- Everything OwlMeans cannot assert on the operator's behalf — who the controller is, the lawful
259
- basis, how to exercise rights — is deferred to those two links. It also renders a *Manage
260
- preferences* control, so the policy page is a way back into the decision.
261
-
262
- **`geo`**, the same object the dialog was given, adds the regional rule
263
- (`[data-cookie-policy-regional]`, `policyRegional`) after the category list.
264
-
265
- **`services`** (`ConsentService[]`, see the `consent` skill) discloses WHO receives data: each
266
- service is listed inside the item of the category whose `key` it names — name, provider, purpose,
267
- cookie names in `<code>`, and a link to the provider's privacy policy (new tab). A service whose
268
- category is not among `categories` goes into a trailing *Other services* item rather than being
269
- dropped. Without `services` the page renders exactly as it always has.
270
-
271
- The structure stays one `h1` and lists: categories are `li`s, a category's services are a nested
272
- `ul` named by `aria-label` (*Services — ‹category label›*), each service a `li` holding a `dl`.
273
- Never add headings for categories or services — they would skip a level under the `h1`. Test and
274
- CSS hooks: `[data-cookie-policy-category="<key>"]`, `[data-cookie-policy-services]`,
275
- `[data-cookie-policy-service]`, `[data-cookie-policy-other]`.
276
-
277
- **`linker`**, same shape as `CookieConsent`'s, adds a plain `[data-cookie-policy-domains]` LIST
278
- (never a heading) right after the storage-key paragraph — the current host plus every domain
279
- `linker.domains` names, computed the same direct way the dialog computes it. Omitted or with
280
- nothing beyond the current host to disclose, nothing here renders.
281
-
282
- The service labels (`policyProvider`, `policyPurpose`, `policyCookies`, `policyServicesOf`,
283
- `policyServicePrivacy`, `policyOtherServices`, `policyOtherServicesDesc`) go through `translate`
284
- with English defaults and are in the packaged bundle for every locale; the service's own `name`,
285
- `provider` and `purpose` are data and render as given.
286
-
287
- ## Tailwind — one `@source` line, pointing at `src`
288
-
289
- The components emit Tailwind classes that exist nowhere in the consumer's own sources, and
290
- Tailwind's scanner reads the CSS root plus `@source` directives only, excluding `node_modules`. Add
291
- the line to the app's Tailwind entry:
292
-
293
- ```css
294
- @import "tailwindcss";
295
-
296
- @source "<relative path to node_modules>/@owlmeans/web-consent/src";
297
- ```
298
-
299
- **Point it at `src`, never at `build`.** The scanner applies the `.gitignore` of whatever repository
300
- a path resolves into, and under a linked workspace the `node_modules` entry is a symlink into a
301
- monorepo where every package's build output is ignored. A `build` source there scans **zero files
302
- and reports nothing**: the build succeeds, the CSS is emitted, and the dialog renders *half*-styled
303
- — the utilities the app happens to use elsewhere still exist while the ones only this package asks
304
- for (`max-w-lg`, `bg-black/70`, `z-[999998]`) do not. What reaches the screen is a full-width,
305
- backdrop-less dialog with the page bleeding through, and nothing in the app's sources looks wrong.
306
- `src` is tracked and ships in the published tarball, so one path serves both a linked checkout and
307
- an npm install.
308
-
309
- The failure is silent, so verify rather than assume — grep the emitted stylesheet for a class only
310
- this package uses:
311
-
312
- ```sh
313
- grep -c 'max-w-lg' dist/assets/*.css
314
- ```
315
-
316
- A `*/` inside a CSS comment ends it early, so a comment above an `@source` that spells out a glob
317
- closes the comment at that star-slash and turns the next `@source` into an invalid declaration.
318
- Describe a glob in words, or keep it out of the comment.
319
-
320
- ## i18n
321
-
322
- Given no `translate` prop, the components resolve every string through the packaged bundle for
323
- `locale` — every language in `CONSENT_LOCALES` (the framework's seven plus French). The bundle
324
- lives in `@owlmeans/consent` (`src/i18n/<locale>.json`); a new key goes into every one of them,
325
- and that package's tests fail on a missing key or a dropped `{{placeholder}}` in any locale.
326
- This package's `tests/bundle.spec.ts` reads every `t('…')` literal from the components, so a new
327
- string without an English bundle entry fails there. A JSON-only edit is not re-emitted by an
328
- incremental `tsc -b` — rebuild `@owlmeans/consent` with `tsc -b --force`, or consumers keep the old
329
- bundle from `build/i18n`. **Once a `translate` prop is given,
330
- the packaged bundle is not consulted at all**, so a wrapper that forwards a framework resolver alone
331
- renders the English default for every key the application has not overridden, in every language. The
332
- resolver must fall through to `consentI18nHelper.defaultConsentTranslate(locale)` for the default — which is exactly
333
- what `@owlmeans/web-panel/consent` does, and why an app inside the panel family mounts
334
- `PanelCookieConsent` / `PanelCookiePolicy` rather than these components directly.
335
-
336
- `ConsentToggle` is exported for a host that builds its own dialog body; the wording it shows is the
337
- caller's, already resolved.
338
-
339
83
  ## Depends On
340
84
 
341
85
  - `@owlmeans/consent` — the model; the named selection above is re-exported from this package's root
@@ -351,3 +95,15 @@ caller's, already resolved.
351
95
  - `login-methods` / `login-plugins` — the sign-in precondition that raises this dialog with
352
96
  `reason: 'login'`
353
97
  - `web-panel` — its `./consent` subpath, the OwlMeans-i18n binding of these components
98
+
99
+ ## Parts
100
+
101
+ Load a part on top of this core when its job is in play.
102
+
103
+ | Part | Load it when |
104
+ |---|---|
105
+ | [dialog](parts/dialog.md) | Mounting `CookieConsent` — its surfaces, props (`mode`, `geo`, `linker`, `categories`), the re-open button, the bar, focus, the login-raised window |
106
+ | [look](parts/look.md) | Restyling the bar or the window, or checking the flat look, focus outlines, switch names and the a11y harness |
107
+ | [menu](parts/menu.md) | Offering cookie preferences from a host's own menu (`ConsentMenuWidget`) |
108
+ | [policy-page](parts/policy-page.md) | Rendering the generated `CookiePolicy` page, its services disclosure or regional rule |
109
+ | [setup](parts/setup.md) | Adding the Tailwind `@source` line every consumer needs (pointing at `src`), or passing a `translate` resolver / adding a string |
@@ -0,0 +1,110 @@
1
+ ---
2
+ name: dialog
3
+ description: Mounting CookieConsent once — its surfaces (locating spinner, bar, preferences window), its props, the re-open button, the linker line, the bar, focus, and the login-raised window.
4
+ metadata:
5
+ purposes: consent, dialog, layout
6
+ mentions: CookieConsent, CookieConsentProps, ConsentDisplayMode, CONSENT_DEFAULT_MODE, noReopenButton, policyHref, ConsentGeoOptions, ConsentLinkerOptions, barClassName, data-consent-dialog, data-consent-bar, data-consent-reopen
7
+ ---
8
+ <!-- AUTO-GENERATED — do not edit. Regenerate via sync-agent-meta. -->
9
+
10
+ # @owlmeans/web-consent — The dialog and the bar
11
+
12
+ ## Mounting the dialog
13
+
14
+ `CookieConsent` is mounted **once**, at the application root or in the layout. It opens itself when
15
+ no decision is stored, renders nothing but the re-open button once one is, and needs no state from
16
+ the caller. One surface at a time:
17
+
18
+ - **while a first-time visitor is located** (`geo`): a transparent overlay that blocks the page,
19
+ and a spinner (`[data-consent-locating="first"]`, `[data-consent-spinner]` after
20
+ `CONSENT_LOCATING_DELAY`, 300 ms) — never the bar;
21
+ - **the first ask** (`reason: 'initial'`) in `bar` mode, the default: the bar;
22
+ - **every other opening** — the bar's own "Cookie preferences" (`'preferences'`), a footer link or
23
+ the corner button (`'reopen'`), the sign-in gate (`'login'`) — and the first ask in `window`
24
+ mode: the preferences window.
25
+
26
+ `[data-consent-dialog]` (with `role="dialog"`, `aria-modal`) and `[data-consent-accept-all]` sit on
27
+ whichever surface is up, so a test or a host keys on them without knowing the mode;
28
+ `data-consent-mode="bar|window"` and `[data-consent-bar]` tell them apart. "Cookie preferences"
29
+ REPLACES the bar with the window — exactly one of each exists at a time.
30
+
31
+ ```tsx
32
+ import { CookieConsent } from '@owlmeans/web-consent'
33
+
34
+ <CookieConsent
35
+ policyHref="/legal/cookies"
36
+ links={[{ href: '/legal/privacy', labelKey: 'privacy', defaultLabel: 'Privacy Policy' }]}
37
+ />
38
+ ```
39
+
40
+ - **The category set the dialog renders is the set it saves.** Pass `categories` here and pass the
41
+ same set to whatever stamps the head snippet, or the two disagree about what was asked. The same
42
+ goes for `storageKey`, `cookieDays` and `cookieDomain`.
43
+ - `locale` picks the packaged language; leave it unset and English is used, so an app with a
44
+ language of its own passes it (or mounts `PanelCookieConsent`, which does). A region tag is
45
+ reduced to its base (`pl-PL` → `pl`), and anything outside `CONSENT_LOCALES` falls back to English.
46
+ - `policyHref` is a plain string, so an app-resolved path, a framework route and a raw href all
47
+ work — the component must not know how its host does routing.
48
+ - `noReopenButton` hides the floating button for an app that offers a footer link instead; that link
49
+ calls `consentStore.open('reopen')`.
50
+ - **`mode`** (`'bar'` | `'window'`, default `CONSENT_DEFAULT_MODE`) picks the first ask's surface.
51
+ - **`geo`** (`ConsentGeoOptions`, plain data — an Astro island can pass it) turns on the geo gate:
52
+ `geo={{ cloudflare: true }}` on a Cloudflare-proxied host. It is passed through to
53
+ `consentStore.init` like `linker`; the `consent` skill's "Where consent is asked" is the model. A
54
+ locator plugin is code and is registered, not passed. Pass the same `geo` to `CookiePolicy`.
55
+ - **The re-open button is a bare icon in the very corner (`bottom-1 left-1`), not a card.** No
56
+ filled background, no border, no shadow, no hover-scale — `bg-transparent`, dimmed
57
+ (`opacity-70`) at rest and picked out on hover/focus — because it sits on every page of a site
58
+ for as long as a visitor stays and must read as a small fixture rather than compete with the
59
+ page's own controls. The pictogram is 20px (`h-5 w-5`) inside an invisible 44px hit area
60
+ (`h-11 w-11`). `[data-consent-reopen]` is what any test or CSS override keys on.
61
+ - **`linker` (`ConsentLinkerOptions`)** turns on cross-domain consent (see the `consent` skill's
62
+ plugin-seam section): `<CookieConsent linker={{ domains: ['owlmeans.com', 'owlmeans.pl'] }} />`
63
+ passes it straight to `useConsent`, which registers `consentLinkHelper.consentLinker` on mount. With it set and
64
+ more than just the current host to disclose, a domain line renders right after the description
65
+ (`[data-consent-domains]`, added to the dialog's `aria-describedby`) — computed DIRECTLY from
66
+ `linker.domains` plus the current host, not through the plugin registry, so it never depends on
67
+ registration having finished yet. Omit `linker` and nothing here changes at all. The same object
68
+ carries `language` (`{}` to send the page's language, `{ supported }` to adopt one — the `consent`
69
+ skill's language section): the dialog only forwards it to the store and discloses nothing extra.
70
+ - **The interface language is not a category.** The default set renders the required row and the
71
+ analytics and marketing toggles only; the language is strictly necessary storage that no toggle
72
+ governs (the `consent` skill). The built-in bundle (8 languages) describes it in the required row.
73
+ - `silent` skips every `dataLayer` and global write. It is for tests and for an app that runs no
74
+ tags at all.
75
+ - The window's switches are **seeded from storage every time it opens** — it is mounted only while
76
+ open — never from the last render: a visitor reopening preferences must see the answer they gave.
77
+ An automatic decision past its age seeds nothing.
78
+
79
+ **When the window was raised by something waiting on it** — `reason === 'login'`, which the sign-in
80
+ precondition in `@owlmeans/client-iam` raises — it says so and relabels the primary action *Accept
81
+ & continue*. The sign-in gate always gets the window, never the bar. An automatic decision is said
82
+ to be one when the window opens over it (`[data-consent-auto]`, `autoReason`). That is what makes the interruption legible instead of looking like the page asking
83
+ twice. Word that path as an acknowledgement, never as "you must consent to essential cookies": a
84
+ required category is disclosure, not a question.
85
+
86
+ ## The bar
87
+
88
+ A tall bar across the bottom of a **dimmed** overlay (`[data-consent-overlay]`,
89
+ `fixed inset-0 bg-black/70`): the page stays in sight and scrolls, but nothing on it can be
90
+ pressed until the visitor answers. The bar (`fixed inset-x-0 bottom-0 max-h-[85vh] border-t
91
+ bg-background`) holds, left to right on a wide screen and stacked on a phone:
92
+
93
+ - the cookie icon and `barTitle`;
94
+ - `barDescription` — what the essential cookies do, the optional categories in force as a list
95
+ after a colon (`{{categories}}`, so no language has to decline a label), that they stay off until
96
+ allowed, and that the choice can be changed or withdrawn at any time; `barDescriptionEssential`
97
+ when nothing is optional;
98
+ - the cross-domain line (`[data-consent-domains]`) when `linker` names more than this host;
99
+ - the links row (`[data-consent-links]`: `policyHref` then `links`, 44px targets, new tab);
100
+ - three answers: `[data-consent-preferences]` "Cookie preferences" (outlined), then
101
+ `[data-consent-mandatory]` "Accept only mandatory" and `[data-consent-accept-all]` "Accept All" —
102
+ the two answers carry the SAME accent class: refusing is exactly as prominent as accepting, and
103
+ one click away. With no optional category there is nothing to refuse, so "mandatory" is absent.
104
+
105
+ `barClassName` styles the bar; `className` still styles the window's card.
106
+
107
+ **Focus.** On open, focus moves to the surface itself (`tabIndex={-1}`) — never to an answer, which
108
+ would be a nudge; Tab and Shift+Tab cycle inside it; on close focus returns where it was. Nothing
109
+ pulls focus back on `focusin` (a script focusing a field is not a keyboard user escaping). The
110
+ spinner overlay takes no focus.
@@ -0,0 +1,52 @@
1
+ ---
2
+ name: look
3
+ description: The flat look from the host's tokens — the class table, focus outlines, switches named by reference, and the axe and harness checks that pin them.
4
+ metadata:
5
+ purposes: consent, theme
6
+ mentions: barClassName, ConsentToggle, data-consent-overlay, a11y.spec, outline-ring
7
+ ---
8
+ <!-- AUTO-GENERATED — do not edit. Regenerate via sync-agent-meta. -->
9
+
10
+ # @owlmeans/web-consent — The look
11
+
12
+ ## The look — flat, from the host's tokens
13
+
14
+ The bar and the window are the first thing every new visitor of a generated app sees, so they
15
+ follow the flat rule those apps are held to: **no gradient, shadow, glow, glass or
16
+ `backdrop-filter`** anywhere. Everything is a theme token, so light and dark follow the host:
17
+
18
+ | Part | Classes |
19
+ |---|---|
20
+ | Bar overlay | `bg-black/70`, matching the preferences window — the page is dimmed beneath the opaque bar |
21
+ | Locating overlay | `bg-transparent` — a quick region lookup flashes no backdrop |
22
+ | Bar | `border-t border-border bg-background text-foreground`, no shadow |
23
+ | Bar answers | "Accept only mandatory" and "Accept All" both the accent pill; "Cookie preferences" outlined |
24
+ | Spinner | `text-muted-foreground motion-safe:animate-spin` |
25
+ | Window overlay | `bg-black/70`, flat — no blur |
26
+ | Card | `rounded-3xl border border-border bg-background text-foreground`, no shadow or ring |
27
+ | Icon | `rounded-full bg-muted text-primary` |
28
+ | Category row | `rounded-2xl border border-border`; *Required* is plain uppercase muted text, never a badge |
29
+ | Switch | `bg-primary` on / `bg-muted-foreground/40` off, white knob; 44px hit area around the 24×44 track |
30
+ | Accept | the one accent pill — `rounded-full bg-primary text-primary-foreground min-h-11` |
31
+ | Save, policy *Manage preferences* | outlined pill — `rounded-full border-[1.5px] border-foreground bg-transparent`, `hover:bg-muted` |
32
+ | Links | muted, underlined at rest, `min-h-11` |
33
+
34
+ Every control shows a 3px `outline-ring` focus outline 3px off the element (`outline-3
35
+ outline-offset-3`; the switch draws it on its track through `peer-focus-visible`). An outline, not
36
+ a ring, so a host that zeroes shadows keeps it. Never reintroduce a decorative class here — the
37
+ package tests fail on `bg-gradient`, `backdrop-`, `blur`, `shadow` or `ring-primary` in the dialog.
38
+
39
+ **Each switch is named by reference.** The `<label>` around a switch's checkbox holds only the
40
+ drawn track, and the category's words sit in a sibling column it cannot reach, so the checkbox
41
+ carries `aria-labelledby="<id>-label"` (the category label) and `aria-describedby` pointing at
42
+ `<id>-required` and `<id>-desc`. Drop those and axe reports three unnamed checkboxes (`label`,
43
+ critical). A required category stays `disabled` and keeps its name.
44
+
45
+ `tests/a11y.spec.ts` runs axe-core (a dev dependency) against the open bar and the open window in
46
+ both schemes, the locating overlay, the re-open button, and the policy page with services and the
47
+ regional rule, and fails on any serious or critical violation. It loads the harness with `?styled=1` (plus `&theme=dark`), which compiles the package
48
+ sources with a generated app's neutral tokens — contrast cannot be judged on the unstyled harness
49
+ the other specs need. The harness also takes `?mode=bar|window`, `?categories=essential`,
50
+ `?geo=<CC>|fail|html|hang` (its server answers `/cdn-cgi/trace` from the page's own query, so every
51
+ case is still chosen by URL; `&geoDelay=<ms>`, `&geoTimeout=<ms>`), `?geoPlugin=<CC>|fail` and
52
+ `?gpc=1` — `tests/bar.spec.ts` and `tests/geo.spec.ts` drive them.
@@ -0,0 +1,28 @@
1
+ ---
2
+ name: menu
3
+ description: ConsentMenuWidget, a plain row a host's own menu renders to reopen the dialog, and where its presence signal must come from.
4
+ metadata:
5
+ purposes: consent, navigation
6
+ mentions: ConsentMenuWidget, useConsentMenuPresence, consentStore.open
7
+ ---
8
+ <!-- AUTO-GENERATED — do not edit. Regenerate via sync-agent-meta. -->
9
+
10
+ # @owlmeans/web-consent — A menu row
11
+
12
+ ## A menu row for a host that already has one
13
+
14
+ `ConsentMenuWidget` renders one row — icon, translated label, `onClick` reopening the dialog
15
+ (`consentStore.open('reopen')` by default, or a caller's own `onSelect`) — for a host whose own
16
+ navigation already carries a settings menu and would rather offer cookie preferences there than as
17
+ a second floating button. It calls `consentStore.open` directly, never `useConsent()`, for the same
18
+ init-on-mount reason as everywhere else in this skill (*Reading the decision* in [SKILL.md](../SKILL.md)).
19
+
20
+ **It carries no presence signalling of its own.** A host that wants `CookieConsent`'s floating
21
+ button to hide while this row is reachable needs to know that itself, from something that stays
22
+ mounted for as long as the row is reachable — never from the row's own mount. A row placed inside a
23
+ host menu's lazily-rendered content (Radix's `DropdownMenuContent`, and most headless menu
24
+ primitives, only mount their children while the menu is actually OPEN) reports "present" for a
25
+ fraction of the time the menu itself is on screen, and the floating button reappears the instant
26
+ the menu closes. `@owlmeans/web-panel/consent`'s `useConsentMenuPresence()` is the fix for a
27
+ context-aware host: call it from the menu's own always-mounted shell component, not from inside the
28
+ row. A context-free host (an Astro island with its own menu) owns that judgement itself.
@@ -0,0 +1,55 @@
1
+ ---
2
+ name: policy-page
3
+ description: The generated CookiePolicy page — what it states, the regional rule, the services disclosure, the linker domain list and its heading structure.
4
+ metadata:
5
+ purposes: consent, screen
6
+ mentions: CookiePolicy, CookiePolicyProps, ConsentService, googleTagServices, data-cookie-policy
7
+ ---
8
+ <!-- AUTO-GENERATED — do not edit. Regenerate via sync-agent-meta. -->
9
+
10
+ # @owlmeans/web-consent — The policy page
11
+
12
+ ## The policy page
13
+
14
+ `CookiePolicy` states only what the widget provably does — the categories in force, the storage key,
15
+ the dual storage, the retention — all read from the same configuration the dialog renders. That is
16
+ why it is generated rather than written: a hand-written policy drifts the first time a category
17
+ changes, and nobody notices because nobody reads it until it matters.
18
+
19
+ ```tsx
20
+ import { googleTagHelper } from '@owlmeans/web-gtm'
21
+
22
+ <CookiePolicy
23
+ operator="Example Sp. z o.o." privacyHref="/legal/privacy" termsHref="/legal/terms"
24
+ services={googleTag !== '' ? googleTagHelper.googleTagServices(googleTag) : undefined}
25
+ />
26
+ ```
27
+
28
+ Everything OwlMeans cannot assert on the operator's behalf — who the controller is, the lawful
29
+ basis, how to exercise rights — is deferred to those two links. It also renders a *Manage
30
+ preferences* control, so the policy page is a way back into the decision.
31
+
32
+ **`geo`**, the same object the dialog was given, adds the regional rule
33
+ (`[data-cookie-policy-regional]`, `policyRegional`) after the category list.
34
+
35
+ **`services`** (`ConsentService[]`, see the `consent` skill) discloses WHO receives data: each
36
+ service is listed inside the item of the category whose `key` it names — name, provider, purpose,
37
+ cookie names in `<code>`, and a link to the provider's privacy policy (new tab). A service whose
38
+ category is not among `categories` goes into a trailing *Other services* item rather than being
39
+ dropped. Without `services` the page renders exactly as it always has.
40
+
41
+ The structure stays one `h1` and lists: categories are `li`s, a category's services are a nested
42
+ `ul` named by `aria-label` (*Services — ‹category label›*), each service a `li` holding a `dl`.
43
+ Never add headings for categories or services — they would skip a level under the `h1`. Test and
44
+ CSS hooks: `[data-cookie-policy-category="<key>"]`, `[data-cookie-policy-services]`,
45
+ `[data-cookie-policy-service]`, `[data-cookie-policy-other]`.
46
+
47
+ **`linker`**, same shape as `CookieConsent`'s, adds a plain `[data-cookie-policy-domains]` LIST
48
+ (never a heading) right after the storage-key paragraph — the current host plus every domain
49
+ `linker.domains` names, computed the same direct way the dialog computes it. Omitted or with
50
+ nothing beyond the current host to disclose, nothing here renders.
51
+
52
+ The service labels (`policyProvider`, `policyPurpose`, `policyCookies`, `policyServicesOf`,
53
+ `policyServicePrivacy`, `policyOtherServices`, `policyOtherServicesDesc`) go through `translate`
54
+ with English defaults and are in the packaged bundle for every locale; the service's own `name`,
55
+ `provider` and `purpose` are data and render as given.
@@ -0,0 +1,62 @@
1
+ ---
2
+ name: setup
3
+ description: The Tailwind @source line pointing at src, and the packaged i18n bundle with the translate fall-through.
4
+ metadata:
5
+ purposes: consent, config
6
+ mentions: defaultConsentTranslate, consentI18nHelper, CONSENT_LOCALES, web-consent/src, @source
7
+ ---
8
+ <!-- AUTO-GENERATED — do not edit. Regenerate via sync-agent-meta. -->
9
+
10
+ # @owlmeans/web-consent — Tailwind and i18n setup
11
+
12
+ ## Tailwind — one `@source` line, pointing at `src`
13
+
14
+ The components emit Tailwind classes that exist nowhere in the consumer's own sources, and
15
+ Tailwind's scanner reads the CSS root plus `@source` directives only, excluding `node_modules`. Add
16
+ the line to the app's Tailwind entry:
17
+
18
+ ```css
19
+ @import "tailwindcss";
20
+
21
+ @source "<relative path to node_modules>/@owlmeans/web-consent/src";
22
+ ```
23
+
24
+ **Point it at `src`, never at `build`.** The scanner applies the `.gitignore` of whatever repository
25
+ a path resolves into, and under a linked workspace the `node_modules` entry is a symlink into a
26
+ monorepo where every package's build output is ignored. A `build` source there scans **zero files
27
+ and reports nothing**: the build succeeds, the CSS is emitted, and the dialog renders *half*-styled
28
+ — the utilities the app happens to use elsewhere still exist while the ones only this package asks
29
+ for (`max-w-lg`, `bg-black/70`, `z-[999998]`) do not. What reaches the screen is a full-width,
30
+ backdrop-less dialog with the page bleeding through, and nothing in the app's sources looks wrong.
31
+ `src` is tracked and ships in the published tarball, so one path serves both a linked checkout and
32
+ an npm install.
33
+
34
+ The failure is silent, so verify rather than assume — grep the emitted stylesheet for a class only
35
+ this package uses:
36
+
37
+ ```sh
38
+ grep -c 'max-w-lg' dist/assets/*.css
39
+ ```
40
+
41
+ A `*/` inside a CSS comment ends it early, so a comment above an `@source` that spells out a glob
42
+ closes the comment at that star-slash and turns the next `@source` into an invalid declaration.
43
+ Describe a glob in words, or keep it out of the comment.
44
+
45
+ ## i18n
46
+
47
+ Given no `translate` prop, the components resolve every string through the packaged bundle for
48
+ `locale` — every language in `CONSENT_LOCALES` (the framework's seven plus French). The bundle
49
+ lives in `@owlmeans/consent` (`src/i18n/<locale>.json`); a new key goes into every one of them,
50
+ and that package's tests fail on a missing key or a dropped `{{placeholder}}` in any locale.
51
+ This package's `tests/bundle.spec.ts` reads every `t('…')` literal from the components, so a new
52
+ string without an English bundle entry fails there. A JSON-only edit is not re-emitted by an
53
+ incremental `tsc -b` — rebuild `@owlmeans/consent` with `tsc -b --force`, or consumers keep the old
54
+ bundle from `build/i18n`. **Once a `translate` prop is given,
55
+ the packaged bundle is not consulted at all**, so a wrapper that forwards a framework resolver alone
56
+ renders the English default for every key the application has not overridden, in every language. The
57
+ resolver must fall through to `consentI18nHelper.defaultConsentTranslate(locale)` for the default — which is exactly
58
+ what `@owlmeans/web-panel/consent` does, and why an app inside the panel family mounts
59
+ `PanelCookieConsent` / `PanelCookiePolicy` rather than these components directly.
60
+
61
+ `ConsentToggle` is exported for a host that builds its own dialog body; the wording it shows is the
62
+ caller's, already resolved.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@owlmeans/web-consent",
3
- "version": "0.1.18-rc.47",
3
+ "version": "0.1.18-rc.49",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -22,7 +22,7 @@
22
22
  }
23
23
  },
24
24
  "dependencies": {
25
- "@owlmeans/consent": "^0.1.18-rc.12"
25
+ "@owlmeans/consent": "^0.1.18-rc.13"
26
26
  },
27
27
  "peerDependencies": {
28
28
  "clsx": "*",
@@ -33,7 +33,7 @@
33
33
  },
34
34
  "devDependencies": {
35
35
  "@owlmeans/dep-config": "workspace:*",
36
- "@owlmeans/test-ui": "^0.1.18-rc.54",
36
+ "@owlmeans/test-ui": "^0.1.18-rc.55",
37
37
  "@tailwindcss/vite": "^4.3.3",
38
38
  "@types/bun": "^1.4.2",
39
39
  "@types/react": "^19.3.0",