| 1 | import { useCallback, useEffect, useMemo, useState } from "react"; |
| 2 | import { Check, Copy, Images, LockKeyhole, Minus, Plus, RotateCcw } from "lucide-react"; |
| 3 | import { app } from "../lib/bridge"; |
| 4 | import { useT, type DictKey } from "../lib/i18n"; |
| 5 | import { THEME_STYLES, type Theme, type ThemeStyle, isThemeStyle } from "../lib/theme"; |
| 6 | import type { TerminalThemePreference } from "../lib/terminalTheme"; |
| 7 | import type { ConversationWidth } from "../lib/conversationWidth"; |
| 8 | import { TEXT_SIZES, type TextSize } from "../lib/textSize"; |
| 9 | import { type FontFamily, type MonoFontFamily } from "../lib/fontFamily"; |
| 10 | import { DEFAULT_ZOOM, MIN_ZOOM, MAX_ZOOM, ZOOM_STEP, zoomToPercent, type ZoomLevel } from "../lib/dpiScale"; |
| 11 | import { getAvailableFontFamilies, getAvailableMonoFontFamilies } from "../lib/fontAvailability"; |
| 12 | import { |
| 13 | type ThemeExperienceView, |
| 14 | activateBaseStyle, |
| 15 | applyExperienceToDOM, |
| 16 | cancelGlobalPreview, |
| 17 | configuredBaseStyleForSync, |
| 18 | disableThemePack, |
| 19 | loadThemeExperience, |
| 20 | restoreGraphiteAppearance, |
| 21 | setThemeMode, |
| 22 | } from "../lib/themeExperience"; |
| 23 | import { useToast } from "../lib/toast"; |
| 24 | import { ThemeGallery } from "./ThemeGallery"; |
| 25 | import { TypographySettings } from "./TypographySettings"; |
| 26 | |
| 27 | const STYLE_NAME_KEY: Record<ThemeStyle, DictKey> = { |
| 28 | graphite: "settings.style.graphite.zh", |
| 29 | aurora: "settings.style.aurora.zh", |
| 30 | slate: "settings.style.slate.zh", |
| 31 | carbon: "settings.style.carbon.zh", |
| 32 | nocturne: "settings.style.nocturne.zh", |
| 33 | amber: "settings.style.amber.zh", |
| 34 | }; |
| 35 | |
| 36 | function textSizeLabel(size: TextSize, t: (key: never) => string): string { |
| 37 | switch (size) { |
| 38 | case "small": |
| 39 | return t("settings.textSizeSmall" as never); |
| 40 | case "default": |
| 41 | return t("settings.textSizeDefault" as never); |
| 42 | case "large": |
| 43 | return t("settings.textSizeLarge" as never); |
| 44 | case "xlarge": |
| 45 | return t("settings.textSizeXLarge" as never); |
| 46 | case "xxlarge": |
| 47 | return t("settings.textSizeXXLarge" as never); |
| 48 | default: |
| 49 | return size; |
| 50 | } |
| 51 | } |
| 52 | |
| 53 | function fontFamilyLabel(font: FontFamily, t: ReturnType<typeof useT>): string { |
| 54 | switch (font) { |
| 55 | case "system": |
| 56 | return t("settings.fontFamilySystem"); |
| 57 | case "yahei": |
| 58 | return t("settings.fontFamilyYaHei"); |
| 59 | case "pingfang": |
| 60 | return t("settings.fontFamilyPingFang"); |
| 61 | case "noto": |
| 62 | return t("settings.fontFamilyNoto"); |
| 63 | case "custom": |
| 64 | return t("settings.fontFamilyCustom"); |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | function monoFontFamilyLabel(font: MonoFontFamily, t: ReturnType<typeof useT>): string { |
| 69 | switch (font) { |
| 70 | case "system": |
| 71 | return t("settings.monoFontFamilySystem"); |
| 72 | case "cascadia": |
| 73 | return t("settings.monoFontFamilyCascadia"); |
| 74 | case "jetbrains": |
| 75 | return t("settings.monoFontFamilyJetBrains"); |
| 76 | case "sfmono": |
| 77 | return t("settings.monoFontFamilySFMono"); |
| 78 | case "custom": |
| 79 | return t("settings.monoFontFamilyCustom"); |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | // Re-export field shells used by SettingsPanel (defined there as local helpers). |
| 84 | // AppearanceOverview is rendered inside SettingsPageShell and uses the same CSS. |
| 85 | |
| 86 | export function AppearanceOverview({ |
| 87 | theme, |
| 88 | themeStyle, |
| 89 | terminalTheme, |
| 90 | conversationWidth, |
| 91 | textSize, |
| 92 | showDisplayZoom, |
| 93 | zoomPct, |
| 94 | fontFamily, |
| 95 | monoFontFamily, |
| 96 | customFontName, |
| 97 | customMonoFontName, |
| 98 | onTheme, |
| 99 | onThemeStyle, |
| 100 | onTerminalTheme, |
| 101 | onConversationWidth, |
| 102 | onTextSize, |
| 103 | onRestartZoom, |
| 104 | onFontFamily, |
| 105 | onMonoFontFamily, |
| 106 | onCustomFontNameChange, |
| 107 | onCustomMonoFontNameChange, |
| 108 | }: { |
| 109 | theme: Theme; |
| 110 | themeStyle: ThemeStyle; |
| 111 | terminalTheme: TerminalThemePreference; |
| 112 | conversationWidth: ConversationWidth; |
| 113 | textSize: TextSize; |
| 114 | showDisplayZoom: boolean; |
| 115 | zoomPct: number; |
| 116 | fontFamily: FontFamily; |
| 117 | monoFontFamily: MonoFontFamily; |
| 118 | customFontName: string; |
| 119 | customMonoFontName: string; |
| 120 | onTheme: (t: Theme) => void; |
| 121 | onThemeStyle: (style: ThemeStyle) => void; |
| 122 | onTerminalTheme: (theme: TerminalThemePreference) => void; |
| 123 | onConversationWidth: (width: ConversationWidth) => void; |
| 124 | onTextSize: (size: TextSize) => void; |
| 125 | onRestartZoom: (zoom: ZoomLevel) => Promise<void>; |
| 126 | onFontFamily: (font: FontFamily) => void; |
| 127 | onMonoFontFamily: (font: MonoFontFamily) => void; |
| 128 | onCustomFontNameChange: (name: string) => void; |
| 129 | onCustomMonoFontNameChange: (name: string) => void; |
| 130 | }) { |
| 131 | const t = useT(); |
| 132 | const { showToast } = useToast(); |
| 133 | const [view, setView] = useState<"overview" | "gallery" | "typography">("overview"); |
| 134 | const [galleryIntent, setGalleryIntent] = useState<"browse" | "copy-base">("browse"); |
| 135 | const [experience, setExperience] = useState<ThemeExperienceView | null>(null); |
| 136 | const [busy, setBusy] = useState(false); |
| 137 | |
| 138 | const refresh = useCallback(async () => { |
| 139 | try { |
| 140 | const exp = await loadThemeExperience(); |
| 141 | setExperience(exp); |
| 142 | applyExperienceToDOM(exp); |
| 143 | } catch (err) { |
| 144 | console.warn("theme experience load failed", err); |
| 145 | } |
| 146 | }, []); |
| 147 | |
| 148 | useEffect(() => { |
| 149 | void refresh(); |
| 150 | return () => { |
| 151 | cancelGlobalPreview(); |
| 152 | }; |
| 153 | }, [refresh]); |
| 154 | |
| 155 | // Keep experience in sync when parent theme/style props change from outside. |
| 156 | useEffect(() => { |
| 157 | setExperience((prev) => |
| 158 | prev |
| 159 | ? { |
| 160 | ...prev, |
| 161 | themeMode: theme, |
| 162 | baseStyle: themeStyle, |
| 163 | effectiveStyle: prev.activePack?.baseStyle || themeStyle, |
| 164 | } |
| 165 | : prev, |
| 166 | ); |
| 167 | }, [theme, themeStyle]); |
| 168 | |
| 169 | const availableFontFamilies = useMemo(() => getAvailableFontFamilies(fontFamily), [fontFamily]); |
| 170 | const availableMonoFontFamilies = useMemo(() => getAvailableMonoFontFamilies(monoFontFamily), [monoFontFamily]); |
| 171 | const zoomMinPct = zoomToPercent(MIN_ZOOM); |
| 172 | const zoomMaxPct = zoomToPercent(MAX_ZOOM); |
| 173 | const zoomStepPct = Math.round(ZOOM_STEP * 100); |
| 174 | const zoomProgressPct = Math.min(100, Math.max(0, ((zoomPct - zoomMinPct) / (zoomMaxPct - zoomMinPct)) * 100)); |
| 175 | const canDecreaseZoom = zoomPct > zoomMinPct; |
| 176 | const canIncreaseZoom = zoomPct < zoomMaxPct; |
| 177 | const setZoomPercent = (pct: number) => { |
| 178 | void onRestartZoom(pct / 100); |
| 179 | }; |
| 180 | |
| 181 | const pack = experience?.activePack ?? null; |
| 182 | const baseStyle = (isThemeStyle(experience?.baseStyle) ? experience!.baseStyle : themeStyle) as ThemeStyle; |
| 183 | const styleNameKey = STYLE_NAME_KEY[baseStyle] || STYLE_NAME_KEY.graphite; |
| 184 | |
| 185 | const currentTitle = pack |
| 186 | ? pack.nameKey |
| 187 | ? t(pack.nameKey as never) |
| 188 | : pack.name |
| 189 | : t(styleNameKey); |
| 190 | const kindLabel = pack |
| 191 | ? pack.kind === "user" || (!pack.builtin && pack.kind !== "official") |
| 192 | ? t("settings.themeGallery.kindUser") |
| 193 | : pack.kind === "official" || (pack.builtin && pack.id.startsWith("official-")) |
| 194 | ? t("settings.themeGallery.kindOfficial") |
| 195 | : t("settings.themeGallery.kindBase") |
| 196 | : t("settings.themeGallery.kindBase"); |
| 197 | |
| 198 | const swatches = pack |
| 199 | ? [pack.tokens?.light?.bg || "#fff", pack.tokens?.light?.accent || "#ccc", pack.tokens?.dark?.accent || "#888"] |
| 200 | : null; |
| 201 | |
| 202 | const thumbUrl = pack?.previewUrl || pack?.backgroundUrl || ""; |
| 203 | |
| 204 | const handleBrowse = () => { |
| 205 | setGalleryIntent("browse"); |
| 206 | setView("gallery"); |
| 207 | }; |
| 208 | |
| 209 | const handleCopy = async () => { |
| 210 | if (!pack) { |
| 211 | setGalleryIntent("copy-base"); |
| 212 | setView("gallery"); |
| 213 | return; |
| 214 | } |
| 215 | setBusy(true); |
| 216 | try { |
| 217 | const newId = `${pack.id}-copy`.toLowerCase().replace(/[^a-z0-9-]/g, "-").slice(0, 48); |
| 218 | const created = await app.CopyThemePack(pack.id, newId, `${pack.name} Copy`); |
| 219 | showToast(t("settings.themeLibrary.copied", { name: created.name }), "info"); |
| 220 | setView("gallery"); |
| 221 | } catch (err) { |
| 222 | showToast(err instanceof Error ? err.message : String(err), "error"); |
| 223 | } finally { |
| 224 | setBusy(false); |
| 225 | } |
| 226 | }; |
| 227 | |
| 228 | const handleDisable = async () => { |
| 229 | setBusy(true); |
| 230 | try { |
| 231 | const exp = await disableThemePack(); |
| 232 | setExperience(exp); |
| 233 | onThemeStyle(isThemeStyle(exp.baseStyle) ? exp.baseStyle : "graphite"); |
| 234 | showToast(t("settings.themeGallery.disabled"), "info"); |
| 235 | } catch (err) { |
| 236 | showToast(err instanceof Error ? err.message : String(err), "error"); |
| 237 | } finally { |
| 238 | setBusy(false); |
| 239 | } |
| 240 | }; |
| 241 | |
| 242 | const handleBaseChange = async (style: ThemeStyle) => { |
| 243 | setBusy(true); |
| 244 | try { |
| 245 | const exp = await activateBaseStyle(style); |
| 246 | setExperience(exp); |
| 247 | onThemeStyle(style); |
| 248 | } catch (err) { |
| 249 | showToast(err instanceof Error ? err.message : String(err), "error"); |
| 250 | } finally { |
| 251 | setBusy(false); |
| 252 | } |
| 253 | }; |
| 254 | |
| 255 | const handleThemeMode = async (mode: Theme) => { |
| 256 | onTheme(mode); |
| 257 | try { |
| 258 | const exp = await setThemeMode(mode); |
| 259 | setExperience(exp); |
| 260 | } catch (err) { |
| 261 | showToast(err instanceof Error ? err.message : String(err), "error"); |
| 262 | } |
| 263 | }; |
| 264 | |
| 265 | if (view === "gallery" && experience) { |
| 266 | return ( |
| 267 | <ThemeGallery |
| 268 | experience={experience} |
| 269 | initialCreateBaseStyle={galleryIntent === "copy-base" ? baseStyle : undefined} |
| 270 | onExperienceChange={(exp) => { |
| 271 | setExperience(exp); |
| 272 | // Keep the configured base style separate from an active pack's live |
| 273 | // effective style. applyExperienceToDOM already owns the pack override; |
| 274 | // mirroring it through onThemeStyle would corrupt the restore snapshot. |
| 275 | const configuredStyle = configuredBaseStyleForSync(exp); |
| 276 | if (configuredStyle) onThemeStyle(configuredStyle); |
| 277 | }} |
| 278 | onBack={() => { |
| 279 | cancelGlobalPreview(); |
| 280 | setGalleryIntent("browse"); |
| 281 | setView("overview"); |
| 282 | void refresh(); |
| 283 | }} |
| 284 | /> |
| 285 | ); |
| 286 | } |
| 287 | |
| 288 | if (view === "typography") { |
| 289 | return <TypographySettings onBack={() => setView("overview")} />; |
| 290 | } |
| 291 | |
| 292 | return ( |
| 293 | <div className="appearance-overview"> |
| 294 | <header className="appearance-overview__header"> |
| 295 | <h2 className="appearance-overview__title">{t("settings.appearance")}</h2> |
| 296 | <p className="appearance-overview__sub">{t("settings.appearanceMeta")}</p> |
| 297 | </header> |
| 298 | |
| 299 | <section className="appearance-overview__current" aria-labelledby="appearance-current-label"> |
| 300 | <h3 id="appearance-current-label" className="appearance-overview__section-label"> |
| 301 | {t("settings.themeGallery.currentAppearance")} |
| 302 | </h3> |
| 303 | <div className="appearance-overview__hero"> |
| 304 | <div className="appearance-overview__thumb"> |
| 305 | {thumbUrl ? ( |
| 306 | <img src={thumbUrl} alt="" loading="lazy" /> |
| 307 | ) : ( |
| 308 | <div className="appearance-overview__thumb-base theme-card__swatches" data-theme-style-card={baseStyle}> |
| 309 | <span className="theme-card__swatch theme-card__swatch--bg" /> |
| 310 | <span className="theme-card__swatch theme-card__swatch--surface" /> |
| 311 | <span className="theme-card__swatch theme-card__swatch--accent" /> |
| 312 | </div> |
| 313 | )} |
| 314 | </div> |
| 315 | <div className="appearance-overview__hero-meta"> |
| 316 | <div className="appearance-overview__hero-title-row"> |
| 317 | <h4 className="appearance-overview__hero-name">{currentTitle}</h4> |
| 318 | <span className="theme-gallery__badge">{kindLabel}</span> |
| 319 | </div> |
| 320 | {swatches ? ( |
| 321 | <div className="appearance-overview__swatches" aria-hidden="true"> |
| 322 | {swatches.map((c, i) => ( |
| 323 | <span key={i} style={{ background: c }} /> |
| 324 | ))} |
| 325 | </div> |
| 326 | ) : null} |
| 327 | <div className="appearance-overview__in-use"> |
| 328 | <Check size={14} /> {t("settings.themeLibrary.active")} |
| 329 | </div> |
| 330 | <div className="appearance-overview__hero-actions"> |
| 331 | <button type="button" className="btn btn--primary" disabled={busy} onClick={handleBrowse}> |
| 332 | <Images size={14} /> {t("settings.themeGallery.browse")} |
| 333 | </button> |
| 334 | <button type="button" className="btn" disabled={busy} onClick={() => void handleCopy()}> |
| 335 | <Copy size={14} /> {t("settings.themeGallery.createCopy")} |
| 336 | </button> |
| 337 | {pack ? ( |
| 338 | <button type="button" className="btn" disabled={busy} onClick={() => void handleDisable()}> |
| 339 | {t("settings.themeGallery.disable")} |
| 340 | </button> |
| 341 | ) : null} |
| 342 | </div> |
| 343 | </div> |
| 344 | </div> |
| 345 | </section> |
| 346 | |
| 347 | <div className="appearance-overview__rows"> |
| 348 | <div className="appearance-overview__row"> |
| 349 | <div id="appearance-theme-mode-label" className="appearance-overview__row-label">{t("settings.theme")}</div> |
| 350 | <div |
| 351 | className="set-seg appearance-overview__segmented appearance-overview__segmented--theme" |
| 352 | role="radiogroup" |
| 353 | aria-labelledby="appearance-theme-mode-label" |
| 354 | > |
| 355 | {(["auto", "light", "dark"] as Theme[]).map((opt) => ( |
| 356 | <button |
| 357 | key={opt} |
| 358 | type="button" |
| 359 | role="radio" |
| 360 | aria-checked={theme === opt} |
| 361 | className={`set-seg__btn${theme === opt ? " set-seg__btn--on" : ""}`} |
| 362 | onClick={() => void handleThemeMode(opt)} |
| 363 | > |
| 364 | {opt === "auto" ? t("settings.themeAuto") : opt === "light" ? t("settings.themeLight") : t("settings.themeDark")} |
| 365 | </button> |
| 366 | ))} |
| 367 | </div> |
| 368 | </div> |
| 369 | |
| 370 | <div className="appearance-overview__row"> |
| 371 | <div id="appearance-terminal-theme-label" className="appearance-overview__row-label">{t("settings.terminalTheme")}</div> |
| 372 | <div |
| 373 | className="set-seg appearance-overview__segmented appearance-overview__segmented--theme" |
| 374 | role="radiogroup" |
| 375 | aria-labelledby="appearance-terminal-theme-label" |
| 376 | > |
| 377 | {(["auto", "light", "dark"] as TerminalThemePreference[]).map((opt) => ( |
| 378 | <button |
| 379 | key={opt} |
| 380 | type="button" |
| 381 | role="radio" |
| 382 | aria-checked={terminalTheme === opt} |
| 383 | className={`set-seg__btn${terminalTheme === opt ? " set-seg__btn--on" : ""}`} |
| 384 | onClick={() => onTerminalTheme(opt)} |
| 385 | > |
| 386 | {opt === "auto" |
| 387 | ? t("settings.terminalThemeAuto") |
| 388 | : opt === "light" |
| 389 | ? t("settings.terminalThemeLight") |
| 390 | : t("settings.terminalThemeDark")} |
| 391 | </button> |
| 392 | ))} |
| 393 | </div> |
| 394 | </div> |
| 395 | |
| 396 | <div className="appearance-overview__row"> |
| 397 | <div id="appearance-base-style-label" className="appearance-overview__row-label">{t("settings.themeGallery.baseStyle")}</div> |
| 398 | <div className="appearance-overview__control-stack"> |
| 399 | <select |
| 400 | className="appearance-overview__select" |
| 401 | value={baseStyle} |
| 402 | disabled={busy || !!pack} |
| 403 | aria-labelledby="appearance-base-style-label" |
| 404 | aria-describedby={pack ? "appearance-base-style-help" : undefined} |
| 405 | onChange={(e) => void handleBaseChange(e.target.value as ThemeStyle)} |
| 406 | > |
| 407 | {THEME_STYLES.map((s) => ( |
| 408 | <option key={s} value={s}> |
| 409 | {t(STYLE_NAME_KEY[s])} |
| 410 | </option> |
| 411 | ))} |
| 412 | </select> |
| 413 | {pack ? ( |
| 414 | <span id="appearance-base-style-help" className="appearance-overview__lock-note"> |
| 415 | <LockKeyhole size={12} aria-hidden="true" /> |
| 416 | {t("settings.themeGallery.baseLockedByPack")} |
| 417 | </span> |
| 418 | ) : null} |
| 419 | </div> |
| 420 | </div> |
| 421 | |
| 422 | <div className="appearance-overview__row"> |
| 423 | <div id="appearance-conversation-width-label" className="appearance-overview__row-label"> |
| 424 | {t("settings.conversationWidth")} |
| 425 | </div> |
| 426 | <div |
| 427 | className="set-seg appearance-overview__segmented" |
| 428 | role="radiogroup" |
| 429 | aria-labelledby="appearance-conversation-width-label" |
| 430 | > |
| 431 | <button |
| 432 | type="button" |
| 433 | role="radio" |
| 434 | aria-checked={conversationWidth === "standard"} |
| 435 | className={`set-seg__btn${conversationWidth === "standard" ? " set-seg__btn--on" : ""}`} |
| 436 | onClick={() => onConversationWidth("standard")} |
| 437 | > |
| 438 | {t("settings.conversationWidthStandard")} (960px) |
| 439 | </button> |
| 440 | <button |
| 441 | type="button" |
| 442 | role="radio" |
| 443 | aria-checked={conversationWidth === "full"} |
| 444 | className={`set-seg__btn${conversationWidth === "full" ? " set-seg__btn--on" : ""}`} |
| 445 | onClick={() => onConversationWidth("full")} |
| 446 | > |
| 447 | {t("settings.conversationWidthFull")} (90%) |
| 448 | </button> |
| 449 | </div> |
| 450 | </div> |
| 451 | |
| 452 | <div className="appearance-overview__row"> |
| 453 | <div id="appearance-text-size-label" className="appearance-overview__row-label">{t("settings.textSize")}</div> |
| 454 | <div |
| 455 | className="set-seg appearance-overview__segmented appearance-overview__segmented--text-size" |
| 456 | role="radiogroup" |
| 457 | aria-labelledby="appearance-text-size-label" |
| 458 | > |
| 459 | {TEXT_SIZES.map((size) => ( |
| 460 | <button |
| 461 | key={size} |
| 462 | type="button" |
| 463 | role="radio" |
| 464 | aria-checked={textSize === size} |
| 465 | className={`set-seg__btn${textSize === size ? " set-seg__btn--on" : ""}`} |
| 466 | onClick={() => onTextSize(size)} |
| 467 | > |
| 468 | {textSizeLabel(size, t)} |
| 469 | </button> |
| 470 | ))} |
| 471 | </div> |
| 472 | </div> |
| 473 | |
| 474 | <div className="appearance-overview__row"> |
| 475 | <div id="appearance-font-family-label" className="appearance-overview__row-label">{t("settings.fontFamily")}</div> |
| 476 | <select |
| 477 | className="appearance-overview__select" |
| 478 | value={fontFamily} |
| 479 | aria-labelledby="appearance-font-family-label" |
| 480 | onChange={(e) => onFontFamily(e.target.value as FontFamily)} |
| 481 | > |
| 482 | {availableFontFamilies.map((f) => ( |
| 483 | <option key={f} value={f}> |
| 484 | {fontFamilyLabel(f, t)} |
| 485 | </option> |
| 486 | ))} |
| 487 | </select> |
| 488 | </div> |
| 489 | |
| 490 | {fontFamily === "custom" ? ( |
| 491 | <div className="appearance-overview__row"> |
| 492 | <div id="appearance-custom-font-name-label" className="appearance-overview__row-label"> |
| 493 | {t("settings.fontFamilyCustomName")} |
| 494 | </div> |
| 495 | <textarea |
| 496 | className="mem-input appearance-overview__font-input" |
| 497 | rows={2} |
| 498 | aria-labelledby="appearance-custom-font-name-label" |
| 499 | placeholder={t("settings.fontFamilyCustomPlaceholder")} |
| 500 | value={customFontName} |
| 501 | onChange={(e) => onCustomFontNameChange(e.target.value)} |
| 502 | /> |
| 503 | </div> |
| 504 | ) : null} |
| 505 | |
| 506 | <div className="appearance-overview__row"> |
| 507 | <div id="appearance-mono-font-family-label" className="appearance-overview__row-label">{t("settings.monoFontFamily")}</div> |
| 508 | <select |
| 509 | className="appearance-overview__select" |
| 510 | value={monoFontFamily} |
| 511 | aria-labelledby="appearance-mono-font-family-label" |
| 512 | onChange={(e) => onMonoFontFamily(e.target.value as MonoFontFamily)} |
| 513 | > |
| 514 | {availableMonoFontFamilies.map((f) => ( |
| 515 | <option key={f} value={f}> |
| 516 | {monoFontFamilyLabel(f, t)} |
| 517 | </option> |
| 518 | ))} |
| 519 | </select> |
| 520 | </div> |
| 521 | |
| 522 | {monoFontFamily === "custom" ? ( |
| 523 | <div className="appearance-overview__row"> |
| 524 | <div id="appearance-custom-mono-font-name-label" className="appearance-overview__row-label"> |
| 525 | {t("settings.monoFontFamilyCustomName")} |
| 526 | </div> |
| 527 | <textarea |
| 528 | className="mem-input appearance-overview__font-input" |
| 529 | rows={2} |
| 530 | aria-labelledby="appearance-custom-mono-font-name-label" |
| 531 | placeholder={t("settings.monoFontFamilyCustomPlaceholder")} |
| 532 | value={customMonoFontName} |
| 533 | onChange={(e) => onCustomMonoFontNameChange(e.target.value)} |
| 534 | /> |
| 535 | </div> |
| 536 | ) : null} |
| 537 | |
| 538 | <div className="appearance-overview__row appearance-overview__row--typography"> |
| 539 | <div> |
| 540 | <div className="appearance-overview__row-label">{t("settings.typography.title")}</div> |
| 541 | <div className="appearance-overview__row-note">{t("settings.typography.entrySummary")}</div> |
| 542 | </div> |
| 543 | <button type="button" className="btn btn--small" onClick={() => setView("typography")}> |
| 544 | {t("settings.typography.open")} |
| 545 | </button> |
| 546 | </div> |
| 547 | |
| 548 | {showDisplayZoom ? ( |
| 549 | <div className="appearance-overview__row"> |
| 550 | <div className="appearance-overview__row-label">{t("settings.displayZoom")}</div> |
| 551 | <div className="zoom-slider-wrap"> |
| 552 | <div className="zoom-slider__head"> |
| 553 | <div className="zoom-slider__value">{zoomPct}%</div> |
| 554 | <div className="zoom-stepper"> |
| 555 | <button |
| 556 | type="button" |
| 557 | className="zoom-stepper__btn" |
| 558 | aria-label={t("settings.displayZoomDecrease")} |
| 559 | title={t("settings.displayZoomDecrease")} |
| 560 | disabled={!canDecreaseZoom} |
| 561 | onClick={() => setZoomPercent(zoomPct - zoomStepPct)} |
| 562 | > |
| 563 | <Minus size={13} aria-hidden="true" /> |
| 564 | </button> |
| 565 | <button |
| 566 | type="button" |
| 567 | className="zoom-stepper__reset" |
| 568 | aria-label={t("settings.displayZoomReset")} |
| 569 | title={t("settings.displayZoomReset")} |
| 570 | disabled={zoomPct === zoomToPercent(DEFAULT_ZOOM)} |
| 571 | onClick={() => { |
| 572 | void onRestartZoom(DEFAULT_ZOOM); |
| 573 | }} |
| 574 | > |
| 575 | <RotateCcw size={12} aria-hidden="true" /> |
| 576 | <span>100%</span> |
| 577 | </button> |
| 578 | <button |
| 579 | type="button" |
| 580 | className="zoom-stepper__btn" |
| 581 | aria-label={t("settings.displayZoomIncrease")} |
| 582 | title={t("settings.displayZoomIncrease")} |
| 583 | disabled={!canIncreaseZoom} |
| 584 | onClick={() => setZoomPercent(zoomPct + zoomStepPct)} |
| 585 | > |
| 586 | <Plus size={13} aria-hidden="true" /> |
| 587 | </button> |
| 588 | </div> |
| 589 | </div> |
| 590 | <div className="zoom-slider-row"> |
| 591 | <span className="zoom-slider__label">{zoomMinPct}%</span> |
| 592 | <div className="slider-track"> |
| 593 | <div className="slider-track__bg" /> |
| 594 | <div className="slider-track__fill" style={{ width: `calc(${zoomProgressPct}% + 15px)` }} /> |
| 595 | <div className="slider-thumb" style={{ left: `${zoomProgressPct}%` }} /> |
| 596 | <input |
| 597 | aria-label={t("settings.displayZoom")} |
| 598 | type="range" |
| 599 | min={zoomMinPct} |
| 600 | max={zoomMaxPct} |
| 601 | step={zoomStepPct} |
| 602 | value={zoomPct} |
| 603 | onChange={(e) => setZoomPercent(Number(e.target.value))} |
| 604 | /> |
| 605 | </div> |
| 606 | <span className="zoom-slider__label">{zoomMaxPct}%</span> |
| 607 | </div> |
| 608 | </div> |
| 609 | </div> |
| 610 | ) : null} |
| 611 | |
| 612 | <div className="appearance-overview__row appearance-overview__row--footer"> |
| 613 | <span id="appearance-restore-help" className="appearance-overview__reset-hint"> |
| 614 | {t("settings.themeGallery.restoreGraphiteHint")} |
| 615 | </span> |
| 616 | <button |
| 617 | type="button" |
| 618 | className="btn btn--small" |
| 619 | aria-describedby="appearance-restore-help" |
| 620 | disabled={busy} |
| 621 | onClick={() => { |
| 622 | void (async () => { |
| 623 | setBusy(true); |
| 624 | try { |
| 625 | const exp = await restoreGraphiteAppearance(); |
| 626 | setExperience(exp); |
| 627 | onThemeStyle("graphite"); |
| 628 | showToast(t("settings.themeGallery.restoredGraphite"), "info"); |
| 629 | } catch (err) { |
| 630 | showToast(err instanceof Error ? err.message : String(err), "error"); |
| 631 | } finally { |
| 632 | setBusy(false); |
| 633 | } |
| 634 | })(); |
| 635 | }} |
| 636 | > |
| 637 | {t("settings.themeGallery.restoreGraphite")} |
| 638 | </button> |
| 639 | </div> |
| 640 | </div> |
| 641 | </div> |
| 642 | ); |
| 643 | } |
| 644 |