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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion docs/design-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
55 changes: 55 additions & 0 deletions docs/plugin-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
2 changes: 2 additions & 0 deletions examples/plugins/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions examples/plugins/shortcut-counter/README.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions examples/plugins/shortcut-counter/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"id": "example.shortcut-counter",
"name": "Shortcut counter",
"apiVersion": 1
}
54 changes: 54 additions & 0 deletions examples/plugins/shortcut-counter/plugin.js
Original file line number Diff line number Diff line change
@@ -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",
}),
);
},
});
}
12 changes: 12 additions & 0 deletions public/appearance-init.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
);
})();
17 changes: 16 additions & 1 deletion src/app/App.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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
Expand Down
43 changes: 42 additions & 1 deletion src/app/AppearanceSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
Expand Down Expand Up @@ -53,6 +53,47 @@ export function AppearanceSettings({ appearance }: { appearance: Appearance }) {
))}
</div>
</fieldset>
<fieldset className="mt-6 min-w-0 border-0 p-0">
<legend className="mb-2 text-base font-medium">Text size</legend>
<p className="mt-0 mb-3 text-sm text-muted">
Resize text without zooming the window. Saved on this device.
</p>
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
aria-label="Decrease text size"
disabled={fontScale <= 0.8}
onClick={() => appearance.setFontScale(fontScale - 0.1)}
>
</button>
<output aria-label="Text size">
{Math.round(fontScale * 100)}%
</output>
<button
type="button"
aria-label="Increase text size"
disabled={fontScale >= 2}
onClick={() => appearance.setFontScale(fontScale + 0.1)}
>
+
</button>
<button type="button" onClick={() => appearance.setFontScale(1)}>
Reset text size
</button>
</div>
</fieldset>
{fontError && (
<div role="alert" className="notice mb-0">
<p>{fontError}</p>
<button
type="button"
onClick={() => appearance.setFontScale(fontScale)}
>
Retry saving text size
</button>
</div>
)}
{error && (
<div role="alert" className="notice mb-0">
<p>{error}</p>
Expand Down
2 changes: 1 addition & 1 deletion src/app/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ export function Settings({
/>
</span>
<div className="min-w-0">
<h3 className="m-0 text-[15px] font-medium">
<h3 className="m-0 text-[length:calc(15px*var(--buzz-text-scale,1))] font-medium">
{plugin.manifest.name}
</h3>
{failure && (
Expand Down
3 changes: 3 additions & 0 deletions src/app/services.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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);
Expand All @@ -25,6 +27,7 @@ export function createServices() {
const relay = communities.relay;
let disposal: Promise<void> | undefined;
return {
shortcuts,
conversation,
pages,
panels,
Expand Down
61 changes: 61 additions & 0 deletions src/app/shortcuts.ts
Original file line number Diff line number Diff line change
@@ -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();
};
}
2 changes: 1 addition & 1 deletion src/bundled/agents/AgentsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ function AgentCard({
{identities.map((identity) => (
<li key={identity.pubkey}>
<span className="font-medium text-ink">{identity.name}</span>
<p className="m-0 mt-1 select-all break-all font-mono text-[10px]">
<p className="m-0 mt-1 select-all break-all font-mono text-[length:calc(10px*var(--buzz-text-scale,1))]">
{identity.pubkey}
</p>
</li>
Expand Down
Loading