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. +

+
+ + + {Math.round(fontScale * 100)}% + + + +
+
+ {fontError && ( +
+

{fontError}

+ +
+ )} {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"); +});