Legal document index with in-app reader view.
import * as React from "react";
import { ScrollView, View } from "react-native";
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
import { Glass, Icon, List, Text, type IconName } from "@lumioui/ui";
import { BLOCK_STAGGER, Mount, Press, RowIcon, SEPARATOR_INSET, type IconTone, } from "../shared";
export interface LegalDocument {
id: string;
title: string;
updated: string;
/** Rendered document body. */
body: string;
/** e.g. "4 min read" — shown after `updated` in the row subtitle. */
readTime?: string;
/** e.g. "Accepted Sep 2" — renders a green status chip on the row. */
accepted?: string;
}
export interface TermsPrivacyScreenProps {
documents?: LegalDocument[];
/** Set to open directly into a document instead of the index. */
initialDocument?: string;
onOpenDocument?: (id: string) => void;
onBack?: () => void;
}
const DEFAULT_DOCUMENTS: LegalDocument[] = [
{
id: "terms",
title: "Terms of Service",
updated: "Updated Sep 1, 2026",
readTime: "4 min read",
accepted: "Accepted Sep 2",
body: `1. Acceptance of terms
By creating an account or using Lumio, you agree to these Terms of Service. If you do not agree, do not use the service. We may update these terms; material changes are announced 30 days in advance.
2. Your account
You are responsible for activity under your account and for keeping your credentials confidential. You must be at least 13 years old.
3. Acceptable use
You may not misuse the service — no scraping, no interfering with infrastructure, no unlawful content. We may suspend accounts that violate these rules.
4. Content you share
You retain ownership of content you post. You grant Lumio a limited license to host and display it so the service can function.
5. Termination
You can delete your account at any time from Settings. We may terminate access for violations with notice where required.`,
},
{
id: "privacy",
title: "Privacy Policy",
updated: "Updated Aug 18, 2026",
readTime: "3 min read",
accepted: "Accepted Sep 2",
body: `What we collect
Account details (name, email), content you create, and usage data such as feature interactions and crash reports. We do not sell personal data.
How we use it
To operate and improve the service, personalize your experience, and keep the platform secure.
Sharing
We share data with processors under contract (hosting, analytics) and when legally required — never with advertisers.
Your rights
Access, correct, export, or delete your data from Settings → Privacy. Contact privacy@lumio.dev for anything else.`,
},
{
id: "cookies",
title: "Cookie Policy",
updated: "Updated Jul 30, 2026",
readTime: "2 min read",
accepted: "Accepted Sep 2",
body: `What cookies we use
Strictly necessary cookies keep you signed in and remember your preferences. Analytics cookies help us understand which features are used. We do not use advertising cookies.
Managing cookies
You can disable non-essential cookies in Settings → Privacy → Cookies, or through your browser controls. Disabling some cookies may degrade sign-in and personalization.`,
},
{
id: "licenses",
title: "Open-source licenses",
updated: "Updated Jun 12, 2026",
readTime: "6 min read",
accepted: "Accepted Sep 2",
body: `Lumio is built on open source
React Native (MIT), Reanimated (MIT), Expo (MIT), NativeWind (MIT), React (MIT), and the broader ecosystem their maintainers keep alive.
Full texts
Complete license texts ship inside the app bundle and are listed per dependency. If a license seems misapplied, contact legal@lumio.dev.`,
},
{
id: "dpa",
title: "Data Processing Addendum",
updated: "Updated Aug 18, 2026",
readTime: "5 min read",
body: `Scope
This addendum governs personal data Lumio processes on behalf of workspace administrators — member profiles, content, and operational logs.
Our commitments
Processing only on documented instructions, subprocessors under equivalent terms, 30-day breach notification, and deletion or return of data on termination.`,
},
];
const DOC_ICONS: Record<string, { icon: IconName; tone: IconTone }> = {
terms: { icon: "file-text", tone: "blue" },
privacy: { icon: "shield", tone: "green" },
cookies: { icon: "info", tone: "orange" },
licenses: { icon: "book-open", tone: "purple" },
dpa: { icon: "lock", tone: "teal" },
};
const LAST_UPDATE_BULLETS = [
"Clarified how workspace content is licensed",
"Added a plain-language data sharing section",
"New cookie controls in Settings → Privacy",
];
/** Terms & privacy — document index + readable legal text view. */
export function TermsPrivacyScreen({
documents = DEFAULT_DOCUMENTS,
initialDocument,
onOpenDocument,
onBack,
}: TermsPrivacyScreenProps) {
const insets = useSafeAreaInsets();
const [openId, setOpenId] = React.useState<string | undefined>(initialDocument);
const open = documents.find((d) => d.id === openId);
return (
<SafeAreaView className="flex-1 bg-[#F4F5F9] dark:bg-[#111114]" edges={["top"]}>
{/* Floating chrome — back disc + centered title pill. */}
<View className="absolute inset-x-4 z-10" style={{ top: insets.top + 8 }} pointerEvents="box-none">
<View className="flex-row items-center gap-2">
<Glass
effect="regular"
className="h-11 w-11 items-center justify-center rounded-full"
>
<Press
onPress={() => (open ? setOpenId(undefined) : onBack?.())}
accessibilityLabel="Go back"
accessibilityRole="button"
className="h-9 w-9 items-center justify-center"
>
<Icon name="chevron-left" size="md" color="foreground" />
</Press>
</Glass>
</View>
<View pointerEvents="none" className="absolute inset-0 items-center justify-center">
<Glass
effect="regular"
className="h-11 flex-row items-center justify-center rounded-full px-5"
>
<Text accessibilityRole="header" className="text-sm font-bold tracking-tight">
{open ? open.title : "Terms & privacy"}
</Text>
</Glass>
</View>
</View>
{open ? (
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{
paddingTop: insets.top + 60,
paddingBottom: insets.bottom + 32,
paddingHorizontal: 24,
}}
>
<Text variant="small" className="pb-4 text-muted-foreground">
{open.updated}
</Text>
<Text variant="small" className="leading-6 text-foreground">
{open.body}
</Text>
</ScrollView>
) : (
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingTop: insets.top + 60, paddingBottom: insets.bottom + 32 }}
>
{/* "What changed" hero — ink card with lime checks, diff highlights
of the latest update so the index opens with substance. */}
<Mount>
<View className="mx-4 mb-4 overflow-hidden rounded-3xl bg-[#1B1B26]">
<View className="gap-3 p-5">
<View className="flex-row items-center gap-2">
<Icon name="zap" size="sm" color="#BEF04C" />
<Text variant="small" className="font-semibold uppercase tracking-widest text-white/55">
What changed in Sep
</Text>
</View>
<Text className="text-2xl font-bold tracking-tight text-white">
Terms, simplified
</Text>
{LAST_UPDATE_BULLETS.map((b) => (
<View key={b} className="flex-row items-center gap-2.5">
<View className="h-5 w-5 items-center justify-center rounded-full bg-[#BEF04C]">
<Icon name="check" size="sm" color="#1B1B26" />
</View>
<Text variant="small" className="flex-1 text-white/80">
{b}
</Text>
</View>
))}
<Press
haptic="light"
pressedScale={0.97}
className="mt-1 flex-row items-center gap-1.5 self-start rounded-full bg-white/10 px-3.5 py-2"
onPress={() => setOpenId("terms")}
accessibilityRole="button"
accessibilityLabel="Read the updated terms"
>
<Text variant="small" className="font-semibold text-[#BEF04C]">
Read the update
</Text>
<Icon name="chevron-right" size="sm" color="#BEF04C" />
</Press>
</View>
</View>
</Mount>
<Mount delay={BLOCK_STAGGER}>
<List inset>
<List.Section>
{documents.map((doc, ii) => (
<React.Fragment key={doc.id}>
{ii > 0 && <List.Separator className={SEPARATOR_INSET} />}
<List.Item
title={doc.title}
subtitle={[doc.updated, doc.readTime].filter(Boolean).join(" · ")}
leading={
<RowIcon
name={DOC_ICONS[doc.id]?.icon ?? "file-text"}
tone={DOC_ICONS[doc.id]?.tone ?? "gray"}
/>
}
trailing={
doc.accepted ? (
<View className="flex-row items-center gap-1 rounded-full bg-success/15 px-2 py-1">
<Icon name="check" size="sm" color="success" />
<Text className="text-[11px] font-medium text-success">
{doc.accepted}
</Text>
</View>
) : undefined
}
showChevron
haptic="light"
onPress={() => {
setOpenId(doc.id);
onOpenDocument?.(doc.id);
}}
/>
</React.Fragment>
))}
</List.Section>
</List>
<Text variant="small" className="px-8 pt-2 text-muted-foreground">
Previous versions are available on request from legal@lumio.dev.
</Text>
{/* Doc footer — every real legal page ends with the feedback ask. */}
<View className="mx-4 mt-3 flex-row items-center justify-between overflow-hidden rounded-2xl bg-white px-4 py-3 dark:bg-[#1A1A20]">
<Text variant="small" className="text-muted-foreground">
Was this document clear?
</Text>
<View className="flex-row gap-5">
<Press haptic="light" accessibilityRole="button" accessibilityLabel="Yes, clear">
<Text variant="small" className="font-medium text-primary">Yes</Text>
</Press>
<Press haptic="light" accessibilityRole="button" accessibilityLabel="No, unclear">
<Text variant="small" className="font-medium text-muted-foreground">No</Text>
</Press>
</View>
</View>
</Mount>
</ScrollView>
)}
</SafeAreaView>
);
}
TermsPrivacyScreen.displayName = "TermsPrivacyScreen";
Recreate the "Terms & privacy Screen" screen in React Native using LumioUI.
Goal: Legal document index with in-app reader view. Match the reference implementation's layout, behavior, and motion exactly — this is a production screen, not a mockup.
## Setup
Fastest path — install the screen plus its component deps:
```bash
npx lumioui add core-terms-privacy
```
Or pull just the components: `npx lumioui add button icon list text`
Native packages:
- @lumioui/ui@latest
- react-native-safe-area-context@*
- @lumioui/core@latest
- react-native-reanimated@*
- react-native-svg@*
- nativewind@*
Stack assumptions: Expo SDK 54 / RN 0.81, Reanimated 4, NativeWind v4, TypeScript strict, Expo Go compatible.
## Components — how, where, when
### `button` — Tactile pill button with press spring, ink wash and async loading.
- imported but not rendered directly — likely used through a sibling or as a utility
### `icon` — Lucide icon set mapped to theme colors and sizes.
- `<Icon>` ×5
### `list` — Settings-style rows with separators and press feedback.
- `<List>` ×1
### `text` — Themed type scale from caption to display.
- `<Text>` ×12
## Conventions (non-negotiable)
- Styling via NativeWind `className` with semantic tokens only: `bg-background`, `text-foreground`, `bg-card`, `text-card-foreground`, `bg-secondary`, `bg-muted`, `text-muted-foreground`, `bg-accent`, `bg-destructive`, `border-border`. Hard-coded colors only inside media overlays (`text-white`, `bg-black/N`).
- Motion via the token presets from `@lumioui/core` (`spring.press`, `spring.surface`, `spring.glide`, `timing.fast`, `timing.slow`, …) — no bespoke spring configs or durations.
- Gate transform motion behind `useReducedMotion()`; keep opacity/color feedback, drop travel and overshoot.
- Haptics via `useHaptic()`: `light` on press, `selection` on toggle/select, `warning` on invalid input. Never import expo-haptics directly.
- Press feedback via `usePressAnimation()` (~0.97 scale), not `activeOpacity` tricks.
- a11y: `accessibilityRole` + `accessibilityLabel` on every control, touch targets ≥44pt.
- Entrance choreography: staggered `Mount` wrappers (delay 0/60/120…) so regions land in sequence instead of popping in at once.
- Wrap the screen in `SafeAreaView`; keep dark mode working via the semantic tokens.
## Deliverable
One file: `screens/core/terms-privacy.tsx` exporting TermsPrivacyScreen. Preserve the props interface — the host app wires the callbacks (navigation, submit handlers) through them.
## Reference implementation
The LumioUI source for this screen — reproduce it faithfully:
```tsx
import * as React from "react";
import { ScrollView, View } from "react-native";
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
import { Glass, Icon, List, Text, type IconName } from "@lumioui/ui";
import { BLOCK_STAGGER, Mount, Press, RowIcon, SEPARATOR_INSET, type IconTone, } from "../shared";
export interface LegalDocument {
id: string;
title: string;
updated: string;
/** Rendered document body. */
body: string;
/** e.g. "4 min read" — shown after `updated` in the row subtitle. */
readTime?: string;
/** e.g. "Accepted Sep 2" — renders a green status chip on the row. */
accepted?: string;
}
export interface TermsPrivacyScreenProps {
documents?: LegalDocument[];
/** Set to open directly into a document instead of the index. */
initialDocument?: string;
onOpenDocument?: (id: string) => void;
onBack?: () => void;
}
const DEFAULT_DOCUMENTS: LegalDocument[] = [
{
id: "terms",
title: "Terms of Service",
updated: "Updated Sep 1, 2026",
readTime: "4 min read",
accepted: "Accepted Sep 2",
body: `1. Acceptance of terms
By creating an account or using Lumio, you agree to these Terms of Service. If you do not agree, do not use the service. We may update these terms; material changes are announced 30 days in advance.
2. Your account
You are responsible for activity under your account and for keeping your credentials confidential. You must be at least 13 years old.
3. Acceptable use
You may not misuse the service — no scraping, no interfering with infrastructure, no unlawful content. We may suspend accounts that violate these rules.
4. Content you share
You retain ownership of content you post. You grant Lumio a limited license to host and display it so the service can function.
5. Termination
You can delete your account at any time from Settings. We may terminate access for violations with notice where required.`,
},
{
id: "privacy",
title: "Privacy Policy",
updated: "Updated Aug 18, 2026",
readTime: "3 min read",
accepted: "Accepted Sep 2",
body: `What we collect
Account details (name, email), content you create, and usage data such as feature interactions and crash reports. We do not sell personal data.
How we use it
To operate and improve the service, personalize your experience, and keep the platform secure.
Sharing
We share data with processors under contract (hosting, analytics) and when legally required — never with advertisers.
Your rights
Access, correct, export, or delete your data from Settings → Privacy. Contact privacy@lumio.dev for anything else.`,
},
{
id: "cookies",
title: "Cookie Policy",
updated: "Updated Jul 30, 2026",
readTime: "2 min read",
accepted: "Accepted Sep 2",
body: `What cookies we use
Strictly necessary cookies keep you signed in and remember your preferences. Analytics cookies help us understand which features are used. We do not use advertising cookies.
Managing cookies
You can disable non-essential cookies in Settings → Privacy → Cookies, or through your browser controls. Disabling some cookies may degrade sign-in and personalization.`,
},
{
id: "licenses",
title: "Open-source licenses",
updated: "Updated Jun 12, 2026",
readTime: "6 min read",
accepted: "Accepted Sep 2",
body: `Lumio is built on open source
React Native (MIT), Reanimated (MIT), Expo (MIT), NativeWind (MIT), React (MIT), and the broader ecosystem their maintainers keep alive.
Full texts
Complete license texts ship inside the app bundle and are listed per dependency. If a license seems misapplied, contact legal@lumio.dev.`,
},
{
id: "dpa",
title: "Data Processing Addendum",
updated: "Updated Aug 18, 2026",
readTime: "5 min read",
body: `Scope
This addendum governs personal data Lumio processes on behalf of workspace administrators — member profiles, content, and operational logs.
Our commitments
Processing only on documented instructions, subprocessors under equivalent terms, 30-day breach notification, and deletion or return of data on termination.`,
},
];
const DOC_ICONS: Record<string, { icon: IconName; tone: IconTone }> = {
terms: { icon: "file-text", tone: "blue" },
privacy: { icon: "shield", tone: "green" },
cookies: { icon: "info", tone: "orange" },
licenses: { icon: "book-open", tone: "purple" },
dpa: { icon: "lock", tone: "teal" },
};
const LAST_UPDATE_BULLETS = [
"Clarified how workspace content is licensed",
"Added a plain-language data sharing section",
"New cookie controls in Settings → Privacy",
];
/** Terms & privacy — document index + readable legal text view. */
export function TermsPrivacyScreen({
documents = DEFAULT_DOCUMENTS,
initialDocument,
onOpenDocument,
onBack,
}: TermsPrivacyScreenProps) {
const insets = useSafeAreaInsets();
const [openId, setOpenId] = React.useState<string | undefined>(initialDocument);
const open = documents.find((d) => d.id === openId);
return (
<SafeAreaView className="flex-1 bg-[#F4F5F9] dark:bg-[#111114]" edges={["top"]}>
{/* Floating chrome — back disc + centered title pill. */}
<View className="absolute inset-x-4 z-10" style={{ top: insets.top + 8 }} pointerEvents="box-none">
<View className="flex-row items-center gap-2">
<Glass
effect="regular"
className="h-11 w-11 items-center justify-center rounded-full"
>
<Press
onPress={() => (open ? setOpenId(undefined) : onBack?.())}
accessibilityLabel="Go back"
accessibilityRole="button"
className="h-9 w-9 items-center justify-center"
>
<Icon name="chevron-left" size="md" color="foreground" />
</Press>
</Glass>
</View>
<View pointerEvents="none" className="absolute inset-0 items-center justify-center">
<Glass
effect="regular"
className="h-11 flex-row items-center justify-center rounded-full px-5"
>
<Text accessibilityRole="header" className="text-sm font-bold tracking-tight">
{open ? open.title : "Terms & privacy"}
</Text>
</Glass>
</View>
</View>
{open ? (
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{
paddingTop: insets.top + 60,
paddingBottom: insets.bottom + 32,
paddingHorizontal: 24,
}}
>
<Text variant="small" className="pb-4 text-muted-foreground">
{open.updated}
</Text>
<Text variant="small" className="leading-6 text-foreground">
{open.body}
</Text>
</ScrollView>
) : (
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingTop: insets.top + 60, paddingBottom: insets.bottom + 32 }}
>
{/* "What changed" hero — ink card with lime checks, diff highlights
of the latest update so the index opens with substance. */}
<Mount>
<View className="mx-4 mb-4 overflow-hidden rounded-3xl bg-[#1B1B26]">
<View className="gap-3 p-5">
<View className="flex-row items-center gap-2">
<Icon name="zap" size="sm" color="#BEF04C" />
<Text variant="small" className="font-semibold uppercase tracking-widest text-white/55">
What changed in Sep
</Text>
</View>
<Text className="text-2xl font-bold tracking-tight text-white">
Terms, simplified
</Text>
{LAST_UPDATE_BULLETS.map((b) => (
<View key={b} className="flex-row items-center gap-2.5">
<View className="h-5 w-5 items-center justify-center rounded-full bg-[#BEF04C]">
<Icon name="check" size="sm" color="#1B1B26" />
</View>
<Text variant="small" className="flex-1 text-white/80">
{b}
</Text>
</View>
))}
<Press
haptic="light"
pressedScale={0.97}
className="mt-1 flex-row items-center gap-1.5 self-start rounded-full bg-white/10 px-3.5 py-2"
onPress={() => setOpenId("terms")}
accessibilityRole="button"
accessibilityLabel="Read the updated terms"
>
<Text variant="small" className="font-semibold text-[#BEF04C]">
Read the update
</Text>
<Icon name="chevron-right" size="sm" color="#BEF04C" />
</Press>
</View>
</View>
</Mount>
<Mount delay={BLOCK_STAGGER}>
<List inset>
<List.Section>
{documents.map((doc, ii) => (
<React.Fragment key={doc.id}>
{ii > 0 && <List.Separator className={SEPARATOR_INSET} />}
<List.Item
title={doc.title}
subtitle={[doc.updated, doc.readTime].filter(Boolean).join(" · ")}
leading={
<RowIcon
name={DOC_ICONS[doc.id]?.icon ?? "file-text"}
tone={DOC_ICONS[doc.id]?.tone ?? "gray"}
/>
}
trailing={
doc.accepted ? (
<View className="flex-row items-center gap-1 rounded-full bg-success/15 px-2 py-1">
<Icon name="check" size="sm" color="success" />
<Text className="text-[11px] font-medium text-success">
{doc.accepted}
</Text>
</View>
) : undefined
}
showChevron
haptic="light"
onPress={() => {
setOpenId(doc.id);
onOpenDocument?.(doc.id);
}}
/>
</React.Fragment>
))}
</List.Section>
</List>
<Text variant="small" className="px-8 pt-2 text-muted-foreground">
Previous versions are available on request from legal@lumio.dev.
</Text>
{/* Doc footer — every real legal page ends with the feedback ask. */}
<View className="mx-4 mt-3 flex-row items-center justify-between overflow-hidden rounded-2xl bg-white px-4 py-3 dark:bg-[#1A1A20]">
<Text variant="small" className="text-muted-foreground">
Was this document clear?
</Text>
<View className="flex-row gap-5">
<Press haptic="light" accessibilityRole="button" accessibilityLabel="Yes, clear">
<Text variant="small" className="font-medium text-primary">Yes</Text>
</Press>
<Press haptic="light" accessibilityRole="button" accessibilityLabel="No, unclear">
<Text variant="small" className="font-medium text-muted-foreground">No</Text>
</Press>
</View>
</View>
</Mount>
</ScrollView>
)}
</SafeAreaView>
);
}
TermsPrivacyScreen.displayName = "TermsPrivacyScreen";
```
Screen: https://lumioui.dev/screens/core-terms-privacy