Files
buzz/desktop/src/shared/lib/conversationDensityPreference.ts
klopez4212 7e2651791d Add font size and conversation density preferences (#5644)
## Summary

- Add separate **Font size** and **Conversation density** controls in
Appearance.
- Use a 13 / 14 / 15px conversation text ramp for Smaller / Default /
Larger while scaling interface typography through a shared virtual-rem
system.
- Keep layout geometry fixed while typography changes, and compose Cmd
+/- text zoom on top of the selected preference.
- Let Compact, Comfy, and Spacious control conversation row spacing plus
paragraph and list rhythm in Markdown content across Buzz.
- Preview font size and density together with click-and-drag comparison;
interrupted scrubbing restores the saved preference.
- Synchronize preferences across open Buzz windows, including full
local-storage clears during sign-out or dev reset.
- Promote the segmented control to shared UI and make Settings rows
adapt to their card width.

## Validation

- `just ci`
- 4,965+ desktop unit tests across the final merged branch
- 1,465 mobile tests through the full repository gate
- Focused Playwright coverage for font-size/density independence, drag
preview cancellation, Inbox geometry, thread rendering, keyboard zoom,
and cross-surface typography
- Fresh E2E build against the final merged branch

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
Signed-off-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Co-authored-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Co-authored-by: morgmart <98432065+morgmart@users.noreply.github.com>
2026-08-18 15:24:09 -07:00

102 lines
3.0 KiB
TypeScript

import * as React from "react";
/** Device-level spacing used across conversation surfaces. */
export type ConversationDensity = "compact" | "comfortable" | "spacious";
export const CONVERSATION_DENSITY_STORAGE_KEY =
"buzz.appearance.conversationDensity";
export const DEFAULT_CONVERSATION_DENSITY: ConversationDensity = "comfortable";
const listeners = new Set<() => void>();
let conversationDensity: ConversationDensity = DEFAULT_CONVERSATION_DENSITY;
let listeningForStorageChanges = false;
export function parseConversationDensity(
value: string | null | undefined,
): ConversationDensity {
return value === "compact" || value === "comfortable" || value === "spacious"
? value
: DEFAULT_CONVERSATION_DENSITY;
}
function readStoredConversationDensity(): ConversationDensity {
try {
return parseConversationDensity(
globalThis.localStorage?.getItem(CONVERSATION_DENSITY_STORAGE_KEY),
);
} catch {
return DEFAULT_CONVERSATION_DENSITY;
}
}
function applyConversationDensity(density: ConversationDensity): void {
globalThis.document?.documentElement?.setAttribute(
"data-conversation-density",
density,
);
}
function notifyListeners(): void {
for (const listener of listeners) listener();
}
function applyStoredConversationDensity(): void {
const nextDensity = readStoredConversationDensity();
const changed = nextDensity !== conversationDensity;
conversationDensity = nextDensity;
applyConversationDensity(nextDensity);
if (changed) notifyListeners();
}
function listenForStorageChanges(): void {
if (listeningForStorageChanges || !globalThis.window?.addEventListener)
return;
globalThis.window.addEventListener("storage", (event) => {
if (event.key === CONVERSATION_DENSITY_STORAGE_KEY || event.key === null) {
applyStoredConversationDensity();
}
});
listeningForStorageChanges = true;
}
/** Apply the persisted preference before React renders to avoid a layout jump. */
export function initializeConversationDensityPreference(): void {
applyStoredConversationDensity();
listenForStorageChanges();
}
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
export function getConversationDensity(): ConversationDensity {
return conversationDensity;
}
export function setConversationDensity(density: ConversationDensity): void {
conversationDensity = density;
applyConversationDensity(density);
try {
globalThis.localStorage?.setItem(CONVERSATION_DENSITY_STORAGE_KEY, density);
} catch {
// Persistence is best-effort; the live preference still applies.
}
notifyListeners();
}
/** Temporarily apply a density without changing the saved preference. */
export function previewConversationDensity(
density: ConversationDensity | null,
): void {
applyConversationDensity(density ?? conversationDensity);
}
export function useConversationDensity(): ConversationDensity {
return React.useSyncExternalStore(
subscribe,
getConversationDensity,
() => DEFAULT_CONVERSATION_DENSITY,
);
}