From 1e62f4c87f108dfc8764255d7f0403399eb1b5f6 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 7 Jul 2026 20:47:55 +0900 Subject: [PATCH 01/27] Replace Pico CSS with BotKit's own web design system Redesign the bot web pages with a self-contained, quiet "Canvas mode" that foregrounds each bot's own identity, driven by the bot's accent color. Bundle the stylesheet and Inter font locally (served from a content-addressed, immutable path) so pages need no CDN and no build step on Deno or Node.js. Add a pages.theme option, distinguish reposts from the bot's own posts, and document the two-tier brand/design language in DESIGN.md. Assisted-by: Claude Code:claude-opus-4-8 --- AGENTS.md | 30 + CHANGES.md | 16 + DESIGN.md | 461 +++++++++ docs/concepts/bot.md | 25 +- mise.toml | 14 + packages/botkit/deno.json | 8 +- packages/botkit/scripts/build-assets.ts | 130 +++ packages/botkit/src/assets.ts | 114 +++ packages/botkit/src/bot.ts | 9 + .../botkit/src/components/FollowButton.tsx | 88 +- packages/botkit/src/components/Follower.tsx | 61 +- packages/botkit/src/components/Layout.tsx | 98 +- .../botkit/src/components/Message.test.tsx | 16 +- packages/botkit/src/components/Message.tsx | 170 ++-- packages/botkit/src/css/botkit.css | 880 ++++++++++++++++++ packages/botkit/src/fonts/LICENSE-inter.txt | 93 ++ packages/botkit/src/fonts/inter-italic.woff2 | Bin 0 -> 51832 bytes packages/botkit/src/fonts/inter.woff2 | Bin 0 -> 48256 bytes packages/botkit/src/instance-impl.ts | 6 + packages/botkit/src/pages.test.ts | 94 ++ packages/botkit/src/pages.tsx | 386 ++++---- packages/botkit/src/static/fonts.ts | 27 + packages/botkit/src/static/style.ts | 5 + 23 files changed, 2421 insertions(+), 310 deletions(-) create mode 100644 DESIGN.md create mode 100644 packages/botkit/scripts/build-assets.ts create mode 100644 packages/botkit/src/assets.ts create mode 100644 packages/botkit/src/css/botkit.css create mode 100644 packages/botkit/src/fonts/LICENSE-inter.txt create mode 100644 packages/botkit/src/fonts/inter-italic.woff2 create mode 100644 packages/botkit/src/fonts/inter.woff2 create mode 100644 packages/botkit/src/static/fonts.ts create mode 100644 packages/botkit/src/static/style.ts diff --git a/AGENTS.md b/AGENTS.md index 0e257c0..520011e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -152,6 +152,36 @@ Architecture - Node.js support via *package.json* and tsdown transpilation - Separate import maps for each runtime (JSR for Deno, npm for Node.js) +### Web pages and design language + +BotKit renders each bot's public web pages (the profile, individual posts, the +follower list, and the multi-bot instance index). Their appearance follows +a deliberate brand and design language, not ad-hoc styling. + +> [!IMPORTANT] +> Before touching anything about the web front end, read [*DESIGN.md*]. That +> includes the page components (*src/pages.tsx*, *src/components/*), the +> stylesheet (*src/css/botkit.css*), accent colors and theming +> (`~PagesOptions.color`, `~PagesOptions.theme`, `~PagesOptions.css`), and the +> asset pipeline. *DESIGN.md* is the source of truth for how these fit +> together; keep changes consistent with it, and update it when the language +> itself changes. + +Two rules from *DESIGN.md* matter most in everyday work: + + - *Two modes.* Pages the library serves for a user's bot use the restrained + *Canvas mode*, which foregrounds the bot's own identity and keeps BotKit + quiet. BotKit's expressive *Expression mode* is reserved for BotKit's own + sites (its homepage and docs) and must never leak into a hosted bot's pages. + + - *Bundled assets, no user build step.* The stylesheet and web fonts are + authored under *src/css/* and *src/fonts/*, then compiled into the committed + _src/static/\*.ts_ modules by `mise run generate:assets`. Never hand-edit + _src/static/\*.ts_; edit the sources and regenerate. The assets are served + from a content-hashed path, so regenerating busts caches automatically. + +[*DESIGN.md*]: DESIGN.md + Development practices --------------------- diff --git a/CHANGES.md b/CHANGES.md index 968d738..4234113 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -8,6 +8,22 @@ To be released. ### @fedify/botkit + - Redesigned the bot's web pages with a new, self-contained design language. + + The profile, post, and follower pages now use BotKit's own quiet, modern + design instead of Pico CSS. The look is driven by the bot's accent color, + adapts to light and dark color schemes automatically, and foregrounds the + bot's own identity rather than BotKit's brand. Reposts are now clearly + distinguished from the bot's own posts. The full system is described in + the new document. + + The stylesheet and web fonts are bundled with the package and served + locally, so the pages no longer load anything from an external CDN and + work with no build step on either Deno or Node.js. + + - Added the `~PagesOptions.theme` option, which selects the color + scheme (`"auto"`, `"light"`, or `"dark"`; default `"auto"`). + - Added support for hosting multiple bots on a single instance. [[#16], [#24]] diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..59bc843 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,461 @@ +BotKit design language +====================== + +This document describes the brand and design language behind BotKit's web +front end: the visual system used for the pages a bot serves (its profile, +its posts, its follower list), and the separate, more expressive identity +that belongs to BotKit itself. + +It is written for anyone extending the front end, theming a bot, or building +a BotKit-branded surface such as the project site. It explains not just what +the system looks like, but why it is shaped the way it is, so that future work +stays coherent with it. + + +The core principle: whose page is this? +--------------------------------------- + +Every screen BotKit renders belongs to someone. A bot's profile page belongs +to the *bot* and, by extension, to the person who built it. BotKit's homepage +belongs to *BotKit*. These are different owners with different goals, and the +design language treats them differently on purpose. + +A framework that stamps its own identity onto every page it generates makes +each of its users' creations look like a demo of the framework rather than a +thing its author made. BotKit should be the opposite: the bots people build +with it should feel like *theirs*. So the single most important rule in this +system is a question you ask before designing any surface: + +> [!IMPORTANT] +> Whose page is this? If it belongs to a user's bot, BotKit stays quiet and +> lets the bot lead. If it belongs to BotKit, BotKit may speak in its full +> voice. + +Everything else follows from that question. It gives us two modes. + + +Two modes +--------- + +### Canvas mode + +*Canvas mode* is what BotKit uses when it hosts someone else's bot. It is the +neutral chrome around a bot's identity and content: quiet, modern, and +restrained, so the bot's name, avatar, chosen color, and posts are what a +visitor notices. BotKit appears only as a small, honest footer credit. + +This is the mode every page the library serves is built in: profiles, post +permalinks, follower and hashtag listings, and the instance index. + +### Expression mode + +*Expression mode* is BotKit speaking as itself, with a confident and +distinctive identity. It is appropriate for BotKit's own surfaces: the project +homepage, documentation, marketing, and social cards. It may use an expressive +display typeface, the BotKit mark, and stronger motifs. + +Expression mode is documented here as brand guidance, but it is deliberately +*not* shipped by the library and never applied to a hosted bot's pages. + +### Choosing a mode + +| Surface | Owner | Mode | +| --------------------------------- | ------------ | ----------------------------------------- | +| A bot's profile, posts, followers | The bot | Canvas | +| The multi-bot instance index | The bot host | Canvas | +| BotKit's homepage and docs | BotKit | Expression | +| A “Powered by BotKit” credit | BotKit | A quiet token of Expression inside Canvas | + +The instance index lists a server operator's own bots, so it stays in Canvas +mode. The only place BotKit names itself on a hosted page is the footer credit, +and that is intentionally the smallest possible piece of Expression mode. + + +Foundations +----------- + +The foundations below are shared by both modes. What differs between modes is +how boldly they are used, not the tokens themselves. + +### Color + +BotKit's color model is built for theming. A bot picks one accent color, and +everything else is a small set of neutral surface tokens plus tints derived +from that accent. This keeps a bot's page recognizably *its color* without +letting color turn into decoration. + +The twenty accent names are the same legend Pico CSS uses, and the accent +values are taken verbatim from Pico's own themes. Reusing Pico's tuned values +means each accent already has an accessible on-surface “ink” variant for links +and an appropriate contrast color for solid fills, in both light and dark +schemes, without us re-deriving twenty palettes by hand. + +The semantic tokens are the vocabulary the components speak. They never +reference a raw color directly: + +~~~~ css +:root { + /* Neutral surfaces (light scheme) */ + --bk-bg: #f5f6f7; /* page background */ + --bk-surface: #ffffff; /* cards and the profile */ + --bk-surface-2: #f1f2f4; /* insets: code, inputs */ + --bk-border: #e7e8ec; /* hairline separators */ + --bk-border-strong: #d7d9df; + --bk-text: #1c1d21; + --bk-muted: #5f636c; /* secondary text */ + --bk-faint: #9095a0; /* tertiary text, icons */ + + /* Accent, resolved from the chosen theme name */ + --botkit-accent: #398712; /* solid fills */ + --botkit-accent-contrast: #fff; /* text on a solid fill */ + --bk-ink: var(--botkit-accent-ink); /* links, small highlights */ + + /* Tints derived from the accent, never hard-coded */ + --bk-accent-soft: color-mix(in oklab, var(--botkit-accent) 9%, var(--bk-surface)); + --bk-accent-line: color-mix(in oklab, var(--botkit-accent) 40%, var(--bk-border)); +} +~~~~ + +Each accent name is a preset that only sets the four accent variables. Two +of them, the link “ink” colors, differ between light and dark so links stay +legible on either background: + +~~~~ css +[data-botkit-color="green"] { + --botkit-accent: #398712; + --botkit-accent-contrast: #fff; + --botkit-accent-ink: #33790f; /* used in light scheme */ + --botkit-accent-ink-dark: #4eb31b; /* used in dark scheme */ +} +~~~~ + +The chosen name lives on the `` element as `data-botkit-color`, so the +whole document inherits the accent. Dark scheme is handled by overriding the +neutral tokens (and switching `--bk-ink` to the dark ink) under both the OS +preference and an explicit `data-theme`: + +~~~~ css +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --bk-bg: #0f1013; + --bk-surface: #17181c; + /* …remaining neutrals… */ + --bk-ink: var(--botkit-accent-ink-dark); + } +} + +[data-theme="dark"] { /* the same overrides, forced on */ } +~~~~ + +Because color is only ever applied through these tokens, restraint is the +default. A component that wants to be tinted opts in by naming an accent token; +everything else is neutral. + +> [!NOTE] +> The one deliberate use of the accent as *atmosphere* is the profile banner +> fallback, a faint gradient of `--bk-accent-soft` shown when a bot has no +> banner image. It gives every bot a hint of its own color without asking the +> author for artwork. + +### Typography + +Canvas mode ships a single typeface, [Inter], used across names, body, and UI. +A quiet page does not need a display face fighting the bot's content for +attention, and one excellent, neutral, well-hinted sans covers every role when +paired with a clear type scale and deliberate weights. + +~~~~ css +:root { + --bk-font: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif; + --bk-font-mono: ui-monospace, "SF Mono", Menlo, monospace; + + --bk-text-xs: 0.8125rem; /* timestamps, captions */ + --bk-text-sm: 0.9rem; /* secondary text, meta */ + --bk-text-base: 1rem; /* body */ + --bk-text-lg: 1.15rem; /* dialog titles */ + --bk-text-xl: 1.4rem; /* section titles */ + --bk-text-2xl: 1.75rem; /* the bot's name */ +} +~~~~ + +Monospace is used only where text is literally machine data a reader might copy +(inline code inside a post). It is not a decorative texture. Weight does most +of the typographic work: names and section titles are 700, everything else is +400 to 600. + +Expression mode is where a more characterful display face belongs. BotKit's own +sites may pair Inter with an expressive display typeface for headlines. That +face is a brand asset for those surfaces and is not bundled with the library. + +[Inter]: https://rsms.me/inter/ + +### Space and geometry + +Spacing follows a `0.25rem` grid (`--bk-space-1` through `--bk-space-7`). +Corners are generously rounded to read as calm and contemporary rather than +sharp and technical: `--bk-radius: 16px` for cards and the profile, +`--bk-radius-sm: 10px` for nested media, and a pill radius for buttons. The +reading column is capped at `--bk-measure: 38rem` so lines stay comfortable. + + +Canvas mode components +---------------------- + +Every BotKit-owned class is namespaced `bk-` so it never collides with the +HTML inside a post. User-authored content (a post's body, a bot's summary, +a property value) is styled only within a `.bk-prose` container, so generic +element rules never leak onto the rest of the page. + +### The profile header + +The profile is a single rounded card: a banner, an avatar that overlaps it as +on a familiar social profile, then the name, handle, bio, optional property +list, and a meta row with the follower and post counts and the follow action. +There is no BotKit branding here at all. + +~~~~ tsx +
+
+ {image && {name}} +
+
+ {name} +

{name}

+ + {handle} + + +
+
+
+ {followers} followers + {posts} posts +
+ + +
+
+
+~~~~ + +The avatar overlap is the one piece of positioning worth noting: the avatar is +pulled up over the banner with a negative margin and lifted above it in the +stacking order. + +~~~~ css +.bk-avatar { + position: relative; /* raise above the banner… */ + z-index: 1; /* …so the overlap paints correctly */ + width: 92px; + height: 92px; + margin-top: -46px; + border-radius: var(--bk-radius); + border: 3px solid var(--bk-surface); +} +~~~~ + +### Posts and reposts + +A post is a quiet card: the content, any image attachments, and a small muted +timestamp that links to the permalink. On a bot's own profile every post is the +bot's, so the author is redundant and omitted. + +A reposted (boosted) post is the exception, and it must never be mistaken for +the bot's own writing. It carries an explicit “Reposted” marker and shows the +original author, following the familiar convention: + +~~~~ tsx +
+ {!isSelf && ( + <> +
Reposted
+ + + )} +
+
{/* images */}
+
+ +
+
+~~~~ + +`isSelf` compares the post's author to the profile's bot. When they match, the +post renders bare; when they differ, the repost marker and author appear. The +distinction is carried by structure and a single small accent-colored glyph, +not by a change of tone. + +### Buttons and the follow dialog + +There are two button styles: a solid `bk-btn--primary` in the accent color for +the primary action (Follow), and a bordered `bk-btn--ghost` for secondary +actions. Both are pill-shaped. + +Following opens a native `` where a visitor enters their own handle. +The dialog uses the same surface, border, and radius tokens as the rest of the +page, so it feels part of the whole rather than a system pop-up. + +### Rosters, secondary headings, and the credit + +Follower lists and the instance index share a `.bk-actor` card: avatar, name, +and handle in a responsive grid. Secondary pages (followers, a hashtag, a +single post) open with a `.bk-page-head`: a circular back button and a title, +with an optional count. + +The footer credit is the entire BotKit presence on a hosted page: + +~~~~ tsx + +~~~~ + +It is `--bk-text-xs`, `--bk-faint`, and centered. Honest attribution, nothing +more. + + +Theming API +----------- + +Everything above is driven by three options on `createBot({ pages })`: + +`color` +: One of the twenty accent names (default `"green"`). Sets + `data-botkit-color` on the document. + +`theme` +: `"auto"` (default), `"light"`, or `"dark"`. `"auto"` follows the visitor's + operating system; the others force a scheme via `data-theme`. + +`css` +: A string of custom CSS, injected after BotKit's stylesheet so it can + override any token or rule. This is the escape hatch for a bot author who + wants to go further than the accent allows. + +~~~~ typescript +const bot = createBot({ + username: "mybot", + pages: { + color: "violet", + theme: "auto", + css: ":root { --bk-radius: 6px; }", // e.g. squarer corners + }, +}); +~~~~ + +Because the components read tokens rather than literal colors, a small amount +of custom CSS overriding a few variables can restyle the whole page coherently. + + +Expression mode +--------------- + +Expression mode is BotKit's own voice, for BotKit's own surfaces. It is not +part of the shipped library; it is recorded here so BotKit-branded work stays +consistent. + + - *Mark.* BotKit's mark is a bracketed node: a small circular node held + between two brackets, evoking an addressable actor on the network. It is a + brand element for BotKit's surfaces and does not appear on hosted bot pages. + + - *Type.* Expression mode may pair Inter with an expressive display typeface + for headlines, used with restraint. Where Canvas mode keeps a single quiet + face, Expression mode is allowed one confident one. + + - *Color.* Expression mode still draws from the same twenty-name palette, but + may use it more boldly, including BotKit's own signature accent. + + - *Restraint still applies.* Even in Expression mode, one bold element per + screen carries the personality while everything around it stays disciplined. + +The boundary is firm: nothing in Expression mode may be introduced into the +pages the library serves for a user's bot. + + +Asset and packaging architecture +-------------------------------- + +BotKit is a library, so its design has to arrive with zero build steps on the +user's side. A bot author installs the package and gets the full front end, +fonts included, whether they run on Deno or Node.js. + +The pipeline that makes this work: + + - *Authoring sources* live under *src/css/* (the stylesheet) and *src/fonts/* + (the woff2 files). These are not needed at runtime and are excluded from + the published package. + + - *A codegen step* (`mise run generate:assets`, which runs + *scripts/build-assets.ts*) compiles the stylesheet to a minified string and + base64-encodes the fonts, writing two committed modules under *src/static/*. + + - *The generated modules* (*src/static/style.ts* and *src/static/fonts.ts*) + are plain source. They ship as-is to JSR and are bundled by tsdown for npm, + so they load identically on both runtimes with no file-system access. + +Embedding the assets as source rather than shipping loose files and copying +them at build time is what keeps the “no build step” promise on both runtimes +at once: there is no path resolution, no bundler configuration, and nothing to +copy. The cost is roughly a hundred kilobytes of base64 in the package, which +is a fair price for a framework whose look arrives out of the box. + +At runtime the assets are served from a content-addressed path: + +~~~~ typescript +// src/assets.ts +export const ASSET_VERSION = fingerprint(`${css}|${fontFingerprint}`); +export const ASSET_PATH_PREFIX = `/.botkit/${ASSET_VERSION}`; +export const STYLESHEET_PATH = `${ASSET_PATH_PREFIX}/botkit.css`; +~~~~ + +The path includes a short fingerprint of the stylesheet and fonts, so the +assets can be cached forever +(`Cache-Control: public, max-age=31536000, immutable`) yet update automatically +whenever their content changes. The serving route lives next to the existing +custom-emoji handler in *instance-impl.ts* and is matched before any bot +routing, so it works for both single-bot and multi-bot hosting. The +stylesheet's `@font-face` rules reference the fonts by relative path, so they +resolve under the same fingerprinted prefix without hard-coding it. + +> [!IMPORTANT] +> The generated _src/static/\*.ts_ modules are build output. Do not edit them +> by hand; edit *src/css/botkit.css* or the fonts and run +> `mise run generate:assets`. + + +Quality baseline +---------------- + +These hold everywhere, in both modes, and are not optional: + + - *Responsive.* Layouts reflow to a single column on small screens; the + reading column and profile adapt down to mobile widths. + + - *Keyboard focus.* Interactive elements show a visible accent focus ring + (`:focus-visible`), never removed. + + - *Color scheme.* Both light and dark are first-class; `color-scheme` and a + `theme-color` meta hint are emitted so browser chrome matches. + + - *Contrast.* Accent “ink” and contrast values are taken from Pico's + accessibility-tuned palette so text on tints and solid fills meets WCAG AA. + + - *Content safety.* User HTML is confined to `.bk-prose`, so a bot's post can + never restyle the page around it. + + +Extending the system +-------------------- + + - *To change a token or component,* edit *src/css/botkit.css*, run + `mise run generate:assets`, and verify in a browser (run an example bot and + open its profile). The content fingerprint changes automatically, so caches + do not need manual busting. + + - *To add or change a font,* place the woff2 in *src/fonts/*, update the + `FONTS` list and the `@font-face` rules, and regenerate. + + - *To add a new page,* build it from the existing `bk-` components and the + shared `Layout`, which already supplies the reading column and the footer + credit. Ask the ownership question first: a page the library serves is + Canvas mode. diff --git a/docs/concepts/bot.md b/docs/concepts/bot.md index 1fbc79a..ea97e5b 100644 --- a/docs/concepts/bot.md +++ b/docs/concepts/bot.md @@ -358,9 +358,16 @@ Turned off by default. The options for the web pages of the bot. +BotKit serves a bot's profile, posts, and follower list with a quiet, modern +design that foregrounds the bot's own identity. The whole look is driven by a +single accent color, adapts to light and dark automatically, and needs no build +step or external CDN: the stylesheet and fonts are bundled with the package. +See the [design language document][DESIGN.md] for the full system. + `~PagesOptions.color` -: The color of the theme. It will be used for the theme color of the web - pages. The default color is `"green"`. +: The accent color of the bot's pages. It tints links, the follow button, + and small highlights, and is used as the theme color for browser chrome. + The default color is `"green"`. Here's the list of available colors: @@ -385,12 +392,20 @@ The options for the web pages of the bot. - `"yellow"` - `"zinc"` - See also the [*Colors* section] of the Pico CSS docs. + The accent values are taken from the [*Colors* section] of the Pico CSS + docs, so each name looks the same as it does there. + +`~PagesOptions.theme` +: The color scheme of the web pages: `"auto"` (the default) follows the + visitor's operating system preference, while `"light"` and `"dark"` force a + fixed scheme. *This option is available since BotKit 0.5.0.* `~PagesOptions.css` -: The custom CSS to be injected into the web pages. It should be a string - of CSS code. +: Custom CSS injected after BotKit's own stylesheet, so it can override any of + the design system's tokens or rules. It should be a string of CSS code. + For example, `:root { --bk-radius: 6px; }` gives the pages squarer corners. +[DESIGN.md]: https://github.com/fedify-dev/botkit/blob/main/DESIGN.md [*Colors* section]: https://picocss.com/docs/colors diff --git a/mise.toml b/mise.toml index c4c4f81..8830064 100644 --- a/mise.toml +++ b/mise.toml @@ -66,8 +66,22 @@ run = [ "hongdown --write", ] +[tasks."generate:assets"] +description = "Compile the web-page CSS and fonts into src/static/*.ts" +sources = [ + "packages/botkit/scripts/build-assets.ts", + "packages/botkit/src/css/**/*.css", + "packages/botkit/src/fonts/*.woff2", +] +outputs = [ + "packages/botkit/src/static/style.ts", + "packages/botkit/src/static/fonts.ts", +] +run = "deno run --allow-read --allow-write packages/botkit/scripts/build-assets.ts" + [tasks."build:botkit"] description = "Build @fedify/botkit" +depends = ["generate:assets"] sources = [ "packages/botkit/deno.json", "packages/botkit/package.json", diff --git a/packages/botkit/deno.json b/packages/botkit/deno.json index a601524..bf7b928 100644 --- a/packages/botkit/deno.json +++ b/packages/botkit/deno.json @@ -33,8 +33,14 @@ "exclude": [ "dist", "junit.xml", - "src/css" + "src/css", + "src/fonts" ], + "publish": { + "exclude": [ + "scripts" + ] + }, "fmt": { "exclude": [ "*.md", diff --git a/packages/botkit/scripts/build-assets.ts b/packages/botkit/scripts/build-assets.ts new file mode 100644 index 0000000..018d5a9 --- /dev/null +++ b/packages/botkit/scripts/build-assets.ts @@ -0,0 +1,130 @@ +#!/usr/bin/env -S deno run --allow-read --allow-write +// BotKit by Fedify: A framework for creating ActivityPub bots +// Copyright (C) 2025–2026 Hong Minhee +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Asset codegen for BotKit's web pages. +// +// Reads the authoring sources under src/css/ and src/fonts/ and emits two +// committed, runtime-importable modules under src/static/: +// +// - style.ts — the design-system stylesheet as a minified string. +// - fonts.ts — the bundled web fonts as base64, decoded at load time. +// +// These generated modules ship as source on JSR and are bundled by tsdown for +// npm, so library users get BotKit's front end with no build step of their own. +// +// Run via `mise run generate:assets` whenever the CSS or fonts change, and +// commit the regenerated src/static/*.ts. This script has no dependencies +// beyond the Deno runtime. + +// Canvas mode ships a single quiet, neutral typeface (Inter) so a hosted bot's +// own identity leads. BotKit's expressive brand type is reserved for BotKit's +// own sites and is not bundled here. +/** Font files to bundle, keyed by the name each is served under. */ +const FONTS: readonly string[] = [ + "inter.woff2", + "inter-italic.woff2", +]; + +const cssUrl = new URL("../src/css/botkit.css", import.meta.url); +const fontsUrl = new URL("../src/fonts/", import.meta.url); +const staticUrl = new URL("../src/static/", import.meta.url); + +const HEADER = + "// This file is @generated by scripts/build-assets.ts — do not edit.\n" + + "// deno-fmt-ignore-file\n"; + +/** + * Conservatively minifies CSS: strips block comments and collapses runs of + * whitespace, then trims spaces around structural punctuation. It deliberately + * keeps spaces around operators so `calc()` expressions and multi-value + * properties stay intact, and around `:` so a descendant space before a + * pseudo-class (e.g. `.bk-prose :is(...)`) is not collapsed into a compound + * selector. + */ +function minifyCss(css: string): string { + return css + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/\s+/g, " ") + .replace(/\s*([{};,])\s*/g, "$1") + .replace(/;}/g, "}") + .trim(); +} + +/** Encodes bytes as base64 without overflowing the call stack. */ +function toBase64(bytes: Uint8Array): string { + let binary = ""; + const chunk = 0x8000; + for (let i = 0; i < bytes.length; i += chunk) { + binary += String.fromCharCode(...bytes.subarray(i, i + chunk)); + } + return btoa(binary); +} + +async function buildStyle(): Promise { + const css = await Deno.readTextFile(cssUrl); + const minified = minifyCss(css); + const module = `${HEADER} +/** The BotKit design-system stylesheet, served under a content-hashed path. */ +export const css: string = ${JSON.stringify(minified)}; +`; + await Deno.writeTextFile(new URL("style.ts", staticUrl), module); + console.info(`Wrote src/static/style.ts (${minified.length} bytes of CSS).`); +} + +async function buildFonts(): Promise { + const entries: string[] = []; + for (const name of FONTS) { + const bytes = await Deno.readFile(new URL(name, fontsUrl)); + entries.push( + ` ${JSON.stringify(name)}: ${JSON.stringify(toBase64(bytes))},`, + ); + } + const module = `${HEADER} +/** A bundled font file, base64-encoded so it ships as source on JSR. */ +export interface FontAsset { + readonly bytes: Uint8Array; + readonly mediaType: string; +} + +const base64: Record = { +${entries.join("\n")} +}; + +function decode(data: string): Uint8Array { + const binary = atob(data); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes; +} + +/** The bundled web fonts, keyed by the file name they are served under. */ +export const fonts: Record = Object.fromEntries( + Object.entries(base64).map( + ([name, data]) => [name, { bytes: decode(data), mediaType: "font/woff2" }], + ), +); +`; + await Deno.writeTextFile(new URL("fonts.ts", staticUrl), module); + console.info(`Wrote src/static/fonts.ts (${FONTS.length} fonts).`); +} + +if (import.meta.main) { + await Deno.mkdir(staticUrl, { recursive: true }); + await buildStyle(); + await buildFonts(); + console.info("Assets generated."); +} diff --git a/packages/botkit/src/assets.ts b/packages/botkit/src/assets.ts new file mode 100644 index 0000000..362c6aa --- /dev/null +++ b/packages/botkit/src/assets.ts @@ -0,0 +1,114 @@ +// BotKit by Fedify: A framework for creating ActivityPub bots +// Copyright (C) 2025–2026 Hong Minhee +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Serving of BotKit's bundled front-end assets (the design-system stylesheet +// and its web fonts). The assets are embedded as source modules under +// src/static/ (see scripts/build-assets.ts), so they need no build step or file +// system access at runtime and work identically on Deno and Node.js. +// +// Assets are served under a content-addressed path so that upgrading BotKit +// automatically busts any cached copies while allowing an aggressive, +// immutable cache lifetime. + +import { fonts } from "./static/fonts.ts"; +import { css } from "./static/style.ts"; + +/** Folds a string or a byte sequence into a running djb2 hash. */ +function fold(hash: number, input: string | Uint8Array): number { + for (let i = 0; i < input.length; i++) { + const code = typeof input === "string" ? input.charCodeAt(i) : input[i]; + hash = ((hash << 5) + hash) ^ code; + } + return hash; +} + +/** + * Computes a small, stable content fingerprint (djb2) rendered in base36 over + * the full stylesheet and font contents. It namespaces the asset paths so the + * assets can be cached forever yet bust automatically whenever the stylesheet + * or a font changes — including when a font's byte length stays the same, which + * is why the actual bytes are folded in rather than just their length. + */ +function computeAssetVersion(): string { + let hash = fold(5381, css); + for (const [name, font] of Object.entries(fonts)) { + hash = fold(hash, name); + hash = fold(hash, font.bytes); + } + return (hash >>> 0).toString(36); +} + +/** The content fingerprint that namespaces the asset paths. */ +export const ASSET_VERSION = computeAssetVersion(); + +/** The content-addressed base path under which all assets are served. */ +export const ASSET_PATH_PREFIX = `/.botkit/${ASSET_VERSION}`; + +/** The absolute path of the design-system stylesheet. */ +export const STYLESHEET_PATH = `${ASSET_PATH_PREFIX}/botkit.css`; + +const IMMUTABLE_CACHE = "public, max-age=31536000, immutable"; +const ETAG = `"botkit-${ASSET_VERSION}"`; +const CSS_BYTES = new TextEncoder().encode(css); + +/** + * Serves a bundled asset (the stylesheet or a web font) for a request path + * under {@link ASSET_PATH_PREFIX}. + * + * Only the current content fingerprint is served, so an `immutable` response + * always contains exactly the bytes its URL names. A request under + * `/.botkit/` carrying a stale or mistyped fingerprint is rejected with a `404` + * rather than caching the current bytes under the wrong URL for a year. + * + * @param pathname The request URL pathname. + * @returns A {@link Response} for the asset, a `404` response if the path is + * under the asset prefix but carries the wrong fingerprint or names no + * known asset, or `null` if the path is not an asset path at all (so + * the caller can keep routing). + */ +export function serveAsset(pathname: string): Response | null { + const match = /^\/\.botkit\/([^/]+)\/(.+)$/.exec(pathname); + if (match == null) return null; + const [, version, asset] = match; + if (version !== ASSET_VERSION) { + return new Response("Not Found", { status: 404 }); + } + if (asset === "botkit.css") { + return new Response(CSS_BYTES as Uint8Array, { + headers: { + "Content-Type": "text/css; charset=utf-8", + "Content-Length": CSS_BYTES.byteLength.toString(), + "Cache-Control": IMMUTABLE_CACHE, + "ETag": ETAG, + }, + }); + } + const fontMatch = /^fonts\/([A-Za-z0-9._-]+)$/.exec(asset); + if (fontMatch != null) { + const font = fonts[fontMatch[1]]; + if (font != null) { + return new Response(font.bytes as Uint8Array, { + headers: { + "Content-Type": font.mediaType, + "Content-Length": font.bytes.byteLength.toString(), + "Cache-Control": IMMUTABLE_CACHE, + "ETag": ETAG, + }, + }); + } + } + return new Response("Not Found", { status: 404 }); +} diff --git a/packages/botkit/src/bot.ts b/packages/botkit/src/bot.ts index de12ed4..dac56a2 100644 --- a/packages/botkit/src/bot.ts +++ b/packages/botkit/src/bot.ts @@ -467,6 +467,15 @@ export interface PagesOptions { | "yellow" | "zinc"; + /** + * The color scheme of the web pages. `"auto"` follows the visitor's + * operating system preference, while `"light"` and `"dark"` force a fixed + * scheme. The default is `"auto"`. + * @default `"auto"` + * @since 0.5.0 + */ + readonly theme?: "auto" | "light" | "dark"; + /** * The CSS code for the bot. It will be used for the custom CSS of the web * pages. diff --git a/packages/botkit/src/components/FollowButton.tsx b/packages/botkit/src/components/FollowButton.tsx index 9d4bc13..116f916 100644 --- a/packages/botkit/src/components/FollowButton.tsx +++ b/packages/botkit/src/components/FollowButton.tsx @@ -1,3 +1,18 @@ +// BotKit by Fedify: A framework for creating ActivityPub bots +// Copyright (C) 2025–2026 Hong Minhee +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . /** @jsxImportSource hono/jsx */ import type { BotImpl } from "../bot-impl.ts"; @@ -7,56 +22,61 @@ export interface FollowButtonProps { } export function FollowButton({ bot, action }: FollowButtonProps) { + const name = bot.name ?? bot.username; return ( <> - -
-
-

Follow {bot.name ?? bot.username}

-
-
-

Enter your fediverse handle to follow this account:

-
+ +
+

Follow {name}

+ +
+
+

+ Enter your fediverse handle and we'll send you to your own server to + confirm. +

+ +
-
+ + + +
` + + `tap`, + published: Temporal.Instant.from("2025-01-01T00:00:00Z"), + }), + published: Temporal.Instant.from("2025-01-01T00:00:00Z"), + }), + ); + const html = await (await bot.fetch( + new Request("https://example.com/"), + undefined, + )).text(); + // The safe markup survives, but the script and the javascript: URL do not. + assert.ok(html.includes("

hello

")); + assert.ok(!html.includes("