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:
parent
5de668926c
commit
bc23135996
128 changed files with 6217 additions and 1511 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
22
studio/frontend/package-lock.json
generated
22
studio/frontend/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
27
studio/frontend/public/theme-boot.js
Normal file
27
studio/frontend/public/theme-boot.js
Normal 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) {}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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={{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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<{
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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", {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
: "";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 })}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}`}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue