@meith/web 0.33.1 → 0.33.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -22,7 +22,7 @@ export async function generateMetadata(): Promise<Metadata> {
22
22
  }
23
23
 
24
24
  const INPUT =
25
- 'rounded-md border border-border bg-background px-3 py-2 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
25
+ 'rounded-md border border-input bg-card px-3 py-2 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
26
26
 
27
27
  export default async function MembersPage({
28
28
  searchParams,
@@ -1,6 +1,5 @@
1
1
  import type { Metadata } from 'next'
2
2
  import { notFound, redirect } from 'next/navigation'
3
- import { after as scheduleAfterResponse } from 'next/server'
4
3
 
5
4
  import { currentRequestId } from '@meith/core/logger'
6
5
  import { acceptsThreads, canHoldThreads } from '@meith/forums'
@@ -268,14 +267,11 @@ export default async function ThreadPage({
268
267
  })
269
268
 
270
269
  if (readState !== null && actor.userId !== null) {
271
- const readerId = actor.userId
272
270
  const lastRenderedPostId = postPage.rows.at(-1)?.id
273
271
  if (lastRenderedPostId !== undefined) {
274
- scheduleAfterResponse(() =>
275
- readState
276
- .markThreadRead(readerId, thread.id, lastRenderedPostId, new Date())
277
- .catch(() => undefined),
278
- )
272
+ await readState
273
+ .markThreadRead(actor.userId, thread.id, lastRenderedPostId, new Date())
274
+ .catch(() => undefined)
279
275
  }
280
276
  }
281
277
 
@@ -111,7 +111,7 @@ export default async function AdminAttachmentsPage({
111
111
  <input
112
112
  name="filename"
113
113
  defaultValue={filename ?? ''}
114
- className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
114
+ className="w-full rounded-md border border-input bg-card px-3 py-2 text-sm"
115
115
  />
116
116
  </label>
117
117
 
@@ -120,7 +120,7 @@ export default async function AdminAttachmentsPage({
120
120
  <select
121
121
  name="status"
122
122
  defaultValue={status ?? ''}
123
- className="rounded-md border border-border bg-background px-3 py-2 text-sm"
123
+ className="rounded-md border border-input bg-card px-3 py-2 text-sm"
124
124
  >
125
125
  <option value="">{translator.t('adminAttachments.any')}</option>
126
126
  <option value="ready">{translator.t('adminAttachments.ready')}</option>
@@ -73,13 +73,30 @@ export default async function AdminLayout({ children }: { children: React.ReactN
73
73
  {await tr('page.skip-content')}
74
74
  </a>
75
75
 
76
- <header className="sticky top-0 z-30 border-b border-border bg-card">
77
- <div className="mx-auto flex min-h-14 w-full max-w-6xl items-center gap-x-3 px-4 py-2 sm:gap-x-4 sm:px-6">
76
+ <header className="sticky top-0 z-30 border-b border-border bg-card/95 backdrop-blur supports-[backdrop-filter]:bg-card/85">
77
+ <div className="mx-auto flex h-14 w-full max-w-6xl items-center gap-x-3 px-4 sm:gap-x-4 sm:px-6">
78
78
  <a
79
79
  href="/admin"
80
- className="min-w-0 truncate font-heading text-base font-semibold whitespace-nowrap text-foreground sm:text-lg"
80
+ className="flex min-w-0 items-center gap-2.5 font-heading text-base font-semibold whitespace-nowrap text-foreground sm:text-lg"
81
81
  >
82
- {await tr('page.control-panel')}
82
+ <span
83
+ aria-hidden="true"
84
+ className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground shadow-sm"
85
+ >
86
+ <svg
87
+ aria-hidden="true"
88
+ viewBox="0 0 16 16"
89
+ className="size-4"
90
+ fill="none"
91
+ stroke="currentColor"
92
+ strokeWidth="1.5"
93
+ strokeLinecap="round"
94
+ strokeLinejoin="round"
95
+ >
96
+ <path d="M3 4.5h10M3 8h10M3 11.5h10M6 3v3M10.5 6.5v3M5 10v3" />
97
+ </svg>
98
+ </span>
99
+ <span className="truncate">{await tr('page.control-panel')}</span>
83
100
  </a>
84
101
  <div className="ml-auto flex shrink-0 items-center gap-3 text-sm whitespace-nowrap sm:gap-4">
85
102
  <AdminSignOutForm copy={adminFormsCopy(t)} />
@@ -17,7 +17,7 @@ export async function generateMetadata(): Promise<Metadata> {
17
17
  }
18
18
 
19
19
  const INPUT =
20
- 'w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
20
+ 'w-full rounded-md border border-input bg-card px-3 py-2 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
21
21
 
22
22
  export default async function AdminMergePage({
23
23
  params,
@@ -24,7 +24,7 @@ export async function generateMetadata(): Promise<Metadata> {
24
24
  }
25
25
 
26
26
  const INPUT =
27
- 'w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
27
+ 'w-full rounded-md border border-input bg-card px-3 py-2 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
28
28
 
29
29
  export default async function AdminUsersPage({
30
30
  searchParams,
@@ -16,7 +16,7 @@ export async function generateMetadata(): Promise<Metadata> {
16
16
  }
17
17
 
18
18
  const INPUT =
19
- 'w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
19
+ 'w-full rounded-md border border-input bg-card px-3 py-2 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
20
20
 
21
21
  export default async function AdminPrunePage({
22
22
  searchParams,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meith/web",
3
- "version": "0.33.1",
3
+ "version": "0.33.3",
4
4
  "description": "The board itself: the Next.js app, and the forum-web bin that materializes it into an external board workspace.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -43,54 +43,54 @@
43
43
  "react-dom": "19.2.8",
44
44
  "tailwindcss": "^4.3.3",
45
45
  "typescript": "7.0.2",
46
- "@meith/accounts": "0.33.1",
47
- "@meith/admin": "0.33.1",
48
- "@meith/antispam": "0.33.1",
49
- "@meith/api": "0.33.1",
50
- "@meith/attachments": "0.33.1",
51
- "@meith/authorization": "0.33.1",
52
- "@meith/avatars": "0.33.1",
53
- "@meith/board-digest": "0.33.1",
54
- "@meith/core": "0.33.1",
55
- "@meith/db": "0.33.1",
56
- "@meith/demo": "0.33.1",
57
- "@meith/drafts": "0.33.1",
58
- "@meith/drivers": "0.33.1",
59
- "@meith/events": "0.33.1",
60
- "@meith/forums": "0.33.1",
61
- "@meith/groups": "0.33.1",
62
- "@meith/i18n": "0.33.1",
63
- "@meith/import": "0.33.1",
64
- "@meith/install": "0.33.1",
65
- "@meith/mail": "0.33.1",
66
- "@meith/markdown": "0.33.1",
67
- "@meith/marketplace": "0.33.1",
68
- "@meith/messages": "0.33.1",
69
- "@meith/moderation": "0.33.1",
70
- "@meith/notifications": "0.33.1",
71
- "@meith/plugin-calendar": "0.33.1",
72
- "@meith/plugin-dues": "0.33.1",
73
- "@meith/plugin-kit": "0.33.1",
74
- "@meith/polls": "0.33.1",
75
- "@meith/posts": "0.33.1",
76
- "@meith/profile-fields": "0.33.1",
77
- "@meith/relations": "0.33.1",
78
- "@meith/reputation": "0.33.1",
79
- "@meith/runtime": "0.33.1",
80
- "@meith/search": "0.33.1",
81
- "@meith/settings": "0.33.1",
82
- "@meith/signatures": "0.33.1",
83
- "@meith/subscriptions": "0.33.1",
84
- "@meith/tasks": "0.33.1",
85
- "@meith/theme-clubhouse": "0.33.1",
86
- "@meith/theme-default": "0.33.1",
87
- "@meith/theme-kit": "0.33.1",
88
- "@meith/theme-midnight": "0.33.1",
89
- "@meith/theme-phasebook": "0.33.1",
90
- "@meith/theme-raidframe": "0.33.1",
91
- "@meith/threads": "0.33.1",
92
- "@meith/ui": "0.33.1",
93
- "@meith/upgrade": "0.33.1"
46
+ "@meith/accounts": "0.33.3",
47
+ "@meith/admin": "0.33.3",
48
+ "@meith/antispam": "0.33.3",
49
+ "@meith/api": "0.33.3",
50
+ "@meith/attachments": "0.33.3",
51
+ "@meith/authorization": "0.33.3",
52
+ "@meith/avatars": "0.33.3",
53
+ "@meith/board-digest": "0.33.3",
54
+ "@meith/core": "0.33.3",
55
+ "@meith/db": "0.33.3",
56
+ "@meith/demo": "0.33.3",
57
+ "@meith/drafts": "0.33.3",
58
+ "@meith/drivers": "0.33.3",
59
+ "@meith/events": "0.33.3",
60
+ "@meith/forums": "0.33.3",
61
+ "@meith/groups": "0.33.3",
62
+ "@meith/i18n": "0.33.3",
63
+ "@meith/import": "0.33.3",
64
+ "@meith/install": "0.33.3",
65
+ "@meith/mail": "0.33.3",
66
+ "@meith/markdown": "0.33.3",
67
+ "@meith/marketplace": "0.33.3",
68
+ "@meith/messages": "0.33.3",
69
+ "@meith/moderation": "0.33.3",
70
+ "@meith/notifications": "0.33.3",
71
+ "@meith/plugin-calendar": "0.33.3",
72
+ "@meith/plugin-dues": "0.33.3",
73
+ "@meith/plugin-kit": "0.33.3",
74
+ "@meith/polls": "0.33.3",
75
+ "@meith/posts": "0.33.3",
76
+ "@meith/profile-fields": "0.33.3",
77
+ "@meith/reputation": "0.33.3",
78
+ "@meith/relations": "0.33.3",
79
+ "@meith/runtime": "0.33.3",
80
+ "@meith/search": "0.33.3",
81
+ "@meith/settings": "0.33.3",
82
+ "@meith/signatures": "0.33.3",
83
+ "@meith/subscriptions": "0.33.3",
84
+ "@meith/tasks": "0.33.3",
85
+ "@meith/theme-default": "0.33.3",
86
+ "@meith/theme-clubhouse": "0.33.3",
87
+ "@meith/theme-kit": "0.33.3",
88
+ "@meith/theme-midnight": "0.33.3",
89
+ "@meith/theme-phasebook": "0.33.3",
90
+ "@meith/theme-raidframe": "0.33.3",
91
+ "@meith/threads": "0.33.3",
92
+ "@meith/ui": "0.33.3",
93
+ "@meith/upgrade": "0.33.3"
94
94
  },
95
95
  "scripts": {
96
96
  "dev": "next dev",
@@ -92,7 +92,7 @@ export function AvatarForm({
92
92
  type="file"
93
93
  name="avatar"
94
94
  accept=".png,.jpg,.jpeg"
95
- className="rounded-md border border-border bg-background px-3 py-2 text-sm file:mr-3 file:rounded file:border-0 file:bg-muted file:px-3 file:py-1 file:text-sm"
95
+ className="rounded-md border border-input bg-card px-3 py-2 text-sm file:mr-3 file:rounded file:border-0 file:bg-muted file:px-3 file:py-1 file:text-sm"
96
96
  />
97
97
  <span className="text-xs text-muted-foreground">
98
98
  {fromCopy(copy, 'accountForm.avatar.hint')}
@@ -204,7 +204,7 @@ export function AnnouncementsOptInForm({ optedIn, copy }: { optedIn: boolean; co
204
204
  }
205
205
 
206
206
  const FIELD =
207
- 'w-full max-w-xs rounded-md border border-border bg-background px-3 py-2 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
207
+ 'w-full max-w-xs rounded-md border border-input bg-card px-3 py-2 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
208
208
 
209
209
  export function BoardDigestCadenceForm({ cadence, copy }: { cadence: string; copy: Copy }) {
210
210
  const [state, action] = useActionState(saveBoardDigestCadenceAction, EMPTY_STATE)
@@ -9,7 +9,7 @@ import { FormError, PendingButton } from '../auth/form-controls'
9
9
  import { type Copy, fromCopy } from '../shell/copy'
10
10
 
11
11
  const FIELD =
12
- 'w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
12
+ 'w-full rounded-md border border-input bg-card px-3 py-2 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
13
13
 
14
14
  const CARD = 'flex flex-col gap-3 rounded-lg border border-border bg-card p-5'
15
15
 
@@ -22,7 +22,7 @@ import { type Copy, fromCopy } from '../shell/copy'
22
22
  export type { CustomFieldInput }
23
23
 
24
24
  const FIELD =
25
- 'w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
25
+ 'w-full rounded-md border border-input bg-card px-3 py-2 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
26
26
 
27
27
  const BUTTON =
28
28
  'inline-flex h-9 items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
@@ -1,7 +1,7 @@
1
1
  import { Alert, AlertDescription } from '@meith/ui'
2
2
 
3
3
  export const INPUT =
4
- 'w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
4
+ 'w-full rounded-md border border-input bg-card px-3 py-2 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
5
5
 
6
6
  export function Saved({ when = true, children }: { when?: boolean; children: React.ReactNode }) {
7
7
  if (!when) return null
@@ -368,7 +368,10 @@ function MatrixSection({
368
368
  </thead>
369
369
  <tbody>
370
370
  {rows.map((row) => (
371
- <tr key={row.key} className="border-b border-border/60 last:border-b-0">
371
+ <tr
372
+ key={row.key}
373
+ className="scroll-mt-16 scroll-mb-24 border-b border-border/60 last:border-b-0"
374
+ >
372
375
  <th
373
376
  scope="row"
374
377
  title={row.description}
@@ -120,7 +120,7 @@ export function OklchPicker({
120
120
  value={value}
121
121
  onChange={(event) => onChange(event.target.value)}
122
122
  placeholder={shipped}
123
- className="w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-xs focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
123
+ className="w-full rounded-md border border-input bg-card px-3 py-2 font-mono text-xs focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
124
124
  />
125
125
  </div>
126
126
 
@@ -135,7 +135,7 @@ export function RetryJobForm({ copy }: { copy: Copy }) {
135
135
  <span className="font-medium">{fromCopy(copy, 'adminPanel.system.jobId')}</span>
136
136
  <input
137
137
  name="jobId"
138
- className="w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-xs"
138
+ className="w-full rounded-md border border-input bg-card px-3 py-2 font-mono text-xs"
139
139
  required
140
140
  />
141
141
  <span className="text-xs text-muted-foreground">
@@ -40,7 +40,7 @@ import { type CellState, PaletteGrid } from './theme-palette'
40
40
  import { ThemePreview, ValidatedSample } from './theme-preview'
41
41
 
42
42
  const INPUT =
43
- 'w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-xs focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
43
+ 'w-full rounded-md border border-input bg-card px-3 py-2 font-mono text-xs focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
44
44
 
45
45
  const GHOST_BUTTON =
46
46
  'inline-flex h-8 items-center justify-center rounded-md border border-border px-3 text-xs font-medium hover:bg-accent hover:text-accent-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
@@ -348,7 +348,7 @@ export function ThemeEditorForm({
348
348
  value={query}
349
349
  onChange={(event) => setQuery(event.target.value)}
350
350
  placeholder={fromCopy(copy, 'adminTheme.find.placeholder')}
351
- className="w-full rounded-md border border-border bg-background px-3 py-2 text-xs focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
351
+ className="w-full rounded-md border border-input bg-card px-3 py-2 text-xs focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
352
352
  />
353
353
  </label>
354
354
 
@@ -117,7 +117,7 @@ export function EditPostForm({
117
117
  name="reason"
118
118
  maxLength={200}
119
119
  defaultValue={state.values?.reason ?? reason ?? ''}
120
- className="rounded-md border border-border bg-background px-3 py-2 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
120
+ className="rounded-md border border-input bg-card px-3 py-2 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
121
121
  />
122
122
  </label>
123
123
 
@@ -128,7 +128,7 @@ export function NewThreadForm({
128
128
  <select
129
129
  name="prefixId"
130
130
  defaultValue={state.values?.prefixId ?? draft?.prefixId?.toString() ?? ''}
131
- className="h-10 rounded-md border border-border bg-background px-3 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
131
+ className="h-10 rounded-md border border-input bg-card px-3 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
132
132
  >
133
133
  <option value="">
134
134
  {requiresPrefix
@@ -183,7 +183,7 @@ export function NewThreadForm({
183
183
  min={0}
184
184
  step={1}
185
185
  defaultValue={pollDraft?.maxOptions ?? '1'}
186
- className="h-10 rounded-md border border-border bg-background px-3 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
186
+ className="h-10 rounded-md border border-input bg-card px-3 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
187
187
  />
188
188
  <span className="text-xs text-muted-foreground">
189
189
  {fromCopy(copy, 'composer.newThread.pollChoicesHint')}
@@ -215,7 +215,7 @@ export function NewThreadForm({
215
215
  type="datetime-local"
216
216
  name="pollClosesAt"
217
217
  defaultValue={pollDraft?.closesAt ?? ''}
218
- className="h-10 rounded-md border border-border bg-background px-3 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
218
+ className="h-10 rounded-md border border-input bg-card px-3 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
219
219
  />
220
220
  <span className="text-xs text-muted-foreground">
221
221
  {fromCopy(copy, 'pollEdit.closesAtHint')}
@@ -12,7 +12,7 @@ import { type Copy, fromCopy } from '../shell/copy'
12
12
  import { RecipientField } from './recipient-field'
13
13
 
14
14
  const FIELD =
15
- 'w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
15
+ 'w-full rounded-md border border-input bg-card px-3 py-2 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
16
16
 
17
17
  const BUTTON =
18
18
  'inline-flex h-9 items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
@@ -9,7 +9,7 @@ import { FormError, PendingButton } from '../auth/form-controls'
9
9
  import { type Copy, fromCopy } from '../shell/copy'
10
10
 
11
11
  const FIELD =
12
- 'w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
12
+ 'w-full rounded-md border border-input bg-card px-3 py-2 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
13
13
 
14
14
  const BUTTON =
15
15
  'inline-flex h-9 items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
@@ -4,13 +4,13 @@ export const PANEL_LIST =
4
4
  'flex flex-col divide-y divide-border overflow-hidden rounded-xl border border-border bg-card shadow-elevation'
5
5
 
6
6
  export const PANEL_ROW =
7
- 'flex flex-wrap items-center justify-between gap-x-3 gap-y-2 px-4 py-3 transition-colors hover:bg-muted/40'
7
+ 'flex flex-wrap items-center justify-between gap-x-3 gap-y-2 px-4 py-3 transition-colors hover:bg-muted/40 sm:px-5'
8
8
 
9
9
  export const PANEL_CARD =
10
- 'flex flex-col gap-3 rounded-xl border border-border bg-card p-4 shadow-elevation'
10
+ 'flex flex-col gap-4 rounded-xl border border-border bg-card p-5 shadow-elevation sm:p-6'
11
11
 
12
12
  export const PANEL_NOTE =
13
- 'rounded-xl border border-border bg-card p-4 text-sm text-muted-foreground shadow-elevation'
13
+ 'rounded-xl border border-border bg-card px-5 py-4 text-sm text-muted-foreground shadow-elevation'
14
14
 
15
15
  export function PanelList({
16
16
  children,
@@ -85,7 +85,7 @@ export function PanelEmpty({
85
85
  readonly description?: React.ReactNode
86
86
  }) {
87
87
  return (
88
- <div className="rounded-xl border border-border bg-card shadow-elevation">
88
+ <div className="rounded-xl border border-dashed border-border bg-card/60">
89
89
  <Empty className="py-10">
90
90
  <EmptyTitle>{title}</EmptyTitle>
91
91
  {description !== undefined && <EmptyDescription>{description}</EmptyDescription>}
@@ -12,13 +12,15 @@ export interface WaitingItem {
12
12
 
13
13
  function PanelWaiting({ item }: { item: WaitingItem }) {
14
14
  return (
15
- <Card className="flex-1 border-moderation-pending/50">
16
- <CardContent className="flex items-center justify-between gap-4 p-4">
15
+ <Card className="flex-1 rounded-xl border-l-4 border-l-thread-pinned">
16
+ <CardContent className="flex items-center justify-between gap-4 p-5">
17
17
  <div>
18
- <p className="text-2xl font-semibold tabular-nums text-foreground">{item.count}</p>
18
+ <p className="text-3xl font-semibold tracking-tight tabular-nums text-foreground">
19
+ {item.count}
20
+ </p>
19
21
  <p className="text-sm text-muted-foreground">{item.count === 1 ? item.one : item.many}</p>
20
22
  </div>
21
- <a href={item.href} className={buttonVariants({ variant: 'outline', size: 'sm' })}>
23
+ <a href={item.href} className={buttonVariants({ variant: 'primary', size: 'sm' })}>
22
24
  {item.action}
23
25
  </a>
24
26
  </CardContent>
@@ -39,12 +41,29 @@ export function PanelWaitingList({
39
41
 
40
42
  if (waiting.length === 0) {
41
43
  return (
42
- <Card>
44
+ <div className="rounded-xl border border-dashed border-border bg-card/60">
43
45
  <Empty className="py-8">
46
+ <span
47
+ aria-hidden="true"
48
+ className="mb-1 flex size-9 items-center justify-center rounded-full bg-moderation-approved/10 text-moderation-approved"
49
+ >
50
+ <svg
51
+ aria-hidden="true"
52
+ viewBox="0 0 16 16"
53
+ className="size-4"
54
+ fill="none"
55
+ stroke="currentColor"
56
+ strokeWidth="1.75"
57
+ strokeLinecap="round"
58
+ strokeLinejoin="round"
59
+ >
60
+ <path d="m3.5 8.5 3 3 6-7" />
61
+ </svg>
62
+ </span>
44
63
  <EmptyTitle>{emptyTitle}</EmptyTitle>
45
64
  <EmptyDescription>{emptyDescription}</EmptyDescription>
46
65
  </Empty>
47
- </Card>
66
+ </div>
48
67
  )
49
68
  }
50
69
 
@@ -62,15 +81,29 @@ export function PanelSectionGrid({ sections }: { sections: readonly PanelSection
62
81
  <ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
63
82
  {sections.map((section) => (
64
83
  <li key={section.href}>
65
- <Card className="relative h-full transition-colors hover:bg-muted/50">
66
- <CardContent className="flex flex-col gap-0.5 p-4">
67
- <a
68
- href={section.href}
69
- className="text-sm font-medium text-foreground underline-offset-2 hover:underline"
70
- >
71
- <span className="absolute inset-0" />
72
- {section.title}
73
- </a>
84
+ <Card className="group relative h-full rounded-xl transition-[border-color,box-shadow] hover:border-primary/40 hover:shadow-lg">
85
+ <CardContent className="flex h-full flex-col gap-1 p-4">
86
+ <span className="flex items-center justify-between gap-2">
87
+ <a
88
+ href={section.href}
89
+ className="text-sm font-semibold text-foreground underline-offset-2 group-hover:text-primary"
90
+ >
91
+ <span className="absolute inset-0" />
92
+ {section.title}
93
+ </a>
94
+ <svg
95
+ aria-hidden="true"
96
+ viewBox="0 0 16 16"
97
+ className="size-4 shrink-0 text-muted-foreground/60 transition-[color,transform] group-hover:translate-x-0.5 group-hover:text-primary"
98
+ fill="none"
99
+ stroke="currentColor"
100
+ strokeWidth="1.5"
101
+ strokeLinecap="round"
102
+ strokeLinejoin="round"
103
+ >
104
+ <path d="M3.5 8h9M8.5 4l4 4-4 4" />
105
+ </svg>
106
+ </span>
74
107
  <p className="text-xs text-muted-foreground">{section.blurb}</p>
75
108
  </CardContent>
76
109
  </Card>
@@ -1,11 +1,12 @@
1
1
  import 'server-only'
2
2
 
3
- import { env, logger, ValidationError } from '@meith/core'
3
+ import { ConfigurationError, env, logger, ValidationError } from '@meith/core'
4
4
  import {
5
5
  appliedPluginMigrations,
6
6
  applyPluginMigration,
7
7
  getDb,
8
8
  PostgresNavigationRepository,
9
+ pendingCoreMigrations,
9
10
  readVersion,
10
11
  recordVersion,
11
12
  } from '@meith/db'
@@ -16,12 +17,29 @@ import { planUpgrade, type UpgradeState, upgradeNotice } from '@meith/upgrade'
16
17
 
17
18
  import { activeDefinitions } from './plugin-host'
18
19
 
19
- export const CODE_VERSION = '0.33.1'
20
+ export const CODE_VERSION = '0.33.3'
20
21
 
21
22
  export interface UpgradeApplied {
22
23
  readonly plugins: readonly string[]
23
24
  }
24
25
 
26
+ let coreMigrationsUnreadable = false
27
+
28
+ async function missingCoreMigrations(db: ReturnType<typeof getDb>): Promise<readonly string[]> {
29
+ if (coreMigrationsUnreadable) return []
30
+
31
+ try {
32
+ return await pendingCoreMigrations(db)
33
+ } catch (error) {
34
+ if (error instanceof ConfigurationError) coreMigrationsUnreadable = true
35
+ logger().warn(
36
+ { err: String(error) },
37
+ 'could not read the pending core migrations, comparing versions only',
38
+ )
39
+ return []
40
+ }
41
+ }
42
+
25
43
  export async function applyPendingUpgrade(): Promise<UpgradeApplied> {
26
44
  const db = getDb()
27
45
  const definitions = activeDefinitions()
@@ -31,10 +49,12 @@ export async function applyPendingUpgrade(): Promise<UpgradeApplied> {
31
49
  applied[plugin.key] = await appliedPluginMigrations(db, plugin.key)
32
50
  }
33
51
 
52
+ const pendingCore = await missingCoreMigrations(db)
53
+
34
54
  const state: UpgradeState = {
35
55
  recordedVersion: (await readVersion(db, 'core')) ?? CODE_VERSION,
36
56
  codeVersion: CODE_VERSION,
37
- pendingCoreMigrations: [],
57
+ pendingCoreMigrations: pendingCore,
38
58
  plugins: definitions.map((plugin) => ({
39
59
  key: plugin.key,
40
60
  version: plugin.version,
@@ -52,6 +72,15 @@ export async function applyPendingUpgrade(): Promise<UpgradeApplied> {
52
72
  : new ValidationError(notice)
53
73
  }
54
74
 
75
+ if (pendingCore.length > 0) {
76
+ throw new ValidationError(
77
+ msg('error.app.core-migrations-pending', {
78
+ count: pendingCore.length,
79
+ migrations: pendingCore.join(', '),
80
+ }),
81
+ )
82
+ }
83
+
55
84
  const fresh = new Set<string>()
56
85
  for (const plugin of definitions) {
57
86
  if ((await readVersion(db, `plugin:${plugin.key}`)) === null) fresh.add(plugin.key)
@@ -101,7 +130,7 @@ export async function pendingUpgradeNotice(): Promise<string | null> {
101
130
  const state: UpgradeState = {
102
131
  recordedVersion: (await readVersion(db, 'core')) ?? CODE_VERSION,
103
132
  codeVersion: CODE_VERSION,
104
- pendingCoreMigrations: [],
133
+ pendingCoreMigrations: await missingCoreMigrations(db),
105
134
  plugins: plugins.map((plugin) => ({
106
135
  key: plugin.key,
107
136
  version: plugin.version,
@@ -485,10 +485,12 @@
485
485
  /*
486
486
  * A permalink to `#post-12` otherwise lands the post flush against the top
487
487
  * of the viewport, with its own header cropped and the previous post's footer
488
- * nowhere — the reader cannot tell they arrived at the right one.
488
+ * nowhere — the reader cannot tell they arrived at the right one. The
489
+ * default theme's header is sticky — 3.5rem on a phone, 6.25rem with its
490
+ * navigation row on a desktop — so the margin clears it with room to spare.
489
491
  */
490
492
  :target {
491
- scroll-margin-block-start: 1.5rem;
493
+ scroll-margin-block-start: 7.5rem;
492
494
  }
493
495
 
494
496
  ::selection {
@@ -954,6 +956,34 @@ div.md-directive {
954
956
  margin-block-end: 0;
955
957
  }
956
958
 
959
+ /*
960
+ * A themed arrow on every single-choice <select>.
961
+ *
962
+ * The browser's own arrow ignores the palette — it stays the platform grey in
963
+ * both schemes and sits hard against the field's edge. `appearance: none`
964
+ * removes it, and two small gradient squares draw a chevron in
965
+ * `--muted-foreground` instead, so it follows a runtime token override like
966
+ * everything else. Gradients rather than an SVG data URI because a URI cannot
967
+ * read a custom property, and this file never hardcodes a colour twice.
968
+ * Multi-selects and list boxes keep their native rendering: they have no arrow.
969
+ */
970
+ select:not([multiple], [size]) {
971
+ appearance: none;
972
+ padding-inline-end: 2.25rem;
973
+ background-image:
974
+ linear-gradient(45deg, transparent 50%, var(--muted-foreground) 50%),
975
+ linear-gradient(135deg, var(--muted-foreground) 50%, transparent 50%);
976
+ background-position:
977
+ calc(100% - 1.05rem) 50%,
978
+ calc(100% - 0.75rem) 50%;
979
+ background-size: 0.3rem 0.3rem;
980
+ background-repeat: no-repeat;
981
+ }
982
+
983
+ select:not([multiple], [size]):disabled {
984
+ opacity: 0.6;
985
+ }
986
+
957
987
  @media (pointer: coarse) {
958
988
  input:not(
959
989
  [type="checkbox"],