Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion apps/mcp/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion apps/server/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@tsbb/server",
"version": "0.12.1",
"version": "0.13.0",
"private": true,
"type": "module",
"dependencies": {
Expand Down
79 changes: 65 additions & 14 deletions apps/server/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
isSkin,
stylesheetUrl,
SKIN_THEME_COLOR,
LIGHT_ONLY_SKINS,
type Brand,
type LayoutSlots,
type Skin,
Expand All @@ -27,6 +28,16 @@ export function brandOf(settings: Record<string, unknown>): 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<string, unknown>, key: string): string | undefined {
const value = settings[key];
Expand Down Expand Up @@ -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<string, unknown>): { items: NavItem[]; forumsOnly: boolean } {
export function siteLinks(settings: Record<string, unknown>): {
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 {
Expand Down Expand Up @@ -177,15 +210,23 @@ export async function render(

const slots: LayoutSlots = { head, header, bodyStart, bodyEnd, footer };
const site = siteLinks(settings as Record<string, unknown>);
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<string, unknown>);
// 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;
Expand Down Expand Up @@ -236,14 +277,24 @@ export async function render(
nav,
unread,
stylesheetUrl: stylesheetUrl(skin, brandOf(settings as Record<string, unknown>)),
actions: site.actions,
footerNav: {
site: site.items,
siteLabel: hostOf(urlSetting(settings as Record<string, unknown>, '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<string, unknown>, 'board.logoUrl'),
logoHref: urlSetting(settings as Record<string, unknown>, 'board.logoHref'),
faviconUrl: urlSetting(settings as Record<string, unknown>, 'board.faviconUrl'),
themeColor: SKIN_THEME_COLOR[skin],
canonical: options.canonical ?? boardUrl(url.pathname, services.baseUrl),
feedUrl: options.feedUrl,
slots,
theme: c.get('theme') ?? readTheme(c, settings as Record<string, unknown>),
theme: lightOnly ? 'light' : (c.get('theme') ?? readTheme(c, settings as Record<string, unknown>)),
body: options.body as never,
});

Expand Down
4 changes: 2 additions & 2 deletions apps/server/src/routes/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand All @@ -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':
Expand Down
4 changes: 3 additions & 1 deletion apps/server/src/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`<div style="max-width:26rem;margin:2rem auto">
// 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`<div class="auth-page">
${Card(html`
${CardHeader(heading, {
description: 'We email you a link. There is no password to choose or forget.',
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/routes/docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/routes/write.ts
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,7 @@ function composerPage(
options: ComposerOptions,
) {
const format = String(options.settings['posts.defaultFormat'] ?? 'markdown');
return html`<div style="max-width:48rem">
return html`<div class="composer-page">
${Card(html`
${CardHeader(options.heading)}
${CardContent(html`
Expand Down
2 changes: 1 addition & 1 deletion apps/tui/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion apps/worker/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@tsbb/worker",
"version": "0.12.1",
"version": "0.13.0",
"private": true,
"type": "module",
"dependencies": {
Expand Down
59 changes: 54 additions & 5 deletions docs/SKINS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "tsbb",
"version": "0.12.1",
"version": "0.13.0",
"private": true,
"type": "module",
"packageManager": "pnpm@11.18.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/client/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@tsbb/client",
"version": "0.12.1",
"version": "0.13.0",
"private": true,
"type": "module",
"exports": {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@tsbb/core",
"version": "0.12.1",
"version": "0.13.0",
"private": true,
"type": "module",
"exports": {
Expand Down
9 changes: 7 additions & 2 deletions packages/core/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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': '',

Expand Down
2 changes: 1 addition & 1 deletion packages/db/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@tsbb/db",
"version": "0.12.1",
"version": "0.13.0",
"private": true,
"type": "module",
"exports": {
Expand Down
2 changes: 1 addition & 1 deletion packages/design-tokens/package.json
Original file line number Diff line number Diff line change
@@ -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" }
Expand Down
2 changes: 1 addition & 1 deletion packages/mail/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@tsbb/mail",
"version": "0.12.1",
"version": "0.13.0",
"private": true,
"type": "module",
"exports": { ".": "./src/index.ts" },
Expand Down
Loading
Loading