From a48d83c23e64d8bac2f92ca15e795eb3526a9c79 Mon Sep 17 00:00:00 2001 From: Jordan Harrison Date: Sun, 20 Sep 2026 21:29:54 +0100 Subject: [PATCH] feat(site): redesign homepage and extensions experience --- .../[key]/page.tsx | 8 +- .../app/{marketplace => extensions}/page.tsx | 18 +- apps/web/app/page.tsx | 332 +++++---- apps/web/app/sitemap.ts | 4 +- apps/web/e2e/marketing.spec.ts | 110 ++- apps/web/next.config.mjs | 2 + apps/web/package.json | 6 + apps/web/src/components/preview-scheme.tsx | 35 + apps/web/src/components/site-footer.tsx | 7 +- apps/web/src/components/site-header.tsx | 8 +- .../web/src/components/theme-showcase.test.ts | 31 + apps/web/src/components/theme-showcase.tsx | 241 +++++-- apps/web/src/marketplace/catalog.ts | 2 +- apps/web/src/styles/chrome.css | 3 +- apps/web/src/styles/globals.css | 28 + apps/web/src/styles/home.css | 659 +++++++++++------- apps/web/tsconfig.json | 19 - docs/contributing/brand.md | 12 + docs/contributing/marketplace-maintenance.md | 2 + pnpm-lock.yaml | 18 + 20 files changed, 1049 insertions(+), 496 deletions(-) rename apps/web/app/{marketplace => extensions}/[key]/page.tsx (96%) rename apps/web/app/{marketplace => extensions}/page.tsx (90%) create mode 100644 apps/web/src/components/preview-scheme.tsx create mode 100644 apps/web/src/components/theme-showcase.test.ts diff --git a/apps/web/app/marketplace/[key]/page.tsx b/apps/web/app/extensions/[key]/page.tsx similarity index 96% rename from apps/web/app/marketplace/[key]/page.tsx rename to apps/web/app/extensions/[key]/page.tsx index cbcf7eef6..5778f71d8 100644 --- a/apps/web/app/marketplace/[key]/page.tsx +++ b/apps/web/app/extensions/[key]/page.tsx @@ -32,12 +32,12 @@ export async function generateMetadata({ params }: PageProps): Promise return { title, description: listing.description, - alternates: { canonical: `/marketplace/${listing.key}` }, + alternates: { canonical: `/extensions/${listing.key}` }, openGraph: { type: 'article', title, description: listing.description, - url: `/marketplace/${listing.key}`, + url: `/extensions/${listing.key}`, images: ogImage('/og', title), }, } @@ -74,7 +74,7 @@ function MetaRow({ label, children }: { label: string; children: React.ReactNode ) } -export default async function MarketplaceListingPage({ params }: PageProps) { +export default async function ExtensionListingPage({ params }: PageProps) { const { key } = await params const listing = await findListing(key) if (!listing) notFound() @@ -88,7 +88,7 @@ export default async function MarketplaceListingPage({ params }: PageProps) { current={listing.name} trail={[ { label: site.name, href: '/' }, - { label: 'Extensions', href: '/marketplace' }, + { label: 'Extensions', href: '/extensions' }, ]} />
diff --git a/apps/web/app/marketplace/page.tsx b/apps/web/app/extensions/page.tsx similarity index 90% rename from apps/web/app/marketplace/page.tsx rename to apps/web/app/extensions/page.tsx index 4eb5c18ef..86661477c 100644 --- a/apps/web/app/marketplace/page.tsx +++ b/apps/web/app/extensions/page.tsx @@ -19,13 +19,13 @@ const DESCRIPTION = export const metadata: Metadata = { title: 'Extensions', description: DESCRIPTION, - alternates: { canonical: '/marketplace' }, + alternates: { canonical: '/extensions' }, openGraph: { type: 'website', siteName: site.name, title: `${site.name} extensions`, description: DESCRIPTION, - url: '/marketplace', + url: '/extensions', images: ogImage('/og', `${site.name} extensions`), }, } @@ -85,7 +85,7 @@ function ListingCard({ listing }: { listing: Listing }) { ) } -export default async function MarketplaceIndexPage() { +export default async function ExtensionsIndexPage() { const grouped = await Promise.all( sections.map(async (section) => ({ section, listings: await listingsOfKind(section.kind) })), ) @@ -147,14 +147,14 @@ export default async function MarketplaceIndexPage() {

Create a theme or plugin with typed, documented extension points.

- - Build a theme → + + Build a theme ↗ - - Write a plugin + + Write a plugin ↗ - - How to install + + How to install ↗
diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 4d0c7cef2..6d610d80f 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -2,7 +2,8 @@ import Link from 'next/link' import { CommandLine } from '../src/components/command-line' import { ForumLink } from '../src/components/forum-link' -import { scaffoldCommand, site } from '../src/content/site' +import { ThemeShowcase } from '../src/components/theme-showcase' +import { licenceHref, scaffoldCommand, site } from '../src/content/site' import { readStack, type StackFacts } from '../src/content/stack' import { docHref, quickstartHref } from '../src/docs/registry' @@ -11,17 +12,17 @@ const communities = [ { name: 'Open-source projects', slug: 'open-source', - body: 'A home for questions and ideas beyond the issue tracker.', + body: 'Questions and ideas with a home beyond the issue tracker.', }, { name: 'Communities', slug: 'communities', - body: 'Keep the conversations that bring your people together.', + body: 'Shared interests. Familiar names. Conversations that last.', }, { name: 'Clubs and associations', slug: 'clubs-and-associations', - body: 'A shared space for members, plans and club life.', + body: 'A place for members, plans and everything that keeps a club going.', }, ] as const @@ -45,7 +46,7 @@ const technologies = [ name: 'Next.js', body: 'Rendered on the server with React Server Components, so the board opens fast on any ' + - 'device and every page and form works with JavaScript switched off in the browser. ' + + 'device. Most reading and posting works with JavaScript switched off in the browser. ' + 'Scripts only enhance what already works.', }, { @@ -74,7 +75,7 @@ export default async function LandingPage() { return (
-
+

OPEN-SOURCE FORUM SOFTWARE

@@ -82,112 +83,227 @@ export default async function LandingPage() { BUILT IN THE OPEN
-
-

- Long live -
- the forum. +
+

+ Long live the forum.

+
+
+

A proper home for your community.

- A proper home for your community. -
- On your domain. On your terms. + Meith is open-source, self-hosted forum software. Discussions, search and + moderation, on your domain, with your data under your control.

-
+
+
+
Start your forum - + Try the demo - - Join the community -

- Free and open source. Self-hosted. Yours to keep. The demo runs on sample data. + Free software. No licence fee. No per-member pricing.

-
-
-
-

WHO IT’S FOR

-

- Your kind of community. -

+
+
+
+
+

BUILT FOR KEEPS

+

+ A conversation today. +
A resource tomorrow. +

+
+

+ The answer someone gave. The guide somebody wrote. The decision you made together. + Give your community a place where those things stay useful, and the next person can + find them. +

- - Find your fit - -
-
- {communities.map((community) => ( - -
-

{community.name}

- -
-

{community.body}

+
+ + 01 / KEEP THE CONTEXT +

+ Threads worth returning to +

+

+ Posts in the order they were written. A link you can share. An archive that grows + with your community. +

+ + + 02 / SET THE TONE +

+ Tools for the people in charge +

+

+ Reports, moderation queues and forum permissions help your team look after the space + you share. +

- ))} + + 03 / KEEP IT ACCESSIBLE +

+ A forum that gets out of the way +

+

+ Pages render on the server. Most reading and posting works with JavaScript switched + off, on a phone or a desktop. +

+ +
-
-
-
-

OWNERSHIP

+
+
+
+

OWNERSHIP, IN PRACTICE

- A home you own. + Your community.
- Not a platform you rent. + All the way down.

-

- Your community shouldn’t disappear because a platform changes direction. A Meith board - runs on a server you rent, at a domain you own, from a database you can take with you. +

+ Run Meith on infrastructure you control. You choose the domain, the host and the + people who look after it. The whole project is MIT licensed.

+ + Read the licence +
-
    +
    1. -

      Your domain, your server

      -

      - No Meith-hosted control plane and no company in the middle — nothing that can be - switched off from outside your community. -

      + 01 +
      +

      Your domain. Your server.

      +

      + No Meith-hosted control plane or account with a company in the middle. You decide + where your board runs. +

      +
    2. -

      Your database, yours to move

      -

      - It’s a PostgreSQL database you can back up, move, or take apart with the operator - CLI. Nothing is locked in. -

      + 02 +
      +

      Your data. Yours to move.

      +

      + Posts and members live in your PostgreSQL database. Back it up, move hosts or take + it with you. +

      +
    3. -

      No per-member pricing

      -

      - No licence fee and nothing priced per member. A community that doubles in size - doesn’t double its bill. -

      + 03 +
      +

      No charge for growing.

      +

      + No licence fee or per-member bill from Meith. You pay for your hosting and any + services you choose to connect. +

      +
    4. -

      Handed over, not started over

      + 04 +
      +

      A future beyond one organiser.

      +

      + Hand over the roles and infrastructure when the people running it change. The + board stays with the community. +

      +
      +
    5. +
    +
+
+ +
+
+
+
+

MAKE IT YOURS

+

+ Same foundations. +
+ Your expression. +

+
+

- Nothing lives in a personal account. When the people running it change, the roles - move on and the board stays the community’s. + Start with a theme. Make one of your own. Add plugins for the things your people + need. A board should feel like the community that runs it.

- - + + Browse themes and plugins + +
+
+
+
+ +

Live forum components. Sample content.

+
+ +
+
+
+ +
+
+
+

WHO IT’S FOR

+

+ Different people. +
+ Common ground. +

+

+ For people building something together, and looking for a place to keep it. +

+ + Join the Meith community + +
+
+ {communities.map((community) => ( + +

{community.name}

+

{community.body}

+ + + ))} +
-
+
-
+

UNDER THE HOOD

@@ -196,11 +312,10 @@ export default async function LandingPage() { technology.

-
+

- The same tools behind today’s fastest websites, pinned to exact versions and kept - current release by release. Nothing exotic to hire for or host, and nothing that - quietly goes out of date. + A TypeScript codebase, server-rendered pages and PostgreSQL for storage, search and + background work. Configuration, themes and plugins live in your board’s repository.

How it fits together @@ -212,50 +327,25 @@ export default async function LandingPage() {
  • {technology.name}

    - {stack[technology.key]} + + {stack[technology.key]} +

    {technology.body}

  • ))} -

    - Versions read from the repository’s own pins when this page is built. +

    + Major versions read from the repository when this page is built.

    -
    -
    -
    -

    MAKE IT YOURS

    -

    - Your community. -
    - Your expression. -

    -
    -
    -

    - Build a theme that’s entirely your own. Add the features your people need. Meith gives - you the foundations; you decide what it becomes. -

    - -
    -
    -
    - -
    -
    +
    +

    OVER TO YOU

    @@ -263,21 +353,25 @@ export default async function LandingPage() {
    worth keeping.

    +

    + Try a board on your own machine. Make it yours, then give your community the keys. +

    +

    YOUR FIRST BOARD STARTS HERE

    -
    +

    + The local preview uses sample data. Going live means arranging a domain, hosting and + PostgreSQL. Follow the deployment guide for setup and ongoing operation. +

    +
    - Create your community + Start your forum - - Try the demo - - - Read the docs + + Deployment guide
    -

    Free software. Your infrastructure. Your future.

    diff --git a/apps/web/app/sitemap.ts b/apps/web/app/sitemap.ts index ad72d6bda..35d18572f 100644 --- a/apps/web/app/sitemap.ts +++ b/apps/web/app/sitemap.ts @@ -18,9 +18,9 @@ export default async function sitemap(): Promise { priority: 0.9, })), { url: `${site.url}${about.href}`, changeFrequency: 'monthly', priority: 0.8 }, - { url: `${site.url}/marketplace`, changeFrequency: 'weekly', priority: 0.8 }, + { url: `${site.url}/extensions`, changeFrequency: 'weekly', priority: 0.8 }, ...listings.map((listing) => ({ - url: `${site.url}/marketplace/${listing.key}`, + url: `${site.url}/extensions/${listing.key}`, changeFrequency: 'weekly' as const, priority: 0.5, })), diff --git a/apps/web/e2e/marketing.spec.ts b/apps/web/e2e/marketing.spec.ts index 079709fe5..3c18b7ecf 100644 --- a/apps/web/e2e/marketing.spec.ts +++ b/apps/web/e2e/marketing.spec.ts @@ -9,8 +9,8 @@ const routes = [ '/who-its-for/developers', '/who-its-for/open-source', '/who-its-for/legacy-forums', - '/marketplace', - '/marketplace/default', + '/extensions', + '/extensions/default', '/docs', ] @@ -40,7 +40,10 @@ test('colour scheme persists across navigation and resets to the system preferen page, }) => { await page.goto('/') - const scheme = page.getByRole('group', { name: 'Colour scheme' }) + const scheme = page.getByRole('contentinfo').getByRole('group', { name: 'Colour scheme' }) + await expect(page.getByRole('banner').getByRole('group', { name: 'Colour scheme' })).toHaveCount( + 0, + ) await scheme.getByRole('button', { name: 'Light' }).click() await expect(page.locator('html')).toHaveAttribute('data-theme', 'light') await scheme.getByRole('button', { name: 'Dark' }).click() @@ -73,15 +76,12 @@ test('mobile navigation closes on Escape, outside interaction, and link selectio await expect(menu).toBeFocused() await menu.click() - await page - .getByRole('group', { name: 'Colour scheme' }) - .getByRole('button', { name: 'System' }) - .click() + await page.getByRole('banner').getByRole('link', { name: 'Meith home' }).click() await expect(navigation).toBeHidden() await menu.click() - await navigation.getByRole('link', { name: 'Who it’s for', exact: true }).click() - await expect(page).toHaveURL(/\/who-its-for$/) + await navigation.getByRole('link', { name: 'Extensions', exact: true }).click() + await expect(page).toHaveURL(/\/extensions$/) await expect(navigation).toBeHidden() }) @@ -94,6 +94,7 @@ test('site navigation points to real destinations and exposes customisation guid ['Home', '/'], ['About', '/about'], ['Who it’s for', '/who-its-for'], + ['Extensions', '/extensions'], ['Docs', '/docs'], ] as const) { await expect(nav.getByRole('link', { name, exact: true })).toHaveAttribute('href', href) @@ -110,10 +111,13 @@ test('site navigation points to real destinations and exposes customisation guid const customise = page.getByRole('navigation', { name: 'Customise Meith', exact: true }) for (const [name, href] of [ ['Build a theme', '/docs/themes'], - ['Extend with plugins', '/docs/plugins'], - ['Browse extensions', '/marketplace'], + ['Write a plugin', '/docs/plugins'], + ['Connect through the API', '/docs/api'], ] as const) { - await expect(customise.getByRole('link', { name, exact: true })).toHaveAttribute('href', href) + await expect(customise.getByRole('link', { name: new RegExp(`^${name}`) })).toHaveAttribute( + 'href', + href, + ) } const audiences = [ @@ -154,3 +158,85 @@ test('the homepage says what Meith is built on, with a version for each part', a '/docs/architecture', ) }) + +test('old catalogue pages redirect without moving the update feed or images', async ({ + request, +}) => { + for (const path of ['', '/default']) { + const response = await request.get(`/marketplace${path}`, { maxRedirects: 0 }) + expect(response.status()).toBe(308) + expect(response.headers().location).toBe(`/extensions${path}`) + } + for (const path of ['/marketplace/v1.json', '/marketplace/screenshots/default-light.png']) { + const response = await request.get(path, { maxRedirects: 0 }) + expect(response.status()).toBe(200) + } +}) + +test('the forum island switches real themes and palettes without JavaScript', async ({ + browser, +}) => { + const context = await browser.newContext({ javaScriptEnabled: false, colorScheme: 'light' }) + const page = await context.newPage() + await page.goto('http://localhost:3100/') + const preview = page.locator('#board-preview') + await expect(preview.getByRole('radio', { name: 'Default', exact: true })).toBeChecked() + await expect(preview.locator('img, iframe')).toHaveCount(0) + for (const width of [390, 1440]) { + await page.setViewportSize({ width, height: 900 }) + for (const name of ['Default', 'Clubhouse', 'Phasebook', 'Raidframe', 'Midnight']) { + await preview.getByRole('radio', { name, exact: true }).check() + const panel = preview.locator(`[data-board-theme="${name.toLowerCase()}"]`) + await expect(panel).toBeVisible() + await expect(preview.locator('.board-preview-panel:visible')).toHaveCount(1) + await expect( + panel.getByRole('link', { name: 'General discussions', exact: true }), + ).toBeVisible() + if (name === 'Clubhouse') { + const labels = panel + .locator('li') + .first() + .locator('span[class~="md:hidden"]') + .filter({ visible: true }) + await expect(labels).toHaveCount(2) + for (const label of await labels.all()) await expect(label).toHaveCSS('display', 'inline') + } + await preview.getByRole('radio', { name: 'Light', exact: true }).check() + const light = await panel.evaluate((element) => getComputedStyle(element).backgroundColor) + await preview.getByRole('radio', { name: 'Dark', exact: true }).check() + await expect + .poll(() => panel.evaluate((element) => getComputedStyle(element).backgroundColor)) + .not.toBe(light) + expect( + await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), + ).toBe(true) + await expect(page.locator('html')).not.toHaveAttribute('data-theme', 'dark') + } + } + await preview.getByRole('radio', { name: 'Default', exact: true }).check() + await page.keyboard.press('ArrowRight') + await expect(preview.getByRole('radio', { name: 'Clubhouse', exact: true })).toBeChecked() + await expect(preview.getByRole('radio', { name: 'Clubhouse', exact: true })).toBeFocused() + await context.close() +}) + +test('the preview takes the site scheme on load and then changes independently', async ({ + page, +}) => { + await page.emulateMedia({ colorScheme: 'dark' }) + await page.goto('/') + const preview = page.getByRole('group', { name: 'Preview colour scheme', exact: true }) + const site = page.getByRole('group', { name: 'Colour scheme', exact: true }) + await expect(preview.getByRole('radio', { name: 'Dark', exact: true })).toBeChecked() + await site.getByRole('button', { name: 'Light', exact: true }).click() + await expect(preview.getByRole('radio', { name: 'Dark', exact: true })).toBeChecked() + await page.reload() + await expect(preview.getByRole('radio', { name: 'Light', exact: true })).toBeChecked() + await preview.getByRole('radio', { name: 'Dark', exact: true }).check() + await expect(page.locator('html')).toHaveAttribute('data-theme', 'light') + await site.getByRole('button', { name: 'Dark', exact: true }).click() + await preview.getByRole('radio', { name: 'Light', exact: true }).check() + await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark') + await page.reload() + await expect(preview.getByRole('radio', { name: 'Dark', exact: true })).toBeChecked() +}) diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index 88b7cdc7b..bb1bbc847 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -24,6 +24,8 @@ const nextConfig = { async redirects() { return [ + { source: '/marketplace', destination: '/extensions', permanent: true }, + { source: '/marketplace/:key([a-z0-9-]+)', destination: '/extensions/:key', permanent: true }, { source: '/docs/committee-guide', destination: '/docs/organiser-guide', permanent: true }, { source: '/docs/self-hosting', destination: '/docs/docker-compose', permanent: true }, { source: '/docs/theme-api', destination: '/docs/themes', permanent: true }, diff --git a/apps/web/package.json b/apps/web/package.json index ba165dcbe..0bbfce6fa 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,6 +12,12 @@ "dependencies": { "@fontsource/inter": "^5", "@fontsource/lora": "^5", + "@meith/theme-clubhouse": "workspace:^", + "@meith/theme-default": "workspace:^", + "@meith/theme-kit": "workspace:^", + "@meith/theme-midnight": "workspace:^", + "@meith/theme-phasebook": "workspace:^", + "@meith/theme-raidframe": "workspace:^", "@swc/helpers": "0.5.23", "marked": "^18.0.11", "mermaid": "^12.0.0", diff --git a/apps/web/src/components/preview-scheme.tsx b/apps/web/src/components/preview-scheme.tsx new file mode 100644 index 000000000..e8d8fbccc --- /dev/null +++ b/apps/web/src/components/preview-scheme.tsx @@ -0,0 +1,35 @@ +'use client' + +import { useEffect, useState } from 'react' + +export function PreviewScheme() { + const [scheme, setScheme] = useState('light') + + useEffect(() => { + const selected = document.documentElement.dataset.theme + setScheme( + selected === 'dark' || + (selected !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches) + ? 'dark' + : 'light', + ) + }, []) + + return ( +
    + Preview colour scheme + {['light', 'dark'].map((value) => ( + + ))} +
    + ) +} diff --git a/apps/web/src/components/site-footer.tsx b/apps/web/src/components/site-footer.tsx index c5bc1ab8c..100b74ecd 100644 --- a/apps/web/src/components/site-footer.tsx +++ b/apps/web/src/components/site-footer.tsx @@ -5,6 +5,7 @@ import { audienceIndexHref } from '../content/segments' import { licence, licenceHref, site } from '../content/site' import { version } from '../content/version' import { ForumLink } from './forum-link' +import { ThemeToggle } from './theme-toggle' export function SiteFooter() { return ( @@ -17,7 +18,7 @@ export function SiteFooter() {
    +
    + Appearance + +
    v{version} · diff --git a/apps/web/src/components/site-header.tsx b/apps/web/src/components/site-header.tsx index 812ce0887..edd27215c 100644 --- a/apps/web/src/components/site-header.tsx +++ b/apps/web/src/components/site-header.tsx @@ -9,7 +9,6 @@ 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() { const startHref = quickstartHref() @@ -26,6 +25,7 @@ export function SiteHeader() { Home About Who it’s for + Extensions Docs Demo ↗ @@ -39,7 +39,6 @@ export function SiteHeader() {
    - Get started ↗ @@ -61,6 +60,11 @@ export function SiteHeader() { Who it’s for ↗ +
  • + + Extensions ↗ + +
  • Docs ↗ diff --git a/apps/web/src/components/theme-showcase.test.ts b/apps/web/src/components/theme-showcase.test.ts new file mode 100644 index 000000000..4f4788552 --- /dev/null +++ b/apps/web/src/components/theme-showcase.test.ts @@ -0,0 +1,31 @@ +import { createElement } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { expect, it } from 'vitest' + +import { site } from '../content/site' +import { ThemeShowcase } from './theme-showcase' + +it('renders the real theme layouts with safe sample links and native preview controls', () => { + const html = renderToStaticMarkup(createElement(ThemeShowcase)) + const ids = [...html.matchAll(/\bid="([^"]+)"/g)].map((match) => match[1]) + const targets = [...html.matchAll(/aria-controls="([^"]+)"/g)].map((match) => match[1]) + const hrefs = [...html.matchAll(/\bhref="([^"]+)"/g)].map((match) => match[1]) + + expect(new Set(ids).size).toBe(ids.length) + expect(targets).toEqual([ + 'board-default', + 'board-clubhouse', + 'board-phasebook', + 'board-raidframe', + 'board-midnight', + ]) + for (const target of targets) expect(ids).toContain(target) + expect(html.match(/]+name="preview-theme"/g)).toHaveLength(5) + expect(html.match(/]+name="preview-scheme"/g)).toHaveLength(2) + expect(html.match(/ checked=""/g)).toHaveLength(2) + expect(html.match(/data-slot="card-rows"/g)).toHaveLength(2) + expect(html.match(/ (current + by + themes.list.length) % themes.list.length) - } +const forums: readonly ForumRowModel[] = [ + { + title: 'Start here', + description: 'New faces, introductions and a few useful things to know.', + threads: 24, + posts: 108, + latest: 'What brought you here?', + author: 'aoife', + time: '09:42', + }, + { + title: 'General discussions', + description: 'Good questions, considered answers and room to think.', + threads: 86, + posts: 412, + latest: 'What are you working on?', + author: 'sam', + time: '09:16', + }, +].map((forum, index) => ({ + id: index + 1, + title: forum.title, + description: forum.description, + href: site.demo, + type: 'forum', + threadCount: { value: forum.threads, label: String(forum.threads) }, + postCount: { value: forum.posts, label: String(forum.posts) }, + lastPost: { + threadTitle: forum.latest, + href: site.demo, + author: { userId: null, username: forum.author, profileHref: null }, + at: { iso: `2026-09-20T${forum.time}:00Z`, label: forum.time }, + }, + isUnread: false, + subforums: [], +})) +export function ThemeShowcase() { return ( -
    -
    { - if (event.key === 'ArrowRight') { - event.preventDefault() - move(1) - } - if (event.key === 'ArrowLeft') { - event.preventDefault() - move(-1) - } - }} - role="tablist" - > - {themes.list.map((entry, index) => ( - - ))} -
    - -
    -

    {theme.blurb}

    - -
    - {themes.schemes.map((scheme) => ( -
    - -
    {scheme.label}
    -
    +
    +
    +
    + Preview theme + {previews.map((preview, index) => ( + ))} -
    + +
    + {previews.map(({ key, title, Category, Forum, copy, light, dark }, index) => ( +
    + +
    + + {forums.map((forum) => ( + + ))} + +
    +
    + {title} theme · Read-only preview + + Open the demo + +
    +
    + ))}
    ) } diff --git a/apps/web/src/marketplace/catalog.ts b/apps/web/src/marketplace/catalog.ts index 3928b2596..8cb395087 100644 --- a/apps/web/src/marketplace/catalog.ts +++ b/apps/web/src/marketplace/catalog.ts @@ -46,7 +46,7 @@ export async function listingsOfKind(kind: ListingKind): Promise> = { diff --git a/apps/web/src/styles/chrome.css b/apps/web/src/styles/chrome.css index 50dd19801..1cbd7bd64 100644 --- a/apps/web/src/styles/chrome.css +++ b/apps/web/src/styles/chrome.css @@ -303,8 +303,9 @@ .site-footer-bottom { display: flex; + flex-wrap: wrap; align-items: center; - justify-content: flex-end; + justify-content: space-between; gap: 1rem; padding-block: 1.2rem; border-top: 1px solid var(--border); diff --git a/apps/web/src/styles/globals.css b/apps/web/src/styles/globals.css index b4fc9be99..3df98f239 100644 --- a/apps/web/src/styles/globals.css +++ b/apps/web/src/styles/globals.css @@ -1,5 +1,33 @@ @import "tailwindcss"; +@source "../../../../themes/default/src"; +@source "../../../../themes/clubhouse/src"; +@source "../../../../themes/phasebook/src"; +@source "../../../../themes/raidframe/src"; +@source "../../../../themes/midnight/src"; +@source "../../../../packages/ui/src"; + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-primary-hover: var(--primary-hover); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent-foreground: var(--accent-foreground); + --color-input: var(--input); + --color-ring: var(--ring); + --color-forum-read: var(--forum-read); + --color-forum-unread: var(--forum-unread); + --font-heading: var(--font-heading-stack); + --shadow-elevation: var(--elevation); +} + /* * ── The palette ─────────────────────────────────────────────────────────── * diff --git a/apps/web/src/styles/home.css b/apps/web/src/styles/home.css index 2ec59b25f..fe2fadf87 100644 --- a/apps/web/src/styles/home.css +++ b/apps/web/src/styles/home.css @@ -11,69 +11,102 @@ overflow: clip; } -.edition-hero { - background: var(--canvas); +body:has(.editorial-home) .site-header-actions > .site-start-link { + background: transparent; + border-color: var(--border-strong); color: var(--fg); +} + +body:has(.editorial-home) .site-header-actions > .site-start-link:hover { + border-color: var(--accent); +} + +.edition-hero { border-bottom: 1px solid var(--border); } -.edition-running-head { +.edition-running-head, +.board-preview-footer { display: flex; - justify-content: space-between; align-items: center; - gap: 1rem; - padding-block: 1.5rem; - color: var(--fg-muted); + justify-content: space-between; + gap: 1.5rem; +} + +.edition-running-head { + padding-block: 1.75rem; font-family: var(--mono); - font-size: 0.6rem; + font-size: 0.625rem; letter-spacing: 0.075em; + color: var(--fg-muted); } .edition-running-head a { display: flex; + gap: 1rem; align-items: center; - gap: 1.4rem; -} -.edition-running-head a:hover { - color: var(--accent); } -.edition-hero-grid { +.edition-hero-grid, +.edition-split { display: grid; - grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); + grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr); gap: 4rem; align-items: center; - padding-block: 4rem 5.5rem; +} + +.edition-hero-title { + padding-block: 2rem 2.75rem; + border-bottom: 1px solid var(--border-strong); +} + +.edition-hero-grid { + padding-block: 2.75rem 3.5rem; + align-items: center; } .edition-hero h1 { - font-size: clamp(3.5rem, 6.2vw, 6.5rem); + font-size: clamp(4rem, 11.4vw, 10.25rem); + line-height: 1.05; font-weight: 400; - letter-spacing: -0.025em; - line-height: 1.18; + letter-spacing: -0.055em; } .edition-hero h1 em, .edition-section-title h2 em { font-family: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", Georgia, serif; font-weight: 400; - letter-spacing: -0.025em; color: var(--accent); } -.edition-hero-copy > p:first-child { - max-width: 27rem; - margin-bottom: 1.8rem; +.edition-hero-copy > p { + max-width: var(--measure); color: var(--fg-muted); - font-size: clamp(1rem, 1.5vw, 1.25rem); - line-height: 1.9; + font-size: 0.95rem; + line-height: 1.85; +} + +.edition-hero-copy > .edition-hero-lede { + margin-bottom: 0.85rem; + font-size: 1.4rem; + color: var(--fg); + letter-spacing: -0.02em; } .edition-hero-actions { + justify-self: end; +} + +.edition-actions { display: flex; - flex-wrap: wrap; align-items: center; + flex-wrap: wrap; gap: 1.5rem; + margin-top: 1.75rem; +} + +.edition-hero-actions .edition-actions { + margin-top: 0; } .edition-button { @@ -85,191 +118,337 @@ padding: 0.85rem 1.25rem; background: var(--accent); color: var(--accent-contrast); - border: 1px solid transparent; font-size: 0.85rem; font-weight: 500; - transition: - background 160ms, - border-color 160ms; + transition: background 160ms; } .edition-button:hover { background: var(--accent-hover); } + .edition-button span { font-size: 1.2rem; } -.edition-hero-source { + +:is(.edition-button, .edition-text-link) > span { + transition: transform 160ms; +} + +:is(.edition-button, .edition-text-link):where(:hover) > span { + transform: translate(2px, -2px); +} + +.edition-hero-note { + color: var(--fg-muted); + margin-top: 1.2rem; + font-size: 0.7rem; +} + +.edition-text-link { display: inline-flex; align-items: center; gap: 1rem; font-size: 0.8rem; - color: var(--fg-muted); - padding-block: 0.8rem; + padding-block: 0.35rem; + text-decoration: underline; + text-decoration-color: color-mix(in srgb, var(--accent) 60%, transparent); + text-underline-offset: 0.4rem; + transition: color 160ms; } -.edition-hero-source:hover { +.edition-text-link:hover { color: var(--accent); } -.edition-hero-note { - margin-top: 1.4rem; +.edition-label { + font-family: var(--mono); + font-size: 0.625rem; + font-weight: 400; + letter-spacing: 0.075em; color: var(--fg-muted); - font-size: 0.68rem; } -.edition-label { +.edition-customise-content { + align-items: start; +} + +.board-preview { + container-type: inline-size; + border: 1px solid var(--border-strong); + background: var(--canvas); +} + +.board-preview-controls { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.5rem 1.5rem; + border-bottom: 1px solid var(--border-strong); + padding-inline: 0.75rem; +} + +.board-preview-controls fieldset { + display: flex; + align-items: center; + min-width: 0; + flex-wrap: wrap; + gap: 0.25rem; +} + +.board-preview-controls fieldset:first-child { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + width: 100%; +} + +.board-preview-controls fieldset:last-child { + margin-left: auto; +} + +.edition-choice { + position: relative; + display: inline-flex; + min-height: 3rem; + align-items: center; + justify-content: center; + padding-inline: 0.5rem; + border-bottom: 2px solid transparent; color: var(--fg-muted); + font-size: 0.75rem; + cursor: pointer; + transition: + color 160ms, + border-color 160ms; +} + +.edition-choice input { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + opacity: 0; + cursor: pointer; +} + +.edition-choice:has(:checked) { + color: var(--fg); + border-color: var(--accent); + font-weight: 600; +} + +.edition-choice:hover { + color: var(--accent); +} + +.edition-choice:has(:focus-visible) { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.board-preview-panel { + --font-inter: Inter; + --sans: var(--font-sans-stack); + --mono: var(--font-mono-stack); + --arrow: currentColor; + display: none; + color: var(--foreground); + background: var(--background); + font-family: var(--font-sans-stack); + animation: edition-preview-in 180ms ease-out; + transition: + background-color 180ms, + color 180ms; +} + +.board-preview:has([name="preview-theme"][value="clubhouse"]:checked) + [data-board-theme="clubhouse"], +.board-preview:has([name="preview-theme"][value="phasebook"]:checked) + [data-board-theme="phasebook"], +.board-preview:has([name="preview-theme"][value="raidframe"]:checked) + [data-board-theme="raidframe"], +.board-preview:has([name="preview-theme"][value="default"]:checked) [data-board-theme="default"], +.board-preview:has([name="preview-theme"][value="midnight"]:checked) [data-board-theme="midnight"] { + display: block; +} + +.board-preview-content { + padding: 1rem; +} + +.board-preview-content :focus-visible { + outline-color: var(--ring); +} + +.board-preview-footer { + padding: 0.85rem 1rem; + flex-wrap: wrap; + border-top: 1px solid var(--border); + color: var(--muted-foreground); font-family: var(--mono); - font-size: 0.6rem; - letter-spacing: 0.075em; + font-size: 0.65rem; +} + +@keyframes edition-preview-in { + from { + opacity: 0; + transform: translateY(3px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.edition-band { + padding-block: 5.5rem; +} + +.edition-section-intro { + margin-bottom: 3rem; + align-items: end; } .edition-section-title h2 { + margin-top: 1.2rem; font-size: clamp(2.1rem, 3.5vw, 3.35rem); font-weight: 400; line-height: 1.25; letter-spacing: -0.025em; - margin-top: 1.4rem; } -.edition-audiences { - padding-block: 4.5rem 5rem; +.edition-copy { + max-width: var(--measure); + color: var(--fg-muted); + font-size: 0.95rem; + line-height: 1.9; + text-wrap: pretty; } -.edition-audiences-heading { - display: flex; - justify-content: space-between; - align-items: flex-end; - gap: 2rem; - margin-bottom: 2.75rem; +.edition-section-title > .edition-copy, +.edition-section-title > .edition-text-link, +.edition-copy > .edition-text-link { + margin-top: 1.5rem; } -.edition-audience-grid { +.edition-feature-grid { display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 2.25rem; } -.edition-audience-grid > a { - padding-block: 1.5rem 1rem; +.edition-feature-grid > a { + padding-top: 1.5rem; border-top: 1px solid var(--border-strong); transition: border-color 180ms; } -.edition-audience-grid > a:hover { +.edition-feature-grid > a:hover { border-color: var(--accent); } -.edition-audience-grid > a > div { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 0.8rem; -} - -.edition-audience-grid h3 { - font-size: 1.02rem; +.edition-feature-grid h3, +.edition-ownership-list h3, +.edition-audience-list h3, +.edition-extension-links h3 { + font-size: 1rem; line-height: 1.55; font-weight: 500; - transition: color 180ms; } -.edition-audience-grid > a:hover h3 { - color: var(--accent); +.edition-feature-grid h3 { + margin-top: 1.4rem; } -.edition-audience-grid p { - margin-top: 0.85rem; - max-width: 26ch; +.edition-feature-grid :where(h3) span { + display: inline-block; color: var(--fg-muted); - font-size: 0.85rem; - line-height: 1.85; + transition: transform 160ms; } -.edition-text-link { - display: inline-flex; - align-items: center; - gap: 1.5rem; - font-size: 0.77rem; - padding-bottom: 0.35rem; - border-bottom: 1px solid currentColor; - transition: color 160ms; +.edition-feature-grid :where(a:hover h3) span { + transform: translate(2px, -2px); } -.edition-text-link:hover { - color: var(--accent); +.edition-feature-grid p, +.edition-ownership-list p, +.edition-audience-list p, +.edition-extension-links p { + margin-top: 0.65rem; + color: var(--fg-muted); + font-size: 0.85rem; + line-height: 1.85; } -.edition-own { - border-top: 1px solid var(--border); +.edition-own, +.edition-audiences, +.edition-start { + border-block: 1px solid var(--border); background: var(--surface); - padding-block: 4.5rem; } -.edition-own-grid { - display: grid; - grid-template-columns: minmax(0, 1.05fr) minmax(0, 1fr); - gap: 4rem; +.edition-own .edition-split, +.edition-audiences .edition-split { align-items: start; } -.edition-own-lede { - margin-top: 1.5rem; - max-width: 30rem; - color: var(--fg-muted); - font-size: 0.95rem; - line-height: 1.9; +.edition-sticky { + position: sticky; + top: 7rem; } -.edition-own-points { +.edition-ownership-list > li { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 2rem 2.25rem; + grid-template-columns: 2.5rem 1fr; + gap: 0.75rem; + border-top: 1px solid var(--border-strong); + padding-block: 1.5rem; } -.edition-own-points > li { - padding-top: 1.1rem; - border-top: 1px solid var(--border-strong); +.edition-ownership-list > li > span { + padding-top: 0.3rem; } -.edition-own-points h3 { - font-size: 0.95rem; - font-weight: 500; - line-height: 1.5; +.edition-extension-links > a { + display: grid; + grid-template-columns: 1fr auto; + gap: 0 1rem; + padding-block: 1.3rem; + border-top: 1px solid var(--border-strong); + transition: color 160ms; } -.edition-own-points p { - margin-top: 0.7rem; - max-width: 32ch; - color: var(--fg-muted); - font-size: 0.85rem; - line-height: 1.8; +.edition-extension-links p { + grid-column: 1; + max-width: 36ch; } -.edition-stack { - border-top: 1px solid var(--border); - background: var(--canvas); - padding-block: 4.5rem; +.edition-extension-links > a:hover { + color: var(--accent); } -.edition-stack-heading { +.edition-audience-list > a { display: grid; - grid-template-columns: minmax(0, 1.05fr) minmax(0, 1fr); - gap: 4rem; - align-items: end; - margin-bottom: 2.75rem; + grid-template-columns: 1fr auto; + column-gap: 1rem; + padding-block: 1.3rem; + border-top: 1px solid var(--border-strong); + transition: border-color 180ms; } -.edition-stack-copy > p { - max-width: 30rem; - color: var(--fg-muted); - font-size: 0.95rem; - line-height: 1.9; +.edition-audience-list > a:hover { + border-color: var(--accent); } -.edition-stack-copy > .edition-text-link { - margin-top: 1.5rem; +.edition-audience-list p { + grid-column: 1; + margin-top: 0.35rem; +} + +.edition-audience-list > a > span { + grid-column: 2; + grid-row: 1; } .edition-stack-grid { @@ -299,8 +478,10 @@ .edition-stack-version { color: var(--accent); font-family: var(--mono); - font-size: 0.78rem; - letter-spacing: 0.075em; + font-size: 1.375rem; + font-weight: 500; + line-height: 1; + letter-spacing: -0.04em; } .edition-stack-grid p { @@ -310,66 +491,22 @@ line-height: 1.8; } -.edition-stack-note { - margin-top: 2.5rem; +.edition-small { + margin-top: 1.25rem; color: var(--fg-muted); - font-family: var(--mono); - font-size: 0.6rem; - letter-spacing: 0.075em; -} - -.edition-customise { - border-top: 1px solid var(--border); - padding-block: 4.5rem; - background: var(--canvas); -} - -.edition-customise-grid, -.edition-start-grid { - display: grid; - grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); - gap: 4rem; - align-items: center; -} - -.edition-customise-copy > p { - max-width: 32rem; - font-size: 0.95rem; - color: var(--fg-muted); - line-height: 1.9; -} - -.edition-customise-copy nav { - margin-top: 1.5rem; - border-top: 1px solid var(--border-strong); -} - -.edition-customise-copy nav a { - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; - padding-block: 1rem; - border-bottom: 1px solid var(--border-strong); - font-size: 0.85rem; - transition: color 180ms; -} - -.edition-customise-copy nav a:hover { - color: var(--accent); + font-size: 0.75rem; + line-height: 1.8; } .edition-start { - border-top: 1px solid var(--border); - background: var(--surface); - color: var(--fg); - padding-block: 4.5rem; + border-bottom: 0; } .edition-start-actions .command { display: flex; justify-content: space-between; width: 100%; + margin-top: 1rem; padding: 1rem; border-color: var(--border-strong); border-radius: 0; @@ -381,41 +518,17 @@ border-radius: 0; } -.edition-start-actions > div:not(.command) { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 1.5rem; - margin-top: 1.5rem; -} - -.edition-start-actions > p { - margin-top: 1.5rem; - color: var(--fg-muted); - font-size: 0.67rem; -} - -@media (min-width: 90rem) { - .editorial-home .shell { - width: min(78rem, 100% - 5rem); - } -} - @media (max-width: 60rem) { .edition-hero-grid, - .edition-customise-grid, - .edition-start-grid { - gap: 2rem; - } - .edition-own-grid, - .edition-stack-heading { - grid-template-columns: 1fr; + .edition-split { gap: 2.5rem; } - .edition-stack-heading { + + .edition-feature-grid, + .edition-stack-grid { gap: 1.5rem; } - .edition-audience-grid, + .edition-stack-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } @@ -423,96 +536,106 @@ @media (max-width: 46rem) { .edition-running-head { - padding-block: 1rem; - font-size: 0.52rem; + padding-block: 1.25rem; + font-size: 0.55rem; } + .edition-running-head a { - gap: 0.6rem; - font-size: 0.48rem; + gap: 0.5rem; } - .edition-hero-grid { + + .edition-hero-grid, + .edition-split { grid-template-columns: 1fr; - gap: 2rem; - padding-block: 2.5rem 3rem; + gap: 1.75rem; } - .edition-hero h1 { - font-size: clamp(3.7rem, 12vw, 5.5rem); + + .edition-hero-title { + padding-block: 1.5rem 2rem; } - .edition-hero-copy > p:first-child { - margin-bottom: 1.5rem; - font-size: 1rem; + + .edition-hero-grid { + padding-block: 2rem 3rem; + gap: 1.75rem; } + .edition-hero-actions { - gap: 1rem; + justify-self: start; } - .edition-hero-source { - font-size: 0.75rem; + + .edition-hero h1 { + font-size: clamp(3.5rem, 14.5vw, 6.5rem); + line-height: 1.08; } - .edition-button { - gap: 1.5rem; - font-size: 0.78rem; + + .edition-hero h1 em { + display: block; } - .edition-audiences { - padding-block: 3rem; + + .edition-hero-copy > .edition-hero-lede { + font-size: 1.15rem; } - .edition-audiences-heading { - align-items: flex-start; - flex-direction: column; - gap: 1.5rem; - margin-bottom: 2rem; + + .edition-band { + padding-block: 3.5rem; } - .edition-audience-grid { - gap: 1.5rem; + + .edition-section-intro { + margin-bottom: 2rem; } - .edition-audience-grid h3 { - font-size: 0.95rem; + + .edition-feature-grid { + grid-template-columns: 1fr; + gap: 1.75rem; } - .edition-audience-grid p { - font-size: 0.8rem; + + .edition-sticky { + position: static; } - .edition-section-title h2 { - margin-top: 1rem; + + .edition-feature-grid h3 { + margin-top: 0.75rem; } - .edition-label { - font-size: 0.57rem; + + .board-preview-controls fieldset:first-child .edition-choice { + padding-inline: 0; + font-size: 0.6875rem; } - .edition-own, - .edition-stack, - .edition-customise, - .edition-start { - padding-block: 3rem; + + .board-preview-content { + padding: 1.25rem 1rem; } - .edition-stack-heading { - margin-bottom: 2rem; + + .board-preview-footer { + padding: 0.85rem 1rem; + flex-wrap: wrap; + gap: 0.25rem 1rem; } + .edition-stack-grid { - gap: 1.5rem; - } - .edition-stack-note { - font-size: 0.57rem; - } - .edition-own-points { - grid-template-columns: 1fr; - gap: 1.75rem; - } - .edition-customise-grid, - .edition-start-grid { grid-template-columns: 1fr; - gap: 2rem; } + .edition-start-actions .command { - font-size: 0.68rem; padding-inline: 0.75rem; + font-size: 0.7rem; } } -@media (max-width: 25rem) { - .edition-audience-grid, - .edition-stack-grid { - grid-template-columns: 1fr; - gap: 0.75rem; +@container (max-width: 48rem) { + .board-preview-content :is(td, th, col)[class~="hidden"] { + display: none; } - .edition-audience-grid p { - max-width: none; + + .board-preview-content [class~="sm:hidden"] { + display: block; + } + + .board-preview-content p[class~="md:hidden"] { + display: flex; + } + + .board-preview-content span[class~="md:hidden"] { + display: inline; } } diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 37205a0a4..dd8eb5f8e 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -3,25 +3,6 @@ "compilerOptions": { "jsx": "preserve", "plugins": [{ "name": "next" }], - /* - * No path aliases at all, and specifically no `@/*`. - * - * `@` is already spoken for across this workspace: it resolves to - * `apps/community/src` in `vitest.config.ts` and in - * `.dependency-cruiser.webpack.cjs`, both of which are single-valued and - * repository-wide. A second app claiming the same prefix would hand a test - * importing `@/…` from here a module out of the *community* — silently, and only - * in the tools, so `tsc` and `next build` would both be green while - * `pnpm test` compiled something else entirely. - * - * Relative imports instead. This app is shallow enough that the deepest one - * is three levels, and they are unambiguous in every tool without a third - * place to register them. - * - * TypeScript replaces `paths` rather than merging it, so the workspace - * aliases in tsconfig.base.json are deliberately not inherited: this app - * depends on no `@meith/*` package, and that is worth keeping visible. - */ "paths": {} }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], diff --git a/docs/contributing/brand.md b/docs/contributing/brand.md index b7989f11a..3f8cb7ce2 100644 --- a/docs/contributing/brand.md +++ b/docs/contributing/brand.md @@ -67,6 +67,18 @@ Use actual product captures with fixture data, descriptive alt text and paired l Exclude browser chrome, device mockups, stock/generated imagery, mascots and decorative icons. Use a flat screenshot with a border. Diagrams use the brand tokens. +## Homepage and live preview + +The homepage introduces Meith as open-source, self-hosted forum software, then explains conversations, moderation, ownership, extensions and deployment. The hero is a simple editorial composition: one large mixed sans/serif headline, a fine dividing rule, a clear introduction on the left and the setup/demo actions on the right. Whitespace and typography establish the hierarchy. The actions stack below the introduction on phones. Keep the opening editorial; the compact, two-forum live preview sits below the left-hand “Make it yours” heading, with extension guides in the right column. The columns stack on phones. The technology section explains the role of each part of the stack alongside major versions read from the repository. Versions use slightly larger, medium-weight accent mono numerals beside the technology name. Keep hosting costs distinct from Meith’s lack of licence and per-member fees. The local scaffold uses sample data. The closing setup note explicitly distinguishes that local preview from arranging a domain, hosting and PostgreSQL for a live board, and links to deployment and ongoing operation instructions. + +`apps/web/src/components/theme-showcase.tsx` renders the real `CategoryBlock` and `ForumRow` slots from the Default, Clubhouse, Phasebook, Raidframe and Midnight theme packages with static, fictional view models and each theme’s English copy and colour tokens. It does not load screenshots, board services or member data. Preview links lead to the configured demo; the footer identifies the sample as read-only. + +The primary and mobile menus include Extensions at `/extensions`. The catalogue’s three closing actions are all underlined secondary links with ↗ arrows; none is a filled primary action. The page colour controls sit in the footer beside an Appearance label. + +Default is the initial preview theme. Native radio groups select the preview theme and its light/dark scheme. `preview-scheme.tsx` reads the site’s selected scheme (or system preference) once when the preview mounts. After that, the two colour controls are independent; preview changes are not saved to the site preference. Without JavaScript, the preview starts light and the radios still work. CSS scopes theme tokens to the preview panels, so these controls work without JavaScript and do not change the marketing page’s scheme. Selection has a weight and underline change; keyboard focus retains the accent outline. The short panel transition respects reduced motion. The site scans the shared theme and UI sources for their Tailwind utilities. Container rules keep the table themes in their compact layouts inside the half-width preview, while Clubhouse’s count labels stay inline beside their numbers. + +Check all five themes in both schemes at desktop and phone widths, including arrow-key selection, focus visibility and overflow. Run `pnpm exec vitest run apps/web/src/components/theme-showcase.test.ts` to check server-rendered layouts, control targets, unique IDs and read-only sample links. + ## Source files | Concern | Source under `apps/web` | diff --git a/docs/contributing/marketplace-maintenance.md b/docs/contributing/marketplace-maintenance.md index 3f452d574..3c74d6664 100644 --- a/docs/contributing/marketplace-maintenance.md +++ b/docs/contributing/marketplace-maintenance.md @@ -13,6 +13,8 @@ The generator reads `marketplace/listings` and `marketplace/screenshots`, then w It validates kind, key, version, compatibility range, unique keys/packages and referenced screenshots. Images must have a PNG signature and be smaller than 5,000,000 bytes. The check rejects orphan source or output files; generation deletes them. Output is sorted by key and has no timestamp. +The public catalogue is at `/extensions`, with detail pages at `/extensions/:key`. The former `/marketplace` and listing URLs redirect permanently to these pages. Feed and screenshot URLs stay under `/marketplace/` so installed boards and published listings keep working. + Keep `/marketplace/v1.json` compatible. Publish a changed schema at a new versioned path. ## Board update checks diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 46a727a8d..905684cb2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -286,6 +286,24 @@ importers: '@fontsource/lora': specifier: ^5 version: 5.3.0 + '@meith/theme-clubhouse': + specifier: workspace:^ + version: link:../../themes/clubhouse + '@meith/theme-default': + specifier: workspace:^ + version: link:../../themes/default + '@meith/theme-kit': + specifier: workspace:^ + version: link:../../packages/theme-kit + '@meith/theme-midnight': + specifier: workspace:^ + version: link:../../themes/midnight + '@meith/theme-phasebook': + specifier: workspace:^ + version: link:../../themes/phasebook + '@meith/theme-raidframe': + specifier: workspace:^ + version: link:../../themes/raidframe '@swc/helpers': specifier: 0.5.23 version: 0.5.23