Skip to content

Studio: appearance palettes, customization options - #7075

Closed
shimmyshimmer wants to merge 2 commits into
mainfrom
studio-appearance-palettes-customization
Closed

shimmyshimmer wants to merge 2 commits into
mainfrom
studio-appearance-palettes-customization

Conversation

@shimmyshimmer

Copy link
Copy Markdown
Member

What this adds

Appearance settings in Studio previously only offered light/dark. This PR adds color palettes, a full set of appearance customization options, and restyles the core controls (dropdowns, buttons, inputs) for a cleaner, more neutral look in both modes.

Color palettes

Three palettes in Settings > Appearance, each with its own light and dark scheme:

  • Standard: the current Unsloth green look.
  • Classic: a neutral enterprise scheme (greys, blacks, white) with a blue accent used sparingly on toggles, "New" badges, and focus rings. Buttons stay neutral.
  • Minimal: strictly black, grey, and white, including monochrome badges and a white sidebar in light mode.

Dark mode surfaces (page, sidebar, cards, popovers, borders) are shared by all three palettes so the app stays consistent; palettes only swap accents. The palette is stored as a data-palette attribute on <html>, orthogonal to the existing dark/light class, so the default look is untouched.

Customization options

All options apply to the currently active mode only (the other mode keeps its own values), persist through the personalization API, and sync across devices:

  • Accent, background, and foreground colors with an in-app color picker (saturation/value area, hue slider, hex field, eyedropper where supported)
  • UI font and code font with a searchable dropdown covering bundled fonts, fonts detected on the device, and imported fonts
  • Font file import (woff2/woff/ttf/otf, validated and size capped, max 3)
  • UI font size, code font size, contrast slider, pointer cursors, reduce motion, font smoothing, translucent sidebar
  • Reset control at the bottom of the tab

The customization applier writes inline CSS variables and gated classes on <html>, so a default customization leaves the document byte-identical to stock. Persisted payloads are sanitized on every rehydrate so stale local storage can never break the UI.

Control restyle

  • Dropdown triggers, outline buttons, inputs, and textareas follow a flat pill style: solid or translucent fills, subtle borders, no drop shadows
  • Single-row controls are fully rounded; multi-row controls keep rounded rectangles
  • All rounded dropdown arrows (ArrowDown01Icon, ArrowUp01Icon, ArrowUp02Icon, UnfoldMoreIcon) are replaced by simple straight-line chevrons across the app (selects, accordions, navigation, collapsibles, export, data recipes, download manager, folder browser)

Backend

PersonalizationAppearance gains palette and customization with strict validation (hex color patterns, font name length, size and contrast ranges, imported font data URL checks).

Testing

  • npm run typecheck, npm run build, and npm run i18n:check pass (en, ja, zh-CN, pt-br strings added)
  • 16 backend tests in test_personalization_settings.py cover palette and customization validation, defaults, imported font limits, and full round-trips
  • Verified in the browser across all three palettes in light and dark: settings dialog, sidebar, popovers, training, export, data recipes, chat

… controls

Adds Standard, Classic, and Minimal color palettes to Appearance settings,
each adapting to light and dark mode. Classic is a neutral enterprise look
that reserves its blue accent for toggles, badges, and focus rings; Minimal
is strictly black, grey, and white.

Adds customization options: accent, background, and foreground colors per
active mode, UI and code fonts with device font search and font file import,
UI and code font sizes, contrast, pointer cursors, reduce motion, font
smoothing, and translucent sidebar. Settings persist through the
personalization API with backend validation and sync across devices.

Restyles dropdowns, buttons, and inputs to match the ChatGPT app in both
modes, with fully rounded pills for single-row controls, no drop shadows,
and simple straight-line chevrons replacing the rounded arrow icons
everywhere.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a comprehensive appearance customization feature to Unsloth Studio, allowing users to customize colors, fonts, font sizes, contrast, and other interface preferences across standard, classic, and minimal palettes. The backend adds validation and storage for these settings, while the frontend implements state stores, DOM style injection, and settings controls. The review feedback highlights several key areas for improvement: strengthening backend validation for font data URLs to prevent malicious payloads, escaping backslashes in the font sanitization function to avoid CSS syntax breakage, removing redundant sanitization logic in the font name generator, and fixing a UI sync bug in the font size input when values are clamped.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +78 to +87
function sanitizeFont(value: unknown): string | null {
if (typeof value !== "string") return null;
// Strip characters that could terminate the declaration or smuggle extra
// CSS through the inline style (the value lands in style.setProperty).
const cleaned = value
.replace(/[;{}()<>"']/g, "")
.trim()
.slice(0, 200);
return cleaned.length > 0 ? cleaned : null;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The sanitizeFont function strips common CSS-breaking characters but does not strip backslashes (\). If a font name contains a trailing backslash (e.g., MyFont\), it will escape the closing double quote in applyCustomizationToDocument (e.g., "MyFont\", ...), breaking the CSS syntax and preventing subsequent styles from parsing correctly. Strip backslashes as well to ensure robust sanitization.

Suggested change
function sanitizeFont(value: unknown): string | null {
if (typeof value !== "string") return null;
// Strip characters that could terminate the declaration or smuggle extra
// CSS through the inline style (the value lands in style.setProperty).
const cleaned = value
.replace(/[;{}()<>"']/g, "")
.trim()
.slice(0, 200);
return cleaned.length > 0 ? cleaned : null;
}
function sanitizeFont(value: unknown): string | null {
if (typeof value !== "string") return null;
// Strip characters that could terminate the declaration or smuggle extra
// CSS through the inline style (the value lands in style.setProperty).
const cleaned = value
.replace(/[;{}()\u003c\u003e\\\"'\\\\/]/g, "")
.trim()
.slice(0, 200);
return cleaned.length > 0 ? cleaned : null;
}

Comment on lines +592 to +597
@field_validator("dataUrl")
@classmethod
def _validate_font_data_url(cls, value: str) -> str:
if not (value.startswith("data:font/") or value.startswith("data:application/")):
raise ValueError("dataUrl must be a font data URL.")
return value

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

The current dataUrl validation only checks if the string starts with data:font/ or data:application/. This is extremely loose and allows storing arbitrary or potentially malicious payloads (e.g., data:application/javascript;base64,...) in the database. To prevent storage of garbage or malicious data, use a stricter regex pattern that matches the frontend's FONT_DATA_URL_PATTERN to ensure only valid base64-encoded font data URLs are accepted.

Suggested change
@field_validator("dataUrl")
@classmethod
def _validate_font_data_url(cls, value: str) -> str:
if not (value.startswith("data:font/") or value.startswith("data:application/")):
raise ValueError("dataUrl must be a font data URL.")
return value
@field_validator("dataUrl")
@classmethod
def _validate_font_data_url(cls, value: str) -> str:
import re
pattern = r"^data:(?:font/(?:woff2?|ttf|otf|sfnt)|application/(?:octet-stream|x-font-\w+|font-\w+));base64,[A-Za-z0-9+/=]+$"
if not re.match(pattern, value):
raise ValueError("dataUrl must be a valid base64-encoded font data URL.")
return value

Comment on lines +394 to +401
function fontNameFromFile(fileName: string, taken: Set<string>): string {
const base =
fileName
.replace(/\.[^.]+$/, "")
.replace(/[;{}()<>"']/g, "")
.replace(/[_-]+/g, " ")
.trim()
.slice(0, 60) || "Imported font";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To avoid redundant sanitization in the UI component, we should not duplicate the sanitization logic in fontNameFromFile. Since the font name is already guaranteed to be sanitized on write in the state store, we should reuse the store's sanitization function (sanitizeFont) or use the sanitized name from the store to register the font. This prevents code duplication and ensures the registered font name matches the store name.

function fontNameFromFile(fileName: string, taken: Set<string>): string {
  const base =
    fileName
      .replace(/\.[^.]+$/, "")
      .replace(/[_-]+/g, " ")
      .trim()
      .slice(0, 60) || "Imported font";
  return sanitizeFont(base) || "Imported font";
}
References
  1. Avoid adding redundant sanitization or dead code for values (such as API tokens) that are already guaranteed to be sanitized, validated, or formatted at the source (e.g., on write in the state store).

Comment on lines +534 to +546
const commit = () => {
const trimmed = draft.trim();
if (trimmed === "") {
onCommit(null);
return;
}
const parsed = Number.parseInt(trimmed, 10);
if (Number.isNaN(parsed)) {
setDraft(value === null ? "" : String(value));
return;
}
onCommit(Math.min(range.max, Math.max(range.min, parsed)));
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In FontSizeInput, when a user types an out-of-bounds or decimal value, onCommit is called with the clamped integer value. However, the local draft state is not updated with the clamped value. If the clamped value happens to equal the current store value, the store state won't change, and the useEffect won't trigger to sync the input field. This leaves the out-of-bounds or decimal string visible in the input field. Updating draft with the clamped value inside commit ensures the UI immediately reflects the actual applied value.

  const commit = () => {
    const trimmed = draft.trim();
    if (trimmed === "") {
      onCommit(null);
      return;
    }
    const parsed = Number.parseInt(trimmed, 10);
    if (Number.isNaN(parsed)) {
      setDraft(value === null ? "" : String(value));
      return;
    }
    const clamped = Math.min(range.max, Math.max(range.min, parsed));
    setDraft(String(clamped));
    onCommit(clamped);
  };

export function setPalette(next: Palette): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(PALETTE_STORAGE_KEY, next);

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c44345d68e

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +362 to +365
setVar(
"--font-sans",
c.uiFont ? `"${c.uiFont}", ${DEFAULT_SANS_STACK}` : null,
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make the custom font choice affect Tailwind text

When a user selects a UI font, this only updates --font-sans, but most Studio text is styled through Tailwind's font-sans utility (including html/body via @apply in index.css), and the font theme tokens are declared under @theme inline, so those utilities inline the default font family instead of reading this runtime variable. In that setup the setting changes headings or hand-written font-family: var(--font-sans) rules at best, while the bulk of the UI remains Inter; add a runtime font-family rule/class that consumes this variable or make the Tailwind font utility reference it.

Useful? React with 👍 / 👎.

Comment on lines +491 to +496
function AppearanceCustomizationEffect() {
const { resolved } = useTheme();
const customization = useAppearanceCustomStore((s) => s.customization);
useEffect(() => {
applyCustomizationToDocument(customization, resolved);
}, [customization, resolved]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reapply custom colors when system theme changes

When the theme is set to System, an OS light/dark change updates the <html> class in theme-store, but useTheme() still snapshots the same string ("system"), so this effect does not re-render and applyCustomizationToDocument is not called with the new resolved mode. Users with different light and dark custom colors will keep the previous mode's inline --background, --foreground, or accent variables until some unrelated customization/theme change happens; include the resolved mode in the subscribed snapshot or otherwise trigger this effect from the media-query change.

Useful? React with 👍 / 👎.

Comment on lines +500 to +504
const REDUCED_MOTION_MAP = {
system: "user",
on: "always",
off: "never",
} as const;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Let Reduce motion Off bypass system media rules

When the OS has prefers-reduced-motion: reduce, selecting the new Off option only maps Motion animations to never; the global CSS @media (prefers-reduced-motion: reduce) rules and components using useReducedMotion() still collapse CSS and local transitions. In that environment the Off setting remains mostly reduced despite the UI saying it is off, so the persisted preference needs to gate or override those system-based reductions too.

Useful? React with 👍 / 👎.

Comment on lines +589 to +593
html.force-reduced-motion *,
html.force-reduced-motion *::before,
html.force-reduced-motion *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve loading indicators when forcing reduced motion

When a user sets Reduce motion to On, this blanket selector also clamps .animate-spin and generated-image loading dots to a single 0.01ms iteration. The existing OS-level reduced-motion block later keeps those loaders animating because otherwise startup/update/tool progress can look frozen, but this new class path lacks the same exceptions; add matching exemptions for loading indicators under html.force-reduced-motion.

Useful? React with 👍 / 👎.

Comment on lines +23 to +25
export const MAX_IMPORTED_FONTS = 3;
/** ~1.5 MB file → ~2 MB base64; must stay in sync with the backend cap. */
export const MAX_IMPORTED_FONT_DATA_URL_LENGTH = 2_200_000;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep imported fonts below localStorage quota

When a user imports three fonts near the advertised 1.5 MB cap, each one is stored as a base64 data URL in the zustand persist localStorage entry, so the allowed payload can exceed common 5 MiB per-origin quotas before any other Studio data is counted. In that scenario the localStorage write fails and appearance settings stop persisting locally, especially for unauthenticated/offline users; reduce the aggregate cap or store font blobs outside localStorage and persist only references.

Useful? React with 👍 / 👎.

@shimmyshimmer

Copy link
Copy Markdown
Member Author

Superseded by #7077, which includes additional control and settings changes.

@shimmyshimmer
shimmyshimmer deleted the studio-appearance-palettes-customization branch July 11, 2026 11:09
@shimmyshimmer shimmyshimmer changed the title Studio: appearance palettes, customization options, and ChatGPT-style controls Studio: appearance palettes, customization options Jul 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants