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>
This commit is contained in:
Michael Han 2026-07-14 05:12:08 -07:00 committed by GitHub
commit bc23135996
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
128 changed files with 6217 additions and 1511 deletions

View file

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

View file

@ -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<il", "Ev'il", "Ev\\il", "Ev,il", "Ev/il", "Ev\til"]:
with pytest.raises(ValidationError):
PersonalizationPayload.model_validate(
_imported([{"name": bad, "dataUrl": "data:font/woff2;base64,AAAA"}])
)
def test_selected_font_names_validated():
for field in ("uiFont", "headingFont", "chatFont", "codeFont"):
for bad in ["Ev\\il", "Ev,il", "Ev/il", "Ev;il", "Ev\nil"]:
with pytest.raises(ValidationError):
PersonalizationPayload.model_validate(
{"appearance": {"customization": {field: bad}}}
)
# A normal family name (spaces + digits) is still accepted.
p = PersonalizationPayload.model_validate(
{"appearance": {"customization": {"uiFont": "Source Serif 4"}}}
)
assert p.appearance.customization.uiFont == "Source Serif 4"
def test_imported_font_data_url_must_be_base64():
with pytest.raises(ValidationError):
PersonalizationPayload.model_validate(
_imported([{"name": "F", "dataUrl": "data:font/woff2;base64,?not base64?"}])
)
with pytest.raises(ValidationError):
PersonalizationPayload.model_validate(
_imported([{"name": "F", "dataUrl": "data:application/json;base64,AAAA"}])
)
def test_imported_font_data_url_rejects_newline():
# re's ``$`` also matches just before a trailing newline, so a data URL
# ending in "\n" (or with an embedded newline) must be rejected the same way
# the frontend JS pattern rejects it; otherwise the backend accepts a value
# the client would never have produced.
for bad in [
"data:font/woff2;base64,AAAA\n",
"data:font/woff2;base64,AAAA\nBBBB",
"\ndata:font/woff2;base64,AAAA",
]:
with pytest.raises(ValidationError):
PersonalizationPayload.model_validate(_imported([{"name": "F", "dataUrl": bad}]))
# The same URL without the newline is still accepted.
ok = PersonalizationPayload.model_validate(
_imported([{"name": "F", "dataUrl": "data:font/woff2;base64,AAAA"}])
)
assert ok.appearance.customization.importedFonts[0].dataUrl == "data:font/woff2;base64,AAAA"
def test_imported_fonts_total_size_capped():
big = "data:font/woff2;base64," + "A" * 1_600_000
with pytest.raises(ValidationError):
PersonalizationPayload.model_validate(
_imported([{"name": f"F{i}", "dataUrl": big} for i in range(3)])
)
# Two fit under the aggregate cap.
PersonalizationPayload.model_validate(
_imported([{"name": f"F{i}", "dataUrl": big} for i in range(2)])
)
def test_avatar_must_be_image_data_url():
with pytest.raises(ValidationError):
PersonalizationPayload.model_validate(
@ -132,8 +346,43 @@ def test_personalization_route_roundtrip_real_shape(monkeypatch):
"nickname": "M",
"avatarDataUrl": "/Sloth%20emojis/large%20sloth%20yay.png",
"avatarShape": "rounded",
"showGreetingSloth": False,
},
"appearance": {
"theme": "dark",
"palette": "classic",
"language": "en",
"customization": {
"colors": {
"light": {"accent": "#339cff", "background": None, "foreground": None},
"dark": {"accent": None, "background": "#111111", "foreground": None},
},
"uiFont": "SF Pro Text",
"headingFont": "Avenir Next",
"chatFont": "Georgia",
"codeFont": None,
"importedFonts": [
{"name": "SF Pro Text", "dataUrl": "data:font/woff2;base64,AAAA"}
],
"uiFontSize": 14,
"codeFontSize": 13,
"contrast": 60,
"pointerCursors": True,
"reduceMotion": "off",
"fontSmoothing": True,
"edgeFades": False,
"sidebarMenu": [
{"id": "darkMode", "visible": True},
{"id": "api", "visible": False},
{"id": "guidedTour", "visible": True},
{"id": "profile", "visible": True},
{"id": "appearance", "visible": False},
{"id": "resources", "visible": False},
{"id": "chat", "visible": False},
{"id": "connections", "visible": False},
],
},
},
"appearance": {"theme": "dark", "language": "en"},
}
put = client.put("/api/settings/personalization", json = payload)
assert put.status_code == 200
@ -142,5 +391,109 @@ def test_personalization_route_roundtrip_real_shape(monkeypatch):
assert saved.status_code == 200
body = saved.json()
assert body["saved"] is True
assert body["customizationSaved"] is True
assert body["paletteSaved"] is True
assert body["greetingSlothSaved"] is True
assert body["profile"] == payload["profile"]
assert body["appearance"] == payload["appearance"]
def test_personalization_get_flags_legacy_fields(monkeypatch):
# A record written before these fields existed must report them as unsaved
# so the client keeps local overrides instead of the server-filled defaults.
store: dict = {
pers.PERSONALIZATION_SETTING_KEY: {
"version": 1,
"profile": {"displayName": "Mike"},
"appearance": {"theme": "dark"},
}
}
monkeypatch.setattr("storage.studio_db.get_app_setting", lambda k, d = None: store.get(k, d))
app = FastAPI()
app.dependency_overrides[get_current_subject] = lambda: "unsloth"
app.include_router(settings_routes.router, prefix = "/api/settings")
body = TestClient(app).get("/api/settings/personalization").json()
assert body["saved"] is True
assert body["customizationSaved"] is False
assert body["paletteSaved"] is False
assert body["greetingSlothSaved"] is False
def test_personalization_put_preserves_absent_fields(monkeypatch):
# A stale client that omits palette/customization must not materialize them,
# so the record stays legacy and GET keeps reporting those fields unsaved.
store: dict = {}
monkeypatch.setattr("storage.studio_db.get_app_setting", lambda k, d = None: store.get(k, d))
monkeypatch.setattr("storage.studio_db.upsert_app_settings", lambda d: store.update(d))
app = FastAPI()
app.dependency_overrides[get_current_subject] = lambda: "unsloth"
app.include_router(settings_routes.router, prefix = "/api/settings")
client = TestClient(app)
put = client.put(
"/api/settings/personalization",
json = {
"version": 1,
"profile": {"displayName": "Mike"},
"appearance": {"theme": "dark", "language": "en"},
},
)
assert put.status_code == 200
stored_appearance = store[pers.PERSONALIZATION_SETTING_KEY]["appearance"]
assert "palette" not in stored_appearance
assert "customization" not in stored_appearance
body = client.get("/api/settings/personalization").json()
assert body["saved"] is True
assert body["paletteSaved"] is False
assert body["customizationSaved"] is False
assert body["greetingSlothSaved"] is False
def test_personalization_put_preserves_existing_fields_on_stale_write(monkeypatch):
# A stale client that omits palette/customization must not clobber values a
# newer client already stored; the merge keeps them and only updates theme.
store: dict = {
pers.PERSONALIZATION_SETTING_KEY: {
"version": 1,
"profile": {"displayName": "Mike", "showGreetingSloth": False},
"appearance": {
"theme": "light",
"palette": "classic",
"customization": {"uiFont": "Georgia"},
},
}
}
monkeypatch.setattr("storage.studio_db.get_app_setting", lambda k, d = None: store.get(k, d))
monkeypatch.setattr("storage.studio_db.upsert_app_settings", lambda d: store.update(d))
app = FastAPI()
app.dependency_overrides[get_current_subject] = lambda: "unsloth"
app.include_router(settings_routes.router, prefix = "/api/settings")
client = TestClient(app)
put = client.put(
"/api/settings/personalization",
json = {"version": 1, "profile": {"displayName": "Mike"}, "appearance": {"theme": "dark"}},
)
assert put.status_code == 200
# The PUT response reflects the stored record, not the defaults-filled request:
# a stale write that omits palette/customization still echoes the preserved values.
put_body = put.json()
assert put_body["appearance"]["theme"] == "dark"
assert put_body["appearance"]["palette"] == "classic"
assert put_body["appearance"]["customization"]["uiFont"] == "Georgia"
stored_appearance = store[pers.PERSONALIZATION_SETTING_KEY]["appearance"]
assert stored_appearance["theme"] == "dark"
assert stored_appearance["palette"] == "classic"
assert stored_appearance["customization"]["uiFont"] == "Georgia"
body = client.get("/api/settings/personalization").json()
assert body["paletteSaved"] is True
assert body["customizationSaved"] is True
assert body["greetingSlothSaved"] is True

View file

@ -8,6 +8,9 @@
<link rel="icon" type="image/png" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Unsloth Studio</title>
<!-- Applies the stored theme before the bundle loads; external file
because the backend CSP is script-src 'self'. -->
<script src="/theme-boot.js"></script>
</head>
<body>
<div id="root"></div>

View file

@ -53,7 +53,6 @@
"lucide-react": "^1.7.0",
"mammoth": "^1.11.0",
"motion": "^12.34.0",
"next-themes": "^0.4.6",
"node-forge": "^1.4.0",
"radix-ui": "^1.4.3",
"react": "^19.2.4",
@ -1704,7 +1703,6 @@
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">= 10"
},
@ -1725,7 +1723,6 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">= 10"
},
@ -1746,7 +1743,6 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">= 10"
},
@ -1767,7 +1763,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 10"
},
@ -1788,7 +1783,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 10"
},
@ -1809,7 +1803,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 10"
},
@ -1830,7 +1823,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 10"
},
@ -1851,7 +1843,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 10"
},
@ -1872,7 +1863,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 10"
},
@ -1893,7 +1883,6 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">= 10"
},
@ -1914,7 +1903,6 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">= 10"
},
@ -12570,16 +12558,6 @@
"node": ">= 0.6"
}
},
"node_modules/next-themes": {
"version": "0.4.6",
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
"integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",

View file

@ -62,7 +62,6 @@
"lucide-react": "^1.7.0",
"mammoth": "^1.11.0",
"motion": "^12.34.0",
"next-themes": "^0.4.6",
"node-forge": "^1.4.0",
"radix-ui": "^1.4.3",
"react": "^19.2.4",

View file

@ -0,0 +1,27 @@
// 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) {}

View file

@ -1,33 +1,38 @@
// 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 { LlamaUpdateBanner } from "@/components/llama-update-banner";
import { StartupScreen } from "@/components/tauri/startup-screen";
import { UpdateBanner } from "@/components/tauri/update-banner";
import { UpdateScreen } from "@/components/tauri/update-screen";
import {
WindowTitlebar,
shouldUseNativeMacWindowTitlebar,
shouldUseCustomWindowTitlebar,
shouldUseNativeMacWindowTitlebar,
} from "@/components/tauri/window-titlebar";
import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import { WebUpdateBanner } from "@/components/web/update-banner";
import { LlamaUpdateBanner } from "@/components/llama-update-banner";
import { DownloadManagerPanel } from "@/features/hub/download-manager";
import { fetchDeviceType } from "@/config/env";
import { getTauriAuthFailure, tauriAutoAuth } from "@/features/auth";
import { DownloadManagerPanel } from "@/features/hub/download-manager";
import { NativeIntentDrain } from "@/features/native-intents/native-intent-drain";
import { useTauriBackend, type BackendStatus } from "@/hooks/use-tauri-backend";
import {
applyCustomizationToDocument,
useAppearanceCustomStore,
useTheme,
} from "@/features/settings";
import { type BackendStatus, useTauriBackend } from "@/hooks/use-tauri-backend";
import { useTauriUpdate } from "@/hooks/use-tauri-update";
import { isTauri } from "@/lib/api-base";
import { fetchDeviceType } from "@/config/env";
import { useRouterState } from "@tanstack/react-router";
import { ThemeProvider } from "next-themes";
import { MotionConfig } from "motion/react";
import {
type CSSProperties,
type ReactNode,
useEffect,
useRef,
useState,
type CSSProperties,
type ReactNode,
} from "react";
interface AppProviderProps {
@ -43,7 +48,9 @@ const SETUP_WINDOW_WIDTH = 760;
const SETUP_WINDOW_HEIGHT = 560;
async function showSetupWindow(isCurrent: WindowLayoutGuard): Promise<void> {
const { getCurrentWindow, LogicalSize } = await import("@tauri-apps/api/window");
const { getCurrentWindow, LogicalSize } = await import(
"@tauri-apps/api/window"
);
if (!isCurrent()) return;
const win = getCurrentWindow();
@ -57,7 +64,9 @@ async function showSetupWindow(isCurrent: WindowLayoutGuard): Promise<void> {
}
async function enforceMinimumWindowSize(
win: Awaited<ReturnType<typeof import("@tauri-apps/api/window")["getCurrentWindow"]>>,
win: Awaited<
ReturnType<typeof import("@tauri-apps/api/window")["getCurrentWindow"]>
>,
LogicalSize: typeof import("@tauri-apps/api/window")["LogicalSize"],
isCurrent: WindowLayoutGuard,
): Promise<void> {
@ -76,10 +85,16 @@ async function enforceMinimumWindowSize(
}
}
async function applyAppWindowLayout(isCurrent: WindowLayoutGuard): Promise<void> {
const { getCurrentWindow, currentMonitor, LogicalSize } = await import("@tauri-apps/api/window");
async function applyAppWindowLayout(
isCurrent: WindowLayoutGuard,
): Promise<void> {
const { getCurrentWindow, currentMonitor, LogicalSize } = await import(
"@tauri-apps/api/window"
);
const { invoke } = await import("@tauri-apps/api/core");
const { restoreStateCurrent, StateFlags } = await import("@tauri-apps/plugin-window-state");
const { restoreStateCurrent, StateFlags } = await import(
"@tauri-apps/plugin-window-state"
);
if (!isCurrent()) return;
const win = getCurrentWindow();
@ -123,7 +138,10 @@ async function applyAppWindowLayout(isCurrent: WindowLayoutGuard): Promise<void>
if (!isCurrent()) return;
// Apply constraints after restore/show: doing so before plugin restore can emit
// a Resized event and overwrite the plugin's cached saved size.
await win.setSizeConstraints({ minWidth: MIN_WINDOW_WIDTH, minHeight: MIN_WINDOW_HEIGHT });
await win.setSizeConstraints({
minWidth: MIN_WINDOW_WIDTH,
minHeight: MIN_WINDOW_HEIGHT,
});
if (!isCurrent()) return;
await enforceMinimumWindowSize(win, LogicalSize, isCurrent);
}
@ -252,9 +270,18 @@ const CUSTOM_CHROME_STYLE = {
function TauriWrapper({ children }: { children: ReactNode }) {
const pathname = useRouterState({ select: (s) => s.location.pathname });
const {
status, logs, error, isExternalServer,
currentStepIndex, progressDetail, elevationPackages,
startInstall, retry, retryInstall, approveElevation, copyDiagnostics,
status,
logs,
error,
isExternalServer,
currentStepIndex,
progressDetail,
elevationPackages,
startInstall,
retry,
retryInstall,
approveElevation,
copyDiagnostics,
} = useTauriBackend();
const appliedWindowModeRef = useRef<TauriWindowMode | null>(null);
@ -288,14 +315,18 @@ function TauriWrapper({ children }: { children: ReactNode }) {
const layoutGeneration = windowLayoutGenerationRef.current + 1;
windowLayoutGenerationRef.current = layoutGeneration;
const isCurrent = () => windowLayoutGenerationRef.current === layoutGeneration;
const applyWindowMode = nextMode === "setup" ? showSetupWindow : applyAppWindowLayout;
const isCurrent = () =>
windowLayoutGenerationRef.current === layoutGeneration;
const applyWindowMode =
nextMode === "setup" ? showSetupWindow : applyAppWindowLayout;
applyWindowMode(isCurrent).catch(async () => {
if (!isCurrent()) return;
// On failure, at minimum make the window visible and resizable so user can fix manually.
try {
await showWindowFallback();
} catch { /* swallow — window may still be functional */ }
} catch {
/* swallow; window may still be functional */
}
});
}, [status]);
@ -325,7 +356,9 @@ function TauriWrapper({ children }: { children: ReactNode }) {
}
});
return () => { disposed = true; };
return () => {
disposed = true;
};
}, [status, desktopAuthRetry]);
useEffect(() => {
@ -370,7 +403,9 @@ function TauriWrapper({ children }: { children: ReactNode }) {
positioned={false}
enabled={showInteractiveApp && !hidesTitlebarSidebar}
/>
{showInteractiveApp ? <DownloadManagerPanel positioned={false} /> : null}
{showInteractiveApp ? (
<DownloadManagerPanel positioned={false} />
) : null}
</TauriUpdateLayer>
{showInteractiveApp ? <NativeIntentDrain /> : null}
{showInteractiveApp ? children : null}
@ -378,7 +413,10 @@ function TauriWrapper({ children }: { children: ReactNode }) {
<div className="pointer-events-none fixed inset-x-0 bottom-5 z-[9999] flex justify-center px-4">
<div className="absolute inset-x-4 bottom-16 mx-auto flex max-w-[520px] flex-col items-center gap-2 rounded-2xl border border-border/70 bg-background/95 px-6 py-5 text-center shadow-xl">
<div className="font-medium text-sm">Preparing Studio</div>
<div className="text-muted-foreground text-xs">The local backend is ready. Signing in to your desktop session before loading chats.</div>
<div className="text-muted-foreground text-xs">
The local backend is ready. Signing in to your desktop session
before loading chats.
</div>
</div>
<div className="rounded-full border border-border/70 bg-background/95 px-4 py-2 text-xs text-muted-foreground shadow-lg">
Signing in to desktop session...
@ -416,9 +454,9 @@ function TauriWrapper({ children }: { children: ReactNode }) {
}
style={MAC_NATIVE_CHROME_STYLE}
>
{(!showApp || hidesTitlebarSidebar) ? (
{!showApp || hidesTitlebarSidebar ? (
<div
data-tauri-drag-region
data-tauri-drag-region={true}
aria-hidden="true"
className="pointer-events-auto fixed inset-x-0 top-0 z-50 h-[var(--studio-mac-titlebar-height,34px)] select-none"
/>
@ -428,13 +466,10 @@ function TauriWrapper({ children }: { children: ReactNode }) {
);
}
return (
<>{content}</>
);
return <>{content}</>;
}
const showSidebarSurface =
showApp && !hidesTitlebarSidebar;
const showSidebarSurface = showApp && !hidesTitlebarSidebar;
return (
<div
@ -442,20 +477,40 @@ function TauriWrapper({ children }: { children: ReactNode }) {
style={CUSTOM_CHROME_STYLE}
>
<WindowTitlebar showSidebarSurface={showSidebarSurface} />
<div className="h-full min-h-0 overflow-hidden">
{content}
</div>
<div className="h-full min-h-0 overflow-hidden">{content}</div>
</div>
);
}
/**
* Mirrors the appearance customization store onto <html> (inline CSS vars,
* classes, attributes). Colors are per resolved light/dark mode, so re-apply
* whenever either the customization or the resolved theme changes.
*/
function AppearanceCustomizationEffect() {
const { resolved } = useTheme();
const customization = useAppearanceCustomStore((s) => s.customization);
useEffect(() => {
applyCustomizationToDocument(customization, resolved);
}, [customization, resolved]);
return null;
}
const REDUCED_MOTION_MAP = {
system: "user",
on: "always",
off: "never",
} as const;
export function AppProvider({ children }: AppProviderProps) {
const reduceMotion = useAppearanceCustomStore(
(s) => s.customization.reduceMotion,
);
return (
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<MotionConfig reducedMotion={REDUCED_MOTION_MAP[reduceMotion]}>
<TooltipProvider>
<TauriWrapper>
{children}
</TauriWrapper>
<AppearanceCustomizationEffect />
<TauriWrapper>{children}</TauriWrapper>
<Toaster
position="top-right"
visibleToasts={2}
@ -465,6 +520,6 @@ export function AppProvider({ children }: AppProviderProps) {
offset={{ top: 12, right: 64 }}
/>
</TooltipProvider>
</ThemeProvider>
</MotionConfig>
);
}

View file

@ -56,6 +56,8 @@ import {
ArrowRight02Icon,
BadgeInfoIcon,
ChefHatIcon,
CloudIcon,
CpuIcon,
CursorInfo02Icon,
DashboardCircleIcon,
Delete02Icon,
@ -68,7 +70,9 @@ import {
Globe02Icon,
HelpCircleIcon,
Logout05Icon,
Message01Icon,
MoreVerticalIcon,
PaintBrush02Icon,
Search01Icon,
PinIcon,
PinOffIcon,
@ -79,6 +83,7 @@ import {
Settings02Icon,
Sun03Icon,
TestTube01Icon,
UserIcon,
ZapIcon,
} from "@hugeicons/core-free-icons";
import {
@ -109,7 +114,10 @@ import {
type ProjectRecord,
type SidebarItem,
} from "@/features/chat";
import { useSettingsDialogStore } from "@/features/settings";
import {
useAppearanceCustomStore,
useSettingsDialogStore,
} from "@/features/settings";
import { useEffectiveProfile, UserAvatar } from "@/features/profile";
import { fetchDeviceType, usePlatformStore } from "@/config/env";
import { clearAuthTokens, logout } from "@/features/auth";
@ -162,6 +170,19 @@ function getTourId(pathname: string): string | null {
return null;
}
// Optional user-menu shortcuts that jump straight to a settings tab; the id
// doubles as the settings dialog tab id.
const SETTINGS_TAB_MENU_ITEMS: Record<
"profile" | "appearance" | "resources" | "chat" | "connections",
{ icon: typeof ZapIcon; labelKey: TranslationKey }
> = {
profile: { icon: UserIcon, labelKey: "settings.tabs.profile" },
appearance: { icon: PaintBrush02Icon, labelKey: "settings.tabs.appearance" },
resources: { icon: CpuIcon, labelKey: "settings.tabs.resources" },
chat: { icon: Message01Icon, labelKey: "settings.tabs.chat" },
connections: { icon: CloudIcon, labelKey: "settings.tabs.connections" },
};
// TestTube01Icon's last 2 paths are interior bubbles; slice to the first
// 3 (outline + cap + liquid line) to drop them. Original export untouched.
const TestTubeOutlineIcon = TestTube01Icon.slice(
@ -290,6 +311,9 @@ function NavItem({
export function AppSidebar() {
const t = useT();
const { isDark, toggleTheme, anchorRef } = useAnimatedThemeToggle();
const sidebarMenu = useAppearanceCustomStore(
(s) => s.customization.sidebarMenu,
);
const [usesCustomTitlebar] = useState(shouldUseCustomWindowTitlebar);
const [usesNativeMacTitlebar] = useState(shouldUseNativeMacWindowTitlebar);
const { pathname, search } = useRouterState({
@ -1068,7 +1092,7 @@ export function AppSidebar() {
<button
type="button"
onClick={togglePinned}
className="inline-flex h-[33px] w-[32px] cursor-pointer items-center justify-center rounded-[10px] text-nav-icon-idle dark:text-nav-fg-muted transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="inline-flex h-[33px] w-[32px] cursor-pointer items-center justify-center rounded-[10px] text-nav-icon-idle dark:text-nav-fg-muted transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
aria-label={t("shell.aria.closeSidebar")}
>
<HugeiconsIcon icon={LayoutAlignLeftIcon} strokeWidth={1.75} className="size-icon" />
@ -1091,7 +1115,7 @@ export function AppSidebar() {
<button
type="button"
onClick={togglePinned}
className="inline-flex h-[33px] w-[32px] cursor-pointer items-center justify-center rounded-[10px] text-nav-fg transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="inline-flex h-[33px] w-[32px] cursor-pointer items-center justify-center rounded-[10px] text-nav-fg transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
aria-label={t("shell.aria.openSidebar")}
>
<HugeiconsIcon icon={LayoutAlignLeftIcon} strokeWidth={1.75} className="size-icon" />
@ -1486,7 +1510,7 @@ export function AppSidebar() {
.openDialog("about", { scrollTarget: "about-updates" });
closeMobileIfOpen();
}}
className="flex h-[44px] w-full items-center gap-[9px] rounded-[14px] border border-border/60 bg-transparent px-2 py-[3px] text-left transition-colors hover:bg-nav-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring group-data-[collapsible=icon]:mx-auto group-data-[collapsible=icon]:h-[34px] group-data-[collapsible=icon]:w-[34px] group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:gap-0 group-data-[collapsible=icon]:rounded-full group-data-[collapsible=icon]:p-0"
className="flex h-[44px] w-full items-center gap-[9px] rounded-[14px] border border-border/60 bg-transparent px-2 py-[3px] text-left transition-colors hover:bg-nav-surface-hover focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring group-data-[collapsible=icon]:mx-auto group-data-[collapsible=icon]:h-[34px] group-data-[collapsible=icon]:w-[34px] group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:gap-0 group-data-[collapsible=icon]:rounded-full group-data-[collapsible=icon]:p-0"
>
<span
aria-hidden="true"
@ -1568,39 +1592,71 @@ export function AppSidebar() {
<span>{t("shell.navigation.settings")}</span>
<DropdownMenuShortcut>,</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => useSettingsDialogStore.getState().openDialog("api-keys")}
>
<HugeiconsIcon icon={Globe02Icon} strokeWidth={1.75} className="size-[18px]" />
<span>{t("shell.navigation.api")}</span>
</DropdownMenuItem>
<DropdownMenuItem
ref={anchorRef as React.Ref<HTMLDivElement>}
onSelect={(e) => { e.preventDefault(); toggleTheme(); }}
>
{isDark ? <HugeiconsIcon icon={Sun03Icon} strokeWidth={1.75} className="size-icon" /> : <Moon strokeWidth={1.75} className="size-icon" />}
<span>
{isDark
? t("shell.navigation.lightMode")
: t("shell.navigation.darkMode")}
</span>
</DropdownMenuItem>
{getTourId(pathname) && (
<DropdownMenuItem
onSelect={() => {
const tourId = getTourId(pathname);
if (!tourId) return;
window.dispatchEvent(
new CustomEvent(TOUR_OPEN_EVENT, {
detail: { id: tourId },
}),
);
}}
>
<HugeiconsIcon icon={CursorInfo02Icon} strokeWidth={1.75} className="size-icon" />
<span>{t("shell.navigation.guidedTour")}</span>
</DropdownMenuItem>
)}
{/* Optional items follow the order and visibility set in
Appearance settings; Settings above and the block after
the separator are pinned. */}
{sidebarMenu.map((item) => {
if (!item.visible) return null;
if (item.id === "api") {
return (
<DropdownMenuItem
key={item.id}
onSelect={() => useSettingsDialogStore.getState().openDialog("api-keys")}
>
<HugeiconsIcon icon={Globe02Icon} strokeWidth={1.75} className="size-[18px]" />
<span>{t("shell.navigation.api")}</span>
</DropdownMenuItem>
);
}
if (item.id === "darkMode") {
return (
<DropdownMenuItem
key={item.id}
ref={anchorRef as React.Ref<HTMLDivElement>}
onSelect={(e) => { e.preventDefault(); toggleTheme(); }}
>
{isDark ? <HugeiconsIcon icon={Sun03Icon} strokeWidth={1.75} className="size-icon" /> : <Moon strokeWidth={1.75} className="size-icon" />}
<span>
{isDark
? t("shell.navigation.lightMode")
: t("shell.navigation.darkMode")}
</span>
</DropdownMenuItem>
);
}
if (item.id === "guidedTour") {
if (!getTourId(pathname)) return null;
return (
<DropdownMenuItem
key={item.id}
onSelect={() => {
const tourId = getTourId(pathname);
if (!tourId) return;
window.dispatchEvent(
new CustomEvent(TOUR_OPEN_EVENT, {
detail: { id: tourId },
}),
);
}}
>
<HugeiconsIcon icon={CursorInfo02Icon} strokeWidth={1.75} className="size-icon" />
<span>{t("shell.navigation.guidedTour")}</span>
</DropdownMenuItem>
);
}
// Remaining ids are settings tabs shown by their tab name.
const settingsTabId = item.id;
const tab = SETTINGS_TAB_MENU_ITEMS[settingsTabId];
return (
<DropdownMenuItem
key={item.id}
onSelect={() => useSettingsDialogStore.getState().openDialog(settingsTabId)}
>
<HugeiconsIcon icon={tab.icon} strokeWidth={1.75} className="size-icon" />
<span>{t(tab.labelKey)}</span>
</DropdownMenuItem>
);
})}
</DropdownMenuGroup>
<DropdownMenuSeparator className="mx-1! my-2.5! h-0! border-t border-border/70 bg-transparent!" />
<DropdownMenuItem

View file

@ -168,9 +168,9 @@ function ModelSelectorTrigger({
// Suppress the pill's hover background while the eject hit area is
// hovered, so only the dot's own circle reacts.
variant === "outline" &&
"rounded-full border border-border/60 hover:bg-[#ececec] has-[[data-eject-hit]:hover]:!bg-transparent dark:hover:bg-[#2d2e32]",
"rounded-full border border-border/60 hover:bg-accent has-[[data-eject-hit]:hover]:!bg-transparent",
variant === "ghost" &&
"rounded-full hover:bg-[#ececec] has-[[data-eject-hit]:hover]:!bg-transparent dark:hover:bg-[#2d2e32]",
"rounded-full hover:bg-accent has-[[data-eject-hit]:hover]:!bg-transparent",
variant === "muted" &&
"rounded-full bg-muted hover:bg-muted/80 has-[[data-eject-hit]:hover]:!bg-muted",
// More left padding than right; the chevron is pulled close to the

View file

@ -18,8 +18,9 @@ import {
type BrowseFoldersResponse,
browseFolders,
} from "@/features/chat/api/chat-api";
import { ChevronUpStandardIcon } from "@/lib/chevron-icons";
import { cn } from "@/lib/utils";
import { ArrowUp02Icon, Folder02Icon } from "@hugeicons/core-free-icons";
import { Folder02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@ -230,7 +231,7 @@ export function FolderBrowser({
className="flex w-full items-center gap-2 px-6 py-1.5 text-left text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<HugeiconsIcon
icon={ArrowUp02Icon}
icon={ChevronUpStandardIcon}
className="size-3 shrink-0"
/>
<span className="font-mono">..</span>

View file

@ -459,7 +459,7 @@ function ModelRow({
}}
onClick={onClick}
className={cn(
"flex w-full items-center gap-2 rounded-full px-2 py-1.5 text-left text-sm transition-colors hover:bg-[#ececec] focus-visible:bg-[#ececec] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/45 dark:hover:bg-[var(--sidebar-accent)] dark:focus-visible:bg-[var(--sidebar-accent)]",
"flex w-full items-center gap-2 rounded-full px-2 py-1.5 text-left text-sm transition-colors hover:bg-[#ececec] focus-visible:bg-[#ececec] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring dark:hover:bg-[var(--sidebar-accent)] dark:focus-visible:bg-[var(--sidebar-accent)]",
selected && "bg-[#ececec] dark:bg-[var(--sidebar-accent)]",
className,
)}
@ -935,7 +935,7 @@ function GgufVariantExpander({
handleVariantClick(v.quant, v.downloaded, expectedBytes)
}
className={cn(
"flex min-w-0 flex-1 items-center justify-between gap-2 rounded-full px-2 py-1 text-left text-sm transition-colors hover:bg-[#ececec] focus-visible:bg-[#ececec] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/45 dark:hover:bg-[var(--sidebar-accent)] dark:focus-visible:bg-[var(--sidebar-accent)]",
"flex min-w-0 flex-1 items-center justify-between gap-2 rounded-full px-2 py-1 text-left text-sm transition-colors hover:bg-[#ececec] focus-visible:bg-[#ececec] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring dark:hover:bg-[var(--sidebar-accent)] dark:focus-visible:bg-[var(--sidebar-accent)]",
)}
>
<span className="min-w-0 flex-1 truncate font-mono text-xs">

View file

@ -104,7 +104,7 @@ function Source({
variant={variant}
size={size}
className={cn(
"rounded-full cursor-pointer outline-none hover:bg-chat-icon-bg-hover! hover:text-chat-icon-fg-hover! focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"rounded-full cursor-pointer outline-none hover:bg-chat-icon-bg-hover! hover:text-chat-icon-fg-hover! focus-visible:border-ring",
className,
)}
>

View file

@ -1217,7 +1217,7 @@ const ThreadComposerDock: FC<{
<div
aria-hidden={true}
className={cn(
"absolute inset-x-0 bottom-0 bg-gradient-to-t from-background from-[calc(100%_-_28px)] to-[rgb(from_var(--background)_r_g_b/0)]",
"thread-bottom-fade absolute inset-x-0 bottom-0 bg-gradient-to-t from-background from-[calc(100%_-_28px)] to-[rgb(from_var(--background)_r_g_b/0)]",
queueVisible
? "h-32 backdrop-blur-[1px] [mask-image:linear-gradient(to_top,black_0%,black_58%,transparent_100%)]"
: "top-[10px]",
@ -1317,6 +1317,7 @@ const ThreadWelcome: FC<{
const incognito = useChatRuntimeStore((s) => s.incognito);
const displayName = useUserProfileStore((s) => s.displayName);
const nickname = useUserProfileStore((s) => s.nickname);
const showGreetingSloth = useUserProfileStore((s) => s.showGreetingSloth);
const [welcome, setWelcome] = useState<Welcome>(DEFAULT_WELCOME);
useEffect(() => {
@ -1333,10 +1334,12 @@ const ThreadWelcome: FC<{
<div className="aui-thread-welcome-message flex w-full flex-col justify-center gap-9 px-4">
{/* Center the greeting (sloth + title) over the composer. */}
<div className="flex flex-row items-center justify-center gap-[15px]">
<MascotImg
src={currentEmojiSrc}
className="size-[44px] -translate-y-[2px]"
/>
{showGreetingSloth && (
<MascotImg
src={currentEmojiSrc}
className="size-[44px] -translate-y-[2px]"
/>
)}
<h1 className="aui-thread-welcome-message-inner unsloth-welcome-title fade-in slide-in-from-bottom-1 animate-in text-3xl tracking-[-0.02em] duration-200">
{incognito ? "Temporary chat" : welcome.text}
</h1>
@ -3283,7 +3286,7 @@ const PromptQueueStack: FC<{ queueThreadIds: string[] }> = ({
cancelEditing();
}
}}
className="max-h-20 min-h-8 min-w-0 resize-none rounded-md border border-border/45 bg-transparent px-2 py-1.5 text-sm leading-5 text-foreground outline-none transition-colors focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/35"
className="max-h-20 min-h-8 min-w-0 resize-none rounded-md border border-border/45 bg-transparent px-2 py-1.5 text-sm leading-5 text-foreground outline-none transition-colors focus-visible:border-ring"
aria-label={`Edit queued prompt ${visiblePosition}`}
/>
<Button
@ -3637,7 +3640,7 @@ const AssistantMessage: FC = () => {
<textarea
ref={textareaRef}
defaultValue={extractTaggedText(messageContent)}
className="w-full p-3 rounded-xl bg-muted border border-border text-foreground focus:ring-2 focus:ring-primary outline-none overflow-y-auto resize-none font-mono text-sm max-h-[70vh]"
className="w-full p-3 rounded-xl bg-muted border border-border text-foreground focus:ring-1 focus:ring-ring outline-none overflow-y-auto resize-none font-mono text-sm max-h-[70vh]"
autoFocus
onInput={adjustHeight}
onKeyDown={(e) => {

View file

@ -326,7 +326,7 @@ const ImageGenerationToolUIImpl: ToolCallMessagePartComponent = ({
<button
type="button"
className={cn(
"block cursor-zoom-in rounded-2xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
"block cursor-zoom-in rounded-2xl focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring",
imageDimensions ? "size-full" : "max-w-full",
)}
onClick={showPreview}
@ -382,7 +382,7 @@ const ImageGenerationToolUIImpl: ToolCallMessagePartComponent = ({
{promptCanExpand ? (
<button
type="button"
className="mt-2 inline-flex text-xs font-medium text-foreground/80 underline-offset-4 hover:text-foreground hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
className="mt-2 inline-flex text-xs font-medium text-foreground/80 underline-offset-4 hover:text-foreground hover:underline focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
onClick={() =>
setExpandedCaptionPrompt((value) =>
value === captionPrompt ? null : captionPrompt,

View file

@ -22,7 +22,7 @@ function usageIndicatorClass(percent: number): string {
if (percent >= 70) {
return "bg-amber-500";
}
return "bg-primary";
return "bg-control-accent";
}
function usageTextClass(percent: number): string {

View file

@ -17,10 +17,10 @@ interface SectionCardProps {
}
const accentStyles = {
// Brand accent variant; follows the active palette via --control-accent.
emerald: {
border: "ring-emerald-500/20",
iconBox:
"ring-emerald-200 bg-emerald-50 text-emerald-600 dark:ring-emerald-800 dark:bg-emerald-950 dark:text-emerald-400",
border: "ring-control-accent/20",
iconBox: "ring-control-accent/25 bg-control-accent/10 text-control-accent",
},
indigo: {
border: "ring-indigo-500/20",
@ -61,7 +61,7 @@ export function SectionCard({
)}
>
{featured && (
<div className="pointer-events-none absolute inset-x-0 top-0 h-24 bg-gradient-to-b from-emerald-500/[0.04] to-transparent" />
<div className="pointer-events-none absolute inset-x-0 top-0 h-24 bg-gradient-to-b from-control-accent/[0.04] to-transparent" />
)}
{/* Header */}
<div className="flex items-center gap-3">
@ -77,7 +77,7 @@ export function SectionCard({
<div className="flex items-center gap-2 pb-1">
<h3 className="text-sm font-semibold">{title}</h3>
{badge && (
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-semibold text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300">
<span className="rounded-full bg-control-accent/15 px-2 py-0.5 text-[10px] font-semibold text-control-accent">
{badge}
</span>
)}

View file

@ -93,7 +93,7 @@ function WindowControlButton({
title={label}
onClick={onClick}
className={cn(
"relative z-[80] inline-flex size-8 items-center justify-center rounded-[10px] text-muted-foreground/90 transition-colors hover:bg-nav-surface-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
"relative z-[80] inline-flex size-8 items-center justify-center rounded-[10px] text-muted-foreground/90 transition-colors hover:bg-nav-surface-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
className,
)}
>
@ -287,7 +287,7 @@ export function WindowTitlebar({
event.stopPropagation();
togglePinned();
}}
className="inline-flex size-8 shrink-0 items-center justify-center rounded-[10px] text-nav-icon-idle transition-colors hover:bg-nav-surface-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="inline-flex size-8 shrink-0 items-center justify-center rounded-[10px] text-nav-icon-idle transition-colors hover:bg-nav-surface-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<HugeiconsIcon
icon={LayoutAlignLeftIcon}
@ -307,7 +307,7 @@ export function WindowTitlebar({
event.stopPropagation();
togglePinned();
}}
className="inline-flex size-8 items-center justify-center rounded-[10px] transition-colors hover:bg-nav-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="inline-flex size-8 items-center justify-center rounded-[10px] transition-colors hover:bg-nav-surface-hover focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<img
src="/rounded-512.png"

View file

@ -7,7 +7,7 @@ import { Accordion as AccordionPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils";
import { ArrowDown01Icon, ArrowUp01Icon } from "@hugeicons/core-free-icons";
import { ChevronDownStandardIcon, ChevronUpStandardIcon } from "@/lib/chevron-icons";
import { HugeiconsIcon } from "@hugeicons/react";
function Accordion({
@ -56,13 +56,13 @@ function AccordionTrigger({
>
{children}
<HugeiconsIcon
icon={ArrowDown01Icon}
icon={ChevronDownStandardIcon}
strokeWidth={2}
data-slot="accordion-trigger-icon"
className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden"
/>
<HugeiconsIcon
icon={ArrowUp01Icon}
icon={ChevronUpStandardIcon}
strokeWidth={2}
data-slot="accordion-trigger-icon"
className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline"

View file

@ -6,7 +6,7 @@ import { Moon, Sun } from "lucide-react"
import { flushSync } from "react-dom"
import { cn } from "@/lib/utils"
import { setTheme } from "@/features/settings/stores/theme-store"
import { prefersReducedMotion, setTheme } from "@/features/settings"
interface AnimatedThemeTogglerProps extends React.ComponentPropsWithoutRef<"button"> {
duration?: number
@ -15,6 +15,7 @@ interface AnimatedThemeTogglerProps extends React.ComponentPropsWithoutRef<"butt
export function useAnimatedThemeToggle(duration = 400) {
const [isDark, setIsDark] = useState(false)
const anchorRef = useRef<HTMLElement | null>(null)
const inFlightRef = useRef(false)
useEffect(() => {
const updateTheme = () => {
@ -30,45 +31,63 @@ export function useAnimatedThemeToggle(duration = 400) {
}, [])
const toggleTheme = useCallback(async () => {
const anchor = anchorRef.current
// One toggle per animation. Clicks during a slow view transition would
// otherwise queue up and land as invisible back-and-forth flips, so the
// theme looks stuck until an odd number of clicks gets through.
if (inFlightRef.current) return
const applyTheme = () => {
flushSync(() => {
const newTheme = !isDark
setIsDark(newTheme)
setTheme(newTheme ? "dark" : "light")
// Read the live class instead of React state, which can lag the DOM
// while a transition is being captured.
const nextDark = !document.documentElement.classList.contains("dark")
setIsDark(nextDark)
setTheme(nextDark ? "dark" : "light")
})
}
if (!document.startViewTransition) {
// Skip the view transition (its clip-path runs via the Web Animations API,
// which CSS force-reduced-motion cannot reach) when reduced motion is set.
if (!document.startViewTransition || prefersReducedMotion()) {
applyTheme()
return
}
await document.startViewTransition(applyTheme).ready
inFlightRef.current = true
try {
const transition = document.startViewTransition(applyTheme)
await transition.ready
if (anchor) {
const { top, left, width, height } = anchor.getBoundingClientRect()
const x = left + width / 2
const y = top + height / 2
const maxRadius = Math.hypot(
Math.max(left, window.innerWidth - left),
Math.max(top, window.innerHeight - top)
)
document.documentElement.animate(
{
clipPath: [
`circle(0px at ${x}px ${y}px)`,
`circle(${maxRadius}px at ${x}px ${y}px)`,
],
},
{
duration,
easing: "ease-in-out",
pseudoElement: "::view-transition-new(root)",
}
)
const anchor = anchorRef.current
if (anchor) {
const { top, left, width, height } = anchor.getBoundingClientRect()
const x = left + width / 2
const y = top + height / 2
const maxRadius = Math.hypot(
Math.max(left, window.innerWidth - left),
Math.max(top, window.innerHeight - top)
)
document.documentElement.animate(
{
clipPath: [
`circle(0px at ${x}px ${y}px)`,
`circle(${maxRadius}px at ${x}px ${y}px)`,
],
},
{
duration,
easing: "ease-in-out",
pseudoElement: "::view-transition-new(root)",
}
)
}
await transition.finished
} catch {
// A skipped transition still applied the theme.
} finally {
inFlightRef.current = false
}
}, [isDark, duration])
}, [duration])
return { isDark, toggleTheme, anchorRef }
}
@ -78,70 +97,13 @@ export const AnimatedThemeToggler = ({
duration = 400,
...props
}: AnimatedThemeTogglerProps) => {
const [isDark, setIsDark] = useState(false)
const buttonRef = useRef<HTMLButtonElement>(null)
useEffect(() => {
const updateTheme = () => {
setIsDark(document.documentElement.classList.contains("dark"))
}
updateTheme()
const observer = new MutationObserver(updateTheme)
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
})
return () => observer.disconnect()
}, [])
const toggleTheme = useCallback(async () => {
if (!buttonRef.current) return
const apply = () => {
flushSync(() => {
const newTheme = !isDark
setIsDark(newTheme)
setTheme(newTheme ? "dark" : "light")
})
}
if (!document.startViewTransition) {
apply()
return
}
await document.startViewTransition(apply).ready
const { top, left, width, height } =
buttonRef.current.getBoundingClientRect()
const x = left + width / 2
const y = top + height / 2
const maxRadius = Math.hypot(
Math.max(left, window.innerWidth - left),
Math.max(top, window.innerHeight - top)
)
document.documentElement.animate(
{
clipPath: [
`circle(0px at ${x}px ${y}px)`,
`circle(${maxRadius}px at ${x}px ${y}px)`,
],
},
{
duration,
easing: "ease-in-out",
pseudoElement: "::view-transition-new(root)",
}
)
}, [isDark, duration])
const { isDark, toggleTheme, anchorRef } = useAnimatedThemeToggle(duration)
return (
<button
ref={buttonRef}
ref={(node) => {
anchorRef.current = node
}}
onClick={toggleTheme}
className={cn(className)}
{...props}

View file

@ -10,7 +10,7 @@ import type * as React from "react";
import { cn } from "@/lib/utils";
export const badgeVariants = cva(
"h-5 gap-1 rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! inline-flex items-center justify-center w-fit whitespace-nowrap shrink-0 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive overflow-hidden group/badge",
"h-5 gap-1 rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! inline-flex items-center justify-center w-fit whitespace-nowrap shrink-0 [&>svg]:pointer-events-none focus-visible:border-ring aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive overflow-hidden group/badge",
{
variants: {
variant: {

View file

@ -10,14 +10,14 @@ import type * as React from "react";
import { cn } from "@/lib/utils";
export const buttonVariants = cva(
"focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-full border border-transparent text-sm font-medium focus-visible:ring-[3px] aria-invalid:ring-[3px] [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none cursor-pointer",
"focus-visible:border-ring dark:focus-visible:border-ring aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-full border border-transparent text-sm font-medium aria-invalid:ring-[3px] [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none cursor-pointer",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
dark: "bg-foreground text-background hover:bg-foreground/85 dark:bg-foreground dark:text-background",
outline:
"border-border bg-input/30 hover:bg-input/50 hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground",
"border-border bg-background hover:bg-accent/50 dark:border-transparent dark:bg-white/[0.06] dark:hover:bg-white/10 hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:

View file

@ -225,7 +225,7 @@ function CalendarDayButton({
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-foreground relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) [&>span]:text-xs [&>span]:opacity-70",
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring dark:hover:text-foreground relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-1 data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className,
)}

View file

@ -16,7 +16,7 @@ function Checkbox({
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"border-input dark:bg-input/30 data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-checked:border-primary aria-invalid:aria-checked:border-primary aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 flex size-4 items-center justify-center rounded-[6px] border transition-shadow group-has-disabled/field:opacity-50 focus-visible:ring-[3px] aria-invalid:ring-[3px] peer relative shrink-0 outline-none after:absolute after:-inset-x-3 after:-inset-y-2 disabled:cursor-not-allowed disabled:opacity-50",
"border-input dark:bg-input/30 data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-checked:border-primary aria-invalid:aria-checked:border-primary aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 focus-visible:border-ring dark:focus-visible:border-ring aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 flex size-4 items-center justify-center rounded-[6px] border transition-shadow group-has-disabled/field:opacity-50 aria-invalid:ring-[3px] peer relative shrink-0 outline-none after:absolute after:-inset-x-3 after:-inset-y-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}

View file

@ -139,6 +139,7 @@ function ComboboxContent({
alignOffset = 0,
anchor,
container,
onWheel,
...props
}: ComboboxPrimitive.Popup.Props &
Pick<
@ -161,6 +162,24 @@ function ComboboxContent({
<ComboboxPrimitive.Popup
data-slot="combobox-content"
data-chips={!!anchor}
onWheel={(event) => {
onWheel?.(event);
// Dialog scroll locks cancel native wheel scrolling on this
// body-portaled popup, so scroll the list by hand while one is
// active.
if (!document.body.hasAttribute("data-scroll-locked")) return;
const list = event.currentTarget.querySelector<HTMLElement>(
'[data-slot="combobox-list"]',
);
if (!list) return;
const step =
event.deltaMode === 1
? event.deltaY * 24
: event.deltaMode === 2
? event.deltaY * list.clientHeight
: event.deltaY;
list.scrollTop += step;
}}
className={cn(
"bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:bg-input/30 max-h-72 min-w-36 overflow-hidden rounded-xl corner-squircle duration-100 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-9 *:data-[slot=input-group]:border-none *:data-[slot=input-group]:shadow-none group/combobox-content relative pointer-events-auto max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) data-[chips=true]:min-w-(--anchor-width)",
className,
@ -290,7 +309,7 @@ function ComboboxChips({
<ComboboxPrimitive.Chips
data-slot="combobox-chips"
className={cn(
"bg-input/30 border-input focus-within:border-ring focus-within:ring-ring/50 has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:ring-destructive/40 has-aria-invalid:border-destructive dark:has-aria-invalid:border-destructive/50 flex min-h-9 flex-wrap items-center gap-1.5 rounded-4xl border bg-clip-padding px-2.5 py-1.5 text-sm transition-colors focus-within:ring-[3px] has-aria-invalid:ring-[3px] has-data-[slot=combobox-chip]:px-1.5",
"bg-input/30 border-input dark:border-transparent focus-within:border-ring dark:focus-within:border-transparent dark:focus-within:bg-white/[0.09] has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:ring-destructive/40 has-aria-invalid:border-destructive dark:has-aria-invalid:border-destructive/50 flex min-h-9 flex-wrap items-center gap-1.5 rounded-4xl border bg-clip-padding px-2.5 py-1.5 text-sm transition-colors has-aria-invalid:ring-[3px] has-data-[slot=combobox-chip]:px-1.5",
className,
)}
{...props}

View file

@ -75,8 +75,8 @@ function CommandInput({
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div data-slot="command-input-wrapper" className="p-1 pb-0">
<InputGroup className="bg-input/30 h-9">
<div data-slot="command-input-wrapper" className="p-0 pb-1">
<InputGroup className="h-9">
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
@ -164,7 +164,7 @@ function CommandItem({
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-selected:bg-muted data-selected:text-foreground data-selected:*:[svg]:text-foreground relative flex cursor-default items-center gap-2 rounded-lg px-3 py-2 text-sm outline-hidden select-none [&_svg:not([class*='size-'])]:size-4 [[data-slot=dialog-content]_&]:rounded-2xl group/command-item data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"data-selected:bg-accent data-selected:text-accent-foreground data-selected:*:[svg]:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-lg px-3 py-2 text-sm outline-hidden select-none [&_svg:not([class*='size-'])]:size-4 group/command-item data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...props}

View file

@ -53,7 +53,7 @@ export function CopyableErrorChip({
<button
type="button"
className={cn(
"flex max-w-[28rem] min-w-0 cursor-pointer items-center rounded-md text-left text-xs text-destructive transition-colors hover:bg-destructive/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
"flex max-w-[28rem] min-w-0 cursor-pointer items-center rounded-md text-left text-xs text-destructive transition-colors hover:bg-destructive/10 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
className,
)}
>
@ -72,7 +72,7 @@ export function CopyableErrorChip({
onClick={handleCopy}
aria-label={copied ? "Copied" : "Copy error message"}
className={cn(
"inline-flex items-center gap-1 rounded-md border border-border/60 px-2 py-1 text-[11px] text-muted-foreground transition-colors hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
"inline-flex items-center gap-1 rounded-md border border-border/60 px-2 py-1 text-[11px] text-muted-foreground transition-colors hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
copied && "border-emerald-500/40 text-emerald-600 dark:text-emerald-500",
)}
>

View file

@ -107,7 +107,7 @@ function FieldLabel({
<Label
data-slot="field-label"
className={cn(
"has-data-checked:bg-primary/5 has-data-checked:border-primary/50 dark:has-data-checked:bg-primary/10 gap-2 group-data-[disabled=true]/field:opacity-50 has-[>[data-slot=field]]:rounded-xl has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4 group/field-label peer/field-label flex w-fit leading-snug",
"has-data-checked:bg-primary/5 has-data-checked:border-ring-strong dark:has-data-checked:bg-primary/10 gap-2 group-data-[disabled=true]/field:opacity-50 has-[>[data-slot=field]]:rounded-xl has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4 group/field-label peer/field-label flex w-fit leading-snug",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
className,
)}

View file

@ -20,7 +20,7 @@ export function InfoHint({ children }: { children: ReactNode }) {
<button
type="button"
aria-label="More info"
className="inline-flex size-4 shrink-0 cursor-help items-center justify-center rounded-full text-muted-foreground/70 transition-colors hover:text-[#383835] dark:hover:text-[#e8e8e8] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="inline-flex size-4 shrink-0 cursor-help items-center justify-center rounded-full text-muted-foreground/70 transition-colors hover:text-[#383835] dark:hover:text-[#e8e8e8] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<HugeiconsIcon
icon={InformationCircleIcon}

View file

@ -15,7 +15,7 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
data-slot="input-group"
role="group"
className={cn(
"border-input bg-input/30 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 h-9 rounded-4xl border transition-colors has-data-[align=block-end]:rounded-2xl has-data-[align=block-start]:rounded-2xl has-[[data-slot=input-group-control]:focus-visible]:ring-[3px] has-[[data-slot][aria-invalid=true]]:ring-[3px] has-[textarea]:rounded-xl has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5 [[data-slot=combobox-content]_&]:focus-within:border-inherit [[data-slot=combobox-content]_&]:focus-within:ring-0 group/input-group relative flex w-full min-w-0 items-center outline-none has-[>textarea]:h-auto",
"border-border bg-background dark:border-transparent dark:bg-white/[0.06] has-[[data-slot=input-group-control]:focus-visible]:border-ring dark:has-[[data-slot=input-group-control]:focus-visible]:border-transparent dark:has-[[data-slot=input-group-control]:focus-visible]:bg-white/[0.12] has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 h-9 rounded-full border transition-colors has-data-[align=block-end]:rounded-2xl has-data-[align=block-start]:rounded-2xl has-[[data-slot][aria-invalid=true]]:ring-[3px] has-[textarea]:rounded-xl has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5 [[data-slot=combobox-content]_&]:focus-within:border-inherit [[data-slot=combobox-content]_&]:focus-within:ring-0 group/input-group relative flex w-full min-w-0 items-center outline-none has-[>textarea]:h-auto",
className,
)}
{...props}

View file

@ -5,18 +5,142 @@ import type * as React from "react";
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
const BASE_CLASSES =
// White fill with a subtle border,
// fully-rounded pill for single-row controls.
"bg-background border-border dark:border-transparent dark:bg-white/[0.06] focus-visible:border-ring dark:focus-visible:border-transparent dark:focus-visible:bg-white/[0.12] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 h-9 rounded-full border px-3.5 py-1 text-base transition-colors file:h-7 file:text-sm file:font-medium aria-invalid:ring-[3px] md:text-sm file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50";
function stepNumberInput(input: HTMLInputElement, direction: 1 | -1): void {
if (input.disabled || input.readOnly) {
return;
}
const step = input.step === "" ? 1 : Number(input.step) || 1;
const min = input.min === "" ? null : Number(input.min);
const max = input.max === "" ? null : Number(input.max);
// An empty field steps from its placeholder (the effective default).
const current =
input.value === "" ? Number(input.placeholder) : Number(input.value);
let next: number;
if (Number.isFinite(current)) {
// Snap to the step grid (anchored at min, like the native spinner) rather
// than adding step to an off-grid typed value, which would leave a
// step-invalid result. Mirrors HTMLInputElement.stepUp/stepDown.
const base = min ?? 0;
const pos = (current - base) / step;
const rounded = Math.round(pos);
const onGrid = Math.abs(pos - rounded) < 1e-9;
const nextPos = onGrid
? rounded + direction
: direction === 1
? Math.ceil(pos)
: Math.floor(pos);
next = base + nextPos * step;
} else {
// Stepping an empty, non-numeric field starts from the minimum, matching
// the native spinner.
next = min ?? direction * step;
}
if (min !== null) {
next = Math.max(min, next);
}
if (max !== null) {
next = Math.min(max, next);
}
// Trim float noise from fractional steps.
const decimals = (String(step).split(".")[1] ?? "").length;
const value = String(Number(next.toFixed(decimals)));
// Write through the native setter and emit "input" so the React onChange
// of controlled callers fires.
const setter = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
"value",
)?.set;
setter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
}
function StepperArrow({ direction }: { direction: 1 | -1 }) {
return (
<svg
viewBox="0 0 10 6"
aria-hidden="true"
className={cn("h-[5px] w-2", direction === -1 && "rotate-180")}
>
<path d="M5 0.5 9 5.5H1Z" fill="currentColor" />
</svg>
);
}
function StepperButton({ direction }: { direction: 1 | -1 }) {
return (
<button
type="button"
tabIndex={-1}
onMouseDown={(event) => {
// Keep focus on the field itself.
event.preventDefault();
}}
onClick={(event) => {
const input = event.currentTarget
.closest('[data-slot="number-input"]')
?.querySelector("input");
if (input) {
input.focus({ preventScroll: true });
stepNumberInput(input, direction);
}
}}
className="flex h-1/2 w-full cursor-default items-center justify-center text-foreground/55 hover:bg-black/10 active:bg-black/15 dark:text-white/70 dark:hover:bg-white/10 dark:active:bg-white/15"
>
<StepperArrow direction={direction} />
</button>
);
}
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
const field = (
<input
type={type}
data-slot="input"
className={cn(
"bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 h-9 rounded-4xl border px-3 py-1 text-base transition-colors file:h-7 file:text-sm file:font-medium focus-visible:ring-[3px] aria-invalid:ring-[3px] md:text-sm file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
className={cn(BASE_CLASSES, type === "number" && "pr-8", className)}
{...props}
/>
);
if (type !== "number") {
return field;
}
// Number fields swap the native spinner (hidden globally in index.css) for
// a shared grey stepper.
return (
<span
data-slot="number-input"
className={cn(
// The wrapper is now the flex/grid item, so mirror the field's width:
// default to the full width the bare input used, and let an explicit
// w-*/max-w-* from the caller win so the stepper stays on the field edge.
// Also carry React Flow interaction classes (nodrag/nopan/nowheel) so
// the stepper buttons, which live in the wrapper, don't drag the node.
"group/number relative inline-flex items-center w-full min-w-0",
(className ?? "")
.split(/\s+/)
.filter(
(c) =>
/^(?:min-w|max-w|w)-/.test(c) ||
c === "nodrag" ||
c === "nopan" ||
c === "nowheel",
),
)}
>
{field}
<span
aria-hidden="true"
className="absolute top-1/2 right-1.5 flex h-[21px] w-4 -translate-y-1/2 flex-col overflow-hidden rounded-[5px] bg-black/[0.07] group-has-[input:disabled]/number:pointer-events-none group-has-[input:disabled]/number:opacity-40 dark:bg-white/[0.12]"
>
<StepperButton direction={1} />
<StepperButton direction={-1} />
</span>
</span>
);
}
export { Input };

View file

@ -8,7 +8,7 @@ import { NavigationMenu as NavigationMenuPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils";
import { ArrowDown01Icon } from "@hugeicons/core-free-icons";
import { ChevronDownStandardIcon } from "@/lib/chevron-icons";
import { HugeiconsIcon } from "@hugeicons/react";
export function NavigationMenu({
@ -69,7 +69,7 @@ export function NavigationMenuItem({
}
export const navigationMenuTriggerStyle = cva(
"bg-background hover:bg-muted focus:bg-muted data-open:hover:bg-muted data-open:focus:bg-muted data-open:bg-muted/50 focus-visible:ring-ring/50 data-popup-open:bg-muted/50 data-popup-open:hover:bg-muted rounded-2xl px-4.5 py-2.5 text-sm font-medium transition-all focus-visible:ring-[3px] focus-visible:outline-1 disabled:opacity-50 group/navigation-menu-trigger inline-flex h-9 w-max items-center justify-center disabled:pointer-events-none outline-none",
"bg-background hover:bg-muted focus:bg-muted data-open:hover:bg-muted data-open:focus:bg-muted data-open:bg-muted/50 focus-visible:ring-ring data-popup-open:bg-muted/50 data-popup-open:hover:bg-muted rounded-2xl px-4.5 py-2.5 text-sm font-medium transition-all focus-visible:ring-1 focus-visible:outline-1 disabled:opacity-50 group/navigation-menu-trigger inline-flex h-9 w-max items-center justify-center disabled:pointer-events-none outline-none",
);
export function NavigationMenuTrigger({
@ -87,7 +87,7 @@ export function NavigationMenuTrigger({
>
{children}{" "}
<HugeiconsIcon
icon={ArrowDown01Icon}
icon={ChevronDownStandardIcon}
strokeWidth={2}
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-open/navigation-menu-trigger:rotate-180 group-data-popup-open/navigation-menu-trigger:rotate-180"
aria-hidden="true"
@ -148,7 +148,7 @@ export function NavigationMenuLink({
<NavigationMenuPrimitive.Link
data-slot="navigation-menu-link"
className={cn(
"data-[active=true]:focus:bg-muted data-[active=true]:hover:bg-muted data-[active=true]:bg-muted/50 focus-visible:ring-ring/50 hover:bg-muted focus:bg-muted flex items-center gap-1.5 rounded-xl p-3 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
"data-[active=true]:focus:bg-muted data-[active=true]:hover:bg-muted data-[active=true]:bg-muted/50 focus-visible:ring-ring hover:bg-muted focus:bg-muted flex items-center gap-1.5 rounded-xl p-3 text-sm transition-all outline-none focus-visible:ring-1 focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}

View file

@ -6,6 +6,7 @@
import { Popover as PopoverPrimitive } from "radix-ui";
import type * as React from "react";
import { useDialogPortalContainer } from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
function Popover({
@ -24,10 +25,18 @@ function PopoverContent({
className,
align = "center",
sideOffset = 0,
container,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
}: React.ComponentProps<typeof PopoverPrimitive.Content> & {
container?: HTMLElement | null;
}) {
// Inside a modal dialog the body scroll lock swallows wheel events on
// body-portaled content; portal into the dialog instead (like Select).
const dialogContainer = useDialogPortalContainer();
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Portal
container={container ?? dialogContainer ?? undefined}
>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}

View file

@ -27,7 +27,10 @@ function Progress({
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className={cn("bg-primary size-full flex-1 transition-all", indicatorClassName)}
className={cn(
"bg-control-accent size-full flex-1 transition-all",
indicatorClassName,
)}
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>

View file

@ -29,7 +29,7 @@ function RadioGroupItem({
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"border-input text-primary dark:bg-input/30 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 data-checked:bg-primary data-checked:border-primary flex size-4 rounded-full transition-none focus-visible:ring-[3px] aria-invalid:ring-[3px] group/radio-group-item peer relative aspect-square shrink-0 border outline-none after:absolute after:-inset-x-3 after:-inset-y-2 disabled:cursor-not-allowed disabled:opacity-50",
"border-input text-primary dark:bg-input/30 focus-visible:border-ring dark:focus-visible:border-ring aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 data-checked:bg-primary data-checked:border-primary flex size-4 rounded-full transition-none aria-invalid:ring-[3px] group/radio-group-item peer relative aspect-square shrink-0 border outline-none after:absolute after:-inset-x-3 after:-inset-y-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}

View file

@ -39,7 +39,7 @@ function ResizableHandle({
<ResizablePrimitive.Separator
data-slot="resizable-handle"
className={cn(
"group bg-border/80 relative z-10 flex w-px cursor-col-resize items-center justify-center transition-[background-color,box-shadow] duration-150 ease-out after:absolute after:inset-y-0 after:left-1/2 after:w-2 after:-translate-x-1/2 hover:bg-primary/80 hover:shadow-[0_0_16px_rgba(23,184,139,0.55)] active:bg-primary/90 active:shadow-[0_0_18px_rgba(23,184,139,0.7)] focus-visible:bg-primary/80 focus-visible:ring-1 focus-visible:ring-primary/50 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:cursor-row-resize data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-2 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
"group bg-border/80 relative z-10 flex w-px cursor-col-resize items-center justify-center transition-[background-color,box-shadow] duration-150 ease-out after:absolute after:inset-y-0 after:left-1/2 after:w-2 after:-translate-x-1/2 hover:bg-primary/80 hover:shadow-[0_0_16px_rgba(23,184,139,0.55)] active:bg-primary/90 active:shadow-[0_0_18px_rgba(23,184,139,0.7)] focus-visible:bg-primary/80 focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:cursor-row-resize data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-2 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className,
)}
{...props}

View file

@ -21,7 +21,7 @@ function ScrollArea({
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
className="focus-visible:ring-ring size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-1 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>

View file

@ -8,14 +8,12 @@ import type * as React from "react";
import { createContext, useContext, useState } from "react";
import { Tick02Icon } from "@/lib/tick-icon";
import { ChevronDownStandardIcon } from "@/lib/chevron-icons";
import {
ChevronDownStandardIcon,
ChevronUpStandardIcon,
} from "@/lib/chevron-icons";
import { cn } from "@/lib/utils";
import { useDialogPortalContainer } from "@/components/ui/dialog";
import {
ArrowDown01Icon,
ArrowUp01Icon,
UnfoldMoreIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
const SelectOpenContext = createContext(false);
@ -70,7 +68,7 @@ function SelectTrigger({
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
icon?: typeof UnfoldMoreIcon;
icon?: typeof ChevronDownStandardIcon;
iconClassName?: string;
animateRadius?: boolean;
}) {
@ -91,7 +89,7 @@ function SelectTrigger({
: undefined
}
className={cn(
"border-input data-[placeholder]:text-muted-foreground bg-input/30 dark:hover:bg-input/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 gap-1.5 rounded-4xl border px-3 py-2 text-sm transition-colors focus-visible:ring-[3px] aria-invalid:ring-[3px] data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:flex *:data-[slot=select-value]:gap-1.5 [&_svg:not([class*='size-'])]:size-4 flex w-fit items-center justify-between whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center [&_svg]:pointer-events-none [&_svg]:shrink-0 cursor-pointer",
"border-border data-[placeholder]:text-muted-foreground bg-background hover:bg-accent/50 dark:border-transparent dark:bg-white/[0.06] dark:hover:bg-white/10 focus-visible:border-ring dark:focus-visible:border-transparent dark:focus-visible:bg-white/[0.12] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 gap-1.5 rounded-full border px-3.5 py-2 text-sm transition-colors aria-invalid:ring-[3px] data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:flex *:data-[slot=select-value]:gap-1.5 [&_svg:not([class*='size-'])]:size-4 flex w-fit items-center justify-between whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center [&_svg]:pointer-events-none [&_svg]:shrink-0 cursor-pointer",
className,
)}
{...props}
@ -222,7 +220,7 @@ function SelectScrollUpButton({
)}
{...props}
>
<HugeiconsIcon icon={ArrowUp01Icon} strokeWidth={2} />
<HugeiconsIcon icon={ChevronUpStandardIcon} strokeWidth={2} />
</SelectPrimitive.ScrollUpButton>
);
}
@ -240,7 +238,7 @@ function SelectScrollDownButton({
)}
{...props}
>
<HugeiconsIcon icon={ArrowDown01Icon} strokeWidth={2} />
<HugeiconsIcon icon={ChevronDownStandardIcon} strokeWidth={2} />
</SelectPrimitive.ScrollDownButton>
);
}

View file

@ -471,7 +471,7 @@ function SidebarGroupLabel({
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"text-[#94a3b8] dark:text-[#666] ring-sidebar-ring h-auto pt-3 pb-2 px-4 rounded-md text-[10px] font-semibold uppercase tracking-[0em] group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-3 flex shrink-0 items-center outline-hidden [&>svg]:shrink-0",
"text-[#94a3b8] dark:text-[#666] ring-sidebar-ring h-auto pt-3 pb-2 px-4 rounded-md text-[10px] font-semibold uppercase tracking-[0em] group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-1 [&>svg]:size-3 flex shrink-0 items-center outline-hidden [&>svg]:shrink-0",
className
)}
{...props}
@ -491,7 +491,7 @@ function SidebarGroupAction({
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 w-5 rounded-md p-0 focus-visible:ring-2 [&>svg]:size-4 flex aspect-square items-center justify-center outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 md:after:hidden [&>svg]:shrink-0",
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 w-5 rounded-md p-0 focus-visible:ring-1 [&>svg]:size-4 flex aspect-square items-center justify-center outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 md:after:hidden [&>svg]:shrink-0",
className
)}
{...props}
@ -637,7 +637,7 @@ function SidebarMenuAction({
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 aspect-square w-5 rounded-md p-0 peer-data-[size=default]/menu-button:top-2 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 focus-visible:ring-2 [&>svg]:size-4 flex items-center justify-center outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 md:after:hidden [&>svg]:shrink-0",
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 aspect-square w-5 rounded-md p-0 peer-data-[size=default]/menu-button:top-2 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 focus-visible:ring-1 [&>svg]:size-4 flex items-center justify-center outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 md:after:hidden [&>svg]:shrink-0",
showOnHover &&
"peer-data-active/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-open:opacity-100 md:opacity-0",
className
@ -747,7 +747,7 @@ function SidebarMenuSubButton({
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground h-7 gap-2 rounded-md px-2 focus-visible:ring-2 data-[size=md]:text-sm data-[size=sm]:text-xs [&>svg]:size-4 flex min-w-0 -translate-x-px items-center overflow-hidden outline-hidden group-data-[collapsible=icon]:hidden disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:shrink-0",
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground h-7 gap-2 rounded-md px-2 focus-visible:ring-1 data-[size=md]:text-sm data-[size=sm]:text-xs [&>svg]:size-4 flex min-w-0 -translate-x-px items-center overflow-hidden outline-hidden group-data-[collapsible=icon]:hidden disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:shrink-0",
className
)}
{...props}

View file

@ -103,7 +103,7 @@ function Slider({
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="ring-ring/50 relative z-10 size-4 rounded-4xl bg-white shadow-sm block shrink-0 select-none cursor-pointer disabled:pointer-events-none disabled:opacity-50 transition-transform duration-100 ease-out hover:scale-110 hover:ring-4 active:scale-95 focus-visible:ring-4 focus-visible:outline-hidden"
className="ring-ring/50 relative z-10 size-4 rounded-4xl bg-white shadow-sm block shrink-0 select-none cursor-pointer disabled:pointer-events-none disabled:opacity-50 transition-transform duration-100 ease-out hover:scale-110 hover:ring-4 active:scale-95 focus-visible:ring-1 focus-visible:outline-hidden"
/>
))}
</SliderPrimitive.Root>

View file

@ -9,7 +9,7 @@ import {
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { Spinner } from "@/components/ui/spinner";
import { useTheme } from "next-themes";
import { useTheme } from "@/features/settings/stores/theme-store";
import { Toaster as Sonner, type ToasterProps } from "sonner";
// Make toast text selectable. Sonner's onPointerDown calls setPointerCapture(),
@ -33,9 +33,9 @@ const handleToastPointerDownCapture = (
};
const Toaster = ({ ...props }: ToasterProps) => {
// Use resolvedTheme so sonner's data-sonner-theme always matches the class
// next-themes puts on <html>; sonner-side "system" resolution can drift.
const { resolvedTheme } = useTheme();
// Use the resolved mode so sonner's data-sonner-theme always matches the
// class the theme store puts on <html>.
const { resolved } = useTheme();
return (
// display:contents adds no box; only carries the selection-fix handler.
@ -45,7 +45,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
onPointerDownCapture={handleToastPointerDownCapture}
>
<Sonner
theme={(resolvedTheme as ToasterProps["theme"]) ?? "light"}
theme={resolved}
className="toaster group"
duration={5000}
icons={{

View file

@ -18,14 +18,14 @@ function Switch({
data-slot="switch"
data-size={size}
className={cn(
"data-checked:bg-primary data-unchecked:bg-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 dark:data-unchecked:bg-input/80 shrink-0 rounded-full border border-transparent focus-visible:ring-[3px] aria-invalid:ring-[3px] data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] peer group/switch relative inline-flex items-center transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 data-disabled:cursor-not-allowed data-disabled:opacity-50",
"data-checked:bg-control-accent data-unchecked:bg-input focus-visible:border-ring aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 dark:data-unchecked:bg-input/80 shrink-0 rounded-full border border-transparent aria-invalid:ring-[3px] data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] peer group/switch relative inline-flex items-center transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 data-disabled:cursor-not-allowed data-disabled:opacity-50",
className,
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className="bg-background dark:data-unchecked:bg-foreground dark:data-checked:bg-primary-foreground rounded-full group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 pointer-events-none block ring-0 transition-transform"
className="bg-background dark:data-unchecked:bg-foreground dark:data-checked:bg-control-accent-foreground rounded-full group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 pointer-events-none block ring-0 transition-transform"
/>
</SwitchPrimitive.Root>
);

View file

@ -106,11 +106,13 @@ export function TabsTrigger({
data-slot="tabs-trigger"
value={value}
className={cn(
"gap-1.5 rounded-xl corner-squircle border border-transparent px-2 py-1 text-sm font-medium group-data-vertical/tabs:px-2.5 group-data-vertical/tabs:py-1.5 [&_svg:not([class*='size-'])]:size-4 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center whitespace-nowrap transition-colors group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"gap-1.5 rounded-xl corner-squircle border border-transparent px-2 py-1 text-sm font-medium group-data-vertical/tabs:px-2.5 group-data-vertical/tabs:py-1.5 [&_svg:not([class*='size-'])]:size-4 focus-visible:border-ring dark:focus-visible:border-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center whitespace-nowrap transition-colors group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
// Line variant is a roomier pill (no underline); padding overrides px-2 py-1.
"group-data-[variant=line]/tabs-list:px-3.5 group-data-[variant=line]/tabs-list:py-2.5",
"data-active:text-foreground dark:data-active:text-foreground",
// The sliding pill marks the active tab; no focus ring on top of it.
"data-active:focus-visible:ring-0 data-active:focus-visible:border-transparent dark:data-active:focus-visible:border-transparent data-active:focus-visible:outline-none",
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5",
className,
)}
@ -122,7 +124,7 @@ export function TabsTrigger({
className={cn(
"absolute inset-0",
indicatorClassName ??
"rounded-xl bg-background dark:bg-input/30 dark:border dark:border-input group-data-[variant=line]/tabs-list:bg-[#ececec] dark:group-data-[variant=line]/tabs-list:bg-[#2d2f33] dark:group-data-[variant=line]/tabs-list:border-0",
"rounded-xl bg-background dark:bg-input/30 group-data-[variant=line]/tabs-list:bg-[#ececec] dark:group-data-[variant=line]/tabs-list:bg-[#2d2f33] dark:group-data-[variant=line]/tabs-list:border-0",
)}
transition={{
type: "spring",

View file

@ -18,7 +18,9 @@ function Textarea({
<textarea
data-slot="textarea"
className={cn(
"border-input bg-input/30 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 resize-none rounded-xl border px-3 py-3 text-base transition-colors focus-visible:ring-[3px] aria-invalid:ring-[3px] md:text-sm placeholder:text-muted-foreground flex min-h-16 min-w-0 max-w-full w-full whitespace-pre-wrap break-words [overflow-wrap:anywhere] outline-none disabled:cursor-not-allowed disabled:opacity-50",
// White fill with a subtle border.
// Multi-row control, so rounded-xl rather than a full pill.
"border-border bg-background dark:border-transparent dark:bg-white/[0.06] focus-visible:border-ring dark:focus-visible:border-transparent dark:focus-visible:bg-white/[0.12] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 resize-none rounded-xl border px-3.5 py-3 text-base transition-colors aria-invalid:ring-[3px] md:text-sm placeholder:text-muted-foreground flex min-h-16 min-w-0 max-w-full w-full whitespace-pre-wrap break-words [overflow-wrap:anywhere] outline-none disabled:cursor-not-allowed disabled:opacity-50",
fieldSizing === "content" ? "field-sizing-content" : "[field-sizing:fixed]",
className,
)}

View file

@ -12,7 +12,7 @@ import type * as React from "react";
import { cn } from "@/lib/utils";
export const toggleVariants = cva(
"hover:text-foreground aria-pressed:bg-muted focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive gap-1 rounded-4xl text-sm font-medium transition-colors [&_svg:not([class*='size-'])]:size-4 group/toggle hover:bg-muted inline-flex items-center justify-center whitespace-nowrap outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"hover:text-foreground aria-pressed:bg-muted focus-visible:border-ring aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive gap-1 rounded-4xl text-sm font-medium transition-colors [&_svg:not([class*='size-'])]:size-4 group/toggle hover:bg-muted inline-flex items-center justify-center whitespace-nowrap outline-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {

View file

@ -23,7 +23,7 @@ import {
} from "./types";
const CARD_BASE =
"group/artifact-card relative flex min-h-[52px] cursor-pointer items-center overflow-hidden rounded-lg border border-border/70 bg-muted/15 px-3 py-2 text-left transition-colors hover:bg-muted/25 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 dark:bg-muted/10 dark:hover:bg-muted/20";
"group/artifact-card relative flex min-h-[52px] cursor-pointer items-center overflow-hidden rounded-lg border border-border/70 bg-muted/15 px-3 py-2 text-left transition-colors hover:bg-muted/25 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring dark:bg-muted/10 dark:hover:bg-muted/20";
export function ArtifactCard({
code,

View file

@ -2557,7 +2557,7 @@ export function ChatPage({
{view.mode !== "compare" && (
<div
aria-hidden
className="pointer-events-none absolute left-0 right-[10px] top-[calc(var(--studio-content-top-inset,0px)+var(--studio-chat-header-height,48px))] z-20 h-6 bg-gradient-to-b from-background to-transparent"
className="chat-header-fade pointer-events-none absolute left-0 right-[10px] top-[calc(var(--studio-content-top-inset,0px)+var(--studio-chat-header-height,48px))] z-20 h-6 bg-gradient-to-b from-background to-transparent"
/>
)}
<div
@ -2687,7 +2687,7 @@ export function ChatPage({
type="button"
onClick={toggleIncognito}
className={cn(
"flex size-[var(--studio-chat-control-height,34px)] cursor-pointer items-center justify-center rounded-[12px] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
"flex size-[var(--studio-chat-control-height,34px)] cursor-pointer items-center justify-center rounded-[12px] transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
incognito
? "bg-primary/10 text-primary hover:bg-primary/15"
: "text-nav-fg hover:bg-nav-surface-hover hover:text-black dark:hover:text-white",
@ -2717,7 +2717,7 @@ export function ChatPage({
<button
type="button"
onClick={() => setSettingsOpen(true)}
className="flex size-[var(--studio-chat-control-height,34px)] translate-x-[2px] cursor-pointer items-center justify-center rounded-[12px] text-nav-fg transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="flex size-[var(--studio-chat-control-height,34px)] translate-x-[2px] cursor-pointer items-center justify-center rounded-[12px] text-nav-fg transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
aria-label="Open run settings"
>
<HugeiconsIcon

View file

@ -1137,7 +1137,7 @@ export function ChatProvidersSettings({
<button
type="button"
onClick={() => setShowApiKey((visible) => !visible)}
className="absolute top-1/2 right-1.5 flex size-5 -translate-y-1/2 items-center justify-center rounded text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="absolute top-1/2 right-1.5 flex size-5 -translate-y-1/2 items-center justify-center rounded text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
aria-label={showApiKey ? "Hide API key" : "Show API key"}
aria-pressed={showApiKey}
>
@ -1567,9 +1567,9 @@ export function ChatProvidersSettings({
<button
type="button"
onClick={openAddProvider}
className="group/add flex w-full items-center justify-between gap-3 border-border/60 border-b px-3 py-2.5 text-left text-sm font-medium text-muted-foreground transition-colors hover:bg-muted/35 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/25 focus-visible:ring-inset"
className="group/add flex w-full items-center justify-between gap-3 border-border/60 border-b px-3 py-2.5 text-left text-sm font-medium text-muted-foreground transition-colors hover:bg-muted/35 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-inset"
>
<span className="flex min-w-0 items-center gap-2 rounded-full border border-border bg-background/50 px-3 py-1.5 transition-colors group-hover/add:border-emerald-500/25 group-hover/add:text-emerald-700 dark:group-hover/add:text-emerald-300">
<span className="flex min-w-0 items-center gap-2 rounded-full border border-border bg-background/50 px-3 py-1.5 transition-colors group-hover/add:border-control-accent/25 group-hover/add:text-control-accent">
<HugeiconsIcon icon={PlusSignIcon} className="size-4 shrink-0" />
<span>Add connection</span>
</span>
@ -1616,7 +1616,7 @@ export function ChatProvidersSettings({
<span className="truncate text-sm font-medium text-foreground">
{provider.name}
</span>
<span className="shrink-0 rounded-[6px] border border-emerald-500/15 bg-emerald-500/8 px-1.5 py-0.5 text-[10px] leading-none text-emerald-700 dark:text-emerald-300">
<span className="shrink-0 rounded-[6px] border border-control-accent/15 bg-control-accent/8 px-1.5 py-0.5 text-[10px] leading-none text-control-accent">
{provider.models.length}{" "}
{provider.models.length === 1 ? "model" : "models"}
</span>

View file

@ -39,6 +39,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
InputGroup,
InputGroupAddon,
@ -942,7 +943,7 @@ export function ChatSettingsPanel({
<button
type="button"
onClick={() => onOpenChange?.(false)}
className="flex h-[34px] w-[34px] cursor-pointer items-center justify-center rounded-full text-nav-icon-idle dark:text-nav-fg-muted transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="flex h-[34px] w-[34px] cursor-pointer items-center justify-center rounded-full text-nav-icon-idle dark:text-nav-fg-muted transition-colors hover:bg-nav-surface-hover hover:text-black dark:hover:text-white focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
aria-label="Close run settings"
>
<HugeiconsIcon
@ -1068,7 +1069,7 @@ export function ChatSettingsPanel({
animateRadius={false}
icon={ChevronDownStandardIcon}
iconClassName="size-3.5"
className="grid h-7 w-[64px] min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-1 rounded-full border-transparent bg-black/[0.04] dark:bg-white/[0.05] hover:bg-black/[0.06] dark:hover:bg-white/[0.1] pl-3 pr-2 py-0 text-[13px]! font-medium text-nav-fg focus-visible:ring-0 focus-visible:border-transparent [&_[data-slot=select-value]]:min-w-0 [&_[data-slot=select-value]]:truncate [&>svg]:shrink-0"
className="grid h-7 w-[64px] min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-1 rounded-full border-border bg-background hover:bg-accent/50 dark:border-transparent dark:bg-white/[0.05] dark:hover:bg-white/[0.1] pl-3 pr-2 py-0 text-[13px]! font-medium text-nav-fg focus-visible:ring-0 focus-visible:border-transparent [&_[data-slot=select-value]]:min-w-0 [&_[data-slot=select-value]]:truncate [&>svg]:shrink-0"
>
<SelectValue />
</SelectTrigger>
@ -1111,7 +1112,7 @@ export function ChatSettingsPanel({
animateRadius={false}
icon={ChevronDownStandardIcon}
iconClassName="size-3.5"
className="grid h-7 w-[124px] min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-1 rounded-full border-transparent bg-black/[0.04] dark:bg-white/[0.05] hover:bg-black/[0.06] dark:hover:bg-white/[0.1] pl-3 pr-2 py-0 text-[13px]! font-medium text-nav-fg focus-visible:ring-0 focus-visible:border-transparent [&_[data-slot=select-value]]:min-w-0 [&_[data-slot=select-value]]:truncate [&>svg]:shrink-0"
className="grid h-7 w-[124px] min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-1 rounded-full border-border bg-background hover:bg-accent/50 dark:border-transparent dark:bg-white/[0.05] dark:hover:bg-white/[0.1] pl-3 pr-2 py-0 text-[13px]! font-medium text-nav-fg focus-visible:ring-0 focus-visible:border-transparent [&_[data-slot=select-value]]:min-w-0 [&_[data-slot=select-value]]:truncate [&>svg]:shrink-0"
data-test-id="speculative-type-select"
>
<SelectValue />
@ -1171,7 +1172,7 @@ export function ChatSettingsPanel({
3 on CPU/Mac.
</InfoHint>
</div>
<input
<Input
type="number"
disabled={modelControlsDisabled}
min={1}
@ -1193,7 +1194,7 @@ export function ChatSettingsPanel({
}}
data-test-id="spec-draft-n-max-input"
aria-label="Speculative decoding draft tokens"
className="h-7 w-[76px] rounded-full border-transparent bg-black/[0.04] dark:bg-white/[0.05] hover:bg-black/[0.06] dark:hover:bg-white/[0.1] pl-3 pr-2 py-0 text-[13px] font-medium text-nav-fg outline-none focus-visible:ring-0"
className="h-7 w-[88px] rounded-full border-border bg-background hover:bg-accent/50 dark:border-transparent dark:bg-white/[0.05] dark:hover:bg-white/[0.1] pl-3 py-0 text-[13px] font-medium text-nav-fg outline-none focus-visible:ring-0"
/>
</div>
)}

View file

@ -23,7 +23,7 @@ function getSeverityColor(percent: number): {
} {
if (percent > 85) return { bar: "bg-red-500", text: "text-red-500" };
if (percent > 65) return { bar: "bg-amber-500", text: "text-amber-500" };
return { bar: "bg-emerald-500", text: "text-emerald-500" };
return { bar: "bg-control-accent", text: "text-control-accent" };
}
export const ContextUsageBar: FC<{

View file

@ -60,18 +60,6 @@ const TTL_MAX = 20; // OpenAI hard cap on expires_after.minutes
// minute without hammering /v1/containers.
const REFRESH_POLL_MS = 30_000;
function ageLabel(epochSeconds: number | null | undefined): string {
if (!epochSeconds) return "";
const ageSec = Math.max(0, Math.floor(Date.now() / 1000) - epochSeconds);
if (ageSec < 60) return `${ageSec}s ago`;
const ageMin = Math.floor(ageSec / 60);
if (ageMin < 60) return `${ageMin}m ago`;
const ageHr = Math.floor(ageMin / 60);
if (ageHr < 48) return `${ageHr}h ago`;
const ageDay = Math.floor(ageHr / 24);
return `${ageDay}d ago`;
}
function shortContainerId(id: string): string {
// Mid-truncate keeps the "cntr_" prefix readable and still surfaces the
// tail digits users sometimes copy off OpenAI's dashboard.
@ -463,7 +451,7 @@ export function OpenAICodeExecSection({
max={TTL_MAX}
value={ttlValue}
onChange={(e) => onTtlChange(e.target.value)}
className="h-8 w-14 px-2 text-center text-sm tabular-nums"
className="h-8 w-[72px] pl-3 text-sm tabular-nums"
/>
</div>
@ -509,7 +497,7 @@ export function OpenAICodeExecSection({
key={c.id}
className={`flex items-center gap-2 rounded-md border px-2 py-1.5 text-xs transition-colors ${
isActive
? "border-primary/30 bg-primary/5"
? "border-ring-strong bg-primary/5"
: "border-border/60 hover:bg-muted/40"
} ${canActivate ? "cursor-pointer" : ""} ${
running ? "" : "opacity-60"

View file

@ -49,7 +49,7 @@ export function ProjectSwitcher({
? "Loading project"
: "Pick a project"
}
className="-mx-1 flex h-[34px] shrink-0 items-center gap-2 rounded-full pl-3 pr-2.5 transition-colors hover:bg-[#ececec] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring dark:hover:bg-[#2d2e32]"
className="-mx-1 flex h-[34px] shrink-0 items-center gap-2 rounded-full pl-3 pr-2.5 transition-colors hover:bg-[#ececec] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring dark:hover:bg-accent"
>
<HugeiconsIcon
icon={Folder01Icon}

View file

@ -429,7 +429,7 @@ export function ProjectsPage() {
openProject(project.id);
}
}}
className="group/project-card relative flex min-h-[172px] cursor-pointer flex-col rounded-[26px] bg-card p-6 text-left shadow-[0_2px_12px_-4px_rgba(0,0,0,0.10)] transition-colors duration-150 hover:bg-[#f2f2f2] dark:shadow-none dark:hover:bg-accent/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="group/project-card relative flex min-h-[172px] cursor-pointer flex-col rounded-[26px] bg-card p-6 text-left shadow-[0_2px_12px_-4px_rgba(0,0,0,0.10)] transition-colors duration-150 hover:bg-[#f2f2f2] dark:shadow-none dark:hover:bg-accent/30 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<div className="flex items-start justify-between gap-2">
<span className="flex size-10 shrink-0 items-center justify-center rounded-[14px] bg-muted text-foreground/70 transition-colors group-hover/project-card:bg-primary/10 group-hover/project-card:text-primary">

View file

@ -143,14 +143,6 @@ function exportAllListsCsv(entries: PromptListEntry[]): void {
downloadBlob(`list_name,order,prompt_text\n${rows}`, "prompt-lists.csv", "text/csv");
}
function exportCollectionJsonl(prompts: PromptEntry[], lists: PromptListEntry[]): void {
const lines = [
...prompts.map((e) => JSON.stringify({ type: "prompt", name: e.name, text: e.text })),
...lists.map((e) => JSON.stringify({ type: "prompt_list", name: e.name, items: e.items })),
].join("\n");
downloadBlob(lines, "prompt-collection.jsonl", "application/x-ndjson");
}
function contentBlocksToText(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return JSON.stringify(content);
@ -1129,7 +1121,7 @@ function ExportModal({
className={cn(
"flex w-full cursor-pointer items-center gap-3 rounded-lg border px-4 py-3 transition-all",
scope === "single"
? "border-primary/40 bg-primary/5 ring-1 ring-primary/20"
? "border-ring-strong bg-primary/5"
: "border-border/60 hover:border-border hover:bg-muted/30",
)}
>
@ -1152,7 +1144,7 @@ function ExportModal({
className={cn(
"flex w-full cursor-pointer items-start gap-3 rounded-lg border px-4 py-3 transition-all",
scope === "training"
? "border-primary/40 bg-primary/5 ring-1 ring-primary/20"
? "border-ring-strong bg-primary/5"
: "border-border/60 hover:border-border hover:bg-muted/30",
)}
>
@ -1274,14 +1266,14 @@ function PromptCard({
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Prompt name..."
className="w-full rounded-lg border-0 bg-background/80 px-3 py-2 text-sm ring-1 ring-border/60 outline-none focus:ring-primary/50 transition-shadow"
className="w-full rounded-lg border-0 bg-background/80 px-3 py-2 text-sm ring-1 ring-border/60 outline-none focus:ring-ring transition-shadow"
/>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
rows={4}
placeholder="Prompt text..."
className="w-full resize-y rounded-lg border-0 bg-background/80 px-3 py-2 text-sm ring-1 ring-border/60 outline-none focus:ring-primary/50 transition-shadow leading-relaxed"
className="w-full resize-y rounded-lg border-0 bg-background/80 px-3 py-2 text-sm ring-1 ring-border/60 outline-none focus:ring-ring transition-shadow leading-relaxed"
/>
<div className="flex gap-2 justify-end">
<Button size="sm" variant="ghost" onClick={() => { setName(entry.name); setText(entry.text); setEditing(false); }}>
@ -1385,14 +1377,14 @@ function NewPromptForm({ onClose, onRefresh }: { onClose: () => void; onRefresh:
onChange={(e) => setName(e.target.value)}
placeholder="Prompt name (optional)..."
autoFocus
className="w-full rounded-lg border-0 bg-background/80 px-3 py-2 text-sm ring-1 ring-border/60 outline-none focus:ring-primary/50 transition-shadow"
className="w-full rounded-lg border-0 bg-background/80 px-3 py-2 text-sm ring-1 ring-border/60 outline-none focus:ring-ring transition-shadow"
/>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
rows={4}
placeholder="Write your prompt here..."
className="w-full resize-y rounded-lg border-0 bg-background/80 px-3 py-2 text-sm ring-1 ring-border/60 outline-none focus:ring-primary/50 transition-shadow leading-relaxed"
className="w-full resize-y rounded-lg border-0 bg-background/80 px-3 py-2 text-sm ring-1 ring-border/60 outline-none focus:ring-ring transition-shadow leading-relaxed"
/>
<div className="flex gap-2 justify-end">
<Button size="sm" variant="ghost" onClick={onClose}>
@ -1452,7 +1444,7 @@ function PromptListCard({
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="List name..."
className="w-full rounded-lg border-0 bg-background/80 px-3 py-2 text-sm ring-1 ring-border/60 outline-none focus:ring-primary/50 transition-shadow"
className="w-full rounded-lg border-0 bg-background/80 px-3 py-2 text-sm ring-1 ring-border/60 outline-none focus:ring-ring transition-shadow"
/>
<p className="text-xs font-semibold text-muted-foreground">Prompts (sent in order)</p>
<div className="flex flex-col gap-2">
@ -1465,7 +1457,7 @@ function PromptListCard({
onChange={(e) => updateItem(i, e.target.value)}
rows={2}
placeholder={`Prompt ${i + 1}...`}
className="flex-1 resize-y rounded-lg border-0 bg-background/80 px-3 py-2 text-sm ring-1 ring-border/60 outline-none focus:ring-primary/50 transition-shadow leading-relaxed"
className="flex-1 resize-y rounded-lg border-0 bg-background/80 px-3 py-2 text-sm ring-1 ring-border/60 outline-none focus:ring-ring transition-shadow leading-relaxed"
/>
<button
type="button"
@ -1605,7 +1597,7 @@ function NewPromptListForm({ onClose, onRefresh }: { onClose: () => void; onRefr
onChange={(e) => setName(e.target.value)}
placeholder="List name..."
autoFocus
className="w-full rounded-lg border-0 bg-background/80 px-3 py-2 text-sm ring-1 ring-border/60 outline-none focus:ring-primary/50 transition-shadow"
className="w-full rounded-lg border-0 bg-background/80 px-3 py-2 text-sm ring-1 ring-border/60 outline-none focus:ring-ring transition-shadow"
/>
<p className="text-xs font-semibold text-muted-foreground">
Prompts loaded into the composer one at a time
@ -1619,7 +1611,7 @@ function NewPromptListForm({ onClose, onRefresh }: { onClose: () => void; onRefr
onChange={(e) => updateItem(i, e.target.value)}
rows={2}
placeholder={`Prompt ${i + 1}...`}
className="flex-1 resize-y rounded-lg border-0 bg-background/80 px-3 py-2 text-sm ring-1 ring-border/60 outline-none focus:ring-primary/50 transition-shadow leading-relaxed"
className="flex-1 resize-y rounded-lg border-0 bg-background/80 px-3 py-2 text-sm ring-1 ring-border/60 outline-none focus:ring-ring transition-shadow leading-relaxed"
/>
<button
type="button"
@ -1899,7 +1891,7 @@ export function PromptStorageDialog({
onFocus={() => setShowSuggestions(true)}
onBlur={() => setTimeout(() => setShowSuggestions(false), 150)}
placeholder={`Search ${activeTab === "prompts" ? "prompts by name or text" : "prompt lists by name"}`}
className="w-full rounded-lg border-0 bg-muted/50 pl-9 pr-3 py-2 text-sm outline-none focus:ring-1 focus:ring-primary/50 placeholder:text-muted-foreground/60 transition-shadow"
className="w-full rounded-lg border-0 bg-muted/50 pl-9 pr-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/60 transition-shadow"
/>
{showSuggestions && searchQuery.trim() !== "" && suggestions.length > 0 && (
<div className="absolute top-full left-0 right-0 z-50 mt-1 rounded-xl border border-border/60 bg-popover shadow-lg overflow-hidden">

View file

@ -25,10 +25,10 @@ import {
EmptyTitle,
} from "@/components/ui/empty";
import { ShineBorder } from "@/components/ui/shine-border";
import { ChevronDownStandardIcon } from "@/lib/chevron-icons";
import { toastError } from "@/shared/toast";
import {
Album02Icon,
ArrowDown01Icon,
CodeIcon,
CookBookIcon,
Database02Icon,
@ -415,7 +415,10 @@ export function DataRecipesPage(): ReactElement {
<Button type="button" disabled={isBusy}>
<HugeiconsIcon icon={PlusSignIcon} className="size-4" />
New Recipe
<HugeiconsIcon icon={ArrowDown01Icon} className="size-4" />
<HugeiconsIcon
icon={ChevronDownStandardIcon}
className="size-4"
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
@ -439,7 +442,88 @@ export function DataRecipesPage(): ReactElement {
</DropdownMenu>
</div>
{!ready ? (
{ready ? (
recipes.length === 0 ? (
<Empty className="mt-8 border border-dashed border-border/70 dark:border-none">
<EmptyHeader>
<EmptyMedia variant="icon">
<HugeiconsIcon icon={CookBookIcon} className="size-5" />
</EmptyMedia>
<EmptyTitle>No recipes yet</EmptyTitle>
<EmptyDescription>
Browse Learning Recipes below to understand how recipe
workflows work.
</EmptyDescription>
</EmptyHeader>
<EmptyContent className="max-w-6xl items-stretch">
{/*<Button*/}
{/* type="button"*/}
{/* variant="secondary"*/}
{/* className="mx-auto"*/}
{/* onClick={() => setLearningDialogOpen(true)}*/}
{/* disabled={isBusy}*/}
{/*>*/}
{/* <HugeiconsIcon icon={CookBookIcon} className="size-4" />*/}
{/* Start Tutorial*/}
{/*</Button>*/}
<LearningRecipeCards
onSelect={(template) => {
openLearningRecipe(template).catch(() => undefined);
}}
loadingTemplateId={loadingTemplateId}
/>
</EmptyContent>
</Empty>
) : (
<div className="mt-8 space-y-2">
{recipes.map((recipe) => (
<div
key={recipe.id}
className="flex items-center gap-3 rounded-xl border bg-card px-4 py-3"
>
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-3 text-left"
onClick={() => openRecipe(recipe)}
>
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-border/70 bg-muted/20">
<HugeiconsIcon
icon={CookBookIcon}
className="size-4 text-muted-foreground"
/>
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="truncate text-sm font-medium">
{recipe.name}
</p>
{recipe.learningRecipeId ? (
<Badge variant="outline">Learning Recipe</Badge>
) : null}
</div>
<p className="text-xs text-muted-foreground">
Last updated {formatRelativeTime(recipe.updatedAt)} |
Created {formatRelativeTime(recipe.createdAt)}
</p>
</div>
</button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8"
onClick={() => {
handleDeleteRecipe(recipe.id).catch(() => undefined);
}}
aria-label={`Delete ${recipe.name}`}
>
<HugeiconsIcon icon={Delete02Icon} className="size-4" />
</Button>
</div>
))}
</div>
)
) : (
<div className="mt-8 rounded-2xl border border-border/70 bg-card px-6 py-10 text-center">
<p className="text-sm font-medium text-foreground">
Loading recipes
@ -448,85 +532,6 @@ export function DataRecipesPage(): ReactElement {
Fetching your saved recipes and learning templates.
</p>
</div>
) : recipes.length === 0 ? (
<Empty className="mt-8 border border-dashed border-border/70 dark:border-none">
<EmptyHeader>
<EmptyMedia variant="icon">
<HugeiconsIcon icon={CookBookIcon} className="size-5" />
</EmptyMedia>
<EmptyTitle>No recipes yet</EmptyTitle>
<EmptyDescription>
Browse Learning Recipes below to understand how recipe workflows
work.
</EmptyDescription>
</EmptyHeader>
<EmptyContent className="max-w-6xl items-stretch">
{/*<Button*/}
{/* type="button"*/}
{/* variant="secondary"*/}
{/* className="mx-auto"*/}
{/* onClick={() => setLearningDialogOpen(true)}*/}
{/* disabled={isBusy}*/}
{/*>*/}
{/* <HugeiconsIcon icon={CookBookIcon} className="size-4" />*/}
{/* Start Tutorial*/}
{/*</Button>*/}
<LearningRecipeCards
onSelect={(template) => {
openLearningRecipe(template).catch(() => undefined);
}}
loadingTemplateId={loadingTemplateId}
/>
</EmptyContent>
</Empty>
) : (
<div className="mt-8 space-y-2">
{recipes.map((recipe) => (
<div
key={recipe.id}
className="flex items-center gap-3 rounded-xl border bg-card px-4 py-3"
>
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-3 text-left"
onClick={() => openRecipe(recipe)}
>
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-border/70 bg-muted/20">
<HugeiconsIcon
icon={CookBookIcon}
className="size-4 text-muted-foreground"
/>
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="truncate text-sm font-medium">
{recipe.name}
</p>
{recipe.learningRecipeId ? (
<Badge variant="outline">Learning Recipe</Badge>
) : null}
</div>
<p className="text-xs text-muted-foreground">
Last updated {formatRelativeTime(recipe.updatedAt)} |
Created {formatRelativeTime(recipe.createdAt)}
</p>
</div>
</button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8"
onClick={() => {
handleDeleteRecipe(recipe.id).catch(() => undefined);
}}
aria-label={`Delete ${recipe.name}`}
>
<HugeiconsIcon icon={Delete02Icon} className="size-4" />
</Button>
</div>
))}
</div>
)}
</main>

View file

@ -61,13 +61,14 @@ export function MethodPicker({ value, onChange, disabledMethods = [], disabledRe
key={m.value}
type="button"
disabled={isDisabled}
aria-pressed={selected}
onClick={() => !isDisabled && onChange(m.value)}
className={cn(
"flex items-start gap-3 rounded-xl p-4 text-left ring-1 transition-colors",
isDisabled
? "ring-border opacity-40 cursor-not-allowed"
: selected
? "ring-2 ring-primary bg-primary/5"
? "ring-1 ring-ring-strong bg-primary/5"
: "ring-border hover:bg-muted/40",
)}
>

View file

@ -73,11 +73,12 @@ export function QuantPicker({ value, onChange, sizes }: QuantPickerProps) {
<button
key={q.value}
type="button"
aria-pressed={active}
onClick={() => toggle(q.value)}
className={cn(
"inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium ring-1 transition-colors",
active
? "ring-primary bg-primary/10 text-foreground"
? "ring-ring-strong bg-primary/10 text-foreground"
: "ring-border text-muted-foreground hover:text-foreground hover:ring-foreground/20",
)}
>

View file

@ -2,8 +2,8 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { SectionCard } from "@/components/section-card";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import {
Combobox,
ComboboxContent,
@ -12,6 +12,14 @@ import {
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
InputGroup,
InputGroupAddon,
@ -24,46 +32,37 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Alert,
AlertDescription,
AlertTitle,
} from "@/components/ui/alert";
import { Separator } from "@/components/ui/separator";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
listLocalModels,
type LocalModelInfo,
useTrainingConfigStore,
} from "@/features/training";
import { usePlatformStore } from "@/config/env";
import { useHubModelSearch } from "@/features/hub/hooks/use-hub-model-search";
import { confirmRemoteCodeIfNeeded } from "@/features/security";
import { GuidedTour, useGuidedTourController } from "@/features/tour";
import {
type LocalModelInfo,
listLocalModels,
useTrainingConfigStore,
} from "@/features/training";
import { useDebouncedValue, useHfTokenValidation } from "@/hooks";
import { useHardwareInfo } from "@/hooks/use-hardware-info";
import { ChevronDownStandardIcon } from "@/lib/chevron-icons";
import {
AlertCircleIcon,
ArrowDown01Icon,
FolderSearchIcon,
InformationCircleIcon,
Key01Icon,
PackageIcon,
Search01Icon,
} from "@hugeicons/core-free-icons";
import { useSearch } from "@tanstack/react-router";
import { HugeiconsIcon } from "@hugeicons/react";
import { useSearch } from "@tanstack/react-router";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import type { ModelCheckpoints } from "./api/export-api";
@ -77,22 +76,23 @@ import {
GUIDE_STEPS,
MERGED_FORMATS,
type MergedFormatOption,
mergedFormatPayload,
QUANT_OPTIONS,
buildQuantSizeLabels,
getEstimatedSize,
mergedFormatPayload,
} from "./constants";
import { useHardwareInfo } from "@/hooks/use-hardware-info";
import { usePlatformStore } from "@/config/env";
import { useExportSizeEstimate } from "./hooks/use-export-size-estimate";
import {
isExportPanelActive,
useExportRuntimeStore,
} from "./stores/export-runtime-store";
import { useExportSizeEstimate } from "./hooks/use-export-size-estimate";
import { GuidedTour, useGuidedTourController } from "@/features/tour";
import { exportTourSteps } from "./tour";
const SEARCH_INPUT_REASONS = new Set(["input-change", "input-paste", "input-clear"]);
const SEARCH_INPUT_REASONS = new Set([
"input-change",
"input-paste",
"input-clear",
]);
// GGUF LoRA output float types (Q8_0 first / default). Q8_0 falls back to F16 per tensor for dims
// not divisible by the block size (32); no "auto" - the choice is explicit.
@ -101,7 +101,6 @@ const LORA_GGUF_OUTTYPES = ["q8_0", "f16", "bf16", "f32"] as const;
type SourceTab = "local" | "checkpoint" | "hf";
type SourceMode = "checkpoint" | "model";
function safePathSegment(
value: string | null | undefined,
fallback = "model",
@ -125,7 +124,7 @@ function buildRelativeSaveDirectory(
if (exportMethod === "gguf") {
const rawName =
sourceMode === "checkpoint"
? checkpoint ?? selectedModelIdx ?? sourceBaseModelName
? (checkpoint ?? selectedModelIdx ?? sourceBaseModelName)
: sourceBaseModelName;
return `${safePathSegment(rawName)}-GGUF`;
}
@ -136,7 +135,7 @@ function buildRelativeSaveDirectory(
// Local / HF source (no checkpoint): name from the model id to avoid "model/null".
const rawName =
sourceMode === "checkpoint"
? checkpoint ?? selectedModelIdx ?? sourceBaseModelName
? (checkpoint ?? selectedModelIdx ?? sourceBaseModelName)
: sourceBaseModelName;
return `${safePathSegment(rawName)}-${exportMethod === "lora" ? "adapter" : "merged"}`;
}
@ -154,11 +153,12 @@ function siblingGgufDirectory(sourcePath: string): string | null {
: trimmed.slice(0, slash);
const name = trimmed.slice(slash + 1);
if (!name) return null;
const sep = parent.endsWith("/") || parent.endsWith("\\")
? ""
: trimmed.includes("\\")
? "\\"
: "/";
const sep =
parent.endsWith("/") || parent.endsWith("\\")
? ""
: trimmed.includes("\\")
? "\\"
: "/";
return `${parent}${sep}${name}_gguf`;
}
@ -250,9 +250,7 @@ export function ExportPage() {
);
const toggleFormat = useCallback((value: string) => {
setSelectedFormats((prev) =>
prev.includes(value)
? prev.filter((v) => v !== value)
: [...prev, value],
prev.includes(value) ? prev.filter((v) => v !== value) : [...prev, value],
);
}, []);
// availableFormats already drops NVIDIA-only formats on other hardware, so no pruning needed.
@ -353,7 +351,9 @@ export function ExportPage() {
.catch((error) => {
if (controller.signal.aborted) return;
setLocalModelsError(
error instanceof Error ? error.message : "Failed to load local models",
error instanceof Error
? error.message
: "Failed to load local models",
);
})
.finally(() => {
@ -367,7 +367,7 @@ export function ExportPage() {
const selectedModelData = useMemo(
() =>
selectedModelIdx != null
? models.find((m) => m.name === selectedModelIdx) ?? null
? (models.find((m) => m.name === selectedModelIdx) ?? null)
: null,
[models, selectedModelIdx],
);
@ -390,9 +390,8 @@ export function ExportPage() {
const trainingMethodLabel = selectedModelData?.peft_type
? "LoRA / QLoRA"
: "Full Fine-tune";
const sourceBaseModelName = sourceMode === "model"
? selectedSourceModel ?? "—"
: baseModelName;
const sourceBaseModelName =
sourceMode === "model" ? (selectedSourceModel ?? "—") : baseModelName;
// For a full fine-tune checkpoint the weights live in the checkpoint dir
// itself (its base_model may be a local/custom path that can't be sized), so
@ -405,10 +404,19 @@ export function ExportPage() {
}
}
return sourceBaseModelName;
}, [sourceMode, isAdapter, checkpointsForModel, checkpoint, sourceBaseModelName]);
}, [
sourceMode,
isAdapter,
checkpointsForModel,
checkpoint,
sourceBaseModelName,
]);
// Real (MoE-aware) fp16 size, used to scale the GGUF quant estimates.
const { fp16Bytes } = useExportSizeEstimate(sizeTargetModel, debouncedHfToken);
const { fp16Bytes } = useExportSizeEstimate(
sizeTargetModel,
debouncedHfToken,
);
const quantSizeLabels = useMemo(
() => buildQuantSizeLabels(fp16Bytes),
[fp16Bytes],
@ -521,7 +529,13 @@ export function ExportPage() {
if ((!effectiveIsAdapter || isMacHost) && ggufTarget !== "model") {
setGgufTarget("model");
}
}, [effectiveIsAdapter, effectiveIsQuantized, exportMethod, isMacHost, ggufTarget]);
}, [
effectiveIsAdapter,
effectiveIsQuantized,
exportMethod,
isMacHost,
ggufTarget,
]);
const handleSourceTabChange = useCallback((next: string) => {
if (next === "checkpoint") {
@ -579,7 +593,10 @@ export function ExportPage() {
selectedSourceModel
) {
const localModel = localMetaById.get(selectedSourceModel);
if (localModel && (localModel.source === "models_dir" || localModel.source === "custom")) {
if (
localModel &&
(localModel.source === "models_dir" || localModel.source === "custom")
) {
return siblingGgufDirectory(localModel.path) ?? relative;
}
}
@ -606,7 +623,9 @@ export function ExportPage() {
// Restrict a Hub merged export to a single format; multi-format stays available for local export.
const hubMultiFormat =
destination === "hub" && exportMethod === "merged" && selectedFormats.length > 1;
destination === "hub" &&
exportMethod === "merged" &&
selectedFormats.length > 1;
const canExport = !!(
selectedExportSource &&
@ -657,7 +676,14 @@ export function ExportPage() {
useEffect(() => {
setCustomSaveDirectory(null);
}, [checkpoint, exportMethod, modelSource, selectedModelIdx, selectedSourceModel, sourceMode]);
}, [
checkpoint,
exportMethod,
modelSource,
selectedModelIdx,
selectedSourceModel,
sourceMode,
]);
const handleLocalSourceInputChange = useCallback(
(value: string, eventDetails?: { reason?: string }) => {
@ -677,27 +703,31 @@ export function ExportPage() {
// Assemble the run params from the current form and hand off to the global
// runtime store, which drives load -> export -> cleanup in the background.
const handleStart = useCallback(async () => {
const source = sourceMode === "checkpoint" ? checkpoint : selectedSourceModel;
const source =
sourceMode === "checkpoint" ? checkpoint : selectedSourceModel;
if (!source || !exportMethod) return;
// No supported accelerator (or PyTorch/MLX missing): the backend would reject anyway; don't submit.
if (exportUnsupported) return;
// GGUF with no quant, or merged with no format, would run an unintended/empty export; require
// at least one (mirrors canExport, in case the panel's Start button bypasses the outer one).
if (exportMethod === "gguf" && !ggufAsLora && quantLevels.length === 0) return;
if (exportMethod === "gguf" && !ggufAsLora && quantLevels.length === 0)
return;
if (exportMethod === "merged" && selectedFormats.length === 0) return;
// A Hub merged push writes each format to the repo root; several would collide (mirrors canExport).
if (hubMultiFormat) return;
const selectedCp = sourceMode === "checkpoint"
? checkpointsForModel.find((cp) => cp.display_name === checkpoint)
: null;
const selectedCp =
sourceMode === "checkpoint"
? checkpointsForModel.find((cp) => cp.display_name === checkpoint)
: null;
if (sourceMode === "checkpoint" && !selectedCp) return;
const checkpointPath = selectedCp?.path ?? null;
const pushToHub = destination === "hub";
const repoId = pushToHub && hfUsername && modelName
? `${hfUsername}/${modelName}`
: undefined;
const repoId =
pushToHub && hfUsername && modelName
? `${hfUsername}/${modelName}`
: undefined;
const token = pushToHub && hfToken ? hfToken : undefined;
// The GGUF method with the LoRA target reuses the LoRA-adapter export path.
const effectiveMethod: ExportMethod = ggufAsLora ? "lora" : exportMethod;
@ -705,7 +735,8 @@ export function ExportPage() {
ggufAsLora || (effectiveMethod === "lora" && loraAsGguf && !isMacHost);
const methodLabel = ggufAsLora
? "GGUF LoRA adapter"
: (EXPORT_METHODS.find((m) => m.value === exportMethod)?.title ?? exportMethod);
: (EXPORT_METHODS.find((m) => m.value === exportMethod)?.title ??
exportMethod);
const adapterExport = sourceMode === "checkpoint" && isAdapter;
// Consent gate for an HF source's custom (auto_map) code, run before we hand
@ -936,7 +967,10 @@ export function ExportPage() {
{sourceMode === "checkpoint" ? (
<div className="flex flex-col gap-2 overflow-visible">
<div data-tour="export-training-run" className="flex flex-col gap-2">
<div
data-tour="export-training-run"
className="flex flex-col gap-2"
>
<label className="text-xs font-medium text-muted-foreground">
Training Run
</label>
@ -960,13 +994,12 @@ export function ExportPage() {
? m.name.slice(0, tsMatch.index)
: m.name;
const timeStr = tsMatch
? new Date(Number(tsMatch[1]) * 1000).toLocaleString(
undefined,
{
dateStyle: "medium",
timeStyle: "short",
},
)
? new Date(
Number(tsMatch[1]) * 1000,
).toLocaleString(undefined, {
dateStyle: "medium",
timeStyle: "short",
})
: null;
return (
<SelectItem key={m.name} value={m.name}>
@ -989,7 +1022,10 @@ export function ExportPage() {
</Select>
</div>
<div data-tour="export-checkpoint" className="flex flex-col gap-2">
<div
data-tour="export-checkpoint"
className="flex flex-col gap-2"
>
<label className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
Checkpoint
<Tooltip>
@ -1026,11 +1062,11 @@ export function ExportPage() {
<SelectTrigger className="w-full">
<SelectValue
placeholder={
!selectedModelIdx
? "Select a training run first"
: checkpointsForModel.length === 0
selectedModelIdx
? checkpointsForModel.length === 0
? "No checkpoints found"
: "Select a checkpoint…"
: "Select a training run first"
}
/>
</SelectTrigger>
@ -1064,7 +1100,9 @@ export function ExportPage() {
items={hfResultIds}
filteredItems={hfResultIds}
filter={null}
value={modelInput || selectedSourceModel || null}
value={
modelInput || selectedSourceModel || null
}
onValueChange={handleHfSourceModelSelect}
onInputValueChange={handleHfSourceInputChange}
itemToStringValue={(id) => id}
@ -1083,7 +1121,10 @@ export function ExportPage() {
}}
>
<InputGroupAddon>
<HugeiconsIcon icon={Search01Icon} className="size-4" />
<HugeiconsIcon
icon={Search01Icon}
className="size-4"
/>
</InputGroupAddon>
</ComboboxInput>
<ComboboxContent anchor={hfComboboxAnchorRef}>
@ -1092,11 +1133,17 @@ export function ExportPage() {
<Spinner className="size-4" /> Searching
</div>
) : (
<ComboboxEmpty>No models found</ComboboxEmpty>
<ComboboxEmpty>
No models found
</ComboboxEmpty>
)}
<ComboboxList className="p-1 !max-h-none !overflow-visible">
{(id: string) => (
<ComboboxItem key={id} value={id} className="gap-2">
<ComboboxItem
key={id}
value={id}
className="gap-2"
>
<span className="block min-w-0 flex-1 truncate">
{id}
</span>
@ -1120,7 +1167,10 @@ export function ExportPage() {
</label>
<InputGroup>
<InputGroupAddon>
<HugeiconsIcon icon={Key01Icon} className="size-4" />
<HugeiconsIcon
icon={Key01Icon}
className="size-4"
/>
</InputGroupAddon>
<InputGroupInput
type="password"
@ -1132,7 +1182,9 @@ export function ExportPage() {
/>
</InputGroup>
{isCheckingToken && (
<p className="text-xs text-muted-foreground">Checking token</p>
<p className="text-xs text-muted-foreground">
Checking token
</p>
)}
</div>
</>
@ -1164,15 +1216,24 @@ export function ExportPage() {
: "./models/my-model"
}
className="w-full"
onBlur={() => applyLocalSourceModel(localModelInputRef.current)}
onBlur={() =>
applyLocalSourceModel(
localModelInputRef.current,
)
}
onKeyDown={(event) => {
if (event.key !== "Enter") return;
event.preventDefault();
applyLocalSourceModel(localModelInputRef.current);
applyLocalSourceModel(
localModelInputRef.current,
);
}}
>
<InputGroupAddon>
<HugeiconsIcon icon={FolderSearchIcon} className="size-4" />
<HugeiconsIcon
icon={FolderSearchIcon}
className="size-4"
/>
</InputGroupAddon>
</ComboboxInput>
<ComboboxContent anchor={localComboboxAnchorRef}>
@ -1185,7 +1246,9 @@ export function ExportPage() {
{localModelsError}
</div>
) : (
<ComboboxEmpty>No local models found</ComboboxEmpty>
<ComboboxEmpty>
No local models found
</ComboboxEmpty>
)}
<ComboboxList className="p-1 !max-h-none !overflow-visible">
{(id: string) => {
@ -1197,7 +1260,11 @@ export function ExportPage() {
? "Custom Folders"
: "Local dir";
return (
<ComboboxItem key={id} value={id} className="gap-2">
<ComboboxItem
key={id}
value={id}
className="gap-2"
>
<span className="block min-w-0 flex-1 truncate">
{model?.display_name ?? id}
</span>
@ -1216,7 +1283,9 @@ export function ExportPage() {
Scanning local models...
</p>
) : localModelsError ? (
<p className="text-[10px] text-red-500">{localModelsError}</p>
<p className="text-[10px] text-red-500">
{localModelsError}
</p>
) : (
<p className="text-[10px] text-muted-foreground">
{exportableLocalModels.length > 0
@ -1242,7 +1311,9 @@ export function ExportPage() {
</span>
<div className="grid grid-cols-1 gap-x-6 gap-y-1.5 text-xs sm:grid-cols-2">
<div className="flex justify-between">
<span className="text-muted-foreground">Base Model</span>
<span className="text-muted-foreground">
Base Model
</span>
<span className="font-medium">{baseModelName}</span>
</div>
<div className="flex justify-between">
@ -1252,14 +1323,18 @@ export function ExportPage() {
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Checkpoints</span>
<span className="text-muted-foreground">
Checkpoints
</span>
<span className="font-medium">
{checkpointsForModel.length}
</span>
</div>
{isAdapter && (
<div className="flex justify-between">
<span className="text-muted-foreground">LoRA Rank</span>
<span className="text-muted-foreground">
LoRA Rank
</span>
<span className="font-medium">{loraRank}</span>
</div>
)}
@ -1292,7 +1367,9 @@ export function ExportPage() {
<Alert variant="destructive">
<HugeiconsIcon icon={AlertCircleIcon} className="size-4" />
<AlertTitle>Export unavailable</AlertTitle>
<AlertDescription>{exportUnsupportedMessage}</AlertDescription>
<AlertDescription>
{exportUnsupportedMessage}
</AlertDescription>
</Alert>
)}
@ -1304,18 +1381,18 @@ export function ExportPage() {
? ["merged", "lora", "gguf"]
: !effectiveIsAdapter && effectiveIsQuantized
? ["merged", "lora", "gguf"]
: !effectiveIsAdapter
? ["lora"]
: []
: effectiveIsAdapter
? []
: ["lora"]
}
disabledReason={
exportUnsupported
? exportUnsupportedMessage
: !effectiveIsAdapter && effectiveIsQuantized
? "Pre-quantized (BNB 4-bit) models cannot be exported without LoRA adapters"
: !effectiveIsAdapter
? "LoRA-only export needs a LoRA adapter checkpoint"
: undefined
: effectiveIsAdapter
? undefined
: "LoRA-only export needs a LoRA adapter checkpoint"
}
/>
@ -1445,66 +1522,68 @@ export function ExportPage() {
</div>
)}
{exportMethod === "lora" && effectiveIsAdapter && !exportUnsupported && (
<div className="space-y-3">
<div className="space-y-2">
<div className="text-sm font-medium">Adapter format</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant={!loraAsGguf ? "default" : "outline"}
size="sm"
onClick={() => setLoraAsGguf(false)}
title="Standard PEFT adapter (adapter_model.safetensors)."
>
Adapter (safetensors)
</Button>
<Button
type="button"
variant={loraAsGguf ? "default" : "outline"}
size="sm"
disabled={isMacHost}
onClick={() => setLoraAsGguf(true)}
title={
isMacHost
? "GGUF LoRA export is not available on macOS/MLX. Use the safetensors adapter."
: "llama.cpp GGUF LoRA, loadable with `llama-cli --lora`."
}
>
GGUF adapter
</Button>
{exportMethod === "lora" &&
effectiveIsAdapter &&
!exportUnsupported && (
<div className="space-y-3">
<div className="space-y-2">
<div className="text-sm font-medium">Adapter format</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant={loraAsGguf ? "outline" : "default"}
size="sm"
onClick={() => setLoraAsGguf(false)}
title="Standard PEFT adapter (adapter_model.safetensors)."
>
Adapter (safetensors)
</Button>
<Button
type="button"
variant={loraAsGguf ? "default" : "outline"}
size="sm"
disabled={isMacHost}
onClick={() => setLoraAsGguf(true)}
title={
isMacHost
? "GGUF LoRA export is not available on macOS/MLX. Use the safetensors adapter."
: "llama.cpp GGUF LoRA, loadable with `llama-cli --lora`."
}
>
GGUF adapter
</Button>
</div>
<div className="text-xs text-muted-foreground">
{isMacHost
? "GGUF LoRA is not available on macOS/MLX; exporting the safetensors adapter."
: loraAsGguf
? "Converts the adapter to a GGUF LoRA (llama.cpp `--lora`). The base model stays separate."
: "Standard PEFT adapter files. Pair with the base model at inference."}
</div>
</div>
<div className="text-xs text-muted-foreground">
{isMacHost
? "GGUF LoRA is not available on macOS/MLX; exporting the safetensors adapter."
: loraAsGguf
? "Converts the adapter to a GGUF LoRA (llama.cpp `--lora`). The base model stays separate."
: "Standard PEFT adapter files. Pair with the base model at inference."}
</div>
</div>
{loraAsGguf && (
<div className="space-y-1.5">
<div className="text-sm font-medium">Output type</div>
<Select
value={loraGgufOuttype}
onValueChange={(v) => setLoraGgufOuttype(v)}
>
<SelectTrigger className="w-full sm:w-56">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LORA_GGUF_OUTTYPES.map((t) => (
<SelectItem key={t} value={t}>
{t.toUpperCase()}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
)}
{loraAsGguf && (
<div className="space-y-1.5">
<div className="text-sm font-medium">Output type</div>
<Select
value={loraGgufOuttype}
onValueChange={(v) => setLoraGgufOuttype(v)}
>
<SelectTrigger className="w-full sm:w-56">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LORA_GGUF_OUTTYPES.map((t) => (
<SelectItem key={t} value={t}>
{t.toUpperCase()}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
)}
{exportMethod === "gguf" && !exportUnsupported && (
<div className="space-y-3">
@ -1514,7 +1593,9 @@ export function ExportPage() {
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant={ggufTarget === "model" ? "default" : "outline"}
variant={
ggufTarget === "model" ? "default" : "outline"
}
size="sm"
onClick={() => setGgufTarget("model")}
title="Merge the adapter into the base model, then quantize the full model to GGUF."
@ -1523,7 +1604,9 @@ export function ExportPage() {
</Button>
<Button
type="button"
variant={ggufTarget === "lora" ? "default" : "outline"}
variant={
ggufTarget === "lora" ? "default" : "outline"
}
size="sm"
onClick={() => setGgufTarget("lora")}
title="Export just the adapter as a GGUF LoRA (llama.cpp `--lora`); the base model stays separate."
@ -1598,32 +1681,36 @@ export function ExportPage() {
<Separator />
{showPanel && (
<ExportRunPanel
exportMethod={exportMethod}
quantLevels={quantLevels}
checkpoint={selectedExportSource}
baseModelName={sourceBaseModelName}
isAdapter={sourceMode === "checkpoint" && isAdapter}
destination={destination}
onDestinationChange={setDestination}
saveDirectory={saveDirectory}
defaultSaveDirectory={defaultSaveDirectory}
saveDirectoryOverridden={!!customSaveDirectory}
onSaveDirectoryChange={setCustomSaveDirectory}
hfUsername={hfUsername}
onHfUsernameChange={setHfUsername}
modelName={modelName}
onModelNameChange={setModelName}
hfToken={hfToken}
onHfTokenChange={setHfToken}
privateRepo={privateRepo}
onPrivateRepoChange={setPrivateRepo}
onStart={handleStart}
onClose={handleClosePanel}
/>
)}
<ExportRunPanel
exportMethod={exportMethod}
quantLevels={quantLevels}
checkpoint={selectedExportSource}
baseModelName={sourceBaseModelName}
isAdapter={sourceMode === "checkpoint" && isAdapter}
destination={destination}
onDestinationChange={setDestination}
saveDirectory={saveDirectory}
defaultSaveDirectory={defaultSaveDirectory}
saveDirectoryOverridden={!!customSaveDirectory}
onSaveDirectoryChange={setCustomSaveDirectory}
hfUsername={hfUsername}
onHfUsernameChange={setHfUsername}
modelName={modelName}
onModelNameChange={setModelName}
hfToken={hfToken}
onHfTokenChange={setHfToken}
privateRepo={privateRepo}
onPrivateRepoChange={setPrivateRepo}
onStart={handleStart}
onClose={handleClosePanel}
/>
)}
{showPanel && (
<div ref={panelEndRef} aria-hidden="true" className="h-px w-full" />
<div
ref={panelEndRef}
aria-hidden="true"
className="h-px w-full"
/>
)}
{showPanel && !panelEndVisible && (
<button
@ -1637,7 +1724,10 @@ export function ExportPage() {
aria-label="Scroll to export output"
className="fixed bottom-6 right-6 z-30 flex size-10 items-center justify-center rounded-full border border-border/60 bg-background/90 text-foreground shadow-md backdrop-blur transition-colors hover:bg-muted"
>
<HugeiconsIcon icon={ArrowDown01Icon} className="size-5" />
<HugeiconsIcon
icon={ChevronDownStandardIcon}
className="size-5"
/>
</button>
)}
{!showPanel && (

View file

@ -140,7 +140,7 @@ export function CardUpdateButton({
e.stopPropagation();
onClick();
}}
className="inline-flex h-7 shrink-0 cursor-pointer items-center gap-1.5 rounded-full bg-amber-500/[0.07] pl-2 pr-2.5 text-[12px] font-medium text-amber-800/90 transition-colors duration-150 hover:bg-amber-500/[0.12] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/25 dark:bg-amber-400/[0.08] dark:text-amber-200/85 dark:hover:bg-amber-400/[0.16]"
className="inline-flex h-7 shrink-0 cursor-pointer items-center gap-1.5 rounded-full bg-amber-500/[0.07] pl-2 pr-2.5 text-[12px] font-medium text-amber-800/90 transition-colors duration-150 hover:bg-amber-500/[0.12] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-amber-500/25 dark:bg-amber-400/[0.08] dark:text-amber-200/85 dark:hover:bg-amber-400/[0.16]"
>
<HugeiconsIcon
icon={ArrowReloadHorizontalIcon}

View file

@ -120,7 +120,7 @@ const CHIP_BASE =
const CHIP_DEFAULT =
"border-foreground/15 bg-muted text-foreground/85 dark:border-border/60 dark:bg-white/[0.04] dark:text-foreground/85";
const CHIP_ACTIVE =
"border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300";
"border-control-accent/40 bg-control-accent/10 text-control-accent";
function QuantBadge({
quant,
@ -173,7 +173,7 @@ function QuantBadge({
<span
className={cn(
"inline-flex shrink-0 cursor-help items-center gap-1.5 whitespace-nowrap text-[12.5px] font-medium tracking-tight tabular-nums",
active ? "text-emerald-600 dark:text-emerald-400" : "text-foreground",
active ? "text-control-accent" : "text-foreground",
)}
>
{showFit && (

View file

@ -58,7 +58,7 @@ export const HubSectionRow = memo(function HubSectionRow({
type="button"
onClick={onOpenList}
aria-label={`See all ${title}`}
className="hub-section-title group/section -mx-1 inline-flex cursor-pointer items-center gap-1.5 rounded-md px-1 text-[18px] font-semibold tracking-[-0.02em] text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="hub-section-title group/section -mx-1 inline-flex cursor-pointer items-center gap-1.5 rounded-md px-1 text-[18px] font-semibold tracking-[-0.02em] text-foreground outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
{title}
<HugeiconsIcon

View file

@ -277,7 +277,7 @@ export const ModelCard = memo(function ModelCard({
aria-label={row.repo}
onClick={() => onSelect(row.id)}
style={cardAccentStyle}
className="hub-model-card hub-trending-card group/card flex h-full w-full cursor-pointer flex-col overflow-hidden px-3 py-3.5 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
className="hub-model-card hub-trending-card group/card flex h-full w-full cursor-pointer flex-col overflow-hidden px-3 py-3.5 text-left outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-inset"
>
<div className="flex min-w-0 items-start gap-2.5">
<OwnerAvatar
@ -295,7 +295,7 @@ export const ModelCard = memo(function ModelCard({
{row.owner.toLowerCase() === "unsloth" && (
<span
aria-label="Verified Unsloth"
className="hub-verified-badge size-3.5 shrink-0 text-primary"
className="hub-verified-badge size-3.5 shrink-0 text-verified"
/>
)}
</span>

View file

@ -577,7 +577,7 @@ export const ModelInspector = memo(function ModelInspector({
{model.owner.toLowerCase() === "unsloth" && (
<span
aria-label="Verified Unsloth"
className="hub-verified-badge size-[18px] shrink-0 text-primary"
className="hub-verified-badge size-[18px] shrink-0 text-verified"
/>
)}
</div>

View file

@ -270,7 +270,7 @@ function CatalogRow({
aria-label={label}
onClick={onClick}
className={cn(
"absolute inset-0 cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
"absolute inset-0 cursor-pointer outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-inset",
card ? "rounded-[16px]" : "rounded-[14px]",
)}
/>
@ -515,7 +515,7 @@ export const DiscoverModelRow = memo(function DiscoverModelRow({
{row.owner.toLowerCase() === "unsloth" && (
<span
aria-label="Verified Unsloth"
className="hub-verified-badge size-3.5 shrink-0 text-primary"
className="hub-verified-badge size-3.5 shrink-0 text-verified"
/>
)}
</span>
@ -712,7 +712,7 @@ export const InventoryRow = memo(function InventoryRow({
{subLabel.toLowerCase() === "unsloth" && (
<span
aria-label="Verified Unsloth"
className="hub-verified-badge size-3.5 shrink-0 text-primary"
className="hub-verified-badge size-3.5 shrink-0 text-verified"
/>
)}
</span>
@ -789,7 +789,7 @@ export const InventoryRow = memo(function InventoryRow({
{subLabel.toLowerCase() === "unsloth" && (
<span
aria-label="Verified Unsloth"
className="hub-verified-badge size-3 shrink-0 text-primary"
className="hub-verified-badge size-3 shrink-0 text-verified"
/>
)}
</span>

View file

@ -340,7 +340,7 @@ function VerifiedOwner({ owner }: { owner: string }) {
{owner.toLowerCase() === "unsloth" && (
<span
aria-label="Verified Unsloth"
className="hub-verified-badge size-3.5 shrink-0 text-primary"
className="hub-verified-badge size-3.5 shrink-0 text-verified"
/>
)}
</span>
@ -586,7 +586,7 @@ export const ResultCard = memo(function ResultCard({
type="button"
aria-label={row.repo}
onClick={() => onSelect(row.id)}
className="hub-result-row hub-result-card group/row flex h-full w-full cursor-pointer items-center gap-3.5 px-4 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
className="hub-result-row hub-result-card group/row flex h-full w-full cursor-pointer items-center gap-3.5 px-4 text-left outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-inset"
>
<OwnerAvatar
owner={row.owner}
@ -685,7 +685,7 @@ export const ResultGridRow = memo(function ResultGridRow({
type="button"
aria-label={row.repo}
onClick={() => onSelect(row.id)}
className="absolute inset-0 z-0 cursor-pointer rounded-[inherit] outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
className="absolute inset-0 z-0 cursor-pointer rounded-[inherit] outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-inset"
/>
);
@ -827,7 +827,7 @@ export const ResultSplitRow = memo(function ResultSplitRow({
aria-current={selected || undefined}
data-selected={selected || undefined}
onClick={() => onSelect(row.id)}
className="group/row flex h-full w-full cursor-pointer items-center gap-2.5 rounded-[12px] px-2.5 text-left outline-none transition-colors hover:bg-foreground/[0.04] data-[selected]:bg-foreground/[0.07] focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset dark:hover:bg-white/[0.05] dark:data-[selected]:bg-white/[0.08]"
className="group/row flex h-full w-full cursor-pointer items-center gap-2.5 rounded-[12px] px-2.5 text-left outline-none transition-colors hover:bg-foreground/[0.04] data-[selected]:bg-foreground/[0.07] focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-inset dark:hover:bg-white/[0.05] dark:data-[selected]:bg-white/[0.08]"
>
<OwnerAvatar
owner={row.owner}

View file

@ -50,7 +50,7 @@ export function HfTokenIndicator({ showLabel = false }: HfTokenIndicatorProps =
className={cn(
"inline-flex size-6 items-center justify-center rounded-full transition-colors",
hasToken
? "bg-primary/15 text-primary"
? "bg-verified/15 text-verified"
: "bg-foreground/[0.07] text-muted-foreground dark:bg-white/[0.07]",
)}
>
@ -65,7 +65,7 @@ export function HfTokenIndicator({ showLabel = false }: HfTokenIndicatorProps =
<span
className={cn(
"shrink-0 text-[11px] font-normal tabular-nums",
hasToken ? "text-primary" : "text-muted-foreground/70",
hasToken ? "text-verified" : "text-muted-foreground/70",
)}
>
{hasToken ? "Set" : "Add"}
@ -89,7 +89,7 @@ export function HfTokenIndicator({ showLabel = false }: HfTokenIndicatorProps =
className={cn(
// Solid circle reads optically larger than the flat HTTP/Xet box, so
// keep it 22px to sit within the row rather than bulging above it.
"inline-flex h-[22px] w-[22px] items-center justify-center rounded-full text-[11.5px] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
"inline-flex h-[22px] w-[22px] items-center justify-center rounded-full text-[11.5px] transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
hasToken
? "hub-tag-soft text-muted-foreground hover:text-foreground/80"
: "bg-destructive text-destructive-foreground hover:bg-destructive/90",

View file

@ -29,7 +29,7 @@ export function PageHeading({
onClick={onTitleClick}
className={cn(
TITLE_CLASS,
"cursor-pointer rounded-md text-left outline-none transition-opacity hover:opacity-80 focus-visible:ring-2 focus-visible:ring-ring",
"cursor-pointer rounded-md text-left outline-none transition-opacity hover:opacity-80 focus-visible:ring-1 focus-visible:ring-ring",
)}
>
{title}

View file

@ -19,7 +19,7 @@ function parseErrorText(status: number, body: unknown): string {
if (typeof message === "string" && message) return message;
}
if (status === 405) {
return "Method Not Allowed - the Studio backend did not accept this API method. Restart Studio so the frontend and backend are on the same build.";
return "Method Not Allowed - the Unsloth backend did not accept this API method. Restart Unsloth so the frontend and backend are on the same build.";
}
return `Request failed (${status})`;
}
@ -137,7 +137,7 @@ const DOWNLOAD_TRANSPORT_CAPABILITIES_FALLBACK: DownloadTransportCapabilities =
http: { available: true, reason: null },
xet: {
available: null,
reason: "Couldn't verify Xet support with the Studio backend.",
reason: "Couldn't verify Xet support with the Unsloth backend.",
},
};
let downloadTransportCapabilitiesCache: DownloadTransportCapabilities | null =

View file

@ -184,7 +184,7 @@ export async function effectiveTransportMode(
return preferred;
}
const reason =
capabilities.xet.reason ?? "Studio will use HTTP downloads instead.";
capabilities.xet.reason ?? "Unsloth will use HTTP downloads instead.";
if (lastXetUnavailableWarningReason !== reason) {
lastXetUnavailableWarningReason = reason;
toast.warning("Xet download transport unavailable", {

View file

@ -8,10 +8,10 @@ import {
} from "@/components/ui/tooltip";
import { hasAuthToken, mustChangePassword } from "@/features/auth/session";
import { isTauri } from "@/lib/api-base";
import { ChevronDownStandardIcon } from "@/lib/chevron-icons";
import { cn } from "@/lib/utils";
import {
Alert02Icon,
ArrowDown01Icon,
Cancel01Icon,
CheckmarkCircle02Icon,
Download01Icon,
@ -19,13 +19,13 @@ import {
import { HugeiconsIcon } from "@hugeicons/react";
import { useRouterState } from "@tanstack/react-router";
import { useEffect, useMemo, useState } from "react";
import { DownloadProgressBar } from "./download-progress-bar";
import {
type ManagedDownload,
downloadManager,
hydrateDownloadManager,
useDownloadManagerStore,
} from "./download-manager-controller";
import { DownloadProgressBar } from "./download-progress-bar";
function createOrderedJobKeysSelector(): (state: {
jobs: Record<string, ManagedDownload>;
@ -241,7 +241,7 @@ export function DownloadManagerPanel({
className="inline-flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-foreground/[0.06] hover:text-foreground dark:hover:bg-white/[0.06]"
>
<HugeiconsIcon
icon={ArrowDown01Icon}
icon={ChevronDownStandardIcon}
strokeWidth={1.75}
className="size-3.5"
/>

View file

@ -1527,3 +1527,16 @@ html.dark .hub-page [data-hub-scroll="true"]::-webkit-scrollbar-thumb {
body[data-scroll-locked] .hub-modal-pe-guard {
pointer-events: none !important;
}
@layer components {
/* Edge fades off (Appearance setting): hub scroll dissolves become thin
divider lines. */
html.no-edge-fades .hub-page .hub-detail-bar::after {
height: 1px;
background: var(--border);
}
html.no-edge-fades .hub-page .hub-scroll-fade {
background: none;
border-top: 1px solid var(--border);
}
}

View file

@ -322,8 +322,8 @@ export function DatasetStep() {
<button
type="button"
className={cn(
"border-2 border-dashed rounded-xl p-8 text-center transition-colors cursor-pointer hover:border-primary/50 hover:bg-muted/50",
uploadedFile && "border-primary/50 bg-primary/5",
"border-2 border-dashed rounded-xl p-8 text-center transition-colors cursor-pointer hover:border-ring hover:bg-muted/50",
uploadedFile && "border-ring-strong bg-primary/5",
)}
onClick={handleFileUpload}
>

View file

@ -225,7 +225,7 @@ export function HyperparametersStep() {
min={1}
max={useEpochs ? epochsSliderMax : maxStepsSliderMax}
step={1}
className="w-16 text-right font-mono text-xs font-medium bg-muted/50 border border-border rounded-lg px-1.5 py-0.5 focus:outline-none focus:ring-1 focus:ring-primary/30 [&::-webkit-inner-spin-button]:appearance-none"
className="w-16 text-right font-mono text-xs font-medium bg-muted/50 border border-border rounded-lg px-1.5 py-0.5 focus:outline-none focus:ring-1 focus:ring-ring [&::-webkit-inner-spin-button]:appearance-none"
/>
</div>
</div>
@ -379,7 +379,7 @@ export function HyperparametersStep() {
min={4}
max={128}
step={4}
className="w-12 text-right font-mono text-xs font-medium bg-muted/50 border border-border rounded-lg px-1.5 py-0.5 focus:outline-none focus:ring-1 focus:ring-primary/30 [&::-webkit-inner-spin-button]:appearance-none"
className="w-12 text-right font-mono text-xs font-medium bg-muted/50 border border-border rounded-lg px-1.5 py-0.5 focus:outline-none focus:ring-1 focus:ring-ring [&::-webkit-inner-spin-button]:appearance-none"
/>
</div>
</div>
@ -428,7 +428,7 @@ export function HyperparametersStep() {
min={8}
max={256}
step={8}
className="w-12 text-right font-mono text-xs font-medium bg-muted/50 border border-border rounded-lg px-1.5 py-0.5 focus:outline-none focus:ring-1 focus:ring-primary/30 [&::-webkit-inner-spin-button]:appearance-none"
className="w-12 text-right font-mono text-xs font-medium bg-muted/50 border border-border rounded-lg px-1.5 py-0.5 focus:outline-none focus:ring-1 focus:ring-ring [&::-webkit-inner-spin-button]:appearance-none"
/>
</div>
</div>
@ -478,7 +478,7 @@ export function HyperparametersStep() {
min={0}
max={0.5}
step={0.01}
className="w-12 text-right font-mono text-xs font-medium bg-muted/50 border border-border rounded-lg px-1.5 py-0.5 focus:outline-none focus:ring-1 focus:ring-primary/30 [&::-webkit-inner-spin-button]:appearance-none"
className="w-12 text-right font-mono text-xs font-medium bg-muted/50 border border-border rounded-lg px-1.5 py-0.5 focus:outline-none focus:ring-1 focus:ring-ring [&::-webkit-inner-spin-button]:appearance-none"
/>
</div>
</div>

View file

@ -97,10 +97,10 @@ export function ModelTypeStep(): ReactElement {
"relative shadow-primary/30 transition-all duration-150 ease-out",
isDisabled && "opacity-50 bg-muted/50",
!isDisabled &&
"hover:ring-primary/40 hover:-translate-y-0.5 hover:shadow-sm",
"hover:ring-ring hover:-translate-y-0.5 hover:shadow-sm",
isSelected &&
!isDisabled &&
"ring-2 ring-primary -translate-y-0.5 shadow-sm",
"ring-1 ring-ring-strong -translate-y-0.5 shadow-sm",
)}
>
{isDisabled && (
@ -187,8 +187,8 @@ export function ModelTypeStep(): ReactElement {
size="sm"
className={cn(
"relative shadow-primary/30 transition-all duration-150 ease-out",
"hover:ring-primary/40 hover:-translate-y-0.5 hover:shadow-sm",
chatOnlySelected && "ring-2 ring-primary -translate-y-0.5 shadow-sm",
"hover:ring-ring hover:-translate-y-0.5 hover:shadow-sm",
chatOnlySelected && "ring-1 ring-ring-strong -translate-y-0.5 shadow-sm",
)}
>
<CardContent className="flex items-center gap-4 py-4">

View file

@ -118,8 +118,8 @@ export function SummaryStep() {
</CardHeader>
<CardContent className="flex flex-1 flex-col">
<div className="flex items-start gap-3">
<div className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg bg-emerald-500/10">
<HugeiconsIcon icon={GpuIcon} className="size-4 text-emerald-600" />
<div className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg bg-control-accent/10">
<HugeiconsIcon icon={GpuIcon} className="size-4 text-control-accent" />
</div>
<div className="flex flex-1 flex-col">
<span className="text-xs text-muted-foreground">GPU</span>
@ -144,8 +144,8 @@ export function SummaryStep() {
</CardHeader>
<CardContent className="flex flex-1 flex-col">
<div className="flex items-start gap-3">
<div className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg bg-emerald-500/10">
<HugeiconsIcon icon={ChipIcon} className="size-4 text-emerald-600" />
<div className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg bg-control-accent/10">
<HugeiconsIcon icon={ChipIcon} className="size-4 text-control-accent" />
</div>
<div className="flex flex-1 flex-col overflow-hidden">
<span className="text-xs text-muted-foreground">Model</span>

View file

@ -9,6 +9,7 @@ import { Suspense, lazy, useEffect, useRef, useState } from "react";
import type { ConfettiRef } from "@/components/ui/confetti";
import { STEPS } from "@/config/training";
import { isOnboardingDone, markOnboardingDone } from "@/features/auth";
import { prefersReducedMotion } from "@/features/settings";
import { useTrainingConfigStore } from "@/features/training";
import { SplashScreen } from "./splash-screen";
import { WizardContent } from "./wizard-content";
@ -52,20 +53,23 @@ export function WizardLayout() {
useEffect(() => {
if (isFinalStep && !hasFiredRef.current) {
hasFiredRef.current = true;
confettiRef.current?.fire({
particleCount: 80,
angle: 60,
spread: 55,
origin: { x: 0, y: 0.6 },
colors: ["#34b482", "#26ccff", "#a25afd", "#88ff5a"],
});
confettiRef.current?.fire({
particleCount: 80,
angle: 120,
spread: 55,
origin: { x: 1, y: 0.6 },
colors: ["#34b482", "#26ccff", "#a25afd", "#88ff5a"],
});
// Honor Appearance > Reduce motion; skip the celebration when reduced.
if (!prefersReducedMotion()) {
confettiRef.current?.fire({
particleCount: 80,
angle: 60,
spread: 55,
origin: { x: 0, y: 0.6 },
colors: ["#34b482", "#26ccff", "#a25afd", "#88ff5a"],
});
confettiRef.current?.fire({
particleCount: 80,
angle: 120,
spread: 55,
origin: { x: 1, y: 0.6 },
colors: ["#34b482", "#26ccff", "#a25afd", "#88ff5a"],
});
}
}
if (!isFinalStep) {
hasFiredRef.current = false;

View file

@ -5,11 +5,12 @@ import { publicAssetUrl } from "@/components/mascot-img";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { getAuthToken } from "@/features/auth";
import { cn } from "@/lib/utils";
import { useT } from "@/i18n";
import { toastError, toastSuccess } from "@/shared/toast";
import { Camera01Icon } from "@hugeicons/core-free-icons";
import { Edit03Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useEffect, useMemo, useRef, useState } from "react";
import { SLOTH_AVATARS } from "../sloth-avatars";
@ -63,6 +64,10 @@ export function ProfilePersonalizationPanel() {
const setAvatarDataUrl = useUserProfileStore((s) => s.setAvatarDataUrl);
const avatarShape = useUserProfileStore((s) => s.avatarShape);
const setAvatarShape = useUserProfileStore((s) => s.setAvatarShape);
const showGreetingSloth = useUserProfileStore((s) => s.showGreetingSloth);
const setShowGreetingSloth = useUserProfileStore(
(s) => s.setShowGreetingSloth,
);
const [imageError, setImageError] = useState<string | null>(null);
const [draftName, setDraftName] = useState(displayName);
@ -128,7 +133,7 @@ export function ProfilePersonalizationPanel() {
}
};
const applyAvatar = (value: string) => {
const applyAvatar = (value: string | null) => {
setAvatarDataUrl(value);
const persisted = readPersistedProfile();
if (persisted && persisted.avatarDataUrl === value) {
@ -154,9 +159,29 @@ export function ProfilePersonalizationPanel() {
}
};
const pickSloth = (path: string) => {
// The avatar is shown all over the app (sidebar, chat messages, greeting),
// so writing it to the store can trigger a wide re-render. Mark the picked
// value locally first so its ring moves this frame, then commit the store
// write on the next frame. Boxed because null is a valid pick (no picture).
const [pendingAvatar, setPendingAvatar] = useState<{
value: string | null;
} | null>(null);
const shownAvatar = pendingAvatar ? pendingAvatar.value : avatarDataUrl;
useEffect(() => {
if (pendingAvatar && avatarDataUrl === pendingAvatar.value) {
setPendingAvatar(null);
}
}, [avatarDataUrl, pendingAvatar]);
const pickAvatarValue = (value: string | null) => {
setImageError(null);
applyAvatar(publicAssetUrl(path));
setPendingAvatar({ value });
requestAnimationFrame(() => applyAvatar(value));
};
const pickSloth = (path: string) => {
pickAvatarValue(publicAssetUrl(path));
};
return (
@ -164,7 +189,7 @@ export function ProfilePersonalizationPanel() {
<div className="relative">
<UserAvatar
name={previewName}
imageUrl={avatarDataUrl}
imageUrl={shownAvatar}
size="lg"
className="size-[124px] text-[3.15rem]"
/>
@ -181,14 +206,17 @@ export function ProfilePersonalizationPanel() {
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="absolute right-0 bottom-0 -translate-x-[15.625%] -translate-y-[15.625%] flex size-8 items-center justify-center rounded-full border border-border bg-background text-foreground shadow-[0_2px_8px_-2px_rgba(0,0,0,0.16)] transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
className="absolute right-0 bottom-0 -translate-x-[15.625%] -translate-y-[15.625%] flex size-8 items-center justify-center rounded-full border border-border bg-background text-foreground shadow-[0_2px_8px_-2px_rgba(0,0,0,0.16)] transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
aria-label={t("settings.profile.changePicture")}
>
<HugeiconsIcon icon={Camera01Icon} className="size-3.5" strokeWidth={2} />
<HugeiconsIcon icon={Edit03Icon} className="size-3.5" strokeWidth={2} />
</button>
</div>
<div className="flex w-full max-w-[560px] flex-col gap-2">
<div
data-settings-label={t("settings.profile.displayName")}
className="flex w-full max-w-[560px] flex-col gap-2"
>
<Label htmlFor="profile-display-name" className="text-xs font-medium text-muted-foreground">
{t("settings.profile.displayName")}
</Label>
@ -215,7 +243,10 @@ export function ProfilePersonalizationPanel() {
</div>
</div>
<div className="flex w-full max-w-[560px] flex-col gap-2">
<div
data-settings-label={t("settings.profile.nickname")}
className="flex w-full max-w-[560px] flex-col gap-2"
>
<Label htmlFor="profile-nickname" className="text-xs font-medium text-muted-foreground">
{t("settings.profile.nickname")}
</Label>
@ -242,7 +273,10 @@ export function ProfilePersonalizationPanel() {
</div>
</div>
<div className="flex w-full max-w-[560px] flex-col gap-2">
<div
data-settings-label={t("settings.profile.avatarShape")}
className="flex w-full max-w-[560px] flex-col gap-2"
>
<Label className="text-xs font-medium text-muted-foreground">
{t("settings.profile.avatarShape")}
</Label>
@ -268,6 +302,28 @@ export function ProfilePersonalizationPanel() {
</div>
</div>
<div
data-settings-label={t("settings.profile.greetingSloth")}
className="flex w-full max-w-[560px] items-center justify-between gap-4"
>
<div className="flex min-w-0 flex-col gap-0.5">
<Label
htmlFor="profile-greeting-sloth"
className="text-xs font-medium text-muted-foreground"
>
{t("settings.profile.greetingSloth")}
</Label>
<p className="text-xs text-muted-foreground/75">
{t("settings.profile.greetingSlothDescription")}
</p>
</div>
<Switch
id="profile-greeting-sloth"
checked={showGreetingSloth}
onCheckedChange={setShowGreetingSloth}
/>
</div>
<div className="flex w-full max-w-[560px] flex-col gap-2">
<Label className="text-xs font-medium text-muted-foreground">
{t("settings.profile.chooseSloth")}
@ -275,7 +331,7 @@ export function ProfilePersonalizationPanel() {
<div className="grid grid-cols-7 gap-2 sm:grid-cols-9">
{SLOTH_AVATARS.map((path) => {
const url = publicAssetUrl(path);
const selected = avatarDataUrl === url;
const selected = shownAvatar === url;
const label =
path.split("/").pop()?.replace(/\.png$/i, "").replace(/^large\s+/i, "").trim() ??
"sloth";
@ -288,14 +344,32 @@ export function ProfilePersonalizationPanel() {
aria-label={label}
title={label}
className={cn(
"relative aspect-square overflow-hidden rounded-full bg-muted ring-1 ring-border transition hover:ring-primary/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
selected && "ring-2 ring-primary",
// No transition here: animating the ring makes the old
// icon's selection border linger when switching sloths.
"relative aspect-square overflow-hidden rounded-full bg-muted ring-1 ring-border hover:ring-ring focus-visible:outline-none focus-visible:ring-ring",
// Selection keeps the 1px weight, only darker.
selected && "ring-ring-strong hover:ring-ring-strong",
)}
>
<img src={url} alt="" loading="lazy" className="size-full object-cover" />
</button>
);
})}
<button
type="button"
onClick={() => pickAvatarValue(null)}
aria-pressed={shownAvatar === null}
aria-label={t("settings.profile.noPicture")}
title={t("settings.profile.noPicture")}
className={cn(
"relative flex aspect-square items-center justify-center overflow-hidden rounded-full bg-muted text-muted-foreground ring-1 ring-border hover:ring-ring focus-visible:outline-none focus-visible:ring-ring",
shownAvatar === null && "ring-ring-strong hover:ring-ring-strong",
)}
>
<span className="text-[11px] font-medium">
{t("settings.profile.noneLabel")}
</span>
</button>
</div>
</div>

View file

@ -47,7 +47,7 @@ export function UserAvatar({ name, imageUrl, size, className, shape }: UserAvata
<span
style={avatarBgStyle()}
className={cn(
"inline-flex shrink-0 items-center justify-center font-semibold text-white",
"inline-flex shrink-0 items-center justify-center font-semibold",
shapeClass,
SIZE[size],
className,

View file

@ -2,11 +2,19 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import {
loadPersonalization,
savePersonalization,
setTheme,
useTheme,
type AppearanceCustomization,
type Palette,
type Theme,
isDefaultCustomization,
isPalette,
loadPersonalization,
sanitizeCustomization,
savePersonalization,
setPalette,
setTheme,
useAppearanceCustomStore,
usePalette,
useTheme,
} from "@/features/settings";
import {
DEFAULT_LOCALE_PREFERENCE,
@ -35,6 +43,7 @@ type ProfileSnapshot = {
nickname: string;
avatarDataUrl: string | null;
avatarShape: AvatarShape;
showGreetingSloth: boolean;
};
type PersonalizationWrite = Parameters<typeof savePersonalization>[0];
@ -62,7 +71,8 @@ function sameProfile(a: ProfileSnapshot, b: ProfileSnapshot): boolean {
a.displayName === b.displayName &&
a.nickname === b.nickname &&
a.avatarDataUrl === b.avatarDataUrl &&
a.avatarShape === b.avatarShape
a.avatarShape === b.avatarShape &&
a.showGreetingSloth === b.showGreetingSloth
);
}
@ -109,18 +119,21 @@ function profileSnapshot(): ProfileSnapshot {
nickname: s.nickname,
avatarDataUrl: s.avatarDataUrl,
avatarShape: s.avatarShape,
showGreetingSloth: s.showGreetingSloth,
};
}
function payload(
profile: ProfileSnapshot,
theme: Theme,
palette: Palette,
customization: AppearanceCustomization,
language: LocalePreference | null,
): PersonalizationWrite {
return {
version: PERSONALIZATION_VERSION,
profile: normalizeProfile(profile),
appearance: { theme, language },
appearance: { theme, palette, language, customization },
};
}
@ -144,6 +157,8 @@ export function remoteLanguagePreference(
function hasLocalSettings(
profile: ProfileSnapshot,
theme: Theme,
palette: Palette,
customization: AppearanceCustomization,
language: LocalePreference,
): boolean {
return Boolean(
@ -151,7 +166,10 @@ function hasLocalSettings(
profile.nickname ||
profile.avatarDataUrl ||
profile.avatarShape !== "circle" ||
!profile.showGreetingSloth ||
theme !== "system" ||
palette !== "standard" ||
!isDefaultCustomization(customization) ||
language !== DEFAULT_LOCALE_PREFERENCE,
);
}
@ -161,11 +179,16 @@ export function usePersonalizationSync(enabled: boolean): void {
const nickname = useUserProfileStore((s) => s.nickname);
const avatarDataUrl = useUserProfileStore((s) => s.avatarDataUrl);
const avatarShape = useUserProfileStore((s) => s.avatarShape);
const showGreetingSloth = useUserProfileStore((s) => s.showGreetingSloth);
const { theme } = useTheme();
const { palette } = usePalette();
const customization = useAppearanceCustomStore((s) => s.customization);
const language = useLocalePreference();
const [hydratedGeneration, setHydratedGeneration] = useState(0);
const authGenerationRef = useRef(0);
const latestThemeRef = useRef(theme);
const latestPaletteRef = useRef(palette);
const latestCustomizationRef = useRef(customization);
const latestLanguageRef = useRef(language);
const lastSavedRef = useRef("");
const saveInFlightRef = useRef(false);
@ -184,6 +207,14 @@ export function usePersonalizationSync(enabled: boolean): void {
latestThemeRef.current = theme;
}, [theme]);
useEffect(() => {
latestPaletteRef.current = palette;
}, [palette]);
useEffect(() => {
latestCustomizationRef.current = customization;
}, [customization]);
useEffect(() => {
latestLanguageRef.current = language;
}, [language]);
@ -202,13 +233,41 @@ export function usePersonalizationSync(enabled: boolean): void {
const remote = await loadPersonalization();
if (cancelled) return;
if (remote.saved) {
// Legacy records predating a field come back server-defaulted. Keep
// the local value and re-push it (lastSaved below records the remote
// default so the push detects the diff) rather than treating the
// default as an explicit remote choice. A record that actually stored
// the field reports <field>Saved=true and still wins.
const localGreeting = useUserProfileStore.getState().showGreetingSloth;
const remoteGreeting = remote.profile.showGreetingSloth !== false;
const keepLocalGreeting =
remote.greetingSlothSaved === false && localGreeting === false;
const nextProfile: ProfileSnapshot = {
displayName: remote.profile.displayName ?? "",
nickname: remote.profile.nickname ?? "",
avatarDataUrl: remote.profile.avatarDataUrl ?? null,
avatarShape: remote.profile.avatarShape === "rounded" ? "rounded" : "circle",
avatarShape:
remote.profile.avatarShape === "rounded" ? "rounded" : "circle",
showGreetingSloth: keepLocalGreeting ? localGreeting : remoteGreeting,
};
const nextTheme = remote.appearance.theme;
const localPalette = latestPaletteRef.current;
const remotePalette = isPalette(remote.appearance.palette)
? remote.appearance.palette
: localPalette;
const keepLocalPalette =
remote.paletteSaved === false && localPalette !== "standard";
const nextPalette = keepLocalPalette ? localPalette : remotePalette;
const remoteCustomization = sanitizeCustomization(
remote.appearance.customization,
);
const localCustomization = latestCustomizationRef.current;
const keepLocalCustomization =
remote.customizationSaved === false &&
!isDefaultCustomization(localCustomization);
const nextCustomization = keepLocalCustomization
? localCustomization
: remoteCustomization;
const remoteLanguage = remoteLanguagePreference(
remote.version,
remote.appearance.language,
@ -218,9 +277,27 @@ export function usePersonalizationSync(enabled: boolean): void {
: latestLanguageRef.current;
useUserProfileStore.setState(nextProfile);
if (nextTheme !== latestThemeRef.current) setTheme(nextTheme);
if (nextLanguage !== latestLanguageRef.current) setLocale(nextLanguage);
if (nextPalette !== latestPaletteRef.current) setPalette(nextPalette);
if (
!keepLocalCustomization &&
JSON.stringify(nextCustomization) !==
JSON.stringify(latestCustomizationRef.current)
) {
useAppearanceCustomStore.getState().replaceAll(nextCustomization);
}
if (nextLanguage !== latestLanguageRef.current)
setLocale(nextLanguage);
// lastSaved records what the server actually has (server-side defaults
// for legacy fields) so the debounced push re-uploads preserved local
// values.
lastSavedRef.current = serialized(
payload(nextProfile, nextTheme, nextLanguage),
payload(
{ ...nextProfile, showGreetingSloth: remoteGreeting },
nextTheme,
remotePalette,
remoteCustomization,
nextLanguage,
),
);
} else {
const rawProfile = profileSnapshot();
@ -229,10 +306,26 @@ export function usePersonalizationSync(enabled: boolean): void {
useUserProfileStore.setState(nextProfile);
}
const nextTheme = latestThemeRef.current;
const nextPalette = latestPaletteRef.current;
const nextCustomization = latestCustomizationRef.current;
const nextLanguage = getLocalePreference();
const nextPayload = payload(nextProfile, nextTheme, nextLanguage);
const nextPayload = payload(
nextProfile,
nextTheme,
nextPalette,
nextCustomization,
nextLanguage,
);
const nextSerialized = serialized(nextPayload);
if (hasLocalSettings(nextProfile, nextTheme, nextLanguage)) {
if (
hasLocalSettings(
nextProfile,
nextTheme,
nextPalette,
nextCustomization,
nextLanguage,
)
) {
try {
await savePersonalization(nextPayload);
lastSavedRef.current = nextSerialized;
@ -260,8 +353,10 @@ export function usePersonalizationSync(enabled: boolean): void {
useEffect(() => {
if (!enabled || hydratedGeneration !== authGenerationRef.current) return;
const current = payload(
{ displayName, nickname, avatarDataUrl, avatarShape },
{ displayName, nickname, avatarDataUrl, avatarShape, showGreetingSloth },
theme,
palette,
customization,
language,
);
const currentSerialized = serialized(current);
@ -282,7 +377,10 @@ export function usePersonalizationSync(enabled: boolean): void {
nickname,
avatarDataUrl,
avatarShape,
showGreetingSloth,
theme,
palette,
customization,
language,
drainSaveQueue,
]);

View file

@ -12,10 +12,12 @@ export interface UserProfileState {
nickname: string;
avatarDataUrl: string | null;
avatarShape: AvatarShape;
showGreetingSloth: boolean;
setDisplayName: (displayName: string) => void;
setNickname: (nickname: string) => void;
setAvatarDataUrl: (avatarDataUrl: string | null) => void;
setAvatarShape: (avatarShape: AvatarShape) => void;
setShowGreetingSloth: (showGreetingSloth: boolean) => void;
}
export const useUserProfileStore = create<UserProfileState>()(
@ -25,10 +27,12 @@ export const useUserProfileStore = create<UserProfileState>()(
nickname: "",
avatarDataUrl: null,
avatarShape: "circle",
showGreetingSloth: true,
setDisplayName: (displayName) => set({ displayName }),
setNickname: (nickname) => set({ nickname }),
setAvatarDataUrl: (avatarDataUrl) => set({ avatarDataUrl }),
setAvatarShape: (avatarShape) => set({ avatarShape }),
setShowGreetingSloth: (showGreetingSloth) => set({ showGreetingSloth }),
}),
{ name: "unsloth_user_profile" },
),

View file

@ -8,18 +8,16 @@ export function initialsFromName(name: string): string {
}
/**
* Default Unsloth-brand background for the avatar fallback (readable white text).
* Accent background for the avatar fallback with a readable foreground.
*
* Uses the shared `--primary` design token instead of a one-off hardcoded
* shade, so the avatar always matches the app's general brand green (send
* button, primary buttons, etc.). It previously hardcoded a slightly different
* `#14b789`, which looked inconsistent next to primary-colored UI such as the
* artifact preview/code panel.
*
* The literal fallback (the current `--primary` value) only applies if this
* reusable avatar is ever rendered outside the theme root, where `--primary`
* is undefined -- it keeps the avatar branded instead of transparent.
* Uses `--control-accent`, the token behind toggles and badges, so the
* avatar follows the palette accent (green in standard, blue in classic)
* and any custom accent the user picks in Appearance. The literals only
* apply outside the theme root, keeping the avatar branded there.
*/
export function avatarBgStyle(): { backgroundColor: string } {
return { backgroundColor: "var(--primary, #17b88b)" };
export function avatarBgStyle(): { backgroundColor: string; color: string } {
return {
backgroundColor: "var(--control-accent, #17b88b)",
color: "var(--control-accent-foreground, #ffffff)",
};
}

View file

@ -84,7 +84,7 @@ export function ChipInput({
return (
<div
ref={containerRef}
className={`bg-input/30 border-input focus-within:border-ring focus-within:ring-ring/50 flex min-h-9 flex-wrap items-center gap-1.5 border bg-clip-padding px-1.5 py-1.5 text-sm transition-colors focus-within:ring-[3px] ${isWrapped ? "corner-squircle rounded-xl" : "rounded-4xl"}`}
className={`bg-input/30 border-input dark:border-transparent focus-within:border-ring dark:focus-within:border-transparent dark:focus-within:bg-white/[0.09] flex min-h-9 flex-wrap items-center gap-1.5 border bg-clip-padding px-1.5 py-1.5 text-sm transition-colors ${isWrapped ? "corner-squircle rounded-xl" : "rounded-4xl"}`}
>
{values.map((value, index) => (
<span

View file

@ -55,7 +55,7 @@ export function ExecutionSidebar({
className={cn(
"w-full rounded-xl corner-squircle border border-r-2 border-border/60 bg-card/60 p-3 text-left transition-colors",
selectedExecutionId === execution.id
? "border-primary/35 bg-primary/[0.045]"
? "border-ring-strong bg-primary/[0.045]"
: "hover:bg-muted/25",
statusRightBorder(execution.status),
)}

View file

@ -455,7 +455,7 @@ function RecipeGraphNodeBase({
: meta.tone;
const runtimeNodeTone =
runtimeState === "running"
? "border-primary/70 ring-2 ring-primary/20 shadow-md"
? "border-ring-strong shadow-md"
: runtimeState === "done"
? "border-emerald-500/60 ring-1 ring-emerald-500/20"
: "";

View file

@ -13,9 +13,8 @@ export function BaseNode({
<div
className={cn(
"bg-card text-card-foreground relative rounded-md border transition-[border-color,box-shadow] duration-150",
"hover:border-primary/40 hover:ring-1 hover:ring-primary/20 hover:shadow-sm",
"[.react-flow\\_\\_node.selected_&]:border-primary/45",
"[.react-flow\\_\\_node.selected_&]:ring-1 [.react-flow\\_\\_node.selected_&]:ring-primary/25",
"hover:border-ring hover:shadow-sm",
"[.react-flow\\_\\_node.selected_&]:border-ring-strong",
"[.react-flow\\_\\_node.selected_&]:shadow-md",
className,
)}

View file

@ -1,18 +1,17 @@
// 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 {
ArrowDown01Icon,
ArrowUp01Icon,
CheckmarkCircle02Icon,
Flag02Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import type { ReactElement } from "react";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { Spinner } from "@/components/ui/spinner";
import {
ChevronDownStandardIcon,
ChevronUpStandardIcon,
} from "@/lib/chevron-icons";
import { cn } from "@/lib/utils";
import { CheckmarkCircle02Icon, Flag02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import type { ReactElement } from "react";
import type { RecipeExecutionRecord } from "../../execution-types";
import { isExecutionInProgress } from "../../executions/execution-helpers";
import {
@ -112,8 +111,11 @@ export function ExecutionProgressIsland({
execution.source_progress?.status === "rate_limited" ||
execution.source_progress?.status === "retrying"),
);
const sourcePercent = showSourceProgress ? execution.source_progress?.percent : null;
const progressPercent = sourcePercent ?? execution.progress?.percent ?? (complete ? 100 : 0);
const sourcePercent = showSourceProgress
? execution.source_progress?.percent
: null;
const progressPercent =
sourcePercent ?? execution.progress?.percent ?? (complete ? 100 : 0);
const hasProgressSignal = Boolean(
(execution.progress &&
(typeof execution.progress.done === "number" ||
@ -165,7 +167,7 @@ export function ExecutionProgressIsland({
title={minimized ? "Expand" : "Minimize"}
>
<HugeiconsIcon
icon={minimized ? ArrowDown01Icon : ArrowUp01Icon}
icon={minimized ? ChevronDownStandardIcon : ChevronUpStandardIcon}
className="size-3.5"
/>
</button>
@ -179,16 +181,28 @@ export function ExecutionProgressIsland({
{!minimized && (
<>
<div className="grid grid-cols-2 gap-2 px-3 pt-2 text-[11px] text-muted-foreground sm:grid-cols-4">
<p className="truncate" title={`Done: ${formatMetricValue(execution.progress?.done)}`}>
<p
className="truncate"
title={`Done: ${formatMetricValue(execution.progress?.done)}`}
>
Done: {formatMetricValue(execution.progress?.done)}
</p>
<p className="truncate" title={`Total: ${formatMetricValue(execution.progress?.total)}`}>
<p
className="truncate"
title={`Total: ${formatMetricValue(execution.progress?.total)}`}
>
Total: {formatMetricValue(execution.progress?.total)}
</p>
<p className="truncate" title={`Rate: ${formatMetricValue(execution.progress?.rate)}`}>
<p
className="truncate"
title={`Rate: ${formatMetricValue(execution.progress?.rate)}`}
>
Rate: {formatMetricValue(execution.progress?.rate)}
</p>
<p className="truncate" title={`ETA: ${formatEta(execution.progress?.eta_sec)}`}>
<p
className="truncate"
title={`ETA: ${formatEta(execution.progress?.eta_sec)}`}
>
ETA: {formatEta(execution.progress?.eta_sec)}
</p>
</div>
@ -208,10 +222,7 @@ export function ExecutionProgressIsland({
icon={currentColumnIcon}
className="size-3.5 shrink-0"
/>
<p
className="truncate"
title={execution.current_column ?? "--"}
>
<p className="truncate" title={execution.current_column ?? "--"}>
Column: {execution.current_column ?? "--"}
</p>
</div>
@ -221,7 +232,8 @@ export function ExecutionProgressIsland({
className="mt-1 truncate px-3 text-[11px] text-muted-foreground"
title={`Batch: ${execution.batch?.idx ?? "--"}/${execution.batch?.total ?? "--"}`}
>
Batch: {execution.batch?.idx ?? "--"}/{execution.batch?.total ?? "--"}
Batch: {execution.batch?.idx ?? "--"}/
{execution.batch?.total ?? "--"}
</div>
)}
<div className="px-3 pb-2 pt-2">

View file

@ -577,7 +577,8 @@ export function LocalRecipeModelSelector({
);
return (
<Popover open={open} onOpenChange={handleOpenChange}>
// modal keeps the list wheel-scrollable inside dialog scroll locks
<Popover open={open} onOpenChange={handleOpenChange} modal={true}>
<PopoverTrigger asChild={true}>
<SelectorTrigger
value={value}

View file

@ -82,7 +82,7 @@ export function ModelProviderDialog({
type="button"
className={`rounded-xl border px-4 py-3 text-left transition-colors ${
isLocal
? "border-primary/40 bg-primary/5"
? "border-ring-strong bg-primary/5"
: "border-border/60 bg-muted/10 hover:border-border"
}`}
onClick={() =>
@ -105,7 +105,7 @@ export function ModelProviderDialog({
type="button"
className={`rounded-xl border px-4 py-3 text-left transition-colors ${
!isLocal
? "border-primary/40 bg-primary/5"
? "border-ring-strong bg-primary/5"
: "border-border/60 bg-muted/10 hover:border-border"
}`}
onClick={() => onUpdate({ is_local: false })}

View file

@ -17,10 +17,10 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { ChevronDownStandardIcon } from "@/lib/chevron-icons";
import { cn } from "@/lib/utils";
import {
AlertCircleIcon,
ArrowDown01Icon,
CheckmarkCircle02Icon,
CookBookIcon,
TestTube01Icon,
@ -413,7 +413,7 @@ function RunDialogBody({
className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground hover:text-foreground"
>
<HugeiconsIcon
icon={ArrowDown01Icon}
icon={ChevronDownStandardIcon}
className={cn(
"size-3.5 transition-transform",
advancedOpen && "rotate-180",
@ -615,7 +615,9 @@ function RunDialogBody({
0,
MAX_RETRY_STEPS,
(value) =>
onSettingsChange({ maxConversationCorrectionSteps: value }),
onSettingsChange({
maxConversationCorrectionSteps: value,
}),
setCorrectionsDraft,
)
}
@ -624,7 +626,8 @@ function RunDialogBody({
<div className="space-y-0.5">
<p className="font-medium">Keep running through failures</p>
<p className="text-xs text-muted-foreground">
Useful for longer runs when you want as many rows as possible.
Useful for longer runs when you want as many rows as
possible.
</p>
</div>
<Switch

View file

@ -211,7 +211,7 @@ export function UnstructuredDropZone({
<div
className={`nodrag flex cursor-pointer flex-col items-center justify-center rounded-md border-2 border-dashed px-4 py-6 text-center transition-colors ${
isDragOver
? "border-primary bg-primary/5"
? "border-ring-strong bg-primary/5"
: "border-muted-foreground/25 hover:border-muted-foreground/50"
} ${disabled ? "pointer-events-none opacity-50" : ""}`}
onDrop={handleDrop}

View file

@ -2,12 +2,12 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { Badge } from "@/components/ui/badge";
import { ArrowDown01Icon } from "@hugeicons/core-free-icons";
import { ChevronDownStandardIcon } from "@/lib/chevron-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { type ReactElement, useMemo, useState } from "react";
import { useRecipeStudioStore } from "../../stores/recipe-studio";
import { getAvailableVariableEntries } from "../../utils/variables";
import { RECIPE_STUDIO_REFERENCE_BADGE_TONES } from "../../utils/ui-tones";
import { getAvailableVariableEntries } from "../../utils/variables";
type AvailableVariablesProps = {
configId: string;
@ -27,7 +27,10 @@ export function AvailableVariables({
const [showUserFields, setShowUserFields] = useState(false);
const configs = useRecipeStudioStore((state) => state.configs);
const vars = getAvailableVariableEntries(configs, configId);
const variableNames = useMemo(() => new Set(vars.map((entry) => entry.name)), [vars]);
const variableNames = useMemo(
() => new Set(vars.map((entry) => entry.name)),
[vars],
);
const hasUserRoot = variableNames.has("user");
const userFieldEntries = useMemo(
() =>
@ -71,19 +74,22 @@ export function AvailableVariables({
onClick={() => setShowUserFields((prev) => !prev)}
className="cursor-pointer"
aria-expanded={showUserFields}
aria-label={showUserFields ? "Hide user fields" : "Show user fields"}
aria-label={
showUserFields ? "Hide user fields" : "Show user fields"
}
>
<Badge variant="secondary" className={className}>
<span>{`{{ ${v.name} }}`}</span>
<HugeiconsIcon
icon={ArrowDown01Icon}
icon={ChevronDownStandardIcon}
className={`size-3 transition-transform ${showUserFields ? "rotate-180" : ""}`}
/>
</Badge>
</button>
);
})}
{hasUserRoot && showUserFields &&
{hasUserRoot &&
showUserFields &&
userFieldEntries.map((entry) => (
<Badge
key={`user-expanded:${entry.name}`}

View file

@ -1,13 +1,13 @@
// 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 { ChevronDownStandardIcon } from "@/lib/chevron-icons";
import { cn } from "@/lib/utils";
import { ArrowDown01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import {
forwardRef,
type ButtonHTMLAttributes,
type ReactElement,
forwardRef,
} from "react";
type CollapsibleSectionTriggerProps = {
@ -42,7 +42,7 @@ export const CollapsibleSectionTriggerButton = forwardRef<
>
<span className="flex min-w-0 items-center gap-2">
<HugeiconsIcon
icon={ArrowDown01Icon}
icon={ChevronDownStandardIcon}
className={cn(
"size-3.5 shrink-0 transition-transform",
open && "rotate-180",

View file

@ -3,17 +3,21 @@
import { authFetch } from "@/features/auth";
import { readFastApiError } from "@/lib/format-fastapi-error";
import type { AppearanceCustomization } from "../stores/appearance-custom-store";
export type PersonalizationProfile = {
displayName: string;
nickname: string;
avatarDataUrl: string | null;
avatarShape: "circle" | "rounded";
showGreetingSloth: boolean;
};
export type PersonalizationAppearance = {
theme: "light" | "dark" | "system";
palette: "standard" | "classic" | "minimal";
language: string | null;
customization: AppearanceCustomization;
};
export type Personalization = {
@ -22,18 +26,28 @@ export type Personalization = {
appearance: PersonalizationAppearance;
// Distinguishes server hydrate from first local migration.
saved: boolean;
// False when the stored record predates these fields (legacy migration): the
// client then keeps local values instead of the server-filled defaults.
customizationSaved: boolean;
paletteSaved: boolean;
greetingSlothSaved: boolean;
};
export async function loadPersonalization(): Promise<Personalization> {
const res = await authFetch("/api/settings/personalization");
if (!res.ok) {
throw new Error(await readFastApiError(res, "Failed to load personalization"));
throw new Error(
await readFastApiError(res, "Failed to load personalization"),
);
}
return (await res.json()) as Personalization;
}
export async function savePersonalization(
data: Omit<Personalization, "saved">,
data: Omit<
Personalization,
"saved" | "customizationSaved" | "paletteSaved" | "greetingSlothSaved"
>,
): Promise<void> {
const res = await authFetch("/api/settings/personalization", {
method: "PUT",
@ -41,6 +55,8 @@ export async function savePersonalization(
body: JSON.stringify(data),
});
if (!res.ok) {
throw new Error(await readFastApiError(res, "Failed to save personalization"));
throw new Error(
await readFastApiError(res, "Failed to save personalization"),
);
}
}

View file

@ -80,7 +80,7 @@ function UsageBar({ value }: { value?: number | null }): ReactElement | null {
return (
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary"
className="h-full rounded-full bg-control-accent"
style={{ width: `${pct}%` }}
/>
</div>

View file

@ -0,0 +1,984 @@
// 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 { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Slider } from "@/components/ui/slider";
import { Switch } from "@/components/ui/switch";
import { type TranslationKey, useT } from "@/i18n";
import { ChevronDownStandardIcon } from "@/lib/chevron-icons";
import { toast } from "@/lib/toast";
import { cn } from "@/lib/utils";
import {
Cancel01Icon,
Folder01Icon,
Upload01Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { motion, useReducedMotionConfig } from "motion/react";
import {
useEffect,
useRef,
useState,
useSyncExternalStore,
} from "react";
import {
CODE_FONT_SIZE_RANGE,
type CustomModeColors,
DEFAULT_CUSTOMIZATION,
MAX_IMPORTED_FONTS,
MAX_TOTAL_IMPORTED_FONT_DATA_URL_LENGTH,
type ReduceMotionSetting,
UI_FONT_SIZE_RANGE,
isDefaultCustomization,
useAppearanceCustomStore,
} from "../stores/appearance-custom-store";
import {
type Palette,
type ResolvedTheme,
usePalette,
useTheme,
} from "../stores/theme-store";
import { ColorPickerSwatch } from "./color-picker";
/* ------------------------------- Colors -------------------------------- */
// Seed values shown in the pickers while no override is set. Mirrors the
// palette token values in index.css (foregrounds converted from oklch).
type DefaultModeColors = { [K in keyof CustomModeColors]: string };
const PALETTE_DEFAULT_COLORS: Record<
Palette,
Record<ResolvedTheme, DefaultModeColors>
> = {
standard: {
light: { accent: "#17b88b", background: "#fefefd", foreground: "#262626" },
dark: { accent: "#17b88b", background: "#181818", foreground: "#ececec" },
},
classic: {
light: { accent: "#339cff", background: "#ffffff", foreground: "#1a1c1f" },
dark: { accent: "#4dabff", background: "#181818", foreground: "#ececec" },
},
minimal: {
light: { accent: "#171717", background: "#ffffff", foreground: "#171717" },
dark: { accent: "#ededed", background: "#181818", foreground: "#ededed" },
},
};
/**
* Color override control for the CURRENTLY ACTIVE resolved mode. Only the
* active mode is editable; the other mode's overrides stay stored and take
* effect when the color scheme flips.
*/
export function ActiveColorControl({
colorKey,
label,
}: {
colorKey: keyof CustomModeColors;
label: string;
}) {
const t = useT();
const { resolved } = useTheme();
const { palette } = usePalette();
const override = useAppearanceCustomStore(
(s) => s.customization.colors[resolved][colorKey],
);
const setColor = useAppearanceCustomStore((s) => s.setColor);
const fallback = PALETTE_DEFAULT_COLORS[palette][resolved][colorKey];
const value = override ?? fallback;
return (
<div className="flex items-center gap-2">
{override !== null && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-muted-foreground"
onClick={() => setColor(resolved, colorKey, null)}
>
{t("settings.appearance.custom.reset")}
</Button>
)}
<ColorPickerSwatch
value={value}
onChange={(hex) => setColor(resolved, colorKey, hex)}
label={label}
/>
</div>
);
}
/* ------------------------------ Typography ------------------------------ */
/** Font each slot resolves to when no override is set (see index.css). */
const DEFAULT_FONT_NAMES = {
ui: "Inter Variable",
heading: "Hellix",
chat: "Inter Variable",
code: "JetBrains Mono",
} as const;
/** Fonts Unsloth Studio already ships (bundled @font-face / fontsource). */
const BUNDLED_FONTS = [
"Inter Variable",
"Hellix",
"Space Grotesk Variable",
"Figtree Variable",
"JetBrains Mono",
"Fira Code",
] as const;
/* ----------------------------- Device fonts ------------------------------ */
// Fallback candidates probed by canvas measurement when the Local Font
// Access API (Chromium-only) is unavailable or denied.
const CANDIDATE_DEVICE_FONTS = [
"American Typewriter",
"Andale Mono",
"Arial",
"Avenir",
"Avenir Next",
"Baskerville",
"Calibri",
"Cambria",
"Candara",
"Cantarell",
"Charter",
"Comic Sans MS",
"Consolas",
"Constantia",
"Corbel",
"Courier",
"Courier New",
"DejaVu Sans",
"DejaVu Sans Mono",
"DejaVu Serif",
"Didot",
"Fira Sans",
"Franklin Gothic Medium",
"Futura",
"Geneva",
"Georgia",
"Gill Sans",
"Helvetica",
"Helvetica Neue",
"Hoefler Text",
"IBM Plex Mono",
"IBM Plex Sans",
"Impact",
"Inconsolata",
"Iosevka",
"Lato",
"Liberation Mono",
"Liberation Sans",
"Liberation Serif",
"Lucida Grande",
"Menlo",
"Monaco",
"Montserrat",
"Noto Sans",
"Noto Serif",
"Nunito",
"Open Sans",
"Optima",
"Palatino",
"Roboto",
"Rockwell",
"Segoe UI",
"Seravek",
"Source Sans Pro",
"Tahoma",
"Times",
"Times New Roman",
"Trebuchet MS",
"Ubuntu",
"Verdana",
];
function detectFontsByMeasurement(candidates: string[]): string[] {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (!ctx) return [];
const sample = "mmmmmmmmmwwwwwwwlli";
const baselines = ["monospace", "sans-serif", "serif"].map((base) => {
ctx.font = `72px ${base}`;
return { base, width: ctx.measureText(sample).width };
});
return candidates.filter((family) =>
baselines.some(({ base, width }) => {
ctx.font = `72px "${family}", ${base}`;
return ctx.measureText(sample).width !== width;
}),
);
}
let deviceFontsCache: string[] | null = null;
async function loadDeviceFonts(): Promise<string[]> {
if (deviceFontsCache) return deviceFontsCache;
let families: string[] = [];
const query = (
globalThis as {
queryLocalFonts?: () => Promise<{ family: string }[]>;
}
).queryLocalFonts;
if (typeof query === "function") {
try {
const fonts = await query();
families = [...new Set(fonts.map((f) => f.family))];
} catch {
// permission denied; fall back to measurement probing
}
}
if (families.length === 0) {
families = detectFontsByMeasurement(CANDIDATE_DEVICE_FONTS);
}
deviceFontsCache = families.sort((a, b) => a.localeCompare(b));
return deviceFontsCache;
}
function FontSelect({
value,
defaultFont,
onCommit,
ariaLabel,
}: {
value: string | null;
defaultFont: string;
onCommit: (next: string | null) => void;
ariaLabel: string;
}) {
const t = useT();
const defaultLabel = `${defaultFont} (${t("settings.appearance.custom.fontDefault")})`;
const [open, setOpen] = useState(false);
const [deviceFonts, setDeviceFonts] = useState<string[] | null>(
deviceFontsCache,
);
const importedFonts = useAppearanceCustomStore(
(s) => s.customization.importedFonts,
);
const removeImportedFont = useAppearanceCustomStore(
(s) => s.removeImportedFont,
);
const handleOpenChange = (next: boolean) => {
setOpen(next);
// Kick off inside the click gesture: queryLocalFonts may need transient
// user activation for its permission prompt.
if (next && deviceFonts === null) {
void loadDeviceFonts().then(setDeviceFonts);
}
};
const select = (next: string | null) => {
onCommit(next);
setOpen(false);
};
const folderFonts = useFolderFonts();
const { inputs: uploadInputs, requestUpload, requestFolder, importFile } =
useFontImport((name) => select(name));
const knownNames = new Set<string>([
...BUNDLED_FONTS,
...importedFonts.map((f) => f.name),
]);
const deviceOnlyFonts = (deviceFonts ?? []).filter((f) => !knownNames.has(f));
const renderItem = (font: string) => (
<CommandItem
key={font}
value={font}
onSelect={() => select(font)}
data-checked={value === font}
className="cursor-pointer rounded-[11px]"
>
{/* Preview each entry in its own typeface. */}
<span
className="min-w-0 truncate"
style={{ fontFamily: `"${font}", var(--font-sans)` }}
>
{font}
</span>
</CommandItem>
);
return (
<Popover open={open} onOpenChange={handleOpenChange} modal={true}>
<PopoverTrigger asChild={true}>
<button
type="button"
aria-label={ariaLabel}
aria-expanded={open}
className="flex h-8 w-48 cursor-pointer items-center justify-between gap-1.5 rounded-full border border-border bg-background px-3.5 text-xs outline-none transition-colors hover:bg-accent/50 focus-visible:border-ring dark:focus-visible:border-transparent dark:focus-visible:bg-white/[0.12] dark:border-transparent dark:bg-white/[0.06] dark:hover:bg-white/10"
>
<span
className="min-w-0 truncate"
style={{ fontFamily: `"${value ?? defaultFont}", var(--font-sans)` }}
>
{value ?? defaultLabel}
</span>
<HugeiconsIcon
icon={ChevronDownStandardIcon}
strokeWidth={2}
className="size-4 shrink-0 text-muted-foreground"
/>
</button>
</PopoverTrigger>
<PopoverContent
align="end"
className="w-64 gap-0 rounded-lg p-1 font-heading"
>
<Command className="rounded-none bg-transparent p-0">
<CommandInput
placeholder={t("settings.appearance.custom.fontSearch")}
/>
<CommandList className="max-h-64 pt-1.5">
<CommandEmpty>
{t("settings.appearance.custom.fontNoResults")}
</CommandEmpty>
<CommandItem
value={defaultLabel}
onSelect={() => select(null)}
data-checked={value === null}
className="cursor-pointer rounded-[11px]"
>
<span style={{ fontFamily: `"${defaultFont}", var(--font-sans)` }}>
{defaultLabel}
</span>
</CommandItem>
<CommandGroup
className="p-0"
heading={t("settings.appearance.custom.fontBundledGroup")}
>
{BUNDLED_FONTS.map(renderItem)}
</CommandGroup>
{importedFonts.length > 0 && (
<CommandGroup
className="p-0"
heading={t("settings.appearance.custom.fontImportedGroup")}
>
{importedFonts.map((font) => (
<CommandItem
key={font.name}
value={font.name}
onSelect={() => select(font.name)}
data-checked={value === font.name}
className="cursor-pointer rounded-[11px]"
>
<span
className="min-w-0 truncate"
style={{ fontFamily: `"${font.name}", var(--font-sans)` }}
>
{font.name}
</span>
<button
type="button"
aria-label={`${t("settings.appearance.custom.importFont.remove")}: ${font.name}`}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
removeImportedFont(font.name);
}}
className="ml-auto rounded p-0.5 text-muted-foreground transition-colors hover:text-foreground"
>
<HugeiconsIcon icon={Cancel01Icon} className="size-3" />
</button>
</CommandItem>
))}
</CommandGroup>
)}
{folderFonts.some((f) => !knownNames.has(f.name)) && (
<CommandGroup
className="p-0"
heading={t("settings.appearance.custom.fontFolderGroup")}
>
{folderFonts
.filter((f) => !knownNames.has(f.name))
.map(({ name, file }) => (
<CommandItem
key={`folder-${name}`}
value={name}
onSelect={() => importFile(file)}
className="cursor-pointer rounded-[11px]"
>
<span className="min-w-0 truncate">{name}</span>
</CommandItem>
))}
</CommandGroup>
)}
<CommandGroup
className="p-0"
heading={t("settings.appearance.custom.fontDeviceGroup")}
>
{deviceFonts === null ? (
<div className="px-3 py-2 text-xs text-muted-foreground">
{t("settings.appearance.custom.fontDeviceLoading")}
</div>
) : (
deviceOnlyFonts.map(renderItem)
)}
</CommandGroup>
</CommandList>
</Command>
<div className="mt-1 flex items-center gap-1 border-t border-border/60 pt-1">
<button
type="button"
onClick={requestUpload}
className="flex flex-1 cursor-pointer items-center gap-2 whitespace-nowrap rounded-[11px] px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground"
>
<HugeiconsIcon
icon={Upload01Icon}
className="size-4 shrink-0 text-muted-foreground"
/>
{t("settings.appearance.custom.importFont.upload")}
</button>
<div className="h-4 w-px shrink-0 bg-border" />
<button
type="button"
onClick={requestFolder}
className="flex flex-1 cursor-pointer items-center gap-2 whitespace-nowrap rounded-[11px] px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground"
>
<HugeiconsIcon
icon={Folder01Icon}
className="size-4 shrink-0 text-muted-foreground"
/>
{t("settings.appearance.custom.importFont.scanFolder")}
</button>
</div>
{uploadInputs}
</PopoverContent>
</Popover>
);
}
export function UiFontRow() {
const t = useT();
const uiFont = useAppearanceCustomStore((s) => s.customization.uiFont);
const patch = useAppearanceCustomStore((s) => s.patch);
return (
<FontSelect
value={uiFont}
defaultFont={DEFAULT_FONT_NAMES.ui}
onCommit={(next) => patch({ uiFont: next })}
ariaLabel={t("settings.appearance.custom.uiFont.label")}
/>
);
}
export function HeadingFontRow() {
const t = useT();
const headingFont = useAppearanceCustomStore(
(s) => s.customization.headingFont,
);
const patch = useAppearanceCustomStore((s) => s.patch);
return (
<FontSelect
value={headingFont}
defaultFont={DEFAULT_FONT_NAMES.heading}
onCommit={(next) => patch({ headingFont: next })}
ariaLabel={t("settings.appearance.custom.headingFont.label")}
/>
);
}
export function ChatFontRow() {
const t = useT();
const chatFont = useAppearanceCustomStore((s) => s.customization.chatFont);
const patch = useAppearanceCustomStore((s) => s.patch);
return (
<FontSelect
value={chatFont}
defaultFont={DEFAULT_FONT_NAMES.chat}
onCommit={(next) => patch({ chatFont: next })}
ariaLabel={t("settings.appearance.custom.chatFont.label")}
/>
);
}
export function CodeFontRow() {
const t = useT();
const codeFont = useAppearanceCustomStore((s) => s.customization.codeFont);
const patch = useAppearanceCustomStore((s) => s.patch);
return (
<FontSelect
value={codeFont}
defaultFont={DEFAULT_FONT_NAMES.code}
onCommit={(next) => patch({ codeFont: next })}
ariaLabel={t("settings.appearance.custom.codeFont.label")}
/>
);
}
/* ---------------------------- Imported fonts ---------------------------- */
const FONT_MIME_BY_EXTENSION: Record<string, string> = {
woff2: "font/woff2",
woff: "font/woff",
ttf: "font/ttf",
otf: "font/otf",
};
const MAX_FONT_FILE_BYTES = Math.floor(1.5 * 1024 * 1024);
// Trailing style words stripped when matching a file name to a family the
// user already has ("Roboto-Bold.ttf" should match an installed "Roboto").
const FONT_STYLE_SUFFIXES = new Set([
"regular",
"bold",
"italic",
"light",
"medium",
"thin",
"black",
"semibold",
"extrabold",
"extralight",
"heavy",
"book",
"oblique",
"normal",
"variable",
"vf",
]);
function fontBaseName(fileName: string): string {
return fileName
.replace(/\.[^.]+$/, "")
.replace(/[;{}()<>"']/g, "")
.replace(/[_-]+/g, " ")
.trim();
}
function familyCandidates(base: string): string[] {
const candidates = [base];
let words = base.split(/\s+/);
while (
words.length > 1 &&
FONT_STYLE_SUFFIXES.has(words[words.length - 1].toLowerCase())
) {
words = words.slice(0, -1);
candidates.push(words.join(" "));
}
return candidates;
}
/* ----------------------------- Folder fonts ----------------------------- */
// Font files found by a folder scan this session, shared by every dropdown.
// File handles cannot be persisted from a plain directory input, so the list
// lives for the session; picking one imports it through the normal path.
type FolderFont = { name: string; file: File };
let folderFontsCache: FolderFont[] = [];
const folderFontsListeners = new Set<() => void>();
function setFolderFonts(next: FolderFont[]) {
folderFontsCache = next;
for (const listener of folderFontsListeners) listener();
}
function useFolderFonts(): FolderFont[] {
return useSyncExternalStore(
(onChange) => {
folderFontsListeners.add(onChange);
return () => folderFontsListeners.delete(onChange);
},
() => folderFontsCache,
);
}
function fontNameFromFile(fileName: string, taken: Set<string>): string {
const base = fontBaseName(fileName).slice(0, 60) || "Imported font";
let candidate = base;
let suffix = 2;
while (taken.has(candidate)) {
candidate = `${base} ${suffix}`;
suffix += 1;
}
return candidate;
}
/**
* File-import plumbing for the font dropdowns: hidden inputs, validation,
* and persistence. Successful uploads call onImported with the font name
* so the dropdown can select it for its slot right away. Fonts the user
* already has (bundled, imported, or installed on the device) are selected
* directly instead of embedding a duplicate copy.
*/
function useFontImport(onImported: (name: string) => void) {
const t = useT();
const importedFonts = useAppearanceCustomStore(
(s) => s.customization.importedFonts,
);
const addImportedFont = useAppearanceCustomStore((s) => s.addImportedFont);
const inputRef = useRef<HTMLInputElement>(null);
const folderRef = useRef<HTMLInputElement>(null);
// Match the file name against fonts that already exist somewhere.
const findExisting = (fileName: string): string | null => {
for (const candidate of familyCandidates(fontBaseName(fileName))) {
const lower = candidate.toLowerCase();
const imported = importedFonts.find((f) => f.name.toLowerCase() === lower);
if (imported) return imported.name;
const bundled = BUNDLED_FONTS.find((f) => f.toLowerCase() === lower);
if (bundled) return bundled;
if (detectFontsByMeasurement([candidate]).length > 0) return candidate;
}
return null;
};
const importFile = (file: File) => {
const existing = findExisting(file.name);
if (existing) {
toast.info(t("settings.appearance.custom.importFont.alreadyAvailable"));
onImported(existing);
return;
}
if (importedFonts.length >= MAX_IMPORTED_FONTS) {
toast.error(t("settings.appearance.custom.importFont.errorLimit"));
return;
}
const extension = file.name.split(".").pop()?.toLowerCase() ?? "";
const mime = FONT_MIME_BY_EXTENSION[extension];
if (!mime) {
toast.error(t("settings.appearance.custom.importFont.errorInvalidType"));
return;
}
if (file.size > MAX_FONT_FILE_BYTES) {
toast.error(t("settings.appearance.custom.importFont.errorTooLarge"));
return;
}
const reader = new FileReader();
reader.onerror = () => {
toast.error(t("settings.appearance.custom.importFont.errorFailed"));
};
reader.onload = () => {
const result = reader.result;
if (typeof result !== "string") {
toast.error(t("settings.appearance.custom.importFont.errorFailed"));
return;
}
// Rewrite whatever MIME the browser guessed to the extension's font
// type so the stored data URL passes frontend and backend validation.
const base64 = result.slice(result.indexOf(",") + 1);
const dataUrl = `data:${mime};base64,${base64}`;
// Keep the persisted store under the localStorage quota.
const existingTotal = importedFonts.reduce(
(sum, f) => sum + f.dataUrl.length,
0,
);
if (
existingTotal + dataUrl.length >
MAX_TOTAL_IMPORTED_FONT_DATA_URL_LENGTH
) {
toast.error(
t("settings.appearance.custom.importFont.errorStorageFull"),
);
return;
}
const taken = new Set<string>([
...BUNDLED_FONTS,
...importedFonts.map((f) => f.name),
]);
const name = fontNameFromFile(file.name, taken);
// Prove the file is a loadable font before persisting it.
const face = new FontFace(name, `url(${dataUrl})`);
face
.load()
.then(() => {
addImportedFont({ name, dataUrl });
onImported(name);
})
.catch(() => {
toast.error(t("settings.appearance.custom.importFont.errorFailed"));
});
};
reader.readAsDataURL(file);
};
const scanFolder = (files: FileList) => {
const found: FolderFont[] = [];
const seen = new Set<string>();
for (const file of Array.from(files)) {
const extension = file.name.split(".").pop()?.toLowerCase() ?? "";
if (!FONT_MIME_BY_EXTENSION[extension]) continue;
const name = fontBaseName(file.name).slice(0, 60);
if (!name || seen.has(name.toLowerCase())) continue;
seen.add(name.toLowerCase());
found.push({ name, file });
if (found.length >= 200) break;
}
if (found.length === 0) {
toast.info(t("settings.appearance.custom.importFont.folderNoFonts"));
return;
}
found.sort((a, b) => a.name.localeCompare(b.name));
setFolderFonts(found);
};
const requestUpload = () => inputRef.current?.click();
const requestFolder = () => folderRef.current?.click();
const inputs = (
<>
<input
ref={inputRef}
type="file"
accept=".woff2,.woff,.ttf,.otf"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) importFile(file);
e.target.value = "";
}}
/>
<input
ref={folderRef}
type="file"
className="hidden"
// Non-standard but universal directory picker attributes.
{...({ webkitdirectory: "", directory: "" } as object)}
onChange={(e) => {
if (e.target.files) scanFolder(e.target.files);
e.target.value = "";
}}
/>
</>
);
return { inputs, requestUpload, requestFolder, importFile };
}
function FontSizeInput({
value,
range,
onCommit,
ariaLabel,
}: {
value: number | null;
range: { min: number; max: number; default: number };
onCommit: (next: number | null) => void;
ariaLabel: string;
}) {
const [draft, setDraft] = useState(value === null ? "" : String(value));
useEffect(() => {
setDraft(value === null ? "" : String(value));
}, [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;
}
onCommit(Math.min(range.max, Math.max(range.min, parsed)));
};
return (
<div className="flex items-center gap-1.5">
<Input
type="number"
inputMode="numeric"
min={range.min}
max={range.max}
value={draft}
placeholder={String(range.default)}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") {
commit();
e.currentTarget.blur();
}
}}
aria-label={ariaLabel}
className="h-8 w-20 text-xs"
/>
<span className="text-xs text-muted-foreground">px</span>
</div>
);
}
export function UiFontSizeRow() {
const t = useT();
const uiFontSize = useAppearanceCustomStore(
(s) => s.customization.uiFontSize,
);
const patch = useAppearanceCustomStore((s) => s.patch);
return (
<FontSizeInput
value={uiFontSize}
range={UI_FONT_SIZE_RANGE}
onCommit={(next) => patch({ uiFontSize: next })}
ariaLabel={t("settings.appearance.custom.uiFontSize.label")}
/>
);
}
export function CodeFontSizeRow() {
const t = useT();
const codeFontSize = useAppearanceCustomStore(
(s) => s.customization.codeFontSize,
);
const patch = useAppearanceCustomStore((s) => s.patch);
return (
<FontSizeInput
value={codeFontSize}
range={CODE_FONT_SIZE_RANGE}
onCommit={(next) => patch({ codeFontSize: next })}
ariaLabel={t("settings.appearance.custom.codeFontSize.label")}
/>
);
}
export function EdgeFadesSwitch() {
const edgeFades = useAppearanceCustomStore((s) => s.customization.edgeFades);
const patch = useAppearanceCustomStore((s) => s.patch);
return (
<Switch
checked={edgeFades}
onCheckedChange={(checked) => patch({ edgeFades: checked })}
/>
);
}
export function FontSmoothingSwitch() {
const fontSmoothing = useAppearanceCustomStore(
(s) => s.customization.fontSmoothing,
);
const patch = useAppearanceCustomStore((s) => s.patch);
return (
<Switch
checked={fontSmoothing}
onCheckedChange={(checked) => patch({ fontSmoothing: checked })}
/>
);
}
/* ------------------------------ Interface ------------------------------- */
export function ContrastSliderRow() {
const t = useT();
const contrast = useAppearanceCustomStore((s) => s.customization.contrast);
const patch = useAppearanceCustomStore((s) => s.patch);
return (
<div className="flex w-48 items-center gap-3">
<Slider
value={[contrast]}
min={0}
max={100}
step={5}
onValueChange={(values: number[]) => patch({ contrast: values[0] })}
aria-label={t("settings.appearance.custom.contrast.label")}
/>
<span className="w-8 text-right text-xs tabular-nums text-muted-foreground">
{contrast}
</span>
</div>
);
}
export function PointerCursorsSwitch() {
const pointerCursors = useAppearanceCustomStore(
(s) => s.customization.pointerCursors,
);
const patch = useAppearanceCustomStore((s) => s.patch);
return (
<Switch
checked={pointerCursors}
onCheckedChange={(checked) => patch({ pointerCursors: checked })}
/>
);
}
const REDUCE_MOTION_OPTIONS: {
value: ReduceMotionSetting;
labelKey: TranslationKey;
}[] = [
{
value: "system",
labelKey: "settings.appearance.custom.reduceMotion.system",
},
{ value: "on", labelKey: "settings.appearance.custom.reduceMotion.on" },
{ value: "off", labelKey: "settings.appearance.custom.reduceMotion.off" },
];
export function ReduceMotionSegmented() {
const t = useT();
const reduceMotion = useAppearanceCustomStore(
(s) => s.customization.reduceMotion,
);
const patch = useAppearanceCustomStore((s) => s.patch);
const reduced = useReducedMotionConfig();
return (
<div className="hub-tab-toggle inline-flex h-8 items-center rounded-full">
{REDUCE_MOTION_OPTIONS.map((opt) => {
const active = reduceMotion === opt.value;
return (
<button
key={opt.value}
type="button"
onClick={() => patch({ reduceMotion: opt.value })}
aria-pressed={active}
className={cn(
"relative flex h-8 items-center rounded-full px-3 text-xs font-medium transition-colors",
active
? "text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{active && (
<motion.span
layoutId="reduce-motion-pill"
className="hub-tab-toggle-pill absolute inset-0 rounded-full"
transition={
reduced
? { duration: 0 }
: { type: "spring", stiffness: 500, damping: 35, mass: 0.5 }
}
/>
)}
<span className="relative z-10">{t(opt.labelKey)}</span>
</button>
);
})}
</div>
);
}
/* ------------------------------- Reset all ------------------------------ */
export function ResetCustomizationButton() {
const t = useT();
const customization = useAppearanceCustomStore((s) => s.customization);
const resetAll = useAppearanceCustomStore((s) => s.resetAll);
const pristine = isDefaultCustomization(customization);
return (
<Button
type="button"
variant="outline"
size="sm"
disabled={pristine}
onClick={resetAll}
>
{t("settings.appearance.custom.resetAll")}
</Button>
);
}
export { DEFAULT_CUSTOMIZATION };

View file

@ -0,0 +1,270 @@
// 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 {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { useT } from "@/i18n";
import { cn } from "@/lib/utils";
import { ColorPickerIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useEffect, useRef, useState } from "react";
/* ------------------------- HSV ↔ hex conversions ------------------------- */
type Hsv = { h: number; s: number; v: number };
function hexToHsv(hex: string): Hsv {
const r = Number.parseInt(hex.slice(1, 3), 16) / 255;
const g = Number.parseInt(hex.slice(3, 5), 16) / 255;
const b = Number.parseInt(hex.slice(5, 7), 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const d = max - min;
let h = 0;
if (d !== 0) {
if (max === r) h = ((g - b) / d) % 6;
else if (max === g) h = (b - r) / d + 2;
else h = (r - g) / d + 4;
h *= 60;
if (h < 0) h += 360;
}
return { h, s: max === 0 ? 0 : d / max, v: max };
}
function hsvToHex({ h, s, v }: Hsv): string {
const c = v * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = v - c;
let rgb: [number, number, number];
if (h < 60) rgb = [c, x, 0];
else if (h < 120) rgb = [x, c, 0];
else if (h < 180) rgb = [0, c, x];
else if (h < 240) rgb = [0, x, c];
else if (h < 300) rgb = [x, 0, c];
else rgb = [c, 0, x];
const channel = (value: number) =>
Math.round((value + m) * 255)
.toString(16)
.padStart(2, "0");
return `#${channel(rgb[0])}${channel(rgb[1])}${channel(rgb[2])}`;
}
const HEX_PATTERN = /^#?([0-9a-fA-F]{6})$/;
function isLightColor(hex: string): boolean {
const r = Number.parseInt(hex.slice(1, 3), 16);
const g = Number.parseInt(hex.slice(3, 5), 16);
const b = Number.parseInt(hex.slice(5, 7), 16);
return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255 > 0.62;
}
/* ------------------------------- Component ------------------------------- */
type EyeDropperResult = { sRGBHex: string };
type EyeDropperConstructor = new () => {
open: () => Promise<EyeDropperResult>;
};
/**
* In-app color picker in a Popover: saturation/value area, hue slider, hex
* field, and (where supported) a screen eyedropper. Replaces the native
* <input type="color"> so no OS-level color panel is left dangling when the
* user clicks away; the popover dismisses like any other popup.
*/
export function ColorPickerSwatch({
value,
onChange,
label,
}: {
value: string;
onChange: (hex: string) => void;
label: string;
}) {
const t = useT();
const [open, setOpen] = useState(false);
const [hsv, setHsv] = useState<Hsv>(() => hexToHsv(value));
const [hexDraft, setHexDraft] = useState(value);
const areaRef = useRef<HTMLDivElement>(null);
// Re-seed the picker from the outside value each time it opens (the value
// may have changed via reset, palette switch, or remote sync).
useEffect(() => {
if (open) {
setHsv(hexToHsv(value));
setHexDraft(value);
}
}, [open, value]);
const emit = (next: Hsv) => {
setHsv(next);
const hex = hsvToHex(next);
setHexDraft(hex);
onChange(hex);
};
const moveFromPointer = (e: React.PointerEvent) => {
const area = areaRef.current;
if (!area) return;
const rect = area.getBoundingClientRect();
const s = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
const v =
1 - Math.min(1, Math.max(0, (e.clientY - rect.top) / rect.height));
emit({ ...hsv, s, v });
};
const commitHex = () => {
const match = HEX_PATTERN.exec(hexDraft.trim());
if (!match) {
setHexDraft(hsvToHex(hsv));
return;
}
const hex = `#${match[1].toLowerCase()}`;
setHsv(hexToHsv(hex));
setHexDraft(hex);
onChange(hex);
};
const eyeDropperCtor = (
globalThis as { EyeDropper?: EyeDropperConstructor }
).EyeDropper;
const pickFromScreen = async () => {
if (!eyeDropperCtor) return;
try {
const result = await new eyeDropperCtor().open();
const hex = result.sRGBHex.toLowerCase();
if (HEX_PATTERN.test(hex)) {
setHsv(hexToHsv(hex));
setHexDraft(hex);
onChange(hex);
}
} catch {
// user dismissed the eyedropper
}
};
const hueColor = hsvToHex({ h: hsv.h, s: 1, v: 1 });
const light = isLightColor(value);
return (
<Popover open={open} onOpenChange={setOpen} modal={true}>
<PopoverTrigger asChild>
<button
type="button"
aria-label={label}
className={cn(
"flex h-8 w-24 cursor-pointer items-center gap-1.5 rounded-full border px-2.5 font-mono text-xs uppercase transition-colors",
light
? "border-black/10 text-black/80"
: "border-white/15 text-white",
)}
style={{ backgroundColor: value }}
>
<span
className={cn(
"size-3.5 shrink-0 rounded-full border",
light ? "border-black/20" : "border-white/40",
)}
/>
{value.toUpperCase()}
</button>
</PopoverTrigger>
<PopoverContent align="end" className="w-60 rounded-lg p-3">
<div className="flex flex-col gap-3">
<div
ref={areaRef}
role="slider"
aria-label={label}
aria-valuetext={hexDraft}
aria-valuenow={Math.round(hsv.v * 100)}
tabIndex={0}
className="relative h-36 w-full cursor-crosshair touch-none rounded-lg"
style={{
background: `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, ${hueColor})`,
}}
onPointerDown={(e) => {
e.currentTarget.setPointerCapture(e.pointerId);
moveFromPointer(e);
}}
onPointerMove={(e) => {
if (e.buttons === 1) moveFromPointer(e);
}}
onKeyDown={(e) => {
// Arrow keys move the saturation (x) / value (y) selection so the
// area is operable without a pointer; Shift takes coarser steps.
const step = e.shiftKey ? 0.1 : 0.01;
let next: Hsv | null = null;
if (e.key === "ArrowLeft")
next = { ...hsv, s: Math.max(0, hsv.s - step) };
else if (e.key === "ArrowRight")
next = { ...hsv, s: Math.min(1, hsv.s + step) };
else if (e.key === "ArrowDown")
next = { ...hsv, v: Math.max(0, hsv.v - step) };
else if (e.key === "ArrowUp")
next = { ...hsv, v: Math.min(1, hsv.v + step) };
if (next) {
e.preventDefault();
emit(next);
}
}}
>
<span
className="pointer-events-none absolute size-3.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-[0_0_0_1px_rgba(0,0,0,0.35)]"
style={{
left: `${hsv.s * 100}%`,
top: `${(1 - hsv.v) * 100}%`,
backgroundColor: hsvToHex(hsv),
}}
/>
</div>
<input
type="range"
min={0}
max={360}
step={1}
value={Math.round(hsv.h)}
aria-label={t("settings.appearance.custom.colorPicker.hue")}
onChange={(e) => emit({ ...hsv, h: Number(e.target.value) })}
className="h-3 w-full cursor-pointer appearance-none rounded-full outline-none [&::-moz-range-thumb]:size-3.5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-white [&::-moz-range-thumb]:bg-transparent [&::-webkit-slider-thumb]:size-3.5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-white [&::-webkit-slider-thumb]:shadow-[0_0_0_1px_rgba(0,0,0,0.35)]"
style={{
background:
"linear-gradient(to right, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00)",
}}
/>
<div className="flex items-center gap-2">
<input
value={hexDraft.toUpperCase()}
onChange={(e) => setHexDraft(e.target.value)}
onBlur={commitHex}
onKeyDown={(e) => {
if (e.key === "Enter") {
commitHex();
e.currentTarget.blur();
}
}}
aria-label={t("settings.appearance.custom.colorPicker.hex")}
spellCheck={false}
className="h-8 w-full min-w-0 rounded-full border border-border bg-background px-3 font-mono text-xs text-foreground uppercase outline-none focus-visible:border-ring dark:focus-visible:border-transparent dark:focus-visible:bg-white/[0.12] dark:border-transparent dark:bg-white/[0.06]"
/>
{eyeDropperCtor && (
<button
type="button"
onClick={() => void pickFromScreen()}
aria-label={t(
"settings.appearance.custom.colorPicker.eyedropper",
)}
title={t("settings.appearance.custom.colorPicker.eyedropper")}
className="flex size-8 shrink-0 items-center justify-center rounded-full border border-border text-muted-foreground transition-colors hover:text-foreground"
>
<HugeiconsIcon icon={ColorPickerIcon} className="size-4" />
</button>
)}
</div>
</div>
</PopoverContent>
</Popover>
);
}

View file

@ -64,7 +64,7 @@ export function CreateKeyForm({
onClick={() => setExpiry(p.value)}
aria-pressed={active}
className={cn(
"inline-flex h-8 items-center rounded-full px-3.5 text-[12px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
"inline-flex h-8 items-center rounded-full px-3.5 text-[12px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
active
? "hub-tab-toggle-pill text-foreground"
: "text-muted-foreground hover:text-foreground",

View file

@ -43,7 +43,7 @@ export function KeyRevealCard({
onClick={handleCopy}
className={cn(
"flex w-full items-center justify-between gap-3 rounded-md border border-border bg-muted/40 px-3 py-2.5 font-mono text-sm transition-colors hover:bg-muted/60",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
copied && "border-emerald-500/40 bg-emerald-500/10",
)}
aria-label={
@ -68,7 +68,7 @@ export function KeyRevealCard({
type="button"
size="sm"
onClick={onDone}
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background"
className="focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
{t("common.done")}
</Button>

View file

@ -121,7 +121,7 @@ export function ModelAutoSwitchSection() {
>
<div className="flex flex-col items-end gap-1">
<div className="flex items-center gap-2">
<div className="relative w-28">
<div className="flex items-center gap-1.5">
<Input
type="number"
min={0}
@ -130,9 +130,9 @@ export function ModelAutoSwitchSection() {
aria-label="Idle auto-unload seconds"
disabled={!settings?.enabled || isSaving}
onChange={(event) => setDraftIdleSeconds(event.target.value)}
className="h-8 w-full pr-8"
className="h-8 w-24"
/>
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-xs font-medium text-muted-foreground">
<span className="text-xs font-medium text-muted-foreground">
s
</span>
</div>

View file

@ -0,0 +1,147 @@
// 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 { type TranslationKey, useT } from "@/i18n";
import {
type Palette,
type ResolvedTheme,
usePalette,
useTheme,
} from "../stores/theme-store";
type PreviewColors = {
bg: string;
sidebar: string;
accent: string;
text: string;
border: string;
};
// Representative swatches per palette × resolved mode. Hardcoded (rather than
// reading the live CSS variables) so every card previews its own palette while
// only one palette is active on <html>. Sidebar tones are slightly exaggerated
// so the two surfaces stay distinguishable at thumbnail size.
const PREVIEWS: Record<Palette, Record<ResolvedTheme, PreviewColors>> = {
standard: {
light: {
bg: "#fefefd",
sidebar: "#f1f1ef",
accent: "#17b88b",
text: "#444444",
border: "#e4e4e0",
},
dark: {
bg: "#181818",
sidebar: "#262626",
accent: "#17b88b",
text: "#b5b5b5",
border: "#303030",
},
},
classic: {
light: {
bg: "#ffffff",
sidebar: "#ededed",
accent: "#339cff",
text: "#4b4d50",
border: "#e6e6e6",
},
dark: {
bg: "#181818",
sidebar: "#262626",
accent: "#4dabff",
text: "#b5b5b5",
border: "#303030",
},
},
minimal: {
light: {
bg: "#ffffff",
sidebar: "#f2f2f2",
accent: "#171717",
text: "#555555",
border: "#e2e2e2",
},
dark: {
bg: "#181818",
sidebar: "#262626",
accent: "#ededed",
text: "#a8a8a8",
border: "#303030",
},
},
};
const OPTIONS: {
value: Palette;
labelKey: TranslationKey;
}[] = [
{ value: "standard", labelKey: "settings.appearance.palette.standard" },
{ value: "classic", labelKey: "settings.appearance.palette.classic" },
{ value: "minimal", labelKey: "settings.appearance.palette.minimal" },
];
function PalettePreview({ colors }: { colors: PreviewColors }) {
return (
<div
className="h-16 w-full overflow-hidden rounded-lg border"
style={{ backgroundColor: colors.bg, borderColor: colors.border }}
aria-hidden="true"
>
<div className="flex h-full">
<div
className="h-full w-1/4"
style={{ backgroundColor: colors.sidebar }}
/>
<div className="flex flex-1 flex-col justify-center gap-1.5 px-2.5">
<span
className="h-1.5 w-3/4 rounded-full"
style={{ backgroundColor: colors.text }}
/>
<span
className="h-1.5 w-1/2 rounded-full opacity-60"
style={{ backgroundColor: colors.text }}
/>
<span
className="h-2.5 w-8 rounded-full"
style={{ backgroundColor: colors.accent }}
/>
</div>
</div>
</div>
);
}
export function PaletteCards() {
const t = useT();
const { resolved } = useTheme();
const { palette, setPalette } = usePalette();
return (
<div className="grid w-full grid-cols-1 gap-3 sm:grid-cols-3">
{OPTIONS.map((opt) => {
const active = palette === opt.value;
return (
<button
key={opt.value}
type="button"
onClick={() => setPalette(opt.value)}
aria-pressed={active}
data-palette-value={opt.value}
// The active ring is CSS-driven off html[data-palette] (see
// .palette-card in index.css) so it moves in the same style
// pass that swaps the tokens; keying it off React state leaves
// the ring on the old card until the app finishes re-rendering.
className="palette-card flex flex-col gap-2 rounded-xl border border-border p-2.5 text-left transition-colors focus-visible:border-ring focus-visible:outline-none"
>
<PalettePreview colors={PREVIEWS[opt.value][resolved]} />
<div className="flex items-center gap-2 px-0.5">
<span className="text-sm font-medium text-foreground">
{t(opt.labelKey)}
</span>
</div>
</button>
);
})}
</div>
);
}

View file

@ -24,6 +24,7 @@ export function SettingsRow({
}) {
return (
<div
data-settings-label={label}
className={cn(
"flex justify-between gap-6 py-3",
alignTop ? "items-start" : "items-center",

Some files were not shown because too many files have changed in this diff Show more