diff --git a/studio/frontend/src/components/shutdown-dialog.tsx b/studio/frontend/src/components/shutdown-dialog.tsx index e3e6ca9920..014deb7fe6 100644 --- a/studio/frontend/src/components/shutdown-dialog.tsx +++ b/studio/frontend/src/components/shutdown-dialog.tsx @@ -1,9 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-only // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 -import { authFetch } from "@/features/auth"; -import { toastError } from "@/shared/toast"; -import { useState } from "react"; import { AlertDialog, AlertDialogAction, @@ -14,6 +11,9 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; +import { authFetch } from "@/features/auth"; +import { toastError } from "@/shared/toast"; +import { useState } from "react"; interface ShutdownDialogProps { open: boolean; @@ -52,8 +52,8 @@ export function ShutdownDialog({ onAfterShutdown?.(); document.body.innerHTML = `
-

Unsloth Studio has stopped.

-

You can now close this tab.

+

Unsloth Studio has stopped.

+

You can now close this tab.

`; }; diff --git a/studio/frontend/src/features/settings/stores/appearance-custom-store.ts b/studio/frontend/src/features/settings/stores/appearance-custom-store.ts index f3618ddca5..f4e6bc1895 100644 --- a/studio/frontend/src/features/settings/stores/appearance-custom-store.ts +++ b/studio/frontend/src/features/settings/stores/appearance-custom-store.ts @@ -2,7 +2,11 @@ // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 import { create } from "zustand"; -import { createJSONStorage, persist, type StateStorage } from "zustand/middleware"; +import { + type StateStorage, + createJSONStorage, + persist, +} from "zustand/middleware"; import type { ResolvedTheme } from "./theme-store"; // Best-effort persistence: localStorage can be blocked (private browsing) and @@ -200,7 +204,9 @@ function sanitizeImportedFonts(value: unknown): ImportedFont[] { const source = (entry ?? {}) as Partial; // Cap to the backend name length so an over-long name can't fail the PUT. const rawName = sanitizeFont(source.name); - const name = rawName ? rawName.slice(0, MAX_IMPORTED_FONT_NAME_LENGTH) : null; + const name = rawName + ? rawName.slice(0, MAX_IMPORTED_FONT_NAME_LENGTH) + : null; if (!name || seen.has(name)) continue; const dataUrl = source.dataUrl; if ( @@ -397,7 +403,10 @@ function readableForeground(hex: string): string { * FontFaces registered for imported fonts, keyed by family name. The dataUrl is * tracked too so a re-import under the same name (new bytes) replaces the face. */ -const registeredFontFaces = new Map(); +const registeredFontFaces = new Map< + string, + { face: FontFace; dataUrl: string } +>(); function syncImportedFonts(fonts: ImportedFont[]): void { if (typeof document === "undefined" || !("fonts" in document)) return; @@ -510,11 +519,19 @@ export function applyCustomizationToDocument( setVar("--custom-chat-font", null); } + // Scale text via a factor, not the root rem, so layout dimensions stay fixed. if (c.uiFontSize !== null && c.uiFontSize !== UI_FONT_SIZE_RANGE.default) { - style.fontSize = `${c.uiFontSize}px`; + el.setAttribute("data-ui-font-size", ""); + setVar( + "--ui-font-scale", + String(c.uiFontSize / UI_FONT_SIZE_RANGE.default), + ); } else { - style.removeProperty("font-size"); + el.removeAttribute("data-ui-font-size"); + setVar("--ui-font-scale", null); } + // Drop the root font-size older builds set, so it never lingers. + style.removeProperty("font-size"); if (c.codeFontSize !== null) { el.setAttribute("data-code-font-size", ""); @@ -554,7 +571,8 @@ export function applyCustomizationToDocument( * (canvas-confetti, view transitions) that CSS/MotionConfig cannot reach. */ export function prefersReducedMotion(): boolean { - const setting = useAppearanceCustomStore.getState().customization.reduceMotion; + const setting = + useAppearanceCustomStore.getState().customization.reduceMotion; if (setting === "on") return true; if (setting === "off") return false; return ( diff --git a/studio/frontend/src/features/studio/sections/charts/eval-loss-chart-card.tsx b/studio/frontend/src/features/studio/sections/charts/eval-loss-chart-card.tsx index b5eeac34f3..cc55cf613e 100644 --- a/studio/frontend/src/features/studio/sections/charts/eval-loss-chart-card.tsx +++ b/studio/frontend/src/features/studio/sections/charts/eval-loss-chart-card.tsx @@ -17,6 +17,7 @@ import type { ReactElement } from "react"; import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"; import { CHART_CONTAINER_CLASS, + CHART_FONT_SIZE, DEFAULT_CHART_MARGIN, DEFAULT_Y_AXIS_WIDTH, formatAxisMetric, @@ -46,13 +47,18 @@ export function EvalLossChartCard({ return ( - 0 ? "" : " text-muted-foreground"}`}> + 0 ? "" : " text-muted-foreground"}`} + > {t("studio.charts.evalLoss")} {data.length > 0 ? ( - + formatStepTick(Number(value))} interval="preserveStartEnd" /> @@ -81,7 +87,7 @@ export function EvalLossChartCard({ axisLine={false} tickMargin={8} tickCount={5} - fontSize={10} + fontSize={CHART_FONT_SIZE} width={DEFAULT_Y_AXIS_WIDTH} tickFormatter={(value) => formatAxisMetric(Number(value))} /> @@ -132,7 +138,7 @@ export function EvalLossChartCard({ tickLine={false} axisLine={false} tickMargin={8} - fontSize={10} + fontSize={CHART_FONT_SIZE} interval="preserveStartEnd" /> - + formatStepTick(Number(value))} interval="preserveStartEnd" /> @@ -89,7 +93,7 @@ export function GradNormChartCard({ axisLine={false} tickMargin={8} tickCount={5} - fontSize={10} + fontSize={CHART_FONT_SIZE} width={DEFAULT_Y_AXIS_WIDTH} tickFormatter={(value) => { const num = Number(value); diff --git a/studio/frontend/src/features/studio/sections/charts/learning-rate-chart-card.tsx b/studio/frontend/src/features/studio/sections/charts/learning-rate-chart-card.tsx index 1a7495b493..badc0f22dc 100644 --- a/studio/frontend/src/features/studio/sections/charts/learning-rate-chart-card.tsx +++ b/studio/frontend/src/features/studio/sections/charts/learning-rate-chart-card.tsx @@ -16,6 +16,7 @@ import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"; import type { ScaleMode } from "./types"; import { CHART_CONTAINER_CLASS, + CHART_FONT_SIZE, CHART_SYNC_ID, DEFAULT_CHART_MARGIN, DEFAULT_Y_AXIS_WIDTH, @@ -76,7 +77,7 @@ export function LearningRateChartCard({ tickLine={false} axisLine={false} tickMargin={8} - fontSize={10} + fontSize={CHART_FONT_SIZE} tickFormatter={(value) => formatStepTick(Number(value))} interval="preserveStartEnd" /> @@ -87,7 +88,7 @@ export function LearningRateChartCard({ axisLine={false} tickMargin={8} tickCount={5} - fontSize={10} + fontSize={CHART_FONT_SIZE} width={DEFAULT_Y_AXIS_WIDTH} tickFormatter={(value) => { const num = Number(value); diff --git a/studio/frontend/src/features/studio/sections/charts/training-loss-chart-card.tsx b/studio/frontend/src/features/studio/sections/charts/training-loss-chart-card.tsx index 16113f3219..3f85886d7e 100644 --- a/studio/frontend/src/features/studio/sections/charts/training-loss-chart-card.tsx +++ b/studio/frontend/src/features/studio/sections/charts/training-loss-chart-card.tsx @@ -22,8 +22,9 @@ import { } from "recharts"; import type { ScaleMode } from "./types"; import { - CHART_SYNC_ID, CHART_CONTAINER_CLASS, + CHART_FONT_SIZE, + CHART_SYNC_ID, DEFAULT_CHART_MARGIN, DEFAULT_Y_AXIS_WIDTH, formatAxisMetric, @@ -73,7 +74,9 @@ export function TrainingLossChartCard({ return ( - {t("studio.charts.trainingLoss")} + + {t("studio.charts.trainingLoss")} + @@ -96,7 +99,7 @@ export function TrainingLossChartCard({ tickLine={false} axisLine={false} tickMargin={8} - fontSize={10} + fontSize={CHART_FONT_SIZE} tickFormatter={(value) => formatStepTick(Number(value))} interval="preserveStartEnd" /> @@ -107,7 +110,7 @@ export function TrainingLossChartCard({ axisLine={false} tickMargin={8} tickCount={5} - fontSize={10} + fontSize={CHART_FONT_SIZE} width={DEFAULT_Y_AXIS_WIDTH} tickFormatter={(value) => { const num = Number(value); diff --git a/studio/frontend/src/features/studio/sections/charts/utils.ts b/studio/frontend/src/features/studio/sections/charts/utils.ts index 4a4a1f3b48..15a980a8d0 100644 --- a/studio/frontend/src/features/studio/sections/charts/utils.ts +++ b/studio/frontend/src/features/studio/sections/charts/utils.ts @@ -9,6 +9,8 @@ export const DEFAULT_VISIBLE_POINTS = 160; export const CHART_CONTAINER_CLASS = "h-[220px] w-full"; export const DEFAULT_CHART_MARGIN = { top: 4, right: 8, bottom: 0, left: 4 }; export const DEFAULT_Y_AXIS_WIDTH = 45; +// Recharts axis text is inline SVG, so scale it with the UI font preference. +export const CHART_FONT_SIZE = "calc(10px * var(--ui-font-scale, 1))"; const TRAILING_ZEROES_RE = /\.?0+$/; const NEGATIVE_ZERO_RE = /^-0$/; diff --git a/studio/frontend/src/features/studio/sections/training-section.tsx b/studio/frontend/src/features/studio/sections/training-section.tsx index 5650b9c145..e34b829479 100644 --- a/studio/frontend/src/features/studio/sections/training-section.tsx +++ b/studio/frontend/src/features/studio/sections/training-section.tsx @@ -17,6 +17,8 @@ import { useTrainingConfigStore, validateTrainingConfig, } from "@/features/training"; +import { useT } from "@/i18n"; +import { toast } from "@/lib/toast"; import { Archive04Icon, ChartAverageIcon, @@ -26,9 +28,7 @@ import { } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { useRef } from "react"; -import { toast } from "@/lib/toast"; import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"; -import { useT } from "@/i18n"; const placeholderData = [ { step: 0, loss: 2.5 }, @@ -65,11 +65,15 @@ export function TrainingSection() { try { const config = parseYamlConfig(reader.result as string); store.applyConfigPatch(config); - toast.success(t("studio.training.configLoaded"), { description: file.name }); + toast.success(t("studio.training.configLoaded"), { + description: file.name, + }); } catch (err) { toast.error(t("studio.training.failedToLoadConfig"), { description: - err instanceof Error ? err.message : t("studio.training.invalidYamlFile"), + err instanceof Error + ? err.message + : t("studio.training.invalidYamlFile"), }); } }; @@ -105,7 +109,10 @@ export function TrainingSection() { const model = (store.selectedModel ?? "model").split("/").pop(); const method = store.trainingMethod ?? "qlora"; const dataset = (store.dataset ?? "dataset").split("/").pop(); - const timestamp = new Date().toISOString().replace(/[:T]/g, "-").slice(0, 19); + const timestamp = new Date() + .toISOString() + .replace(/[:T]/g, "-") + .slice(0, 19); a.download = `${model}_${method}_${dataset}_${timestamp}.yaml`; a.click(); @@ -127,135 +134,153 @@ export function TrainingSection() { className="min-h-studio-config-column" >
- {/* Loss chart */} -
- - - - + + + + + + + + +
+ - - - - -
- -

- {t("studio.training.chartNoDataTitle")} -

-

- {t("studio.training.chartNoDataDescription")} -

+

+ {t("studio.training.chartNoDataTitle")} +

+

+ {t("studio.training.chartNoDataDescription")} +

+
-
- {/* Start/Stop */} - - {startError && ( -

{startError}

- )} - {isIncompatible && ( -

- {!store.isAudioModel && store.isDatasetAudio === true - ? t("studio.training.audioIncompatible") - : t("studio.training.visionIncompatible")} + {/* Start/Stop */} + + {startError && ( +

{startError}

+ )} + {isIncompatible && ( +

+ {!store.isAudioModel && store.isDatasetAudio === true + ? t("studio.training.audioIncompatible") + : t("studio.training.visionIncompatible")} +

+ )} + {!configValidation.ok && + configValidation.message && + !isIncompatible && ( +

+ {configValidation.message} +

+ )} + + {/* Upload / Save / Reset */} +

+ {t("studio.training.configLabel")}

- )} - {!configValidation.ok && configValidation.message && !isIncompatible && ( -

{configValidation.message}

- )} - - {/* Upload / Save / Reset */} -

{t("studio.training.configLabel")}

-
- - - - - {t("studio.training.uploadConfigTooltip")} - - - - - - {t("studio.training.saveConfigTooltip")} - - - - - - {t("studio.training.resetConfigTooltip")} - -
- +
+ + + + + + {t("studio.training.uploadConfigTooltip")} + + + + + + + + {t("studio.training.saveConfigTooltip")} + + + + + + + + {t("studio.training.resetConfigTooltip")} + + +
+
diff --git a/studio/frontend/src/index.css b/studio/frontend/src/index.css index 52ca81e064..85218ae22b 100644 --- a/studio/frontend/src/index.css +++ b/studio/frontend/src/index.css @@ -577,6 +577,43 @@ html[data-code-font-size] :is(pre, code, kbd, samp) { font-size: var(--custom-code-font-size) !important; } +/* UI font size scales text and icons only. The root rem stays fixed, so + --spacing / --radius (every box, gap, height, and corner) are untouched. We + retune the Tailwind text tokens that all text-* utilities read, plus the + shared icon size, by --ui-font-scale. */ +html[data-ui-font-size] { + --text-xs: calc(0.75rem * var(--ui-font-scale)); + --text-sm: calc(0.875rem * var(--ui-font-scale)); + --text-base: calc(1rem * var(--ui-font-scale)); + --text-lg: calc(1.125rem * var(--ui-font-scale)); + --text-xl: calc(1.25rem * var(--ui-font-scale)); + --text-2xl: calc(1.5rem * var(--ui-font-scale)); + --text-3xl: calc(1.875rem * var(--ui-font-scale)); + --text-4xl: calc(2.25rem * var(--ui-font-scale)); + --text-5xl: calc(3rem * var(--ui-font-scale)); + --text-6xl: calc(3.75rem * var(--ui-font-scale)); + --text-7xl: calc(4.5rem * var(--ui-font-scale)); + --text-8xl: calc(6rem * var(--ui-font-scale)); + --text-9xl: calc(8rem * var(--ui-font-scale)); + --icon-size: calc(18px * var(--ui-font-scale)); +} + +/* Composer text uses fixed px, not a token, so scale it explicitly. */ +html[data-ui-font-size] .unsloth-composer-input { + font-size: calc(15px * var(--ui-font-scale)); +} +html[data-ui-font-size] .composer-pill-btn { + font-size: calc(14px * var(--ui-font-scale)); +} + +/* Scale composer glyphs without resizing their button boxes. The "+" is left + out since it owns a rotate transform for its open state. */ +html[data-ui-font-size] + :is(.composer-pill-btn, .aui-composer-action-wrapper) svg { + transform: scale(var(--ui-font-scale)); + transform-origin: center; +} + /* Pointer cursors: hand cursor over interactive elements. */ html.pointer-cursors :is(