From 07fea387641e8ea6eb695c8184ea113f01566aee Mon Sep 17 00:00:00 2001
From: Jordan Harrison
Date: Sun, 20 Sep 2026 17:52:13 +0100
Subject: [PATCH] feat(brand): apply the brand guidelines to meith.dev and the
meith theme
Site: token-driven buttons, square header controls, mono uppercase nav
with a current-item rule, a search bar, a three-way scheme toggle, an
accent Get started, one site-wide arrow convention, demo and community
links, and British copy without ampersands.
Theme: fills every slot in the registry, semantic marks on the greyscale
ramp, a neutral accent wash, 60% link underlines, 160-180ms motion,
arrows that stay legible on filled actions, a 60rem post measure and a
full-width subforum list.
Docs: a brand guidelines page under contributing, registered and indexed.
Co-Authored-By: Claude Fable 5.1
---
apps/web/app/docs/page.tsx | 2 +-
apps/web/app/page.tsx | 16 +-
apps/web/content/docs.manifest.json | 10 +
apps/web/e2e/marketing.spec.ts | 26 ++-
apps/web/src/components/docs-search.tsx | 2 +-
apps/web/src/components/forum-link.tsx | 12 +-
apps/web/src/components/nav-link.tsx | 23 ++
apps/web/src/components/site-bands.tsx | 4 +-
apps/web/src/components/site-footer.tsx | 7 +
apps/web/src/components/site-header.tsx | 18 +-
apps/web/src/components/theme-toggle.tsx | 92 ++++----
apps/web/src/content/segments.ts | 10 +-
apps/web/src/content/site.ts | 1 +
apps/web/src/og/card.tsx | 2 +-
apps/web/src/styles/chrome.css | 138 ++++++++----
apps/web/src/styles/globals.css | 32 ++-
apps/web/src/styles/home.css | 30 +--
apps/web/src/styles/marketing.css | 2 +-
docs/README.md | 1 +
docs/contributing/brand.md | 175 ++++++++++++++++
docs/extensions/theme-design.md | 15 +-
themes/meith/src/copy.ts | 75 ++++++-
themes/meith/src/messages/en.json | 37 ++++
themes/meith/src/shared.tsx | 29 ++-
themes/meith/src/slots/auth-page.tsx | 46 ++++
themes/meith/src/slots/discovery-view.tsx | 116 +++++++++++
themes/meith/src/slots/editor-toolbar.tsx | 89 ++++++++
themes/meith/src/slots/error-notice.tsx | 45 ++++
themes/meith/src/slots/footer.tsx | 2 +-
themes/meith/src/slots/forum-jump.tsx | 41 ++++
themes/meith/src/slots/header.tsx | 10 +-
themes/meith/src/slots/notice.tsx | 8 +-
themes/meith/src/slots/panel-nav.tsx | 107 ++++++++++
themes/meith/src/slots/panel-page.tsx | 85 ++++++++
themes/meith/src/slots/panel-shell.tsx | 34 +++
themes/meith/src/slots/post-form.tsx | 41 ++++
themes/meith/src/slots/quick-reply.tsx | 9 +
themes/meith/src/slots/redirect-notice.tsx | 29 +++
themes/meith/src/slots/search-form.tsx | 145 +++++++++++++
themes/meith/src/slots/search-results.tsx | 231 +++++++++++++++++++++
themes/meith/src/slots/subforum-list.tsx | 6 +-
themes/meith/src/slots/user-panel.tsx | 6 +-
themes/meith/src/theme.test.ts | 40 ++--
themes/meith/src/theme.ts | 47 +++++
themes/meith/src/tokens.ts | 30 ++-
45 files changed, 1737 insertions(+), 189 deletions(-)
create mode 100644 apps/web/src/components/nav-link.tsx
create mode 100644 docs/contributing/brand.md
create mode 100644 themes/meith/src/slots/auth-page.tsx
create mode 100644 themes/meith/src/slots/discovery-view.tsx
create mode 100644 themes/meith/src/slots/editor-toolbar.tsx
create mode 100644 themes/meith/src/slots/error-notice.tsx
create mode 100644 themes/meith/src/slots/forum-jump.tsx
create mode 100644 themes/meith/src/slots/panel-nav.tsx
create mode 100644 themes/meith/src/slots/panel-page.tsx
create mode 100644 themes/meith/src/slots/panel-shell.tsx
create mode 100644 themes/meith/src/slots/post-form.tsx
create mode 100644 themes/meith/src/slots/quick-reply.tsx
create mode 100644 themes/meith/src/slots/redirect-notice.tsx
create mode 100644 themes/meith/src/slots/search-form.tsx
create mode 100644 themes/meith/src/slots/search-results.tsx
diff --git a/apps/web/app/docs/page.tsx b/apps/web/app/docs/page.tsx
index 52823fe2b..545793b5d 100644
--- a/apps/web/app/docs/page.tsx
+++ b/apps/web/app/docs/page.tsx
@@ -30,7 +30,7 @@ export default function DocsIndexPage() {
Let’s get you started.
From your first board to your next big idea.
-
+
Start the quickstart ↗
diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx
index f6cf93bd1..4d0c7cef2 100644
--- a/apps/web/app/page.tsx
+++ b/apps/web/app/page.tsx
@@ -19,7 +19,7 @@ const communities = [
body: 'Keep the conversations that bring your people together.',
},
{
- name: 'Clubs & associations',
+ name: 'Clubs and associations',
slug: 'clubs-and-associations',
body: 'A shared space for members, plans and club life.',
},
@@ -95,14 +95,19 @@ export default async function LandingPage() {
On your domain. On your terms.
-
+
Start your forum ↗
+
+ Try the demo ↗
+
- See Meith in use ↗
+ Join the community ↗
- Free & open source. Self-hosted. Yours to keep.
+
+ Free and open source. Self-hosted. Yours to keep. The demo runs on sample data.
+
@@ -265,6 +270,9 @@ export default async function LandingPage() {
Create your community ↗
+
+ Try the demo ↗
+
Read the docs ↗
diff --git a/apps/web/content/docs.manifest.json b/apps/web/content/docs.manifest.json
index 2908301fe..3401f1795 100644
--- a/apps/web/content/docs.manifest.json
+++ b/apps/web/content/docs.manifest.json
@@ -851,6 +851,16 @@
"primary": false,
"group": "Development workflow"
},
+ {
+ "slug": "brand",
+ "file": "contributing/brand.md",
+ "section": "contributing",
+ "title": "Brand guidelines",
+ "blurb": "How Meith looks, sounds and spends its one colour.",
+ "generated": false,
+ "primary": false,
+ "group": "Development workflow"
+ },
{
"slug": "repository-checks",
"file": "contributing/repository-checks.md",
diff --git a/apps/web/e2e/marketing.spec.ts b/apps/web/e2e/marketing.spec.ts
index 101aa5062..079709fe5 100644
--- a/apps/web/e2e/marketing.spec.ts
+++ b/apps/web/e2e/marketing.spec.ts
@@ -40,19 +40,22 @@ test('colour scheme persists across navigation and resets to the system preferen
page,
}) => {
await page.goto('/')
- await page.getByRole('button', { name: 'Colour scheme: system. Switch to light' }).click()
+ const scheme = page.getByRole('group', { name: 'Colour scheme' })
+ await scheme.getByRole('button', { name: 'Light' }).click()
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light')
- await page.getByRole('button', { name: 'Colour scheme: light. Switch to dark' }).click()
+ await scheme.getByRole('button', { name: 'Dark' }).click()
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark')
await page.goto('/about')
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark')
- await page.getByRole('button', { name: 'Colour scheme: dark. Switch to system' }).click()
+ await expect(scheme.getByRole('button', { name: 'Dark' })).toHaveAttribute('aria-pressed', 'true')
+ await scheme.getByRole('button', { name: 'System' }).click()
await expect(page.locator('html')).not.toHaveAttribute('data-theme')
await page.reload()
- await expect(
- page.getByRole('button', { name: 'Colour scheme: system. Switch to light' }),
- ).toBeVisible()
+ await expect(scheme.getByRole('button', { name: 'System' })).toHaveAttribute(
+ 'aria-pressed',
+ 'true',
+ )
})
test('mobile navigation closes on Escape, outside interaction, and link selection', async ({
@@ -70,7 +73,10 @@ test('mobile navigation closes on Escape, outside interaction, and link selectio
await expect(menu).toBeFocused()
await menu.click()
- await page.getByRole('button', { name: /^Colour scheme:/ }).click()
+ await page
+ .getByRole('group', { name: 'Colour scheme' })
+ .getByRole('button', { name: 'System' })
+ .click()
await expect(navigation).toBeHidden()
await menu.click()
@@ -92,6 +98,10 @@ test('site navigation points to real destinations and exposes customisation guid
] as const) {
await expect(nav.getByRole('link', { name, exact: true })).toHaveAttribute('href', href)
}
+ await expect(nav.getByRole('link', { name: /^Demo/ })).toHaveAttribute(
+ 'href',
+ /^https:\/\/demo\./,
+ )
await expect(nav.getByRole('link', { name: /^Community/ })).toHaveAttribute(
'href',
/^https:\/\/forum\./,
@@ -110,7 +120,7 @@ test('site navigation points to real destinations and exposes customisation guid
{ name: /^Developers/, slug: 'developers' },
{ name: /^Open-source projects/, slug: 'open-source' },
{ name: /^Communities/, slug: 'communities' },
- { name: /^Clubs & associations/, slug: 'clubs-and-associations' },
+ { name: /^Clubs and associations/, slug: 'clubs-and-associations' },
]
for (const audience of audiences) {
diff --git a/apps/web/src/components/docs-search.tsx b/apps/web/src/components/docs-search.tsx
index 98c50cee8..40cb592f1 100644
--- a/apps/web/src/components/docs-search.tsx
+++ b/apps/web/src/components/docs-search.tsx
@@ -105,7 +105,7 @@ export function DocsSearch() {
className="inline-flex items-center gap-2 rounded-[var(--radius-control)] border border-border px-2.5 py-1.5 text-micro text-fg-subtle transition-colors hover:border-border-strong hover:text-fg"
>
⌕
- Search
+ Search the docs
⌘K
diff --git a/apps/web/src/components/forum-link.tsx b/apps/web/src/components/forum-link.tsx
index 52893ebe8..2aa63e29a 100644
--- a/apps/web/src/components/forum-link.tsx
+++ b/apps/web/src/components/forum-link.tsx
@@ -2,9 +2,17 @@ import type { ReactNode } from 'react'
import { site } from '../content/site'
-export function ForumLink({ children, className }: { children: ReactNode; className?: string }) {
+export function ForumLink({
+ children,
+ className,
+ href = site.forum,
+}: {
+ children: ReactNode
+ className?: string
+ href?: string
+}) {
return (
-
+
{children}
(opens in a new tab)
diff --git a/apps/web/src/components/nav-link.tsx b/apps/web/src/components/nav-link.tsx
new file mode 100644
index 000000000..92e1c2d34
--- /dev/null
+++ b/apps/web/src/components/nav-link.tsx
@@ -0,0 +1,23 @@
+'use client'
+
+import Link from 'next/link'
+import { usePathname } from 'next/navigation'
+import type { ReactNode } from 'react'
+
+export function NavLink({
+ href,
+ children,
+}: {
+ readonly href: string
+ readonly children: ReactNode
+}) {
+ const pathname = usePathname()
+ const current =
+ href === '/' ? pathname === '/' : pathname === href || pathname.startsWith(`${href}/`)
+
+ return (
+
+ {children}
+
+ )
+}
diff --git a/apps/web/src/components/site-bands.tsx b/apps/web/src/components/site-bands.tsx
index 7911cd982..6ad51c776 100644
--- a/apps/web/src/components/site-bands.tsx
+++ b/apps/web/src/components/site-bands.tsx
@@ -24,7 +24,7 @@ export function ClosingBand({
- Get started →
+ Get started ↗
{docsHref === undefined ? (
@@ -32,7 +32,7 @@ export function ClosingBand({
) : (
- Explore the docs →
+ Explore the docs ↗
)}
diff --git a/apps/web/src/components/site-footer.tsx b/apps/web/src/components/site-footer.tsx
index 30f3d379a..c5bc1ab8c 100644
--- a/apps/web/src/components/site-footer.tsx
+++ b/apps/web/src/components/site-footer.tsx
@@ -4,6 +4,7 @@ import { about } from '../content/about'
import { audienceIndexHref } from '../content/segments'
import { licence, licenceHref, site } from '../content/site'
import { version } from '../content/version'
+import { ForumLink } from './forum-link'
export function SiteFooter() {
return (
@@ -18,6 +19,12 @@ export function SiteFooter() {
Who it’s for
Extensions
Docs
+
+ Demo ↗
+
+
+ Community ↗
+
GitHub ↗
diff --git a/apps/web/src/components/site-header.tsx b/apps/web/src/components/site-header.tsx
index b737a54c3..812ce0887 100644
--- a/apps/web/src/components/site-header.tsx
+++ b/apps/web/src/components/site-header.tsx
@@ -2,11 +2,13 @@ import Link from 'next/link'
import { about } from '../content/about'
import { audienceIndexHref } from '../content/segments'
+import { site } from '../content/site'
import { quickstartHref } from '../docs/registry'
import { DocsSearch } from './docs-search'
import { ForumLink } from './forum-link'
import { Logomark } from './logomark'
import { MobileMenu } from './mobile-menu'
+import { NavLink } from './nav-link'
import { ThemeToggle } from './theme-toggle'
export function SiteHeader() {
@@ -21,10 +23,13 @@ export function SiteHeader() {
- Home
- About
- Who it’s for
- Docs
+ Home
+ About
+ Who it’s for
+ Docs
+
+ Demo ↗
+
Community ↗
@@ -61,6 +66,11 @@ export function SiteHeader() {
Docs ↗
+
+
+ Demo ↗
+
+
Community ↗
diff --git a/apps/web/src/components/theme-toggle.tsx b/apps/web/src/components/theme-toggle.tsx
index f0ff22cf2..1695e7dce 100644
--- a/apps/web/src/components/theme-toggle.tsx
+++ b/apps/web/src/components/theme-toggle.tsx
@@ -6,7 +6,11 @@ import { THEME_STORAGE_KEY } from '../theme-storage'
type Choice = 'light' | 'dark' | 'system'
-const NEXT_CHOICE = { system: 'light', light: 'dark', dark: 'system' } as const
+const CHOICES: readonly { readonly value: Choice; readonly label: string }[] = [
+ { value: 'light', label: 'Light' },
+ { value: 'dark', label: 'Dark' },
+ { value: 'system', label: 'System' },
+]
function apply(choice: Choice) {
const root = document.documentElement
@@ -19,6 +23,36 @@ function apply(choice: Choice) {
}
}
+function Glyph({ choice }: { choice: Choice }) {
+ return (
+
+ {choice === 'light' ? (
+ <>
+
+
+ >
+ ) : choice === 'dark' ? (
+
+ ) : (
+ <>
+
+
+ >
+ )}
+
+ )
+}
+
export function ThemeToggle() {
const [choice, setChoice] = useState('system')
@@ -27,45 +61,23 @@ export function ThemeToggle() {
if (stored === 'light' || stored === 'dark') setChoice(stored)
}, [])
- const next = NEXT_CHOICE[choice]
- const label = `Colour scheme: ${choice}. Switch to ${next}`
-
return (
- {
- setChoice(next)
- apply(next)
- }}
- >
-
- {choice === 'light' ? (
- <>
-
-
- >
- ) : choice === 'dark' ? (
-
- ) : (
- <>
-
-
- >
- )}
-
-
+
+ {CHOICES.map((option) => (
+ {
+ setChoice(option.value)
+ apply(option.value)
+ }}
+ >
+
+
+ ))}
+
)
}
diff --git a/apps/web/src/content/segments.ts b/apps/web/src/content/segments.ts
index 9771672bf..a8f3dfded 100644
--- a/apps/web/src/content/segments.ts
+++ b/apps/web/src/content/segments.ts
@@ -229,17 +229,17 @@ export const segments: readonly [Segment, ...Segment[]] = [
slug: 'clubs-and-associations',
theme: 'clubhouse',
boardCaption: "A club's board, in the Clubhouse theme.",
- name: 'Clubs & associations',
- lowerName: 'clubs & associations',
+ name: 'Clubs and associations',
+ lowerName: 'clubs and associations',
card: {
heading: 'A forum that survives the committee.',
line:
'Keep discussions, announcements, records and institutional knowledge in a system ' +
'that can be handed from one group of organisers to the next.',
- cta: 'Meith for Clubs & Associations',
+ cta: 'Meith for clubs and associations',
},
meta: {
- title: 'Meith for Clubs & Associations',
+ title: 'Meith for clubs and associations',
description:
'Self-hosted forum software for clubs, associations and member-led organisations ' +
'that need continuity, ownership and searchable history.',
@@ -307,7 +307,7 @@ export const segments: readonly [Segment, ...Segment[]] = [
slug: 'legacy-forums',
theme: 'default',
boardCaption: 'A freshly imported board, in the default theme.',
- name: 'MyBB & phpBB boards',
+ name: 'MyBB and phpBB boards',
lowerName: 'MyBB and phpBB boards',
secondary: true,
card: {
diff --git a/apps/web/src/content/site.ts b/apps/web/src/content/site.ts
index 5fee5b7ac..259e16e33 100644
--- a/apps/web/src/content/site.ts
+++ b/apps/web/src/content/site.ts
@@ -5,6 +5,7 @@ export const site = {
domain: 'meith.dev',
url: 'https://www.meith.dev',
forum: 'https://forum.meith.dev',
+ demo: 'https://demo.meith.dev',
repository: 'https://github.com/meith-dev/meith',
tagline: 'Open-source, self-hosted forum software for conversations worth keeping.',
description:
diff --git a/apps/web/src/og/card.tsx b/apps/web/src/og/card.tsx
index cf8d81adb..bf5c22929 100644
--- a/apps/web/src/og/card.tsx
+++ b/apps/web/src/og/card.tsx
@@ -142,7 +142,7 @@ export function OgCard({
color: card.fgSubtle,
}}
>
- Free & open source · Self-hosted · Yours to keep
+ Free and open source · Self-hosted · Yours to keep
Built in the open
diff --git a/apps/web/src/styles/chrome.css b/apps/web/src/styles/chrome.css
index 869e3ecbe..50dd19801 100644
--- a/apps/web/src/styles/chrome.css
+++ b/apps/web/src/styles/chrome.css
@@ -9,8 +9,8 @@
.site-header-inner {
display: flex;
align-items: center;
- justify-content: space-between;
- gap: 2rem;
+ gap: 2.5rem;
+ width: min(80rem, 100% - 2.5rem);
min-height: 4.75rem;
}
@@ -34,20 +34,22 @@
.site-navigation {
display: flex;
align-items: center;
- justify-content: center;
gap: 2rem;
- margin-left: auto;
}
.site-navigation > a,
.site-footer-navigation > a {
padding-block: 0.6rem;
color: var(--fg-muted);
- font-size: 0.8125rem;
+ font-family: var(--mono);
+ font-size: 0.6875rem;
font-weight: 500;
- letter-spacing: 0.01em;
+ letter-spacing: 0.075em;
+ text-transform: uppercase;
white-space: nowrap;
- transition: color 160ms ease;
+ transition:
+ color 160ms ease,
+ box-shadow 160ms ease;
}
.site-navigation > a:hover,
@@ -60,24 +62,45 @@
display: flex;
align-items: center;
gap: 0.6rem;
+ margin-left: auto;
}
.site-header-search > button {
+ width: 15rem;
height: 2.5rem;
- border: 0;
- border-radius: 2px;
+ justify-content: space-between;
+ gap: 0.75rem;
+ padding-inline: 0.8rem 0.5rem;
+ border: 1px solid var(--border);
+ border-radius: 0;
+ background: var(--surface);
+ color: var(--fg-subtle);
+ font-size: 0.8125rem;
+ text-align: left;
+ transition:
+ border-color 160ms ease,
+ color 160ms ease;
}
-.site-header-search > button:hover {
- background: var(--surface);
+.site-header-search > button:hover,
+.site-header-search > button:focus-visible {
+ border-color: var(--border-strong);
+ color: var(--fg-muted);
}
-.site-header-search > button > span:first-child,
-.site-header-search kbd {
+.site-header-search > button > span:first-child {
display: none;
}
-.site-theme-toggle,
+.site-header-search > button > span:nth-child(2) {
+ flex: 1;
+}
+
+.site-header-search kbd {
+ border-radius: 0;
+ background: var(--canvas);
+}
+
.site-menu-button {
display: grid;
width: 2.75rem;
@@ -85,7 +108,7 @@
flex-shrink: 0;
place-items: center;
border: 1px solid transparent;
- border-radius: 2px;
+ border-radius: 0;
background: transparent;
color: var(--fg-muted);
cursor: pointer;
@@ -95,7 +118,6 @@
border-color 160ms ease;
}
-.site-theme-toggle:hover,
.site-menu-button:hover {
border-color: var(--border);
background: var(--surface);
@@ -106,27 +128,25 @@
display: inline-flex;
min-height: 2.75rem;
align-items: center;
- justify-content: center;
- gap: 1rem;
+ justify-content: space-between;
+ gap: 1.25rem;
padding: 0.65rem 1.2rem;
- border: 1px solid var(--fg);
- border-radius: 2px;
- background: var(--fg);
- color: var(--canvas);
- font-size: 0.75rem;
+ border: 1px solid var(--accent);
+ border-radius: 0;
+ background: var(--accent);
+ color: var(--accent-contrast);
+ font-size: 0.8125rem;
font-weight: 500;
line-height: 1.4;
white-space: nowrap;
transition:
- background-color 180ms ease,
- border-color 180ms ease,
- color 180ms ease;
+ background-color 160ms ease,
+ border-color 160ms ease;
}
.site-start-link:hover {
- border-color: var(--accent);
- background: var(--accent);
- color: var(--accent-contrast);
+ border-color: var(--accent-hover);
+ background: var(--accent-hover);
}
.site-start-link > span {
@@ -166,6 +186,40 @@
transform: translateY(-3.55px) rotate(-45deg);
}
+.site-theme-toggle {
+ display: inline-flex;
+ flex-shrink: 0;
+ border: 1px solid var(--border);
+}
+
+.site-theme-toggle > button {
+ display: grid;
+ width: 2.25rem;
+ height: 2.375rem;
+ place-items: center;
+ border: 0;
+ background: transparent;
+ color: var(--fg-subtle);
+ cursor: pointer;
+ transition:
+ color 160ms ease,
+ background-color 160ms ease;
+}
+
+.site-theme-toggle > button + button {
+ border-left: 1px solid var(--border);
+}
+
+.site-theme-toggle > button:hover {
+ color: var(--fg);
+}
+
+.site-theme-toggle > button[aria-pressed="true"] {
+ background: var(--surface);
+ color: var(--fg);
+ box-shadow: inset 0 -2px 0 var(--accent);
+}
+
.site-menu-panel {
position: absolute;
inset: 100% 0 auto;
@@ -196,10 +250,6 @@
color: var(--fg);
}
-.site-mobile-links a > span[aria-hidden] {
- color: var(--fg-subtle);
-}
-
.site-mobile-links a:hover {
color: var(--accent);
}
@@ -240,8 +290,15 @@
max-width: 27rem;
}
-.site-footer-navigation > a > span {
- margin-left: 0.15rem;
+.site-navigation > a > span[aria-hidden],
+.site-footer-navigation > a > span[aria-hidden] {
+ margin-left: 0.35rem;
+ font-size: 1.25em;
+}
+
+.site-navigation > a[aria-current="page"] {
+ color: var(--fg);
+ box-shadow: inset 0 -1px 0 var(--accent);
}
.site-footer-bottom {
@@ -267,6 +324,12 @@
}
@media (max-width: 1279px) {
+ .site-header-search > button {
+ width: 11rem;
+ }
+}
+
+@media (max-width: 1199px) {
.site-header-search {
display: none;
}
@@ -319,6 +382,11 @@
gap: 0.25rem 1.35rem;
}
+ .site-navigation > a[aria-current="page"] {
+ color: var(--fg);
+ box-shadow: inset 0 -1px 0 var(--accent);
+ }
+
.site-footer-bottom {
justify-content: flex-start;
}
diff --git a/apps/web/src/styles/globals.css b/apps/web/src/styles/globals.css
index 35fdcfc5c..ddd0f11a5 100644
--- a/apps/web/src/styles/globals.css
+++ b/apps/web/src/styles/globals.css
@@ -402,10 +402,10 @@ body {
background: var(--raised);
box-shadow: var(--lift);
transition:
- border-color 0.15s ease,
- color 0.15s ease,
- background-color 0.15s ease,
- box-shadow 0.15s ease;
+ border-color 160ms ease,
+ color 160ms ease,
+ background-color 160ms ease,
+ box-shadow 160ms ease;
}
.btn:hover {
@@ -422,7 +422,7 @@ body {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-contrast);
- font-weight: 560;
+ font-weight: 500;
box-shadow:
0 1px 2px color-mix(in srgb, var(--accent) 30%, transparent),
0 12px 28px -14px color-mix(in srgb, var(--accent) 75%, transparent);
@@ -443,13 +443,25 @@ body {
background: var(--surface);
}
-/* The arrow moves, the button does not. Motion at the size of the affordance. */
-.btn .btn-arrow {
- transition: transform 0.15s ease;
+a > span[aria-hidden] {
+ display: inline-block;
+ color: var(--arrow, var(--fg-subtle));
+ font-family: var(--sans);
+ line-height: 1;
+ transition:
+ color 160ms ease,
+ transform 160ms ease;
+}
+
+a:hover > span[aria-hidden] {
+ color: var(--arrow, var(--accent));
+ transform: translate(2px, -2px);
}
-.btn:hover .btn-arrow {
- transform: translateX(0.2rem);
+.btn-primary,
+.edition-button,
+.site-start-link {
+ --arrow: currentColor;
}
/*
diff --git a/apps/web/src/styles/home.css b/apps/web/src/styles/home.css
index 0fb93dc78..2ec59b25f 100644
--- a/apps/web/src/styles/home.css
+++ b/apps/web/src/styles/home.css
@@ -83,8 +83,8 @@
gap: 2rem;
min-height: 3.2rem;
padding: 0.85rem 1.25rem;
- background: #34d399;
- color: #04160f;
+ background: var(--accent);
+ color: var(--accent-contrast);
border: 1px solid transparent;
font-size: 0.85rem;
font-weight: 500;
@@ -94,24 +94,11 @@
}
.edition-button:hover {
- background: #5ee7b7;
+ background: var(--accent-hover);
}
.edition-button span {
font-size: 1.2rem;
- transition: transform 160ms;
-}
-.edition-button:hover span {
- transform: translate(2px, -2px);
-}
-.edition-button-paper {
- color: var(--accent-contrast);
- background: var(--accent);
-}
-.edition-button-paper:hover {
- background: var(--accent-hover);
- border-color: var(--accent-hover);
}
-
.edition-hero-source {
display: inline-flex;
align-items: center;
@@ -200,14 +187,6 @@
line-height: 1.85;
}
-.edition-audience-grid span {
- color: var(--accent);
- transition: transform 180ms;
-}
-.edition-audience-grid > a:hover span {
- transform: translate(2px, -2px);
-}
-
.edition-text-link {
display: inline-flex;
align-items: center;
@@ -379,9 +358,6 @@
.edition-customise-copy nav a:hover {
color: var(--accent);
}
-.edition-customise-copy nav a span {
- color: var(--accent);
-}
.edition-start {
border-top: 1px solid var(--border);
diff --git a/apps/web/src/styles/marketing.css b/apps/web/src/styles/marketing.css
index bf1f34765..47a3dd299 100644
--- a/apps/web/src/styles/marketing.css
+++ b/apps/web/src/styles/marketing.css
@@ -561,7 +561,7 @@
height: 100%;
object-fit: cover;
object-position: top;
- transition: transform 250ms ease;
+ transition: transform 180ms ease;
}
.marketplace-preview-action {
diff --git a/docs/README.md b/docs/README.md
index ffee77e1d..12b60ee42 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -174,6 +174,7 @@ Develop, test, document and release Meith.
- [Run and debug tests](./contributing/testing.md) — Choose the test layer for the behavior you are changing.
- [Develop accessible interface components](./contributing/ui-conventions.md) — Use the shared design system for forms, panels and destructive actions.
- [Maintain the documentation](./contributing/documentation.md) — Documentation source lives in docs/.
+- [Brand guidelines](./contributing/brand.md) — How Meith looks, sounds and spends its one colour.
- [Repository rules and checks](./contributing/repository-checks.md) — Follow the repository contract and run the checks that enforce it.
### Implementation reference
diff --git a/docs/contributing/brand.md b/docs/contributing/brand.md
new file mode 100644
index 000000000..647fe00f3
--- /dev/null
+++ b/docs/contributing/brand.md
@@ -0,0 +1,175 @@
+# Brand guidelines
+
+How Meith looks, sounds and spends its one colour, for anyone making a page, a post, a slide or a pull request that carries the name. The stylesheet is the brand: when this page and `apps/web/src/styles` disagree, the repository wins and this page is corrected.
+
+## The idea
+
+Meith takes its name from meitheal, the Irish tradition of neighbours coming together for shared work because the work benefits the group. The brand carries that idea quietly. It is never a craft or landscape identity: Meith is server software somebody is deciding whether to run, and it should look like it. Four ideas everything is checked against:
+
+- **Owned.** Your domain, your server, your database. The brand never implies a platform in the middle.
+- **Open.** MIT licensed and built in the open. Show the repository, the command, the real numbers.
+- **Plain-spoken.** Say what the software does in the words an operator would use. No hype, no mascots.
+- **Built to last.** Forums outlive chat. Restraint in colour, type and motion is how that reads on a page.
+
+## Logo
+
+The mark is a panel with two threads in it and a speech tail: a forum at icon size. It always takes the accent of the scheme it sits on, and the threads take the accent-contrast value. The wordmark is the name in lowercase, set in the sans at weight 450 with tracking −0.025em.
+
+- **Proportion.** Mark height 2rem beside type at 2.125rem, with a 0.6rem gap. Scale the lockup as one unit.
+- **Clear space.** Keep half the mark's height clear on every side.
+- **Minimum size.** 16px for the mark alone, 20px mark height for the lockup. Below that, use the mark without the word.
+- **Static assets.** Favicons and avatars cannot read tokens, so they use `#0b8f66`, which holds on light and dark. It is for icons only, never text.
+- **Do not** recolour the mark, decorate it with an outline, shadow, glow or container, set it on an accent fill, or retype the word in the serif or the mono. The social card is the one exception to the wordmark: it sets “Meith” at weight 600, as `apps/web/src/og/card.tsx` does.
+
+The site draws the mark in `apps/web/src/components/logomark.tsx` and `apps/web/public/icon.svg`; the theme draws the same mark in `themes/meith/src/shared.tsx`.
+
+## Colour
+
+The ground is greyscale and the accent is a single green: a deep pine against white in light, emerald against near-black in dark. Dark is a first-class scheme, not an inversion. No single green clears 4.5:1 on both grounds, so each scheme carries its own step of the ramp.
+
+| Token | Light | Dark | Theme token |
+|---|---|---|---|
+| `--canvas` | `#fcfcfd` | `#08090c` | `background`, `card` |
+| `--surface` | `#f4f5f8` | `#0d0f13` | `surface`, `secondary`, `muted`, `accent` |
+| `--raised` | `#ffffff` | `#12141a` | — |
+| `--fg` | `#0b0d12` | `#f2f3f6` | `foreground` |
+| `--fg-muted` | `#4e5563` | `#a3a9b6` | `muted-foreground` |
+| `--fg-subtle` | `#7c838f` | `#6e7480` | — |
+| `--accent` | `#047857` | `#34d399` | `primary`, `ring` |
+| `--accent-hover` | `#036045` | `#5ee7b7` | `primary-hover` |
+| `--accent-contrast` | `#ffffff` | `#04160f` | `primary-foreground` |
+| `--border` | `#e4e6eb` | `#202430` | `border` |
+| `--border-strong` | `#d0d4dc` | `#2f3442` | `input` |
+
+The accent marks four things and nothing else:
+
+1. **The one primary action.** One filled button per screen. Its label takes `--accent-contrast`.
+2. **The current item.** The active row in a nav or list, and the hover state of a link or top rule.
+3. **A link's underline.** Text links stay in the text colour; the underline carries 60% accent until hover.
+4. **The emphasised word.** The serif italic word that closes a headline, and a measured bar in a chart.
+
+Washes are mixed from the tokens, never picked: the hero glow is accent at 18% (22% in dark), the hero grid line is `--fg` at 6% (7% in dark). Shadows are neutral, mixed from `--fg`; the glow under the primary button is the only shadow that carries a hue. There are no new colours. Status, charts and illustrations use the greyscale ramp and the accent, so in the theme the pinned, locked, moderation and group marks sit on the ramp and the label carries the meaning.
+
+Measured pairs, against WCAG 2.1:
+
+| Pair | Light | Dark | Use for |
+|---|---|---|---|
+| `--fg` on `--canvas` | 18.96:1 | 17.94:1 | All text |
+| `--fg-muted` on `--canvas` | 7.31:1 | 8.45:1 | Body copy, labels |
+| `--accent` on `--canvas` | 5.35:1 | 10.36:1 | Links, emphasis, small text |
+| `--accent-contrast` on `--accent` | 5.48:1 | 9.69:1 | Primary button label |
+| `--fg-subtle` on `--canvas` | 3.72:1 | 4.24:1 | Large or non-essential text only |
+
+`--fg-subtle` fails for small text in both schemes, so it never carries meaning on its own.
+
+## Typography
+
+Three faces, no web font. The site carries no third-party request, so every face is a system stack with Inter named first.
+
+- **Sans.** Everything by default: headlines at 400, sub-heads and buttons at 500, body at 400 in `--fg-muted`. `"Inter var", Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif`.
+- **Serif italic.** Emphasis only: one word or phrase per headline, always in the accent. Never upright, never body copy. `"Iowan Old Style", "Palatino Linotype", "Book Antiqua", Georgia, serif`.
+- **Mono.** Section labels in uppercase, commands, tokens and version numbers. `ui-monospace, "JetBrains Mono", "SF Mono", Menlo, Consolas, monospace`.
+
+The marketing scale:
+
+| Step | Size | Weight · tracking · leading |
+|---|---|---|
+| Hero h1 | `clamp(3.5rem, 6.2vw, 6.5rem)` | 400 · −0.025em · 1.18 |
+| Section h2 | `clamp(2.1rem, 3.5vw, 3.35rem)` | 400 · −0.025em · 1.25 |
+| Item h3 | 1.02rem | 500 · 1.55 |
+| Body | 0.85–0.95rem | 400 · 1.8–1.9, `--fg-muted`, measure ≤ 34rem |
+| Label | mono 0.6rem | 0.075em, uppercase, `--fg-muted` |
+
+The theme keeps the weights, tracking and leading and scales the headline steps down for an application. Its labels run at 0.6875rem, because a board's labels (pinned, locked, moderation) carry meaning and must stay legible, and a post's body runs to 60rem so code blocks, tables and long paragraphs have room.
+
+## Interface
+
+- **Square on the page.** Marketing buttons and panels have no radius. The 0.5rem control and 0.75rem card radii belong to docs chrome and app UI.
+- **The arrow moves.** Every outbound or forward action ends in ↗, and nothing else does. On hover the arrow shifts 2px up and right; the control itself stays still.
+- **Rules before boxes.** Separate items with a 1px top rule in `--border-strong`. Reach for a filled panel only for code or a change of scheme.
+- **Left-aligned, always.** Headlines, copy and actions share a left edge. Intros split 1.05–1.15fr to 1fr, headline left, copy right.
+- **Works without script.** Most reading and posting works with JavaScript off. Brand moments never depend on animation or client code.
+- **Visible focus.** A 2px accent outline, offset 2px, on everything focusable. Never remove it, never restyle it per component.
+- **Quiet motion.** Transitions run 160–180ms and collapse under `prefers-reduced-motion`. Nothing loops, nothing autoplays.
+- **Never colour alone.** Current items also change weight or rule; links keep an underline; charts label their bars.
+
+Layout measurements: the shell is `min(72rem, 100% − 2.5rem)`, the wide shell for the header and docs is `min(80rem, 100% − 2.5rem)`, the header runs wordmark and navigation from the left with search, scheme and the action from the right, bands pad 4.5rem block, grids gap 2.25rem and two-column intros 4rem, and the top beam is a 1px accent gradient, the first colour on the page.
+
+## Imagery
+
+Meith has no illustration style, no stock photography and no mascot. The only imagery is the software itself: real screenshots of a real board, captured in both schemes, with sample data that reads like a community.
+
+- Every shot exists as `-light.png` and `-dark.png` in `apps/web/public/shots`, and the page swaps them with the scheme.
+- Desktop 2560 × 1640, phone 780 × 1560, both at 2×. Keep the browser chrome out; the frame is the 1px border.
+- Use the fixture board. Real-sounding thread titles, no celebrity names, no real member data.
+- Alt text describes what the screen shows and why it matters, in a sentence, in the voice.
+
+Not in the brand: stock or generated imagery, landscape motifs (the fields, gorse and limewash identity was retired on purpose), device mockups, and decorative icons. The only glyphs are the mark, ↗ and the theme toggle. Diagrams are Mermaid, coloured from the tokens.
+
+## Voice
+
+Meith talks to the person who will run the server. Short declarative sentences, concrete nouns, and a point of view about ownership. A headline states a position and lets its last word carry the weight: “Long live *the forum.*”, “A home you own. Not a platform you *rent.*”
+
+| Say | Not |
+|---|---|
+| It's a PostgreSQL database you can back up, move, or take apart. | Enterprise-grade data portability for modern communities. |
+| On your domain. On your terms. | The all-in-one community platform. |
+| `npx create-meith my-community` | Get started in minutes with our seamless onboarding. |
+| This preview uses sample data. Saving posts requires PostgreSQL. | Works out of the box for everyone. |
+
+House style:
+
+- **Spelling.** Irish and British English: colour, licence, organisation. Curly quotes and real apostrophes.
+- **The name.** “Meith” in prose, “meith” only in the wordmark and in commands. A forum installation is a “board”.
+- **Labels.** Section labels are two or three words in uppercase mono. Nothing else is uppercase.
+- **No hype.** No exclamation marks, no superlatives without a number, no emoji.
+- **Commands and code.** Set in mono, exactly as typed, lowercase. Never inside a sentence that ends with a full stop.
+- **Numbers and versions.** Versions as 0.37.5, no “v” in prose. Node.js, PostgreSQL, pnpm: each project's own capitalisation.
+- **Headings.** Sentence case. One italic emphasis at the end on marketing pages, none in docs.
+- **Punctuation.** Spaced em dashes used rarely. No ampersands in sentences.
+
+Terminology:
+
+| Term | Means | Not |
+|---|---|---|
+| Meith | The project and the software. Capitalised in prose. | MEITH, Meith.dev as the product name |
+| meith.dev | The website. Always lowercase, no www in copy. | Meith.dev, the Meith site |
+| Board | One running installation of Meith. | Instance, site, workspace, server |
+| Forum | A section of a board that holds threads. | Category, channel, room |
+| Thread · post | A discussion, and one message in it. | Topic, conversation · comment, message |
+| Member | Someone with an account on a board. | User (except in technical docs), customer |
+| Operator | The person who runs the server a board lives on. | Admin (a role on the board), owner |
+| Operator CLI | The command-line tool for backups, imports and upgrades. | Admin tool, the script |
+| Theme · plugin | Extensions that change how a board looks, or what it does. | Skin, template · add-on, module, app |
+| Marketplace | The listing at meith.dev/marketplace. | Store, shop |
+| Self-hosted | Runs on infrastructure the community controls. | On-prem, DIY |
+| Open source | MIT licensed. Two words as a noun, hyphenated before one. | OSS in headlines, “open” alone |
+
+## Where the truth lives
+
+| What | File |
+|---|---|
+| Colour, type scale, radii, base controls | `apps/web/src/styles/globals.css` |
+| Homepage edition styles | `apps/web/src/styles/home.css` |
+| Header, wordmark, footer | `apps/web/src/styles/chrome.css` |
+| Inner marketing pages | `apps/web/src/styles/marketing.css` |
+| The mark | `apps/web/src/components/logomark.tsx`, `apps/web/public/icon.svg` |
+| Social card, its palette and fonts | `apps/web/src/og/card.tsx`, `palette.ts`, `fonts.ts` |
+| Name, tagline, descriptions, the story of the name | `apps/web/src/content/site.ts`, `about.ts` |
+| Screenshots | `apps/web/public/shots` |
+| The board theme | `themes/meith/src/tokens.ts`, `shared.tsx` |
+
+Tokens change in code first: update `globals.css`, then the theme tokens, then this page. Never introduce a colour the stylesheet does not have. Before anything public ships, check it against the list:
+
+- One accent, only on the action, current item, link underline or emphasised word.
+- Headline closes with one serif italic phrase, and only one.
+- Labels are uppercase mono; nothing else is uppercase.
+- Works in light and dark, and every screenshot has its pair.
+- Text pairs pass 4.5:1; `--fg-subtle` is not carrying meaning.
+- Corners are square on marketing surfaces.
+- Forward actions end in ↗, and nothing else does.
+- Copy is concrete, in Irish and British spelling, with no exclamation marks.
+- The name is “Meith” in prose and “meith” in the wordmark and commands.
+- Colours, type and spacing come from the theme tokens, not picked by eye.
+
+[Theme tokens and shared controls](../extensions/theme-design.md) describes how `themes/meith` maps these rules onto the board.
diff --git a/docs/extensions/theme-design.md b/docs/extensions/theme-design.md
index 339ef5047..72bf7fea6 100644
--- a/docs/extensions/theme-design.md
+++ b/docs/extensions/theme-design.md
@@ -109,11 +109,16 @@ is a `primary` block with a `↗` arrow, every link that goes somewhere ends
in a muted arrow that turns `primary` under the pointer, the counts beside a
listed item are `primary`, and a forum row's rule turns `primary` under the
pointer. In a post, a 1px `border` rule stands between the byline column
-and the body. The header's sign-in is
-the site's `foreground` block that fills `primary` on hover. Nothing glows,
-nothing is tinted and nothing is rounded. Semantic tokens keep the default
-theme's values. The theme fills the 22 slots that carry the look and inherits the
-panels, forms, search and authentication screens, which take the tokens.
+and the body. The header's sign-in is the same `primary` block, and the header and footer
+navigation run in the mono face as uppercase labels, as meith.dev's own do. Nothing glows,
+nothing is tinted and nothing is rounded. The semantic marks — pinned, locked,
+moderation and group tokens — sit on the greyscale ramp, so the label carries
+the meaning and the green stays reserved; the `accent` wash is `surface`.
+Transitions run 160–180ms. [Brand guidelines](../contributing/brand.md) holds
+the rules the theme follows. The theme fills every slot in the registry, so
+the sign-in, search, discovery, posting and panel screens draw with the same
+rules, labels and one filled action as the board index; only the form
+controls come from `@meith/ui`, and they take the tokens.
In this repository it is registered only behind `SHOWCASE_THEMES`, so the
contract tests, the fixture gallery and the browser suites cover it.
diff --git a/themes/meith/src/copy.ts b/themes/meith/src/copy.ts
index c89d7204e..c95efe0dd 100644
--- a/themes/meith/src/copy.ts
+++ b/themes/meith/src/copy.ts
@@ -1,4 +1,4 @@
-import { mobileHeaderNavCopy } from '@meith/theme-default'
+import { mobileHeaderNavCopy, mobilePanelNavCopy } from '@meith/theme-default'
import type { SlotCopy, Translator } from '@meith/theme-kit'
function copyFor(t: Translator, keys: readonly string[]): SlotCopy {
@@ -260,3 +260,76 @@ export function whoIsOnlineCopy(t: Translator): SlotCopy {
'meith.whoIsOnline.invisible',
])
}
+
+export function discoveryViewCopy(t: Translator): SlotCopy {
+ return copyFor(t, [
+ 'meith.discoveryView.by',
+ 'meith.discoveryView.in',
+ 'meith.discoveryView.lastPost',
+ 'meith.discoveryView.reply.one',
+ 'meith.discoveryView.reply.other',
+ 'meith.discoveryView.startedBy',
+ ])
+}
+
+export function errorNoticeCopy(t: Translator): SlotCopy {
+ return copyFor(t, [
+ 'meith.errorNotice.error',
+ 'meith.errorNotice.forumHome',
+ 'meith.errorNotice.quoteThis',
+ ])
+}
+
+export function panelNavCopy(t: Translator): SlotCopy {
+ return {
+ ...mobilePanelNavCopy(t),
+ ...copyFor(t, ['meith.panelNav.countCap', 'meith.panelNav.waiting']),
+ }
+}
+
+export function panelPageCopy(t: Translator): SlotCopy {
+ return copyFor(t, ['meith.panelPage.back'])
+}
+
+export function postFormCopy(t: Translator): SlotCopy {
+ return copyFor(t, ['meith.postForm.cannotPost'])
+}
+
+export function redirectNoticeCopy(t: Translator): SlotCopy {
+ return copyFor(t, [
+ 'meith.redirectNotice.continueNow',
+ 'meith.redirectNotice.continuingOnItsOwnIn',
+ 'meith.redirectNotice.pleaseWait',
+ 'meith.redirectNotice.redirecting',
+ 'meith.redirectNotice.second.one',
+ 'meith.redirectNotice.second.other',
+ ])
+}
+
+export function searchFormCopy(t: Translator): SlotCopy {
+ return copyFor(t, [
+ 'meith.searchForm.in',
+ 'meith.searchForm.noResults',
+ 'meith.searchForm.placeholder',
+ 'meith.searchForm.search',
+ 'meith.searchForm.searchFor',
+ 'meith.searchForm.sortBy',
+ ])
+}
+
+export function searchResultsCopy(t: Translator): SlotCopy {
+ return copyFor(t, [
+ 'meith.searchResults.clearFilters',
+ 'meith.searchResults.closeQuote',
+ 'meith.searchResults.dot',
+ 'meith.searchResults.nothingMatched',
+ 'meith.searchResults.openQuote',
+ 'meith.searchResults.removeFilter',
+ 'meith.searchResults.removeFilterX',
+ 'meith.searchResults.resultsFor',
+ 'meith.searchResults.searched',
+ 'meith.searchResults.searchedNote',
+ 'meith.searchResults.startNewSearch',
+ 'meith.searchResults.tryFewerWords',
+ ])
+}
diff --git a/themes/meith/src/messages/en.json b/themes/meith/src/messages/en.json
index ebc074632..7d699fb1e 100644
--- a/themes/meith/src/messages/en.json
+++ b/themes/meith/src/messages/en.json
@@ -12,6 +12,15 @@
"meith.boardStats.notComputed": "The totals are counted on a schedule, and the first count has not happened.",
"meith.boardStats.posts": "Posts",
"meith.boardStats.threads": "Threads",
+ "meith.discoveryView.by": "by",
+ "meith.discoveryView.in": "in",
+ "meith.discoveryView.lastPost": "Last post",
+ "meith.discoveryView.reply.one": "reply",
+ "meith.discoveryView.reply.other": "replies",
+ "meith.discoveryView.startedBy": "Started by",
+ "meith.errorNotice.error": "Error",
+ "meith.errorNotice.forumHome": "Forum home",
+ "meith.errorNotice.quoteThis": "Quote this if you report it:",
"meith.footer.nav": "Footer",
"meith.footer.timesShownIn": "Times are shown in",
"meith.forumDisplay.activityHeader": "Activity",
@@ -79,6 +88,9 @@
"meith.pagination.page": "Page",
"meith.pagination.previous": "Previous",
"meith.pagination.previousArrow": "←",
+ "meith.panelNav.countCap": "99+",
+ "meith.panelNav.waiting": "waiting",
+ "meith.panelPage.back": "←",
"meith.postActions.edit": "Edit",
"meith.postActions.history": "History",
"meith.postActions.moderate": "Moderate",
@@ -103,6 +115,31 @@
"meith.postBit.showAnyway": "Show it anyway",
"meith.postBit.staffOnly": "Only moderators can see this.",
"meith.postBit.waitingApproval": "Waiting for approval",
+ "meith.postForm.cannotPost": "Cannot post",
+ "meith.redirectNotice.continueNow": "Continue now",
+ "meith.redirectNotice.continuingOnItsOwnIn": "Continuing on its own in",
+ "meith.redirectNotice.pleaseWait": "Please wait",
+ "meith.redirectNotice.redirecting": "Redirecting",
+ "meith.redirectNotice.second.one": "second",
+ "meith.redirectNotice.second.other": "seconds",
+ "meith.searchForm.in": "In",
+ "meith.searchForm.noResults": "No results",
+ "meith.searchForm.placeholder": "Words in a subject or a post",
+ "meith.searchForm.search": "Search",
+ "meith.searchForm.searchFor": "Search for",
+ "meith.searchForm.sortBy": "Sort by",
+ "meith.searchResults.clearFilters": "Clear filters",
+ "meith.searchResults.closeQuote": "”",
+ "meith.searchResults.dot": "·",
+ "meith.searchResults.nothingMatched": "Nothing matched.",
+ "meith.searchResults.openQuote": "“",
+ "meith.searchResults.removeFilter": "remove this filter",
+ "meith.searchResults.removeFilterX": "×",
+ "meith.searchResults.resultsFor": "Results for",
+ "meith.searchResults.searched": "Searched",
+ "meith.searchResults.searchedNote": "Results are checked against your access every time this page is opened, so they can change.",
+ "meith.searchResults.startNewSearch": "Start a new search",
+ "meith.searchResults.tryFewerWords": "Or nothing you can see does. Try fewer words, or a different spelling.",
"meith.shell.skipToContent": "Skip to content",
"meith.subforumList.heading": "Subforums",
"meith.subforumList.post.one": "post",
diff --git a/themes/meith/src/shared.tsx b/themes/meith/src/shared.tsx
index bc071ad99..84aee9fc4 100644
--- a/themes/meith/src/shared.tsx
+++ b/themes/meith/src/shared.tsx
@@ -22,22 +22,22 @@ const SECTION_TITLE_SIZE = 'text-[clamp(1.5rem,1.2rem+1vw,2.1rem)]'
export const SECTION_TITLE = `font-heading ${SECTION_TITLE_SIZE} font-normal leading-[1.25] tracking-[-0.025em] text-foreground`
export const ITEM_TITLE =
- 'text-[0.95rem] font-medium leading-normal text-foreground sm:text-[1.02rem]'
+ 'text-[0.95rem] font-medium leading-[1.55] text-foreground sm:text-[1.02rem]'
export const META = 'text-[0.8125rem] leading-relaxed text-muted-foreground'
export const SMALL_PRINT = 'text-xs leading-relaxed text-muted-foreground'
-export const LEDE = 'max-w-[32rem] text-[0.95rem] leading-[1.75] text-muted-foreground text-pretty'
+export const LEDE = 'max-w-[34rem] text-[0.95rem] leading-[1.8] text-muted-foreground text-pretty'
-export const BODY_MEASURE = 'max-w-[42rem]'
+export const BODY_MEASURE = 'max-w-[60rem]'
export const NUMERIC = 'font-mono tabular-nums'
export const TEXT_LINK =
- 'underline decoration-primary/55 decoration-1 underline-offset-[0.3em] transition-colors hover:text-primary hover:decoration-primary'
+ 'underline decoration-primary/60 decoration-1 underline-offset-[0.3em] transition-colors duration-[160ms] hover:text-primary hover:decoration-primary'
-export const QUIET_LINK = 'transition-colors hover:text-primary'
+export const QUIET_LINK = 'transition-colors duration-[160ms] hover:text-primary'
const SERIF_FACE = 'font-[Iowan_Old_Style,Palatino_Linotype,Book_Antiqua,Georgia,serif]'
@@ -49,15 +49,21 @@ export const ITEM_RULE = 'border-t border-foreground/15'
export const RULE = 'border-t border-border'
-export const ROW_HOVER = 'transition-colors hover:border-primary hover:bg-accent'
+export const ROW_HOVER = 'transition-colors duration-[160ms] hover:border-primary'
export const PRIMARY_ACTION =
- 'group inline-flex min-h-[3.2rem] items-center justify-between gap-8 bg-primary px-5 text-[0.85rem] font-medium whitespace-nowrap text-primary-foreground transition-colors hover:bg-primary-hover'
+ 'group inline-flex min-h-[3.2rem] items-center justify-between gap-8 bg-primary px-5 text-[0.85rem] font-medium whitespace-nowrap text-primary-foreground transition-colors duration-[160ms] hover:bg-primary-hover'
+
+export const SECONDARY_ACTION =
+ 'inline-flex min-h-[2.75rem] items-center gap-3 border border-foreground px-4 text-[0.8125rem] font-medium text-foreground transition-colors duration-[160ms] hover:border-primary hover:text-primary'
+
+export const PANEL_TITLE =
+ 'text-[1.15rem] font-normal leading-[1.3] tracking-[-0.025em] text-foreground'
export const TOUCH = 'pointer-coarse:min-h-11'
export const ARROW =
- 'ms-1 inline-block text-muted-foreground transition-[color,transform] group-hover:text-primary motion-safe:group-hover:-translate-y-px motion-safe:group-hover:translate-x-px'
+ 'ms-1 inline-block text-muted-foreground transition-[color,transform] duration-[160ms] group-hover:text-primary motion-safe:group-hover:-translate-y-0.5 motion-safe:group-hover:translate-x-0.5'
export function Arrow({ className }: { className?: string }) {
return (
@@ -79,7 +85,12 @@ export function Action({
return (
{children}
-
+
+ ↗
+
)
}
diff --git a/themes/meith/src/slots/auth-page.tsx b/themes/meith/src/slots/auth-page.tsx
new file mode 100644
index 000000000..aa265108f
--- /dev/null
+++ b/themes/meith/src/slots/auth-page.tsx
@@ -0,0 +1,46 @@
+import type { AuthPageModel } from '@meith/theme-kit'
+
+import { EDITORIAL_RULE, LEDE, META, PAGE_TITLE, RULE, SHELL, TEXT_LINK } from '../shared'
+
+export function AuthPage({ title, alert, links, regions }: AuthPageModel) {
+ return (
+
+
+
+
{title}
+ {regions.lede !== undefined &&
{regions.lede}
}
+
+
+ {alert !== null && (
+
+ {alert}
+
+ )}
+
+ {regions.note !== undefined &&
{regions.note}
}
+
+ {regions.form}
+
+ {links.length > 0 && (
+
+ {links.map((link) => (
+
+ {link.lead === null ? null : `${link.lead} `}
+
+ {link.label}
+
+
+ ))}
+
+ )}
+
+
+ )
+}
diff --git a/themes/meith/src/slots/discovery-view.tsx b/themes/meith/src/slots/discovery-view.tsx
new file mode 100644
index 000000000..d25031aa8
--- /dev/null
+++ b/themes/meith/src/slots/discovery-view.tsx
@@ -0,0 +1,116 @@
+import type { DiscoveryViewModel, SlotCopy } from '@meith/theme-kit'
+import { fromSlotCopy } from '@meith/theme-kit'
+import { NavTabs } from '@meith/ui'
+
+import {
+ Arrow,
+ Counts,
+ EDITORIAL_RULE,
+ ITEM_TITLE,
+ LEDE,
+ META,
+ PAGE_TITLE,
+ QUIET_LINK,
+ ROW_HOVER,
+ RULE,
+ SHELL,
+ Stamp,
+ TEXT_LINK,
+} from '../shared'
+
+export function DiscoveryView({
+ title,
+ blurb,
+ tabsLabel,
+ tabs,
+ rows,
+ nextHref,
+ nextLabel,
+ emptyMessage,
+ refusal,
+ copy,
+}: DiscoveryViewModel & { copy: SlotCopy }) {
+ const c = (key: string) => fromSlotCopy(copy, `meith.discoveryView.${key}`)
+
+ return (
+
+
+
+
+
+ {refusal !== null ? (
+
+ {refusal.message}{' '}
+
+ {refusal.signInLabel}
+
+
+ ) : rows.length === 0 ? (
+ {emptyMessage}
+ ) : (
+
+ )}
+
+ {nextHref !== null && (
+
+
+ {nextLabel}
+
+
+
+ )}
+
+ )
+}
diff --git a/themes/meith/src/slots/editor-toolbar.tsx b/themes/meith/src/slots/editor-toolbar.tsx
new file mode 100644
index 000000000..d2f53520f
--- /dev/null
+++ b/themes/meith/src/slots/editor-toolbar.tsx
@@ -0,0 +1,89 @@
+'use client'
+
+import {
+ applyEditorTag,
+ applyInsertion,
+ type EditorTag,
+ type EditorToolbarButtonModel,
+ type EditorToolbarModel,
+ type SlotCopy,
+} from '@meith/theme-kit'
+
+const GLYPHS: Readonly> = {
+ bold: 'B',
+ italic: 'I',
+ strikethrough: 'S',
+ link: 'Link',
+ image: 'Image',
+ quote: '“”',
+ code: '>',
+ spoiler: 'Spoiler',
+ bulletedList: '•',
+ numberedList: '1.',
+ taskList: '☑',
+ heading: 'H',
+ table: 'Table',
+}
+
+const BUTTON =
+ 'min-w-8 px-2 py-1.5 font-mono text-[0.75rem] uppercase tracking-[0.05em] text-muted-foreground transition-colors duration-[160ms] hover:text-foreground'
+
+function runButton(textareaId: string, button: EditorToolbarButtonModel): void {
+ const field = document.getElementById(textareaId)
+ if (!(field instanceof HTMLTextAreaElement)) return
+
+ if (button.tag !== null) {
+ applyEditorTag(field, button.tag, button.placeholder)
+ return
+ }
+
+ if (button.insertion !== null) applyInsertion(field, button.insertion)
+}
+
+function glyphFor(button: EditorToolbarButtonModel): string {
+ return button.tag === null ? button.label.charAt(0).toUpperCase() : GLYPHS[button.tag]
+}
+
+export function EditorToolbar({
+ textareaId,
+ groupLabel,
+ buttons,
+ attachment,
+}: EditorToolbarModel & { copy: SlotCopy }) {
+ return (
+
+ {buttons.map((button, index) => (
+ event.preventDefault()}
+ onClick={() => runButton(textareaId, button)}
+ title={button.title}
+ aria-label={button.label}
+ {...(button.keyShortcut === null ? {} : { 'aria-keyshortcuts': button.keyShortcut })}
+ className={BUTTON}
+ >
+ {glyphFor(button)}
+
+ ))}
+
+ {attachment !== null && (
+ event.preventDefault()}
+ onClick={() => document.getElementById(attachment.inputId)?.click()}
+ title={attachment.label}
+ aria-label={attachment.label}
+ className={BUTTON}
+ >
+ {attachment.label}
+
+ )}
+
+ )
+}
diff --git a/themes/meith/src/slots/error-notice.tsx b/themes/meith/src/slots/error-notice.tsx
new file mode 100644
index 000000000..bdba9575a
--- /dev/null
+++ b/themes/meith/src/slots/error-notice.tsx
@@ -0,0 +1,45 @@
+import type { ErrorNoticeModel, SlotCopy } from '@meith/theme-kit'
+import { fromSlotCopy } from '@meith/theme-kit'
+
+import {
+ Action,
+ EDITORIAL_RULE,
+ Label,
+ LEDE,
+ NUMERIC,
+ PAGE_TITLE,
+ RULE,
+ SMALL_PRINT,
+} from '../shared'
+
+export function ErrorNotice({
+ status,
+ title,
+ message,
+ homeHref,
+ requestId,
+ copy,
+}: ErrorNoticeModel & { copy: SlotCopy }) {
+ const c = (key: string) => fromSlotCopy(copy, `meith.errorNotice.${key}`)
+
+ return (
+
+
+ {c('error')} {status}
+
+
{title}
+
{message}
+
+
+
+ {requestId !== null && (
+
+ {c('quoteThis')}{' '}
+ {requestId}
+
+ )}
+
+ )
+}
diff --git a/themes/meith/src/slots/footer.tsx b/themes/meith/src/slots/footer.tsx
index d903cf6b0..8127b4b63 100644
--- a/themes/meith/src/slots/footer.tsx
+++ b/themes/meith/src/slots/footer.tsx
@@ -3,7 +3,7 @@ import { fromSlotCopy } from '@meith/theme-kit'
import { Arrow, LABEL, QUIET_LINK, SHELL, TOUCH } from '../shared'
-const FOOTER_LINK = `group inline-flex items-center py-2 text-[0.8125rem] font-medium tracking-[0.01em] text-muted-foreground ${QUIET_LINK} ${TOUCH}`
+const FOOTER_LINK = `group inline-flex items-center py-2 font-mono text-[0.6875rem] font-medium tracking-[0.075em] text-muted-foreground uppercase ${QUIET_LINK} ${TOUCH}`
export function Footer({
boardTitle,
diff --git a/themes/meith/src/slots/forum-jump.tsx b/themes/meith/src/slots/forum-jump.tsx
new file mode 100644
index 000000000..b480e2556
--- /dev/null
+++ b/themes/meith/src/slots/forum-jump.tsx
@@ -0,0 +1,41 @@
+import type { ForumJumpModel } from '@meith/theme-kit'
+import { NativeSelect } from '@meith/ui'
+
+import { LABEL, SECONDARY_ACTION, TOUCH } from '../shared'
+
+export function ForumJump({ action, field, forums, submitLabel, label }: ForumJumpModel) {
+ if (forums.length === 0) return null
+
+ const id = `forum-jump-${field}`
+
+ return (
+
+ )
+}
diff --git a/themes/meith/src/slots/header.tsx b/themes/meith/src/slots/header.tsx
index afb613ef3..0ff3ebad3 100644
--- a/themes/meith/src/slots/header.tsx
+++ b/themes/meith/src/slots/header.tsx
@@ -4,7 +4,7 @@ import { fromSlotCopy, linkTarget } from '@meith/theme-kit'
import { Logomark, SHELL, TOUCH, Wordmark } from '../shared'
-const DESKTOP_LINK = `inline-flex h-10 items-center px-1 text-[0.8125rem] font-medium tracking-[0.01em] whitespace-nowrap text-muted-foreground transition-colors hover:text-primary ${TOUCH}`
+const DESKTOP_LINK = `inline-flex h-10 items-center px-1 font-mono text-[0.6875rem] font-medium tracking-[0.075em] uppercase whitespace-nowrap text-muted-foreground transition-colors duration-[160ms] hover:text-primary ${TOUCH}`
function BoardMark({ boardTitle, logo }: Pick) {
if (logo === undefined) {
@@ -42,13 +42,13 @@ function Submenu({ items }: { items: HeaderModel['navigation'][number]['submenu'
if (items === undefined || items.length === 0) return null
return (
-
+
{items.map((child) => (
{child.label}
@@ -76,12 +76,12 @@ export function Header({
return (