@meith/theme-phasebook 0.1.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.
@@ -0,0 +1,68 @@
1
+ import type { ThreadViewModel } from '@meith/theme-kit'
2
+
3
+ import { FEED, MUTED_LINK, NUMERIC, PAGE, PILL, PILL_PRIMARY, Prefix, Tag, count, plural } from '../shared'
4
+
5
+ export function ThreadView({ thread, forum, replyHref, markReadAction, regions }: ThreadViewModel) {
6
+ return (
7
+ <div className={`${PAGE} py-4 sm:py-6`}>
8
+ <div className={`${FEED} flex flex-col gap-4`}>
9
+ <section className="rounded-lg border border-border bg-card text-card-foreground shadow-elevation">
10
+ <div className="flex flex-wrap items-start justify-between gap-3 px-4 py-3">
11
+ <div className="min-w-0">
12
+ <a href={forum.href} className={`text-xs font-semibold ${MUTED_LINK}`}>
13
+ {forum.label}
14
+ </a>
15
+
16
+ <h1 className="mt-0.5 text-xl leading-tight font-bold tracking-tight text-balance">
17
+ {thread.title}
18
+ </h1>
19
+
20
+ <div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1">
21
+ {thread.prefix !== null && <Prefix prefix={thread.prefix} />}
22
+ {thread.isSticky && <Tag token="thread-pinned">Pinned</Tag>}
23
+ {thread.isLocked && <Tag token="thread-locked">Locked — no new replies</Tag>}
24
+ {thread.isMoved && <Tag token="thread-moved">Moved</Tag>}
25
+
26
+ <span className={`text-xs text-muted-foreground ${NUMERIC}`}>
27
+ {count(thread.replyCount)} {plural(thread.replyCount, 'reply', 'replies')} ·{' '}
28
+ {count(thread.viewCount)} {plural(thread.viewCount, 'view', 'views')}
29
+ </span>
30
+ </div>
31
+ </div>
32
+
33
+ <div className="flex shrink-0 items-center gap-2">
34
+ {markReadAction !== null && (
35
+ <form action={markReadAction} method="post">
36
+ <button type="submit" className={PILL}>
37
+ Mark read
38
+ </button>
39
+ </form>
40
+ )}
41
+ {replyHref !== null && (
42
+ <a href={replyHref} className={PILL_PRIMARY}>
43
+ Reply
44
+ </a>
45
+ )}
46
+ </div>
47
+ </div>
48
+ </section>
49
+
50
+ {regions.tools !== undefined && (
51
+ <div className="flex flex-col gap-3 empty:hidden">{regions.tools}</div>
52
+ )}
53
+
54
+ <div className="flex flex-col gap-3">{regions.posts}</div>
55
+
56
+ {regions.pagination}
57
+
58
+ {regions.afterContent !== undefined && (
59
+ <div className="flex flex-wrap items-center justify-between gap-x-8 gap-y-3 rounded-lg border border-border bg-card px-4 py-3 shadow-elevation empty:hidden">
60
+ {regions.afterContent}
61
+ </div>
62
+ )}
63
+
64
+ {regions.quickReply}
65
+ </div>
66
+ </div>
67
+ )
68
+ }
@@ -0,0 +1,154 @@
1
+ import { Menu } from '@meith/ui/menu'
2
+ import type { UserPanelModel } from '@meith/theme-kit'
3
+
4
+ import { Circle, ICON_BUTTON, MUTED_LINK, PILL, PILL_PRIMARY, count } from '../shared'
5
+
6
+ function BellIcon() {
7
+ return (
8
+ <svg
9
+ aria-hidden="true"
10
+ viewBox="0 0 20 20"
11
+ className="size-5"
12
+ fill="none"
13
+ stroke="currentColor"
14
+ strokeWidth="1.6"
15
+ strokeLinecap="round"
16
+ strokeLinejoin="round"
17
+ >
18
+ <path d="M10 2.5a4.5 4.5 0 0 0-4.5 4.5c0 3.5-1.5 4.5-1.5 4.5h12s-1.5-1-1.5-4.5A4.5 4.5 0 0 0 10 2.5Z" />
19
+ <path d="M8.5 14.5a1.75 1.75 0 0 0 3 0" />
20
+ </svg>
21
+ )
22
+ }
23
+
24
+ function MessageIcon() {
25
+ return (
26
+ <svg
27
+ aria-hidden="true"
28
+ viewBox="0 0 20 20"
29
+ className="size-5"
30
+ fill="none"
31
+ stroke="currentColor"
32
+ strokeWidth="1.6"
33
+ strokeLinecap="round"
34
+ strokeLinejoin="round"
35
+ >
36
+ <path d="M10 3c4 0 7 2.8 7 6.3S14 15.5 10 15.5a8 8 0 0 1-2.2-.3L4 16.5l.9-2.8A6 6 0 0 1 3 9.3C3 5.8 6 3 10 3Z" />
37
+ </svg>
38
+ )
39
+ }
40
+
41
+ function IconButton({
42
+ href,
43
+ title,
44
+ children,
45
+ }: {
46
+ href: string | null
47
+ title: string
48
+ children: React.ReactNode
49
+ }) {
50
+ if (href === null) {
51
+ return (
52
+ <span className={`relative ${ICON_BUTTON}`} aria-hidden="true">
53
+ {children}
54
+ </span>
55
+ )
56
+ }
57
+
58
+ return (
59
+ <a href={href} title={title} className={`relative ${ICON_BUTTON}`}>
60
+ <span className="sr-only">{title}</span>
61
+ {children}
62
+ </a>
63
+ )
64
+ }
65
+
66
+ function CountBadge({ value, label }: { value: number; label: string }) {
67
+ return (
68
+ <span className="absolute -top-0.5 -right-0.5 inline-flex min-w-5 items-center justify-center rounded-full bg-destructive px-1 text-xs font-semibold text-destructive-foreground ring-2 ring-card">
69
+ <span aria-hidden="true">{value > 99 ? '99+' : value}</span>
70
+ <span className="sr-only">
71
+ {count(value)} {label}
72
+ </span>
73
+ </span>
74
+ )
75
+ }
76
+
77
+ export function UserPanel({
78
+ viewer,
79
+ links,
80
+ unreadNotifications,
81
+ unreadMessages,
82
+ children,
83
+ }: UserPanelModel) {
84
+ if (viewer.isGuest) {
85
+ return (
86
+ <div className="flex items-center gap-2">
87
+ {links.map((link, index) => (
88
+ <a key={link.href} href={link.href} className={index === 0 ? PILL_PRIMARY : PILL}>
89
+ {link.label}
90
+ </a>
91
+ ))}
92
+ </div>
93
+ )
94
+ }
95
+
96
+ const name = viewer.username ?? 'Signed in'
97
+ const hrefFor = (label: string): string | null =>
98
+ links.find((link) => link.label === label)?.href ?? null
99
+
100
+ return (
101
+ <div className="flex items-center gap-2">
102
+ <noscript
103
+ dangerouslySetInnerHTML={{
104
+ __html:
105
+ '<style>[data-account="menu"]{display:none}[data-account="plain"]{display:flex!important}</style>',
106
+ }}
107
+ />
108
+
109
+ {unreadNotifications > 0 && (
110
+ <IconButton href={hrefFor('Notifications')} title="Notifications">
111
+ <BellIcon />
112
+ <CountBadge value={unreadNotifications} label="unread notifications" />
113
+ </IconButton>
114
+ )}
115
+
116
+ {unreadMessages > 0 && (
117
+ <IconButton href={hrefFor('Messages')} title="Messages">
118
+ <MessageIcon />
119
+ <CountBadge value={unreadMessages} label="unread messages" />
120
+ </IconButton>
121
+ )}
122
+
123
+ <div data-account="menu" className="flex min-w-0 items-center">
124
+ <Menu
125
+ label="Your account"
126
+ items={links}
127
+ trigger={
128
+ <>
129
+ <Circle src={viewer.avatarUrl} name={name} size={32} />
130
+ <span className="sr-only">{name}</span>
131
+ </>
132
+ }
133
+ >
134
+ {children}
135
+ </Menu>
136
+ </div>
137
+
138
+ <nav
139
+ data-account="plain"
140
+ style={{ display: 'none' }}
141
+ aria-label="Your account"
142
+ className="flex-wrap items-center gap-x-3 gap-y-1 text-xs"
143
+ >
144
+ <span className="font-semibold text-foreground">{name}</span>
145
+ {links.map((link) => (
146
+ <a key={link.href} href={link.href} className={MUTED_LINK}>
147
+ {link.label}
148
+ </a>
149
+ ))}
150
+ {children}
151
+ </nav>
152
+ </div>
153
+ )
154
+ }
@@ -0,0 +1,87 @@
1
+ import type { OnlineMemberModel, WhoIsOnlineModel } from '@meith/theme-kit'
2
+
3
+ import { Circle, MUTED_LINK, NUMERIC, OnlineDot, Rail, Stamp, Tag, UserRef, count, plural } from '../shared'
4
+
5
+ const VISIBLE_NAMES = 8
6
+
7
+ function Row({ member }: { member: OnlineMemberModel }) {
8
+ return (
9
+ <li className="flex items-center gap-2.5 rounded-lg px-2 py-1.5 transition-colors hover:bg-accent">
10
+ <span className="relative shrink-0">
11
+ <Circle name={member.username} size={32} />
12
+ <OnlineDot />
13
+ </span>
14
+
15
+ <span className="min-w-0 flex-1 truncate text-sm">
16
+ <UserRef user={member} className="text-sm" />
17
+ {member.location.href === null ? (
18
+ <span className="block truncate text-xs text-muted-foreground">
19
+ {member.location.label}
20
+ </span>
21
+ ) : (
22
+ <a href={member.location.href} className={`block truncate text-xs ${MUTED_LINK}`}>
23
+ {member.location.label}
24
+ </a>
25
+ )}
26
+ </span>
27
+
28
+ {member.isInvisible && <Tag>Invisible</Tag>}
29
+ </li>
30
+ )
31
+ }
32
+
33
+ export function WhoIsOnline({
34
+ guestCount,
35
+ members,
36
+ total,
37
+ recordCount,
38
+ recordAt,
39
+ fullListHref,
40
+ }: WhoIsOnlineModel) {
41
+ const shown = members.slice(0, VISIBLE_NAMES)
42
+ const hidden = members.length - shown.length
43
+
44
+ return (
45
+ <Rail
46
+ title="Online now"
47
+ titleId="who-is-online-heading"
48
+ action={
49
+ <a href={fullListHref} className={`text-xs font-semibold ${MUTED_LINK}`}>
50
+ See all
51
+ </a>
52
+ }
53
+ >
54
+ <p className={`px-3 text-xs text-muted-foreground ${NUMERIC}`}>
55
+ {count(total)} online — {count(members.length)} {plural(members.length, 'member', 'members')}
56
+ , {count(guestCount)} {plural(guestCount, 'guest', 'guests')}
57
+ </p>
58
+
59
+ {members.length === 0 ? (
60
+ <p className="px-3 pt-2 pb-3 text-xs text-muted-foreground">
61
+ {guestCount === 0
62
+ ? 'Nobody is reading the board right now.'
63
+ : 'Only guests are reading the board right now.'}
64
+ </p>
65
+ ) : (
66
+ <ul className="px-1 pt-1 pb-1">
67
+ {shown.map((member) => (
68
+ <Row key={member.userId ?? member.username} member={member} />
69
+ ))}
70
+ {hidden > 0 && (
71
+ <li className="px-2 py-1.5">
72
+ <a href={fullListHref} className={`text-xs font-semibold ${MUTED_LINK}`}>
73
+ and {count(hidden)} more
74
+ </a>
75
+ </li>
76
+ )}
77
+ </ul>
78
+ )}
79
+
80
+ {recordAt !== null && (
81
+ <p className={`border-t border-border px-3 py-2 text-xs text-muted-foreground ${NUMERIC}`}>
82
+ Record: {count(recordCount)} on <Stamp at={recordAt} />
83
+ </p>
84
+ )}
85
+ </Rail>
86
+ )
87
+ }
package/src/theme.ts ADDED
@@ -0,0 +1,72 @@
1
+ import { defaultTheme } from '@meith/theme-default'
2
+ import { defineTheme } from '@meith/theme-kit'
3
+
4
+ import { Announcement } from './slots/announcement'
5
+ import { BoardIndex } from './slots/board-index'
6
+ import { BoardStats } from './slots/board-stats'
7
+ import { CategoryBlock } from './slots/category-block'
8
+ import { ErrorNotice } from './slots/error-notice'
9
+ import { Footer } from './slots/footer'
10
+ import { ForumJump } from './slots/forum-jump'
11
+ import { ForumDisplay } from './slots/forum-display'
12
+ import { ForumRow } from './slots/forum-row'
13
+ import { Header } from './slots/header'
14
+ import { LatestPosts } from './slots/latest-posts'
15
+ import { LatestThreads } from './slots/latest-threads'
16
+ import { MemberProfile } from './slots/member-profile'
17
+ import { Navigation } from './slots/navigation'
18
+ import { Notice } from './slots/notice'
19
+ import { Pagination } from './slots/pagination'
20
+ import { PostActions } from './slots/post-actions'
21
+ import { PostBit } from './slots/post-bit'
22
+ import { PostForm } from './slots/post-form'
23
+ import { RedirectNotice } from './slots/redirect-notice'
24
+ import { SearchForm } from './slots/search-form'
25
+ import { Shell } from './slots/shell'
26
+ import { SubforumList } from './slots/subforum-list'
27
+ import { ThreadRow } from './slots/thread-row'
28
+ import { ThreadView } from './slots/thread-view'
29
+ import { UserPanel } from './slots/user-panel'
30
+ import { WhoIsOnline } from './slots/who-is-online'
31
+
32
+ export const phasebookTheme = defineTheme({
33
+ key: 'phasebook',
34
+ title: 'Phasebook',
35
+ extends: defaultTheme,
36
+ slots: {
37
+ Shell,
38
+ Header,
39
+ UserPanel,
40
+ Navigation,
41
+ Footer,
42
+ Notice,
43
+ Announcement,
44
+
45
+ BoardIndex,
46
+ CategoryBlock,
47
+ ForumRow,
48
+ BoardStats,
49
+ WhoIsOnline,
50
+ LatestThreads,
51
+ LatestPosts,
52
+
53
+ ForumDisplay,
54
+ ThreadRow,
55
+ SubforumList,
56
+ Pagination,
57
+
58
+ ThreadView,
59
+ PostBit,
60
+ PostActions,
61
+
62
+ PostForm,
63
+
64
+ MemberProfile,
65
+
66
+ SearchForm,
67
+ ForumJump,
68
+
69
+ RedirectNotice,
70
+ ErrorNotice,
71
+ },
72
+ })
package/src/tokens.ts ADDED
@@ -0,0 +1,102 @@
1
+ import type { TokenName } from '@meith/theme-default'
2
+
3
+ export const LIGHT_TOKENS: Record<TokenName, string> = {
4
+ background: 'oklch(0.96 0.005 258.3)',
5
+ foreground: 'oklch(0.115 0 0)',
6
+ surface: 'oklch(0.925 0.007 268.5)',
7
+ card: 'oklch(1 0 0)',
8
+ 'card-foreground': 'oklch(0.115 0 0)',
9
+ primary: 'oklch(0.562 0.196 258.2)',
10
+ 'primary-foreground': 'oklch(1 0 0)',
11
+ 'primary-hover': 'oklch(0.471 0.17 258.8)',
12
+ secondary: 'oklch(0.925 0.007 268.5)',
13
+ 'secondary-foreground': 'oklch(0.115 0 0)',
14
+ muted: 'oklch(0.96 0.005 258.3)',
15
+ 'muted-foreground': 'oklch(0.513 0.007 264.5)',
16
+ accent: 'oklch(0.961 0 0)',
17
+ 'accent-foreground': 'oklch(0.115 0 0)',
18
+ destructive: 'oklch(0.54 0.219 26.3)',
19
+ 'destructive-foreground': 'oklch(1 0 0)',
20
+ border: 'oklch(0.857 0.006 264.5)',
21
+ input: 'oklch(0.642 0.007 255.5)',
22
+ ring: 'oklch(0.562 0.196 258.2)',
23
+ 'shadow-tint': 'oklch(0.115 0 0 / 12%)',
24
+ radius: '0.5rem',
25
+ 'density-unit': '0.25rem',
26
+ 'font-mono-stack': 'ui-monospace, "SFMono-Regular", "Menlo", "Consolas", monospace',
27
+ 'font-sans-stack':
28
+ 'system-ui, -apple-system, "Segoe UI", "Helvetica Neue", Helvetica, Arial, sans-serif',
29
+ 'font-heading-stack': 'var(--font-sans-stack)',
30
+ elevation: '0 1px 2px var(--shadow-tint), 0 1px 3px -1px var(--shadow-tint)',
31
+ 'forum-unread': 'oklch(0.562 0.196 258.2)',
32
+ 'forum-read': 'oklch(0.561 0.007 255.5)',
33
+ 'forum-locked': 'oklch(0.543 0.174 29.7)',
34
+ 'thread-pinned': 'oklch(0.547 0.122 65.3)',
35
+ 'thread-locked': 'oklch(0.543 0.174 29.7)',
36
+ 'thread-moved': 'oklch(0.561 0.007 255.5)',
37
+ 'thread-unapproved': 'oklch(0.519 0.106 90.3)',
38
+ 'thread-deleted': 'oklch(0.54 0.219 26.3)',
39
+ 'post-highlight': 'oklch(0.966 0.04 88.2)',
40
+ 'post-own': 'oklch(0.966 0.014 264.5)',
41
+ 'post-unapproved': 'oklch(0.965 0.036 89.4)',
42
+ 'moderation-pending': 'oklch(0.547 0.122 65.3)',
43
+ 'moderation-approved': 'oklch(0.522 0.14 146.9)',
44
+ 'moderation-rejected': 'oklch(0.543 0.174 29.7)',
45
+ 'group-admin': 'oklch(0.543 0.174 29.7)',
46
+ 'group-supermod': 'oklch(0.509 0.191 301.4)',
47
+ 'group-mod': 'oklch(0.562 0.196 258.2)',
48
+ 'group-banned': 'oklch(0.561 0.007 255.5)',
49
+ }
50
+
51
+ export const DARK_TOKENS: Record<TokenName, string> = {
52
+ background: 'oklch(0.213 0.003 247.9)',
53
+ foreground: 'oklch(0.925 0.007 268.5)',
54
+ surface: 'oklch(0.313 0.002 247.9)',
55
+ card: 'oklch(0.264 0.002 247.9)',
56
+ 'card-foreground': 'oklch(0.925 0.007 268.5)',
57
+ primary: 'oklch(0.663 0.181 256.2)',
58
+ 'primary-foreground': 'oklch(0.196 0.029 251.5)',
59
+ 'primary-hover': 'oklch(0.747 0.133 254.1)',
60
+ secondary: 'oklch(0.352 0.002 247.9)',
61
+ 'secondary-foreground': 'oklch(0.925 0.007 268.5)',
62
+ muted: 'oklch(0.292 0.005 248)',
63
+ 'muted-foreground': 'oklch(0.766 0.008 260.7)',
64
+ accent: 'oklch(0.352 0.002 247.9)',
65
+ 'accent-foreground': 'oklch(0.925 0.007 268.5)',
66
+ destructive: 'oklch(0.716 0.181 13.8)',
67
+ 'destructive-foreground': 'oklch(0.183 0.027 2.8)',
68
+ border: 'oklch(0.37 0.004 247.9)',
69
+ input: 'oklch(0.562 0.005 258.3)',
70
+ ring: 'oklch(0.663 0.181 256.2)',
71
+ 'shadow-tint': 'oklch(0 0 0 / 50%)',
72
+ radius: '0.5rem',
73
+ 'density-unit': '0.25rem',
74
+ 'font-mono-stack': 'ui-monospace, "SFMono-Regular", "Menlo", "Consolas", monospace',
75
+ 'font-sans-stack':
76
+ 'system-ui, -apple-system, "Segoe UI", "Helvetica Neue", Helvetica, Arial, sans-serif',
77
+ 'font-heading-stack': 'var(--font-sans-stack)',
78
+ elevation: '0 1px 2px var(--shadow-tint), 0 1px 3px -1px var(--shadow-tint)',
79
+ 'forum-unread': 'oklch(0.663 0.181 256.2)',
80
+ 'forum-read': 'oklch(0.642 0.007 255.5)',
81
+ 'forum-locked': 'oklch(0.753 0.155 41.6)',
82
+ 'thread-pinned': 'oklch(0.821 0.16 82.5)',
83
+ 'thread-locked': 'oklch(0.753 0.155 41.6)',
84
+ 'thread-moved': 'oklch(0.642 0.007 255.5)',
85
+ 'thread-unapproved': 'oklch(0.84 0.154 87.4)',
86
+ 'thread-deleted': 'oklch(0.716 0.181 13.8)',
87
+ 'post-highlight': 'oklch(0.283 0.039 259.4)',
88
+ 'post-own': 'oklch(0.26 0.037 261.8)',
89
+ 'post-unapproved': 'oklch(0.299 0.03 90.8)',
90
+ 'moderation-pending': 'oklch(0.821 0.16 82.5)',
91
+ 'moderation-approved': 'oklch(0.71 0.168 148.3)',
92
+ 'moderation-rejected': 'oklch(0.753 0.155 41.6)',
93
+ 'group-admin': 'oklch(0.716 0.181 13.8)',
94
+ 'group-supermod': 'oklch(0.764 0.145 301.5)',
95
+ 'group-mod': 'oklch(0.72 0.147 257.7)',
96
+ 'group-banned': 'oklch(0.642 0.007 255.5)',
97
+ }
98
+
99
+ export const BROWSER_THEME_COLOR = {
100
+ light: '#f0f2f5',
101
+ dark: '#18191a',
102
+ } as const