Compare commits

...
Sign in to create a new pull request.

1 commit

Author SHA1 Message Date
Unsloth
ed64e6fc13 Studio: scale UI font size via text tokens, not the root rem 2026-07-22 08:08:51 -07:00
9 changed files with 253 additions and 157 deletions

View file

@ -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 = `
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;font-family:sans-serif;gap:12px">
<p style="font-size:1.1rem;font-weight:600;margin:0">Unsloth Studio has stopped.</p>
<p style="font-size:0.9rem;color:#888;margin:0">You can now close this tab.</p>
<p style="font-size:calc(1.1rem * var(--ui-font-scale, 1));font-weight:600;margin:0">Unsloth Studio has stopped.</p>
<p style="font-size:calc(0.9rem * var(--ui-font-scale, 1));color:#888;margin:0">You can now close this tab.</p>
</div>`;
};

View file

@ -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<ImportedFont>;
// 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<string, { face: FontFace; dataUrl: string }>();
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 (

View file

@ -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 (
<Card data-tour="studio-eval-loss" size="sm">
<CardHeader>
<CardTitle className={`text-sm${data.length > 0 ? "" : " text-muted-foreground"}`}>
<CardTitle
className={`text-sm${data.length > 0 ? "" : " text-muted-foreground"}`}
>
{t("studio.charts.evalLoss")}
</CardTitle>
</CardHeader>
<CardContent>
{data.length > 0 ? (
<ChartContainer config={evalLossConfig} className={CHART_CONTAINER_CLASS}>
<ChartContainer
config={evalLossConfig}
className={CHART_CONTAINER_CLASS}
>
<LineChart
data={data}
accessibilityLayer={true}
@ -70,7 +76,7 @@ export function EvalLossChartCard({
tickLine={false}
axisLine={false}
tickMargin={8}
fontSize={10}
fontSize={CHART_FONT_SIZE}
tickFormatter={(value) => 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"
/>
<YAxis
@ -140,7 +146,7 @@ export function EvalLossChartCard({
axisLine={false}
tickMargin={8}
tickCount={5}
fontSize={10}
fontSize={CHART_FONT_SIZE}
width={DEFAULT_Y_AXIS_WIDTH}
/>
<Line

View file

@ -15,8 +15,9 @@ import type { ReactElement } from "react";
import { CartesianGrid, Line, LineChart, XAxis, YAxis } 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,
@ -58,7 +59,10 @@ export function GradNormChartCard({
</CardTitle>
</CardHeader>
<CardContent>
<ChartContainer config={gradNormConfig} className={CHART_CONTAINER_CLASS}>
<ChartContainer
config={gradNormConfig}
className={CHART_CONTAINER_CLASS}
>
<LineChart
data={data}
syncId={CHART_SYNC_ID}
@ -78,7 +82,7 @@ export function GradNormChartCard({
tickLine={false}
axisLine={false}
tickMargin={8}
fontSize={10}
fontSize={CHART_FONT_SIZE}
tickFormatter={(value) => 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);

View file

@ -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);

View file

@ -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 (
<Card data-tour="studio-training-loss" size="sm">
<CardHeader>
<CardTitle className="text-sm">{t("studio.charts.trainingLoss")}</CardTitle>
<CardTitle className="text-sm">
{t("studio.charts.trainingLoss")}
</CardTitle>
</CardHeader>
<CardContent>
<ChartContainer config={lossConfig} className={CHART_CONTAINER_CLASS}>
@ -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);

View file

@ -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$/;

View file

@ -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"
>
<div className="flex flex-col gap-4">
{/* Loss chart */}
<div className="relative ">
<ChartContainer
config={chartConfig}
className="h-[180px] w-full relative right-8 blur"
>
<LineChart data={placeholderData} accessibilityLayer={true}>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis
dataKey="step"
tickLine={false}
axisLine={false}
tickMargin={8}
fontSize={10}
{/* Loss chart */}
<div className="relative ">
<ChartContainer
config={chartConfig}
className="h-[180px] w-full relative right-8 blur"
>
<LineChart data={placeholderData} accessibilityLayer={true}>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis
dataKey="step"
tickLine={false}
axisLine={false}
tickMargin={8}
fontSize="calc(10px * var(--ui-font-scale, 1))"
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={8}
fontSize="calc(10px * var(--ui-font-scale, 1))"
/>
<Line
type="monotone"
dataKey="loss"
stroke="var(--color-loss)"
strokeWidth={2}
dot={false}
/>
</LineChart>
</ChartContainer>
<div className="absolute inset-0 flex flex-col items-center justify-center gap-1">
<HugeiconsIcon
icon={ChartAverageIcon}
className="size-5 text-muted-foreground/50"
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={8}
fontSize={10}
/>
<Line
type="monotone"
dataKey="loss"
stroke="var(--color-loss)"
strokeWidth={2}
dot={false}
/>
</LineChart>
</ChartContainer>
<div className="absolute inset-0 flex flex-col items-center justify-center gap-1">
<HugeiconsIcon
icon={ChartAverageIcon}
className="size-5 text-muted-foreground/50"
/>
<p className="text-sm font-medium text-muted-foreground">
{t("studio.training.chartNoDataTitle")}
</p>
<p className="text-xs text-muted-foreground/60">
{t("studio.training.chartNoDataDescription")}
</p>
<p className="text-sm font-medium text-muted-foreground">
{t("studio.training.chartNoDataTitle")}
</p>
<p className="text-xs text-muted-foreground/60">
{t("studio.training.chartNoDataDescription")}
</p>
</div>
</div>
</div>
{/* Start/Stop */}
<Button
data-tour="studio-start"
className="w-full cursor-pointer bg-primary text-primary-foreground hover:bg-primary/90"
onClick={() => void startTrainingRun()}
disabled={isStarting || isIncompatible || store.isCheckingDataset || isLoadingModel || !configValidation.ok}
>
<HugeiconsIcon icon={Rocket01Icon} className="size-4" />
{isStarting
? t("studio.training.starting")
: isLoadingModel
? t("studio.training.loadingModel")
: store.isCheckingDataset
? t("studio.training.checkingDataset")
: t("studio.training.startTraining")}
</Button>
{startError && (
<p className="text-xs text-red-500 leading-relaxed">{startError}</p>
)}
{isIncompatible && (
<p className="text-xs text-red-500 leading-relaxed">
{!store.isAudioModel && store.isDatasetAudio === true
? t("studio.training.audioIncompatible")
: t("studio.training.visionIncompatible")}
{/* Start/Stop */}
<Button
data-tour="studio-start"
className="w-full cursor-pointer bg-primary text-primary-foreground hover:bg-primary/90"
onClick={() => void startTrainingRun()}
disabled={
isStarting ||
isIncompatible ||
store.isCheckingDataset ||
isLoadingModel ||
!configValidation.ok
}
>
<HugeiconsIcon icon={Rocket01Icon} className="size-4" />
{isStarting
? t("studio.training.starting")
: isLoadingModel
? t("studio.training.loadingModel")
: store.isCheckingDataset
? t("studio.training.checkingDataset")
: t("studio.training.startTraining")}
</Button>
{startError && (
<p className="text-xs text-red-500 leading-relaxed">{startError}</p>
)}
{isIncompatible && (
<p className="text-xs text-red-500 leading-relaxed">
{!store.isAudioModel && store.isDatasetAudio === true
? t("studio.training.audioIncompatible")
: t("studio.training.visionIncompatible")}
</p>
)}
{!configValidation.ok &&
configValidation.message &&
!isIncompatible && (
<p className="text-xs text-red-500 leading-relaxed">
{configValidation.message}
</p>
)}
{/* Upload / Save / Reset */}
<p className="text-xs text-muted-foreground">
{t("studio.training.configLabel")}
</p>
)}
{!configValidation.ok && configValidation.message && !isIncompatible && (
<p className="text-xs text-red-500 leading-relaxed">{configValidation.message}</p>
)}
{/* Upload / Save / Reset */}
<p className="text-xs text-muted-foreground">{t("studio.training.configLabel")}</p>
<div className="grid grid-cols-3 gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
className="cursor-pointer"
onClick={() => fileInputRef.current?.click()}
>
<HugeiconsIcon icon={CloudUploadIcon} className="size-3.5" />
{t("studio.training.upload")}
</Button>
</TooltipTrigger>
<TooltipContent>{t("studio.training.uploadConfigTooltip")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
data-tour="studio-save"
variant="outline"
size="sm"
className="cursor-pointer"
onClick={handleSaveConfig}
>
<HugeiconsIcon icon={Archive04Icon} className="size-3.5" />
{t("studio.training.save")}
</Button>
</TooltipTrigger>
<TooltipContent>{t("studio.training.saveConfigTooltip")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
className="cursor-pointer"
onClick={handleResetConfig}
disabled={!store.selectedModel}
>
<HugeiconsIcon icon={CleanIcon} className="size-3.5" />
{t("studio.training.reset")}
</Button>
</TooltipTrigger>
<TooltipContent>{t("studio.training.resetConfigTooltip")}</TooltipContent>
</Tooltip>
</div>
<input
ref={fileInputRef}
type="file"
accept=".yaml,.yml"
className="hidden"
onChange={handleFileUpload}
/>
<div className="grid grid-cols-3 gap-2">
<Tooltip>
<TooltipTrigger asChild={true}>
<Button
variant="outline"
size="sm"
className="cursor-pointer"
onClick={() => fileInputRef.current?.click()}
>
<HugeiconsIcon icon={CloudUploadIcon} className="size-3.5" />
{t("studio.training.upload")}
</Button>
</TooltipTrigger>
<TooltipContent>
{t("studio.training.uploadConfigTooltip")}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild={true}>
<Button
data-tour="studio-save"
variant="outline"
size="sm"
className="cursor-pointer"
onClick={handleSaveConfig}
>
<HugeiconsIcon icon={Archive04Icon} className="size-3.5" />
{t("studio.training.save")}
</Button>
</TooltipTrigger>
<TooltipContent>
{t("studio.training.saveConfigTooltip")}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild={true}>
<Button
variant="outline"
size="sm"
className="cursor-pointer"
onClick={handleResetConfig}
disabled={!store.selectedModel}
>
<HugeiconsIcon icon={CleanIcon} className="size-3.5" />
{t("studio.training.reset")}
</Button>
</TooltipTrigger>
<TooltipContent>
{t("studio.training.resetConfigTooltip")}
</TooltipContent>
</Tooltip>
</div>
<input
ref={fileInputRef}
type="file"
accept=".yaml,.yml"
className="hidden"
onChange={handleFileUpload}
/>
</div>
</SectionCard>
</div>

View file

@ -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(