unsloth/studio/frontend/public/theme-boot.js
Michael Han bc23135996
Unsloth: appearance palettes, customization options, and control restyle (#7077)
* Unsloth: appearance palettes, customization options, and control restyle

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 scoped to the active mode: accent, background,
and foreground colors with an in-app color picker, UI and code fonts with a
searchable dropdown covering bundled, device, and imported fonts, 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 core controls for a cleaner, flatter look in both modes: bordered
white input fields, fully rounded pills for single-row controls, no drop
shadows, simple straight-line chevrons replacing all rounded arrow icons,
and consistent hover tones in dropdown menus. Popovers now portal into the
open dialog so their lists scroll correctly inside modal dialogs.

Moves Language into General settings and Chat defaults into the Chat tab
above the Canvas section.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Unsloth: appearance follow-ups, font options, and settings search

Neutralizes focus and selection rings across all palettes so highlighted
elements, including typing boxes and the selected palette card, never take
the accent color. The custom accent no longer recolors rings.

Restyles the color controls as filled pills showing the hex value inside,
with text and border contrast picked from the color's luminance. Menus in
popovers now match the app's dropdown menus: rounded-lg corners, tighter
padding, accent hover rows, and a bordered search field. Popovers inside
modal dialogs are modal so their lists scroll with the wheel. Outline
buttons share the same dark fills as dropdown triggers.

Adds heading and chat font options next to the UI and code fonts, each
using the searchable font dropdown and persisting through the
personalization API. Removes the translucent sidebar option end to end.

Adds settings search: a search field at the top of the settings sidebar
that filters setting names across every tab, grouped by tab with icons,
and jumps to the tab on click.

* Unsloth: use the shared accent token for dark hover fills

The settings dialog nav, its close button, the model selector, and the
project switcher hovered with hardcoded blue tinted greys (#3a3d43,
#2d2e32) in dark mode while every menu and sidebar uses --accent. All
hover and active pill fills now use the accent token so dark hovers are
the same everywhere and adapt to the active palette.

* Unsloth: settings search polish and jump to matched setting

Widens the settings dialog to 880px and the sidebar column to 248px so
the search field has more room. The search pill aligns with the left
start of the Settings title, gets more spacing above and below, and its
icon and placeholder sit slightly further left.

Search results now jump to the exact setting: rows and sections expose
their label as a data attribute, and picking a result opens the tab,
scrolls the matched row into view, and flashes it briefly.

* Unsloth: settings search bar spans the full nav pill width

The search field now starts and ends at the same edges as the nav hover
pills instead of being inset to the title text.

* Unsloth: address review findings on motion, sync, and font limits

Reduce motion Off now opts back out of the OS reduced-motion preference
for CSS animations via a force-motion class that the media rules skip,
and forcing reduce motion On keeps the loader exceptions (spinners,
loading dots, progress bars) animating.

When the color scheme follows the system, the resolved mode is now part
of the theme store snapshot, so an OS scheme flip re-renders consumers
and reapplies per-mode custom colors instead of leaving stale inline
variables from the previous mode.

Imported fonts get an aggregate size cap (4.4M characters) on both the
frontend sanitizer and the backend model so the persisted store always
fits browser localStorage quotas, with a clear error toast when an
import would exceed it. Backend validation also tightens imported font
names (rejects CSS delimiter characters) and requires strict base64
font data URLs, matching the frontend patterns.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Unsloth: profile toggle to hide the sloth in the chat greeting

Adds a Show greeting sloth switch to Settings > Profile. The chat welcome
hides the mascot when it is off. The preference persists locally and
through the personalization API, with backend validation and tests, and
the row is reachable from settings search in all four locales.

* Unsloth: control restyle, dropdown scrolling, and palette consistency

Settings sidebar puts search on top with the tab list under a small
Settings label. Combobox popups scroll with the wheel inside dialogs by
falling back to manual list scrolling while a dialog scroll lock is
active, and the local model selector popover became modal for the same
reason. Number inputs swap native spinners for a shared grey stepper
that clamps to min, max, and step. Run settings fields in light mode use
the same white fill and border as the settings dialog. Selection and
focus rings derive from each palette's border color instead of near
black, hover borders soften the same way, the Classic sidebar stays
white like Standard, decorative greens follow the palette accent, and
meaning-carrying marks like the hub verified badge keep the brand green
in every palette.

* Unsloth: palette card selection keyed off the palette attribute

Switching palettes restyles the whole page the moment data-palette lands
on the html element, but the React re-render that moves the selection
classes arrives later, so the ring and check briefly stayed on the
previous card with the new palette's colors. The active ring and check
now key off html[data-palette] in CSS, so they swap in the same style
pass that swaps the tokens. Also adds breathing room around the settings
search bar and under the Settings label, shortens the greeting sloth
description, and renames the avatar section to Or pick a sloth profile
picture in all locales.

* Unsloth: restore neutral rings, drop the palette check, sidebar spacing

Puts the ring tokens back to their fixed per palette values and removes
the hover border darkening, undoing the derived border experiment. The
selected palette card no longer shows a check since the ring already
marks it. The settings sidebar search bar, nav pills, and search results
get a little side padding, and the Settings label lines up with the pill
text.

* Unsloth: indicator restyle, sidebar menu customization, edge fade toggle

- Derive focus and selection rings from the border color so indicators
  stay 1px and adapt to every theme and palette
- Suppress mouse focus rings except on pressed controls to remove the
  selection flash on the avatar and palette pickers
- Defer settings panel rendering so the active nav pill updates instantly
- Customizable sidebar user menu with drag to reorder and shortcuts to
  the settings tabs
- Grey hover for the standard light palette instead of green
- Borderless controls in dark mode with fill based focus states
- Profile picture: no picture option, pencil edit icon, atomic selection
- Font dropdowns: narrower triggers and the resolved default shown as
  Inter Variable (Default)
- System prompt border darkens on focus
- New appearance setting to swap edge fades for thin divider lines
- Move the theme bootstrap to an external script to satisfy CSP

* Unsloth: harden theme boot and Firefox scroll container focus

- Guard the theme and palette storage reads separately so a blocked
  localStorage (private browsing) still resolves a mode from the OS
  preference instead of skipping the boot entirely
- Firefox makes scrollable containers keyboard focusable and drew its
  3px UA outline on them; swap it for the app's soft 1px indicator

* Unsloth: make the UI and code font settings reach the font utilities

The theme block declared the sans and mono stacks as literals, so
Tailwind inlined them into every font-sans and font-mono utility at
build time and the runtime overrides from Settings > Appearance never
applied. Reference the :root tokens instead, matching how the color
tokens already work.

* Unsloth: in-dropdown font upload, accent meters and avatar, naming cleanup

- Move font importing into each font dropdown: Upload and Select folder
  sit side by side under the list, imported fonts get an inline remove,
  and the standalone Import font row is gone
- Uploads reuse fonts the user already has (bundled, imported, or
  installed, matched by file name with style suffixes stripped) instead
  of embedding a duplicate copy; only new fonts are embedded
- Folder scan lists font files from a picked folder in every dropdown
  for the session; picking one imports it through the same path
- Fallback avatar uses the control accent with a readable foreground
  instead of the neutral primary that rendered black outside standard
- Monitor bars, progress defaults, sliders, and usage meters use the
  control accent; warning and danger tiers stay amber and red
- User facing strings that called the app just Studio now say Unsloth
  in all four locales, keeping Unsloth Studio and LM Studio intact

* Unsloth: left align the font upload actions and divide them

Upload and Select folder now read from the left like the list items,
with a short vertical rule between the two.

* Unsloth: keep sliders neutral and the chat greeting on Hellix

- Sliders are controls, not meters, so their fill goes back to the
  neutral primary instead of the palette accent
- The base h1 rule reads --font-heading with !important and the chat
  thread root resets that variable to the sans stack, which pulled the
  greeting off Hellix; restore the stack on the greeting element

* Unsloth: move the None avatar cell last and keep footer actions on one line

- None sits after the sloth pictures instead of leading the grid
- Upload shrinks to its label so Select folder no longer wraps

* Unsloth: size the folder action to its label

Both footer actions now hug their content so the hover pill does not
stretch across the leftover row width.

* Unsloth: separators only between unrelated settings clusters

Rows inside a titled section are related, so the per row divide-y is
gone from SettingsSection. A SettingsGroupDivider marks the two real
boundaries in the theme section (colors to fonts, fonts to contrast)
and the Clear all chats row gets its destructive border back now that
divide-y no longer draws one for it.

* Unsloth: balance the two font upload actions

Both actions share the footer row evenly again; nowrap keeps Select
folder on one line at the narrower width.

* Unsloth: drop the theme section dividers and split the chat menu groups

The colors, fonts, and contrast rows read fine without rules, and the
chat menu gains its one real boundary between the pin toggles and the
disclaimer rows.

* Unsloth: normalize oversized sidebar menus and reject newline font data URLs

Two backend validation fixes in PersonalizationCustomization:

- sidebarMenu refused any list longer than the number of distinct ids
  because Field(max_length) is enforced before the dedupe validator runs.
  A stale or duplicated payload that would normalize to one entry per id
  was rejected outright, defeating the normalizer that exists for exactly
  that case. Cap the incoming list at a generous multiple so it reaches
  the validator; a pathologically long list is still refused.

- The imported font dataUrl validator used re.match on a pattern ending
  in $, which also matches just before a trailing newline, so
  "data:font/woff2;base64,AAAA\n" passed even though the frontend JS
  pattern rejects it. Use re.fullmatch for parity.

Adds covering tests for both.

* Unsloth: preview fonts in their own typeface and slim the color pills

- Every font dropdown entry, the default item, and the closed trigger
  render in the font they name, falling back to the UI stack for
  families the browser cannot resolve
- Color swatch pills drop from 36px to 28px so they sit closer to the
  row label height

* Unsloth: drop the font row and theme section descriptions

The labels carry the meaning on their own; the mode switching note in
particular read long and confusing.

* Unsloth: let the chat greeting follow the heading font setting

The greeting stays on Hellix by default but adopts a chosen heading
font through a --custom-heading-font variable the applier sets only
while an override exists, so the thread root's sans reset for chat
prose no longer hides the user's pick from the greeting.

* Unsloth: divide the theme section clusters and align the color pill height

Separators return between colors and fonts and between fonts and
contrast, and the color pills share the 32px height of the font
dropdown triggers.

* Unsloth: color pills at half the dropdown width

Fixed w-24 against the w-48 font triggers, with tighter padding so the
hex value still fits.

* Studio: update dep-removal test after next-themes was replaced

The frontend no longer declares next-themes or imports it in src (it was
replaced by the custom theme store and boot script), so the checker now
reports its removal as a safe no-op. The C1 and C8 fixtures in
test_frontend_dep_removal.py still asserted next-themes was a used
dependency, which fails the studio frontend CI dependency-removal safety
check. Update C1 to expect a no-op PASS and drop next-themes from the C8
expected failures so the suite matches the checker's correct output.

* Studio: remove unused ageLabel and exportCollectionJsonl helpers

* Studio: fix blocked-storage theme desync, search jump race, font validation

- theme-store.ts: keep an in-memory currentTheme/currentPalette so a selected
  value survives when localStorage is blocked (private browsing). The snapshots
  previously re-read empty storage and reverted React state to the default while
  the DOM already changed. The matchMedia handler no longer re-reads storage, so
  it cannot clobber the in-memory choice; cross-tab storage events still adopt.
- settings-dialog.tsx: the search jump waited a single fixed 60ms for the
  deferred tab panel to render, then silently missed under render lag. Retry
  across animation frames until the target row exists, then scroll and flash.
- settings.py: apply the font-name character check to the four selected-font
  fields (uiFont/headingFont/chatFont/codeFont), and forbid backslash, comma,
  slash and control characters so a name cannot escape the quoted CSS
  font-family or smuggle extra fallbacks. Adds covering tests.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Fix appearance customization edge cases for PR #7077

- Reset all local preferences now also clears palette and appearance customization
- Number input wrapper keeps full width so fields fill their flex/grid cell, and the stepper stays pinned to the field edge
- Number stepper snaps to the min anchored step grid like the native spinner instead of leaving a step-invalid value
- Code font now applies to chat code fences and inline code via a dedicated token
- Reduce motion (on/off) is honored by onboarding/tour confetti and the theme toggle view transition
- Re-importing a font under the same name with new bytes now swaps the FontFace
- Keep local customization when a synced record predates the customization field, and re-push it

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Align client font name sanitization with server validation for PR #7077

sanitizeFont now strips the same characters the backend _FONT_NAME_FORBIDDEN
rejects (backslash, slash, comma, backtick) plus control chars, so a locally
chosen font name can no longer pass the client but fail the personalization PUT
and silently stall appearance sync.

* Address follow-up review items for PR #7077

- Number input wrapper carries React Flow interaction classes (nodrag/nopan/nowheel) so clicking the stepper arrows increments instead of dragging the node
- Preserve local palette and greeting-sloth toggle when the synced record predates those fields, and re-push them, mirroring the customization handling (new paletteSaved and greetingSlothSaved response flags)
- Add settings-search scroll targets (data-settings-label) for the Profile title, description, display name, nickname, and avatar shape rows

* Preserve absent personalization fields on PUT for PR #7077

A stale client that omits palette or customization previously had those
defaults materialized by model_dump() and persisted, which flipped
paletteSaved/customizationSaved to true and defeated the legacy detection.
The PUT now dumps only the request's set fields and merges them onto the
stored record, so omitted fields keep whatever was already stored.

* Persist theme and palette via a fixed allow-list for PR #7077

The theme/palette values reach setTheme/setPalette from the authenticated
personalization sync, which made the CodeQL clear-text-storage query treat
writing them to localStorage as storing sensitive data. Store a re-derived
literal from a constant map instead, so a plain UI preference is not tracked
as sensitive; behavior is unchanged.

* Harden imported-font handling for PR #7077

- syncImportedFonts: a rejected FontFace.load() only clears the registry entry
  if it still points at that face, so a same-name re-import while the old load
  was pending is no longer untracked/leaked.
- Cap imported-font names to the backend length (100) so an over-long name can
  no longer pass the client but fail the personalization PUT and stall sync.
- Add a backend test that a stale PUT preserves an existing stored palette and
  customization (not just that absent fields stay absent).

* Return the merged personalization record from PUT

The PUT /personalization handler returned the request payload, which
Pydantic had already filled with defaults for any field the client
omitted. A partial or stale write (for example a client sending only
theme) therefore got back a response that contradicted both storage and
the next GET: preserved fields like palette and the custom font showed
their defaults instead of the stored values.

Return model_validate(merged) so the response mirrors what was stored.
The stored record is still the full merged dict, so legacy fields the
model does not know about are preserved as before.

* Fix small UI and keyboard-focus defects in appearance settings

- Settings search now scrolls to the result within its destination tab
  instead of a same-named row in the previously rendered deferred tab
  (for example "Storage" and "Models folder" appear in both General and
  Resources).
- The reduce-motion segmented control honors its own Off/On/System choice
  by reading useReducedMotionConfig instead of the OS-only useReducedMotion.
- The color picker saturation/value area is operable by keyboard, so the
  role="slider" surface responds to the arrow keys it advertises.
- Profile avatars and palette cards show a visible keyboard focus ring
  again.
- Guard the persisted appearance-customization write so a blocked or full
  localStorage does not throw out of a store action, matching the theme
  store.
- Import the appearance store symbols from the settings feature barrel.

* Tighten appearance fix comments

---------

Co-authored-by: shimmyshimmer <shimmyshimmer@users.noreply.github.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
2026-07-14 05:12:08 -07:00

27 lines
1.1 KiB
JavaScript

// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
// Apply the stored theme and palette before the bundle loads so the first
// paint is never the wrong mode. Loaded as an external classic script (it
// blocks parsing, like an inline script) because the backend CSP only
// allows script-src 'self'.
try {
// Storage reads get their own guards so a blocked localStorage (private
// browsing) still resolves a mode from the OS preference.
var theme = "system";
var palette = null;
try {
theme = localStorage.getItem("theme") || "system";
palette = localStorage.getItem("palette");
} catch (e) {}
var dark =
theme === "dark" ||
(theme !== "light" && matchMedia("(prefers-color-scheme: dark)").matches);
var root = document.documentElement;
root.classList.toggle("dark", dark);
root.classList.toggle("light", !dark);
root.style.colorScheme = dark ? "dark" : "light";
if (palette === "classic" || palette === "minimal") {
root.setAttribute("data-palette", palette);
}
} catch (e) {}