diff --git a/docs/design-system.md b/docs/design-system.md
index 0f0d67e39..2e0ae2773 100644
--- a/docs/design-system.md
+++ b/docs/design-system.md
@@ -53,7 +53,7 @@ slice: native radios, buttons, fields and the existing dialogs supply the behavi
Typography uses the shared Inter/system sans stack with Tailwind's existing type
scale: `text-sm` controls, `text-base` body/labels, `text-lg` section headings and
-`text-3xl` page headings. Existing conversation type sizes remain unchanged. Spacing
+`text-3xl` page headings. Existing conversation type sizes remain unchanged at 100%. Spacing
uses Tailwind's 4px rhythm; preserve established responsive card gutters. Avoid
creating new scales for the same values. Motion is optional and respects reduced
motion; theme changes must not fade through the old mode's foreground/background.
@@ -119,3 +119,32 @@ Native Rust test targets compile but contain zero tests. Human light/dark visual
approval and independent source review do not replace attended packaged-app
chrome/relaunch acceptance. Browser evidence also does not cover third-party plugins
that hard-code their own colors.
+
+
+## Text size and shortcuts
+
+The host owns a separate device-local `buzz-font-scale.v1` preference (80–200%,
+10% steps; default/reset 100%). Color-mode storage is unchanged. Settings →
+Appearance supplies visible decrease/increase/reset controls and save-failure retry.
+The bootstrap and appearance service apply `--buzz-text-scale`; invalid persisted
+values fall back to 100%, and same-origin storage events re-read the latest choice.
+
+Command+, opens Settings on Apple platforms. Command+= / Command++ increase text,
+Command+- decreases and Command+0 resets. Other platforms use Control. Zoom works
+while typing and in dialogs without changing browser/WebView zoom; Settings does
+not navigate behind an open modal. The [shortcut service](plugin-architecture.md#in-app-keyboard-shortcuts)
+also serves plugins and owns event dispatch/lifetime rules.
+
+Only typography scales: root rem size, layout spacing, icons and native window
+geometry stay unchanged. Shared Tailwind type utilities and built-in fixed-size
+CSS typography consume the scale. Plugin text can inherit host typography or use
+`font-size: calc(15px * var(--buzz-text-scale, 1))`; avoid multiplying inherited
+font size by the scale again. Use unitless or scaled line-height so enlarged text
+does not overlap. Independent plugins that hard-code sizes and third-party shadow
+widgets need their own adapter; this is not a forced CSS rewrite of arbitrary code.
+
+`tests/browser/shortcuts.spec.mjs` covers real key dispatch to Settings and actual
+message/composer text, draft/node preservation, reset/limits/reload, modal/editor/
+Shadow DOM guards, and the independent example's disable/re-enable path. These
+Chromium/WebKit checks use a fixture broker, not native menu accelerators. An
+attended desktop shortcut try remains necessary for native acceptance.
diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md
index 9c1c6163c..1a33ab8bd 100644
--- a/docs/plugin-architecture.md
+++ b/docs/plugin-architecture.md
@@ -42,6 +42,7 @@ app/ host, startup, navigation, Settings
plugins/ installation, lifecycle, contribution ownership
features/pages/ page contract and host rendering
features/panels/ target resolution, launcher contract and reusable card/frame
+features/shortcuts/ in-app binding dispatch, focus rules and plugin ownership
features/relay/ shared channel data, queries, profiles and durable delivery
features/messages/ reusable timeline, message, thread and composer UI
bundled/channels/ Channels navigation, sidebar, page layout and panel placement
@@ -257,3 +258,57 @@ Formatting needs selection transforms. Attachments and voice need shared media
capabilities, destination-bound asynchronous work and cancellation; accepted
material belongs to the draft, not the optional tool. Add these contracts against
real workflows rather than declaring the toolbar a universal editor API.
+
+
+## In-app keyboard shortcuts
+
+The host composes one `ShortcutsService` in `app/services.ts`. Plugins declare
+`inject = ["shortcuts"]` and call `ctx.shortcuts.register(shortcut)`; their bindings
+use the same matching/dispatch rules as host-owned Settings and text sizing.
+There is no OS-wide hotkey registration, native accelerator API, or command bus.
+
+```ts
+import type { Context, Shortcut } from "@buzz/author";
+export const inject = ["shortcuts"];
+export function apply(ctx: Context) {
+ const shortcut: Shortcut = {
+ id: "show-details",
+ title: "Show details",
+ binding: { key: "k", mod: true, shift: true },
+ when: () => detailsViewIsAvailable(),
+ run: () => showDetails(),
+ };
+ ctx.shortcuts.register(shortcut);
+}
+```
+
+`binding` is one binding or a nonempty array of aliases. `key` matches the logical
+`KeyboardEvent.key` case-insensitively, not a physical `code` (Space is `" "`,
+not `"Space"`). `mod` means Command
+on Apple platforms and Control elsewhere; Shift/Alt and the other primary modifier
+match exactly. IME/AltGraph events and already-prevented events are never consumed.
+The window listener runs in the bubbling phase, after local editor handlers.
+
+By default bindings do not run in editable targets (including open Shadow DOM),
+while a dialog is open, or repeatedly on a held key. Explicit `allowInEditable`,
+`allowInModal` and `repeat` opt in; `when` checks current eligibility without
+re-registering. `run` may return a promise; throws/rejections are logged and isolated.
+Only a selected binding prevents the browser default. An eligible held binding
+still prevents the default when its repeat handler is suppressed.
+
+IDs are namespaced by installation. Only active revisions participate; disable,
+failed activation, replacement and Cordis disposal remove eligibility. Plugin ties
+are resolved by ascending namespaced ID, independent of activation order. Host
+bindings are reserved even while unavailable (Settings does not navigate behind a
+modal). `snapshot`/`subscribe` expose ready plugin registrations, not host bindings
+or a promise that every binding wins every current focus conflict. The host-only
+registration method is deliberately absent from the injected type contract; plugins
+remain trusted same-process code, not sandboxed adversaries.
+
+See [`shortcut-counter`](../examples/plugins/shortcut-counter/README.md) for a
+self-contained external plugin using the real service without a DOM listener.
+The generated type-only `@buzz/author` exports `Shortcuts`, `Shortcut`, `KeyBinding`
+and `RegisteredShortcut`. This is a host-matched preview: older hosts without the
+`shortcuts` capability cannot activate such a plugin. `apiVersion: 1` alone is not
+runtime feature negotiation. Chords, user rebinding, conflict UI and command palettes
+are outside this initial contract.
diff --git a/examples/plugins/README.md b/examples/plugins/README.md
index d2d36cf90..e2463ad37 100644
--- a/examples/plugins/README.md
+++ b/examples/plugins/README.md
@@ -5,6 +5,8 @@ All example plugins live here with `manifest.json` and prebuilt `plugin.js` file
- `composer-lab`: test page for shared composer/message UI; adds no global tools. Requires the
matching host conversation capability; sending posts to the selected channel.
- `counter` and `notes`: offline playgrounds.
+- `shortcut-counter`: offline keyboard-shortcut consumer; Command+Shift+K /
+ Control+Shift+K increments through the injected host service. Requires `shortcuts`.
- `broken-page`: intentionally fails when its page renders to exercise error handling.
## Try the offline playgrounds
diff --git a/examples/plugins/shortcut-counter/README.md b/examples/plugins/shortcut-counter/README.md
new file mode 100644
index 000000000..a568e5ba7
--- /dev/null
+++ b/examples/plugins/shortcut-counter/README.md
@@ -0,0 +1,11 @@
+# Shortcut counter
+
+Import this folder from desktop Settings → Plugins, then enable **Shortcut counter**.
+The prebuilt `plugin.js` has no runtime imports or build step. Requires a host with
+`shortcuts` (host-matched API v1 preview, not a cross-version SDK).
+
+Press **⌘⇧K** on Mac / **Ctrl+Shift+K** elsewhere to increment. The same handler
+backs the button. The count lasts for the plugin lifetime, not a page mount;
+disable/re-enable resets it and disposes/re-registers the binding. The typing
+field demonstrates the default editable-target guard. No custom DOM listener,
+service instance, or manual unload hook is required.
diff --git a/examples/plugins/shortcut-counter/manifest.json b/examples/plugins/shortcut-counter/manifest.json
new file mode 100644
index 000000000..ec13bd9a0
--- /dev/null
+++ b/examples/plugins/shortcut-counter/manifest.json
@@ -0,0 +1,5 @@
+{
+ "id": "example.shortcut-counter",
+ "name": "Shortcut counter",
+ "apiVersion": 1
+}
diff --git a/examples/plugins/shortcut-counter/plugin.js b/examples/plugins/shortcut-counter/plugin.js
new file mode 100644
index 000000000..39aef89ff
--- /dev/null
+++ b/examples/plugins/shortcut-counter/plugin.js
@@ -0,0 +1,54 @@
+// Self-contained external plugin: runtime services come only from the host.
+export const inject = ["react", "pages", "shortcuts"];
+export function apply(ctx) {
+ const React = ctx.react;
+ let count = 0;
+ const listeners = new Set();
+ const increment = () => {
+ count++;
+ for (const listener of listeners) listener();
+ };
+ ctx.shortcuts.register({
+ id: "increment",
+ title: "Increment shortcut counter",
+ binding: { key: "k", mod: true, shift: true },
+ run: increment,
+ });
+ ctx.pages.register({
+ id: "main",
+ title: "Shortcut counter",
+ component: function ShortcutCounter() {
+ const value = React.useSyncExternalStore(
+ (listener) => {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+ },
+ () => count,
+ );
+ return React.createElement(
+ "section",
+ null,
+ React.createElement("h1", null, "Shortcut counter"),
+ React.createElement(
+ "p",
+ { role: "status" },
+ `Shortcut count: ${value}`,
+ ),
+ React.createElement(
+ "button",
+ { type: "button", onClick: increment },
+ "Increment counter",
+ ),
+ React.createElement(
+ "p",
+ null,
+ "Press Command+Shift+K (Control+Shift+K on other platforms). Typing fields and modal dialogs are excluded by default. Disable the plugin to remove its shortcut.",
+ ),
+ React.createElement("input", {
+ "aria-label": "Shortcut typing guard",
+ placeholder: "Shortcuts do not intercept this editor",
+ }),
+ );
+ },
+ });
+}
diff --git a/public/appearance-init.js b/public/appearance-init.js
index 673767df7..4b018929d 100644
--- a/public/appearance-init.js
+++ b/public/appearance-init.js
@@ -8,4 +8,16 @@
// Storage may be denied; the built-in light palette still opens safely.
}
document.documentElement.dataset.colorMode = mode;
+ let scale = 1;
+ try {
+ const value = Number(localStorage.getItem("buzz-font-scale.v1"));
+ if (Number.isFinite(value) && value >= 0.8 && value <= 2)
+ scale = Math.round(value * 10) / 10;
+ } catch {
+ /* Text remains readable when storage is unavailable. */
+ }
+ document.documentElement.style.setProperty(
+ "--buzz-text-scale",
+ String(scale),
+ );
})();
diff --git a/src/app/App.tsx b/src/app/App.tsx
index 6dabf116d..c1b2f7270 100644
--- a/src/app/App.tsx
+++ b/src/app/App.tsx
@@ -1,5 +1,6 @@
// FOUNDATION: Startup, navigation, contributed pages, and built-in Settings.
-import { useState, useSyncExternalStore } from "react";
+import { useEffect, useState, useSyncExternalStore } from "react";
+import { registerAppShortcuts } from "./shortcuts";
import type { AppServices } from "./services";
import { Settings } from "./Settings";
import { RecoveryScreen } from "./RecoveryScreen";
@@ -22,6 +23,20 @@ export function App({ services }: { services: AppServices }) {
setHome(key === "home");
if (key !== "home") pages.select(key);
};
+ useEffect(
+ () =>
+ registerAppShortcuts(
+ services.shortcuts,
+ services.appearance,
+ () => {
+ setHome(false);
+ pages.select("settings");
+ document.getElementById("main-content")?.focus();
+ },
+ startup === "ready",
+ ),
+ [services, pages.select, startup],
+ );
const selected = home ? "home" : pages.selected;
const presentation = home
? shellPresentation.home
diff --git a/src/app/AppearanceSettings.tsx b/src/app/AppearanceSettings.tsx
index a6ea15407..5490fe54f 100644
--- a/src/app/AppearanceSettings.tsx
+++ b/src/app/AppearanceSettings.tsx
@@ -4,7 +4,7 @@ import type { Appearance } from "../shared/theme/service";
/** Native radios provide one Tab stop and standard arrow-key selection. */
export function AppearanceSettings({ appearance }: { appearance: Appearance }) {
- const { mode, error } = useSyncExternalStore(
+ const { mode, error, fontScale, fontError } = useSyncExternalStore(
appearance.subscribe,
appearance.snapshot,
);
@@ -53,6 +53,47 @@ export function AppearanceSettings({ appearance }: { appearance: Appearance }) {
))}
+
+ Text size
+
+ Resize text without zooming the window. Saved on this device.
+
+
+ appearance.setFontScale(fontScale - 0.1)}
+ >
+ −
+
+
+ {Math.round(fontScale * 100)}%
+
+ = 2}
+ onClick={() => appearance.setFontScale(fontScale + 0.1)}
+ >
+ +
+
+ appearance.setFontScale(1)}>
+ Reset text size
+
+
+
+ {fontError && (
+
+
{fontError}
+
appearance.setFontScale(fontScale)}
+ >
+ Retry saving text size
+
+
+ )}
{error && (
{error}
diff --git a/src/app/Settings.tsx b/src/app/Settings.tsx
index 5071d609f..e51f3a068 100644
--- a/src/app/Settings.tsx
+++ b/src/app/Settings.tsx
@@ -135,7 +135,7 @@ export function Settings({
/>
-
+
{plugin.manifest.name}
{failure && (
diff --git a/src/app/services.ts b/src/app/services.ts
index 669d2f1de..9b9b8e2d5 100644
--- a/src/app/services.ts
+++ b/src/app/services.ts
@@ -1,4 +1,5 @@
// FOUNDATION: Compose the bundled distribution, plugin runtime, and services here.
+import { ShortcutsService } from "../features/shortcuts/service";
import { ConversationService } from "../features/conversation/service";
import { createAppearance } from "../shared/theme/service";
import { createCommunities } from "../features/communities/service";
@@ -15,6 +16,7 @@ export function createServices() {
const plugins = createPluginManager(ctx, {
bundled: bundledPlugins,
});
+ const shortcuts = new ShortcutsService(ctx);
const pages = new PagesService(ctx);
const panels = new PanelsService(ctx);
const conversation = new ConversationService(ctx);
@@ -25,6 +27,7 @@ export function createServices() {
const relay = communities.relay;
let disposal: Promise | undefined;
return {
+ shortcuts,
conversation,
pages,
panels,
diff --git a/src/app/shortcuts.ts b/src/app/shortcuts.ts
new file mode 100644
index 000000000..095cdc8d2
--- /dev/null
+++ b/src/app/shortcuts.ts
@@ -0,0 +1,61 @@
+import type { ShortcutsService } from "../features/shortcuts/service";
+import type { Appearance } from "../shared/theme/service";
+
+/** Host actions use the same binding/dispatch rules as plugins, without fake plugin ownership. */
+export function registerAppShortcuts(
+ shortcuts: ShortcutsService,
+ appearance: Appearance,
+ openSettings: () => void,
+ ready: boolean,
+) {
+ const remove = [
+ shortcuts.registerHost({
+ id: "settings",
+ title: "Open Settings",
+ binding: { key: ",", mod: true },
+ allowInEditable: true,
+ when: () => ready,
+ run: openSettings,
+ }),
+ ...(
+ [
+ [
+ "font-increase",
+ "Increase text size",
+ [
+ { key: "=", mod: true },
+ { key: "=", mod: true, shift: true },
+ { key: "+", mod: true },
+ { key: "+", mod: true, shift: true },
+ ],
+ () => appearance.setFontScale(appearance.snapshot().fontScale + 0.1),
+ ],
+ [
+ "font-decrease",
+ "Decrease text size",
+ { key: "-", mod: true },
+ () => appearance.setFontScale(appearance.snapshot().fontScale - 0.1),
+ ],
+ [
+ "font-reset",
+ "Reset text size",
+ { key: "0", mod: true },
+ () => appearance.setFontScale(1),
+ ],
+ ] as const
+ ).map(([id, title, binding, run]) =>
+ shortcuts.registerHost({
+ id,
+ title,
+ binding,
+ run,
+ allowInEditable: true,
+ allowInModal: true,
+ repeat: true,
+ }),
+ ),
+ ];
+ return () => {
+ for (const dispose of remove) dispose();
+ };
+}
diff --git a/src/bundled/agents/AgentsPage.tsx b/src/bundled/agents/AgentsPage.tsx
index db78bd06c..70b5cd357 100644
--- a/src/bundled/agents/AgentsPage.tsx
+++ b/src/bundled/agents/AgentsPage.tsx
@@ -207,7 +207,7 @@ function AgentCard({
{identities.map((identity) => (
{identity.name}
-
+
{identity.pubkey}
diff --git a/src/bundled/channels/Channels.module.css b/src/bundled/channels/Channels.module.css
index a64eb6ae0..16587243b 100644
--- a/src/bundled/channels/Channels.module.css
+++ b/src/bundled/channels/Channels.module.css
@@ -3,7 +3,7 @@
height: 100%;
min-height: 0;
color: var(--text);
- font-size: 14px;
+ font-size: calc(14px * var(--buzz-text-scale, 1));
}
.root button {
color: inherit;
@@ -71,7 +71,7 @@
.channelSection > summary {
padding: 6px 8px;
color: var(--text-muted);
- font-size: 12px;
+ font-size: calc(12px * var(--buzz-text-scale, 1));
font-weight: 600;
cursor: pointer;
}
@@ -85,7 +85,7 @@
background: none;
padding: 5px 8px;
min-height: 30px;
- line-height: 20px;
+ line-height: calc(20px * var(--buzz-text-scale, 1));
border-radius: 10px;
flex-shrink: 0;
}
@@ -104,7 +104,7 @@
.preferenceNotice {
padding: 8px;
color: var(--text-muted);
- font-size: 12px;
+ font-size: calc(12px * var(--buzz-text-scale, 1));
border-top: 1px solid var(--border);
}
.preferenceNotice button {
@@ -131,7 +131,7 @@
border: 0;
background: var(--surface-control);
padding: 7px 9px;
- font-size: 12px;
+ font-size: calc(12px * var(--buzz-text-scale, 1));
}
.heading button[aria-pressed="true"] {
background: var(--selected);
@@ -151,7 +151,7 @@
.empty {
padding: 30px 18px;
color: var(--text-muted);
- font-size: 13px;
+ font-size: calc(13px * var(--buzz-text-scale, 1));
line-height: 1.6;
}
.panelStack {
@@ -186,7 +186,7 @@
margin-bottom: 16px;
}
.connect h1 {
- font-size: 28px;
+ font-size: calc(28px * var(--buzz-text-scale, 1));
}
.connect p {
max-width: 550px;
@@ -196,7 +196,7 @@
margin: 15px 0;
}
.note {
- font-size: 12px;
+ font-size: calc(12px * var(--buzz-text-scale, 1));
}
@media (max-width: 1000px) {
.board,
@@ -231,10 +231,10 @@
padding: 12px;
}
.channelList button {
- font-size: 12px;
+ font-size: calc(12px * var(--buzz-text-scale, 1));
}
.roster {
- font-size: 10px;
+ font-size: calc(10px * var(--buzz-text-scale, 1));
}
}
.channelList button span {
@@ -275,7 +275,7 @@
border-radius: 14px;
background: var(--surface);
box-shadow: var(--elevation-popover);
- font-size: 12px;
+ font-size: calc(12px * var(--buzz-text-scale, 1));
}
.diagnosticsMenu summary {
cursor: pointer;
@@ -288,7 +288,7 @@
justify-content: space-between;
gap: 8px;
padding: 8px 16px;
- font-size: 12px;
+ font-size: calc(12px * var(--buzz-text-scale, 1));
background: var(--warning-surface);
color: var(--warning);
border-bottom: 1px solid var(--border);
diff --git a/src/bundled/emoji/Emoji.module.css b/src/bundled/emoji/Emoji.module.css
index 0a81f2cb3..50006b37b 100644
--- a/src/bundled/emoji/Emoji.module.css
+++ b/src/bundled/emoji/Emoji.module.css
@@ -26,7 +26,7 @@
border-radius: 10px;
background: var(--surface);
box-shadow: var(--elevation-card);
- font-size: 12px;
+ font-size: calc(12px * var(--buzz-text-scale, 1));
}
.emojiStatus button {
width: auto;
diff --git a/src/bundled/github/GitHub.module.css b/src/bundled/github/GitHub.module.css
index 1fa3d047a..74977e70d 100644
--- a/src/bundled/github/GitHub.module.css
+++ b/src/bundled/github/GitHub.module.css
@@ -2,7 +2,7 @@
padding: 20px;
overflow-y: auto;
min-height: 0;
- font-size: 13px;
+ font-size: calc(13px * var(--buzz-text-scale, 1));
color: var(--text);
}
.identity,
@@ -20,7 +20,7 @@
.byline,
.note {
color: var(--text-muted);
- font-size: 11px;
+ font-size: calc(11px * var(--buzz-text-scale, 1));
}
.icon {
display: grid;
@@ -48,7 +48,7 @@
}
.root h2 {
margin: 16px 0;
- font-size: 18px;
+ font-size: calc(18px * var(--buzz-text-scale, 1));
line-height: 1.4;
overflow-wrap: anywhere;
}
diff --git a/src/bundled/mentions/Mentions.module.css b/src/bundled/mentions/Mentions.module.css
index 33029ce8d..864a47c01 100644
--- a/src/bundled/mentions/Mentions.module.css
+++ b/src/bundled/mentions/Mentions.module.css
@@ -17,7 +17,7 @@
gap: 6px;
}
.mentionPopover p {
- font-size: 12px;
+ font-size: calc(12px * var(--buzz-text-scale, 1));
}
.mentionChoices {
display: grid;
@@ -40,7 +40,7 @@
width: 100%;
}
.mentionChoices code {
- font-size: 10px;
+ font-size: calc(10px * var(--buzz-text-scale, 1));
overflow-wrap: anywhere;
}
.mentionChoices button:hover {
@@ -52,7 +52,7 @@
gap: 2px;
}
.mentionLabel > span {
- font-size: 13px;
+ font-size: calc(13px * var(--buzz-text-scale, 1));
font-weight: 500;
}
.mentionLabel code {
diff --git a/src/features/communities/Communities.module.css b/src/features/communities/Communities.module.css
index 91d15c4cb..9c2e0159e 100644
--- a/src/features/communities/Communities.module.css
+++ b/src/features/communities/Communities.module.css
@@ -8,7 +8,7 @@
border-radius: 999px;
padding: 8px 10px;
background: color-mix(in srgb, var(--surface) 31%, transparent);
- font-size: 13px;
+ font-size: calc(13px * var(--buzz-text-scale, 1));
}
.switcher svg {
flex-shrink: 0;
@@ -42,7 +42,7 @@
flex-shrink: 0;
border-radius: 8px;
overflow: hidden;
- font-size: 11px;
+ font-size: calc(11px * var(--buzz-text-scale, 1));
}
.communityIcon img {
width: 100%;
@@ -73,18 +73,18 @@
gap: 1rem;
}
.dialog h2 {
- font-size: 1.25rem;
+ font-size: calc(1.25rem * var(--buzz-text-scale, 1));
font-weight: 650;
}
.dialog p {
margin: 1rem 0;
line-height: 1.6;
- font-size: 0.875rem;
+ font-size: calc(0.875rem * var(--buzz-text-scale, 1));
}
.dialog label:not(.check) {
display: block;
margin-top: 1.1rem;
- font-size: 0.875rem;
+ font-size: calc(0.875rem * var(--buzz-text-scale, 1));
font-weight: 550;
}
.dialog input:not([type="checkbox"]) {
@@ -117,14 +117,14 @@
.note {
color: var(--text-muted);
font-weight: 400;
- font-size: 0.8rem;
+ font-size: calc(0.8rem * var(--buzz-text-scale, 1));
}
.policy {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-top: 1rem;
- font-size: 0.875rem;
+ font-size: calc(0.875rem * var(--buzz-text-scale, 1));
}
.policy a {
text-decoration: underline;
diff --git a/src/features/messages/Messages.module.css b/src/features/messages/Messages.module.css
index c7f3c54d2..be258f09f 100644
--- a/src/features/messages/Messages.module.css
+++ b/src/features/messages/Messages.module.css
@@ -26,7 +26,7 @@
border: 0;
background: var(--surface-control);
padding: 7px 9px;
- font-size: 12px;
+ font-size: calc(12px * var(--buzz-text-scale, 1));
}
.heading button[aria-pressed="true"] {
background: var(--selected);
@@ -51,14 +51,14 @@
display: flex;
align-items: center;
justify-content: center;
- font-size: 11px;
+ font-size: calc(11px * var(--buzz-text-scale, 1));
color: var(--text-muted);
gap: 8px;
}
.edge button {
border: 0;
padding: 5px 10px;
- font-size: 11px;
+ font-size: calc(11px * var(--buzz-text-scale, 1));
color: var(--text-muted);
background: var(--surface-control);
}
@@ -67,7 +67,7 @@
align-items: center;
gap: 0;
color: var(--text);
- font-size: 12px;
+ font-size: calc(12px * var(--buzz-text-scale, 1));
margin: 10px 0 30px;
}
.day::before,
@@ -91,7 +91,7 @@
color: var(--text-muted);
display: grid;
place-items: center;
- font-size: 12px;
+ font-size: calc(12px * var(--buzz-text-scale, 1));
overflow: hidden;
}
.avatar img {
@@ -108,16 +108,16 @@
flex-wrap: wrap;
align-items: baseline;
gap: 8px;
- font-size: 14px;
- line-height: 20px;
+ font-size: calc(14px * var(--buzz-text-scale, 1));
+ line-height: calc(20px * var(--buzz-text-scale, 1));
}
.byline time {
color: var(--text-muted);
- font-size: 12px;
+ font-size: calc(12px * var(--buzz-text-scale, 1));
}
.text {
margin: 4px 0 0;
- font-size: 15px;
+ font-size: calc(15px * var(--buzz-text-scale, 1));
line-height: 1.5;
white-space: pre-wrap;
overflow-wrap: anywhere;
@@ -152,7 +152,7 @@
align-items: center;
gap: 6px;
margin-top: 10px;
- font-size: 13px;
+ font-size: calc(13px * var(--buzz-text-scale, 1));
color: var(--text);
}
.attachment {
@@ -162,7 +162,7 @@
border: 1px solid var(--border);
border-radius: 8px;
color: var(--link);
- font-size: 12px;
+ font-size: calc(12px * var(--buzz-text-scale, 1));
}
.composer {
border: 1px solid var(--border-input);
@@ -175,12 +175,12 @@
flex-direction: column;
gap: 6px;
color: var(--text);
- font-size: 14px;
+ font-size: calc(14px * var(--buzz-text-scale, 1));
}
.empty {
padding: 30px 18px;
color: var(--text-muted);
- font-size: 13px;
+ font-size: calc(13px * var(--buzz-text-scale, 1));
line-height: 1.6;
}
@media (max-width: 650px) {
@@ -220,7 +220,7 @@
}
.composerHint {
color: var(--text-muted);
- font-size: 11px;
+ font-size: calc(11px * var(--buzz-text-scale, 1));
}
@media (max-width: 850px) {
.composerHint {
@@ -233,7 +233,7 @@
flex-wrap: wrap;
gap: 8px;
color: var(--warning);
- font-size: 12px;
+ font-size: calc(12px * var(--buzz-text-scale, 1));
margin-top: 6px;
}
.day span {
@@ -307,7 +307,7 @@
border-top: 1px solid var(--border);
padding: 12px 0;
color: var(--text-muted);
- font-size: 12px;
+ font-size: calc(12px * var(--buzz-text-scale, 1));
}
.threadHistoryControls {
flex-shrink: 0;
@@ -320,11 +320,11 @@
padding: 6px 10px;
background: var(--surface-accent);
border: 0;
- font-size: 12px;
+ font-size: calc(12px * var(--buzz-text-scale, 1));
}
.threadNote {
color: var(--text-muted);
- font-size: 12px;
+ font-size: calc(12px * var(--buzz-text-scale, 1));
}
.threadHistoryControls .threadNote {
width: 100%;
@@ -348,7 +348,7 @@
border-radius: 50%;
background: var(--surface-accent);
color: var(--text-muted);
- font-size: 9px;
+ font-size: calc(9px * var(--buzz-text-scale, 1));
line-height: 1;
}
.threadAvatar img {
@@ -364,7 +364,7 @@
flex-wrap: wrap;
align-items: center;
gap: 6px;
- font-size: 12px;
+ font-size: calc(12px * var(--buzz-text-scale, 1));
}
.mentionRecipients button {
border-radius: 6px;
@@ -375,8 +375,8 @@
border-color: var(--border);
background: var(--action);
color: var(--on-action);
- font-size: 12px;
+ font-size: calc(12px * var(--buzz-text-scale, 1));
}
.mentionRecipients code {
- font-size: 10px;
+ font-size: calc(10px * var(--buzz-text-scale, 1));
}
diff --git a/src/features/panels/Panels.module.css b/src/features/panels/Panels.module.css
index 6bbf7977d..2181c68a4 100644
--- a/src/features/panels/Panels.module.css
+++ b/src/features/panels/Panels.module.css
@@ -9,7 +9,7 @@
border: 1px solid var(--border);
border-radius: var(--radius-card);
box-shadow: var(--elevation-card);
- font-size: 14px;
+ font-size: calc(14px * var(--buzz-text-scale, 1));
color: var(--text);
}
.heading {
diff --git a/src/features/shortcuts/bindings.ts b/src/features/shortcuts/bindings.ts
new file mode 100644
index 000000000..d9380f8e5
--- /dev/null
+++ b/src/features/shortcuts/bindings.ts
@@ -0,0 +1,88 @@
+// FOUNDATION: In-app key matching. Local editors handle events before this layer.
+export type KeyBinding = Readonly<{
+ key: string;
+ /** Command on Apple platforms, Control elsewhere. Other modifiers match exactly. */
+ mod?: boolean;
+ shift?: boolean;
+ alt?: boolean;
+}>;
+export type Shortcut = Readonly<{
+ id: string;
+ title: string;
+ binding: KeyBinding | readonly KeyBinding[];
+ run: () => void | Promise;
+ /** Read current state; false makes this registration ineligible. */
+ when?: () => boolean;
+ allowInEditable?: boolean;
+ allowInModal?: boolean;
+ /** Held keys are consumed but run only once unless opted in. */
+ repeat?: boolean;
+}>;
+
+export function normalizeShortcut(shortcut: Shortcut): Shortcut {
+ if (
+ !shortcut ||
+ typeof shortcut.id !== "string" ||
+ !/^[a-z0-9][a-z0-9._-]*$/.test(shortcut.id) ||
+ typeof shortcut.title !== "string" ||
+ !shortcut.title.trim() ||
+ typeof shortcut.run !== "function" ||
+ (shortcut.when !== undefined && typeof shortcut.when !== "function") ||
+ [shortcut.allowInEditable, shortcut.allowInModal, shortcut.repeat].some(
+ (value) => value !== undefined && typeof value !== "boolean",
+ )
+ )
+ throw new Error("A shortcut needs an id, title, binding and run function");
+ const bindings = Array.isArray(shortcut.binding)
+ ? shortcut.binding
+ : [shortcut.binding];
+ if (
+ !bindings.length ||
+ bindings.some(
+ (binding) =>
+ !binding ||
+ typeof binding.key !== "string" ||
+ binding.key.length === 0 ||
+ [binding.mod, binding.shift, binding.alt].some(
+ (value) => value !== undefined && typeof value !== "boolean",
+ ),
+ )
+ )
+ throw new Error("Invalid shortcut binding");
+ return Object.freeze({
+ ...shortcut,
+ binding: Object.freeze(
+ bindings.map((binding) => Object.freeze({ ...binding })),
+ ),
+ });
+}
+
+export function matches(
+ shortcut: Shortcut,
+ event: KeyboardEvent,
+ apple: boolean,
+) {
+ const bindings = Array.isArray(shortcut.binding)
+ ? shortcut.binding
+ : [shortcut.binding];
+ return bindings.some(
+ (binding) =>
+ binding.key.toLowerCase() === event.key.toLowerCase() &&
+ !!binding.mod === (apple ? event.metaKey : event.ctrlKey) &&
+ !(apple ? event.ctrlKey : event.metaKey) &&
+ !!binding.shift === event.shiftKey &&
+ !!binding.alt === event.altKey,
+ );
+}
+
+export function inEditable(event: KeyboardEvent) {
+ // composedPath includes the actual input inside an open Shadow DOM, unlike target.
+ return event.composedPath().some((target) => {
+ const element = target as HTMLElement;
+ return (
+ element.isContentEditable ||
+ /^(INPUT|TEXTAREA|SELECT)$/.test(element.tagName) ||
+ element.getAttribute?.("role") === "textbox"
+ );
+ });
+}
diff --git a/src/features/shortcuts/service.test.ts b/src/features/shortcuts/service.test.ts
new file mode 100644
index 000000000..f893671b2
--- /dev/null
+++ b/src/features/shortcuts/service.test.ts
@@ -0,0 +1,291 @@
+import { Context } from "@deepseek-ai/cordis";
+import { expect, it, vi } from "vitest";
+import { ShortcutsService } from "./service";
+import { PluginRuntime } from "../../plugins/runtime";
+import type { PluginInfo } from "../../plugins/types";
+import type { Shortcut } from "./bindings";
+
+function browser(apple = true) {
+ const listeners = new Set<(event: KeyboardEvent) => void>();
+ let modal = false;
+ const host = {
+ navigator: { platform: apple ? "MacIntel" : "Linux x86_64" },
+ document: { querySelector: () => (modal ? {} : null) },
+ addEventListener: (_: string, fn: (event: KeyboardEvent) => void) =>
+ listeners.add(fn),
+ removeEventListener: (_: string, fn: (event: KeyboardEvent) => void) =>
+ listeners.delete(fn),
+ } as unknown as Window;
+ return {
+ host,
+ listeners,
+ modal(value: boolean) {
+ modal = value;
+ },
+ key(key = "k", init: Partial = {}, path: unknown[] = []) {
+ const event = {
+ key,
+ metaKey: apple,
+ ctrlKey: !apple,
+ altKey: false,
+ shiftKey: false,
+ defaultPrevented: false,
+ repeat: false,
+ isComposing: false,
+ keyCode: 0,
+ getModifierState: () => false,
+ composedPath: () => path,
+ preventDefault() {
+ Object.defineProperty(this, "defaultPrevented", { value: true });
+ },
+ ...init,
+ } as KeyboardEvent;
+ for (const dispatch of listeners) dispatch(event);
+ return event;
+ },
+ };
+}
+const shortcut = (run: Shortcut["run"] = vi.fn()): Shortcut => ({
+ id: "action",
+ title: "Action",
+ binding: { key: "k", mod: true },
+ run,
+});
+const plugin = (id: string, revision = "one"): PluginInfo => ({
+ manifest: { id, name: id, apiVersion: 1 },
+ enabled: true,
+ source: "external",
+ revision,
+ previous: null,
+ error: null,
+});
+
+it.each([true, false])(
+ "matches platform Mod and exact modifiers; protects local handlers, IME and focus (Apple=%s)",
+ async (apple) => {
+ const b = browser(apple),
+ ctx = new Context();
+ ctx.provide("pluginStatus", {
+ isActive: () => true,
+ subscribe: () => () => {},
+ });
+ const service = new ShortcutsService(ctx, b.host),
+ run = vi.fn();
+ service.registerHost(shortcut(run));
+ expect(b.key().defaultPrevented).toBe(true);
+ expect(run).toHaveBeenCalledTimes(1);
+ for (const init of [
+ { altKey: true },
+ { shiftKey: true },
+ { ctrlKey: true, metaKey: true },
+ { ctrlKey: false, metaKey: false },
+ { isComposing: true },
+ { keyCode: 229 },
+ { defaultPrevented: true },
+ { getModifierState: () => true },
+ ])
+ b.key("k", init);
+ for (const target of [
+ { tagName: "INPUT" },
+ { tagName: "TEXTAREA" },
+ { tagName: "SELECT" },
+ { isContentEditable: true },
+ { getAttribute: () => "textbox" },
+ ]) {
+ expect(
+ b.key("k", {}, [target, { tagName: "SHADOW-HOST" }]).defaultPrevented,
+ ).toBe(false);
+ }
+ b.modal(true);
+ expect(b.key().defaultPrevented).toBe(false);
+ b.modal(false);
+ expect(run).toHaveBeenCalledTimes(1);
+ expect(b.key("k", { repeat: true }).defaultPrevented).toBe(true);
+ expect(run).toHaveBeenCalledTimes(1);
+ await ctx.fiber.dispose();
+ expect(b.listeners.size).toBe(0);
+ b.key();
+ expect(run).toHaveBeenCalledTimes(1);
+ },
+);
+
+it("real plugin activation, stable conflicts, host reservations, replacement and disposal", async () => {
+ const b = browser(),
+ root = new Context(),
+ calls: string[] = [];
+ const runtime = new PluginRuntime(root, async (info) => ({
+ inject: ["shortcuts"],
+ apply(ctx) {
+ ctx.shortcuts.register(
+ shortcut(() => {
+ calls.push(`${info.manifest.id}:${info.revision}`);
+ }),
+ );
+ },
+ }));
+ const service = new ShortcutsService(root, b.host);
+ try {
+ // Reverse activation order deliberately; namespaced ID decides conflicts.
+ runtime.reconcile([plugin("z")]);
+ await vi.waitFor(() => expect(service.snapshot()).toHaveLength(1));
+ runtime.reconcile([plugin("z"), plugin("a")]);
+ await vi.waitFor(() => expect(service.snapshot()).toHaveLength(2));
+ b.key();
+ expect(calls).toEqual(["a:one"]);
+ const remove = service.registerHost({
+ ...shortcut(() => {
+ calls.push("host");
+ }),
+ when: () => false,
+ });
+ expect(b.key().defaultPrevented).toBe(false);
+ expect(calls).toHaveLength(1);
+ remove();
+ runtime.reconcile([plugin("z")]);
+ await vi.waitFor(() => expect(service.snapshot()).toHaveLength(1));
+ b.key();
+ expect(calls.at(-1)).toBe("z:one");
+ runtime.reconcile([plugin("z"), plugin("a", "two")]);
+ await vi.waitFor(() => expect(service.snapshot()).toHaveLength(2));
+ b.key();
+ expect(calls.at(-1)).toBe("a:two");
+ runtime.reconcile([plugin("z"), plugin("a", "three")]);
+ await vi.waitFor(() =>
+ expect(service.snapshot().find((s) => s.pluginId === "a")?.revision).toBe(
+ "three",
+ ),
+ );
+ b.key();
+ expect(calls.at(-1)).toBe("a:three");
+ const host = service.registerHost(
+ shortcut(() => {
+ calls.push("host");
+ }),
+ );
+ b.key();
+ expect(calls.at(-1)).toBe("host");
+ host();
+ await runtime.dispose();
+ expect(service.snapshot()).toHaveLength(0);
+ expect(b.key().defaultPrevented).toBe(false);
+ } finally {
+ await runtime.dispose();
+ await root.fiber.dispose();
+ }
+ expect(b.listeners.size).toBe(0);
+});
+
+it("hides bindings before async apply finishes and after failed activation", async () => {
+ const b = browser(),
+ root = new Context(),
+ run = vi.fn();
+ let release!: () => void;
+ const ready = new Promise((resolve) => {
+ release = resolve;
+ });
+ const registered = vi.fn();
+ const runtime = new PluginRuntime(root, async () => ({
+ inject: ["shortcuts"],
+ async apply(ctx) {
+ ctx.shortcuts.register(shortcut(run));
+ registered();
+ await ready;
+ throw new Error("failed apply");
+ },
+ }));
+ const service = new ShortcutsService(root, b.host);
+ runtime.reconcile([plugin("a")]);
+ try {
+ await vi.waitFor(() => expect(registered).toHaveBeenCalled());
+ expect(service.snapshot()).toHaveLength(0);
+ b.key();
+ expect(run).not.toHaveBeenCalled();
+ release();
+ await vi.waitFor(() => expect(runtime.snapshot().a?.status).toBe("failed"));
+ b.key();
+ expect(run).not.toHaveBeenCalled();
+ } finally {
+ release();
+ await runtime.dispose();
+ await root.fiber.dispose();
+ }
+});
+
+it("validates and copies bindings, keeps owner checks, and contains async handler failures", async () => {
+ const b = browser(),
+ root = new Context();
+ root.provide("pluginStatus", {
+ isActive: () => true,
+ subscribe: () => () => {},
+ });
+ const service = new ShortcutsService(root, b.host),
+ run = vi.fn();
+ const error = vi.spyOn(console, "error").mockImplementation(() => {});
+ try {
+ expect(() => service.register(shortcut())).toThrow("installed plugin");
+ for (const value of [
+ null,
+ { ...shortcut(), binding: [] },
+ { ...shortcut(), binding: { key: "" } },
+ { ...shortcut(), binding: { key: "k", mod: 1 } },
+ { ...shortcut(), run: null },
+ ])
+ expect(() => service.registerHost(value as never)).toThrow();
+ const binding = { key: "k", mod: true };
+ const remove = service.registerHost({
+ ...shortcut(run),
+ binding,
+ allowInEditable: true,
+ allowInModal: true,
+ repeat: true,
+ });
+ binding.key = "x";
+ b.modal(true);
+ b.key("k", { repeat: true }, [{ tagName: "INPUT" }]);
+ expect(run).toHaveBeenCalledOnce();
+ expect(() => service.registerHost(shortcut())).toThrow(
+ "already registered",
+ );
+ remove();
+ service.registerHost(
+ shortcut(async () => {
+ throw new Error("rejected");
+ }),
+ );
+ b.modal(false);
+ b.key();
+ await Promise.resolve();
+ expect(error).toHaveBeenCalledWith(
+ "Shortcut failed: action",
+ expect.any(Error),
+ );
+ } finally {
+ error.mockRestore();
+ await root.fiber.dispose();
+ }
+});
+
+it("accepts the logical Space key but not an empty binding", async () => {
+ const b = browser(),
+ root = new Context(),
+ run = vi.fn();
+ root.provide("pluginStatus", {
+ isActive: () => true,
+ subscribe: () => () => {},
+ });
+ const service = new ShortcutsService(root, b.host);
+ try {
+ expect(() =>
+ service.registerHost({ ...shortcut(), binding: { key: "" } }),
+ ).toThrow();
+ service.registerHost({
+ ...shortcut(run),
+ binding: { key: " ", mod: true },
+ });
+ expect(b.key(" ").defaultPrevented).toBe(true);
+ expect(run).toHaveBeenCalledOnce();
+ expect(b.key("Space").defaultPrevented).toBe(false);
+ } finally {
+ await root.fiber.dispose();
+ }
+});
diff --git a/src/features/shortcuts/service.ts b/src/features/shortcuts/service.ts
new file mode 100644
index 000000000..78f492b85
--- /dev/null
+++ b/src/features/shortcuts/service.ts
@@ -0,0 +1,114 @@
+// FOUNDATION: One dispatcher; plugin readiness/lifetime remain owned by Cordis.
+import { Service, type Context } from "@deepseek-ai/cordis";
+import {
+ createContributions,
+ type Contribution,
+} from "../../plugins/contributions";
+import {
+ inEditable,
+ matches,
+ normalizeShortcut,
+ type Shortcut,
+} from "./bindings";
+export type { KeyBinding, Shortcut } from "./bindings";
+export type RegisteredShortcut = Contribution;
+export type Shortcuts = {
+ register(shortcut: Shortcut): void;
+ snapshot(): readonly RegisteredShortcut[];
+ subscribe(listener: () => void): () => void;
+};
+declare module "@deepseek-ai/cordis" {
+ interface Context {
+ shortcuts: Shortcuts;
+ }
+}
+
+export class ShortcutsService extends Service implements Shortcuts {
+ private readonly contributions;
+ private readonly hostBindings = new Map();
+ constructor(
+ ctx: Context,
+ host: Window | undefined = typeof window === "undefined"
+ ? undefined
+ : window,
+ ) {
+ super(ctx, "shortcuts");
+ this.contributions = createContributions(ctx);
+ const apple = /Mac|iPhone|iPad/.test(host?.navigator.platform ?? "");
+ const dispatch = (event: KeyboardEvent) => {
+ if (
+ event.defaultPrevented ||
+ event.isComposing ||
+ event.keyCode === 229 ||
+ event.getModifierState("AltGraph")
+ )
+ return;
+ const editable = inEditable(event);
+ const modalSelector = 'dialog[open], [aria-modal="true"]';
+ const modal =
+ !!host?.document.querySelector(modalSelector) ||
+ event
+ .composedPath()
+ .some((target) => (target as Element).matches?.(modalSelector));
+ const eligible = (shortcut: Shortcut) => {
+ try {
+ return (
+ (!editable || shortcut.allowInEditable) &&
+ (!modal || shortcut.allowInModal) &&
+ (!shortcut.when || shortcut.when())
+ );
+ } catch (error) {
+ console.error(`Shortcut predicate failed: ${shortcut.id}`, error);
+ return false;
+ }
+ };
+ // Host chords are reserved even while unavailable (e.g. Settings behind a modal).
+ const reserved = [...this.hostBindings.values()].find((shortcut) =>
+ matches(shortcut, event, apple),
+ );
+ const shortcut = reserved
+ ? eligible(reserved)
+ ? reserved
+ : undefined
+ : [...this.contributions.snapshot()]
+ .sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
+ .find(
+ (shortcut) =>
+ matches(shortcut, event, apple) && eligible(shortcut),
+ );
+ if (!shortcut) return;
+ event.preventDefault();
+ if (event.repeat && !shortcut.repeat) return;
+ try {
+ void Promise.resolve(shortcut.run()).catch((error) =>
+ console.error(`Shortcut failed: ${shortcut.id}`, error),
+ );
+ } catch (error) {
+ console.error(`Shortcut failed: ${shortcut.id}`, error);
+ }
+ };
+ ctx.effect(() => {
+ host?.addEventListener("keydown", dispatch);
+ return () => {
+ host?.removeEventListener("keydown", dispatch);
+ this.hostBindings.clear();
+ };
+ });
+ }
+ snapshot = () => this.contributions.snapshot();
+ subscribe = (listener: () => void) => this.contributions.subscribe(listener);
+ register(shortcut: Shortcut) {
+ this.contributions.register(this.ctx, normalizeShortcut(shortcut));
+ }
+ /** Host composition only; not part of the injected plugin contract. */
+ registerHost(shortcut: Shortcut) {
+ const entry = normalizeShortcut(shortcut);
+ if (this.hostBindings.has(entry.id))
+ throw new Error(`Host shortcut already registered: ${entry.id}`);
+ this.hostBindings.set(entry.id, entry);
+ return () => {
+ if (this.hostBindings.get(entry.id) === entry)
+ this.hostBindings.delete(entry.id);
+ };
+ }
+}
diff --git a/src/plugins/author.ts b/src/plugins/author.ts
index 634cee66b..8af24991b 100644
--- a/src/plugins/author.ts
+++ b/src/plugins/author.ts
@@ -12,3 +12,10 @@ export type {
InlineRenderer,
} from "../features/conversation/contracts";
export type { RelayData, RelaySnapshot } from "../features/relay/service";
+
+export type {
+ Shortcuts,
+ Shortcut,
+ KeyBinding,
+ RegisteredShortcut,
+} from "../features/shortcuts/service";
diff --git a/src/shared/styles/globals.css b/src/shared/styles/globals.css
index f100b0827..f35aef785 100644
--- a/src/shared/styles/globals.css
+++ b/src/shared/styles/globals.css
@@ -7,6 +7,21 @@
/* One palette, exposed to both CSS modules and Tailwind utilities. */
@theme inline {
+ /* Scale type, not Tailwind spacing (which is also rem-based). Plugins can
+ consume these utilities or multiply custom font sizes by --buzz-text-scale. */
+ --text-xs: calc(0.75rem * var(--buzz-text-scale, 1));
+ --text-sm: calc(0.875rem * var(--buzz-text-scale, 1));
+ --text-base: calc(1rem * var(--buzz-text-scale, 1));
+ --text-lg: calc(1.125rem * var(--buzz-text-scale, 1));
+ --text-xl: calc(1.25rem * var(--buzz-text-scale, 1));
+ --text-2xl: calc(1.5rem * var(--buzz-text-scale, 1));
+ --text-3xl: calc(1.875rem * var(--buzz-text-scale, 1));
+ --text-4xl: calc(2.25rem * var(--buzz-text-scale, 1));
+ --text-5xl: calc(3rem * var(--buzz-text-scale, 1));
+ --text-6xl: calc(3.75rem * var(--buzz-text-scale, 1));
+ --text-7xl: calc(4.5rem * var(--buzz-text-scale, 1));
+ --text-8xl: calc(6rem * var(--buzz-text-scale, 1));
+ --text-9xl: calc(8rem * var(--buzz-text-scale, 1));
--font-sans:
Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
@@ -47,6 +62,7 @@
box-sizing: border-box;
}
body {
+ font-size: calc(1rem * var(--buzz-text-scale, 1));
margin: 0;
}
button {
@@ -57,7 +73,7 @@
background: var(--surface);
color: var(--accent);
font: inherit;
- font-size: 14px;
+ font-size: calc(14px * var(--buzz-text-scale, 1));
}
input,
textarea,
@@ -90,19 +106,23 @@
cursor: default;
}
h1 {
- font-size: clamp(28px, 4vw, 40px);
+ font-size: clamp(
+ calc(28px * var(--buzz-text-scale, 1)),
+ calc(4vw * var(--buzz-text-scale, 1)),
+ calc(40px * var(--buzz-text-scale, 1))
+ );
line-height: 1.2;
font-weight: 600;
margin: 8px 0 24px;
letter-spacing: -1px;
}
h2 {
- font-size: 22px;
+ font-size: calc(22px * var(--buzz-text-scale, 1));
margin: 28px 0 8px;
font-weight: 600;
}
h3 {
- font-size: 17px;
+ font-size: calc(17px * var(--buzz-text-scale, 1));
margin: 0 0 6px;
font-weight: 600;
}
@@ -182,7 +202,7 @@
}
}
.shell-tab {
- @apply flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full border-0 bg-transparent px-4 py-1.5 text-sm leading-4 text-ink hover:bg-surface/80 hover:text-ink;
+ @apply flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full border-0 bg-transparent px-4 py-1.5 text-sm leading-[calc(1rem*var(--buzz-text-scale,1))] text-ink hover:bg-surface/80 hover:text-ink;
}
.shell-icon {
@apply flex size-9 items-center justify-center rounded-full border-0 bg-surface/50 p-0 text-ink hover:bg-surface hover:text-ink;
@@ -192,10 +212,10 @@
@apply bg-surface text-ink;
}
.muted {
- @apply text-[13px] font-normal text-muted;
+ @apply text-[length:calc(13px*var(--buzz-text-scale,1))] font-normal text-muted;
}
.eyebrow {
- @apply text-[11px] font-semibold tracking-[0.16em] text-muted;
+ @apply text-[length:calc(11px*var(--buzz-text-scale,1))] font-semibold tracking-[0.16em] text-muted;
}
.actions {
@apply flex flex-wrap gap-2;
diff --git a/src/shared/theme/service.test.ts b/src/shared/theme/service.test.ts
index 79464b662..fe9ea1446 100644
--- a/src/shared/theme/service.test.ts
+++ b/src/shared/theme/service.test.ts
@@ -1,12 +1,21 @@
import { readFileSync } from "node:fs";
import { runInNewContext } from "node:vm";
import { expect, it, vi } from "vitest";
-import { APPEARANCE_KEY, createAppearance, parseColorMode } from "./service";
+import {
+ APPEARANCE_KEY,
+ FONT_SCALE_KEY,
+ createAppearance,
+ parseColorMode,
+ parseFontScale,
+} from "./service";
function browser(stored: string | null = null) {
const values = new Map(stored === null ? [] : [[APPEARANCE_KEY, stored]]);
const listeners = new Set<(event: StorageEvent) => void>();
- const root = { dataset: {} as Record };
+ const root = {
+ dataset: {} as Record,
+ style: { setProperty: vi.fn() },
+ };
const meta = { setAttribute: vi.fn() };
const storage = {
getItem: vi.fn((key: string) => values.get(key) ?? null),
@@ -53,6 +62,8 @@ it.each([null, "", "system", "LIGHT", '{"mode":"dark"}', "light", "dark"])(
expect(app.snapshot()).toEqual({
mode: parseColorMode(value),
error: null,
+ fontScale: 1,
+ fontError: null,
});
expect(b.root.dataset.colorMode).toBe(app.snapshot().mode);
expect(b.meta.setAttribute).toHaveBeenLastCalledWith(
@@ -138,3 +149,64 @@ it("rejects an invalid runtime write without saving it", () => {
expect(b.storage.setItem).not.toHaveBeenCalled();
app.dispose();
});
+
+it.each([
+ null,
+ "",
+ "garbage",
+ "0",
+ "Infinity",
+ "0.7",
+ "2.1",
+ "1.3",
+ "2",
+ "0.8",
+])("bootstrap and font preference agree for %j", (value) => {
+ const b = browser("dark");
+ if (value !== null) b.values.set(FONT_SCALE_KEY, value);
+ runInNewContext(readFileSync("public/appearance-init.js", "utf8"), {
+ localStorage: b.storage,
+ document: b.host.document,
+ });
+ expect(b.root.style.setProperty).toHaveBeenLastCalledWith(
+ "--buzz-text-scale",
+ String(parseFontScale(value)),
+ );
+ const app = createAppearance(b.host);
+ expect(app.snapshot().fontScale).toBe(parseFontScale(value));
+ expect(app.snapshot().mode).toBe("dark");
+ app.dispose();
+});
+it("font writes clamp, round, recover from failure, sync and preserve color storage", () => {
+ const b = browser("dark");
+ const app = createAppearance(b.host);
+ app.setFontScale(1.1 + 0.1);
+ expect(b.values.get(FONT_SCALE_KEY)).toBe("1.2");
+ expect(b.values.get(APPEARANCE_KEY)).toBe("dark");
+ app.setFontScale(100);
+ expect(app.snapshot().fontScale).toBe(2);
+ app.setFontScale(0);
+ expect(app.snapshot().fontScale).toBe(0.8);
+ app.setFontScale(NaN);
+ expect(app.snapshot().fontScale).toBe(0.8);
+ b.storage.setItem.mockImplementationOnce(() => {
+ throw new Error("full");
+ });
+ app.setFontScale(1.4);
+ expect(app.snapshot().fontError).toContain("could not be saved");
+ expect(b.root.style.setProperty).toHaveBeenLastCalledWith(
+ "--buzz-text-scale",
+ "1.4",
+ );
+ app.setFontScale(1.4);
+ expect(app.snapshot().fontError).toBeNull();
+ b.values.set(FONT_SCALE_KEY, "1.6");
+ b.change(FONT_SCALE_KEY);
+ expect(app.snapshot().fontScale).toBe(1.6);
+ b.values.delete(FONT_SCALE_KEY);
+ b.change(null);
+ expect(app.snapshot().fontScale).toBe(1);
+ app.dispose();
+ app.setFontScale(2);
+ expect(app.snapshot().fontScale).toBe(1);
+});
diff --git a/src/shared/theme/service.ts b/src/shared/theme/service.ts
index dcaf453db..d9a186f02 100644
--- a/src/shared/theme/service.ts
+++ b/src/shared/theme/service.ts
@@ -1,24 +1,45 @@
/** Host-owned, device-local appearance. Never depends on plugin/relay readiness. */
export type ColorMode = "light" | "dark";
export const APPEARANCE_KEY = "buzz-appearance.v1";
+export const FONT_SCALE_KEY = "buzz-font-scale.v1";
+export const MIN_FONT_SCALE = 0.8;
+export const MAX_FONT_SCALE = 2;
+export function parseFontScale(value: unknown): number {
+ const number =
+ typeof value === "string" && value.trim() ? Number(value) : value;
+ return typeof number === "number" &&
+ Number.isFinite(number) &&
+ number >= MIN_FONT_SCALE &&
+ number <= MAX_FONT_SCALE
+ ? Math.round(number * 10) / 10
+ : 1;
+}
export const parseColorMode = (value: unknown): ColorMode =>
value === "dark" ? "dark" : "light";
export interface AppearanceSnapshot {
readonly mode: ColorMode;
+ readonly fontScale: number;
+ readonly fontError: string | null;
readonly error: string | null;
}
export function createAppearance(
host: Window | undefined = typeof window === "undefined" ? undefined : window,
) {
- let state: AppearanceSnapshot = { mode: "light", error: null };
+ let state: AppearanceSnapshot = {
+ mode: "light",
+ error: null,
+ fontScale: 1,
+ fontError: null,
+ };
let disposed = false;
const listeners = new Set<() => void>();
const apply = () => {
if (!host) return;
const root = host.document.documentElement;
root.dataset.colorMode = state.mode;
+ root.style.setProperty("--buzz-text-scale", String(state.fontScale));
// CSS owns the palette; browser chrome derives from the same canvas token.
host.document
.querySelector('meta[name="theme-color"]')
@@ -34,6 +55,7 @@ export function createAppearance(
const restore = () => {
try {
state = {
+ ...state,
mode: parseColorMode(host?.localStorage.getItem(APPEARANCE_KEY)),
error: null,
};
@@ -46,17 +68,41 @@ export function createAppearance(
}
notify();
};
+ const restoreFont = () => {
+ try {
+ state = {
+ ...state,
+ fontScale: parseFontScale(host?.localStorage.getItem(FONT_SCALE_KEY)),
+ fontError: null,
+ };
+ } catch {
+ state = {
+ ...state,
+ fontError:
+ "Text size could not be restored. Choose a size to try saving it again.",
+ };
+ }
+ notify();
+ };
const onStorage = (event: StorageEvent) => {
- if (event.key !== APPEARANCE_KEY && event.key !== null) return;
+ if (
+ event.key !== APPEARANCE_KEY &&
+ event.key !== FONT_SCALE_KEY &&
+ event.key !== null
+ )
+ return;
// Re-read the current value: an older queued event must not undo a newer save.
try {
if (event.storageArea !== host?.localStorage) return;
- restore();
+ if (event.key !== FONT_SCALE_KEY) restore();
+ if (event.key !== APPEARANCE_KEY) restoreFont();
} catch {
- restore();
+ if (event.key !== FONT_SCALE_KEY) restore();
+ if (event.key !== APPEARANCE_KEY) restoreFont();
}
};
restore();
+ restoreFont();
host?.addEventListener("storage", onStorage);
return {
snapshot: () => state,
@@ -77,7 +123,23 @@ export function createAppearance(
error =
"This appearance is active, but could not be saved on this device. Try again.";
}
- state = { mode, error };
+ state = { ...state, mode, error };
+ notify();
+ },
+ setFontScale(value: number) {
+ if (disposed || !Number.isFinite(value)) return;
+ const fontScale = parseFontScale(
+ Math.min(MAX_FONT_SCALE, Math.max(MIN_FONT_SCALE, value)),
+ );
+ let fontError: string | null = null;
+ try {
+ if (!host) throw new Error("No browser storage");
+ host.localStorage.setItem(FONT_SCALE_KEY, String(fontScale));
+ } catch {
+ fontError =
+ "This text size is active, but could not be saved on this device. Try again.";
+ }
+ state = { ...state, fontScale, fontError };
notify();
},
dispose() {
diff --git a/tests/browser/plugin-fixtures.tsx b/tests/browser/plugin-fixtures.tsx
index c612824ec..c5522cc3e 100644
--- a/tests/browser/plugin-fixtures.tsx
+++ b/tests/browser/plugin-fixtures.tsx
@@ -1,4 +1,6 @@
// Installed fixture code only; app composition, manager, contributions and UI remain production.
+import * as shortcutCounter from "../../examples/plugins/shortcut-counter/plugin.js";
+import shortcutManifest from "../../examples/plugins/shortcut-counter/manifest.json";
import { useEffect, useState } from "react";
import type { BundledPlugin } from "../../src/plugins/manager";
import type { PanelProps } from "../../src/features/panels/service";
@@ -38,6 +40,7 @@ function Legacy() {
);
}
export const fixturePlugins: readonly BundledPlugin[] = [
+ { manifest: { ...shortcutManifest, apiVersion: 1 }, module: shortcutCounter },
{
manifest: { id: "fixture.notes", name: "Notes fixture", apiVersion: 1 },
module: {
diff --git a/tests/browser/shortcuts.spec.mjs b/tests/browser/shortcuts.spec.mjs
new file mode 100644
index 000000000..a88104fa3
--- /dev/null
+++ b/tests/browser/shortcuts.spec.mjs
@@ -0,0 +1,226 @@
+import { test, expect } from "./fixture.mjs";
+import { open } from "./timeline.mjs";
+
+test.use({ pluginFixtures: true });
+const button = (page, name) => page.getByRole("button", { name, exact: true });
+const font = (locator) =>
+ locator.evaluate((el) => parseFloat(getComputedStyle(el).fontSize));
+const mod = async (page) =>
+ (await page.evaluate(() => /Mac|iPhone|iPad/.test(navigator.platform)))
+ ? "Meta"
+ : "Control";
+const scale = async (page, expected) =>
+ expect
+ .poll(() =>
+ page.evaluate(() =>
+ document.documentElement.style.getPropertyValue("--buzz-text-scale"),
+ ),
+ )
+ .toBe(String(expected));
+
+test("real Settings keys respect dialogs and modifiers, focus main, and preserve drafts", async ({
+ page,
+ app,
+}) => {
+ await open(page, app);
+ const modifier = await mod(page);
+ const composer = page.getByRole("textbox", {
+ name: "Message #Alpha",
+ exact: true,
+ });
+ await composer.fill("Keep my draft");
+ await page.keyboard.press(`${modifier}+Shift+,`);
+ await expect(composer).toBeVisible();
+ await button(page, "Find a page").click();
+ await page.keyboard.press(`${modifier}+,`);
+ await expect(page.getByRole("dialog", { name: "Find a page" })).toBeVisible();
+ await expect(
+ page.getByRole("heading", { name: "Settings", exact: true }),
+ ).toHaveCount(0);
+ await page.keyboard.press("Escape");
+ await composer.focus();
+ await page.keyboard.press(`${modifier}+,`);
+ await expect(
+ page.getByRole("heading", { name: "Settings", exact: true }),
+ ).toBeVisible();
+ await expect(page.getByRole("main")).toBeFocused();
+ await button(page, "Messages").first().click();
+ await expect(composer).toHaveValue("Keep my draft");
+});
+
+test("zoom keys resize real message/composer text, not window or spacing, and persist/reset", async ({
+ page,
+ app,
+}, testInfo) => {
+ await open(page, app);
+ const modifier = await mod(page);
+ const composer = page.getByRole("textbox", {
+ name: "Message #Alpha",
+ exact: true,
+ });
+ const message = page.locator("[data-message-id] p").last();
+ const base = { composer: await font(composer), message: await font(message) };
+ const header = await page.locator(".shell-header").boundingBox();
+ const node = await composer.elementHandle();
+ await composer.fill("Unsent zoom draft");
+ await page.keyboard.press(`${modifier}+=`);
+ await scale(page, 1.1);
+ expect(await font(composer)).toBeCloseTo(base.composer * 1.1, 1);
+ expect(await font(message)).toBeCloseTo(base.message * 1.1, 1);
+ await page.keyboard.press(`${modifier}+Shift+=`);
+ await scale(page, 1.2);
+ await page.keyboard.press(`${modifier}+-`);
+ await scale(page, 1.1);
+ await page.keyboard.press(`${modifier}+0`);
+ await scale(page, 1);
+ for (const init of [
+ { isComposing: true },
+ { altKey: true },
+ { ctrlKey: true, metaKey: true },
+ ]) {
+ const prevented = await composer.evaluate((el, init) => {
+ const event = new KeyboardEvent("keydown", {
+ key: "=",
+ metaKey: /Mac|iPhone|iPad/.test(navigator.platform),
+ ctrlKey: !/Mac|iPhone|iPad/.test(navigator.platform),
+ bubbles: true,
+ cancelable: true,
+ ...init,
+ });
+ el.dispatchEvent(event);
+ return event.defaultPrevented;
+ }, init);
+ expect(prevented).toBe(false);
+ await scale(page, 1);
+ }
+ // Locally handled events must win before the window's bubbling dispatcher.
+ await composer.evaluate((el) => {
+ const prevent = (event) => {
+ if (event.key === "=") {
+ event.preventDefault();
+ el.removeEventListener("keydown", prevent);
+ }
+ };
+ el.addEventListener("keydown", prevent);
+ });
+ await page.keyboard.press(`${modifier}+=`);
+ await scale(page, 1);
+ await page.keyboard.press(`${modifier}+=`);
+ await scale(page, 1.1);
+ expect(await node.evaluate((el) => el.isConnected)).toBe(true);
+ await expect(composer).toHaveValue("Unsent zoom draft");
+ expect((await page.locator(".shell-header").boundingBox()).height).toBe(
+ header.height,
+ );
+ expect(await page.evaluate(() => window.visualViewport.scale)).toBe(1);
+ await page.reload();
+ await scale(page, 1.1);
+ await button(page, "Messages").first().click();
+ await expect(composer).toHaveValue("Unsent zoom draft");
+ await composer.focus();
+ for (let i = 0; i < 15; i++) await page.keyboard.press(`${modifier}+=`);
+ await scale(page, 2);
+ expect(await font(composer)).toBeCloseTo(base.composer * 2, 1);
+ expect(await font(message)).toBeCloseTo(base.message * 2, 1);
+ await expect(composer).toBeInViewport();
+ await page.screenshot({ path: testInfo.outputPath("text-200-percent.png") });
+ await page.keyboard.press(`${modifier}+0`);
+ await scale(page, 1);
+ expect(await font(composer)).toBe(base.composer);
+ for (let i = 0; i < 5; i++) await page.keyboard.press(`${modifier}+-`);
+ await scale(page, 0.8);
+ await page.keyboard.press(`${modifier}+,`);
+ await button(page, "Appearance").click();
+ await expect(page.getByRole("status", { name: "Text size" })).toHaveText(
+ "80%",
+ );
+ await button(page, "Reset text size").click();
+ await scale(page, 1);
+});
+
+test("independent plugin consumes injected shortcuts; disable/re-enable and editor guards work", async ({
+ page,
+ app,
+}) => {
+ await page.goto(app.origin);
+ const modifier = await mod(page);
+ await button(page, "Shortcut counter").first().click();
+ const count = page.getByRole("status");
+ await expect(count).toHaveText("Shortcut count: 0");
+ await page.keyboard.press(`${modifier}+Shift+k`);
+ await expect(count).toHaveText("Shortcut count: 1");
+ const input = page.getByRole("textbox", { name: "Shortcut typing guard" });
+ await input.fill("Keep typing");
+ await page.keyboard.press(`${modifier}+Shift+k`);
+ await expect(count).toHaveText("Shortcut count: 1");
+ await page.evaluate(() => {
+ const host = document.createElement("div");
+ document.body.append(host);
+ const root = host.attachShadow({ mode: "open" });
+ const input = document.createElement("input");
+ root.append(input);
+ input.focus();
+ });
+ await page.keyboard.press(`${modifier}+Shift+k`);
+ await expect(count).toHaveText("Shortcut count: 1");
+ // Core zoom explicitly opts into editable targets, including Shadow DOM.
+ await page.keyboard.press(`${modifier}+=`);
+ await scale(page, 1.1);
+ await page.keyboard.press(`${modifier}+0`);
+ await scale(page, 1);
+ await button(page, "Find a page").click();
+ await page.keyboard.press(`${modifier}+Shift+k`);
+ await expect(count).toHaveText("Shortcut count: 1");
+ await page.keyboard.press("Escape");
+ await page.keyboard.press(`${modifier}+,`);
+ await button(page, "Plugins").click();
+ const toggle = page.getByRole("switch", { name: "Enable Shortcut counter" });
+ await toggle.click();
+ await expect(button(page, "Shortcut counter")).toHaveCount(0);
+ await page.keyboard.press(`${modifier}+Shift+k`);
+ await toggle.click();
+ await button(page, "Shortcut counter").first().click();
+ await expect(count).toHaveText("Shortcut count: 0");
+ await page.keyboard.press(`${modifier}+Shift+k`);
+ await expect(count).toHaveText("Shortcut count: 1");
+});
+
+test("a shadow-root modal blocks Settings and plugin bindings but allows text zoom", async ({
+ page,
+ app,
+}) => {
+ await page.goto(app.origin);
+ const modifier = await mod(page);
+ await button(page, "Shortcut counter").first().click();
+ await page.evaluate(() => {
+ const host = document.createElement("div");
+ host.id = "shadow-modal";
+ document.body.append(host);
+ const root = host.attachShadow({ mode: "open" });
+ const dialog = document.createElement("dialog");
+ const control = document.createElement("button");
+ control.textContent = "Modal control";
+ dialog.append(control);
+ root.append(dialog);
+ dialog.showModal();
+ control.focus();
+ });
+ const control = button(page, "Modal control");
+ await expect(control).toBeFocused();
+ await page.keyboard.press(`${modifier}+,`);
+ await expect(control).toBeFocused();
+ await expect(
+ page.getByRole("heading", { name: "Settings", exact: true }),
+ ).toHaveCount(0);
+ await page.keyboard.press(`${modifier}+Shift+k`);
+ await expect(page.getByRole("status")).toHaveText("Shortcut count: 0");
+ await page.keyboard.press(`${modifier}+=`);
+ await scale(page, 1.1);
+ await page.keyboard.press(`${modifier}+0`);
+ await scale(page, 1);
+ await page.keyboard.press("Escape");
+ await page.evaluate(() => document.getElementById("shadow-modal").remove());
+ await page.getByRole("main").focus();
+ await page.keyboard.press(`${modifier}+Shift+k`);
+ await expect(page.getByRole("status")).toHaveText("Shortcut count: 1");
+});