diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index a0ccc161f25f..6cd8456e8a4a 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -8,7 +8,7 @@ import { List, type ListRef } from "@opencode-ai/ui/list" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { Spinner } from "@opencode-ai/ui/spinner" import { TextField } from "@opencode-ai/ui/text-field" -import { showToast } from "@opencode-ai/ui/toast" +import { showToast } from "@/utils/toast" import { createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js" import { createStore, produce } from "solid-js/store" import { Link } from "@/components/link" diff --git a/packages/app/src/components/dialog-custom-provider.tsx b/packages/app/src/components/dialog-custom-provider.tsx index ad30236b0410..38690bb24106 100644 --- a/packages/app/src/components/dialog-custom-provider.tsx +++ b/packages/app/src/components/dialog-custom-provider.tsx @@ -5,7 +5,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { useMutation } from "@tanstack/solid-query" import { TextField } from "@opencode-ai/ui/text-field" -import { showToast } from "@opencode-ai/ui/toast" +import { showToast } from "@/utils/toast" import { batch, For } from "solid-js" import { createStore, produce } from "solid-js/store" import { Link } from "@/components/link" diff --git a/packages/app/src/components/dialog-fork.tsx b/packages/app/src/components/dialog-fork.tsx index 3618a0581e02..8e17f27d3912 100644 --- a/packages/app/src/components/dialog-fork.tsx +++ b/packages/app/src/components/dialog-fork.tsx @@ -6,7 +6,7 @@ import { usePrompt } from "@/context/prompt" import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" import { List } from "@opencode-ai/ui/list" -import { showToast } from "@opencode-ai/ui/toast" +import { showToast } from "@/utils/toast" import { extractPromptFromParts } from "@/utils/prompt" import type { TextPart as SDKTextPart } from "@opencode-ai/sdk/v2/client" import { base64Encode } from "@opencode-ai/core/util/encode" diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index 0cb5a2d60461..05b839bd818b 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -7,7 +7,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button" import { List } from "@opencode-ai/ui/list" import { TextField } from "@opencode-ai/ui/text-field" import { useMutation } from "@tanstack/solid-query" -import { showToast } from "@opencode-ai/ui/toast" +import { showToast } from "@/utils/toast" import { useNavigate } from "@solidjs/router" import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js" import { createStore, reconcile } from "solid-js/store" diff --git a/packages/app/src/components/prompt-input/attachments.ts b/packages/app/src/components/prompt-input/attachments.ts index f12a4210c082..2b1cd1826c04 100644 --- a/packages/app/src/components/prompt-input/attachments.ts +++ b/packages/app/src/components/prompt-input/attachments.ts @@ -1,6 +1,6 @@ import { onMount } from "solid-js" import { makeEventListener } from "@solid-primitives/event-listener" -import { showToast } from "@opencode-ai/ui/toast" +import { showToast } from "@/utils/toast" import { usePrompt, type ContentPart, type ImageAttachmentPart } from "@/context/prompt" import { useLanguage } from "@/context/language" import { uuid } from "@/utils/uuid" diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 1570da016e4a..004427d0c89e 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -1,5 +1,5 @@ import type { Message, Session } from "@opencode-ai/sdk/v2/client" -import { showToast } from "@opencode-ai/ui/toast" +import { showToast } from "@/utils/toast" import { base64Encode } from "@opencode-ai/core/util/encode" import { Binary } from "@opencode-ai/core/util/binary" import { useNavigate, useParams } from "@solidjs/router" diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 1c2cf47a7664..8fd71c04e318 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -5,7 +5,7 @@ import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { Keybind } from "@opencode-ai/ui/keybind" import { Spinner } from "@opencode-ai/ui/spinner" -import { showToast } from "@opencode-ai/ui/toast" +import { showToast } from "@/utils/toast" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { getFilename } from "@opencode-ai/core/util/path" import { createEffect, createMemo, createSignal, For, onMount, Show } from "solid-js" @@ -25,8 +25,8 @@ import { messageAgentColor } from "@/utils/agent" import { decode64 } from "@/utils/base64" import { Persist, persisted } from "@/utils/persist" import { StatusPopover, StatusPopoverV2 } from "../status-popover" -import { IconButtonV2 } from "@opencode-ai/ui/v2/components/icon-button-v2.jsx" -import { Icon as IconV2 } from "@opencode-ai/ui/v2/components/icon.jsx" +import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" const OPEN_APPS = [ "vscode", diff --git a/packages/app/src/components/session/session-new-design-view.tsx b/packages/app/src/components/session/session-new-design-view.tsx index c351cd9497ad..b1192db0e087 100644 --- a/packages/app/src/components/session/session-new-design-view.tsx +++ b/packages/app/src/components/session/session-new-design-view.tsx @@ -1,11 +1,12 @@ import type { JSX } from "solid-js" -import { WordmarkV2 } from "@opencode-ai/ui/v2/components/wordmark-v2.jsx" +import { WordmarkV2 } from "@opencode-ai/ui/v2/wordmark-v2" +import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout" export function NewSessionDesignView(props: { children: JSX.Element }) { return (
-
+
{props.children}
diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index bd95370a6397..3970f5c21081 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -7,7 +7,8 @@ import { Switch } from "@opencode-ai/ui/switch" import { TextField } from "@opencode-ai/ui/text-field" import { Tooltip } from "@opencode-ai/ui/tooltip" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context" -import { showToast } from "@opencode-ai/ui/toast" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { showToast } from "@/utils/toast" import { useParams } from "@solidjs/router" import { useLanguage } from "@/context/language" import { usePermission } from "@/context/permission" @@ -86,6 +87,7 @@ export const SettingsGeneral: Component = () => { const language = useLanguage() const permission = usePermission() const platform = usePlatform() + const dialog = useDialog() const params = useParams() const settings = useSettings() @@ -407,7 +409,13 @@ export const SettingsGeneral: Component = () => {
settings.general.setNewLayoutDesigns(checked)} + onChange={(checked) => { + settings.general.setNewLayoutDesigns(checked) + if (!checked) return + void import("@/components/settings-v2").then((module) => { + dialog.show(() => ) + }) + }} />
diff --git a/packages/app/src/components/settings-keybinds.tsx b/packages/app/src/components/settings-keybinds.tsx index 149a0309b5ca..98f6c9ffa04c 100644 --- a/packages/app/src/components/settings-keybinds.tsx +++ b/packages/app/src/components/settings-keybinds.tsx @@ -1,17 +1,29 @@ -import { Component, For, Show, createMemo, onCleanup, onMount } from "solid-js" +import { Component, For, Show, createMemo, lazy, onCleanup, onMount } from "solid-js" import { createStore } from "solid-js/store" import { makeEventListener } from "@solid-primitives/event-listener" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { TextField } from "@opencode-ai/ui/text-field" -import { showToast } from "@opencode-ai/ui/toast" +import { showToast } from "@/utils/toast" import fuzzysort from "fuzzysort" import { formatKeybind, parseKeybind, useCommand } from "@/context/command" import { useLanguage } from "@/context/language" import { useSettings } from "@/context/settings" import { SettingsList } from "./settings-list" +const ButtonV2 = lazy(() => import("@opencode-ai/ui/v2/button-v2").then((module) => ({ default: module.ButtonV2 }))) +const IconV2 = lazy(() => import("@opencode-ai/ui/v2/icon").then((module) => ({ default: module.Icon }))) +const IconButtonV2 = lazy(() => + import("@opencode-ai/ui/v2/icon-button-v2").then((module) => ({ default: module.IconButtonV2 })), +) +const TextInputV2 = lazy(() => + import("@opencode-ai/ui/v2/text-input-v2").then((module) => ({ default: module.TextInputV2 })), +) +const SettingsListV2 = lazy(() => + import("./settings-v2/parts/list").then((module) => ({ default: module.SettingsListV2 })), +) + const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) const PALETTE_ID = "command.palette" const DEFAULT_PALETTE_KEYBIND = "mod+shift+p" @@ -257,7 +269,7 @@ function useKeyCapture(input: { }) } -export const SettingsKeybinds: Component = () => { +export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => { const command = useCommand() const language = useLanguage() const settings = useSettings() @@ -371,85 +383,178 @@ export const SettingsKeybinds: Component = () => { if (store.active) command.keybinds(true) }) + const emptyResults = ( + +
+ + {language.t("settings.shortcuts.search.empty")} + + + + "{store.filter}" + + +
+
+ ) + + const List = props.v2 ? SettingsListV2 : SettingsList + + const groups = ( +
+ + {(group) => ( + 0}> +
+

+ {language.t(groupKey[group])} +

+ + + {(id) => ( +
+ + {title(id)} + + +
+ )} +
+
+
+
+ )} +
+ {emptyResults} +
+ ) + return ( -
-
-
-
-

{language.t("settings.shortcuts.title")}

- +
+ +
+ + setStore("filter", v)} + placeholder={language.t("settings.shortcuts.search.placeholder")} + spellcheck={false} + autocorrect="off" + autocomplete="off" + autocapitalize="off" + class="flex-1" + /> + + setStore("filter", "")} /> + +
+
+
+ {groups} +
+ } + > + <> +
+
+

{language.t("settings.shortcuts.title")}

+ {language.t("settings.shortcuts.reset.button")} - +
- -
- - + setStore("filter", v)} + onInput={(event) => setStore("filter", event.currentTarget.value)} placeholder={language.t("settings.shortcuts.search.placeholder")} spellcheck={false} autocorrect="off" autocomplete="off" autocapitalize="off" - class="flex-1" + aria-label={language.t("settings.shortcuts.search.placeholder")} /> - setStore("filter", "")} /> + } + onClick={() => setStore("filter", "")} + />
-
- -
- - {(group) => ( - 0}> -
-

{language.t(groupKey[group])}

- - - {(id) => ( -
- {title(id)} - -
- )} -
-
-
-
- )} -
- - -
- {language.t("settings.shortcuts.search.empty")} - - "{store.filter}" - -
-
-
-
+
{groups}
+ + ) } diff --git a/packages/app/src/components/settings-providers.tsx b/packages/app/src/components/settings-providers.tsx index ffd85f97dce1..256a4dbb3f4d 100644 --- a/packages/app/src/components/settings-providers.tsx +++ b/packages/app/src/components/settings-providers.tsx @@ -2,7 +2,7 @@ import { Button } from "@opencode-ai/ui/button" import { useDialog } from "@opencode-ai/ui/context/dialog" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { Tag } from "@opencode-ai/ui/tag" -import { showToast } from "@opencode-ai/ui/toast" +import { showToast } from "@/utils/toast" import { popularProviders, useProviders } from "@/hooks/use-providers" import { createMemo, type Component, For, Show } from "solid-js" import { useLanguage } from "@/context/language" diff --git a/packages/app/src/components/settings-v2/dialog-settings-v2.tsx b/packages/app/src/components/settings-v2/dialog-settings-v2.tsx new file mode 100644 index 000000000000..d574dcf495f3 --- /dev/null +++ b/packages/app/src/components/settings-v2/dialog-settings-v2.tsx @@ -0,0 +1,80 @@ +import { Component } from "solid-js" +import { Dialog } from "@opencode-ai/ui/v2/dialog-v2" +import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2" +import { Icon } from "@opencode-ai/ui/icon" +import { useLanguage } from "@/context/language" +import { usePlatform } from "@/context/platform" +import { SettingsGeneralV2 } from "./general" +import { SettingsKeybinds } from "../settings-keybinds" +import { SettingsProvidersV2 } from "./providers" +import { SettingsModelsV2 } from "./models" +import "./settings-v2.css" + +export const DialogSettings: Component = () => { + const language = useLanguage() + const platform = usePlatform() + + return ( + + + +
+
+
+
+ {language.t("settings.section.desktop")} +
+ + + {language.t("settings.tab.general")} + + + + {language.t("settings.tab.shortcuts")} + +
+
+ +
+ {language.t("settings.section.server")} +
+ + + {language.t("settings.providers.title")} + + + + {language.t("settings.models.title")} + +
+
+
+
+ +
+
+ + + + + + + + + + + + +
+
+ ) +} diff --git a/packages/app/src/components/settings-v2/general.tsx b/packages/app/src/components/settings-v2/general.tsx new file mode 100644 index 000000000000..71f39ded01b1 --- /dev/null +++ b/packages/app/src/components/settings-v2/general.tsx @@ -0,0 +1,825 @@ +import { Component, Show, createMemo, createResource, onMount } from "solid-js" +import { createStore } from "solid-js/store" +import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" +import { Icon } from "@opencode-ai/ui/icon" +import { SelectV2 } from "@opencode-ai/ui/select-v2" +import { Switch } from "@opencode-ai/ui/v2/switch-v2" +import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" +import { Tooltip } from "@opencode-ai/ui/tooltip" +import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { showToast } from "@/utils/toast" +import { useParams } from "@solidjs/router" +import { useLanguage } from "@/context/language" +import { usePermission } from "@/context/permission" +import { usePlatform, type DisplayBackend } from "@/context/platform" +import { useServerSync } from "@/context/server-sync" +import { useServerSDK } from "@/context/server-sdk" +import { + monoDefault, + monoFontFamily, + monoInput, + sansDefault, + sansFontFamily, + sansInput, + terminalDefault, + terminalFontFamily, + terminalInput, + useSettings, +} from "@/context/settings" +import { decode64 } from "@/utils/base64" +import { playSoundById, SOUND_OPTIONS } from "@/utils/sound" +import { Link } from "../link" +import { SettingsListV2 } from "./parts/list" +import { SettingsRowV2 } from "./parts/row" +import "./settings-v2.css" + +let demoSoundState = { + cleanup: undefined as (() => void) | undefined, + timeout: undefined as NodeJS.Timeout | undefined, + run: 0, +} + +type ThemeOption = { + id: string + name: string +} + +type ShellOption = { + path: string + name: string + acceptable: boolean +} + +type ShellSelectOption = { + id: string + value: string + label: string +} + +// To prevent audio from overlapping/playing very quickly when navigating the settings menus, +// delay the playback by 100ms during quick selection changes and pause existing sounds. +const stopDemoSound = () => { + demoSoundState.run += 1 + if (demoSoundState.cleanup) { + demoSoundState.cleanup() + } + clearTimeout(demoSoundState.timeout) + demoSoundState.cleanup = undefined +} + +const playDemoSound = (id: string | undefined) => { + stopDemoSound() + if (!id) return + + const run = ++demoSoundState.run + demoSoundState.timeout = setTimeout(() => { + void playSoundById(id).then((cleanup) => { + if (demoSoundState.run !== run) { + cleanup?.() + return + } + demoSoundState.cleanup = cleanup + }) + }, 100) +} + +export const SettingsGeneralV2: Component = () => { + const theme = useTheme() + const language = useLanguage() + const permission = usePermission() + const platform = usePlatform() + const dialog = useDialog() + const params = useParams() + const settings = useSettings() + + const [store, setStore] = createStore({ + checking: false, + }) + + const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux") + const dir = createMemo(() => decode64(params.dir)) + const accepting = createMemo(() => { + const value = dir() + if (!value) return false + if (!params.id) return permission.isAutoAcceptingDirectory(value) + return permission.isAutoAccepting(params.id, value) + }) + + const toggleAccept = (checked: boolean) => { + const value = dir() + if (!value) return + + if (!params.id) { + if (permission.isAutoAcceptingDirectory(value) === checked) return + permission.toggleAutoAcceptDirectory(value) + return + } + + if (checked) { + permission.enableAutoAccept(params.id, value) + return + } + + permission.disableAutoAccept(params.id, value) + } + const desktop = createMemo(() => platform.platform === "desktop") + + const check = () => { + if (!platform.checkUpdate) return + setStore("checking", true) + + void platform + .checkUpdate() + .then((result) => { + if (!result.updateAvailable) { + showToast({ + variant: "success", + icon: "circle-check", + title: language.t("settings.updates.toast.latest.title"), + description: language.t("settings.updates.toast.latest.description", { version: platform.version ?? "" }), + }) + return + } + + const actions = platform.updateAndRestart + ? [ + { + label: language.t("toast.update.action.installRestart"), + onClick: async () => { + await platform.updateAndRestart!() + }, + }, + { + label: language.t("toast.update.action.notYet"), + onClick: "dismiss" as const, + }, + ] + : [ + { + label: language.t("toast.update.action.notYet"), + onClick: "dismiss" as const, + }, + ] + + showToast({ + persistent: true, + icon: "download", + title: language.t("toast.update.title"), + description: language.t("toast.update.description", { version: result.version ?? "" }), + actions, + }) + }) + .catch((err: unknown) => { + const message = err instanceof Error ? err.message : String(err) + showToast({ title: language.t("common.requestFailed"), description: message }) + }) + .finally(() => setStore("checking", false)) + } + + const themeOptions = createMemo(() => theme.ids().map((id) => ({ id, name: theme.name(id) }))) + + const globalSync = useServerSync() + const globalSdk = useServerSDK() + + const [shells] = createResource( + () => + globalSdk.client.pty + .shells() + .then((res) => res.data ?? []) + .catch(() => [] as ShellOption[]), + { initialValue: [] as ShellOption[] }, + ) + + const [displayBackend, { refetch: refetchDisplayBackend }] = createResource( + () => (linux() && platform.getDisplayBackend ? true : false), + () => Promise.resolve(platform.getDisplayBackend?.() ?? null).catch(() => null as DisplayBackend | null), + { initialValue: null as DisplayBackend | null }, + ) + + const [pinchZoom, { mutate: setPinchZoom }] = createResource( + () => (desktop() && platform.getPinchZoomEnabled ? true : false), + () => Promise.resolve(platform.getPinchZoomEnabled?.() ?? false).catch(() => false), + { initialValue: false }, + ) + + onMount(() => { + void theme.loadThemes() + }) + + const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") } + const currentShell = createMemo(() => globalSync.data.config.shell ?? "") + + const shellOptions = createMemo(() => { + const list = shells.latest + const current = globalSync.data.config.shell + + const nameCounts = new Map() + for (const s of list) { + nameCounts.set(s.name, (nameCounts.get(s.name) || 0) + 1) + } + + const options = [ + autoOption, + ...list.map((s) => { + const ambiguousName = (nameCounts.get(s.name) || 0) > 1 + const text = ambiguousName ? s.path : s.name + const label = s.acceptable ? text : `${text} (${language.t("settings.general.row.shell.terminalOnly")})` + return { + id: s.path, + // Prefer name over path - "bash" is much cleaner than the explicit full route even when it may change due to PATH. + value: ambiguousName ? s.path : s.name, + label, + } + }), + ] + + if (current && !options.some((o) => o.value === current)) { + options.push({ id: current, value: current, label: current }) + } + + return options + }) + + const onDisplayBackendChange = (checked: boolean) => { + const update = platform.setDisplayBackend?.(checked ? "wayland" : "auto") + if (!update) return + void update.finally(() => { + void refetchDisplayBackend() + }) + } + + const onPinchZoomChange = (checked: boolean) => { + setPinchZoom(checked) + const update = platform.setPinchZoomEnabled?.(checked) + if (!update) return + void update.catch(() => setPinchZoom(!checked)) + } + + const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [ + { value: "system", label: language.t("theme.scheme.system") }, + { value: "light", label: language.t("theme.scheme.light") }, + { value: "dark", label: language.t("theme.scheme.dark") }, + ]) + + const languageOptions = createMemo(() => + language.locales.map((locale) => ({ + value: locale, + label: language.label(locale), + })), + ) + + const noneSound = { id: "none", label: "sound.option.none" } as const + const soundOptions = [noneSound, ...SOUND_OPTIONS] + const mono = () => monoInput(settings.appearance.font()) + const sans = () => sansInput(settings.appearance.uiFont()) + const terminal = () => terminalInput(settings.appearance.terminalFont()) + + const soundSelectProps = ( + enabled: () => boolean, + current: () => string, + setEnabled: (value: boolean) => void, + set: (id: string) => void, + ) => ({ + options: soundOptions, + current: enabled() ? (soundOptions.find((o) => o.id === current()) ?? noneSound) : noneSound, + value: (o: (typeof soundOptions)[number]) => o.id, + label: (o: (typeof soundOptions)[number]) => language.t(o.label), + onHighlight: (option: (typeof soundOptions)[number] | undefined) => { + if (!option) return + playDemoSound(option.id === "none" ? undefined : option.id) + }, + onSelect: (option: (typeof soundOptions)[number] | undefined) => { + if (!option) return + if (option.id === "none") { + setEnabled(false) + stopDemoSound() + return + } + setEnabled(true) + set(option.id) + playDemoSound(option.id) + }, + }) + + const GeneralSection = () => ( +
+ + + o.value === language.locale())} + value={(o) => o.value} + label={(o) => o.label} + onSelect={(option) => option && language.setLocale(option.value)} + /> + + + +
+ +
+
+ + + o.value === currentShell()) ?? autoOption} + value={(o) => o.id} + label={(o) => o.label} + onSelect={(option) => { + if (!option) return + if (option.value === currentShell()) return + globalSync.updateConfig({ shell: option.value }) + }} + /> + + + +
+ settings.general.setShowReasoningSummaries(checked)} + /> +
+
+ + +
+ settings.general.setShellToolPartsExpanded(checked)} + /> +
+
+ + +
+ settings.general.setEditToolPartsExpanded(checked)} + /> +
+
+ + +
+ settings.general.setShowSessionProgressBar(checked)} + /> +
+
+ + +
+ { + settings.general.setNewLayoutDesigns(checked) + if (checked) return + void import("@/components/dialog-settings").then((module) => { + dialog.show(() => ) + }) + }} + /> +
+
+
+
+ ) + + const AdvancedSection = () => ( +
+

{language.t("settings.general.section.advanced")}

+ + + +
+ settings.general.setShowFileTree(checked)} + /> +
+
+ + +
+ settings.general.setShowNavigation(checked)} + /> +
+
+ + +
+ settings.general.setShowSearch(checked)} + /> +
+
+ + +
+ settings.general.setShowTerminal(checked)} + /> +
+
+ + +
+ settings.general.setShowStatus(checked)} + /> +
+
+ + +
+ settings.general.setShowCustomAgents(checked)} + /> +
+
+
+
+ ) + + const AppearanceSection = () => ( +
+

{language.t("settings.general.section.appearance")}

+ + + + o.value === theme.colorScheme())} + value={(o) => o.value} + label={(o) => o.label} + onSelect={(option) => option && theme.setColorScheme(option.value)} + onHighlight={(option) => { + if (!option) return + theme.previewColorScheme(option.value) + return () => theme.cancelPreview() + }} + /> + + + + {language.t("settings.general.row.theme.description")}{" "} + + {language.t("common.learnMore")} + + + } + > + o.id === theme.themeId())} + value={(o) => o.id} + label={(o) => o.name} + onSelect={(option) => { + if (!option) return + theme.setTheme(option.id) + }} + onHighlight={(option) => { + if (!option) return + theme.previewTheme(option.id) + return () => theme.cancelPreview() + }} + /> + + + +
+ settings.appearance.setUIFont(event.currentTarget.value)} + placeholder={sansDefault} + spellcheck={false} + autocorrect="off" + autocomplete="off" + autocapitalize="off" + aria-label={language.t("settings.general.row.uiFont.title")} + style={{ "font-family": sansFontFamily(settings.appearance.uiFont()) }} + /> +
+
+ + +
+ settings.appearance.setFont(event.currentTarget.value)} + placeholder={monoDefault} + spellcheck={false} + autocorrect="off" + autocomplete="off" + autocapitalize="off" + aria-label={language.t("settings.general.row.font.title")} + style={{ "font-family": monoFontFamily(settings.appearance.font()) }} + /> +
+
+ + +
+ settings.appearance.setTerminalFont(event.currentTarget.value)} + placeholder={terminalDefault} + spellcheck={false} + autocorrect="off" + autocomplete="off" + autocapitalize="off" + aria-label={language.t("settings.general.row.terminalFont.title")} + style={{ "font-family": terminalFontFamily(settings.appearance.terminalFont()) }} + /> +
+
+
+
+ ) + + const NotificationsSection = () => ( +
+

{language.t("settings.general.section.notifications")}

+ + + +
+ settings.notifications.setAgent(checked)} + /> +
+
+ + +
+ settings.notifications.setPermissions(checked)} + /> +
+
+ + +
+ settings.notifications.setErrors(checked)} + /> +
+
+
+
+ ) + + const SoundsSection = () => ( +
+

{language.t("settings.general.section.sounds")}

+ + + + settings.sounds.agentEnabled(), + () => settings.sounds.agent(), + (value) => settings.sounds.setAgentEnabled(value), + (id) => settings.sounds.setAgent(id), + )} + /> + + + + settings.sounds.permissionsEnabled(), + () => settings.sounds.permissions(), + (value) => settings.sounds.setPermissionsEnabled(value), + (id) => settings.sounds.setPermissions(id), + )} + /> + + + + settings.sounds.errorsEnabled(), + () => settings.sounds.errors(), + (value) => settings.sounds.setErrorsEnabled(value), + (id) => settings.sounds.setErrors(id), + )} + /> + + +
+ ) + + const UpdatesSection = () => ( +
+

{language.t("settings.general.section.updates")}

+ + + +
+ settings.updates.setStartup(checked)} + /> +
+
+ + +
+ settings.general.setReleaseNotes(checked)} + /> +
+
+ + + + {store.checking + ? language.t("settings.updates.action.checking") + : language.t("settings.updates.action.checkNow")} + + +
+
+ ) + + const DisplaySection = () => ( + +
+

{language.t("settings.general.section.display")}

+ + + +
+ +
+
+ + + + {language.t("settings.general.row.wayland.title")} + + + + + +
+ } + description={language.t("settings.general.row.wayland.description")} + > +
+ +
+ +
+ +
+ + ) + + return ( + <> +
+

{language.t("settings.tab.general")}

+
+ +
+ + + + + + + + + + + + + + + +
+ + ) +} diff --git a/packages/app/src/components/settings-v2/index.tsx b/packages/app/src/components/settings-v2/index.tsx new file mode 100644 index 000000000000..8ff588437987 --- /dev/null +++ b/packages/app/src/components/settings-v2/index.tsx @@ -0,0 +1 @@ +export { DialogSettings } from "./dialog-settings-v2" diff --git a/packages/app/src/components/settings-v2/models.tsx b/packages/app/src/components/settings-v2/models.tsx new file mode 100644 index 000000000000..a3f058670e4a --- /dev/null +++ b/packages/app/src/components/settings-v2/models.tsx @@ -0,0 +1,138 @@ +import { useFilteredList } from "@opencode-ai/ui/hooks" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { Switch } from "@opencode-ai/ui/v2/switch-v2" +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" +import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" +import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" +import { type Component, For, Show } from "solid-js" +import { useLanguage } from "@/context/language" +import { useModels } from "@/context/models" +import { popularProviders } from "@/hooks/use-providers" +import { SettingsListV2 } from "./parts/list" +import { SettingsRowV2 } from "./parts/row" +import "./settings-v2.css" + +type ModelItem = ReturnType["list"]>[number] + +const PROVIDER_ICON_SIZE = 16 + +export const SettingsModelsV2: Component = () => { + const language = useLanguage() + const models = useModels() + + const list = useFilteredList({ + items: (_filter) => models.list(), + key: (x) => `${x.provider.id}:${x.id}`, + filterKeys: ["provider.name", "name", "id"], + sortBy: (a, b) => a.name.localeCompare(b.name), + groupBy: (x) => x.provider.id, + sortGroupsBy: (a, b) => { + const aIndex = popularProviders.indexOf(a.category) + const bIndex = popularProviders.indexOf(b.category) + const aPopular = aIndex >= 0 + const bPopular = bIndex >= 0 + + if (aPopular && !bPopular) return -1 + if (!aPopular && bPopular) return 1 + if (aPopular && bPopular) return aIndex - bIndex + + const aName = a.items[0].provider.name + const bName = b.items[0].provider.name + return aName.localeCompare(bName) + }, + }) + + return ( + <> +
+

{language.t("settings.models.title")}

+ +
+ +
+ + {language.t("common.loading")} + {language.t("common.loading.ellipsis")} +
+ } + > + 0} + fallback={ +
+ {language.t("dialog.model.empty")} + + "{list.filter()}" + +
+ } + > + + {(group) => ( +
+
+ +

{group.items[0].provider.name}

+
+ + + {(item) => { + const key = { providerID: item.provider.id, modelID: item.id } + return ( + +
+ { + models.setVisibility(key, checked) + }} + hideLabel + > + {item.name} + +
+
+ ) + }} +
+
+
+ )} +
+
+ + + + ) +} diff --git a/packages/app/src/components/settings-v2/parts/list.tsx b/packages/app/src/components/settings-v2/parts/list.tsx new file mode 100644 index 000000000000..2ebbdbe98fcc --- /dev/null +++ b/packages/app/src/components/settings-v2/parts/list.tsx @@ -0,0 +1,6 @@ +import type { Component, JSX } from "solid-js" +import "../settings-v2.css" + +export const SettingsListV2: Component<{ children: JSX.Element }> = (props) => { + return
{props.children}
+} diff --git a/packages/app/src/components/settings-v2/parts/row.tsx b/packages/app/src/components/settings-v2/parts/row.tsx new file mode 100644 index 000000000000..2c48240bb378 --- /dev/null +++ b/packages/app/src/components/settings-v2/parts/row.tsx @@ -0,0 +1,20 @@ +import type { Component, JSX } from "solid-js" +import "../settings-v2.css" + +export interface SettingsRowV2Props { + title: string | JSX.Element + description: string | JSX.Element + children: JSX.Element +} + +export const SettingsRowV2: Component = (props) => { + return ( +
+
+
{props.title}
+
{props.description}
+
+
{props.children}
+
+ ) +} diff --git a/packages/app/src/components/settings-v2/providers.tsx b/packages/app/src/components/settings-v2/providers.tsx new file mode 100644 index 000000000000..397166be7593 --- /dev/null +++ b/packages/app/src/components/settings-v2/providers.tsx @@ -0,0 +1,263 @@ +import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" +import { Tag } from "@opencode-ai/ui/v2/badge-v2" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { showToast } from "@/utils/toast" +import { popularProviders, useProviders } from "@/hooks/use-providers" +import { createMemo, type Component, For, Show } from "solid-js" +import { useLanguage } from "@/context/language" +import { useServerSDK } from "@/context/server-sdk" +import { useServerSync } from "@/context/server-sync" +import { DialogConnectProvider } from "../dialog-connect-provider" +import { DialogSelectProvider } from "../dialog-select-provider" +import { DialogCustomProvider } from "../dialog-custom-provider" +import { SettingsListV2 } from "./parts/list" +import "./settings-v2.css" + +type ProviderSource = "env" | "api" | "config" | "custom" +type ProviderItem = ReturnType["connected"]>[number] + +const PROVIDER_NOTES = [ + { match: (id: string) => id === "opencode", key: "dialog.provider.opencode.note" }, + { match: (id: string) => id === "opencode-go", key: "dialog.provider.opencodeGo.tagline" }, + { match: (id: string) => id === "anthropic", key: "dialog.provider.anthropic.note" }, + { match: (id: string) => id.startsWith("github-copilot"), key: "dialog.provider.copilot.note" }, + { match: (id: string) => id === "openai", key: "dialog.provider.openai.note" }, + { match: (id: string) => id === "google", key: "dialog.provider.google.note" }, + { match: (id: string) => id === "openrouter", key: "dialog.provider.openrouter.note" }, + { match: (id: string) => id === "vercel", key: "dialog.provider.vercel.note" }, +] as const + +const PROVIDER_ICON_SIZE = 16 + +export const SettingsProvidersV2: Component = () => { + const dialog = useDialog() + const language = useLanguage() + const globalSDK = useServerSDK() + const globalSync = useServerSync() + const providers = useProviders() + + const connected = createMemo(() => { + return providers + .connected() + .filter((p) => p.id !== "opencode" || Object.values(p.models).find((m) => m.cost?.input)) + }) + + const popular = createMemo(() => { + const connectedIDs = new Set(connected().map((p) => p.id)) + const items = providers + .popular() + .filter((p) => !connectedIDs.has(p.id)) + .slice() + items.sort((a, b) => popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)) + return items + }) + + const source = (item: ProviderItem): ProviderSource | undefined => { + if (!("source" in item)) return + const value = item.source + if (value === "env" || value === "api" || value === "config" || value === "custom") return value + return + } + + const type = (item: ProviderItem) => { + const current = source(item) + if (current === "env") return language.t("settings.providers.tag.environment") + if (current === "api") return language.t("provider.connect.method.apiKey") + if (current === "config") { + if (isConfigCustom(item.id)) return language.t("settings.providers.tag.custom") + return language.t("settings.providers.tag.config") + } + if (current === "custom") return language.t("settings.providers.tag.custom") + return language.t("settings.providers.tag.other") + } + + const canDisconnect = (item: ProviderItem) => source(item) !== "env" + + const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key + + const isConfigCustom = (providerID: string) => { + const provider = globalSync.data.config.provider?.[providerID] + if (!provider) return false + if (provider.npm !== "@ai-sdk/openai-compatible") return false + if (!provider.models || Object.keys(provider.models).length === 0) return false + return true + } + + const disableProvider = async (providerID: string, name: string) => { + const before = globalSync.data.config.disabled_providers ?? [] + const next = before.includes(providerID) ? before : [...before, providerID] + globalSync.set("config", "disabled_providers", next) + + await globalSync + .updateConfig({ disabled_providers: next }) + .then(() => { + showToast({ + variant: "success", + icon: "circle-check", + title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }), + description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }), + }) + }) + .catch((err: unknown) => { + globalSync.set("config", "disabled_providers", before) + const message = err instanceof Error ? err.message : String(err) + showToast({ title: language.t("common.requestFailed"), description: message }) + }) + } + + const disconnect = async (providerID: string, name: string) => { + if (isConfigCustom(providerID)) { + await globalSDK.client.auth.remove({ providerID }).catch(() => undefined) + await disableProvider(providerID, name) + return + } + await globalSDK.client.auth + .remove({ providerID }) + .then(async () => { + await globalSDK.client.global.dispose() + showToast({ + variant: "success", + icon: "circle-check", + title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }), + description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }), + }) + }) + .catch((err: unknown) => { + const message = err instanceof Error ? err.message : String(err) + showToast({ title: language.t("common.requestFailed"), description: message }) + }) + } + + return ( + <> +
+

{language.t("settings.providers.title")}

+
+ +
+
+

{language.t("settings.providers.section.connected")}

+ + 0} + fallback={ +
{language.t("settings.providers.connected.empty")}
+ } + > + + {(item) => ( +
+
+ +
+ {item.name} + {type(item)} +
+
+ + {language.t("settings.providers.connected.environmentDescription")} + + } + > + void disconnect(item.id, item.name)}> + {language.t("common.disconnect")} + + +
+ )} +
+
+
+
+ +
+

{language.t("settings.providers.section.popular")}

+ + + {(item) => ( +
+
+ +
+
+ {item.name} + + {language.t("dialog.provider.tag.recommended")} + +
+ + {(key) =>

{language.t(key())}

} +
+
+
+ { + dialog.show(() => ) + }} + > + {language.t("common.connect")} + +
+ )} +
+ +
+
+ +
+
+ {language.t("provider.custom.title")} + {language.t("settings.providers.tag.custom")} +
+

{language.t("settings.providers.custom.description")}

+
+
+ { + dialog.show(() => ) + }} + > + {language.t("common.connect")} + +
+
+ + +
+
+ + ) +} diff --git a/packages/app/src/components/settings-v2/settings-v2.css b/packages/app/src/components/settings-v2/settings-v2.css new file mode 100644 index 000000000000..58a394aab767 --- /dev/null +++ b/packages/app/src/components/settings-v2/settings-v2.css @@ -0,0 +1,515 @@ +@import "@opencode-ai/ui/v2/text-input-v2.css"; +@import "@opencode-ai/ui/v2/button-v2.css"; + +[data-component="settings-v2"] { + height: 100%; +} + +[data-component="settings-v2-dialog"] [data-slot="dialog-body"] { + padding: 0; + overflow: hidden; +} + +.settings-v2-panel { + display: flex; + flex-direction: column; + height: 100%; + overflow-y: auto; + scrollbar-width: none; +} + +.settings-v2-panel::-webkit-scrollbar { + display: none; +} + +.settings-v2-tab-header { + position: sticky; + top: 0; + z-index: 10; + padding: 40px 40px 32px; + background: linear-gradient(to bottom, var(--v2-background-bg-base) calc(100% - 24px), transparent); +} + +.settings-v2-tab-title { + font-size: 15px; + font-weight: 640; + line-height: 1; + color: var(--v2-text-text-base); +} + +.settings-v2-tab-body { + display: flex; + flex-direction: column; + gap: 36px; + width: 100%; + padding: 0 40px 40px; +} + +[data-slot="settings-v2-row-description"] a.settings-v2-link { + color: var(--v2-text-text-accent); + cursor: pointer; + text-decoration: none; +} + +[data-slot="settings-v2-row-description"] a.settings-v2-link:hover { + color: var(--v2-text-text-accent-hover); +} + +.settings-v2-section { + display: flex; + flex-direction: column; + gap: 16px; +} + +.settings-v2-section-title { + padding-bottom: 8px; + font-size: 15px; + font-weight: 640; + line-height: 1; + color: var(--v2-text-text-base); +} + +.settings-v2-section-title + [data-component="settings-v2-list"] { + margin-top: -4px; + margin-bottom: 0; +} + +[data-component="settings-v2-list"] { + border-radius: 8px; + background-color: var(--v2-background-bg-layer-01); + padding-inline: 20px; + box-shadow: inset 0 0 0 0.5px var(--v2-border-border-muted); +} + +[data-slot="dialog-container"]:has(.settings-v2-dialog) { + box-shadow: var(--v2-elevation-overlay); +} + +[data-component="settings-v2-row"] { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 16px; + padding-block: 20px; + border-bottom: 0.5px solid var(--v2-border-border-base); +} + +[data-component="settings-v2-row"]:last-child { + border-bottom: none; +} + +@media (min-width: 640px) { + [data-component="settings-v2-row"] { + flex-wrap: nowrap; + } +} + +[data-slot="settings-v2-row-copy"] { + display: flex; + min-width: 0; + flex: 1; + flex-direction: column; + gap: 8px; +} + +[data-slot="settings-v2-row-title"] { + font-style: normal; + font-size: 13px; + font-weight: 530; + line-height: 1; + letter-spacing: -0.04px; + color: var(--v2-text-text-base); + font-variation-settings: "slnt" 0; +} + +[data-slot="settings-v2-row-description"] { + font-size: 11px; + font-weight: 440; + line-height: 1; + color: var(--v2-text-text-muted); +} + +[data-slot="settings-v2-row-control"] { + display: flex; + width: 100%; + justify-content: flex-end; +} + +[data-slot="settings-v2-row-control"] > div:has([data-component="switch"]), +[data-slot="settings-v2-row-control"] > [data-component="switch"] { + display: inline-flex; + align-items: center; + padding: 4px; +} + +@media (min-width: 640px) { + [data-slot="settings-v2-row-control"] { + width: auto; + flex-shrink: 0; + } +} + +[data-slot="settings-v2-row-control"] [data-component="text-input-v2"] { + width: 100%; +} + +[data-component="settings-v2-dialog"] [data-component="select"][data-trigger-style="settings-v2"] { + width: fit-content; + max-width: 100%; +} + +[data-component="settings-v2-dialog"] + [data-component="select"][data-trigger-style="settings-v2"] + [data-component="button-v2"] { + width: fit-content; + max-width: 100%; +} + +[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"] [data-slot="tabs-v2-list"] { + background-color: var(--v2-background-bg-layer-01); +} + +.settings-v2-nav-footer { + display: flex; + flex-direction: column; + gap: 8px; + padding: 4px 0 4px 4px; +} + +.settings-v2-nav-footer > span { + font-size: 11px; + font-weight: 440; + line-height: 1; + color: var(--v2-text-text-faint); +} + +.settings-v2-legacy-panel { + height: 100%; + overflow: hidden; +} + +.settings-v2-legacy-panel [data-component="dialog"] { + display: contents; +} + +.settings-v2-provider-row { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 16px; + padding-block: 20px; + border-bottom: 0.5px solid var(--v2-border-border-base); +} + +.settings-v2-provider-row:last-child { + border-bottom: none; +} + +@media (min-width: 640px) { + .settings-v2-provider-row { + flex-wrap: nowrap; + } +} + +.settings-v2-providers [data-component="provider-icon"] { + color: var(--v2-icon-icon-base); +} + +.settings-v2-provider-lead { + display: flex; + min-width: 0; + flex: 1; + align-items: flex-start; + gap: 10px; +} + +.settings-v2-provider-lead:not(:has(.settings-v2-provider-copy)) { + align-items: center; +} + +.settings-v2-provider-copy { + display: flex; + min-width: 0; + flex: 1; + flex-direction: column; + gap: 6px; +} + +.settings-v2-provider-main { + display: flex; + min-width: 0; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.settings-v2-provider-name { + font-size: 13px; + font-weight: 530; + line-height: 16px; + color: var(--v2-text-text-base); +} + +.settings-v2-provider-description { + margin: 0; + font-size: 13px; + font-weight: 440; + line-height: 1; + color: var(--v2-text-text-muted); +} + +.settings-v2-provider-empty { + padding-block: 20px; + font-size: 13px; + font-weight: 440; + line-height: 1; + color: var(--v2-text-text-muted); +} + +.settings-v2-provider-env-hint { + padding-right: 12px; + font-size: 13px; + font-weight: 440; + line-height: 1; + color: var(--v2-text-text-muted); + opacity: 0; + transition: opacity 200ms ease; + cursor: default; +} + +.group:hover .settings-v2-provider-env-hint { + opacity: 1; +} + +.settings-v2-providers-view-all { + margin-top: 20px; + padding: 0; + border: 0; + background: transparent; + font-size: 13px; + font-weight: 530; + line-height: 1; + color: var(--v2-text-text-accent); + cursor: pointer; + text-align: left; +} + +.settings-v2-providers-view-all:hover { + color: var(--v2-text-text-accent-hover); +} + +.settings-v2-tab-body.settings-v2-providers { + gap: 32px; +} + +.settings-v2-tab-header:has(+ .settings-v2-tab-body.settings-v2-providers) { + padding-bottom: 32px; +} + +.settings-v2-providers .settings-v2-section-title { + padding-bottom: 0; + font-size: 13px; + font-weight: 530; + line-height: 1; +} + +.settings-v2-providers .settings-v2-section-title + [data-component="settings-v2-list"] { + margin-top: 16px; +} + +.settings-v2-tab-header.settings-v2-tab-header--stacked { + display: flex; + flex-direction: column; + gap: 32px; + padding-bottom: 32px; +} + +.settings-v2-tab-header--stacked > .settings-v2-tab-header-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.settings-v2-tab-search { + position: relative; + width: 100%; +} + +.settings-v2-tab-search [data-component="text-input-v2"] { + width: 100%; +} + +.settings-v2-tab-search [data-slot="text-input-v2-input"] { + padding-right: 28px; +} + +.settings-v2-tab-search-clear { + position: absolute; + top: 50%; + right: 6px; + z-index: 1; + transform: translateY(-50%); +} + +.settings-v2-tab-body.settings-v2-models { + gap: 24px; +} + +.settings-v2-models-group-header { + display: flex; + align-items: center; + gap: 8px; + padding-bottom: 8px; +} + +.settings-v2-models .settings-v2-section-title { + padding-bottom: 0; + font-size: 13px; + font-weight: 530; + line-height: 16px; +} + +.settings-v2-models [data-component="provider-icon"] { + color: var(--v2-icon-icon-base); +} + +.settings-v2-models .settings-v2-section-title + [data-component="settings-v2-list"] { + margin-top: 0; +} + +.settings-v2-models [data-slot="settings-v2-row-description"]:empty { + display: none; +} + +.settings-v2-models [data-slot="settings-v2-row-copy"] { + gap: 0; +} + +.settings-v2-models [data-slot="settings-v2-row-title"] { + min-width: 0; + overflow: hidden; + font-size: 13px; + font-weight: 440; + line-height: 1; + text-overflow: ellipsis; + white-space: nowrap; +} + +.settings-v2-models-status { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + padding-block: 48px; + font-size: 13px; + font-weight: 440; + line-height: 1; + color: var(--v2-text-text-muted); + text-align: center; +} + +.settings-v2-models-status-filter { + color: var(--v2-text-text-base); +} + +.settings-v2-shortcuts .settings-v2-section { + gap: 16px; +} + +.settings-v2-shortcuts .settings-v2-section-title { + padding-bottom: 0; + font-size: 13px; + font-weight: 530; + line-height: 1; +} + +.settings-v2-shortcuts [data-component="settings-v2-list"] { + display: flex; + flex-direction: column; + gap: 0; + padding: 20px; + border-radius: 6px; +} + +.settings-v2-shortcuts [data-component="settings-v2-list"] > div { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding-top: 0; + padding-bottom: 0; + border-bottom: none; +} + +.settings-v2-shortcuts [data-component="settings-v2-list"] > div:not(:last-child) { + padding-bottom: 16px; + margin-bottom: 16px; + border-bottom: 0.5px solid var(--v2-border-border-base); +} + +.settings-v2-shortcuts [data-component="settings-v2-list"] > div > span { + font-weight: 440; + font-size: 13px; + line-height: 1; + letter-spacing: -0.04px; + color: var(--v2-text-text-base); + font-variation-settings: "slnt" 0; +} + +.settings-v2-keybind-button { + box-sizing: border-box; + flex-shrink: 0; + padding: 6px 8px; + margin: -6px -8px; + border: 0; + border-radius: 2px; + background: transparent; + cursor: pointer; + font-style: normal; + font-weight: 530; + font-size: 11px; + line-height: 1; + letter-spacing: 0.05px; + font-variant-numeric: tabular-nums; + font-feature-settings: + "tnum" on, + "lnum" on; + font-variation-settings: "slnt" 0; + color: var(--v2-text-text-faint); +} + +.settings-v2-keybind-button:hover { + background-color: var(--v2-background-bg-layer-02); +} + +.settings-v2-keybind-button:focus-visible { + outline: 2px solid var(--v2-border-border-focus); + outline-offset: 2px; +} + +.settings-v2-keybind-button--active { + color: var(--v2-text-text-faint); + border-radius: 2px; + background-color: var(--v2-background-bg-layer-02); +} + +.settings-v2-shortcuts-status { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + padding-block: 48px; + font-size: 13px; + font-weight: 440; + line-height: 1; + color: var(--v2-text-text-muted); + text-align: center; +} + +.settings-v2-shortcuts-status-filter { + color: var(--v2-text-text-base); +} diff --git a/packages/app/src/components/status-popover-body.tsx b/packages/app/src/components/status-popover-body.tsx index e0d1b01c89ec..80933b812e46 100644 --- a/packages/app/src/components/status-popover-body.tsx +++ b/packages/app/src/components/status-popover-body.tsx @@ -4,7 +4,7 @@ import { Icon } from "@opencode-ai/ui/icon" import { Switch } from "@opencode-ai/ui/switch" import { Tabs } from "@opencode-ai/ui/tabs" import { useMutation, useQueryClient } from "@tanstack/solid-query" -import { showToast } from "@opencode-ai/ui/toast" +import { showToast } from "@/utils/toast" import { useNavigate } from "@solidjs/router" import { type Accessor, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js" import { createStore } from "solid-js/store" diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx index 9c071347fb74..c602fbb002fc 100644 --- a/packages/app/src/components/status-popover.tsx +++ b/packages/app/src/components/status-popover.tsx @@ -1,7 +1,7 @@ import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" -import { IconButtonV2 } from "@opencode-ai/ui/v2/components/icon-button-v2.jsx" -import { Icon as IconV2 } from "@opencode-ai/ui/v2/components/icon.jsx" +import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { Popover } from "@opencode-ai/ui/popover" import { Suspense, createMemo, createSignal, lazy, Show, type JSX } from "solid-js" import { useLanguage } from "@/context/language" diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 6dae9de9550b..035eb3285695 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -2,7 +2,7 @@ import { withAlpha } from "@opencode-ai/ui/theme/color" import { useTheme } from "@opencode-ai/ui/theme/context" import { resolveThemeVariant } from "@opencode-ai/ui/theme/resolve" import type { HexColor } from "@opencode-ai/ui/theme/types" -import { showToast } from "@opencode-ai/ui/toast" +import { showToast } from "@/utils/toast" import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web" import { type ComponentProps, createEffect, createMemo, onCleanup, onMount, splitProps } from "solid-js" import { SerializeAddon } from "@/addons/serialize" diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index d7a2653848d2..511375c1be0f 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -6,10 +6,10 @@ import { Icon } from "@opencode-ai/ui/icon" import { Button } from "@opencode-ai/ui/button" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { useTheme } from "@opencode-ai/ui/theme/context" -import { IconButtonV2 } from "@opencode-ai/ui/v2/components/icon-button-v2.jsx" -import { Icon as IconV2 } from "@opencode-ai/ui/v2/components/icon.jsx" +import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" -import { getAvatarColors, useLayout, type LocalProject } from "@/context/layout" +import { getProjectAvatarVariant, useLayout, type LocalProject } from "@/context/layout" import { usePlatform } from "@/context/platform" import { useCommand } from "@/context/command" import { useLanguage } from "@/context/language" @@ -20,8 +20,9 @@ import { useServerSync } from "@/context/server-sync" import { decodeDirectory } from "@/pages/directory-layout" import { iife } from "@opencode-ai/core/util/iife" import { base64Encode } from "@opencode-ai/core/util/encode" -import { Avatar as AvatarV2 } from "@opencode-ai/ui/v2/components/avatar-v2.jsx" +import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2" import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers" +import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state" import { makeEventListener } from "@solid-primitives/event-listener" import { StatusPopoverV2 } from "@/components/status-popover" import { @@ -370,22 +371,28 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { return true } - makeEventListener( - document, - "keydown", - (event) => { - if (!event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return - if (event.key.toLowerCase() !== "w") return - if (!(closeCurrentSessionTab() || closeNewSessionTab())) return - - event.preventDefault() - event.stopPropagation() - }, - { capture: true }, - ) + const openNewTab = () => navigate(newSessionHref()) + + const closeActiveTab = () => closeCurrentSessionTab() || closeNewSessionTab() command.register(() => { const commands = [ + { + id: "tab.new", + category: "tab", + title: language.t("command.session.new"), + keybind: "mod+t", + hidden: true, + onSelect: openNewTab, + }, + { + id: "tab.close", + category: "tab", + title: language.t("command.tab.close"), + keybind: "mod+w", + hidden: true, + onSelect: closeActiveTab, + }, { id: `tab.prev`, category: "tab", @@ -489,6 +496,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { title={tab.info.title} project={projectForSession(tab.info, projects(), projectByID())} directory={tab.dir} + sessionId={tab.info.id} onClose={() => tabsStoreActions.removeTab(tab.href)} /> @@ -736,26 +744,39 @@ function TabNavItem(props: { title: string project?: LocalProject directory: string + sessionId: string + hideClose?: boolean onClose: () => void }) { const match = useMatch(() => props.href) const isActive = () => !!match() + const closeTab = (event: MouseEvent) => { + event.preventDefault() + event.stopPropagation() + props.onClose() + } return (
{ + if (event.button !== 1) return + closeTab(event) + }} > - - {props.title} + + + + {props.title} -
+
} />
@@ -773,22 +794,35 @@ function TabNavItem(props: { ) } -function ProjectTabAvatar(props: { project?: LocalProject; directory: string }) { +function ProjectTabAvatar(props: { project?: LocalProject; directory: string; sessionId: string }) { + const directory = () => props.directory + const sessionId = () => props.sessionId + const state = useSessionTabAvatarState(directory, sessionId) return ( - ) } function NewSessionTabItem(props: { href: string; title: string; onClose: () => void }) { + const closeTab = (event: MouseEvent) => { + event.preventDefault() + event.stopPropagation() + props.onClose() + } return ( -
+
{ + if (event.button !== 1) return + closeTab(event) + }} + > event.preventDefault() event.stopPropagation() }} - onClick={(event) => { - event.preventDefault() - event.stopPropagation() - props.onClose() - }} + onClick={closeTab} icon={} aria-label="Close tab" /> diff --git a/packages/app/src/components/windows-app-menu.tsx b/packages/app/src/components/windows-app-menu.tsx index 417f0cd1a400..017ac4cf8dea 100644 --- a/packages/app/src/components/windows-app-menu.tsx +++ b/packages/app/src/components/windows-app-menu.tsx @@ -2,8 +2,8 @@ import { Show, type JSX } from "solid-js" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" -import { IconButtonV2 } from "@opencode-ai/ui/v2/components/icon-button-v2.jsx" -import { Icon as IconV2 } from "@opencode-ai/ui/v2/components/icon.jsx" +import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { useCommand } from "@/context/command" import { DESKTOP_MENU, desktopMenuVisible, type DesktopMenuAction, type DesktopMenuEntry } from "@/desktop-menu" diff --git a/packages/app/src/context/file.tsx b/packages/app/src/context/file.tsx index 0298e3416afd..49173c49d635 100644 --- a/packages/app/src/context/file.tsx +++ b/packages/app/src/context/file.tsx @@ -1,7 +1,7 @@ import { batch, createEffect, createMemo, onCleanup } from "solid-js" import { createStore, produce, reconcile } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" -import { showToast } from "@opencode-ai/ui/toast" +import { showToast } from "@/utils/toast" import { useParams } from "@solidjs/router" import { getFilename } from "@opencode-ai/core/util/path" import { useSDK } from "./sdk" diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index ccbfc2aae924..0ded34fe280e 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -9,7 +9,7 @@ import type { Session, Todo, } from "@opencode-ai/sdk/v2/client" -import { showToast } from "@opencode-ai/ui/toast" +import { showToast } from "@/utils/toast" import { getFilename } from "@opencode-ai/core/util/path" import { retry } from "@opencode-ai/core/util/retry" import { batch } from "solid-js" diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 9306d362d2e0..4dc611f0ad04 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -12,6 +12,9 @@ import { decode64 } from "@/utils/base64" import { same } from "@/utils/same" import { createScrollPersistence, type SessionScroll } from "./layout-scroll" import { createPathHelpers } from "./file/path" +import type { ProjectAvatarVariant } from "@opencode-ai/ui/v2/project-avatar-v2" + +export type { ProjectAvatarVariant } const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const const DEFAULT_SIDEBAR_WIDTH = 344 @@ -33,6 +36,16 @@ export function getAvatarColors(key?: string) { } } +export function getProjectAvatarVariant(key?: string): ProjectAvatarVariant { + if (key === "orange") return "orange" + if (key === "pink") return "pink" + if (key === "cyan") return "cyan" + if (key === "purple") return "purple" + if (key === "mint") return "cyan" + if (key === "lime") return "green" + return "gray" +} + type SessionTabs = { active?: string all: string[] diff --git a/packages/app/src/context/server-sync.tsx b/packages/app/src/context/server-sync.tsx index ad7e700cd7e3..a572ec274ee1 100644 --- a/packages/app/src/context/server-sync.tsx +++ b/packages/app/src/context/server-sync.tsx @@ -1,5 +1,5 @@ import type { Config, OpencodeClient, Path, Project, ProviderAuthResponse, Todo } from "@opencode-ai/sdk/v2/client" -import { showToast } from "@opencode-ai/ui/toast" +import { showToast } from "@/utils/toast" import { getFilename } from "@opencode-ai/core/util/path" import { batch, createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js" import { createStore, produce, reconcile } from "solid-js/store" diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 29f662f73270..76c96dcc02af 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -531,6 +531,8 @@ export const dict = { "home.projects": "Projects", "home.project.add": "Add project", "home.sessions.search.placeholder": "Search sessions", + "home.sessions.search.sessions": "Sessions", + "home.sessions.search.noResults": "No sessions found for {{query}}", "home.sessions.empty": "No sessions found", "home.sessions.empty.description": "Start a new session for this project", "home.sessions.group.today": "Today", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index f5c962a990c4..663f2ccf768f 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -502,6 +502,8 @@ export const dict = { "home.projects": "项目", "home.project.add": "添加项目", "home.sessions.search.placeholder": "搜索会话", + "home.sessions.search.sessions": "会话", + "home.sessions.search.noResults": "未找到与 {{query}} 相关的会话", "home.sessions.empty": "未找到会话", "home.sessions.group.today": "今天", "home.sessions.group.yesterday": "昨天", diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index 4759f44c3b4b..efa40db10330 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -1,5 +1,5 @@ import { DataProvider } from "@opencode-ai/ui/context" -import { showToast } from "@opencode-ai/ui/toast" +import { showToast } from "@/utils/toast" import { base64Encode } from "@opencode-ai/core/util/encode" import { useLocation, useNavigate, useParams } from "@solidjs/router" import { createEffect, createMemo, createResource, type ParentProps, Show } from "solid-js" diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index 8e31ac3913e7..db1818b640ae 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -1,16 +1,18 @@ import type { Session } from "@opencode-ai/sdk/v2/client" -import { createMemo, createSignal, For, Match, Show, Switch } from "solid-js" +import { createEffect, createMemo, For, Match, on, onCleanup, onMount, Show, Switch } from "solid-js" +import { makeEventListener } from "@solid-primitives/event-listener" import { createStore } from "solid-js/store" import { useQuery } from "@tanstack/solid-query" import { Button } from "@opencode-ai/ui/button" import { Logo } from "@opencode-ai/ui/logo" import { Spinner } from "@opencode-ai/ui/spinner" -import { Avatar as AvatarV2 } from "@opencode-ai/ui/v2/components/avatar-v2.jsx" -import { ButtonV2 } from "@opencode-ai/ui/v2/components/button-v2.jsx" -import { Icon as IconV2 } from "@opencode-ai/ui/v2/components/icon.jsx" -import { IconButtonV2 } from "@opencode-ai/ui/v2/components/icon-button-v2.jsx" -import { MenuV2 } from "@opencode-ai/ui/v2/components/menu-v2.jsx" -import { getAvatarColors, useLayout, type LocalProject } from "@/context/layout" +import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2" +import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" +import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" +import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" +import { TabStateIndicator } from "@opencode-ai/ui/v2/tab-state-indicator" +import { getProjectAvatarVariant, useLayout, type LocalProject } from "@/context/layout" import { useNavigate } from "@solidjs/router" import { base64Encode } from "@opencode-ai/core/util/encode" import { Icon } from "@opencode-ai/ui/icon" @@ -21,6 +23,7 @@ import { DialogSelectDirectory } from "@/components/dialog-select-directory" import { DialogSelectServer } from "@/components/dialog-select-server" import { ServerConnection, useServer } from "@/context/server" import { useServerSync } from "@/context/server-sync" +import { useServers } from "@/context/servers" import { useLanguage } from "@/context/language" import { useNotification } from "@/context/notification" import { usePermission } from "@/context/permission" @@ -29,14 +32,17 @@ import { sessionTitle } from "@/utils/session-title" import { pathKey } from "@/utils/path-key" import { messageAgentColor } from "@/utils/agent" import { sessionPermissionRequest } from "@/pages/session/composer/session-request-tree" -import { ServerHealthIndicator } from "@/components/server/server-row" -import { useServers } from "@/context/servers" +import { useCommand } from "@/context/command" import { useSettings } from "@/context/settings" +import { ServerHealthIndicator } from "@/components/server/server-row" const HOME_SESSION_LIMIT = 15 -const HOME_ROW = - "flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] border-0 bg-transparent text-left text-v2-text-text-muted transition-colors duration-[120ms] ease-in-out hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none" -const HOME_PROJECT_NAV_ROW = `${HOME_ROW} h-7 gap-2 px-1.5 [&>span]:min-w-0 [&>span]:overflow-hidden [&>span]:text-ellipsis [&>span]:whitespace-nowrap` +const HOME_ROW_LAYOUT = + "flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] bg-transparent text-left transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out focus-visible:outline-none" +const HOME_ROW_BASE = `${HOME_ROW_LAYOUT} border-0` +const HOME_ROW = `${HOME_ROW_BASE} [font-weight:530] text-v2-text-text-muted hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover` +const HOME_PROJECT_NAV_LABEL = "min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap" +const HOME_PROJECT_NAV_ROW = `${HOME_ROW_LAYOUT} h-7 gap-2 px-1.5 [font-weight:440] text-v2-text-text-muted hover:bg-v2-background-bg-layer-01 hover:text-v2-text-text-base hover:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)] data-[selected]:bg-v2-background-bg-layer-02 data-[selected]:text-v2-text-text-base data-[selected]:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)] data-[selected]:hover:bg-v2-background-bg-layer-02 focus-visible:bg-v2-background-bg-layer-01 focus-visible:text-v2-text-text-base focus-visible:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)]` const HOME_SECTION_LABEL = "text-v2-text-text-muted [font-weight:440]" type HomeSessionRecord = { @@ -51,6 +57,48 @@ type HomeSessionGroup = { sessions: HomeSessionRecord[] } +const HOME_SESSION_SEARCH_RESULTS_ID = "home-session-search-results" +const HOME_SEARCH_RESULT_ROW = + "flex h-10 w-full shrink-0 cursor-default items-center gap-2 border-0 py-3 pl-4 pr-6 text-left transition-[background-color] duration-[120ms] ease-in-out hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none" +const HOME_SEARCH_RESULT_TITLE = + "min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-base [font-weight:530]" +const HOME_SEARCH_RESULT_META = + "min-w-0 flex-[1_1_auto] overflow-hidden text-ellipsis whitespace-nowrap text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-muted [font-weight:440]" + +function buildHomeSessionRecords(input: { + sync: ReturnType + projectDirectories: () => string[] + projects: () => LocalProject[] + projectByID: () => Map +}) { + return [ + ...new Map( + input + .projectDirectories() + .flatMap((directory) => sortedRootSessions(input.sync.child(directory, { bootstrap: false })[0], Date.now())) + .map((session) => [`${pathKey(session.directory)}:${session.id}`, session] as const), + ).values(), + ] + .sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created)) + .flatMap((session) => { + const project = projectForSession(session, input.projects(), input.projectByID()) + if (!project) return [] + return { + session, + project, + projectName: displayName(project), + } + }) +} + +function matchesHomeSessionSearch(record: HomeSessionRecord, query: string) { + return `${record.session.title} ${record.projectName}`.toLowerCase().includes(query) +} + +function homeSessionSearchKey(record: HomeSessionRecord) { + return `${pathKey(record.session.directory)}:${record.session.id}` +} + export default function Home() { const settings = useSettings() return ( @@ -68,15 +116,21 @@ function HomeDesign() { const navigate = useNavigate() const server = useServer() const language = useLanguage() + const command = useCommand() const notification = useNotification() - const [state, setState] = createStore({ search: "", project: undefined as string | undefined }) + let focusSessionSearch: (() => void) | undefined + const [state, setState] = createStore({ + search: "", + project: undefined as string | undefined, + searchFocused: false, + }) const projects = createMemo(() => layout.projects.list()) const selectedProject = createMemo(() => projects().find((project) => project.worktree === state.project)) const directories = (project: LocalProject) => [project.worktree, ...(project.sandboxes ?? [])] const projectDirectories = createMemo(() => { const project = selectedProject() - if (!project) return [...projects().flatMap((project) => directories(project))] + if (!project) return projects().flatMap(directories) return directories(project) }) const search = createMemo(() => state.search.trim()) @@ -91,36 +145,46 @@ function HomeDesign() { const projectByID = createMemo( () => new Map(projects().flatMap((project) => (project.id ? [[project.id, project] as const] : []))), ) - const records = createMemo(() => { - return [ - ...new Map( - projectDirectories() - .flatMap((directory) => sortedRootSessions(sync.child(directory, { bootstrap: false })[0], Date.now())) - .map((session) => [`${pathKey(session.directory)}:${session.id}`, session] as const), - ).values(), - ] - .sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created)) - .flatMap((session) => { - const project = projectForSession(session, projects(), projectByID()) - if (!project) return [] - return { - session, - project, - projectName: displayName(project), - } - }) - .filter((record) => { - const value = search().toLowerCase() - if (!value) return true - return `${record.session.title} ${record.projectName}`.toLowerCase().includes(value) - }) - .slice(0, HOME_SESSION_LIMIT) + const allRecords = createMemo(() => + buildHomeSessionRecords({ + sync, + projectDirectories, + projects, + projectByID, + }), + ) + const records = createMemo(() => allRecords().slice(0, HOME_SESSION_LIMIT)) + const searchResults = createMemo(() => { + const query = search().toLowerCase() + if (!query) return [] + return allRecords().filter((record) => matchesHomeSessionSearch(record, query)) }) + const searchOpen = createMemo(() => state.searchFocused && search().length > 0) const groups = createMemo(() => groupSessions(records(), language)) + function closeSearch() { + setState("search", "") + setState("searchFocused", false) + } + + function selectSearchSession(session: Session) { + openSession(session) + closeSearch() + } + + command.register("home", () => [ + { + id: "home.sessions.search.focus", + title: language.t("home.sessions.search.placeholder"), + keybind: "mod+f", + hidden: true, + onSelect: () => focusSessionSearch?.(), + }, + ]) + function selectProject(directory: string) { if (!projects().some((project) => project.worktree === directory)) return - setState("project", directory) + setState("project", state.project === directory ? undefined : directory) } function addProject(directory: string) { @@ -146,19 +210,21 @@ function HomeDesign() { navigate(`/${base64Encode(directory)}/session`) } - const showEditProjectDialog = (project: LocalProject) => { + function editProject(project: LocalProject) { void import("@/components/dialog-edit-project").then((x) => { dialog.show(() => ) }) } - const unseenCount = (project: LocalProject) => - directories(project).reduce((total, directory) => total + notification.project.unseenCount(directory), 0) + function unseenCount(project: LocalProject) { + return directories(project).reduce((total, directory) => total + notification.project.unseenCount(directory), 0) + } - const clearNotifications = (project: LocalProject) => + function clearNotifications(project: LocalProject) { directories(project) .filter((directory) => notification.project.unseenCount(directory) > 0) .forEach((directory) => notification.project.markViewed(directory)) + } function openSession(session: Session) { const project = projectForSession(session, projects(), projectByID()) @@ -193,7 +259,7 @@ function HomeDesign() { } function openSettings() { - void import("@/components/dialog-settings").then((x) => { + void import("@/components/settings-v2").then((x) => { dialog.show(() => ) }) } @@ -201,11 +267,12 @@ function HomeDesign() { return (
void chooseProject()} - editProject={showEditProjectDialog} + editProject={editProject} closeProject={(directory) => { layout.projects.close(directory) if (state.project === directory) setState("project", undefined) @@ -217,74 +284,60 @@ function HomeDesign() { language={language} /> -
- 0} - fallback={ - void chooseProject()} - /> - } - > - setState("search", value)} - clearLabel={language.t("common.clear")} - onClear={() => setState("search", "")} - /> -
-
+
+ { + focusSessionSearch = focus + }} + onInput={(value) => setState("search", value)} + onFocus={() => setState("searchFocused", true)} + onClose={closeSearch} + onSelect={selectSearchSession} + /> +
+
+ }> } + when={groups().length > 0} + fallback={ +
+ +
+ } > - 0} - fallback={ - - } - > - - {(group, index) => ( -
- -
- - {(record) => } - -
+ + {(group, index) => ( +
+ +
+ + {(record) => } +
- )} - - +
+ )} +
-
+
- +
) } function HomeProjectColumn(props: { - selectedProject?: string + projects: LocalProject[] + selected?: string selectProject: (directory: string) => void openNewSession: (directory: string) => void chooseProject: () => void @@ -297,10 +350,8 @@ function HomeProjectColumn(props: { language: ReturnType }) { const servers = useServers() - const layout = useLayout() - const projects = createMemo(() => layout.projects.list()) return ( -
) } -function HomeEmptyState(props: { - icon: Parameters[0]["name"] - title: string - description: string - action: string - onAction: () => void +function HomeSessionSearchResultRow(props: { + record: HomeSessionRecord + selected: boolean + onHighlight: () => void + onSelect: (session: Session) => void }) { + const globalSync = useServerSync() + const notification = useNotification() + const permission = usePermission() + const [sessionStore] = globalSync.child(props.record.session.directory, { bootstrap: false }) + const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id) + const unseenCount = createMemo(() => notification.session.unseenCount(props.record.session.id)) + const hasError = createMemo(() => notification.session.unseenHasError(props.record.session.id)) + const hasPermissions = createMemo( + () => + !!sessionPermissionRequest(sessionStore.session, sessionStore.permission, props.record.session.id, (item) => { + return !permission.autoResponds(item, props.record.session.directory) + }), + ) + const isWorking = createMemo(() => { + if (hasPermissions()) return false + return sessionStore.session_working(props.record.session.id) + }) + const tint = createMemo(() => messageAgentColor(sessionStore.message[props.record.session.id], sessionStore.agent)) + const showStatus = createMemo(() => isWorking() || hasPermissions() || hasError() || unseenCount() > 0) + + const key = () => homeSessionSearchKey(props.record) + return ( -
-
- -
-
-
{props.title}
-
{props.description}
+
+ } + > +
+ + + + + +
+ + +
+ + 0}> +
+ + +
+ +
+ + {title()} + + + {props.record.projectName} +
- - {props.action} - -
+ ) } function HomeSessionGroupHeader(props: { title: string; onNewSession?: () => void }) { const language = useLanguage() return ( -
+
{(onNewSession) => ( {language.t("command.session.new")} @@ -578,10 +863,10 @@ function HomeSessionGroupHeader(props: { title: string; onNewSession?: () => voi } function HomeSessionRow(props: { record: HomeSessionRecord; openSession: (session: Session) => void }) { - const serverSync = useServerSync() + const globalSync = useServerSync() const notification = useNotification() const permission = usePermission() - const [sessionStore] = serverSync.child(props.record.session.directory, { bootstrap: false }) + const [sessionStore] = globalSync.child(props.record.session.directory, { bootstrap: false }) const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id) const unseenCount = createMemo(() => notification.session.unseenCount(props.record.session.id)) const hasError = createMemo(() => notification.session.unseenHasError(props.record.session.id)) @@ -605,7 +890,14 @@ function HomeSessionRow(props: { record: HomeSessionRecord; openSession: (sessio class={`${HOME_ROW} h-10 gap-2 px-6 py-3 pl-4`} onClick={() => props.openSession(props.record.session)} > - + + +
+ } + >
sync.data.path.home) const recent = createMemo(() => { @@ -802,50 +1094,3 @@ function LegacyHome() {
) } - -function ProjectList(props: { - projects: LocalProject[] - selectedProject?: string - onSelectedProjectChange?(project: string): void - onChooseProject?(): void - openNewSession: (directory: string) => void - editProject: (project: LocalProject) => void - closeProject: (directory: string) => void - clearNotifications: (project: LocalProject) => void - unseenCount: (project: LocalProject) => number - language: ReturnType -}) { - return ( - 0} - fallback={ - - } - > -
- - {(project) => ( - props.onSelectedProjectChange?.(directory)} - openNewSession={props.openNewSession} - editProject={props.editProject} - closeProject={props.closeProject} - clearNotifications={props.clearNotifications} - language={props.language} - /> - )} - -
-
- ) -} diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 70154a34de7f..c875b2f43217 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -34,7 +34,8 @@ import { createStore, produce, reconcile } from "solid-js/store" import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" import type { DragEvent } from "@thisbeyond/solid-dnd" import { useProviders } from "@/hooks/use-providers" -import { showToast, Toast, toaster } from "@opencode-ai/ui/toast" +import { toaster } from "@opencode-ai/ui/toast" +import { setV2Toast, showToast, ToastRegion } from "@/utils/toast" import { useServerSDK } from "@/context/server-sdk" import { clearWorkspaceTerminals, getTerminalServerScope } from "@/context/terminal" import { dropSessionCaches, pickSessionCacheEvictions } from "@/context/global-sync/session-cache" @@ -128,6 +129,7 @@ export default function Layout(props: ParentProps) { const theme = useTheme() const language = useLanguage() const newDesign = createMemo(() => settings.general.newLayoutDesigns()) + createEffect(() => setV2Toast(newDesign())) const initialDirectory = decode64(params.dir) const location = useLocation() const route = createMemo(() => { @@ -1227,7 +1229,10 @@ export default function Layout(props: ParentProps) { function openSettings() { const run = ++dialogRun - void import("@/components/dialog-settings").then((x) => { + const module = settings.general.newLayoutDesigns() + ? import("@/components/settings-v2") + : import("@/components/dialog-settings") + void module.then((x) => { if (dialogDead || dialogRun !== run) return dialog.show(() => ) }) @@ -2380,7 +2385,7 @@ export default function Layout(props: ParentProps) { {import.meta.env.DEV && } - +
} > @@ -2533,7 +2538,7 @@ export default function Layout(props: ParentProps) {
{import.meta.env.DEV && }
- +
) diff --git a/packages/app/src/pages/layout/project-avatar-state.ts b/packages/app/src/pages/layout/project-avatar-state.ts new file mode 100644 index 000000000000..0f8c6c7026ed --- /dev/null +++ b/packages/app/src/pages/layout/project-avatar-state.ts @@ -0,0 +1,24 @@ +import { createMemo, type Accessor } from "solid-js" +import { useServerSync } from "@/context/server-sync" +import { useNotification } from "@/context/notification" +import { usePermission } from "@/context/permission" +import { sessionPermissionRequest } from "@/pages/session/composer/session-request-tree" + +export function useSessionTabAvatarState(directory: Accessor, sessionId: Accessor) { + const globalSync = useServerSync() + const notification = useNotification() + const permission = usePermission() + const hasPermissions = createMemo(() => { + const [store] = globalSync.child(directory(), { bootstrap: false }) + return !!sessionPermissionRequest(store.session, store.permission, sessionId(), (item) => { + return !permission.autoResponds(item, directory()) + }) + }) + const unread = createMemo(() => hasPermissions() || notification.session.unseenCount(sessionId()) > 0) + const loading = createMemo(() => { + if (hasPermissions()) return false + const [store] = globalSync.child(directory(), { bootstrap: false }) + return store.session_working(sessionId()) + }) + return { unread, loading } +} diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 312b5c3a4d8e..63c83dd41594 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -28,7 +28,7 @@ import { Tabs } from "@opencode-ai/ui/tabs" import { createAutoScroll } from "@opencode-ai/ui/hooks" import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge" import { Button } from "@opencode-ai/ui/button" -import { showToast } from "@opencode-ai/ui/toast" +import { showToast } from "@/utils/toast" import { checksum } from "@opencode-ai/core/util/encode" import { useLocation, useSearchParams } from "@solidjs/router" import { NewSessionDesignView, NewSessionView, SessionHeader } from "@/components/session" diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx index 6f731234c410..5ff5e99a4577 100644 --- a/packages/app/src/pages/session/composer/session-composer-region.tsx +++ b/packages/app/src/pages/session/composer/session-composer-region.tsx @@ -17,6 +17,7 @@ import type { SessionComposerState } from "@/pages/session/composer/session-comp import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock" import type { FollowupDraft } from "@/components/prompt-input/submit" import { createResizeObserver } from "@solid-primitives/resize-observer" +import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout" export function SessionComposerRegion(props: { state: SessionComposerState @@ -150,8 +151,9 @@ export function SessionComposerRegion(props: { >
diff --git a/packages/app/src/pages/session/composer/session-composer-state.ts b/packages/app/src/pages/session/composer/session-composer-state.ts index 2287fd674b3d..0fbbee82436e 100644 --- a/packages/app/src/pages/session/composer/session-composer-state.ts +++ b/packages/app/src/pages/session/composer/session-composer-state.ts @@ -2,7 +2,7 @@ import { createEffect, createMemo, on, onCleanup } from "solid-js" import { createStore } from "solid-js/store" import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2" import { useParams } from "@solidjs/router" -import { showToast } from "@opencode-ai/ui/toast" +import { showToast } from "@/utils/toast" import { useServerSync } from "@/context/server-sync" import { useLanguage } from "@/context/language" import { usePermission } from "@/context/permission" diff --git a/packages/app/src/pages/session/composer/session-question-dock.tsx b/packages/app/src/pages/session/composer/session-question-dock.tsx index 03a66ea3a7dd..8fe3c0352cf6 100644 --- a/packages/app/src/pages/session/composer/session-question-dock.tsx +++ b/packages/app/src/pages/session/composer/session-question-dock.tsx @@ -4,7 +4,7 @@ import { useMutation } from "@tanstack/solid-query" import { Button } from "@opencode-ai/ui/button" import { DockPrompt } from "@opencode-ai/ui/dock-prompt" import { Icon } from "@opencode-ai/ui/icon" -import { showToast } from "@opencode-ai/ui/toast" +import { showToast } from "@/utils/toast" import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2" import { useLanguage } from "@/context/language" import { useSDK } from "@/context/sdk" diff --git a/packages/app/src/pages/session/file-tabs.tsx b/packages/app/src/pages/session/file-tabs.tsx index 65b076d7c630..364760eab0bc 100644 --- a/packages/app/src/pages/session/file-tabs.tsx +++ b/packages/app/src/pages/session/file-tabs.tsx @@ -11,7 +11,7 @@ import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { IconButton } from "@opencode-ai/ui/icon-button" import { Tabs } from "@opencode-ai/ui/tabs" import { ScrollView } from "@opencode-ai/ui/scroll-view" -import { showToast } from "@opencode-ai/ui/toast" +import { showToast } from "@/utils/toast" import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file" import { useComments } from "@/context/comments" import { useLanguage } from "@/context/language" diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index e071597c8ab1..e4ae4a23e364 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -48,7 +48,7 @@ import type { ToolPart, UserMessage, } from "@opencode-ai/sdk/v2" -import { showToast } from "@opencode-ai/ui/toast" +import { showToast } from "@/utils/toast" import { Binary } from "@opencode-ai/core/util/binary" import { getDirectory, getFilename } from "@opencode-ai/core/util/path" import { Popover as KobaltePopover } from "@kobalte/core/popover" diff --git a/packages/app/src/pages/session/new-session-layout.ts b/packages/app/src/pages/session/new-session-layout.ts index f99558ddfef0..7429c7c7e888 100644 --- a/packages/app/src/pages/session/new-session-layout.ts +++ b/packages/app/src/pages/session/new-session-layout.ts @@ -1,3 +1,6 @@ +/** Inline new-session content width — keep in sync with session composer `placement === "inline"`. */ +export const NEW_SESSION_CONTENT_WIDTH = "w-full max-w-[720px] px-0" + export function shouldUseV2NewSessionPage(input: { newLayoutDesigns: boolean; sessionID?: string }) { return input.newLayoutDesigns && !input.sessionID } diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index a0252eb8d6ff..73307c108957 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -13,7 +13,7 @@ import { useSDK } from "@/context/sdk" import { useSettings } from "@/context/settings" import { useSync } from "@/context/sync" import { useTerminal } from "@/context/terminal" -import { showToast } from "@opencode-ai/ui/toast" +import { showToast } from "@/utils/toast" import { findLast } from "@opencode-ai/core/util/array" import { createSessionTabs } from "@/pages/session/helpers" import { extractPromptFromParts } from "@/utils/prompt" diff --git a/packages/app/src/utils/toast.tsx b/packages/app/src/utils/toast.tsx new file mode 100644 index 000000000000..e44454850823 --- /dev/null +++ b/packages/app/src/utils/toast.tsx @@ -0,0 +1,34 @@ +import { Icon, type IconProps } from "@opencode-ai/ui/icon" +import { Toast, showToast as showLegacyToast, type ToastOptions, type ToastVariant } from "@opencode-ai/ui/toast" +import { ToastV2, showToastV2 } from "@opencode-ai/ui/v2/toast-v2" + +let v2 = false + +export function setV2Toast(value: boolean) { + v2 = value +} + +export function ToastRegion(props: { v2: boolean }) { + if (props.v2) return + return +} + +export function showToast(options: ToastOptions | string) { + if (!v2) return showLegacyToast(options) + if (typeof options === "string") return showToastV2(options) + + return showToastV2({ + ...options, + icon: resolveIcon(options.icon, options.variant), + actions: options.actions?.map((action) => ({ + ...action, + variant: action.onClick === "dismiss" ? "secondary" : "primary", + })), + }) +} + +function resolveIcon(icon: IconProps["name"] | undefined, variant: ToastVariant | undefined) { + const name = icon ?? (variant === "success" ? "check" : undefined) + if (!name) return + return +} diff --git a/packages/ui/package.json b/packages/ui/package.json index 8f2275ce51bb..fae1ea3d8192 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -23,7 +23,8 @@ "./icons/app": "./src/components/app-icons/types.ts", "./fonts/*": "./src/assets/fonts/*", "./audio/*": "./src/assets/audio/*", - "./v2/*": "./src/v2/*" + "./v2/*": "./src/v2/components/*.tsx", + "./v2/styles/*": "./src/v2/styles/*" }, "scripts": { "typecheck": "tsgo --noEmit", @@ -47,8 +48,8 @@ }, "dependencies": { "@kobalte/core": "catalog:", - "@opencode-ai/sdk": "workspace:*", "@opencode-ai/core": "workspace:*", + "@opencode-ai/sdk": "workspace:*", "@pierre/diffs": "catalog:", "@shikijs/transformers": "3.9.2", "@solid-primitives/bounds": "0.1.3", diff --git a/packages/ui/src/components/select-v2.css b/packages/ui/src/components/select-v2.css new file mode 100644 index 000000000000..b148debfa3da --- /dev/null +++ b/packages/ui/src/components/select-v2.css @@ -0,0 +1,87 @@ +[data-component="select"][data-trigger-style="settings-v2"] [data-slot="select-select-trigger"] { + display: flex; + flex-direction: row; + align-items: center; + gap: 4px; + width: fit-content; + height: 24px; + padding: 4px 4px 4px 8px; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--v2-text-text-base); + cursor: pointer; +} + +[data-component="select"][data-trigger-style="settings-v2"] [data-slot="select-select-trigger-value"] { + display: flex; + flex-direction: row; + align-items: center; + width: fit-content; + height: 13px; + padding: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-style: normal; + font-weight: 530; + font-size: 13px; + line-height: 1; + letter-spacing: -0.04px; + font-variant-numeric: tabular-nums; + font-variation-settings: "slnt" 0; +} + +[data-component="select"][data-trigger-style="settings-v2"] [data-slot="select-select-trigger"]:focus-visible { + outline: 2px solid var(--v2-border-border-focus); + outline-offset: 2.5px; +} + +[data-component="select"][data-trigger-style="settings-v2"] + [data-slot="select-select-trigger"]:is(:hover, [data-state="hover"]):not(:disabled) { + background-color: var(--v2-overlay-simple-overlay-hover); +} + +[data-component="select"][data-trigger-style="settings-v2"] + [data-slot="select-select-trigger"]:is(:active, [data-state="pressed"]):not(:disabled) { + background-color: var(--v2-overlay-simple-overlay-pressed); +} + +[data-component="select"][data-trigger-style="settings-v2"] [data-slot="select-select-trigger"]:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +[data-component="select"][data-trigger-style="settings-v2"] [data-slot="select-select-trigger-icon"] { + display: flex; + width: 16px; + height: 16px; + flex-shrink: 0; + align-items: center; + justify-content: center; +} + +[data-component="select"][data-trigger-style="settings-v2"] + [data-slot="select-select-trigger-icon"] + [data-slot="icon-svg"] { + margin-inline: -5px; + color: #3a3a3a; +} + +[data-component="select-content"][data-trigger-style="settings-v2"] { + min-width: 160px; + border-radius: 8px; + padding: 0; + + [data-slot="select-select-content-list"] { + padding: 4px; + } + + [data-slot="select-select-item"] { + font-size: var(--font-size-base); + font-style: normal; + font-weight: var(--font-weight-regular); + line-height: var(--line-height-large); + letter-spacing: var(--letter-spacing-normal); + } +} diff --git a/packages/ui/src/components/select-v2.stories.tsx b/packages/ui/src/components/select-v2.stories.tsx new file mode 100644 index 000000000000..c5941ada1276 --- /dev/null +++ b/packages/ui/src/components/select-v2.stories.tsx @@ -0,0 +1,22 @@ +// @ts-nocheck +import * as mod from "./select-v2" +import { create } from "../storybook/scaffold" + +const story = create({ + title: "UI/SelectV2", + mod, + args: { + options: ["One", "Two", "Three"], + current: "One", + placeholder: "Choose...", + }, +}) + +export default { + title: "UI/SelectV2", + id: "components-select-v2", + component: story.meta.component, + tags: ["autodocs"], +} + +export const Basic = story.Basic diff --git a/packages/ui/src/components/select-v2.tsx b/packages/ui/src/components/select-v2.tsx new file mode 100644 index 000000000000..f737d57e4d7b --- /dev/null +++ b/packages/ui/src/components/select-v2.tsx @@ -0,0 +1,171 @@ +import { Select as Kobalte } from "@kobalte/core/select" +import { createMemo, onCleanup, splitProps, type ComponentProps, type JSX } from "solid-js" +import { pipe, groupBy, entries, map } from "remeda" +import { Icon as IconV2 } from "../v2/components/icon" +import { Icon } from "./icon" +import "./select-v2.css" + +export type SelectV2Props = Omit>, "value" | "onSelect" | "children"> & { + placeholder?: string + options: T[] + current?: T + value?: (x: T) => string + label?: (x: T) => string + groupBy?: (x: T) => string + valueClass?: ComponentProps<"div">["class"] + onSelect?: (value: T | undefined) => void + onHighlight?: (value: T | undefined) => (() => void) | void + class?: ComponentProps<"div">["class"] + classList?: ComponentProps<"div">["classList"] + children?: (item: T | undefined) => JSX.Element + triggerStyle?: JSX.CSSProperties + triggerProps?: Record +} + +export function SelectV2(props: SelectV2Props & { disabled?: boolean }) { + const [local, others] = splitProps(props, [ + "class", + "classList", + "placeholder", + "options", + "current", + "value", + "label", + "groupBy", + "valueClass", + "onSelect", + "onHighlight", + "onOpenChange", + "children", + "triggerStyle", + "triggerProps", + ]) + + const state = { + key: undefined as string | undefined, + cleanup: undefined as (() => void) | void, + } + + const stop = () => { + state.cleanup?.() + state.cleanup = undefined + state.key = undefined + } + + const keyFor = (item: T) => (local.value ? local.value(item) : (item as string)) + + const move = (item: T | undefined) => { + if (!local.onHighlight) return + if (!item) { + stop() + return + } + + const key = keyFor(item) + if (state.key === key) return + state.cleanup?.() + state.cleanup = local.onHighlight(item) + state.key = key + } + + onCleanup(stop) + + const grouped = createMemo(() => { + const result = pipe( + local.options, + groupBy((x) => (local.groupBy ? local.groupBy(x) : "")), + entries(), + map(([k, v]) => ({ category: k, options: v })), + ) + return result + }) + + return ( + // @ts-ignore + + {...others} + data-component="select" + data-trigger-style="settings-v2" + placement="bottom-end" + gutter={4} + value={local.current} + options={grouped()} + optionValue={(x) => (local.value ? local.value(x) : (x as string))} + optionTextValue={(x) => (local.label ? local.label(x) : (x as string))} + optionGroupChildren="options" + placeholder={local.placeholder} + sectionComponent={(local) => ( + {local.section.rawValue.category} + )} + itemComponent={(itemProps) => ( + move(itemProps.item.rawValue)} + onPointerMove={() => move(itemProps.item.rawValue)} + onFocus={() => move(itemProps.item.rawValue)} + > + + {local.children + ? local.children(itemProps.item.rawValue) + : local.label + ? local.label(itemProps.item.rawValue) + : (itemProps.item.rawValue as string)} + + + + + + )} + onChange={(v) => { + local.onSelect?.(v ?? undefined) + stop() + }} + onOpenChange={(open) => { + local.onOpenChange?.(open) + if (!open) stop() + }} + > + + data-slot="select-select-trigger-value" class={local.valueClass}> + {(state) => { + const selected = state.selectedOption() ?? local.current + if (!selected) return local.placeholder || "" + if (local.label) return local.label(selected) + return selected as string + }} + + + + + + + + + + + + ) +} diff --git a/packages/ui/src/v2/components/accordion-v2.css b/packages/ui/src/v2/components/accordion-v2.css index 73d9ebbc1d2d..3bf4b39df3dd 100644 --- a/packages/ui/src/v2/components/accordion-v2.css +++ b/packages/ui/src/v2/components/accordion-v2.css @@ -15,7 +15,6 @@ box-shadow: 0 0 0 0.5px var(--accordion-v2-border); overflow: hidden; - font-family: var(--v2-font-family-sans), "Inter", system-ui, sans-serif; color: var(--accordion-v2-fg); -webkit-font-smoothing: antialiased; @@ -59,7 +58,6 @@ cursor: default; user-select: none; - font-family: inherit; font-size: 13px; font-weight: 440; line-height: 100%; diff --git a/packages/ui/src/v2/components/avatar-v2.css b/packages/ui/src/v2/components/avatar-v2.css index 50c3509fcc95..8d717afbe1ef 100644 --- a/packages/ui/src/v2/components/avatar-v2.css +++ b/packages/ui/src/v2/components/avatar-v2.css @@ -14,7 +14,6 @@ height: 28px; border-radius: var(--avatar-radius); border: 0.5px solid var(--v2-border-border-base); - font-family: var(--v2-font-family-sans); font-weight: 530; font-size: var(--avatar-font-size); line-height: 1; diff --git a/packages/ui/src/v2/components/badge-v2.css b/packages/ui/src/v2/components/badge-v2.css index 300903fcb5dd..748c08a7116f 100644 --- a/packages/ui/src/v2/components/badge-v2.css +++ b/packages/ui/src/v2/components/badge-v2.css @@ -10,19 +10,18 @@ user-select: none; border-radius: 2px; - border: 0.5px solid var(--border-border-base); - background: var(--background-bg-layer-02); + border: 0.5px solid var(--v2-border-border-base); + background: var(--v2-background-bg-layer-02); - font-family: var(--v2-font-family-sans); font-style: normal; font-weight: 530; font-size: 11px; line-height: 1; letter-spacing: 0.05px; - color: var(--text-text-muted); + color: var(--v2-text-text-muted); font-variant-numeric: tabular-nums; } [data-component="tag"][data-high-contrast] { - border-color: var(--border-border-strong); + border-color: var(--v2-border-border-strong); } diff --git a/packages/ui/src/v2/components/basic-tool-v2.css b/packages/ui/src/v2/components/basic-tool-v2.css index 73ac7de79334..256552ab1b0e 100644 --- a/packages/ui/src/v2/components/basic-tool-v2.css +++ b/packages/ui/src/v2/components/basic-tool-v2.css @@ -21,7 +21,6 @@ gap: 8px; min-width: 0; width: 100%; - font-family: var(--v2-font-family-sans), var(--sans), system-ui, sans-serif; font-variant-numeric: tabular-nums; [data-slot="basic-tool-v2-trigger"] { diff --git a/packages/ui/src/v2/components/button-v2.css b/packages/ui/src/v2/components/button-v2.css index 2e4364057ee9..975dc2cc288b 100644 --- a/packages/ui/src/v2/components/button-v2.css +++ b/packages/ui/src/v2/components/button-v2.css @@ -11,7 +11,6 @@ justify-content: center; gap: 6px; border-radius: 6px; - font-family: var(--v2-font-family-sans); font-style: normal; font-weight: 530; font-size: 13px; @@ -145,3 +144,26 @@ opacity: 0.5; cursor: not-allowed; } + +/* Ghost muted */ +[data-component="button-v2"][data-variant="ghost-muted"] { + background-color: transparent; + color: var(--v2-text-text-muted); +} + +[data-component="button-v2"][data-variant="ghost-muted"] [data-slot="icon-svg"] { + color: var(--v2-icon-icon-muted); +} + +[data-component="button-v2"][data-variant="ghost-muted"]:is(:hover, [data-state="hover"]):not(:disabled) { + background-color: var(--v2-overlay-simple-overlay-hover); +} + +[data-component="button-v2"][data-variant="ghost-muted"]:is(:active, [data-state="pressed"]):not(:disabled) { + background-color: var(--v2-overlay-simple-overlay-pressed); +} + +[data-component="button-v2"][data-variant="ghost-muted"]:is(:disabled, [data-state="disabled"]) { + opacity: 0.5; + cursor: not-allowed; +} diff --git a/packages/ui/src/v2/components/button-v2.stories.tsx b/packages/ui/src/v2/components/button-v2.stories.tsx index b00c1e68cf98..17aefb79f8f2 100644 --- a/packages/ui/src/v2/components/button-v2.stories.tsx +++ b/packages/ui/src/v2/components/button-v2.stories.tsx @@ -4,7 +4,7 @@ const docs = `### Overview Button v2 with three visual variants and two sizes. ### API -- \`variant\`: "neutral" | "contrast" | "ghost". +- \`variant\`: "neutral" | "contrast" | "ghost" | "ghost-muted". - \`size\`: "normal" | "large". - \`icon\`: Optional icon name. - Inherits Kobalte Button props and native button attributes. @@ -39,7 +39,7 @@ export default { }, variant: { control: "select", - options: ["neutral", "contrast", "ghost"], + options: ["neutral", "contrast", "ghost", "ghost-muted"], }, size: { control: "select", @@ -63,6 +63,9 @@ export const Variants = { Neutral Contrast Ghost + + Ghost muted +
), } @@ -112,7 +115,7 @@ export const Icon = { export const AllStates = { render: () => { - const variants = ["neutral", "contrast", "ghost"] as const + const variants = ["neutral", "contrast", "ghost", "ghost-muted"] as const const states = ["default", "hover", "pressed", "focus", "disabled"] as const const toTitleCase = (value: string) => value.charAt(0).toUpperCase() + value.slice(1) return ( diff --git a/packages/ui/src/v2/components/button-v2.tsx b/packages/ui/src/v2/components/button-v2.tsx index ce9129d40082..8146549e3878 100644 --- a/packages/ui/src/v2/components/button-v2.tsx +++ b/packages/ui/src/v2/components/button-v2.tsx @@ -7,7 +7,7 @@ export interface ButtonV2Props extends ComponentProps, Pick, "class" | "classList" | "children"> { size?: "small" | "normal" | "large" - variant?: "neutral" | "contrast" | "ghost" + variant?: "neutral" | "contrast" | "ghost" | "ghost-muted" icon?: IconProps["name"] } @@ -27,7 +27,7 @@ export function ButtonV2(props: ButtonV2Props) { }} > - + {props.children} diff --git a/packages/ui/src/v2/components/checkbox-v2.css b/packages/ui/src/v2/components/checkbox-v2.css index 07df3970e394..49d7ea56f927 100644 --- a/packages/ui/src/v2/components/checkbox-v2.css +++ b/packages/ui/src/v2/components/checkbox-v2.css @@ -10,7 +10,6 @@ [data-slot="checkbox-v2-error"] { color: var(--state-fg-danger); - font-family: var(--v2-font-family-sans); font-size: 12px; font-weight: var(--font-weight-regular); line-height: var(--line-height-normal); @@ -90,7 +89,6 @@ display: inline-flex; user-select: none; color: inherit; - font-family: var(--v2-font-family-sans); font-style: normal; font-weight: 440; font-variant-numeric: tabular-nums; @@ -109,7 +107,6 @@ [data-slot="checkbox-v2-description"] { color: var(--text-text-muted); - font-family: var(--v2-font-family-sans); font-size: 11px; font-weight: 440; line-height: 1; diff --git a/packages/ui/src/v2/components/dialog-v2.css b/packages/ui/src/v2/components/dialog-v2.css index 066017f9561e..e7d0f60f39c1 100644 --- a/packages/ui/src/v2/components/dialog-v2.css +++ b/packages/ui/src/v2/components/dialog-v2.css @@ -4,7 +4,7 @@ position: fixed; inset: 0; z-index: 50; - background-color: var(--overlay-simple-overlay-scrim); + background-color: var(--v2-overlay-simple-overlay-scrim); } [data-component="dialog"] { @@ -22,8 +22,8 @@ align-items: flex-start; width: 480px; height: 368px; - background: var(--background-bg-layer-01); - box-shadow: var(--elevation-overlay); + background: var(--v2-background-bg-layer-01); + box-shadow: var(--v2-elevation-overlay); border-radius: 6px; overflow: visible; pointer-events: auto; @@ -64,22 +64,20 @@ [data-slot="dialog-title"] { margin: 0; - font-family: "Inter", var(--v2-font-family-sans); font-weight: 530; font-size: 15px; line-height: 100%; letter-spacing: -0.13px; - color: var(--text-text-base); + color: var(--v2-text-text-base); font-variation-settings: "slnt" 0; } [data-slot="dialog-description"] { - font-family: "Inter", var(--v2-font-family-sans); font-weight: 440; font-size: 13px; line-height: 100%; letter-spacing: -0.04px; - color: var(--text-text-muted); + color: var(--v2-text-text-muted); font-variation-settings: "slnt" 0; } @@ -94,7 +92,7 @@ cursor: pointer; &:hover { - background: var(--overlay-simple-overlay-hover); + background: var(--v2-overlay-simple-overlay-hover); } } } diff --git a/packages/ui/src/v2/components/diff-changes-v2.css b/packages/ui/src/v2/components/diff-changes-v2.css index 82397ccd2ca3..a4405dc0c0dd 100644 --- a/packages/ui/src/v2/components/diff-changes-v2.css +++ b/packages/ui/src/v2/components/diff-changes-v2.css @@ -6,7 +6,6 @@ [data-slot="diff-changes-additions"], [data-slot="diff-changes-deletions"] { - font-family: var(--v2-font-family-sans); font-size: 11px; font-style: normal; font-weight: 440; diff --git a/packages/ui/src/v2/components/field-v2.css b/packages/ui/src/v2/components/field-v2.css index dd1715204a51..853d5da6fc91 100644 --- a/packages/ui/src/v2/components/field-v2.css +++ b/packages/ui/src/v2/components/field-v2.css @@ -17,7 +17,6 @@ margin: 0; padding: 0; border: 0; - font-family: var(--v2-font-family-sans); font-style: normal; font-weight: 530; font-size: 13px; @@ -76,7 +75,6 @@ align-self: stretch; width: 100%; min-height: 11px; - font-family: var(--v2-font-family-sans); font-style: normal; font-weight: 440; font-size: 11px; diff --git a/packages/ui/src/v2/components/icon.tsx b/packages/ui/src/v2/components/icon.tsx index 09aaea2e59b0..60202a85c8a7 100644 --- a/packages/ui/src/v2/components/icon.tsx +++ b/packages/ui/src/v2/components/icon.tsx @@ -2,20 +2,20 @@ import { onMount, type ComponentProps, splitProps } from "solid-js" const icons = { edit: { - viewBox: "0 0 20 20", - body: ``, + viewBox: "0 0 16 16", + body: ``, }, "folder-add-left": { - viewBox: "0 0 20 20", - body: ``, + viewBox: "0 0 16 16", + body: ``, }, "grid-plus": { viewBox: "0 0 16 16", body: ``, }, help: { - viewBox: "0 0 20 20", - body: ``, + viewBox: "0 0 16 16", + body: ``, }, "sidebar-right": { viewBox: "0 0 20 20", @@ -31,7 +31,7 @@ const icons = { }, "magnifying-glass": { viewBox: "0 0 16 16", - body: ``, + body: ``, }, menu: { viewBox: "0 0 16 16", @@ -42,8 +42,16 @@ const icons = { body: ``, }, "settings-gear": { + viewBox: "0 0 16 16", + body: ``, + }, + "chevron-down": { + viewBox: "0 0 16 16", + body: ``, + }, + close: { viewBox: "0 0 20 20", - body: ``, + body: ``, }, "xmark-small": { viewBox: "0 0 16 16", diff --git a/packages/ui/src/v2/components/inline-input-v2.css b/packages/ui/src/v2/components/inline-input-v2.css index 861628160466..71297af5a8fb 100644 --- a/packages/ui/src/v2/components/inline-input-v2.css +++ b/packages/ui/src/v2/components/inline-input-v2.css @@ -81,7 +81,6 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - font-family: var(--v2-font-family-sans); font-style: normal; font-weight: 440; font-size: 13px; @@ -138,7 +137,6 @@ border: 0; background: transparent; outline: none; - font-family: var(--v2-font-family-sans); font-style: normal; font-weight: 440; font-size: 13px; diff --git a/packages/ui/src/v2/components/keybind-v2.css b/packages/ui/src/v2/components/keybind-v2.css index 9eac86c1ffa6..82e84127062b 100644 --- a/packages/ui/src/v2/components/keybind-v2.css +++ b/packages/ui/src/v2/components/keybind-v2.css @@ -6,13 +6,13 @@ [data-component="keybind-v2"] { box-sizing: border-box; - font-family: var(--v2-font-family-sans), var(--sans), system-ui, sans-serif; font-variant-numeric: tabular-nums; display: inline-flex; flex-direction: row; align-items: center; - padding: 0px; + padding: 0; gap: 2px; + flex-shrink: 0; } [data-component="keybind-v2"] *, @@ -26,9 +26,9 @@ flex-direction: row; justify-content: center; align-items: center; - padding: 0px; + padding: 0; gap: 4px; - width: 14px; + min-width: 14px; height: 14px; border-radius: 2px; flex: none; @@ -36,7 +36,7 @@ } [data-component="keybind-v2"][data-variant="neutral"] [data-slot="keybind-v2-key"] { - background: var(--background-bg-layer-03); + background: var(--v2-background-bg-layer-03); } [data-component="keybind-v2"][data-variant="ghost"] [data-slot="keybind-v2-key"] { @@ -48,26 +48,27 @@ flex-direction: row; justify-content: center; align-items: center; - width: 14px; - height: 14px; - padding: 0px; - flex: 1 1 auto; - align-self: stretch; - font-family: "Inter", var(--v2-font-family-sans), var(--sans), system-ui, sans-serif; + min-width: 14px; + height: 11px; + padding: 0; + flex: none; font-style: normal; font-weight: 530; font-size: 11px; - line-height: 100%; + line-height: 1; text-align: center; letter-spacing: 0.05px; + text-transform: uppercase; + font-variant-numeric: tabular-nums; + font-feature-settings: "tnum" on, "lnum" on; font-variation-settings: "slnt" 0; user-select: none; } [data-component="keybind-v2"][data-variant="neutral"] [data-slot="keybind-v2-label"] { - color: var(--text-text-muted); + color: var(--v2-text-text-muted); } [data-component="keybind-v2"][data-variant="ghost"] [data-slot="keybind-v2-label"] { - color: var(--text-text-faint); + color: var(--v2-text-text-faint); } diff --git a/packages/ui/src/v2/components/line-comment-v2.css b/packages/ui/src/v2/components/line-comment-v2.css index 8a97f063734e..e0843d836789 100644 --- a/packages/ui/src/v2/components/line-comment-v2.css +++ b/packages/ui/src/v2/components/line-comment-v2.css @@ -6,7 +6,6 @@ [data-component="line-comment-v2"] { box-sizing: border-box; - font-family: var(--v2-font-family-sans), var(--sans), system-ui, sans-serif; font-variant-numeric: tabular-nums; min-width: 0; width: 100%; @@ -155,7 +154,6 @@ border: 1px solid var(--border-border-base); border-radius: 6px; background: linear-gradient(180deg, var(--alpha-light-2) 0%, var(--alpha-light-0) 100%), var(--background-bg-base); - font-family: inherit; font-size: 13px; font-style: normal; font-weight: 440; diff --git a/packages/ui/src/v2/components/menu-v2.css b/packages/ui/src/v2/components/menu-v2.css index 10fb97d631ad..471f545e4bee 100644 --- a/packages/ui/src/v2/components/menu-v2.css +++ b/packages/ui/src/v2/components/menu-v2.css @@ -6,12 +6,11 @@ padding: 2px; min-width: 160px; - background: var(--v2-background-bg-layer-01); + background: var(--background-bg-layer-01); border-radius: 6px; - box-shadow: var(--v2-elevation-floating); + box-shadow: var(--elevation-floating); outline: none; - font-family: var(--v2-font-family-sans), "Inter", system-ui, sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; @@ -31,9 +30,9 @@ --menu-v2-fg-subtle: var(--text-text-muted); --menu-v2-icon: var(--icon-icon-base); --menu-v2-accent: var(--text-text-accent); - --menu-v2-badge-bg: var(--v2-background-bg-layer-02); - --menu-v2-badge-border: var(--v2-border-border-base); - --menu-v2-hover: var(--v2-overlay-simple-overlay-hover); + --menu-v2-badge-bg: var(--background-bg-layer-02); + --menu-v2-badge-border: var(--border-border-base); + --menu-v2-hover: var(--overlay-simple-overlay-hover); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; @@ -52,7 +51,6 @@ cursor: default; user-select: none; - font-family: var(--v2-font-family-sans), "Inter", system-ui, sans-serif; font-variation-settings: "slnt" 0; font-variant-numeric: tabular-nums; color: var(--menu-v2-fg); @@ -153,7 +151,7 @@ height: 1px; width: calc(100% + 4px); margin: 2px -2px; - background: var(--v2-border-border-muted); + background: var(--border-border-muted); border: none; } @@ -164,7 +162,6 @@ height: 28px; padding: 0 12px; - font-family: var(--v2-font-family-sans), "Inter", system-ui, sans-serif; font-size: 11px; font-weight: 530; line-height: 100%; diff --git a/packages/ui/src/v2/components/project-avatar-v2.css b/packages/ui/src/v2/components/project-avatar-v2.css new file mode 100644 index 000000000000..c150f0b79901 --- /dev/null +++ b/packages/ui/src/v2/components/project-avatar-v2.css @@ -0,0 +1,128 @@ +[data-component="project-avatar-v2"] { + --project-avatar-bg: var(--v2-avatar-bg-gray); + --project-avatar-border: var(--v2-avatar-border-gray); + position: relative; + box-sizing: border-box; + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + overflow: hidden; + border-radius: 4px; + background: + linear-gradient(180deg, var(--v2-alpha-light-16) 0%, var(--v2-alpha-light-0) 100%), var(--project-avatar-bg); + box-shadow: inset 0 0 0 0.5px var(--project-avatar-border); + font-weight: 530; + font-size: 11px; + line-height: 1; + letter-spacing: 0.05px; + font-variant-numeric: tabular-nums; + text-transform: uppercase; + color: var(--v2-grey-100); + text-shadow: 0 0 4px var(--v2-alpha-dark-20); + user-select: none; + -webkit-user-select: none; +} + +[data-component="project-avatar-v2"][data-variant="orange"] { + --project-avatar-bg: var(--v2-avatar-bg-orange); + --project-avatar-border: var(--v2-avatar-border-orange); +} + +[data-component="project-avatar-v2"][data-variant="yellow"] { + --project-avatar-bg: var(--v2-avatar-bg-yellow); + --project-avatar-border: var(--v2-avatar-border-yellow); +} + +[data-component="project-avatar-v2"][data-variant="cyan"] { + --project-avatar-bg: var(--v2-avatar-bg-cyan); + --project-avatar-border: var(--v2-avatar-border-cyan); +} + +[data-component="project-avatar-v2"][data-variant="green"] { + --project-avatar-bg: var(--v2-avatar-bg-green); + --project-avatar-border: var(--v2-avatar-border-green); +} + +[data-component="project-avatar-v2"][data-variant="red"] { + --project-avatar-bg: var(--v2-avatar-bg-red); + --project-avatar-border: var(--v2-avatar-border-red); +} + +[data-component="project-avatar-v2"][data-variant="pink"] { + --project-avatar-bg: var(--v2-avatar-bg-pink); + --project-avatar-border: var(--v2-avatar-border-pink); +} + +[data-component="project-avatar-v2"][data-variant="blue"] { + --project-avatar-bg: var(--v2-avatar-bg-blue); + --project-avatar-border: var(--v2-avatar-border-blue); +} + +[data-component="project-avatar-v2"][data-variant="purple"] { + --project-avatar-bg: var(--v2-avatar-bg-purple); + --project-avatar-border: var(--v2-avatar-border-purple); +} + +[data-component="project-avatar-v2"][data-variant="gray"] { + --project-avatar-bg: var(--v2-avatar-bg-gray); + --project-avatar-border: var(--v2-avatar-border-gray); +} + +[data-component="project-avatar-v2"][data-has-image] { + background: var(--project-avatar-bg); +} + +[data-component="project-avatar-v2"] [data-slot="project-avatar-image"] { + position: relative; + z-index: 1; + display: block; + width: 100%; + height: 100%; + border-radius: inherit; + object-fit: cover; + user-select: none; + -webkit-user-select: none; + -webkit-user-drag: none; +} + +[data-component="project-avatar-v2"] [data-slot="project-avatar-loader"] { + position: absolute; + inset: 0; + z-index: 2; + border-radius: 4px; + background: conic-gradient( + from 180deg at 50% 50%, + var(--v2-grey-100) 0deg, + var(--v2-grey-1200) 0.04deg, + var(--v2-alpha-dark-50) 90deg, + var(--v2-grey-100) 360deg + ); + mix-blend-mode: soft-light; + pointer-events: none; + animation: project-avatar-v2-loader-spin 1.2s linear infinite; +} + +@keyframes project-avatar-v2-loader-spin { + to { + transform: rotate(360deg); + } +} + +[data-slot="project-avatar-slot"] { + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + overflow: visible; +} + +[data-component="project-avatar-v2"][data-unread] { + overflow: visible; + outline: 2px solid var(--v2-background-bg-accent); + outline-offset: 1px; +} diff --git a/packages/ui/src/v2/components/project-avatar-v2.stories.tsx b/packages/ui/src/v2/components/project-avatar-v2.stories.tsx new file mode 100644 index 000000000000..1fab4f57b7f1 --- /dev/null +++ b/packages/ui/src/v2/components/project-avatar-v2.stories.tsx @@ -0,0 +1,88 @@ +// @ts-nocheck +import { For } from "solid-js" +import { ProjectAvatar, PROJECT_AVATAR_VARIANTS } from "./project-avatar-v2" + +const docs = `### Overview +Saturated 16px project avatar with color variants and optional unread ring. + +### API +- Required: \`fallback\` string. +- Optional: \`src\`, \`variant\`, \`unread\`. + +### Variants +- Color: orange, yellow, cyan, green, red, pink, blue, purple, gray. +- Image vs initial content state. +- Unread ring when \`unread\` is set. + +### Theming +- Uses \`--v2-avatar-bg-*\` and \`--v2-avatar-border-*\` tokens with inset box-shadow borders. +` + +export default { + title: "UI V2/ProjectAvatar", + id: "components-project-avatar-v2", + component: ProjectAvatar, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, + argTypes: { + variant: { + control: "select", + options: [...PROJECT_AVATAR_VARIANTS], + }, + }, + args: { + fallback: "O", + variant: "orange", + }, +} + +export const Basic = {} + +export const WithImage = { + args: { + src: "https://placehold.co/32x32/png", + fallback: "O", + variant: "blue", + }, +} + +export const AllVariants = { + render: () => ( +
+ + {(variant) => } + +
+ ), +} + +export const Unread = { + args: { + fallback: "O", + variant: "orange", + unread: true, + }, +} + +export const Loading = { + args: { + fallback: "O", + variant: "orange", + loading: true, + }, +} + +export const LoadingAndUnread = { + args: { + fallback: "O", + variant: "blue", + loading: true, + unread: true, + }, +} diff --git a/packages/ui/src/v2/components/project-avatar-v2.tsx b/packages/ui/src/v2/components/project-avatar-v2.tsx new file mode 100644 index 000000000000..926f2e1ffb36 --- /dev/null +++ b/packages/ui/src/v2/components/project-avatar-v2.tsx @@ -0,0 +1,71 @@ +import { type ComponentProps, splitProps, Show } from "solid-js" +import "./project-avatar-v2.css" + +const segmenter = + typeof Intl !== "undefined" && "Segmenter" in Intl + ? new Intl.Segmenter(undefined, { granularity: "grapheme" }) + : undefined + +function first(value: string) { + if (!value) return "" + if (!segmenter) return Array.from(value)[0] ?? "" + return segmenter.segment(value)[Symbol.iterator]().next().value?.segment ?? Array.from(value)[0] ?? "" +} + +export const PROJECT_AVATAR_VARIANTS = [ + "orange", + "yellow", + "cyan", + "green", + "red", + "pink", + "blue", + "purple", + "gray", +] as const + +export type ProjectAvatarVariant = (typeof PROJECT_AVATAR_VARIANTS)[number] + +export interface ProjectAvatarProps extends ComponentProps<"div"> { + fallback: string + src?: string + variant?: ProjectAvatarVariant + unread?: boolean + loading?: boolean +} + +export function ProjectAvatar(props: ProjectAvatarProps) { + const [split, rest] = splitProps(props, [ + "fallback", + "src", + "variant", + "unread", + "loading", + "class", + "classList", + "style", + ]) + const src = split.src + return ( +
+ + {(value) => } + + + +
+ ) +} diff --git a/packages/ui/src/v2/components/radio-v2.css b/packages/ui/src/v2/components/radio-v2.css index 1bf248632417..b9f954d301b3 100644 --- a/packages/ui/src/v2/components/radio-v2.css +++ b/packages/ui/src/v2/components/radio-v2.css @@ -9,7 +9,6 @@ align-items: center; user-select: none; color: var(--text-text-faint); - font-family: var(--v2-font-family-sans); font-size: 11px; font-style: normal; font-weight: 440; @@ -20,7 +19,6 @@ [data-slot="radio-v2-description"] { color: var(--text-text-faint); - font-family: var(--v2-font-family-sans); font-size: 11px; font-weight: 440; line-height: 1.2; @@ -35,7 +33,6 @@ [data-slot="radio-v2-error"] { color: var(--state-fg-danger); - font-family: var(--v2-font-family-sans); font-size: 12px; font-weight: var(--font-weight-regular); line-height: var(--line-height-normal); @@ -167,7 +164,6 @@ display: inline-flex; user-select: none; color: inherit; - font-family: var(--v2-font-family-sans); font-style: normal; font-weight: 440; font-variant-numeric: tabular-nums; @@ -186,7 +182,6 @@ [data-slot="radio-v2-item-description"] { color: var(--text-text-muted); - font-family: var(--v2-font-family-sans); font-size: 11px; font-weight: 440; line-height: 1; diff --git a/packages/ui/src/v2/components/segmented-control-v2.css b/packages/ui/src/v2/components/segmented-control-v2.css index 0692e184acf9..6303a357ff04 100644 --- a/packages/ui/src/v2/components/segmented-control-v2.css +++ b/packages/ui/src/v2/components/segmented-control-v2.css @@ -34,7 +34,6 @@ background: transparent; box-shadow: none; cursor: pointer; - font-family: var(--v2-font-family-sans), var(--sans); font-style: normal; font-weight: 440; font-size: 13px; diff --git a/packages/ui/src/v2/components/select-v2.css b/packages/ui/src/v2/components/select-v2.css index 553f8afcd47e..85343eb02ed0 100644 --- a/packages/ui/src/v2/components/select-v2.css +++ b/packages/ui/src/v2/components/select-v2.css @@ -110,7 +110,6 @@ background: transparent; outline: none; text-align: left; - font-family: var(--v2-font-family-sans); font-style: normal; font-weight: 440; font-size: 13px; diff --git a/packages/ui/src/v2/components/switch-v2.css b/packages/ui/src/v2/components/switch-v2.css index 1459abf62418..f5aadefe5451 100644 --- a/packages/ui/src/v2/components/switch-v2.css +++ b/packages/ui/src/v2/components/switch-v2.css @@ -29,8 +29,8 @@ border-radius: 4px; border: none; background: - linear-gradient(180deg, var(--alpha-light-0) 0%, var(--alpha-light-20) 100%), var(--background-bg-layer-03); - box-shadow: var(--elevation-switch-off); + linear-gradient(180deg, var(--v2-alpha-light-0) 0%, var(--v2-alpha-light-20) 100%), var(--v2-background-bg-layer-03); + box-shadow: var(--v2-elevation-switch-off); transition: background 90ms ease-out, opacity 90ms ease-out, @@ -43,15 +43,15 @@ height: 12px; transform: translateX(0); border-radius: 2px; - border: 0.5px solid var(--overlay-gradient-depth-overlay-depth-top); + border: 0.5px solid var(--v2-overlay-gradient-depth-overlay-depth-top); background: linear-gradient( 180deg, - var(--overlay-gradient-depth-overlay-depth-top) 0%, - var(--overlay-gradient-depth-overlay-depth-bot) 100% + var(--v2-overlay-gradient-depth-overlay-depth-top) 0%, + var(--v2-overlay-gradient-depth-overlay-depth-bot) 100% ), - var(--grey-200); - box-shadow: var(--elevation-elements); + var(--v2-grey-200); + box-shadow: var(--v2-elevation-elements); transition: transform 90ms ease-out, width 90ms ease-out, @@ -64,8 +64,7 @@ align-items: center; height: 16px; user-select: none; - color: var(--text-text-faint); - font-family: var(--v2-font-family-sans); + color: var(--v2-text-text-faint); font-size: 11px; font-style: normal; font-weight: 440; @@ -75,8 +74,7 @@ } [data-slot="switch-error"] { - color: var(--state-fg-danger); - font-family: var(--v2-font-family-sans); + color: var(--v2-state-fg-danger); font-size: 12px; font-weight: var(--font-weight-regular); line-height: var(--line-height-normal); @@ -89,8 +87,8 @@ &:hover:not([data-disabled], [data-readonly]) [data-slot="switch-control"] { background: - linear-gradient(0deg, var(--overlay-simple-overlay-hover), var(--overlay-simple-overlay-hover)), - linear-gradient(180deg, var(--alpha-light-0) 0%, var(--alpha-light-20) 100%), var(--background-bg-layer-03); + linear-gradient(0deg, var(--v2-overlay-simple-overlay-hover), var(--v2-overlay-simple-overlay-hover)), + linear-gradient(180deg, var(--v2-alpha-light-0) 0%, var(--v2-alpha-light-20) 100%), var(--v2-background-bg-layer-03); } &:hover:not([data-disabled], [data-readonly]) [data-slot="switch-thumb"] { @@ -99,14 +97,14 @@ } &:not([data-readonly]) [data-slot="switch-input"]:focus-visible ~ [data-slot="switch-control"] { - outline: 2px solid var(--border-border-focus); + outline: 2px solid var(--v2-border-border-focus); outline-offset: 1px; } &[data-checked] [data-slot="switch-control"] { background: - linear-gradient(180deg, var(--alpha-light-0) 0%, var(--alpha-light-10) 100%), var(--background-bg-accent); - box-shadow: var(--elevation-switch-on); + linear-gradient(180deg, var(--v2-alpha-light-0) 0%, var(--v2-alpha-light-10) 100%), var(--v2-background-bg-accent); + box-shadow: var(--v2-elevation-switch-on); } &[data-checked] [data-slot="switch-thumb"] { @@ -115,16 +113,16 @@ background: linear-gradient( 180deg, - var(--overlay-gradient-depth-overlay-depth-top) 0%, - var(--overlay-gradient-depth-overlay-depth-bot) 100% + var(--v2-overlay-gradient-depth-overlay-depth-top) 0%, + var(--v2-overlay-gradient-depth-overlay-depth-bot) 100% ), - var(--grey-300); + var(--v2-grey-300); } &[data-checked]:hover:not([data-disabled], [data-readonly]) [data-slot="switch-control"] { background: - linear-gradient(0deg, var(--overlay-simple-overlay-contrast-hover), var(--overlay-simple-overlay-contrast-hover)), - linear-gradient(180deg, var(--alpha-light-0) 0%, var(--alpha-light-10) 100%), var(--background-bg-accent); + linear-gradient(0deg, var(--v2-overlay-simple-overlay-contrast-hover), var(--v2-overlay-simple-overlay-contrast-hover)), + linear-gradient(180deg, var(--v2-alpha-light-0) 0%, var(--v2-alpha-light-10) 100%), var(--v2-background-bg-accent); } &[data-checked]:hover:not([data-disabled], [data-readonly]) [data-slot="switch-thumb"] { @@ -140,7 +138,7 @@ } &[data-invalid] [data-slot="switch-control"] { - border-color: var(--state-border-danger); + border-color: var(--v2-state-border-danger); } &[data-readonly] { diff --git a/packages/ui/src/v2/components/tab-state-indicator.tsx b/packages/ui/src/v2/components/tab-state-indicator.tsx new file mode 100644 index 000000000000..90b814d776ca --- /dev/null +++ b/packages/ui/src/v2/components/tab-state-indicator.tsx @@ -0,0 +1,37 @@ +import { splitProps, type ComponentProps } from "solid-js" + +export function TabStateIndicator(props: ComponentProps<"svg">) { + const [local, rest] = splitProps(props, ["class", "classList", "width", "height"]) + return ( + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/packages/ui/src/v2/components/tabs-v2.css b/packages/ui/src/v2/components/tabs-v2.css index 83706aa71eb8..10e4261bae17 100644 --- a/packages/ui/src/v2/components/tabs-v2.css +++ b/packages/ui/src/v2/components/tabs-v2.css @@ -9,7 +9,6 @@ height: 100%; display: flex; overflow: clip; - font-family: var(--v2-font-family-sans); } [data-component="tabs-v2"][data-orientation="horizontal"] { @@ -75,11 +74,11 @@ display: flex; align-items: center; justify-content: center; - color: var(--text-text-faint); + color: var(--v2-text-text-faint); } [data-component="tabs-v2"] [data-slot="tabs-v2-close-button"]:hover { - color: var(--text-text-muted); + color: var(--v2-text-text-muted); } [data-component="tabs-v2"] [data-component="icon-button"] { @@ -88,7 +87,7 @@ [data-component="tabs-v2"] [data-slot="tabs-v2-trigger-wrapper"]:disabled { pointer-events: none; - color: var(--text-text-faint); + color: var(--v2-text-text-faint); } [data-component="tabs-v2"][data-variant="normal"][data-orientation="horizontal"] [data-slot="tabs-v2-list"], @@ -105,7 +104,7 @@ height: 1px; content: ""; width: calc(100% + 16px); - background-color: var(--border-border-base); + background-color: var(--v2-border-border-base); position: absolute; bottom: 0px; left: -8px; @@ -119,7 +118,7 @@ [data-component="tabs-v2"][data-variant="normal"][data-orientation="horizontal"] [data-slot="tabs-v2-trigger-wrapper"] { height: 100%; gap: 4px; - color: var(--text-text-muted); + color: var(--v2-text-text-muted); border-bottom: 1px solid transparent; } @@ -130,18 +129,18 @@ [data-component="tabs-v2"][data-variant="normal"][data-orientation="horizontal"] [data-slot="tabs-v2-trigger-wrapper"]:hover:not(:disabled):not([data-selected]) { - color: var(--text-text-base); + color: var(--v2-text-text-base); } [data-component="tabs-v2"][data-variant="normal"][data-orientation="horizontal"] [data-slot="tabs-v2-trigger-wrapper"]:has([data-selected]) { - border-bottom-color: var(--text-text-faint); - color: var(--text-text-base); + border-bottom-color: var(--v2-text-text-faint); + color: var(--v2-text-text-base); } [data-component="tabs-v2"][data-variant="normal"][data-orientation="horizontal"] [data-slot="tabs-v2-trigger-wrapper"]:not(:has([data-selected])) { - color: var(--text-text-muted); + color: var(--v2-text-text-muted); } [data-component="tabs-v2"][data-variant="pill"][data-orientation="horizontal"] [data-slot="tabs-v2-trigger-wrapper"] { @@ -149,7 +148,7 @@ border-radius: 4px; border: 0.5px solid transparent; box-sizing: border-box; - color: var(--text-text-muted); + color: var(--v2-text-text-muted); } [data-component="tabs-v2"][data-variant="pill"][data-orientation="horizontal"] [data-slot="tabs-v2-trigger"] { @@ -162,16 +161,16 @@ [data-component="tabs-v2"][data-variant="pill"][data-orientation="horizontal"] [data-slot="tabs-v2-trigger-wrapper"]:hover:not(:disabled):not(:has([data-selected])) { - background-color: var(--background-bg-layer-01); - color: var(--text-text-base); - border: 0.5px solid var(--border-border-muted); + background-color: var(--v2-background-bg-layer-01); + color: var(--v2-text-text-base); + border: 0.5px solid var(--v2-border-border-muted); } [data-component="tabs-v2"][data-variant="pill"][data-orientation="horizontal"] [data-slot="tabs-v2-trigger-wrapper"]:has([data-selected]) { - background-color: var(--background-bg-layer-02); - color: var(--text-text-base); - border: 0.5px solid var(--border-border-muted); + background-color: var(--v2-background-bg-layer-02); + color: var(--v2-text-text-base); + border: 0.5px solid var(--v2-border-border-muted); } [data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"] [data-slot="tabs-v2-list"] { @@ -182,13 +181,13 @@ padding: 12px; gap: 4px; overflow-y: auto; - border-right: 1px solid var(--border-border-base); + border-right: 1px solid var(--v2-border-border-base); } [data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"] [data-slot="tabs-v2-section-title"] { width: 100%; padding-left: 4px; - color: var(--text-text-muted); + color: var(--v2-text-text-muted); font-size: 12px; font-weight: 500; } @@ -199,7 +198,7 @@ border-radius: 4px; border: 0.5px solid transparent; box-sizing: border-box; - color: var(--text-text-muted); + color: var(--v2-text-text-muted); } [data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"] [data-slot="tabs-v2-trigger"] { @@ -212,12 +211,12 @@ [data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"] [data-slot="tabs-v2-trigger-wrapper"]:hover:not(:disabled):not(:has([data-selected])) { - color: var(--text-text-base); + color: var(--v2-text-text-base); } [data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"] [data-slot="tabs-v2-trigger-wrapper"]:has([data-selected]) { - background-color: var(--background-bg-layer-02); - color: var(--text-text-base); - border: 0.5px solid var(--border-border-muted); + background-color: var(--v2-background-bg-layer-02); + color: var(--v2-text-text-base); + border: 0.5px solid var(--v2-border-border-muted); } diff --git a/packages/ui/src/v2/components/text-input-v2.css b/packages/ui/src/v2/components/text-input-v2.css index 287f7f70a789..c6bcafaf1e53 100644 --- a/packages/ui/src/v2/components/text-input-v2.css +++ b/packages/ui/src/v2/components/text-input-v2.css @@ -11,8 +11,9 @@ border-radius: 6px; outline: 1px solid transparent; outline-offset: 0; - background: linear-gradient(180deg, var(--alpha-light-2) 0%, var(--alpha-light-0) 100%), var(--background-bg-base); - box-shadow: var(--elevation-button-neutral); + background: + linear-gradient(180deg, var(--v2-alpha-light-2) 0%, var(--v2-alpha-light-0) 100%), var(--v2-background-bg-base); + box-shadow: var(--v2-elevation-button-neutral); flex: none; align-self: stretch; transition: @@ -27,17 +28,17 @@ [data-component="text-input-v2"]:where(:hover):not([data-disabled], [data-invalid]):not(:focus-within) { background: - linear-gradient(0deg, var(--overlay-simple-overlay-hover), var(--overlay-simple-overlay-hover)), - linear-gradient(180deg, var(--alpha-light-2) 0%, var(--alpha-light-0) 100%), var(--background-bg-base); + linear-gradient(0deg, var(--v2-overlay-simple-overlay-hover), var(--v2-overlay-simple-overlay-hover)), + linear-gradient(180deg, var(--v2-alpha-light-2) 0%, var(--v2-alpha-light-0) 100%), var(--v2-background-bg-base); } [data-component="text-input-v2"]:where(:focus-within):not([data-disabled], [data-invalid]) { - outline-color: var(--border-border-focus); + outline-color: var(--v2-border-border-focus); box-shadow: none; } [data-component="text-input-v2"]:where([data-invalid]):not([data-disabled]) { - outline-color: var(--state-fg-danger); + outline-color: var(--v2-state-fg-danger); box-shadow: none; } @@ -67,18 +68,17 @@ border: 0; background: transparent; outline: none; - font-family: var(--v2-font-family-sans); font-style: normal; font-weight: 440; font-size: 13px; line-height: 1; letter-spacing: -0.04px; - color: var(--text-text-base); + color: var(--v2-text-text-base); font-variation-settings: "slnt" 0; } [data-component="text-input-v2"] [data-slot="text-input-v2-input"]::placeholder { - color: var(--text-text-faint); + color: var(--v2-text-text-faint); } [data-component="text-input-v2"][data-numeric] [data-slot="text-input-v2-input"] { @@ -99,19 +99,19 @@ border: 0; border-radius: 4px; background: transparent; - color: var(--icon-icon-muted); + color: var(--v2-icon-icon-muted); cursor: pointer; outline: none; } [data-component="text-input-v2"] [data-slot="text-input-v2-icon-button"]:is(:hover, [data-state="hover"]):not(:disabled) { - background-color: var(--overlay-simple-overlay-hover); + background-color: var(--v2-overlay-simple-overlay-hover); } [data-component="text-input-v2"] [data-slot="text-input-v2-icon-button"]:is(:active, [data-state="pressed"]):not(:disabled) { - background-color: var(--overlay-simple-overlay-pressed); + background-color: var(--v2-overlay-simple-overlay-pressed); } [data-component="text-input-v2"] [data-slot="text-input-v2-icon-button"]:focus { @@ -119,7 +119,7 @@ } [data-component="text-input-v2"] [data-slot="text-input-v2-icon-button"]:focus-visible { - outline: 2px solid var(--border-border-focus); + outline: 2px solid var(--v2-border-border-focus); outline-offset: 1px; } @@ -135,11 +135,11 @@ } [data-component="text-input-v2"][data-invalid]:not([data-disabled]) [data-slot="text-input-v2-input"] { - color: var(--state-fg-danger); - caret-color: var(--state-fg-danger); + color: var(--v2-state-fg-danger); + caret-color: var(--v2-state-fg-danger); } [data-component="text-input-v2"][data-invalid]:not([data-disabled]) [data-slot="text-input-v2-input"]::placeholder { - color: var(--state-fg-danger); + color: var(--v2-state-fg-danger); opacity: 1; } diff --git a/packages/ui/src/v2/components/text-input-v2.stories.tsx b/packages/ui/src/v2/components/text-input-v2.stories.tsx index 5e218bb82e70..8a77b891a8b7 100644 --- a/packages/ui/src/v2/components/text-input-v2.stories.tsx +++ b/packages/ui/src/v2/components/text-input-v2.stories.tsx @@ -19,7 +19,7 @@ Compact single-line text field with neutral elevation, optional trailing copy ac - **Focus** (\`:focus-within\`): focus border, elevation removed. - **Invalid**: danger border and text. - **Disabled**: 50% opacity. -- Uses \`data-component="text-input-v2"\` with \`--background-bg-base\`, \`--elevation-button-neutral\`, \`--text-text-faint\` (placeholder), and \`--icon-icon-muted\` (copy icon). +- Uses \`data-component="text-input-v2"\` with \`--v2-background-bg-base\`, \`--v2-elevation-button-neutral\`, \`--v2-text-text-faint\` (placeholder), and \`--v2-icon-icon-muted\` (copy icon). ### Field Compose with \`Field\` for label, helper prefix/suffix, and tooltip — see the **Field** story. diff --git a/packages/ui/src/v2/components/textarea-v2.css b/packages/ui/src/v2/components/textarea-v2.css index e43e18e9eef6..17bad60ed181 100644 --- a/packages/ui/src/v2/components/textarea-v2.css +++ b/packages/ui/src/v2/components/textarea-v2.css @@ -53,7 +53,6 @@ background: transparent; outline: none; resize: vertical; - font-family: var(--v2-font-family-sans); font-style: normal; font-weight: 440; font-size: 13px; diff --git a/packages/ui/src/v2/components/toast-v2.css b/packages/ui/src/v2/components/toast-v2.css index de777cf93192..5bce87dbdc7c 100644 --- a/packages/ui/src/v2/components/toast-v2.css +++ b/packages/ui/src/v2/components/toast-v2.css @@ -43,9 +43,9 @@ transition: transform 140ms ease-out; border-radius: 8px; - color: var(--text-text-base); - background: var(--background-bg-layer-01); - box-shadow: var(--elevation-floating); + color: var(--v2-text-text-base); + background: var(--v2-background-bg-layer-01); + box-shadow: var(--v2-elevation-floating); &[data-opened] { animation: toastV2PopIn 140ms ease-out; @@ -71,9 +71,10 @@ height: 20px; min-width: 16px; min-height: 20px; + color: var(--v2-icon-icon-base); [data-component="icon"] { - color: var(--text-text-base); + color: var(--v2-icon-icon-base); width: 16px; height: 16px; display: inline-flex; @@ -109,11 +110,10 @@ } [data-slot="toast-v2-title"] { - color: var(--text-text-base); + color: var(--v2-text-text-base); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - font-family: "Inter Variable"; font-size: 13px; font-style: normal; font-weight: 530; @@ -124,11 +124,10 @@ } [data-slot="toast-v2-description"] { - color: var(--text-text-muted); + color: var(--v2-text-text-muted); text-wrap-style: pretty; overflow-wrap: anywhere; word-break: break-word; - font-family: "Inter Variable"; font-size: 13px; font-style: normal; font-weight: 440; @@ -147,7 +146,6 @@ [data-slot="toast-v2-actions"] [data-component="button-v2"] { min-height: 24px; - font-family: "Inter Variable"; font-size: 13px; font-style: normal; font-weight: 530; @@ -166,10 +164,26 @@ border: 0; border-radius: 4px; background: transparent; + color: var(--v2-icon-icon-muted); + cursor: pointer; display: inline-flex; align-items: center; justify-content: center; + &:hover { + background: var(--v2-overlay-simple-overlay-hover); + color: var(--v2-icon-icon-base); + } + + &:active { + background: var(--v2-overlay-simple-overlay-pressed); + } + + &:focus-visible { + outline: 2px solid var(--v2-border-border-focus); + outline-offset: 2px; + } + svg { width: 16px; height: 16px; diff --git a/packages/ui/src/v2/components/toast-v2.tsx b/packages/ui/src/v2/components/toast-v2.tsx index 15a5c985430e..67eb23b7fc27 100644 --- a/packages/ui/src/v2/components/toast-v2.tsx +++ b/packages/ui/src/v2/components/toast-v2.tsx @@ -61,8 +61,8 @@ function ToastV2CloseButton(props: ToastCloseButtonProps & ComponentProps<"butto return ( ) diff --git a/packages/ui/src/v2/components/tool-error-card-v2.css b/packages/ui/src/v2/components/tool-error-card-v2.css index 7622226d2bfd..2eaa3c4276b8 100644 --- a/packages/ui/src/v2/components/tool-error-card-v2.css +++ b/packages/ui/src/v2/components/tool-error-card-v2.css @@ -16,7 +16,6 @@ padding: 0 0 0 10px; gap: 8px; border-left: 2px solid var(--tec-border); - font-family: var(--v2-font-family-sans), var(--sans), system-ui, sans-serif; font-variant-numeric: tabular-nums; [data-slot="tool-error-card-trigger"] { diff --git a/packages/ui/src/v2/components/tooltip-v2.css b/packages/ui/src/v2/components/tooltip-v2.css index e3dc298aa78e..19f17f1b617b 100644 --- a/packages/ui/src/v2/components/tooltip-v2.css +++ b/packages/ui/src/v2/components/tooltip-v2.css @@ -10,7 +10,6 @@ box-shadow: var(--elevation-floating); border-radius: 4px; - font-family: "Inter Variable"; font-style: normal; font-weight: 530; font-size: 11px; diff --git a/packages/ui/src/v2/styles/theme.css b/packages/ui/src/v2/styles/theme.css index 6bd3c0f6c9e6..24fe43300ba0 100644 --- a/packages/ui/src/v2/styles/theme.css +++ b/packages/ui/src/v2/styles/theme.css @@ -63,6 +63,26 @@ --v2-state-fg-info: var(--v2-blue-800); --v2-state-border-info: var(--v2-blue-300); + /* ── Project avatar ── */ + --v2-avatar-bg-orange: var(--v2-orange-700); + --v2-avatar-border-orange: var(--v2-orange-800); + --v2-avatar-bg-yellow: var(--v2-yellow-700); + --v2-avatar-border-yellow: var(--v2-yellow-800); + --v2-avatar-bg-cyan: var(--v2-cyan-700); + --v2-avatar-border-cyan: var(--v2-cyan-800); + --v2-avatar-bg-green: var(--v2-green-700); + --v2-avatar-border-green: var(--v2-green-800); + --v2-avatar-bg-red: var(--v2-red-700); + --v2-avatar-border-red: var(--v2-red-800); + --v2-avatar-bg-pink: var(--v2-pink-700); + --v2-avatar-border-pink: var(--v2-pink-800); + --v2-avatar-bg-blue: var(--v2-blue-700); + --v2-avatar-border-blue: var(--v2-blue-800); + --v2-avatar-bg-purple: var(--v2-purple-700); + --v2-avatar-border-purple: var(--v2-purple-800); + --v2-avatar-bg-gray: var(--v2-grey-700); + --v2-avatar-border-gray: var(--v2-grey-800); + /* ── Elevation ── */ --v2-elevation-raised: 0px 2px 4px 0px var(--v2-alpha-dark-4), 0px 1px 2px -1px var(--v2-alpha-dark-8), @@ -285,6 +305,25 @@ --v2-illustration-illustration-layer-01: var(--v2-grey-300); --v2-illustration-illustration-layer-02: var(--v2-grey-400); --v2-illustration-illustration-layer-03: var(--v2-grey-500); + + --v2-avatar-bg-orange: var(--v2-orange-700); + --v2-avatar-border-orange: var(--v2-orange-800); + --v2-avatar-bg-yellow: var(--v2-yellow-700); + --v2-avatar-border-yellow: var(--v2-yellow-800); + --v2-avatar-bg-cyan: var(--v2-cyan-700); + --v2-avatar-border-cyan: var(--v2-cyan-800); + --v2-avatar-bg-green: var(--v2-green-700); + --v2-avatar-border-green: var(--v2-green-800); + --v2-avatar-bg-red: var(--v2-red-700); + --v2-avatar-border-red: var(--v2-red-800); + --v2-avatar-bg-pink: var(--v2-pink-700); + --v2-avatar-border-pink: var(--v2-pink-800); + --v2-avatar-bg-blue: var(--v2-blue-700); + --v2-avatar-border-blue: var(--v2-blue-800); + --v2-avatar-bg-purple: var(--v2-purple-700); + --v2-avatar-border-purple: var(--v2-purple-800); + --v2-avatar-bg-gray: var(--v2-grey-700); + --v2-avatar-border-gray: var(--v2-grey-800); } /* Explicit dark mode via data attribute (Storybook toggle, runtime JS) */ @@ -346,6 +385,25 @@ --v2-state-fg-info: var(--v2-blue-500); --v2-state-border-info: var(--v2-blue-900); + --v2-avatar-bg-orange: var(--v2-orange-1100); + --v2-avatar-border-orange: var(--v2-orange-600); + --v2-avatar-bg-yellow: var(--v2-yellow-1100); + --v2-avatar-border-yellow: var(--v2-yellow-700); + --v2-avatar-bg-cyan: var(--v2-cyan-1000); + --v2-avatar-border-cyan: var(--v2-cyan-700); + --v2-avatar-bg-green: var(--v2-green-1000); + --v2-avatar-border-green: var(--v2-green-600); + --v2-avatar-bg-red: var(--v2-red-1000); + --v2-avatar-border-red: var(--v2-red-700); + --v2-avatar-bg-pink: var(--v2-pink-1000); + --v2-avatar-border-pink: var(--v2-pink-700); + --v2-avatar-bg-blue: var(--v2-blue-900); + --v2-avatar-border-blue: var(--v2-blue-500); + --v2-avatar-bg-purple: var(--v2-purple-1000); + --v2-avatar-border-purple: var(--v2-purple-600); + --v2-avatar-bg-gray: var(--v2-grey-700); + --v2-avatar-border-gray: var(--v2-grey-500); + --v2-elevation-raised: 0px 2px 4px 0px var(--v2-alpha-dark-30), 0px 1px 2px 0px var(--v2-alpha-dark-30), 0px 0px 0px 0.5px var(--v2-alpha-light-16), 0px -0.5px 0px 0px var(--v2-alpha-light-6);