@openpolicy/react 0.0.19 → 0.0.20

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openpolicy/react",
3
- "version": "0.0.19",
3
+ "version": "0.0.20",
4
4
  "type": "module",
5
5
  "description": "React components and hooks for OpenPolicy",
6
6
  "license": "GPL-3.0-only",
@@ -12,7 +12,8 @@
12
12
  "files": [
13
13
  "dist",
14
14
  "styles.css",
15
- "README.md"
15
+ "README.md",
16
+ "skills"
16
17
  ],
17
18
  "exports": {
18
19
  ".": {
@@ -36,7 +37,8 @@
36
37
  "@openpolicy/core": "workspace:*",
37
38
  "@openpolicy/tooling": "workspace:*",
38
39
  "@types/react": "^19.0.0",
39
- "react": "^19.0.0"
40
+ "react": "^19.0.0",
41
+ "@tanstack/intent": "^0.0.29"
40
42
  },
41
43
  "publishConfig": {
42
44
  "exports": {
@@ -46,5 +48,8 @@
46
48
  },
47
49
  "./styles.css": "./styles.css"
48
50
  }
49
- }
51
+ },
52
+ "keywords": [
53
+ "tanstack-intent"
54
+ ]
50
55
  }
@@ -0,0 +1,338 @@
1
+ ---
2
+ name: cookie-banner
3
+ description: >
4
+ Cookie consent banner with useCookies hook, route state machine (cookie | preferences | closed), acceptAll, acceptNecessary, save, categories, toggle, ConsentGate conditional rendering, has expression DSL, localStorage persistence under op_consent key.
5
+ type: framework
6
+ library: openpolicy
7
+ framework: react
8
+ library_version: "0.0.19"
9
+ requires:
10
+ - openpolicy/define-config
11
+ - openpolicy/render-policies
12
+ sources:
13
+ - jamiedavenport/openpolicy:packages/react/src/context.tsx
14
+ - jamiedavenport/openpolicy:apps/www/registry/cookie-banner.tsx
15
+ ---
16
+
17
+ This skill builds on openpolicy/define-config and openpolicy/render-policies. Read them first.
18
+
19
+ ## Route State Machine
20
+
21
+ `useCookies()` exposes a `route` value that drives which UI to show:
22
+
23
+ ```
24
+ "cookie" — consent not yet given; show the banner
25
+ "preferences" — user clicked "Manage"; show the preferences panel
26
+ "closed" — consent resolved; render nothing
27
+ ```
28
+
29
+ The provider sets `route` to `"cookie"` automatically when `status === "undecided"` (no prior consent in localStorage). Once the user acts (`acceptAll`, `acceptNecessary`, or `save`), the provider sets `route` to `"closed"`. `setRoute` lets you navigate between `"cookie"` and `"preferences"` manually.
30
+
31
+ ## Setup
32
+
33
+ The following is a complete minimal implementation using plain divs and buttons — no UI library required.
34
+
35
+ ### 1. Config — include a `cookie` section
36
+
37
+ ```ts
38
+ // openpolicy.ts
39
+ import { defineConfig } from "@openpolicy/sdk";
40
+
41
+ export default defineConfig({
42
+ company: {
43
+ name: "Acme",
44
+ legalName: "Acme, Inc.",
45
+ address: "123 Main St, San Francisco, CA",
46
+ contact: "privacy@acme.com",
47
+ },
48
+ cookie: {
49
+ cookies: {
50
+ essential: true,
51
+ analytics: true,
52
+ marketing: false,
53
+ },
54
+ },
55
+ });
56
+ ```
57
+
58
+ ### 2. Provider — wrap your app root
59
+
60
+ ```tsx
61
+ // App.tsx
62
+ import { OpenPolicy } from "@openpolicy/react";
63
+ import config from "./openpolicy";
64
+ import { CookieBanner } from "./CookieBanner";
65
+ import { CookiePreferences } from "./CookiePreferences";
66
+
67
+ export function App() {
68
+ return (
69
+ <OpenPolicy config={config}>
70
+ <CookieBanner />
71
+ <CookiePreferences />
72
+ {/* rest of app */}
73
+ </OpenPolicy>
74
+ );
75
+ }
76
+ ```
77
+
78
+ ### 3. Banner — gate on `route === "cookie"`
79
+
80
+ ```tsx
81
+ // CookieBanner.tsx
82
+ import { useCookies } from "@openpolicy/react";
83
+
84
+ export function CookieBanner() {
85
+ const { route, setRoute, acceptAll, acceptNecessary } = useCookies();
86
+
87
+ if (route !== "cookie") return null;
88
+
89
+ return (
90
+ <div
91
+ style={{
92
+ position: "fixed",
93
+ bottom: "1rem",
94
+ right: "1rem",
95
+ padding: "1.5rem",
96
+ background: "#fff",
97
+ border: "1px solid #e5e7eb",
98
+ borderRadius: "0.5rem",
99
+ maxWidth: "24rem",
100
+ zIndex: 50,
101
+ }}
102
+ >
103
+ <p style={{ marginBottom: "1rem" }}>
104
+ We use cookies to improve your experience and analyse site traffic.
105
+ </p>
106
+ <div style={{ display: "flex", gap: "0.5rem", justifyContent: "flex-end" }}>
107
+ <button onClick={() => setRoute("preferences")}>Manage Cookies</button>
108
+ <button onClick={acceptNecessary}>Necessary Only</button>
109
+ <button onClick={acceptAll}>Accept All</button>
110
+ </div>
111
+ </div>
112
+ );
113
+ }
114
+ ```
115
+
116
+ ### 4. Preferences panel — toggle categories, then save
117
+
118
+ ```tsx
119
+ // CookiePreferences.tsx
120
+ import { useCookies } from "@openpolicy/react";
121
+
122
+ export function CookiePreferences() {
123
+ const { route, setRoute, categories, toggle, save, acceptNecessary } =
124
+ useCookies();
125
+
126
+ if (route !== "preferences") return null;
127
+
128
+ return (
129
+ <div
130
+ style={{
131
+ position: "fixed",
132
+ inset: 0,
133
+ background: "rgba(0,0,0,0.5)",
134
+ display: "flex",
135
+ alignItems: "center",
136
+ justifyContent: "center",
137
+ zIndex: 50,
138
+ }}
139
+ >
140
+ <div
141
+ style={{
142
+ background: "#fff",
143
+ padding: "2rem",
144
+ borderRadius: "0.5rem",
145
+ minWidth: "20rem",
146
+ }}
147
+ >
148
+ <h2>Cookie preferences</h2>
149
+ <p>Choose which cookies you allow.</p>
150
+
151
+ <ul style={{ listStyle: "none", padding: 0 }}>
152
+ {categories.map(({ key, label, enabled, locked }) => (
153
+ <li
154
+ key={key}
155
+ style={{
156
+ display: "flex",
157
+ justifyContent: "space-between",
158
+ padding: "0.5rem 0",
159
+ }}
160
+ >
161
+ <span>{label}</span>
162
+ <input
163
+ type="checkbox"
164
+ checked={enabled}
165
+ disabled={locked}
166
+ onChange={() => toggle(key)}
167
+ />
168
+ </li>
169
+ ))}
170
+ </ul>
171
+
172
+ <div style={{ display: "flex", gap: "0.5rem", justifyContent: "flex-end" }}>
173
+ <button onClick={acceptNecessary}>Reject All</button>
174
+ <button onClick={() => save()}>Save Preferences</button>
175
+ </div>
176
+ </div>
177
+ </div>
178
+ );
179
+ }
180
+ ```
181
+
182
+ ## Core Patterns
183
+
184
+ ### 1. Route-gated rendering
185
+
186
+ Always check `route` before rendering. Each component owns one route value and returns `null` for all others.
187
+
188
+ ```tsx
189
+ // Banner owns "cookie"
190
+ if (route !== "cookie") return null;
191
+
192
+ // Preferences owns "preferences"
193
+ if (route !== "preferences") return null;
194
+ ```
195
+
196
+ Navigating between them:
197
+
198
+ ```tsx
199
+ // Open preferences from banner
200
+ <button onClick={() => setRoute("preferences")}>Manage Cookies</button>
201
+
202
+ // Close preferences without saving (returns to "closed", not back to "cookie")
203
+ <button onClick={() => setRoute("closed")}>Cancel</button>
204
+ ```
205
+
206
+ ### 2. Preferences panel with categories, toggle, and save
207
+
208
+ `categories` is derived from your config's `cookie.cookies` keys. Each entry:
209
+
210
+ ```ts
211
+ type CookieCategory = {
212
+ key: string; // "essential" | "analytics" | "marketing" | ...
213
+ label: string; // human-readable, e.g. "Analytics"
214
+ enabled: boolean;
215
+ locked: boolean; // true for "essential" — cannot be toggled
216
+ };
217
+ ```
218
+
219
+ `toggle(key)` updates local draft state without persisting. `save()` merges draft into localStorage and closes the banner. Until `save()` is called, the user's changes are not committed.
220
+
221
+ ```tsx
222
+ {categories.map(({ key, label, enabled, locked }) => (
223
+ <label key={key}>
224
+ <input
225
+ type="checkbox"
226
+ checked={enabled}
227
+ disabled={locked}
228
+ onChange={() => toggle(key)}
229
+ />
230
+ {label}
231
+ </label>
232
+ ))}
233
+ <button onClick={() => save()}>Save</button>
234
+ ```
235
+
236
+ ### 3. ConsentGate — conditional rendering by consent
237
+
238
+ Wrap any content that should only appear when a category has consent:
239
+
240
+ ```tsx
241
+ import { ConsentGate } from "@openpolicy/react";
242
+
243
+ // Simple category string
244
+ <ConsentGate requires="analytics">
245
+ <AnalyticsDashboard />
246
+ </ConsentGate>
247
+
248
+ // Compound expression
249
+ <ConsentGate requires={{ and: ["analytics", "marketing"] }}>
250
+ <RetargetingPixel />
251
+ </ConsentGate>
252
+
253
+ <ConsentGate requires={{ or: ["analytics", "functional"] }}>
254
+ <EnhancedFeatures />
255
+ </ConsentGate>
256
+ ```
257
+
258
+ `has()` from `useCookies()` evaluates the same `HasExpression` DSL imperatively:
259
+
260
+ ```tsx
261
+ const { has } = useCookies();
262
+
263
+ // Load analytics script only when consent is given
264
+ useEffect(() => {
265
+ if (has("analytics")) {
266
+ loadAnalytics();
267
+ }
268
+ }, [has]);
269
+ ```
270
+
271
+ `HasExpression` type: `string | { and: HasExpression[] } | { or: HasExpression[] } | { not: HasExpression }`
272
+
273
+ ### 4. shadcn registry shortcut
274
+
275
+ For a pre-styled banner and preferences panel that matches your shadcn/ui theme:
276
+
277
+ ```sh
278
+ shadcn add @openpolicy/cookie-banner
279
+ ```
280
+
281
+ This installs `CookieBanner` and `CookiePreferences` components using shadcn Card, Dialog, Switch, and Button primitives. The logic is identical to the manual implementation above — only the styling differs.
282
+
283
+ ## Common Mistakes
284
+
285
+ ### Mistake 1 — Not gating banner render on `route === "cookie"` (HIGH)
286
+
287
+ Without the route check the banner renders permanently, ignoring whether consent has already been given.
288
+
289
+ ```tsx
290
+ // WRONG: renders always, no route check
291
+ function CookieBanner() {
292
+ const { acceptAll, acceptNecessary } = useCookies();
293
+ return <div>...</div>;
294
+ }
295
+
296
+ // Correct
297
+ function CookieBanner() {
298
+ const { route, acceptAll, acceptNecessary } = useCookies();
299
+ if (route !== "cookie") return null;
300
+ return <div>...</div>;
301
+ }
302
+ ```
303
+
304
+ ### Mistake 2 — Building custom consent state with `useState` instead of `useCookies()` (HIGH)
305
+
306
+ Manual `useState` consent flags miss localStorage persistence, cross-tab sync, and the `document.body` `data-consent-*` attributes that `useCookies()` maintains automatically.
307
+
308
+ ```tsx
309
+ // WRONG: custom state, no persistence or sync
310
+ const [accepted, setAccepted] = useState(false);
311
+ // manually writing to localStorage, missing cross-tab broadcast
312
+
313
+ // Correct: use the hook
314
+ const { status, acceptAll, acceptNecessary, has } = useCookies();
315
+ ```
316
+
317
+ The provider writes consent under key `"op_consent"` in localStorage and syncs via `useSyncExternalStore` — all tabs sharing the same origin update simultaneously.
318
+
319
+ ### Mistake 3 — Using `useCookies()` without `<OpenPolicy>` provider (HIGH)
320
+
321
+ Without the provider, `useCookies()` reads from the default context: `consent: null`, `categories: []`, `route: "closed"`. The banner never appears and the preferences panel renders no toggles. There is no thrown error — it fails silently.
322
+
323
+ ```tsx
324
+ // WRONG: no provider
325
+ function App() {
326
+ return <CookieBanner />;
327
+ }
328
+
329
+ // Correct: provider at root, banner and preferences inside it
330
+ function App() {
331
+ return (
332
+ <OpenPolicy config={config}>
333
+ <CookieBanner />
334
+ <CookiePreferences />
335
+ </OpenPolicy>
336
+ );
337
+ }
338
+ ```
@@ -0,0 +1,237 @@
1
+ ---
2
+ name: render-policies
3
+ description: >
4
+ Render OpenPolicy privacy, terms of service, and cookie policy documents as
5
+ React components using @openpolicy/react. Components — PrivacyPolicy,
6
+ TermsOfService, CookiePolicy — read config from the OpenPolicyProvider
7
+ (alias OpenPolicy) context and accept optional config and components props
8
+ for per-page overrides and full rendering customization via PolicyComponents.
9
+ type: framework
10
+ library: openpolicy
11
+ framework: react
12
+ library_version: "0.0.19"
13
+ requires:
14
+ - openpolicy/define-config
15
+ sources:
16
+ - jamiedavenport/openpolicy:packages/react/src/context.tsx
17
+ - jamiedavenport/openpolicy:packages/react/src/render.tsx
18
+ - jamiedavenport/openpolicy:packages/react/src/types.ts
19
+ ---
20
+
21
+ This skill builds on openpolicy/define-config. Read it first.
22
+
23
+ ## Setup
24
+
25
+ Install the package:
26
+
27
+ ```sh
28
+ bun add @openpolicy/react
29
+ ```
30
+
31
+ Wrap your app with the provider, import styles, and render a policy page:
32
+
33
+ ```tsx
34
+ // layout.tsx (or _app.tsx)
35
+ import '@openpolicy/react/styles.css';
36
+ import { OpenPolicy } from '@openpolicy/react';
37
+ import config from './openpolicy';
38
+
39
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
40
+ return (
41
+ <OpenPolicy config={config}>
42
+ {children}
43
+ </OpenPolicy>
44
+ );
45
+ }
46
+ ```
47
+
48
+ ```tsx
49
+ // app/privacy/page.tsx
50
+ import { PrivacyPolicy } from '@openpolicy/react';
51
+
52
+ export default function PrivacyPage() {
53
+ return <PrivacyPolicy />;
54
+ }
55
+ ```
56
+
57
+ ## Core Patterns
58
+
59
+ ### 1. Provider at app root
60
+
61
+ `OpenPolicyProvider` (exported as `OpenPolicy`) must wrap the entire application. All policy components and hooks read from this context.
62
+
63
+ ```tsx
64
+ import { OpenPolicy } from '@openpolicy/react';
65
+ import config from './openpolicy';
66
+
67
+ <OpenPolicy config={config}>
68
+ <App />
69
+ </OpenPolicy>
70
+ ```
71
+
72
+ `config` must be an `OpenPolicyConfig` (the nested shape produced by `defineConfig()`). The provider also injects default styles via a React `<style>` tag, so the CSS import is optional when using the provider — but import it explicitly for non-provider rendering paths.
73
+
74
+ ### 2. Rendering all three policy types
75
+
76
+ Each component reads its slice of the config from context automatically:
77
+
78
+ ```tsx
79
+ import { PrivacyPolicy, TermsOfService, CookiePolicy } from '@openpolicy/react';
80
+
81
+ // Privacy policy page
82
+ export default function PrivacyPage() {
83
+ return <PrivacyPolicy />;
84
+ }
85
+
86
+ // Terms of service page
87
+ export default function TermsPage() {
88
+ return <TermsOfService />;
89
+ }
90
+
91
+ // Cookie policy page
92
+ export default function CookiePolicyPage() {
93
+ return <CookiePolicy />;
94
+ }
95
+ ```
96
+
97
+ All three components accept the same optional props:
98
+
99
+ | Prop | Type | Description |
100
+ |------|------|-------------|
101
+ | `config` | `OpenPolicyConfig \| PrivacyPolicyConfig` (or equivalent) | Per-page config override; falls back to context |
102
+ | `components` | `PolicyComponents` | Override default renderers for individual node types |
103
+ | `style` | `CSSProperties` | Inline styles applied to the wrapper `<div>` |
104
+
105
+ ### 3. Component customization with PolicyComponents
106
+
107
+ Override any node renderer by passing a `components` prop. The `PolicyComponents` interface:
108
+
109
+ ```ts
110
+ type PolicyComponents = {
111
+ Section?: ComponentType<{ section: DocumentSection; children: ReactNode }>;
112
+ Heading?: ComponentType<{ node: HeadingNode }>;
113
+ Paragraph?: ComponentType<{ node: ParagraphNode; children: ReactNode }>;
114
+ List?: ComponentType<{ node: ListNode; children: ReactNode }>;
115
+ Text?: ComponentType<{ node: TextNode }>;
116
+ Bold?: ComponentType<{ node: BoldNode }>;
117
+ Italic?: ComponentType<{ node: ItalicNode }>;
118
+ Link?: ComponentType<{ node: LinkNode }>;
119
+ }
120
+ ```
121
+
122
+ All fields are optional — unspecified slots use the default renderers. Example: swap in a custom heading:
123
+
124
+ ```tsx
125
+ import { PrivacyPolicy } from '@openpolicy/react';
126
+ import type { HeadingNode } from '@openpolicy/core';
127
+
128
+ function MyHeading({ node }: { node: HeadingNode }) {
129
+ return <h2 className="text-2xl font-bold text-slate-900">{node.text}</h2>;
130
+ }
131
+
132
+ export default function PrivacyPage() {
133
+ return <PrivacyPolicy components={{ Heading: MyHeading }} />;
134
+ }
135
+ ```
136
+
137
+ ### 4. Theming with CSS custom properties
138
+
139
+ The `.op-policy` wrapper exposes CSS custom properties. Override them globally or scoped:
140
+
141
+ ```css
142
+ .op-policy {
143
+ --op-heading-color: #0f172a;
144
+ --op-body-color: #475569;
145
+ --op-link-color: #6366f1;
146
+ --op-link-color-hover: #4f46e5;
147
+ --op-font-family: 'Inter', sans-serif;
148
+ --op-font-size-body: 0.9375rem;
149
+ --op-font-size-heading: 1.125rem;
150
+ --op-font-weight-heading: 600;
151
+ --op-line-height: 1.75;
152
+ --op-section-gap: 2.5rem;
153
+ --op-border-color: #e2e8f0;
154
+ --op-border-radius: 0.5rem;
155
+ }
156
+ ```
157
+
158
+ ### 5. shadcn/ui registry install
159
+
160
+ Pre-styled variants are available via the shadcn registry. Install individual components:
161
+
162
+ ```sh
163
+ shadcn add @openpolicy/privacy-policy
164
+ shadcn add @openpolicy/terms-of-service
165
+ shadcn add @openpolicy/cookie-policy
166
+ ```
167
+
168
+ This copies styled component files into your project that you can edit freely. They use the same `PolicyComponents` customization interface under the hood.
169
+
170
+ ## Common Mistakes
171
+
172
+ ### CRITICAL — Using `openPolicy()` from `@openpolicy/vite` to generate static files
173
+
174
+ The `openPolicy()` Vite plugin emits `.md` / `.html` / `.pdf` files at build time. It is a separate, legacy path. Agents trained on older docs may reach for it when asked to "add a privacy policy page."
175
+
176
+ ```ts
177
+ // vite.config.ts — WRONG
178
+ import { openPolicy } from '@openpolicy/vite';
179
+ plugins: [openPolicy({ formats: ['html'], outDir: 'public' })]
180
+ ```
181
+
182
+ The correct approach is React runtime rendering — render `<PrivacyPolicy />` as a page component. The static files produced by `openPolicy()` are never read by the React components.
183
+
184
+ ```tsx
185
+ // correct: render as a React page
186
+ import { PrivacyPolicy } from '@openpolicy/react';
187
+ export default function PrivacyPage() {
188
+ return <PrivacyPolicy />;
189
+ }
190
+ ```
191
+
192
+ ### HIGH — Rendering policy components outside `<OpenPolicy>` provider
193
+
194
+ `PrivacyPolicy`, `TermsOfService`, and `CookiePolicy` call `useContext(OpenPolicyContext)` and return `null` when no config is found. Without the provider, nothing renders and no error is thrown — the bug is invisible in development.
195
+
196
+ ```tsx
197
+ // privacy-page.tsx — WRONG: no provider anywhere in the tree
198
+ export default function PrivacyPage() {
199
+ return <PrivacyPolicy />;
200
+ }
201
+ ```
202
+
203
+ Wrap at the application root, not inside individual pages:
204
+
205
+ ```tsx
206
+ // layout.tsx — correct
207
+ import config from './openpolicy';
208
+ export default function RootLayout({ children }) {
209
+ return (
210
+ <OpenPolicy config={config}>
211
+ {children}
212
+ </OpenPolicy>
213
+ );
214
+ }
215
+
216
+ // privacy-page.tsx — correct: provider is already in the tree
217
+ export default function PrivacyPage() {
218
+ return <PrivacyPolicy />;
219
+ }
220
+ ```
221
+
222
+ ### MEDIUM — Not importing `@openpolicy/react/styles.css`
223
+
224
+ Without the CSS import, policy components render as unstyled HTML. The provider injects inline styles via a React `<style>` tag, but this may not work in all SSR or bundler setups.
225
+
226
+ ```tsx
227
+ // WRONG: no styles imported
228
+ import { PrivacyPolicy } from '@openpolicy/react';
229
+ ```
230
+
231
+ ```tsx
232
+ // correct
233
+ import '@openpolicy/react/styles.css';
234
+ import { PrivacyPolicy } from '@openpolicy/react';
235
+ ```
236
+
237
+ The default styles scope all rules to `.op-policy` so they do not leak to the rest of the page.