@meith/theme-default 0.32.0 → 0.33.0

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meith/theme-default",
3
- "version": "0.32.0",
3
+ "version": "0.33.0",
4
4
  "description": "Meith's default theme — every slot and every token, and the reference for what a complete theme fills.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -20,8 +20,8 @@
20
20
  "access": "public"
21
21
  },
22
22
  "dependencies": {
23
- "@meith/theme-kit": "^0.32.0",
24
- "@meith/ui": "^0.32.0"
23
+ "@meith/theme-kit": "^0.33.0",
24
+ "@meith/ui": "^0.33.0"
25
25
  },
26
26
  "peerDependencies": {
27
27
  "react": "^19.2.0"
package/src/shared.tsx CHANGED
@@ -17,12 +17,18 @@ export function pageAt(width: 'max-w-3xl' | 'max-w-4xl'): string {
17
17
  return `mx-auto w-full ${width} px-4 sm:px-6`
18
18
  }
19
19
 
20
- export const LINK = 'hover:underline underline-offset-2 decoration-1 decoration-primary'
20
+ export const LINK =
21
+ 'transition-colors hover:text-primary hover:underline underline-offset-2 decoration-1 decoration-primary'
21
22
 
22
- export const MUTED_LINK = `text-muted-foreground transition-colors hover:text-foreground ${LINK}`
23
+ export const MUTED_LINK = `text-muted-foreground ${LINK}`
23
24
 
24
25
  export function UserRef({ user, className }: { user: UserRefModel; className?: string }) {
25
- const classes = cn('font-medium', className, user.nameClass)
26
+ const classes = cn(
27
+ 'font-medium',
28
+ user.nameClass == null && 'text-primary',
29
+ className,
30
+ user.nameClass,
31
+ )
26
32
  if (user.profileHref === null) {
27
33
  return <span className={classes}>{user.username}</span>
28
34
  }
@@ -50,8 +56,8 @@ export function ReadSpacer() {
50
56
  }
51
57
 
52
58
  export function isEmptyRegion(node: React.ReactNode): boolean {
53
- if (node === null || node === undefined || node === false) return true
54
- return Array.isArray(node) && node.length === 0
59
+ if (node === null || node === undefined || node === false || node === '') return true
60
+ return Array.isArray(node) && node.every((child) => isEmptyRegion(child))
55
61
  }
56
62
 
57
63
  export const NUMERIC = 'tabular-nums'
@@ -96,7 +102,7 @@ export function Counts({ items, className }: { items: readonly CountItem[]; clas
96
102
  <div key={item.label}>
97
103
  <dt className="sr-only">{item.label}</dt>
98
104
  <dd>
99
- <span className={cn('font-medium text-foreground', NUMERIC)}>{item.value.label}</span>{' '}
105
+ <span className={cn('font-semibold text-primary', NUMERIC)}>{item.value.label}</span>{' '}
100
106
  {item.value.value === 1 ? item.one : item.many}
101
107
  </dd>
102
108
  </div>
@@ -15,7 +15,7 @@ export function Announcement({
15
15
  const c = (key: string) => fromSlotCopy(copy, `default.announcement.${key}`)
16
16
 
17
17
  return (
18
- <Card as="article" className="border-l-4 border-l-foreground">
18
+ <Card as="article" className="border-l-4 border-l-primary">
19
19
  <CardContent className="flex flex-col gap-2 p-4">
20
20
  <h2 className="text-lg font-semibold tracking-tight text-balance">{title}</h2>
21
21
 
@@ -28,7 +28,7 @@ export function Announcement({
28
28
  c('posted')
29
29
  ) : (
30
30
  <>
31
- {c('postedBy')} <UserRef user={postedBy} className="text-foreground" />
31
+ {c('postedBy')} <UserRef user={postedBy} />
32
32
  </>
33
33
  )}{' '}
34
34
  <Stamp at={postedAt} />
@@ -9,7 +9,7 @@ export function AuthPage({ title, alert, links, regions }: AuthPageModel) {
9
9
  tabIndex={-1}
10
10
  className="flex flex-1 flex-col items-center justify-center px-6 py-12"
11
11
  >
12
- <div className="flex w-full max-w-sm flex-col gap-6 rounded-lg border border-border bg-card p-6 text-card-foreground shadow-sm">
12
+ <div className="flex w-full max-w-sm flex-col gap-6 rounded-lg border border-border bg-card p-6 text-card-foreground shadow-elevation">
13
13
  <div className="flex flex-col gap-1">
14
14
  <h1 className="font-heading text-2xl font-semibold text-foreground">{title}</h1>
15
15
  {regions.lede !== undefined && (
@@ -21,8 +21,8 @@ export function BoardIndex({
21
21
  )}
22
22
 
23
23
  {markAllReadAction !== null && (
24
- <form action={markAllReadAction} method="post" className="self-end">
25
- <button type="submit" className={buttonVariants({ variant: 'ghost', size: 'sm' })}>
24
+ <form action={markAllReadAction} method="post" className="-mb-2 self-end">
25
+ <button type="submit" className={buttonVariants({ variant: 'outline', size: 'sm' })}>
26
26
  {c('markAllRead')}
27
27
  </button>
28
28
  </form>
@@ -39,7 +39,7 @@ export function BoardStats({
39
39
 
40
40
  {newestMember !== null && (
41
41
  <span>
42
- {c('newestMember')} <UserRef user={newestMember} className="text-foreground" />
42
+ {c('newestMember')} <UserRef user={newestMember} />
43
43
  </span>
44
44
  )}
45
45
 
@@ -8,9 +8,9 @@ export function CategoryBlock({ category, children }: CategoryBlockModel) {
8
8
 
9
9
  return (
10
10
  <Card aria-labelledby={headingId}>
11
- <CardHeader className="flex-col items-start gap-0.5">
12
- <CardTitle id={headingId}>
13
- <a href={category.href} className={LINK}>
11
+ <CardHeader className="flex-col items-start gap-0.5 border-b-primary/15 bg-primary/6">
12
+ <CardTitle id={headingId} className="text-primary">
13
+ <a href={category.href} className={`${LINK} hover:text-primary-hover`}>
14
14
  {category.title}
15
15
  </a>
16
16
  </CardTitle>
@@ -21,7 +21,7 @@ function Tabs({ label, tabs }: { label: string; tabs: readonly TabModel[] }) {
21
21
  className={cn(
22
22
  TAB,
23
23
  tab.isCurrent
24
- ? 'bg-card font-semibold text-foreground shadow-sm'
24
+ ? 'bg-card font-semibold text-primary shadow-sm'
25
25
  : 'font-medium text-muted-foreground hover:text-foreground',
26
26
  )}
27
27
  >
@@ -80,7 +80,7 @@ export function DiscoveryView({
80
80
  {rows.map((row) => (
81
81
  <li
82
82
  key={row.threadId}
83
- className="flex flex-col gap-1.5 px-4 py-3.5 transition-colors hover:bg-accent sm:flex-row sm:items-center sm:justify-between sm:gap-6"
83
+ className="flex flex-col gap-1.5 px-4 py-3.5 transition-colors hover:bg-muted/60 sm:flex-row sm:items-center sm:justify-between sm:gap-6"
84
84
  >
85
85
  <div className="flex min-w-0 flex-col gap-1">
86
86
  <a
@@ -17,15 +17,15 @@ export function Footer({
17
17
  <footer className="mt-auto border-t border-border bg-card">
18
18
  {regions?.controls && (
19
19
  <div
20
- className={`${PAGE} flex flex-wrap items-center justify-end gap-x-6 gap-y-2 border-b border-border py-3`}
20
+ className={`${PAGE} flex flex-wrap items-center justify-end gap-x-6 gap-y-3 border-b border-border py-3`}
21
21
  >
22
22
  {regions.controls}
23
23
  </div>
24
24
  )}
25
25
  <div
26
- className={`${PAGE} flex flex-col gap-2 py-5 text-xs text-muted-foreground sm:flex-row sm:items-center sm:justify-between sm:gap-6`}
26
+ className={`${PAGE} flex flex-col gap-3 py-5 text-xs text-muted-foreground sm:flex-row sm:items-center sm:justify-between sm:gap-6`}
27
27
  >
28
- <span className="font-medium text-foreground">{boardTitle}</span>
28
+ <span className="font-semibold text-primary">{boardTitle}</span>
29
29
 
30
30
  {links.length > 0 && (
31
31
  <nav aria-label={c('nav')} className="flex flex-wrap gap-x-4 gap-y-1">
@@ -4,6 +4,7 @@ import {
4
4
  buttonVariants,
5
5
  Card,
6
6
  CardRows,
7
+ cn,
7
8
  Empty,
8
9
  EmptyAction,
9
10
  EmptyDescription,
@@ -50,16 +51,19 @@ export function ForumDisplay({
50
51
  )}
51
52
  </div>
52
53
 
53
- <div className="flex shrink-0 items-center gap-2">
54
+ <div className="flex shrink-0 flex-wrap items-center gap-2">
54
55
  {markReadAction !== null && (
55
56
  <form action={markReadAction} method="post">
56
- <button type="submit" className={buttonVariants({ variant: 'ghost' })}>
57
+ <button type="submit" className={buttonVariants({ variant: 'outline' })}>
57
58
  {c('markRead')}
58
59
  </button>
59
60
  </form>
60
61
  )}
61
62
  {newThreadHref !== null && (
62
- <a href={newThreadHref} className={buttonVariants({ variant: 'primary' })}>
63
+ <a
64
+ href={newThreadHref}
65
+ className={cn(buttonVariants({ variant: 'primary' }), 'ms-auto sm:ms-0')}
66
+ >
63
67
  {c('newThread')}
64
68
  </a>
65
69
  )}
@@ -12,7 +12,7 @@ export function ForumRow({ forum, copy }: ForumRowSlotModel & { copy: SlotCopy }
12
12
  <li
13
13
  data-unread={forum.isUnread ? '' : undefined}
14
14
  className={
15
- 'grid grid-cols-[auto_minmax(0,1fr)] gap-x-2.5 gap-y-2 px-4 py-3 transition-colors hover:bg-muted/60' +
15
+ 'grid grid-cols-[auto_minmax(0,1fr)] gap-x-2.5 gap-y-1.5 px-4 py-3 transition-colors hover:bg-muted/60' +
16
16
  (isLink ? '' : ' md:grid-cols-[auto_minmax(0,1fr)_9rem_15rem] md:items-center md:gap-x-4')
17
17
  }
18
18
  >
@@ -47,9 +47,9 @@ export function ForumRow({ forum, copy }: ForumRowSlotModel & { copy: SlotCopy }
47
47
  </div>
48
48
 
49
49
  {!isLink && (
50
- <>
50
+ <div className="col-start-2 flex min-w-0 flex-wrap items-baseline gap-x-4 gap-y-1 text-xs text-muted-foreground md:contents">
51
51
  <Counts
52
- className="col-start-2 md:col-start-3 md:justify-end"
52
+ className="md:col-start-3 md:justify-end"
53
53
  items={[
54
54
  {
55
55
  label: c('threadsLabel'),
@@ -66,25 +66,25 @@ export function ForumRow({ forum, copy }: ForumRowSlotModel & { copy: SlotCopy }
66
66
  ]}
67
67
  />
68
68
 
69
- <div className="col-start-2 min-w-0 text-xs text-muted-foreground md:col-start-4">
69
+ <div className="flex min-w-0 max-w-full flex-wrap gap-x-1 md:col-start-4 md:block">
70
70
  {forum.lastPost === null ? (
71
71
  <span className="text-forum-read">{c('noPostsYet')}</span>
72
72
  ) : (
73
73
  <>
74
74
  <a
75
75
  href={forum.lastPost.href}
76
- className={`block truncate font-medium text-foreground ${LINK}`}
76
+ className={`max-w-full truncate font-medium text-foreground md:block ${LINK}`}
77
77
  >
78
78
  {forum.lastPost.threadTitle}
79
79
  </a>
80
- <span className="mt-0.5 block truncate">
80
+ <span className="md:mt-0.5 md:block md:truncate">
81
81
  {c('by')} <UserRef user={forum.lastPost.author} className="font-normal" />{' '}
82
82
  {c('dot')} <Stamp at={forum.lastPost.at} />
83
83
  </span>
84
84
  </>
85
85
  )}
86
86
  </div>
87
- </>
87
+ </div>
88
88
  )}
89
89
  </li>
90
90
  )
@@ -1,7 +1,16 @@
1
1
  import type { HeaderModel, SlotCopy } from '@meith/theme-kit'
2
2
  import { fromSlotCopy, linkTarget } from '@meith/theme-kit'
3
3
 
4
- import { LINK, PAGE } from '../shared'
4
+ import { PAGE } from '../shared'
5
+
6
+ const DESKTOP_LINK =
7
+ 'inline-flex h-10 items-center rounded-md px-3 font-medium text-muted-foreground transition-colors pointer-coarse:h-11 hover:bg-primary/8 hover:text-primary'
8
+
9
+ const MOBILE_LINK =
10
+ 'flex min-h-11 items-center rounded-md px-3 text-sm font-medium text-muted-foreground hover:bg-primary/8 hover:text-primary'
11
+
12
+ const SUMMARY =
13
+ 'cursor-default list-none select-none [&::-webkit-details-marker]:hidden [&::marker]:content-none'
5
14
 
6
15
  function BoardMark({ boardTitle, logo }: Pick<HeaderModel, 'boardTitle' | 'logo'>) {
7
16
  if (logo === undefined) return <>{boardTitle}</>
@@ -29,13 +38,13 @@ function Submenu({ items }: { items: HeaderModel['navigation'][number]['submenu'
29
38
  if (items === undefined || items.length === 0) return null
30
39
 
31
40
  return (
32
- <ul className="invisible absolute top-full left-0 z-30 min-w-44 rounded-b-md border border-border bg-card py-1 opacity-0 shadow-elevation transition-opacity group-focus-within:visible group-focus-within:opacity-100 group-hover:visible group-hover:opacity-100">
41
+ <ul className="invisible absolute top-full left-0 z-30 min-w-48 rounded-md border border-border bg-card p-1 opacity-0 shadow-elevation transition-opacity group-focus-within:visible group-focus-within:opacity-100 group-hover:visible group-hover:opacity-100">
33
42
  {items.map((child) => (
34
43
  <li key={child.href}>
35
44
  <a
36
45
  href={child.href}
37
46
  {...linkTarget(child)}
38
- className="flex items-center px-3 py-1.5 text-muted-foreground pointer-coarse:min-h-11 hover:bg-muted hover:text-foreground"
47
+ className="flex items-center rounded-sm px-2.5 py-1.5 text-muted-foreground pointer-coarse:min-h-11 hover:bg-primary/8 hover:text-primary"
39
48
  >
40
49
  {child.label}
41
50
  </a>
@@ -62,15 +71,28 @@ function Chevron({ className }: { className: string }) {
62
71
  )
63
72
  }
64
73
 
74
+ function MenuGlyph() {
75
+ return (
76
+ <svg
77
+ aria-hidden="true"
78
+ viewBox="0 0 20 20"
79
+ className="size-5"
80
+ fill="none"
81
+ stroke="currentColor"
82
+ strokeWidth="1.75"
83
+ strokeLinecap="round"
84
+ >
85
+ <path className="group-open/menu:hidden" d="M3 5.5h14M3 10h14M3 14.5h14" />
86
+ <path className="hidden group-open/menu:block" d="m5 5 10 10M15 5 5 15" />
87
+ </svg>
88
+ )
89
+ }
90
+
65
91
  function MobileNavItem({ item }: { item: HeaderModel['navigation'][number] }) {
66
92
  if (item.submenu === undefined || item.submenu.length === 0) {
67
93
  return (
68
94
  <li>
69
- <a
70
- href={item.href}
71
- {...linkTarget(item)}
72
- className="flex min-h-11 items-center rounded-md px-2.5 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground"
73
- >
95
+ <a href={item.href} {...linkTarget(item)} className={MOBILE_LINK}>
74
96
  {item.label}
75
97
  </a>
76
98
  </li>
@@ -80,7 +102,9 @@ function MobileNavItem({ item }: { item: HeaderModel['navigation'][number] }) {
80
102
  return (
81
103
  <li>
82
104
  <details data-nav-disclosure name="default-header-submenu" className="group/submenu">
83
- <summary className="flex min-h-11 cursor-default list-none items-center justify-between gap-2 rounded-md px-2.5 text-sm font-medium text-muted-foreground select-none [&::-webkit-details-marker]:hidden [&::marker]:content-none hover:bg-muted hover:text-foreground">
105
+ <summary
106
+ className={`${SUMMARY} flex min-h-11 items-center justify-between gap-2 rounded-md pl-3 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground`}
107
+ >
84
108
  <a
85
109
  href={item.href}
86
110
  {...linkTarget(item)}
@@ -92,14 +116,10 @@ function MobileNavItem({ item }: { item: HeaderModel['navigation'][number] }) {
92
116
  <Chevron className="size-3 text-muted-foreground transition-transform group-open/submenu:rotate-90" />
93
117
  </span>
94
118
  </summary>
95
- <ul className="ml-2 flex flex-col gap-0.5 border-l border-border py-1 pl-2.5">
119
+ <ul className="mb-1 ml-3 flex flex-col gap-0.5 border-l border-border py-1 pl-2">
96
120
  {item.submenu.map((child) => (
97
121
  <li key={child.href}>
98
- <a
99
- href={child.href}
100
- {...linkTarget(child)}
101
- className="flex min-h-11 items-center rounded-md px-2.5 text-sm text-muted-foreground hover:bg-muted hover:text-foreground"
102
- >
122
+ <a href={child.href} {...linkTarget(child)} className={MOBILE_LINK}>
103
123
  {child.label}
104
124
  </a>
105
125
  </li>
@@ -120,58 +140,58 @@ export function Header({
120
140
  }: HeaderModel & { copy: SlotCopy }) {
121
141
  const c = (key: string) => fromSlotCopy(copy, `default.header.${key}`)
122
142
 
123
- const opensMenus = navigation.some((item) => item.submenu !== undefined)
143
+ const hasNavigation = navigation.length > 0
124
144
 
125
145
  return (
126
- <header className="border-b border-border bg-card">
127
- <div className={`${PAGE} flex flex-wrap items-center justify-between gap-x-6 gap-y-3 py-3`}>
146
+ <header className="relative border-t-4 border-b border-t-primary border-b-border bg-card">
147
+ <div className={`${PAGE} flex flex-wrap items-center gap-x-4 gap-y-3 py-3`}>
128
148
  <a
129
149
  href={homeHref}
130
- className={`inline-flex items-center text-xl font-semibold tracking-tight text-foreground ${LINK}`}
150
+ className="me-auto inline-flex min-h-9 min-w-0 items-center text-xl font-semibold tracking-tight text-primary transition-colors hover:text-primary-hover"
131
151
  >
132
152
  <BoardMark boardTitle={boardTitle} logo={logo} />
133
153
  </a>
154
+
134
155
  {children}
156
+
157
+ {hasNavigation && (
158
+ <nav aria-label={c('sections')} data-nav-view="mobile" className="lg:hidden">
159
+ <details data-nav-disclosure className="group/menu">
160
+ <summary
161
+ className={`${SUMMARY} flex size-11 items-center justify-center rounded-md border border-border text-foreground transition-colors hover:border-primary/40 hover:text-primary group-open/menu:border-primary/40 group-open/menu:bg-primary/8 group-open/menu:text-primary`}
162
+ >
163
+ <MenuGlyph />
164
+ <span className="sr-only">{c('sections')}</span>
165
+ </summary>
166
+
167
+ <div className="absolute inset-x-0 top-full z-30 max-h-[calc(100dvh-5rem)] overflow-y-auto border-b border-border bg-card shadow-elevation">
168
+ <ul className={`${PAGE} flex flex-col gap-0.5 py-2`}>
169
+ {navigation.map((item) => (
170
+ <MobileNavItem key={item.href} item={item} />
171
+ ))}
172
+ </ul>
173
+ </div>
174
+ </details>
175
+ </nav>
176
+ )}
135
177
  </div>
136
178
 
137
- {navigation.length > 0 && (
138
- <nav aria-label={c('sections')} className="border-t border-border bg-surface">
179
+ {hasNavigation && (
180
+ <nav aria-label={c('sections')} className="hidden border-t border-border lg:block">
139
181
  <div className={PAGE}>
140
182
  <ul
141
183
  data-nav-view="desktop"
142
- className={`-mx-4 hidden items-stretch gap-1 px-4 text-sm sm:-mx-6 sm:px-6 lg:flex ${
143
- opensMenus
144
- ? 'flex-wrap'
145
- : 'overflow-x-auto [mask-image:linear-gradient(to_right,black_calc(100%-1.5rem),transparent)] sm:[mask-image:none]'
146
- }`}
184
+ className="-mx-3 flex flex-wrap items-center gap-0.5 py-1 text-sm"
147
185
  >
148
186
  {navigation.map((item) => (
149
187
  <li key={item.href} className="group relative shrink-0">
150
- <a
151
- href={item.href}
152
- {...linkTarget(item)}
153
- className="inline-flex h-10 items-center rounded-t-md border-b-2 border-transparent px-2.5 font-medium text-muted-foreground transition-colors pointer-coarse:h-11 hover:border-border hover:text-foreground"
154
- >
188
+ <a href={item.href} {...linkTarget(item)} className={DESKTOP_LINK}>
155
189
  {item.label}
156
190
  </a>
157
191
  <Submenu items={item.submenu} />
158
192
  </li>
159
193
  ))}
160
194
  </ul>
161
-
162
- <div data-nav-view="mobile" className="-mx-4 px-4 py-1 sm:-mx-6 sm:px-6 lg:hidden">
163
- <details data-nav-disclosure className="group">
164
- <summary className="flex min-h-11 cursor-default list-none items-center gap-2 text-sm font-medium text-foreground select-none [&::-webkit-details-marker]:hidden [&::marker]:content-none">
165
- <Chevron className="size-3 shrink-0 transition-transform group-open:rotate-90" />
166
- {c('sections')}
167
- </summary>
168
- <ul className="flex flex-col gap-0.5 pb-2">
169
- {navigation.map((item) => (
170
- <MobileNavItem key={item.href} item={item} />
171
- ))}
172
- </ul>
173
- </details>
174
- </div>
175
195
  </div>
176
196
  </nav>
177
197
  )}
@@ -46,7 +46,7 @@ export function LatestThreads({
46
46
  {thread.title}
47
47
  </a>
48
48
  <span className={`shrink-0 text-xs text-muted-foreground ${NUMERIC}`}>
49
- {thread.replyCount.label}{' '}
49
+ <span className="font-medium text-primary">{thread.replyCount.label}</span>{' '}
50
50
  {thread.replyCount.value === 1 ? c('reply.one') : c('reply.other')}
51
51
  </span>
52
52
  </div>
@@ -64,9 +64,7 @@ export function MemberProfile({
64
64
 
65
65
  <dl className="grid grid-cols-3 gap-3 border-t border-border px-5 py-4">
66
66
  <div>
67
- <dd className={`text-lg font-semibold text-foreground ${NUMERIC}`}>
68
- {postCount.label}
69
- </dd>
67
+ <dd className={`text-lg font-semibold text-primary ${NUMERIC}`}>{postCount.label}</dd>
70
68
  <dt className="text-xs text-muted-foreground">{c('postsLabel')}</dt>
71
69
  </div>
72
70
  <div>
@@ -1,7 +1,7 @@
1
1
  import type { NavigationModel, SlotCopy } from '@meith/theme-kit'
2
2
  import { fromSlotCopy } from '@meith/theme-kit'
3
3
 
4
- import { MUTED_LINK, PAGE } from '../shared'
4
+ import { PAGE } from '../shared'
5
5
 
6
6
  export function Navigation({ items, copy }: NavigationModel & { copy: SlotCopy }) {
7
7
  if (items.length === 0) return null
@@ -9,28 +9,29 @@ export function Navigation({ items, copy }: NavigationModel & { copy: SlotCopy }
9
9
  const c = (key: string) => fromSlotCopy(copy, `default.navigation.${key}`)
10
10
 
11
11
  return (
12
- <nav aria-label={c('breadcrumb')} className="border-b border-border bg-card/40">
13
- <ol
14
- className={`${PAGE} flex items-center gap-1.5 overflow-x-auto py-2 text-xs whitespace-nowrap text-muted-foreground`}
15
- >
12
+ <nav aria-label={c('breadcrumb')} className={`${PAGE} pt-4`}>
13
+ <ol className="-mx-1.5 flex items-center overflow-x-auto py-0.5 text-xs whitespace-nowrap text-muted-foreground">
16
14
  {items.map((item, index) => {
17
15
  const isLast = index === items.length - 1
18
16
  return (
19
- <li key={item.href} className="flex shrink-0 items-center gap-1.5">
17
+ <li key={item.href} className="flex shrink-0 items-center">
20
18
  {index > 0 && (
21
- <span aria-hidden="true" className="text-border select-none">
19
+ <span aria-hidden="true" className="px-0.5 text-border select-none">
22
20
  {c('separator')}
23
21
  </span>
24
22
  )}
25
23
  {isLast ? (
26
24
  <span
27
25
  aria-current="page"
28
- className="max-w-[24ch] truncate font-medium text-foreground sm:max-w-none"
26
+ className="max-w-[24ch] truncate px-1.5 py-1 font-medium text-foreground sm:max-w-none"
29
27
  >
30
28
  {item.label}
31
29
  </span>
32
30
  ) : (
33
- <a href={item.href} className={MUTED_LINK}>
31
+ <a
32
+ href={item.href}
33
+ className="rounded-md px-1.5 py-1 transition-colors hover:bg-primary/8 hover:text-primary"
34
+ >
34
35
  {item.label}
35
36
  </a>
36
37
  )}
@@ -131,7 +131,7 @@ export function PanelNav({
131
131
  </Disclosure>
132
132
  </div>
133
133
 
134
- <div className="hidden rounded-xl border border-border bg-card p-2 shadow-elevation lg:block">
134
+ <div className="hidden rounded-lg border border-border bg-card p-2 shadow-elevation lg:block">
135
135
  <Sections label={label} sections={sections} copy={copy} />
136
136
  </div>
137
137
  </>
@@ -14,7 +14,7 @@ export function PanelShell({ panel, links, linksLabel, regions, children }: Pane
14
14
  {links.length > 0 && (
15
15
  <nav
16
16
  aria-label={linksLabel}
17
- className="hidden rounded-xl border border-border bg-card p-2 shadow-elevation lg:block"
17
+ className="hidden rounded-lg border border-border bg-card p-2 shadow-elevation lg:block"
18
18
  >
19
19
  <p className="px-2.5 pt-1 pb-1.5 text-xs font-medium tracking-wide text-muted-foreground uppercase">
20
20
  {linksLabel}
@@ -2,6 +2,8 @@ import type { PostActionsSlotModel, SlotCopy } from '@meith/theme-kit'
2
2
  import { fromSlotCopy } from '@meith/theme-kit'
3
3
  import { buttonVariants, Separator } from '@meith/ui'
4
4
 
5
+ import { isEmptyRegion } from '../shared'
6
+
5
7
  interface Action {
6
8
  readonly href: string
7
9
  readonly label: string
@@ -28,7 +30,9 @@ export function PostActions({
28
30
  actions.moderateHref === null ? null : { href: actions.moderateHref, label: c('moderate') },
29
31
  ].filter((action): action is Action => action !== null)
30
32
 
31
- if (reader.length === 0 && staff.length === 0 && children === undefined) return null
33
+ const extra = isEmptyRegion(children) ? null : children
34
+
35
+ if (reader.length === 0 && staff.length === 0 && extra === null) return null
32
36
 
33
37
  return (
34
38
  <nav aria-label={c('nav')} className="flex flex-wrap items-center gap-x-1 gap-y-1">
@@ -56,7 +60,9 @@ export function PostActions({
56
60
  </a>
57
61
  ))}
58
62
 
59
- {children !== undefined && <span className="ms-auto empty:hidden">{children}</span>}
63
+ {extra !== null && (
64
+ <span className="ms-auto flex flex-wrap items-center gap-x-1 gap-y-1">{extra}</span>
65
+ )}
60
66
  </nav>
61
67
  )
62
68
  }
@@ -1,8 +1,8 @@
1
1
  import type { PostBitSlotModel, SlotCopy } from '@meith/theme-kit'
2
2
  import { fromSlotCopy } from '@meith/theme-kit'
3
- import { Alert, AlertDescription, AlertTitle, Avatar, Card } from '@meith/ui'
3
+ import { Alert, AlertDescription, AlertTitle, Avatar, Card, cn } from '@meith/ui'
4
4
 
5
- import { groupTags, LINK, MUTED_LINK, NUMERIC, Stamp, UserRef } from '../shared'
5
+ import { groupTags, isEmptyRegion, LINK, MUTED_LINK, NUMERIC, Stamp, UserRef } from '../shared'
6
6
 
7
7
  const VISIBILITY_TINT = {
8
8
  visible: '',
@@ -10,6 +10,8 @@ const VISIBILITY_TINT = {
10
10
  deleted: 'border-thread-deleted/50 bg-destructive/5',
11
11
  } as const
12
12
 
13
+ const BODY_X = 'px-4 sm:px-5'
14
+
13
15
  function StatusBanner({
14
16
  visibility,
15
17
  copy,
@@ -74,12 +76,12 @@ function AuthorBlock({
74
76
  const c = (key: string) => fromSlotCopy(copy, `default.postBit.${key}`)
75
77
 
76
78
  return (
77
- <div className="flex gap-3 sm:flex-col sm:gap-2 sm:text-center">
79
+ <div className="flex items-center gap-3 sm:flex-col sm:items-stretch sm:gap-2 sm:text-center">
78
80
  <Avatar src={author.avatarUrl} name={author.username} size={48} className="sm:self-center" />
79
81
 
80
82
  <div className="min-w-0 flex-1 sm:flex-none">
81
83
  <p className="truncate text-sm">
82
- <UserRef user={author} className="font-semibold text-foreground" />
84
+ <UserRef user={author} className="font-semibold" />
83
85
  </p>
84
86
 
85
87
  {author.badge != null && (
@@ -103,7 +105,9 @@ function AuthorBlock({
103
105
 
104
106
  {badges}
105
107
 
106
- <dl className={`mt-1.5 text-xs text-muted-foreground ${NUMERIC}`}>
108
+ <dl
109
+ className={`mt-1 flex flex-wrap gap-x-2 text-xs text-muted-foreground sm:mt-2 sm:flex-col sm:gap-x-0 sm:gap-y-0.5 ${NUMERIC}`}
110
+ >
107
111
  <div className="flex gap-1 sm:justify-center">
108
112
  <dt className="sr-only">{c('postsLabel')}</dt>
109
113
  <dd>
@@ -130,9 +134,9 @@ function AuthorBlock({
130
134
  </dl>
131
135
 
132
136
  {author.fields.length > 0 && (
133
- <dl className="mt-1.5 space-y-0.5 text-xs text-muted-foreground">
137
+ <dl className="mt-1 flex flex-wrap gap-x-3 gap-y-0.5 text-xs text-muted-foreground sm:mt-1.5 sm:flex-col sm:gap-x-0">
134
138
  {author.fields.map((field) => (
135
- <div key={field.label} className="flex justify-center gap-1">
139
+ <div key={field.label} className="flex min-w-0 gap-1 sm:justify-center">
136
140
  <dt className="font-medium">{field.label}:</dt>
137
141
  <dd className="truncate">{field.value}</dd>
138
142
  </div>
@@ -147,46 +151,57 @@ function AuthorBlock({
147
151
  export function PostBit({ post, select, regions, copy }: PostBitSlotModel & { copy: SlotCopy }) {
148
152
  const c = (key: string) => fromSlotCopy(copy, `default.postBit.${key}`)
149
153
 
154
+ const actions = regions.actions !== null && !isEmptyRegion(regions.actions)
155
+ const pluginFooter = !isEmptyRegion(regions.pluginFooter)
156
+
150
157
  return (
151
158
  <Card
152
159
  as="article"
153
160
  id={`post-${post.number}`}
154
161
  data-post-id={post.id}
155
162
  data-visibility={post.visibility}
156
- className={VISIBILITY_TINT[post.visibility]}
163
+ className={cn(
164
+ 'target:border-primary/60 target:ring-2 target:ring-primary/20',
165
+ VISIBILITY_TINT[post.visibility],
166
+ )}
157
167
  >
158
168
  <StatusBanner visibility={post.visibility} copy={copy} />
159
169
 
160
- <div className="grid grid-cols-1 sm:grid-cols-[11rem_minmax(0,1fr)]">
161
- <div className="border-b border-border px-4 py-3 sm:border-r sm:border-b-0 sm:bg-muted/40">
170
+ <div className="grid grid-cols-[minmax(0,1fr)_auto] sm:grid-cols-[10.5rem_minmax(0,1fr)] sm:grid-rows-[auto_minmax(0,1fr)]">
171
+ <div className="col-start-1 row-start-1 min-w-0 border-b border-border py-3 pl-4 sm:row-span-2 sm:border-r sm:border-b-0 sm:bg-surface/60 sm:px-4 sm:py-4">
162
172
  <AuthorBlock author={post.author} badges={regions.pluginBadges} copy={copy} />
163
173
  </div>
164
174
 
165
- <div className="min-w-0">
166
- <div className="flex items-center justify-between gap-3 border-b border-border px-4 py-2 text-xs text-muted-foreground">
167
- {select === null ? (
168
- <span />
169
- ) : (
170
- <label className="flex items-center gap-2">
171
- <input
172
- type="checkbox"
173
- name={select.name}
174
- value={select.value}
175
- form={select.formId}
176
- className="size-4 accent-primary"
177
- />
178
- <span className="sr-only">{select.label}</span>
179
- </label>
180
- )}
175
+ <div
176
+ className={`col-start-2 row-start-1 flex flex-col-reverse items-end justify-center gap-1.5 border-b border-border py-3 text-xs text-muted-foreground sm:flex-row sm:items-center sm:justify-between sm:py-1.5 ${BODY_X}`}
177
+ >
178
+ {select === null ? (
179
+ <span className="hidden sm:block" />
180
+ ) : (
181
+ <label className="flex min-h-6 items-center gap-2">
182
+ <input
183
+ type="checkbox"
184
+ name={select.name}
185
+ value={select.value}
186
+ form={select.formId}
187
+ className="size-4 accent-primary"
188
+ />
189
+ <span className="sr-only">{select.label}</span>
190
+ </label>
191
+ )}
181
192
 
182
- <a href={post.permalink} className={MUTED_LINK}>
183
- <Stamp at={post.postedAt} />
184
- <span className={`ml-2 ${NUMERIC}`}>#{post.number}</span>
185
- </a>
186
- </div>
193
+ <a
194
+ href={post.permalink}
195
+ className={`inline-flex min-h-8 items-center gap-2 ${MUTED_LINK} ${NUMERIC}`}
196
+ >
197
+ <Stamp at={post.postedAt} />
198
+ <span className="font-semibold text-primary">#{post.number}</span>
199
+ </a>
200
+ </div>
187
201
 
202
+ <div className="col-span-2 row-start-2 min-w-0 sm:col-span-1 sm:col-start-2">
188
203
  {post.ignored !== null ? (
189
- <div className="px-4 py-5 text-sm text-muted-foreground">
204
+ <div className={`py-5 text-sm text-muted-foreground ${BODY_X}`}>
190
205
  {c('ignoringPrefix')}{' '}
191
206
  <span className="font-medium text-foreground">{post.ignored.authorUsername}</span>.{' '}
192
207
  {c('hiddenNotice')}{' '}
@@ -195,9 +210,9 @@ export function PostBit({ post, select, regions, copy }: PostBitSlotModel & { co
195
210
  </a>
196
211
  </div>
197
212
  ) : (
198
- <div className="px-4 py-4">
213
+ <div className={`py-4 sm:py-5 ${BODY_X}`}>
199
214
  <div
200
- className="prose-md text-sm"
215
+ className="prose-md text-[0.9375rem] sm:text-base"
201
216
  dangerouslySetInnerHTML={{ __html: post.bodyHtml }}
202
217
  />
203
218
 
@@ -210,7 +225,7 @@ export function PostBit({ post, select, regions, copy }: PostBitSlotModel & { co
210
225
  )}
211
226
 
212
227
  {post.attachments.length > 0 && (
213
- <div className="border-t border-border px-4 py-3">
228
+ <div className={`border-t border-border py-3 ${BODY_X}`}>
214
229
  <h4 className="mb-2 text-xs font-semibold tracking-wide text-muted-foreground uppercase">
215
230
  {post.attachments.length}{' '}
216
231
  {post.attachments.length === 1 ? c('attachment.one') : c('attachment.other')}
@@ -246,19 +261,19 @@ export function PostBit({ post, select, regions, copy }: PostBitSlotModel & { co
246
261
 
247
262
  {post.ignored === null && post.author.signatureHtml !== null && (
248
263
  <div
249
- className="prose-md border-t border-border px-4 py-2.5 text-xs text-muted-foreground"
264
+ className={`prose-md border-t border-border py-2.5 text-xs text-muted-foreground ${BODY_X}`}
250
265
  dangerouslySetInnerHTML={{ __html: post.author.signatureHtml }}
251
266
  />
252
267
  )}
253
268
 
254
- {regions.pluginFooter !== undefined && regions.pluginFooter !== null && (
255
- <div className="border-t border-border px-4 py-2 text-xs empty:hidden">
269
+ {pluginFooter && (
270
+ <div className={`border-t border-border py-2 text-xs empty:hidden ${BODY_X}`}>
256
271
  {regions.pluginFooter}
257
272
  </div>
258
273
  )}
259
274
 
260
- {regions.actions !== null && (
261
- <footer className="border-t border-border px-4 py-2 empty:hidden">
275
+ {actions && (
276
+ <footer className={`border-t border-border py-1.5 empty:hidden ${BODY_X}`}>
262
277
  {regions.actions}
263
278
  </footer>
264
279
  )}
@@ -19,7 +19,7 @@ export function ThreadRow({
19
19
  <li
20
20
  data-unread={thread.isUnread ? '' : undefined}
21
21
  data-visibility={hidden ? thread.visibility : undefined}
22
- className={`grid grid-cols-[auto_minmax(0,1fr)] gap-x-2.5 gap-y-2 px-4 py-3 transition-colors hover:bg-muted/60 md:grid-cols-[auto_minmax(0,1fr)_9rem_14rem] md:items-center md:gap-x-4 ${hidden ? tint : ''}`}
22
+ className={`grid grid-cols-[auto_minmax(0,1fr)] gap-x-2.5 gap-y-1.5 px-4 py-3 transition-colors hover:bg-muted/60 md:grid-cols-[auto_minmax(0,1fr)_9rem_14rem] md:items-center md:gap-x-4 ${hidden ? tint : ''}`}
23
23
  >
24
24
  <span className="flex items-start gap-2">
25
25
  {select !== null && (
@@ -65,38 +65,43 @@ export function ThreadRow({
65
65
  </p>
66
66
  </div>
67
67
 
68
- <Counts
69
- className="col-start-2 md:col-start-3 md:justify-end"
70
- items={[
71
- {
72
- label: c('repliesLabel'),
73
- value: thread.replyCount,
74
- one: c('reply.one'),
75
- many: c('reply.other'),
76
- },
77
- {
78
- label: c('viewsLabel'),
79
- value: thread.viewCount,
80
- one: c('view.one'),
81
- many: c('view.other'),
82
- },
83
- ]}
84
- />
68
+ <div className="col-start-2 flex min-w-0 flex-wrap items-baseline gap-x-4 gap-y-1 text-xs text-muted-foreground md:contents">
69
+ <Counts
70
+ className="md:col-start-3 md:justify-end"
71
+ items={[
72
+ {
73
+ label: c('repliesLabel'),
74
+ value: thread.replyCount,
75
+ one: c('reply.one'),
76
+ many: c('reply.other'),
77
+ },
78
+ {
79
+ label: c('viewsLabel'),
80
+ value: thread.viewCount,
81
+ one: c('view.one'),
82
+ many: c('view.other'),
83
+ },
84
+ ]}
85
+ />
85
86
 
86
- <div className="col-start-2 min-w-0 text-xs text-muted-foreground md:col-start-4">
87
- {thread.lastPost === null ? (
88
- <span className="text-thread-moved">{c('noRepliesYet')}</span>
89
- ) : (
90
- <>
91
- <a href={thread.lastPost.href} className={`block font-medium text-foreground ${LINK}`}>
92
- {c('latestReply')}
93
- </a>
94
- <span className="mt-0.5 block truncate">
95
- {c('by')} <UserRef user={thread.lastPost.author} className="font-normal" /> {c('dot')}{' '}
96
- <Stamp at={thread.lastPost.at} />
97
- </span>
98
- </>
99
- )}
87
+ <div className="flex min-w-0 max-w-full flex-wrap gap-x-1 md:col-start-4 md:block">
88
+ {thread.lastPost === null ? (
89
+ <span className="text-thread-moved">{c('noRepliesYet')}</span>
90
+ ) : (
91
+ <>
92
+ <a
93
+ href={thread.lastPost.href}
94
+ className={`font-medium text-foreground md:block ${LINK}`}
95
+ >
96
+ {c('latestReply')}
97
+ </a>
98
+ <span className="md:mt-0.5 md:block md:truncate">
99
+ {c('by')} <UserRef user={thread.lastPost.author} className="font-normal" />{' '}
100
+ {c('dot')} <Stamp at={thread.lastPost.at} />
101
+ </span>
102
+ </>
103
+ )}
104
+ </div>
100
105
  </div>
101
106
  </li>
102
107
  )
@@ -1,12 +1,11 @@
1
1
  import type { SlotCopy, ThreadViewModel } from '@meith/theme-kit'
2
2
  import { fromSlotCopy } from '@meith/theme-kit'
3
- import { Badge, buttonVariants } from '@meith/ui'
3
+ import { Badge, buttonVariants, cn } from '@meith/ui'
4
4
 
5
- import { Counts, MUTED_LINK, PAGE_BODY, Prefix } from '../shared'
5
+ import { Counts, PAGE_BODY, Prefix } from '../shared'
6
6
 
7
7
  export function ThreadView({
8
8
  thread,
9
- forum,
10
9
  replyHref,
11
10
  markReadAction,
12
11
  watch,
@@ -19,13 +18,7 @@ export function ThreadView({
19
18
  <div className={PAGE_BODY}>
20
19
  <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-6">
21
20
  <div className="min-w-0">
22
- <a href={forum.href} className={`text-sm sm:hidden ${MUTED_LINK}`}>
23
- {forum.label}
24
- </a>
25
-
26
- <h1 className="mt-1 text-2xl font-semibold tracking-tight text-balance sm:mt-0">
27
- {thread.title}
28
- </h1>
21
+ <h1 className="text-2xl font-semibold tracking-tight text-balance">{thread.title}</h1>
29
22
 
30
23
  <div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-2">
31
24
  {thread.prefix !== null && <Prefix prefix={thread.prefix} />}
@@ -52,10 +45,10 @@ export function ThreadView({
52
45
  </div>
53
46
  </div>
54
47
 
55
- <div className="flex shrink-0 items-center gap-2">
48
+ <div className="flex shrink-0 flex-wrap items-center gap-2">
56
49
  {watch != null && (
57
50
  <form action={watch.action} method="post">
58
- <button type="submit" className={buttonVariants({ variant: 'ghost' })}>
51
+ <button type="submit" className={buttonVariants({ variant: 'outline' })}>
59
52
  {watch.subscribed ? (
60
53
  <>
61
54
  {c('watching')} <span aria-hidden="true">✓</span>
@@ -74,7 +67,10 @@ export function ThreadView({
74
67
  </form>
75
68
  )}
76
69
  {replyHref !== null && (
77
- <a href={replyHref} className={buttonVariants({ variant: 'primary' })}>
70
+ <a
71
+ href={replyHref}
72
+ className={cn(buttonVariants({ variant: 'primary' }), 'ms-auto sm:ms-0')}
73
+ >
78
74
  {c('replyAction')}
79
75
  </a>
80
76
  )}
package/src/theme.ts CHANGED
@@ -70,7 +70,7 @@ import { WhoIsOnline } from './slots/who-is-online'
70
70
  export const defaultTheme = defineTheme({
71
71
  key: 'default',
72
72
  title: 'Default',
73
- version: '0.32.0',
73
+ version: '0.33.0',
74
74
  slots: {
75
75
  Shell,
76
76
  Header,