Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .cursor/rules/form-spacing.mdc
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
---
description: Form spacing hierarchy — small/medium/large rhythm for settings, modals, and auth forms
globs: src/app/**/*.{ts,tsx},src/app/_components/**/*.{ts,tsx}
alwaysApply: false
---

# Form spacing

Import tokens from `~/lib/form-spacing` (`formSpacing.tight` / `.stack` / `.section`). Do not invent one-off `space-y-*` values for form layout.

## Scale

| Level | Token | Use for |
|---|---|---|
| Small | `formSpacing.tight` (`space-y-1.5`) | Title→description, label→input, input→helper/error, avatar→upload copy |
| Medium | `formSpacing.stack` (`space-y-5`) | Related controls in the same section (name→username, password fields) |
| Large | `formSpacing.section` (`space-y-8`) | Distinct sections (photo vs profile info) and content→actions |

Horizontal siblings in a row use the matching `*Gap` token (`tightGap` / `stackGap` / `sectionGap`).

## Grouping

Think in logical groups, not identical gaps between every control:

```tsx
<div className={formSpacing.section}>
<AvatarField /> {/* internal: tight */}
<div className={formSpacing.stack}>
<NameFields />
<UsernameField />
</div>
</div>
```

Section chrome (title + description) uses `tight` between those lines, then `stack` before the fields in that section.

## Avoid

- Cards, boxed groups, or heavy dividers used only to create hierarchy
- Flat `space-y-4` (or any single value) across an entire form
- Mixing raw Tailwind spacing with the tokens in the same form

## Primitives

- `FormItem` already uses the tight scale for label→control→message
- Account settings `SettingsField` / `DialogSection` follow the same tokens
- New settings pages, dialogs, and auth forms should compose the same way
74 changes: 74 additions & 0 deletions .cursor/rules/mobile-drawer-menus.mdc
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
---
description: Mobile drill-down drawer menus — use the shared kit; do not reinvent sizing/keyboard behavior
globs: src/app/_components/mobile-drawer/**/*.{ts,tsx},src/app/_components/account-settings/**/*.{ts,tsx},src/app/_components/document-actions.tsx,src/app/_components/document-breadcrumb.tsx,src/app/_components/editor/document-publish-panels.tsx
alwaysApply: false
---

# Mobile drawer menus

Build mobile drill-down menus with `~/app/_components/mobile-drawer`. Defaults already match Account + Document Actions. Prefer composing these primitives over custom Vaul height/keyboard logic.

## Recipe (new menu)

1. **Shell** — `MobileMenuDrawer` (keyboard offset + content-sized shell). Do not hand-wire `useMobileDrawerKeyboardOffset` + `MOBILE_DRAWER_SHELL_CLASS` unless you have a special case.
2. **Stage** — `useMobileDrawerStage` + `MobileDrawerViewStack`. Pass `mainView`, `keyboardView` (string or stable array of input screens), and `measureDeps` when main content changes.
3. **Root list** — `MobileDrawerScreenHeader` + `MobileActionGroup` / `MobileActionButtonRow`.
4. **Intermediate list** (optional, e.g. Profile) — `MobileDrawerNavHeader` + rows. Stage auto-grows/shrinks from measured content.
5. **Field / keyboard screen** — `MobileDrawerFieldView` + inputs using `MOBILE_DRAWER_FIELD_INPUT_CLASS`.

Standalone single-field edit (no stack): `MobileFormDrawer`.

## Stage views

| Kind | Examples | Stage behavior |
|---|---|---|
| Main | Account root, Publish root | Measured as baseline |
| Intermediate | Profile list | Remeasured; taller than main is fine |
| Keyboard | First name, Edit URL, Password | Sized to **content + small clearance**; not main/intermediate height |

`keyboardView` must list every screen with text inputs. Intermediate lists without inputs are **not** keyboard views.

## Keyboard / leave (required)

- **Back** from a field screen: always dismiss via `MobileDrawerFieldView` (default) or `useMobileDrawerLeave()` so the previous view does not resize against a shifting visual viewport.
- **Async save then navigate**: `dismissKeyboardOnDone={false}` on `MobileDrawerFieldView`, then `leave(onSaved)` after success (`useMobileDrawerLeave`).
- **Autofocus**: built into `MobileDrawerFieldView` (first `input`/`textarea`). Prefer that over manual focus + timeouts.

## Do not

```tsx
// ❌ Full-viewport Vaul height while keyboard is open (fills the screen)
applyMobileDrawerKeyboardInset();
keyboardShellInset: true;

// ❌ Predictive tall floor for simple fields
keyboardMinContentPx: 268;

// ❌ Measure main via stageRef while on an intermediate view (poisons height)
mainMeasureRef.current ?? stageRef.current;

// ❌ Navigate back while keyboard is up without wait/blur
onBack={stage.returnToView("profile")};
```

```tsx
// ✅ Defaults — content-measured, shell inset off
useMobileDrawerStage({ view, setView, mainView: "main", keyboardView: KEYBOARD_VIEWS });

// ✅ Keyboard-safe back
<MobileDrawerFieldView title="…" onBack={() => stage.returnToView("profile")} onDone={…}>
<Input className={MOBILE_DRAWER_FIELD_INPUT_CLASS} />
</MobileDrawerFieldView>
```

## Wiring checklist

- Open subviews with `stage.measureMainStage()` then `stage.goToView(next, 1)`.
- Back to root: `stage.returnToMainView`. Back to intermediate: `stage.returnToView("profile")`.
- Feature code owns: row labels/icons, field schemas, mutations, save feedback. Kit owns: sizing, transitions, focus, keyboard dismiss.

## Reference implementations

- Multi-level + fields: `account-settings/account-settings.tsx`, `mobile-profile-field-edit.tsx`
- Main → keyboard field: `document-actions.tsx` + `editor/document-publish-panels.tsx`
- Standalone field: `document-breadcrumb.tsx` → `MobileFormDrawer`
1 change: 1 addition & 0 deletions migrations/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,3 +12,4 @@ Older tables in your project may have been created before this repo’s filename
| File | Purpose |
|------|--------|
| `document_publications.sql` | Public published docs: `/[owner segment]/[slug]`, RLS |
| `document_publication_redirects.sql` | Path redirects after username/slug changes; redirect-before-publication lookup |
99 changes: 99 additions & 0 deletions migrations/document_publication_redirects.sql
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
-- Path-exact redirects for published docs after owner segment / slug changes.
-- Lookup order on public pages: redirect first, then publication — so old URLs
-- keep resolving even if another user later claims the same username+slug.

CREATE TABLE IF NOT EXISTS document_publication_redirects (
from_owner_username TEXT NOT NULL,
from_slug TEXT NOT NULL,
to_owner_username TEXT NOT NULL,
to_slug TEXT NOT NULL,
document_id UUID NOT NULL REFERENCES documents (id) ON DELETE CASCADE,
creator_id UUID NOT NULL REFERENCES auth.users (id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (from_owner_username, from_slug),
CONSTRAINT document_publication_redirects_from_username_format CHECK (
from_owner_username ~ '^[a-z0-9_-]{2,50}$'
),
CONSTRAINT document_publication_redirects_to_username_format CHECK (
to_owner_username ~ '^[a-z0-9_-]{2,50}$'
),
CONSTRAINT document_publication_redirects_from_slug_format CHECK (
from_slug ~ '^[a-z0-9]+(?:-[a-z0-9]+)*$'
AND char_length(from_slug) BETWEEN 1 AND 200
),
CONSTRAINT document_publication_redirects_to_slug_format CHECK (
to_slug ~ '^[a-z0-9]+(?:-[a-z0-9]+)*$'
AND char_length(to_slug) BETWEEN 1 AND 200
),
CONSTRAINT document_publication_redirects_not_identity CHECK (
from_owner_username <> to_owner_username
OR from_slug <> to_slug
)
);

CREATE INDEX IF NOT EXISTS document_publication_redirects_to_path_idx
ON document_publication_redirects (to_owner_username, to_slug);

CREATE INDEX IF NOT EXISTS document_publication_redirects_document_id_idx
ON document_publication_redirects (document_id);

CREATE INDEX IF NOT EXISTS document_publication_redirects_creator_id_idx
ON document_publication_redirects (creator_id);

ALTER TABLE document_publication_redirects ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Public read document publication redirects"
ON document_publication_redirects FOR SELECT USING (true);

CREATE POLICY "Document editors insert publication redirects"
ON document_publication_redirects FOR INSERT WITH CHECK (
EXISTS (
SELECT 1
FROM document_permissions dp
WHERE
dp.document_id = document_publication_redirects.document_id
AND dp.user_id = (SELECT auth.uid())
)
AND creator_id = (
SELECT d.creator_id
FROM documents d
WHERE d.id = document_publication_redirects.document_id
)
);

CREATE POLICY "Document editors update publication redirects"
ON document_publication_redirects FOR UPDATE USING (
EXISTS (
SELECT 1
FROM document_permissions dp
WHERE
dp.document_id = document_publication_redirects.document_id
AND dp.user_id = (SELECT auth.uid())
)
)
WITH CHECK (
EXISTS (
SELECT 1
FROM document_permissions dp
WHERE
dp.document_id = document_publication_redirects.document_id
AND dp.user_id = (SELECT auth.uid())
)
AND creator_id = (
SELECT d.creator_id
FROM documents d
WHERE d.id = document_publication_redirects.document_id
)
);

CREATE POLICY "Document editors delete publication redirects"
ON document_publication_redirects FOR DELETE USING (
EXISTS (
SELECT 1
FROM document_permissions dp
WHERE
dp.document_id = document_publication_redirects.document_id
AND dp.user_id = (SELECT auth.uid())
)
);
Loading