Compare commits

...

1 Commits

Author SHA1 Message Date
Lambda
2f7ff444b6 feat(settings): add Typography section to Appearance tab
Adds UI/Code font size steppers, UI/Code font family inputs, and a Font
Smoothing toggle backed by a persisted typography store.
2026-04-23 18:00:29 +08:00
4 changed files with 224 additions and 5 deletions

View File

@@ -0,0 +1,7 @@
export {
useTypographyStore,
DEFAULT_UI_FONT_SIZE,
DEFAULT_CODE_FONT_SIZE,
MIN_FONT_SIZE,
MAX_FONT_SIZE,
} from "./store";

View File

@@ -0,0 +1,49 @@
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { defaultStorage } from "../platform/storage";
export const DEFAULT_UI_FONT_SIZE = 14;
export const DEFAULT_CODE_FONT_SIZE = 13;
export const MIN_FONT_SIZE = 10;
export const MAX_FONT_SIZE = 24;
interface TypographyState {
uiFontSize: number;
codeFontSize: number;
uiFontFamily: string;
codeFontFamily: string;
fontSmoothing: boolean;
setUiFontSize: (size: number) => void;
setCodeFontSize: (size: number) => void;
setUiFontFamily: (family: string) => void;
setCodeFontFamily: (family: string) => void;
setFontSmoothing: (enabled: boolean) => void;
resetUiFontSize: () => void;
resetCodeFontSize: () => void;
}
const clampFontSize = (value: number): number =>
Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, Math.round(value)));
export const useTypographyStore = create<TypographyState>()(
persist(
(set) => ({
uiFontSize: DEFAULT_UI_FONT_SIZE,
codeFontSize: DEFAULT_CODE_FONT_SIZE,
uiFontFamily: "",
codeFontFamily: "",
fontSmoothing: false,
setUiFontSize: (size) => set({ uiFontSize: clampFontSize(size) }),
setCodeFontSize: (size) => set({ codeFontSize: clampFontSize(size) }),
setUiFontFamily: (family) => set({ uiFontFamily: family }),
setCodeFontFamily: (family) => set({ codeFontFamily: family }),
setFontSmoothing: (enabled) => set({ fontSmoothing: enabled }),
resetUiFontSize: () => set({ uiFontSize: DEFAULT_UI_FONT_SIZE }),
resetCodeFontSize: () => set({ codeFontSize: DEFAULT_CODE_FONT_SIZE }),
}),
{
name: "multica_typography",
storage: createJSONStorage(() => defaultStorage),
},
),
);

View File

@@ -67,7 +67,8 @@
"./utils": "./utils.ts",
"./constants/*": "./constants/*.ts",
"./platform": "./platform/index.ts",
"./analytics": "./analytics/index.ts"
"./analytics": "./analytics/index.ts",
"./appearance": "./appearance/index.ts"
},
"dependencies": {
"@tanstack/react-query": "catalog:",

View File

@@ -1,6 +1,16 @@
"use client";
import { Minus, Plus, RotateCcw } from "lucide-react";
import {
DEFAULT_CODE_FONT_SIZE,
DEFAULT_UI_FONT_SIZE,
MAX_FONT_SIZE,
MIN_FONT_SIZE,
useTypographyStore,
} from "@multica/core/appearance";
import { useTheme } from "@multica/ui/components/common/theme-provider";
import { Input } from "@multica/ui/components/ui/input";
import { Switch } from "@multica/ui/components/ui/switch";
import { cn } from "@multica/ui/lib/utils";
const LIGHT_COLORS = {
@@ -30,7 +40,6 @@ function WindowMockup({
return (
<div className={cn("flex h-full w-full flex-col", className)}>
{/* Title bar */}
<div
className="flex items-center gap-[3px] px-2 py-1.5"
style={{ backgroundColor: colors.titleBar }}
@@ -39,12 +48,10 @@ function WindowMockup({
<span className="size-[6px] rounded-full bg-[#febc2e]" />
<span className="size-[6px] rounded-full bg-[#28c840]" />
</div>
{/* Content area */}
<div
className="flex flex-1"
style={{ backgroundColor: colors.content }}
>
{/* Sidebar */}
<div
className="w-[30%] space-y-1 p-2"
style={{ backgroundColor: colors.sidebar }}
@@ -58,7 +65,6 @@ function WindowMockup({
style={{ backgroundColor: colors.bar }}
/>
</div>
{/* Main */}
<div className="flex-1 space-y-1.5 p-2">
<div
className="h-1.5 w-4/5 rounded-full"
@@ -84,8 +90,95 @@ const themeOptions = [
{ value: "system" as const, label: "System" },
];
function SettingRow({
label,
description,
control,
}: {
label: string;
description: string;
control: React.ReactNode;
}) {
return (
<div className="flex items-center justify-between gap-6 py-3 first:pt-0 last:pb-0">
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-foreground">{label}</div>
<div className="text-xs text-muted-foreground">{description}</div>
</div>
<div className="flex shrink-0 items-center">{control}</div>
</div>
);
}
function FontSizeControl({
value,
defaultValue,
onChange,
onReset,
ariaLabel,
}: {
value: number;
defaultValue: number;
onChange: (next: number) => void;
onReset: () => void;
ariaLabel: string;
}) {
const isDefault = value === defaultValue;
return (
<div className="flex items-center gap-2">
<button
type="button"
onClick={onReset}
disabled={isDefault}
aria-label={`Reset ${ariaLabel}`}
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
>
<RotateCcw className="size-3.5" />
</button>
<div className="flex h-8 items-center rounded-lg border border-input">
<button
type="button"
onClick={() => onChange(value - 1)}
disabled={value <= MIN_FONT_SIZE}
aria-label={`Decrease ${ariaLabel}`}
className="flex size-8 items-center justify-center text-muted-foreground transition-colors hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
>
<Minus className="size-3.5" />
</button>
<span
aria-label={ariaLabel}
className="min-w-8 text-center text-sm tabular-nums"
>
{value}
</span>
<button
type="button"
onClick={() => onChange(value + 1)}
disabled={value >= MAX_FONT_SIZE}
aria-label={`Increase ${ariaLabel}`}
className="flex size-8 items-center justify-center text-muted-foreground transition-colors hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
>
<Plus className="size-3.5" />
</button>
</div>
</div>
);
}
export function AppearanceTab() {
const { theme, setTheme } = useTheme();
const uiFontSize = useTypographyStore((s) => s.uiFontSize);
const codeFontSize = useTypographyStore((s) => s.codeFontSize);
const uiFontFamily = useTypographyStore((s) => s.uiFontFamily);
const codeFontFamily = useTypographyStore((s) => s.codeFontFamily);
const fontSmoothing = useTypographyStore((s) => s.fontSmoothing);
const setUiFontSize = useTypographyStore((s) => s.setUiFontSize);
const setCodeFontSize = useTypographyStore((s) => s.setCodeFontSize);
const setUiFontFamily = useTypographyStore((s) => s.setUiFontFamily);
const setCodeFontFamily = useTypographyStore((s) => s.setCodeFontFamily);
const setFontSmoothing = useTypographyStore((s) => s.setFontSmoothing);
const resetUiFontSize = useTypographyStore((s) => s.resetUiFontSize);
const resetCodeFontSize = useTypographyStore((s) => s.resetCodeFontSize);
return (
<div className="space-y-8">
@@ -141,6 +234,75 @@ export function AppearanceTab() {
})}
</div>
</section>
<section className="space-y-4">
<h2 className="text-sm font-semibold">Typography</h2>
<div className="divide-y divide-border rounded-lg border border-border bg-card px-4">
<SettingRow
label="UI Font Size"
description="Font size for the Multica user interface"
control={
<FontSizeControl
value={uiFontSize}
defaultValue={DEFAULT_UI_FONT_SIZE}
onChange={setUiFontSize}
onReset={resetUiFontSize}
ariaLabel="UI font size"
/>
}
/>
<SettingRow
label="Code Font Size"
description="Font size for code editors and diffs"
control={
<FontSizeControl
value={codeFontSize}
defaultValue={DEFAULT_CODE_FONT_SIZE}
onChange={setCodeFontSize}
onReset={resetCodeFontSize}
ariaLabel="Code font size"
/>
}
/>
<SettingRow
label="UI Font Family"
description="Override the Multica user interface typeface"
control={
<Input
aria-label="UI font family"
placeholder="System font"
value={uiFontFamily}
onChange={(e) => setUiFontFamily(e.target.value)}
className="h-8 w-56"
/>
}
/>
<SettingRow
label="Code Font Family"
description="Override the font for code editors and diffs"
control={
<Input
aria-label="Code font family"
placeholder="System monospace"
value={codeFontFamily}
onChange={(e) => setCodeFontFamily(e.target.value)}
className="h-8 w-56"
/>
}
/>
<SettingRow
label="Font Smoothing"
description="Use native macOS font anti-aliasing"
control={
<Switch
aria-label="Font smoothing"
checked={fontSmoothing}
onCheckedChange={setFontSmoothing}
/>
}
/>
</div>
</section>
</div>
);
}