improve privacy consent modal: policy link, clearer CTAs, mobile layout (#1921)

The first-run consent banner had no link to a privacy policy, an
affirmative button ("Help improve") that didn't read as a consent
choice, and a fixed bottom-right card that crowded content on phones.

- Add a "Read the privacy policy" link (external-link icon, accent,
  underlined) above the actions, plus a root PRIVACY.md it points to
  documenting the telemetry behaviour the modal discloses.
- Rename the CTAs to "Share usage data" / "Don't share" so both name
  the action; they stay equal-prominence per the EDPB/GDPR comment.
- Stretch the banner to a bottom-edge bar under 540px with a
  safe-area inset so it clears mobile browser chrome.
- Add PrivacyConsentModal tests; sync the new i18n key to every
  locale and update the consent-label assertion in App.connectors.

Refs #1756
This commit is contained in:
kami 2026-05-17 21:24:15 +09:00 committed by GitHub
parent 9cc1fb28f3
commit 30ad8b8ac3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 263 additions and 46 deletions

70
PRIVACY.md Normal file
View file

@ -0,0 +1,70 @@
# Privacy
This page describes what data the Open Design desktop and web app collects,
when it collects it, and how you stay in control. It documents the behavior
shipped in the app — the same controls live under **Settings → Privacy**.
Open Design is **local-first**. Your projects, generated files, and BYOK API
keys stay on your machine. The app works fully offline; nothing in this page
applies unless you explicitly turn telemetry on.
## Telemetry is opt-in
Usage telemetry is **off by default**. On first run the app shows a privacy
consent banner asking you to make a choice — it never starts sending anything
before you do. You can change your decision at any time under
**Settings → Privacy**, where each category below has its own toggle.
## What is collected when you opt in
When telemetry is enabled, the app may send the following to the Open Design
team. Each category is independently controllable in Settings.
- **Anonymous metrics** — run counts, token usage, error rate, and duration.
No prompts and no project data.
- **Conversation and tool content** — your prompts, assistant responses, tool
inputs, and tool outputs (truncated before send). API keys, tokens, JWTs,
emails, IP addresses, and credit-card numbers are stripped automatically
before anything leaves your machine.
- **Project artifacts manifest** — filenames, types, and sizes of generated
files. The **contents** of those files are never sent.
## What is never collected
- The contents of your generated artifact files.
- Your BYOK API keys, tokens, or other secrets — these are redacted before
send and are never part of telemetry.
- Anything at all while telemetry is turned off.
## How telemetry is sent
Redacted telemetry batches are sent to a Cloudflare Worker relay operated by
the Open Design team, which forwards them to [Langfuse](https://langfuse.com)
for analysis. The relay holds the Langfuse write credentials server-side, so
packaged clients only ever ship a public relay URL — no secret keys. If the
relay is unavailable the app retries quietly and keeps working; telemetry
never blocks your workflow.
## Your anonymous ID
When telemetry is enabled the app generates a random, opaque installation ID
so related events can be grouped. It is not tied to your name, email, or
account, and it carries no personal information.
## Deleting your data
**Settings → Privacy → Delete my data** rotates your anonymous ID and stops
sending. Telemetry already received ages out under the team's retention
policy.
## Bring your own key
Open Design is BYOK at every layer. The API keys you configure for coding
agents and model providers are stored locally and used only to talk to those
providers directly. They are never sent to the Open Design team.
## Changes to this page
This document tracks the data handling of the shipped app. When the telemetry
behavior changes, this page is updated alongside it. For questions, open a
[GitHub Discussion](https://github.com/nexu-io/open-design/discussions).

View file

@ -1,4 +1,12 @@
import { useT } from '../i18n';
import { Icon } from './Icon';
/**
* Canonical location of the full privacy policy. Kept as a single named
* constant so it can be repointed (e.g. to a hosted page) without touching
* markup. `PRIVACY.md` documents the same data handling the modal discloses.
*/
const PRIVACY_POLICY_URL = 'https://github.com/nexu-io/open-design/blob/main/PRIVACY.md';
interface Props {
/** Affirmative consent (Share usage data). */
@ -12,11 +20,17 @@ interface Props {
*
* Anchored to the bottom-right of the viewport (cookie-consent style)
* so it's prominently visible without blocking the underlying app
* the user can move around and read while deciding. The two action
* buttons share equal visual prominence so the reject path is not
* de-emphasised, matching the EDPB equal-prominence requirement
* under GDPR. Neither button is rendered as selected before the user
* chooses.
* the user can move around and read while deciding. On narrow viewports
* it stretches to a bottom-edge bar (see `.privacy-consent-banner` in
* index.css) so it doesn't crowd content on phones.
*
* The two action buttons share equal visual prominence so the reject
* path is not de-emphasised, matching the EDPB equal-prominence
* requirement under GDPR. Neither button is rendered as selected before
* the user chooses, and their labels name the action ("Share usage
* data" / "Don't share") rather than a vague outcome so the affirmative
* button reads as a consent choice. A link to the full privacy policy
* sits above the actions so the policy is reachable before deciding.
*
* Stays mounted until the user picks Share or Don't share there is
* no dismiss-without-choice button on purpose. Telemetry decisions
@ -47,6 +61,16 @@ export function PrivacyConsentModal({ onShare, onDecline }: Props): JSX.Element
<p className="hint privacy-consent-banner-footer">{t('settings.privacyConsentFooter')}</p>
<a
className="privacy-consent-policy-link"
href={PRIVACY_POLICY_URL}
target="_blank"
rel="noopener noreferrer"
>
<Icon name="external-link" size={13} />
<span>{t('settings.privacyConsentPolicyLink')}</span>
</a>
<div
className="privacy-consent-actions"
role="group"

View file

@ -175,8 +175,9 @@ export const ar: Dict = {
'settings.privacyConsentKicker': 'Help us improve Open Design',
'settings.privacyConsentLead': 'Open Design can share usage data with our team to help us improve. This includes:',
'settings.privacyConsentFooter': 'You can change either of these any time in Settings → Privacy. We never upload the contents of your generated artifact files.',
'settings.privacyConsentShare': 'Help improve',
'settings.privacyConsentDecline': 'Not now',
'settings.privacyConsentShare': 'Share usage data',
'settings.privacyConsentDecline': "Don't share",
'settings.privacyConsentPolicyLink': 'Read the privacy policy',
'settings.privacyMetrics': 'Anonymous metrics',
'settings.privacyMetricsHint': 'Run counts, token usage, error rate, duration. No prompts, no project data.',
'settings.privacyContent': 'Conversation content',

View file

@ -175,8 +175,9 @@ export const de: Dict = {
'settings.privacyConsentKicker': 'Help us improve Open Design',
'settings.privacyConsentLead': 'Open Design can share usage data with our team to help us improve. This includes:',
'settings.privacyConsentFooter': 'You can change either of these any time in Settings → Privacy. We never upload the contents of your generated artifact files.',
'settings.privacyConsentShare': 'Help improve',
'settings.privacyConsentDecline': 'Not now',
'settings.privacyConsentShare': 'Share usage data',
'settings.privacyConsentDecline': "Don't share",
'settings.privacyConsentPolicyLink': 'Read the privacy policy',
'settings.privacyMetrics': 'Anonymous metrics',
'settings.privacyMetricsHint': 'Run counts, token usage, error rate, duration. No prompts, no project data.',
'settings.privacyContent': 'Conversation content',

View file

@ -181,8 +181,9 @@ export const en: Dict = {
'settings.privacyConsentKicker': 'Help us improve Open Design',
'settings.privacyConsentLead': 'Open Design can share usage data with our team to help us improve. This includes:',
'settings.privacyConsentFooter': 'You can change either of these any time in Settings → Privacy. We never upload the contents of your generated artifact files.',
'settings.privacyConsentShare': 'Help improve',
'settings.privacyConsentDecline': 'Not now',
'settings.privacyConsentShare': 'Share usage data',
'settings.privacyConsentDecline': "Don't share",
'settings.privacyConsentPolicyLink': 'Read the privacy policy',
'settings.privacyMetrics': 'Anonymous metrics',
'settings.privacyMetricsHint': 'Run counts, token usage, error rate, duration. No prompts, no project data.',
'settings.privacyContent': 'Conversation and tool content',

View file

@ -175,8 +175,9 @@ export const esES: Dict = {
'settings.privacyConsentKicker': 'Help us improve Open Design',
'settings.privacyConsentLead': 'Open Design can share usage data with our team to help us improve. This includes:',
'settings.privacyConsentFooter': 'You can change either of these any time in Settings → Privacy. We never upload the contents of your generated artifact files.',
'settings.privacyConsentShare': 'Help improve',
'settings.privacyConsentDecline': 'Not now',
'settings.privacyConsentShare': 'Share usage data',
'settings.privacyConsentDecline': "Don't share",
'settings.privacyConsentPolicyLink': 'Read the privacy policy',
'settings.privacyMetrics': 'Anonymous metrics',
'settings.privacyMetricsHint': 'Run counts, token usage, error rate, duration. No prompts, no project data.',
'settings.privacyContent': 'Conversation content',

View file

@ -175,8 +175,9 @@ export const fa: Dict = {
'settings.privacyConsentKicker': 'Help us improve Open Design',
'settings.privacyConsentLead': 'Open Design can share usage data with our team to help us improve. This includes:',
'settings.privacyConsentFooter': 'You can change either of these any time in Settings → Privacy. We never upload the contents of your generated artifact files.',
'settings.privacyConsentShare': 'Help improve',
'settings.privacyConsentDecline': 'Not now',
'settings.privacyConsentShare': 'Share usage data',
'settings.privacyConsentDecline': "Don't share",
'settings.privacyConsentPolicyLink': 'Read the privacy policy',
'settings.privacyMetrics': 'Anonymous metrics',
'settings.privacyMetricsHint': 'Run counts, token usage, error rate, duration. No prompts, no project data.',
'settings.privacyContent': 'Conversation content',

View file

@ -175,8 +175,9 @@ export const fr: Dict = {
'settings.privacyConsentKicker': 'Help us improve Open Design',
'settings.privacyConsentLead': 'Open Design can share usage data with our team to help us improve. This includes:',
'settings.privacyConsentFooter': 'You can change either of these any time in Settings → Privacy. We never upload the contents of your generated artifact files.',
'settings.privacyConsentShare': 'Help improve',
'settings.privacyConsentDecline': 'Not now',
'settings.privacyConsentShare': 'Share usage data',
'settings.privacyConsentDecline': "Don't share",
'settings.privacyConsentPolicyLink': 'Read the privacy policy',
'settings.privacyMetrics': 'Anonymous metrics',
'settings.privacyMetricsHint': 'Run counts, token usage, error rate, duration. No prompts, no project data.',
'settings.privacyContent': 'Conversation content',

View file

@ -175,8 +175,9 @@ export const hu: Dict = {
'settings.privacyConsentKicker': 'Help us improve Open Design',
'settings.privacyConsentLead': 'Open Design can share usage data with our team to help us improve. This includes:',
'settings.privacyConsentFooter': 'You can change either of these any time in Settings → Privacy. We never upload the contents of your generated artifact files.',
'settings.privacyConsentShare': 'Help improve',
'settings.privacyConsentDecline': 'Not now',
'settings.privacyConsentShare': 'Share usage data',
'settings.privacyConsentDecline': "Don't share",
'settings.privacyConsentPolicyLink': 'Read the privacy policy',
'settings.privacyMetrics': 'Anonymous metrics',
'settings.privacyMetricsHint': 'Run counts, token usage, error rate, duration. No prompts, no project data.',
'settings.privacyContent': 'Conversation content',

View file

@ -173,8 +173,9 @@ export const id: Dict = {
'settings.privacyConsentKicker': 'Help us improve Open Design',
'settings.privacyConsentLead': 'Open Design can share usage data with our team to help us improve. This includes:',
'settings.privacyConsentFooter': 'You can change either of these any time in Settings → Privacy. We never upload the contents of your generated artifact files.',
'settings.privacyConsentShare': 'Help improve',
'settings.privacyConsentDecline': 'Not now',
'settings.privacyConsentShare': 'Share usage data',
'settings.privacyConsentDecline': "Don't share",
'settings.privacyConsentPolicyLink': 'Read the privacy policy',
'settings.privacyMetrics': 'Anonymous metrics',
'settings.privacyMetricsHint': 'Run counts, token usage, error rate, duration. No prompts, no project data.',
'settings.privacyContent': 'Conversation content',

View file

@ -167,8 +167,9 @@ export const it: Dict = {
'settings.privacyConsentKicker': 'Aiutaci a migliorare Open Design',
'settings.privacyConsentLead': 'Open Design può condividere dati di utilizzo con il nostro team per aiutarci a migliorare. Questo include:',
'settings.privacyConsentFooter': 'Puoi modificare entrambe queste opzioni in qualsiasi momento in Impostazioni → Privacy. Non carichiamo mai i contenuti dei tuoi file di artefatti generati.',
'settings.privacyConsentShare': 'Aiuta a migliorare',
'settings.privacyConsentDecline': 'Non ora',
'settings.privacyConsentShare': 'Condividi i dati di utilizzo',
'settings.privacyConsentDecline': 'Non condividere',
'settings.privacyConsentPolicyLink': "Leggi l'informativa sulla privacy",
'settings.privacyMetrics': 'Metriche anonime',
'settings.privacyMetricsHint': 'Conteggi di esecuzione, utilizzo di token, tasso di errore, durata. Nessun prompt, nessun dato di progetto.',
'settings.privacyContent': 'Contenuto della conversazione',

View file

@ -175,8 +175,9 @@ export const ja: Dict = {
'settings.privacyConsentKicker': 'Help us improve Open Design',
'settings.privacyConsentLead': 'Open Design can share usage data with our team to help us improve. This includes:',
'settings.privacyConsentFooter': 'You can change either of these any time in Settings → Privacy. We never upload the contents of your generated artifact files.',
'settings.privacyConsentShare': 'Help improve',
'settings.privacyConsentDecline': 'Not now',
'settings.privacyConsentShare': 'Share usage data',
'settings.privacyConsentDecline': "Don't share",
'settings.privacyConsentPolicyLink': 'Read the privacy policy',
'settings.privacyMetrics': 'Anonymous metrics',
'settings.privacyMetricsHint': 'Run counts, token usage, error rate, duration. No prompts, no project data.',
'settings.privacyContent': 'Conversation content',

View file

@ -175,8 +175,9 @@ export const ko: Dict = {
'settings.privacyConsentKicker': 'Help us improve Open Design',
'settings.privacyConsentLead': 'Open Design can share usage data with our team to help us improve. This includes:',
'settings.privacyConsentFooter': 'You can change either of these any time in Settings → Privacy. We never upload the contents of your generated artifact files.',
'settings.privacyConsentShare': 'Help improve',
'settings.privacyConsentDecline': 'Not now',
'settings.privacyConsentShare': 'Share usage data',
'settings.privacyConsentDecline': "Don't share",
'settings.privacyConsentPolicyLink': 'Read the privacy policy',
'settings.privacyMetrics': 'Anonymous metrics',
'settings.privacyMetricsHint': 'Run counts, token usage, error rate, duration. No prompts, no project data.',
'settings.privacyContent': 'Conversation content',

View file

@ -175,8 +175,9 @@ export const pl: Dict = {
'settings.privacyConsentKicker': 'Help us improve Open Design',
'settings.privacyConsentLead': 'Open Design can share usage data with our team to help us improve. This includes:',
'settings.privacyConsentFooter': 'You can change either of these any time in Settings → Privacy. We never upload the contents of your generated artifact files.',
'settings.privacyConsentShare': 'Help improve',
'settings.privacyConsentDecline': 'Not now',
'settings.privacyConsentShare': 'Share usage data',
'settings.privacyConsentDecline': "Don't share",
'settings.privacyConsentPolicyLink': 'Read the privacy policy',
'settings.privacyMetrics': 'Anonymous metrics',
'settings.privacyMetricsHint': 'Run counts, token usage, error rate, duration. No prompts, no project data.',
'settings.privacyContent': 'Conversation content',

View file

@ -174,8 +174,9 @@ export const ptBR: Dict = {
'settings.privacyConsentKicker': 'Help us improve Open Design',
'settings.privacyConsentLead': 'Open Design can share usage data with our team to help us improve. This includes:',
'settings.privacyConsentFooter': 'You can change either of these any time in Settings → Privacy. We never upload the contents of your generated artifact files.',
'settings.privacyConsentShare': 'Help improve',
'settings.privacyConsentDecline': 'Not now',
'settings.privacyConsentShare': 'Share usage data',
'settings.privacyConsentDecline': "Don't share",
'settings.privacyConsentPolicyLink': 'Read the privacy policy',
'settings.privacyMetrics': 'Anonymous metrics',
'settings.privacyMetricsHint': 'Run counts, token usage, error rate, duration. No prompts, no project data.',
'settings.privacyContent': 'Conversation content',

View file

@ -174,8 +174,9 @@ export const ru: Dict = {
'settings.privacyConsentKicker': 'Help us improve Open Design',
'settings.privacyConsentLead': 'Open Design can share usage data with our team to help us improve. This includes:',
'settings.privacyConsentFooter': 'You can change either of these any time in Settings → Privacy. We never upload the contents of your generated artifact files.',
'settings.privacyConsentShare': 'Help improve',
'settings.privacyConsentDecline': 'Not now',
'settings.privacyConsentShare': 'Share usage data',
'settings.privacyConsentDecline': "Don't share",
'settings.privacyConsentPolicyLink': 'Read the privacy policy',
'settings.privacyMetrics': 'Anonymous metrics',
'settings.privacyMetricsHint': 'Run counts, token usage, error rate, duration. No prompts, no project data.',
'settings.privacyContent': 'Conversation content',

View file

@ -158,8 +158,9 @@ export const th: Dict = {
'settings.privacyConsentKicker': 'ช่วยเราพัฒนา Open Design',
'settings.privacyConsentLead': 'Open Design สามารถแชร์ข้อมูลการใช้งานกับทีมของเราเพื่อช่วยพัฒนา ซึ่งรวมถึง:',
'settings.privacyConsentFooter': 'คุณสามารถเปลี่ยนการตั้งค่าเหล่านี้ได้ตลอดเวลาใน การตั้งค่า → ความเป็นส่วนตัว เราจะไม่ส่งเนื้อหาในไฟล์ที่คุณสร้างขึ้น',
'settings.privacyConsentShare': 'ช่วยปรับปรุง',
'settings.privacyConsentDecline': 'ไว้ทีหลัง',
'settings.privacyConsentShare': 'แชร์ข้อมูลการใช้งาน',
'settings.privacyConsentDecline': 'ไม่แชร์',
'settings.privacyConsentPolicyLink': 'อ่านนโยบายความเป็นส่วนตัว',
'settings.privacyMetrics': 'ข้อมูลผู้ใช้นิรนาม',
'settings.privacyMetricsHint': 'จำนวนการใช้งาน, การใช้โทเค็น, อัตราข้อผิดพลาด',
'settings.privacyContent': 'เนื้อหาการสนทนา',

View file

@ -165,8 +165,9 @@ export const tr: Dict = {
'settings.privacyConsentKicker': 'Help us improve Open Design',
'settings.privacyConsentLead': 'Open Design can share usage data with our team to help us improve. This includes:',
'settings.privacyConsentFooter': 'You can change either of these any time in Settings → Privacy. We never upload the contents of your generated artifact files.',
'settings.privacyConsentShare': 'Help improve',
'settings.privacyConsentDecline': 'Not now',
'settings.privacyConsentShare': 'Share usage data',
'settings.privacyConsentDecline': "Don't share",
'settings.privacyConsentPolicyLink': 'Read the privacy policy',
'settings.privacyMetrics': 'Anonymous metrics',
'settings.privacyMetricsHint': 'Run counts, token usage, error rate, duration. No prompts, no project data.',
'settings.privacyContent': 'Conversation content',

View file

@ -176,8 +176,9 @@ export const uk: Dict = {
'settings.privacyConsentKicker': 'Help us improve Open Design',
'settings.privacyConsentLead': 'Open Design can share usage data with our team to help us improve. This includes:',
'settings.privacyConsentFooter': 'You can change either of these any time in Settings → Privacy. We never upload the contents of your generated artifact files.',
'settings.privacyConsentShare': 'Help improve',
'settings.privacyConsentDecline': 'Not now',
'settings.privacyConsentShare': 'Share usage data',
'settings.privacyConsentDecline': "Don't share",
'settings.privacyConsentPolicyLink': 'Read the privacy policy',
'settings.privacyMetrics': 'Anonymous metrics',
'settings.privacyMetricsHint': 'Run counts, token usage, error rate, duration. No prompts, no project data.',
'settings.privacyContent': 'Conversation content',

View file

@ -180,8 +180,9 @@ export const zhCN: Dict = {
'settings.privacyConsentKicker': '帮助我们改进 Open Design',
'settings.privacyConsentLead': 'Open Design 可以将使用数据共享给我们的团队以协助改进。包括:',
'settings.privacyConsentFooter': '你可以随时在 设置 → 隐私 中修改任意一项。我们绝不上传你生成的产物文件内容。',
'settings.privacyConsentShare': '帮助改进',
'settings.privacyConsentDecline': '暂不',
'settings.privacyConsentShare': '分享使用数据',
'settings.privacyConsentDecline': '不分享',
'settings.privacyConsentPolicyLink': '阅读隐私政策',
'settings.privacyMetrics': '匿名指标',
'settings.privacyMetricsHint': '运行次数、token 用量、错误率、时长。不包含 prompt,不包含项目数据。',
'settings.privacyContent': '对话和工具内容',

View file

@ -173,8 +173,9 @@ export const zhTW: Dict = {
'settings.privacyConsentKicker': '協助我們改進 Open Design',
'settings.privacyConsentLead': 'Open Design 可以將使用資料分享給我們的團隊以協助改進。包含:',
'settings.privacyConsentFooter': '你可以隨時在 設定 → 隱私 中變更任一項。我們絕不上傳你產生的產出檔案內容。',
'settings.privacyConsentShare': '協助改進',
'settings.privacyConsentDecline': '暫不',
'settings.privacyConsentShare': '分享使用資料',
'settings.privacyConsentDecline': '不分享',
'settings.privacyConsentPolicyLink': '閱讀隱私政策',
'settings.privacyMetrics': '匿名指標',
'settings.privacyMetricsHint': '執行次數、token 用量、錯誤率、時長。不包含 prompt,不包含專案資料。',
'settings.privacyContent': '對話內容',

View file

@ -204,6 +204,7 @@ export interface Dict {
'settings.privacyConsentFooter': string;
'settings.privacyConsentShare': string;
'settings.privacyConsentDecline': string;
'settings.privacyConsentPolicyLink': string;
'settings.privacyMetrics': string;
'settings.privacyMetricsHint': string;
'settings.privacyContent': string;

View file

@ -19525,7 +19525,10 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
pointer-events: none;
}
.privacy-consent-banner .privacy-consent-actions {
/* The banner is pointer-events:none so the user can click through the empty
card area to the app behind it. Interactive children opt back in. */
.privacy-consent-banner .privacy-consent-actions,
.privacy-consent-banner .privacy-consent-policy-link {
pointer-events: auto;
}
@ -19557,7 +19560,46 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
}
.privacy-consent-banner-footer {
margin: 2px 0 6px;
margin: 2px 0 0;
}
/* Privacy policy link between the disclosure copy and the action buttons.
Underlined and accent-coloured so it's an obvious, distinct affordance,
not just tinted text. */
.privacy-consent-policy-link {
display: inline-flex;
align-items: center;
align-self: flex-start;
gap: 6px;
font-size: 12.5px;
font-weight: 600;
color: var(--accent);
text-decoration: underline;
text-underline-offset: 2px;
}
.privacy-consent-policy-link:hover {
color: color-mix(in srgb, var(--accent) 80%, var(--text));
}
.privacy-consent-policy-link:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
/* On narrow viewports the fixed bottom-right card crowds page content
stretch it into a bottom-edge bar with even gutters, and clear the
mobile browser chrome / home indicator via the safe-area inset. */
@media (max-width: 540px) {
.privacy-consent-banner {
right: 12px;
left: 12px;
bottom: calc(12px + env(safe-area-inset-bottom, 0px));
width: auto;
max-width: none;
padding: 16px;
}
}
/* ============================================================

View file

@ -273,7 +273,7 @@ describe('App connectors settings flows', () => {
const banner = container.querySelector('.privacy-consent-banner');
expect(banner?.querySelector('.seg-control')).toBeNull();
expect(banner?.querySelector('.seg-btn.active')).toBeNull();
expect(screen.getByRole('button', { name: 'Help improve' }).className).toContain(
expect(screen.getByRole('button', { name: 'Share usage data' }).className).toContain(
'privacy-consent-action',
);
});

View file

@ -0,0 +1,61 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { PrivacyConsentModal } from '../../src/components/PrivacyConsentModal';
import { I18nProvider } from '../../src/i18n';
const PRIVACY_POLICY_HREF = 'https://github.com/nexu-io/open-design/blob/main/PRIVACY.md';
function renderModal(overrides?: { onShare?: () => void; onDecline?: () => void }) {
const onShare = overrides?.onShare ?? vi.fn();
const onDecline = overrides?.onDecline ?? vi.fn();
render(
<I18nProvider initial="en">
<PrivacyConsentModal onShare={onShare} onDecline={onDecline} />
</I18nProvider>,
);
return { onShare, onDecline };
}
describe('PrivacyConsentModal', () => {
afterEach(cleanup);
it('labels the affirmative action as a consent choice, not "Help improve"', () => {
renderModal();
expect(screen.getByRole('button', { name: 'Share usage data' })).toBeTruthy();
expect(screen.getByRole('button', { name: "Don't share" })).toBeTruthy();
// The old label gave no signal that this was a privacy consent decision.
expect(screen.queryByRole('button', { name: 'Help improve' })).toBeNull();
});
it('keeps the accept and decline buttons equal-prominence (EDPB/GDPR)', () => {
renderModal();
const share = screen.getByRole('button', { name: 'Share usage data' });
const decline = screen.getByRole('button', { name: "Don't share" });
// Identical class lists — neither button is styled as primary/secondary.
expect(share.className).toBe(decline.className);
expect(share.className).toContain('privacy-consent-action');
});
it('exposes the privacy policy via an obvious external link', () => {
renderModal();
const link = screen.getByRole('link', { name: /privacy policy/i });
expect(link.getAttribute('href')).toBe(PRIVACY_POLICY_HREF);
expect(link.getAttribute('target')).toBe('_blank');
expect(link.getAttribute('rel') ?? '').toContain('noopener');
});
it('invokes the matching handler when each action is clicked', () => {
const { onShare, onDecline } = renderModal();
fireEvent.click(screen.getByRole('button', { name: 'Share usage data' }));
expect(onShare).toHaveBeenCalledTimes(1);
expect(onDecline).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: "Don't share" }));
expect(onDecline).toHaveBeenCalledTimes(1);
expect(onShare).toHaveBeenCalledTimes(1);
});
});