diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index ebeca3b9ed..9a326cee38 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -1,11 +1,12 @@ -import { createEffect, createMemo, Show, untrack } from "solid-js" -import { createStore } from "solid-js/store" -import { useLocation, useNavigate, useParams } from "@solidjs/router" +import { createEffect, createMemo, For, mapArray, Match, Show, startTransition, Switch, untrack } from "solid-js" +import { createStore, produce } from "solid-js/store" +import { useLocation, useMatch, useNavigate, useParams } from "@solidjs/router" import { IconButton } from "@opencode-ai/ui/icon-button" 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 { useLayout } from "@/context/layout" import { usePlatform } from "@/context/platform" @@ -14,6 +15,9 @@ import { useLanguage } from "@/context/language" import { useSettings } from "@/context/settings" import { WindowsAppMenu } from "./windows-app-menu" import { applyPath, backPath, forwardPath } from "./titlebar-history" +import { useGlobalSync } from "@/context/global-sync" +import { decodeDirectory } from "@/pages/directory-layout" +import { iife } from "@opencode-ai/core/util/iife" type TauriDesktopWindow = { startDragging?: () => Promise @@ -40,6 +44,8 @@ const titlebarHeight = 40 const minTitlebarZoom = 0.25 const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each. +const makeSessionHref = (b64Dir: string, sessionId: string) => `/${b64Dir}/session/${sessionId}` + export function Titlebar() { const layout = useLayout() const platform = usePlatform() @@ -53,6 +59,7 @@ export function Titlebar() { const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos") const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows") + const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux") const web = createMemo(() => platform.platform === "web") const zoom = () => platform.webviewZoom?.() ?? 1 const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom()) @@ -176,165 +183,378 @@ export function Titlebar() { return (
-
-
- - - - -
-
- -
- - -
- -
-
-
- - - -
) } + +function TabNavItem(props: { href: string; title: string; hideClose?: boolean; onClose: () => void }) { + const match = useMatch(() => props.href) + const isActive = () => !!match() + return ( +
+ + {props.title} + + +
+
+ + + + } + /> +
+
+ ) +} +function ChannelIndicator() { + return ( + <> + {["beta", "dev"].includes(import.meta.env.VITE_OPENCODE_CHANNEL) && ( +
+ {import.meta.env.VITE_OPENCODE_CHANNEL.toUpperCase()} +
+ )} + + ) +} diff --git a/packages/app/src/context/directory-sync.ts b/packages/app/src/context/directory-sync.ts new file mode 100644 index 0000000000..1fdeb68227 --- /dev/null +++ b/packages/app/src/context/directory-sync.ts @@ -0,0 +1,596 @@ +import { batch, createMemo } from "solid-js" +import { createStore, produce, reconcile } from "solid-js/store" +import { Binary } from "@opencode-ai/core/util/binary" +import { retry } from "@opencode-ai/core/util/retry" +import { + clearSessionPrefetch, + getSessionPrefetch, + getSessionPrefetchPromise, + setSessionPrefetch, +} from "./global-sync/session-prefetch" +import { useGlobalSync } from "./global-sync" +import type { Message, OpencodeClient, Part } from "@opencode-ai/sdk/v2/client" +import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache" +import { diffs as list, message as clean } from "@/utils/diffs" + +const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) + +function sortParts(parts: Part[]) { + return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)) +} + +function runInflight(map: Map>, key: string, task: () => Promise) { + const pending = map.get(key) + if (pending) return pending + const promise = task().finally(() => { + map.delete(key) + }) + map.set(key, promise) + return promise +} + +const keyFor = (directory: string, id: string) => `${directory}\n${id}` + +const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) + +function merge(a: readonly T[], b: readonly T[]) { + const map = new Map(a.map((item) => [item.id, item] as const)) + for (const item of b) map.set(item.id, item) + return [...map.values()].sort((x, y) => cmp(x.id, y.id)) +} + +type OptimisticStore = { + message: Record + part: Record +} + +type OptimisticAddInput = { + sessionID: string + message: Message + parts: Part[] +} + +type OptimisticRemoveInput = { + sessionID: string + messageID: string +} + +type OptimisticItem = { + message: Message + parts: Part[] +} + +type MessagePage = { + session: Message[] + part: { id: string; part: Part[] }[] + cursor?: string + complete: boolean +} + +const hasParts = (parts: Part[] | undefined, want: Part[]) => { + if (!parts) return want.length === 0 + return want.every((part) => Binary.search(parts, part.id, (item) => item.id).found) +} + +const mergeParts = (parts: Part[] | undefined, want: Part[]) => { + if (!parts) return sortParts(want) + const next = [...parts] + let changed = false + for (const part of want) { + const result = Binary.search(next, part.id, (item) => item.id) + if (result.found) continue + next.splice(result.index, 0, part) + changed = true + } + if (!changed) return parts + return next +} + +export function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) { + if (items.length === 0) return { ...page, confirmed: [] as string[] } + + const session = [...page.session] + const part = new Map(page.part.map((item) => [item.id, sortParts(item.part)])) + const confirmed: string[] = [] + + for (const item of items) { + const result = Binary.search(session, item.message.id, (message) => message.id) + const found = result.found + if (!found) session.splice(result.index, 0, item.message) + + const current = part.get(item.message.id) + if (found && hasParts(current, item.parts)) { + confirmed.push(item.message.id) + continue + } + + part.set(item.message.id, mergeParts(current, item.parts)) + } + + return { + cursor: page.cursor, + complete: page.complete, + session, + part: [...part.entries()].sort((a, b) => cmp(a[0], b[0])).map(([id, part]) => ({ id, part })), + confirmed, + } +} + +export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) { + const messages = draft.message[input.sessionID] + if (messages) { + const result = Binary.search(messages, input.message.id, (m) => m.id) + messages.splice(result.index, 0, input.message) + } else { + draft.message[input.sessionID] = [input.message] + } + draft.part[input.message.id] = sortParts(input.parts) +} + +export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticRemoveInput) { + const messages = draft.message[input.sessionID] + if (messages) { + const result = Binary.search(messages, input.messageID, (m) => m.id) + if (result.found) messages.splice(result.index, 1) + } + delete draft.part[input.messageID] +} + +function setOptimisticAdd(setStore: (...args: unknown[]) => void, input: OptimisticAddInput) { + setStore("message", input.sessionID, (messages: Message[] | undefined) => { + if (!messages) return [input.message] + const result = Binary.search(messages, input.message.id, (m) => m.id) + const next = [...messages] + next.splice(result.index, 0, input.message) + return next + }) + setStore("part", input.message.id, sortParts(input.parts)) +} + +function setOptimisticRemove(setStore: (...args: unknown[]) => void, input: OptimisticRemoveInput) { + setStore("message", input.sessionID, (messages: Message[] | undefined) => { + if (!messages) return messages + const result = Binary.search(messages, input.messageID, (m) => m.id) + if (!result.found) return messages + const next = [...messages] + next.splice(result.index, 1) + return next + }) + setStore("part", (part: Record) => { + if (!(input.messageID in part)) return part + const next = { ...part } + delete next[input.messageID] + return next + }) +} + +export const createDirSyncContext = (client: OpencodeClient, directory: string) => { + const globalSync = useGlobalSync() + + type Child = ReturnType<(typeof globalSync)["child"]> + type Setter = Child[1] + + const current = createMemo(() => globalSync.child(directory)) + const target = (directory?: string) => { + if (!directory || directory === directory) return current() + return globalSync.child(directory) + } + const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/") + const initialMessagePageSize = 80 + const historyMessagePageSize = 200 + const inflight = new Map>() + const inflightDiff = new Map>() + const inflightTodo = new Map>() + const optimistic = new Map>() + const maxDirs = 30 + const seen = new Map>() + const [meta, setMeta] = createStore({ + limit: {} as Record, + cursor: {} as Record, + complete: {} as Record, + loading: {} as Record, + }) + + const getSession = (sessionID: string) => { + const store = current()[0] + const match = Binary.search(store.session, sessionID, (s) => s.id) + if (match.found) return store.session[match.index] + return undefined + } + + const setOptimistic = (directory: string, sessionID: string, item: OptimisticItem) => { + const key = keyFor(directory, sessionID) + const list = optimistic.get(key) + if (list) { + list.set(item.message.id, { message: item.message, parts: sortParts(item.parts) }) + return + } + optimistic.set(key, new Map([[item.message.id, { message: item.message, parts: sortParts(item.parts) }]])) + } + + const clearOptimistic = (directory: string, sessionID: string, messageID?: string) => { + const key = keyFor(directory, sessionID) + if (!messageID) { + optimistic.delete(key) + return + } + + const list = optimistic.get(key) + if (!list) return + list.delete(messageID) + if (list.size === 0) optimistic.delete(key) + } + + const getOptimistic = (directory: string, sessionID: string) => [ + ...(optimistic.get(keyFor(directory, sessionID))?.values() ?? []), + ] + + const seenFor = (directory: string) => { + const existing = seen.get(directory) + if (existing) { + seen.delete(directory) + seen.set(directory, existing) + return existing + } + const created = new Set() + seen.set(directory, created) + while (seen.size > maxDirs) { + const first = seen.keys().next().value + if (!first) break + const stale = [...(seen.get(first) ?? [])] + seen.delete(first) + const [, setStore] = globalSync.child(first, { bootstrap: false }) + evict(first, setStore, stale) + } + return created + } + + const clearMeta = (directory: string, sessionIDs: string[]) => { + if (sessionIDs.length === 0) return + for (const sessionID of sessionIDs) { + clearOptimistic(directory, sessionID) + } + setMeta( + produce((draft) => { + for (const sessionID of sessionIDs) { + const key = keyFor(directory, sessionID) + delete draft.limit[key] + delete draft.cursor[key] + delete draft.complete[key] + delete draft.loading[key] + } + }), + ) + } + + const evict = (directory: string, setStore: Setter, sessionIDs: string[]) => { + if (sessionIDs.length === 0) return + clearSessionPrefetch(directory, sessionIDs) + for (const sessionID of sessionIDs) { + globalSync.todo.set(sessionID, undefined) + } + setStore( + produce((draft) => { + dropSessionCaches(draft, sessionIDs) + }), + ) + clearMeta(directory, sessionIDs) + } + + const touch = (directory: string, setStore: Setter, sessionID: string) => { + const stale = pickSessionCacheEvictions({ + seen: seenFor(directory), + keep: sessionID, + limit: SESSION_CACHE_LIMIT, + }) + evict(directory, setStore, stale) + } + + const fetchMessages = async (input: { client: typeof client; sessionID: string; limit: number; before?: string }) => { + const messages = await retry(() => + input.client.session.messages({ sessionID: input.sessionID, limit: input.limit, before: input.before }), + ) + const items = (messages.data ?? []).filter((x) => !!x?.info?.id) + const session = items.map((x) => clean(x.info)).sort((a, b) => cmp(a.id, b.id)) + const part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) })) + const cursor = messages.response.headers.get("x-next-cursor") ?? undefined + return { + session, + part, + cursor, + complete: !cursor, + } + } + + const tracked = (directory: string, sessionID: string) => seen.get(directory)?.has(sessionID) ?? false + + const loadMessages = async (input: { + directory: string + client: typeof client + setStore: Setter + sessionID: string + limit: number + before?: string + mode?: "replace" | "prepend" + }) => { + const key = keyFor(input.directory, input.sessionID) + if (meta.loading[key]) return + + setMeta("loading", key, true) + await fetchMessages(input) + .then((page) => { + if (!tracked(input.directory, input.sessionID)) return + const next = mergeOptimisticPage(page, getOptimistic(input.directory, input.sessionID)) + for (const messageID of next.confirmed) { + clearOptimistic(input.directory, input.sessionID, messageID) + } + const [store] = globalSync.child(input.directory, { bootstrap: false }) + const cached = input.mode === "prepend" ? (store.message[input.sessionID] ?? []) : [] + const message = input.mode === "prepend" ? merge(cached, next.session) : next.session + batch(() => { + input.setStore("message", input.sessionID, reconcile(message, { key: "id" })) + for (const p of next.part) { + const filtered = p.part.filter((x) => !SKIP_PARTS.has(x.type)) + if (filtered.length) input.setStore("part", p.id, filtered) + } + setMeta("limit", key, message.length) + setMeta("cursor", key, next.cursor) + setMeta("complete", key, next.complete) + setSessionPrefetch({ + directory: input.directory, + sessionID: input.sessionID, + limit: message.length, + cursor: next.cursor, + complete: next.complete, + }) + }) + }) + .finally(() => { + setMeta( + produce((draft) => { + if (!tracked(input.directory, input.sessionID)) { + delete draft.loading[key] + return + } + draft.loading[key] = false + }), + ) + }) + } + + return { + get data() { + return current()[0] + }, + get set(): Setter { + return current()[1] + }, + get status() { + return current()[0].status + }, + get ready() { + return current()[0].status !== "loading" + }, + get project() { + const store = current()[0] + const match = Binary.search(globalSync.data.project, store.project, (p) => p.id) + if (match.found) return globalSync.data.project[match.index] + return undefined + }, + session: { + get: getSession, + optimistic: { + add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) { + const _directory = input.directory ?? directory + const [, setStore] = target(input.directory) + setOptimistic(_directory, input.sessionID, { message: input.message, parts: input.parts }) + setOptimisticAdd(setStore as (...args: unknown[]) => void, input) + }, + remove(input: { directory?: string; sessionID: string; messageID: string }) { + const _directory = input.directory ?? directory + const [, setStore] = target(input.directory) + clearOptimistic(_directory, input.sessionID, input.messageID) + setOptimisticRemove(setStore as (...args: unknown[]) => void, input) + }, + }, + addOptimisticMessage(input: { + sessionID: string + messageID: string + parts: Part[] + agent: string + model: { providerID: string; modelID: string } + variant?: string + }) { + const message: Message = { + id: input.messageID, + sessionID: input.sessionID, + role: "user", + time: { created: Date.now() }, + agent: input.agent, + model: { ...input.model, variant: input.variant }, + } + const [, setStore] = target() + setOptimistic(directory, input.sessionID, { message, parts: input.parts }) + setOptimisticAdd(setStore as (...args: unknown[]) => void, { + sessionID: input.sessionID, + message, + parts: input.parts, + }) + }, + async sync(sessionID: string, opts?: { force?: boolean }) { + const [store, setStore] = globalSync.child(directory) + const key = keyFor(directory, sessionID) + + touch(directory, setStore, sessionID) + + const seeded = getSessionPrefetch(directory, sessionID) + if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) { + batch(() => { + setMeta("limit", key, seeded.limit) + setMeta("cursor", key, seeded.cursor) + setMeta("complete", key, seeded.complete) + setMeta("loading", key, false) + }) + } + + return runInflight(inflight, key, async () => { + const pending = getSessionPrefetchPromise(directory, sessionID) + if (pending) { + await pending + const seeded = getSessionPrefetch(directory, sessionID) + if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) { + batch(() => { + setMeta("limit", key, seeded.limit) + setMeta("cursor", key, seeded.cursor) + setMeta("complete", key, seeded.complete) + setMeta("loading", key, false) + }) + } + } + + const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found + const cached = store.message[sessionID] !== undefined && meta.limit[key] !== undefined + if (cached && hasSession && !opts?.force) return + + const limit = meta.limit[key] ?? initialMessagePageSize + const sessionReq = + hasSession && !opts?.force + ? Promise.resolve() + : retry(() => client.session.get({ sessionID })).then((session) => { + if (!tracked(directory, sessionID)) return + const data = session.data + if (!data) return + setStore( + "session", + produce((draft) => { + const match = Binary.search(draft, sessionID, (s) => s.id) + if (match.found) { + draft[match.index] = data + return + } + draft.splice(match.index, 0, data) + }), + ) + }) + + const messagesReq = + cached && !opts?.force + ? Promise.resolve() + : loadMessages({ + directory, + client, + setStore, + sessionID, + limit, + }) + + await Promise.all([sessionReq, messagesReq]) + }) + }, + async diff(sessionID: string, opts?: { force?: boolean }) { + const [store, setStore] = globalSync.child(directory) + touch(directory, setStore, sessionID) + if (store.session_diff[sessionID] !== undefined && !opts?.force) return + + const key = keyFor(directory, sessionID) + return runInflight(inflightDiff, key, () => + retry(() => client.session.diff({ sessionID })).then((diff) => { + if (!tracked(directory, sessionID)) return + setStore("session_diff", sessionID, reconcile(list(diff.data), { key: "file" })) + }), + ) + }, + async todo(sessionID: string, opts?: { force?: boolean }) { + const [store, setStore] = globalSync.child(directory) + touch(directory, setStore, sessionID) + const existing = store.todo[sessionID] + const cached = globalSync.data.session_todo[sessionID] + if (existing !== undefined) { + if (cached === undefined) { + globalSync.todo.set(sessionID, existing) + } + if (!opts?.force) return + } + + if (cached !== undefined) { + setStore("todo", sessionID, reconcile(cached, { key: "id" })) + } + + const key = keyFor(directory, sessionID) + return runInflight(inflightTodo, key, () => + retry(() => client.session.todo({ sessionID })).then((todo) => { + if (!tracked(directory, sessionID)) return + const list = todo.data ?? [] + setStore("todo", sessionID, reconcile(list, { key: "id" })) + globalSync.todo.set(sessionID, list) + }), + ) + }, + history: { + more(sessionID: string) { + const store = current()[0] + const key = keyFor(directory, sessionID) + if (store.message[sessionID] === undefined) return false + if (meta.limit[key] === undefined) return false + if (meta.complete[key]) return false + return !!meta.cursor[key] + }, + loading(sessionID: string) { + const key = keyFor(directory, sessionID) + return meta.loading[key] ?? false + }, + async loadMore(sessionID: string, count?: number) { + const [, setStore] = globalSync.child(directory) + touch(directory, setStore, sessionID) + const key = keyFor(directory, sessionID) + const step = count ?? historyMessagePageSize + if (meta.loading[key]) return + if (meta.complete[key]) return + const before = meta.cursor[key] + if (!before) return + + await loadMessages({ + directory, + client, + setStore, + sessionID, + limit: step, + before, + mode: "prepend", + }) + }, + }, + evict(sessionID: string, _directory = directory) { + const [, setStore] = globalSync.child(_directory) + seenFor(_directory).delete(sessionID) + evict(_directory, setStore, [sessionID]) + }, + fetch: async (count = 10) => { + const [store, setStore] = globalSync.child(directory) + setStore("limit", (x) => x + count) + await client.session.list().then((x) => { + const sessions = (x.data ?? []) + .filter((s) => !!s?.id) + .sort((a, b) => cmp(a.id, b.id)) + .slice(0, store.limit) + setStore("session", reconcile(sessions, { key: "id" })) + }) + }, + more: createMemo(() => current()[0].session.length >= current()[0].limit), + archive: async (sessionID: string) => { + const [, setStore] = globalSync.child(directory) + await client.session.update({ sessionID, time: { archived: Date.now() } }) + setStore( + produce((draft) => { + const match = Binary.search(draft.session, sessionID, (s) => s.id) + if (match.found) draft.session.splice(match.index, 1) + }), + ) + }, + }, + absolute, + get directory() { + return current()[0].path.directory + }, + } +} diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 96c4526da6..c418420da5 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -36,6 +36,7 @@ import { queryOptions, useMutation, useQueries, useQuery, useQueryClient } from import { createRefreshQueue } from "./global-sync/queue" import { directoryKey } from "./global-sync/utils" import { PathKey } from "@/utils/path-key" +import { createDirSyncContext } from "./directory-sync" type GlobalStore = { ready: boolean @@ -431,6 +432,9 @@ function createGlobalSync() { }, })) + const dirSyncContexts = new Map>() + const dirSyncContextRefCounts = new Map() + return { data: globalStore, set, @@ -449,6 +453,26 @@ function createGlobalSync() { todo: { set: setSessionTodo, }, + createDirSyncContext: (directory: string) => { + onCleanup(() => { + dirSyncContextRefCounts.set(directory, (dirSyncContextRefCounts.get(directory) ?? 0) - 1) + if (dirSyncContextRefCounts.get(directory) === 0) { + dirSyncContexts.delete(directory) + dirSyncContextRefCounts.delete(directory) + } + }) + + const cached = dirSyncContexts.get(directory) + if (cached) { + dirSyncContextRefCounts.set(directory, (dirSyncContextRefCounts.get(directory) ?? 0) + 1) + return cached + } + const ctx = createDirSyncContext(globalSDK.createClient({ directory, throwOnError: true }), directory) + dirSyncContexts.set(directory, ctx) + dirSyncContextRefCounts.set(directory, 1) + + return ctx + }, } } diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index 34b597b6bb..ba97d14282 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -1,19 +1,8 @@ -import { batch, createMemo } from "solid-js" -import { createStore, produce, reconcile } from "solid-js/store" import { Binary } from "@opencode-ai/core/util/binary" -import { retry } from "@opencode-ai/core/util/retry" import { createSimpleContext } from "@opencode-ai/ui/context" -import { - clearSessionPrefetch, - getSessionPrefetch, - getSessionPrefetchPromise, - setSessionPrefetch, -} from "./global-sync/session-prefetch" import { useGlobalSync } from "./global-sync" import { useSDK } from "./sdk" import type { Message, Part } from "@opencode-ai/sdk/v2/client" -import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache" -import { diffs as list, message as clean } from "@/utils/diffs" const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) @@ -172,448 +161,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const globalSync = useGlobalSync() const sdk = useSDK() - type Child = ReturnType<(typeof globalSync)["child"]> - type Setter = Child[1] - - const current = createMemo(() => globalSync.child(sdk.directory)) - const target = (directory?: string) => { - if (!directory || directory === sdk.directory) return current() - return globalSync.child(directory) - } - const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/") - const initialMessagePageSize = 80 - const historyMessagePageSize = 200 - const inflight = new Map>() - const inflightDiff = new Map>() - const inflightTodo = new Map>() - const optimistic = new Map>() - const maxDirs = 30 - const seen = new Map>() - const [meta, setMeta] = createStore({ - limit: {} as Record, - cursor: {} as Record, - complete: {} as Record, - loading: {} as Record, - }) - - const getSession = (sessionID: string) => { - const store = current()[0] - const match = Binary.search(store.session, sessionID, (s) => s.id) - if (match.found) return store.session[match.index] - return undefined - } - - const setOptimistic = (directory: string, sessionID: string, item: OptimisticItem) => { - const key = keyFor(directory, sessionID) - const list = optimistic.get(key) - if (list) { - list.set(item.message.id, { message: item.message, parts: sortParts(item.parts) }) - return - } - optimistic.set(key, new Map([[item.message.id, { message: item.message, parts: sortParts(item.parts) }]])) - } - - const clearOptimistic = (directory: string, sessionID: string, messageID?: string) => { - const key = keyFor(directory, sessionID) - if (!messageID) { - optimistic.delete(key) - return - } - - const list = optimistic.get(key) - if (!list) return - list.delete(messageID) - if (list.size === 0) optimistic.delete(key) - } - - const getOptimistic = (directory: string, sessionID: string) => [ - ...(optimistic.get(keyFor(directory, sessionID))?.values() ?? []), - ] - - const seenFor = (directory: string) => { - const existing = seen.get(directory) - if (existing) { - seen.delete(directory) - seen.set(directory, existing) - return existing - } - const created = new Set() - seen.set(directory, created) - while (seen.size > maxDirs) { - const first = seen.keys().next().value - if (!first) break - const stale = [...(seen.get(first) ?? [])] - seen.delete(first) - const [, setStore] = globalSync.child(first, { bootstrap: false }) - evict(first, setStore, stale) - } - return created - } - - const clearMeta = (directory: string, sessionIDs: string[]) => { - if (sessionIDs.length === 0) return - for (const sessionID of sessionIDs) { - clearOptimistic(directory, sessionID) - } - setMeta( - produce((draft) => { - for (const sessionID of sessionIDs) { - const key = keyFor(directory, sessionID) - delete draft.limit[key] - delete draft.cursor[key] - delete draft.complete[key] - delete draft.loading[key] - } - }), - ) - } - - const evict = (directory: string, setStore: Setter, sessionIDs: string[]) => { - if (sessionIDs.length === 0) return - clearSessionPrefetch(directory, sessionIDs) - for (const sessionID of sessionIDs) { - globalSync.todo.set(sessionID, undefined) - } - setStore( - produce((draft) => { - dropSessionCaches(draft, sessionIDs) - }), - ) - clearMeta(directory, sessionIDs) - } - - const touch = (directory: string, setStore: Setter, sessionID: string) => { - const stale = pickSessionCacheEvictions({ - seen: seenFor(directory), - keep: sessionID, - limit: SESSION_CACHE_LIMIT, - }) - evict(directory, setStore, stale) - } - - const fetchMessages = async (input: { - client: typeof sdk.client - sessionID: string - limit: number - before?: string - }) => { - const messages = await retry(() => - input.client.session.messages({ sessionID: input.sessionID, limit: input.limit, before: input.before }), - ) - const items = (messages.data ?? []).filter((x) => !!x?.info?.id) - const session = items.map((x) => clean(x.info)).sort((a, b) => cmp(a.id, b.id)) - const part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) })) - const cursor = messages.response.headers.get("x-next-cursor") ?? undefined - return { - session, - part, - cursor, - complete: !cursor, - } - } - - const tracked = (directory: string, sessionID: string) => seen.get(directory)?.has(sessionID) ?? false - - const loadMessages = async (input: { - directory: string - client: typeof sdk.client - setStore: Setter - sessionID: string - limit: number - before?: string - mode?: "replace" | "prepend" - }) => { - const key = keyFor(input.directory, input.sessionID) - if (meta.loading[key]) return - - setMeta("loading", key, true) - await fetchMessages(input) - .then((page) => { - if (!tracked(input.directory, input.sessionID)) return - const next = mergeOptimisticPage(page, getOptimistic(input.directory, input.sessionID)) - for (const messageID of next.confirmed) { - clearOptimistic(input.directory, input.sessionID, messageID) - } - const [store] = globalSync.child(input.directory, { bootstrap: false }) - const cached = input.mode === "prepend" ? (store.message[input.sessionID] ?? []) : [] - const message = input.mode === "prepend" ? merge(cached, next.session) : next.session - batch(() => { - input.setStore("message", input.sessionID, reconcile(message, { key: "id" })) - for (const p of next.part) { - const filtered = p.part.filter((x) => !SKIP_PARTS.has(x.type)) - if (filtered.length) input.setStore("part", p.id, filtered) - } - setMeta("limit", key, message.length) - setMeta("cursor", key, next.cursor) - setMeta("complete", key, next.complete) - setSessionPrefetch({ - directory: input.directory, - sessionID: input.sessionID, - limit: message.length, - cursor: next.cursor, - complete: next.complete, - }) - }) - }) - .finally(() => { - setMeta( - produce((draft) => { - if (!tracked(input.directory, input.sessionID)) { - delete draft.loading[key] - return - } - draft.loading[key] = false - }), - ) - }) - } - - return { - get data() { - return current()[0] - }, - get set(): Setter { - return current()[1] - }, - get status() { - return current()[0].status - }, - get ready() { - return current()[0].status !== "loading" - }, - get project() { - const store = current()[0] - const match = Binary.search(globalSync.data.project, store.project, (p) => p.id) - if (match.found) return globalSync.data.project[match.index] - return undefined - }, - session: { - get: getSession, - optimistic: { - add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) { - const directory = input.directory ?? sdk.directory - const [, setStore] = target(input.directory) - setOptimistic(directory, input.sessionID, { message: input.message, parts: input.parts }) - setOptimisticAdd(setStore as (...args: unknown[]) => void, input) - }, - remove(input: { directory?: string; sessionID: string; messageID: string }) { - const directory = input.directory ?? sdk.directory - const [, setStore] = target(input.directory) - clearOptimistic(directory, input.sessionID, input.messageID) - setOptimisticRemove(setStore as (...args: unknown[]) => void, input) - }, - }, - addOptimisticMessage(input: { - sessionID: string - messageID: string - parts: Part[] - agent: string - model: { providerID: string; modelID: string } - variant?: string - }) { - const message: Message = { - id: input.messageID, - sessionID: input.sessionID, - role: "user", - time: { created: Date.now() }, - agent: input.agent, - model: { ...input.model, variant: input.variant }, - } - const [, setStore] = target() - setOptimistic(sdk.directory, input.sessionID, { message, parts: input.parts }) - setOptimisticAdd(setStore as (...args: unknown[]) => void, { - sessionID: input.sessionID, - message, - parts: input.parts, - }) - }, - async sync(sessionID: string, opts?: { force?: boolean }) { - const directory = sdk.directory - const client = sdk.client - const [store, setStore] = globalSync.child(directory) - const key = keyFor(directory, sessionID) - - touch(directory, setStore, sessionID) - - const seeded = getSessionPrefetch(directory, sessionID) - if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) { - batch(() => { - setMeta("limit", key, seeded.limit) - setMeta("cursor", key, seeded.cursor) - setMeta("complete", key, seeded.complete) - setMeta("loading", key, false) - }) - } - - return runInflight(inflight, key, async () => { - const pending = getSessionPrefetchPromise(directory, sessionID) - if (pending) { - await pending - const seeded = getSessionPrefetch(directory, sessionID) - if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) { - batch(() => { - setMeta("limit", key, seeded.limit) - setMeta("cursor", key, seeded.cursor) - setMeta("complete", key, seeded.complete) - setMeta("loading", key, false) - }) - } - } - - const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found - const cached = store.message[sessionID] !== undefined && meta.limit[key] !== undefined - if (cached && hasSession && !opts?.force) return - - const limit = meta.limit[key] ?? initialMessagePageSize - const sessionReq = - hasSession && !opts?.force - ? Promise.resolve() - : retry(() => client.session.get({ sessionID })).then((session) => { - if (!tracked(directory, sessionID)) return - const data = session.data - if (!data) return - setStore( - "session", - produce((draft) => { - const match = Binary.search(draft, sessionID, (s) => s.id) - if (match.found) { - draft[match.index] = data - return - } - draft.splice(match.index, 0, data) - }), - ) - }) - - const messagesReq = - cached && !opts?.force - ? Promise.resolve() - : loadMessages({ - directory, - client, - setStore, - sessionID, - limit, - }) - - await Promise.all([sessionReq, messagesReq]) - }) - }, - async diff(sessionID: string, opts?: { force?: boolean }) { - const directory = sdk.directory - const client = sdk.client - const [store, setStore] = globalSync.child(directory) - touch(directory, setStore, sessionID) - if (store.session_diff[sessionID] !== undefined && !opts?.force) return - - const key = keyFor(directory, sessionID) - return runInflight(inflightDiff, key, () => - retry(() => client.session.diff({ sessionID })).then((diff) => { - if (!tracked(directory, sessionID)) return - setStore("session_diff", sessionID, reconcile(list(diff.data), { key: "file" })) - }), - ) - }, - async todo(sessionID: string, opts?: { force?: boolean }) { - const directory = sdk.directory - const client = sdk.client - const [store, setStore] = globalSync.child(directory) - touch(directory, setStore, sessionID) - const existing = store.todo[sessionID] - const cached = globalSync.data.session_todo[sessionID] - if (existing !== undefined) { - if (cached === undefined) { - globalSync.todo.set(sessionID, existing) - } - if (!opts?.force) return - } - - if (cached !== undefined) { - setStore("todo", sessionID, reconcile(cached, { key: "id" })) - } - - const key = keyFor(directory, sessionID) - return runInflight(inflightTodo, key, () => - retry(() => client.session.todo({ sessionID })).then((todo) => { - if (!tracked(directory, sessionID)) return - const list = todo.data ?? [] - setStore("todo", sessionID, reconcile(list, { key: "id" })) - globalSync.todo.set(sessionID, list) - }), - ) - }, - history: { - more(sessionID: string) { - const store = current()[0] - const key = keyFor(sdk.directory, sessionID) - if (store.message[sessionID] === undefined) return false - if (meta.limit[key] === undefined) return false - if (meta.complete[key]) return false - return !!meta.cursor[key] - }, - loading(sessionID: string) { - const key = keyFor(sdk.directory, sessionID) - return meta.loading[key] ?? false - }, - async loadMore(sessionID: string, count?: number) { - const directory = sdk.directory - const client = sdk.client - const [, setStore] = globalSync.child(directory) - touch(directory, setStore, sessionID) - const key = keyFor(directory, sessionID) - const step = count ?? historyMessagePageSize - if (meta.loading[key]) return - if (meta.complete[key]) return - const before = meta.cursor[key] - if (!before) return - - await loadMessages({ - directory, - client, - setStore, - sessionID, - limit: step, - before, - mode: "prepend", - }) - }, - }, - evict(sessionID: string, directory = sdk.directory) { - const [, setStore] = globalSync.child(directory) - seenFor(directory).delete(sessionID) - evict(directory, setStore, [sessionID]) - }, - fetch: async (count = 10) => { - const directory = sdk.directory - const client = sdk.client - const [store, setStore] = globalSync.child(directory) - setStore("limit", (x) => x + count) - await client.session.list().then((x) => { - const sessions = (x.data ?? []) - .filter((s) => !!s?.id) - .sort((a, b) => cmp(a.id, b.id)) - .slice(0, store.limit) - setStore("session", reconcile(sessions, { key: "id" })) - }) - }, - more: createMemo(() => current()[0].session.length >= current()[0].limit), - archive: async (sessionID: string) => { - const directory = sdk.directory - const client = sdk.client - const [, setStore] = globalSync.child(directory) - await client.session.update({ sessionID, time: { archived: Date.now() } }) - setStore( - produce((draft) => { - const match = Binary.search(draft.session, sessionID, (s) => s.id) - if (match.found) draft.session.splice(match.index, 1) - }), - ) - }, - }, - absolute, - get directory() { - return current()[0].path.directory - }, - } + return globalSync.createDirSyncContext(sdk.directory) }, }) diff --git a/packages/app/src/index.css b/packages/app/src/index.css index 79641cae5d..9f11f7a062 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -1,4 +1,5 @@ @import "@opencode-ai/ui/styles/tailwind"; +@import "@opencode-ai/ui/v2/styles/tailwind.css"; @font-face { font-family: "JetBrainsMono Nerd Font Mono"; diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index 90ce3c1a52..59f454ff57 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -8,6 +8,7 @@ import { LocalProvider } from "@/context/local" import { SDKProvider } from "@/context/sdk" import { SyncProvider, useSync } from "@/context/sync" import { decode64 } from "@/utils/base64" +import { Schema } from "effect" function DirectoryDataProvider(props: ParentProps<{ directory: string }>) { const location = useLocation() @@ -40,6 +41,15 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) { ) } +export const ProjectDirString = Schema.String.pipe(Schema.brand("ProjectDirString")) +export type ProjectDirString = Schema.Schema.Type + +export function decodeDirectory(dir: string): ProjectDirString | undefined { + const decoded = decode64(dir) + if (!decoded) return + return ProjectDirString.make(decoded) +} + export default function Layout(props: ParentProps) { const params = useParams() const language = useLanguage() @@ -48,7 +58,7 @@ export default function Layout(props: ParentProps) { const resolved = createMemo(() => { if (!params.dir) return "" - return decode64(params.dir) ?? "" + return decodeDirectory(params.dir) ?? "" }) createEffect(() => { diff --git a/packages/ui/package.json b/packages/ui/package.json index 114842134f..006d6f52cc 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -22,7 +22,8 @@ "./icons/file-type": "./src/components/file-icons/types.ts", "./icons/app": "./src/components/app-icons/types.ts", "./fonts/*": "./src/assets/fonts/*", - "./audio/*": "./src/assets/audio/*" + "./audio/*": "./src/assets/audio/*", + "./v2/*": "./src/v2/*" }, "scripts": { "typecheck": "tsgo --noEmit", diff --git a/packages/ui/src/v2/components/accordion-v2.css b/packages/ui/src/v2/components/accordion-v2.css new file mode 100644 index 0000000000..69fba782cd --- /dev/null +++ b/packages/ui/src/v2/components/accordion-v2.css @@ -0,0 +1,141 @@ +[data-component="accordion-v2"] { + --accordion-v2-fg: var(--text-text-base); + --accordion-v2-icon: var(--icon-icon-base); + --accordion-v2-border: var(--border-border-strong); + --accordion-v2-bg: var(--background-bg-base); + + box-sizing: border-box; + display: flex; + flex-direction: column; + align-items: stretch; + + width: 100%; + background: var(--accordion-v2-bg); + border-radius: 6px; + box-shadow: 0 0 0 0.5px var(--accordion-v2-border); + overflow: hidden; + + font-family: var(--font-family-sans), 'Inter', system-ui, sans-serif; + color: var(--accordion-v2-fg); + + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: geometricPrecision; +} + +[data-component="accordion-v2-item"] { + box-sizing: border-box; + display: flex; + flex-direction: column; + align-items: stretch; + width: 100%; + background: var(--accordion-v2-bg); + box-shadow: inset 0 -0.5px 0 var(--accordion-v2-border); +} + +[data-component="accordion-v2-item"]:last-child { + box-shadow: none; +} + +[data-slot="accordion-v2-header"] { + display: flex; + margin: 0; +} + +[data-component="accordion-v2-trigger"] { + box-sizing: border-box; + display: flex; + flex-direction: row; + align-items: center; + gap: 4px; + + width: 100%; + min-height: 30px; + padding: 0 12px; + + background: transparent; + border: 0; + outline: none; + cursor: default; + user-select: none; + + font-family: inherit; + font-size: 13px; + font-weight: 440; + line-height: 100%; + letter-spacing: -0.04px; + color: var(--accordion-v2-fg); + text-align: left; +} + +[data-component="accordion-v2-trigger"] [data-slot="accordion-v2-trigger-content"] { + display: flex; + flex: 1 1 auto; + flex-direction: row; + align-items: center; + gap: 8px; + min-width: 0; + height: 30px; +} + +[data-component="accordion-v2-trigger"] [data-slot="accordion-v2-chevron"] { + flex: none; + width: 14px; + height: 14px; + color: var(--accordion-v2-icon); + transition: transform 150ms ease; +} + +[data-component="accordion-v2-trigger"][data-expanded] [data-slot="accordion-v2-chevron"] { + transform: rotate(180deg); +} + +[data-component="accordion-v2-item"][data-expanded] [data-component="accordion-v2-trigger"] { + box-shadow: inset 0 -0.5px 0 var(--accordion-v2-border); +} + +[data-component="accordion-v2-trigger"][data-disabled] { + pointer-events: none; + opacity: 0.5; +} + +[data-component="accordion-v2-content"] { + overflow: hidden; + font-size: 13px; + line-height: 140%; + color: var(--accordion-v2-fg); +} + +[data-component="accordion-v2-content"][data-expanded] { + animation: accordion-v2-down 180ms ease-out; +} + +[data-component="accordion-v2-content"][data-closed] { + animation: accordion-v2-up 180ms ease-out; +} + +[data-component="accordion-v2-content"] [data-slot="accordion-v2-content-inner"] { + display: flex; + flex-direction: row; + align-items: flex-start; + width: 100%; + padding: 12px; +} + +@keyframes accordion-v2-down { + from { + height: 0; + } + to { + height: var(--kb-collapsible-content-height); + } +} + +@keyframes accordion-v2-up { + from { + height: var(--kb-collapsible-content-height); + } + to { + height: 0; + } +} diff --git a/packages/ui/src/v2/components/accordion-v2.stories.tsx b/packages/ui/src/v2/components/accordion-v2.stories.tsx new file mode 100644 index 0000000000..d76d13277d --- /dev/null +++ b/packages/ui/src/v2/components/accordion-v2.stories.tsx @@ -0,0 +1,175 @@ +// @ts-nocheck +import { AccordionV2 } from "./accordion-v2" + +const docs = `### Overview +Compound accordion built on Kobalte's \`Accordion\` primitive. The trigger automatically renders a chevron that rotates open. + +### API +- \`AccordionV2\` — root; forwards Kobalte props (\`multiple\`, \`collapsible\`, \`value\`, \`defaultValue\`, \`onChange\`, etc.). +- \`AccordionV2.Item\` — one expandable row; requires a unique \`value: string\`. +- \`AccordionV2.Header\` — wraps the trigger; preserves heading semantics. +- \`AccordionV2.Trigger\` — auto-renders a trailing chevron; pass \`hideChevron\` to opt out. +- \`AccordionV2.Content\` — body shown when the item is expanded; height-animated. + +### Behavior +- Single-select by default (\`collapsible\` allows closing the active item). Use \`multiple\` to let several items open at once. +- Open/closed state is reflected on items, triggers, and content via \`data-expanded\` / \`data-closed\`. +- Content height animates using Kobalte's \`--kb-collapsible-content-height\` variable. +` + +export default { + title: "UI V2/Accordion", + id: "components-accordion-v2", + component: AccordionV2, + tags: ["autodocs"], + parameters: { + frameBackground: "#f5f5f5", + docs: { + description: { + component: docs, + }, + }, + }, +} + +const frame = { width: "346px", "font-family": "var(--font-family-sans)", "font-size": "13px" } as const + +export const Basic = { + render: () => ( +
+ + + + Is it accessible? + + + Yes. It follows the WAI-ARIA Accordion pattern and ships with full keyboard support. + + + + + Is it styled? + + Yeah + + + + Is it animated? + + Yes. Height animates via Kobalte's collapsible height variable. + + +
+ ), +} + +export const Multiple = { + render: () => ( +
+ + + + Section A + + Multiple items can be open at once. + + + + Section B + + Open me too. + + + + Section C + + Already open by default. + + +
+ ), +} + +export const Disabled = { + render: () => ( +
+ + + + Enabled item + + Body content. + + + + Disabled item + + You can't open this one. + + + + Another enabled item + + Body content. + + +
+ ), +} + +export const LongContent = { + render: () => ( +
+ + + + What's inside? + + +
+

+ Accordions are useful for compressing dense content into scannable sections. They preserve heading + semantics and announce open/closed state to screen readers. +

+

+ The body can hold arbitrary content — paragraphs, lists, even nested components. +

+
    +
  • Keyboard navigable
  • +
  • Animated
  • +
  • Themeable via CSS variables
  • +
+
+
+
+ + + One more + + Short body. + +
+
+ ), +} + +export const NoChevron = { + render: () => ( +
+ + + + Trigger without chevron + + Pass hideChevron on the trigger. + + + + Default trigger + + Chevron renders by default. + + +
+ ), +} diff --git a/packages/ui/src/v2/components/accordion-v2.tsx b/packages/ui/src/v2/components/accordion-v2.tsx new file mode 100644 index 0000000000..d1e3297e2b --- /dev/null +++ b/packages/ui/src/v2/components/accordion-v2.tsx @@ -0,0 +1,96 @@ +import { Accordion as Kobalte } from "@kobalte/core/accordion" +import { Show, splitProps, type Component, type ComponentProps, type ParentProps } from "solid-js" +import "./accordion-v2.css" + +const ChevronDown: Component = () => ( + +) + +export interface AccordionV2Props extends ComponentProps {} +export interface AccordionV2ItemProps extends ComponentProps {} +export interface AccordionV2HeaderProps extends ComponentProps {} +export interface AccordionV2TriggerProps extends ComponentProps { + hideChevron?: boolean +} +export interface AccordionV2ContentProps extends ComponentProps {} + +function AccordionV2Root(props: ParentProps) { + const [s, r] = splitProps(props, ["class", "classList"]) + return ( + + ) +} + +function AccordionV2Item(props: ParentProps) { + const [s, r] = splitProps(props, ["class", "classList"]) + return ( + + ) +} + +function AccordionV2Header(props: ParentProps) { + const [s, r] = splitProps(props, ["class", "classList", "children"]) + return ( + + {s.children} + + ) +} + +function AccordionV2Trigger(props: ParentProps) { + const [s, r] = splitProps(props, ["class", "classList", "children", "hideChevron"]) + return ( + + {s.children} + + + + + ) +} + +function AccordionV2Content(props: ParentProps) { + const [s, r] = splitProps(props, ["class", "classList", "children"]) + return ( + +
{s.children}
+
+ ) +} + +export const AccordionV2 = Object.assign(AccordionV2Root, { + Item: AccordionV2Item, + Header: AccordionV2Header, + Trigger: AccordionV2Trigger, + Content: AccordionV2Content, +}) diff --git a/packages/ui/src/v2/components/avatar-v2.css b/packages/ui/src/v2/components/avatar-v2.css new file mode 100644 index 0000000000..8b0e6ee529 --- /dev/null +++ b/packages/ui/src/v2/components/avatar-v2.css @@ -0,0 +1,71 @@ +[data-component="avatar"] { + --avatar-bg: var(--background-bg-layer-02); + --avatar-fg: var(--text-text-muted); + --avatar-radius: 9999px; + --avatar-font-size: 11px; + --avatar-tracking: 0.05px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + position: relative; + overflow: hidden; + width: 28px; + height: 28px; + border-radius: var(--avatar-radius); + border: 0.5px solid var(--border-border-base); + font-family: var(--font-sans); + font-weight: 530; + font-size: var(--avatar-font-size); + line-height: 1; + letter-spacing: var(--avatar-tracking); + text-transform: uppercase; + background-color: var(--avatar-bg); + color: var(--avatar-fg); + user-select: none; + -webkit-user-select: none; +} + +[data-component="avatar"]::before { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(180deg, var(--alpha-light-16) 0%, var(--alpha-light-0) 100%); + pointer-events: none; +} + +[data-component="avatar"][data-has-image]::before { + display: none; +} + +[data-component="avatar"][data-kind="org"] { + --avatar-radius: 6px; +} + +[data-component="avatar"][data-kind="org"][data-size="normal"], +[data-component="avatar"][data-kind="org"][data-size="small"] { + --avatar-radius: 4px; +} + +[data-component="avatar"][data-size="normal"] { + width: 20px; + height: 20px; +} + +[data-component="avatar"][data-size="small"] { + width: 16px; + height: 16px; +} + +[data-component="avatar"] [data-slot="avatar-image"] { + position: relative; + z-index: 1; + width: 100%; + height: 100%; + display: block; + object-fit: cover; + border-radius: inherit; + user-select: none; + -webkit-user-select: none; + -webkit-user-drag: none; +} diff --git a/packages/ui/src/v2/components/avatar-v2.stories.tsx b/packages/ui/src/v2/components/avatar-v2.stories.tsx new file mode 100644 index 0000000000..f857aa933e --- /dev/null +++ b/packages/ui/src/v2/components/avatar-v2.stories.tsx @@ -0,0 +1,85 @@ +// @ts-nocheck +import { Avatar } from "./avatar-v2" + +const docs = `### Overview +Avatar matching OpenCode DS variants from Figma. + +Use in user lists and headers. + +### API +- Required: \`fallback\` string. +- Optional: \`src\`, \`background\`, \`foreground\`, \`size\`, \`kind\`. + +### Variants and states +- Sizes: small (16), normal (20), large (28). +- Kind: user (circle), org (rounded-square). +- Image vs initials content state. + +### Behavior +- Uses grapheme-aware fallback rendering. + +### Accessibility +- TODO: provide alt text when using images; currently image is decorative. + +### Theming/tokens +- Uses \`data-component="avatar"\` with size and image state attributes. + +` + +export default { + title: "UI V2/Avatar", + id: "components-avatar-v2", + component: Avatar, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, + argTypes: { + size: { + control: "select", + options: ["small", "normal", "large"], + }, + kind: { + control: "select", + options: ["user", "org"], + }, + }, + args: { + fallback: "WW", + size: "large", + kind: "user", + }, +} + +export const Basic = {} + +export const WithImage = { + args: { + src: "https://placehold.co/80x80/png", + fallback: "WW", + }, +} + +export const Sizes = { + render: () => ( +
+ + + +
+ ), +} + +export const OrgVariant = { + render: () => ( +
+ + + +
+ ), +} diff --git a/packages/ui/src/v2/components/avatar-v2.tsx b/packages/ui/src/v2/components/avatar-v2.tsx new file mode 100644 index 0000000000..68a1030cd6 --- /dev/null +++ b/packages/ui/src/v2/components/avatar-v2.tsx @@ -0,0 +1,59 @@ +import { type ComponentProps, splitProps, Show } from "solid-js" +import "./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 interface AvatarProps extends ComponentProps<"div"> { + fallback: string + src?: string + background?: string + foreground?: string + size?: "small" | "normal" | "large" + kind?: "user" | "org" +} + +export function Avatar(props: AvatarProps) { + const [split, rest] = splitProps(props, [ + "fallback", + "src", + "background", + "foreground", + "size", + "kind", + "class", + "classList", + "style", + ]) + const src = split.src // did this so i can zero it out to test fallback + return ( +
+ + {(src) => } + +
+ ) +} diff --git a/packages/ui/src/v2/components/badge-v2.css b/packages/ui/src/v2/components/badge-v2.css new file mode 100644 index 0000000000..dfb2e69dc1 --- /dev/null +++ b/packages/ui/src/v2/components/badge-v2.css @@ -0,0 +1,28 @@ +[data-component="tag"] { + box-sizing: border-box; + display: inline-flex; + flex-direction: row; + align-items: center; + justify-content: center; + gap: 4px; + height: 16px; + padding: 0 4px; + user-select: none; + + border-radius: 2px; + border: 0.5px solid var(--border-border-base); + background: var(--background-bg-layer-02); + + font-family: var(--font-family-sans); + font-style: normal; + font-weight: 530; + font-size: 11px; + line-height: 1; + letter-spacing: 0.05px; + color: var(--text-text-muted); + font-variant-numeric: tabular-nums; +} + +[data-component="tag"][data-high-contrast] { + border-color: var(--border-border-strong); +} diff --git a/packages/ui/src/v2/components/badge-v2.stories.tsx b/packages/ui/src/v2/components/badge-v2.stories.tsx new file mode 100644 index 0000000000..084838c0e1 --- /dev/null +++ b/packages/ui/src/v2/components/badge-v2.stories.tsx @@ -0,0 +1,54 @@ +// @ts-nocheck +import { Tag } from "./badge-v2" + +const docs = `### Overview +Small label tag for metadata and status chips. + +Use alongside headings or lists for quick metadata. + +### API +- Accepts standard span props. +- Optional: \`data-high-contrast\` attribute for stronger border contrast. + +### Variants and states +- Single size style. +- Optional high-contrast border style. + +### Behavior +- Inline element with fixed 16px height and tabular numeric text. + +### Accessibility +- Ensure text conveys meaning; avoid color-only distinction. + +### Theming/tokens +- Uses \`data-component="tag"\`. + +` + +export default { + title: "UI V2/Badge", + id: "components-badge-v2", + component: Tag, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, + args: { + children: "Label", + }, +} + +export const Basic = {} + +export const HighContrast = { + render: () => ( +
+ Label + Label +
+ ), +} diff --git a/packages/ui/src/v2/components/badge-v2.tsx b/packages/ui/src/v2/components/badge-v2.tsx new file mode 100644 index 0000000000..3bb06259e1 --- /dev/null +++ b/packages/ui/src/v2/components/badge-v2.tsx @@ -0,0 +1,20 @@ +import { type ComponentProps, splitProps } from "solid-js" +import "./badge-v2.css" + +export interface TagProps extends ComponentProps<"span"> {} + +export function Tag(props: TagProps) { + const [split, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + {split.children} + + ) +} diff --git a/packages/ui/src/v2/components/basic-tool-v2.css b/packages/ui/src/v2/components/basic-tool-v2.css new file mode 100644 index 0000000000..075885afbc --- /dev/null +++ b/packages/ui/src/v2/components/basic-tool-v2.css @@ -0,0 +1,164 @@ +* { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: geometricPrecision; +} + +[data-component="basic-tool-v2"] { + --bt-title: var(--text-text-base); + --bt-sep: var(--text-text-muted); + --bt-subtitle: var(--text-text-muted); + --bt-args: var(--text-text-muted); + --bt-chevron: var(--text-text-faint); + --bt-content: var(--text-text-muted); + + box-sizing: border-box; + display: flex; + flex-direction: column; + justify-content: center; + align-items: flex-start; + padding: 4px 0; + gap: 8px; + min-width: 0; + width: 100%; + font-family: var(--font-family-sans), var(--sans), system-ui, sans-serif; + font-variant-numeric: tabular-nums; + + [data-slot="basic-tool-v2-trigger"] { + box-sizing: border-box; + display: flex; + flex-direction: row; + align-items: center; + gap: 6px; + width: 100%; + min-width: 0; + min-height: 20px; + padding: 0; + margin: 0; + text-align: left; + color: inherit; + outline: none; + } + + [data-slot="basic-tool-v2-trigger"]:focus-visible { + outline: 2px solid var(--border-border-focus); + outline-offset: 2px; + border-radius: 2px; + } + + [data-slot="basic-tool-v2-labels"] { + display: inline-flex; + flex-direction: row; + align-items: center; + gap: 6px; + min-width: 0; + max-width: 100%; + } + + [data-slot="basic-tool-v2-title"] { + display: flex; + align-items: center; + flex-shrink: 0; + font-size: 13px; + font-weight: 440; + line-height: 1; + letter-spacing: -0.04px; + color: var(--bt-title); + font-variation-settings: "slnt" 0; + user-select: none; + } + + [data-slot="basic-tool-v2-sep"] { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + font-size: 11px; + font-weight: 530; + line-height: 12px; + letter-spacing: 0.05px; + color: var(--bt-sep); + font-variation-settings: "slnt" 0; + user-select: none; + } + + [data-slot="basic-tool-v2-subtitle"] { + display: flex; + align-items: center; + min-width: 0; + flex: 0 1 auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; + font-weight: 440; + line-height: 1; + letter-spacing: -0.04px; + color: var(--bt-subtitle); + font-variation-settings: "slnt" 0; + user-select: none; + } + + [data-slot="basic-tool-v2-arg"] { + display: flex; + align-items: center; + flex-shrink: 0; + font-size: 13px; + font-weight: 440; + line-height: 1; + letter-spacing: -0.04px; + color: var(--bt-args); + font-variation-settings: "slnt" 0; + user-select: none; + } + + [data-slot="basic-tool-v2-diff"] { + flex-shrink: 0; + } + + [data-slot="basic-tool-v2-diff"] [data-component="diff-changes"] { + gap: 4px; + } + + [data-slot="basic-tool-v2-chevron-wrap"] { + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + flex-shrink: 0; + width: 20px; + height: 20px; + padding: 2px; + border-radius: 4px; + color: var(--bt-chevron); + user-select: none; + } + + [data-slot="basic-tool-v2-chevron"] { + display: block; + flex-shrink: 0; + width: 16px; + height: 16px; + transition: transform 0.15s ease-out; + } + + &[data-expanded] [data-slot="basic-tool-v2-chevron"] { + transform: rotate(90deg); + } + + [data-slot="basic-tool-v2-content"] { + box-sizing: border-box; + min-width: 0; + width: 100%; + overflow: hidden; + } + + [data-slot="basic-tool-v2-content-inner"] { + font-size: 15px; + font-weight: 440; + line-height: 24px; + letter-spacing: 0; + color: var(--bt-content); + font-variation-settings: "slnt" 0; + } +} diff --git a/packages/ui/src/v2/components/basic-tool-v2.stories.tsx b/packages/ui/src/v2/components/basic-tool-v2.stories.tsx new file mode 100644 index 0000000000..f00610206a --- /dev/null +++ b/packages/ui/src/v2/components/basic-tool-v2.stories.tsx @@ -0,0 +1,135 @@ +import { createSignal } from "solid-js" +import { BasicToolV2 } from "./basic-tool-v2" + +const docs = `### Overview +Compact collapsible tool row showing title, subtitle, args, and diff changes, with an expand/collapse chevron. + +### API +- \`BasicToolV2\` wraps Kobalte \`Collapsible\`. Pass \`open\`, \`defaultOpen\`, and \`onOpenChange\` for controlled/uncontrolled disclosure. +- \`trigger\` accepts either a \`BasicToolV2TriggerTitle\` object (title, subtitle, args, changes) or arbitrary JSX. +- When \`status\` is \`"pending"\` or \`"running"\`, subtitle/args/chevron hide and the title shows a shimmer animation. +- Pass \`children\` for expandable detail content. + +### Theming +- Uses \`data-component="basic-tool-v2"\` and slot attributes; colors via \`--bt-*\` CSS variables. +` + +export default { + title: "UI V2/BasicTool", + id: "components-basic-tool-v2", + component: BasicToolV2, + tags: ["autodocs"], + parameters: { + frameBackground: "#fff", + layout: "padded", + docs: { + description: { + component: docs, + }, + }, + }, +} + +export const Default = { + render: () => ( + + File content appears here. + + ), +} + +export const Expanded = { + render: () => ( + + File content appears here. + + ), +} + +export const Pending = { + render: () => ( + + ), +} + +export const NoChildren = { + render: () => ( + + ), +} + +export const CustomTrigger = { + render: () => ( + Custom trigger content} + > + Expandable detail for custom trigger. + + ), +} + +export const Controlled = { + render: () => { + const [open, setOpen] = createSignal(false) + return ( +
+ + + Controlled content. + +
+ ) + }, +} diff --git a/packages/ui/src/v2/components/basic-tool-v2.tsx b/packages/ui/src/v2/components/basic-tool-v2.tsx new file mode 100644 index 0000000000..42861dc6c5 --- /dev/null +++ b/packages/ui/src/v2/components/basic-tool-v2.tsx @@ -0,0 +1,160 @@ +import { Collapsible } from "@kobalte/core/collapsible" +import { + type ComponentProps, + type JSX, + For, + Show, + createMemo, + splitProps, +} from "solid-js" +import { DiffChanges } from "./diff-changes-v2" +import { TextShimmerV2 } from "./text-shimmer-v2" +import "./basic-tool-v2.css" + +function ChevronIcon() { + return ( + + ) +} + +export interface BasicToolV2TriggerTitle { + title: string + subtitle?: string + args?: string[] + changes?: { additions: number; deletions: number } | { additions: number; deletions: number }[] + action?: JSX.Element +} + +const isTriggerTitle = (val: unknown): val is BasicToolV2TriggerTitle => + typeof val === "object" && + val !== null && + "title" in val && + (typeof Node === "undefined" || !(val instanceof Node)) + +export interface BasicToolV2Props extends Omit, "children" | "title"> { + trigger: BasicToolV2TriggerTitle | JSX.Element + children?: JSX.Element + status?: string + open?: boolean + defaultOpen?: boolean + onOpenChange?: (open: boolean) => void + onSubtitleClick?: () => void +} + +export function BasicToolV2(props: BasicToolV2Props) { + const [local, rest] = splitProps(props, [ + "trigger", + "children", + "status", + "open", + "defaultOpen", + "onOpenChange", + "onSubtitleClick", + "class", + "classList", + ]) + + const pending = createMemo(() => local.status === "pending" || local.status === "running") + + const hasChildren = createMemo(() => { + const c = local.children + if (c == null) return false + return true + }) + + const canExpand = createMemo(() => hasChildren() && !pending()) + + const handleOpenChange = (value: boolean) => { + if (pending()) return + local.onOpenChange?.(value) + } + + return ( + + +
+ + {(title) => ( + <> + + + + + + { + if (local.onSubtitleClick) { + e.stopPropagation() + local.onSubtitleClick() + } + }} + > + {title().subtitle} + + + + + {(arg) => ( + {arg} + )} + + + + + + + + + {(action) => action()} + + + )} + + + + + + +
+
+ + +
{local.children}
+
+
+
+ ) +} diff --git a/packages/ui/src/v2/components/button-v2.css b/packages/ui/src/v2/components/button-v2.css new file mode 100644 index 0000000000..07576f9ec2 --- /dev/null +++ b/packages/ui/src/v2/components/button-v2.css @@ -0,0 +1,139 @@ +* { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: geometricPrecision; +} + +[data-component="button-v2"] { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + border-radius: 6px; + font-family: var(--font-family-sans); + font-style: normal; + font-weight: 530; + font-size: 13px; + line-height: 1; + color: var(--text-text-base); + text-shadow: none; + font-variant-numeric: tabular-nums; + letter-spacing: -0.04px; + user-select: none; + cursor: pointer; + outline: none; +} + +[data-component="button-v2"]:focus { + outline: none; +} + +[data-component="button-v2"]:is(:focus-visible, [data-state="focus"]):not(:disabled) { + outline: 2px solid var(--border-border-focus); + outline-offset: 2.5px; +} + +[data-component="button-v2"][data-size="normal"] { + height: 28px; + padding: 0 11px; +} + +[data-component="button-v2"][data-size="small"] { + height: 24px; + padding: 0 9px; + border-radius: 4px; +} + +[data-component="button-v2"][data-size="large"] { + height: 32px; + padding: 0 15px; +} + +[data-component="button-v2"][data-icon][data-size="small"] { + padding-left: 9px; +} + +[data-component="button-v2"][data-icon][data-size="normal"] { + padding-left: 11px; +} + +[data-component="button-v2"][data-icon][data-size="large"] { + padding-left: 15px; +} + +[data-component="button-v2"] [data-slot="icon-svg"] { + color: currentColor; +} + +/* Neutral */ +[data-component="button-v2"][data-variant="neutral"] { + background-color: var(--background-bg-button-neutral); + color: var(--text-text-base); + box-shadow: var(--elevation-button-neutral); +} + +[data-component="button-v2"][data-variant="neutral"]:is(:hover, [data-state="hover"]):not(:disabled) { + background-image: + linear-gradient(90deg, var(--overlay-simple-overlay-hover) 0%, var(--overlay-simple-overlay-hover) 100%), + linear-gradient(90deg, var(--background-bg-button-neutral) 0%, var(--background-bg-button-neutral) 100%); +} + +[data-component="button-v2"][data-variant="neutral"]:is(:active, [data-state="pressed"]):not(:disabled) { + background-image: + linear-gradient(90deg, var(--overlay-simple-overlay-pressed) 0%, var(--overlay-simple-overlay-pressed) 100%), + linear-gradient(90deg, var(--background-bg-button-neutral) 0%, var(--background-bg-button-neutral) 100%); +} + +[data-component="button-v2"][data-variant="neutral"]:is(:disabled, [data-state="disabled"]) { + opacity: 0.5; + cursor: not-allowed; +} + +/* Contrast */ +[data-component="button-v2"][data-variant="contrast"] { + background-image: + linear-gradient(180deg, var(--alpha-light-20) 0%, var(--alpha-light-0) 100%), + linear-gradient(90deg, var(--background-bg-contrast) 0%, var(--background-bg-contrast) 100%); + color: var(--text-text-contrast); + text-shadow: 0 0.5px 0.5px rgba(0, 0, 0, 0.3); + box-shadow: var(--elevation-button-contrast); +} + +[data-component="button-v2"][data-variant="contrast"]:is(:hover, [data-state="hover"]):not(:disabled) { + background-image: + linear-gradient(90deg, var(--overlay-simple-overlay-contrast-hover) 0%, var(--overlay-simple-overlay-contrast-hover) 100%), + linear-gradient(180deg, var(--alpha-light-20) 0%, var(--alpha-light-0) 100%), + linear-gradient(90deg, var(--background-bg-contrast) 0%, var(--background-bg-contrast) 100%); +} + +[data-component="button-v2"][data-variant="contrast"]:is(:active, [data-state="pressed"]):not(:disabled) { + background-image: + linear-gradient(90deg, var(--overlay-simple-overlay-contrast-pressed) 0%, var(--overlay-simple-overlay-contrast-pressed) 100%), + linear-gradient(180deg, var(--alpha-light-20) 0%, var(--alpha-light-0) 100%), + linear-gradient(90deg, var(--background-bg-contrast) 0%, var(--background-bg-contrast) 100%); +} + +[data-component="button-v2"][data-variant="contrast"]:is(:disabled, [data-state="disabled"]) { + opacity: 0.4; + cursor: not-allowed; +} + +/* Ghost */ +[data-component="button-v2"][data-variant="ghost"] { + background-color: transparent; + color: var(--text-text-base); +} + +[data-component="button-v2"][data-variant="ghost"]:is(:hover, [data-state="hover"]):not(:disabled) { + background-color: var(--overlay-simple-overlay-hover); +} + +[data-component="button-v2"][data-variant="ghost"]:is(:active, [data-state="pressed"]):not(:disabled) { + background-color: var(--overlay-simple-overlay-pressed); +} + +[data-component="button-v2"][data-variant="ghost"]: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 new file mode 100644 index 0000000000..bc8e10ba6f --- /dev/null +++ b/packages/ui/src/v2/components/button-v2.stories.tsx @@ -0,0 +1,166 @@ +import { ButtonV2 } from "./button-v2"; + +const docs = `### Overview +Button v2 with three visual variants and two sizes. + +### API +- \`variant\`: "neutral" | "contrast" | "ghost". +- \`size\`: "normal" | "large". +- \`icon\`: Optional icon name. +- Inherits Kobalte Button props and native button attributes. + +### States +- default, hover, pressed, focus, disabled. +- State selectors are available via pseudo-classes and \`[data-state]\`. +`; + +export default { + title: "UI V2/Button", + id: "components-button-v2", + component: ButtonV2, + tags: ["autodocs"], + parameters: { + frameHeight: "240px", + frameBackground: "#fff", + docs: { + description: { + component: docs, + }, + }, + }, + args: { + children: "Button", + variant: "neutral", + size: "normal", + }, + argTypes: { + icon: { + control: "text", + }, + variant: { + control: "select", + options: ["neutral", "contrast", "ghost"], + }, + size: { + control: "select", + options: ["normal", "large"], + }, + }, +}; + +export const Playground = {}; + +export const Variants = { + render: () => ( +
+ Neutral + Contrast + Ghost +
+ ), +}; + +export const Sizes = { + render: () => ( +
+ + Small + + + Normal + + + Large + +
+ ), +}; + +export const Icon = { + render: () => ( +
+ + Normal + + + Large + +
+ ), +}; + +export const AllStates = { + render: () => { + const variants = [ + "neutral", + "contrast", + "ghost", + ] as const; + const states = [ + "default", + "hover", + "pressed", + "focus", + "disabled", + ] as const; + const toTitleCase = (value: string) => + value.charAt(0).toUpperCase() + value.slice(1); + return ( +
+ {variants.map((variant) => ( +
+
+ {variant} +
+
+ {states.map((state) => ( + + {toTitleCase(state)} + + ))} +
+
+ ))} +
+ ); + }, +}; diff --git a/packages/ui/src/v2/components/button-v2.tsx b/packages/ui/src/v2/components/button-v2.tsx new file mode 100644 index 0000000000..ce9129d400 --- /dev/null +++ b/packages/ui/src/v2/components/button-v2.tsx @@ -0,0 +1,35 @@ +import { Button as Kobalte } from "@kobalte/core/button" +import { type ComponentProps, Show, createMemo, splitProps } from "solid-js" +import { Icon, type IconProps } from "./icon" +import "./button-v2.css" + +export interface ButtonV2Props + extends ComponentProps, + Pick, "class" | "classList" | "children"> { + size?: "small" | "normal" | "large" + variant?: "neutral" | "contrast" | "ghost" + icon?: IconProps["name"] +} + +export function ButtonV2(props: ButtonV2Props) { + const [split, rest] = splitProps(props, ["variant", "size", "icon", "class", "classList"]) + const resolvedIcon = createMemo(() => split.icon) + return ( + + + + + {props.children} + + ) +} diff --git a/packages/ui/src/v2/components/checkbox-v2.css b/packages/ui/src/v2/components/checkbox-v2.css new file mode 100644 index 0000000000..7755d5a5ab --- /dev/null +++ b/packages/ui/src/v2/components/checkbox-v2.css @@ -0,0 +1,187 @@ +[data-slot="checkbox-v2"] { + display: flex; + flex-direction: column; + gap: 6px; + cursor: default; + + &:where([data-disabled]) { + cursor: not-allowed; + } + + [data-slot="checkbox-v2-error"] { + color: var(--state-fg-danger); + font-family: var(--font-family-sans); + font-size: 12px; + font-weight: var(--font-weight-regular); + line-height: var(--line-height-normal); + letter-spacing: var(--letter-spacing-normal); + } + + [data-slot="checkbox-v2-error"]:empty { + display: none; + } +} + +[data-slot="checkbox-v2-row"] { + position: relative; + display: flex; + flex-direction: row; + align-items: flex-start; + padding: 0px; + gap: 8px; + + [data-slot="checkbox-v2-input"] { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; + } + + [data-slot="checkbox-v2-control-stack"] { + position: relative; + width: 16px; + height: 20px; + flex: none; + } + + [data-slot="checkbox-v2-control"] { + box-sizing: border-box; + position: absolute; + width: 16px; + height: 16px; + flex: none; + flex-shrink: 0; + left: 0; + top: calc(50% - 16px / 2); + border-radius: 4px; + border: none; + box-shadow: inset 0 0 0 0.5px var(--border-border-strong); + + background: + linear-gradient(180deg, var(--alpha-light-6) 0%, var(--alpha-light-0) 100%), + var(--background-bg-base); + transition: + background 170ms ease-out, + opacity 170ms ease-out, + outline-color 170ms ease-out; + } + + [data-slot="checkbox-v2-indicator"] { + position: absolute; + inset: 0; + width: 16px; + height: 16px; + pointer-events: none; + } + + [data-slot="checkbox-v2-text"] { + display: flex; + flex-direction: column; + justify-content: center; + align-items: flex-start; + padding: 0px; + gap: 6px; + } + + [data-slot="checkbox-v2-label"] { + display: inline-flex; + user-select: none; + color: inherit; + font-family: var(--font-family-sans); + font-style: normal; + font-weight: 440; + font-variant-numeric: tabular-nums; + font-variation-settings: "slnt" 0; + } + + [data-slot="checkbox-v2-label-text"] { + display: inline-flex; + align-items: center; + user-select: none; + color: var(--text-text-base); + font-size: 13px; + line-height: 20px; + letter-spacing: -0.04px; + } + + [data-slot="checkbox-v2-description"] { + color: var(--text-text-muted); + font-family: var(--font-family-sans); + font-size: 11px; + font-weight: 440; + line-height: 1; + letter-spacing: 0.05px; + font-variant-numeric: tabular-nums; + user-select: none; + } +} + +[data-slot="checkbox-v2"]:where(:not([data-readonly])) + [data-slot="checkbox-v2-input"]:focus-visible + ~ [data-slot="checkbox-v2-control-stack"] + [data-slot="checkbox-v2-control"] { + outline: 2px solid var(--border-border-focus); + outline-offset: 1px; +} + +[data-slot="checkbox-v2"]:where(:hover):where(:not([data-disabled], [data-readonly], [data-invalid])):where( + :not([data-checked], [data-indeterminate]) + ) + [data-slot="checkbox-v2-control"] { + background: + linear-gradient(0deg, var(--overlay-simple-overlay-hover), var(--overlay-simple-overlay-hover)), + linear-gradient(180deg, var(--alpha-light-6) 0%, var(--alpha-light-0) 100%), + var(--background-bg-base); +} + +[data-slot="checkbox-v2"]:where([data-disabled]) [data-slot="checkbox-v2-control"] { + opacity: 0.5; +} + +[data-slot="checkbox-v2"]:where([data-checked], [data-indeterminate]) [data-slot="checkbox-v2-control"] { + background: + linear-gradient(180deg, var(--alpha-light-20) 0%, var(--alpha-light-0) 100%), + var(--background-bg-accent); +} + +[data-slot="checkbox-v2"]:where([data-checked], [data-indeterminate]):where(:hover):where( + :not([data-disabled], [data-readonly]) + ) + [data-slot="checkbox-v2-control"] { + background: + linear-gradient(0deg, var(--overlay-simple-overlay-contrast-hover), var(--overlay-simple-overlay-contrast-hover)), + linear-gradient(180deg, var(--alpha-light-20) 0%, var(--alpha-light-0) 100%), + var(--background-bg-accent); +} + +[data-slot="checkbox-v2"]:where([data-invalid]):where(:not([data-checked], [data-indeterminate])) + [data-slot="checkbox-v2-control"] { + background: var(--state-bg-danger); + box-shadow: inset 0 0 0 0.5px #b82d35; +} + +[data-slot="checkbox-v2"] .checkbox-v2-icon { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + display: none; +} + +[data-slot="checkbox-v2"]:where([data-checked]):where(:not([data-indeterminate])) .checkbox-v2-icon--check { + display: block; +} + +[data-slot="checkbox-v2"]:where([data-indeterminate]) .checkbox-v2-icon--minus { + display: block; +} + +[data-slot="checkbox-v2"]:where([data-disabled]) [data-slot="checkbox-v2-label"], +[data-slot="checkbox-v2"]:where([data-disabled]) [data-slot="checkbox-v2-description"] { + opacity: 0.5; +} diff --git a/packages/ui/src/v2/components/checkbox-v2.stories.tsx b/packages/ui/src/v2/components/checkbox-v2.stories.tsx new file mode 100644 index 0000000000..364a3c5ee6 --- /dev/null +++ b/packages/ui/src/v2/components/checkbox-v2.stories.tsx @@ -0,0 +1,98 @@ +// @ts-nocheck +import { createSignal } from "solid-js" +import { CheckboxV2 } from "./checkbox-v2" + +const docs = `### Overview +Binary and tri-state checkbox using Kobalte Checkbox. + +### API +- Forwards Kobalte Checkbox props (\`checked\`, \`defaultChecked\`, \`onChange\`, \`indeterminate\`, \`name\`, \`required\`, \`validationState\`, \`disabled\`, etc.). +- Adds \`label\`, optional \`description\`, and \`hideLabel\`. + +### Behavior +- Controlled or uncontrolled via \`checked\` / \`defaultChecked\`. +- Indeterminate is driven by the \`indeterminate\` prop (pass a reactive boolean, e.g. \`indeterminate={flag()}\`). + +### Theming/tokens +- Uses \`data-slot="checkbox-v2"\` and slot attributes aligned with radio item layout. +` + +export default { + title: "UI V2/Checkbox", + id: "components-checkbox-v2", + component: CheckboxV2, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, +} + +export const Basic = { + render: () => ( + + ), +} + +export const Controlled = { + render: () => { + const [checked, setChecked] = createSignal(false) + return ( +
+ +
+ Checked: {String(checked())} +
+
+ ) + }, +} + +export const Indeterminate = { + render: () => { + const [indeterminate, setIndeterminate] = createSignal(true) + const [checked, setChecked] = createSignal(false) + return ( + { + setChecked(v) + if (v) setIndeterminate(false) + }} + label="Select all" + description="Starts indeterminate; checking clears mixed state." + /> + ) + }, +} + +export const States = { + render: () => ( +
+ + + + + + + +
+ ), +} diff --git a/packages/ui/src/v2/components/checkbox-v2.tsx b/packages/ui/src/v2/components/checkbox-v2.tsx new file mode 100644 index 0000000000..6625e56ad6 --- /dev/null +++ b/packages/ui/src/v2/components/checkbox-v2.tsx @@ -0,0 +1,67 @@ +import { Checkbox as Kobalte } from "@kobalte/core/checkbox" +import { Show, splitProps, type JSX } from "solid-js" +import type { ComponentProps } from "solid-js" +import "./checkbox-v2.css" + +export interface CheckboxV2Props extends ComponentProps { + label: JSX.Element + description?: JSX.Element + hideLabel?: boolean +} + +export function CheckboxV2(props: CheckboxV2Props) { + const [local, others] = splitProps(props, ["class", "classList", "label", "description", "hideLabel"]) + return ( + +
+ +
+ + + + + + +
+ +
+ {local.label} + + {(description) => ( + {description()} + )} + +
+
+
+ +
+ ) +} diff --git a/packages/ui/src/v2/components/dialog-v2.css b/packages/ui/src/v2/components/dialog-v2.css new file mode 100644 index 0000000000..a0ed79d8d6 --- /dev/null +++ b/packages/ui/src/v2/components/dialog-v2.css @@ -0,0 +1,150 @@ +/* [data-component="dialog-trigger"] { } */ + +[data-component="dialog-overlay"] { + position: fixed; + inset: 0; + z-index: 50; + background-color: var(--overlay-simple-overlay-scrim); +} + +[data-component="dialog"] { + position: fixed; + inset: 0; + z-index: 50; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + + [data-slot="dialog-container"] { + display: flex; + flex-direction: column; + align-items: flex-start; + width: 480px; + height: 368px; + background: var(--background-bg-layer-01); + box-shadow: var(--elevation-overlay); + border-radius: 6px; + overflow: visible; + pointer-events: auto; + + [data-slot="dialog-content"] { + display: flex; + flex-direction: column; + align-items: flex-start; + align-self: stretch; + width: 100%; + max-height: 100%; + flex: 1; + overflow: auto; + + scrollbar-width: none; + -ms-overflow-style: none; + &::-webkit-scrollbar { + display: none; + } + + [data-slot="dialog-header"] { + display: flex; + flex-direction: row; + align-items: flex-start; + padding: 16px; + gap: 8px; + flex-shrink: 0; + align-self: stretch; + + [data-slot="dialog-title-group"] { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + flex: 1; + min-width: 0; + } + + [data-slot="dialog-title"] { + margin: 0; + font-family: 'Inter', var(--font-family-sans); + font-weight: 530; + font-size: 15px; + line-height: 100%; + letter-spacing: -0.13px; + color: var(--text-text-base); + font-variation-settings: 'slnt' 0; + } + + [data-slot="dialog-description"] { + font-family: 'Inter', var(--font-family-sans); + font-weight: 440; + font-size: 13px; + line-height: 100%; + letter-spacing: -0.04px; + color: var(--text-text-muted); + font-variation-settings: 'slnt' 0; + } + + [data-slot="dialog-close-button"] { + display: flex; + justify-content: center; + align-items: center; + width: 24px; + height: 24px; + border-radius: 4px; + flex-shrink: 0; + cursor: pointer; + + &:hover { + background: var(--overlay-simple-overlay-hover); + } + } + } + + [data-slot="dialog-footer"] { + display: flex; + flex-direction: row; + justify-content: flex-end; + align-items: flex-start; + padding: 16px; + gap: 8px; + align-self: stretch; + flex-shrink: 0; + } + + [data-slot="dialog-body"] { + width: 100%; + position: relative; + display: flex; + flex-direction: column; + flex: 1; + overflow: hidden; + + &:focus-visible { + outline: none; + } + } + &:focus-visible { + outline: none; + } + } + } + + &[data-fit] { + [data-slot="dialog-container"] { + height: auto; + + [data-slot="dialog-content"] { + min-height: 0; + } + } + } + + &[data-size="large"] [data-slot="dialog-container"] { + width: 640px; + height: 480px; + } + + &[data-size="x-large"] [data-slot="dialog-container"] { + width: 800px; + height: 560px; + } +} diff --git a/packages/ui/src/v2/components/dialog-v2.stories.tsx b/packages/ui/src/v2/components/dialog-v2.stories.tsx new file mode 100644 index 0000000000..00013be41d --- /dev/null +++ b/packages/ui/src/v2/components/dialog-v2.stories.tsx @@ -0,0 +1,170 @@ +import { Dialog as KobalteDialog } from "@kobalte/core/dialog" +import { Dialog, DialogFooter } from "./dialog-v2" +import { ButtonV2 } from "./button-v2" + +const docs = `### Overview +Dialog content wrapper built on Kobalte's dialog primitive with v2 styling. + +### API +- Optional: \`title\`, \`description\`, \`action\`. +- \`size\`: normal | large | x-large. +- \`fit\` and \`transition\` control layout and animation. + +### Variants and states +- Sizes and optional header/action controls. + +### Accessibility +- Focus trapping and aria attributes provided by Kobalte Dialog. + +### Theming/tokens +- Uses \`data-component="dialog"\` and slot attributes. +` + +export default { + title: "UI V2/Dialog", + id: "components-dialog-v2", + component: Dialog, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, +} + +export const Basic = { + render: () => ( + + + Open dialog + + + + + Dialog body content. + + + + ), +} + +export const Sizes = { + render: () => ( +
+ + + Normal + + + + + Normal dialog content. + + + + + + + Large + + + + + Large dialog content. + + + + + + + X-Large + + + + + X-large dialog content. + + + +
+ ), +} + +export const CustomAction = { + render: () => ( + + + Open action dialog + + + + Help} + > + Dialog body content. + + + + ), +} + +export const WithFooter = { + render: () => ( + + + Open dialog + + + + + + Cancel + Save + + + + + ), +} + +export const WithFooterThreeButtons = { + render: () => ( + + + Open dialog + + + + + + + Remind me later + + Cancel + Save + + + + + ), +} + +export const Fit = { + render: () => ( + + + Open fit dialog + + + + + Dialog fits its content. + + + + ), +} diff --git a/packages/ui/src/v2/components/dialog-v2.tsx b/packages/ui/src/v2/components/dialog-v2.tsx new file mode 100644 index 0000000000..2c87900c97 --- /dev/null +++ b/packages/ui/src/v2/components/dialog-v2.tsx @@ -0,0 +1,83 @@ +import { Dialog as Kobalte } from "@kobalte/core/dialog" +import { type ComponentProps, type JSXElement, type ParentProps, Show, children, splitProps } from "solid-js" +import "./dialog-v2.css" + +export interface DialogProps extends ParentProps { + title?: JSXElement + description?: JSXElement + action?: JSXElement + size?: "normal" | "large" | "x-large" + class?: ComponentProps<"div">["class"] + classList?: ComponentProps<"div">["classList"] + fit?: boolean +} + +export function DialogFooter(props: ParentProps) { + return
{props.children}
+} + +export function Dialog(props: DialogProps) { + const [local] = splitProps(props, [ + "title", + "description", + "action", + "size", + "class", + "classList", + "fit", + "children", + ]) + const title = children(() => local.title) + const description = children(() => local.description) + const action = children(() => local.action) + const hasHeader = () => title() || action() + + return ( +
+
+ { + const target = e.currentTarget as HTMLElement | null + const autofocusEl = target?.querySelector("[autofocus]") as HTMLElement | null + if (autofocusEl) { + e.preventDefault() + autofocusEl.focus() + } + }} + > + +
+
+ + {(t) => {t()}} + + + {(d) => ( + {d()} + )} + +
+ {(a) => a()} + + + +
+
+
{local.children}
+
+
+
+ ) +} diff --git a/packages/ui/src/v2/components/diff-changes-v2.css b/packages/ui/src/v2/components/diff-changes-v2.css new file mode 100644 index 0000000000..ab95db6ee3 --- /dev/null +++ b/packages/ui/src/v2/components/diff-changes-v2.css @@ -0,0 +1,25 @@ +[data-component="diff-changes"] { + display: flex; + gap: 8px; + justify-content: flex-end; + align-items: center; + + [data-slot="diff-changes-additions"], + [data-slot="diff-changes-deletions"] { + font-family: var(--font-family-sans); + font-size: 11px; + font-style: normal; + font-weight: 440; + line-height: 1; + letter-spacing: 0.05px; + text-align: right; + } + + [data-slot="diff-changes-additions"] { + color: var(--state-fg-success); + } + + [data-slot="diff-changes-deletions"] { + color: var(--state-fg-danger); + } +} diff --git a/packages/ui/src/v2/components/diff-changes-v2.stories.tsx b/packages/ui/src/v2/components/diff-changes-v2.stories.tsx new file mode 100644 index 0000000000..cc855412ad --- /dev/null +++ b/packages/ui/src/v2/components/diff-changes-v2.stories.tsx @@ -0,0 +1,60 @@ +import { DiffChanges } from "./diff-changes-v2" + +const docs = `### Overview +Summarize additions/deletions as compact text. + +Pair with \`Diff\`/\`DiffSSR\` to contextualize a change set. + +### API +- Required: \`changes\` as { additions, deletions } or an array of those objects. + +### Variants and states +- Handles zero-change state (renders nothing). + +### Behavior +- Aggregates arrays into total additions/deletions. + +### Accessibility +- Ensure surrounding context conveys meaning of the counts/bars. + +### Theming/tokens +- Uses \`data-component="diff-changes"\` and diff color tokens. + +` + +const changes = { additions: 12, deletions: 5 } + +export default { + title: "UI V2/DiffChanges", + id: "components-diff-changes-v2", + component: DiffChanges, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, + args: { + changes, + }, +} + +export const Default = {} + +export const MultipleFiles = { + args: { + changes: [ + { additions: 4, deletions: 1 }, + { additions: 8, deletions: 3 }, + { additions: 2, deletions: 0 }, + ], + }, +} + +export const Zero = { + args: { + changes: { additions: 0, deletions: 0 }, + }, +} diff --git a/packages/ui/src/v2/components/diff-changes-v2.tsx b/packages/ui/src/v2/components/diff-changes-v2.tsx new file mode 100644 index 0000000000..9413d2e9c2 --- /dev/null +++ b/packages/ui/src/v2/components/diff-changes-v2.tsx @@ -0,0 +1,28 @@ +import { createMemo, Show } from "solid-js" +import "./diff-changes-v2.css" + +export function DiffChanges(props: { + class?: string + changes: { additions: number; deletions: number } | { additions: number; deletions: number }[] +}) { + const additions = createMemo(() => + Array.isArray(props.changes) + ? props.changes.reduce((acc, diff) => acc + (diff.additions ?? 0), 0) + : props.changes.additions, + ) + const deletions = createMemo(() => + Array.isArray(props.changes) + ? props.changes.reduce((acc, diff) => acc + (diff.deletions ?? 0), 0) + : props.changes.deletions, + ) + const total = createMemo(() => (additions() ?? 0) + (deletions() ?? 0)) + + return ( + 0}> +
+ {`+${additions()}`} + {`-${deletions()}`} +
+
+ ) +} diff --git a/packages/ui/src/v2/components/field-v2.css b/packages/ui/src/v2/components/field-v2.css new file mode 100644 index 0000000000..a4d87f55ce --- /dev/null +++ b/packages/ui/src/v2/components/field-v2.css @@ -0,0 +1,96 @@ +[data-component="field-v2"] { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + width: 100%; + min-width: 0; +} + +[data-component="field-v2"] [data-slot="field-v2-label"] { + display: flex; + flex-direction: row; + align-items: center; + align-self: stretch; + gap: 4px; + min-height: 16px; + margin: 0; + padding: 0; + border: 0; + font-family: var(--font-family-sans); + font-style: normal; + font-weight: 530; + font-size: 13px; + line-height: 1; + letter-spacing: -0.04px; + color: var(--text-text-base); + font-variation-settings: "slnt" 0; + cursor: default; + user-select: none; +} + +[data-component="field-v2"] [data-slot="field-v2-label-text"] { + display: inline-flex; + align-items: center; +} + +[data-component="field-v2"] [data-slot="field-v2-label"] [data-component="tooltip-v2-trigger"] { + display: inline-flex; + flex: none; + width: 16px; + height: 16px; +} + +[data-component="field-v2"] [data-slot="field-v2-label-info"] { + display: inline-flex; + align-items: center; + justify-content: center; + flex: none; + width: 16px; + height: 16px; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + color: var(--icon-icon-muted); + cursor: pointer; + outline: none; +} + +[data-component="field-v2"] [data-slot="field-v2-label-info"]:is(:hover, [data-state="hover"]) { + color: var(--text-text-base); +} + +[data-component="field-v2"] [data-slot="field-v2-label-info"]:focus { + outline: none; +} + +[data-component="field-v2"] [data-slot="field-v2-label-info"]:focus-visible { + outline: 2px solid var(--border-border-focus); + outline-offset: 1px; + border-radius: 2px; +} + +[data-component="field-v2"] [data-slot="field-v2-prefix"], +[data-component="field-v2"] [data-slot="field-v2-suffix"] { + align-self: stretch; + width: 100%; + min-height: 11px; + font-family: var(--font-family-sans); + font-style: normal; + font-weight: 440; + font-size: 11px; + line-height: 1; + letter-spacing: 0.05px; + color: var(--text-text-muted); + font-variation-settings: "slnt" 0; + user-select: none; +} + +[data-component="field-v2"] [data-slot="field-v2-control"] { + display: flex; + flex-direction: column; + align-items: flex-start; + width: 100%; + min-width: 0; +} diff --git a/packages/ui/src/v2/components/field-v2.stories.tsx b/packages/ui/src/v2/components/field-v2.stories.tsx new file mode 100644 index 0000000000..5b0e9cce2f --- /dev/null +++ b/packages/ui/src/v2/components/field-v2.stories.tsx @@ -0,0 +1,140 @@ +// @ts-nocheck +import { createSignal } from "solid-js" +import { Field } from "./field-v2" +import { InlineInputV2 } from "./inline-input-v2" +import { TextInputV2 } from "./text-input-v2" +import { TextareaV2 } from "./textarea-v2" + +const docs = `### Overview +Composable field layout for TextInput, Textarea, and InlineInput v2. + +### Usage +\`\`\`tsx + + Label + Prefix + + + + Suffix + +\`\`\` + +Omit \`Field.Control\` and place the input directly inside \`Field\` — a11y props are merged automatically. + +### API +- \`Field\`: \`invalid\` propagates to the control. +- \`Field.Label\`: \`tooltip\` shows the info icon with tooltip text. +- \`Field.Prefix\` / \`Field.Suffix\`: helper copy above / below the control. +- \`Field.Control\`: optional wrapper (marker only). +` + +export default { + title: "UI V2/Field", + id: "components-field-v2", + subcomponents: { + Label: Field.Label, + Prefix: Field.Prefix, + Suffix: Field.Suffix, + Control: Field.Control, + }, + tags: ["autodocs"], + parameters: { + frameHeight: "500px", + frameBackground: "#fff", + docs: { + description: { + component: docs, + }, + }, + }, +} + +export const TextInputExample = { + render: () => ( +
+ + Label + Prefix + + + + Suffix + +
+ ), +} + +export const TextInputDirectChild = { + render: () => ( +
+ + Label + Prefix + + Suffix + +
+ ), +} + +export const TextareaExample = { + render: () => ( +
+ + Label + Prefix + + Suffix + +
+ ), +} + +export const InlineInputExample = { + render: () => ( +
+ + Label + Prefix + + Suffix + +
+ ), +} + +export const Invalid = { + render: () => ( +
+ + Label + Prefix + + Suffix + +
+ ), +} + +export const Controlled = { + render: () => { + const [value, setValue] = createSignal("") + return ( +
+ + Amount + + setValue(e.currentTarget.value)} + numeric + /> + + {value() ? `Entered: ${value()}` : "Suffix"} + +
+ ) + }, +} diff --git a/packages/ui/src/v2/components/field-v2.tsx b/packages/ui/src/v2/components/field-v2.tsx new file mode 100644 index 0000000000..19f748fc5c --- /dev/null +++ b/packages/ui/src/v2/components/field-v2.tsx @@ -0,0 +1,275 @@ +import { + createContext, + createEffect, + createSignal, + createUniqueId, + onCleanup, + onMount, + splitProps, + useContext, + Show, + type ComponentProps, + type ParentProps, +} from "solid-js" +import { TooltipV2 } from "./tooltip-v2" +import "./field-v2.css" + +type FieldContextValue = { + controlId: string + labelId: string + prefixId: string + suffixId: string + invalid: () => boolean + registerPrefix: () => void + unregisterPrefix: () => void + registerSuffix: () => void + unregisterSuffix: () => void + getDescribedBy: () => string | undefined +} + +const FieldContext = createContext() + +function useField() { + const ctx = useContext(FieldContext) + if (!ctx) { + throw new Error("Field subcomponents must be used within ") + } + return ctx +} + +const CONTROL_SELECTOR = [ + "[data-slot='text-input-v2-input']", + "[data-slot='textarea-v2-textarea']", + "[data-slot='inline-input-v2-input']", +].join(", ") + +export interface FieldV2Props extends ComponentProps<"div"> { + invalid?: boolean +} + +function FieldV2Root(props: ParentProps) { + const [local, rest] = splitProps(props, ["invalid", "class", "classList", "children"]) + + const controlId = `field-control-${createUniqueId()}` + const labelId = `field-label-${createUniqueId()}` + const prefixId = `field-prefix-${createUniqueId()}` + const suffixId = `field-suffix-${createUniqueId()}` + + const [prefixCount, setPrefixCount] = createSignal(0) + const [suffixCount, setSuffixCount] = createSignal(0) + + let rootRef: HTMLDivElement | undefined + + const ctx: FieldContextValue = { + controlId, + labelId, + prefixId, + suffixId, + invalid: () => !!local.invalid, + registerPrefix: () => setPrefixCount((n) => n + 1), + unregisterPrefix: () => setPrefixCount((n) => Math.max(0, n - 1)), + registerSuffix: () => setSuffixCount((n) => n + 1), + unregisterSuffix: () => setSuffixCount((n) => Math.max(0, n - 1)), + getDescribedBy: () => { + const ids: string[] = [] + if (prefixCount() > 0) ids.push(prefixId) + if (suffixCount() > 0) ids.push(suffixId) + return ids.length > 0 ? ids.join(" ") : undefined + }, + } + + const syncControlA11y = () => { + const root = rootRef + if (!root) return + + const control = root.querySelector(CONTROL_SELECTOR) as + | HTMLInputElement + | HTMLTextAreaElement + | null + if (!control) return + + const shell = control.closest( + "[data-component='text-input-v2'], [data-component='textarea-v2'], [data-component='inline-input-v2']", + ) as HTMLElement | null + + control.id = controlId + control.setAttribute("aria-labelledby", labelId) + + const describedBy = ctx.getDescribedBy() + if (describedBy) { + control.setAttribute("aria-describedby", describedBy) + } else { + control.removeAttribute("aria-describedby") + } + + if (ctx.invalid()) { + control.setAttribute("aria-invalid", "true") + shell?.setAttribute("data-invalid", "") + } else { + control.removeAttribute("aria-invalid") + shell?.removeAttribute("data-invalid") + } + } + + onMount(() => { + syncControlA11y() + }) + + createEffect(() => { + prefixCount() + suffixCount() + local.invalid + syncControlA11y() + }) + + return ( + +
+ {local.children} +
+
+ ) +} + +function FieldLabelInfoIcon() { + return ( + + ) +} + +export interface FieldLabelProps extends ComponentProps<"label"> { + /** When set, shows the info icon with a tooltip containing this text. */ + tooltip?: string +} + +function FieldLabel(props: ParentProps) { + const [local, rest] = splitProps(props, ["class", "classList", "children", "tooltip"]) + const field = useField() + + return ( + + ) +} + +function FieldPrefix(props: ParentProps>) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + const field = useField() + + onMount(() => { + field.registerPrefix() + onCleanup(() => field.unregisterPrefix()) + }) + + return ( +
+ {local.children} +
+ ) +} + +function FieldSuffix(props: ParentProps>) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + const field = useField() + + onMount(() => { + field.registerSuffix() + onCleanup(() => field.unregisterSuffix()) + }) + + return ( +
+ {local.children} +
+ ) +} + +/** Optional layout wrapper around the control. */ +function FieldControl(props: ParentProps>) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + + return ( +
+ {local.children} +
+ ) +} + +export const FieldV2 = Object.assign(FieldV2Root, { + Label: FieldLabel, + Prefix: FieldPrefix, + Suffix: FieldSuffix, + Control: FieldControl, +}) + +export const Field = FieldV2 diff --git a/packages/ui/src/v2/components/icon-button-v2.css b/packages/ui/src/v2/components/icon-button-v2.css new file mode 100644 index 0000000000..f5ea604e92 --- /dev/null +++ b/packages/ui/src/v2/components/icon-button-v2.css @@ -0,0 +1,146 @@ +* { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: geometricPrecision; +} + +[data-component="icon-button-v2"] { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 6px; + color: var(--v2-text-text-base); + user-select: none; + outline: none; + padding: 0; + cursor: default; +} + +[data-component="icon-button-v2"]:focus { + outline: none; +} + +[data-component="icon-button-v2"]:is(:focus-visible, [data-state="focus"]):not(:disabled) { + outline: 2px solid var(--v2-border-border-focus); + outline-offset: 2.5px; +} + +[data-component="icon-button-v2"][data-size="small"] { + width: 20px; + height: 20px; + border-radius: 4px; +} + +[data-component="icon-button-v2"][data-size="normal"] { + width: 24px; + height: 24px; +} + +[data-component="icon-button-v2"][data-size="large"] { + width: 28px; + height: 28px; +} + +[data-component="icon-button-v2"] [data-slot="icon-svg"] { + color: currentColor; +} + +/* Neutral */ +[data-component="icon-button-v2"][data-variant="neutral"] { + background-color: var(--v2-background-bg-button-neutral); + color: var(--v2-text-text-base); + box-shadow: var(--v2-elevation-button-neutral); +} + +[data-component="icon-button-v2"][data-variant="neutral"]:is(:hover, [data-state="hover"]):not(:disabled) { + background-image: + linear-gradient(90deg, var(--v2-overlay-simple-overlay-hover) 0%, var(--v2-overlay-simple-overlay-hover) 100%), + linear-gradient(90deg, var(--v2-background-bg-button-neutral) 0%, var(--v2-background-bg-button-neutral) 100%); +} + +[data-component="icon-button-v2"][data-variant="neutral"]:is(:active, [data-state="pressed"]):not(:disabled) { + background-image: + linear-gradient(90deg, var(--v2-overlay-simple-overlay-pressed) 0%, var(--v2-overlay-simple-overlay-pressed) 100%), + linear-gradient(90deg, var(--v2-background-bg-button-neutral) 0%, var(--v2-background-bg-button-neutral) 100%); +} + +[data-component="icon-button-v2"][data-variant="neutral"]:is(:disabled, [data-state="disabled"]) { + opacity: 0.5; + cursor: not-allowed; +} + +/* Contrast */ +[data-component="icon-button-v2"][data-variant="contrast"] { + background-image: + linear-gradient(180deg, var(--v2-alpha-light-20) 0%, var(--v2-alpha-light-0) 100%), + linear-gradient(90deg, var(--v2-background-bg-contrast) 0%, var(--v2-background-bg-contrast) 100%); + color: var(--v2-text-text-contrast); + text-shadow: 0 0.5px 0.5px rgba(0, 0, 0, 0.3); + box-shadow: var(--v2-elevation-button-contrast); +} + +[data-component="icon-button-v2"][data-variant="contrast"]:is(:hover, [data-state="hover"]):not(:disabled) { + background-image: + linear-gradient( + 90deg, + var(--v2-overlay-simple-overlay-contrast-hover) 0%, + var(--v2-overlay-simple-overlay-contrast-hover) 100% + ), + linear-gradient(180deg, var(--v2-alpha-light-20) 0%, var(--v2-alpha-light-0) 100%), + linear-gradient(90deg, var(--v2-background-bg-contrast) 0%, var(--v2-background-bg-contrast) 100%); +} + +[data-component="icon-button-v2"][data-variant="contrast"]:is(:active, [data-state="pressed"]):not(:disabled) { + background-image: + linear-gradient( + 90deg, + var(--v2-overlay-simple-overlay-contrast-pressed) 0%, + var(--v2-overlay-simple-overlay-contrast-pressed) 100% + ), + linear-gradient(180deg, var(--v2-alpha-light-20) 0%, var(--v2-alpha-light-0) 100%), + linear-gradient(90deg, var(--v2-background-bg-contrast) 0%, var(--v2-background-bg-contrast) 100%); +} + +[data-component="icon-button-v2"][data-variant="contrast"]:is(:disabled, [data-state="disabled"]) { + opacity: 0.4; + cursor: not-allowed; +} + +/* Ghost */ +[data-component="icon-button-v2"][data-variant="ghost"] { + background-color: transparent; + color: var(--v2-text-text-base); +} + +[data-component="icon-button-v2"][data-variant="ghost"]:is(:hover, [data-state="hover"]):not(:disabled) { + background-color: var(--v2-overlay-simple-overlay-hover); +} + +[data-component="icon-button-v2"][data-variant="ghost"]:is(:active, [data-state="pressed"]):not(:disabled) { + background-color: var(--v2-overlay-simple-overlay-pressed); +} + +[data-component="icon-button-v2"][data-variant="ghost"]:is(:disabled, [data-state="disabled"]) { + opacity: 0.5; + cursor: not-allowed; +} + +/* Ghost */ +[data-component="icon-button-v2"][data-variant="ghost-muted"] { + background-color: transparent; + color: var(--v2-icon-icon-muted); +} + +[data-component="icon-button-v2"][data-variant="ghost-muted"]:is(:hover, [data-state="hover"]):not(:disabled) { + background-color: var(--v2-overlay-simple-overlay-hover); +} + +[data-component="icon-button-v2"][data-variant="ghost-muted"]:is(:active, [data-state="pressed"]):not(:disabled) { + background-color: var(--v2-overlay-simple-overlay-pressed); +} + +[data-component="icon-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/icon-button-v2.stories.tsx b/packages/ui/src/v2/components/icon-button-v2.stories.tsx new file mode 100644 index 0000000000..e32100103f --- /dev/null +++ b/packages/ui/src/v2/components/icon-button-v2.stories.tsx @@ -0,0 +1,105 @@ +import { IconButtonV2 } from "./icon-button-v2" + +const docs = `### Overview +Square icon-only button v2 with three visual variants and three sizes. + +### API +- \`icon\`: Icon name from the icon component. +- \`variant\`: "neutral" | "contrast" | "ghost". +- \`size\`: "small" | "normal" | "large". +- \`iconSize\`: Optional explicit icon size override. +- Inherits Kobalte Button props and native button attributes. + +### States +- default, hover, pressed, focus, disabled. +- State selectors are available via pseudo-classes and \`[data-state]\`. +` + +export default { + title: "UI V2/IconButton", + id: "components-icon-button-v2", + component: IconButtonV2, + tags: ["autodocs"], + parameters: { + frameHeight: "300px", + frameBackground: "#fff", + docs: { + description: { + component: docs, + }, + }, + }, + args: { + icon: "plus", + variant: "neutral", + size: "normal", + }, + argTypes: { + icon: { + control: "text", + }, + variant: { + control: "select", + options: ["neutral", "contrast", "ghost"], + }, + size: { + control: "select", + options: ["small", "normal", "large"], + }, + iconSize: { + control: "select", + options: ["small", "normal", "large"], + }, + }, +} + +export const Playground = {} + +export const Variants = { + render: () => ( +
+ + + +
+ ), +} + +export const Sizes = { + render: () => ( +
+ + + +
+ ), +} + +export const AllStates = { + render: () => { + const variants = ["neutral", "contrast", "ghost"] as const + const states = ["default", "hover", "pressed", "focus", "disabled"] as const + + return ( +
+ {variants.map((variant) => ( +
+
+ {variant} +
+
+ {states.map((state) => ( + + ))} +
+
+ ))} +
+ ) + }, +} diff --git a/packages/ui/src/v2/components/icon-button-v2.tsx b/packages/ui/src/v2/components/icon-button-v2.tsx new file mode 100644 index 0000000000..1f7ce3efab --- /dev/null +++ b/packages/ui/src/v2/components/icon-button-v2.tsx @@ -0,0 +1,37 @@ +import { Button as Kobalte } from "@kobalte/core/button" +import { type ComponentProps, splitProps } from "solid-js" +import { JSX } from "solid-js" +import "./icon-button-v2.css" + +export interface IconButtonV2Props + extends ComponentProps, + Pick, "class" | "classList"> { + // temporary + icon?: JSX.Element + // icon: IconProps["name"] + size?: "small" | "normal" | "large" + // iconSize?: IconProps["size"] + variant?: "neutral" | "contrast" | "ghost" | "ghost-muted" + state?: "rest" | "hover" | "pressed" +} + +export function IconButtonV2(props: ComponentProps<"button"> & IconButtonV2Props) { + const [split, rest] = splitProps(props, ["variant", "size", "iconSize", "class", "classList", "state"]) + return ( + + {props.icon} + {/**/} + + ) +} diff --git a/packages/ui/src/v2/components/icon.tsx b/packages/ui/src/v2/components/icon.tsx new file mode 100644 index 0000000000..06005fbbf8 --- /dev/null +++ b/packages/ui/src/v2/components/icon.tsx @@ -0,0 +1,29 @@ +import { type ComponentProps, splitProps } from "solid-js" + +export interface IconProps extends ComponentProps<"svg"> { + name: string + size?: "small" | "normal" | "large" +} + +/** + * Placeholder icon component + */ +export function Icon(props: IconProps) { + const [split, rest] = splitProps(props, ["name", "size"]) + const pixelSize = split.size === "small" ? 14 : split.size === "large" ? 20 : 16 + return ( + + + + + ) +} diff --git a/packages/ui/src/v2/components/inline-input-v2.css b/packages/ui/src/v2/components/inline-input-v2.css new file mode 100644 index 0000000000..28820ae217 --- /dev/null +++ b/packages/ui/src/v2/components/inline-input-v2.css @@ -0,0 +1,220 @@ +[data-component="inline-input-v2"] { + box-sizing: border-box; + display: flex; + flex-direction: row; + align-items: stretch; + padding: 0; + width: 280px; + height: 28px; + border: 0; + 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); + flex: none; + align-self: stretch; + overflow: hidden; + transition: + background 85ms ease-out, + outline-color 85ms ease-out, + box-shadow 85ms ease-out; +} + +[data-component="inline-input-v2"][data-appearance="large"] { + height: 32px; +} + +[data-component="inline-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); +} + +[data-component="inline-input-v2"]:where(:hover):not([data-disabled], [data-invalid]):not(:focus-within) + [data-slot="inline-input-v2-prefix"] { + background: transparent; +} + +[data-component="inline-input-v2"]:where(:focus-within):not([data-disabled], [data-invalid]) { + outline-color: var(--border-border-focus); + box-shadow: none; +} + +[data-component="inline-input-v2"]:where([data-invalid]):not([data-disabled]) { + outline-color: var(--state-fg-danger); + box-shadow: none; +} + +[data-component="inline-input-v2"]:where([data-disabled]) { + opacity: 0.5; + cursor: not-allowed; +} + +[data-component="inline-input-v2"] [data-slot="inline-input-v2-prefix"] { + box-sizing: border-box; + display: flex; + flex-direction: column; + justify-content: center; + align-items: flex-start; + flex: none; + align-self: stretch; + width: fit-content; + min-width: 0; + max-width: 100%; + padding: 4px 8px; + gap: 4px; + background: var(--background-bg-layer-01); + border-radius: 4px 0 0 4px; + transition: background 85ms ease-out; +} + +[data-component="inline-input-v2"][data-label-width] [data-slot="inline-input-v2-prefix"] { + width: var(--inline-input-v2-label-width); + min-width: var(--inline-input-v2-label-width); + max-width: var(--inline-input-v2-label-width); +} + +[data-component="inline-input-v2"] [data-slot="inline-input-v2-prefix-text"] { + display: block; + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--font-family-sans); + font-style: normal; + font-weight: 440; + font-size: 13px; + line-height: 1; + letter-spacing: -0.04px; + color: var(--text-text-muted); + font-variation-settings: "slnt" 0; +} + +[data-component="inline-input-v2"][data-numeric] [data-slot="inline-input-v2-prefix-text"] { + font-variant-numeric: tabular-nums; +} + +[data-component="inline-input-v2"] [data-slot="inline-input-v2-divider"] { + flex: none; + align-self: stretch; + width: 0.5px; + min-width: 0.5px; + background: var(--border-border-base); +} + +[data-component="inline-input-v2"] [data-slot="inline-input-v2-field"] { + display: flex; + flex-direction: row; + align-items: center; + align-self: stretch; + flex: 1 1 auto; + min-width: 0; + padding: 4px 8px; + gap: 8px; +} + +[data-component="inline-input-v2"][data-appearance="large"] [data-slot="inline-input-v2-field"] { + padding-top: 6px; + padding-bottom: 6px; +} + +[data-component="inline-input-v2"] [data-slot="inline-input-v2-value"] { + display: flex; + flex-direction: row; + align-items: center; + flex: 1 1 auto; + min-width: 0; + min-height: 0; +} + +[data-component="inline-input-v2"] [data-slot="inline-input-v2-input"] { + display: block; + width: 100%; + min-width: 0; + height: 100%; + padding: 0; + margin: 0; + border: 0; + background: transparent; + outline: none; + font-family: var(--font-family-sans); + font-style: normal; + font-weight: 440; + font-size: 13px; + line-height: 1; + letter-spacing: -0.04px; + color: var(--text-text-base); + font-variation-settings: "slnt" 0; +} + +[data-component="inline-input-v2"] [data-slot="inline-input-v2-input"]::placeholder { + color: var(--text-text-faint); +} + +[data-component="inline-input-v2"][data-numeric] [data-slot="inline-input-v2-input"] { + font-variant-numeric: tabular-nums; +} + +[data-component="inline-input-v2"] [data-slot="inline-input-v2-icon-button"] { + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + flex: none; + width: 20px; + height: 20px; + padding: 2px; + gap: 3px; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--icon-icon-muted); + cursor: pointer; + outline: none; +} + +[data-component="inline-input-v2"] [data-slot="inline-input-v2-icon-button"]:is(:hover, [data-state="hover"]):not(:disabled) { + background-color: var(--overlay-simple-overlay-hover); +} + +[data-component="inline-input-v2"] [data-slot="inline-input-v2-icon-button"]:is(:active, [data-state="pressed"]):not(:disabled) { + background-color: var(--overlay-simple-overlay-pressed); +} + +[data-component="inline-input-v2"] [data-slot="inline-input-v2-icon-button"]:focus { + outline: none; +} + +[data-component="inline-input-v2"] [data-slot="inline-input-v2-icon-button"]:focus-visible { + outline: 2px solid var(--border-border-focus); + outline-offset: 1px; +} + +[data-component="inline-input-v2"]:where([data-disabled]) [data-slot="inline-input-v2-icon-button"] { + cursor: not-allowed; + pointer-events: none; +} + +[data-component="inline-input-v2"] [data-slot="inline-input-v2-icon-button"] [data-slot="icon-svg"] { + display: block; + flex: none; + color: currentColor; +} + +[data-component="inline-input-v2"][data-invalid]:not([data-disabled]) [data-slot="inline-input-v2-prefix-text"] { + color: var(--text-text-muted); +} + +[data-component="inline-input-v2"][data-invalid]:not([data-disabled]) [data-slot="inline-input-v2-input"] { + color: var(--state-fg-danger); + caret-color: var(--state-fg-danger); +} + +[data-component="inline-input-v2"][data-invalid]:not([data-disabled]) [data-slot="inline-input-v2-input"]::placeholder { + color: var(--state-fg-danger); + opacity: 1; +} diff --git a/packages/ui/src/v2/components/inline-input-v2.stories.tsx b/packages/ui/src/v2/components/inline-input-v2.stories.tsx new file mode 100644 index 0000000000..8d2de63a38 --- /dev/null +++ b/packages/ui/src/v2/components/inline-input-v2.stories.tsx @@ -0,0 +1,141 @@ +// @ts-nocheck +import { createSignal } from "solid-js" +import { Field as FieldV2 } from "./field-v2" +import { InlineInputV2 } from "./inline-input-v2" + +const docs = `### Overview +Single-line field with an inline prefix label, vertical divider, and the same states as TextInput v2. + +### API +- \`prefix\`: Inline label in the leading segment (required). +- \`labelWidth\`: Fixed prefix width (px number or CSS length). Omit for fit-content. +- Forwards native \`input\` props (\`value\`, \`defaultValue\`, \`placeholder\`, \`disabled\`, etc.). +- \`showCopyButton\`, \`copyLabel\`, \`onCopyClick\`: Optional trailing copy control. +- \`invalid\`: Error outline and danger text color. +- \`appearance\`: \`"base"\` (28px) or \`"large"\` (32px). +- \`numeric\`: Tabular numerals on prefix and value. + +### States +- **Hover**, **Focus**, **Invalid**, **Disabled** — same as TextInput v2 on the outer shell. + +### Field +Compose with \`Field\` for label, helper prefix/suffix, and tooltip — see the **Field** story. +` + +export default { + title: "UI V2/InlineInput", + id: "components-inline-input-v2", + component: InlineInputV2, + tags: ["autodocs"], + parameters: { + frameHeight: "400px", + frameBackground: "#fff", + docs: { + description: { + component: docs, + }, + }, + }, + args: { + prefix: "Label", + placeholder: "Text", + showCopyButton: true, + disabled: false, + invalid: false, + appearance: "base", + }, + argTypes: { + prefix: { + control: "text", + }, + labelWidth: { + control: "number", + }, + appearance: { + control: "select", + options: ["base", "large"], + }, + showCopyButton: { + control: "boolean", + }, + disabled: { + control: "boolean", + }, + invalid: { + control: "boolean", + }, + placeholder: { + control: "text", + }, + }, +} + +export const Playground = {} + +export const Controlled = { + render: () => { + const [value, setValue] = createSignal("42") + return ( +
+ setValue(e.currentTarget.value)} + placeholder="0.00" + numeric + /> +
+ Value: {value()} +
+
+ ) + }, +} + +export const Appearances = { + render: () => ( +
+ + + + +
+ ), +} + +export const Field = { + parameters: { frameHeight: "500px" }, + render: () => ( +
+ + Label + Prefix + + Suffix + + + Label + Prefix + + Suffix + +
+ ), +} + +export const States = { + render: () => ( +
+ + + + +
+ ), +} diff --git a/packages/ui/src/v2/components/inline-input-v2.tsx b/packages/ui/src/v2/components/inline-input-v2.tsx new file mode 100644 index 0000000000..cce1af2693 --- /dev/null +++ b/packages/ui/src/v2/components/inline-input-v2.tsx @@ -0,0 +1,90 @@ +import { type ComponentProps, type JSX, Show, splitProps } from "solid-js" +import { Icon } from "./icon" +import "./inline-input-v2.css" + +export interface InlineInputV2Props extends Omit, "type" | "prefix"> { + /** Inline label shown before the field (prefix segment). */ + prefix: JSX.Element + /** Fixed width for the prefix segment (px number or CSS length). Omit for fit-content. */ + labelWidth?: number | string + /** Show the trailing copy action. */ + showCopyButton?: boolean + /** Accessible label for the copy button. */ + copyLabel?: string + onCopyClick?: (event: MouseEvent) => void + /** Apply tabular numerals to the prefix and field value. */ + numeric?: boolean + /** Error styling for the field and value text. */ + invalid?: boolean + /** `base` is 28px tall; `large` is 32px tall. */ + appearance?: "base" | "large" + type?: ComponentProps<"input">["type"] +} + +export function InlineInputV2(props: InlineInputV2Props) { + const [local, inputProps] = splitProps(props, [ + "class", + "classList", + "prefix", + "labelWidth", + "showCopyButton", + "copyLabel", + "onCopyClick", + "numeric", + "invalid", + "appearance", + "disabled", + "style", + ]) + + return ( +
+
+ {local.prefix} +
+ + ) +} diff --git a/packages/ui/src/v2/components/keybind-v2.css b/packages/ui/src/v2/components/keybind-v2.css new file mode 100644 index 0000000000..41ea903b13 --- /dev/null +++ b/packages/ui/src/v2/components/keybind-v2.css @@ -0,0 +1,73 @@ +* { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: geometricPrecision; +} + +[data-component="keybind-v2"] { + box-sizing: border-box; + font-family: var(--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; + gap: 2px; +} + +[data-component="keybind-v2"] *, +[data-component="keybind-v2"] *::before, +[data-component="keybind-v2"] *::after { + box-sizing: border-box; +} + +[data-component="keybind-v2"] [data-slot="keybind-v2-key"] { + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + padding: 0px; + gap: 4px; + width: 14px; + height: 14px; + border-radius: 2px; + flex: none; + flex-grow: 0; +} + +[data-component="keybind-v2"][data-variant="neutral"] [data-slot="keybind-v2-key"] { + background: var(--background-bg-layer-03); +} + +[data-component="keybind-v2"][data-variant="ghost"] [data-slot="keybind-v2-key"] { + background: transparent; +} + +[data-component="keybind-v2"] [data-slot="keybind-v2-label"] { + display: flex; + 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(--font-family-sans), var(--sans), system-ui, sans-serif; + font-style: normal; + font-weight: 530; + font-size: 11px; + line-height: 100%; + text-align: center; + letter-spacing: 0.05px; + 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); +} + +[data-component="keybind-v2"][data-variant="ghost"] [data-slot="keybind-v2-label"] { + color: var(--text-text-faint); +} diff --git a/packages/ui/src/v2/components/keybind-v2.stories.tsx b/packages/ui/src/v2/components/keybind-v2.stories.tsx new file mode 100644 index 0000000000..a50f9401d9 --- /dev/null +++ b/packages/ui/src/v2/components/keybind-v2.stories.tsx @@ -0,0 +1,82 @@ +import { KeybindV2 } from "./keybind-v2" + +const docs = `### Overview +Inline keybind indicator that renders one or more keyboard keys in a compact row. + +### API +- \`keys\`: Array of key labels to display (e.g. \`["⌘", "K"]\`). +- \`variant\`: "neutral" (gray background) | "ghost" (no background). +- Inherits native div attributes. + +### Variants +- **Neutral** — each key sits on a \`#D4D4D4\` pill with darker text. +- **Ghost** — keys render without a background, lighter text color. +` + +export default { + title: "UI V2/Keybind", + id: "components-keybind-v2", + component: KeybindV2, + tags: ["autodocs"], + parameters: { + frameHeight: "200px", + frameBackground: "#fff", + docs: { + description: { + component: docs, + }, + }, + }, + args: { + keys: ["⌘"], + variant: "neutral", + }, + argTypes: { + keys: { + control: "object", + }, + variant: { + control: "select", + options: ["neutral", "ghost"], + }, + }, +} + +export const Playground = {} + +export const Variants = { + render: () => ( +
+ + +
+ ), +} + +export const MultipleKeys = { + render: () => ( +
+ + +
+ ), +} + +export const AllExamples = { + render: () => ( +
+
+ Neutral + + + +
+
+ Ghost + + + +
+
+ ), +} diff --git a/packages/ui/src/v2/components/keybind-v2.tsx b/packages/ui/src/v2/components/keybind-v2.tsx new file mode 100644 index 0000000000..4286465613 --- /dev/null +++ b/packages/ui/src/v2/components/keybind-v2.tsx @@ -0,0 +1,30 @@ +import { type ComponentProps, For, splitProps } from "solid-js" +import "./keybind-v2.css" + +export interface KeybindV2Props extends ComponentProps<"div"> { + keys: string[] + variant?: "neutral" | "ghost" +} + +export function KeybindV2(props: KeybindV2Props) { + const [local, rest] = splitProps(props, ["keys", "variant", "class", "classList"]) + return ( +
+ + {(key) => ( +
+ {key} +
+ )} +
+
+ ) +} diff --git a/packages/ui/src/v2/components/line-comment-v2.css b/packages/ui/src/v2/components/line-comment-v2.css new file mode 100644 index 0000000000..249cd99d8c --- /dev/null +++ b/packages/ui/src/v2/components/line-comment-v2.css @@ -0,0 +1,207 @@ +* { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: geometricPrecision; +} + +[data-component="line-comment-v2"] { + box-sizing: border-box; + font-family: var(--font-family-sans), var(--sans), system-ui, sans-serif; + font-variant-numeric: tabular-nums; + min-width: 0; + width: 100%; + max-width: 400px; +} + +[data-component="line-comment-v2"] *, +[data-component="line-comment-v2"] *::before, +[data-component="line-comment-v2"] *::after { + box-sizing: border-box; +} + +[data-component="line-comment-v2"] [data-slot="line-comment-v2-shell"] { + background: var(--background-bg-layer-01); + border-radius: 6px; + box-shadow: var(--elevation-raised); +} + +/* --- Display (read) --- */ +[data-component="line-comment-v2"][data-variant="display"] [data-slot="line-comment-v2-shell"] { + display: flex; + flex-direction: row; + align-items: flex-start; + padding: 12px; + gap: 8px; +} + +[data-component="line-comment-v2"][data-variant="display"] [data-slot="line-comment-v2-column"] { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + flex: 1 1 auto; + min-width: 0; +} + +[data-component="line-comment-v2"][data-variant="display"] [data-slot="line-comment-v2-text"] { + margin: 0; + width: 100%; + font-size: 13px; + font-style: normal; + font-weight: 440; + line-height: 1; + letter-spacing: -0.04px; + color: var(--text-text-base); + font-variation-settings: "slnt" 0; +} + +[data-component="line-comment-v2"][data-variant="display"] [data-slot="line-comment-v2-meta"] { + font-size: 11px; + font-style: normal; + font-weight: 530; + line-height: 1; + letter-spacing: 0.05px; + color: var(--text-text-faint); + font-variation-settings: "slnt" 0; +} + +[data-component="line-comment-v2"][data-variant="display"] [data-slot="line-comment-v2-tools"] { + display: flex; + flex-direction: row; + align-items: flex-start; + flex-shrink: 0; +} + +[data-slot="line-comment-v2-overflow"] { + display: inline-flex; + flex-direction: row; + justify-content: center; + align-items: center; + width: 20px; + height: 20px; + padding: 2px; + gap: 3px; + margin: 0; + border: none; + border-radius: 4px; + background: transparent; + color: var(--icon-icon-muted); + cursor: pointer; + outline: none; +} + +[data-slot="line-comment-v2-overflow"]:focus { + outline: none; +} + +[data-slot="line-comment-v2-overflow"]:focus-visible { + outline: 2px solid var(--border-border-focus); + outline-offset: 2px; +} + +[data-slot="line-comment-v2-overflow"]:is(:hover, [data-state="hover"]) { + background-color: var(--overlay-simple-overlay-hover); +} + +[data-slot="line-comment-v2-overflow"]:is(:active, [data-state="pressed"]) { + background-color: var(--overlay-simple-overlay-pressed); +} + +[data-slot="line-comment-v2-overflow"] svg { + display: block; + flex-shrink: 0; +} + +/* --- Editor --- */ +[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-shell"] { + display: flex; + flex-direction: column; + align-items: flex-start; + padding: 12px; + gap: 12px; +} + +[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-field"] { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + width: 100%; + min-width: 0; +} + +[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-label"] { + display: flex; + flex-direction: row; + align-items: center; + width: 100%; + font-size: 13px; + font-style: normal; + font-weight: 530; + line-height: 1; + letter-spacing: -0.04px; + color: var(--text-text-base); + user-select: none; +} + +[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-textarea"] { + display: block; + width: 100%; + min-width: 0; + min-height: 80px; + padding: 8px; + margin: 0; + resize: vertical; + 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; + line-height: 1.35; + letter-spacing: -0.04px; + color: var(--text-text-base); + font-variation-settings: "slnt" 0; + outline: none; +} + +[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-textarea"]::placeholder { + color: var(--text-text-faint); + user-select: none; +} + +[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-textarea"]:focus { + border-color: var(--border-border-focus); +} + +[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-footer"] { + display: flex; + flex-direction: row; + align-items: center; + gap: 4px; + width: 100%; + min-width: 0; +} + +[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-footer-meta"] { + flex: 1 1 auto; + min-width: 0; + font-size: 11px; + font-style: normal; + font-weight: 530; + line-height: 1; + letter-spacing: 0.05px; + color: var(--text-text-faint); + font-variation-settings: "slnt" 0; +} + +[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-footer-actions"] { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + flex-shrink: 0; +} diff --git a/packages/ui/src/v2/components/line-comment-v2.stories.tsx b/packages/ui/src/v2/components/line-comment-v2.stories.tsx new file mode 100644 index 0000000000..cebdd6b846 --- /dev/null +++ b/packages/ui/src/v2/components/line-comment-v2.stories.tsx @@ -0,0 +1,92 @@ +// @ts-nocheck +import { createSignal } from "solid-js" +import { + LineCommentEditorV2, + LineCommentV2, + LineCommentV2OverflowIcon, +} from "./line-comment-v2" + +const docs = `### Overview +Line comment **display** and **editor** cards aligned with OpenCode line-comment specs (raised \`#FAFAFA\` surface, footer line context, \`ButtonV2\` neutral + contrast actions). + +### Display +- \`LineCommentV2\`: column stack (body + meta) beside optional \`actions\` (overflow). +- Use \`LineCommentV2OverflowIcon\` inside a \`data-slot="line-comment-v2-overflow"\` button for the Figma dots control. + +### Editor +- \`LineCommentEditorV2\`: optional \`heading\` above the textarea (default “Comment”), footer (selection meta + Cancel / Comment). +- \`Enter\` submits (Shift+Enter newline); \`Escape\` cancels. Controlled via \`value\` / \`onInput\`. +` + +export default { + title: "UI V2/LineComment", + id: "components-line-comment-v2", + component: LineCommentV2, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, +} + +export const Display = { + render: () => ( +
+ + + + } + /> +
+ ), +} + +export const DisplayWithoutActions = { + render: () => ( +
+ +
+ ), +} + +export const Editor = { + render: () => { + const [value, setValue] = createSignal("") + return ( +
+ setValue("")} + onSubmit={() => setValue("")} + selection="Comment on line 40" + /> +
+ ) + }, +} + +export const EditorFilled = { + render: () => { + const [value, setValue] = createSignal("Use a sentinel or early return when the list is empty.") + return ( +
+ setValue("")} + onSubmit={() => {}} + selection="Comment on line 40" + autofocus={false} + /> +
+ ) + }, +} diff --git a/packages/ui/src/v2/components/line-comment-v2.tsx b/packages/ui/src/v2/components/line-comment-v2.tsx new file mode 100644 index 0000000000..f3e4c14db5 --- /dev/null +++ b/packages/ui/src/v2/components/line-comment-v2.tsx @@ -0,0 +1,164 @@ +import { type ComponentProps, type JSX, Show, onMount, splitProps } from "solid-js" +import { ButtonV2 } from "./button-v2" +import "./line-comment-v2.css" + +/** Horizontal “more” glyph for the display-card overflow control (Figma outline-dots). */ +export function LineCommentV2OverflowIcon(props: ComponentProps<"svg">) { + return ( + + + + + + ) +} + +export interface LineCommentV2Props extends ComponentProps<"div"> { + /** Main comment body (text or rich content). */ + comment: JSX.Element + /** Line / selection context (e.g. “Comment on line 40”). */ + selection: JSX.Element + /** Typically an overflow menu trigger; use `LineCommentV2OverflowIcon` inside `line-comment-v2-overflow`. */ + actions?: JSX.Element +} + +export function LineCommentV2(props: LineCommentV2Props) { + const [local, rest] = splitProps(props, ["comment", "selection", "actions", "class", "classList"]) + return ( +
+
+
+
{local.comment}
+
{local.selection}
+
+ + {(actions) =>
{actions()}
} +
+
+
+ ) +} + +export interface LineCommentEditorV2Props + extends Omit, "children" | "onInput" | "onSubmit"> { + /** Visible field label above the textarea (default: “Comment”). */ + heading?: JSX.Element | string + value: string + onInput: (value: string) => void + onCancel: () => void + onSubmit: (value: string) => void + selection: JSX.Element + placeholder?: string + rows?: number + cancelLabel?: string + submitLabel?: string + autofocus?: boolean +} + +export function LineCommentEditorV2(props: LineCommentEditorV2Props) { + let textareaRef: HTMLTextAreaElement | undefined + + const [local, rest] = splitProps(props, [ + "heading", + "value", + "onInput", + "onCancel", + "onSubmit", + "selection", + "placeholder", + "rows", + "cancelLabel", + "submitLabel", + "autofocus", + "class", + "classList", + ]) + + const heading = () => local.heading ?? "Comment" + const canSubmit = () => local.value.trim().length > 0 + + const submit = () => { + const v = local.value.trim() + if (!v) return + local.onSubmit(v) + } + + onMount(() => { + if (local.autofocus === false) return + requestAnimationFrame(() => textareaRef?.focus()) + }) + + return ( +
+
+
+
{heading()}
+