diff --git a/README.md b/README.md index 42c1383..14bf4ff 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ and human-ok are the same board, not two products. | Front door | | |---|---| -| **Pages** | Server-rendered HTML, no client-side JavaScript, three skins. | +| **Pages** | Server-rendered HTML, no client-side JavaScript, five skins. | | **App** | An installable PWA: manifest, service worker, offline page. [Docs](docs/PWA.md) | | **API** | `/api/v1`, permission-checked, with an OpenAPI description. [Docs](docs/API.md) | | **CLI** | `tsbb`, with `--json` on every command. [Docs](docs/CLI.md) | @@ -122,10 +122,12 @@ else. | `modern` | Cards, generous spacing, soft shadows. The default. | | `classic` | A 2000s bulletin board: boxy, dense, gradient title bars, Verdana. | | `terminal` | Neutral surfaces, hairline rules, monospace chrome, window furniture on section headers. | +| `deals` | A coupon and deal-sharing storefront: white, slate, flat accent fills, Geist. | +| `paper` | Editorial: warm paper, ink, Georgia headlines, mono labels, hairline rules, a full site footer. Light-only. | Full guide: **[docs/SKINS.md](docs/SKINS.md)**. -`classic` and `terminal` are **layers on top of** the modern sheet rather than +`classic`, `terminal`, `deals` and `paper` are **layers on top of** the modern sheet rather than replacements, so a component's structure is defined in exactly one place and a skin only argues about how it looks. Two full stylesheets drift apart within a week. diff --git a/apps/cli/package.json b/apps/cli/package.json index 6a141b1..31beb27 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/tsbb", - "version": "0.12.1", + "version": "0.13.0", "description": "A TypeScript bulletin board. Forums, plugins, avatars, signatures and a terminal client.", "type": "module", "license": "MIT", diff --git a/apps/mcp/package.json b/apps/mcp/package.json index 1e7d4c3..d1cbea3 100644 --- a/apps/mcp/package.json +++ b/apps/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/tsbb-mcp", - "version": "0.12.1", + "version": "0.13.0", "description": "MCP server for a tsbb bulletin board. Lets an AI assistant read and post on any board, as the member whose token it holds.", "type": "module", "license": "MIT", diff --git a/apps/server/package.json b/apps/server/package.json index 105e09d..5ef7a1a 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "@tsbb/server", - "version": "0.12.1", + "version": "0.13.0", "private": true, "type": "module", "dependencies": { diff --git a/apps/server/src/context.ts b/apps/server/src/context.ts index 217cec1..fda4adc 100644 --- a/apps/server/src/context.ts +++ b/apps/server/src/context.ts @@ -9,6 +9,7 @@ import { isSkin, stylesheetUrl, SKIN_THEME_COLOR, + LIGHT_ONLY_SKINS, type Brand, type LayoutSlots, type Skin, @@ -27,6 +28,16 @@ export function brandOf(settings: Record): Brand { return { accent: typeof accent === 'string' && accent.trim() ? accent.trim() : null }; } +/** The host name of an absolute URL, for labelling a host site's links. */ +function hostOf(href: string | undefined): string | undefined { + if (!href || !/^https?:/i.test(href)) return undefined; + try { + return new URL(href).hostname.replace(/^www\./, ''); + } catch { + return undefined; + } +} + /** A configured URL, or undefined for the empty string every setting defaults to. */ function urlSetting(settings: Record, key: string): string | undefined { const value = settings[key]; @@ -100,29 +111,51 @@ function navFor(viewer: Viewer, path: string): NavItem[] { * says `{board}` marks where the board's own items (Forums, Latest, Members) * go, and `{forums}` puts only the Forums link there, for a site whose header * has no room for more; without either, the site's links come first. + * `Label | {forums}` does the same under the host site's own word for it + * ("Community"), and a link ending `| button` is drawn as the header's call to + * action beside the account controls rather than as a nav item. * * Only absolute http(s) URLs are accepted. A root-relative path would be read * as a path on the board, and a board mounted under /bbs would rewrite it. */ -export function siteLinks(settings: Record): { items: NavItem[]; forumsOnly: boolean } { +export function siteLinks(settings: Record): { + items: NavItem[]; + actions: NavItem[]; + forumsOnly: boolean; + forumsLabel: string | null; +} { const lines = String(settings['board.navLinks'] ?? '') .split('\n') .map((line) => line.trim()) .filter(Boolean); - const isToken = (line: string) => line === '{board}' || line === '{forums}'; - const boardAt = lines.findIndex(isToken); + const parts = (line: string) => line.split('|').map((part) => part.trim()); + const tokenOf = (line: string) => { + const bits = parts(line); + const token = bits.length === 1 ? bits[0] : bits.length === 2 ? bits[1] : ''; + return token === '{board}' || token === '{forums}' ? token : null; + }; + const boardAt = lines.findIndex((line) => tokenOf(line) !== null); const items: NavItem[] = []; + const actions: NavItem[] = []; lines.forEach((line, index) => { - if (isToken(line)) return; - const bar = line.indexOf('|'); - if (bar < 1) return; - const label = line.slice(0, bar).trim(); - const href = line.slice(bar + 1).trim(); + if (tokenOf(line)) return; + const [label = '', href = '', kind = ''] = parts(line); if (!label || !/^https?:\/\/[^\s"'<>]+$/i.test(href)) return; + if (kind.toLowerCase() === 'button') { + actions.push({ label, href, weight: index }); + return; + } const after = boardAt !== -1 && index > boardAt; items.push({ label, href, weight: after ? 1000 + index : -1000 + index }); }); - return { items, forumsOnly: lines[boardAt] === '{forums}' }; + const token = boardAt === -1 ? null : tokenOf(lines[boardAt]!); + const named = boardAt === -1 ? [] : parts(lines[boardAt]!); + return { + items, + actions, + forumsOnly: token === '{forums}', + forumsLabel: token === '{forums}' && named.length === 2 && named[0] ? named[0] : null, + }; } export interface RenderOptions { @@ -177,15 +210,23 @@ export async function render( const slots: LayoutSlots = { head, header, bodyStart, bodyEnd, footer }; const site = siteLinks(settings as Record); - const own = navFor(viewer, url.pathname).filter( - // `{forums}` asks for the board's Forums link alone in the site's nav. - (item) => !site.forumsOnly || item.href === '/', - ); + const board = navFor(viewer, url.pathname); + const own = site.forumsOnly + ? // `{forums}` asks for the board's Forums link alone in the site's nav. + // It is then the board's only entry in someone else's header, so it is + // the current section on every board page, not just the front one. + board + .filter((item) => item.href === '/') + .map((item) => ({ ...item, label: site.forumsLabel ?? item.label, match: 'active' })) + : board; const nav = (await bus.applyFilter('nav:items', [...own, ...site.items], renderContext)).sort( (a, b) => (a.weight ?? 0) - (b.weight ?? 0), ); const skin = skinOf(settings as Record); + // A light-only skin is light for everybody: it has no dark palette to show, + // so neither a cookie nor board.theme can ask it for one. + const lightOnly = LIGHT_ONLY_SKINS.includes(skin); const boardName = String(settings['board.name'] ?? 'tsbb'); const tagline = String(settings['board.tagline'] ?? '').trim(); const operator = String(settings['board.operator'] ?? '').trim() || undefined; @@ -236,6 +277,16 @@ export async function render( nav, unread, stylesheetUrl: stylesheetUrl(skin, brandOf(settings as Record)), + actions: site.actions, + footerNav: { + site: site.items, + siteLabel: hostOf(urlSetting(settings as Record, 'board.logoHref')) ?? hostOf(site.items[0]?.href), + board: [ + ...board.filter((item) => !item.requires).map((item) => ({ ...item, match: undefined })), + { label: 'Search', href: '/search' }, + ], + }, + showThemeToggle: !lightOnly, logoUrl: urlSetting(settings as Record, 'board.logoUrl'), logoHref: urlSetting(settings as Record, 'board.logoHref'), faviconUrl: urlSetting(settings as Record, 'board.faviconUrl'), @@ -243,7 +294,7 @@ export async function render( canonical: options.canonical ?? boardUrl(url.pathname, services.baseUrl), feedUrl: options.feedUrl, slots, - theme: c.get('theme') ?? readTheme(c, settings as Record), + theme: lightOnly ? 'light' : (c.get('theme') ?? readTheme(c, settings as Record)), body: options.body as never, }); diff --git a/apps/server/src/routes/admin.ts b/apps/server/src/routes/admin.ts index d574b8c..483e4e4 100644 --- a/apps/server/src/routes/admin.ts +++ b/apps/server/src/routes/admin.ts @@ -235,7 +235,7 @@ export function adminRoutes(services: Services) { 'board.contactEmail': 'Shown on the About page and published in /.well-known/security.txt. Leave empty to use the address the board sends from.', 'board.skin': - 'modern is cards and generous spacing. classic is a 2000s bulletin board: boxy, dense, gradient title bars. terminal is neutral surfaces, hairline rules and monospace chrome. deals is a coupon or shopping storefront: white, slate, flat accent buttons, Geist, a card grid. Same board either way — only the stylesheet changes.', + 'modern is cards and generous spacing. classic is a 2000s bulletin board: boxy, dense, gradient title bars. terminal is neutral surfaces, hairline rules and monospace chrome. deals is a coupon or shopping storefront: white, slate, flat accent buttons, Geist, a card grid. paper is editorial: warm paper, ink, Georgia headlines, hairline rules, a full site footer; it is light-only, so it ignores the theme setting. Same board either way — only the stylesheet changes.', 'board.theme': 'What a reader who has never touched the theme toggle sees. system follows their operating system. Anyone who does use the toggle keeps their own choice either way.', 'board.accent': @@ -246,7 +246,7 @@ export function adminRoutes(services: Services) { 'Where the header logo points. / is this board. An absolute URL is for a board that is one room in a larger site — the nav still leads back to the front page, so nobody is stranded.', 'board.faviconUrl': 'A URL to a browser-tab icon. Replaces the bundled tsbb icons.', 'board.navLinks': - 'Your site\'s own navigation, for a board inside a larger site. One link per line as Label | https://example.com/page. Put {board} on its own line where Forums, Latest and Members should sit, or {forums} for the Forums link alone; without either your links come first. Absolute URLs only.', + 'Your site\'s own navigation, for a board inside a larger site. One link per line as Label | https://example.com/page. Put {board} on its own line where Forums, Latest and Members should sit, or {forums} for the Forums link alone (Community | {forums} renames it); without either your links come first. End a line with | button to make it the header call to action. Absolute URLs only.', 'board.showPlatform': 'A panel at the foot of the front page telling a visitor what the software under this board can do: the API, the CLI, MCP, the app, plugins. Guests only, never members. Turn it off for a board whose readers are not looking for one of their own.', 'signatures.minPosts': diff --git a/apps/server/src/routes/auth.ts b/apps/server/src/routes/auth.ts index 00a67b6..bc35122 100644 --- a/apps/server/src/routes/auth.ts +++ b/apps/server/src/routes/auth.ts @@ -291,7 +291,9 @@ async function authPage( : `Signing in with ${via?.name ?? 'that account'} did not work. Try again, or use your email below.` : null; - const body = html`
+ // A class, not a style attribute: style-src has no 'unsafe-inline', so the + // attribute was refused and the form ran the full width of the page. + const body = html`
${Card(html` ${CardHeader(heading, { description: 'We email you a link. There is no password to choose or forget.', diff --git a/apps/server/src/routes/docs.ts b/apps/server/src/routes/docs.ts index f12146f..968cbd5 100644 --- a/apps/server/src/routes/docs.ts +++ b/apps/server/src/routes/docs.ts @@ -73,7 +73,7 @@ export const DOCS: Doc[] = [ { slug: 'skins', file: 'SKINS.md', - blurb: 'The three skins, the terminal client, and the five branding settings.', + blurb: 'The five skins, the terminal client, and the branding settings.', }, { slug: 'network', diff --git a/apps/server/src/routes/write.ts b/apps/server/src/routes/write.ts index 3916e0e..c6644c1 100644 --- a/apps/server/src/routes/write.ts +++ b/apps/server/src/routes/write.ts @@ -379,7 +379,7 @@ function composerPage( options: ComposerOptions, ) { const format = String(options.settings['posts.defaultFormat'] ?? 'markdown'); - return html`
+ return html`
${Card(html` ${CardHeader(options.heading)} ${CardContent(html` diff --git a/apps/tui/package.json b/apps/tui/package.json index 8f479d2..880b7bd 100644 --- a/apps/tui/package.json +++ b/apps/tui/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/tsbb-tui", - "version": "0.12.1", + "version": "0.13.0", "description": "Terminal client for a tsbb bulletin board. Reads and posts against any centralised install.", "type": "module", "license": "MIT", diff --git a/apps/worker/package.json b/apps/worker/package.json index 5f21a8d..35d053c 100644 --- a/apps/worker/package.json +++ b/apps/worker/package.json @@ -1,6 +1,6 @@ { "name": "@tsbb/worker", - "version": "0.12.1", + "version": "0.13.0", "private": true, "type": "module", "dependencies": { diff --git a/docs/SKINS.md b/docs/SKINS.md index ec9a72f..5e67296 100644 --- a/docs/SKINS.md +++ b/docs/SKINS.md @@ -11,11 +11,60 @@ look and not another. | `classic` | A 2000s bulletin board: boxy, dense, gradient title bars, Verdana. | | `terminal` | Neutral surfaces, hairline rules, monospace chrome, window furniture on section headers. | | `deals` | A coupon and deal-sharing storefront: white surfaces, slate greys, the accent as a flat fill with white text, Geist (self-hosted), a dark footer. Made for a board that lives inside a deals site, like [c0upons.com/bbs](https://c0upons.com/bbs). | +| `paper` | Editorial: warm paper (`#f6f4ed`), green-black ink, sage hairlines, Georgia headlines, small mono capitals for labels, system fonts only. A 100px header ruled off by a hairline, forums as catalogue cards, topics as a ruled list, code as a dark terminal, a three-column site footer. **Light-only.** Made for a board that lives inside a publication-style site, like [w3bs.org/bbs](https://w3bs.org/bbs). | -`classic`, `terminal` and `deals` are **layers on top of** the modern sheet rather than -replacements. A component's structure is defined in exactly one place, and a -skin only argues about how it looks. That is why a new component appears in all -three the day it is written. +`classic`, `terminal`, `deals` and `paper` are **layers on top of** the modern +sheet rather than replacements. A component's structure is defined in exactly +one place, and a skin only argues about how it looks. That is why a new +component appears in every skin the day it is written. + +## The paper skin + +`paper` is for a board that is one room in an editorial site: it should look +like the site's own pages, not like a forum bolted on. It follows the accent +like every skin (primary buttons fill with it, links and the current page are +set in it, and with no accent it brings its own vermilion, `#d95231`), but every +neutral is its own: paper, ink, sage lines. + +What it does differently from the other skins: + +- **It is light-only.** Paper has no dark palette, so the board renders light + for every reader, ignores `board.theme` and a reader's theme cookie, and drops + the theme toggle. A light-only host site with a board that went dark on the + reader's OS setting would stop looking like one site. +- **The wordmark is the logo plus the board name**, with a full stop in the + accent: set `board.logoUrl` to a square mark and `board.name` to the word. +- **The header is the host site's header**: no search box (search is in the + footer), plain-text navigation, the sign-in link as bold text, and the host's + call to action as an outlined button (a `| button` line in `board.navLinks`). +- **The footer is a site footer**: the wordmark and tagline, a column of the + host site's links (headed with its host name), a column of the board's own, + and the copyright line as a mono colophon. + +A board inside w3bs.org, for example, is set up as: + +| Setting | Value | +|---|---| +| `board.skin` | `paper` | +| `board.accent` | `#d95231` | +| `board.name` | `w3bs` | +| `board.logoUrl` | `https://w3bs.org/assets/mark.svg` | +| `board.logoHref` | `https://w3bs.org/mission` | +| `board.theme` | `light` | +| `board.showPlatform` | off | + +with `board.navLinks`: + +``` +Packages | https://w3bs.org/packages +Standards | https://w3bs.org/specs +Browse | https://w3bs.org/browse +Prompts | https://w3bs.org/prompts +Desktop | https://w3bs.org/desktop +Community | {forums} +Participate | https://w3bs.org/governance +Start building ↗ | https://w3bs.org/developers | button +``` ## The terminal look, and the actual terminal @@ -39,7 +88,7 @@ Six settings sit beside the skin. | `board.logoUrl` | Your own artwork in the header, replacing the generated letter mark. | | `board.logoHref` | Where that logo points. `/` is the board; an absolute URL is for a board that is one room in a larger site. | | `board.faviconUrl` | Your own browser-tab icon. | -| `board.navLinks` | Your site's own navigation for a board inside a larger site: one `Label \| https://…` per line, `{board}` where Forums, Latest and Members go, or `{forums}` for Forums alone. The `deals` skin lays the header out like a storefront: logo, search with its button, then this nav. | +| `board.navLinks` | Your site's own navigation for a board inside a larger site: one `Label \| https://…` per line, `{board}` where Forums, Latest and Members go, or `{forums}` for Forums alone (`Community \| {forums}` to call it something else; it is then the current section on every board page). A line ending `\| button` becomes the header's call to action. The `deals` skin lays the header out like a storefront: logo, search with its button, then this nav; `paper` lays it out like the host site's header. | Two things about the accent are worth knowing, because both are the difference between a setting that works and one that looks broken on half the boards using diff --git a/package.json b/package.json index 0e3c6be..6d6e030 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tsbb", - "version": "0.12.1", + "version": "0.13.0", "private": true, "type": "module", "packageManager": "pnpm@11.18.0", diff --git a/packages/client/package.json b/packages/client/package.json index d8acc2b..040b695 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@tsbb/client", - "version": "0.12.1", + "version": "0.13.0", "private": true, "type": "module", "exports": { diff --git a/packages/core/package.json b/packages/core/package.json index 1b05c18..5250878 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@tsbb/core", - "version": "0.12.1", + "version": "0.13.0", "private": true, "type": "module", "exports": { diff --git a/packages/core/src/settings.ts b/packages/core/src/settings.ts index e3381cb..4fd7fcb 100644 --- a/packages/core/src/settings.ts +++ b/packages/core/src/settings.ts @@ -14,7 +14,10 @@ export const DEFAULT_SETTINGS = { 'board.timezone': 'UTC', /** 'system' | 'light' | 'dark' — what a reader with no cookie is shown. */ 'board.theme': 'system', - /** 'modern', 'classic' (the 2000s bulletin board), 'terminal' or 'deals' (a coupon/deal storefront). */ + /** + * 'modern', 'classic' (the 2000s bulletin board), 'terminal', 'deals' (a + * coupon/deal storefront) or 'paper' (editorial, light-only). + */ 'board.skin': 'modern', /** * One hex colour that links, buttons and highlights follow. EMPTY means the @@ -60,7 +63,9 @@ export const DEFAULT_SETTINGS = { 'board.showPlatform': true, /** * The host site's navigation, for a board inside a larger site: one - * `Label | https://…` per line, and `{board}` where the board's own items go. + * `Label | https://…` per line, and `{board}` where the board's own items go + * (`{forums}`, or `Community | {forums}`, for the Forums link alone). A line + * ending `| button` is the header's call to action. */ 'board.navLinks': '', diff --git a/packages/db/package.json b/packages/db/package.json index 7dda0f7..74a8f8f 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -1,6 +1,6 @@ { "name": "@tsbb/db", - "version": "0.12.1", + "version": "0.13.0", "private": true, "type": "module", "exports": { diff --git a/packages/design-tokens/package.json b/packages/design-tokens/package.json index 9691d49..03e3759 100644 --- a/packages/design-tokens/package.json +++ b/packages/design-tokens/package.json @@ -1,6 +1,6 @@ { "name": "@tsbb/design-tokens", - "version": "0.12.1", + "version": "0.13.0", "private": true, "type": "module", "exports": { "./tokens.css": "./src/tokens.css" } diff --git a/packages/mail/package.json b/packages/mail/package.json index 23060b3..582788e 100644 --- a/packages/mail/package.json +++ b/packages/mail/package.json @@ -1,6 +1,6 @@ { "name": "@tsbb/mail", - "version": "0.12.1", + "version": "0.13.0", "private": true, "type": "module", "exports": { ".": "./src/index.ts" }, diff --git a/packages/markup/package.json b/packages/markup/package.json index 90aaf92..56e7ca7 100644 --- a/packages/markup/package.json +++ b/packages/markup/package.json @@ -1,6 +1,6 @@ { "name": "@tsbb/markup", - "version": "0.12.1", + "version": "0.13.0", "private": true, "type": "module", "exports": { ".": "./src/index.ts" } diff --git a/packages/mcp/package.json b/packages/mcp/package.json index cc635ae..b0e710a 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@tsbb/mcp", - "version": "0.12.1", + "version": "0.13.0", "private": true, "type": "module", "exports": { diff --git a/packages/plugin-api/package.json b/packages/plugin-api/package.json index f56886d..a263b2f 100644 --- a/packages/plugin-api/package.json +++ b/packages/plugin-api/package.json @@ -1,6 +1,6 @@ { "name": "@tsbb/plugin-api", - "version": "0.12.1", + "version": "0.13.0", "private": true, "type": "module", "exports": { ".": "./src/index.ts" } diff --git a/packages/plugin-host/package.json b/packages/plugin-host/package.json index dbc6bb7..98ffbe2 100644 --- a/packages/plugin-host/package.json +++ b/packages/plugin-host/package.json @@ -1,6 +1,6 @@ { "name": "@tsbb/plugin-host", - "version": "0.12.1", + "version": "0.13.0", "private": true, "type": "module", "exports": { ".": "./src/index.ts" }, diff --git a/packages/ui/package.json b/packages/ui/package.json index fe7820c..b5263cb 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@tsbb/ui", - "version": "0.12.1", + "version": "0.13.0", "private": true, "type": "module", "exports": { ".": "./src/index.ts", "./styles": "./src/styles/index.ts" }, diff --git a/packages/ui/src/components/layout.ts b/packages/ui/src/components/layout.ts index 5d6730d..9687199 100644 --- a/packages/ui/src/components/layout.ts +++ b/packages/ui/src/components/layout.ts @@ -27,6 +27,19 @@ export interface LayoutProps { faviconUrl?: string; /** `board.logoHref`. Where the header brand points. Defaults to the board. */ logoHref?: string; + /** + * The host site's call-to-action links (`board.navLinks` lines ending + * `| button`), drawn beside the account controls. + */ + actions?: NavItem[]; + /** + * Link columns for skins that draw a full site footer (paper): the host + * site's links under its host name, and the board's own. Hidden by every + * skin that does not ask for them. + */ + footerNav?: { site: NavItem[]; siteLabel?: string; board: NavItem[] }; + /** False for a light-only skin, which has nothing for the toggle to switch to. */ + showThemeToggle?: boolean; /** The browser-chrome colour for the active skin, light and dark. */ themeColor?: { light: string; dark: string }; canonical?: string; @@ -162,9 +175,10 @@ export function Layout(props: LayoutProps) {
${props.logoUrl - ? html`` + ? html` + ` : html` - ${props.boardName}`} + ${props.boardName}`}