diff --git a/src/App.tsx b/src/App.tsx index 5450e48..6e57e65 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,7 +7,7 @@ import { startNotifications } from "./lib/notify"; import { startRinger } from "./lib/ringer"; import { startUpdateChecks } from "./lib/update"; import { resetPresence } from "./lib/presence"; -import { applyAccent, load as loadSettings } from "./lib/settings"; +import { applyAccent, applyTypography, load as loadSettings } from "./lib/settings"; import type { RoomSummary } from "./lib/types"; import { useEdgeSwipeBack, useIsMobile, useKeyboardSafeArea } from "./lib/viewport"; import { selectActiveRoom, useStore } from "./store"; @@ -37,10 +37,12 @@ export default function App() { })), ); - // Paint the saved accent before anything renders, so the app never flashes - // the default colour on the way to the chosen one. + // Paint the saved accent and fonts before anything renders, so the app never + // flashes the defaults on the way to the chosen ones. useEffect(() => { - applyAccent(loadSettings().accent); + const saved = loadSettings(); + applyAccent(saved.accent); + applyTypography(saved); }, []); // Try to pick up where we left off before showing anything, so a returning diff --git a/src/components/Composer.tsx b/src/components/Composer.tsx index 05da1be..0cc1367 100644 --- a/src/components/Composer.tsx +++ b/src/components/Composer.tsx @@ -33,13 +33,12 @@ export function Composer({ const cameraInput = useRef(null); const [attachOpen, setAttachOpen] = useState(false); - const { draft, setDraft, clearDraft, showBanner, settings, packs } = useStore( + const { draft, setDraft, clearDraft, showBanner, packs } = useStore( useShallow((s) => ({ draft: selectDraft(s, key), setDraft: s.setDraft, clearDraft: s.clearDraft, showBanner: s.showBanner, - settings: s.settings, packs: s.packs, })), ); @@ -499,11 +498,8 @@ export function Composer({ } } - // Either Enter sends and Shift+Enter breaks the line, or the other - // way round — whichever the user picked in settings. - const sends = settings.sendOnEnter - ? e.key === "Enter" && !e.shiftKey && !e.metaKey - : e.key === "Enter" && (e.metaKey || e.ctrlKey); + // Enter sends; Shift+Enter breaks the line. + const sends = e.key === "Enter" && !e.shiftKey && !e.metaKey; if (sends) { e.preventDefault(); @@ -524,7 +520,7 @@ export function Composer({ outline: "none", resize: "none", color: "var(--text-primary)", - fontSize: 14.5, + fontSize: "var(--text-message)", lineHeight: 1.5, padding: "9px 6px", maxHeight: 160, diff --git a/src/components/MessageRow.tsx b/src/components/MessageRow.tsx index 1f79373..180e754 100644 --- a/src/components/MessageRow.tsx +++ b/src/components/MessageRow.tsx @@ -753,7 +753,8 @@ function ActionButton({ function ContentBody({ content, emojiOnly }: { content: Content; emojiOnly: boolean }) { const textStyle = { - fontSize: emojiOnly ? 34 : 14.5, + // Emoji-only messages keep their ratio to the text around them. + fontSize: emojiOnly ? "calc(var(--text-message) * 34 / 14.5)" : "var(--text-message)", lineHeight: emojiOnly ? 1.2 : 1.55, color: "var(--text-primary)", maxWidth: "min(640px, 100%)", diff --git a/src/components/SettingsView.tsx b/src/components/SettingsView.tsx index cfab0ca..d72dd57 100644 --- a/src/components/SettingsView.tsx +++ b/src/components/SettingsView.tsx @@ -12,7 +12,10 @@ import { mediaUrl } from "../lib/ipc"; import { invalidateProfile } from "../lib/profiles"; import { ACCENT_SWATCHES, + DEFAULTS, + FONT_PRESETS, listMediaDevices, + MESSAGE_FONT_SIZE_RANGE, type Accent, type AudioDevice, type MediaDevices, @@ -1137,23 +1140,123 @@ function AppearanceSection() { - composer - updateSettings({ fontFamily })} + /> + + - updateSettings({ sendOnEnter: next })} - /> - +
+ updateSettings({ messageFontSize: Number(e.target.value) })} + style={{ flex: 1, accentColor: "var(--accent-primary)" }} + /> + +
+ + +
+ the quick brown fox jumps over the lazy dog~ ✨ +
); } +/** + * The body font: a row of presets, and a box for the name of anything else + * installed. The box keeps its own draft so typing doesn't repaint the whole + * app with every half-written font name. + */ +function FontPicker({ + value, + onChange, +}: { + value: string; + onChange: (family: string) => void; +}) { + const preset = FONT_PRESETS.find((p) => p.family === value); + const [draft, setDraft] = useState(preset ? "" : value); + + // Picking a preset clears the box; a value set elsewhere shows up in it. + useEffect(() => { + setDraft(FONT_PRESETS.some((p) => p.family === value) ? "" : value); + }, [value]); + + const commit = () => { + const next = draft.trim(); + if (next !== value) onChange(next); + }; + + return ( + +
+ {FONT_PRESETS.map((p) => { + const active = p.family === value; + return ( + + ); + })} +
+ setDraft(e.target.value)} + onBlur={commit} + onKeyDown={(e) => { + if (e.key === "Enter") commit(); + }} + placeholder="custom font, e.g. Comic Sans MS" + spellCheck={false} + style={{ ...inputStyle, fontFamily: draft.trim() || undefined }} + /> +
+ ); +} + // --------------------------------------------------------------------------- // about // --------------------------------------------------------------------------- diff --git a/src/lib/settings.ts b/src/lib/settings.ts index b931a95..523d9d5 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -9,6 +9,13 @@ export type Accent = "acid" | "pink" | "violet" | "cyan"; export interface Settings { accent: Accent; + /** + * The CSS font family for body text, or "" for the design's own. Anything + * installed on the machine works; a name that isn't falls back to the default. + */ + fontFamily: string; + /** Size of message text, in px. The rest of the UI keeps its own sizes. */ + messageFontSize: number; /** `deviceId` of the microphone, or "" for the system default. */ audioInput: string; /** `deviceId` of the speaker, or "" for the system default. */ @@ -20,8 +27,6 @@ export interface Settings { * quieter than this. At `GATE_OFF_DB` or below, nothing is ever cut. */ inputSensitivity: number; - /** Send on Enter (Discord-style) vs. Cmd+Enter. */ - sendOnEnter: boolean; /** Show the room info panel beside the timeline. */ showInfoPanel: boolean; /** Which skin tone the emoji picker offers. 0 is the default yellow. */ @@ -64,13 +69,14 @@ export const MAX_RECENT_REACTIONS = 6; export const DEFAULTS: Settings = { accent: "acid", + fontFamily: "", + messageFontSize: 14.5, audioInput: "", audioOutput: "", videoInput: "", // Wide open until someone drags it up: a threshold set wrong cuts people off // mid-word, which is worse than the background noise it was meant to hide. inputSensitivity: GATE_OFF_DB, - sendOnEnter: true, showInfoPanel: true, skinTone: 0, notifyMessages: true, @@ -84,6 +90,17 @@ export const DEFAULTS: Settings = { recentReactions: ["💜", "😹", "🥺", "✨", "👀", "🔥"], }; +/** The range the message text size slider covers, in px. */ +export const MESSAGE_FONT_SIZE_RANGE = { min: 12, max: 22, step: 0.5 } as const; + +/** One-click choices for the font picker; anything else is typed in by name. */ +export const FONT_PRESETS: { label: string; family: string }[] = [ + { label: "nunito", family: "" }, + { label: "system", family: "system-ui" }, + { label: "baloo", family: "'Baloo 2'" }, + { label: "mono", family: "'JetBrains Mono Variable'" }, +]; + const STORAGE_KEY = "uwum:settings"; /** @@ -138,6 +155,28 @@ export function applyAccent(accent: Accent): void { root.style.setProperty("--accent-primary-press", `var(${press})`); } +/** The font stack `tokens/typography.css` gives body text. */ +const DEFAULT_BODY_FONT = "'Nunito Variable', 'Nunito', ui-sans-serif, system-ui, sans-serif"; + +/** + * Paint the chosen font and message size onto the document. + * + * A custom family is put in front of the design's stack rather than replacing + * it, so a misspelt or uninstalled font lands on Nunito instead of Times. + */ +export function applyTypography({ + fontFamily, + messageFontSize, +}: Pick): void { + const root = document.documentElement; + const family = fontFamily.trim(); + root.style.setProperty( + "--font-body", + family ? `${family}, ${DEFAULT_BODY_FONT}` : DEFAULT_BODY_FONT, + ); + root.style.setProperty("--text-message", `${messageFontSize}px`); +} + export interface AudioDevice { deviceId: string; label: string; diff --git a/src/store/index.ts b/src/store/index.ts index 12cd0c9..f351ad9 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -374,6 +374,9 @@ export const useStore = create((set, get) => ({ const settings = { ...s.settings, ...patch }; prefs.save(settings); if (patch.accent) prefs.applyAccent(patch.accent); + if (patch.fontFamily !== undefined || patch.messageFontSize !== undefined) { + prefs.applyTypography(settings); + } return { settings }; }), diff --git a/src/styles/tokens/typography.css b/src/styles/tokens/typography.css index 216bd38..dbb3770 100644 --- a/src/styles/tokens/typography.css +++ b/src/styles/tokens/typography.css @@ -1,11 +1,12 @@ :root{ --font-display:'Baloo 2',ui-rounded,'Segoe UI',sans-serif; --font-rave:'Unbounded',ui-sans-serif,sans-serif; ---font-body:'Nunito',ui-sans-serif,system-ui,sans-serif; ---font-mono:'JetBrains Mono',ui-monospace,'SFMono-Regular',monospace; +--font-body:'Nunito Variable','Nunito',ui-sans-serif,system-ui,sans-serif; +--font-mono:'JetBrains Mono Variable','JetBrains Mono',ui-monospace,'SFMono-Regular',monospace; --text-display-xl:clamp(40px,6vw,72px); --text-display-lg:40px;--text-display-md:32px;--text-display-sm:26px; --text-body-lg:18px;--text-body-md:15px;--text-body-sm:13px;--text-caption:11.5px; +--text-message:14.5px; --lh-tight:1.08;/* @kind other */--lh-snug:1.25;/* @kind other */--lh-normal:1.5;/* @kind other */--lh-relaxed:1.65;/* @kind other */ --ls-tight:-0.01em;/* @kind other */--ls-wide:0.06em;/* @kind other */--ls-widest:0.14em;/* @kind other */ --fw-regular:400;/* @kind other */--fw-medium:500;/* @kind other */--fw-semibold:600;/* @kind other */--fw-bold:700;/* @kind other */--fw-black:800;/* @kind other */