diff --git a/.cursor/rules/alerts.mdc b/.cursor/rules/alerts.mdc
new file mode 100644
index 0000000..dfd2dc5
--- /dev/null
+++ b/.cursor/rules/alerts.mdc
@@ -0,0 +1,48 @@
+---
+description: Unified Alert (toast/banner) for app notifications — Figma + drift prevention
+globs: app/**/*.tsx, stories/modals/Alert.stories.js, tests/components/Alert.test.tsx
+alwaysApply: false
+---
+
+# Alerts and notifications
+
+## Source of truth
+
+- **Figma:** [Community Rule System — Modal / Alert](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=6351-14646) (node **6351-14646**).
+- **Code:** `app/components/modals/Alert` — default export `Alert` from `Alert.container.tsx` (Figma docstring on the container).
+
+## When to use `Alert`
+
+Use **`Alert`** for **app-level, section-level, and shell-level** success, warning, error, and neutral status messages that should read as a designed system surface (not body copy alone).
+
+Do **not** recreate the same job with ad-hoc UI: bordered `
`, free-standing `role="alert"` blocks, or raw `text-[var(--color-border-default-utility-negative)]` paragraphs for product messaging.
+
+## Props (lowercase in code; match Figma intent)
+
+| Concern | Prop | Notes |
+| --- | --- | --- |
+| Layout | `type` | `toast` — bottom accent bar, top rounded corners; `banner` — full rounded block, inline or stacked. |
+| Intent | `status` | `default` \| `positive` \| `warning` \| `danger`. |
+| Density | `size` | `s` \| `m` (Figma S/M). Typography and padding are implemented inside `Alert.container.tsx` — do not fork spacing per call site. |
+| Copy | `title`, `description?` | Required title; optional description when `hasBodyText` is true. |
+| Icon | `hasLeadingIcon?` | Default `true`. |
+| Body | `hasBodyText?` | Default `true`; set `false` for title-only. |
+| Dismiss | `onClose?`, `hasTrailingIcon?` | Close control shows only when `onClose` is provided **and** `hasTrailingIcon` is not `false`. Omit `onClose` for non-dismissible messages. |
+
+Valid enum slices for Storybook / guards: `ALERT_*_OPTIONS` in `lib/propNormalization.ts`.
+
+## Choosing toast vs banner
+
+- **`toast`** — transient edge / bottom emphasis (e.g. completed flow), strong bottom border accent.
+- **`banner`** — rounded block; for **page / shell / modal** messaging, mount inside a **`fixed`** (or equivalent) overlay wrapper with `pointer-events-none` on the outer layer and `pointer-events-auto` on the alert so layout chrome does not reflow when the message appears (see `CreateFlowLayoutClient` `topBanners`, profile overlays, `LoginForm`, `PostLoginDraftTransfer`).
+
+## Exemptions (do not force `Alert`)
+
+1. **Single-field validation** under a control — keep `TextInput` / `TextArea` `error` and helper text (e.g. invalid email on the login form) unless design explicitly moves that line into `Alert`.
+2. **Marketing layout** — `HeroBanner`, `ContentBanner` are not system alerts.
+3. **Landmarks** — `role="banner"` on headers/nav is not the `Alert` “banner” type.
+4. **A11y-only live regions** — e.g. tooltip / incrementer `aria-live` for widget state, not product notifications.
+
+## Copy
+
+All user-visible strings go through **`messages/`** and `useTranslation` / message modules per `localization.mdc`.
diff --git a/.cursor/rules/coding-guidelines.mdc b/.cursor/rules/coding-guidelines.mdc
index edd317f..3119783 100644
--- a/.cursor/rules/coding-guidelines.mdc
+++ b/.cursor/rules/coding-guidelines.mdc
@@ -3,7 +3,7 @@ description: Behavioral guidelines to reduce common LLM coding mistakes. Use whe
alwaysApply: true
---
-# Karpathy behavioral guidelines
+# Coding behavioral guidelines
Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed.
diff --git a/.cursor/rules/create-flow.mdc b/.cursor/rules/create-flow.mdc
index fb76949..caa10b8 100644
--- a/.cursor/rules/create-flow.mdc
+++ b/.cursor/rules/create-flow.mdc
@@ -31,7 +31,7 @@ Reach for these before writing new markup:
| Mid-paragraph "expand / see all" link button | `app/components/buttons/InlineTextButton` |
| Help-icon + label above a control | `app/components/utility/InputLabel` (`helpIcon` prop) |
| Toggle chip (dim-but-clickable) | `Chip` with `state="Disabled" disabled={false}` |
-| Card-click → structured creation modal | `Create` with `backdropVariant="loginYellow"` |
+| Card-click → structured creation modal | `Create` with `backdropVariant="blurredYellow"` |
If a screen grows a 2nd inline copy of any pattern above, **extract a shared
component** rather than duplicate. Local section components inside a screen
diff --git a/.cursor/rules/routes.mdc b/.cursor/rules/routes.mdc
index 1f9d9dd..9d253ed 100644
--- a/.cursor/rules/routes.mdc
+++ b/.cursor/rules/routes.mdc
@@ -15,7 +15,7 @@ the file tree without affecting URLs.
| Group | URL surface | Audience | Chrome |
|---|---|---|---|
| `app/(marketing)/` | `/`, `/learn`, `/blog`, `/templates`, future public pages | Public, indexable | TopNav (via root) + marketing `` |
-| `app/(app)/` | `/create/*`, `/login`, `/profile`, future signed-in surfaces | Authenticated product | TopNav (via root) — no footer |
+| `app/(app)/` | `/create/*`, `/login`, `/profile`, future signed-in surfaces | Authenticated product | TopNav (via root) — no footer except **`/profile`** (see `profile/layout.tsx`) |
| `app/(admin)/` | `/monitor`, future ops dashboards | Operators | TopNav (via root) — no footer |
| `app/(dev)/` | `/components-preview`, future dev previews | Local dev (NODE_ENV gated) | TopNav (via root) — no footer |
| `app/api/` | API routes | n/a | n/a |
@@ -33,7 +33,8 @@ the folder next to `(marketing)/`.
- **`app/(marketing)/layout.tsx`** — wraps with ``
and appends the public ``.
- **`app/(app)/layout.tsx`** / **`(admin)/layout.tsx`** / **`(dev)/layout.tsx`** —
- wrap with ``. No footer.
+ wrap with ``. No footer by default; **`app/(app)/profile/layout.tsx`**
+ appends the marketing `` for `/profile` only.
- **Nested layouts** (e.g. `(app)/create/layout.tsx`) compose feature-specific
chrome inside the group's `` — never render ``, ``,
``, or providers.
diff --git a/AGENTS.md b/AGENTS.md
index c750780..5c26812 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -15,6 +15,7 @@ Single-locale (English) today; designed for i18n via `messages/`.
| If you're touching… | Load this rule |
| --- | --- |
| `app/components/**` | `component-structure.mdc`, `component-props.mdc`, `tailwind-styling.mdc` |
+| `Alert`, or user-visible notifications / shell errors / success banners | `alerts.mdc` (and `localization.mdc` for copy) |
| `app/(app)/create/**` | `create-flow.mdc` (+ component rules) |
| `app/api/**` | `api-routes.mdc` |
| `app/hooks/**` | `hooks.mdc` |
diff --git a/app/(app)/create/PostLoginDraftTransfer.tsx b/app/(app)/create/PostLoginDraftTransfer.tsx
index bf56460..178469e 100644
--- a/app/(app)/create/PostLoginDraftTransfer.tsx
+++ b/app/(app)/create/PostLoginDraftTransfer.tsx
@@ -11,6 +11,7 @@ import { useCreateFlow } from "./context/CreateFlowContext";
import { parseCreateFlowScreenFromPathname } from "./utils/flowSteps";
import { saveDraftToServer } from "../../../lib/create/api";
import messages from "../../../messages/en/index";
+import Alert from "../../components/modals/Alert";
const SYNC_ENABLED = process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true";
@@ -139,12 +140,27 @@ export function PostLoginDraftTransfer({
if (!transferError) return null;
+ const [titleLine, ...rest] = transferError.split(/\n\n+/);
+ const title = (titleLine ?? transferError).trim();
+ const description = rest.join("\n\n").trim() || undefined;
+
return (
-
- {transferError}
+
+
+
{
+ setTransferError(null);
+ }}
+ className="w-full"
+ />
+
);
}
diff --git a/app/(app)/create/SignedInDraftHydration.tsx b/app/(app)/create/SignedInDraftHydration.tsx
index bae94e5..b53364f 100644
--- a/app/(app)/create/SignedInDraftHydration.tsx
+++ b/app/(app)/create/SignedInDraftHydration.tsx
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useRef, useState } from "react";
-import { useSearchParams } from "next/navigation";
+import { usePathname, useRouter, useSearchParams } from "next/navigation";
import type { CreateFlowState } from "./types";
import { createFlowStateHasKeys } from "../../../lib/create/draftHydrationUtils";
import {
@@ -11,6 +11,11 @@ import {
import { useCreateFlow } from "./context/CreateFlowContext";
import { fetchDraftFromServer } from "../../../lib/create/api";
import messages from "../../../messages/en/index";
+import Alert from "../../components/modals/Alert";
+import {
+ isValidStep,
+ parseCreateFlowScreenFromPathname,
+} from "./utils/flowSteps";
const SYNC_ENABLED = process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true";
@@ -36,6 +41,8 @@ export function SignedInDraftHydration({
sessionResolved: boolean;
}) {
const searchParams = useSearchParams();
+ const pathname = usePathname();
+ const router = useRouter();
const syncDraftParam = searchParams.get("syncDraft");
const { replaceState, interactionTouched } = useCreateFlow();
const touchedRef = useRef(interactionTouched);
@@ -82,7 +89,15 @@ export function SignedInDraftHydration({
}
if (serverDraft != null && createFlowStateHasKeys(serverDraft)) {
- replaceState(serverDraft as CreateFlowState);
+ const next = serverDraft as CreateFlowState;
+ replaceState(next);
+ const saved = next.currentStep;
+ if (saved && isValidStep(saved)) {
+ const urlStep = parseCreateFlowScreenFromPathname(pathname ?? null);
+ if (urlStep !== saved) {
+ router.replace(`/create/${saved}`);
+ }
+ }
}
finishedUserIdRef.current = userId;
} finally {
@@ -93,17 +108,31 @@ export function SignedInDraftHydration({
return () => {
cancelled = true;
};
- }, [sessionResolved, sessionUser, syncDraftParam, replaceState]);
+ }, [
+ sessionResolved,
+ sessionUser,
+ syncDraftParam,
+ replaceState,
+ pathname,
+ router,
+ ]);
if (!loadingHydration) return null;
return (
-
- {messages.create.draftHydration.loadingSavedProgress}
+
);
}
diff --git a/app/(app)/create/components/FinalReviewChipEditModal.tsx b/app/(app)/create/components/FinalReviewChipEditModal.tsx
index d53d0cc..76f2264 100644
--- a/app/(app)/create/components/FinalReviewChipEditModal.tsx
+++ b/app/(app)/create/components/FinalReviewChipEditModal.tsx
@@ -175,7 +175,7 @@ export function FinalReviewChipEditModal({
+ <>
-
+
+
+
+ >
);
}
diff --git a/app/(app)/create/screens/CreateFlowScreenView.tsx b/app/(app)/create/screens/CreateFlowScreenView.tsx
index a12d06c..c9f0635 100644
--- a/app/(app)/create/screens/CreateFlowScreenView.tsx
+++ b/app/(app)/create/screens/CreateFlowScreenView.tsx
@@ -47,7 +47,7 @@ export function CreateFlowScreenView({
);
diff --git a/app/(app)/create/screens/card/CommunicationMethodsScreen.tsx b/app/(app)/create/screens/card/CommunicationMethodsScreen.tsx
index c386e43..7213651 100644
--- a/app/(app)/create/screens/card/CommunicationMethodsScreen.tsx
+++ b/app/(app)/create/screens/card/CommunicationMethodsScreen.tsx
@@ -213,7 +213,7 @@ export function CommunicationMethodsScreen() {
description={modalConfig.description}
nextButtonText={modalConfig.nextButtonText}
showBackButton={false}
- backdropVariant="loginYellow"
+ backdropVariant="blurredYellow"
>
{pendingCardId && pendingDraft ? (
{pendingCardId && pendingDraft ? (
{pendingCardId && pendingDraft ? (
0 ? sum : undefined,
+ documentSections: parsed,
+ };
+}
+
export function CompletedScreen() {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const ruleIdParam = searchParams.get("ruleId");
const mdUp = useCreateFlowMdUp();
const m = useMessages();
const completed = m.create.reviewAndComplete.completed;
- const fallbackSections = useMemo(
- () =>
- [...completed.fallbackDocumentSections] as CommunityRuleDocumentSection[],
- [completed.fallbackDocumentSections],
- );
-
+ const initial = initialCompletedUi(ruleIdParam);
const [toastDismissed, setToastDismissed] = useState(false);
- const [headerTitle, setHeaderTitle] = useState(
- () => completed.fallbackTitle,
- );
+ const [headerTitle, setHeaderTitle] = useState(initial.headerTitle);
const [headerDescription, setHeaderDescription] = useState<
string | undefined
- >(() => completed.fallbackDescription);
+ >(initial.headerDescription);
const [documentSections, setDocumentSections] =
- useState(fallbackSections);
+ useState(initial.documentSections);
useEffect(() => {
- const stored = readLastPublishedRule();
- if (!stored) return;
- const parsed = parseDocumentSectionsForDisplay(stored.document);
- if (parsed.length === 0) return;
- queueMicrotask(() => {
- setDocumentSections(parsed);
- setHeaderTitle(stored.title);
- const sum =
- typeof stored.summary === "string" ? stored.summary.trim() : "";
- setHeaderDescription(sum.length > 0 ? sum : undefined);
- });
- }, []);
+ if (!ruleIdParam) return;
+ let cancelled = false;
+ void (async () => {
+ const detail = await fetchPublishedRuleDetail(ruleIdParam);
+ if (cancelled) return;
+ if (
+ !detail ||
+ !detail.viewerIsOwner ||
+ detail.rule.document === null ||
+ typeof detail.rule.document !== "object" ||
+ Array.isArray(detail.rule.document)
+ ) {
+ router.replace(`/rules/${encodeURIComponent(ruleIdParam)}`);
+ return;
+ }
+ const doc = detail.rule.document as Record;
+ writeLastPublishedRule({
+ id: detail.rule.id,
+ title: detail.rule.title,
+ summary: detail.rule.summary,
+ document: doc,
+ });
+ const parsed = parseDocumentSectionsForDisplay(doc);
+ if (parsed.length === 0) {
+ router.replace(`/rules/${encodeURIComponent(ruleIdParam)}`);
+ return;
+ }
+ queueMicrotask(() => {
+ setDocumentSections(parsed);
+ setHeaderTitle(detail.rule.title);
+ const sum =
+ typeof detail.rule.summary === "string"
+ ? detail.rule.summary.trim()
+ : "";
+ setHeaderDescription(sum.length > 0 ? sum : undefined);
+ });
+ router.replace("/create/completed");
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [ruleIdParam, router]);
const toast = !toastDismissed ? (
{pendingCardId && pendingDraft ? (
{children};
}
diff --git a/app/(app)/profile/ProfilePageClient.tsx b/app/(app)/profile/ProfilePageClient.tsx
index b2b8e44..aeb6446 100644
--- a/app/(app)/profile/ProfilePageClient.tsx
+++ b/app/(app)/profile/ProfilePageClient.tsx
@@ -1,55 +1,365 @@
"use client";
-import { useCallback, useEffect, useState } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { useRouter } from "next/navigation";
+import { useAuthModal } from "../../contexts/AuthModalContext";
import { useTranslation } from "../../contexts/MessagesContext";
-import Button from "../../components/buttons/Button";
-import { fetchAuthSession, logout } from "../../../lib/create/api";
+import {
+ deleteAccount,
+ deletePublishedRule,
+ deleteServerDraft,
+ duplicatePublishedRule,
+ fetchAuthSession,
+ fetchMyPublishedRules,
+ fetchServerDraftForProfile,
+ logout,
+ requestEmailChange,
+ type MyPublishedRule,
+} from "../../../lib/create/api";
+import {
+ FIRST_STEP,
+ isValidStep,
+} from "../create/utils/flowSteps";
+import type { CreateFlowStep } from "../create/types";
+import { clearAnonymousCreateFlowStorage } from "../create/utils/anonymousDraftStorage";
+import { useMediaQuery } from "../../hooks/useMediaQuery";
+import {
+ ProfilePageSignedOutView,
+ ProfilePageView,
+} from "./_components/ProfilePage.view";
+
+function resolveContinueStepState(
+ state: { currentStep?: CreateFlowStep } & Record,
+): CreateFlowStep {
+ const s = state.currentStep;
+ if (s && isValidStep(s)) return s;
+ return FIRST_STEP;
+}
export default function ProfilePageClient() {
const t = useTranslation("pages.profile");
+ const router = useRouter();
+ const { openLogin } = useAuthModal();
+ const [sessionLoaded, setSessionLoaded] = useState(false);
const [user, setUser] = useState<{ id: string; email: string } | null>(null);
- const [loaded, setLoaded] = useState(false);
+ const [rules, setRules] = useState([]);
+ const [rulesError, setRulesError] = useState(false);
+ const [draft, setDraft] = useState<
+ Awaited>
+ >(null);
+ const [ruleDeleteTargetId, setRuleDeleteTargetId] = useState(
+ null,
+ );
+ const [ruleDeleteBusy, setRuleDeleteBusy] = useState(false);
+ const [draftDeleteOpen, setDraftDeleteOpen] = useState(false);
+ const [draftDeleteBusy, setDraftDeleteBusy] = useState(false);
+ const [accountDeleteOpen, setAccountDeleteOpen] = useState(false);
+ const [accountDeleteBusy, setAccountDeleteBusy] = useState(false);
+ const [actionError, setActionError] = useState(null);
+ const [emailChangeOpen, setEmailChangeOpen] = useState(false);
+ const [emailChangeInput, setEmailChangeInput] = useState("");
+ const [emailChangeBusy, setEmailChangeBusy] = useState(false);
+ const [emailChangeModalError, setEmailChangeModalError] = useState<
+ string | null
+ >(null);
+ const [emailChangeRequestSent, setEmailChangeRequestSent] = useState(false);
+ const [profileSuccessMessage, setProfileSuccessMessage] = useState<
+ string | null
+ >(null);
+ const emailChangeQueryHandledRef = useRef(false);
+
+ const load = useCallback(async () => {
+ setActionError(null);
+ const { user: u } = await fetchAuthSession();
+ setUser(u);
+ setSessionLoaded(true);
+ if (!u) {
+ setRules([]);
+ setRulesError(false);
+ setDraft(null);
+ return;
+ }
+ const [r, d] = await Promise.all([
+ fetchMyPublishedRules(),
+ fetchServerDraftForProfile(),
+ ]);
+ if (r === null) {
+ setRules([]);
+ setRulesError(true);
+ } else {
+ setRules(r);
+ setRulesError(false);
+ }
+ setDraft(d);
+ }, []);
useEffect(() => {
- let cancelled = false;
- void fetchAuthSession().then(({ user: u }) => {
- if (!cancelled) {
- setUser(u);
- setLoaded(true);
- }
- });
- return () => {
- cancelled = true;
- };
+ void load();
+ }, [load]);
+
+ useEffect(() => {
+ if (emailChangeQueryHandledRef.current) return;
+ if (typeof window === "undefined") return;
+ const search = window.location.search;
+ if (!search) return;
+ const params = new URLSearchParams(search);
+ const ok = params.get("email_change");
+ const err = params.get("error");
+ if (ok !== "ok" && !err?.startsWith("email_change_")) return;
+
+ emailChangeQueryHandledRef.current = true;
+
+ if (ok === "ok") {
+ setProfileSuccessMessage(t("emailChangeSuccess"));
+ void load().then(() => {
+ router.refresh();
+ });
+ } else if (err === "email_change_expired") {
+ setActionError(t("emailChangeVerifyExpired"));
+ } else if (err === "email_change_invalid") {
+ setActionError(t("emailChangeVerifyInvalid"));
+ } else if (err === "email_change_taken") {
+ setActionError(t("emailChangeVerifyTaken"));
+ } else if (err === "email_change_server") {
+ setActionError(t("actionError"));
+ }
+
+ router.replace("/profile", { scroll: false });
+ }, [load, router, t]);
+
+ const handleOpenEmailChange = useCallback(() => {
+ if (!user) return;
+ setActionError(null);
+ setProfileSuccessMessage(null);
+ setEmailChangeModalError(null);
+ setEmailChangeRequestSent(false);
+ setEmailChangeInput(user.email);
+ setEmailChangeOpen(true);
+ }, [user]);
+
+ const handleCloseEmailChange = useCallback(() => {
+ if (emailChangeBusy) return;
+ setEmailChangeOpen(false);
+ setEmailChangeRequestSent(false);
+ }, [emailChangeBusy]);
+
+ const handleDismissProfileSuccess = useCallback(() => {
+ setProfileSuccessMessage(null);
}, []);
+ const handleDismissActionError = useCallback(() => {
+ setActionError(null);
+ }, []);
+
+ const handleDismissRulesError = useCallback(() => {
+ setRulesError(false);
+ }, []);
+
+ const handleDismissEmailChangeModalError = useCallback(() => {
+ setEmailChangeModalError(null);
+ }, []);
+
+ const handleSubmitEmailChange = useCallback(async () => {
+ const trimmed = emailChangeInput.trim();
+ if (!trimmed || emailChangeBusy) return;
+ setEmailChangeModalError(null);
+ setEmailChangeBusy(true);
+ const res = await requestEmailChange(trimmed);
+ setEmailChangeBusy(false);
+ if (res.ok === false) {
+ if (res.retryAfterMs != null && res.retryAfterMs > 0) {
+ const sec = Math.max(1, Math.ceil(res.retryAfterMs / 1000));
+ setEmailChangeModalError(
+ t("emailChangeRateLimited").replace(/\{\{seconds\}\}/g, String(sec)),
+ );
+ } else {
+ setEmailChangeModalError(res.error);
+ }
+ } else {
+ setEmailChangeRequestSent(true);
+ }
+ }, [emailChangeBusy, emailChangeInput, t]);
+
const handleSignOut = useCallback(async () => {
+ setActionError(null);
await logout();
setUser(null);
+ setRules([]);
+ setDraft(null);
+ router.refresh();
+ }, [router]);
+
+ const handleRequestDeleteRule = useCallback((id: string) => {
+ setActionError(null);
+ setRuleDeleteTargetId(id);
}, []);
+ const handleCloseDeleteRuleDialog = useCallback(() => {
+ if (ruleDeleteBusy) return;
+ setRuleDeleteTargetId(null);
+ }, [ruleDeleteBusy]);
+
+ const handleConfirmDeleteRule = useCallback(async () => {
+ const id = ruleDeleteTargetId;
+ if (!id || ruleDeleteBusy) return;
+
+ setActionError(null);
+ setRuleDeleteBusy(true);
+ const res = await deletePublishedRule(id);
+ setRuleDeleteBusy(false);
+ if (res.ok === true) {
+ setRuleDeleteTargetId(null);
+ void load();
+ return;
+ }
+ if (res.status === 404) {
+ setActionError(t("notFound"));
+ setRuleDeleteTargetId(null);
+ } else if (res.status === 403) {
+ setActionError(t("forbidden"));
+ setRuleDeleteTargetId(null);
+ } else {
+ setActionError(t("actionError"));
+ }
+ }, [load, ruleDeleteBusy, ruleDeleteTargetId, t]);
+
+ const handleDuplicateRule = useCallback(
+ async (id: string) => {
+ setActionError(null);
+ const res = await duplicatePublishedRule(id);
+ if (res.ok === true) {
+ void load();
+ } else {
+ if (res.status === 404) {
+ setActionError(t("notFound"));
+ } else if (res.status === 403) {
+ setActionError(t("forbidden"));
+ } else {
+ setActionError(t("actionError"));
+ }
+ }
+ },
+ [load, t],
+ );
+
+ const handleContinueDraft = useCallback(() => {
+ if (draft == null || !draft.hasDraft) return;
+ const step = resolveContinueStepState(draft.state);
+ router.push(`/create/${step}`);
+ }, [draft, router]);
+
+ const handleRequestDeleteDraft = useCallback(() => {
+ setActionError(null);
+ setDraftDeleteOpen(true);
+ }, []);
+
+ const handleCloseDeleteDraftDialog = useCallback(() => {
+ if (draftDeleteBusy) return;
+ setDraftDeleteOpen(false);
+ }, [draftDeleteBusy]);
+
+ const handleConfirmDeleteDraft = useCallback(async () => {
+ if (draftDeleteBusy) return;
+ setActionError(null);
+ setDraftDeleteBusy(true);
+ clearAnonymousCreateFlowStorage();
+ await deleteServerDraft();
+ setDraftDeleteBusy(false);
+ setDraftDeleteOpen(false);
+ void load();
+ }, [draftDeleteBusy, load]);
+
+ const handleConfirmDeleteAccount = useCallback(async () => {
+ setActionError(null);
+ setAccountDeleteBusy(true);
+ const res = await deleteAccount();
+ setAccountDeleteBusy(false);
+ if (res.ok) {
+ setAccountDeleteOpen(false);
+ setUser(null);
+ setRules([]);
+ setDraft(null);
+ router.push("/");
+ router.refresh();
+ return;
+ }
+ setActionError(t("actionError"));
+ }, [router, t]);
+
+ /** `lg`+ layout; matches `--breakpoint-lg` in `app/tailwind.css`. */
+ const isProfileLgUp = useMediaQuery("(min-width: 1024px)");
+ /** `List` L + Bricolage section titles — Figma `22143:900247`; matches `--breakpoint-xl` (1440px). */
+ const isProfileXlUp = useMediaQuery("(min-width: 1440px)");
+
+ if (!sessionLoaded) {
+ return (
+
+ );
+ }
+
+ if (!user) {
+ return (
+ openLogin({ nextPath: "/profile" })}
+ />
+ );
+ }
+
+ const showDraftCard = Boolean(
+ draft && draft.hasDraft,
+ );
+
return (
-
-
- {t("placeholderTitle")}
-
-
- {t("placeholderBody")}
-
- {loaded && user ? (
-
- void handleSignOut()}
- ariaLabel={t("signOut")}
- >
- {t("signOut")}
-
-
- ) : null}
-
+ setEmailChangeInput(value)}
+ emailChangeBusy={emailChangeBusy}
+ emailChangeRequestSent={emailChangeRequestSent}
+ emailChangeModalError={emailChangeModalError}
+ onDismissProfileSuccess={handleDismissProfileSuccess}
+ onDismissActionError={handleDismissActionError}
+ onDismissRulesError={handleDismissRulesError}
+ onDismissEmailChangeModalError={handleDismissEmailChangeModalError}
+ onOpenEmailChange={handleOpenEmailChange}
+ onCloseEmailChange={handleCloseEmailChange}
+ onSubmitEmailChange={handleSubmitEmailChange}
+ onSignOut={handleSignOut}
+ onDeleteRule={handleRequestDeleteRule}
+ onCloseDeleteRule={handleCloseDeleteRuleDialog}
+ onConfirmDeleteRule={handleConfirmDeleteRule}
+ onDuplicateRule={handleDuplicateRule}
+ onContinueDraft={handleContinueDraft}
+ onDeleteDraft={handleRequestDeleteDraft}
+ onCloseDeleteDraft={handleCloseDeleteDraftDialog}
+ onConfirmDeleteDraft={handleConfirmDeleteDraft}
+ onOpenDeleteAccount={() => {
+ setActionError(null);
+ setAccountDeleteOpen(true);
+ }}
+ onCloseDeleteAccount={() => setAccountDeleteOpen(false)}
+ onConfirmDeleteAccount={handleConfirmDeleteAccount}
+ />
);
}
diff --git a/app/(app)/profile/_components/ProfilePage.view.tsx b/app/(app)/profile/_components/ProfilePage.view.tsx
new file mode 100644
index 0000000..e93877a
--- /dev/null
+++ b/app/(app)/profile/_components/ProfilePage.view.tsx
@@ -0,0 +1,643 @@
+"use client";
+
+import { useId, useMemo } from "react";
+import Button from "../../../components/buttons/Button";
+import RuleCard from "../../../components/cards/RuleCard";
+import TextInput from "../../../components/controls/TextInput";
+import List from "../../../components/layout/List";
+import type { ListItem, ListSize } from "../../../components/layout/List";
+import Icon from "../../../components/asset/Icon";
+import Dialog from "../../../components/modals/Dialog";
+import Alert from "../../../components/modals/Alert";
+import HeaderLockup from "../../../components/type/HeaderLockup";
+import { useTranslation } from "../../../contexts/MessagesContext";
+import type { CreateFlowState } from "../../create/types";
+import type {
+ MyPublishedRule,
+ ServerDraftForProfile,
+} from "../../../../lib/create/api";
+
+function draftBodyTextFromState(
+ state: CreateFlowState,
+): string | undefined {
+ const ctx = state.communityContext?.trim();
+ if (ctx) return ctx;
+ const summary = state.summary?.trim();
+ if (summary) return summary;
+ return undefined;
+}
+
+export type ProfilePageViewProps = {
+ userEmail: string;
+ /** `M` below `lg` (1024px); `L` at `lg`+ per Figma Card / Rule. Breakpoints: `md` (640px) → `lg` (1024px) only. */
+ ruleCardSize: "M" | "L";
+ /** `true` at `lg` (1024px)+ — welcome uses {@link HeaderLockup} size `L` per `21962:17220`. */
+ profileLgUp: boolean;
+ /** `m` = {@link List} M; `l` = List L at `xl` per Figma `22143:900256`. */
+ profileListSize: Extract;
+ rules: MyPublishedRule[];
+ rulesError: boolean;
+ draft: ServerDraftForProfile | null;
+ showDraftCard: boolean;
+ ruleDeleteOpen: boolean;
+ ruleDeleteBusy: boolean;
+ draftDeleteOpen: boolean;
+ draftDeleteBusy: boolean;
+ accountDeleteOpen: boolean;
+ accountDeleteBusy: boolean;
+ actionError: string | null;
+ profileSuccessMessage: string | null;
+ emailChangeOpen: boolean;
+ emailChangeValue: string;
+ onEmailChangeValueChange: (value: string) => void;
+ emailChangeBusy: boolean;
+ emailChangeRequestSent: boolean;
+ emailChangeModalError: string | null;
+ onDismissEmailChangeModalError: () => void;
+ onOpenEmailChange: () => void;
+ onCloseEmailChange: () => void;
+ onSubmitEmailChange: () => void;
+ onSignOut: () => void;
+ onDeleteRule: (id: string) => void;
+ onCloseDeleteRule: () => void;
+ onConfirmDeleteRule: () => void;
+ onDuplicateRule: (id: string) => void;
+ onContinueDraft: () => void;
+ onDeleteDraft: () => void;
+ onCloseDeleteDraft: () => void;
+ onConfirmDeleteDraft: () => void;
+ onOpenDeleteAccount: () => void;
+ onCloseDeleteAccount: () => void;
+ onConfirmDeleteAccount: () => void;
+ onDismissProfileSuccess: () => void;
+ onDismissActionError: () => void;
+ onDismissRulesError: () => void;
+};
+
+/**
+ * Figma: Inter 20/28 from `md` to `lg`+ (e.g. `21962:17224`); at `xl` Bricolage 28/36 (`22143:900251`, `22143:900255` — `Medium/Heading`);
+ * mobile: smaller Bricolage.
+ */
+const profileSectionHeadingClass =
+ "font-bricolage text-base font-bold leading-[22px] text-[var(--color-content-default-primary)] md:font-inter md:text-xl md:font-bold md:leading-7 xl:font-bricolage-grotesque xl:font-bold xl:text-[28px] xl:leading-9";
+
+/**
+ * Sticky `top` for page content below the product {@link TopNav} (standard variant).
+ * Must match `TopNav.view.tsx`: nav `h` 40px → `lg` 84px → `xl` 88px, plus `header` `border-b` (+1px).
+ */
+const stickyBelowTopNavTopClass =
+ "top-[41px] lg:top-[85px] xl:top-[89px]";
+
+export type ProfilePageSignedOutViewProps = {
+ onSignIn: () => void;
+ /** `min-width: 1024px` — welcome uses {@link HeaderLockup} `L` per Figma `21962:17220`. */
+ profileLgUp: boolean;
+};
+
+/**
+ * Signed-out profile: same shell as {@link ProfilePageView}
+ * (Figma mobile `22143:900762`, md `22143:900534`, lg `21962:17220` via {@link HeaderLockup}).
+ */
+export function ProfilePageSignedOutView({
+ onSignIn,
+ profileLgUp,
+}: ProfilePageSignedOutViewProps) {
+ const t = useTranslation("pages.profile");
+ const titleId = useId();
+
+ return (
+
+
+
+ {profileLgUp ? (
+
+ ) : (
+ <>
+
+ {t("pageTitle")}
+
+
+ {t("signInPrompt")}
+
+ >
+ )}
+
+
+ {t("signInCta")}
+
+
+
+ );
+}
+
+/**
+ * Figma: mobile `22143:900762`; tablet `md` `22143:900534` (`@theme --breakpoint-md` 640px);
+ * desktop `lg` `21962:17220` (`@theme --breakpoint-lg` 1024px);
+ * `xl` `22143:900247` (same content spacing as lg; list + section type at `xl` — `List` L `21844:4405`).
+ */
+export function ProfilePageView({
+ userEmail,
+ ruleCardSize,
+ profileLgUp,
+ profileListSize,
+ rules,
+ rulesError,
+ draft,
+ showDraftCard,
+ ruleDeleteOpen,
+ ruleDeleteBusy,
+ draftDeleteOpen,
+ draftDeleteBusy,
+ accountDeleteOpen,
+ accountDeleteBusy,
+ actionError,
+ profileSuccessMessage,
+ emailChangeOpen,
+ emailChangeValue,
+ onEmailChangeValueChange,
+ emailChangeBusy,
+ emailChangeRequestSent,
+ emailChangeModalError,
+ onDismissEmailChangeModalError,
+ onOpenEmailChange,
+ onCloseEmailChange,
+ onSubmitEmailChange,
+ onSignOut,
+ onDeleteRule,
+ onCloseDeleteRule,
+ onConfirmDeleteRule,
+ onDuplicateRule,
+ onContinueDraft,
+ onDeleteDraft,
+ onCloseDeleteDraft,
+ onConfirmDeleteDraft,
+ onOpenDeleteAccount,
+ onCloseDeleteAccount,
+ onConfirmDeleteAccount,
+ onDismissProfileSuccess,
+ onDismissActionError,
+ onDismissRulesError,
+}: ProfilePageViewProps) {
+ const t = useTranslation("pages.profile");
+ const tLogin = useTranslation("pages.login");
+ const titleId = useId();
+ const welcomeTitle = t("welcomeTitle").replace(/\{\{name\}\}/g, userEmail);
+ const welcomeBody =
+ rules.length > 0 ? t("welcomeBodyFirstRule") : t("welcomeBodyNoRules");
+
+ const profileOptionsItems = useMemo((): ListItem[] => {
+ return [
+ {
+ id: "create-custom",
+ title: t("optionCreateCustom"),
+ description: "",
+ href: "/create",
+ leadingIcon: "edit",
+ showDescription: false,
+ },
+ {
+ id: "create-template",
+ title: t("optionCreateTemplate"),
+ description: "",
+ href: "/templates?fromFlow=1",
+ leadingIcon: "content_copy",
+ showDescription: false,
+ },
+ {
+ id: "logout",
+ title: t("optionLogout"),
+ description: "",
+ onClick: onSignOut,
+ leadingIcon: "log_out",
+ showDescription: false,
+ },
+ {
+ id: "change-email",
+ title: t("optionChangeEmail"),
+ description: "",
+ onClick: onOpenEmailChange,
+ leadingIcon: "mail",
+ showDescription: false,
+ },
+ {
+ id: "delete-account",
+ title: t("deleteAccount"),
+ description: "",
+ onClick: onOpenDeleteAccount,
+ leadingIcon: "warning",
+ variant: "danger",
+ showDescription: false,
+ },
+ ];
+ }, [t, onSignOut, onOpenDeleteAccount, onOpenEmailChange]);
+
+ const ruleCardShellClass =
+ "w-full !max-w-full cursor-default !gap-3 !rounded-[12px] shadow-[0_0_48px_rgba(0,0,0,0.1)] lg:!rounded-[24px] lg:shadow-[0_0_24px_rgba(0,0,0,0.1)]";
+
+ return (
+ <>
+
+
+
+ {profileLgUp ? (
+
+ ) : (
+ <>
+
+ {welcomeTitle}
+
+
+ {welcomeBody}
+
+ >
+ )}
+
+
+
+
+
+ {t("yourRulesHeading")}
+
+
+ {showDraftCard && draft?.hasDraft ? (
+ {
+ const raw = draft.state.title;
+ const s = typeof raw === "string" ? raw.trim() : "";
+ return s || t("draftHeading");
+ })()}
+ description={draftBodyTextFromState(draft.state)}
+ expanded
+ size={ruleCardSize}
+ hasBottomLinks
+ bottomStatusLabel={t("draftInProgressBadge")}
+ bottomLinks={[
+ {
+ id: "continue",
+ label: t("continueDraft"),
+ onClick: onContinueDraft,
+ },
+ {
+ id: "delete-draft",
+ label: t("deleteRule"),
+ onClick: onDeleteDraft,
+ },
+ ]}
+ communityInitials={(() => {
+ const raw = draft.state.title;
+ const s = typeof raw === "string" ? raw.trim() : "";
+ return s.charAt(0).toUpperCase() || "·";
+ })()}
+ backgroundColor="bg-[var(--color-surface-invert-brand-teal)]"
+ className={ruleCardShellClass}
+ />
+ ) : null}
+ {rules.map((rule) => (
+ onDuplicateRule(rule.id),
+ },
+ {
+ id: "del",
+ label: t("deleteRule"),
+ onClick: () => onDeleteRule(rule.id),
+ },
+ ]}
+ communityInitials={
+ rule.title.trim().charAt(0).toUpperCase() || "·"
+ }
+ backgroundColor="bg-[var(--color-surface-invert-brand-teal)]"
+ className={ruleCardShellClass}
+ />
+ ))}
+
+ {rules.length === 0 && !rulesError && !showDraftCard ? (
+
+ {t("yourRulesEmpty")}
+
+ ) : null}
+
+
+
+
+ {t("yourOptionsHeading")}
+
+
+
+
+
+
+
+
+
+ {
+ if (!ruleDeleteBusy) onCloseDeleteRule();
+ }}
+ backdropVariant="blurredYellow"
+ title={t("deleteRuleModalTitle")}
+ description={t("deleteRuleModalBody")}
+ footer={
+ <>
+
+ {t("deleteRuleCancel")}
+
+
+ {t("deleteRuleConfirmCta")}
+
+ >
+ }
+ />
+
+ {
+ if (!draftDeleteBusy) onCloseDeleteDraft();
+ }}
+ backdropVariant="blurredYellow"
+ title={t("deleteDraftModalTitle")}
+ description={t("deleteDraftModalBody")}
+ footer={
+ <>
+
+ {t("deleteDraftCancel")}
+
+
+ {t("deleteDraftConfirmCta")}
+
+ >
+ }
+ />
+
+ {
+ if (!accountDeleteBusy) onCloseDeleteAccount();
+ }}
+ backdropVariant="blurredYellow"
+ title={t("deleteAccountModalTitle")}
+ description={t("deleteAccountModalBody")}
+ footer={
+ <>
+
+ {t("deleteAccountCancel")}
+
+
+ {t("deleteAccountConfirm")}
+
+ >
+ }
+ />
+
+ {
+ if (!emailChangeBusy) onCloseEmailChange();
+ }}
+ backdropVariant="blurredYellow"
+ title={
+ emailChangeRequestSent
+ ? tLogin("successTitle")
+ : t("emailChangeModalTitle")
+ }
+ description={
+ emailChangeRequestSent
+ ? tLogin("successBody")
+ : t("emailChangeModalDescription")
+ }
+ footer={
+ emailChangeRequestSent ? (
+
+ {t("emailChangeConfirmationClose")}
+
+ ) : (
+ <>
+
+ {t("emailChangeCancel")}
+
+
+ {t("emailChangeSubmit")}
+
+ >
+ )
+ }
+ >
+ {emailChangeRequestSent ? (
+
+ ) : (
+ onEmailChangeValueChange(e.target.value)}
+ disabled={emailChangeBusy}
+ error={Boolean(emailChangeModalError)}
+ autoComplete="email"
+ />
+ )}
+
+
+ {(profileSuccessMessage || actionError || rulesError) && (
+
+
+ {profileSuccessMessage ? (
+
+ ) : null}
+ {actionError ? (
+
+ ) : null}
+ {rulesError ? (
+
+ ) : null}
+
+
+ )}
+
+ {emailChangeOpen && emailChangeModalError ? (
+
+ ) : null}
+ >
+ );
+}
diff --git a/app/(app)/profile/layout.tsx b/app/(app)/profile/layout.tsx
new file mode 100644
index 0000000..cc647aa
--- /dev/null
+++ b/app/(app)/profile/layout.tsx
@@ -0,0 +1,19 @@
+import dynamic from "next/dynamic";
+import type { ReactNode } from "react";
+
+/** Profile uses the public marketing footer; other `(app)` routes stay footer-free. */
+const Footer = dynamic(() => import("../../components/navigation/Footer"), {
+ loading: () => (
+
+ ),
+ ssr: true,
+});
+
+export default function ProfileLayout({ children }: { children: ReactNode }) {
+ return (
+ <>
+ {children}
+
+ >
+ );
+}
diff --git a/app/(dev)/components-preview/page.tsx b/app/(dev)/components-preview/page.tsx
index 6681f22..f4df3c3 100644
--- a/app/(dev)/components-preview/page.tsx
+++ b/app/(dev)/components-preview/page.tsx
@@ -1,967 +1,215 @@
"use client";
-import { useState } from "react";
-import RuleCard from "../../components/cards/RuleCard";
-import Card from "../../components/cards/Card";
-import Chip from "../../components/controls/Chip";
-import MultiSelect from "../../components/controls/MultiSelect";
-import Image from "next/image";
-import { getAssetPath } from "../../../lib/assetUtils";
+import List from "../../components/layout/List";
+import Link from "../../components/navigation/Link";
+import Divider from "../../components/utility/Divider";
-/** Module-level counter for unique rule card chip IDs (avoids ref in initial state). */
-let ruleCardIdCounter = 0;
-
-interface ChipData {
- id: string;
- label: string;
- state: "unselected" | "selected" | "custom";
- palette: "default" | "inverse";
- size: "s" | "m";
-}
-
-function MultiSelectExample({ size }: { size: "s" | "m" }) {
- const [options, setOptions] = useState<
- Array<{
- id: string;
- label: string;
- state: "unselected" | "selected" | "custom";
- }>
- >([
- { id: "1", label: "1 member", state: "unselected" },
- { id: "2", label: "2-10 members", state: "unselected" },
- { id: "3", label: "10-24 members", state: "unselected" },
- { id: "4", label: "24-64 members", state: "unselected" },
- { id: "5", label: "64-128 members", state: "unselected" },
- { id: "6", label: "125-1000 members", state: "unselected" },
- { id: "7", label: "1000+ members", state: "unselected" },
- ]);
-
- const handleChipClick = (chipId: string) => {
- setOptions((prev) =>
- prev.map((opt) =>
- opt.id === chipId
- ? {
- ...opt,
- state: opt.state === "selected" ? "unselected" : "selected",
- }
- : opt,
- ),
- );
- };
-
- const handleAddClick = () => {
- const newId = `custom-${Date.now()}`;
- setOptions((prev) => [...prev, { id: newId, label: "", state: "custom" }]);
- };
-
- const handleCustomConfirm = (chipId: string, value: string) => {
- setOptions((prev) =>
- prev.map((opt) =>
- opt.id === chipId
- ? { ...opt, label: value, state: "selected" as const }
- : opt,
- ),
- );
- };
-
- const handleCustomClose = (chipId: string) => {
- setOptions((prev) => prev.filter((opt) => opt.id !== chipId));
- };
-
- return (
-
-
- {size === "s" ? "Small (S)" : "Medium (M)"}
-
-
-
- );
-}
+const listSampleItems = [
+ {
+ id: "1",
+ title: "Item",
+ description: "Description/text only option here.",
+ href: "#",
+ },
+ {
+ id: "2",
+ title: "Item",
+ description: "Description/text only option here.",
+ href: "#",
+ },
+ {
+ id: "3",
+ title: "Item",
+ description: "Description/text only option here.",
+ href: "#",
+ },
+ {
+ id: "4",
+ title: "Item",
+ description: "Description/text only option here.",
+ href: "#",
+ },
+ {
+ id: "5",
+ title: "Item",
+ description: "Description/text only option here.",
+ href: "#",
+ },
+] as const;
export default function ComponentsPreview() {
- const [chipStates, setChipStates] = useState<
- Record
- >({
- "default-s": "unselected",
- "default-m": "unselected",
- "inverse-s": "unselected",
- "inverse-m": "unselected",
- });
-
- // Manage custom chips separately
- const [customChips, setCustomChips] = useState([
- {
- id: "custom-1",
- label: "",
- state: "custom",
- palette: "default",
- size: "s",
- },
- {
- id: "custom-2",
- label: "",
- state: "custom",
- palette: "default",
- size: "m",
- },
- ]);
-
- // RuleCard categories with chip options and state management
- const [ruleCardCategories, setRuleCardCategories] = useState<
- Array<{
- name: string;
- chipOptions: Array<{
- id: string;
- label: string;
- state: "unselected" | "selected" | "custom";
- }>;
- onChipClick?: (_categoryName: string, _chipId: string) => void;
- onAddClick?: (_categoryName: string) => void;
- onCustomChipConfirm?: (
- _categoryName: string,
- _chipId: string,
- _value: string,
- ) => void;
- onCustomChipClose?: (_categoryName: string, _chipId: string) => void;
- }>
- >([
- {
- name: "Values",
- chipOptions: [
- { id: "values-1", label: "Consciousness", state: "unselected" },
- { id: "values-2", label: "Ecology", state: "unselected" },
- { id: "values-3", label: "Abundance", state: "unselected" },
- { id: "values-4", label: "Art", state: "unselected" },
- { id: "values-5", label: "Decisiveness", state: "unselected" },
- ],
- onChipClick: (categoryName: string, chipId: string) => {
- setRuleCardCategories((prev) =>
- prev.map((cat) =>
- cat.name === categoryName
- ? {
- ...cat,
- chipOptions: cat.chipOptions.map((opt) =>
- opt.id === chipId
- ? {
- ...opt,
- state:
- opt.state === "selected"
- ? "unselected"
- : "selected",
- }
- : opt,
- ),
- }
- : cat,
- ),
- );
- },
- onAddClick: (categoryName: string) => {
- const newId = `custom-${categoryName}-${++ruleCardIdCounter}`;
- setRuleCardCategories((prev) =>
- prev.map((cat) =>
- cat.name === categoryName
- ? {
- ...cat,
- chipOptions: [
- ...cat.chipOptions,
- { id: newId, label: "", state: "custom" },
- ],
- }
- : cat,
- ),
- );
- },
- onCustomChipConfirm: (
- categoryName: string,
- chipId: string,
- value: string,
- ) => {
- setRuleCardCategories((prev) =>
- prev.map((cat) =>
- cat.name === categoryName
- ? {
- ...cat,
- chipOptions: cat.chipOptions.map((opt) =>
- opt.id === chipId
- ? { ...opt, label: value, state: "selected" }
- : opt,
- ),
- }
- : cat,
- ),
- );
- },
- onCustomChipClose: (categoryName: string, chipId: string) => {
- setRuleCardCategories((prev) =>
- prev.map((cat) =>
- cat.name === categoryName
- ? {
- ...cat,
- chipOptions: cat.chipOptions.filter(
- (opt) => opt.id !== chipId,
- ),
- }
- : cat,
- ),
- );
- },
- },
- {
- name: "Communication",
- chipOptions: [{ id: "comm-1", label: "Signal", state: "unselected" }],
- onChipClick: (categoryName: string, chipId: string) => {
- setRuleCardCategories((prev) =>
- prev.map((cat) =>
- cat.name === categoryName
- ? {
- ...cat,
- chipOptions: cat.chipOptions.map((opt) =>
- opt.id === chipId
- ? {
- ...opt,
- state:
- opt.state === "selected"
- ? "unselected"
- : "selected",
- }
- : opt,
- ),
- }
- : cat,
- ),
- );
- },
- onAddClick: (categoryName: string) => {
- const newId = `custom-${categoryName}-${++ruleCardIdCounter}`;
- setRuleCardCategories((prev) =>
- prev.map((cat) =>
- cat.name === categoryName
- ? {
- ...cat,
- chipOptions: [
- ...cat.chipOptions,
- { id: newId, label: "", state: "custom" },
- ],
- }
- : cat,
- ),
- );
- },
- onCustomChipConfirm: (
- categoryName: string,
- chipId: string,
- value: string,
- ) => {
- setRuleCardCategories((prev) =>
- prev.map((cat) =>
- cat.name === categoryName
- ? {
- ...cat,
- chipOptions: cat.chipOptions.map((opt) =>
- opt.id === chipId
- ? { ...opt, label: value, state: "selected" }
- : opt,
- ),
- }
- : cat,
- ),
- );
- },
- onCustomChipClose: (categoryName: string, chipId: string) => {
- setRuleCardCategories((prev) =>
- prev.map((cat) =>
- cat.name === categoryName
- ? {
- ...cat,
- chipOptions: cat.chipOptions.filter(
- (opt) => opt.id !== chipId,
- ),
- }
- : cat,
- ),
- );
- },
- },
- {
- name: "Membership",
- chipOptions: [
- { id: "membership-1", label: "Open Admission", state: "unselected" },
- ],
- onChipClick: (categoryName: string, chipId: string) => {
- setRuleCardCategories((prev) =>
- prev.map((cat) =>
- cat.name === categoryName
- ? {
- ...cat,
- chipOptions: cat.chipOptions.map((opt) =>
- opt.id === chipId
- ? {
- ...opt,
- state:
- opt.state === "selected"
- ? "unselected"
- : "selected",
- }
- : opt,
- ),
- }
- : cat,
- ),
- );
- },
- onAddClick: (categoryName: string) => {
- const newId = `custom-${categoryName}-${++ruleCardIdCounter}`;
- setRuleCardCategories((prev) =>
- prev.map((cat) =>
- cat.name === categoryName
- ? {
- ...cat,
- chipOptions: [
- ...cat.chipOptions,
- { id: newId, label: "", state: "custom" },
- ],
- }
- : cat,
- ),
- );
- },
- onCustomChipConfirm: (
- categoryName: string,
- chipId: string,
- value: string,
- ) => {
- setRuleCardCategories((prev) =>
- prev.map((cat) =>
- cat.name === categoryName
- ? {
- ...cat,
- chipOptions: cat.chipOptions.map((opt) =>
- opt.id === chipId
- ? { ...opt, label: value, state: "selected" }
- : opt,
- ),
- }
- : cat,
- ),
- );
- },
- onCustomChipClose: (categoryName: string, chipId: string) => {
- setRuleCardCategories((prev) =>
- prev.map((cat) =>
- cat.name === categoryName
- ? {
- ...cat,
- chipOptions: cat.chipOptions.filter(
- (opt) => opt.id !== chipId,
- ),
- }
- : cat,
- ),
- );
- },
- },
- {
- name: "Decision-making",
- chipOptions: [
- { id: "decision-1", label: "Lazy Consensus", state: "unselected" },
- { id: "decision-2", label: "Modified Consensus", state: "unselected" },
- ],
- onChipClick: (categoryName: string, chipId: string) => {
- setRuleCardCategories((prev) =>
- prev.map((cat) =>
- cat.name === categoryName
- ? {
- ...cat,
- chipOptions: cat.chipOptions.map((opt) =>
- opt.id === chipId
- ? {
- ...opt,
- state:
- opt.state === "selected"
- ? "unselected"
- : "selected",
- }
- : opt,
- ),
- }
- : cat,
- ),
- );
- },
- onAddClick: (categoryName: string) => {
- const newId = `custom-${categoryName}-${++ruleCardIdCounter}`;
- setRuleCardCategories((prev) =>
- prev.map((cat) =>
- cat.name === categoryName
- ? {
- ...cat,
- chipOptions: [
- ...cat.chipOptions,
- { id: newId, label: "", state: "custom" },
- ],
- }
- : cat,
- ),
- );
- },
- onCustomChipConfirm: (
- categoryName: string,
- chipId: string,
- value: string,
- ) => {
- setRuleCardCategories((prev) =>
- prev.map((cat) =>
- cat.name === categoryName
- ? {
- ...cat,
- chipOptions: cat.chipOptions.map((opt) =>
- opt.id === chipId
- ? { ...opt, label: value, state: "selected" }
- : opt,
- ),
- }
- : cat,
- ),
- );
- },
- onCustomChipClose: (categoryName: string, chipId: string) => {
- setRuleCardCategories((prev) =>
- prev.map((cat) =>
- cat.name === categoryName
- ? {
- ...cat,
- chipOptions: cat.chipOptions.filter(
- (opt) => opt.id !== chipId,
- ),
- }
- : cat,
- ),
- );
- },
- },
- {
- name: "Conflict management",
- chipOptions: [
- { id: "conflict-1", label: "Code of Conduct", state: "unselected" },
- { id: "conflict-2", label: "Restorative Justice", state: "unselected" },
- ],
- onChipClick: (categoryName: string, chipId: string) => {
- setRuleCardCategories((prev) =>
- prev.map((cat) =>
- cat.name === categoryName
- ? {
- ...cat,
- chipOptions: cat.chipOptions.map((opt) =>
- opt.id === chipId
- ? {
- ...opt,
- state:
- opt.state === "selected"
- ? "unselected"
- : "selected",
- }
- : opt,
- ),
- }
- : cat,
- ),
- );
- },
- onAddClick: (categoryName: string) => {
- const newId = `custom-${categoryName}-${++ruleCardIdCounter}`;
- setRuleCardCategories((prev) =>
- prev.map((cat) =>
- cat.name === categoryName
- ? {
- ...cat,
- chipOptions: [
- ...cat.chipOptions,
- { id: newId, label: "", state: "custom" },
- ],
- }
- : cat,
- ),
- );
- },
- onCustomChipConfirm: (
- categoryName: string,
- chipId: string,
- value: string,
- ) => {
- setRuleCardCategories((prev) =>
- prev.map((cat) =>
- cat.name === categoryName
- ? {
- ...cat,
- chipOptions: cat.chipOptions.map((opt) =>
- opt.id === chipId
- ? { ...opt, label: value, state: "selected" }
- : opt,
- ),
- }
- : cat,
- ),
- );
- },
- onCustomChipClose: (categoryName: string, chipId: string) => {
- setRuleCardCategories((prev) =>
- prev.map((cat) =>
- cat.name === categoryName
- ? {
- ...cat,
- chipOptions: cat.chipOptions.filter(
- (opt) => opt.id !== chipId,
- ),
- }
- : cat,
- ),
- );
- },
- },
- ]);
-
return (
-
+
-
+
Component Preview
- RuleCard, Card, and Chip component examples - states, palettes,
- sizes, and interactions
+ Navigation Link (Figma 21861:21428) and List (21863:45631 / 45493 /
+ 4405) with ListEntry (21844:4118). Use Tab and hover to review
+ states.
- {/* Chip Component - Controls */}
-
-
- Chip Component (Controls)
+
+
+ Link
-
- {/* Default palette */}
-
-
- Default palette
-
-
-
- setChipStates((prev) => ({
- ...prev,
- "default-s":
- prev["default-s"] === "selected"
- ? "unselected"
- : "selected",
- }))
- }
- />
-
- setChipStates((prev) => ({
- ...prev,
- "default-m":
- prev["default-m"] === "selected"
- ? "unselected"
- : "selected",
- }))
- }
- />
-
- {customChips
- .filter((chip) => chip.palette === "default")
- .map((chip) => (
- {
- e.stopPropagation();
- setCustomChips((prev) =>
- prev.map((c) =>
- c.id === chip.id
- ? { ...c, label: value, state: "selected" }
- : c,
- ),
- );
- }}
- onClose={(e) => {
- e.stopPropagation();
- setCustomChips((prev) =>
- prev.filter((c) => c.id !== chip.id),
- );
- }}
- onClick={(e) => {
- e.stopPropagation();
- // Only toggle if the chip is in Selected or Unselected state (not Custom)
- if (
- chip.state === "selected" ||
- chip.state === "unselected"
- ) {
- setCustomChips((prev) =>
- prev.map((c) =>
- c.id === chip.id
- ? {
- ...c,
- state:
- c.state === "selected"
- ? "unselected"
- : "selected",
- }
- : c,
- ),
- );
- }
- }}
- />
- ))}
- {/* Add new custom chip button - Ghost button style */}
- {
- const newId = `custom-${Date.now()}`;
- setCustomChips((prev) => [
- ...prev,
- {
- id: newId,
- label: "",
- state: "custom",
- palette: "default",
- size: "s",
- },
- ]);
- }}
- className="flex gap-[var(--measures-spacing-050,2px)] items-center justify-center p-[var(--measures-spacing-200,8px)] rounded-[var(--measures-radius-full,9999px)] shrink-0 hover:opacity-80 transition-opacity"
- >
- {/* Plus icon */}
-
+
+
+ Light surface
+
+
+
+
+ Primary / default
+
+
-
-
- {/* Text */}
-
- Add Applicable Scope
-
-
+ Primary / paragraph
+
+
+ Secondary / default
+
+
+ Secondary / paragraph
+
+
+
+ undefined}
+ >
+ Button (paragraph)
+
+
- {/* Inverse palette - on white background */}
-
-
- Inverse palette (on white background)
-
-
-
-
- setChipStates((prev) => ({
- ...prev,
- "inverse-s":
- prev["inverse-s"] === "selected"
- ? "unselected"
- : "selected",
- }))
- }
- />
-
- setChipStates((prev) => ({
- ...prev,
- "inverse-m":
- prev["inverse-m"] === "selected"
- ? "unselected"
- : "selected",
- }))
- }
- />
-
+
+
+ Dark surface
+
+
+
+
+ Primary / default
+
+
+ Primary / paragraph
+
+
+ Secondary / default
+
+
+ Secondary / paragraph
+
+
+
+ undefined}
+ >
+ Button (paragraph)
+
- {/* Card Component - Create flow selection card variants */}
-
-
- Card Component
+
+
+ Divider
-
-
- Horizontal and vertical orientations with recommended and selected
- states.
-
-
-
-
- Horizontal + Recommended
-
- console.warn("Card clicked")}
- />
-
-
-
- Horizontal + Selected
-
- console.warn("Card clicked")}
- />
-
-
-
- Vertical + Recommended
-
- console.warn("Card clicked")}
- />
-
-
-
- Vertical + Selected
-
- console.warn("Card clicked")}
- />
-
+
+ Utility / Divider (450:1941). List rows use the Content line; Menu is
+ slightly higher-contrast.
+
+
- {/* Collapsed State - Large */}
-
- Collapsed State - Large (L)
+
+ List
-
- console.warn("Card clicked: Mutual Aid Mondays")}
- />
-
-
-
- {/* Collapsed State - Medium */}
-
-
- Collapsed State - Medium (M)
-
-
- console.warn("Card clicked: Mutual Aid Mondays")}
- />
-
-
-
- {/* Expanded State - Large */}
-
-
- Expanded State - Large (L)
-
-
- console.warn("Card clicked: Mutual Aid Mondays")}
- />
-
-
-
- {/* Expanded State - Medium */}
-
-
- Expanded State - Medium (M)
-
-
- console.warn("Card clicked: Mutual Aid Mondays")}
- />
-
-
-
- {/* Different Background Colors */}
-
-
- Different Background Colors
-
-
-
-
- }
- onClick={() => console.warn("Consensus clusters selected")}
- />
-
- }
- onClick={() => console.warn("Consensus selected")}
- />
+
+ List frame: S (21863:45631), M (21863:45493), L (21844:4405). Row:
+ ListEntry (21844:4118).
+
+
-
-
- {/* Logo Fallback */}
-
-
- Logo Fallback (Community Initials)
-
-
- console.warn("Community Example selected")}
- />
-
-
-
- {/* MultiSelect Component */}
-
-
- MultiSelect Component (Controls)
-
-
- {/* Small size */}
-
-
- {/* Medium size */}
-
diff --git a/app/api/rules/[id]/duplicate/route.ts b/app/api/rules/[id]/duplicate/route.ts
new file mode 100644
index 0000000..aa9c69f
--- /dev/null
+++ b/app/api/rules/[id]/duplicate/route.ts
@@ -0,0 +1,58 @@
+import { NextResponse } from "next/server";
+import { prisma } from "../../../../../lib/server/db";
+import { isDatabaseConfigured } from "../../../../../lib/server/env";
+import {
+ dbUnavailable,
+ forbidden,
+ notFound,
+ unauthorized,
+} from "../../../../../lib/server/responses";
+import { getSessionUser } from "../../../../../lib/server/session";
+import { apiRoute } from "../../../../../lib/server/apiRoute";
+
+type RouteContext = { params: Promise<{ id: string }> };
+
+export const POST = apiRoute
(
+ "rules.byId.duplicate",
+ async (_request, context) => {
+ if (!isDatabaseConfigured()) {
+ return dbUnavailable();
+ }
+
+ const user = await getSessionUser();
+ if (!user) {
+ return unauthorized();
+ }
+
+ const { id } = await context.params;
+
+ const source = await prisma.publishedRule.findUnique({
+ where: { id },
+ });
+ if (!source) {
+ return notFound();
+ }
+ if (source.userId !== user.id) {
+ return forbidden("You do not have permission to duplicate this rule");
+ }
+
+ const newRule = await prisma.publishedRule.create({
+ data: {
+ userId: user.id,
+ title: `${source.title} (Copy)`,
+ summary: source.summary,
+ document: source.document,
+ },
+ });
+
+ return NextResponse.json({
+ rule: {
+ id: newRule.id,
+ title: newRule.title,
+ summary: newRule.summary,
+ createdAt: newRule.createdAt,
+ updatedAt: newRule.updatedAt,
+ },
+ });
+ },
+);
diff --git a/app/api/rules/[id]/route.ts b/app/api/rules/[id]/route.ts
index 5d6e497..2092e54 100644
--- a/app/api/rules/[id]/route.ts
+++ b/app/api/rules/[id]/route.ts
@@ -1,7 +1,14 @@
import { NextResponse } from "next/server";
+import { prisma } from "../../../../lib/server/db";
import { isDatabaseConfigured } from "../../../../lib/server/env";
-import { dbUnavailable, notFound } from "../../../../lib/server/responses";
+import {
+ dbUnavailable,
+ forbidden,
+ notFound,
+ unauthorized,
+} from "../../../../lib/server/responses";
import { getPublicPublishedRuleById } from "../../../../lib/server/publishedRules";
+import { getSessionUser } from "../../../../lib/server/session";
import { apiRoute } from "../../../../lib/server/apiRoute";
type RouteContext = { params: Promise<{ id: string }> };
@@ -20,6 +27,47 @@ export const GET = apiRoute(
return notFound();
}
- return NextResponse.json({ rule });
+ const user = await getSessionUser();
+ let viewerIsOwner = false;
+ if (user) {
+ const ownerRow = await prisma.publishedRule.findUnique({
+ where: { id },
+ select: { userId: true },
+ });
+ viewerIsOwner = ownerRow?.userId === user.id;
+ }
+
+ return NextResponse.json({ rule, viewerIsOwner });
+ },
+);
+
+export const DELETE = apiRoute(
+ "rules.byId.delete",
+ async (_request, context) => {
+ if (!isDatabaseConfigured()) {
+ return dbUnavailable();
+ }
+
+ const user = await getSessionUser();
+ if (!user) {
+ return unauthorized();
+ }
+
+ const { id } = await context.params;
+
+ const row = await prisma.publishedRule.findUnique({
+ where: { id },
+ select: { id: true, userId: true },
+ });
+ if (!row) {
+ return notFound();
+ }
+ if (row.userId !== user.id) {
+ return forbidden("You do not have permission to delete this rule");
+ }
+
+ await prisma.publishedRule.delete({ where: { id: row.id } });
+
+ return NextResponse.json({ ok: true });
},
);
diff --git a/app/api/rules/me/route.ts b/app/api/rules/me/route.ts
new file mode 100644
index 0000000..69d5e8f
--- /dev/null
+++ b/app/api/rules/me/route.ts
@@ -0,0 +1,31 @@
+import { NextRequest, NextResponse } from "next/server";
+import { isDatabaseConfigured } from "../../../../lib/server/env";
+import { listPublishedRulesForUser } from "../../../../lib/server/publishedRules";
+import {
+ dbUnavailable,
+ internalError,
+ unauthorized,
+} from "../../../../lib/server/responses";
+import { getSessionUser } from "../../../../lib/server/session";
+import { apiRoute } from "../../../../lib/server/apiRoute";
+
+export const GET = apiRoute("rules.me.list", async (request: NextRequest) => {
+ if (!isDatabaseConfigured()) {
+ return dbUnavailable();
+ }
+
+ const user = await getSessionUser();
+ if (!user) {
+ return unauthorized();
+ }
+
+ const { searchParams } = new URL(request.url);
+ const take = Math.min(Number(searchParams.get("limit") ?? "50") || 50, 100);
+
+ const rules = await listPublishedRulesForUser(user.id, take);
+ if (rules === null) {
+ return internalError("Failed to list rules");
+ }
+
+ return NextResponse.json({ rules });
+});
diff --git a/app/api/user/email-change/request/route.ts b/app/api/user/email-change/request/route.ts
new file mode 100644
index 0000000..f27cf7b
--- /dev/null
+++ b/app/api/user/email-change/request/route.ts
@@ -0,0 +1,133 @@
+import { NextRequest, NextResponse } from "next/server";
+import { prisma } from "../../../../../lib/server/db";
+import {
+ getSessionPepper,
+ isDatabaseConfigured,
+} from "../../../../../lib/server/env";
+import {
+ hashSessionToken,
+ newSessionToken,
+} from "../../../../../lib/server/hash";
+import { sendEmailChangeEmail } from "../../../../../lib/server/mail";
+import { rateLimitKey } from "../../../../../lib/server/rateLimit";
+import { apiRoute } from "../../../../../lib/server/apiRoute";
+import { logRouteError } from "../../../../../lib/server/requestId";
+import {
+ dbUnavailable,
+ errorJson,
+ rateLimited,
+ serverMisconfigured,
+ unauthorized,
+} from "../../../../../lib/server/responses";
+import { getSessionUser } from "../../../../../lib/server/session";
+import { readLimitedJson } from "../../../../../lib/server/validation/requestBody";
+import { emailChangeRequestBodySchema } from "../../../../../lib/server/validation/userEmailChangeSchemas";
+import { jsonFromZodError } from "../../../../../lib/server/validation/zodHttp";
+
+const EMAIL_CHANGE_TTL_MS = 15 * 60 * 1000;
+const EMAIL_MIN_INTERVAL_MS = 60 * 1000;
+const IP_MIN_INTERVAL_MS = 20 * 1000;
+const SCOPE = "user.emailChange.request";
+
+export const POST = apiRoute(SCOPE, async (request: NextRequest, _ctx, { requestId }) => {
+ if (!isDatabaseConfigured()) {
+ return dbUnavailable();
+ }
+
+ const user = await getSessionUser();
+ if (!user) {
+ return unauthorized();
+ }
+
+ const limited = await readLimitedJson(request);
+ if (limited.ok === false) {
+ return limited.response;
+ }
+
+ const parsed = emailChangeRequestBodySchema.safeParse(limited.value);
+ if (!parsed.success) {
+ return jsonFromZodError(parsed.error);
+ }
+
+ const { newEmail } = parsed.data;
+ if (newEmail === user.email) {
+ return errorJson(
+ "validation_error",
+ "New email must be different from your current email",
+ 400,
+ );
+ }
+
+ const ip =
+ request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
+ request.headers.get("x-real-ip") ??
+ "unknown";
+
+ const rlEmail = rateLimitKey(
+ `email-change-email:${newEmail}`,
+ EMAIL_MIN_INTERVAL_MS,
+ );
+ if (rlEmail.ok === false) {
+ return rateLimited(rlEmail.retryAfterMs);
+ }
+
+ const rlIp = rateLimitKey(`email-change-ip:${ip}`, IP_MIN_INTERVAL_MS);
+ if (rlIp.ok === false) {
+ return rateLimited(rlIp.retryAfterMs);
+ }
+
+ const rlUser = rateLimitKey(
+ `email-change-user:${user.id}`,
+ EMAIL_MIN_INTERVAL_MS,
+ );
+ if (rlUser.ok === false) {
+ return rateLimited(rlUser.retryAfterMs);
+ }
+
+ const existing = await prisma.user.findUnique({ where: { email: newEmail } });
+ if (existing && existing.id !== user.id) {
+ return errorJson(
+ "validation_error",
+ "That email is already used by another account",
+ 400,
+ { details: { field: "newEmail" } },
+ );
+ }
+
+ let pepper: string;
+ try {
+ pepper = getSessionPepper();
+ } catch {
+ return serverMisconfigured();
+ }
+
+ const token = newSessionToken();
+ const tokenHash = hashSessionToken(token, pepper);
+ const expiresAt = new Date(Date.now() + EMAIL_CHANGE_TTL_MS);
+
+ await prisma.emailChangeToken.deleteMany({ where: { userId: user.id } });
+ await prisma.emailChangeToken.create({
+ data: {
+ userId: user.id,
+ newEmail,
+ tokenHash,
+ expiresAt,
+ },
+ });
+
+ const origin = request.nextUrl.origin;
+ const verifyUrl = `${origin}/api/user/email-change/verify?token=${encodeURIComponent(token)}`;
+
+ try {
+ await sendEmailChangeEmail(newEmail, verifyUrl);
+ } catch (err) {
+ logRouteError(SCOPE, requestId, err, {
+ phase: "sendEmailChangeEmail",
+ newEmail,
+ });
+ await prisma.emailChangeToken.deleteMany({ where: { userId: user.id } });
+ return errorJson("mail_failed", "Could not send email", 502);
+ }
+
+ return NextResponse.json({ ok: true });
+});
diff --git a/app/api/user/email-change/verify/route.ts b/app/api/user/email-change/verify/route.ts
new file mode 100644
index 0000000..c40399b
--- /dev/null
+++ b/app/api/user/email-change/verify/route.ts
@@ -0,0 +1,172 @@
+import { NextRequest, NextResponse } from "next/server";
+import { prisma } from "../../../../../lib/server/db";
+import {
+ getSessionPepper,
+ isDatabaseConfigured,
+} from "../../../../../lib/server/env";
+import { hashSessionToken } from "../../../../../lib/server/hash";
+import {
+ createSessionForUser,
+ getValidatedSessionTokenHashForUser,
+ setSessionCookie,
+} from "../../../../../lib/server/session";
+import { dbUnavailable } from "../../../../../lib/server/responses";
+import {
+ REQUEST_ID_HEADER,
+ getOrCreateRequestId,
+ logRouteError,
+} from "../../../../../lib/server/requestId";
+
+const SCOPE = "user.emailChange.verify";
+
+export async function GET(request: NextRequest) {
+ const requestId = getOrCreateRequestId(request);
+
+ if (!isDatabaseConfigured()) {
+ const res = dbUnavailable();
+ res.headers.set(REQUEST_ID_HEADER, requestId);
+ return res;
+ }
+
+ try {
+ const token = request.nextUrl.searchParams.get("token");
+ if (!token || token.length < 10) {
+ return redirectWithRequestId(
+ request,
+ "/profile?error=email_change_invalid",
+ requestId,
+ );
+ }
+
+ let pepper: string;
+ try {
+ pepper = getSessionPepper();
+ } catch (err) {
+ logRouteError(SCOPE, requestId, err, { phase: "getSessionPepper" });
+ return redirectWithRequestId(
+ request,
+ "/profile?error=email_change_server",
+ requestId,
+ );
+ }
+
+ const tokenHash = hashSessionToken(token, pepper);
+ const row = await prisma.emailChangeToken.findUnique({
+ where: { tokenHash },
+ });
+
+ if (!row || row.expiresAt < new Date()) {
+ return redirectWithRequestId(
+ request,
+ "/profile?error=email_change_expired",
+ requestId,
+ );
+ }
+
+ const keepSessionTokenHash = await getValidatedSessionTokenHashForUser(
+ row.userId,
+ );
+
+ try {
+ await prisma.$transaction(async (tx) => {
+ const claim = await tx.emailChangeToken.findUnique({
+ where: { id: row.id },
+ });
+ if (!claim || claim.expiresAt < new Date()) {
+ throw Object.assign(new Error("expired"), { __expired: true });
+ }
+
+ const taken = await tx.user.findFirst({
+ where: {
+ email: claim.newEmail,
+ NOT: { id: claim.userId },
+ },
+ });
+ if (taken) {
+ await tx.emailChangeToken.delete({ where: { id: claim.id } });
+ throw Object.assign(new Error("taken"), { __taken: true });
+ }
+
+ await tx.user.update({
+ where: { id: claim.userId },
+ data: { email: claim.newEmail },
+ });
+ await tx.emailChangeToken.delete({ where: { id: claim.id } });
+
+ if (keepSessionTokenHash) {
+ await tx.session.deleteMany({
+ where: {
+ userId: claim.userId,
+ tokenHash: { not: keepSessionTokenHash },
+ },
+ });
+ } else {
+ await tx.session.deleteMany({
+ where: { userId: claim.userId },
+ });
+ }
+ });
+ } catch (err: unknown) {
+ if (
+ err &&
+ typeof err === "object" &&
+ "__taken" in err &&
+ (err as { __taken?: boolean }).__taken
+ ) {
+ return redirectWithRequestId(
+ request,
+ "/profile?error=email_change_taken",
+ requestId,
+ );
+ }
+ if (
+ err &&
+ typeof err === "object" &&
+ "__expired" in err &&
+ (err as { __expired?: boolean }).__expired
+ ) {
+ return redirectWithRequestId(
+ request,
+ "/profile?error=email_change_expired",
+ requestId,
+ );
+ }
+ logRouteError(SCOPE, requestId, err, { phase: "transaction" });
+ return redirectWithRequestId(
+ request,
+ "/profile?error=email_change_server",
+ requestId,
+ );
+ }
+
+ if (!keepSessionTokenHash) {
+ const { token: sessionToken, expiresAt } = await createSessionForUser(
+ row.userId,
+ );
+ await setSessionCookie(sessionToken, expiresAt);
+ }
+
+ return redirectWithRequestId(
+ request,
+ "/profile?email_change=ok",
+ requestId,
+ );
+ } catch (err) {
+ logRouteError(SCOPE, requestId, err);
+ return redirectWithRequestId(
+ request,
+ "/profile?error=email_change_server",
+ requestId,
+ );
+ }
+}
+
+function redirectWithRequestId(
+ request: NextRequest,
+ path: string,
+ requestId: string,
+): NextResponse {
+ const res = NextResponse.redirect(new URL(path, request.url));
+ res.headers.set(REQUEST_ID_HEADER, requestId);
+ return res;
+}
diff --git a/app/api/user/me/route.ts b/app/api/user/me/route.ts
new file mode 100644
index 0000000..40e26ee
--- /dev/null
+++ b/app/api/user/me/route.ts
@@ -0,0 +1,43 @@
+import { NextResponse } from "next/server";
+import { prisma } from "../../../../lib/server/db";
+import { isDatabaseConfigured } from "../../../../lib/server/env";
+import {
+ dbUnavailable,
+ internalError,
+ unauthorized,
+} from "../../../../lib/server/responses";
+import {
+ clearSessionCookie,
+ getSessionUser,
+} from "../../../../lib/server/session";
+import { apiRoute } from "../../../../lib/server/apiRoute";
+
+/**
+ * Delete the signed-in user and associated data.
+ *
+ * **Policy (CR-86 / Ticket 15):** Prisma `User` deletion cascades `Session` and
+ * `RuleDraft`. `PublishedRule` uses `onDelete: SetNull` on `userId`, so published
+ * rules remain public with `userId = null` (anonymous/orphan rows) rather than
+ * being removed with the account. Change would require a schema migration if
+ * product later requires deleting all published rules with the user.
+ */
+export const DELETE = apiRoute("user.me.delete", async () => {
+ if (!isDatabaseConfigured()) {
+ return dbUnavailable();
+ }
+
+ const user = await getSessionUser();
+ if (!user) {
+ return unauthorized();
+ }
+
+ try {
+ await prisma.user.delete({ where: { id: user.id } });
+ } catch {
+ return internalError("Failed to delete account");
+ }
+
+ await clearSessionCookie();
+
+ return NextResponse.json({ ok: true });
+});
diff --git a/app/components/asset/Icon.tsx b/app/components/asset/Icon.tsx
index 6adaea1..cddba05 100644
--- a/app/components/asset/Icon.tsx
+++ b/app/components/asset/Icon.tsx
@@ -1,19 +1,59 @@
"use client";
import { memo } from "react";
+import ArrowBackIcon from "./icon/arrow_back.svg";
+import ContentCopyIcon from "./icon/content_copy.svg";
+import EditIcon from "./icon/edit.svg";
import ExclamationIcon from "./icon/exclamation.svg";
+import ChevronRightIcon from "./icon/chevron_right.svg";
+import LogOutIcon from "./icon/log_out.svg";
+import MailIcon from "./icon/mail.svg";
+import WarningIcon from "./icon/warning.svg";
-export type IconName = "exclamation";
+export const ICON_NAME_OPTIONS = [
+ "arrow_back",
+ "chevron_right",
+ "content_copy",
+ "edit",
+ "exclamation",
+ "log_out",
+ "mail",
+ "warning",
+] as const;
+
+export type IconName = (typeof ICON_NAME_OPTIONS)[number];
+
+type SvgComponent =
+ | React.ComponentType>
+ | { default: React.ComponentType> };
/** SVG import may be a React component or a module object { default: Component } (e.g. with Turbopack) */
-const iconMap: Record<
- IconName,
- | React.ComponentType>
- | { default: React.ComponentType> }
-> = {
+const iconMap: Record = {
+ arrow_back: ArrowBackIcon,
+ chevron_right: ChevronRightIcon,
+ content_copy: ContentCopyIcon,
+ edit: EditIcon,
exclamation: ExclamationIcon,
+ log_out: LogOutIcon,
+ mail: MailIcon,
+ warning: WarningIcon,
};
+function resolveSvgComponent(module: SvgComponent) {
+ if (
+ typeof module === "object" &&
+ module !== null &&
+ "default" in module
+ ) {
+ return (
+ module as {
+ default: React.ComponentType>;
+ }
+ ).default;
+ }
+ return module as React.ComponentType>;
+}
+
export interface IconProps {
name: IconName;
className?: string;
@@ -30,18 +70,26 @@ function IconComponent({
}: IconProps) {
const SvgModule = iconMap[name];
if (!SvgModule) return null;
- // Turbopack/bundler may expose SVG as { default: Component } instead of the component directly
- const Svg =
- typeof SvgModule === "object" &&
- SvgModule !== null &&
- "default" in SvgModule
- ? (
- SvgModule as {
- default: React.ComponentType>;
- }
- ).default
- : (SvgModule as React.ComponentType>);
- if (typeof Svg !== "function") return null;
+ const resolved = resolveSvgComponent(SvgModule);
+
+ // Turbopack/webpack mismatch: `.svg` may be a URL string instead of SVGR output.
+ if (typeof resolved === "string") {
+ return (
+
+ );
+ }
+
+ if (resolved == null) return null;
+
+ const Svg = resolved as React.ComponentType>;
+
return (
+
+
diff --git a/app/components/asset/icon/chevron_right.svg b/app/components/asset/icon/chevron_right.svg
new file mode 100644
index 0000000..380fb1d
--- /dev/null
+++ b/app/components/asset/icon/chevron_right.svg
@@ -0,0 +1,6 @@
+
+
+
diff --git a/app/components/asset/icon/content_copy.svg b/app/components/asset/icon/content_copy.svg
new file mode 100644
index 0000000..5380eb7
--- /dev/null
+++ b/app/components/asset/icon/content_copy.svg
@@ -0,0 +1,6 @@
+
+
+
diff --git a/app/components/asset/icon/edit.svg b/app/components/asset/icon/edit.svg
new file mode 100644
index 0000000..d97a684
--- /dev/null
+++ b/app/components/asset/icon/edit.svg
@@ -0,0 +1,6 @@
+
+
+
diff --git a/app/components/asset/icon/log_out.svg b/app/components/asset/icon/log_out.svg
new file mode 100644
index 0000000..a63157c
--- /dev/null
+++ b/app/components/asset/icon/log_out.svg
@@ -0,0 +1,6 @@
+
+
+
diff --git a/app/components/asset/icon/mail.svg b/app/components/asset/icon/mail.svg
new file mode 100644
index 0000000..62e5c47
--- /dev/null
+++ b/app/components/asset/icon/mail.svg
@@ -0,0 +1,6 @@
+
+
+
diff --git a/app/components/asset/icon/warning.svg b/app/components/asset/icon/warning.svg
new file mode 100644
index 0000000..45c270c
--- /dev/null
+++ b/app/components/asset/icon/warning.svg
@@ -0,0 +1,6 @@
+
+
+
diff --git a/app/components/asset/index.tsx b/app/components/asset/index.tsx
index 47bc8b8..7ae1046 100644
--- a/app/components/asset/index.tsx
+++ b/app/components/asset/index.tsx
@@ -1,3 +1,3 @@
-export { default as Icon } from "./Icon";
+export { default as Icon, ICON_NAME_OPTIONS } from "./Icon";
export type { IconName, IconProps } from "./Icon";
export { default as Logo } from "./logo";
diff --git a/app/components/asset/logo/Logo.tsx b/app/components/asset/logo/Logo.tsx
index 1e494f0..471d6b6 100644
--- a/app/components/asset/logo/Logo.tsx
+++ b/app/components/asset/logo/Logo.tsx
@@ -42,14 +42,14 @@ const Logo = memo(
},
footer: {
containerHeight:
- "h-[41px] sm:h-[calc(40px*1.37)] lg:h-[calc(40px*2.05)]",
- gap: "gap-[8.28px] sm:gap-[calc(8px*1.37)] lg:gap-[calc(8px*2.05)]",
+ "h-[41px] md:h-[calc(40px*1.37)] lg:h-[calc(40px*2.05)]",
+ gap: "gap-[8.28px] md:gap-[calc(8px*1.37)] lg:gap-[calc(8px*2.05)]",
textSize:
- "text-[21.97px] sm:text-[calc(21.97px*1.37)] lg:text-[calc(21.97px*2.05)]",
+ "text-[21.97px] md:text-[calc(21.97px*1.37)] lg:text-[calc(21.97px*2.05)]",
lineHeight:
- "leading-[27.05px] sm:leading-[calc(27.05px*1.37)] lg:leading-[calc(27.05px*2.05)]",
+ "leading-[27.05px] md:leading-[calc(27.05px*1.37)] lg:leading-[calc(27.05px*2.05)]",
iconSize:
- "w-[27.05px] h-[27.05px] sm:w-[calc(27.05px*1.37)] sm:h-[calc(27.05px*1.37)] lg:w-[calc(27.05px*2.05)] lg:h-[calc(27.05px*2.05)]",
+ "w-[27.05px] h-[27.05px] md:w-[calc(27.05px*1.37)] md:h-[calc(27.05px*1.37)] lg:w-[calc(27.05px*2.05)] lg:h-[calc(27.05px*2.05)]",
},
createFlow: {
containerHeight: "h-[30px] md:h-[41px]",
diff --git a/app/components/cards/RuleCard/RuleCard.container.tsx b/app/components/cards/RuleCard/RuleCard.container.tsx
index 4a39dc0..13909e1 100644
--- a/app/components/cards/RuleCard/RuleCard.container.tsx
+++ b/app/components/cards/RuleCard/RuleCard.container.tsx
@@ -17,6 +17,10 @@ declare global {
}
}
+/**
+ * Figma: "Card / Rule" — e.g. profile `22143:900771` when **Has bottom link** is on
+ * (`hasBottomLinks` + `bottomLinks` / optional `bottomStatusLabel`).
+ */
const RuleCardContainer = memo(
({
title,
@@ -32,10 +36,14 @@ const RuleCardContainer = memo(
logoAlt,
communityInitials,
hideCategoryAddButton = false,
+ hasBottomLinks = false,
+ bottomStatusLabel,
+ bottomLinks,
}) => {
const size = sizeProp ?? "L";
const handleClick = () => {
+ if (hasBottomLinks) return;
// Basic analytics event tracking
if (typeof window !== "undefined" && window.gtag) {
window.gtag("event", "template_selected", {
@@ -56,6 +64,7 @@ const RuleCardContainer = memo(
};
const handleKeyDown = (event: React.KeyboardEvent) => {
+ if (hasBottomLinks) return;
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
handleClick();
@@ -69,8 +78,8 @@ const RuleCardContainer = memo(
icon={icon}
backgroundColor={backgroundColor}
className={className}
- onClick={handleClick}
- onKeyDown={handleKeyDown}
+ onClick={hasBottomLinks ? undefined : handleClick}
+ onKeyDown={hasBottomLinks ? undefined : handleKeyDown}
expanded={expanded}
size={size}
categories={categories}
@@ -78,6 +87,9 @@ const RuleCardContainer = memo(
logoAlt={logoAlt}
communityInitials={communityInitials}
hideCategoryAddButton={hideCategoryAddButton}
+ hasBottomLinks={hasBottomLinks}
+ bottomStatusLabel={bottomStatusLabel}
+ bottomLinks={bottomLinks}
/>
);
},
diff --git a/app/components/cards/RuleCard/RuleCard.types.ts b/app/components/cards/RuleCard/RuleCard.types.ts
index 846374c..fa41176 100644
--- a/app/components/cards/RuleCard/RuleCard.types.ts
+++ b/app/components/cards/RuleCard/RuleCard.types.ts
@@ -13,6 +13,14 @@ export interface Category {
onCustomChipClose?: (categoryName: string, chipId: string) => void;
}
+/** Bottom row for `Card / Rule` when Figma **Has bottom link** is on (profile, etc.). */
+export interface RuleCardBottomLink {
+ id: string;
+ label: string;
+ href?: string;
+ onClick?: () => void;
+}
+
export interface RuleCardProps {
title: string;
description?: string;
@@ -28,6 +36,15 @@ export interface RuleCardProps {
communityInitials?: string;
/** Hide the per-category "+" add chip affordance (e.g. read-only template review). */
hideCategoryAddButton?: boolean;
+ /**
+ * Figma `Card / Rule` variant: description + optional status chip + text links
+ * (e.g. Duplicate / Delete, or Continue / Start new rule). When set, the card
+ * is not a single interactive button — links handle their own actions.
+ */
+ hasBottomLinks?: boolean;
+ /** Uppercase chip (e.g. IN PROGRESS); omit when no left badge. */
+ bottomStatusLabel?: string;
+ bottomLinks?: RuleCardBottomLink[];
}
export interface RuleCardViewProps {
@@ -36,8 +53,8 @@ export interface RuleCardViewProps {
icon?: React.ReactNode;
backgroundColor: string;
className: string;
- onClick: () => void;
- onKeyDown: (_event: React.KeyboardEvent) => void;
+ onClick?: () => void;
+ onKeyDown?: (_event: React.KeyboardEvent) => void;
expanded: boolean;
size: "XS" | "S" | "M" | "L";
categories?: Category[];
@@ -45,4 +62,7 @@ export interface RuleCardViewProps {
logoAlt?: string;
communityInitials?: string;
hideCategoryAddButton?: boolean;
+ hasBottomLinks?: boolean;
+ bottomStatusLabel?: string;
+ bottomLinks?: RuleCardBottomLink[];
}
diff --git a/app/components/cards/RuleCard/RuleCard.view.tsx b/app/components/cards/RuleCard/RuleCard.view.tsx
index f416672..69f440e 100644
--- a/app/components/cards/RuleCard/RuleCard.view.tsx
+++ b/app/components/cards/RuleCard/RuleCard.view.tsx
@@ -3,7 +3,8 @@
import Image from "next/image";
import { useTranslation } from "../../../contexts/MessagesContext";
import MultiSelect from "../../controls/MultiSelect";
-import type { RuleCardViewProps } from "./RuleCard.types";
+import NavigationLink from "../../navigation/Link";
+import type { RuleCardBottomLink, RuleCardViewProps } from "./RuleCard.types";
export function RuleCardView({
title,
@@ -20,9 +21,13 @@ export function RuleCardView({
logoAlt,
communityInitials,
hideCategoryAddButton = false,
+ hasBottomLinks = false,
+ bottomStatusLabel,
+ bottomLinks,
}: RuleCardViewProps) {
const t = useTranslation("ruleCard");
const ariaLabel = t("ariaLabel")?.replace("{title}", title) || title;
+ const interactiveCard = !hasBottomLinks;
// Size-based styling
const isLarge = size === "L";
@@ -70,15 +75,14 @@ export function RuleCardView({
: "" // XS and S: no fixed width
: "";
- // Logo/Icon dimensions - use CSS responsive classes
- // For S: 80px container with 12px padding = 56px icon area
- // For XS: 72px container with 16px padding = 40px icon (72 - 16*2 = 40px)
- const logoSize = 103; // Use max size, CSS will resize
+ // Logo/Icon dimensions (inner circle) after Figma header `pl-1 pr-2 py-2` in icon cell
+ // (Card / Rule — e.g. `22143:900771` / `19706:12110`); outer column width holds padding + this.
+ const logoSize = 103; // `next/image` prop; actual box comes from `logoContainerClass`
const logoContainerClass = `
- max-[639px]:size-[72px]
- min-[640px]:max-[1023px]:size-[80px]
+ max-[639px]:size-[56px]
+ min-[640px]:max-[1023px]:size-[64px]
min-[1024px]:max-[1439px]:size-[56px]
- min-[1440px]:size-[103px]
+ min-[1440px]:size-[88px]
`;
// Title typography - use CSS responsive classes
@@ -106,7 +110,7 @@ export function RuleCardView({
logoUrl.startsWith("http://localhost") ||
logoUrl.startsWith("https://localhost");
- const containerClass = `${logoContainerClass} relative rounded-full overflow-hidden mix-blend-luminosity max-[639px]:p-[16px] min-[640px]:max-[1023px]:p-[12px]`;
+ const containerClass = `${logoContainerClass} relative rounded-full overflow-hidden mix-blend-luminosity`;
if (isLocalhost) {
return (
@@ -139,7 +143,7 @@ export function RuleCardView({
if (icon) {
return (
{icon}
@@ -150,8 +154,7 @@ export function RuleCardView({
const initialsSize = `
max-[639px]:text-[16px]
min-[640px]:max-[1023px]:text-[20px]
- min-[1024px]:max-[1439px]:text-[24px]
- min-[1440px]:text-[36px]
+ min-[1024px]:text-[36px]
`;
return (
e.stopPropagation()}
+ />
+ );
+ }
+ return (
+
{
+ e.stopPropagation();
+ link.onClick?.();
+ }}
+ />
+ );
+ }
+
return (
- {/* Outermost container with bottom border - taller to match Figma */}
+ {/* Figma: Header = `border-b` row, `gap-px`, icon `pl-1 pr-2 py-2` + `border-l` on title. */}
- {/* Logo/Icon - fixed width/height, vertically centered, does not touch bottom */}
{renderLogo() && (
{renderLogo()}
)}
- {/* Spacing between icon and title */}
-
- {/* Container with no padding and left border - extends full height to touch bottom */}
{title && (
{/* Inner container for header text with padding */}
@@ -234,8 +255,7 @@ export function RuleCardView({
flex items-center justify-center w-full
max-[639px]:pl-[8px] max-[639px]:py-[8px]
min-[640px]:max-[1023px]:pl-[12px] min-[640px]:max-[1023px]:py-[12px]
- min-[1024px]:max-[1439px]:px-[16px] min-[1024px]:max-[1439px]:py-[12px]
- min-[1440px]:px-[16px] min-[1440px]:py-[24px]
+ min-[1024px]:px-[16px] min-[1024px]:py-[24px]
`}
>
- {expanded ? (
+ {hasBottomLinks ? (
+
+ {description ? (
+
+ {description}
+
+ ) : null}
+ {bottomLinks && bottomLinks.length > 0 ? (
+
+ {bottomStatusLabel ? (
+
+ {bottomStatusLabel}
+
+ ) : null}
+ {/**
+ * Figma `22143:900539` / `21867:46099`: one row — status (optional) + all links in
+ * a single `flex-nowrap` group (`space/800` = 32px between links on large).
+ * If the row is too narrow, scroll horizontally; links never wrap.
+ */}
+
+ {bottomLinks.map((link) => renderBottomLink(link))}
+
+
+ ) : null}
+
+ ) : expanded ? (
<>
{/* Categories Section - Using MultiSelect */}
{categories && categories.length > 0 && (
@@ -314,3 +378,4 @@ export function RuleCardView({
);
}
+
diff --git a/app/components/cards/RuleCard/index.tsx b/app/components/cards/RuleCard/index.tsx
index 7a8db96..b96054c 100644
--- a/app/components/cards/RuleCard/index.tsx
+++ b/app/components/cards/RuleCard/index.tsx
@@ -1,2 +1,5 @@
export { default } from "./RuleCard.container";
-export type { RuleCardProps } from "./RuleCard.types";
+export type {
+ RuleCardBottomLink,
+ RuleCardProps,
+} from "./RuleCard.types";
diff --git a/app/components/cards/TemplateReviewCard/TemplateChipDetailModal.tsx b/app/components/cards/TemplateReviewCard/TemplateChipDetailModal.tsx
index aa8bfab..5bfd110 100644
--- a/app/components/cards/TemplateReviewCard/TemplateChipDetailModal.tsx
+++ b/app/components/cards/TemplateReviewCard/TemplateChipDetailModal.tsx
@@ -36,7 +36,7 @@ export function TemplateChipDetailModal({
(
showHelpIcon = true,
textHint = false,
formHeader = true,
+ maxLength,
...props
},
ref,
@@ -242,6 +243,7 @@ const TextInputContainer = forwardRef(
focusRingClasses={stateStyles.focusRing}
textHint={textHint}
formHeader={formHeader}
+ maxLength={maxLength}
{...props}
/>
);
diff --git a/app/components/controls/TextInput/TextInput.types.ts b/app/components/controls/TextInput/TextInput.types.ts
index acbcb18..de1e5ba 100644
--- a/app/components/controls/TextInput/TextInput.types.ts
+++ b/app/components/controls/TextInput/TextInput.types.ts
@@ -64,4 +64,5 @@ export interface TextInputViewProps {
focusRingClasses?: string;
textHint?: boolean | string;
formHeader?: boolean;
+ maxLength?: number;
}
diff --git a/app/components/controls/TextInput/TextInput.view.tsx b/app/components/controls/TextInput/TextInput.view.tsx
index 7414013..7e114c9 100644
--- a/app/components/controls/TextInput/TextInput.view.tsx
+++ b/app/components/controls/TextInput/TextInput.view.tsx
@@ -28,6 +28,7 @@ export const TextInputView = forwardRef(
focusRingClasses = "",
textHint = false,
formHeader = true,
+ maxLength,
},
ref,
) => {
@@ -70,6 +71,7 @@ export const TextInputView = forwardRef(
onBlur={handleBlur}
onMouseDown={handleMouseDown}
disabled={disabled}
+ maxLength={maxLength}
className={inputClasses}
style={{ borderRadius }}
/>
diff --git a/app/components/layout/List/List.container.tsx b/app/components/layout/List/List.container.tsx
new file mode 100644
index 0000000..e00188e
--- /dev/null
+++ b/app/components/layout/List/List.container.tsx
@@ -0,0 +1,17 @@
+"use client";
+
+import { memo } from "react";
+import { ListView } from "./List.view";
+import type { ListProps } from "./List.types";
+
+/**
+ * Figma: "List Edit" list frame — S (21863:45631), M (21863:45493), L (21844:4405).
+ * Composes {@link ListEntry} rows with a shared list-level top rule when enabled.
+ */
+const ListContainer = memo((props) => {
+ return ;
+});
+
+ListContainer.displayName = "List";
+
+export default ListContainer;
diff --git a/app/components/layout/List/List.types.ts b/app/components/layout/List/List.types.ts
new file mode 100644
index 0000000..7a9366f
--- /dev/null
+++ b/app/components/layout/List/List.types.ts
@@ -0,0 +1,29 @@
+import type { IconName } from "../../asset/Icon";
+import type {
+ ListEntryVariant,
+ ListSize,
+} from "../ListEntry/ListEntry.types";
+
+export type ListItem = {
+ id: string;
+ title: string;
+ description: string;
+ href?: string;
+ onClick?: () => void;
+ /** Per-row icon; falls back to list-level {@link ListProps.leadingIcon}. */
+ leadingIcon?: IconName;
+ variant?: ListEntryVariant;
+ showDescription?: boolean;
+};
+
+export type ListProps = {
+ items: ListItem[];
+ size?: ListSize;
+ topDivider?: boolean;
+ leadingIcon?: IconName;
+ className?: string;
+};
+
+export type { ListEntryVariant, ListSize };
+
+export type ListViewProps = ListProps;
diff --git a/app/components/layout/List/List.view.tsx b/app/components/layout/List/List.view.tsx
new file mode 100644
index 0000000..0b05bdd
--- /dev/null
+++ b/app/components/layout/List/List.view.tsx
@@ -0,0 +1,47 @@
+"use client";
+
+import { memo } from "react";
+import Divider from "../../utility/Divider";
+import ListEntry from "../ListEntry";
+import { FIGMA_LIST_ROOT } from "../listSizeLayout";
+import type { ListViewProps } from "./List.types";
+
+export const ListView = memo(function ListView({
+ items,
+ size = "m",
+ topDivider = true,
+ leadingIcon = "edit",
+ className = "",
+}: ListViewProps) {
+ return (
+
+ {topDivider ?
: null}
+
+ {items.map((item) => (
+
+
+
+ ))}
+
+
+ );
+});
+
+ListView.displayName = "ListView";
diff --git a/app/components/layout/List/index.tsx b/app/components/layout/List/index.tsx
new file mode 100644
index 0000000..5b2c2ec
--- /dev/null
+++ b/app/components/layout/List/index.tsx
@@ -0,0 +1,3 @@
+export { default } from "./List.container";
+export type { ListProps, ListItem, ListSize, ListViewProps } from "./List.types";
+export { LIST_SIZE_OPTIONS } from "../ListEntry/ListEntry.types";
diff --git a/app/components/layout/ListEntry/ListEntry.container.tsx b/app/components/layout/ListEntry/ListEntry.container.tsx
new file mode 100644
index 0000000..e3857ec
--- /dev/null
+++ b/app/components/layout/ListEntry/ListEntry.container.tsx
@@ -0,0 +1,17 @@
+"use client";
+
+import { memo } from "react";
+import { ListEntryView } from "./ListEntry.view";
+import type { ListEntryProps } from "./ListEntry.types";
+
+/**
+ * Figma: "Base / Interactive" (21844:4118). Single list row: optional top rule,
+ * leading icon, title, optional description, chevron, optional bottom rule.
+ */
+const ListEntryContainer = memo((props) => {
+ return ;
+});
+
+ListEntryContainer.displayName = "ListEntry";
+
+export default ListEntryContainer;
diff --git a/app/components/layout/ListEntry/ListEntry.types.ts b/app/components/layout/ListEntry/ListEntry.types.ts
new file mode 100644
index 0000000..cbfec59
--- /dev/null
+++ b/app/components/layout/ListEntry/ListEntry.types.ts
@@ -0,0 +1,27 @@
+import type { IconName } from "../../asset/Icon";
+
+export const LIST_SIZE_OPTIONS = ["s", "m", "l"] as const;
+export type ListSize = (typeof LIST_SIZE_OPTIONS)[number];
+
+export const LIST_ENTRY_VARIANT_OPTIONS = ["default", "danger", "muted"] as const;
+export type ListEntryVariant = (typeof LIST_ENTRY_VARIANT_OPTIONS)[number];
+
+export type ListEntryProps = {
+ title: string;
+ description?: string;
+ /** @default true */
+ showDescription?: boolean;
+ href?: string;
+ onClick?: () => void;
+ size?: ListSize;
+ leadingIcon?: IconName;
+ /** Row tone (e.g. profile destructive / disabled rows). @default "default" */
+ variant?: ListEntryVariant;
+ /** Renders a line above the row (Base / Interactive). @default false */
+ topDivider?: boolean;
+ /** Renders a line under the row. @default true */
+ bottomDivider?: boolean;
+ className?: string;
+};
+
+export type ListEntryViewProps = ListEntryProps;
diff --git a/app/components/layout/ListEntry/ListEntry.view.tsx b/app/components/layout/ListEntry/ListEntry.view.tsx
new file mode 100644
index 0000000..daced17
--- /dev/null
+++ b/app/components/layout/ListEntry/ListEntry.view.tsx
@@ -0,0 +1,173 @@
+"use client";
+
+import { memo } from "react";
+import Link from "next/link";
+import Icon, { type IconName } from "../../asset/Icon";
+import Divider from "../../utility/Divider";
+import { FIGMA_LIST_ENTRY_OUTER, listEntrySizeLayout } from "../listSizeLayout";
+import type {
+ ListEntryViewProps,
+ ListEntryVariant,
+ ListSize,
+} from "./ListEntry.types";
+
+type RowCoreProps = {
+ title: string;
+ description?: string;
+ showDescription: boolean;
+ href?: string;
+ onClick?: () => void;
+ leadingIcon: IconName;
+ size: ListSize;
+ variant: ListEntryVariant;
+};
+
+const ListEntryRow = memo(function ListEntryRow({
+ title,
+ description,
+ showDescription,
+ href,
+ onClick,
+ leadingIcon,
+ size,
+ variant,
+}: RowCoreProps) {
+ const layout = listEntrySizeLayout[size];
+
+ const leadingBoxClass =
+ size === "s"
+ ? "flex h-6 w-6 shrink-0 items-center justify-center"
+ : size === "m"
+ ? "flex size-8 shrink-0 items-center justify-center"
+ : "flex size-10 shrink-0 items-center justify-center";
+
+ const chevronSize = size === "s" ? 16 : size === "l" ? 32 : 24;
+
+ const shellExtra =
+ variant === "muted" ? "opacity-60 hover:!bg-transparent" : "";
+
+ const titleClass =
+ variant === "danger"
+ ? `${layout.title} !text-[var(--color-content-default-negative-primary)]`
+ : layout.title;
+
+ const leadingToneClass =
+ variant === "danger"
+ ? "text-[var(--color-content-default-negative-primary)]"
+ : "text-[var(--color-content-default-primary)]";
+
+ const chevronToneClass =
+ variant === "danger"
+ ? "text-[var(--color-content-default-negative-primary)]"
+ : "text-[var(--color-content-default-primary)]";
+
+ const leadingSlot = (
+
+
+
+ );
+
+ const chevronSlot = (
+
+
+
+ );
+
+ const textBlock = (
+ <>
+
+ {showDescription && description != null && description !== "" ? (
+ {description}
+ ) : null}
+ >
+ );
+
+ const inner = (
+ <>
+ {leadingSlot}
+ {textBlock}
+ {chevronSlot}
+ >
+ );
+
+ const shellClass = `${layout.shell} ${shellExtra}`.trim();
+
+ if (href) {
+ return (
+
+ {inner}
+
+ );
+ }
+
+ if (onClick) {
+ return (
+
+ {inner}
+
+ );
+ }
+
+ return (
+
+ {inner}
+
+ );
+});
+
+ListEntryRow.displayName = "ListEntryRow";
+
+export const ListEntryView = memo(function ListEntryView({
+ title,
+ description = "",
+ showDescription = true,
+ href,
+ onClick,
+ size = "m",
+ leadingIcon = "edit",
+ variant = "default",
+ topDivider = false,
+ bottomDivider = true,
+ className = "",
+}: ListEntryViewProps) {
+ return (
+
+ {topDivider ?
: null}
+
+ {bottomDivider ?
: null}
+
+ );
+});
+
+ListEntryView.displayName = "ListEntryView";
diff --git a/app/components/layout/ListEntry/index.tsx b/app/components/layout/ListEntry/index.tsx
new file mode 100644
index 0000000..5893b66
--- /dev/null
+++ b/app/components/layout/ListEntry/index.tsx
@@ -0,0 +1,3 @@
+export { default } from "./ListEntry.container";
+export type { ListEntryProps, ListSize } from "./ListEntry.types";
+export { LIST_SIZE_OPTIONS } from "./ListEntry.types";
diff --git a/app/components/layout/listSizeLayout.ts b/app/components/layout/listSizeLayout.ts
new file mode 100644
index 0000000..4116ab9
--- /dev/null
+++ b/app/components/layout/listSizeLayout.ts
@@ -0,0 +1,69 @@
+import type { ListSize } from "./ListEntry/ListEntry.types";
+
+export const rowShellBase =
+ "flex w-full cursor-pointer items-center text-left text-[var(--color-content-default-primary)] outline-none " +
+ "transition-colors " +
+ "hover:bg-[var(--color-surface-default-tertiary)] " +
+ "focus-visible:ring-2 focus-visible:ring-[var(--color-content-default-primary)] " +
+ "focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-default-primary)]";
+
+/**
+ * Figma: "ListEntry" / Base Interactive (21844:4118) — S/M/L row + outer shell node ids.
+ * Full list frame roots: 21863:45631 (S), 21863:45493 (M), 21844:4405 (L).
+ */
+export const FIGMA_LIST_ENTRY_OUTER: Record = {
+ s: "21863:45436",
+ m: "21863:45422",
+ l: "21844:4119",
+};
+
+export const FIGMA_LIST_ROOT: Record = {
+ s: "21863:45631",
+ m: "21863:45493",
+ l: "21844:4405",
+};
+
+export const FIGMA_LIST_ENTRY_ROW: Record = {
+ s: "21863:45438",
+ m: "21863:45424",
+ l: "21844:4120",
+};
+
+type RowLayout = {
+ shell: string;
+ textCol: string;
+ title: string;
+ description: string;
+ rowFigma: string;
+};
+
+export const listEntrySizeLayout: Record = {
+ s: {
+ shell: `${rowShellBase} min-h-0 gap-1.5 py-[var(--spacing-scale-012)]`,
+ textCol: "flex min-w-0 flex-1 flex-col items-start justify-center",
+ title:
+ "min-w-0 flex-1 font-inter text-sm font-medium leading-[18px] text-[var(--color-content-default-primary)]",
+ description:
+ "w-full font-inter text-xs font-normal leading-4 text-[var(--color-content-default-secondary)]",
+ rowFigma: FIGMA_LIST_ENTRY_ROW.s,
+ },
+ m: {
+ shell: `${rowShellBase} min-h-16 gap-[var(--spacing-scale-008)] py-[var(--spacing-scale-012)]`,
+ textCol: "flex min-w-0 flex-1 flex-col items-start justify-center",
+ title:
+ "min-w-0 flex-1 font-inter text-lg font-medium leading-6 text-[var(--color-content-default-primary)]",
+ description:
+ "w-full font-inter text-base font-normal leading-6 text-[var(--color-content-default-secondary)]",
+ rowFigma: FIGMA_LIST_ENTRY_ROW.m,
+ },
+ l: {
+ shell: `${rowShellBase} min-h-16 gap-[var(--spacing-scale-012)] py-[var(--spacing-scale-016)]`,
+ textCol:
+ "flex min-w-0 flex-1 flex-col items-start justify-center gap-[var(--spacing-scale-004)]",
+ title:
+ "min-w-0 flex-1 font-inter text-2xl font-normal leading-7 text-[var(--color-content-default-primary)]",
+ description:
+ "w-full font-inter text-lg font-normal leading-[1.3] text-[var(--color-content-default-secondary)]",
+ rowFigma: FIGMA_LIST_ENTRY_ROW.l,
+ },
+};
diff --git a/app/components/modals/Alert/Alert.container.tsx b/app/components/modals/Alert/Alert.container.tsx
index cdd678d..ce01a55 100644
--- a/app/components/modals/Alert/Alert.container.tsx
+++ b/app/components/modals/Alert/Alert.container.tsx
@@ -1,52 +1,117 @@
"use client";
+/**
+ * Figma: "Modal / Alert" (6351-14646)
+ * https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=6351-14646
+ */
+
import { memo } from "react";
import { AlertView } from "./Alert.view";
import type { AlertProps } from "./Alert.types";
+function layoutFor(
+ type: NonNullable,
+ size: NonNullable,
+): {
+ containerClasses: string;
+ titleClasses: string;
+ descriptionClasses: string;
+} {
+ if (type === "toast") {
+ const padH =
+ size === "s"
+ ? "px-[var(--space-500)]"
+ : "px-[var(--space-1200)]";
+ const containerClasses = `flex gap-[var(--space-300)] items-center ${padH} pb-[var(--space-500)] pt-[var(--space-400)] rounded-tl-[var(--radius-200,8px)] rounded-tr-[var(--radius-200,8px)] border-solid`;
+ if (size === "s") {
+ return {
+ containerClasses,
+ titleClasses:
+ "font-inter text-[14px] leading-[18px] font-medium tracking-[0%]",
+ descriptionClasses:
+ "font-inter text-[14px] leading-[20px] font-normal tracking-[0%] mt-[var(--spacing-scale-004)]",
+ };
+ }
+ return {
+ containerClasses,
+ titleClasses:
+ "font-inter text-[18px] leading-[24px] font-medium tracking-[0%]",
+ descriptionClasses:
+ "font-inter text-[18px] leading-[1.3] font-normal tracking-[0%] mt-[var(--spacing-scale-004)]",
+ };
+ }
+
+ if (size === "s") {
+ return {
+ containerClasses:
+ "flex gap-[var(--space-300)] items-center p-[var(--space-300)] rounded-[var(--radius-200,8px)] border-solid",
+ titleClasses:
+ "font-inter text-[14px] leading-[18px] font-medium tracking-[0%]",
+ descriptionClasses:
+ "font-inter text-[14px] leading-[20px] font-normal tracking-[0%] mt-[var(--spacing-scale-004)]",
+ };
+ }
+ return {
+ containerClasses:
+ "flex gap-[var(--space-300)] items-center px-[var(--space-600)] py-[var(--space-400)] rounded-[var(--radius-200,8px)] border-solid",
+ titleClasses:
+ "font-inter text-[16px] leading-[20px] font-medium tracking-[0%]",
+ descriptionClasses:
+ "font-inter text-[16px] leading-[24px] font-normal tracking-[0%] mt-[var(--spacing-scale-004)]",
+ };
+}
+
const AlertContainer = memo(
({
title,
description,
status: statusProp = "default",
type: typeProp = "toast",
+ size: sizeProp = "m",
hasLeadingIcon = true,
hasBodyText = true,
+ hasTrailingIcon: hasTrailingIconProp,
onClose,
className = "",
}) => {
const status = statusProp;
const type = typeProp;
- // Determine background and border colors based on status and type
+ const size = sizeProp;
+
const getStatusStyles = () => {
switch (status) {
case "positive":
return {
- background: "bg-[var(--color-kiwi-kiwi0)]",
+ background:
+ "bg-[var(--color-surface-invert-positive-secondary,var(--color-kiwi-kiwi0))]",
borderColor:
type === "toast"
? "var(--color-border-invert-positive-primary)"
: undefined,
titleColor: "text-[var(--color-content-invert-primary)]",
- descriptionColor: "text-[var(--color-content-invert-secondary)]",
+ descriptionColor:
+ "text-[var(--color-content-invert-secondary)]",
iconColor: "var(--color-kiwi-kiwi500)",
closeButtonIconColor: "var(--color-content-invert-primary)",
};
case "warning":
return {
- background: "bg-[var(--color-yellow-yellow0)]",
+ background:
+ "bg-[var(--color-surface-invert-warning-secondary,var(--color-yellow-yellow0))]",
borderColor:
type === "toast"
? "var(--color-border-invert-warning-primary)"
: undefined,
titleColor: "text-[var(--color-content-invert-primary)]",
- descriptionColor: "text-[var(--color-content-invert-secondary)]",
+ descriptionColor:
+ "text-[var(--color-content-invert-secondary)]",
iconColor: "var(--color-yellow-yellow500)",
closeButtonIconColor: "var(--color-content-invert-primary)",
};
case "danger":
return {
- background: "bg-[var(--color-red-red0)]",
+ background:
+ "bg-[var(--color-surface-invert-negative-secondary,var(--color-red-red0))]",
borderColor:
type === "toast"
? "var(--color-border-invert-negative-primary)"
@@ -67,18 +132,14 @@ const AlertContainer = memo(
titleColor: "text-[var(--color-content-default-primary)]",
descriptionColor: "text-[var(--color-content-default-primary)]",
iconColor: "var(--color-content-default-brand-primary)",
- closeButtonIconColor: "var(--color-content-default-brand-primary)",
+ closeButtonIconColor:
+ "var(--color-content-default-brand-primary)",
};
}
};
const statusStyles = getStatusStyles();
-
- const containerClasses = `flex gap-[var(--space-300)] items-center ${
- type === "toast"
- ? `pb-[var(--space-500)] pt-[var(--space-400)] px-[var(--space-1200)] rounded-tl-[var(--radius-200,8px)] rounded-tr-[var(--radius-200,8px)]`
- : `px-[var(--spacing-scale-024)] py-[var(--spacing-scale-016)] rounded-[var(--radius-200,8px)]`
- } ${statusStyles.background} border-solid`;
+ const layout = layoutFor(type, size);
const containerStyle =
type === "toast" && statusStyles.borderColor
@@ -88,15 +149,14 @@ const AlertContainer = memo(
}
: undefined;
- const titleClasses =
- type === "banner"
- ? `font-inter text-[16px] leading-[20px] font-medium tracking-[0%] ${statusStyles.titleColor} relative shrink-0 w-full`
- : `font-inter text-[18px] leading-[24px] font-medium tracking-[0%] ${statusStyles.titleColor} relative shrink-0 w-full`;
+ const containerClasses = `${layout.containerClasses} ${statusStyles.background}`;
- const descriptionClasses =
- type === "banner"
- ? `font-inter text-[16px] leading-[24px] font-normal tracking-[0%] ${statusStyles.descriptionColor} relative shrink-0 w-full mt-[var(--spacing-scale-004)]`
- : `font-inter text-[18px] leading-[23.4px] font-normal tracking-[0%] ${statusStyles.descriptionColor} relative shrink-0 w-full mt-[var(--spacing-scale-004)]`;
+ const titleClasses = `${layout.titleClasses} ${statusStyles.titleColor} relative shrink-0 w-full`;
+ const descriptionClasses = `${layout.descriptionClasses} ${statusStyles.descriptionColor} relative shrink-0 w-full`;
+
+ const hasTrailingIcon =
+ hasTrailingIconProp ?? Boolean(onClose);
+ const showClose = hasTrailingIcon && Boolean(onClose);
return (
(
type={type}
hasLeadingIcon={hasLeadingIcon}
hasBodyText={hasBodyText}
+ hasTrailingIcon={showClose}
className={className}
containerClasses={containerClasses}
containerStyle={containerStyle}
diff --git a/app/components/modals/Alert/Alert.types.ts b/app/components/modals/Alert/Alert.types.ts
index e01a8d2..3278b1b 100644
--- a/app/components/modals/Alert/Alert.types.ts
+++ b/app/components/modals/Alert/Alert.types.ts
@@ -1,7 +1,11 @@
+import type { AlertSizeValue } from "../../../../lib/propNormalization";
+
export type AlertStatusValue = "default" | "positive" | "warning" | "danger";
export type AlertTypeValue = "toast" | "banner";
+export type { AlertSizeValue };
+
export interface AlertProps {
title: string;
description?: string;
@@ -13,6 +17,11 @@ export interface AlertProps {
* Alert type.
*/
type?: AlertTypeValue;
+ /**
+ * Density / typography scale (Figma Modal Alert S | M).
+ * @default "m"
+ */
+ size?: AlertSizeValue;
/**
* Whether to show the leading icon (Figma prop).
* @default true
@@ -23,6 +32,11 @@ export interface AlertProps {
* @default true
*/
hasBodyText?: boolean;
+ /**
+ * Trailing dismiss control (Figma `hasTrailingIcon`).
+ * When omitted, defaults to `true` when `onClose` is provided, else `false`.
+ */
+ hasTrailingIcon?: boolean;
onClose?: () => void;
className?: string;
}
@@ -34,6 +48,7 @@ export interface AlertViewProps {
type: "toast" | "banner";
hasLeadingIcon: boolean;
hasBodyText: boolean;
+ hasTrailingIcon: boolean;
className: string;
containerClasses: string;
containerStyle?: React.CSSProperties;
diff --git a/app/components/modals/Alert/Alert.view.tsx b/app/components/modals/Alert/Alert.view.tsx
index 4482230..08a67b6 100644
--- a/app/components/modals/Alert/Alert.view.tsx
+++ b/app/components/modals/Alert/Alert.view.tsx
@@ -8,6 +8,7 @@ export function AlertView({
type: _type,
hasLeadingIcon,
hasBodyText,
+ hasTrailingIcon,
className,
containerClasses,
containerStyle,
@@ -54,40 +55,42 @@ export function AlertView({
{description}
)}
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+ ) : null}
);
}
diff --git a/app/components/modals/Create/Create.container.tsx b/app/components/modals/Create/Create.container.tsx
index 180f528..7c8a29c 100644
--- a/app/components/modals/Create/Create.container.tsx
+++ b/app/components/modals/Create/Create.container.tsx
@@ -1,8 +1,9 @@
"use client";
-import { memo, useEffect, useRef } from "react";
+import { memo, useRef } from "react";
import { CreateView } from "./Create.view";
import type { CreateProps } from "./Create.types";
+import { useCreateModalA11y } from "./useCreateModalA11y";
const CreateContainer = memo(
({
@@ -29,85 +30,8 @@ const CreateContainer = memo(
}) => {
const createRef = useRef(null);
const overlayRef = useRef(null);
- const previousActiveElementRef = useRef(null);
- // Handle ESC key to close
- useEffect(() => {
- if (!isOpen) return;
-
- const handleEscape = (e: KeyboardEvent) => {
- if (e.key === "Escape") {
- onClose();
- }
- };
-
- document.addEventListener("keydown", handleEscape);
- return () => {
- document.removeEventListener("keydown", handleEscape);
- };
- }, [isOpen, onClose]);
-
- // Focus trap and body scroll lock
- useEffect(() => {
- if (!isOpen) return;
-
- // Store previous active element
- previousActiveElementRef.current = document.activeElement as HTMLElement;
-
- // Lock body scroll
- document.body.style.overflow = "hidden";
-
- // Focus the first focusable element in the create dialog
- if (createRef.current) {
- const focusableElements = createRef.current.querySelectorAll(
- 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
- );
- const firstElement = focusableElements[0] as HTMLElement;
- if (firstElement) {
- firstElement.focus();
- } else {
- // Fallback: make create dialog focusable and focus it
- createRef.current.setAttribute("tabindex", "-1");
- createRef.current.focus();
- }
- }
-
- // Focus trap
- const handleTab = (e: KeyboardEvent) => {
- if (e.key !== "Tab" || !createRef.current) return;
-
- const focusableElements = createRef.current.querySelectorAll(
- 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
- );
- const firstElement = focusableElements[0] as HTMLElement;
- const lastElement = focusableElements[
- focusableElements.length - 1
- ] as HTMLElement;
-
- if (e.shiftKey) {
- // Shift + Tab
- if (document.activeElement === firstElement) {
- e.preventDefault();
- lastElement?.focus();
- }
- } else {
- // Tab
- if (document.activeElement === lastElement) {
- e.preventDefault();
- firstElement?.focus();
- }
- }
- };
-
- document.addEventListener("keydown", handleTab);
-
- return () => {
- document.body.style.overflow = "";
- document.removeEventListener("keydown", handleTab);
- // Restore focus to previous element
- previousActiveElementRef.current?.focus();
- };
- }, [isOpen]);
+ useCreateModalA11y(isOpen, onClose, createRef);
return (
void;
@@ -28,10 +31,10 @@ export interface CreateProps {
upload?: boolean;
proportion?: boolean;
/**
- * Backdrop behind the dialog. `loginYellow` matches the Login modal’s blurred brand overlay.
+ * Backdrop behind the dialog. `blurredYellow` matches the login-style blurred brand overlay.
* @default "default"
*/
- backdropVariant?: "default" | "loginYellow";
+ backdropVariant?: CreateModalBackdropVariant;
}
export interface CreateViewProps {
@@ -54,7 +57,7 @@ export interface CreateViewProps {
className: string;
ariaLabel?: string;
ariaLabelledBy?: string;
- createRef: React.RefObject;
- overlayRef: React.RefObject;
- backdropVariant: "default" | "loginYellow";
+ createRef: RefObject;
+ overlayRef: RefObject;
+ backdropVariant: CreateModalBackdropVariant;
}
diff --git a/app/components/modals/Create/Create.view.tsx b/app/components/modals/Create/Create.view.tsx
index 98933bb..e881e49 100644
--- a/app/components/modals/Create/Create.view.tsx
+++ b/app/components/modals/Create/Create.view.tsx
@@ -1,20 +1,11 @@
"use client";
-import { createPortal } from "react-dom";
import ContentLockup from "../../type/ContentLockup";
import ModalFooter from "../../utility/ModalFooter";
import ModalHeader from "../../utility/ModalHeader";
+import { CreateModalFrameView } from "./CreateModalFrame.view";
import type { CreateViewProps } from "./Create.types";
-const backdropOverlayClasses: Record<
- CreateViewProps["backdropVariant"],
- string
-> = {
- default: "fixed inset-0 bg-black/50 z-[9998]",
- loginYellow:
- "fixed inset-0 z-[9998] bg-[var(--color-surface-inverse-brand-primary)]/85 backdrop-blur-md supports-[backdrop-filter]:bg-[var(--color-surface-inverse-brand-primary)]/75",
-};
-
export function CreateView({
isOpen,
onClose,
@@ -39,70 +30,48 @@ export function CreateView({
overlayRef,
backdropVariant,
}: CreateViewProps) {
- if (!isOpen) return null;
+ return (
+
+
- const createContent = (
- <>
- {/* Overlay */}
-
-
- {/* Create Dialog: max-h ensures modal fits viewport; content scrolls inside */}
-
- {/* Header with close buttons */}
-
-
- {/* Header: custom headerContent (when provided) or default title/description */}
- {headerContent !== undefined ? (
-
{headerContent}
- ) : title || description ? (
-
-
-
- ) : null}
-
- {/* Content Area (scrollable when content overflows) */}
-
- {children}
+ {headerContent !== undefined ? (
+
{headerContent}
+ ) : title || description ? (
+
+
+ ) : null}
- {/* Footer (always visible at bottom of modal) */}
-
+
+ {children}
- >
+
+
+
);
-
- // Portal to body
- if (typeof window !== "undefined") {
- return createPortal(createContent, document.body);
- }
-
- return null;
}
diff --git a/app/components/modals/Create/CreateModalFrame.view.tsx b/app/components/modals/Create/CreateModalFrame.view.tsx
new file mode 100644
index 0000000..821d679
--- /dev/null
+++ b/app/components/modals/Create/CreateModalFrame.view.tsx
@@ -0,0 +1,69 @@
+"use client";
+
+import type { ReactNode, RefObject } from "react";
+import { createPortal } from "react-dom";
+
+/** Matches {@link CreateView} overlay options — shared with {@link DialogView}. */
+export type CreateModalBackdropVariant = "default" | "blurredYellow";
+
+const backdropOverlayClasses: Record
= {
+ default: "fixed inset-0 bg-black/50 z-[9998]",
+ blurredYellow:
+ "fixed inset-0 z-[9998] bg-[var(--color-surface-inverse-brand-primary)]/85 backdrop-blur-md supports-[backdrop-filter]:bg-[var(--color-surface-inverse-brand-primary)]/75",
+};
+
+export type CreateModalFrameViewProps = {
+ isOpen: boolean;
+ onOverlayClick: () => void;
+ backdropVariant: CreateModalBackdropVariant;
+ className: string;
+ ariaLabel?: string;
+ ariaLabelledBy?: string;
+ overlayRef: RefObject;
+ dialogRef: RefObject;
+ children: ReactNode;
+};
+
+/**
+ * Portal + dimmed overlay + centered dialog shell used by Create and Dialog.
+ */
+export function CreateModalFrameView({
+ isOpen,
+ onOverlayClick,
+ backdropVariant,
+ className,
+ ariaLabel,
+ ariaLabelledBy,
+ overlayRef,
+ dialogRef,
+ children,
+}: CreateModalFrameViewProps) {
+ if (!isOpen) return null;
+
+ const content = (
+ <>
+
+
+ {children}
+
+ >
+ );
+
+ if (typeof window !== "undefined") {
+ return createPortal(content, document.body);
+ }
+
+ return null;
+}
diff --git a/app/components/modals/Create/useCreateModalA11y.ts b/app/components/modals/Create/useCreateModalA11y.ts
new file mode 100644
index 0000000..de50f1e
--- /dev/null
+++ b/app/components/modals/Create/useCreateModalA11y.ts
@@ -0,0 +1,82 @@
+"use client";
+
+import type { RefObject } from "react";
+import { useEffect, useRef } from "react";
+
+/**
+ * Escape-to-close, body scroll lock, focus move-in and tab trap for Create-shell modals.
+ */
+export function useCreateModalA11y(
+ isOpen: boolean,
+ onClose: () => void,
+ dialogRef: RefObject,
+): void {
+ const previousActiveElementRef = useRef(null);
+
+ useEffect(() => {
+ if (!isOpen) return;
+
+ const handleEscape = (e: KeyboardEvent) => {
+ if (e.key === "Escape") {
+ onClose();
+ }
+ };
+
+ document.addEventListener("keydown", handleEscape);
+ return () => {
+ document.removeEventListener("keydown", handleEscape);
+ };
+ }, [isOpen, onClose]);
+
+ useEffect(() => {
+ if (!isOpen) return;
+
+ previousActiveElementRef.current = document.activeElement as HTMLElement;
+ document.body.style.overflow = "hidden";
+
+ if (dialogRef.current) {
+ const focusableElements = dialogRef.current.querySelectorAll(
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
+ );
+ const firstElement = focusableElements[0] as HTMLElement;
+ if (firstElement) {
+ firstElement.focus();
+ } else {
+ dialogRef.current.setAttribute("tabindex", "-1");
+ dialogRef.current.focus();
+ }
+ }
+
+ const handleTab = (e: KeyboardEvent) => {
+ if (e.key !== "Tab" || !dialogRef.current) return;
+
+ const focusableElements = dialogRef.current.querySelectorAll(
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
+ );
+ const firstElement = focusableElements[0] as HTMLElement;
+ const lastElement = focusableElements[
+ focusableElements.length - 1
+ ] as HTMLElement;
+
+ if (e.shiftKey) {
+ if (document.activeElement === firstElement) {
+ e.preventDefault();
+ lastElement?.focus();
+ }
+ } else {
+ if (document.activeElement === lastElement) {
+ e.preventDefault();
+ firstElement?.focus();
+ }
+ }
+ };
+
+ document.addEventListener("keydown", handleTab);
+
+ return () => {
+ document.body.style.overflow = "";
+ document.removeEventListener("keydown", handleTab);
+ previousActiveElementRef.current?.focus();
+ };
+ }, [isOpen]);
+}
diff --git a/app/components/modals/Dialog/Dialog.container.tsx b/app/components/modals/Dialog/Dialog.container.tsx
new file mode 100644
index 0000000..db6f25e
--- /dev/null
+++ b/app/components/modals/Dialog/Dialog.container.tsx
@@ -0,0 +1,50 @@
+"use client";
+
+import { memo, useId, useRef } from "react";
+import { useCreateModalA11y } from "../Create/useCreateModalA11y";
+import { DialogView } from "./Dialog.view";
+import type { DialogProps } from "./Dialog.types";
+
+const DialogContainer = memo(
+ ({
+ isOpen,
+ onClose,
+ title,
+ description,
+ footer,
+ children,
+ className = "",
+ ariaLabel,
+ ariaLabelledBy: ariaLabelledByProp,
+ backdropVariant = "default",
+ }) => {
+ const dialogRef = useRef(null);
+ const overlayRef = useRef(null);
+ const autoTitleId = useId();
+ const titleId = ariaLabelledByProp ?? autoTitleId;
+
+ useCreateModalA11y(isOpen, onClose, dialogRef);
+
+ return (
+
+ );
+ },
+);
+
+DialogContainer.displayName = "Dialog";
+
+export default DialogContainer;
diff --git a/app/components/modals/Dialog/Dialog.types.ts b/app/components/modals/Dialog/Dialog.types.ts
new file mode 100644
index 0000000..40faa7b
--- /dev/null
+++ b/app/components/modals/Dialog/Dialog.types.ts
@@ -0,0 +1,37 @@
+import type { ReactNode, RefObject } from "react";
+import type { CreateModalBackdropVariant } from "../Create/CreateModalFrame.view";
+
+export interface DialogProps {
+ isOpen: boolean;
+ onClose: () => void;
+ title: string;
+ description?: string;
+ /** Primary actions row (e.g. Cancel + Confirm) — use design-system `Button`s. */
+ footer: ReactNode;
+ /** Optional body below the title block (scrolls when tall). */
+ children?: ReactNode;
+ className?: string;
+ ariaLabel?: string;
+ ariaLabelledBy?: string;
+ /**
+ * Same backdrop options as the Create modal shell.
+ * @default "default"
+ */
+ backdropVariant?: CreateModalBackdropVariant;
+}
+
+export interface DialogViewProps {
+ isOpen: boolean;
+ onClose: () => void;
+ title: string;
+ description?: string;
+ footer: ReactNode;
+ children?: ReactNode;
+ className: string;
+ ariaLabel?: string;
+ ariaLabelledBy?: string;
+ titleId: string;
+ backdropVariant: CreateModalBackdropVariant;
+ overlayRef: RefObject;
+ dialogRef: RefObject;
+}
diff --git a/app/components/modals/Dialog/Dialog.view.tsx b/app/components/modals/Dialog/Dialog.view.tsx
new file mode 100644
index 0000000..ee718c4
--- /dev/null
+++ b/app/components/modals/Dialog/Dialog.view.tsx
@@ -0,0 +1,68 @@
+"use client";
+
+import { memo } from "react";
+import ContentLockup from "../../type/ContentLockup";
+import ModalFooter from "../../utility/ModalFooter";
+import ModalHeader from "../../utility/ModalHeader";
+import { CreateModalFrameView } from "../Create/CreateModalFrame.view";
+import type { DialogViewProps } from "./Dialog.types";
+
+export const DialogView = memo(function DialogView({
+ isOpen,
+ onClose,
+ title,
+ description,
+ footer,
+ children,
+ className,
+ ariaLabel,
+ ariaLabelledBy,
+ titleId,
+ backdropVariant,
+ overlayRef,
+ dialogRef,
+}: DialogViewProps) {
+ return (
+
+
+
+
+
+
+
+ {children ? (
+
+ {children}
+
+ ) : null}
+
+
+ {footer}
+
+ }
+ />
+
+ );
+});
+
+DialogView.displayName = "DialogView";
diff --git a/app/components/modals/Dialog/index.tsx b/app/components/modals/Dialog/index.tsx
new file mode 100644
index 0000000..4e16c5f
--- /dev/null
+++ b/app/components/modals/Dialog/index.tsx
@@ -0,0 +1,2 @@
+export { default } from "./Dialog.container";
+export type { DialogProps } from "./Dialog.types";
diff --git a/app/components/modals/Login/LoginForm.tsx b/app/components/modals/Login/LoginForm.tsx
index 2276c84..02abeb4 100644
--- a/app/components/modals/Login/LoginForm.tsx
+++ b/app/components/modals/Login/LoginForm.tsx
@@ -7,6 +7,7 @@ import { useTranslation } from "../../../contexts/MessagesContext";
import Button from "../../buttons/Button";
import TextInput from "../../controls/TextInput";
import ContentLockup from "../../type/ContentLockup";
+import Alert from "../Alert";
import { requestMagicLink } from "../../../../lib/create/api";
import { safeInternalPath } from "../../../../lib/safeInternalPath";
import { setTransferPendingFlag } from "../../../(app)/create/utils/anonymousDraftStorage";
@@ -55,7 +56,6 @@ export default function LoginForm({
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
- const formAlertId = useId();
const emailErrorId = useId();
const [email, setEmail] = useState("");
@@ -166,26 +166,40 @@ export default function LoginForm({
/>
- {urlErrorMessage ? (
-
- {urlErrorMessage}
-
- ) : null}
-
- {formError ? (
-
- {formError}
-
- ) : null}
+ {(urlErrorMessage || formError) && (
+
+
+ {urlErrorMessage ? (
+
{
+ stripErrorQuery();
+ }}
+ className="w-full"
+ />
+ ) : null}
+ {formError ? (
+ {
+ setFormError("");
+ }}
+ className="w-full"
+ />
+ ) : null}
+
+
+ )}
{!sent ? (