Privacy policy, terms of service, cookie policy + a cookie notice

Three plain-language documents in both app languages (lib/legal.ts, rendered
by components/LegalPage.tsx): /privacy describes the local-first storage, the
key never leaving the browser and the three opt-in server copies (sync,
reminders, reports); /terms covers the GPL-3.0-only software, your own Ollama
key, fair use and liability; /cookies states what is true — no cookies at all
(no Set-Cookie, no document.cookie), strictly-necessary local storage instead,
no tracking.

A cookie notice tells every new browser the same thing once and remembers the
dismissal (freezmo.consent.v1). It sits at z-44: below the immersive study
feeds so it can never cover a session, above the phone tab bar. Settings links
all three documents. The regression suite checks the pages render with real
sections and that dismissing the notice sticks.

Version 0.1.7, SW -> freezmo-v53.
This commit is contained in:
Martin Dvořák 2026-10-12 21:26:55 +00:00
commit 464781a7fd
13 changed files with 583 additions and 2 deletions

View file

@ -1551,6 +1551,22 @@ Things I chose while building, with the reasoning:
(one fixture per format, including a text PDF and a scanned PDF), 15/15 language preservation,
24/24 activity/scoring/feed checks and 11/11 real-model browser checks.
## Legal pages
Freezmo ships its own plain-language documents, in both app languages, rendered by
`components/LegalPage.tsx` from `lib/legal.ts`:
| Page | What it says |
| --- | --- |
| `/privacy` | Local-first storage, the key never leaving the browser, the three opt-in server copies (sync, reminders, reports), what the server does *not* store, and the third parties (Ollama, your browser's push service). |
| `/terms` | Free software under GPL-3.0-only with no warranty, your own Ollama key and its limits, fair use of the instance, your content, availability and liability. |
| `/cookies` | No cookies at all (verified: no `Set-Cookie`, no `document.cookie`), what the strictly-necessary local storage holds, no tracking, and how to clear it. |
A cookie notice (`components/CookieConsent.tsx`) tells every new browser the same thing once and
remembers the dismissal (`freezmo.consent.v1`). It sits below the immersive feeds in the stacking
order, so it can never cover a study session, and above the phone tab bar. Settings links all three
documents. The documents describe what the code actually does — keep them in step with it.
## License
Freezmo is free software, licensed under the **GNU General Public License, version 3 only**

20
app/cookies/page.tsx Normal file
View file

@ -0,0 +1,20 @@
import { AppHeader } from "@/components/AppHeader";
import { LegalPage } from "@/components/LegalPage";
import { Sidebar, SidebarOffset } from "@/components/Sidebar";
export const metadata = {
title: "Cookie policy",
};
export default function CookiesPage() {
return (
<div className="flex min-h-dvh">
<Sidebar />
<SidebarOffset />
<main className="flex min-h-dvh min-w-0 flex-1 flex-col">
<AppHeader />
<LegalPage slug="cookies" />
</main>
</div>
);
}

View file

@ -1,6 +1,7 @@
import type { Metadata, Viewport } from "next";
import { IceBackdrop } from "@/components/IceBackdrop";
import { CookieConsent } from "@/components/CookieConsent";
import { InstallSheet } from "@/components/InstallSheet";
import { OfflineBanner } from "@/components/OfflineBanner";
import { ServiceWorkerRegistrar } from "@/components/ServiceWorkerRegistrar";
@ -104,6 +105,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
{children}
<InstallSheet />
<OfflineBanner />
<CookieConsent />
<Toaster />
<ServiceWorkerRegistrar />
</body>

20
app/privacy/page.tsx Normal file
View file

@ -0,0 +1,20 @@
import { AppHeader } from "@/components/AppHeader";
import { LegalPage } from "@/components/LegalPage";
import { Sidebar, SidebarOffset } from "@/components/Sidebar";
export const metadata = {
title: "Privacy policy",
};
export default function PrivacyPage() {
return (
<div className="flex min-h-dvh">
<Sidebar />
<SidebarOffset />
<main className="flex min-h-dvh min-w-0 flex-1 flex-col">
<AppHeader />
<LegalPage slug="privacy" />
</main>
</div>
);
}

20
app/terms/page.tsx Normal file
View file

@ -0,0 +1,20 @@
import { AppHeader } from "@/components/AppHeader";
import { LegalPage } from "@/components/LegalPage";
import { Sidebar, SidebarOffset } from "@/components/Sidebar";
export const metadata = {
title: "Terms of service",
};
export default function TermsPage() {
return (
<div className="flex min-h-dvh">
<Sidebar />
<SidebarOffset />
<main className="flex min-h-dvh min-w-0 flex-1 flex-col">
<AppHeader />
<LegalPage slug="terms" />
</main>
</div>
);
}

View file

@ -0,0 +1,62 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useI18n } from "@/lib/i18n";
const STORAGE_KEY = "freezmo.consent.v1";
/**
* The cookie notice. There are no cookies (see /cookies), so this is an
* honest "here is what we store in your browser" line, dismissed once and
* remembered. It sits below the immersive feeds in the stack (z-44) so it can
* never cover a study session, and above the phone tab bar.
*/
export function CookieConsent() {
const { t } = useI18n();
const [visible, setVisible] = useState(false);
useEffect(() => {
try {
setVisible(window.localStorage.getItem(STORAGE_KEY) === null);
} catch {
/* private mode: no storage, no banner */
}
}, []);
if (!visible) return null;
const dismiss = () => {
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify({ at: Date.now() }));
} catch {
/* private mode */
}
setVisible(false);
};
return (
<div className="pointer-events-none fixed inset-x-0 bottom-[calc(env(safe-area-inset-bottom,0px)+5.25rem)] z-[44] flex justify-center px-4 sm:bottom-6">
<div
role="region"
aria-label={t("consent.title")}
className="card pointer-events-auto flex max-w-xl flex-wrap items-center gap-3 p-3"
>
<p className="min-w-0 flex-1 text-[12.5px] leading-snug text-[var(--text-muted)]">
{t("consent.body")}{" "}
<Link href="/cookies" className="font-semibold text-[var(--text)] underline decoration-dotted">
{t("consent.link")}
</Link>
</p>
<button
type="button"
onClick={dismiss}
className="btn-primary touch-target h-10 shrink-0 rounded-xl px-4 text-[13px] font-semibold"
>
{t("consent.ok")}
</button>
</div>
</div>
);
}

68
components/LegalPage.tsx Normal file
View file

@ -0,0 +1,68 @@
"use client";
import Link from "next/link";
import { useI18n } from "@/lib/i18n";
import { LEGAL, type LegalSlug } from "@/lib/legal";
/**
* Renders one legal document in the current language (the text lives in
* lib/legal.ts, both languages side by side so they cannot drift apart).
*/
export function LegalPage({ slug }: { slug: LegalSlug }) {
const { locale, t } = useI18n();
const document = LEGAL[slug][locale === "cs" ? "cs" : "en"];
return (
<main className="frost-bg min-h-dvh px-4 pb-tabbar lg:px-10 lg:pb-16 lg:pt-10">
<article className="mx-auto w-full max-w-2xl py-8">
<p className="text-[11px] font-semibold uppercase tracking-wider text-[var(--text-subtle)]">
{t("legal.updated", { when: document.updated })}
</p>
<h1 className="mt-1 text-[26px] font-semibold leading-tight tracking-[-0.02em] text-[var(--text)]">
{document.title}
</h1>
<p className="mt-3 text-[15px] leading-relaxed text-[var(--text-muted)]">{document.intro}</p>
<div className="mt-6 space-y-5">
{document.sections.map((section) => (
<section key={section.heading} className="card space-y-2 p-4 sm:p-5">
<h2 className="text-[15px] font-semibold text-[var(--text)]">{section.heading}</h2>
{section.paragraphs.map((paragraph) => (
<p key={paragraph} className="text-[14px] leading-relaxed text-[var(--text-muted)]">
{paragraph}
</p>
))}
{section.list ? (
<ul className="list-disc space-y-1.5 pl-5 text-[14px] leading-relaxed text-[var(--text-muted)]">
{section.list.map((entry) => (
<li key={entry}>{entry}</li>
))}
</ul>
) : null}
</section>
))}
</div>
<p className="mt-6 text-[12px] text-[var(--text-subtle)]">{document.footer}</p>
<nav className="mt-6 flex flex-wrap gap-3 text-[13px] font-semibold">
{(["privacy", "terms", "cookies"] as const)
.filter((other) => other !== slug)
.map((other) => (
<Link
key={other}
href={`/${other}`}
className="rounded-xl border border-[var(--hairline)] bg-[var(--surface-sunken)] px-3 py-2 text-[var(--text)] transition hover:bg-[var(--hover)]"
>
{t(`legal.${other}`)}
</Link>
))}
<Link href="/" className="rounded-xl px-3 py-2 text-[var(--text-muted)] underline decoration-dotted">
{t("feed.back")}
</Link>
</nav>
</article>
</main>
);
}

View file

@ -1,5 +1,6 @@
"use client";
import Link from "next/link";
import { AnimatePresence, motion, useDragControls, useReducedMotion } from "framer-motion";
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
@ -529,6 +530,24 @@ export function SettingsDialog({
/>
) : null}
</dl>
{/* Legal: the same three documents the cookie banner
links to, always reachable from Settings. */}
<div className="pt-3">
<p className="text-[11px] font-semibold uppercase tracking-wider text-[var(--text-subtle)]">
{t("settings.legal")}
</p>
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-1 text-[12.5px] font-semibold">
<Link href="/privacy" className="text-[var(--text)] underline decoration-dotted">
{t("legal.privacy")}
</Link>
<Link href="/terms" className="text-[var(--text)] underline decoration-dotted">
{t("legal.terms")}
</Link>
<Link href="/cookies" className="text-[var(--text)] underline decoration-dotted">
{t("legal.cookies")}
</Link>
</div>
</div>
{health?.mode === "direct-cloud" && health.needsKey ? (
<p className="pt-2 text-[11px] leading-relaxed text-[var(--text-subtle)]">
{t("health.createKey.before")}{" "}
@ -808,6 +827,19 @@ export function SettingsDialog({
</p>
) : null}
{/* Legal: the same three documents the cookie banner links to. */}
<div className="mt-3 flex flex-wrap gap-x-3 gap-y-1 text-[12px] font-semibold">
<Link href="/privacy" className="text-[var(--text-muted)] underline decoration-dotted">
{t("legal.privacy")}
</Link>
<Link href="/terms" className="text-[var(--text-muted)] underline decoration-dotted">
{t("legal.terms")}
</Link>
<Link href="/cookies" className="text-[var(--text-muted)] underline decoration-dotted">
{t("legal.cookies")}
</Link>
</div>
{/* keeps real content out of the 36px fade strip at the bottom */}
<div className="h-9" aria-hidden />
</>

View file

@ -997,6 +997,15 @@ const en: Record<string, string> = {
"chat.savedToNotes": "Saved to {deck}'s notes",
"chat.savedToNotesTrimmed": "Saved to {deck}'s notes (trimmed to fit)",
"chat.savedFromChat": "Saved from chat",
"legal.updated": "Last updated {when}",
"legal.privacy": "Privacy policy",
"legal.terms": "Terms of service",
"legal.cookies": "Cookie policy",
"settings.legal": "Legal",
"consent.title": "About cookies",
"consent.body": "Freezmo uses no cookies and no trackers — your decks, homework and settings stay in this browser.",
"consent.link": "Cookie policy",
"consent.ok": "Got it",
"settings.version": "Version",
"settings.chatNotes": "Keep notes text for chat",
"settings.chatNotesNote": "Chat answers from the notes you added (stored with each deck). Turn off to use only the generated study content.",
@ -1940,6 +1949,15 @@ const cs: Record<string, string> = {
"chat.savedToNotes": "Uloženo do poznámek sady {deck}",
"chat.savedToNotesTrimmed": "Uloženo do poznámek sady {deck} (zkráceno, aby se vešlo)",
"chat.savedFromChat": "Uloženo z chatu",
"legal.updated": "Naposledy upraveno {when}",
"legal.privacy": "Zásady soukromí",
"legal.terms": "Podmínky použití",
"legal.cookies": "Zásady cookies",
"settings.legal": "Právní informace",
"consent.title": "O cookies",
"consent.body": "Freezmo nepoužívá cookies ani sledování — sady, úkoly a nastavení zůstávají v tomto prohlížeči.",
"consent.link": "Zásady cookies",
"consent.ok": "Rozumím",
"settings.version": "Verze",
"settings.chatNotes": "Uchovávat text poznámek pro chat",
"settings.chatNotesNote": "Chat odpovídá z poznámek, které jsi přidal (jsou uložené u každé sady). Vypnutím bude používat jen vygenerovaný obsah.",

296
lib/legal.ts Normal file
View file

@ -0,0 +1,296 @@
/**
* Privacy policy, terms of service and cookie policy — in both app languages.
*
* Written to describe what this app *actually* does (local-first storage, the
* key never leaving the browser, opt-in server copies), not a template. The
* pages that render this live in app/privacy, app/terms and app/cookies; the
* cookie banner links to the same text. Keep them in step with the code.
*/
export type LegalSection = {
heading: string;
paragraphs: string[];
list?: string[];
};
export type LegalDocument = {
title: string;
updated: string;
intro: string;
sections: LegalSection[];
footer: string;
};
const UPDATED = "October 2026";
const PRIVACY_EN: LegalDocument = {
title: "Privacy policy",
updated: UPDATED,
intro:
"Freezmo is a self-hosted study app with no accounts. This page describes exactly what happens to your data — nothing here is boilerplate.",
sections: [
{
heading: "Everything lives in your browser",
paragraphs: [
"Your decks, folders, homework, chats, study history and settings are stored in this browser (localStorage and IndexedDB). Nothing is uploaded unless you switch on one of the opt-ins below.",
"Clearing the site data deletes it. Settings → Export all data gives you a complete copy at any time.",
],
},
{
heading: "Your Ollama API key",
paragraphs: [
"The key is kept in this browser only. It is sent to this Freezmo server for the single model call you asked for, forwarded to Ollama, and forgotten: never written to disk, never logged, never part of a backup, sync payload or share link.",
],
},
{
heading: "What the server stores — only if you opt in",
paragraphs: ["There is no other server-side data. Each of these is off until you turn it on:"],
list: [
"Sync — an encrypted blob the server cannot read (the key stays in your browser). Revocable from Settings → Delete synced data.",
"Reminders — the push endpoint your browser gave us, its keys and a timestamp. No personal data, no history.",
"Reported questions — the question text, the deck and activity ids and a timestamp. These are how bad AI cards get fixed; they are the one thing an operator can read.",
],
},
{
heading: "What the server does not store",
paragraphs: [
"No accounts, no email addresses, no analytics, no cookies, no tracking. The rate limiter keeps a client IP address in memory for a few minutes to stop floods and never writes it to disk.",
],
},
{
heading: "Third parties",
paragraphs: [
"Model calls go to the Ollama endpoint this instance is configured for, using your key — Ollama's own privacy terms apply to them. Reminders travel through your browser's push service (Google, Mozilla or Apple). There is nothing else: no ads, no analytics, no embeds.",
],
},
{
heading: "Your choices",
paragraphs: [
"Delete the local copy by clearing the site data (or Settings). Revoke the synced copy from Settings. Turn reminders off from Settings. If you want a reported question removed from the operator's file, ask the person running this instance — it is their file.",
],
},
],
footer: "Plain-language description of how this app handles data — not legal advice.",
};
const PRIVACY_CS: LegalDocument = {
title: "Zásady ochrany soukromí",
updated: "říjen 2026",
intro:
"Freezmo je samoobslužná studijní aplikace bez účtů. Tahle stránka popisuje, co se přesně děje s tvými daty — nic z toho není šablona.",
sections: [
{
heading: "Všechno zůstává v tvém prohlížeči",
paragraphs: [
"Sady, složky, úkoly, chaty, historie učení i nastavení jsou uložené v tomto prohlížeči (localStorage a IndexedDB). Nic se neodesílá, dokud nezapneš některou z níže uvedených voleb.",
"Smazáním dat stránky je smažeš. Nastavení → Exportovat všechna data ti kdykoli dá úplnou kopii.",
],
},
{
heading: "Tvůj API klíč Ollama",
paragraphs: [
"Klíč je uložený jen v tomto prohlížeči. Posílá se tomuto serveru Freezmo pro jeden požadovaný modelový dotaz, server ho předá Ollamě a zapomene ho: nikdy se neukládá na disk, neloguje se a není součástí zálohy, synchronizace ani sdíleného odkazu.",
],
},
{
heading: "Co server ukládá — jen když to zapneš",
paragraphs: ["Jiná data na serveru nejsou. Každá z těchhle věcí je vypnutá, dokud ji nezapneš:"],
list: [
"Synchronizace — zašifrovaný blob, který server nedokáže přečíst (klíč zůstává v prohlížeči). Zrušitelný v Nastavení → Smazat synchronizovaná data.",
"Připomínky — push endpoint, který ti dal prohlížeč, jeho klíče a časové razítko. Žádná osobní data, žádná historie.",
"Nahlášené otázky — text otázky, ID sady a aktivity a časové razítko. Díky nim se opravují špatné AI kartičky; je to jediná věc, kterou provozovatel může číst.",
],
},
{
heading: "Co server neukládá",
paragraphs: [
"Žádné účty, e-maily, analytiku, cookies ani sledování. Omezovač rychlosti drží IP adresu klienta pár minut v paměti, aby zastavil záplavy požadavků, a nikdy ji nezapisuje na disk.",
],
},
{
heading: "Třetí strany",
paragraphs: [
"Modelové dotazy jdou na Ollama endpoint, který je tato instance nastavená používat, a to s tvým klíčem — platí na ně vlastní podmínky Ollamy. Připomínky putují přes push službu tvého prohlížeče (Google, Mozilla nebo Apple). Nic jiného tu není: žádné reklamy, analytika ani vložený obsah.",
],
},
{
heading: "Tvoje volby",
paragraphs: [
"Lokální kopii smažeš smazáním dat stránky (nebo v Nastavení). Synchronizovanou kopii zrušíš v Nastavení. Připomínky vypneš v Nastavení. Pokud chceš, aby provozovatel odstranil nahlášenou otázku ze svého souboru, řekni mu to — ten soubor je jeho.",
],
},
],
footer: "Popis v lidské řeči, jak aplikace nakládá s daty — není právní rada.",
};
const TERMS_EN: LegalDocument = {
title: "Terms of service",
updated: UPDATED,
intro:
"Freezmo is free software (GPL-3.0-only) that someone is hosting for you — usually a friend or yourself. These are the house rules for using that instance.",
sections: [
{
heading: "The software comes as-is",
paragraphs: [
"Freezmo is provided under the GNU General Public License v3 only, without warranty of any kind to the extent the law allows. AI output can be wrong: read it before you hand it in — that is what the note under every card is for.",
],
},
{
heading: "Your key, your usage",
paragraphs: [
"You bring your own Ollama API key. Limits, costs and account standing are between you and Ollama. Never paste a key you do not own, and keep your own key to yourself.",
],
},
{
heading: "Fair use of the instance",
paragraphs: [
"Don't flood it, don't try to break it and don't use it to attack anything else. The operator can rate-limit, block or shut the instance down at any time, and the app already refuses obvious abuse.",
],
},
{
heading: "Your content",
paragraphs: [
"Your notes, decks, homework and chats are yours. They stay in your browser, so the operator cannot read them — the only exception is a question you report on purpose (see the privacy policy).",
],
},
{
heading: "Availability and changes",
paragraphs: [
"This instance is a courtesy, with no uptime promise. The app and these terms may change; the current version always lives in the app, linked from Settings.",
],
},
{
heading: "Liability",
paragraphs: [
"To the extent permitted by law, the operator and the authors are not liable for anything this app does or fails to do — including lost study time, lost data or a wrong answer in your homework.",
],
},
],
footer: "Plain-language terms for a self-hosted app — not legal advice.",
};
const TERMS_CS: LegalDocument = {
title: "Podmínky použití",
updated: "říjen 2026",
intro:
"Freezmo je svobodný software (GPL-3.0-only), který ti někdo hostuje — většinou kamarád nebo ty sám. Tohle jsou pravidla pro používání téhle instance.",
sections: [
{
heading: "Software je tak, jak je",
paragraphs: [
"Freezmo se poskytuje pod licencí GNU General Public License v3 only, bez jakékoli záruky v rozsahu, který zákon dovoluje. AI výstup může být špatně: než ho odevzdáš, přečti si ho — od toho je poznámka pod každou kartičkou.",
],
},
{
heading: "Tvůj klíč, tvoje spotřeba",
paragraphs: [
"Používáš vlastní API klíč Ollama. Limity, náklady a stav účtu jsou mezi tebou a Ollamou. Nikdy nevkládej klíč, který ti nepatří, a ten svůj nikomu nedávej.",
],
},
{
heading: "Férové používání instance",
paragraphs: [
"Nezahlcuj ji, nezkoušej ji rozbít a nepoužívej ji k útoku na něco jiného. Provozovatel může kdykoli omezit rychlost, zablokovat přístup nebo instanci vypnout; aplikace už sama odmítá zjevné zneužití.",
],
},
{
heading: "Tvůj obsah",
paragraphs: [
"Poznámky, sady, úkoly a chaty jsou tvoje. Zůstávají v tvém prohlížeči, takže je provozovatel nemůže číst — jediná výjimka je otázka, kterou sám nahlásíš (viz zásady soukromí).",
],
},
{
heading: "Dostupnost a změny",
paragraphs: [
"Instance je zdvořilost, bez slibu dostupnosti. Aplikace i tyhle podmínky se mohou měnit; aktuální verze je vždy v aplikaci, odkaz v Nastavení.",
],
},
{
heading: "Odpovědnost",
paragraphs: [
"V rozsahu, který zákon dovoluje, provozovatel ani autoři neodpovídají za nic, co aplikace udělá nebo neudělá — včetně ztraceného času na učení, ztracených dat nebo špatné odpovědi v úkolu.",
],
},
],
footer: "Podmínky v lidské řeči pro samoobslužnou aplikaci — nejsou právní rada.",
};
const COOKIES_EN: LegalDocument = {
title: "Cookie policy",
updated: UPDATED,
intro: "Short version: Freezmo sets no cookies at all. This page exists because the law asks for one, and because it is worth being explicit.",
sections: [
{
heading: "No cookies",
paragraphs: [
"The server sends no Set-Cookie header and the app never touches document.cookie. There are no third-party embeds, no pixels and no tag managers.",
],
},
{
heading: "Local storage instead",
paragraphs: [
"The app keeps your data in localStorage and IndexedDB — the same idea as a cookie, except it is never sent to the server automatically. It is strictly necessary for the app to work (it *is* the app's storage), which is why it does not need consent; the banner only tells you it happens.",
],
list: [
"Settings: language, theme, daily goal, haptics, chat notes preference.",
"Content: decks, folders, homework, chats, study history and schedule.",
"Bookkeeping: the sync id, the last-synced time, which questions you reported, whether you dismissed this banner.",
],
},
{
heading: "No tracking, no analytics, no ads",
paragraphs: ["Nothing measures you, and nothing is sold. The only counters the app keeps are the rate limits on the server, held in memory."],
},
{
heading: "Clearing it",
paragraphs: [
"Clear the site data in your browser to wipe the local copy, or use Settings → Export all data first if you want a backup. The opt-in server copies (sync, reminders, reports) are described in the privacy policy.",
],
},
],
footer: "Plain-language description of what this app stores in your browser — not legal advice.",
};
const COOKIES_CS: LegalDocument = {
title: "Zásady používání cookies",
updated: "říjen 2026",
intro: "Krátce: Freezmo nepoužívá žádné cookies. Tahle stránka existuje proto, že to zákon vyžaduje — a protože je dobré to říct napřímo.",
sections: [
{
heading: "Žádné cookies",
paragraphs: [
"Server neposílá hlavičku Set-Cookie a aplikace se nikdy nedotkne document.cookie. Nejsou tu žádné vložené prvky třetích stran, pixely ani správci značek.",
],
},
{
heading: "Místo toho lokální úložiště",
paragraphs: [
"Aplikace drží tvá data v localStorage a IndexedDB — stejná myšlenka jako cookie, jen se nikdy neposílají serveru automaticky. Je to nezbytné pro fungování aplikace (je to *její* úložiště), a proto to nepotřebuje souhlas; banner ti to jen oznamuje.",
],
list: [
"Nastavení: jazyk, téma, denní cíl, haptika, preference poznámek pro chat.",
"Obsah: sady, složky, úkoly, chaty, historie a plán učení.",
"Evidence: sync ID, čas poslední synchronizace, které otázky jsi nahlásil, jestli jsi zavřel tenhle banner.",
],
},
{
heading: "Žádné sledování, analytika ani reklamy",
paragraphs: ["Nic tě neměří a nic se neprodává. Jediné počítadla jsou limity rychlosti na serveru, držené v paměti."],
},
{
heading: "Jak to smazat",
paragraphs: [
"Smaž data stránky v prohlížeči, nebo si nejdřív udělej kopii přes Nastavení → Exportovat všechna data. Volitelné kopie na serveru (synchronizace, připomínky, hlášení) popisují zásady soukromí.",
],
},
],
footer: "Popis v lidské řeči, co aplikace ukládá v prohlížeči — není právní rada.",
};
export type LegalSlug = "privacy" | "terms" | "cookies";
export const LEGAL: Record<LegalSlug, { en: LegalDocument; cs: LegalDocument }> = {
privacy: { en: PRIVACY_EN, cs: PRIVACY_CS },
terms: { en: TERMS_EN, cs: TERMS_CS },
cookies: { en: COOKIES_EN, cs: COOKIES_CS },
};

View file

@ -1,6 +1,6 @@
{
"name": "freezmo",
"version": "0.1.6",
"version": "0.1.7",
"private": true,
"license": "GPL-3.0-only",
"scripts": {

View file

@ -9,7 +9,7 @@
*
* Bumping CACHE_VERSION is how a new deploy invalidates old assets.
*/
const CACHE_VERSION = "freezmo-v52";
const CACHE_VERSION = "freezmo-v53";
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const ASSET_CACHE = `${CACHE_VERSION}-assets`;

View file

@ -111,5 +111,32 @@ const browser = await launchBrowser();
await context.close();
}
/* 5. legal pages + the cookie notice --------------------------------- */
{
const { context, page } = await makePage(browser);
await seed(page, { decks: [] });
for (const [path, heading] of [
["/privacy", "Privacy policy"],
["/terms", "Terms of service"],
["/cookies", "Cookie policy"],
]) {
await page.goto(`${BASE}${path}`, { waitUntil: "networkidle" });
await page.waitForTimeout(600);
check(`legal: ${path} renders`, await page.getByRole("heading", { name: heading, level: 1 }).isVisible());
}
check("legal: the pages carry real sections", (await page.locator("article section").count()) >= 3);
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForTimeout(700);
const notice = page.getByRole("region", { name: "About cookies" });
check("consent: the notice shows once", (await notice.count()) === 1);
await page.getByRole("button", { name: "Got it" }).click();
await page.waitForTimeout(400);
await page.reload({ waitUntil: "networkidle" });
await page.waitForTimeout(800);
check("consent: dismissing is remembered", (await notice.count()) === 0);
await context.close();
}
await browser.close();
finish();