From 3deaaa741819e66305d6acbc44b71245d382ee6e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 18 Aug 2026 16:14:57 -0700 Subject: [PATCH 1/5] feat(consent): add a hosted-only cookie consent banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a c15t-backed consent runtime and a Sim-styled banner, mounted from the root layout only when `isHosted` is true. A self-hosted deployment never mounts the runtime, so it makes no request to Sim's consent backend and never sees the banner. The banner is a non-modal card docked bottom-left, opposite the toast stack, built from the same chrome (border, --bg, --shadow-overlay) and from Chip/Switch/Label rather than c15t's own components — the runtime is imported from `@c15t/nextjs/headless`, which ships no UI or stylesheet. "Customize" expands the same card into per-category switches instead of opening a dialog over the app. Visibility and the available actions come from the jurisdiction policy the runtime resolves, and accept and reject are rendered with identical weight. --- .../sim/app/_shell/consent/consent-banner.tsx | 142 ++++++++++++++++++ .../_shell/consent/consent-provider.test.tsx | 74 +++++++++ .../app/_shell/consent/consent-provider.tsx | 42 ++++++ apps/sim/app/_shell/consent/constants.ts | 41 +++++ apps/sim/app/layout.tsx | 5 +- apps/sim/package.json | 1 + bun.lock | 19 ++- 7 files changed, 322 insertions(+), 2 deletions(-) create mode 100644 apps/sim/app/_shell/consent/consent-banner.tsx create mode 100644 apps/sim/app/_shell/consent/consent-provider.test.tsx create mode 100644 apps/sim/app/_shell/consent/consent-provider.tsx create mode 100644 apps/sim/app/_shell/consent/constants.ts diff --git a/apps/sim/app/_shell/consent/consent-banner.tsx b/apps/sim/app/_shell/consent/consent-banner.tsx new file mode 100644 index 00000000000..3263e523817 --- /dev/null +++ b/apps/sim/app/_shell/consent/consent-banner.tsx @@ -0,0 +1,142 @@ +'use client' + +import { useConsentManager, useHeadlessConsentUI } from '@c15t/nextjs/headless' +import { Chip, Label, Switch } from '@sim/emcn' +import { AnimatePresence, motion, useReducedMotion } from 'framer-motion' +import Link from 'next/link' +import { + CONSENT_CATEGORY_COPY, + CONSENT_CATEGORY_SET, + type ConsentCategory, +} from '@/app/_shell/consent/constants' + +/** Card width; mirrors the toast stack so both floating surfaces read as one system. */ +const CARD_WIDTH = 'min(100vw - 2rem, 380px)' + +const EASE = [0.22, 1, 0.36, 1] as const +const ENTER_DURATION = 0.28 +const EXPAND_DURATION = 0.22 + +/** + * Cookie consent banner — a non-modal card docked bottom-left, opposite the + * toast stack. It never dims or blocks the page, and "Customize" expands this + * same card into per-category switches rather than opening a dialog over the + * app. + * + * Visibility and the available actions come from the jurisdiction policy the + * consent runtime resolves, so the banner is absent entirely where no consent + * is required and never offers an action the policy does not allow. Accept and + * reject are rendered with identical weight, which GDPR requires. + */ +export function ConsentBanner() { + const { consents, selectedConsents, setSelectedConsent, consentTypes } = useConsentManager() + const { banner, dialog, openDialog, performAction, saveCustomPreferences } = + useHeadlessConsentUI() + const prefersReducedMotion = useReducedMotion() + + const isExpanded = dialog.isVisible + const surface = isExpanded ? dialog : banner + const allowedActions = surface.allowedActions + const categories = consentTypes.filter( + (type) => type.display && CONSENT_CATEGORY_SET.has(type.name) + ) + + return ( + + {(banner.isVisible || dialog.isVisible) && ( + +
+

Cookies

+

+ We use cookies to run Sim, understand how it is used, and improve it. See our{' '} + + Privacy Policy + + . +

+
+ + + {isExpanded && ( + +
    + {categories.map((type) => { + const copy = CONSENT_CATEGORY_COPY[type.name as ConsentCategory] + const inputId = `consent-${type.name}` + return ( +
  • +
    + +

    + {copy?.description ?? type.description} +

    +
    + setSelectedConsent(type.name, checked)} + /> +
  • + ) + })} +
+
+ )} +
+ +
+ {!isExpanded && allowedActions.includes('customize') && ( + + Customize + + )} + {allowedActions.includes('reject') && ( + + void performAction('reject', { surface: isExpanded ? 'dialog' : 'banner' }) + } + > + Reject all + + )} + {allowedActions.includes('accept') && ( + + void performAction('accept', { surface: isExpanded ? 'dialog' : 'banner' }) + } + > + Accept all + + )} + {isExpanded && ( + void saveCustomPreferences()}> + Save + + )} +
+
+ )} +
+ ) +} diff --git a/apps/sim/app/_shell/consent/consent-provider.test.tsx b/apps/sim/app/_shell/consent/consent-provider.test.tsx new file mode 100644 index 00000000000..3d26f2336aa --- /dev/null +++ b/apps/sim/app/_shell/consent/consent-provider.test.tsx @@ -0,0 +1,74 @@ +/** + * @vitest-environment jsdom + */ +import type { ReactNode } from 'react' +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { mockConsentManagerProvider, mockConsentBanner } = vi.hoisted(() => ({ + mockConsentManagerProvider: vi.fn(), + mockConsentBanner: vi.fn(), +})) + +vi.mock('@c15t/nextjs/headless', () => ({ + ConsentManagerProvider: (props: { children: ReactNode; options: unknown }) => { + mockConsentManagerProvider(props.options) + return props.children + }, +})) + +vi.mock('@/app/_shell/consent/consent-banner', () => ({ + ConsentBanner: () => { + mockConsentBanner() + return null + }, +})) + +import { ConsentProvider } from '@/app/_shell/consent/consent-provider' + +let root: Root | null = null + +/** Mounts the provider in a real React 19 root under jsdom. */ +function renderProvider(enabled: boolean): HTMLDivElement { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + act(() => { + root?.render( + + app + + ) + }) + return container +} + +afterEach(() => { + act(() => root?.unmount()) + root = null + vi.clearAllMocks() +}) + +describe('ConsentProvider', () => { + it('mounts no consent runtime and no banner when disabled', () => { + const container = renderProvider(false) + + expect(container.querySelector('[data-testid="app"]')).not.toBeNull() + expect(mockConsentManagerProvider).not.toHaveBeenCalled() + expect(mockConsentBanner).not.toHaveBeenCalled() + }) + + it('mounts the runtime and banner against the hosted backend when enabled', () => { + const container = renderProvider(true) + + expect(container.querySelector('[data-testid="app"]')).not.toBeNull() + expect(mockConsentBanner).toHaveBeenCalled() + expect(mockConsentManagerProvider).toHaveBeenCalledWith({ + mode: 'hosted', + backendURL: 'https://sim-sim.inth.app', + consentCategories: ['necessary', 'measurement', 'marketing'], + }) + }) +}) diff --git a/apps/sim/app/_shell/consent/consent-provider.tsx b/apps/sim/app/_shell/consent/consent-provider.tsx new file mode 100644 index 00000000000..09e62892782 --- /dev/null +++ b/apps/sim/app/_shell/consent/consent-provider.tsx @@ -0,0 +1,42 @@ +'use client' + +import type { ReactNode } from 'react' +import { type ConsentManagerOptions, ConsentManagerProvider } from '@c15t/nextjs/headless' +import { ConsentBanner } from '@/app/_shell/consent/consent-banner' +import { CONSENT_BACKEND_URL, CONSENT_CATEGORIES } from '@/app/_shell/consent/constants' + +/** + * Imported from `@c15t/nextjs/headless`, not the package root: the headless + * entry ships the store and hooks without the runtime's own components or + * stylesheet, so {@link ConsentBanner} is the only consent UI that exists and + * nothing can leak styles into the app. + */ +const CONSENT_OPTIONS = { + mode: 'hosted', + backendURL: CONSENT_BACKEND_URL, + consentCategories: [...CONSENT_CATEGORIES], +} satisfies ConsentManagerOptions + +interface ConsentProviderProps { + /** + * Mounts the consent runtime. Pass `isHosted` — a self-hosted deployment sets + * no cookies on Sim's behalf and must never see the banner or reach Sim's + * consent backend, so the whole runtime stays unmounted rather than being + * mounted and hidden. + */ + enabled: boolean + children: ReactNode +} + +export function ConsentProvider({ enabled, children }: ConsentProviderProps) { + if (!enabled) { + return <>{children} + } + + return ( + + {children} + + + ) +} diff --git a/apps/sim/app/_shell/consent/constants.ts b/apps/sim/app/_shell/consent/constants.ts new file mode 100644 index 00000000000..b686d5b1bd5 --- /dev/null +++ b/apps/sim/app/_shell/consent/constants.ts @@ -0,0 +1,41 @@ +/** + * Consent runtime configuration. Hosted-only, so the backend URL is a constant + * next to the GTM/GA container IDs it governs rather than an environment + * variable a self-hosted deployment would never set. + */ +export const CONSENT_BACKEND_URL = 'https://sim-sim.inth.app' as const + +/** + * Categories offered in the banner, in display order. `necessary` is always + * granted and rendered as a locked row so the list reads complete. + */ +export const CONSENT_CATEGORIES = ['necessary', 'measurement', 'marketing'] as const + +export type ConsentCategory = (typeof CONSENT_CATEGORIES)[number] + +/** Membership test for the categories we render, keyed off the runtime's wider union. */ +export const CONSENT_CATEGORY_SET: ReadonlySet = new Set(CONSENT_CATEGORIES) + +interface ConsentCategoryCopy { + title: string + description: string +} + +/** + * Sim's own wording for each category. The runtime ships generic descriptions; + * these say what the cookies actually do here. + */ +export const CONSENT_CATEGORY_COPY: Record = { + necessary: { + title: 'Necessary', + description: 'Keeps you signed in and the workspace secure. Always on.', + }, + measurement: { + title: 'Analytics', + description: 'Shows us how Sim is used so we can make it better.', + }, + marketing: { + title: 'Marketing', + description: 'Measures which campaigns bring builders to Sim.', + }, +} diff --git a/apps/sim/app/layout.tsx b/apps/sim/app/layout.tsx index 21688e8470e..b6d0f877fea 100644 --- a/apps/sim/app/layout.tsx +++ b/apps/sim/app/layout.tsx @@ -12,6 +12,7 @@ import { isReactGrabEnabled, isReactScanEnabled, } from '@/lib/core/config/env-flags' +import { ConsentProvider } from '@/app/_shell/consent/consent-provider' import { DesktopUpdateGate } from '@/app/_shell/desktop-update-gate' import { HydrationErrorHandler } from '@/app/_shell/hydration-error-handler' import { QueryProvider } from '@/app/_shell/providers/query-provider' @@ -281,7 +282,9 @@ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src= - {children} + + {children} + diff --git a/apps/sim/package.json b/apps/sim/package.json index b314feb3bce..6e2954f145d 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -65,6 +65,7 @@ "@better-auth/sso": "1.6.23", "@better-auth/stripe": "1.6.23", "@browserbasehq/stagehand": "^3.2.1", + "@c15t/nextjs": "2.2.0", "@calcom/embed-react": "1.5.3", "@cerebras/cerebras_cloud_sdk": "^1.23.0", "@daytona/sdk": "0.200.0", diff --git a/bun.lock b/bun.lock index 36bcacd7309..7d1a7d6e2a0 100644 --- a/bun.lock +++ b/bun.lock @@ -168,6 +168,7 @@ "@better-auth/sso": "1.6.23", "@better-auth/stripe": "1.6.23", "@browserbasehq/stagehand": "^3.2.1", + "@c15t/nextjs": "2.2.0", "@calcom/embed-react": "1.5.3", "@cerebras/cerebras_cloud_sdk": "^1.23.0", "@daytona/sdk": "0.200.0", @@ -590,7 +591,7 @@ }, "packages/sim-cli": { "name": "sim", - "version": "2.0.0", + "version": "2.1.0", "bin": { "sim": "dist/index.js", }, @@ -1048,6 +1049,16 @@ "@bugsnag/cuid": ["@bugsnag/cuid@3.2.2", "", {}, "sha512-7onuYLTMqMmHE9BBPG0YER4nFsU1rB+me1/YIeMusqcLbVbKKuG9u9+BDVDpje5e0llkkrVNOKYwmzM9DRIo7A=="], + "@c15t/nextjs": ["@c15t/nextjs@2.2.0", "", { "dependencies": { "@c15t/react": "2.2.0", "@c15t/translations": "2.2.0", "c15t": "2.2.0" }, "peerDependencies": { "next": "^16.0.0 || ^15.0.0 || ^14.0.0 || ^13.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-q9RbM/uf6sM+u2BPhL2CbwUekTnJGKy2w0LRNpVX7gSWBnoo9fIs25+hHjJqx3AnWG2A3QhhAPCOQL4LpM9GaA=="], + + "@c15t/react": ["@c15t/react@2.2.0", "", { "dependencies": { "@c15t/ui": "2.2.0", "@types/google.maps": "3.65.3", "c15t": "2.2.0" }, "peerDependencies": { "react": "^19.0.0 || ^19.0.0-rc || ^18.0.0 || ^17.0.0 || ^16.8.0", "react-dom": "^19.0.0 || ^19.0.0-rc || ^18.0.0 || ^17.0.0 || ^16.8.0" } }, "sha512-2TX1mnlDCO/v4eUDUFpdcektHoWOx3JxjJfYFU+nXyV0yf6NR4dlO6KLxkKv1VDvkqWgV3ruUnn8azkQ5wjvkw=="], + + "@c15t/schema": ["@c15t/schema@2.2.0", "", { "dependencies": { "valibot": "1.4.2" } }, "sha512-rj6H1AlbbSnioNaw9Cvoc3vCchVAV/55Jd/6wrzhzW+2z2ByUyf+pDvZXQnAm0KMfsuQ8oDgqNqmPdqD1aJNFw=="], + + "@c15t/translations": ["@c15t/translations@2.2.0", "", {}, "sha512-dW5hIgkDobwzNr2xhTMII0HK+QGetoUscWs2uv/OZWNF2B7E8VhOIbRYyqp7wvBopBDQ4+iszHqGerp+P51rvg=="], + + "@c15t/ui": ["@c15t/ui@2.2.0", "", { "dependencies": { "@c15t/translations": "2.2.0", "c15t": "2.2.0" } }, "sha512-1u8Sd2o/vacqSkxvQoQB0cx3blqEWZIZD1CN3BEWdgfG+5MyP8iqRRNq7V/URbhF0nO9aX+7kF4CMKbT/e21SQ=="], + "@calcom/embed-core": ["@calcom/embed-core@1.5.3", "", {}, "sha512-GeId9gaByJ5EWiPmuvelZOvFWPOTWkcWZr5vGTCbIUTX125oE5yn0n8lDF1MJk5Xj1WO+/dk9jKIE08Ad9ytiQ=="], "@calcom/embed-react": ["@calcom/embed-react@1.5.3", "", { "dependencies": { "@calcom/embed-core": "1.5.3", "@calcom/embed-snippet": "1.3.3" }, "peerDependencies": { "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0" } }, "sha512-JCgge04pc8fhdvUmPNVLhW8/lCWK+AAziKecKWWPfv1nn2s+qKP2BwsEAnxhxK9yPOBgE1EIEgmYkrrNB1iajA=="], @@ -2144,6 +2155,8 @@ "@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="], + "@types/google.maps": ["@types/google.maps@3.65.3", "", {}, "sha512-tqbbx7MUtoDk+RwpZMymPDj6Skez0FhqDZNhLhS5UDmCx4D1dgP+BJOHTebiO/rhjJLa1f8te2p6mJJeFKVFOQ=="], + "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], "@types/heic-convert": ["@types/heic-convert@2.1.1", "", {}, "sha512-+s14762Nf62z9zziIs7ItvAkSUCS3ls4Z5XPT9BlVve3Q3S4DAnf6qekffMTvEWycSUF+kulkGaSN65fK6eKvg=="], @@ -2478,6 +2491,8 @@ "c12": ["c12@3.1.0", "", { "dependencies": { "chokidar": "^4.0.3", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^16.6.1", "exsolve": "^1.0.7", "giget": "^2.0.0", "jiti": "^2.4.2", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^1.0.0", "pkg-types": "^2.2.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "^0.3.5" }, "optionalPeers": ["magicast"] }, "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw=="], + "c15t": ["c15t@2.2.0", "", { "dependencies": { "@c15t/schema": "2.2.0", "@c15t/translations": "2.2.0", "zustand": "5.0.14" } }, "sha512-1OOjr371PfDN44c7LhaHF2Ap1EaqLNpzC0j7ratXAzr14WXWNmVsNjikJaKaBU4+5GyjWY7p2vi+0noMZxsrbA=="], + "cacheable-lookup": ["cacheable-lookup@5.0.4", "", {}, "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA=="], "cacheable-request": ["cacheable-request@7.0.4", "", { "dependencies": { "clone-response": "^1.0.2", "get-stream": "^5.1.0", "http-cache-semantics": "^4.0.0", "keyv": "^4.0.0", "lowercase-keys": "^2.0.0", "normalize-url": "^6.0.1", "responselike": "^2.0.0" } }, "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg=="], @@ -4492,6 +4507,8 @@ "uzip": ["uzip@0.20201231.0", "", {}, "sha512-OZeJfZP+R0z9D6TmBgLq2LHzSSptGMGDGigGiEe0pr8UBe/7fdflgHlHBNDASTXB5jnFuxHpNaJywSg8YFeGng=="], + "valibot": ["valibot@1.4.2", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg=="], + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], From c4ad66e486601e8f43595a95f4c00aa91fd063e1 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 18 Aug 2026 16:41:38 -0700 Subject: [PATCH 2/5] feat(consent): cookie policy page, CSP allowance, and design-system alignment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The consent backend was blocked by our own CSP, so the runtime silently fell back to an offline policy that showed the banner to every visitor worldwide and recorded nothing. The backend origin now lives in lib/consent/constants and the CSP builder allows it from that single source. Banner: mount the runtime beside the app rather than wrapping it, behind a dynamic() boundary, so consent state cannot re-render the page tree and a self-hosted build never fetches the chunk. Align chrome with the toast card (z token, font scale, --text-body/--text-muted pairing) and mirror the light token layer the public shells pin, which a dark-theme visitor on a landing route outside ThemeProvider's forced list would otherwise miss. Read the category list from the store's own getDisplayedConsents() — the shipped defaults mark every category except necessary as display:false, so the hand-rolled filter rendered a one-row list. Docs: add /cookie-policy as a third ProsePage consumer with the cookie inventory in tables (a new table block kind on the shared primitive), cross-reference it from the Privacy Policy, and wire it into the sitemap and llms.txt. The policy promises consent can be changed at any time, so the banner can be reopened from it. --- .../components/legal-block/legal-block.tsx | 46 ++- .../components/prose-page/constants.ts | 7 + .../(landing)/components/prose-page/types.ts | 18 + .../cookie-policy/cookie-policy-content.tsx | 326 ++++++++++++++++++ .../(landing)/cookie-policy/cookie-policy.tsx | 12 + apps/sim/app/(landing)/cookie-policy/page.tsx | 18 + .../app/(landing)/privacy/privacy-content.tsx | 12 +- .../sim/app/_shell/consent/consent-banner.tsx | 112 ++++-- .../consent/consent-preferences-link.tsx | 25 ++ .../_shell/consent/consent-provider.test.tsx | 74 ---- .../app/_shell/consent/consent-provider.tsx | 56 ++- .../_shell/consent/consent-runtime.test.tsx | 57 +++ .../app/_shell/consent/consent-runtime.tsx | 30 ++ apps/sim/app/_shell/consent/constants.ts | 41 --- apps/sim/app/layout.tsx | 6 +- apps/sim/app/llms-full.txt/route.ts | 1 + apps/sim/app/llms.txt/route.ts | 1 + apps/sim/app/sitemap.ts | 6 +- apps/sim/lib/consent/constants.ts | 46 +++ apps/sim/lib/core/security/csp.ts | 8 +- 20 files changed, 719 insertions(+), 183 deletions(-) create mode 100644 apps/sim/app/(landing)/cookie-policy/cookie-policy-content.tsx create mode 100644 apps/sim/app/(landing)/cookie-policy/cookie-policy.tsx create mode 100644 apps/sim/app/(landing)/cookie-policy/page.tsx create mode 100644 apps/sim/app/_shell/consent/consent-preferences-link.tsx delete mode 100644 apps/sim/app/_shell/consent/consent-provider.test.tsx create mode 100644 apps/sim/app/_shell/consent/consent-runtime.test.tsx create mode 100644 apps/sim/app/_shell/consent/consent-runtime.tsx delete mode 100644 apps/sim/app/_shell/consent/constants.ts create mode 100644 apps/sim/lib/consent/constants.ts diff --git a/apps/sim/app/(landing)/components/prose-page/components/legal-block-group/components/legal-block/legal-block.tsx b/apps/sim/app/(landing)/components/prose-page/components/legal-block-group/components/legal-block/legal-block.tsx index 8cbee9c18ad..d108ee903e2 100644 --- a/apps/sim/app/(landing)/components/prose-page/components/legal-block-group/components/legal-block/legal-block.tsx +++ b/apps/sim/app/(landing)/components/prose-page/components/legal-block-group/components/legal-block/legal-block.tsx @@ -5,7 +5,8 @@ import type { LegalBlock } from '@/app/(landing)/components/prose-page/types' /** * Renders a single {@link LegalBlock} into its canonical chrome. The block's * `kind` discriminant selects the element (paragraph / subheading `

` / - * bulleted list / callout box); all sizing and color come from `PROSE_TYPE`, so + * bulleted list / callout box / reference table); all sizing and color come + * from `PROSE_TYPE`, so * Terms and Privacy share one visual treatment for every block type. Content * only - no layout knob. Server Component. */ @@ -35,6 +36,49 @@ export function LegalBlockView({ block }: LegalBlockViewProps) { ) case 'callout': return
{block.content}
+ case 'table': + return ( +
+ + {block.columnWidths ? ( + + {block.columnWidths.map((width, index) => ( + + ))} + + ) : null} + {block.caption ? ( + + ) : null} + + + {block.columns.map((column) => ( + + ))} + + + + {block.rows.map((row, rowIndex) => { + const rowKey = `row-${rowIndex}` + return ( + + {row.map((cell, cellIndex) => ( + + ))} + + ) + })} + +
{block.caption}
+ {column} +
+ {cell} +
+
+ ) default: return null } diff --git a/apps/sim/app/(landing)/components/prose-page/constants.ts b/apps/sim/app/(landing)/components/prose-page/constants.ts index d388afa1f11..c04ee961656 100644 --- a/apps/sim/app/(landing)/components/prose-page/constants.ts +++ b/apps/sim/app/(landing)/components/prose-page/constants.ts @@ -53,4 +53,11 @@ export const PROSE_TYPE = { callout: 'rounded-lg border border-[var(--border)] bg-[var(--surface-2)] px-4 py-3 text-[14px] text-[var(--text-body)] leading-[1.6]', link: 'text-[var(--text-primary)] underline underline-offset-2 transition-colors hover:text-[var(--text-body)]', + tableWrap: 'w-full overflow-x-auto', + tableCaption: 'pb-2 text-left text-[14px] text-[var(--text-muted)]', + table: 'w-full min-w-[560px] table-fixed border-collapse text-left', + tableHeadCell: + 'border-[var(--border)] border-b px-3 py-2 align-bottom font-medium text-[13px] text-[var(--text-primary)] first:pl-0 last:pr-0', + tableCell: + 'border-[var(--border)] border-b px-3 py-2.5 align-top text-[14px] text-[var(--text-body)] leading-[1.55] first:pl-0 last:pr-0 [&_code]:font-mono [&_code]:text-[13px]', } as const diff --git a/apps/sim/app/(landing)/components/prose-page/types.ts b/apps/sim/app/(landing)/components/prose-page/types.ts index bf8d7853e2e..5413d011635 100644 --- a/apps/sim/app/(landing)/components/prose-page/types.ts +++ b/apps/sim/app/(landing)/components/prose-page/types.ts @@ -22,6 +22,24 @@ export type LegalBlock = | { kind: 'list'; items: ReactNode[] } /** An emphasized callout box (e.g. the arbitration / GDPR notices). */ | { kind: 'callout'; content: ReactNode } + /** + * A reference table — the cookie inventory's name / provider / purpose / + * retention grid. Rows are positional against `columns`, so every row must + * have the same length as the header. + */ + | { + kind: 'table' + caption?: string + columns: string[] + /** + * Tailwind width fragments applied per column, e.g. `['w-[22%]', …]`. Set + * them whenever a page renders sibling tables with the same columns: + * without a fixed layout each table sizes itself to its own content and + * the group reads as three unaligned grids. + */ + columnWidths?: string[] + rows: ReactNode[][] + } /** A numbered (or named) legal section - an `

` plus its ordered blocks. */ export interface LegalSection { diff --git a/apps/sim/app/(landing)/cookie-policy/cookie-policy-content.tsx b/apps/sim/app/(landing)/cookie-policy/cookie-policy-content.tsx new file mode 100644 index 00000000000..f3ec467475f --- /dev/null +++ b/apps/sim/app/(landing)/cookie-policy/cookie-policy-content.tsx @@ -0,0 +1,326 @@ +import { ConsentPreferencesLink } from '@/app/_shell/consent/consent-preferences-link' +import { type LegalPageConfig, ProseLink } from '@/app/(landing)/components/prose-page' + +/** + * Cookie Policy content — the inventory a consent banner has to stand on, + * expressed as the typed {@link LegalPageConfig} that `ProsePage` renders, so it + * shares its layout and rhythm with Terms and Privacy and cannot drift. + * + * The tables describe what Sim and its providers actually set, grouped by the + * three categories the banner offers. Keep them in step with the banner's + * categories (`lib/consent/constants`) and with the tags configured in Google + * Tag Manager: naming a cookie the site no longer sets is as wrong as omitting + * one it does. + */ +/** Shared column widths so the three cookie tables read as one aligned grid. */ +const COOKIE_TABLE_WIDTHS = ['w-[24%]', 'w-[16%]', 'w-[45%]', 'w-[15%]'] + +export const COOKIE_POLICY_CONFIG: LegalPageConfig = { + title: 'Cookie Policy', + description: + 'What cookies Sim sets, why, how long they last, and how to change your choice at any time.', + lastUpdated: 'August 18, 2026', + intro: [ + { + kind: 'paragraph', + content: ( + <> + This Cookie Policy explains how Sim uses cookies and similar technologies on sim.ai and in + the Sim application, what each one does, and the choices you have. It forms part of our{' '} + Privacy Policy, which describes how we handle + personal data more broadly. + + ), + }, + { + kind: 'paragraph', + content: ( + <> + If you are in the EU, the UK, or another region where consent is required, we ask before + setting anything that is not strictly necessary. You can{' '} + change your cookie choices at any time. + + ), + }, + ], + sections: [ + { + id: 'what-are-cookies', + heading: 'What are cookies?', + blocks: [ + { + kind: 'paragraph', + content: `A cookie is a small text file a site stores on your device so it can recognize your browser on a later request. Cookies are how a site keeps you signed in between pages, remembers a preference, or counts a visit.`, + }, + { + kind: 'list', + items: [ + <> + Session cookies are deleted when you close your browser.{' '} + Persistent cookies stay until they expire or you delete them. + , + <> + First-party cookies are set by the site you are visiting.{' '} + Third-party cookies are set by another company whose code the site + loads, such as an analytics or advertising provider. + , + ], + }, + { + kind: 'paragraph', + content: `We also use technologies that behave like cookies without being one. Local storage and session storage keep data in your browser rather than sending it with each request; pixels (also called web beacons or tags) are tiny images or scripts that record that a page or email was opened. Where this policy says "cookies", it means all of these.`, + }, + ], + }, + { + id: 'how-we-use-cookies', + heading: 'How we use cookies', + blocks: [ + { + kind: 'paragraph', + content: `We group cookies into the three categories the consent banner offers. Necessary cookies are always on because the service cannot run without them. The other two are off until you turn them on.`, + }, + { + kind: 'list', + items: [ + <> + Necessary — sign-in, session security, abuse prevention, and + remembering the choice you made in the consent banner. These do not require consent + because the service you asked for cannot be delivered without them. + , + <> + Analytics — how many people use Sim, which pages and features they + reach, and where errors happen, so we can improve the product. Measurement only; we do + not use these to target advertising. + , + <> + Marketing — measuring which campaigns bring builders to Sim and + showing relevant ads on other sites. + , + ], + }, + ], + }, + { + id: 'cookies-we-use', + heading: 'Cookies we use', + blocks: [ + { + kind: 'paragraph', + content: `Retention periods are the maximum lifetime set when the cookie is written; a cookie can be cleared sooner at any time. Third-party providers occasionally rename or re-scope their cookies, so treat the provider column as the authoritative reference for anything not set by Sim.`, + }, + { + kind: 'table', + caption: 'Necessary', + columns: ['Cookie', 'Provider', 'Purpose', 'Retention'], + columnWidths: COOKIE_TABLE_WIDTHS, + rows: [ + [ + better-auth.session_token, + 'Sim', + 'Keeps you signed in and identifies your session.', + '30 days', + ], + [ + better-auth.session_data, + 'Sim', + 'Short-lived signed cache of your session so each page load does not re-read the database.', + '5 minutes', + ], + [ + c15t, + 'Sim (via c15t)', + 'Records the cookie choice you made so the banner is not shown again.', + '365 days', + ], + [ + sidebar_collapsed, + 'Sim', + 'Remembers whether the workspace sidebar is collapsed, so the layout does not jump on load.', + '1 year', + ], + [ + __cf_bm, + 'Cloudflare', + 'Bot-management check on requests to providers we load, such as HubSpot and X.', + '30 minutes', + ], + ], + }, + { + kind: 'table', + caption: 'Analytics', + columns: ['Cookie', 'Provider', 'Purpose', 'Retention'], + columnWidths: COOKIE_TABLE_WIDTHS, + rows: [ + [ + _ga, + 'Google Analytics', + 'Distinguishes one visitor from another.', + '13 months', + ], + [ + _ga_*, + 'Google Analytics', + 'Holds the session state for a specific Analytics property.', + '13 months', + ], + [ + __hstc, + 'HubSpot', + 'Tracks visits across sessions for the main tracker.', + '6 months', + ], + [ + hubspotutk, + 'HubSpot', + 'Identifies a visitor across form submissions.', + '6 months', + ], + [__hssc, 'HubSpot', 'Tracks the current session.', '30 minutes'], + [ + __hssrc, + 'HubSpot', + 'Detects whether the visitor restarted their browser.', + 'Session', + ], + ], + }, + { + kind: 'table', + caption: 'Marketing', + columns: ['Cookie', 'Provider', 'Purpose', 'Retention'], + columnWidths: COOKIE_TABLE_WIDTHS, + rows: [ + [ + guest_id, + 'X (Twitter)', + 'Identifies a browser to the X conversion pixel.', + '13 months', + ], + [ + guest_id_ads, + 'X (Twitter)', + 'Measures conversions from X advertising.', + '13 months', + ], + [ + guest_id_marketing, + 'X (Twitter)', + 'Measures the performance of X marketing campaigns.', + '13 months', + ], + [ + personalization_id, + 'X (Twitter)', + 'Personalizes the ads shown on X.', + '13 months', + ], + [ + muc_ads, + 'X (Twitter)', + 'Measures ad conversions across X domains.', + '13 months', + ], + [ + _gcl_*, + 'Google Ads', + 'Attributes a sign-up to the ad that led to it.', + '90 days', + ], + ], + }, + ], + }, + { + id: 'your-choices', + heading: 'Your choices', + blocks: [ + { + kind: 'paragraph', + content: ( + <> + Where consent is required, the banner appears on your first visit with accept and + reject offered equally, and "Customize" lets you turn each category on or off + individually. To revisit that decision later — including withdrawing consent you + already gave —{' '} + change your cookie choices. We ask + again after 365 days. + + ), + }, + { + kind: 'paragraph', + content: `Independently of the banner, every major browser lets you block or delete cookies from its privacy settings, and can be set to clear them each time you close it. Blocking necessary cookies will sign you out and prevent parts of Sim from working.`, + }, + { + kind: 'paragraph', + content: `We honor Global Privacy Control (GPC). If your browser or an extension sends a GPC signal, we treat it as an instruction to opt out of analytics and marketing cookies without your having to use the banner.`, + }, + { + kind: 'paragraph', + content: ( + <> + You can also opt out with the providers directly:{' '} + + Google Analytics + + , Google Ads,{' '} + X (Twitter), + and HubSpot. + + ), + }, + ], + }, + { + id: 'third-party-cookies', + heading: 'Third-party cookies', + blocks: [ + { + kind: 'paragraph', + content: `Some cookies above are set by companies we work with rather than by Sim. We choose these providers and decide when their code loads, but the data they collect is also governed by their own policies, which we cannot change on your behalf.`, + }, + { + kind: 'paragraph', + content: ( + <> + The providers currently in use are{' '} + Google{' '} + (Analytics, Tag Manager, and Ads),{' '} + HubSpot,{' '} + X (Twitter),{' '} + Ahrefs, and{' '} + Cloudflare. + + ), + }, + ], + }, + { + id: 'changes-to-this-policy', + heading: 'Changes to this policy', + blocks: [ + { + kind: 'paragraph', + content: `We update this policy when the cookies we set change, and we revise the "Last updated" date above whenever we do. If a change materially widens what we collect, we will ask for your consent again rather than rely on a choice you made under the previous version.`, + }, + ], + }, + { + id: 'contact', + heading: 'Contact', + blocks: [ + { + kind: 'paragraph', + content: ( + <> + Questions about this policy, or about how we use cookies, can go to{' '} + privacy@sim.ai. + + ), + }, + ], + }, + ], +} diff --git a/apps/sim/app/(landing)/cookie-policy/cookie-policy.tsx b/apps/sim/app/(landing)/cookie-policy/cookie-policy.tsx new file mode 100644 index 00000000000..1214fb5b2d0 --- /dev/null +++ b/apps/sim/app/(landing)/cookie-policy/cookie-policy.tsx @@ -0,0 +1,12 @@ +import { ProsePage } from '@/app/(landing)/components/prose-page' +import { COOKIE_POLICY_CONFIG } from '@/app/(landing)/cookie-policy/cookie-policy-content' + +/** + * Cookie Policy page - a thin consumer of the shared {@link ProsePage} + * primitive, alongside Terms and Privacy. The whole document is one typed + * config ({@link COOKIE_POLICY_CONFIG}) rendered inside the shared route-group + * layout chrome, so the three legal pages share a layout and cannot drift. + */ +export default function CookiePolicy() { + return +} diff --git a/apps/sim/app/(landing)/cookie-policy/page.tsx b/apps/sim/app/(landing)/cookie-policy/page.tsx new file mode 100644 index 00000000000..703faf0b2d4 --- /dev/null +++ b/apps/sim/app/(landing)/cookie-policy/page.tsx @@ -0,0 +1,18 @@ +import { buildLandingMetadata } from '@/lib/landing/seo' +import CookiePolicy from '@/app/(landing)/cookie-policy/cookie-policy' + +export const revalidate = 3600 + +const TITLE = 'Cookie Policy | Sim, the AI Workspace' +const DESCRIPTION = + 'What cookies Sim sets, why, how long they last, and how to change your choice at any time.' + +export const metadata = buildLandingMetadata({ + title: TITLE, + description: DESCRIPTION, + path: '/cookie-policy', +}) + +export default function Page() { + return +} diff --git a/apps/sim/app/(landing)/privacy/privacy-content.tsx b/apps/sim/app/(landing)/privacy/privacy-content.tsx index f72fb96d3b5..bf5240e4695 100644 --- a/apps/sim/app/(landing)/privacy/privacy-content.tsx +++ b/apps/sim/app/(landing)/privacy/privacy-content.tsx @@ -10,7 +10,7 @@ export const PRIVACY_CONFIG: LegalPageConfig = { title: 'Privacy Policy', description: 'How Sim, the open-source AI workspace, collects, uses, and protects your data, including data obtained from Google APIs, and the controls you have over it.', - lastUpdated: 'October 11, 2025', + lastUpdated: 'August 18, 2026', intro: [ { kind: 'paragraph', @@ -214,6 +214,16 @@ export const PRIVACY_CONFIG: LegalPageConfig = { kind: 'paragraph', content: `You can instruct your browser to refuse all cookies or to indicate when a cookie is being sent. However, if you do not accept cookies, you may not be able to use some portions of our Service.`, }, + { + kind: 'paragraph', + content: ( + <> + Our Cookie Policy lists every cookie we + and our providers set, what each one does, how long it lasts, and how to change or + withdraw your choice. + + ), + }, ], }, { diff --git a/apps/sim/app/_shell/consent/consent-banner.tsx b/apps/sim/app/_shell/consent/consent-banner.tsx index 3263e523817..b58d2d6d0b3 100644 --- a/apps/sim/app/_shell/consent/consent-banner.tsx +++ b/apps/sim/app/_shell/consent/consent-banner.tsx @@ -1,45 +1,105 @@ 'use client' +import { useEffect, useState } from 'react' import { useConsentManager, useHeadlessConsentUI } from '@c15t/nextjs/headless' -import { Chip, Label, Switch } from '@sim/emcn' +import { Chip, cn, Label, Switch } from '@sim/emcn' import { AnimatePresence, motion, useReducedMotion } from 'framer-motion' import Link from 'next/link' -import { - CONSENT_CATEGORY_COPY, - CONSENT_CATEGORY_SET, - type ConsentCategory, -} from '@/app/_shell/consent/constants' +import { usePathname } from 'next/navigation' +import { type ConsentCategory, OPEN_CONSENT_PREFERENCES_EVENT } from '@/lib/consent/constants' -/** Card width; mirrors the toast stack so both floating surfaces read as one system. */ +interface ConsentCategoryCopy { + title: string + description: string +} + +/** + * Sim's own wording per category. The runtime ships generic descriptions; these + * say what the cookies actually do here. A category without an entry falls back + * to the runtime's description rather than disappearing. + */ +const CONSENT_CATEGORY_COPY: Partial> = { + necessary: { + title: 'Necessary', + description: 'Sign-in and security. Always on.', + }, + measurement: { + title: 'Analytics', + description: 'Shows us how Sim is used so we can make it better.', + }, + marketing: { + title: 'Marketing', + description: 'Measures which campaigns bring builders to Sim.', + }, +} + +/** Card width; sized against the toast stack so both floating surfaces read as one system. */ const CARD_WIDTH = 'min(100vw - 2rem, 380px)' +/** Shared expo-out easing and timings, matching the toast stack's motion. */ const EASE = [0.22, 1, 0.36, 1] as const const ENTER_DURATION = 0.28 const EXPAND_DURATION = 0.22 +/** Inline link chrome, aligned with the auth and legal-prose link treatments. */ +const LINK_CLASS = + 'text-[var(--text-secondary)] underline underline-offset-2 transition-colors hover:text-[var(--text-primary)]' + +/** + * Reports whether the current surface pins the light token layer. + * + * Public surfaces force light over the visitor's theme — some through + * `ThemeProvider`'s forced-theme list, which puts `light` on ``, and the + * rest through a shell wrapper (`LandingShell`, `AuthShell`, the chat + * interfaces, the public file view). The banner mounts at the root, outside + * those wrappers, so on a landing route missing from the forced-theme list a + * dark-theme visitor would get a dark card over a light page. Probing for the + * layer covers both mechanisms without a route list to keep in sync. + */ +function useLightTokenLayer(): boolean { + const pathname = usePathname() + const [isLight, setIsLight] = useState(false) + + useEffect(() => { + setIsLight(document.querySelector('.light') !== null) + }, [pathname]) + + return isLight +} + /** * Cookie consent banner — a non-modal card docked bottom-left, opposite the - * toast stack. It never dims or blocks the page, and "Customize" expands this - * same card into per-category switches rather than opening a dialog over the - * app. + * toast stack and wearing the same chrome. It never dims, blocks, or reflows + * the page, and "Customize" expands this same card into per-category switches + * rather than opening a dialog over the app. * * Visibility and the available actions come from the jurisdiction policy the * consent runtime resolves, so the banner is absent entirely where no consent * is required and never offers an action the policy does not allow. Accept and - * reject are rendered with identical weight, which GDPR requires. + * reject carry identical weight, which GDPR requires. */ export function ConsentBanner() { - const { consents, selectedConsents, setSelectedConsent, consentTypes } = useConsentManager() + const { consents, selectedConsents, setSelectedConsent, getDisplayedConsents } = + useConsentManager() const { banner, dialog, openDialog, performAction, saveCustomPreferences } = useHeadlessConsentUI() const prefersReducedMotion = useReducedMotion() + const isLightSurface = useLightTokenLayer() + + useEffect(() => { + window.addEventListener(OPEN_CONSENT_PREFERENCES_EVENT, openDialog) + return () => window.removeEventListener(OPEN_CONSENT_PREFERENCES_EVENT, openDialog) + }, [openDialog]) const isExpanded = dialog.isVisible const surface = isExpanded ? dialog : banner - const allowedActions = surface.allowedActions - const categories = consentTypes.filter( - (type) => type.display && CONSENT_CATEGORY_SET.has(type.name) - ) + const { allowedActions } = surface + /** + * The store's own selector, not a hand-rolled filter over `consentTypes`: the + * shipped defaults mark every category except `necessary` as `display: false`, + * so filtering on that flag silently renders a one-row list. + */ + const categories = getDisplayedConsents() return ( @@ -51,16 +111,16 @@ export function ConsentBanner() { exit={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: 8 }} transition={{ duration: ENTER_DURATION, ease: EASE }} style={{ width: CARD_WIDTH }} - className='fixed bottom-4 left-4 z-40 flex flex-col gap-3 overflow-hidden rounded-xl border border-[var(--border-1)] bg-[var(--bg)] p-4 shadow-[var(--shadow-overlay)]' + className={cn( + isLightSurface && 'light', + 'fixed bottom-4 left-4 z-[var(--z-toast)] flex flex-col gap-3 overflow-hidden rounded-xl border border-[var(--border-1)] bg-[var(--bg)] p-4 shadow-[var(--shadow-overlay)]' + )} >
-

Cookies

-

- We use cookies to run Sim, understand how it is used, and improve it. See our{' '} - +

Cookies

+

+ We use cookies to run Sim, understand how it is used, and improve it. Read our{' '} + Privacy Policy . @@ -83,9 +143,9 @@ export function ConsentBanner() { const inputId = `consent-${type.name}` return (

  • -
    +
    -

    +

    {copy?.description ?? type.description}

    diff --git a/apps/sim/app/_shell/consent/consent-preferences-link.tsx b/apps/sim/app/_shell/consent/consent-preferences-link.tsx new file mode 100644 index 00000000000..64994fb9a92 --- /dev/null +++ b/apps/sim/app/_shell/consent/consent-preferences-link.tsx @@ -0,0 +1,25 @@ +'use client' + +import { OPEN_CONSENT_PREFERENCES_EVENT } from '@/lib/consent/constants' +import { PROSE_TYPE } from '@/app/(landing)/components/prose-page/constants' + +/** + * Inline control that reopens the consent banner with its category switches + * expanded, so a recorded choice can be withdrawn or changed. Wearing the + * prose link chrome, it reads as part of the sentence it sits in. + * + * On a self-hosted deployment the consent runtime is never mounted, so nothing + * listens and the control is inert — but it is also unreachable, since the + * Cookie Policy documents Sim's own hosted service. + */ +export function ConsentPreferencesLink({ children }: { children: React.ReactNode }) { + return ( + + ) +} diff --git a/apps/sim/app/_shell/consent/consent-provider.test.tsx b/apps/sim/app/_shell/consent/consent-provider.test.tsx deleted file mode 100644 index 3d26f2336aa..00000000000 --- a/apps/sim/app/_shell/consent/consent-provider.test.tsx +++ /dev/null @@ -1,74 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import type { ReactNode } from 'react' -import { act } from 'react' -import { createRoot, type Root } from 'react-dom/client' -import { afterEach, describe, expect, it, vi } from 'vitest' - -const { mockConsentManagerProvider, mockConsentBanner } = vi.hoisted(() => ({ - mockConsentManagerProvider: vi.fn(), - mockConsentBanner: vi.fn(), -})) - -vi.mock('@c15t/nextjs/headless', () => ({ - ConsentManagerProvider: (props: { children: ReactNode; options: unknown }) => { - mockConsentManagerProvider(props.options) - return props.children - }, -})) - -vi.mock('@/app/_shell/consent/consent-banner', () => ({ - ConsentBanner: () => { - mockConsentBanner() - return null - }, -})) - -import { ConsentProvider } from '@/app/_shell/consent/consent-provider' - -let root: Root | null = null - -/** Mounts the provider in a real React 19 root under jsdom. */ -function renderProvider(enabled: boolean): HTMLDivElement { - ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true - const container = document.createElement('div') - document.body.appendChild(container) - root = createRoot(container) - act(() => { - root?.render( - - app - - ) - }) - return container -} - -afterEach(() => { - act(() => root?.unmount()) - root = null - vi.clearAllMocks() -}) - -describe('ConsentProvider', () => { - it('mounts no consent runtime and no banner when disabled', () => { - const container = renderProvider(false) - - expect(container.querySelector('[data-testid="app"]')).not.toBeNull() - expect(mockConsentManagerProvider).not.toHaveBeenCalled() - expect(mockConsentBanner).not.toHaveBeenCalled() - }) - - it('mounts the runtime and banner against the hosted backend when enabled', () => { - const container = renderProvider(true) - - expect(container.querySelector('[data-testid="app"]')).not.toBeNull() - expect(mockConsentBanner).toHaveBeenCalled() - expect(mockConsentManagerProvider).toHaveBeenCalledWith({ - mode: 'hosted', - backendURL: 'https://sim-sim.inth.app', - consentCategories: ['necessary', 'measurement', 'marketing'], - }) - }) -}) diff --git a/apps/sim/app/_shell/consent/consent-provider.tsx b/apps/sim/app/_shell/consent/consent-provider.tsx index 09e62892782..0b599685696 100644 --- a/apps/sim/app/_shell/consent/consent-provider.tsx +++ b/apps/sim/app/_shell/consent/consent-provider.tsx @@ -1,42 +1,28 @@ 'use client' -import type { ReactNode } from 'react' -import { type ConsentManagerOptions, ConsentManagerProvider } from '@c15t/nextjs/headless' -import { ConsentBanner } from '@/app/_shell/consent/consent-banner' -import { CONSENT_BACKEND_URL, CONSENT_CATEGORIES } from '@/app/_shell/consent/constants' +import dynamic from 'next/dynamic' /** - * Imported from `@c15t/nextjs/headless`, not the package root: the headless - * entry ships the store and hooks without the runtime's own components or - * stylesheet, so {@link ConsentBanner} is the only consent UI that exists and - * nothing can leak styles into the app. + * Lazy boundary for the cookie-consent runtime. + * + * The runtime is loaded on the client only, and only once this component is + * rendered — the root layout renders it behind `isHosted`, so a self-hosted + * deployment never fetches the chunk, never reaches Sim's consent backend, and + * never sees the banner. Deferring it also keeps the third-party store out of + * the server render and off the landing page's hydration path; the banner + * cannot paint before its geo lookup resolves anyway. */ -const CONSENT_OPTIONS = { - mode: 'hosted', - backendURL: CONSENT_BACKEND_URL, - consentCategories: [...CONSENT_CATEGORIES], -} satisfies ConsentManagerOptions +const ConsentRuntime = dynamic( + () => import('@/app/_shell/consent/consent-runtime').then((m) => m.ConsentRuntime), + { ssr: false } +) -interface ConsentProviderProps { - /** - * Mounts the consent runtime. Pass `isHosted` — a self-hosted deployment sets - * no cookies on Sim's behalf and must never see the banner or reach Sim's - * consent backend, so the whole runtime stays unmounted rather than being - * mounted and hidden. - */ - enabled: boolean - children: ReactNode -} - -export function ConsentProvider({ enabled, children }: ConsentProviderProps) { - if (!enabled) { - return <>{children} - } - - return ( - - {children} - - - ) +/** + * Mounts the consent runtime alongside the app rather than wrapping it, so + * consent state changes can never re-render the page tree. Nothing outside + * {@link ConsentRuntime} reads consent today; a surface that needs to (a footer + * "Cookie preferences" link, say) would move the provider above it. + */ +export function ConsentProvider() { + return } diff --git a/apps/sim/app/_shell/consent/consent-runtime.test.tsx b/apps/sim/app/_shell/consent/consent-runtime.test.tsx new file mode 100644 index 00000000000..142bfb47c4a --- /dev/null +++ b/apps/sim/app/_shell/consent/consent-runtime.test.tsx @@ -0,0 +1,57 @@ +/** + * @vitest-environment jsdom + */ +import type { ReactNode } from 'react' +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { mockConsentManagerProvider, mockConsentBanner } = vi.hoisted(() => ({ + mockConsentManagerProvider: vi.fn(), + mockConsentBanner: vi.fn(), +})) + +vi.mock('@c15t/nextjs/headless', () => ({ + ConsentManagerProvider: (props: { children: ReactNode; options: unknown }) => { + mockConsentManagerProvider(props.options) + return props.children + }, +})) + +vi.mock('@/app/_shell/consent/consent-banner', () => ({ + ConsentBanner: () => { + mockConsentBanner() + return + }, +})) + +import { ConsentRuntime } from '@/app/_shell/consent/consent-runtime' + +let root: Root | null = null + +afterEach(() => { + act(() => root?.unmount()) + root = null + vi.clearAllMocks() +}) + +describe('ConsentRuntime', () => { + it('mounts the banner against the hosted consent backend', () => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + act(() => root?.render()) + + expect(container.querySelector('[data-testid="banner"]')).not.toBeNull() + expect(mockConsentBanner).toHaveBeenCalled() + // `toMatchObject`, not exact equality: `DEV_CONSENT_COUNTRY` adds an + // `overrides` key whenever a developer has NEXT_PUBLIC_CONSENT_COUNTRY set + // locally, and the assertion is about the shipped configuration. + expect(mockConsentManagerProvider.mock.calls[0]?.[0]).toMatchObject({ + mode: 'hosted', + backendURL: 'https://sim-sim.inth.app', + consentCategories: ['necessary', 'measurement', 'marketing'], + }) + }) +}) diff --git a/apps/sim/app/_shell/consent/consent-runtime.tsx b/apps/sim/app/_shell/consent/consent-runtime.tsx new file mode 100644 index 00000000000..ad74c071768 --- /dev/null +++ b/apps/sim/app/_shell/consent/consent-runtime.tsx @@ -0,0 +1,30 @@ +'use client' + +import { type ConsentManagerOptions, ConsentManagerProvider } from '@c15t/nextjs/headless' +import { + CONSENT_BACKEND_URL, + CONSENT_CATEGORIES, + DEV_CONSENT_COUNTRY, +} from '@/lib/consent/constants' +import { ConsentBanner } from '@/app/_shell/consent/consent-banner' + +/** + * Imported from `@c15t/nextjs/headless`, not the package root: the headless + * entry ships the store and hooks without the runtime's own components or + * stylesheet, so {@link ConsentBanner} is the only consent UI that exists and + * nothing can leak styles into the app. + */ +const CONSENT_OPTIONS = { + mode: 'hosted', + backendURL: CONSENT_BACKEND_URL, + consentCategories: [...CONSENT_CATEGORIES], + ...(DEV_CONSENT_COUNTRY ? { overrides: { country: DEV_CONSENT_COUNTRY } } : {}), +} satisfies ConsentManagerOptions + +export function ConsentRuntime() { + return ( + + + + ) +} diff --git a/apps/sim/app/_shell/consent/constants.ts b/apps/sim/app/_shell/consent/constants.ts deleted file mode 100644 index b686d5b1bd5..00000000000 --- a/apps/sim/app/_shell/consent/constants.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Consent runtime configuration. Hosted-only, so the backend URL is a constant - * next to the GTM/GA container IDs it governs rather than an environment - * variable a self-hosted deployment would never set. - */ -export const CONSENT_BACKEND_URL = 'https://sim-sim.inth.app' as const - -/** - * Categories offered in the banner, in display order. `necessary` is always - * granted and rendered as a locked row so the list reads complete. - */ -export const CONSENT_CATEGORIES = ['necessary', 'measurement', 'marketing'] as const - -export type ConsentCategory = (typeof CONSENT_CATEGORIES)[number] - -/** Membership test for the categories we render, keyed off the runtime's wider union. */ -export const CONSENT_CATEGORY_SET: ReadonlySet = new Set(CONSENT_CATEGORIES) - -interface ConsentCategoryCopy { - title: string - description: string -} - -/** - * Sim's own wording for each category. The runtime ships generic descriptions; - * these say what the cookies actually do here. - */ -export const CONSENT_CATEGORY_COPY: Record = { - necessary: { - title: 'Necessary', - description: 'Keeps you signed in and the workspace secure. Always on.', - }, - measurement: { - title: 'Analytics', - description: 'Shows us how Sim is used so we can make it better.', - }, - marketing: { - title: 'Marketing', - description: 'Measures which campaigns bring builders to Sim.', - }, -} diff --git a/apps/sim/app/layout.tsx b/apps/sim/app/layout.tsx index b6d0f877fea..aaa0b96fb16 100644 --- a/apps/sim/app/layout.tsx +++ b/apps/sim/app/layout.tsx @@ -282,9 +282,9 @@ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src= - - {children} - + {children} + {/* Cookie consent — hosted only */} + {isHosted && } diff --git a/apps/sim/app/llms-full.txt/route.ts b/apps/sim/app/llms-full.txt/route.ts index f39b43d10de..b7a81b38310 100644 --- a/apps/sim/app/llms-full.txt/route.ts +++ b/apps/sim/app/llms-full.txt/route.ts @@ -169,6 +169,7 @@ Built-in table creation and management: - [Terms of Service](${baseUrl}/terms): Legal terms - [Privacy Policy](${baseUrl}/privacy): Data handling practices +- [Cookie Policy](${baseUrl}/cookie-policy): Cookies Sim sets, why, and how to change your choice - [Security](${baseUrl}/.well-known/security.txt): Vulnerability disclosure policy ` diff --git a/apps/sim/app/llms.txt/route.ts b/apps/sim/app/llms.txt/route.ts index 73c24e3dc41..d368cfe8668 100644 --- a/apps/sim/app/llms.txt/route.ts +++ b/apps/sim/app/llms.txt/route.ts @@ -56,6 +56,7 @@ Sim lets teams create agents visually with the workflow builder, conversationall - [Docs](https://docs.sim.ai): Canonical documentation source - [Terms of Service](${baseUrl}/terms): Legal terms - [Privacy Policy](${baseUrl}/privacy): Data handling practices +- [Cookie Policy](${baseUrl}/cookie-policy): Cookies Sim sets, why, and how to change your choice - [Sitemap](${baseUrl}/sitemap.xml): Public URL inventory ` diff --git a/apps/sim/app/sitemap.ts b/apps/sim/app/sitemap.ts index e0b7a0d6a8d..8cf342837d6 100644 --- a/apps/sim/app/sitemap.ts +++ b/apps/sim/app/sitemap.ts @@ -138,7 +138,11 @@ export default async function sitemap(): Promise { }, { url: `${baseUrl}/privacy`, - lastModified: new Date('2024-10-14'), + lastModified: new Date('2026-08-18'), + }, + { + url: `${baseUrl}/cookie-policy`, + lastModified: new Date('2026-08-18'), }, ] diff --git a/apps/sim/lib/consent/constants.ts b/apps/sim/lib/consent/constants.ts new file mode 100644 index 00000000000..40c91c7272d --- /dev/null +++ b/apps/sim/lib/consent/constants.ts @@ -0,0 +1,46 @@ +/** + * Cookie-consent runtime configuration. + * + * Lives in `lib` rather than beside the banner because the CSP builder + * (`lib/core/security/csp`) has to allow the same origin the runtime calls — + * two copies of the URL would drift, and the failure mode is silent: a blocked + * request makes the runtime fall back to an offline policy that shows the + * banner to every visitor worldwide and records nothing. + */ + +/** + * Sim's consent instance. Public by construction — the browser calls it + * directly, so it is a client-visible origin like the GTM and GA container IDs + * in the root layout, not a credential. + */ +export const CONSENT_BACKEND_URL = 'https://sim-sim.inth.app' + +/** + * Categories offered in the banner, in display order. `necessary` is always + * granted and renders as a locked row so the list reads complete. + */ +export const CONSENT_CATEGORIES = ['necessary', 'measurement', 'marketing'] as const + +export type ConsentCategory = (typeof CONSENT_CATEGORIES)[number] + +/** + * Development-only country override, e.g. `NEXT_PUBLIC_CONSENT_COUNTRY=DE`. + * + * The banner is geo-gated by the consent runtime, so outside the EU/UK it never + * appears and cannot be reviewed locally. This mirrors the `NEXT_PUBLIC_FORCE_HOSTED` + * escape hatch in `env-flags`: the `NODE_ENV` comparison is a literal Next inlines, + * so a production build eliminates the branch and can never force a jurisdiction. + */ +export const DEV_CONSENT_COUNTRY = + process.env.NODE_ENV === 'production' ? undefined : process.env.NEXT_PUBLIC_CONSENT_COUNTRY + +/** + * Reopens the consent banner in its expanded state. + * + * The consent runtime mounts beside the app rather than wrapping it, so a + * surface that wants to reopen the banner — the Cookie Policy's "Change your + * cookie choices" control — cannot reach the store through a React context. A + * window event keeps that isolation intact and stays a one-shot command rather + * than state anything has to hold. + */ +export const OPEN_CONSENT_PREFERENCES_EVENT = 'sim:open-consent-preferences' diff --git a/apps/sim/lib/core/security/csp.ts b/apps/sim/lib/core/security/csp.ts index b7ac36a4832..4f3241b2fb4 100644 --- a/apps/sim/lib/core/security/csp.ts +++ b/apps/sim/lib/core/security/csp.ts @@ -1,3 +1,4 @@ +import { CONSENT_BACKEND_URL } from '../../consent/constants' import { env, getEnv } from '../config/env' import { isDev, isHosted, isReactGrabEnabled } from '../config/env-flags' @@ -6,7 +7,8 @@ import { isDev, isHosted, isReactGrabEnabled } from '../config/env-flags' * * NOTE: This file is loaded by next.config.ts at build time, before @/ path * aliases are resolved. Do NOT import from ../utils/urls (which uses @/ imports). - * Keep all URL constants local to this file. + * Keep URL constants local to this file, or in a leaf module reachable by a + * relative import that itself pulls in no `@/` paths (../../consent/constants). */ const DEFAULT_SOCKET_URL = 'http://localhost:3002' @@ -115,6 +117,10 @@ const STATIC_CONNECT_SRC = [ ...(isDev ? ['ws://localhost:4722'] : []), ...(isHosted ? [ + // Cookie-consent runtime — the banner's policy lookup and consent writes. + // Without this the request is blocked, the runtime silently falls back to + // an offline policy, and the banner shows to every visitor worldwide. + CONSENT_BACKEND_URL, 'https://www.googletagmanager.com', 'https://*.google-analytics.com', 'https://*.analytics.google.com', From df6ffd3adeebcf073a5e6169af7af76a06ac7ec7 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 18 Aug 2026 16:48:11 -0700 Subject: [PATCH 3/5] refactor(consent): apply the cleanup pass - Drop the .light DOM probe: it matched the banner's own element, so once set it could never flip back, and it went stale on a theme toggle with no navigation. The card now pins the light layer unconditionally, as every other public surface does. - Hoist the motion/style objects to module scope. - Move a chip's mr-auto into the row layout; chips carry no outer margin. - Use the shadow-overlay utility and --border rather than the legacy alias. - Render before , which the HTML spec requires. - Make the code formatting of a table column a renderer concern (codeColumns) instead of JSX smuggled into the row content. - Raise the table caption above body weight, and tighten comments. --- .../components/legal-block/legal-block.tsx | 12 +- .../components/prose-page/constants.ts | 2 +- .../(landing)/components/prose-page/types.ts | 7 + .../cookie-policy/cookie-policy-content.tsx | 71 ++++----- .../sim/app/_shell/consent/consent-banner.tsx | 145 ++++++++---------- .../app/_shell/consent/consent-provider.tsx | 4 +- apps/sim/lib/consent/constants.ts | 10 +- apps/sim/lib/core/security/csp.ts | 5 +- 8 files changed, 113 insertions(+), 143 deletions(-) diff --git a/apps/sim/app/(landing)/components/prose-page/components/legal-block-group/components/legal-block/legal-block.tsx b/apps/sim/app/(landing)/components/prose-page/components/legal-block-group/components/legal-block/legal-block.tsx index d108ee903e2..7e6d95bc1a7 100644 --- a/apps/sim/app/(landing)/components/prose-page/components/legal-block-group/components/legal-block/legal-block.tsx +++ b/apps/sim/app/(landing)/components/prose-page/components/legal-block-group/components/legal-block/legal-block.tsx @@ -6,8 +6,8 @@ import type { LegalBlock } from '@/app/(landing)/components/prose-page/types' * Renders a single {@link LegalBlock} into its canonical chrome. The block's * `kind` discriminant selects the element (paragraph / subheading `

    ` / * bulleted list / callout box / reference table); all sizing and color come - * from `PROSE_TYPE`, so - * Terms and Privacy share one visual treatment for every block type. Content + * from `PROSE_TYPE`, so Terms and Privacy share one visual treatment for every + * block type. Content * only - no layout knob. Server Component. */ @@ -40,6 +40,9 @@ export function LegalBlockView({ block }: LegalBlockViewProps) { return (
    + {block.caption ? ( + + ) : null} {block.columnWidths ? ( {block.columnWidths.map((width, index) => ( @@ -47,9 +50,6 @@ export function LegalBlockView({ block }: LegalBlockViewProps) { ))} ) : null} - {block.caption ? ( - - ) : null} {block.columns.map((column) => ( @@ -69,7 +69,7 @@ export function LegalBlockView({ block }: LegalBlockViewProps) { key={`${rowKey}-${block.columns[cellIndex]}`} className={PROSE_TYPE.tableCell} > - {cell} + {block.codeColumns?.includes(cellIndex) ? {cell} : cell} ))} diff --git a/apps/sim/app/(landing)/components/prose-page/constants.ts b/apps/sim/app/(landing)/components/prose-page/constants.ts index c04ee961656..d19085e3559 100644 --- a/apps/sim/app/(landing)/components/prose-page/constants.ts +++ b/apps/sim/app/(landing)/components/prose-page/constants.ts @@ -54,7 +54,7 @@ export const PROSE_TYPE = { 'rounded-lg border border-[var(--border)] bg-[var(--surface-2)] px-4 py-3 text-[14px] text-[var(--text-body)] leading-[1.6]', link: 'text-[var(--text-primary)] underline underline-offset-2 transition-colors hover:text-[var(--text-body)]', tableWrap: 'w-full overflow-x-auto', - tableCaption: 'pb-2 text-left text-[14px] text-[var(--text-muted)]', + tableCaption: 'pb-2 text-left text-[15px] text-[var(--text-primary)]', table: 'w-full min-w-[560px] table-fixed border-collapse text-left', tableHeadCell: 'border-[var(--border)] border-b px-3 py-2 align-bottom font-medium text-[13px] text-[var(--text-primary)] first:pl-0 last:pr-0', diff --git a/apps/sim/app/(landing)/components/prose-page/types.ts b/apps/sim/app/(landing)/components/prose-page/types.ts index 5413d011635..6166b8e7865 100644 --- a/apps/sim/app/(landing)/components/prose-page/types.ts +++ b/apps/sim/app/(landing)/components/prose-page/types.ts @@ -38,6 +38,13 @@ export type LegalBlock = * the group reads as three unaligned grids. */ columnWidths?: string[] + /** + * Indices of columns rendered as inline code — cookie names, config keys. + * A marker rather than a `` in the row data, because a row carries + * content and the renderer owns chrome, and because JSX inside an array + * literal needs a key that means nothing here. + */ + codeColumns?: number[] rows: ReactNode[][] } diff --git a/apps/sim/app/(landing)/cookie-policy/cookie-policy-content.tsx b/apps/sim/app/(landing)/cookie-policy/cookie-policy-content.tsx index f3ec467475f..0558cd9b285 100644 --- a/apps/sim/app/(landing)/cookie-policy/cookie-policy-content.tsx +++ b/apps/sim/app/(landing)/cookie-policy/cookie-policy-content.tsx @@ -1,6 +1,12 @@ import { ConsentPreferencesLink } from '@/app/_shell/consent/consent-preferences-link' import { type LegalPageConfig, ProseLink } from '@/app/(landing)/components/prose-page' +/** Shared column widths so the three cookie tables read as one aligned grid. */ +const COOKIE_TABLE_WIDTHS = ['w-[24%]', 'w-[16%]', 'w-[45%]', 'w-[15%]'] + +/** The cookie-name column reads as code. */ +const COOKIE_TABLE_CODE_COLUMNS = [0] + /** * Cookie Policy content — the inventory a consent banner has to stand on, * expressed as the typed {@link LegalPageConfig} that `ProsePage` renders, so it @@ -12,9 +18,6 @@ import { type LegalPageConfig, ProseLink } from '@/app/(landing)/components/pros * Tag Manager: naming a cookie the site no longer sets is as wrong as omitting * one it does. */ -/** Shared column widths so the three cookie tables read as one aligned grid. */ -const COOKIE_TABLE_WIDTHS = ['w-[24%]', 'w-[16%]', 'w-[45%]', 'w-[15%]'] - export const COOKIE_POLICY_CONFIG: LegalPageConfig = { title: 'Cookie Policy', description: @@ -114,33 +117,34 @@ export const COOKIE_POLICY_CONFIG: LegalPageConfig = { caption: 'Necessary', columns: ['Cookie', 'Provider', 'Purpose', 'Retention'], columnWidths: COOKIE_TABLE_WIDTHS, + codeColumns: COOKIE_TABLE_CODE_COLUMNS, rows: [ [ - better-auth.session_token, + 'better-auth.session_token', 'Sim', 'Keeps you signed in and identifies your session.', '30 days', ], [ - better-auth.session_data, + 'better-auth.session_data', 'Sim', 'Short-lived signed cache of your session so each page load does not re-read the database.', '5 minutes', ], [ - c15t, + 'c15t', 'Sim (via c15t)', 'Records the cookie choice you made so the banner is not shown again.', '365 days', ], [ - sidebar_collapsed, + 'sidebar_collapsed', 'Sim', 'Remembers whether the workspace sidebar is collapsed, so the layout does not jump on load.', '1 year', ], [ - __cf_bm, + '__cf_bm', 'Cloudflare', 'Bot-management check on requests to providers we load, such as HubSpot and X.', '30 minutes', @@ -152,34 +156,25 @@ export const COOKIE_POLICY_CONFIG: LegalPageConfig = { caption: 'Analytics', columns: ['Cookie', 'Provider', 'Purpose', 'Retention'], columnWidths: COOKIE_TABLE_WIDTHS, + codeColumns: COOKIE_TABLE_CODE_COLUMNS, rows: [ + ['_ga', 'Google Analytics', 'Distinguishes one visitor from another.', '13 months'], [ - _ga, - 'Google Analytics', - 'Distinguishes one visitor from another.', - '13 months', - ], - [ - _ga_*, + '_ga_*', 'Google Analytics', 'Holds the session state for a specific Analytics property.', '13 months', ], [ - __hstc, + '__hstc', 'HubSpot', 'Tracks visits across sessions for the main tracker.', '6 months', ], + ['hubspotutk', 'HubSpot', 'Identifies a visitor across form submissions.', '6 months'], + ['__hssc', 'HubSpot', 'Tracks the current session.', '30 minutes'], [ - hubspotutk, - 'HubSpot', - 'Identifies a visitor across form submissions.', - '6 months', - ], - [__hssc, 'HubSpot', 'Tracks the current session.', '30 minutes'], - [ - __hssrc, + '__hssrc', 'HubSpot', 'Detects whether the visitor restarted their browser.', 'Session', @@ -191,43 +186,29 @@ export const COOKIE_POLICY_CONFIG: LegalPageConfig = { caption: 'Marketing', columns: ['Cookie', 'Provider', 'Purpose', 'Retention'], columnWidths: COOKIE_TABLE_WIDTHS, + codeColumns: COOKIE_TABLE_CODE_COLUMNS, rows: [ [ - guest_id, + 'guest_id', 'X (Twitter)', 'Identifies a browser to the X conversion pixel.', '13 months', ], [ - guest_id_ads, + 'guest_id_ads', 'X (Twitter)', 'Measures conversions from X advertising.', '13 months', ], [ - guest_id_marketing, + 'guest_id_marketing', 'X (Twitter)', 'Measures the performance of X marketing campaigns.', '13 months', ], - [ - personalization_id, - 'X (Twitter)', - 'Personalizes the ads shown on X.', - '13 months', - ], - [ - muc_ads, - 'X (Twitter)', - 'Measures ad conversions across X domains.', - '13 months', - ], - [ - _gcl_*, - 'Google Ads', - 'Attributes a sign-up to the ad that led to it.', - '90 days', - ], + ['personalization_id', 'X (Twitter)', 'Personalizes the ads shown on X.', '13 months'], + ['muc_ads', 'X (Twitter)', 'Measures ad conversions across X domains.', '13 months'], + ['_gcl_*', 'Google Ads', 'Attributes a sign-up to the ad that led to it.', '90 days'], ], }, ], diff --git a/apps/sim/app/_shell/consent/consent-banner.tsx b/apps/sim/app/_shell/consent/consent-banner.tsx index b58d2d6d0b3..991c5c13438 100644 --- a/apps/sim/app/_shell/consent/consent-banner.tsx +++ b/apps/sim/app/_shell/consent/consent-banner.tsx @@ -1,11 +1,10 @@ 'use client' -import { useEffect, useState } from 'react' +import { useEffect } from 'react' import { useConsentManager, useHeadlessConsentUI } from '@c15t/nextjs/headless' -import { Chip, cn, Label, Switch } from '@sim/emcn' +import { Chip, Label, Switch } from '@sim/emcn' import { AnimatePresence, motion, useReducedMotion } from 'framer-motion' import Link from 'next/link' -import { usePathname } from 'next/navigation' import { type ConsentCategory, OPEN_CONSENT_PREFERENCES_EVENT } from '@/lib/consent/constants' interface ConsentCategoryCopy { @@ -33,39 +32,25 @@ const CONSENT_CATEGORY_COPY: Partial`, and the - * rest through a shell wrapper (`LandingShell`, `AuthShell`, the chat - * interfaces, the public file view). The banner mounts at the root, outside - * those wrappers, so on a landing route missing from the forced-theme list a - * dark-theme visitor would get a dark card over a light page. Probing for the - * layer covers both mechanisms without a route list to keep in sync. - */ -function useLightTokenLayer(): boolean { - const pathname = usePathname() - const [isLight, setIsLight] = useState(false) +const CARD_HIDDEN = { opacity: 0, y: 8 } as const +const CARD_SHOWN = { opacity: 1, y: 0 } as const +const CARD_HIDDEN_REDUCED = { opacity: 0 } as const +const CARD_SHOWN_REDUCED = { opacity: 1 } as const - useEffect(() => { - setIsLight(document.querySelector('.light') !== null) - }, [pathname]) +const CATEGORIES_COLLAPSED = { height: 0, opacity: 0 } as const +const CATEGORIES_OPEN = { height: 'auto', opacity: 1 } as const - return isLight -} +/** Inline link chrome, matching `PROSE_TYPE.link` on the legal pages. */ +const LINK_CLASS = + 'text-[var(--text-primary)] underline underline-offset-2 transition-colors hover:text-[var(--text-body)]' /** * Cookie consent banner — a non-modal card docked bottom-left, opposite the @@ -77,6 +62,12 @@ function useLightTokenLayer(): boolean { * consent runtime resolves, so the banner is absent entirely where no consent * is required and never offers an action the policy does not allow. Accept and * reject carry identical weight, which GDPR requires. + * + * The card pins the `light` token layer rather than following the visitor's + * theme, as every other public surface does (`LandingShell`, `AuthShell`, the + * chat interfaces, the public file view). Consent is asked for on a first + * visit, which always lands on one of those; reaching the themed app without a + * consent record takes an expiry against a live session. */ export function ConsentBanner() { const { consents, selectedConsents, setSelectedConsent, getDisplayedConsents } = @@ -84,7 +75,6 @@ export function ConsentBanner() { const { banner, dialog, openDialog, performAction, saveCustomPreferences } = useHeadlessConsentUI() const prefersReducedMotion = useReducedMotion() - const isLightSurface = useLightTokenLayer() useEffect(() => { window.addEventListener(OPEN_CONSENT_PREFERENCES_EVENT, openDialog) @@ -106,22 +96,19 @@ export function ConsentBanner() { {(banner.isVisible || dialog.isVisible) && (

    Cookies

    We use cookies to run Sim, understand how it is used, and improve it. Read our{' '} - - Privacy Policy + + Cookie Policy .

    @@ -131,10 +118,10 @@ export function ConsentBanner() { {isExpanded && (
      @@ -163,37 +150,39 @@ export function ConsentBanner() { )} -
      - {!isExpanded && allowedActions.includes('customize') && ( - - Customize - - )} - {allowedActions.includes('reject') && ( - - void performAction('reject', { surface: isExpanded ? 'dialog' : 'banner' }) - } - > - Reject all - - )} - {allowedActions.includes('accept') && ( - - void performAction('accept', { surface: isExpanded ? 'dialog' : 'banner' }) - } - > - Accept all - - )} - {isExpanded && ( - void saveCustomPreferences()}> - Save - - )} +
      +
      + {!isExpanded && allowedActions.includes('customize') && ( + Customize + )} +
      +
      + {allowedActions.includes('reject') && ( + + void performAction('reject', { surface: isExpanded ? 'dialog' : 'banner' }) + } + > + Reject all + + )} + {allowedActions.includes('accept') && ( + + void performAction('accept', { surface: isExpanded ? 'dialog' : 'banner' }) + } + > + Accept all + + )} + {isExpanded && ( + void saveCustomPreferences()}> + Save + + )} +
      )} diff --git a/apps/sim/app/_shell/consent/consent-provider.tsx b/apps/sim/app/_shell/consent/consent-provider.tsx index 0b599685696..7ff31ebcf9d 100644 --- a/apps/sim/app/_shell/consent/consent-provider.tsx +++ b/apps/sim/app/_shell/consent/consent-provider.tsx @@ -19,9 +19,7 @@ const ConsentRuntime = dynamic( /** * Mounts the consent runtime alongside the app rather than wrapping it, so - * consent state changes can never re-render the page tree. Nothing outside - * {@link ConsentRuntime} reads consent today; a surface that needs to (a footer - * "Cookie preferences" link, say) would move the provider above it. + * consent state changes can never re-render the page tree. */ export function ConsentProvider() { return diff --git a/apps/sim/lib/consent/constants.ts b/apps/sim/lib/consent/constants.ts index 40c91c7272d..333124cd2ee 100644 --- a/apps/sim/lib/consent/constants.ts +++ b/apps/sim/lib/consent/constants.ts @@ -1,11 +1,7 @@ /** - * Cookie-consent runtime configuration. - * - * Lives in `lib` rather than beside the banner because the CSP builder - * (`lib/core/security/csp`) has to allow the same origin the runtime calls — - * two copies of the URL would drift, and the failure mode is silent: a blocked - * request makes the runtime fall back to an offline policy that shows the - * banner to every visitor worldwide and records nothing. + * Cookie-consent runtime configuration. Lives in `lib` so the CSP builder + * (`lib/core/security/csp`) can allow the same origin without a second copy of + * the URL to drift. */ /** diff --git a/apps/sim/lib/core/security/csp.ts b/apps/sim/lib/core/security/csp.ts index 4f3241b2fb4..ca54d1afa9a 100644 --- a/apps/sim/lib/core/security/csp.ts +++ b/apps/sim/lib/core/security/csp.ts @@ -117,9 +117,8 @@ const STATIC_CONNECT_SRC = [ ...(isDev ? ['ws://localhost:4722'] : []), ...(isHosted ? [ - // Cookie-consent runtime — the banner's policy lookup and consent writes. - // Without this the request is blocked, the runtime silently falls back to - // an offline policy, and the banner shows to every visitor worldwide. + // Blocked here, the consent runtime silently falls back to an offline + // policy and the banner shows to every visitor worldwide. CONSENT_BACKEND_URL, 'https://www.googletagmanager.com', 'https://*.google-analytics.com', From 29874fc2121f33bb20e046802627cc3c6c484d68 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 18 Aug 2026 16:55:12 -0700 Subject: [PATCH 4/5] refactor(consent): apply the simplify pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The consent runtime installs a childList+subtree MutationObserver on document.body for its iframe blocker, for the life of every hosted page — including the workflow canvas — and re-scans each added subtree. Sim gates no iframes by consent, so disableAutomaticBlocking turns it off. Also: collapse the ConsentProvider passthrough into the dynamic() export; move ConsentPreferencesLink under (landing)/cookie-policy so a shell module no longer imports landing chrome; build the three cookie tables from one shape; move the table column widths into the prose chrome layer; only compute the category list when the card is expanded; drop the ConsentCategory cast; express the card width in Tailwind rather than an inline style. Comment corrections: the sibling mount is forced by ssr:false, not by re-render concerns; lib/consent/constants must stay dependency-free because next.config loads it and the browser bundles it; codeColumns exists for biome's useJsxKeyInIterable, not for React; the headless entry omits the components but the provider still injects an inert --c15t-* style block. --- .../components/legal-block/legal-block.tsx | 7 +- .../components/prose-page/constants.ts | 10 + .../(landing)/components/prose-page/types.ts | 6 +- .../consent-preferences-link.tsx | 0 .../cookie-policy/cookie-policy-content.tsx | 185 ++++++++---------- .../sim/app/_shell/consent/consent-banner.tsx | 63 +++--- .../app/_shell/consent/consent-provider.tsx | 27 ++- .../app/_shell/consent/consent-runtime.tsx | 15 +- apps/sim/lib/consent/constants.ts | 6 + 9 files changed, 159 insertions(+), 160 deletions(-) rename apps/sim/app/{_shell/consent => (landing)/cookie-policy}/consent-preferences-link.tsx (100%) diff --git a/apps/sim/app/(landing)/components/prose-page/components/legal-block-group/components/legal-block/legal-block.tsx b/apps/sim/app/(landing)/components/prose-page/components/legal-block-group/components/legal-block/legal-block.tsx index 7e6d95bc1a7..0c3f4cda18d 100644 --- a/apps/sim/app/(landing)/components/prose-page/components/legal-block-group/components/legal-block/legal-block.tsx +++ b/apps/sim/app/(landing)/components/prose-page/components/legal-block-group/components/legal-block/legal-block.tsx @@ -46,7 +46,7 @@ export function LegalBlockView({ block }: LegalBlockViewProps) { {block.columnWidths ? (
    {block.columnWidths.map((width, index) => ( - + ))} ) : null} @@ -65,10 +65,7 @@ export function LegalBlockView({ block }: LegalBlockViewProps) { return ( {row.map((cell, cellIndex) => ( - ))} diff --git a/apps/sim/app/(landing)/components/prose-page/constants.ts b/apps/sim/app/(landing)/components/prose-page/constants.ts index d19085e3559..4fda51fc303 100644 --- a/apps/sim/app/(landing)/components/prose-page/constants.ts +++ b/apps/sim/app/(landing)/components/prose-page/constants.ts @@ -36,6 +36,16 @@ export const PROSE_SPACING = { listIndent: 'pl-6', } as const +/** + * Column widths for the cookie-inventory tables. Sibling tables sharing a header + * must be given a fixed layout or each sizes itself to its own content and the + * group reads as unaligned grids — so the widths are chrome, and live here + * rather than as class strings in a content config. + */ +export const PROSE_TABLE_WIDTHS = { + cookieInventory: ['w-[24%]', 'w-[16%]', 'w-[45%]', 'w-[15%]'], +} as const + /** * Prose type tokens - the single source of truth for every heading size, body * color, list, callout, and inline-link treatment. Centralized alongside the diff --git a/apps/sim/app/(landing)/components/prose-page/types.ts b/apps/sim/app/(landing)/components/prose-page/types.ts index 6166b8e7865..b8cfc2cc505 100644 --- a/apps/sim/app/(landing)/components/prose-page/types.ts +++ b/apps/sim/app/(landing)/components/prose-page/types.ts @@ -41,8 +41,10 @@ export type LegalBlock = /** * Indices of columns rendered as inline code — cookie names, config keys. * A marker rather than a `` in the row data, because a row carries - * content and the renderer owns chrome, and because JSX inside an array - * literal needs a key that means nothing here. + * content and the renderer owns chrome. It is also what keeps the rows + * plain strings: biome's `useJsxKeyInIterable` fires on JSX inside an + * array literal, and the key it wants means nothing to a cell the + * renderer already keys by column. */ codeColumns?: number[] rows: ReactNode[][] diff --git a/apps/sim/app/_shell/consent/consent-preferences-link.tsx b/apps/sim/app/(landing)/cookie-policy/consent-preferences-link.tsx similarity index 100% rename from apps/sim/app/_shell/consent/consent-preferences-link.tsx rename to apps/sim/app/(landing)/cookie-policy/consent-preferences-link.tsx diff --git a/apps/sim/app/(landing)/cookie-policy/cookie-policy-content.tsx b/apps/sim/app/(landing)/cookie-policy/cookie-policy-content.tsx index 0558cd9b285..a0b1d9f30e1 100644 --- a/apps/sim/app/(landing)/cookie-policy/cookie-policy-content.tsx +++ b/apps/sim/app/(landing)/cookie-policy/cookie-policy-content.tsx @@ -1,11 +1,26 @@ -import { ConsentPreferencesLink } from '@/app/_shell/consent/consent-preferences-link' -import { type LegalPageConfig, ProseLink } from '@/app/(landing)/components/prose-page' +import type { ReactNode } from 'react' +import { + type LegalBlock, + type LegalPageConfig, + ProseLink, +} from '@/app/(landing)/components/prose-page' +import { PROSE_TABLE_WIDTHS } from '@/app/(landing)/components/prose-page/constants' +import { ConsentPreferencesLink } from '@/app/(landing)/cookie-policy/consent-preferences-link' -/** Shared column widths so the three cookie tables read as one aligned grid. */ -const COOKIE_TABLE_WIDTHS = ['w-[24%]', 'w-[16%]', 'w-[45%]', 'w-[15%]'] - -/** The cookie-name column reads as code. */ -const COOKIE_TABLE_CODE_COLUMNS = [0] +/** + * One cookie-inventory table per consent category. The three share a header and + * a column layout, so they are built from one shape rather than repeated. + */ +function cookieTable(caption: string, rows: ReactNode[][]): LegalBlock { + return { + kind: 'table', + caption, + columns: ['Cookie', 'Provider', 'Purpose', 'Retention'], + columnWidths: [...PROSE_TABLE_WIDTHS.cookieInventory], + codeColumns: [0], + rows, + } +} /** * Cookie Policy content — the inventory a consent banner has to stand on, @@ -112,105 +127,69 @@ export const COOKIE_POLICY_CONFIG: LegalPageConfig = { kind: 'paragraph', content: `Retention periods are the maximum lifetime set when the cookie is written; a cookie can be cleared sooner at any time. Third-party providers occasionally rename or re-scope their cookies, so treat the provider column as the authoritative reference for anything not set by Sim.`, }, - { - kind: 'table', - caption: 'Necessary', - columns: ['Cookie', 'Provider', 'Purpose', 'Retention'], - columnWidths: COOKIE_TABLE_WIDTHS, - codeColumns: COOKIE_TABLE_CODE_COLUMNS, - rows: [ - [ - 'better-auth.session_token', - 'Sim', - 'Keeps you signed in and identifies your session.', - '30 days', - ], - [ - 'better-auth.session_data', - 'Sim', - 'Short-lived signed cache of your session so each page load does not re-read the database.', - '5 minutes', - ], - [ - 'c15t', - 'Sim (via c15t)', - 'Records the cookie choice you made so the banner is not shown again.', - '365 days', - ], - [ - 'sidebar_collapsed', - 'Sim', - 'Remembers whether the workspace sidebar is collapsed, so the layout does not jump on load.', - '1 year', - ], - [ - '__cf_bm', - 'Cloudflare', - 'Bot-management check on requests to providers we load, such as HubSpot and X.', - '30 minutes', - ], + cookieTable('Necessary', [ + [ + 'better-auth.session_token', + 'Sim', + 'Keeps you signed in and identifies your session.', + '30 days', ], - }, - { - kind: 'table', - caption: 'Analytics', - columns: ['Cookie', 'Provider', 'Purpose', 'Retention'], - columnWidths: COOKIE_TABLE_WIDTHS, - codeColumns: COOKIE_TABLE_CODE_COLUMNS, - rows: [ - ['_ga', 'Google Analytics', 'Distinguishes one visitor from another.', '13 months'], - [ - '_ga_*', - 'Google Analytics', - 'Holds the session state for a specific Analytics property.', - '13 months', - ], - [ - '__hstc', - 'HubSpot', - 'Tracks visits across sessions for the main tracker.', - '6 months', - ], - ['hubspotutk', 'HubSpot', 'Identifies a visitor across form submissions.', '6 months'], - ['__hssc', 'HubSpot', 'Tracks the current session.', '30 minutes'], - [ - '__hssrc', - 'HubSpot', - 'Detects whether the visitor restarted their browser.', - 'Session', - ], + [ + 'better-auth.session_data', + 'Sim', + 'Short-lived signed cache of your session so each page load does not re-read the database.', + '5 minutes', ], - }, - { - kind: 'table', - caption: 'Marketing', - columns: ['Cookie', 'Provider', 'Purpose', 'Retention'], - columnWidths: COOKIE_TABLE_WIDTHS, - codeColumns: COOKIE_TABLE_CODE_COLUMNS, - rows: [ - [ - 'guest_id', - 'X (Twitter)', - 'Identifies a browser to the X conversion pixel.', - '13 months', - ], - [ - 'guest_id_ads', - 'X (Twitter)', - 'Measures conversions from X advertising.', - '13 months', - ], - [ - 'guest_id_marketing', - 'X (Twitter)', - 'Measures the performance of X marketing campaigns.', - '13 months', - ], - ['personalization_id', 'X (Twitter)', 'Personalizes the ads shown on X.', '13 months'], - ['muc_ads', 'X (Twitter)', 'Measures ad conversions across X domains.', '13 months'], - ['_gcl_*', 'Google Ads', 'Attributes a sign-up to the ad that led to it.', '90 days'], + [ + 'c15t', + 'Sim (via c15t)', + 'Records the cookie choice you made so the banner is not shown again.', + '365 days', ], - }, + [ + 'sidebar_collapsed', + 'Sim', + 'Remembers whether the workspace sidebar is collapsed, so the layout does not jump on load.', + '1 year', + ], + [ + '__cf_bm', + 'Cloudflare', + 'Bot-management check on requests to providers we load, such as HubSpot and X.', + '30 minutes', + ], + ]), + cookieTable('Analytics', [ + ['_ga', 'Google Analytics', 'Distinguishes one visitor from another.', '13 months'], + [ + '_ga_*', + 'Google Analytics', + 'Holds the session state for a specific Analytics property.', + '13 months', + ], + ['__hstc', 'HubSpot', 'Tracks visits across sessions for the main tracker.', '6 months'], + ['hubspotutk', 'HubSpot', 'Identifies a visitor across form submissions.', '6 months'], + ['__hssc', 'HubSpot', 'Tracks the current session.', '30 minutes'], + ['__hssrc', 'HubSpot', 'Detects whether the visitor restarted their browser.', 'Session'], + ]), + cookieTable('Marketing', [ + [ + 'guest_id', + 'X (Twitter)', + 'Identifies a browser to the X conversion pixel.', + '13 months', + ], + ['guest_id_ads', 'X (Twitter)', 'Measures conversions from X advertising.', '13 months'], + [ + 'guest_id_marketing', + 'X (Twitter)', + 'Measures the performance of X marketing campaigns.', + '13 months', + ], + ['personalization_id', 'X (Twitter)', 'Personalizes the ads shown on X.', '13 months'], + ['muc_ads', 'X (Twitter)', 'Measures ad conversions across X domains.', '13 months'], + ['_gcl_*', 'Google Ads', 'Attributes a sign-up to the ad that led to it.', '90 days'], + ]), ], }, { diff --git a/apps/sim/app/_shell/consent/consent-banner.tsx b/apps/sim/app/_shell/consent/consent-banner.tsx index 991c5c13438..230033ff19c 100644 --- a/apps/sim/app/_shell/consent/consent-banner.tsx +++ b/apps/sim/app/_shell/consent/consent-banner.tsx @@ -14,10 +14,14 @@ interface ConsentCategoryCopy { /** * Sim's own wording per category. The runtime ships generic descriptions; these - * say what the cookies actually do here. A category without an entry falls back - * to the runtime's description rather than disappearing. + * say what the cookies actually do here. + * + * Typed by name rather than by {@link ConsentCategory} because the runtime's + * union is wider than the three categories we configure — a policy that adds + * one server-side falls back to the runtime's description instead of + * disappearing. The `satisfies` still requires an entry for each of ours. */ -const CONSENT_CATEGORY_COPY: Partial> = { +const CONSENT_CATEGORY_COPY: Record = { necessary: { title: 'Necessary', description: 'Sign-in and security. Always on.', @@ -30,25 +34,23 @@ const CONSENT_CATEGORY_COPY: Partial -/** Shared expo-out easing, matching the toast stack's motion. */ +/** Shared expo-out easing and timings, matching the toast stack's motion. */ const EASE = [0.22, 1, 0.36, 1] as const const ENTER_TRANSITION = { duration: 0.28, ease: EASE } as const const EXPAND_TRANSITION = { duration: 0.22, ease: EASE } as const -const CARD_HIDDEN = { opacity: 0, y: 8 } as const -const CARD_SHOWN = { opacity: 1, y: 0 } as const -const CARD_HIDDEN_REDUCED = { opacity: 0 } as const -const CARD_SHOWN_REDUCED = { opacity: 1 } as const +const NO_CATEGORIES: ReturnType['getDisplayedConsents']> = [] const CATEGORIES_COLLAPSED = { height: 0, opacity: 0 } as const const CATEGORIES_OPEN = { height: 'auto', opacity: 1 } as const -/** Inline link chrome, matching `PROSE_TYPE.link` on the legal pages. */ +/** + * A copy of `PROSE_TYPE.link` rather than an import: the banner lives in the + * app shell and the token lives in the landing route group, and a shell module + * reaching into a route group is the wrong direction for one class string. + */ const LINK_CLASS = 'text-[var(--text-primary)] underline underline-offset-2 transition-colors hover:text-[var(--text-body)]' @@ -66,8 +68,9 @@ const LINK_CLASS = * The card pins the `light` token layer rather than following the visitor's * theme, as every other public surface does (`LandingShell`, `AuthShell`, the * chat interfaces, the public file view). Consent is asked for on a first - * visit, which always lands on one of those; reaching the themed app without a - * consent record takes an expiry against a live session. + * visit, which lands on one of those. A record expiring against a live session + * is the one path that renders this card over the themed app, where it will + * read light-on-dark; accepted as the rarer case. */ export function ConsentBanner() { const { consents, selectedConsents, setSelectedConsent, getDisplayedConsents } = @@ -82,26 +85,27 @@ export function ConsentBanner() { }, [openDialog]) const isExpanded = dialog.isVisible - const surface = isExpanded ? dialog : banner - const { allowedActions } = surface + const surfaceName = isExpanded ? 'dialog' : 'banner' + const { allowedActions } = isExpanded ? dialog : banner /** * The store's own selector, not a hand-rolled filter over `consentTypes`: the * shipped defaults mark every category except `necessary` as `display: false`, - * so filtering on that flag silently renders a one-row list. + * so filtering on that flag silently renders a one-row list. It re-filters and + * re-allocates on every call, so only the expanded card pays for it. */ - const categories = getDisplayedConsents() + const categories = isExpanded ? getDisplayedConsents() : NO_CATEGORIES + const enterOffset = prefersReducedMotion ? 0 : 8 return ( {(banner.isVisible || dialog.isVisible) && (

    Cookies

    @@ -126,7 +130,7 @@ export function ConsentBanner() { >
      {categories.map((type) => { - const copy = CONSENT_CATEGORY_COPY[type.name as ConsentCategory] + const copy = CONSENT_CATEGORY_COPY[type.name] const inputId = `consent-${type.name}` return (
    • @@ -150,6 +154,7 @@ export function ConsentBanner() { )} + {/* Two clusters, not `mr-auto` on the chip: chips carry no outer margin. */}
      {!isExpanded && allowedActions.includes('customize') && ( @@ -160,9 +165,7 @@ export function ConsentBanner() { {allowedActions.includes('reject') && ( - void performAction('reject', { surface: isExpanded ? 'dialog' : 'banner' }) - } + onClick={() => void performAction('reject', { surface: surfaceName })} > Reject all @@ -170,9 +173,7 @@ export function ConsentBanner() { {allowedActions.includes('accept') && ( - void performAction('accept', { surface: isExpanded ? 'dialog' : 'banner' }) - } + onClick={() => void performAction('accept', { surface: surfaceName })} > Accept all diff --git a/apps/sim/app/_shell/consent/consent-provider.tsx b/apps/sim/app/_shell/consent/consent-provider.tsx index 7ff31ebcf9d..d1c4f4d6dde 100644 --- a/apps/sim/app/_shell/consent/consent-provider.tsx +++ b/apps/sim/app/_shell/consent/consent-provider.tsx @@ -3,24 +3,19 @@ import dynamic from 'next/dynamic' /** - * Lazy boundary for the cookie-consent runtime. + * The cookie-consent runtime, loaded on the client only and only once this + * component is rendered — the root layout renders it behind `isHosted`, so a + * self-hosted deployment never fetches the chunk, never reaches Sim's consent + * backend, and never sees the banner. Deferring it also keeps the third-party + * store out of the server render and off the landing page's hydration path; the + * banner cannot paint before its geo lookup resolves anyway. * - * The runtime is loaded on the client only, and only once this component is - * rendered — the root layout renders it behind `isHosted`, so a self-hosted - * deployment never fetches the chunk, never reaches Sim's consent backend, and - * never sees the banner. Deferring it also keeps the third-party store out of - * the server render and off the landing page's hydration path; the banner - * cannot paint before its geo lookup resolves anyway. + * It mounts alongside the app rather than wrapping it because an `ssr: false` + * boundary around the tree would disable SSR for every route. Nothing can reach + * the store through context as a result, which is what + * `OPEN_CONSENT_PREFERENCES_EVENT` exists for. */ -const ConsentRuntime = dynamic( +export const ConsentProvider = dynamic( () => import('@/app/_shell/consent/consent-runtime').then((m) => m.ConsentRuntime), { ssr: false } ) - -/** - * Mounts the consent runtime alongside the app rather than wrapping it, so - * consent state changes can never re-render the page tree. - */ -export function ConsentProvider() { - return -} diff --git a/apps/sim/app/_shell/consent/consent-runtime.tsx b/apps/sim/app/_shell/consent/consent-runtime.tsx index ad74c071768..adfe5c381cb 100644 --- a/apps/sim/app/_shell/consent/consent-runtime.tsx +++ b/apps/sim/app/_shell/consent/consent-runtime.tsx @@ -10,14 +10,23 @@ import { ConsentBanner } from '@/app/_shell/consent/consent-banner' /** * Imported from `@c15t/nextjs/headless`, not the package root: the headless - * entry ships the store and hooks without the runtime's own components or - * stylesheet, so {@link ConsentBanner} is the only consent UI that exists and - * nothing can leak styles into the app. + * entry leaves the runtime's own components and stylesheet out of the bundle, + * so {@link ConsentBanner} is the only consent UI that exists. The provider + * still injects a `
    {block.caption} {block.caption}
    + {block.codeColumns?.includes(cellIndex) ? {cell} : cell}