@meith/web 0.33.2 → 0.33.4

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.
Files changed (30) hide show
  1. package/app/(board)/members/page.tsx +1 -1
  2. package/app/(board)/thread/[slug]/page.tsx +4 -7
  3. package/app/admin/content/attachments/page.tsx +2 -2
  4. package/app/admin/layout.tsx +21 -4
  5. package/app/admin/users/[id]/merge/page.tsx +1 -1
  6. package/app/admin/users/page.tsx +1 -1
  7. package/app/admin/users/prune/page.tsx +1 -1
  8. package/package.json +49 -49
  9. package/src/components/account/avatar-form.tsx +1 -1
  10. package/src/components/account/notification-forms.tsx +1 -1
  11. package/src/components/account/reputation-forms.tsx +1 -1
  12. package/src/components/account/usercp-forms.tsx +1 -1
  13. package/src/components/admin/form-bits.tsx +1 -1
  14. package/src/components/admin/forum-forms.tsx +4 -1
  15. package/src/components/admin/oklch-picker.tsx +1 -1
  16. package/src/components/admin/system-forms.tsx +1 -1
  17. package/src/components/admin/theme-forms.tsx +2 -2
  18. package/src/components/content/edit-post-form.tsx +1 -1
  19. package/src/components/content/new-thread-form.tsx +3 -3
  20. package/src/components/messages/message-forms.tsx +1 -1
  21. package/src/components/moderation/thread-surgery-form.tsx +6 -5
  22. package/src/components/moderation/thread-tools-form.tsx +80 -57
  23. package/src/components/moderation/warning-forms.tsx +1 -1
  24. package/src/components/shell/header-peek-enhancer.tsx +58 -0
  25. package/src/components/shell/page-shell.tsx +2 -0
  26. package/src/components/shell/panel-list.tsx +4 -4
  27. package/src/components/shell/panel-overview.tsx +48 -15
  28. package/src/server/upgrade-notice.ts +1 -1
  29. package/src/styles/globals.css +32 -2
  30. package/src/view/navigation.ts +16 -7
@@ -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
 
@@ -648,6 +644,7 @@ export default async function ThreadPage({
648
644
  rights={toolRights}
649
645
  moveTargets={moveTargets}
650
646
  heading={threadToolsHeading(appointment.isForumModerator, await getTranslator())}
647
+ open={toolNotice !== undefined}
651
648
  >
652
649
  <ThreadSurgeryForm
653
650
  copy={moderationFormsCopy(await getTranslator())}
@@ -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.2",
3
+ "version": "0.33.4",
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.2",
47
- "@meith/admin": "0.33.2",
48
- "@meith/antispam": "0.33.2",
49
- "@meith/api": "0.33.2",
50
- "@meith/attachments": "0.33.2",
51
- "@meith/authorization": "0.33.2",
52
- "@meith/avatars": "0.33.2",
53
- "@meith/board-digest": "0.33.2",
54
- "@meith/core": "0.33.2",
55
- "@meith/db": "0.33.2",
56
- "@meith/demo": "0.33.2",
57
- "@meith/drafts": "0.33.2",
58
- "@meith/drivers": "0.33.2",
59
- "@meith/events": "0.33.2",
60
- "@meith/forums": "0.33.2",
61
- "@meith/groups": "0.33.2",
62
- "@meith/i18n": "0.33.2",
63
- "@meith/import": "0.33.2",
64
- "@meith/install": "0.33.2",
65
- "@meith/mail": "0.33.2",
66
- "@meith/markdown": "0.33.2",
67
- "@meith/marketplace": "0.33.2",
68
- "@meith/messages": "0.33.2",
69
- "@meith/moderation": "0.33.2",
70
- "@meith/notifications": "0.33.2",
71
- "@meith/plugin-calendar": "0.33.2",
72
- "@meith/plugin-dues": "0.33.2",
73
- "@meith/plugin-kit": "0.33.2",
74
- "@meith/polls": "0.33.2",
75
- "@meith/posts": "0.33.2",
76
- "@meith/profile-fields": "0.33.2",
77
- "@meith/relations": "0.33.2",
78
- "@meith/reputation": "0.33.2",
79
- "@meith/runtime": "0.33.2",
80
- "@meith/search": "0.33.2",
81
- "@meith/settings": "0.33.2",
82
- "@meith/signatures": "0.33.2",
83
- "@meith/subscriptions": "0.33.2",
84
- "@meith/tasks": "0.33.2",
85
- "@meith/theme-clubhouse": "0.33.2",
86
- "@meith/theme-default": "0.33.2",
87
- "@meith/theme-kit": "0.33.2",
88
- "@meith/theme-midnight": "0.33.2",
89
- "@meith/theme-phasebook": "0.33.2",
90
- "@meith/theme-raidframe": "0.33.2",
91
- "@meith/threads": "0.33.2",
92
- "@meith/ui": "0.33.2",
93
- "@meith/upgrade": "0.33.2"
46
+ "@meith/accounts": "0.33.4",
47
+ "@meith/admin": "0.33.4",
48
+ "@meith/antispam": "0.33.4",
49
+ "@meith/api": "0.33.4",
50
+ "@meith/attachments": "0.33.4",
51
+ "@meith/authorization": "0.33.4",
52
+ "@meith/avatars": "0.33.4",
53
+ "@meith/board-digest": "0.33.4",
54
+ "@meith/core": "0.33.4",
55
+ "@meith/db": "0.33.4",
56
+ "@meith/demo": "0.33.4",
57
+ "@meith/drivers": "0.33.4",
58
+ "@meith/drafts": "0.33.4",
59
+ "@meith/events": "0.33.4",
60
+ "@meith/forums": "0.33.4",
61
+ "@meith/groups": "0.33.4",
62
+ "@meith/i18n": "0.33.4",
63
+ "@meith/import": "0.33.4",
64
+ "@meith/install": "0.33.4",
65
+ "@meith/mail": "0.33.4",
66
+ "@meith/markdown": "0.33.4",
67
+ "@meith/marketplace": "0.33.4",
68
+ "@meith/messages": "0.33.4",
69
+ "@meith/moderation": "0.33.4",
70
+ "@meith/notifications": "0.33.4",
71
+ "@meith/plugin-calendar": "0.33.4",
72
+ "@meith/plugin-dues": "0.33.4",
73
+ "@meith/plugin-kit": "0.33.4",
74
+ "@meith/polls": "0.33.4",
75
+ "@meith/posts": "0.33.4",
76
+ "@meith/profile-fields": "0.33.4",
77
+ "@meith/relations": "0.33.4",
78
+ "@meith/reputation": "0.33.4",
79
+ "@meith/runtime": "0.33.4",
80
+ "@meith/search": "0.33.4",
81
+ "@meith/settings": "0.33.4",
82
+ "@meith/signatures": "0.33.4",
83
+ "@meith/subscriptions": "0.33.4",
84
+ "@meith/tasks": "0.33.4",
85
+ "@meith/theme-clubhouse": "0.33.4",
86
+ "@meith/theme-default": "0.33.4",
87
+ "@meith/theme-kit": "0.33.4",
88
+ "@meith/theme-midnight": "0.33.4",
89
+ "@meith/theme-phasebook": "0.33.4",
90
+ "@meith/theme-raidframe": "0.33.4",
91
+ "@meith/threads": "0.33.4",
92
+ "@meith/ui": "0.33.4",
93
+ "@meith/upgrade": "0.33.4"
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'
@@ -11,7 +11,8 @@ import { type Copy, formatFromCopy, fromCopy } from '../shell/copy'
11
11
  const BUTTON =
12
12
  'inline-flex h-8 items-center rounded-md border border-border px-3 text-xs font-medium hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
13
13
  const FIELD =
14
- 'h-8 rounded-md border border-border bg-background px-2 text-xs focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
14
+ 'h-8 min-w-0 max-w-full rounded-md border border-border bg-background px-2 text-xs focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
15
+ const LABEL = 'flex min-w-0 max-w-full items-center text-xs'
15
16
 
16
17
  export interface SplitPoint {
17
18
  readonly id: number
@@ -39,9 +40,9 @@ export function ThreadSurgeryForm({
39
40
  <form action={splitAction} className="flex flex-col gap-2">
40
41
  <div className="flex flex-wrap items-center gap-2">
41
42
  <input type="hidden" name="threadId" value={threadId} />
42
- <label className="flex items-center gap-2 text-xs">
43
+ <label className={LABEL}>
43
44
  <span className="sr-only">{fromCopy(copy, 'moderationForm.surgery.fromSr')}</span>
44
- <select name="fromPostId" className={FIELD}>
45
+ <select name="fromPostId" className={`${FIELD} sm:max-w-64`}>
45
46
  {splitPoints.map((point) => (
46
47
  <option key={point.id} value={point.id}>
47
48
  {formatFromCopy(copy, 'moderationForm.surgery.splitPoint', {
@@ -52,7 +53,7 @@ export function ThreadSurgeryForm({
52
53
  ))}
53
54
  </select>
54
55
  </label>
55
- <label className="flex items-center gap-2 text-xs">
56
+ <label className={LABEL}>
56
57
  <span className="sr-only">{fromCopy(copy, 'moderationForm.surgery.titleSr')}</span>
57
58
  <input
58
59
  type="text"
@@ -76,7 +77,7 @@ export function ThreadSurgeryForm({
76
77
  <form action={mergeAction} className="flex flex-col gap-2">
77
78
  <div className="flex flex-wrap items-center gap-2">
78
79
  <input type="hidden" name="threadId" value={threadId} />
79
- <label className="flex items-center gap-2 text-xs">
80
+ <label className={LABEL}>
80
81
  <span className="sr-only">{fromCopy(copy, 'moderationForm.surgery.mergeSr')}</span>
81
82
  <input
82
83
  type="number"
@@ -11,6 +11,27 @@ import { type Copy, fromCopy } from '../shell/copy'
11
11
 
12
12
  const BUTTON =
13
13
  'inline-flex h-8 items-center rounded-md border border-border px-3 text-xs font-medium hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
14
+ const SELECT =
15
+ 'h-8 min-w-0 max-w-full rounded-md border border-border bg-background px-2 text-xs focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
16
+ const SUMMARY =
17
+ 'flex cursor-pointer list-none items-center gap-2 rounded-lg px-4 py-3 text-xs font-medium text-muted-foreground select-none hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring [&::-webkit-details-marker]:hidden'
18
+
19
+ function Chevron() {
20
+ return (
21
+ <svg
22
+ aria-hidden="true"
23
+ viewBox="0 0 12 12"
24
+ className="size-3 shrink-0 transition-transform group-open/tools:rotate-90"
25
+ fill="none"
26
+ stroke="currentColor"
27
+ strokeWidth="1.5"
28
+ strokeLinecap="round"
29
+ strokeLinejoin="round"
30
+ >
31
+ <path d="m4.5 3 3 3-3 3" />
32
+ </svg>
33
+ )
34
+ }
14
35
 
15
36
  export interface MoveOption {
16
37
  readonly id: number
@@ -25,6 +46,7 @@ export function ThreadToolsForm({
25
46
  moveTargets,
26
47
  heading,
27
48
  copy,
49
+ open = false,
28
50
  children,
29
51
  }: {
30
52
  threadId: number
@@ -34,74 +56,75 @@ export function ThreadToolsForm({
34
56
  moveTargets: readonly MoveOption[]
35
57
  heading: string
36
58
  copy: Copy
59
+ open?: boolean
37
60
  children?: React.ReactNode
38
61
  }) {
39
62
  const [state, action] = useActionState(threadToolAction, EMPTY_STATE)
40
63
 
41
64
  return (
42
- <section
43
- aria-label={heading}
44
- className="flex flex-col gap-2 rounded-lg border border-border bg-secondary px-4 py-3"
45
- >
46
- <div className="flex flex-wrap items-center gap-3">
47
- <span className="text-xs font-medium text-muted-foreground">{heading}</span>
48
-
49
- <form action={action} className="flex flex-wrap items-center gap-3">
50
- <input type="hidden" name="threadId" value={threadId} />
65
+ <section aria-label={heading} className="rounded-lg border border-border bg-secondary">
66
+ <details className="group/tools" open={open || state.error !== undefined ? true : undefined}>
67
+ <summary className={SUMMARY}>
68
+ <Chevron />
69
+ {heading}
70
+ </summary>
51
71
 
52
- {rights.lock && (
53
- <PendingButton name="tool" value={isLocked ? 'unlock' : 'lock'} className={BUTTON}>
54
- {isLocked
55
- ? fromCopy(copy, 'moderationForm.tool.unlock')
56
- : fromCopy(copy, 'moderationForm.tool.lock')}
57
- </PendingButton>
58
- )}
59
- {rights.stick && (
60
- <PendingButton name="tool" value={isSticky ? 'unstick' : 'stick'} className={BUTTON}>
61
- {isSticky
62
- ? fromCopy(copy, 'moderationForm.tool.unpin')
63
- : fromCopy(copy, 'moderationForm.tool.pin')}
64
- </PendingButton>
65
- )}
66
- {rights.delete && (
67
- <PendingButton
68
- name="tool"
69
- value="delete"
70
- className={`${BUTTON} border-destructive/40 text-destructive`}
71
- >
72
- {fromCopy(copy, 'moderationForm.tool.deleteThread')}
73
- </PendingButton>
74
- )}
72
+ <div className="flex flex-col gap-2 px-4 pb-3">
73
+ <form action={action} className="flex flex-wrap items-center gap-2">
74
+ <input type="hidden" name="threadId" value={threadId} />
75
75
 
76
- {rights.move && moveTargets.length > 0 && (
77
- <span className="flex items-center gap-2">
78
- <label className="flex items-center gap-2 text-xs">
79
- <span className="sr-only">{fromCopy(copy, 'moderationForm.moveTo')}</span>
80
- <select
81
- name="toForumId"
82
- className="h-8 rounded-md border border-border bg-background px-2 text-xs focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
83
- >
84
- {moveTargets.map((forum) => (
85
- <option key={forum.id} value={forum.id}>
86
- {forum.title}
87
- </option>
88
- ))}
89
- </select>
90
- </label>
91
- <PendingButton name="tool" value="move" className={BUTTON}>
92
- {fromCopy(copy, 'moderationForm.tool.move')}
76
+ {rights.lock && (
77
+ <PendingButton name="tool" value={isLocked ? 'unlock' : 'lock'} className={BUTTON}>
78
+ {isLocked
79
+ ? fromCopy(copy, 'moderationForm.tool.unlock')
80
+ : fromCopy(copy, 'moderationForm.tool.lock')}
81
+ </PendingButton>
82
+ )}
83
+ {rights.stick && (
84
+ <PendingButton name="tool" value={isSticky ? 'unstick' : 'stick'} className={BUTTON}>
85
+ {isSticky
86
+ ? fromCopy(copy, 'moderationForm.tool.unpin')
87
+ : fromCopy(copy, 'moderationForm.tool.pin')}
93
88
  </PendingButton>
94
- <PendingButton name="tool" value="copy" className={BUTTON}>
95
- {fromCopy(copy, 'moderationForm.tool.copy')}
89
+ )}
90
+ {rights.delete && (
91
+ <PendingButton
92
+ name="tool"
93
+ value="delete"
94
+ className={`${BUTTON} border-destructive/40 text-destructive`}
95
+ >
96
+ {fromCopy(copy, 'moderationForm.tool.deleteThread')}
96
97
  </PendingButton>
97
- </span>
98
- )}
99
- </form>
98
+ )}
99
+
100
+ {rights.move && moveTargets.length > 0 && (
101
+ <span className="flex min-w-0 max-w-full basis-full flex-wrap items-center gap-2 sm:basis-auto">
102
+ <label className="flex min-w-0 max-w-full flex-1 items-center text-xs sm:flex-none">
103
+ <span className="sr-only">{fromCopy(copy, 'moderationForm.moveTo')}</span>
104
+ <select name="toForumId" className={`${SELECT} w-full sm:w-auto sm:max-w-56`}>
105
+ {moveTargets.map((forum) => (
106
+ <option key={forum.id} value={forum.id}>
107
+ {forum.title}
108
+ </option>
109
+ ))}
110
+ </select>
111
+ </label>
112
+ <PendingButton name="tool" value="move" className={BUTTON}>
113
+ {fromCopy(copy, 'moderationForm.tool.move')}
114
+ </PendingButton>
115
+ <PendingButton name="tool" value="copy" className={BUTTON}>
116
+ {fromCopy(copy, 'moderationForm.tool.copy')}
117
+ </PendingButton>
118
+ </span>
119
+ )}
120
+ </form>
121
+
122
+ {children}
100
123
 
101
- {children}
102
- </div>
124
+ <FormError message={state.error} />
125
+ </div>
126
+ </details>
103
127
 
104
- <FormError message={state.error} />
105
128
  <ConfirmDialog confirm={state.confirm} action={action} />
106
129
  </section>
107
130
  )
@@ -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'
@@ -0,0 +1,58 @@
1
+ 'use client'
2
+
3
+ import { useEffect } from 'react'
4
+
5
+ const PEEKING_HEADER = '[data-header-peek]'
6
+
7
+ const SETTLE = 8
8
+
9
+ function peek(header: HTMLElement): () => void {
10
+ let last = window.scrollY
11
+ let ticking = false
12
+
13
+ function settle() {
14
+ ticking = false
15
+ const y = Math.max(0, window.scrollY)
16
+ const delta = y - last
17
+
18
+ if (y <= header.offsetHeight) {
19
+ header.removeAttribute('data-peek')
20
+ } else if (delta > SETTLE) {
21
+ header.setAttribute('data-peek', '')
22
+ } else if (delta < -SETTLE) {
23
+ header.removeAttribute('data-peek')
24
+ } else {
25
+ return
26
+ }
27
+ last = y
28
+ }
29
+
30
+ function onScroll() {
31
+ if (ticking) return
32
+ ticking = true
33
+ window.requestAnimationFrame(settle)
34
+ }
35
+
36
+ function onFocusIn() {
37
+ header.removeAttribute('data-peek')
38
+ }
39
+
40
+ window.addEventListener('scroll', onScroll, { passive: true })
41
+ header.addEventListener('focusin', onFocusIn)
42
+
43
+ return () => {
44
+ window.removeEventListener('scroll', onScroll)
45
+ header.removeEventListener('focusin', onFocusIn)
46
+ header.removeAttribute('data-peek')
47
+ }
48
+ }
49
+
50
+ export function HeaderPeekEnhancer() {
51
+ useEffect(() => {
52
+ const header = document.querySelector<HTMLElement>(PEEKING_HEADER)
53
+ if (header === null) return
54
+ return peek(header)
55
+ }, [])
56
+
57
+ return null
58
+ }
@@ -3,6 +3,7 @@ import { currentRequestId } from '@meith/core/logger'
3
3
  import { requireSlot, slotCopy } from '@meith/theme-kit'
4
4
 
5
5
  import { LogoutForm } from '@/components/account/logout-form'
6
+ import { HeaderPeekEnhancer } from '@/components/shell/header-peek-enhancer'
6
7
  import { InstallBanner } from '@/components/shell/install-banner'
7
8
  import { NavDisclosureEnhancer } from '@/components/shell/nav-disclosure-enhancer'
8
9
  import { NotificationMenu } from '@/components/shell/notification-menu'
@@ -154,6 +155,7 @@ export async function PageShell({ actor, children }: { actor: Actor; children: R
154
155
  </Header>
155
156
 
156
157
  <NavDisclosureEnhancer />
158
+ <HeaderPeekEnhancer />
157
159
 
158
160
  {await boardRegion('header.notice', actor)}
159
161
 
@@ -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>
@@ -17,7 +17,7 @@ import { planUpgrade, type UpgradeState, upgradeNotice } from '@meith/upgrade'
17
17
 
18
18
  import { activeDefinitions } from './plugin-host'
19
19
 
20
- export const CODE_VERSION = '0.33.2'
20
+ export const CODE_VERSION = '0.33.4'
21
21
 
22
22
  export interface UpgradeApplied {
23
23
  readonly plugins: readonly string[]
@@ -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 and 3.5rem tall, so the margin clears it
490
+ * with room to spare.
489
491
  */
490
492
  :target {
491
- scroll-margin-block-start: 1.5rem;
493
+ scroll-margin-block-start: 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"],
@@ -22,27 +22,36 @@ export interface BuiltInNavigationItem {
22
22
  readonly messageKey: string
23
23
  readonly href: string
24
24
  readonly audience: NavigationAudience
25
+ readonly shown: boolean
25
26
  }
26
27
 
27
28
  export const BUILT_IN_NAVIGATION: readonly BuiltInNavigationItem[] = [
28
- { key: 'home', messageKey: 'nav.home', href: '/', audience: 'all' },
29
- { key: 'new-posts', messageKey: 'nav.newPosts', href: '/discover/new', audience: 'all' },
29
+ { key: 'home', messageKey: 'nav.home', href: '/', audience: 'all', shown: true },
30
+ {
31
+ key: 'new-posts',
32
+ messageKey: 'nav.newPosts',
33
+ href: '/discover/new',
34
+ audience: 'all',
35
+ shown: true,
36
+ },
30
37
  {
31
38
  key: 'unanswered',
32
39
  messageKey: 'nav.unanswered',
33
40
  href: '/discover/unanswered',
34
41
  audience: 'all',
42
+ shown: false,
35
43
  },
36
44
  {
37
45
  key: 'my-posts',
38
46
  messageKey: 'nav.myPosts',
39
47
  href: '/discover/participated',
40
48
  audience: 'members',
49
+ shown: false,
41
50
  },
42
- { key: 'search', messageKey: 'nav.search', href: '/search', audience: 'all' },
43
- { key: 'online', messageKey: 'nav.online', href: '/online', audience: 'all' },
44
- { key: 'members', messageKey: 'nav.members', href: '/members', audience: 'all' },
45
- { key: 'staff', messageKey: 'nav.staff', href: '/staff', audience: 'all' },
51
+ { key: 'search', messageKey: 'nav.search', href: '/search', audience: 'all', shown: true },
52
+ { key: 'online', messageKey: 'nav.online', href: '/online', audience: 'all', shown: false },
53
+ { key: 'members', messageKey: 'nav.members', href: '/members', audience: 'all', shown: false },
54
+ { key: 'staff', messageKey: 'nav.staff', href: '/staff', audience: 'all', shown: false },
46
55
  ]
47
56
 
48
57
  const SEARCH_KEY = 'search'
@@ -64,7 +73,7 @@ export function defaultNavigationItems(): readonly NavigationItemRow[] {
64
73
  displayOrder: index * 10,
65
74
  audience: item.audience,
66
75
  newTab: false,
67
- enabled: true,
76
+ enabled: item.shown,
68
77
  visibleToGroups: [],
69
78
  }))
70
79
  }