Compare commits

...
8 Commits
Author SHA1 Message Date
ginnoir 51c8d3c92d chore: release v0.5.1
Release / build-and-push (push) Has been cancelled
2026-06-03 19:55:57 -05:00
ginnoir 62c3d33350 fix: dashboard switcher, platform-aware search shortcut, mobile search, static topbar
- Always show dashboard tab strip + add button (was gated behind length > 1)
- Search shortcut now shows Ctrl+K on Windows/Linux, ⌘K on Mac
- Add icon-only search button in topbar for mobile viewports
- Lock html/body overflow and use 100dvh to prevent topbar scrolling off-screen on mobile
2026-06-03 19:55:00 -05:00
ginnoir 2408d37f8e chore: release v0.5.0
Release / build-and-push (push) Has been cancelled
2026-06-03 19:20:37 -05:00
ginnoir f47c1c3a23 fix: replace settings mobile tabs with 2-col grid and remove fab 2026-06-03 19:17:17 -05:00
ginnoir 6e92648a41 feat: mobile experience overhaul — touch targets, calendar mobile layout, swipe gestures 2026-06-03 18:50:15 -05:00
ginnoir 6802ae08db fix: db:seed script points to seed.mjs not nonexistent seed.ts 2026-06-03 16:19:29 -05:00
ginnoir b935ff209c chore: release v0.4.10
Release / build-and-push (push) Has been cancelled
2026-06-03 16:16:01 -05:00
ginnoir 3fb5ddc5dd chore: upgrade node 22 to 24 lts 2026-06-03 16:15:00 -05:00
18 changed files with 785 additions and 431 deletions
+2 -2
View File
@@ -17,7 +17,7 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 24
cache: pnpm cache: pnpm
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
@@ -39,7 +39,7 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 24
cache: pnpm cache: pnpm
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
+1 -1
View File
@@ -1 +1 @@
22 24
+2 -2
View File
@@ -31,7 +31,7 @@ Codex and Claude Code both work on this project. Keep `AGENTS.md`, `CLAUDE.md`,
| Realtime | Postgres `LISTEN/NOTIFY` → SSE | Simpler than WebSockets, fine for two users | | Realtime | Postgres `LISTEN/NOTIFY` → SSE | Simpler than WebSockets, fine for two users |
| Reverse proxy | Caddy (existing) | `fam.ginnoir.com` → famapp:3000, `auth.ginnoir.com` → authentik | | Reverse proxy | Caddy (existing) | `fam.ginnoir.com` → famapp:3000, `auth.ginnoir.com` → authentik |
| Package manager | pnpm | Fast, content-addressed store, strict deps | | Package manager | pnpm | Fast, content-addressed store, strict deps |
| Runtime | Node 22 LTS | In `node:22-alpine` container | | Runtime | Node 24 LTS | In `node:24-alpine` container |
| Host | Ubuntu Server 24.04 x64, Docker + Compose | Existing self-host stack | | Host | Ubuntu Server 24.04 x64, Docker + Compose | Existing self-host stack |
Domain: `fam.ginnoir.com`. SSO: `auth.ginnoir.com`. Domain: `fam.ginnoir.com`. SSO: `auth.ginnoir.com`.
@@ -106,7 +106,7 @@ All household-scoped entity tables include `household_id`. Sub-entities (`calend
```yaml ```yaml
# compose.yaml (sketch — final lives in /deploy) # compose.yaml (sketch — final lives in /deploy)
services: services:
famapp: # node:22-alpine, Next.js famapp: # node:24-alpine, Next.js
famapp-db: # postgres:16 famapp-db: # postgres:16
authentik-server: authentik-server:
authentik-worker: authentik-worker:
+19
View File
@@ -1,5 +1,24 @@
# Changelog # Changelog
## [0.5.1](https://github.com/ginnoir/famapp/compare/v0.5.0...v0.5.1) (2026-06-03)
### Bug Fixes
- **dashboard:** always show tab strip and add-dashboard button regardless of dashboard count
- **search:** show Ctrl+K on Windows/Linux instead of ⌘K
- **mobile:** add search icon button in topbar for mobile viewports
- **mobile:** lock html/body overflow and use 100dvh to prevent topbar scrolling off-screen
## [0.5.0](https://github.com/ginnoir/famapp/compare/v0.4.10...v0.5.0) (2026-06-03)
### Features
- **mobile:** full mobile experience overhaul — bottom nav, touch targets, calendar mobile layout with day/week/agenda views and calendars bottom sheet, bidirectional swipe gestures on list items (right to complete, left to delete), iOS safe-area and keyboard support, input zoom prevention
- **mobile:** settings navigation replaced with 2-column icon grid (no horizontal scroll)
- **mobile:** remove floating action button in favour of persistent topbar quick-add button
## [0.4.10](https://github.com/ginnoir/famapp/compare/v0.4.9...v0.4.10) (2026-06-03)
## [0.4.8](https://github.com/ginnoir/famapp/compare/v0.4.6...v0.4.8) (2026-06-03) ## [0.4.8](https://github.com/ginnoir/famapp/compare/v0.4.6...v0.4.8) (2026-06-03)
### Features ### Features
+2 -2
View File
@@ -31,7 +31,7 @@ Codex and Claude Code both work on this project. Keep `AGENTS.md`, `CLAUDE.md`,
| Realtime | Postgres `LISTEN/NOTIFY` → SSE | Simpler than WebSockets, fine for two users | | Realtime | Postgres `LISTEN/NOTIFY` → SSE | Simpler than WebSockets, fine for two users |
| Reverse proxy | Caddy (existing) | `fam.ginnoir.com` → famapp:3000, `auth.ginnoir.com` → authentik | | Reverse proxy | Caddy (existing) | `fam.ginnoir.com` → famapp:3000, `auth.ginnoir.com` → authentik |
| Package manager | pnpm | Fast, content-addressed store, strict deps | | Package manager | pnpm | Fast, content-addressed store, strict deps |
| Runtime | Node 22 LTS | In `node:22-alpine` container | | Runtime | Node 24 LTS | In `node:24-alpine` container |
| Host | Ubuntu Server 24.04 x64, Docker + Compose | Existing self-host stack | | Host | Ubuntu Server 24.04 x64, Docker + Compose | Existing self-host stack |
Domain: `fam.ginnoir.com`. SSO: `auth.ginnoir.com`. Domain: `fam.ginnoir.com`. SSO: `auth.ginnoir.com`.
@@ -106,7 +106,7 @@ All household-scoped entity tables include `household_id`. Sub-entities (`calend
```yaml ```yaml
# compose.yaml (sketch — final lives in /deploy) # compose.yaml (sketch — final lives in /deploy)
services: services:
famapp: # node:22-alpine, Next.js famapp: # node:24-alpine, Next.js
famapp-db: # postgres:16 famapp-db: # postgres:16
authentik-server: authentik-server:
authentik-worker: authentik-worker:
+2 -2
View File
@@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# ── Stage 1: fetch dependencies ─────────────────────────────────────────────── # ── Stage 1: fetch dependencies ───────────────────────────────────────────────
FROM node:22-alpine AS deps FROM node:24-alpine AS deps
RUN corepack enable && corepack prepare pnpm@10.33.3 --activate RUN corepack enable && corepack prepare pnpm@10.33.3 --activate
WORKDIR /app WORKDIR /app
COPY package.json pnpm-lock.yaml .npmrc ./ COPY package.json pnpm-lock.yaml .npmrc ./
@@ -20,7 +20,7 @@ RUN --mount=type=cache,id=famapp-nextjs,target=/app/.next/cache \
pnpm build pnpm build
# ── Stage 3: production runtime ─────────────────────────────────────────────── # ── Stage 3: production runtime ───────────────────────────────────────────────
FROM node:22-alpine AS runner FROM node:24-alpine AS runner
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
ENV PORT=3000 ENV PORT=3000
+1 -1
View File
@@ -2,7 +2,7 @@
## Prerequisites ## Prerequisites
- **Node.js** 22 LTS (`node --version` should print `v22.x.x`) - **Node.js** 24 LTS (`node --version` should print `v24.x.x`)
- **pnpm** 10 (`pnpm --version` should print `10.x.x`) - **pnpm** 10 (`pnpm --version` should print `10.x.x`)
- **Docker** with the Compose plugin (used for the local Postgres + MinIO containers) - **Docker** with the Compose plugin (used for the local Postgres + MinIO containers)
+5 -4
View File
@@ -1,11 +1,11 @@
{ {
"name": "famapp", "name": "famapp",
"version": "0.4.9", "version": "0.5.1",
"private": true, "private": true,
"type": "module", "type": "module",
"packageManager": "pnpm@10.33.3", "packageManager": "pnpm@10.33.3",
"engines": { "engines": {
"node": ">=22" "node": ">=24"
}, },
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@@ -22,7 +22,7 @@
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:seed": "tsx --env-file=.env scripts/seed.ts", "db:seed": "node --env-file=.env scripts/seed.mjs",
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio",
"gen:icons": "node scripts/generate-icons.mjs", "gen:icons": "node scripts/generate-icons.mjs",
"vapid:generate": "node scripts/vapid-generate.mjs", "vapid:generate": "node scripts/vapid-generate.mjs",
@@ -53,7 +53,7 @@
"@release-it/conventional-changelog": "^11.0.1", "@release-it/conventional-changelog": "^11.0.1",
"@tailwindcss/postcss": "^4.2.4", "@tailwindcss/postcss": "^4.2.4",
"@types/canvas-confetti": "^1.9.0", "@types/canvas-confetti": "^1.9.0",
"@types/node": "^22.9.0", "@types/node": "^24.0.0",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/web-push": "^3.6.4", "@types/web-push": "^3.6.4",
@@ -78,6 +78,7 @@
"@fullcalendar/core": "^6.1.20", "@fullcalendar/core": "^6.1.20",
"@fullcalendar/daygrid": "^6.1.20", "@fullcalendar/daygrid": "^6.1.20",
"@fullcalendar/interaction": "^6.1.20", "@fullcalendar/interaction": "^6.1.20",
"@fullcalendar/list": "^6.1.20",
"@fullcalendar/react": "^6.1.20", "@fullcalendar/react": "^6.1.20",
"@fullcalendar/timegrid": "^6.1.20", "@fullcalendar/timegrid": "^6.1.20",
"canvas-confetti": "^1.9.4", "canvas-confetti": "^1.9.4",
+106 -94
View File
@@ -23,6 +23,9 @@ importers:
'@fullcalendar/interaction': '@fullcalendar/interaction':
specifier: ^6.1.20 specifier: ^6.1.20
version: 6.1.20(@fullcalendar/core@6.1.20) version: 6.1.20(@fullcalendar/core@6.1.20)
'@fullcalendar/list':
specifier: ^6.1.20
version: 6.1.20(@fullcalendar/core@6.1.20)
'@fullcalendar/react': '@fullcalendar/react':
specifier: ^6.1.20 specifier: ^6.1.20
version: 6.1.20(@fullcalendar/core@6.1.20)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) version: 6.1.20(@fullcalendar/core@6.1.20)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
@@ -73,7 +76,7 @@ importers:
version: 2.2.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5) version: 2.2.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
shadcn: shadcn:
specifier: ^4.7.0 specifier: ^4.7.0
version: 4.7.0(@types/node@22.19.17)(typescript@5.9.3) version: 4.7.0(@types/node@24.12.4)(typescript@5.9.3)
tailwind-merge: tailwind-merge:
specifier: ^3.5.0 specifier: ^3.5.0
version: 3.5.0 version: 3.5.0
@@ -89,7 +92,7 @@ importers:
devDependencies: devDependencies:
'@commitlint/cli': '@commitlint/cli':
specifier: ^21.0.2 specifier: ^21.0.2
version: 21.0.2(@types/node@22.19.17)(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(typescript@5.9.3) version: 21.0.2(@types/node@24.12.4)(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(typescript@5.9.3)
'@commitlint/config-conventional': '@commitlint/config-conventional':
specifier: ^21.0.2 specifier: ^21.0.2
version: 21.0.2 version: 21.0.2
@@ -101,7 +104,7 @@ importers:
version: 1.59.1 version: 1.59.1
'@release-it/conventional-changelog': '@release-it/conventional-changelog':
specifier: ^11.0.1 specifier: ^11.0.1
version: 11.0.1(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(release-it@20.2.0(@types/node@22.19.17)) version: 11.0.1(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(release-it@20.2.0(@types/node@24.12.4))
'@tailwindcss/postcss': '@tailwindcss/postcss':
specifier: ^4.2.4 specifier: ^4.2.4
version: 4.2.4 version: 4.2.4
@@ -109,8 +112,8 @@ importers:
specifier: ^1.9.0 specifier: ^1.9.0
version: 1.9.0 version: 1.9.0
'@types/node': '@types/node':
specifier: ^22.9.0 specifier: ^24.0.0
version: 22.19.17 version: 24.12.4
'@types/react': '@types/react':
specifier: ^19.2.14 specifier: ^19.2.14
version: 19.2.14 version: 19.2.14
@@ -149,7 +152,7 @@ importers:
version: 3.8.3 version: 3.8.3
release-it: release-it:
specifier: ^20.2.0 specifier: ^20.2.0
version: 20.2.0(@types/node@22.19.17) version: 20.2.0(@types/node@24.12.4)
tailwindcss: tailwindcss:
specifier: ^4.2.4 specifier: ^4.2.4
version: 4.2.4 version: 4.2.4
@@ -976,6 +979,11 @@ packages:
peerDependencies: peerDependencies:
'@fullcalendar/core': ~6.1.20 '@fullcalendar/core': ~6.1.20
'@fullcalendar/list@6.1.20':
resolution: {integrity: sha512-7Hzkbb7uuSqrXwTyD0Ld/7SwWNxPD6SlU548vtkIpH55rZ4qquwtwYdMPgorHos5OynHA4OUrZNcH51CjrCf2g==}
peerDependencies:
'@fullcalendar/core': ~6.1.20
'@fullcalendar/react@6.1.20': '@fullcalendar/react@6.1.20':
resolution: {integrity: sha512-1w0pZtceaUdfAnxMSCGHCQalhi+mR1jOe76sXzyAXpcPz/Lf0zHSdcGK/U2XpZlnQgQtBZW+d+QBnnzVQKCxAA==} resolution: {integrity: sha512-1w0pZtceaUdfAnxMSCGHCQalhi+mR1jOe76sXzyAXpcPz/Lf0zHSdcGK/U2XpZlnQgQtBZW+d+QBnnzVQKCxAA==}
peerDependencies: peerDependencies:
@@ -1862,8 +1870,8 @@ packages:
'@types/json5@0.0.29': '@types/json5@0.0.29':
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
'@types/node@22.19.17': '@types/node@24.12.4':
resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==} resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==}
'@types/normalize-package-data@2.4.4': '@types/normalize-package-data@2.4.4':
resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==}
@@ -4895,8 +4903,8 @@ packages:
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
undici-types@6.21.0: undici-types@7.16.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
undici@7.24.5: undici@7.24.5:
resolution: {integrity: sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q==} resolution: {integrity: sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q==}
@@ -5347,11 +5355,11 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 19.2.14 '@types/react': 19.2.14
'@commitlint/cli@21.0.2(@types/node@22.19.17)(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(typescript@5.9.3)': '@commitlint/cli@21.0.2(@types/node@24.12.4)(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(typescript@5.9.3)':
dependencies: dependencies:
'@commitlint/format': 21.0.1 '@commitlint/format': 21.0.1
'@commitlint/lint': 21.0.2 '@commitlint/lint': 21.0.2
'@commitlint/load': 21.0.2(@types/node@22.19.17)(typescript@5.9.3) '@commitlint/load': 21.0.2(@types/node@24.12.4)(typescript@5.9.3)
'@commitlint/read': 21.0.2(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0) '@commitlint/read': 21.0.2(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)
'@commitlint/types': 21.0.1 '@commitlint/types': 21.0.1
tinyexec: 1.2.4 tinyexec: 1.2.4
@@ -5396,14 +5404,14 @@ snapshots:
'@commitlint/rules': 21.0.2 '@commitlint/rules': 21.0.2
'@commitlint/types': 21.0.1 '@commitlint/types': 21.0.1
'@commitlint/load@21.0.2(@types/node@22.19.17)(typescript@5.9.3)': '@commitlint/load@21.0.2(@types/node@24.12.4)(typescript@5.9.3)':
dependencies: dependencies:
'@commitlint/config-validator': 21.0.1 '@commitlint/config-validator': 21.0.1
'@commitlint/execute-rule': 21.0.1 '@commitlint/execute-rule': 21.0.1
'@commitlint/resolve-extends': 21.0.1 '@commitlint/resolve-extends': 21.0.1
'@commitlint/types': 21.0.1 '@commitlint/types': 21.0.1
cosmiconfig: 9.0.1(typescript@5.9.3) cosmiconfig: 9.0.1(typescript@5.9.3)
cosmiconfig-typescript-loader: 6.3.0(@types/node@22.19.17)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3) cosmiconfig-typescript-loader: 6.3.0(@types/node@24.12.4)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3)
es-toolkit: 1.47.0 es-toolkit: 1.47.0
is-plain-obj: 4.1.0 is-plain-obj: 4.1.0
picocolors: 1.1.1 picocolors: 1.1.1
@@ -5810,6 +5818,10 @@ snapshots:
dependencies: dependencies:
'@fullcalendar/core': 6.1.20 '@fullcalendar/core': 6.1.20
'@fullcalendar/list@6.1.20(@fullcalendar/core@6.1.20)':
dependencies:
'@fullcalendar/core': 6.1.20
'@fullcalendar/react@6.1.20(@fullcalendar/core@6.1.20)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': '@fullcalendar/react@6.1.20(@fullcalendar/core@6.1.20)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies: dependencies:
'@fullcalendar/core': 6.1.20 '@fullcalendar/core': 6.1.20
@@ -5942,140 +5954,140 @@ snapshots:
'@inquirer/ansi@2.0.7': {} '@inquirer/ansi@2.0.7': {}
'@inquirer/checkbox@5.2.1(@types/node@22.19.17)': '@inquirer/checkbox@5.2.1(@types/node@24.12.4)':
dependencies: dependencies:
'@inquirer/ansi': 2.0.7 '@inquirer/ansi': 2.0.7
'@inquirer/core': 11.2.1(@types/node@22.19.17) '@inquirer/core': 11.2.1(@types/node@24.12.4)
'@inquirer/figures': 2.0.7 '@inquirer/figures': 2.0.7
'@inquirer/type': 4.0.7(@types/node@22.19.17) '@inquirer/type': 4.0.7(@types/node@24.12.4)
optionalDependencies: optionalDependencies:
'@types/node': 22.19.17 '@types/node': 24.12.4
'@inquirer/confirm@6.0.12(@types/node@22.19.17)': '@inquirer/confirm@6.0.12(@types/node@24.12.4)':
dependencies: dependencies:
'@inquirer/core': 11.1.9(@types/node@22.19.17) '@inquirer/core': 11.1.9(@types/node@24.12.4)
'@inquirer/type': 4.0.5(@types/node@22.19.17) '@inquirer/type': 4.0.5(@types/node@24.12.4)
optionalDependencies: optionalDependencies:
'@types/node': 22.19.17 '@types/node': 24.12.4
'@inquirer/core@11.1.9(@types/node@22.19.17)': '@inquirer/core@11.1.9(@types/node@24.12.4)':
dependencies: dependencies:
'@inquirer/ansi': 2.0.5 '@inquirer/ansi': 2.0.5
'@inquirer/figures': 2.0.5 '@inquirer/figures': 2.0.5
'@inquirer/type': 4.0.5(@types/node@22.19.17) '@inquirer/type': 4.0.5(@types/node@24.12.4)
cli-width: 4.1.0 cli-width: 4.1.0
fast-wrap-ansi: 0.2.0 fast-wrap-ansi: 0.2.0
mute-stream: 3.0.0 mute-stream: 3.0.0
signal-exit: 4.1.0 signal-exit: 4.1.0
optionalDependencies: optionalDependencies:
'@types/node': 22.19.17 '@types/node': 24.12.4
'@inquirer/core@11.2.1(@types/node@22.19.17)': '@inquirer/core@11.2.1(@types/node@24.12.4)':
dependencies: dependencies:
'@inquirer/ansi': 2.0.7 '@inquirer/ansi': 2.0.7
'@inquirer/figures': 2.0.7 '@inquirer/figures': 2.0.7
'@inquirer/type': 4.0.7(@types/node@22.19.17) '@inquirer/type': 4.0.7(@types/node@24.12.4)
cli-width: 4.1.0 cli-width: 4.1.0
fast-wrap-ansi: 0.2.0 fast-wrap-ansi: 0.2.0
mute-stream: 3.0.0 mute-stream: 3.0.0
signal-exit: 4.1.0 signal-exit: 4.1.0
optionalDependencies: optionalDependencies:
'@types/node': 22.19.17 '@types/node': 24.12.4
'@inquirer/editor@5.2.2(@types/node@22.19.17)': '@inquirer/editor@5.2.2(@types/node@24.12.4)':
dependencies: dependencies:
'@inquirer/core': 11.2.1(@types/node@22.19.17) '@inquirer/core': 11.2.1(@types/node@24.12.4)
'@inquirer/external-editor': 3.0.3(@types/node@22.19.17) '@inquirer/external-editor': 3.0.3(@types/node@24.12.4)
'@inquirer/type': 4.0.7(@types/node@22.19.17) '@inquirer/type': 4.0.7(@types/node@24.12.4)
optionalDependencies: optionalDependencies:
'@types/node': 22.19.17 '@types/node': 24.12.4
'@inquirer/expand@5.1.1(@types/node@22.19.17)': '@inquirer/expand@5.1.1(@types/node@24.12.4)':
dependencies: dependencies:
'@inquirer/core': 11.2.1(@types/node@22.19.17) '@inquirer/core': 11.2.1(@types/node@24.12.4)
'@inquirer/type': 4.0.7(@types/node@22.19.17) '@inquirer/type': 4.0.7(@types/node@24.12.4)
optionalDependencies: optionalDependencies:
'@types/node': 22.19.17 '@types/node': 24.12.4
'@inquirer/external-editor@3.0.3(@types/node@22.19.17)': '@inquirer/external-editor@3.0.3(@types/node@24.12.4)':
dependencies: dependencies:
chardet: 2.1.1 chardet: 2.1.1
iconv-lite: 0.7.2 iconv-lite: 0.7.2
optionalDependencies: optionalDependencies:
'@types/node': 22.19.17 '@types/node': 24.12.4
'@inquirer/figures@2.0.5': {} '@inquirer/figures@2.0.5': {}
'@inquirer/figures@2.0.7': {} '@inquirer/figures@2.0.7': {}
'@inquirer/input@5.1.2(@types/node@22.19.17)': '@inquirer/input@5.1.2(@types/node@24.12.4)':
dependencies: dependencies:
'@inquirer/core': 11.2.1(@types/node@22.19.17) '@inquirer/core': 11.2.1(@types/node@24.12.4)
'@inquirer/type': 4.0.7(@types/node@22.19.17) '@inquirer/type': 4.0.7(@types/node@24.12.4)
optionalDependencies: optionalDependencies:
'@types/node': 22.19.17 '@types/node': 24.12.4
'@inquirer/number@4.1.1(@types/node@22.19.17)': '@inquirer/number@4.1.1(@types/node@24.12.4)':
dependencies: dependencies:
'@inquirer/core': 11.2.1(@types/node@22.19.17) '@inquirer/core': 11.2.1(@types/node@24.12.4)
'@inquirer/type': 4.0.7(@types/node@22.19.17) '@inquirer/type': 4.0.7(@types/node@24.12.4)
optionalDependencies: optionalDependencies:
'@types/node': 22.19.17 '@types/node': 24.12.4
'@inquirer/password@5.1.1(@types/node@22.19.17)': '@inquirer/password@5.1.1(@types/node@24.12.4)':
dependencies: dependencies:
'@inquirer/ansi': 2.0.7 '@inquirer/ansi': 2.0.7
'@inquirer/core': 11.2.1(@types/node@22.19.17) '@inquirer/core': 11.2.1(@types/node@24.12.4)
'@inquirer/type': 4.0.7(@types/node@22.19.17) '@inquirer/type': 4.0.7(@types/node@24.12.4)
optionalDependencies: optionalDependencies:
'@types/node': 22.19.17 '@types/node': 24.12.4
'@inquirer/prompts@8.4.2(@types/node@22.19.17)': '@inquirer/prompts@8.4.2(@types/node@24.12.4)':
dependencies: dependencies:
'@inquirer/checkbox': 5.2.1(@types/node@22.19.17) '@inquirer/checkbox': 5.2.1(@types/node@24.12.4)
'@inquirer/confirm': 6.0.12(@types/node@22.19.17) '@inquirer/confirm': 6.0.12(@types/node@24.12.4)
'@inquirer/editor': 5.2.2(@types/node@22.19.17) '@inquirer/editor': 5.2.2(@types/node@24.12.4)
'@inquirer/expand': 5.1.1(@types/node@22.19.17) '@inquirer/expand': 5.1.1(@types/node@24.12.4)
'@inquirer/input': 5.1.2(@types/node@22.19.17) '@inquirer/input': 5.1.2(@types/node@24.12.4)
'@inquirer/number': 4.1.1(@types/node@22.19.17) '@inquirer/number': 4.1.1(@types/node@24.12.4)
'@inquirer/password': 5.1.1(@types/node@22.19.17) '@inquirer/password': 5.1.1(@types/node@24.12.4)
'@inquirer/rawlist': 5.3.1(@types/node@22.19.17) '@inquirer/rawlist': 5.3.1(@types/node@24.12.4)
'@inquirer/search': 4.2.1(@types/node@22.19.17) '@inquirer/search': 4.2.1(@types/node@24.12.4)
'@inquirer/select': 5.2.1(@types/node@22.19.17) '@inquirer/select': 5.2.1(@types/node@24.12.4)
optionalDependencies: optionalDependencies:
'@types/node': 22.19.17 '@types/node': 24.12.4
'@inquirer/rawlist@5.3.1(@types/node@22.19.17)': '@inquirer/rawlist@5.3.1(@types/node@24.12.4)':
dependencies: dependencies:
'@inquirer/core': 11.2.1(@types/node@22.19.17) '@inquirer/core': 11.2.1(@types/node@24.12.4)
'@inquirer/type': 4.0.7(@types/node@22.19.17) '@inquirer/type': 4.0.7(@types/node@24.12.4)
optionalDependencies: optionalDependencies:
'@types/node': 22.19.17 '@types/node': 24.12.4
'@inquirer/search@4.2.1(@types/node@22.19.17)': '@inquirer/search@4.2.1(@types/node@24.12.4)':
dependencies: dependencies:
'@inquirer/core': 11.2.1(@types/node@22.19.17) '@inquirer/core': 11.2.1(@types/node@24.12.4)
'@inquirer/figures': 2.0.7 '@inquirer/figures': 2.0.7
'@inquirer/type': 4.0.7(@types/node@22.19.17) '@inquirer/type': 4.0.7(@types/node@24.12.4)
optionalDependencies: optionalDependencies:
'@types/node': 22.19.17 '@types/node': 24.12.4
'@inquirer/select@5.2.1(@types/node@22.19.17)': '@inquirer/select@5.2.1(@types/node@24.12.4)':
dependencies: dependencies:
'@inquirer/ansi': 2.0.7 '@inquirer/ansi': 2.0.7
'@inquirer/core': 11.2.1(@types/node@22.19.17) '@inquirer/core': 11.2.1(@types/node@24.12.4)
'@inquirer/figures': 2.0.7 '@inquirer/figures': 2.0.7
'@inquirer/type': 4.0.7(@types/node@22.19.17) '@inquirer/type': 4.0.7(@types/node@24.12.4)
optionalDependencies: optionalDependencies:
'@types/node': 22.19.17 '@types/node': 24.12.4
'@inquirer/type@4.0.5(@types/node@22.19.17)': '@inquirer/type@4.0.5(@types/node@24.12.4)':
optionalDependencies: optionalDependencies:
'@types/node': 22.19.17 '@types/node': 24.12.4
'@inquirer/type@4.0.7(@types/node@22.19.17)': '@inquirer/type@4.0.7(@types/node@24.12.4)':
optionalDependencies: optionalDependencies:
'@types/node': 22.19.17 '@types/node': 24.12.4
'@jridgewell/gen-mapping@0.3.13': '@jridgewell/gen-mapping@0.3.13':
dependencies: dependencies:
@@ -6431,7 +6443,7 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 19.2.14 '@types/react': 19.2.14
'@release-it/conventional-changelog@11.0.1(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(release-it@20.2.0(@types/node@22.19.17))': '@release-it/conventional-changelog@11.0.1(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(release-it@20.2.0(@types/node@24.12.4))':
dependencies: dependencies:
'@conventional-changelog/git-client': 2.7.0(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0) '@conventional-changelog/git-client': 2.7.0(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)
concat-stream: 2.0.0 concat-stream: 2.0.0
@@ -6439,7 +6451,7 @@ snapshots:
conventional-changelog-angular: 8.3.1 conventional-changelog-angular: 8.3.1
conventional-changelog-conventionalcommits: 9.3.1 conventional-changelog-conventionalcommits: 9.3.1
conventional-recommended-bump: 11.2.0 conventional-recommended-bump: 11.2.0
release-it: 20.2.0(@types/node@22.19.17) release-it: 20.2.0(@types/node@24.12.4)
semver: 7.7.4 semver: 7.7.4
transitivePeerDependencies: transitivePeerDependencies:
- conventional-commits-filter - conventional-commits-filter
@@ -6551,9 +6563,9 @@ snapshots:
'@types/json5@0.0.29': {} '@types/json5@0.0.29': {}
'@types/node@22.19.17': '@types/node@24.12.4':
dependencies: dependencies:
undici-types: 6.21.0 undici-types: 7.16.0
'@types/normalize-package-data@2.4.4': {} '@types/normalize-package-data@2.4.4': {}
@@ -6571,7 +6583,7 @@ snapshots:
'@types/set-cookie-parser@2.4.10': '@types/set-cookie-parser@2.4.10':
dependencies: dependencies:
'@types/node': 22.19.17 '@types/node': 24.12.4
'@types/statuses@2.0.6': {} '@types/statuses@2.0.6': {}
@@ -6579,7 +6591,7 @@ snapshots:
'@types/web-push@3.6.4': '@types/web-push@3.6.4':
dependencies: dependencies:
'@types/node': 22.19.17 '@types/node': 24.12.4
'@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)': '@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)':
dependencies: dependencies:
@@ -7159,9 +7171,9 @@ snapshots:
object-assign: 4.1.1 object-assign: 4.1.1
vary: 1.1.2 vary: 1.1.2
cosmiconfig-typescript-loader@6.3.0(@types/node@22.19.17)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3): cosmiconfig-typescript-loader@6.3.0(@types/node@24.12.4)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3):
dependencies: dependencies:
'@types/node': 22.19.17 '@types/node': 24.12.4
cosmiconfig: 9.0.1(typescript@5.9.3) cosmiconfig: 9.0.1(typescript@5.9.3)
jiti: 2.6.1 jiti: 2.6.1
typescript: 5.9.3 typescript: 5.9.3
@@ -8658,9 +8670,9 @@ snapshots:
ms@2.1.3: {} ms@2.1.3: {}
msw@2.14.3(@types/node@22.19.17)(typescript@5.9.3): msw@2.14.3(@types/node@24.12.4)(typescript@5.9.3):
dependencies: dependencies:
'@inquirer/confirm': 6.0.12(@types/node@22.19.17) '@inquirer/confirm': 6.0.12(@types/node@24.12.4)
'@mswjs/interceptors': 0.41.8 '@mswjs/interceptors': 0.41.8
'@open-draft/deferred-promise': 3.0.0 '@open-draft/deferred-promise': 3.0.0
'@types/statuses': 2.0.6 '@types/statuses': 2.0.6
@@ -9233,9 +9245,9 @@ snapshots:
gopd: 1.2.0 gopd: 1.2.0
set-function-name: 2.0.2 set-function-name: 2.0.2
release-it@20.2.0(@types/node@22.19.17): release-it@20.2.0(@types/node@24.12.4):
dependencies: dependencies:
'@inquirer/prompts': 8.4.2(@types/node@22.19.17) '@inquirer/prompts': 8.4.2(@types/node@24.12.4)
'@octokit/rest': 22.0.1 '@octokit/rest': 22.0.1
'@phun-ky/typeof': 2.0.3 '@phun-ky/typeof': 2.0.3
async-retry: 1.3.3 async-retry: 1.3.3
@@ -9401,7 +9413,7 @@ snapshots:
setprototypeof@1.2.0: {} setprototypeof@1.2.0: {}
shadcn@4.7.0(@types/node@22.19.17)(typescript@5.9.3): shadcn@4.7.0(@types/node@24.12.4)(typescript@5.9.3):
dependencies: dependencies:
'@babel/core': 7.29.0 '@babel/core': 7.29.0
'@babel/parser': 7.29.3 '@babel/parser': 7.29.3
@@ -9422,7 +9434,7 @@ snapshots:
fuzzysort: 3.1.0 fuzzysort: 3.1.0
https-proxy-agent: 7.0.6 https-proxy-agent: 7.0.6
kleur: 4.1.5 kleur: 4.1.5
msw: 2.14.3(@types/node@22.19.17)(typescript@5.9.3) msw: 2.14.3(@types/node@24.12.4)(typescript@5.9.3)
node-fetch: 3.3.2 node-fetch: 3.3.2
open: 11.0.0 open: 11.0.0
ora: 8.2.0 ora: 8.2.0
@@ -9860,7 +9872,7 @@ snapshots:
has-symbols: 1.1.0 has-symbols: 1.1.0
which-boxed-primitive: 1.1.1 which-boxed-primitive: 1.1.1
undici-types@6.21.0: {} undici-types@7.16.0: {}
undici@7.24.5: {} undici@7.24.5: {}
+1 -1
View File
@@ -93,7 +93,7 @@ export default async function DashboardPage({
return ( return (
<div className={containerCls}> <div className={containerCls}>
{userDashboards.length > 1 && ( {userDashboards.length >= 1 && (
<div className="flex items-center gap-1 mb-4 overflow-x-auto"> <div className="flex items-center gap-1 mb-4 overflow-x-auto">
{userDashboards.map((d) => ( {userDashboards.map((d) => (
<DashboardTab key={d.id} slug={d.slug} name={d.name} /> <DashboardTab key={d.id} slug={d.slug} name={d.name} />
+88 -1
View File
@@ -490,6 +490,8 @@
body { body {
margin: 0; margin: 0;
padding: 0; padding: 0;
height: 100%;
overflow: hidden;
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
@@ -521,6 +523,7 @@
display: grid; display: grid;
grid-template-columns: 232px 1fr; grid-template-columns: 232px 1fr;
height: 100vh; height: 100vh;
height: 100dvh;
background: var(--paper); background: var(--paper);
} }
:where(html[data-nav="rail"]) .app { :where(html[data-nav="rail"]) .app {
@@ -725,7 +728,7 @@
} }
:where(html[data-nav="bottom"]) .scroll-area, :where(html[data-nav="bottom"]) .scroll-area,
:where(html[data-nav="fab"]) .scroll-area { :where(html[data-nav="fab"]) .scroll-area {
padding: 12px 14px 88px; padding: 12px 14px 74px;
} }
.scroll-area::-webkit-scrollbar { .scroll-area::-webkit-scrollbar {
@@ -1424,3 +1427,87 @@ select {
.animate-emoji-float { .animate-emoji-float {
animation: emojiFloat 1.8s ease-out both; animation: emojiFloat 1.8s ease-out both;
} }
/* ── Calendar min-height CSS variable ───────────────────────────── */
/* Used by calendar-shell so the grid fills the viewport correctly */
/* on both desktop (topbar only) and mobile (topbar + bottom nav). */
:root {
--cal-topbar-h: 57px;
--cal-nav-h: 0px;
}
:where(html[data-nav="bottom"]),
:where(html[data-nav="fab"]) {
--cal-nav-h: 62px;
}
/* ── Mobile overrides (≤ 759 px) ────────────────────────────────── */
@media (max-width: 759px) {
/* Prevent iOS Safari from zooming into inputs (requires ≥ 16 px) */
.input,
input,
select,
textarea {
font-size: 16px;
}
/* Topbar: tighten padding; avatar is redundant (Settings in bottom nav) */
.topbar {
padding: 10px 14px;
gap: 8px;
}
.topbar-actions > .avatar {
display: none;
}
/* Keyboard shortcut hints are meaningless on touch */
.kbd {
display: none;
}
/* Eliminate 300 ms tap delay on interactive controls */
.btn,
.checkbox,
.nav-item,
.list-row,
.fab,
.bottom-nav button,
.bottom-nav a {
touch-action: manipulation;
}
/* Momentum scrolling + prevent page bounce fighting in-app scroll */
.scroll-area {
-webkit-overflow-scrolling: touch;
overscroll-behavior-y: contain;
}
/* Checkbox: keep the 18 px visual but expand the tap area to 44 px */
.checkbox {
position: relative;
flex-shrink: 0;
}
.checkbox::after {
content: "";
position: absolute;
inset: -13px; /* 18 + 2×13 = 44 px tap target */
}
/* Delete button on list rows: always visible on touch (no hover state) */
.list-row-delete {
opacity: 1 !important;
}
}
/* Landscape phone: compress bottom nav to icon-only to reclaim vertical space */
@media (max-width: 759px) and (orientation: landscape) and (max-height: 500px) {
.bottom-nav {
height: 44px;
padding: 2px 8px env(safe-area-inset-bottom, 2px);
}
.bottom-nav .nav-label {
display: none;
}
:where(html[data-nav="bottom"]) .scroll-area {
padding-bottom: 52px;
}
}
+1
View File
@@ -42,6 +42,7 @@ const jetbrains = JetBrains_Mono({
export const viewport: Viewport = { export const viewport: Viewport = {
themeColor: "#1F1B16", themeColor: "#1F1B16",
viewportFit: "cover",
}; };
export const metadata: Metadata = { export const metadata: Metadata = {
-2
View File
@@ -4,7 +4,6 @@ import type { DashboardMeta } from "@/app/d/actions";
import { Sidebar } from "@/components/sidebar"; import { Sidebar } from "@/components/sidebar";
import { Topbar } from "@/components/topbar"; import { Topbar } from "@/components/topbar";
import { BottomNav } from "@/components/bottom-nav"; import { BottomNav } from "@/components/bottom-nav";
import { Fab } from "@/components/fab";
import { NavModeProvider } from "@/components/nav-mode-provider"; import { NavModeProvider } from "@/components/nav-mode-provider";
const BARE_PREFIXES = ["/s/", "/login"]; const BARE_PREFIXES = ["/s/", "/login"];
@@ -38,7 +37,6 @@ export async function AppShell({ signedIn, navStyle, children }: Props) {
<div className="scroll-area">{children}</div> <div className="scroll-area">{children}</div>
</main> </main>
<BottomNav /> <BottomNav />
<Fab />
<NavModeProvider navStyle={navStyle} /> <NavModeProvider navStyle={navStyle} />
</div> </div>
); );
+7 -1
View File
@@ -29,8 +29,14 @@ const QUICK_ADD_ICON: Record<string, string> = {
"file-plus": "note", "file-plus": "note",
}; };
function useShortcutLabel() {
if (typeof navigator === "undefined") return "⌘K";
return /Mac|iPhone|iPad|iPod/.test(navigator.platform) ? "⌘K" : "Ctrl+K";
}
export function QuickAddSheet() { export function QuickAddSheet() {
const { sheetOpen, closeSheet, actions } = useQuickAdd(); const { sheetOpen, closeSheet, actions } = useQuickAdd();
const shortcut = useShortcutLabel();
const router = useRouter(); const router = useRouter();
const backdropRef = useRef<HTMLDivElement>(null); const backdropRef = useRef<HTMLDivElement>(null);
@@ -110,7 +116,7 @@ export function QuickAddSheet() {
}} }}
> >
<Sparkles className="size-3" /> <Sparkles className="size-3" />
<span>Pick what to add or use K to search.</span> <span>Pick what to add or use {shortcut} to search.</span>
</div> </div>
<div className="max-h-[60vh] overflow-y-auto"> <div className="max-h-[60vh] overflow-y-auto">
+10 -4
View File
@@ -81,20 +81,26 @@ function SectionHashSync({ pathname }: { pathname: string | null }) {
export function SettingsTabsMobile({ active }: { active: SectionId }) { export function SettingsTabsMobile({ active }: { active: SectionId }) {
const router = useRouter(); const router = useRouter();
return ( return (
<div className="seg w-full overflow-x-auto" style={{ display: "flex" }}> <div className="grid grid-cols-2 gap-1.5 mb-1">
{SECTIONS.map((s) => ( {SECTIONS.map((s) => (
<button <button
key={s.id} key={s.id}
type="button" type="button"
role="tab" aria-current={active === s.id ? "page" : undefined}
aria-selected={active === s.id} className={cn(
"flex items-center gap-2.5 px-3 py-2.5 rounded-[var(--r-md)] border-[0.5px] text-left text-[13px] font-medium transition-colors",
active === s.id
? "bg-[var(--card)] border-[var(--hair-2)] text-[var(--ink)] shadow-[var(--shadow-1)]"
: "border-transparent text-[var(--ink-soft)] hover:bg-[var(--shade)]",
)}
onClick={() => { onClick={() => {
const url = new URL(window.location.href); const url = new URL(window.location.href);
url.searchParams.set("s", s.id); url.searchParams.set("s", s.id);
router.replace(url.pathname + "?" + url.searchParams.toString(), { scroll: false }); router.replace(url.pathname + "?" + url.searchParams.toString(), { scroll: false });
}} }}
> >
{s.label.split(" ")[0]} <NavIcon name={s.icon} className="size-4 shrink-0" />
<span>{s.label.split(" ")[0]}</span>
</button> </button>
))} ))}
</div> </div>
+22 -2
View File
@@ -3,14 +3,22 @@
import { useQuickAdd } from "@/components/quick-add-provider"; import { useQuickAdd } from "@/components/quick-add-provider";
import { NavIcon } from "@/components/nav-icon"; import { NavIcon } from "@/components/nav-icon";
function useShortcutLabel() {
if (typeof navigator === "undefined") return "⌘K";
return /Mac|iPhone|iPad|iPod/.test(navigator.platform) ? "⌘K" : "Ctrl+K";
}
export function TopbarSearch() { export function TopbarSearch() {
const { openPalette } = useQuickAdd(); const { openPalette } = useQuickAdd();
const shortcut = useShortcutLabel();
return ( return (
<>
{/* Desktop: pill with shortcut hint */}
<button <button
type="button" type="button"
onClick={openPalette} onClick={openPalette}
aria-label="Search (⌘K)" aria-label={`Search (${shortcut})`}
className="hidden md:flex items-center gap-2 h-8 px-2.5 rounded-md text-[13px]" className="hidden md:flex items-center gap-2 h-8 px-2.5 rounded-md text-[13px]"
style={{ style={{
border: "0.5px solid var(--hair-2)", border: "0.5px solid var(--hair-2)",
@@ -21,7 +29,19 @@ export function TopbarSearch() {
> >
<NavIcon name="search" className="size-3.5" /> <NavIcon name="search" className="size-3.5" />
<span style={{ flex: 1, textAlign: "left" }}>Search</span> <span style={{ flex: 1, textAlign: "left" }}>Search</span>
<span className="kbd">K</span> <span className="kbd">{shortcut}</span>
</button> </button>
{/* Mobile: icon-only */}
<button
type="button"
onClick={openPalette}
aria-label="Search"
className="flex md:hidden items-center justify-center h-8 w-8 rounded-md"
style={{ color: "var(--ink-mute)" }}
>
<NavIcon name="search" className="size-4" />
</button>
</>
); );
} }
@@ -4,10 +4,11 @@ import FullCalendar from "@fullcalendar/react";
import dayGridPlugin from "@fullcalendar/daygrid"; import dayGridPlugin from "@fullcalendar/daygrid";
import interactionPlugin from "@fullcalendar/interaction"; import interactionPlugin from "@fullcalendar/interaction";
import timeGridPlugin from "@fullcalendar/timegrid"; import timeGridPlugin from "@fullcalendar/timegrid";
import listPlugin from "@fullcalendar/list";
import type { DateSelectArg, EventClickArg, EventDropArg } from "@fullcalendar/core"; import type { DateSelectArg, EventClickArg, EventDropArg } from "@fullcalendar/core";
import type { EventResizeDoneArg } from "@fullcalendar/interaction"; import type { EventResizeDoneArg } from "@fullcalendar/interaction";
import { CalendarPlus, Check, Eye, EyeOff, Plus, Trash2 } from "lucide-react"; import { CalendarPlus, Check, Eye, EyeOff, Plus, Trash2, X } from "lucide-react";
import { useMemo, useState, useTransition } from "react"; import { useMemo, useState, useEffect, useTransition } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@@ -44,6 +45,8 @@ type EventDraft = {
remindMinutesBefore: number | null; remindMinutesBefore: number | null;
}; };
type MobileView = "day" | "week" | "agenda";
const DEFAULT_COLOR = "#B85C3C"; const DEFAULT_COLOR = "#B85C3C";
const VIEW_MAP: Record<CalView, string> = { const VIEW_MAP: Record<CalView, string> = {
@@ -52,8 +55,13 @@ const VIEW_MAP: Record<CalView, string> = {
day: "timeGridDay", day: "timeGridDay",
}; };
const MOBILE_VIEW_MAP: Record<MobileView, string> = {
day: "timeGridDay",
week: "timeGridWeek",
agenda: "listWeek",
};
function withAlpha(hex: string, alpha: number): string { function withAlpha(hex: string, alpha: number): string {
// Accept #RGB / #RRGGBB / non-hex (return as-is for non-hex e.g. var(--))
if (!hex.startsWith("#")) return hex; if (!hex.startsWith("#")) return hex;
let h = hex.slice(1); let h = hex.slice(1);
if (h.length === 3) if (h.length === 3)
@@ -88,6 +96,17 @@ export function CalendarShell({
); );
const [lastCalendarId, setLastCalendarId] = useState(calendars[0]?.id ?? ""); const [lastCalendarId, setLastCalendarId] = useState(calendars[0]?.id ?? "");
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const [isMobile, setIsMobile] = useState(false);
const [showCalendarsPanel, setShowCalendarsPanel] = useState(false);
const [mobileView, setMobileView] = useState<MobileView>("day");
useEffect(() => {
const mq = window.matchMedia("(max-width: 759px)");
const apply = () => setIsMobile(mq.matches);
apply();
mq.addEventListener("change", apply);
return () => mq.removeEventListener("change", apply);
}, []);
const defaultCalendarId = calendarRows.some((calendar) => calendar.id === lastCalendarId) const defaultCalendarId = calendarRows.some((calendar) => calendar.id === lastCalendarId)
? lastCalendarId ? lastCalendarId
@@ -289,17 +308,10 @@ export function CalendarShell({
}); });
} }
return ( // Shared calendar list + new-calendar form rendered in both the desktop aside
<div className="grid min-h-[calc(100vh-57px)] grid-cols-1 lg:grid-cols-[280px_1fr]"> // and the mobile bottom-sheet panel.
<aside className="border-b bg-sidebar p-4 lg:border-r lg:border-b-0"> const calendarsPanelContent = (
<div className="mb-4 flex items-center justify-between"> <>
<h1 className="text-xl font-semibold">Calendar</h1>
<Button size="icon-sm" variant="outline" onClick={() => openNewEvent()}>
<Plus />
<span className="sr-only">New event</span>
</Button>
</div>
<div className="space-y-2"> <div className="space-y-2">
{calendarRows.map((calendar) => ( {calendarRows.map((calendar) => (
<div key={calendar.id} className="rounded-lg border bg-background p-2"> <div key={calendar.id} className="rounded-lg border bg-background p-2">
@@ -333,9 +345,7 @@ export function CalendarShell({
<Select <Select
value={calendar.visibility} value={calendar.visibility}
onValueChange={(value) => onValueChange={(value) =>
updateCalendar(calendar, { updateCalendar(calendar, { visibility: value as "private" | "household" })
visibility: value as "private" | "household",
})
} }
> >
<SelectTrigger aria-label={`${calendar.name} visibility`}> <SelectTrigger aria-label={`${calendar.name} visibility`}>
@@ -403,35 +413,12 @@ export function CalendarShell({
</Button> </Button>
</div> </div>
</div> </div>
</aside> </>
);
<section className="fc-skin min-w-0 p-4"> const fcEvents = visibleEvents.map((event) => {
<FullCalendar
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
initialView={VIEW_MAP[defaultView]}
headerToolbar={{
left: "prev,next today",
center: "title",
right: "dayGridMonth,timeGridWeek,timeGridDay",
}}
buttonText={{
today: "Today",
month: "Month",
week: "Week",
day: "Day",
}}
dayHeaderFormat={{ weekday: "short" }}
selectable
editable
eventResizableFromStart
select={handleDateSelect}
eventClick={openExistingEvent}
eventDrop={moveEvent}
eventResize={moveEvent}
events={visibleEvents.map((event) => {
const color = const color =
calendarRows.find((calendar) => calendar.id === event.calendarId)?.color ?? calendarRows.find((calendar) => calendar.id === event.calendarId)?.color ?? DEFAULT_COLOR;
DEFAULT_COLOR;
return { return {
id: event.id, id: event.id,
title: event.title, title: event.title,
@@ -443,42 +430,22 @@ export function CalendarShell({
textColor: "var(--ink-2)", textColor: "var(--ink-2)",
extendedProps: { calendarId: event.calendarId, color }, extendedProps: { calendarId: event.calendarId, color },
}; };
})} });
eventClassNames={(arg) => {
const id = String(arg.event.extendedProps["calendarId"] ?? "");
return id ? [`fc-cal-${id.slice(0, 8)}`] : [];
}}
height="auto"
/>
</section>
{selectedEvent && ( // Shared event form content — rendered inside both the desktop centered modal
<div className="fixed inset-0 z-50 grid place-items-center bg-black/20 p-4"> // and the mobile bottom-sheet.
<div className="w-full max-w-lg rounded-lg bg-popover p-4 text-popover-foreground shadow-lg"> const eventFormContent = selectedEvent && (
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold">
{selectedEvent.id ? "Edit event" : "New event"}
</h2>
<Button size="icon-sm" variant="ghost" onClick={() => setSelectedEvent(null)}>
<span aria-hidden>×</span>
<span className="sr-only">Close</span>
</Button>
</div>
<div className="grid gap-3"> <div className="grid gap-3">
<Label htmlFor="event-title">Title</Label> <Label htmlFor="event-title">Title</Label>
<Input <Input
id="event-title" id="event-title"
value={selectedEvent.title} value={selectedEvent.title}
onChange={(event) => onChange={(event) => setSelectedEvent({ ...selectedEvent, title: event.target.value })}
setSelectedEvent({ ...selectedEvent, title: event.target.value })
}
/> />
<Label htmlFor="event-calendar">Calendar</Label> <Label htmlFor="event-calendar">Calendar</Label>
<Select <Select
value={selectedEvent.calendarId} value={selectedEvent.calendarId}
onValueChange={(value) => onValueChange={(value) => setSelectedEvent({ ...selectedEvent, calendarId: value ?? "" })}
setSelectedEvent({ ...selectedEvent, calendarId: value ?? "" })
}
> >
<SelectTrigger id="event-calendar"> <SelectTrigger id="event-calendar">
<SelectValue> <SelectValue>
@@ -512,9 +479,7 @@ export function CalendarShell({
id="event-end" id="event-end"
type="datetime-local" type="datetime-local"
value={selectedEvent.endAt} value={selectedEvent.endAt}
onChange={(event) => onChange={(event) => setSelectedEvent({ ...selectedEvent, endAt: event.target.value })}
setSelectedEvent({ ...selectedEvent, endAt: event.target.value })
}
/> />
</div> </div>
</div> </div>
@@ -522,18 +487,14 @@ export function CalendarShell({
<Input <Input
id="event-location" id="event-location"
value={selectedEvent.location} value={selectedEvent.location}
onChange={(event) => onChange={(event) => setSelectedEvent({ ...selectedEvent, location: event.target.value })}
setSelectedEvent({ ...selectedEvent, location: event.target.value })
}
/> />
<Label htmlFor="event-notes">Notes</Label> <Label htmlFor="event-notes">Notes</Label>
<textarea <textarea
id="event-notes" id="event-notes"
className="min-h-20 rounded-lg border border-input bg-transparent px-2.5 py-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50" className="min-h-20 rounded-lg border border-input bg-transparent px-2.5 py-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
value={selectedEvent.notes} value={selectedEvent.notes}
onChange={(event) => onChange={(event) => setSelectedEvent({ ...selectedEvent, notes: event.target.value })}
setSelectedEvent({ ...selectedEvent, notes: event.target.value })
}
/> />
{!selectedEvent.id && ( {!selectedEvent.id && (
<label className="flex items-center gap-2 text-sm"> <label className="flex items-center gap-2 text-sm">
@@ -569,6 +530,164 @@ export function CalendarShell({
</div> </div>
</div> </div>
</div> </div>
);
return (
<div
className="grid grid-cols-1 lg:grid-cols-[280px_1fr]"
style={{ minHeight: "calc(100dvh - var(--cal-topbar-h) - var(--cal-nav-h))" }}
>
{/* Desktop aside — hidden on mobile */}
<aside
className={`${isMobile ? "hidden" : ""} border-b bg-sidebar p-4 lg:border-r lg:border-b-0`}
>
<div className="mb-4 flex items-center justify-between">
<h1 className="text-xl font-semibold">Calendar</h1>
<Button size="icon-sm" variant="outline" onClick={() => openNewEvent()}>
<Plus />
<span className="sr-only">New event</span>
</Button>
</div>
{calendarsPanelContent}
</aside>
{/* Calendar section */}
<section className="fc-skin flex min-w-0 flex-col">
{/* Mobile toolbar: view switcher + calendars button + new event */}
{isMobile && (
<div
className="flex items-center gap-2 border-b px-3 py-2"
style={{ borderColor: "var(--hair)" }}
>
<div className="seg flex-1">
{(["day", "week", "agenda"] as MobileView[]).map((v) => (
<button
key={v}
type="button"
aria-selected={mobileView === v}
onClick={() => setMobileView(v)}
>
{v.charAt(0).toUpperCase() + v.slice(1)}
</button>
))}
</div>
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() => setShowCalendarsPanel(true)}
>
<Eye size={14} />
Cals
</button>
<button type="button" className="btn btn-primary btn-sm" onClick={() => openNewEvent()}>
<Plus size={14} />
New
</button>
</div>
)}
<div className="flex-1 p-4">
<FullCalendar
key={isMobile ? `mobile-${mobileView}` : `desktop-${defaultView}`}
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin, listPlugin]}
initialView={isMobile ? MOBILE_VIEW_MAP[mobileView] : VIEW_MAP[defaultView]}
headerToolbar={
isMobile
? { left: "prev,next", center: "title", right: "today" }
: {
left: "prev,next today",
center: "title",
right: "dayGridMonth,timeGridWeek,timeGridDay",
}
}
buttonText={{ today: "Today", month: "Month", week: "Week", day: "Day", list: "List" }}
dayHeaderFormat={{ weekday: "short" }}
selectable={!isMobile}
editable={!isMobile}
eventResizableFromStart={!isMobile}
select={handleDateSelect}
eventClick={openExistingEvent}
eventDrop={moveEvent}
eventResize={moveEvent}
events={fcEvents}
eventClassNames={(arg) => {
const id = String(arg.event.extendedProps["calendarId"] ?? "");
return id ? [`fc-cal-${id.slice(0, 8)}`] : [];
}}
height="auto"
/>
</div>
</section>
{/* Mobile: calendars bottom sheet */}
{isMobile && showCalendarsPanel && (
<div
className="fixed inset-0 z-[45] bg-black/40"
onClick={() => setShowCalendarsPanel(false)}
>
<div
className="absolute inset-x-0 overflow-auto rounded-t-[var(--r-xl)]"
style={{
bottom: "var(--cal-nav-h)",
maxHeight: "72dvh",
background: "var(--card)",
paddingBottom: "env(safe-area-inset-bottom)",
}}
onClick={(e) => e.stopPropagation()}
>
<div
className="flex items-center justify-between border-b p-4"
style={{ borderColor: "var(--hair)" }}
>
<h3 className="serif text-[16px] font-medium">Calendars</h3>
<button
type="button"
className="btn btn-ghost btn-icon"
aria-label="Close calendars panel"
onClick={() => setShowCalendarsPanel(false)}
>
<X size={16} />
</button>
</div>
<div className="p-4">{calendarsPanelContent}</div>
</div>
</div>
)}
{/* Event form — centered modal on desktop, bottom sheet on mobile */}
{selectedEvent && (
<div
className="fixed inset-0 z-50 flex bg-black/20"
style={
isMobile
? { alignItems: "flex-end" }
: { alignItems: "center", justifyContent: "center" }
}
onClick={(e) => {
if (e.target === e.currentTarget) setSelectedEvent(null);
}}
>
<div
className="overflow-auto bg-popover text-popover-foreground shadow-lg"
style={{
borderRadius: isMobile ? "var(--r-xl) var(--r-xl) 0 0" : "var(--r-lg)",
width: isMobile ? "100%" : undefined,
maxWidth: isMobile ? undefined : "512px",
maxHeight: "90dvh",
padding: "1rem",
paddingBottom: isMobile ? "calc(1rem + env(safe-area-inset-bottom))" : "1rem",
}}
>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold">
{selectedEvent.id ? "Edit event" : "New event"}
</h2>
<Button size="icon-sm" variant="ghost" onClick={() => setSelectedEvent(null)}>
<X size={16} />
<span className="sr-only">Close</span>
</Button>
</div>
{eventFormContent}
</div> </div>
</div> </div>
)} )}
+96 -11
View File
@@ -194,17 +194,100 @@ function ListItemRow({
onCommit: () => void; onCommit: () => void;
onRemove: () => void; onRemove: () => void;
}) { }) {
const swipeStart = useRef<number | null>(null); const swipeOrigin = useRef<{ x: number; y: number } | null>(null);
const isScrolling = useRef(false);
const [swipeX, setSwipeX] = useState(0);
const THRESHOLD = 80; // px to commit the swipe action
const MAX_SWIPE = 100; // px maximum visual displacement
function handlePointerDown(e: React.PointerEvent<HTMLDivElement>) {
e.currentTarget.setPointerCapture(e.pointerId);
swipeOrigin.current = { x: e.clientX, y: e.clientY };
isScrolling.current = false;
setSwipeX(0);
}
function handlePointerMove(e: React.PointerEvent<HTMLDivElement>) {
if (!swipeOrigin.current) return;
const dx = e.clientX - swipeOrigin.current.x;
const dy = Math.abs(e.clientY - swipeOrigin.current.y);
// If the gesture is primarily vertical, treat as scroll and abort swipe.
if (!isScrolling.current && dy > Math.abs(dx) && dy > 8) {
isScrolling.current = true;
setSwipeX(0);
return;
}
if (isScrolling.current) return;
setSwipeX(Math.max(-MAX_SWIPE, Math.min(MAX_SWIPE, dx)));
}
function handlePointerUp(e: React.PointerEvent<HTMLDivElement>) {
if (swipeOrigin.current && !isScrolling.current) {
const dx = e.clientX - swipeOrigin.current.x;
if (dx > THRESHOLD) onToggle(!item.done); // right → complete / uncomplete
if (dx < -THRESHOLD) onRemove(); // left → delete
}
swipeOrigin.current = null;
setSwipeX(0);
}
function handlePointerCancel() {
swipeOrigin.current = null;
setSwipeX(0);
}
// Background tint that deepens as the swipe approaches the threshold.
const swipeProgress = Math.abs(swipeX) / MAX_SWIPE;
const swipeBg =
swipeX > 16
? `color-mix(in oklab, var(--ok) ${swipeProgress * 55}%, var(--card))`
: swipeX < -16
? `color-mix(in oklab, var(--bad) ${swipeProgress * 55}%, var(--card))`
: undefined;
return ( return (
<div <div
className={`list-row ${item.done ? "checked" : ""} group/row`} className="relative overflow-hidden border-b-[0.5px] last:border-b-0 group/row"
onPointerDown={(event) => { style={{ borderColor: "var(--hair)", background: swipeBg, touchAction: "pan-y" }}
swipeStart.current = event.clientX; onPointerDown={handlePointerDown}
}} onPointerMove={handlePointerMove}
onPointerUp={(event) => { onPointerUp={handlePointerUp}
const start = swipeStart.current; onPointerCancel={handlePointerCancel}
if (start !== null && event.clientX - start < -60) onRemove(); >
swipeStart.current = null; {/* Swipe affordance icons revealed behind the sliding content */}
<div
className="absolute inset-y-0 left-3 flex items-center"
aria-hidden
style={{ opacity: Math.max(0, swipeX - 16) / (MAX_SWIPE - 16), color: "var(--ok)" }}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.4"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M5 12l5 5L20 7" />
</svg>
</div>
<div
className="absolute inset-y-0 right-3 flex items-center"
aria-hidden
style={{ opacity: Math.max(0, -swipeX - 16) / (MAX_SWIPE - 16), color: "var(--bad)" }}
>
<Trash2 size={16} />
</div>
{/* Sliding content row */}
<div
className={`flex items-center gap-3 px-[14px] py-[10px] hover:bg-[var(--shade)] ${item.done ? "checked" : ""}`}
style={{
transform: `translateX(${swipeX}px)`,
transition: swipeX === 0 ? "transform 0.22s ease-out" : "none",
}} }}
> >
<button <button
@@ -231,20 +314,22 @@ function ListItemRow({
</button> </button>
<Input <Input
aria-label={`${item.text} text`} aria-label={`${item.text} text`}
className="!border-0 !bg-transparent !shadow-none focus-visible:!ring-0 row-text flex-1 px-0 h-7 text-[13.5px]" className={`!border-0 !bg-transparent !shadow-none focus-visible:!ring-0 row-text flex-1 px-0 h-7 text-[13.5px] ${item.done ? "line-through text-[var(--ink-faint)]" : ""}`}
value={item.text} value={item.text}
onChange={(event) => onEdit(event.target.value)} onChange={(event) => onEdit(event.target.value)}
onBlur={onCommit} onBlur={onCommit}
/> />
{/* Always visible on touch (list-row-delete makes opacity-0 override to 1 on mobile) */}
<Button <Button
size="icon-sm" size="icon-sm"
variant="ghost" variant="ghost"
aria-label={`Delete ${item.text}`} aria-label={`Delete ${item.text}`}
onClick={onRemove} onClick={onRemove}
className="opacity-0 group-hover/row:opacity-100 transition-opacity" className="list-row-delete opacity-0 group-hover/row:opacity-100 transition-opacity"
> >
<Trash2 className="size-3.5" /> <Trash2 className="size-3.5" />
</Button> </Button>
</div> </div>
</div>
); );
} }