From bc231359962d9dc943c751ca1ede11e1aa083ad0 Mon Sep 17 00:00:00 2001 From: Michael Han <107991372+shimmyshimmer@users.noreply.github.com> Date: Tue, 14 Jul 2026 05:12:08 -0700 Subject: [PATCH] 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 Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Daniel Han --- studio/backend/routes/settings.py | 205 +++- .../tests/test_personalization_settings.py | 357 ++++++- studio/frontend/index.html | 3 + studio/frontend/package-lock.json | 22 - studio/frontend/package.json | 1 - studio/frontend/public/theme-boot.js | 27 + studio/frontend/src/app/provider.tsx | 131 ++- .../frontend/src/components/app-sidebar.tsx | 130 ++- .../assistant-ui/model-selector.tsx | 4 +- .../model-selector/folder-browser.tsx | 5 +- .../assistant-ui/model-selector/pickers.tsx | 4 +- .../src/components/assistant-ui/sources.tsx | 2 +- .../src/components/assistant-ui/thread.tsx | 17 +- .../assistant-ui/tool-ui-image-generation.tsx | 4 +- .../src/components/floating-monitor.tsx | 2 +- .../frontend/src/components/section-card.tsx | 10 +- .../src/components/tauri/window-titlebar.tsx | 6 +- .../frontend/src/components/ui/accordion.tsx | 6 +- .../components/ui/animated-theme-toggler.tsx | 142 +-- studio/frontend/src/components/ui/badge.tsx | 2 +- studio/frontend/src/components/ui/button.tsx | 4 +- .../frontend/src/components/ui/calendar.tsx | 2 +- .../frontend/src/components/ui/checkbox.tsx | 2 +- .../frontend/src/components/ui/combobox.tsx | 21 +- studio/frontend/src/components/ui/command.tsx | 6 +- .../src/components/ui/copyable-error-chip.tsx | 4 +- studio/frontend/src/components/ui/field.tsx | 2 +- .../frontend/src/components/ui/info-hint.tsx | 2 +- .../src/components/ui/input-group.tsx | 2 +- studio/frontend/src/components/ui/input.tsx | 134 ++- .../src/components/ui/navigation-menu.tsx | 8 +- studio/frontend/src/components/ui/popover.tsx | 13 +- .../frontend/src/components/ui/progress.tsx | 5 +- .../src/components/ui/radio-group.tsx | 2 +- .../frontend/src/components/ui/resizable.tsx | 2 +- .../src/components/ui/scroll-area.tsx | 2 +- studio/frontend/src/components/ui/select.tsx | 18 +- studio/frontend/src/components/ui/sidebar.tsx | 8 +- studio/frontend/src/components/ui/slider.tsx | 2 +- studio/frontend/src/components/ui/sonner.tsx | 10 +- studio/frontend/src/components/ui/switch.tsx | 4 +- studio/frontend/src/components/ui/tabs.tsx | 6 +- .../frontend/src/components/ui/textarea.tsx | 4 +- studio/frontend/src/components/ui/toggle.tsx | 2 +- .../features/chat/artifacts/artifact-card.tsx | 2 +- .../frontend/src/features/chat/chat-page.tsx | 6 +- .../features/chat/chat-providers-dialog.tsx | 8 +- .../src/features/chat/chat-settings-sheet.tsx | 11 +- .../chat/components/context-usage-bar.tsx | 2 +- .../components/openai-code-exec-section.tsx | 16 +- .../chat/components/project-switcher.tsx | 2 +- .../src/features/chat/projects-page.tsx | 2 +- .../prompt-storage/prompt-storage-dialog.tsx | 30 +- .../data-recipes/pages/data-recipes-page.tsx | 169 +-- .../export/components/method-picker.tsx | 3 +- .../export/components/quant-picker.tsx | 3 +- .../src/features/export/export-page.tsx | 446 ++++---- .../features/hub/catalog/download-card.tsx | 2 +- .../hub/catalog/gguf-download-card.tsx | 4 +- .../features/hub/catalog/hub-section-row.tsx | 2 +- .../src/features/hub/catalog/model-card.tsx | 4 +- .../features/hub/catalog/model-inspector.tsx | 2 +- .../hub/catalog/models-catalog-rows.tsx | 8 +- .../src/features/hub/catalog/models-table.tsx | 8 +- .../hub/components/hf-token-indicator.tsx | 6 +- .../features/hub/components/page-heading.tsx | 2 +- .../src/features/hub/download-manager/api.ts | 4 +- .../download-manager/download-api-adapter.ts | 2 +- .../download-manager-panel.tsx | 6 +- studio/frontend/src/features/hub/hub.css | 13 + .../components/steps/dataset-step.tsx | 4 +- .../components/steps/hyperparameters-step.tsx | 8 +- .../components/steps/model-type-step.tsx | 8 +- .../components/steps/summary-step.tsx | 8 +- .../onboarding/components/wizard-layout.tsx | 32 +- .../profile-personalization-panel.tsx | 100 +- .../profile/components/user-avatar.tsx | 2 +- .../profile/hooks/use-personalization-sync.ts | 122 ++- .../profile/stores/user-profile-store.ts | 4 + .../features/profile/utils/avatar-initials.ts | 22 +- .../recipe-studio/components/chip-input.tsx | 2 +- .../executions/execution-sidebar.tsx | 2 +- .../components/recipe-graph-node.tsx | 2 +- .../components/rf-ui/base-node.tsx | 5 +- .../runtime/execution-progress-island.tsx | 52 +- .../models/local-recipe-model-selector.tsx | 3 +- .../dialogs/models/model-provider-dialog.tsx | 4 +- .../recipe-studio/dialogs/preview-dialog.tsx | 11 +- .../dialogs/seed/unstructured-drop-zone.tsx | 2 +- .../dialogs/shared/available-variables.tsx | 18 +- .../shared/collapsible-section-trigger.tsx | 6 +- .../features/settings/api/personalization.ts | 22 +- .../components/api-monitor-console.tsx | 2 +- .../components/appearance-custom-controls.tsx | 984 ++++++++++++++++++ .../settings/components/color-picker.tsx | 270 +++++ .../settings/components/create-key-form.tsx | 2 +- .../settings/components/key-reveal-card.tsx | 4 +- .../components/model-auto-switch-section.tsx | 6 +- .../settings/components/palette-cards.tsx | 147 +++ .../settings/components/settings-row.tsx | 1 + .../settings/components/settings-section.tsx | 11 +- .../components/sidebar-menu-customizer.tsx | 148 +++ .../settings/components/usage-examples.tsx | 20 +- .../frontend/src/features/settings/index.ts | 23 +- .../src/features/settings/settings-dialog.tsx | 202 +++- .../src/features/settings/settings-search.ts | 113 ++ .../stores/appearance-custom-store.ts | 569 ++++++++++ .../features/settings/stores/theme-store.ts | 160 ++- .../features/settings/tabs/appearance-tab.tsx | 141 ++- .../src/features/settings/tabs/chat-tab.tsx | 19 +- .../features/settings/tabs/general-tab.tsx | 39 +- .../features/settings/tabs/profile-tab.tsx | 10 +- .../features/settings/tabs/resources-tab.tsx | 2 +- .../studio/sections/dataset-section.tsx | 802 +++++++------- .../studio/sections/params-section.tsx | 468 +++++---- .../studio/sections/progress-section.tsx | 4 +- .../studio/sections/training-section.tsx | 2 +- .../studio/training-start-overlay.tsx | 2 +- .../features/tour/components/guided-tour.tsx | 12 +- .../features/tour/components/read-more.tsx | 2 +- .../features/tour/lib/confetti-fireworks.ts | 11 +- studio/frontend/src/i18n/locales/en.ts | 110 +- studio/frontend/src/i18n/locales/ja.ts | 106 +- studio/frontend/src/i18n/locales/pt-br.ts | 108 +- studio/frontend/src/i18n/locales/zh-CN.ts | 105 +- studio/frontend/src/index.css | 578 ++++++++-- studio/frontend/src/lib/chevron-icons.ts | 14 + tests/studio/test_frontend_dep_removal.py | 12 +- 128 files changed, 6231 insertions(+), 1525 deletions(-) create mode 100644 studio/frontend/public/theme-boot.js create mode 100644 studio/frontend/src/features/settings/components/appearance-custom-controls.tsx create mode 100644 studio/frontend/src/features/settings/components/color-picker.tsx create mode 100644 studio/frontend/src/features/settings/components/palette-cards.tsx create mode 100644 studio/frontend/src/features/settings/components/sidebar-menu-customizer.tsx create mode 100644 studio/frontend/src/features/settings/settings-search.ts create mode 100644 studio/frontend/src/features/settings/stores/appearance-custom-store.ts diff --git a/studio/backend/routes/settings.py b/studio/backend/routes/settings.py index bbee374334..3fa9df0dde 100644 --- a/studio/backend/routes/settings.py +++ b/studio/backend/routes/settings.py @@ -1,6 +1,7 @@ # 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 re from typing import Literal, Optional from urllib.parse import unquote, urlsplit @@ -551,6 +552,7 @@ class PersonalizationProfile(BaseModel): nickname: str = Field("", max_length = 200) avatarDataUrl: Optional[str] = Field(None, max_length = MAX_AVATAR_DATA_URL_BYTES) avatarShape: Literal["circle", "rounded"] = "circle" + showGreetingSloth: bool = True @field_validator("avatarDataUrl") @classmethod @@ -562,11 +564,180 @@ class PersonalizationProfile(BaseModel): return value +class PersonalizationCustomColors(BaseModel): + model_config = ConfigDict(extra = "ignore") + + accent: Optional[str] = Field(None, pattern = r"^#[0-9a-fA-F]{6}$") + background: Optional[str] = Field(None, pattern = r"^#[0-9a-fA-F]{6}$") + foreground: Optional[str] = Field(None, pattern = r"^#[0-9a-fA-F]{6}$") + + +class PersonalizationCustomColorModes(BaseModel): + model_config = ConfigDict(extra = "ignore") + + light: PersonalizationCustomColors = Field(default_factory = PersonalizationCustomColors) + dark: PersonalizationCustomColors = Field(default_factory = PersonalizationCustomColors) + + +MAX_IMPORTED_FONTS = 3 +# ~1.5 MB font file as base64; matches MAX_IMPORTED_FONT_DATA_URL_LENGTH in +# the frontend appearance-custom-store. +MAX_FONT_DATA_URL_LENGTH = 2_200_000 +# Aggregate cap across all imported fonts; matches +# MAX_TOTAL_IMPORTED_FONT_DATA_URL_LENGTH in the frontend so a synced payload +# always fits the browser's localStorage quota. +MAX_TOTAL_FONT_DATA_URL_LENGTH = 4_400_000 + +# Characters that could terminate a CSS declaration, escape the quoted +# font-family value (backslash), or smuggle extra fallbacks/comments (comma, +# slash) if a stored name ever reached a stylesheet. The server is the +# authoritative gate; the frontend strips the same set before use. +_FONT_NAME_FORBIDDEN = set(";{}()<>\"'\\/,`") + + +def _check_font_name(value: str) -> str: + if any(c in _FONT_NAME_FORBIDDEN or ord(c) < 0x20 for c in value): + raise ValueError("Font name contains invalid characters.") + return value + + +# Matches FONT_DATA_URL_PATTERN in the frontend appearance-custom-store. +_FONT_DATA_URL_PATTERN = re.compile( + r"^data:(?:font/(?:woff2?|ttf|otf|sfnt)" + r"|application/(?:octet-stream|x-font-\w+|font-\w+));base64,[A-Za-z0-9+/=]+$" +) + + +class PersonalizationImportedFont(BaseModel): + model_config = ConfigDict(extra = "ignore") + + name: str = Field(..., min_length = 1, max_length = 100) + dataUrl: str = Field(..., max_length = MAX_FONT_DATA_URL_LENGTH) + + @field_validator("name") + @classmethod + def _validate_font_name(cls, value: str) -> str: + return _check_font_name(value) + + @field_validator("dataUrl") + @classmethod + def _validate_font_data_url(cls, value: str) -> str: + # fullmatch, not match: re's ``$`` also matches just before a trailing + # newline, so ``match`` would accept "data:font/woff2;base64,AAAA\n", + # which the frontend's JS pattern (``$`` = end of string) rejects. + if not _FONT_DATA_URL_PATTERN.fullmatch(value): + raise ValueError("dataUrl must be a base64 font data URL.") + return value + + +# Optional user-menu items; the boolean is each id's default visibility. +# Settings-tab shortcuts ship hidden. +SIDEBAR_MENU_ITEM_DEFAULTS = { + "api": True, + "darkMode": True, + "guidedTour": True, + "profile": False, + "appearance": False, + "resources": False, + "chat": False, + "connections": False, +} + +# The sidebarMenu validator below dedupes ids and re-fills any missing ones, so +# the stored list is always exactly one entry per id. Cap the *incoming* list at +# a generous multiple rather than len(defaults): a stale or duplicated payload +# (more items than distinct ids) must reach the validator so it can normalize, +# instead of being rejected by the length constraint before dedupe runs. A +# pathologically long list is still refused. +MAX_SIDEBAR_MENU_INPUT_ITEMS = 4 * len(SIDEBAR_MENU_ITEM_DEFAULTS) + + +class PersonalizationSidebarMenuItem(BaseModel): + model_config = ConfigDict(extra = "ignore") + + id: Literal[ + "api", + "darkMode", + "guidedTour", + "profile", + "appearance", + "resources", + "chat", + "connections", + ] + visible: bool = True + + +def _default_sidebar_menu() -> "list[PersonalizationSidebarMenuItem]": + return [ + PersonalizationSidebarMenuItem(id = item_id, visible = visible) + for item_id, visible in SIDEBAR_MENU_ITEM_DEFAULTS.items() + ] + + +class PersonalizationCustomization(BaseModel): + model_config = ConfigDict(extra = "ignore") + + colors: PersonalizationCustomColorModes = Field(default_factory = PersonalizationCustomColorModes) + uiFont: Optional[str] = Field(None, max_length = 200) + headingFont: Optional[str] = Field(None, max_length = 200) + chatFont: Optional[str] = Field(None, max_length = 200) + codeFont: Optional[str] = Field(None, max_length = 200) + importedFonts: list[PersonalizationImportedFont] = Field( + default_factory = list, max_length = MAX_IMPORTED_FONTS + ) + + @field_validator("importedFonts") + @classmethod + def _validate_total_font_size( + cls, value: list[PersonalizationImportedFont] + ) -> list[PersonalizationImportedFont]: + if sum(len(f.dataUrl) for f in value) > MAX_TOTAL_FONT_DATA_URL_LENGTH: + raise ValueError("Imported fonts exceed the total size limit.") + return value + + @field_validator("uiFont", "headingFont", "chatFont", "codeFont") + @classmethod + def _validate_selected_fonts(cls, value: Optional[str]) -> Optional[str]: + # Selected font names reach CSS the same way imported names do. + return value if value is None else _check_font_name(value) + + uiFontSize: Optional[int] = Field(None, ge = 12, le = 20) + codeFontSize: Optional[int] = Field(None, ge = 10, le = 20) + contrast: int = Field(50, ge = 0, le = 100) + pointerCursors: bool = False + reduceMotion: Literal["system", "on", "off"] = "system" + fontSmoothing: bool = True + edgeFades: bool = True + sidebarMenu: list[PersonalizationSidebarMenuItem] = Field( + default_factory = _default_sidebar_menu, + max_length = MAX_SIDEBAR_MENU_INPUT_ITEMS, + ) + + @field_validator("sidebarMenu") + @classmethod + def _validate_sidebar_menu( + cls, value: list[PersonalizationSidebarMenuItem] + ) -> list[PersonalizationSidebarMenuItem]: + # Drop duplicate ids (keep the first) and re-append any missing ids so + # the stored list always covers every optional menu item exactly once. + seen: set[str] = set() + items = [item for item in value if not (item.id in seen or seen.add(item.id))] + for item_id, visible in SIDEBAR_MENU_ITEM_DEFAULTS.items(): + if item_id not in seen: + items.append(PersonalizationSidebarMenuItem(id = item_id, visible = visible)) + return items + + class PersonalizationAppearance(BaseModel): model_config = ConfigDict(extra = "ignore") theme: Literal["light", "dark", "system"] = "system" + palette: Literal["standard", "classic", "minimal"] = "standard" language: Optional[str] = Field(None, max_length = 20) + customization: PersonalizationCustomization = Field( + default_factory = PersonalizationCustomization + ) class PersonalizationPayload(BaseModel): @@ -579,6 +750,11 @@ class PersonalizationPayload(BaseModel): class PersonalizationResponse(PersonalizationPayload): saved: bool = False + # False when the stored record predates a field, so the client keeps local + # overrides instead of treating a server-filled default as an explicit value. + customizationSaved: bool = False + paletteSaved: bool = False + greetingSlothSaved: bool = False @router.get("/personalization", response_model = PersonalizationResponse) @@ -588,15 +764,38 @@ def get_personalization_settings( stored = get_personalization() response = PersonalizationResponse.model_validate(stored or {}) response.saved = bool(stored) + appearance = stored.get("appearance") if isinstance(stored, dict) else None + profile = stored.get("profile") if isinstance(stored, dict) else None + response.customizationSaved = isinstance(appearance, dict) and "customization" in appearance + response.paletteSaved = isinstance(appearance, dict) and "palette" in appearance + response.greetingSlothSaved = isinstance(profile, dict) and "showGreetingSloth" in profile return response +def _merge_personalization(base: dict, overlay: dict) -> dict: + # Recursively overlay only the request's set fields onto the stored record, + # so a stale client that omits newer keys (palette, customization) does not + # materialize their defaults and defeat the *Saved legacy detection. + merged = dict(base) + for key, value in overlay.items(): + existing = merged.get(key) + if isinstance(value, dict) and isinstance(existing, dict): + merged[key] = _merge_personalization(existing, value) + else: + merged[key] = value + return merged + + @router.put("/personalization", response_model = PersonalizationPayload) def update_personalization_settings( payload: PersonalizationPayload, current_subject: str = Depends(get_current_subject) ) -> PersonalizationPayload: try: - set_personalization(payload.model_dump()) + # exclude_unset so absent fields are not persisted as defaults; merge so + # fields the request omits keep whatever the record already stored. + incoming = payload.model_dump(exclude_unset = True) + merged = _merge_personalization(get_personalization(), incoming) + set_personalization(merged) except ValueError as exc: raise log_and_http_error( exc, @@ -605,4 +804,6 @@ def update_personalization_settings( event = "settings.update_personalization_failed", log = logger, ) from exc - return payload + # Return the stored record, not the defaults-filled request, so the response + # matches storage (and the next GET) for fields the client omitted. + return PersonalizationPayload.model_validate(merged) diff --git a/studio/backend/tests/test_personalization_settings.py b/studio/backend/tests/test_personalization_settings.py index c80bfa196b..0b3c20c789 100644 --- a/studio/backend/tests/test_personalization_settings.py +++ b/studio/backend/tests/test_personalization_settings.py @@ -16,15 +16,21 @@ if str(_BACKEND) not in sys.path: import utils.personalization_settings as pers # noqa: E402 from auth.authentication import get_current_subject # noqa: E402 from routes import settings as settings_routes # noqa: E402 -from routes.settings import PersonalizationPayload # noqa: E402 +from routes.settings import ( # noqa: E402 + MAX_SIDEBAR_MENU_INPUT_ITEMS, + PersonalizationPayload, + SIDEBAR_MENU_ITEM_DEFAULTS, +) def test_defaults_fill_missing_fields(): p = PersonalizationPayload.model_validate({}) assert p.version == pers.PERSONALIZATION_VERSION assert p.appearance.theme == "system" + assert p.appearance.palette == "standard" assert p.profile.avatarShape == "circle" assert p.profile.displayName == "" + assert p.profile.showGreetingSloth is True def test_unknown_keys_are_ignored(): @@ -39,6 +45,214 @@ def test_invalid_theme_rejected(): PersonalizationPayload.model_validate({"appearance": {"theme": "neon"}}) +def test_invalid_palette_rejected(): + with pytest.raises(ValidationError): + PersonalizationPayload.model_validate({"appearance": {"palette": "neon"}}) + + +def test_customization_defaults(): + p = PersonalizationPayload.model_validate({}) + c = p.appearance.customization + assert c.contrast == 50 + assert c.reduceMotion == "system" + assert c.fontSmoothing is True + assert c.edgeFades is True + assert c.pointerCursors is False + assert c.colors.light.accent is None + assert c.headingFont is None + assert c.chatFont is None + assert c.uiFontSize is None + assert [(i.id, i.visible) for i in c.sidebarMenu] == [ + ("api", True), + ("darkMode", True), + ("guidedTour", True), + ("profile", False), + ("appearance", False), + ("resources", False), + ("chat", False), + ("connections", False), + ] + + +def test_customization_invalid_values_rejected(): + with pytest.raises(ValidationError): + PersonalizationPayload.model_validate( + {"appearance": {"customization": {"colors": {"light": {"accent": "red"}}}}} + ) + with pytest.raises(ValidationError): + PersonalizationPayload.model_validate({"appearance": {"customization": {"uiFontSize": 99}}}) + with pytest.raises(ValidationError): + PersonalizationPayload.model_validate({"appearance": {"customization": {"contrast": 500}}}) + with pytest.raises(ValidationError): + PersonalizationPayload.model_validate( + {"appearance": {"customization": {"reduceMotion": "sometimes"}}} + ) + with pytest.raises(ValidationError): + PersonalizationPayload.model_validate( + {"appearance": {"customization": {"sidebarMenu": [{"id": "chats"}]}}} + ) + + +def test_customization_sidebar_menu_normalized(): + p = PersonalizationPayload.model_validate( + { + "appearance": { + "customization": { + "sidebarMenu": [ + {"id": "guidedTour", "visible": False}, + {"id": "guidedTour", "visible": True}, + {"id": "api"}, + ] + } + } + } + ) + # Duplicates keep the first entry; missing ids are appended with their + # default visibility. + assert [(i.id, i.visible) for i in p.appearance.customization.sidebarMenu] == [ + ("guidedTour", False), + ("api", True), + ("darkMode", True), + ("profile", False), + ("appearance", False), + ("resources", False), + ("chat", False), + ("connections", False), + ] + + +def _sidebar(items): + return {"appearance": {"customization": {"sidebarMenu": items}}} + + +def test_customization_sidebar_menu_dedupes_oversized_payload(): + # A stale/duplicated payload carries more items than there are distinct ids. + # It must reach the dedupe validator and normalize to exactly one entry per + # id, not be rejected by the length cap before dedupe runs. + ids = list(SIDEBAR_MENU_ITEM_DEFAULTS) + doubled = [{"id": i} for i in ids] + [{"id": i} for i in ids] + assert len(doubled) > len(SIDEBAR_MENU_ITEM_DEFAULTS) + p = PersonalizationPayload.model_validate(_sidebar(doubled)) + result = [i.id for i in p.appearance.customization.sidebarMenu] + assert result == ids + assert len(result) == len(SIDEBAR_MENU_ITEM_DEFAULTS) + + +def test_customization_sidebar_menu_rejects_pathological_length(): + # The generous input cap still refuses an absurdly long list outright. + huge = [{"id": "api"} for _ in range(MAX_SIDEBAR_MENU_INPUT_ITEMS + 1)] + with pytest.raises(ValidationError): + PersonalizationPayload.model_validate(_sidebar(huge)) + + +def test_customization_imported_fonts_validated(): + ok = PersonalizationPayload.model_validate( + { + "appearance": { + "customization": { + "importedFonts": [{"name": "My Font", "dataUrl": "data:font/woff2;base64,AAAA"}] + } + } + } + ) + assert ok.appearance.customization.importedFonts[0].name == "My Font" + with pytest.raises(ValidationError): + PersonalizationPayload.model_validate( + { + "appearance": { + "customization": { + "importedFonts": [ + {"name": "Evil", "dataUrl": "https://example.com/font.woff2"} + ] + } + } + } + ) + with pytest.raises(ValidationError): + PersonalizationPayload.model_validate( + { + "appearance": { + "customization": { + "importedFonts": [ + {"name": f"Font {i}", "dataUrl": "data:font/ttf;base64,AAAA"} + for i in range(4) + ] + } + } + } + ) + + +def _imported(fonts): + return {"appearance": {"customization": {"importedFonts": fonts}}} + + +def test_imported_font_name_rejects_css_characters(): + # Includes backslash (escapes the quoted family), comma/slash (extra + # fallbacks / comment start), and a control character. + for bad in ['Ev"il', "Ev;il", "Ev{il", "Ev