chore: initialize qiming workspace repository

This commit is contained in:
Codex
2026-05-29 14:22:48 +08:00
commit bfd67a0f2c
10750 changed files with 1885711 additions and 0 deletions

View File

@@ -0,0 +1,123 @@
[data-component="accordion"] {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0px;
align-self: stretch;
[data-slot="accordion-item"] {
width: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
align-self: stretch;
overflow: visible;
& + [data-slot="accordion-item"] {
margin-top: -1px;
}
[data-slot="accordion-header"] {
width: 100%;
display: flex;
align-items: center;
margin: 0;
padding: 0;
[data-slot="accordion-trigger"] {
width: 100%;
display: flex;
height: 32px;
padding: 8px 12px;
justify-content: space-between;
align-items: center;
align-self: stretch;
cursor: default;
user-select: none;
background-color: var(--background-stronger);
border: 1px solid var(--border-weak-base);
border-radius: 0;
box-shadow: none;
overflow: clip;
color: var(--text-strong);
transition: background-color 0.15s ease;
/* text-12-regular */
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
font-style: normal;
font-weight: var(--font-weight-regular);
line-height: var(--line-height-large); /* 166.667% */
letter-spacing: var(--letter-spacing-normal);
&:hover:not([data-disabled]) {
background-color: var(--surface-base-hover);
}
&:active:not([data-disabled]) {
background-color: var(--surface-base-active);
}
&:focus-visible {
outline: none;
}
&[data-disabled] {
cursor: not-allowed;
}
}
}
&:first-child {
[data-slot="accordion-header"] [data-slot="accordion-trigger"] {
border-top-left-radius: var(--radius-lg);
border-top-right-radius: var(--radius-lg);
}
}
&:last-child:not([data-expanded]) {
[data-slot="accordion-header"] [data-slot="accordion-trigger"] {
border-bottom-left-radius: var(--radius-lg);
border-bottom-right-radius: var(--radius-lg);
}
}
&[data-expanded] {
[data-slot="accordion-content"] {
border: 1px solid var(--border-weak-base);
border-top: 0;
background-color: var(--background-stronger);
}
}
&:last-child[data-expanded] {
[data-slot="accordion-content"] {
border-bottom-left-radius: var(--radius-lg);
border-bottom-right-radius: var(--radius-lg);
}
}
[data-slot="accordion-content"] {
overflow: hidden;
width: 100%;
border: 0;
background-color: transparent;
}
}
}
@keyframes slideDown {
from {
height: 0;
}
to {
height: var(--kb-accordion-content-height);
}
}
@keyframes slideUp {
from {
height: var(--kb-accordion-content-height);
}
to {
height: 0;
}
}

View File

@@ -0,0 +1,149 @@
// @ts-nocheck
import { createEffect, createSignal } from "solid-js"
import * as mod from "./accordion"
import { create } from "../storybook/scaffold"
const docs = `### Overview
Accordion for collapsible content sections with optional multi-open behavior.
Use one trigger per item; keep content concise.
### API
- Root supports Kobalte Accordion props: \`value\`, \`multiple\`, \`collapsible\`, \`onChange\`.
- Compose with \`Accordion.Item\`, \`Header\`, \`Trigger\`, \`Content\`.
### Variants and states
- Single or multiple open items.
- Collapsible or fixed-open behavior.
### Behavior
- Controlled via \`value\`/\`onChange\` when provided.
### Accessibility
- TODO: confirm keyboard navigation from Kobalte Accordion.
### Theming/tokens
- Uses \`data-component="accordion"\` and slot data attributes.
`
const story = create({ title: "UI/Accordion", mod })
export default {
title: "UI/Accordion",
id: "components-accordion",
component: story.meta.component,
tags: ["autodocs"],
parameters: {
docs: {
description: {
component: docs,
},
},
},
}
export const Basic = {
args: {
collapsible: true,
multiple: false,
value: "first",
},
argTypes: {
collapsible: { control: "boolean" },
multiple: { control: "boolean" },
value: {
control: "select",
options: ["first", "second", "none"],
mapping: {
none: undefined,
},
},
},
render: (props) => {
const [value, setValue] = createSignal(props.value)
createEffect(() => {
setValue(props.value)
})
const current = () => {
if (props.multiple) {
if (Array.isArray(value())) return value()
if (value()) return [value()]
return []
}
if (Array.isArray(value())) return value()[0]
return value()
}
return (
<div style={{ display: "grid", gap: "8px", width: "420px" }}>
<mod.Accordion collapsible={props.collapsible} multiple={props.multiple} value={current()} onChange={setValue}>
<mod.Accordion.Item value="first">
<mod.Accordion.Header>
<mod.Accordion.Trigger>First</mod.Accordion.Trigger>
</mod.Accordion.Header>
<mod.Accordion.Content>
<div style={{ color: "var(--text-weak)", padding: "8px 0" }}>Accordion content.</div>
</mod.Accordion.Content>
</mod.Accordion.Item>
<mod.Accordion.Item value="second">
<mod.Accordion.Header>
<mod.Accordion.Trigger>Second</mod.Accordion.Trigger>
</mod.Accordion.Header>
<mod.Accordion.Content>
<div style={{ color: "var(--text-weak)", padding: "8px 0" }}>More content.</div>
</mod.Accordion.Content>
</mod.Accordion.Item>
</mod.Accordion>
</div>
)
},
}
export const Multiple = {
args: {
collapsible: true,
multiple: true,
value: ["first", "second"],
},
render: (props) => (
<mod.Accordion collapsible={props.collapsible} multiple={props.multiple} value={props.value}>
<mod.Accordion.Item value="first">
<mod.Accordion.Header>
<mod.Accordion.Trigger>First</mod.Accordion.Trigger>
</mod.Accordion.Header>
<mod.Accordion.Content>
<div style={{ color: "var(--text-weak)", padding: "8px 0" }}>Accordion content.</div>
</mod.Accordion.Content>
</mod.Accordion.Item>
<mod.Accordion.Item value="second">
<mod.Accordion.Header>
<mod.Accordion.Trigger>Second</mod.Accordion.Trigger>
</mod.Accordion.Header>
<mod.Accordion.Content>
<div style={{ color: "var(--text-weak)", padding: "8px 0" }}>More content.</div>
</mod.Accordion.Content>
</mod.Accordion.Item>
</mod.Accordion>
),
}
export const NonCollapsible = {
args: {
collapsible: false,
multiple: false,
value: "first",
},
render: (props) => (
<mod.Accordion collapsible={props.collapsible} multiple={props.multiple} value={props.value}>
<mod.Accordion.Item value="first">
<mod.Accordion.Header>
<mod.Accordion.Trigger>First</mod.Accordion.Trigger>
</mod.Accordion.Header>
<mod.Accordion.Content>
<div style={{ color: "var(--text-weak)", padding: "8px 0" }}>Accordion content.</div>
</mod.Accordion.Content>
</mod.Accordion.Item>
</mod.Accordion>
),
}

View File

@@ -0,0 +1,92 @@
import { Accordion as Kobalte } from "@kobalte/core/accordion"
import { splitProps } from "solid-js"
import type { ComponentProps, ParentProps } from "solid-js"
export interface AccordionProps extends ComponentProps<typeof Kobalte> {}
export interface AccordionItemProps extends ComponentProps<typeof Kobalte.Item> {}
export interface AccordionHeaderProps extends ComponentProps<typeof Kobalte.Header> {}
export interface AccordionTriggerProps extends ComponentProps<typeof Kobalte.Trigger> {}
export interface AccordionContentProps extends ComponentProps<typeof Kobalte.Content> {}
function AccordionRoot(props: AccordionProps) {
const [split, rest] = splitProps(props, ["class", "classList"])
return (
<Kobalte
{...rest}
data-component="accordion"
classList={{
...split.classList,
[split.class ?? ""]: !!split.class,
}}
/>
)
}
function AccordionItem(props: AccordionItemProps) {
const [split, rest] = splitProps(props, ["class", "classList"])
return (
<Kobalte.Item
{...rest}
data-slot="accordion-item"
classList={{
...split.classList,
[split.class ?? ""]: !!split.class,
}}
/>
)
}
function AccordionHeader(props: ParentProps<AccordionHeaderProps>) {
const [split, rest] = splitProps(props, ["class", "classList", "children"])
return (
<Kobalte.Header
{...rest}
data-slot="accordion-header"
classList={{
...split.classList,
[split.class ?? ""]: !!split.class,
}}
>
{split.children}
</Kobalte.Header>
)
}
function AccordionTrigger(props: ParentProps<AccordionTriggerProps>) {
const [split, rest] = splitProps(props, ["class", "classList", "children"])
return (
<Kobalte.Trigger
{...rest}
data-slot="accordion-trigger"
classList={{
...split.classList,
[split.class ?? ""]: !!split.class,
}}
>
{split.children}
</Kobalte.Trigger>
)
}
function AccordionContent(props: ParentProps<AccordionContentProps>) {
const [split, rest] = splitProps(props, ["class", "classList", "children"])
return (
<Kobalte.Content
{...rest}
data-slot="accordion-content"
classList={{
...split.classList,
[split.class ?? ""]: !!split.class,
}}
>
{split.children}
</Kobalte.Content>
)
}
export const Accordion = Object.assign(AccordionRoot, {
Item: AccordionItem,
Header: AccordionHeader,
Trigger: AccordionTrigger,
Content: AccordionContent,
})

View File

@@ -0,0 +1,75 @@
[data-component="animated-number"] {
display: inline-flex;
align-items: baseline;
vertical-align: baseline;
line-height: inherit;
font-variant-numeric: tabular-nums;
[data-slot="animated-number-value"] {
display: inline-flex;
flex-direction: row-reverse;
align-items: baseline;
justify-content: flex-end;
line-height: inherit;
width: var(--animated-number-width, 1ch);
overflow: hidden;
transition: width var(--tool-motion-spring-ms, 560ms) var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
}
[data-slot="animated-number-digit"] {
display: inline-block;
width: 1ch;
height: 1em;
line-height: 1em;
overflow: hidden;
vertical-align: baseline;
-webkit-mask-image: linear-gradient(
to bottom,
transparent 0%,
#000 var(--tool-motion-mask, 18%),
#000 calc(100% - var(--tool-motion-mask, 18%)),
transparent 100%
);
mask-image: linear-gradient(
to bottom,
transparent 0%,
#000 var(--tool-motion-mask, 18%),
#000 calc(100% - var(--tool-motion-mask, 18%)),
transparent 100%
);
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
}
[data-slot="animated-number-strip"] {
display: inline-flex;
flex-direction: column;
transform: translateY(calc(var(--animated-number-offset, 10) * -1em));
transition-property: transform;
transition-duration: var(--animated-number-duration, 560ms);
transition-timing-function: var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
}
[data-slot="animated-number-strip"][data-animating="false"] {
transition-duration: 0ms;
}
[data-slot="animated-number-cell"] {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1ch;
height: 1em;
line-height: 1em;
}
}
@media (prefers-reduced-motion: reduce) {
[data-component="animated-number"] [data-slot="animated-number-value"] {
transition-duration: 0ms;
}
[data-component="animated-number"] [data-slot="animated-number-strip"] {
transition-duration: 0ms;
}
}

View File

@@ -0,0 +1,109 @@
import { For, Index, createEffect, createMemo, on } from "solid-js"
import { createStore } from "solid-js/store"
const TRACK = Array.from({ length: 30 }, (_, index) => index % 10)
const DURATION = 600
function normalize(value: number) {
return ((value % 10) + 10) % 10
}
function spin(from: number, to: number, direction: 1 | -1) {
if (from === to) return 0
if (direction > 0) return (to - from + 10) % 10
return -((from - to + 10) % 10)
}
function Digit(props: { value: number; direction: 1 | -1 }) {
const [state, setState] = createStore({
step: props.value + 10,
animating: false,
})
const step = () => state.step
const animating = () => state.animating
let last = props.value
createEffect(
on(
() => props.value,
(next) => {
const delta = spin(last, next, props.direction)
last = next
if (!delta) {
setState("animating", false)
setState("step", next + 10)
return
}
setState("animating", true)
setState("step", (value) => value + delta)
},
{ defer: true },
),
)
return (
<span data-slot="animated-number-digit">
<span
data-slot="animated-number-strip"
data-animating={animating() ? "true" : "false"}
onTransitionEnd={() => {
setState("animating", false)
setState("step", (value) => normalize(value) + 10)
}}
style={{
"--animated-number-offset": `${step()}`,
"--animated-number-duration": `var(--tool-motion-odometer-ms, ${DURATION}ms)`,
}}
>
<For each={TRACK}>{(value) => <span data-slot="animated-number-cell">{value}</span>}</For>
</span>
</span>
)
}
export function AnimatedNumber(props: { value: number; class?: string }) {
const target = createMemo(() => {
if (!Number.isFinite(props.value)) return 0
return Math.max(0, Math.round(props.value))
})
const [state, setState] = createStore({
value: target(),
direction: 1 as 1 | -1,
})
const value = () => state.value
const direction = () => state.direction
createEffect(
on(
target,
(next) => {
const current = value()
if (next === current) return
setState("direction", next > current ? 1 : -1)
setState("value", next)
},
{ defer: true },
),
)
const label = createMemo(() => value().toString())
const digits = createMemo(() =>
Array.from(label(), (char) => {
const code = char.charCodeAt(0) - 48
if (code < 0 || code > 9) return 0
return code
}).reverse(),
)
const width = createMemo(() => `${digits().length}ch`)
return (
<span data-component="animated-number" class={props.class} aria-label={label()}>
<span data-slot="animated-number-value" style={{ "--animated-number-width": width() }}>
<Index each={digits()}>{(digit) => <Digit value={digit()} direction={direction()} />}</Index>
</span>
</span>
)
}

View File

@@ -0,0 +1,5 @@
img[data-component="app-icon"] {
display: block;
box-sizing: border-box;
object-fit: contain;
}

View File

@@ -0,0 +1,69 @@
// @ts-nocheck
import { iconNames } from "./app-icons/types"
import * as mod from "./app-icon"
import { create } from "../storybook/scaffold"
const docs = `### Overview
Application icon renderer for known editor/terminal apps.
Use in provider or app selection lists.
### API
- Required: \`id\` (app icon name).
- Accepts standard img props except \`src\`.
### Variants and states
- Auto-switches themed icons when available.
### Behavior
- Watches color scheme changes to swap themed assets.
### Accessibility
- Provide \`alt\` text when the icon conveys meaning.
### Theming/tokens
- Uses \`data-component="app-icon"\`.
`
const story = create({ title: "UI/AppIcon", mod, args: { id: "vscode" } })
export default {
title: "UI/AppIcon",
id: "components-app-icon",
component: story.meta.component,
tags: ["autodocs"],
parameters: {
docs: {
description: {
component: docs,
},
},
},
argTypes: {
id: {
control: "select",
options: iconNames,
},
},
}
export const Basic = story.Basic
export const AllIcons = {
render: () => (
<div
style={{
display: "grid",
gap: "12px",
"grid-template-columns": "repeat(auto-fill, minmax(72px, 1fr))",
}}
>
{iconNames.map((id) => (
<div style={{ display: "grid", gap: "6px", "justify-items": "center" }}>
<mod.AppIcon id={id} alt={id} />
<div style={{ "font-size": "10px", color: "var(--text-weak)", "text-align": "center" }}>{id}</div>
</div>
))}
</div>
),
}

View File

@@ -0,0 +1,85 @@
import type { Component, ComponentProps } from "solid-js"
import { createSignal, onCleanup, onMount, splitProps } from "solid-js"
import type { IconName } from "./app-icons/types"
import androidStudio from "../assets/icons/app/android-studio.svg"
import antigravity from "../assets/icons/app/antigravity.svg"
import cursor from "../assets/icons/app/cursor.svg"
import fileExplorer from "../assets/icons/app/file-explorer.svg"
import finder from "../assets/icons/app/finder.png"
import ghostty from "../assets/icons/app/ghostty.svg"
import iterm2 from "../assets/icons/app/iterm2.svg"
import powershell from "../assets/icons/app/powershell.svg"
import terminal from "../assets/icons/app/terminal.png"
import textmate from "../assets/icons/app/textmate.png"
import vscode from "../assets/icons/app/vscode.svg"
import warp from "../assets/icons/app/warp.png"
import xcode from "../assets/icons/app/xcode.png"
import zed from "../assets/icons/app/zed.svg"
import zedDark from "../assets/icons/app/zed-dark.svg"
import sublimetext from "../assets/icons/app/sublimetext.svg"
const icons = {
vscode,
cursor,
zed,
"file-explorer": fileExplorer,
finder,
terminal,
iterm2,
ghostty,
warp,
xcode,
"android-studio": androidStudio,
antigravity,
textmate,
powershell,
"sublime-text": sublimetext,
} satisfies Record<IconName, string>
const themed: Partial<Record<IconName, { light: string; dark: string }>> = {
zed: {
light: zed,
dark: zedDark,
},
}
const scheme = () => {
if (typeof document !== "object") return "light" as const
if (document.documentElement.dataset.colorScheme === "dark") return "dark" as const
return "light" as const
}
export type AppIconProps = Omit<ComponentProps<"img">, "src"> & {
id: IconName
}
export const AppIcon: Component<AppIconProps> = (props) => {
const [local, rest] = splitProps(props, ["id", "class", "classList", "alt", "draggable"])
const [mode, setMode] = createSignal(scheme())
onMount(() => {
const sync = () => setMode(scheme())
const observer = new MutationObserver(sync)
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-color-scheme"],
})
sync()
onCleanup(() => observer.disconnect())
})
return (
<img
data-component="app-icon"
{...rest}
src={themed[local.id]?.[mode()] ?? icons[local.id]}
alt={local.alt ?? ""}
draggable={local.draggable ?? false}
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
/>
)
}

View File

@@ -0,0 +1,114 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="0" height="0">
<defs>
<symbol id="vscode" viewBox="0 0 24 24">
<rect width="24" height="24" rx="5" fill="#007ACC" />
<g transform="scale(1.5)">
<path
fill="#fff"
d="M11.5 11.19V4.8L7.3 7.99M1.17 6.07a.6.6 0 0 1-.01-.81L2 4.48c.14-.13.48-.18.73 0l2.39 1.83 5.55-5.09c.22-.22.61-.32 1.05-.08l2.8 1.34c.25.15.49.38.49.81v9.49c0 .28-.2.58-.42.7l-3.08 1.48c-.22.09-.64 0-.79-.14L5.11 9.69l-2.38 1.83c-.27.18-.6.13-.74 0l-.84-.77c-.22-.23-.2-.61.04-.84l2.1-1.9"
/>
</g>
</symbol>
<symbol id="cursor" viewBox="0 0 24 24">
<rect width="24" height="24" rx="5" fill="#111827" />
<path
fill="#fff"
d="M11.503.131 1.891 5.678a.84.84 0 0 0-.42.726v11.188c0 .3.162.575.42.724l9.609 5.55a1 1 0 0 0 .998 0l9.61-5.55a.84.84 0 0 0 .42-.724V6.404a.84.84 0 0 0-.42-.726L12.497.131a1.01 1.01 0 0 0-.996 0M2.657 6.338h18.55c.263 0 .43.287.297.515L12.23 22.918c-.062.107-.229.064-.229-.06V12.335a.59.59 0 0 0-.295-.51l-9.11-5.257c-.109-.063-.064-.23.061-.23"
/>
</symbol>
<symbol id="zed" viewBox="0 0 24 24">
<rect width="24" height="24" rx="5" fill="#084CCF" />
<g transform="translate(12 12) scale(0.9) translate(-12 -12)">
<path
fill="#fff"
d="M2.25 1.5a.75.75 0 0 0-.75.75v16.5H0V2.25A2.25 2.25 0 0 1 2.25 0h20.095c1.002 0 1.504 1.212.795 1.92L10.764 14.298h3.486V12.75h1.5v1.922a1.125 1.125 0 0 1-1.125 1.125H9.264l-2.578 2.578h11.689V9h1.5v9.375a1.5 1.5 0 0 1-1.5 1.5H5.185L2.562 22.5H21.75a.75.75 0 0 0 .75-.75V5.25H24v16.5A2.25 2.25 0 0 1 21.75 24H1.655C.653 24 .151 22.788.86 22.08L13.19 9.75H9.75v1.5h-1.5V9.375A1.125 1.125 0 0 1 9.375 8.25h5.314l2.625-2.625H5.625V15h-1.5V5.625a1.5 1.5 0 0 1 1.5-1.5h13.19L21.438 1.5z"
/>
</g>
</symbol>
<symbol id="finder" viewBox="0 0 24 24">
<rect width="24" height="24" rx="5" fill="#8ED0FF" />
<path d="M12 0H19a5 5 0 0 1 5 5V19a5 5 0 0 1-5 5H12Z" fill="#2D7BF7" />
<path d="M12 3v18" stroke="#0B2A4A" stroke-opacity="0.35" stroke-width="1.5" />
<circle cx="8.3" cy="9.2" r="1.1" fill="#0B2A4A" />
<circle cx="15.7" cy="9.2" r="1.1" fill="#0B2A4A" />
<path
d="M7.3 15c1.2 1.55 2.9 2.4 4.7 2.4s3.5-.85 4.7-2.4"
stroke="#0B2A4A"
stroke-width="1.5"
fill="none"
stroke-linecap="round"
/>
</symbol>
<symbol id="terminal" viewBox="0 0 24 24">
<rect width="24" height="24" rx="5" fill="#111827" />
<rect
x="3.5"
y="4.5"
width="17"
height="15"
rx="2.5"
fill="#0B1220"
stroke="#334155"
stroke-opacity="0.5"
/>
<path
d="M7.8 9.2 11 12 7.8 14.8"
stroke="#34D399"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
/>
<rect x="12.2" y="14.2" width="5.4" height="1.6" rx="0.8" fill="#34D399" />
</symbol>
<symbol id="iterm2" viewBox="0 0 24 24">
<rect width="24" height="24" rx="5" fill="#0B0B0B" />
<rect x="3.2" y="4.2" width="17.6" height="15.6" rx="2.4" fill="#000" stroke="#60A5FA" stroke-width="1.2" />
<circle cx="5.5" cy="6.3" r="0.75" fill="#F87171" />
<circle cx="7.6" cy="6.3" r="0.75" fill="#FBBF24" />
<circle cx="9.7" cy="6.3" r="0.75" fill="#34D399" />
<path
d="M7.9 10.2 10.6 12 7.9 13.8"
stroke="#34D399"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
/>
<rect x="11.6" y="13.3" width="5" height="1.4" rx="0.7" fill="#34D399" />
</symbol>
<symbol id="ghostty" viewBox="0 0 24 24">
<rect width="24" height="24" rx="5" fill="#3551F3" />
<g transform="translate(12 12) scale(0.9) translate(-12 -12)">
<path
fill="#fff"
d="M12 0C6.7 0 2.4 4.3 2.4 9.6v11.146c0 1.772 1.45 3.267 3.222 3.254a3.18 3.18 0 0 0 1.955-.686 1.96 1.96 0 0 1 2.444 0 3.18 3.18 0 0 0 1.976.686c.75 0 1.436-.257 1.98-.686.715-.563 1.71-.587 2.419-.018.59.476 1.355.743 2.182.699 1.705-.094 3.022-1.537 3.022-3.244V9.601C21.6 4.3 17.302 0 12 0M6.069 6.562a1 1 0 0 1 .46.131l3.578 2.065v.002a.974.974 0 0 1 0 1.687L6.53 12.512a.975.975 0 0 1-.976-1.687L7.67 9.602 5.553 8.38a.975.975 0 0 1 .515-1.818m7.438 2.063h4.7a.975.975 0 1 1 0 1.95h-4.7a.975.975 0 0 1 0-1.95"
/>
</g>
</symbol>
<symbol id="xcode" viewBox="0 0 24 24">
<rect width="24" height="24" rx="5" fill="#147EFB" />
<path d="M6 8H18" stroke="#fff" stroke-opacity="0.18" stroke-width="1.2" stroke-linecap="round" />
<path d="M8 6V18" stroke="#fff" stroke-opacity="0.18" stroke-width="1.2" stroke-linecap="round" />
<path d="M6 18H18" stroke="#fff" stroke-opacity="0.18" stroke-width="1.2" stroke-linecap="round" />
<path d="M18 6V18" stroke="#fff" stroke-opacity="0.18" stroke-width="1.2" stroke-linecap="round" />
<g transform="translate(12 12) rotate(-35) translate(-12 -12)">
<rect x="11.1" y="6.2" width="2" height="12.6" rx="1" fill="#fff" />
<rect x="9.2" y="5.3" width="5.6" height="2.7" rx="1" fill="#fff" />
</g>
</symbol>
<symbol id="android-studio" viewBox="0 0 24 24">
<rect width="24" height="24" rx="5" fill="#3DDC84" />
<circle cx="12" cy="12.2" r="6.8" fill="#3B82F6" />
<circle cx="12" cy="12.2" r="4.8" fill="none" stroke="#fff" stroke-width="1.6" />
<path d="M12 9.4l2.2 5-2.2-1.3-2.2 1.3z" fill="#fff" />
<circle cx="12" cy="12.2" r="0.9" fill="#fff" />
</symbol>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

@@ -0,0 +1,21 @@
// This file is generated by icon spritesheet generator
export const iconNames = [
"vscode",
"cursor",
"zed",
"file-explorer",
"finder",
"terminal",
"iterm2",
"ghostty",
"warp",
"xcode",
"android-studio",
"antigravity",
"textmate",
"powershell",
"sublime-text",
] as const
export type IconName = (typeof iconNames)[number]

View File

@@ -0,0 +1,43 @@
import { describe, expect, test } from "bun:test"
import { patchFiles } from "./apply-patch-file"
import { text } from "./session-diff"
describe("apply patch file", () => {
test("parses patch metadata from the server", () => {
const file = patchFiles([
{
filePath: "/tmp/a.ts",
relativePath: "a.ts",
type: "update",
patch:
"Index: a.ts\n===================================================================\n--- a.ts\t\n+++ a.ts\t\n@@ -1,2 +1,2 @@\n one\n-two\n+three\n",
additions: 1,
deletions: 1,
},
])[0]
expect(file).toBeDefined()
expect(file?.view.fileDiff.name).toBe("a.ts")
expect(text(file!.view, "deletions")).toBe("one\ntwo\n")
expect(text(file!.view, "additions")).toBe("one\nthree\n")
})
test("keeps legacy before and after payloads working", () => {
const file = patchFiles([
{
filePath: "/tmp/a.ts",
relativePath: "a.ts",
type: "update",
before: "one\n",
after: "two\n",
additions: 1,
deletions: 1,
},
])[0]
expect(file).toBeDefined()
expect(file?.view.patch).toContain("@@ -1,1 +1,1 @@")
expect(text(file!.view, "deletions")).toBe("one\n")
expect(text(file!.view, "additions")).toBe("two\n")
})
})

View File

@@ -0,0 +1,78 @@
import { normalize, type ViewDiff } from "./session-diff"
type Kind = "add" | "update" | "delete" | "move"
type Raw = {
filePath?: string
relativePath?: string
type?: Kind
patch?: string
diff?: string
before?: string
after?: string
additions?: number
deletions?: number
movePath?: string
}
export type ApplyPatchFile = {
filePath: string
relativePath: string
type: Kind
additions: number
deletions: number
movePath?: string
view: ViewDiff
}
function kind(value: unknown) {
if (value === "add" || value === "update" || value === "delete" || value === "move") return value
}
function status(type: Kind): "added" | "deleted" | "modified" {
if (type === "add") return "added"
if (type === "delete") return "deleted"
return "modified"
}
export function patchFile(raw: unknown): ApplyPatchFile | undefined {
if (!raw || typeof raw !== "object") return
const value = raw as Raw
const type = kind(value.type)
const filePath = typeof value.filePath === "string" ? value.filePath : undefined
const relativePath = typeof value.relativePath === "string" ? value.relativePath : filePath
const patch = typeof value.patch === "string" ? value.patch : typeof value.diff === "string" ? value.diff : undefined
const before = typeof value.before === "string" ? value.before : undefined
const after = typeof value.after === "string" ? value.after : undefined
if (!type || !filePath || !relativePath) return
if (!patch && before === undefined && after === undefined) return
const additions = typeof value.additions === "number" ? value.additions : 0
const deletions = typeof value.deletions === "number" ? value.deletions : 0
const movePath = typeof value.movePath === "string" ? value.movePath : undefined
return {
filePath,
relativePath,
type,
additions,
deletions,
movePath,
view: normalize({
file: relativePath,
patch,
before,
after,
additions,
deletions,
status: status(type),
}),
}
}
export function patchFiles(raw: unknown) {
if (!Array.isArray(raw)) return []
return raw.map(patchFile).filter((file): file is ApplyPatchFile => !!file)
}

View File

@@ -0,0 +1,49 @@
[data-component="avatar"] {
--avatar-bg: var(--color-surface-info-base);
--avatar-fg: var(--color-text-base);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border-radius: var(--radius-sm);
border: 1px solid var(--color-border-weak-base);
font-family: var(--font-mono);
font-weight: 500;
text-transform: uppercase;
background-color: var(--avatar-bg);
color: var(--avatar-fg);
}
[data-component="avatar"][data-has-image] {
background-color: transparent;
border: none;
}
[data-component="avatar"][data-size="small"] {
width: 1.25rem;
height: 1.25rem;
font-size: 0.75rem;
line-height: 1;
}
[data-component="avatar"][data-size="normal"] {
width: 1.5rem;
height: 1.5rem;
font-size: 1.125rem;
line-height: 1.5rem;
}
[data-component="avatar"][data-size="large"] {
width: 2rem;
height: 2rem;
font-size: 1.25rem;
line-height: 2rem;
}
[data-component="avatar"] [data-slot="avatar-image"] {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
border-radius: inherit;
}

View File

@@ -0,0 +1,76 @@
// @ts-nocheck
import * as mod from "./avatar"
import { create } from "../storybook/scaffold"
const docs = `### Overview
User avatar with image fallback to initials.
Use in user lists and headers.
### API
- Required: \`fallback\` string.
- Optional: \`src\`, \`background\`, \`foreground\`, \`size\`.
### Variants and states
- Sizes: small, normal, large.
- Image vs fallback 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.
`
const story = create({ title: "UI/Avatar", mod, args: { fallback: "A" } })
export default {
title: "UI/Avatar",
id: "components-avatar",
component: story.meta.component,
tags: ["autodocs"],
parameters: {
docs: {
description: {
component: docs,
},
},
},
argTypes: {
size: {
control: "select",
options: ["small", "normal", "large"],
},
},
}
export const Basic = story.Basic
export const WithImage = {
args: {
src: "https://placehold.co/80x80/png",
fallback: "J",
},
}
export const Sizes = {
render: () => (
<div style={{ display: "flex", gap: "12px", "align-items": "center" }}>
<mod.Avatar size="small" fallback="S" />
<mod.Avatar size="normal" fallback="N" />
<mod.Avatar size="large" fallback="L" />
</div>
),
}
export const CustomColors = {
args: {
fallback: "C",
background: "#1f2a44",
foreground: "#f2f5ff",
},
}

View File

@@ -0,0 +1,55 @@
import { type ComponentProps, splitProps, Show } from "solid-js"
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"
}
export function Avatar(props: AvatarProps) {
const [split, rest] = splitProps(props, [
"fallback",
"src",
"background",
"foreground",
"size",
"class",
"classList",
"style",
])
const src = split.src // did this so i can zero it out to test fallback
return (
<div
{...rest}
data-component="avatar"
data-size={split.size || "normal"}
data-has-image={src ? "" : undefined}
classList={{
...split.classList,
[split.class ?? ""]: !!split.class,
}}
style={{
...(typeof split.style === "object" ? split.style : {}),
...(!src && split.background ? { "--avatar-bg": split.background } : {}),
...(!src && split.foreground ? { "--avatar-fg": split.foreground } : {}),
}}
>
<Show when={src} fallback={first(split.fallback)}>
{(src) => <img src={src()} draggable={false} data-slot="avatar-image" />}
</Show>
</div>
)
}

View File

@@ -0,0 +1,259 @@
[data-component="tool-trigger"] {
content-visibility: auto;
width: 100%;
display: flex;
align-items: center;
align-self: stretch;
gap: 0px;
justify-content: flex-start;
&[data-clickable="true"] {
cursor: pointer;
}
&[data-hide-details="true"] {
[data-slot="basic-tool-tool-trigger-content"] {
flex: 1 1 auto;
max-width: 100%;
}
[data-slot="basic-tool-tool-info"] {
flex: 1 1 auto;
}
}
[data-slot="basic-tool-tool-trigger-content"] {
flex: 0 1 auto;
width: auto;
max-width: calc(100% - 24px);
min-width: 0;
display: flex;
align-items: center;
align-self: stretch;
gap: 8px;
}
[data-slot="basic-tool-tool-indicator"] {
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
[data-component="spinner"] {
width: 16px;
height: 16px;
}
}
[data-slot="basic-tool-tool-spinner"] {
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--text-weak);
[data-component="spinner"] {
width: 16px;
height: 16px;
}
}
[data-slot="icon-svg"] {
flex-shrink: 0;
}
[data-slot="basic-tool-tool-info"] {
flex: 0 1 auto;
min-width: 0;
max-width: 100%;
font-size: 14px;
}
[data-slot="basic-tool-tool-info-structured"] {
flex: 0 1 auto;
width: auto;
max-width: 100%;
min-width: 0;
display: inline-flex;
align-items: center;
gap: 8px;
justify-content: flex-start;
}
[data-slot="basic-tool-tool-info-main"] {
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
overflow: hidden;
}
[data-slot="basic-tool-tool-title"] {
flex-shrink: 0;
font-family: var(--font-family-sans);
font-size: 14px;
font-style: normal;
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal);
color: var(--text-strong);
&.capitalize {
text-transform: capitalize;
}
&.agent-title {
color: var(--text-strong);
font-weight: var(--font-weight-medium);
}
}
[data-slot="basic-tool-tool-subtitle"] {
flex-shrink: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-family-sans);
font-variant-numeric: tabular-nums;
font-size: 14px;
font-style: normal;
font-weight: var(--font-weight-regular);
line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal);
color: var(--text-base);
&.clickable {
cursor: pointer;
text-decoration: underline;
transition: color 0.15s ease;
&:hover {
color: var(--text-base);
}
}
&.subagent-link {
color: var(--text-interactive-base);
text-decoration: none;
text-underline-offset: 2px;
font-weight: var(--font-weight-regular);
&:hover {
color: var(--text-interactive-base);
text-decoration: underline;
}
&:active {
color: var(--text-interactive-base);
}
&:visited {
color: var(--text-interactive-base);
}
}
}
[data-slot="basic-tool-tool-arg"] {
flex-shrink: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-family-sans);
font-variant-numeric: tabular-nums;
font-size: 14px;
font-style: normal;
font-weight: var(--font-weight-regular);
line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal);
color: var(--text-base);
}
[data-slot="basic-tool-tool-action"] {
display: inline-flex;
align-items: center;
flex-shrink: 0;
}
}
[data-component="task-tool-card"] {
width: 100%;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 6px;
border: 1px solid var(--border-weak-base, rgba(255, 255, 255, 0.08));
background: color-mix(in srgb, var(--background-base) 92%, transparent);
transition:
border-color 0.15s ease,
background-color 0.15s ease,
color 0.15s ease;
[data-slot="basic-tool-tool-info-structured"] {
flex: 1 1 auto;
min-width: 0;
}
[data-slot="basic-tool-tool-info-main"] {
flex: 1 1 auto;
min-width: 0;
align-items: center;
}
[data-component="task-tool-spinner"] {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
[data-component="spinner"] {
width: 16px;
height: 16px;
}
}
[data-component="task-tool-action"] {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--icon-weak);
margin-left: auto;
opacity: 0;
transition:
opacity 0.15s ease,
color 0.15s ease;
}
[data-component="task-tool-title"] {
flex-shrink: 0;
font-family: var(--font-family-sans);
font-size: 14px;
font-style: normal;
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal);
text-transform: capitalize;
}
[data-slot="basic-tool-tool-subtitle"] {
color: var(--text-strong);
}
&:hover,
&:focus-visible {
border-color: var(--border-weak-base, rgba(255, 255, 255, 0.08));
background: color-mix(in srgb, var(--background-stronger) 88%, transparent);
[data-component="task-tool-action"] {
opacity: 1;
}
}
}

View File

@@ -0,0 +1,133 @@
// @ts-nocheck
import { createSignal } from "solid-js"
import * as mod from "./basic-tool"
import { create } from "../storybook/scaffold"
const docs = `### Overview
Expandable tool panel with a structured trigger and optional details.
Use structured triggers for consistent layout; custom triggers allowed.
### API
- Required: \`icon\` and \`trigger\` (structured or custom JSX).
- Optional: \`status\`, \`defaultOpen\`, \`forceOpen\`, \`defer\`, \`locked\`.
### Variants and states
- Pending/running status animates the title via TextShimmer.
### Behavior
- Uses Collapsible; can defer content rendering until open.
- Locked state prevents closing.
### Accessibility
- TODO: confirm trigger semantics and aria labeling.
### Theming/tokens
- Uses \`data-component="tool-trigger"\` and related slots.
`
const story = create({
title: "UI/Basic Tool",
mod,
args: {
icon: "mcp",
defaultOpen: true,
trigger: {
title: "Basic Tool",
subtitle: "Example subtitle",
args: ["--flag", "value"],
},
children: "Details content",
},
})
export default {
title: "UI/Basic Tool",
id: "components-basic-tool",
component: story.meta.component,
tags: ["autodocs"],
parameters: {
docs: {
description: {
component: docs,
},
},
},
}
export const Basic = story.Basic
export const Pending = {
args: {
status: "pending",
trigger: {
title: "Running tool",
subtitle: "Working...",
},
children: "Progress details",
},
}
export const Locked = {
args: {
locked: true,
trigger: {
title: "Locked tool",
subtitle: "Cannot close",
},
children: "Locked details",
},
}
export const Deferred = {
args: {
defer: true,
defaultOpen: false,
trigger: {
title: "Deferred tool",
subtitle: "Content mounts on open",
},
children: "Deferred content",
},
}
export const ForceOpen = {
args: {
forceOpen: true,
trigger: {
title: "Forced open",
subtitle: "Cannot close",
},
children: "Forced content",
},
}
export const HideDetails = {
args: {
hideDetails: true,
trigger: {
title: "Summary only",
subtitle: "Details hidden",
},
children: "Hidden content",
},
}
export const SubtitleAction = {
render: () => {
const [message, setMessage] = createSignal("Subtitle not clicked")
return (
<div style={{ display: "grid", gap: "8px" }}>
<div style={{ "font-size": "12px", color: "var(--text-weak)" }}>{message()}</div>
<mod.BasicTool
icon="mcp"
trigger={{ title: "Clickable subtitle", subtitle: "Click me" }}
onSubtitleClick={() => setMessage("Subtitle clicked")}
>
Subtitle action details
</mod.BasicTool>
</div>
)
},
}

View File

@@ -0,0 +1,283 @@
import { createEffect, For, Match, on, onCleanup, Show, Switch, type JSX } from "solid-js"
import { animate, type AnimationPlaybackControls } from "motion"
import { useI18n } from "../context/i18n"
import { createStore } from "solid-js/store"
import { Collapsible } from "./collapsible"
import type { IconProps } from "./icon"
import { TextShimmer } from "./text-shimmer"
export type TriggerTitle = {
title: string
titleClass?: string
subtitle?: string
subtitleClass?: string
args?: string[]
argsClass?: string
action?: JSX.Element
}
const isTriggerTitle = (val: any): val is TriggerTitle => {
return (
typeof val === "object" && val !== null && "title" in val && (typeof Node === "undefined" || !(val instanceof Node))
)
}
export interface BasicToolProps {
icon: IconProps["name"]
trigger: TriggerTitle | JSX.Element
children?: JSX.Element
status?: string
hideDetails?: boolean
defaultOpen?: boolean
forceOpen?: boolean
defer?: boolean
locked?: boolean
animated?: boolean
onSubtitleClick?: () => void
onTriggerClick?: JSX.EventHandlerUnion<HTMLElement, MouseEvent>
triggerHref?: string
clickable?: boolean
}
const SPRING = { type: "spring" as const, visualDuration: 0.35, bounce: 0 }
export function BasicTool(props: BasicToolProps) {
const [state, setState] = createStore({
open: props.defaultOpen ?? false,
ready: props.defaultOpen ?? false,
})
const open = () => state.open
const ready = () => state.ready
const pending = () => props.status === "pending" || props.status === "running"
let frame: number | undefined
const cancel = () => {
if (frame === undefined) return
cancelAnimationFrame(frame)
frame = undefined
}
onCleanup(cancel)
createEffect(() => {
if (props.forceOpen) setState("open", true)
})
createEffect(
on(
open,
(value) => {
if (!props.defer) return
if (!value) {
cancel()
setState("ready", false)
return
}
cancel()
frame = requestAnimationFrame(() => {
frame = undefined
if (!open()) return
setState("ready", true)
})
},
{ defer: true },
),
)
// Animated height for collapsible open/close
let contentRef: HTMLDivElement | undefined
let heightAnim: AnimationPlaybackControls | undefined
const initialOpen = open()
createEffect(
on(
open,
(isOpen) => {
if (!props.animated || !contentRef) return
heightAnim?.stop()
if (isOpen) {
contentRef.style.overflow = "hidden"
heightAnim = animate(contentRef, { height: "auto" }, SPRING)
void heightAnim.finished.then(() => {
if (!contentRef || !open()) return
contentRef.style.overflow = "visible"
contentRef.style.height = "auto"
})
} else {
contentRef.style.overflow = "hidden"
heightAnim = animate(contentRef, { height: "0px" }, SPRING)
}
},
{ defer: true },
),
)
onCleanup(() => {
heightAnim?.stop()
})
const handleOpenChange = (value: boolean) => {
if (pending()) return
if (props.locked && !value) return
setState("open", value)
}
const trigger = () => (
<div
data-component="tool-trigger"
data-clickable={props.clickable ? "true" : undefined}
data-hide-details={props.hideDetails ? "true" : undefined}
>
<div data-slot="basic-tool-tool-trigger-content">
<div data-slot="basic-tool-tool-info">
<Switch>
<Match when={isTriggerTitle(props.trigger) && props.trigger}>
{(title) => (
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
<span
data-slot="basic-tool-tool-title"
classList={{
[title().titleClass ?? ""]: !!title().titleClass,
}}
>
<TextShimmer text={title().title} active={pending()} />
</span>
<Show when={!pending()}>
<Show when={title().subtitle}>
<span
data-slot="basic-tool-tool-subtitle"
classList={{
[title().subtitleClass ?? ""]: !!title().subtitleClass,
clickable: !!props.onSubtitleClick,
}}
onClick={(e) => {
if (props.onSubtitleClick) {
e.stopPropagation()
props.onSubtitleClick()
}
}}
>
{title().subtitle}
</span>
</Show>
<Show when={title().args?.length}>
<For each={title().args}>
{(arg) => (
<span
data-slot="basic-tool-tool-arg"
classList={{
[title().argsClass ?? ""]: !!title().argsClass,
}}
>
{arg}
</span>
)}
</For>
</Show>
</Show>
</div>
<Show when={!pending() && title().action}>
<span data-slot="basic-tool-tool-action">{title().action}</span>
</Show>
</div>
)}
</Match>
<Match when={true}>{props.trigger as JSX.Element}</Match>
</Switch>
</div>
</div>
<Show when={props.children && !props.hideDetails && !props.locked && !pending()}>
<Collapsible.Arrow />
</Show>
</div>
)
return (
<Collapsible open={open()} onOpenChange={handleOpenChange} class="tool-collapsible">
<Show
when={props.triggerHref}
fallback={
<Collapsible.Trigger
data-hide-details={props.hideDetails ? "true" : undefined}
onClick={props.onTriggerClick}
>
{trigger()}
</Collapsible.Trigger>
}
>
{(href) => (
<Collapsible.Trigger
as="a"
href={href()}
data-hide-details={props.hideDetails ? "true" : undefined}
onClick={props.onTriggerClick}
>
{trigger()}
</Collapsible.Trigger>
)}
</Show>
<Show when={props.animated && props.children && !props.hideDetails}>
<div
ref={contentRef}
data-slot="collapsible-content"
data-animated
style={{
height: initialOpen ? "auto" : "0px",
overflow: initialOpen ? "visible" : "hidden",
}}
>
{props.children}
</div>
</Show>
<Show when={!props.animated && props.children && !props.hideDetails}>
<Collapsible.Content>
<Show when={!props.defer || ready()}>{props.children}</Show>
</Collapsible.Content>
</Show>
</Collapsible>
)
}
function label(input: Record<string, unknown> | undefined) {
const keys = ["description", "query", "url", "filePath", "path", "pattern", "name"]
return keys.map((key) => input?.[key]).find((value): value is string => typeof value === "string" && value.length > 0)
}
function args(input: Record<string, unknown> | undefined) {
if (!input) return []
const skip = new Set(["description", "query", "url", "filePath", "path", "pattern", "name"])
return Object.entries(input)
.filter(([key]) => !skip.has(key))
.flatMap(([key, value]) => {
if (typeof value === "string") return [`${key}=${value}`]
if (typeof value === "number") return [`${key}=${value}`]
if (typeof value === "boolean") return [`${key}=${value}`]
return []
})
.slice(0, 3)
}
export function GenericTool(props: {
tool: string
status?: string
hideDetails?: boolean
input?: Record<string, unknown>
}) {
const i18n = useI18n()
return (
<BasicTool
icon="mcp"
status={props.status}
trigger={{
title: i18n.t("ui.basicTool.called", { tool: props.tool }),
subtitle: label(props.input),
args: args(props.input),
}}
hideDetails={props.hideDetails}
/>
)
}

View File

@@ -0,0 +1,194 @@
[data-component="button"] {
display: inline-flex;
align-items: center;
justify-content: center;
border-style: solid;
border-width: 1px;
border-radius: var(--radius-md);
text-decoration: none;
user-select: none;
cursor: default;
outline: none;
white-space: nowrap;
&[data-variant="primary"] {
background-color: var(--button-primary-base);
border-color: var(--border-weak-base);
color: var(--icon-invert-base);
[data-slot="icon-svg"] {
color: var(--icon-invert-base);
}
&:hover:not(:disabled) {
background-color: var(--icon-strong-hover);
}
&:focus:not(:disabled) {
background-color: var(--icon-strong-focus);
}
&:active:not(:disabled) {
background-color: var(--icon-strong-active);
}
&:disabled {
background-color: var(--icon-strong-disabled);
[data-slot="icon-svg"] {
color: var(--icon-invert-base);
}
}
}
&[data-variant="ghost"] {
border-color: transparent;
background-color: transparent;
color: var(--text-strong);
[data-slot="icon-svg"] {
color: var(--icon-base);
}
&:hover:not(:disabled) {
background-color: var(--surface-base-hover);
}
&:focus-visible:not(:disabled) {
background-color: var(--surface-base-hover);
}
&:active:not(:disabled) {
background-color: var(--surface-base-active);
}
&:disabled {
color: var(--text-weak);
cursor: not-allowed;
[data-slot="icon-svg"] {
color: var(--icon-disabled);
}
}
&[data-selected="true"]:not(:disabled) {
background-color: var(--surface-base-hover);
}
&[data-active="true"] {
background-color: var(--surface-base-active);
}
}
&[data-variant="secondary"] {
border: transparent;
background-color: var(--button-secondary-base);
color: var(--text-strong);
box-shadow: var(--shadow-xs-border-base);
&:hover:not(:disabled) {
background-color: var(--button-secondary-hover);
}
&:focus:not(:disabled) {
background-color: var(--button-secondary-base);
}
&:focus-visible:not(:active) {
background-color: var(--button-secondary-base);
box-shadow: var(--shadow-xs-border-focus);
}
&:focus-visible:active {
box-shadow: none;
}
&:active:not(:disabled) {
background-color: var(--button-secondary-base);
}
&:disabled {
border-color: var(--border-disabled);
background-color: var(--surface-disabled);
color: var(--text-weak);
cursor: not-allowed;
}
[data-slot="icon-svg"] {
color: var(--icon-base);
}
}
&[data-size="small"] {
height: 24px;
padding: 0 8px;
&[data-icon] {
padding: 0 12px 0 4px;
}
font-size: var(--font-size-small);
line-height: var(--line-height-large);
gap: 8px;
/* text-12-medium */
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
font-style: normal;
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large); /* 166.667% */
letter-spacing: var(--letter-spacing-normal);
}
&[data-size="normal"] {
height: 28px;
line-height: 28px;
padding: 0 6px;
&[data-icon] {
padding: 0 12px 0 4px;
}
font-size: var(--font-size-small);
gap: 8px;
/* text-12-medium */
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
font-style: normal;
font-weight: var(--font-weight-medium);
letter-spacing: var(--letter-spacing-normal);
}
&[data-size="large"] {
height: 32px;
padding: 6px 12px;
&[data-icon] {
padding: 0 12px 0 8px;
}
gap: 8px;
/* text-14-medium */
font-family: var(--font-family-sans);
font-size: 14px;
font-style: normal;
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large); /* 142.857% */
letter-spacing: var(--letter-spacing-normal);
}
&:focus {
outline: none;
}
}
[data-component="button"].titlebar-icon[data-variant="ghost"][aria-expanded="true"] {
background-color: var(--surface-base-active);
}
[data-component="button"].titlebar-icon[data-variant="ghost"][aria-expanded="true"] [data-slot="icon-svg"] {
color: var(--icon-strong-base);
}
[data-component="button"].titlebar-icon[data-variant="ghost"][aria-expanded="true"]:hover:not(:disabled) {
background-color: var(--surface-base-active);
}
[data-component="button"].titlebar-icon[data-variant="ghost"][aria-current="page"] {
background-color: var(--surface-base-active);
}
[data-component="button"].titlebar-icon[data-variant="ghost"][aria-current="page"] [data-slot="icon-svg"] {
color: var(--icon-strong-base);
}
[data-component="button"].titlebar-icon[data-variant="ghost"][aria-current="page"]:hover:not(:disabled) {
background-color: var(--surface-base-active);
}

View File

@@ -0,0 +1,108 @@
// @ts-nocheck
import { Button } from "./button"
const docs = `### Overview
Primary action button with size, variant, and optional icon support.
Use \`IconButton\` for icon-only actions.
### API
- \`variant\`: "primary" | "secondary" | "ghost".
- \`size\`: "small" | "normal" | "large".
- \`icon\`: Icon name for a leading icon.
- Inherits Kobalte Button props and native button attributes.
### Variants and states
- Variants: primary, secondary, ghost.
- States: disabled.
### Behavior
- Renders an Icon when \`icon\` is set.
### Accessibility
- Provide clear label text; use \`aria-label\` for icon-only buttons.
### Theming/tokens
- Uses \`data-component="button"\` with size/variant data attributes.
`
export default {
title: "UI/Button",
id: "components-button",
component: Button,
tags: ["autodocs"],
parameters: {
docs: {
description: {
component: docs,
},
},
},
args: {
children: "Button",
variant: "secondary",
size: "normal",
},
argTypes: {
variant: {
control: "select",
options: ["primary", "secondary", "ghost"],
},
size: {
control: "select",
options: ["small", "normal", "large"],
},
icon: {
control: "select",
options: ["none", "check", "plus", "arrow-right"],
mapping: {
none: undefined,
},
},
},
}
export const Primary = {
args: {
variant: "primary",
},
}
export const Secondary = {}
export const Ghost = {
args: {
variant: "ghost",
},
}
export const WithIcon = {
args: {
children: "Continue",
icon: "arrow-right",
},
}
export const Disabled = {
args: {
variant: "primary",
disabled: true,
},
}
export const Sizes = {
render: () => (
<div style={{ display: "flex", gap: "12px", "align-items": "center" }}>
<Button size="small" variant="secondary">
Small
</Button>
<Button size="normal" variant="secondary">
Normal
</Button>
<Button size="large" variant="secondary">
Large
</Button>
</div>
),
}

View File

@@ -0,0 +1,33 @@
import { Button as Kobalte } from "@kobalte/core/button"
import { type ComponentProps, Show, splitProps } from "solid-js"
import { Icon, IconProps } from "./icon"
export interface ButtonProps
extends ComponentProps<typeof Kobalte>,
Pick<ComponentProps<"button">, "class" | "classList" | "children"> {
size?: "small" | "normal" | "large"
variant?: "primary" | "secondary" | "ghost"
icon?: IconProps["name"]
}
export function Button(props: ButtonProps) {
const [split, rest] = splitProps(props, ["variant", "size", "icon", "class", "classList"])
return (
<Kobalte
{...rest}
data-component="button"
data-size={split.size || "normal"}
data-variant={split.variant || "secondary"}
data-icon={split.icon}
classList={{
...split.classList,
[split.class ?? ""]: !!split.class,
}}
>
<Show when={split.icon}>
<Icon name={split.icon!} size="small" />
</Show>
{props.children}
</Kobalte>
)
}

View File

@@ -0,0 +1,94 @@
[data-component="card"] {
--card-pad-y: 10px;
--card-pad-r: 12px;
--card-pad-l: 10px;
width: 100%;
display: flex;
flex-direction: column;
position: relative;
background: transparent;
border: none;
border-radius: var(--radius-md);
padding: var(--card-pad-y) var(--card-pad-r) var(--card-pad-y) var(--card-pad-l);
/* text-14-regular */
font-family: var(--font-family-sans);
font-size: var(--font-size-base);
font-style: normal;
font-weight: var(--font-weight-regular);
line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal);
color: var(--text-strong);
--card-gap: 8px;
--card-icon: 16px;
--card-indent: 0px;
--card-line-pad: 8px;
--card-accent: var(--icon-active);
&:has([data-slot="card-title"]) {
gap: 8px;
}
&:has([data-slot="card-title-icon"]) {
--card-indent: calc(var(--card-icon) + var(--card-gap));
}
&::before {
content: "";
position: absolute;
left: 0;
top: var(--card-line-pad);
bottom: var(--card-line-pad);
width: 2px;
border-radius: 2px;
background-color: var(--card-accent);
}
:where([data-card="title"], [data-slot="card-title"]) {
color: var(--text-strong);
font-weight: var(--font-weight-medium);
}
:where([data-slot="card-title"]) {
display: flex;
align-items: center;
gap: var(--card-gap);
}
:where([data-slot="card-title"]) [data-component="icon"] {
color: var(--card-accent);
}
:where([data-slot="card-title-icon"]) {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--card-icon);
height: var(--card-icon);
flex: 0 0 auto;
}
:where([data-slot="card-title-icon"][data-placeholder]) [data-component="icon"] {
color: var(--text-weak);
}
:where([data-slot="card-title-icon"])
[data-slot="icon-svg"]
:is(path, line, polyline, polygon, rect, circle, ellipse)[stroke] {
stroke-width: 1.5px !important;
}
:where([data-card="description"], [data-slot="card-description"]) {
color: var(--text-base);
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
}
:where([data-card="actions"], [data-slot="card-actions"]) {
padding-left: var(--card-indent);
}
}

View File

@@ -0,0 +1,88 @@
// @ts-nocheck
import { Card, CardActions, CardDescription, CardTitle } from "./card"
import { Button } from "./button"
const docs = `### Overview
Surface container for grouping related content and actions.
Pair with \`Button\` or \`Tag\` for quick actions.
### API
- Optional: \`variant\` (normal, error, warning, success, info).
- Accepts standard div props.
### Variants and states
- Semantic variants for status-driven messaging.
### Behavior
- Pure presentational container.
### Accessibility
- Provide headings or aria labels when used in isolation.
### Theming/tokens
- Uses \`data-component="card"\` with variant data attributes.
`
export default {
title: "UI/Card",
id: "components-card",
component: Card,
tags: ["autodocs"],
parameters: {
docs: {
description: {
component: docs,
},
},
},
args: {
variant: "normal",
},
argTypes: {
variant: {
control: "select",
options: ["normal", "error", "warning", "success", "info"],
},
},
render: (props: { variant?: "normal" | "error" | "warning" | "success" | "info" }) => {
return (
<Card variant={props.variant}>
<CardTitle variant={props.variant}>Card title</CardTitle>
<CardDescription>Small supporting text.</CardDescription>
<CardActions>
<Button size="small" variant="secondary">
Action
</Button>
</CardActions>
</Card>
)
},
}
export const Normal = {}
export const Error = {
args: {
variant: "error",
},
}
export const Warning = {
args: {
variant: "warning",
},
}
export const Success = {
args: {
variant: "success",
},
}
export const Info = {
args: {
variant: "info",
},
}

View File

@@ -0,0 +1,123 @@
import { type ComponentProps, splitProps } from "solid-js"
import { Icon, type IconProps } from "./icon"
type Variant = "normal" | "error" | "warning" | "success" | "info"
export interface CardProps extends ComponentProps<"div"> {
variant?: Variant
}
export interface CardTitleProps extends ComponentProps<"div"> {
variant?: Variant
/**
* Optional title icon.
*
* - `undefined`: picks a default icon based on `variant` (error/warning/success/info)
* - `false`/`null`: disables the icon
* - `Icon` name: forces a specific icon
*/
icon?: IconProps["name"] | false | null
}
function pick(variant: Variant) {
if (variant === "error") return "circle-ban-sign" as const
if (variant === "warning") return "warning" as const
if (variant === "success") return "circle-check" as const
if (variant === "info") return "help" as const
return
}
function mix(style: ComponentProps<"div">["style"], value?: string) {
if (!value) return style
if (!style) return { "--card-accent": value }
if (typeof style === "string") return `${style};--card-accent:${value};`
return { ...(style as Record<string, string | number>), "--card-accent": value }
}
export function Card(props: CardProps) {
const [split, rest] = splitProps(props, ["variant", "style", "class", "classList"])
const variant = () => split.variant ?? "normal"
const accent = () => {
const v = variant()
if (v === "error") return "var(--icon-critical-base)"
if (v === "warning") return "var(--icon-warning-active)"
if (v === "success") return "var(--icon-success-active)"
if (v === "info") return "var(--icon-info-active)"
return
}
return (
<div
{...rest}
data-component="card"
data-variant={variant()}
style={mix(split.style, accent())}
classList={{
...split.classList,
[split.class ?? ""]: !!split.class,
}}
>
{props.children}
</div>
)
}
export function CardTitle(props: CardTitleProps) {
const [split, rest] = splitProps(props, ["variant", "icon", "class", "classList", "children"])
const show = () => split.icon !== false && split.icon !== null
const name = () => {
if (split.icon === false || split.icon === null) return
if (typeof split.icon === "string") return split.icon
return pick(split.variant ?? "normal")
}
const placeholder = () => !name()
return (
<div
{...rest}
data-slot="card-title"
classList={{
...split.classList,
[split.class ?? ""]: !!split.class,
}}
>
{show() ? (
<span data-slot="card-title-icon" data-placeholder={placeholder() || undefined}>
<Icon name={name() ?? "dash"} size="small" />
</span>
) : null}
{split.children}
</div>
)
}
export function CardDescription(props: ComponentProps<"div">) {
const [split, rest] = splitProps(props, ["class", "classList", "children"])
return (
<div
{...rest}
data-slot="card-description"
classList={{
...split.classList,
[split.class ?? ""]: !!split.class,
}}
>
{split.children}
</div>
)
}
export function CardActions(props: ComponentProps<"div">) {
const [split, rest] = splitProps(props, ["class", "classList", "children"])
return (
<div
{...rest}
data-slot="card-actions"
classList={{
...split.classList,
[split.class ?? ""]: !!split.class,
}}
>
{split.children}
</div>
)
}

View File

@@ -0,0 +1,131 @@
[data-component="checkbox"] {
display: flex;
align-items: var(--checkbox-align, center);
gap: 12px;
cursor: default;
[data-slot="checkbox-checkbox-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-checkbox-control"] {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
padding: 2px;
margin-top: var(--checkbox-offset, 0px);
aspect-ratio: 1;
flex-shrink: 0;
border-radius: var(--radius-sm);
border: 1px solid var(--border-weak-base);
transition:
border-color 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)),
background-color 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)),
box-shadow 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
/* background-color: var(--surface-weak); */
}
[data-slot="checkbox-checkbox-indicator"] {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: var(--icon-base);
opacity: 0;
transform: scale(0.9);
transition:
opacity 180ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)),
transform 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
}
/* [data-slot="checkbox-checkbox-content"] { */
/* } */
[data-slot="checkbox-checkbox-label"] {
user-select: none;
color: var(--text-base);
/* text-12-regular */
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
font-style: normal;
font-weight: var(--font-weight-regular);
line-height: var(--line-height-large); /* 166.667% */
letter-spacing: var(--letter-spacing-normal);
}
[data-slot="checkbox-checkbox-description"] {
color: var(--text-base);
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-checkbox-error"] {
color: var(--text-error);
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);
}
&:hover:not([data-disabled], [data-readonly]) [data-slot="checkbox-checkbox-control"] {
border-color: var(--border-hover);
background-color: var(--surface-hover);
}
&:not([data-readonly]) [data-slot="checkbox-checkbox-input"]:focus-visible + [data-slot="checkbox-checkbox-control"] {
border-color: var(--border-focus);
box-shadow: var(--shadow-xs-border-focus);
}
&[data-checked] [data-slot="checkbox-checkbox-control"],
&[data-indeterminate] [data-slot="checkbox-checkbox-control"] {
border-color: var(--border-base);
background-color: var(--surface-weak);
}
&[data-checked]:hover:not([data-disabled], [data-readonly]) [data-slot="checkbox-checkbox-control"],
&[data-indeterminate]:hover:not([data-disabled]) [data-slot="checkbox-checkbox-control"] {
border-color: var(--border-hover);
background-color: var(--surface-hover);
}
&[data-checked] [data-slot="checkbox-checkbox-indicator"],
&[data-indeterminate] [data-slot="checkbox-checkbox-indicator"] {
opacity: 1;
transform: scale(1);
}
&[data-disabled] {
cursor: not-allowed;
}
&[data-disabled] [data-slot="checkbox-checkbox-control"] {
border-color: var(--border-disabled);
background-color: var(--surface-disabled);
}
&[data-invalid] [data-slot="checkbox-checkbox-control"] {
border-color: var(--border-error);
}
&[data-readonly] {
cursor: default;
pointer-events: none;
}
}

View File

@@ -0,0 +1,71 @@
// @ts-nocheck
import { Icon } from "./icon"
import * as mod from "./checkbox"
import { create } from "../storybook/scaffold"
const docs = `### Overview
Checkbox control for multi-select or agreement inputs.
Use in forms and multi-select lists.
### API
- Uses Kobalte Checkbox props (\`checked\`, \`defaultChecked\`, \`onChange\`).
- Optional: \`hideLabel\`, \`description\`, \`icon\`.
- Children render as the label.
### Variants and states
- Checked/unchecked, indeterminate, disabled (via Kobalte).
### Behavior
- Controlled or uncontrolled usage.
### Accessibility
- TODO: confirm aria attributes from Kobalte.
### Theming/tokens
- Uses \`data-component="checkbox"\` and related slots.
`
const story = create({ title: "UI/Checkbox", mod, args: { children: "Checkbox", defaultChecked: true } })
export default {
title: "UI/Checkbox",
id: "components-checkbox",
component: story.meta.component,
tags: ["autodocs"],
parameters: {
docs: {
description: {
component: docs,
},
},
},
}
export const Basic = story.Basic
export const States = {
render: () => (
<div style={{ display: "grid", gap: "12px" }}>
<mod.Checkbox defaultChecked>Checked</mod.Checkbox>
<mod.Checkbox>Unchecked</mod.Checkbox>
<mod.Checkbox disabled>Disabled</mod.Checkbox>
<mod.Checkbox description="Helper text">With description</mod.Checkbox>
</div>
),
}
export const CustomIcon = {
render: () => (
<mod.Checkbox icon={<Icon name="check" size="small" />} defaultChecked>
Custom icon
</mod.Checkbox>
),
}
export const HiddenLabel = {
args: {
children: "Hidden label",
hideLabel: true,
},
}

View File

@@ -0,0 +1,43 @@
import { Checkbox as Kobalte } from "@kobalte/core/checkbox"
import { Show, splitProps } from "solid-js"
import type { ComponentProps, JSX, ParentProps } from "solid-js"
export interface CheckboxProps extends ParentProps<ComponentProps<typeof Kobalte>> {
hideLabel?: boolean
description?: string
icon?: JSX.Element
}
export function Checkbox(props: CheckboxProps) {
const [local, others] = splitProps(props, ["children", "class", "label", "hideLabel", "description", "icon"])
return (
<Kobalte {...others} data-component="checkbox">
<Kobalte.Input data-slot="checkbox-checkbox-input" />
<Kobalte.Control data-slot="checkbox-checkbox-control">
<Kobalte.Indicator data-slot="checkbox-checkbox-indicator">
{local.icon || (
<svg viewBox="0 0 12 12" fill="none" width="10" height="10" xmlns="http://www.w3.org/2000/svg">
<path
d="M3 7.17905L5.02703 8.85135L9 3.5"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="square"
/>
</svg>
)}
</Kobalte.Indicator>
</Kobalte.Control>
<div data-slot="checkbox-checkbox-content">
<Show when={props.children}>
<Kobalte.Label data-slot="checkbox-checkbox-label" classList={{ "sr-only": local.hideLabel }}>
{props.children}
</Kobalte.Label>
</Show>
<Show when={local.description}>
<Kobalte.Description data-slot="checkbox-checkbox-description">{local.description}</Kobalte.Description>
</Show>
<Kobalte.ErrorMessage data-slot="checkbox-checkbox-error" />
</div>
</Kobalte>
)
}

View File

@@ -0,0 +1,148 @@
[data-component="collapsible"] {
width: 100%;
display: flex;
flex-direction: column;
background-color: transparent;
border: none;
transition: background-color 0.15s ease;
border-radius: var(--radius-md);
overflow: visible;
&.tool-collapsible {
--tool-content-gap: 4px;
gap: var(--tool-content-gap);
}
[data-slot="collapsible-trigger"] {
width: 100%;
display: flex;
height: 32px;
padding: 0;
align-items: center;
align-self: stretch;
cursor: default;
user-select: none;
color: var(--text-base);
[data-slot="collapsible-arrow"] {
opacity: 0;
transition: opacity 0.15s ease;
will-change: opacity;
transform: translateZ(0);
}
[data-slot="collapsible-arrow-icon"] {
display: inline-flex;
color: var(--icon-weaker);
transform: translateZ(0) rotate(-90deg);
transition: transform 0.15s ease;
will-change: transform;
}
&:hover [data-slot="collapsible-arrow"] {
opacity: 1;
}
/* text-12-medium */
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
font-style: normal;
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large); /* 166.667% */
letter-spacing: var(--letter-spacing-normal);
/* &:hover { */
/* background-color: var(--surface-base); */
/* } */
&:focus-visible {
outline: none;
background-color: var(--surface-raised-base-hover);
}
&[data-disabled] {
cursor: not-allowed;
}
&[data-hide-details="true"] {
height: auto;
align-items: stretch;
}
[data-slot="collapsible-arrow"] {
flex-shrink: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
}
[data-slot="collapsible-trigger"][aria-expanded="true"] {
[data-slot="collapsible-arrow"] {
opacity: 1;
}
[data-slot="collapsible-arrow-icon"] {
transform: translateZ(0) rotate(0deg);
}
}
[data-slot="collapsible-content"] {
overflow: hidden;
/* animation: slideUp 250ms ease-out; */
&[data-expanded] {
overflow: visible;
}
/* &[data-expanded] { */
/* animation: slideDown 250ms ease-out; */
/* } */
}
&[data-variant="ghost"] {
background-color: transparent;
border: none;
> [data-slot="collapsible-trigger"] {
background-color: transparent;
border: none;
padding: 0;
/* &:hover { */
/* color: var(--text-strong); */
/* } */
&:focus-visible {
outline: none;
background-color: var(--surface-raised-base-hover);
}
&[data-disabled] {
cursor: not-allowed;
}
}
}
&[data-variant="ghost"][data-scope="filetree"] {
> [data-slot="collapsible-trigger"] {
height: 24px;
}
}
}
@keyframes slideDown {
from {
height: 0;
}
to {
height: var(--kb-collapsible-content-height);
}
}
@keyframes slideUp {
from {
height: var(--kb-collapsible-content-height);
}
to {
height: 0;
}
}

View File

@@ -0,0 +1,86 @@
// @ts-nocheck
import * as mod from "./collapsible"
const docs = `### Overview
Toggleable content region with optional arrow indicator.
Compose \`Collapsible.Trigger\`, \`Collapsible.Content\`, and \`Collapsible.Arrow\`.
### API
- Root accepts Kobalte Collapsible props (\`open\`, \`defaultOpen\`, \`onOpenChange\`).
- \`variant\` controls styling ("normal" | "ghost").
### Variants and states
- Normal and ghost variants.
- Open/closed states.
### Behavior
- Trigger toggles the content visibility.
### Accessibility
- TODO: confirm ARIA attributes provided by Kobalte.
### Theming/tokens
- Uses \`data-component="collapsible"\` and slots for trigger/content/arrow.
`
export default {
title: "UI/Collapsible",
id: "components-collapsible",
component: mod.Collapsible,
tags: ["autodocs"],
parameters: {
docs: {
description: {
component: docs,
},
},
},
argTypes: {
variant: {
control: "select",
options: ["normal", "ghost"],
},
},
}
export const Basic = {
args: {
variant: "normal",
defaultOpen: true,
},
render: (props) => (
<mod.Collapsible {...props}>
<mod.Collapsible.Trigger data-slot="collapsible-trigger">
<div style={{ display: "flex", "align-items": "center", gap: "8px" }}>
<span>Details</span>
<mod.Collapsible.Arrow />
</div>
</mod.Collapsible.Trigger>
<mod.Collapsible.Content data-slot="collapsible-content">
<div style={{ color: "var(--text-weak)", "padding-top": "8px" }}>Optional details sit here.</div>
</mod.Collapsible.Content>
</mod.Collapsible>
),
}
export const Ghost = {
args: {
variant: "ghost",
defaultOpen: false,
},
render: (props) => (
<mod.Collapsible {...props}>
<mod.Collapsible.Trigger data-slot="collapsible-trigger">
<div style={{ display: "flex", "align-items": "center", gap: "8px" }}>
<span>Ghost trigger</span>
<mod.Collapsible.Arrow />
</div>
</mod.Collapsible.Trigger>
<mod.Collapsible.Content data-slot="collapsible-content">
<div style={{ color: "var(--text-weak)", "padding-top": "8px" }}>Ghost content.</div>
</mod.Collapsible.Content>
</mod.Collapsible>
),
}

View File

@@ -0,0 +1,48 @@
import { Collapsible as Kobalte, CollapsibleRootProps } from "@kobalte/core/collapsible"
import { ComponentProps, ParentProps, splitProps } from "solid-js"
import { Icon } from "./icon"
export interface CollapsibleProps extends ParentProps<CollapsibleRootProps> {
class?: string
classList?: ComponentProps<"div">["classList"]
variant?: "normal" | "ghost"
}
function CollapsibleRoot(props: CollapsibleProps) {
const [local, others] = splitProps(props, ["class", "classList", "variant"])
return (
<Kobalte
data-component="collapsible"
data-variant={local.variant || "normal"}
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
{...others}
/>
)
}
function CollapsibleTrigger(props: ComponentProps<typeof Kobalte.Trigger>) {
return <Kobalte.Trigger data-slot="collapsible-trigger" {...props} />
}
function CollapsibleContent(props: ComponentProps<typeof Kobalte.Content>) {
return <Kobalte.Content data-slot="collapsible-content" {...props} />
}
function CollapsibleArrow(props?: ComponentProps<"div">) {
return (
<div data-slot="collapsible-arrow" {...(props || {})}>
<span data-slot="collapsible-arrow-icon">
<Icon name="chevron-down" size="small" />
</span>
</div>
)
}
export const Collapsible = Object.assign(CollapsibleRoot, {
Arrow: CollapsibleArrow,
Trigger: CollapsibleTrigger,
Content: CollapsibleContent,
})

View File

@@ -0,0 +1,134 @@
[data-component="context-menu-content"],
[data-component="context-menu-sub-content"] {
min-width: 8rem;
overflow: hidden;
border: none;
border-radius: var(--radius-md);
box-shadow: var(--shadow-xs-border);
background-clip: padding-box;
background-color: var(--surface-raised-stronger-non-alpha);
padding: 4px;
z-index: 100;
transform-origin: var(--kb-menu-content-transform-origin);
&:focus-within,
&:focus {
outline: none;
}
animation: contextMenuContentHide var(--transition-duration) var(--transition-easing) forwards;
@starting-style {
animation: none;
}
&[data-expanded] {
pointer-events: auto;
animation: contextMenuContentShow var(--transition-duration) var(--transition-easing) forwards;
}
}
[data-component="context-menu-content"],
[data-component="context-menu-sub-content"] {
[data-slot="context-menu-item"],
[data-slot="context-menu-checkbox-item"],
[data-slot="context-menu-radio-item"],
[data-slot="context-menu-sub-trigger"] {
position: relative;
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
border-radius: var(--radius-sm);
cursor: default;
outline: none;
font-family: var(--font-family-sans);
font-size: var(--font-size-base);
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal);
color: var(--text-strong);
transition-property: background-color, color;
transition-duration: var(--transition-duration);
transition-timing-function: var(--transition-easing);
user-select: none;
&:hover {
background-color: var(--surface-raised-base-hover);
}
&[data-disabled] {
color: var(--text-weak);
pointer-events: none;
}
}
[data-slot="context-menu-sub-trigger"] {
&[data-expanded] {
background: var(--surface-raised-base-hover);
outline: none;
border: none;
}
}
[data-slot="context-menu-item-indicator"] {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
}
[data-slot="context-menu-item-label"] {
flex: 1;
}
[data-slot="context-menu-item-description"] {
font-size: var(--font-size-x-small);
color: var(--text-weak);
}
[data-slot="context-menu-separator"] {
height: 1px;
margin: 4px -4px;
border-top-color: var(--border-weak-base);
}
[data-slot="context-menu-group-label"] {
padding: 4px 8px;
font-family: var(--font-family-sans);
font-size: var(--font-size-x-small);
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal);
color: var(--text-weak);
}
[data-slot="context-menu-arrow"] {
fill: var(--surface-raised-stronger-non-alpha);
}
}
@keyframes contextMenuContentShow {
from {
opacity: 0;
transform: scaleY(0.95);
}
to {
opacity: 1;
transform: scaleY(1);
}
}
@keyframes contextMenuContentHide {
from {
opacity: 1;
transform: scaleY(1);
}
to {
opacity: 0;
transform: scaleY(0.95);
}
}

View File

@@ -0,0 +1,113 @@
// @ts-nocheck
import * as mod from "./context-menu"
const docs = `### Overview
Context menu for right-click interactions with composable items and submenus.
Use \`ItemLabel\` and \`ItemDescription\` for rich items.
### API
- Root accepts Kobalte ContextMenu props (\`open\`, \`defaultOpen\`, \`onOpenChange\`).
- Compose \`Trigger\`, \`Content\`, \`Item\`, \`Separator\`, and optional \`Sub\` sections.
### Variants and states
- Supports grouped sections and nested submenus.
### Behavior
- Opens on context menu gesture over the trigger element.
### Accessibility
- TODO: confirm keyboard and focus behavior from Kobalte.
### Theming/tokens
- Uses \`data-component="context-menu"\` and slot attributes for styling.
`
export default {
title: "UI/ContextMenu",
id: "components-context-menu",
component: mod.ContextMenu,
tags: ["autodocs"],
parameters: {
docs: {
description: {
component: docs,
},
},
},
}
export const Basic = {
render: () => (
<mod.ContextMenu defaultOpen>
<mod.ContextMenu.Trigger>
<div
style={{
padding: "20px",
border: "1px dashed var(--border-weak)",
"border-radius": "8px",
color: "var(--text-weak)",
}}
>
Right click (or open) here
</div>
</mod.ContextMenu.Trigger>
<mod.ContextMenu.Portal>
<mod.ContextMenu.Content>
<mod.ContextMenu.Group>
<mod.ContextMenu.GroupLabel>Actions</mod.ContextMenu.GroupLabel>
<mod.ContextMenu.Item>
<mod.ContextMenu.ItemLabel>Copy</mod.ContextMenu.ItemLabel>
</mod.ContextMenu.Item>
<mod.ContextMenu.Item>
<mod.ContextMenu.ItemLabel>Paste</mod.ContextMenu.ItemLabel>
</mod.ContextMenu.Item>
</mod.ContextMenu.Group>
<mod.ContextMenu.Separator />
<mod.ContextMenu.Sub>
<mod.ContextMenu.SubTrigger>More</mod.ContextMenu.SubTrigger>
<mod.ContextMenu.SubContent>
<mod.ContextMenu.Item>
<mod.ContextMenu.ItemLabel>Duplicate</mod.ContextMenu.ItemLabel>
</mod.ContextMenu.Item>
<mod.ContextMenu.Item>
<mod.ContextMenu.ItemLabel>Move</mod.ContextMenu.ItemLabel>
</mod.ContextMenu.Item>
</mod.ContextMenu.SubContent>
</mod.ContextMenu.Sub>
</mod.ContextMenu.Content>
</mod.ContextMenu.Portal>
</mod.ContextMenu>
),
}
export const CheckboxRadio = {
render: () => (
<mod.ContextMenu defaultOpen>
<mod.ContextMenu.Trigger>
<div
style={{
padding: "20px",
border: "1px dashed var(--border-weak)",
"border-radius": "8px",
color: "var(--text-weak)",
}}
>
Right click (or open) here
</div>
</mod.ContextMenu.Trigger>
<mod.ContextMenu.Portal>
<mod.ContextMenu.Content>
<mod.ContextMenu.CheckboxItem checked>Show line numbers</mod.ContextMenu.CheckboxItem>
<mod.ContextMenu.CheckboxItem>Wrap lines</mod.ContextMenu.CheckboxItem>
<mod.ContextMenu.Separator />
<mod.ContextMenu.RadioGroup value="compact">
<mod.ContextMenu.RadioItem value="compact">Compact</mod.ContextMenu.RadioItem>
<mod.ContextMenu.RadioItem value="comfortable">Comfortable</mod.ContextMenu.RadioItem>
</mod.ContextMenu.RadioGroup>
</mod.ContextMenu.Content>
</mod.ContextMenu.Portal>
</mod.ContextMenu>
),
}

View File

@@ -0,0 +1,308 @@
import { ContextMenu as Kobalte } from "@kobalte/core/context-menu"
import { splitProps } from "solid-js"
import type { ComponentProps, ParentProps } from "solid-js"
export interface ContextMenuProps extends ComponentProps<typeof Kobalte> {}
export interface ContextMenuTriggerProps extends ComponentProps<typeof Kobalte.Trigger> {}
export interface ContextMenuIconProps extends ComponentProps<typeof Kobalte.Icon> {}
export interface ContextMenuPortalProps extends ComponentProps<typeof Kobalte.Portal> {}
export interface ContextMenuContentProps extends ComponentProps<typeof Kobalte.Content> {}
export interface ContextMenuArrowProps extends ComponentProps<typeof Kobalte.Arrow> {}
export interface ContextMenuSeparatorProps extends ComponentProps<typeof Kobalte.Separator> {}
export interface ContextMenuGroupProps extends ComponentProps<typeof Kobalte.Group> {}
export interface ContextMenuGroupLabelProps extends ComponentProps<typeof Kobalte.GroupLabel> {}
export interface ContextMenuItemProps extends ComponentProps<typeof Kobalte.Item> {}
export interface ContextMenuItemLabelProps extends ComponentProps<typeof Kobalte.ItemLabel> {}
export interface ContextMenuItemDescriptionProps extends ComponentProps<typeof Kobalte.ItemDescription> {}
export interface ContextMenuItemIndicatorProps extends ComponentProps<typeof Kobalte.ItemIndicator> {}
export interface ContextMenuRadioGroupProps extends ComponentProps<typeof Kobalte.RadioGroup> {}
export interface ContextMenuRadioItemProps extends ComponentProps<typeof Kobalte.RadioItem> {}
export interface ContextMenuCheckboxItemProps extends ComponentProps<typeof Kobalte.CheckboxItem> {}
export interface ContextMenuSubProps extends ComponentProps<typeof Kobalte.Sub> {}
export interface ContextMenuSubTriggerProps extends ComponentProps<typeof Kobalte.SubTrigger> {}
export interface ContextMenuSubContentProps extends ComponentProps<typeof Kobalte.SubContent> {}
function ContextMenuRoot(props: ContextMenuProps) {
return <Kobalte {...props} data-component="context-menu" />
}
function ContextMenuTrigger(props: ParentProps<ContextMenuTriggerProps>) {
const [local, rest] = splitProps(props, ["class", "classList", "children"])
return (
<Kobalte.Trigger
{...rest}
data-slot="context-menu-trigger"
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
>
{local.children}
</Kobalte.Trigger>
)
}
function ContextMenuIcon(props: ParentProps<ContextMenuIconProps>) {
const [local, rest] = splitProps(props, ["class", "classList", "children"])
return (
<Kobalte.Icon
{...rest}
data-slot="context-menu-icon"
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
>
{local.children}
</Kobalte.Icon>
)
}
function ContextMenuPortal(props: ContextMenuPortalProps) {
return <Kobalte.Portal {...props} />
}
function ContextMenuContent(props: ParentProps<ContextMenuContentProps>) {
const [local, rest] = splitProps(props, ["class", "classList", "children"])
return (
<Kobalte.Content
{...rest}
data-component="context-menu-content"
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
>
{local.children}
</Kobalte.Content>
)
}
function ContextMenuArrow(props: ContextMenuArrowProps) {
const [local, rest] = splitProps(props, ["class", "classList"])
return (
<Kobalte.Arrow
{...rest}
data-slot="context-menu-arrow"
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
/>
)
}
function ContextMenuSeparator(props: ContextMenuSeparatorProps) {
const [local, rest] = splitProps(props, ["class", "classList"])
return (
<Kobalte.Separator
{...rest}
data-slot="context-menu-separator"
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
/>
)
}
function ContextMenuGroup(props: ParentProps<ContextMenuGroupProps>) {
const [local, rest] = splitProps(props, ["class", "classList", "children"])
return (
<Kobalte.Group
{...rest}
data-slot="context-menu-group"
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
>
{local.children}
</Kobalte.Group>
)
}
function ContextMenuGroupLabel(props: ParentProps<ContextMenuGroupLabelProps>) {
const [local, rest] = splitProps(props, ["class", "classList", "children"])
return (
<Kobalte.GroupLabel
{...rest}
data-slot="context-menu-group-label"
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
>
{local.children}
</Kobalte.GroupLabel>
)
}
function ContextMenuItem(props: ParentProps<ContextMenuItemProps>) {
const [local, rest] = splitProps(props, ["class", "classList", "children"])
return (
<Kobalte.Item
{...rest}
data-slot="context-menu-item"
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
>
{local.children}
</Kobalte.Item>
)
}
function ContextMenuItemLabel(props: ParentProps<ContextMenuItemLabelProps>) {
const [local, rest] = splitProps(props, ["class", "classList", "children"])
return (
<Kobalte.ItemLabel
{...rest}
data-slot="context-menu-item-label"
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
>
{local.children}
</Kobalte.ItemLabel>
)
}
function ContextMenuItemDescription(props: ParentProps<ContextMenuItemDescriptionProps>) {
const [local, rest] = splitProps(props, ["class", "classList", "children"])
return (
<Kobalte.ItemDescription
{...rest}
data-slot="context-menu-item-description"
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
>
{local.children}
</Kobalte.ItemDescription>
)
}
function ContextMenuItemIndicator(props: ParentProps<ContextMenuItemIndicatorProps>) {
const [local, rest] = splitProps(props, ["class", "classList", "children"])
return (
<Kobalte.ItemIndicator
{...rest}
data-slot="context-menu-item-indicator"
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
>
{local.children}
</Kobalte.ItemIndicator>
)
}
function ContextMenuRadioGroup(props: ParentProps<ContextMenuRadioGroupProps>) {
const [local, rest] = splitProps(props, ["class", "classList", "children"])
return (
<Kobalte.RadioGroup
{...rest}
data-slot="context-menu-radio-group"
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
>
{local.children}
</Kobalte.RadioGroup>
)
}
function ContextMenuRadioItem(props: ParentProps<ContextMenuRadioItemProps>) {
const [local, rest] = splitProps(props, ["class", "classList", "children"])
return (
<Kobalte.RadioItem
{...rest}
data-slot="context-menu-radio-item"
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
>
{local.children}
</Kobalte.RadioItem>
)
}
function ContextMenuCheckboxItem(props: ParentProps<ContextMenuCheckboxItemProps>) {
const [local, rest] = splitProps(props, ["class", "classList", "children"])
return (
<Kobalte.CheckboxItem
{...rest}
data-slot="context-menu-checkbox-item"
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
>
{local.children}
</Kobalte.CheckboxItem>
)
}
function ContextMenuSub(props: ContextMenuSubProps) {
return <Kobalte.Sub {...props} />
}
function ContextMenuSubTrigger(props: ParentProps<ContextMenuSubTriggerProps>) {
const [local, rest] = splitProps(props, ["class", "classList", "children"])
return (
<Kobalte.SubTrigger
{...rest}
data-slot="context-menu-sub-trigger"
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
>
{local.children}
</Kobalte.SubTrigger>
)
}
function ContextMenuSubContent(props: ParentProps<ContextMenuSubContentProps>) {
const [local, rest] = splitProps(props, ["class", "classList", "children"])
return (
<Kobalte.SubContent
{...rest}
data-component="context-menu-sub-content"
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
>
{local.children}
</Kobalte.SubContent>
)
}
export const ContextMenu = Object.assign(ContextMenuRoot, {
Trigger: ContextMenuTrigger,
Icon: ContextMenuIcon,
Portal: ContextMenuPortal,
Content: ContextMenuContent,
Arrow: ContextMenuArrow,
Separator: ContextMenuSeparator,
Group: ContextMenuGroup,
GroupLabel: ContextMenuGroupLabel,
Item: ContextMenuItem,
ItemLabel: ContextMenuItemLabel,
ItemDescription: ContextMenuItemDescription,
ItemIndicator: ContextMenuItemIndicator,
RadioGroup: ContextMenuRadioGroup,
RadioItem: ContextMenuRadioItem,
CheckboxItem: ContextMenuCheckboxItem,
Sub: ContextMenuSub,
SubTrigger: ContextMenuSubTrigger,
SubContent: ContextMenuSubContent,
})

View File

@@ -0,0 +1,181 @@
/* [data-component="dialog-trigger"] { } */
[data-component="dialog-overlay"] {
position: fixed;
inset: 0;
z-index: 50;
background-color: hsl(from var(--background-base) h s l / 0.2);
}
[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"] {
position: relative;
z-index: 50;
width: min(calc(100vw - 16px), 640px);
height: min(calc(100vh - 16px), 512px);
display: flex;
flex-direction: column;
align-items: center;
justify-items: start;
overflow: visible;
[data-slot="dialog-content"] {
display: flex;
flex-direction: column;
align-items: flex-start;
align-self: stretch;
width: 100%;
max-height: 100%;
min-height: 280px;
overflow: auto;
pointer-events: auto;
/* Hide scrollbar */
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
/* padding: 8px; */
/* padding: 8px 8px 0 8px; */
border-radius: var(--radius-xl);
background: var(--surface-raised-stronger-non-alpha);
background-clip: padding-box;
box-shadow: var(--shadow-lg-border-base);
[data-slot="dialog-header"] {
display: flex;
padding: 16px 20px;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
align-self: stretch;
[data-slot="dialog-title"] {
color: var(--text-strong);
/* text-16-medium */
font-family: var(--font-family-sans);
font-size: var(--font-size-large);
font-style: normal;
font-weight: var(--font-weight-medium);
line-height: var(--line-height-x-large); /* 150% */
letter-spacing: var(--letter-spacing-tight);
}
/* [data-slot="dialog-close-button"] {} */
}
[data-slot="dialog-description"] {
display: flex;
padding: 16px;
padding-left: 24px;
padding-top: 0;
margin-top: -8px;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
align-self: stretch;
color: var(--text-base);
/* text-14-regular */
font-family: var(--font-family-sans);
font-size: 14px;
font-style: normal;
font-weight: var(--font-weight-regular);
line-height: var(--line-height-large); /* 142.857% */
letter-spacing: var(--letter-spacing-normal);
}
[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: min(calc(100vw - 32px), 800px);
height: min(calc(100vh - 32px), 600px);
}
&[data-size="x-large"] [data-slot="dialog-container"] {
width: min(calc(100vw - 32px), 960px);
height: min(calc(100vh - 32px), 600px);
}
}
[data-component="dialog"][data-transition] [data-slot="dialog-content"] {
animation: contentHide 100ms ease-in forwards;
&[data-expanded] {
animation: contentShow 150ms ease-out;
}
}
@keyframes overlayShow {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes overlayHide {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes contentShow {
from {
opacity: 0;
transform: scale(0.98);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes contentHide {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.98);
}
}

View File

@@ -0,0 +1,173 @@
// @ts-nocheck
import { onMount } from "solid-js"
import * as mod from "./dialog"
import { Button } from "./button"
import { useDialog } from "../context/dialog"
const docs = `### Overview
Dialog content wrapper used with the DialogProvider for modal flows.
Provide concise title/description and keep body focused.
### 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.
### Behavior
- Intended to be rendered via \`useDialog().show\`.
### Accessibility
- TODO: confirm focus trapping and aria attributes from Kobalte Dialog.
### Theming/tokens
- Uses \`data-component="dialog"\` and slot attributes.
`
export default {
title: "UI/Dialog",
id: "components-dialog",
component: mod.Dialog,
tags: ["autodocs"],
parameters: {
docs: {
description: {
component: docs,
},
},
},
}
export const Basic = {
render: () => {
const dialog = useDialog()
const open = () =>
dialog.show(() => (
<mod.Dialog title="Dialog" description="Description">
Dialog body content.
</mod.Dialog>
))
onMount(open)
return (
<Button variant="secondary" onClick={open}>
Open dialog
</Button>
)
},
}
export const Sizes = {
render: () => {
const dialog = useDialog()
return (
<div style={{ display: "flex", gap: "12px" }}>
<Button
variant="secondary"
onClick={() =>
dialog.show(() => (
<mod.Dialog title="Normal" description="Normal size">
Normal dialog content.
</mod.Dialog>
))
}
>
Normal
</Button>
<Button
variant="secondary"
onClick={() =>
dialog.show(() => (
<mod.Dialog size="large" title="Large" description="Large size">
Large dialog content.
</mod.Dialog>
))
}
>
Large
</Button>
<Button
variant="secondary"
onClick={() =>
dialog.show(() => (
<mod.Dialog size="x-large" title="Extra large" description="X-large size">
X-large dialog content.
</mod.Dialog>
))
}
>
X-Large
</Button>
</div>
)
},
}
export const Transition = {
render: () => {
const dialog = useDialog()
return (
<Button
variant="secondary"
onClick={() =>
dialog.show(() => (
<mod.Dialog title="Transition" description="Animated" transition>
Transition enabled.
</mod.Dialog>
))
}
>
Open transition dialog
</Button>
)
},
}
export const CustomAction = {
render: () => {
const dialog = useDialog()
return (
<Button
variant="secondary"
onClick={() =>
dialog.show(() => (
<mod.Dialog
title="Custom action"
description="Dialog with a custom header action"
action={<Button variant="ghost">Help</Button>}
>
Dialog body content.
</mod.Dialog>
))
}
>
Open action dialog
</Button>
)
},
}
export const Fit = {
render: () => {
const dialog = useDialog()
return (
<Button
variant="secondary"
onClick={() =>
dialog.show(() => (
<mod.Dialog title="Fit content" fit>
Dialog fits its content.
</mod.Dialog>
))
}
>
Open fit dialog
</Button>
)
},
}

View File

@@ -0,0 +1,72 @@
import { Dialog as Kobalte } from "@kobalte/core/dialog"
import { ComponentProps, JSXElement, Match, ParentProps, Show, Switch } from "solid-js"
import { useI18n } from "../context/i18n"
import { IconButton } from "./icon-button"
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
transition?: boolean
}
export function Dialog(props: DialogProps) {
const i18n = useI18n()
return (
<div
data-component="dialog"
data-fit={props.fit ? true : undefined}
data-size={props.size || "normal"}
data-transition={props.transition ? true : undefined}
>
<div data-slot="dialog-container">
<Kobalte.Content
data-slot="dialog-content"
data-no-header={!props.title && !props.action ? "" : undefined}
classList={{
...props.classList,
[props.class ?? ""]: !!props.class,
}}
onOpenAutoFocus={(e) => {
const target = e.currentTarget as HTMLElement | null
const autofocusEl = target?.querySelector("[autofocus]") as HTMLElement | null
if (autofocusEl) {
e.preventDefault()
autofocusEl.focus()
}
}}
>
<Show when={props.title || props.action}>
<div data-slot="dialog-header">
<Show when={props.title}>
<Kobalte.Title data-slot="dialog-title">{props.title}</Kobalte.Title>
</Show>
<Switch>
<Match when={props.action}>{props.action}</Match>
<Match when={true}>
<Kobalte.CloseButton
data-slot="dialog-close-button"
as={IconButton}
icon="close"
variant="ghost"
aria-label={i18n.t("ui.common.close")}
/>
</Match>
</Switch>
</div>
</Show>
<Show when={props.description}>
<Kobalte.Description data-slot="dialog-description" style={{ "margin-left": "-4px" }}>
{props.description}
</Kobalte.Description>
</Show>
<div data-slot="dialog-body">{props.children}</div>
</Kobalte.Content>
</div>
</div>
)
}

View File

@@ -0,0 +1,42 @@
[data-component="diff-changes"] {
display: flex;
gap: 8px;
justify-content: flex-end;
align-items: center;
[data-slot="diff-changes-additions"] {
font-family: var(--font-family-mono);
font-feature-settings: var(--font-family-mono--font-feature-settings);
font-size: 14px;
font-style: normal;
font-weight: var(--font-weight-regular);
line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal);
text-align: right;
color: var(--text-diff-add-base);
}
[data-slot="diff-changes-deletions"] {
font-family: var(--font-family-mono);
font-feature-settings: var(--font-family-mono--font-feature-settings);
font-size: 14px;
font-style: normal;
font-weight: var(--font-weight-regular);
line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal);
text-align: right;
color: var(--text-diff-delete-base);
}
}
[data-component="diff-changes"][data-variant="bars"] {
width: 18px;
height: 14px;
flex-shrink: 0;
svg {
display: block;
width: 100%;
height: 100%;
}
}

View File

@@ -0,0 +1,81 @@
// @ts-nocheck
import * as mod from "./diff-changes"
import { create } from "../storybook/scaffold"
import { changes } from "../storybook/fixtures"
const docs = `### Overview
Summarize additions/deletions as text or compact bars.
Pair with \`Diff\`/\`DiffSSR\` to contextualize a change set.
### API
- Required: \`changes\` as { additions, deletions } or an array of those objects.
- Optional: \`variant\` ("default" | "bars").
### Variants and states
- Default text summary or bar visualization.
- Handles zero-change state (renders nothing in default variant).
### 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 story = create({
title: "UI/DiffChanges",
mod,
args: {
changes,
variant: "default",
},
})
export default {
title: "UI/DiffChanges",
id: "components-diff-changes",
component: story.meta.component,
tags: ["autodocs"],
parameters: {
docs: {
description: {
component: docs,
},
},
},
argTypes: {
variant: {
control: "select",
options: ["default", "bars"],
},
},
}
export const Default = story.Basic
export const Bars = {
args: {
variant: "bars",
},
}
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 },
},
}

View File

@@ -0,0 +1,115 @@
import { createMemo, For, Match, Show, Switch } from "solid-js"
export function DiffChanges(props: {
class?: string
changes: { additions: number; deletions: number } | { additions: number; deletions: number }[]
variant?: "default" | "bars"
}) {
const variant = () => props.variant ?? "default"
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))
const blockCounts = createMemo(() => {
const TOTAL_BLOCKS = 5
const adds = additions() ?? 0
const dels = deletions() ?? 0
if (adds === 0 && dels === 0) {
return { added: 0, deleted: 0, neutral: TOTAL_BLOCKS }
}
const total = adds + dels
if (total < 5) {
const added = adds > 0 ? 1 : 0
const deleted = dels > 0 ? 1 : 0
const neutral = TOTAL_BLOCKS - added - deleted
return { added, deleted, neutral }
}
const ratio = adds > dels ? adds / dels : dels / adds
let BLOCKS_FOR_COLORS = TOTAL_BLOCKS
if (total < 20) {
BLOCKS_FOR_COLORS = TOTAL_BLOCKS - 1
} else if (ratio < 4) {
BLOCKS_FOR_COLORS = TOTAL_BLOCKS - 1
}
const percentAdded = adds / total
const percentDeleted = dels / total
const added_raw = percentAdded * BLOCKS_FOR_COLORS
const deleted_raw = percentDeleted * BLOCKS_FOR_COLORS
let added = adds > 0 ? Math.max(1, Math.round(added_raw)) : 0
let deleted = dels > 0 ? Math.max(1, Math.round(deleted_raw)) : 0
// Cap bars based on actual change magnitude
if (adds > 0 && adds <= 5) added = Math.min(added, 1)
if (adds > 5 && adds <= 10) added = Math.min(added, 2)
if (dels > 0 && dels <= 5) deleted = Math.min(deleted, 1)
if (dels > 5 && dels <= 10) deleted = Math.min(deleted, 2)
let total_allocated = added + deleted
if (total_allocated > BLOCKS_FOR_COLORS) {
if (added_raw > deleted_raw) {
added = BLOCKS_FOR_COLORS - deleted
} else {
deleted = BLOCKS_FOR_COLORS - added
}
total_allocated = added + deleted
}
const neutral = Math.max(0, TOTAL_BLOCKS - total_allocated)
return { added, deleted, neutral }
})
const ADD_COLOR = "var(--icon-diff-add-base)"
const DELETE_COLOR = "var(--icon-diff-delete-base)"
const NEUTRAL_COLOR = "var(--icon-weak-base)"
const visibleBlocks = createMemo(() => {
const counts = blockCounts()
const blocks = [
...Array(counts.added).fill(ADD_COLOR),
...Array(counts.deleted).fill(DELETE_COLOR),
...Array(counts.neutral).fill(NEUTRAL_COLOR),
]
return blocks.slice(0, 5)
})
return (
<Show when={variant() === "default" ? total() > 0 : true}>
<div data-component="diff-changes" data-variant={variant()} classList={{ [props.class ?? ""]: true }}>
<Switch>
<Match when={variant() === "bars"}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 14" fill="none">
<g>
<For each={visibleBlocks()}>
{(color, i) => <rect x={i() * 4} width="2" height="14" rx="1" fill={color} />}
</For>
</g>
</svg>
</Match>
<Match when={variant() === "default"}>
<span data-slot="diff-changes-additions">{`+${additions()}`}</span>
<span data-slot="diff-changes-deletions">{`-${deletions()}`}</span>
</Match>
</Switch>
</div>
</Show>
)
}

View File

@@ -0,0 +1,62 @@
// @ts-nocheck
import * as mod from "./dock-prompt"
import { create } from "../storybook/scaffold"
const docs = `### Overview
Docked prompt layout for questions and permission requests.
Use with form controls or confirmation buttons in the footer.
### API
- Required: \`kind\` (question | permission), \`header\`, \`children\`, \`footer\`.
- Optional: \`ref\` for measuring or focus management.
### Variants and states
- Question and permission layouts (data attributes).
### Behavior
- Pure layout component; behavior handled by parent.
### Accessibility
- Ensure header and footer content provide clear context and actions.
### Theming/tokens
- Uses \`data-component="dock-prompt"\` with kind data attribute.
`
const story = create({
title: "UI/DockPrompt",
mod,
args: {
kind: "question",
header: "Header",
children: "Prompt content",
footer: "Footer",
},
})
export default {
title: "UI/DockPrompt",
id: "components-dock-prompt",
component: story.meta.component,
tags: ["autodocs"],
parameters: {
docs: {
description: {
component: docs,
},
},
},
}
export const Basic = story.Basic
export const Permission = {
args: {
kind: "permission",
header: "Allow access?",
children: "This action needs permission to proceed.",
footer: "Approve or deny",
},
}

View File

@@ -0,0 +1,23 @@
import type { JSX } from "solid-js"
import { DockShell, DockTray } from "./dock-surface"
export function DockPrompt(props: {
kind: "question" | "permission"
header: JSX.Element
children: JSX.Element
footer: JSX.Element
ref?: (el: HTMLDivElement) => void
onKeyDown?: JSX.EventHandlerUnion<HTMLDivElement, KeyboardEvent>
}) {
const slot = (name: string) => `${props.kind}-${name}`
return (
<div data-component="dock-prompt" data-kind={props.kind} ref={props.ref} onKeyDown={props.onKeyDown}>
<DockShell data-slot={slot("body")}>
<div data-slot={slot("header")}>{props.header}</div>
<div data-slot={slot("content")}>{props.children}</div>
</DockShell>
<DockTray data-slot={slot("footer")}>{props.footer}</DockTray>
</div>
)
}

View File

@@ -0,0 +1,23 @@
[data-dock-surface="shell"] {
background-color: var(--surface-raised-stronger-non-alpha);
box-shadow: var(--shadow-xs-border);
position: relative;
z-index: 10;
border-radius: 12px;
overflow: clip;
}
[data-dock-surface="tray"] {
background-color: var(--background-base);
border: 1px solid var(--border-weak-base);
position: relative;
z-index: 0;
border-radius: 12px;
overflow: clip;
}
[data-dock-surface="tray"][data-dock-attach="top"] {
margin-top: -0.875rem;
border-top-left-radius: 0;
border-top-right-radius: 0;
}

View File

@@ -0,0 +1,54 @@
import { type ComponentProps, splitProps } from "solid-js"
export interface DockTrayProps extends ComponentProps<"div"> {
attach?: "none" | "top"
}
export function DockShell(props: ComponentProps<"div">) {
const [split, rest] = splitProps(props, ["children", "class", "classList"])
return (
<div
{...rest}
data-dock-surface="shell"
classList={{
...split.classList,
[split.class ?? ""]: !!split.class,
}}
>
{split.children}
</div>
)
}
export function DockShellForm(props: ComponentProps<"form">) {
const [split, rest] = splitProps(props, ["children", "class", "classList"])
return (
<form
{...rest}
data-dock-surface="shell"
classList={{
...split.classList,
[split.class ?? ""]: !!split.class,
}}
>
{split.children}
</form>
)
}
export function DockTray(props: DockTrayProps) {
const [split, rest] = splitProps(props, ["attach", "children", "class", "classList"])
return (
<div
{...rest}
data-dock-surface="tray"
data-dock-attach={split.attach || "none"}
classList={{
...split.classList,
[split.class ?? ""]: !!split.class,
}}
>
{split.children}
</div>
)
}

View File

@@ -0,0 +1,135 @@
[data-component="dropdown-menu-content"],
[data-component="dropdown-menu-sub-content"] {
min-width: 8rem;
overflow: hidden;
border-radius: var(--radius-md);
border: 1px solid color-mix(in oklch, var(--border-base) 50%, transparent);
background-clip: padding-box;
background-color: var(--surface-raised-stronger-non-alpha);
padding: 4px;
box-shadow: var(--shadow-md);
z-index: 50;
transform-origin: var(--kb-menu-content-transform-origin);
&:focus,
&:focus-visible {
outline: none;
}
&[data-closed] {
animation: dropdown-menu-close 0.15s ease-out;
}
&[data-expanded] {
animation: dropdown-menu-open 0.15s ease-out;
}
}
[data-component="dropdown-menu-content"],
[data-component="dropdown-menu-sub-content"] {
[data-slot="dropdown-menu-item"],
[data-slot="dropdown-menu-checkbox-item"],
[data-slot="dropdown-menu-radio-item"],
[data-slot="dropdown-menu-sub-trigger"] {
position: relative;
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
border-radius: var(--radius-sm);
cursor: default;
user-select: none;
outline: none;
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal);
color: var(--text-strong);
&[data-highlighted] {
background: var(--surface-raised-base-hover);
}
&[data-disabled] {
color: var(--text-weak);
pointer-events: none;
}
}
[data-slot="dropdown-menu-checkbox-item"],
[data-slot="dropdown-menu-radio-item"] {
padding-right: 28px;
}
[data-slot="dropdown-menu-sub-trigger"] {
&[data-expanded] {
background: var(--surface-raised-base-hover);
}
}
[data-slot="dropdown-menu-item-indicator"] {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
}
[data-slot="dropdown-menu-item-label"] {
flex: 1;
}
[data-slot="dropdown-menu-item-description"] {
font-size: var(--font-size-x-small);
color: var(--text-weak);
}
[data-slot="dropdown-menu-separator"] {
height: 1px;
margin: 4px -4px;
border-top-color: var(--border-weak-base);
}
[data-slot="dropdown-menu-group-label"] {
display: block;
padding: 4px 8px;
font-family: var(--font-family-sans);
font-size: var(--font-size-x-small);
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal);
color: var(--text-weak);
}
[data-slot="dropdown-menu-arrow"] {
fill: var(--surface-raised-stronger-non-alpha);
}
}
@keyframes dropdown-menu-open {
from {
opacity: 0;
transform: scale(0.96);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes dropdown-menu-close {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.96);
}
}

View File

@@ -0,0 +1,97 @@
// @ts-nocheck
import * as mod from "./dropdown-menu"
import { Button } from "./button"
const docs = `### Overview
Dropdown menu built on Kobalte with composable items, groups, and submenus.
Use \`DropdownMenu.ItemLabel\`/\`ItemDescription\` for richer rows.
### API
- Root accepts Kobalte DropdownMenu props (\`open\`, \`defaultOpen\`, \`onOpenChange\`).
- Compose with \`Trigger\`, \`Content\`, \`Item\`, \`Separator\`, and optional \`Sub\` sections.
### Variants and states
- Supports item groups, separators, and nested submenus.
### Behavior
- Menu opens from trigger and renders in a portal by default.
### Accessibility
- TODO: confirm keyboard navigation from Kobalte.
### Theming/tokens
- Uses \`data-component="dropdown-menu"\` and slot attributes for styling.
`
export default {
title: "UI/DropdownMenu",
id: "components-dropdown-menu",
component: mod.DropdownMenu,
tags: ["autodocs"],
parameters: {
docs: {
description: {
component: docs,
},
},
},
}
export const Basic = {
render: () => (
<mod.DropdownMenu defaultOpen>
<mod.DropdownMenu.Trigger as={Button} variant="secondary" size="small">
Open menu
</mod.DropdownMenu.Trigger>
<mod.DropdownMenu.Portal>
<mod.DropdownMenu.Content>
<mod.DropdownMenu.Group>
<mod.DropdownMenu.GroupLabel>Actions</mod.DropdownMenu.GroupLabel>
<mod.DropdownMenu.Item>
<mod.DropdownMenu.ItemLabel>New file</mod.DropdownMenu.ItemLabel>
</mod.DropdownMenu.Item>
<mod.DropdownMenu.Item>
<mod.DropdownMenu.ItemLabel>Rename</mod.DropdownMenu.ItemLabel>
<mod.DropdownMenu.ItemDescription>Shift+R</mod.DropdownMenu.ItemDescription>
</mod.DropdownMenu.Item>
</mod.DropdownMenu.Group>
<mod.DropdownMenu.Separator />
<mod.DropdownMenu.Sub>
<mod.DropdownMenu.SubTrigger>More options</mod.DropdownMenu.SubTrigger>
<mod.DropdownMenu.SubContent>
<mod.DropdownMenu.Item>
<mod.DropdownMenu.ItemLabel>Duplicate</mod.DropdownMenu.ItemLabel>
</mod.DropdownMenu.Item>
<mod.DropdownMenu.Item>
<mod.DropdownMenu.ItemLabel>Move</mod.DropdownMenu.ItemLabel>
</mod.DropdownMenu.Item>
</mod.DropdownMenu.SubContent>
</mod.DropdownMenu.Sub>
</mod.DropdownMenu.Content>
</mod.DropdownMenu.Portal>
</mod.DropdownMenu>
),
}
export const CheckboxRadio = {
render: () => (
<mod.DropdownMenu defaultOpen>
<mod.DropdownMenu.Trigger as={Button} variant="secondary" size="small">
Open menu
</mod.DropdownMenu.Trigger>
<mod.DropdownMenu.Portal>
<mod.DropdownMenu.Content>
<mod.DropdownMenu.CheckboxItem checked>Show line numbers</mod.DropdownMenu.CheckboxItem>
<mod.DropdownMenu.CheckboxItem>Wrap lines</mod.DropdownMenu.CheckboxItem>
<mod.DropdownMenu.Separator />
<mod.DropdownMenu.RadioGroup value="compact">
<mod.DropdownMenu.RadioItem value="compact">Compact</mod.DropdownMenu.RadioItem>
<mod.DropdownMenu.RadioItem value="comfortable">Comfortable</mod.DropdownMenu.RadioItem>
</mod.DropdownMenu.RadioGroup>
</mod.DropdownMenu.Content>
</mod.DropdownMenu.Portal>
</mod.DropdownMenu>
),
}

View File

@@ -0,0 +1,308 @@
import { DropdownMenu as Kobalte } from "@kobalte/core/dropdown-menu"
import { splitProps } from "solid-js"
import type { ComponentProps, ParentProps } from "solid-js"
export interface DropdownMenuProps extends ComponentProps<typeof Kobalte> {}
export interface DropdownMenuTriggerProps extends ComponentProps<typeof Kobalte.Trigger> {}
export interface DropdownMenuIconProps extends ComponentProps<typeof Kobalte.Icon> {}
export interface DropdownMenuPortalProps extends ComponentProps<typeof Kobalte.Portal> {}
export interface DropdownMenuContentProps extends ComponentProps<typeof Kobalte.Content> {}
export interface DropdownMenuArrowProps extends ComponentProps<typeof Kobalte.Arrow> {}
export interface DropdownMenuSeparatorProps extends ComponentProps<typeof Kobalte.Separator> {}
export interface DropdownMenuGroupProps extends ComponentProps<typeof Kobalte.Group> {}
export interface DropdownMenuGroupLabelProps extends ComponentProps<typeof Kobalte.GroupLabel> {}
export interface DropdownMenuItemProps extends ComponentProps<typeof Kobalte.Item> {}
export interface DropdownMenuItemLabelProps extends ComponentProps<typeof Kobalte.ItemLabel> {}
export interface DropdownMenuItemDescriptionProps extends ComponentProps<typeof Kobalte.ItemDescription> {}
export interface DropdownMenuItemIndicatorProps extends ComponentProps<typeof Kobalte.ItemIndicator> {}
export interface DropdownMenuRadioGroupProps extends ComponentProps<typeof Kobalte.RadioGroup> {}
export interface DropdownMenuRadioItemProps extends ComponentProps<typeof Kobalte.RadioItem> {}
export interface DropdownMenuCheckboxItemProps extends ComponentProps<typeof Kobalte.CheckboxItem> {}
export interface DropdownMenuSubProps extends ComponentProps<typeof Kobalte.Sub> {}
export interface DropdownMenuSubTriggerProps extends ComponentProps<typeof Kobalte.SubTrigger> {}
export interface DropdownMenuSubContentProps extends ComponentProps<typeof Kobalte.SubContent> {}
function DropdownMenuRoot(props: DropdownMenuProps) {
return <Kobalte {...props} data-component="dropdown-menu" />
}
function DropdownMenuTrigger(props: ParentProps<DropdownMenuTriggerProps>) {
const [local, rest] = splitProps(props, ["class", "classList", "children"])
return (
<Kobalte.Trigger
{...rest}
data-slot="dropdown-menu-trigger"
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
>
{local.children}
</Kobalte.Trigger>
)
}
function DropdownMenuIcon(props: ParentProps<DropdownMenuIconProps>) {
const [local, rest] = splitProps(props, ["class", "classList", "children"])
return (
<Kobalte.Icon
{...rest}
data-slot="dropdown-menu-icon"
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
>
{local.children}
</Kobalte.Icon>
)
}
function DropdownMenuPortal(props: DropdownMenuPortalProps) {
return <Kobalte.Portal {...props} />
}
function DropdownMenuContent(props: ParentProps<DropdownMenuContentProps>) {
const [local, rest] = splitProps(props, ["class", "classList", "children"])
return (
<Kobalte.Content
{...rest}
data-component="dropdown-menu-content"
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
>
{local.children}
</Kobalte.Content>
)
}
function DropdownMenuArrow(props: DropdownMenuArrowProps) {
const [local, rest] = splitProps(props, ["class", "classList"])
return (
<Kobalte.Arrow
{...rest}
data-slot="dropdown-menu-arrow"
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
/>
)
}
function DropdownMenuSeparator(props: DropdownMenuSeparatorProps) {
const [local, rest] = splitProps(props, ["class", "classList"])
return (
<Kobalte.Separator
{...rest}
data-slot="dropdown-menu-separator"
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
/>
)
}
function DropdownMenuGroup(props: ParentProps<DropdownMenuGroupProps>) {
const [local, rest] = splitProps(props, ["class", "classList", "children"])
return (
<Kobalte.Group
{...rest}
data-slot="dropdown-menu-group"
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
>
{local.children}
</Kobalte.Group>
)
}
function DropdownMenuGroupLabel(props: ParentProps<DropdownMenuGroupLabelProps>) {
const [local, rest] = splitProps(props, ["class", "classList", "children"])
return (
<Kobalte.GroupLabel
{...rest}
data-slot="dropdown-menu-group-label"
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
>
{local.children}
</Kobalte.GroupLabel>
)
}
function DropdownMenuItem(props: ParentProps<DropdownMenuItemProps>) {
const [local, rest] = splitProps(props, ["class", "classList", "children"])
return (
<Kobalte.Item
{...rest}
data-slot="dropdown-menu-item"
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
>
{local.children}
</Kobalte.Item>
)
}
function DropdownMenuItemLabel(props: ParentProps<DropdownMenuItemLabelProps>) {
const [local, rest] = splitProps(props, ["class", "classList", "children"])
return (
<Kobalte.ItemLabel
{...rest}
data-slot="dropdown-menu-item-label"
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
>
{local.children}
</Kobalte.ItemLabel>
)
}
function DropdownMenuItemDescription(props: ParentProps<DropdownMenuItemDescriptionProps>) {
const [local, rest] = splitProps(props, ["class", "classList", "children"])
return (
<Kobalte.ItemDescription
{...rest}
data-slot="dropdown-menu-item-description"
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
>
{local.children}
</Kobalte.ItemDescription>
)
}
function DropdownMenuItemIndicator(props: ParentProps<DropdownMenuItemIndicatorProps>) {
const [local, rest] = splitProps(props, ["class", "classList", "children"])
return (
<Kobalte.ItemIndicator
{...rest}
data-slot="dropdown-menu-item-indicator"
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
>
{local.children}
</Kobalte.ItemIndicator>
)
}
function DropdownMenuRadioGroup(props: ParentProps<DropdownMenuRadioGroupProps>) {
const [local, rest] = splitProps(props, ["class", "classList", "children"])
return (
<Kobalte.RadioGroup
{...rest}
data-slot="dropdown-menu-radio-group"
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
>
{local.children}
</Kobalte.RadioGroup>
)
}
function DropdownMenuRadioItem(props: ParentProps<DropdownMenuRadioItemProps>) {
const [local, rest] = splitProps(props, ["class", "classList", "children"])
return (
<Kobalte.RadioItem
{...rest}
data-slot="dropdown-menu-radio-item"
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
>
{local.children}
</Kobalte.RadioItem>
)
}
function DropdownMenuCheckboxItem(props: ParentProps<DropdownMenuCheckboxItemProps>) {
const [local, rest] = splitProps(props, ["class", "classList", "children"])
return (
<Kobalte.CheckboxItem
{...rest}
data-slot="dropdown-menu-checkbox-item"
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
>
{local.children}
</Kobalte.CheckboxItem>
)
}
function DropdownMenuSub(props: DropdownMenuSubProps) {
return <Kobalte.Sub {...props} />
}
function DropdownMenuSubTrigger(props: ParentProps<DropdownMenuSubTriggerProps>) {
const [local, rest] = splitProps(props, ["class", "classList", "children"])
return (
<Kobalte.SubTrigger
{...rest}
data-slot="dropdown-menu-sub-trigger"
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
>
{local.children}
</Kobalte.SubTrigger>
)
}
function DropdownMenuSubContent(props: ParentProps<DropdownMenuSubContentProps>) {
const [local, rest] = splitProps(props, ["class", "classList", "children"])
return (
<Kobalte.SubContent
{...rest}
data-component="dropdown-menu-sub-content"
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
>
{local.children}
</Kobalte.SubContent>
)
}
export const DropdownMenu = Object.assign(DropdownMenuRoot, {
Trigger: DropdownMenuTrigger,
Icon: DropdownMenuIcon,
Portal: DropdownMenuPortal,
Content: DropdownMenuContent,
Arrow: DropdownMenuArrow,
Separator: DropdownMenuSeparator,
Group: DropdownMenuGroup,
GroupLabel: DropdownMenuGroupLabel,
Item: DropdownMenuItem,
ItemLabel: DropdownMenuItemLabel,
ItemDescription: DropdownMenuItemDescription,
ItemIndicator: DropdownMenuItemIndicator,
RadioGroup: DropdownMenuRadioGroup,
RadioItem: DropdownMenuRadioItem,
CheckboxItem: DropdownMenuCheckboxItem,
Sub: DropdownMenuSub,
SubTrigger: DropdownMenuSubTrigger,
SubContent: DropdownMenuSubContent,
})

View File

@@ -0,0 +1,49 @@
// @ts-nocheck
import * as mod from "./favicon"
const docs = `### Overview
Injects favicon and app icon meta tags for the document head.
Render once near the app root (head management).
### API
- No props.
### Variants and states
- Single configuration.
### Behavior
- Registers link and meta tags via Solid Meta components.
### Accessibility
- Not applicable.
### Theming/tokens
- Not applicable.
`
export default {
title: "UI/Favicon",
id: "components-favicon",
component: mod.Favicon,
tags: ["autodocs"],
parameters: {
docs: {
description: {
component: docs,
},
},
},
}
export const Basic = {
render: () => (
<div style={{ display: "grid", gap: "8px" }}>
<mod.Favicon />
<div style={{ color: "var(--text-weak)", "font-size": "12px" }}>
Head tags are injected for favicon and app icons.
</div>
</div>
),
}

View File

@@ -0,0 +1,13 @@
import { Link, Meta } from "@solidjs/meta"
export const Favicon = () => {
return (
<>
<Link rel="icon" type="image/png" href="/favicon-96x96-v3.png" sizes="96x96" />
<Link rel="shortcut icon" href="/favicon-v3.ico" />
<Link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon-v3.png" />
<Link rel="manifest" href="/site.webmanifest" />
<Meta name="apple-mobile-web-app-title" content="OpenCode" />
</>
)
}

View File

@@ -0,0 +1,26 @@
[data-component="file-icon"] {
display: block;
flex-shrink: 0;
width: 16px;
height: 16px;
}
/*
File tree: show monochrome weak icons by default.
On hover, show the original file-type colors.
*/
[data-component="filetree"] .filetree-icon--mono {
color: var(--icon-base);
}
[data-component="filetree"] .filetree-iconpair {
position: relative;
display: inline-flex;
width: 16px;
height: 16px;
}
[data-component="filetree"] .filetree-iconpair [data-component="file-icon"] {
position: absolute;
inset: 0;
}

View File

@@ -0,0 +1,94 @@
// @ts-nocheck
import * as mod from "./file-icon"
import { create } from "../storybook/scaffold"
const docs = `### Overview
File and folder icon renderer based on file name and extension.
Use in file trees and lists.
### API
- Required: \`node\` with \`path\` and \`type\`.
- Optional: \`expanded\` (for folders), \`mono\` for monochrome rendering.
### Variants and states
- Folder vs file icons; expanded folder variant.
### Behavior
- Maps file names and extensions to sprite icons.
### Accessibility
- Provide adjacent text labels for filenames; icons are decorative.
### Theming/tokens
- Uses \`data-component="file-icon"\` and sprite-based styling.
`
const story = create({
title: "UI/FileIcon",
mod,
args: {
node: { path: "package.json", type: "file" },
mono: true,
},
})
export default {
title: "UI/FileIcon",
id: "components-file-icon",
component: story.meta.component,
tags: ["autodocs"],
parameters: {
docs: {
description: {
component: docs,
},
},
},
}
export const Basic = story.Basic
export const Folder = {
args: {
node: { path: "src", type: "directory" },
expanded: true,
mono: false,
},
}
export const Samples = {
render: () => {
const items = [
{ path: "README.md", type: "file" },
{ path: "package.json", type: "file" },
{ path: "tsconfig.json", type: "file" },
{ path: "index.ts", type: "file" },
{ path: "styles.css", type: "file" },
{ path: "logo.svg", type: "file" },
{ path: "photo.png", type: "file" },
{ path: "Dockerfile", type: "file" },
{ path: ".env", type: "file" },
{ path: "src", type: "directory" },
{ path: "public", type: "directory" },
] as const
return (
<div
style={{
display: "grid",
gap: "12px",
"grid-template-columns": "repeat(auto-fill, minmax(120px, 1fr))",
}}
>
{items.map((node) => (
<div style={{ display: "flex", gap: "8px", "align-items": "center" }}>
<mod.FileIcon node={{ path: node.path, type: node.type }} mono={false} />
<div style={{ "font-size": "12px", color: "var(--text-weak)" }}>{node.path}</div>
</div>
))}
</div>
)
},
}

View File

@@ -0,0 +1,588 @@
import type { Component, JSX } from "solid-js"
import { createMemo, createUniqueId, splitProps, Show } from "solid-js"
import sprite from "./file-icons/sprite.svg"
import type { IconName } from "./file-icons/types"
export type FileIconProps = JSX.GSVGAttributes<SVGSVGElement> & {
node: { path: string; type: "file" | "directory" }
expanded?: boolean
mono?: boolean
}
export const FileIcon: Component<FileIconProps> = (props) => {
const [local, rest] = splitProps(props, ["node", "class", "classList", "expanded", "mono"])
const name = createMemo(() => chooseIconName(local.node.path, local.node.type, local.expanded || false))
const id = `file-icon-mono-${createUniqueId()}`
return (
<svg
data-component="file-icon"
{...rest}
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
>
<Show when={local.mono} fallback={<use href={`${sprite}#${name()}`} />}>
<defs>
<mask id={id} mask-type="alpha">
<use href={`${sprite}#${name()}`} />
</mask>
</defs>
<rect width="100%" height="100%" fill="currentColor" mask={`url(#${id})`} />
</Show>
</svg>
)
}
type IconMaps = {
fileNames: Record<string, IconName>
fileExtensions: Record<string, IconName>
folderNames: Record<string, IconName>
defaults: {
file: IconName
folder: IconName
folderOpen: IconName
}
}
const ICON_MAPS: IconMaps = {
fileNames: {
// Documentation files
"readme.md": "Readme",
"changelog.md": "Changelog",
"contributing.md": "Contributing",
"conduct.md": "Conduct",
license: "Certificate",
authors: "Authors",
credits: "Credits",
install: "Installation",
// Node.js files
"package.json": "Nodejs",
"package-lock.json": "Nodejs",
"yarn.lock": "Yarn",
"pnpm-lock.yaml": "Pnpm",
"bun.lock": "Bun",
"bun.lockb": "Bun",
"bunfig.toml": "Bun",
".nvmrc": "Nodejs",
".node-version": "Nodejs",
// Docker files
dockerfile: "Docker",
"docker-compose.yml": "Docker",
"docker-compose.yaml": "Docker",
".dockerignore": "Docker",
// Config files
"jest.config.js": "Jest",
"jest.config.ts": "Jest",
"jest.config.mjs": "Jest",
"vitest.config.js": "Vitest",
"vitest.config.ts": "Vitest",
"tailwind.config.js": "Tailwindcss",
"tailwind.config.ts": "Tailwindcss",
"turbo.json": "Turborepo",
"tsconfig.json": "Tsconfig",
"jsconfig.json": "Jsconfig",
".eslintrc": "Eslint",
".eslintrc.js": "Eslint",
".eslintrc.json": "Eslint",
".prettierrc": "Prettier",
".prettierrc.js": "Prettier",
".prettierrc.json": "Prettier",
"vite.config.js": "Vite",
"vite.config.ts": "Vite",
"webpack.config.js": "Webpack",
"rollup.config.js": "Rollup",
"astro.config.mjs": "AstroConfig",
"astro.config.js": "AstroConfig",
"next.config.js": "Next",
"next.config.mjs": "Next",
"nuxt.config.js": "Nuxt",
"nuxt.config.ts": "Nuxt",
"svelte.config.js": "Svelte",
"gatsby-config.js": "Gatsby",
"remix.config.js": "Remix",
"prisma.schema": "Prisma",
".gitignore": "Git",
".gitattributes": "Git",
makefile: "Makefile",
cmake: "Cmake",
"cargo.toml": "Rust",
"go.mod": "GoMod",
"go.sum": "GoMod",
"requirements.txt": "Python",
"pyproject.toml": "Python",
pipfile: "Python",
"poetry.lock": "Poetry",
gemfile: "Gemfile",
rakefile: "Ruby",
"composer.json": "Php",
"build.gradle": "Gradle",
"pom.xml": "Maven",
"deno.json": "Deno",
"deno.jsonc": "Deno",
"vercel.json": "Vercel",
"netlify.toml": "Netlify",
".env": "Tune",
".env.local": "Tune",
".env.development": "Tune",
".env.production": "Tune",
".env.example": "Tune",
".editorconfig": "Editorconfig",
"robots.txt": "Robots",
"favicon.ico": "Favicon",
browserlist: "Browserlist",
".babelrc": "Babel",
"babel.config.js": "Babel",
"gulpfile.js": "Gulp",
"gruntfile.js": "Grunt",
"capacitor.config.json": "Capacitor",
"ionic.config.json": "Ionic",
"angular.json": "Angular",
".storybook": "Storybook",
"storybook.config.js": "Storybook",
"cypress.config.js": "Cypress",
"playwright.config.js": "Playwright",
"puppeteer.config.js": "Puppeteer",
"wrangler.toml": "Wrangler",
"firebase.json": "Firebase",
supabase: "Supabase",
terraform: "Terraform",
kubernetes: "Kubernetes",
".gitpod.yml": "Gitpod",
".devcontainer": "Vscode",
"travis.yml": "Travis",
"appveyor.yml": "Appveyor",
".circleci": "Circleci",
"renovate.json": "Renovate",
"dependabot.yml": "Dependabot",
"lerna.json": "Lerna",
"nx.json": "Nx",
},
fileExtensions: {
// Test files
"spec.ts": "TestTs",
"test.ts": "TestTs",
"spec.tsx": "TestJsx",
"test.tsx": "TestJsx",
"spec.js": "TestJs",
"test.js": "TestJs",
"spec.jsx": "TestJsx",
"test.jsx": "TestJsx",
// JavaScript/TypeScript
"js.map": "JavascriptMap",
"d.ts": "TypescriptDef",
ts: "Typescript",
tsx: "React_ts",
js: "Javascript",
jsx: "React",
mjs: "Javascript",
cjs: "Javascript",
// Web languages
html: "Html",
htm: "Html",
css: "Css",
scss: "Sass",
sass: "Sass",
less: "Less",
styl: "Stylus",
// Data formats
json: "Json",
xml: "Xml",
yml: "Yaml",
yaml: "Yaml",
toml: "Toml",
hjson: "Hjson",
// Documentation
md: "Markdown",
mdx: "Mdx",
tex: "Tex",
// Programming languages
py: "Python",
pyx: "Python",
pyw: "Python",
rs: "Rust",
go: "Go",
java: "Java",
kt: "Kotlin",
scala: "Scala",
php: "Php",
rb: "Ruby",
cs: "Csharp",
vb: "Visualstudio",
cpp: "Cpp",
cc: "Cpp",
cxx: "Cpp",
c: "C",
h: "H",
hpp: "Hpp",
swift: "Swift",
m: "ObjectiveC",
mm: "ObjectiveCpp",
dart: "Dart",
lua: "Lua",
pl: "Perl",
r: "R",
jl: "Julia",
hs: "Haskell",
elm: "Elm",
ml: "Ocaml",
clj: "Clojure",
cljs: "Clojure",
erl: "Erlang",
ex: "Elixir",
exs: "Elixir",
nim: "Nim",
zig: "Zig",
v: "Vlang",
odin: "Odin",
gleam: "Gleam",
grain: "Grain",
roc: "Rocket",
fs: "Fsharp",
// Shell scripts
sh: "Console",
bash: "Console",
zsh: "Console",
fish: "Console",
ps1: "Powershell",
// Config/build files
cfg: "Settings",
ini: "Settings",
conf: "Settings",
properties: "Settings",
// Media files
svg: "Svg",
png: "Image",
jpg: "Image",
jpeg: "Image",
gif: "Image",
webp: "Image",
bmp: "Image",
ico: "Favicon",
mp4: "Video",
mov: "Video",
avi: "Video",
webm: "Video",
mp3: "Audio",
wav: "Audio",
flac: "Audio",
// Archive files
zip: "Zip",
tar: "Zip",
gz: "Zip",
rar: "Zip",
"7z": "Zip",
// Document files
pdf: "Pdf",
doc: "Word",
docx: "Word",
ppt: "Powerpoint",
pptx: "Powerpoint",
xls: "Document",
xlsx: "Document",
// Database files
sql: "Database",
db: "Database",
sqlite: "Database",
// Other
env: "Tune",
log: "Log",
lock: "Lock",
key: "Key",
pem: "Certificate",
crt: "Certificate",
proto: "Proto",
graphql: "Graphql",
gql: "Graphql",
wasm: "Webassembly",
dockerfile: "Docker",
},
folderNames: {
// Source code
src: "FolderSrc",
source: "FolderSrc",
lib: "FolderLib",
libs: "FolderLib",
// Testing
test: "FolderTest",
tests: "FolderTest",
testing: "FolderTest",
spec: "FolderTest",
specs: "FolderTest",
__tests__: "FolderTest",
e2e: "FolderTest",
integration: "FolderTest",
unit: "FolderTest",
cypress: "FolderCypress",
// Dependencies
node_modules: "FolderNode",
vendor: "FolderPackages",
packages: "FolderPackages",
deps: "FolderPackages",
// Build/dist
build: "FolderBuildkite",
dist: "FolderDist",
out: "FolderDist",
output: "FolderDist",
target: "FolderTarget",
// Configuration
config: "FolderConfig",
configs: "FolderConfig",
configuration: "FolderConfig",
settings: "FolderConfig",
env: "FolderEnvironment",
environments: "FolderEnvironment",
// Docker
docker: "FolderDocker",
dockerfiles: "FolderDocker",
containers: "FolderDocker",
// Documentation
docs: "FolderDocs",
doc: "FolderDocs",
documentation: "FolderDocs",
readme: "FolderDocs",
// Public/assets
public: "FolderPublic",
static: "FolderPublic",
assets: "FolderImages",
images: "FolderImages",
img: "FolderImages",
icons: "FolderImages",
media: "FolderImages",
fonts: "FolderFont",
styles: "FolderCss",
stylesheets: "FolderCss",
css: "FolderCss",
sass: "FolderSass",
scss: "FolderSass",
less: "FolderLess",
// Scripts
scripts: "FolderScripts",
script: "FolderScripts",
tools: "FolderTools",
utils: "FolderUtils",
utilities: "FolderUtils",
helpers: "FolderHelper",
// Framework specific
components: "FolderComponents",
component: "FolderComponents",
views: "FolderViews",
view: "FolderViews",
layouts: "FolderLayout",
layout: "FolderLayout",
templates: "FolderTemplate",
template: "FolderTemplate",
hooks: "FolderHook",
hook: "FolderHook",
store: "FolderStore",
stores: "FolderStore",
state: "FolderNgrxStore",
reducers: "FolderReduxReducer",
reducer: "FolderReduxReducer",
services: "FolderApi",
service: "FolderApi",
api: "FolderApi",
apis: "FolderApi",
routes: "FolderRoutes",
route: "FolderRoutes",
routing: "FolderRoutes",
middleware: "FolderMiddleware",
middlewares: "FolderMiddleware",
controllers: "FolderController",
controller: "FolderController",
models: "FolderDatabase",
model: "FolderDatabase",
schemas: "FolderDatabase",
schema: "FolderDatabase",
migrations: "FolderDatabase",
migration: "FolderDatabase",
seeders: "FolderSeeders",
seeder: "FolderSeeders",
// TypeScript
types: "FolderTypescript",
typing: "FolderTypescript",
typings: "FolderTypescript",
"@types": "FolderTypescript",
interfaces: "FolderInterface",
interface: "FolderInterface",
// Mobile
android: "FolderAndroid",
ios: "FolderIos",
mobile: "FolderMobile",
flutter: "FolderFlutter",
// Infrastructure
kubernetes: "FolderKubernetes",
k8s: "FolderKubernetes",
terraform: "FolderTerraform",
aws: "FolderAws",
azure: "FolderAzurePipelines",
firebase: "FolderFirebase",
supabase: "FolderSupabase",
vercel: "FolderVercel",
netlify: "FolderNetlify",
// CI/CD
".github": "FolderGithub",
".gitlab": "FolderGitlab",
".circleci": "FolderCircleci",
ci: "FolderCi",
".ci": "FolderCi",
workflows: "FolderGhWorkflows",
// Git
".git": "FolderGit",
// Development tools
".vscode": "FolderVscode",
".idea": "FolderIntellij",
".cursor": "FolderCursor",
".devcontainer": "FolderContainer",
".storybook": "FolderStorybook",
// Localization
i18n: "FolderI18n",
locales: "FolderI18n",
locale: "FolderI18n",
lang: "FolderI18n",
languages: "FolderI18n",
// Other common patterns
temp: "FolderTemp",
tmp: "FolderTemp",
logs: "FolderLog",
log: "FolderLog",
backup: "FolderBackup",
backups: "FolderBackup",
examples: "FolderExamples",
example: "FolderExamples",
demo: "FolderExamples",
demos: "FolderExamples",
samples: "FolderExamples",
sample: "FolderExamples",
fixtures: "FolderTest",
mocks: "FolderMock",
mock: "FolderMock",
data: "FolderDatabase",
database: "FolderDatabase",
db: "FolderDatabase",
sql: "FolderDatabase",
prisma: "FolderPrisma",
drizzle: "FolderDrizzle",
// Security
security: "FolderSecure",
auth: "FolderSecure",
authentication: "FolderSecure",
authorization: "FolderSecure",
keys: "FolderKeys",
certs: "FolderKeys",
certificates: "FolderKeys",
// Content
content: "FolderContent",
posts: "FolderContent",
articles: "FolderContent",
blog: "FolderContent",
// Functions
functions: "FolderFunctions",
function: "FolderFunctions",
lambda: "FolderFunctions",
lambdas: "FolderFunctions",
serverless: "FolderServerless",
// Jobs/tasks
jobs: "FolderJob",
job: "FolderJob",
tasks: "FolderTasks",
task: "FolderTasks",
cron: "FolderTasks",
queue: "FolderQueue",
queues: "FolderQueue",
// Desktop platforms
desktop: "FolderDesktop",
windows: "FolderWindows",
macos: "FolderMacos",
linux: "FolderLinux",
},
defaults: {
file: "Document",
folder: "Folder",
folderOpen: "FolderOpen",
},
}
const toOpenVariant = (icon: IconName): IconName => {
if (!icon.startsWith("Folder")) return icon
if (icon.endsWith("_light")) return icon.replace("_light", "Open_light") as IconName
if (!icon.endsWith("Open")) return (icon + "Open") as IconName
return icon
}
const basenameOf = (p: string) => p.split("\\").join("/").split("/").filter(Boolean).pop() ?? ""
const folderNameVariants = (name: string) => {
const n = name.toLowerCase()
return [n, `.${n}`, `_${n}`, `__${n}__`]
}
const dottedSuffixesDesc = (name: string) => {
const n = name.toLowerCase()
const idxs: number[] = []
for (let i = 0; i < n.length; i++) if (n[i] === ".") idxs.push(i)
const out = new Set<string>()
out.add(n) // allow exact whole-name "extensions" like "dockerfile"
for (const i of idxs) if (i + 1 < n.length) out.add(n.slice(i + 1))
return Array.from(out).sort((a, b) => b.length - a.length) // longest first
}
export function chooseIconName(path: string, type: "directory" | "file", expanded: boolean): IconName {
const base = basenameOf(path)
const baseLower = base.toLowerCase()
if (type === "directory") {
for (const cand of folderNameVariants(baseLower)) {
const icon = ICON_MAPS.folderNames[cand]
if (icon) return expanded ? toOpenVariant(icon) : icon
}
return expanded ? ICON_MAPS.defaults.folderOpen : ICON_MAPS.defaults.folder
}
const byName = ICON_MAPS.fileNames[baseLower]
if (byName) return byName
for (const ext of dottedSuffixesDesc(baseLower)) {
const icon = ICON_MAPS.fileExtensions[ext]
if (icon) return icon
}
return ICON_MAPS.defaults.file
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 922 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,267 @@
import type { FileContent } from "@opencode-ai/sdk/v2"
import { createEffect, createMemo, createResource, Match, on, Show, Switch, type JSX } from "solid-js"
import { useI18n } from "../context/i18n"
import {
dataUrlFromMediaValue,
hasMediaValue,
isBinaryContent,
mediaKindFromPath,
normalizeMimeType,
svgTextFromValue,
} from "../pierre/media"
export type FileMediaOptions = {
mode?: "auto" | "off"
path?: string
current?: unknown
before?: unknown
after?: unknown
deleted?: boolean
readFile?: (path: string) => Promise<FileContent | undefined>
onLoad?: () => void
onError?: (ctx: { kind: "image" | "audio" | "svg" }) => void
}
function mediaValue(cfg: FileMediaOptions, mode: "image" | "audio") {
if (cfg.current !== undefined) return cfg.current
if (mode === "image") return cfg.after ?? cfg.before
return cfg.after ?? cfg.before
}
export function FileMedia(props: { media?: FileMediaOptions; fallback: () => JSX.Element }) {
const i18n = useI18n()
const cfg = () => props.media
const kind = createMemo(() => {
const media = cfg()
if (!media || media.mode === "off") return
return mediaKindFromPath(media.path)
})
const isBinary = createMemo(() => {
const media = cfg()
if (!media || media.mode === "off") return false
if (kind()) return false
return isBinaryContent(media.current as any)
})
const onLoad = () => props.media?.onLoad?.()
const deleted = createMemo(() => {
const media = cfg()
const k = kind()
if (!media || !k) return false
if (media.deleted) return true
if (k === "svg") return false
if (media.current !== undefined) return false
return !hasMediaValue(media.after as any) && hasMediaValue(media.before as any)
})
const direct = createMemo(() => {
const media = cfg()
const k = kind()
if (!media || (k !== "image" && k !== "audio")) return
return dataUrlFromMediaValue(mediaValue(media, k), k)
})
const request = createMemo(() => {
const media = cfg()
const k = kind()
if (!media || (k !== "image" && k !== "audio")) return
if (media.current !== undefined) return
if (deleted()) return
if (direct()) return
if (!media.path || !media.readFile) return
return {
key: `${k}:${media.path}`,
kind: k,
path: media.path,
readFile: media.readFile,
onError: media.onError,
}
})
const [loaded] = createResource(request, async (input) => {
return input.readFile(input.path).then(
(result) => {
const src = dataUrlFromMediaValue(result as any, input.kind)
if (!src) {
input.onError?.({ kind: input.kind })
return { key: input.key, error: true as const }
}
return {
key: input.key,
src,
mime: input.kind === "audio" ? normalizeMimeType(result?.mimeType) : undefined,
}
},
() => {
input.onError?.({ kind: input.kind })
return { key: input.key, error: true as const }
},
)
})
const remote = createMemo(() => {
const input = request()
const value = loaded()
if (!input || !value || value.key !== input.key) return
return value
})
const src = createMemo(() => {
const value = remote()
return direct() ?? (value && "src" in value ? value.src : undefined)
})
const status = createMemo(() => {
if (direct()) return "ready" as const
if (!request()) return "idle" as const
if (loaded.loading) return "loading" as const
if (remote()?.error) return "error" as const
if (src()) return "ready" as const
return "idle" as const
})
const audioMime = createMemo(() => {
const value = remote()
return value && "mime" in value ? value.mime : undefined
})
const svgSource = createMemo(() => {
const media = cfg()
if (!media || kind() !== "svg") return
return svgTextFromValue(media.current as any)
})
const svgSrc = createMemo(() => {
const media = cfg()
if (!media || kind() !== "svg") return
return dataUrlFromMediaValue(media.current as any, "svg")
})
const svgInvalid = createMemo(() => {
const media = cfg()
if (!media || kind() !== "svg") return
if (svgSource() !== undefined) return
if (!hasMediaValue(media.current as any)) return
return [media.path, media.current] as const
})
createEffect(
on(
svgInvalid,
(value) => {
if (!value) return
cfg()?.onError?.({ kind: "svg" })
},
{ defer: true },
),
)
const kindLabel = (value: "image" | "audio") =>
i18n.t(value === "image" ? "ui.fileMedia.kind.image" : "ui.fileMedia.kind.audio")
return (
<Switch>
<Match when={kind() === "image" || kind() === "audio"}>
<Show
when={src()}
fallback={(() => {
const media = cfg()
const k = kind()
if (!media || (k !== "image" && k !== "audio")) return props.fallback()
const label = kindLabel(k)
if (deleted()) {
return (
<div class="flex min-h-40 items-center justify-center px-6 py-4 text-center text-text-weak">
{i18n.t("ui.fileMedia.state.removed", { kind: label })}
</div>
)
}
if (status() === "loading") {
return (
<div class="flex min-h-40 items-center justify-center px-6 py-4 text-center text-text-weak">
{i18n.t("ui.fileMedia.state.loading", { kind: label })}
</div>
)
}
if (status() === "error") {
return (
<div class="flex min-h-40 items-center justify-center px-6 py-4 text-center text-text-weak">
{i18n.t("ui.fileMedia.state.error", { kind: label })}
</div>
)
}
return (
<div class="flex min-h-40 items-center justify-center px-6 py-4 text-center text-text-weak">
{i18n.t("ui.fileMedia.state.unavailable", { kind: label })}
</div>
)
})()}
>
{(value) => {
const k = kind()
if (k !== "image" && k !== "audio") return props.fallback()
if (k === "image") {
return (
<div class="flex justify-center bg-background-stronger px-6 py-4">
<img
src={value()}
alt={cfg()?.path}
class="max-h-[60vh] max-w-full rounded border border-border-weak-base bg-background-base object-contain"
onLoad={onLoad}
/>
</div>
)
}
return (
<div class="flex justify-center bg-background-stronger px-6 py-4">
<audio class="w-full max-w-xl" controls preload="metadata" onLoadedMetadata={onLoad}>
<source src={value()} type={audioMime()} />
</audio>
</div>
)
}}
</Show>
</Match>
<Match when={kind() === "svg"}>
{(() => {
if (svgSource() === undefined && svgSrc() == null) return props.fallback()
return (
<div class="flex flex-col gap-4 px-6 py-4">
<Show when={svgSource() !== undefined}>{props.fallback()}</Show>
<Show when={svgSrc()}>
{(value) => (
<div class="flex justify-center">
<img
src={value()}
alt={cfg()?.path}
class="max-h-[60vh] max-w-full rounded border border-border-weak-base bg-background-base object-contain"
onLoad={onLoad}
/>
</div>
)}
</Show>
</div>
)
})()}
</Match>
<Match when={isBinary()}>
<div class="flex min-h-56 flex-col items-center justify-center gap-2 px-6 py-10 text-center">
<div class="text-14-semibold text-text-strong">
{cfg()?.path?.split("/").pop() ?? i18n.t("ui.fileMedia.binary.title")}
</div>
<div class="text-14-regular text-text-weak">
{(() => {
const path = cfg()?.path
if (!path) return i18n.t("ui.fileMedia.binary.description.default")
return i18n.t("ui.fileMedia.binary.description.path", { path })
})()}
</div>
</div>
</Match>
<Match when={true}>{props.fallback()}</Match>
</Switch>
)
}

View File

@@ -0,0 +1,72 @@
import { Portal } from "solid-js/web"
import { useI18n } from "../context/i18n"
import { Icon } from "./icon"
export function FileSearchBar(props: {
pos: () => { top: number; right: number }
query: () => string
index: () => number
count: () => number
setInput: (el: HTMLInputElement) => void
onInput: (value: string) => void
onKeyDown: (event: KeyboardEvent) => void
onClose: () => void
onPrev: () => void
onNext: () => void
}) {
const i18n = useI18n()
return (
<Portal>
<div
class="fixed z-50 flex h-8 items-center gap-2 rounded-md border border-border-base bg-background-base px-3 shadow-md"
style={{
top: `${props.pos().top}px`,
right: `${props.pos().right}px`,
}}
onPointerDown={(e) => e.stopPropagation()}
>
<Icon name="magnifying-glass" size="small" class="text-text-weak shrink-0" />
<input
ref={props.setInput}
placeholder={i18n.t("ui.fileSearch.placeholder")}
value={props.query()}
class="w-40 bg-transparent outline-none text-14-regular text-text-strong placeholder:text-text-weak"
onInput={(e) => props.onInput(e.currentTarget.value)}
onKeyDown={(e) => props.onKeyDown(e as KeyboardEvent)}
/>
<div class="shrink-0 text-12-regular text-text-weak tabular-nums text-right" style={{ width: "10ch" }}>
{props.count() ? `${props.index() + 1}/${props.count()}` : "0/0"}
</div>
<div class="flex items-center">
<button
type="button"
class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong disabled:opacity-40 disabled:pointer-events-none"
disabled={props.count() === 0}
aria-label={i18n.t("ui.fileSearch.previousMatch")}
onClick={props.onPrev}
>
<Icon name="chevron-down" size="small" class="rotate-180" />
</button>
<button
type="button"
class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong disabled:opacity-40 disabled:pointer-events-none"
disabled={props.count() === 0}
aria-label={i18n.t("ui.fileSearch.nextMatch")}
onClick={props.onNext}
>
<Icon name="chevron-down" size="small" />
</button>
</div>
<button
type="button"
class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong"
aria-label={i18n.t("ui.fileSearch.close")}
onClick={props.onClose}
>
<Icon name="close-small" size="small" />
</button>
</div>
</Portal>
)
}

View File

@@ -0,0 +1,193 @@
import { DIFFS_TAG_NAME, FileDiff, VirtualizedFileDiff } from "@pierre/diffs"
import { type PreloadFileDiffResult, type PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
import { createEffect, onCleanup, onMount, Show, splitProps } from "solid-js"
import { Dynamic, isServer } from "solid-js/web"
import { useWorkerPool } from "../context/worker-pool"
import { createDefaultOptions, styleVariables } from "../pierre"
import { markCommentedDiffLines } from "../pierre/commented-lines"
import { fixDiffSelection } from "../pierre/diff-selection"
import {
applyViewerScheme,
clearReadyWatcher,
createReadyWatcher,
notifyShadowReady,
observeViewerScheme,
} from "../pierre/file-runtime"
import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer"
import { File, type DiffFileProps, type FileProps } from "./file"
type DiffPreload<T> = PreloadMultiFileDiffResult<T> | PreloadFileDiffResult<T>
type SSRDiffFileProps<T> = DiffFileProps<T> & {
preloadedDiff: DiffPreload<T>
}
function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
let container!: HTMLDivElement
let fileDiffRef!: HTMLElement
let fileDiffInstance: FileDiff<T> | undefined
let sharedVirtualizer: NonNullable<ReturnType<typeof acquireVirtualizer>> | undefined
const ready = createReadyWatcher()
const workerPool = useWorkerPool(props.diffStyle)
const [local, others] = splitProps(props, [
"mode",
"media",
"fileDiff",
"before",
"after",
"class",
"classList",
"annotations",
"selectedLines",
"commentedLines",
"onLineSelected",
"onLineSelectionEnd",
"onLineNumberSelectionEnd",
"onRendered",
"preloadedDiff",
])
const getRoot = () => fileDiffRef?.shadowRoot ?? undefined
const getVirtualizer = () => {
if (sharedVirtualizer) return sharedVirtualizer.virtualizer
const result = acquireVirtualizer(container)
if (!result) return
sharedVirtualizer = result
return result.virtualizer
}
const setSelectedLines = (range: DiffFileProps<T>["selectedLines"], attempt = 0) => {
const diff = fileDiffInstance
if (!diff) return
const fixed = fixDiffSelection(getRoot(), range ?? null)
if (fixed === undefined) {
if (attempt >= 120) return
requestAnimationFrame(() => setSelectedLines(range ?? null, attempt + 1))
return
}
diff.setSelectedLines(fixed)
}
const notifyRendered = () => {
notifyShadowReady({
state: ready,
container,
getRoot,
isReady: (root) => root.querySelector("[data-line]") != null,
settleFrames: 1,
onReady: () => {
setSelectedLines(local.selectedLines ?? null)
local.onRendered?.()
},
})
}
onMount(() => {
if (isServer) return
onCleanup(observeViewerScheme(() => fileDiffRef))
const virtualizer = getVirtualizer()
const annotations = local.annotations ?? local.preloadedDiff.annotations ?? []
fileDiffInstance = virtualizer
? new VirtualizedFileDiff<T>(
{
...createDefaultOptions(props.diffStyle),
...others,
...local.preloadedDiff.options,
},
virtualizer,
virtualMetrics,
workerPool,
)
: new FileDiff<T>(
{
...createDefaultOptions(props.diffStyle),
...others,
...local.preloadedDiff.options,
},
workerPool,
)
applyViewerScheme(fileDiffRef)
// @ts-expect-error private field required for hydration
fileDiffInstance.fileContainer = fileDiffRef
fileDiffInstance.hydrate(
local.fileDiff
? {
fileDiff: local.fileDiff,
lineAnnotations: annotations,
fileContainer: fileDiffRef,
containerWrapper: container,
prerenderedHTML: local.preloadedDiff.prerenderedHTML,
}
: {
oldFile: local.before,
newFile: local.after,
lineAnnotations: annotations,
fileContainer: fileDiffRef,
containerWrapper: container,
prerenderedHTML: local.preloadedDiff.prerenderedHTML,
},
)
notifyRendered()
})
createEffect(() => {
const diff = fileDiffInstance
if (!diff) return
diff.setLineAnnotations(local.annotations ?? [])
diff.rerender()
})
createEffect(() => {
setSelectedLines(local.selectedLines ?? null)
})
createEffect(() => {
const ranges = local.commentedLines ?? []
requestAnimationFrame(() => {
const root = getRoot()
if (!root) return
markCommentedDiffLines(root, ranges)
})
})
onCleanup(() => {
clearReadyWatcher(ready)
fileDiffInstance?.cleanUp()
sharedVirtualizer?.release()
sharedVirtualizer = undefined
})
return (
<div
data-component="file"
data-mode="diff"
style={styleVariables}
class={local.class}
classList={local.classList}
ref={container}
>
<Dynamic component={DIFFS_TAG_NAME} ref={fileDiffRef} id="ssr-diff">
<Show when={isServer}>
<template shadowrootmode="open" innerHTML={local.preloadedDiff.prerenderedHTML} />
</Show>
</Dynamic>
</div>
)
}
export type FileSSRProps<T = {}> = FileProps<T>
export function FileSSR<T>(props: FileSSRProps<T>) {
if (props.mode !== "diff" || !props.preloadedDiff) return File(props)
return DiffSSRViewer(props as SSRDiffFileProps<T>)
}

View File

@@ -0,0 +1,42 @@
[data-component="file"] {
content-visibility: auto;
}
[data-component="file"][data-mode="text"] {
overflow: hidden;
}
[data-component="file"][data-mode="diff"] {
[data-slot="diff-hunk-separator-line-number"] {
position: sticky;
left: 0;
background-color: var(--surface-diff-hidden-strong);
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
[data-slot="diff-hunk-separator-line-number-icon"] {
aspect-ratio: 1;
width: 24px;
height: 24px;
color: var(--icon-strong-base);
}
}
[data-slot="diff-hunk-separator-content"] {
position: sticky;
background-color: var(--surface-diff-hidden-base);
color: var(--text-base);
width: var(--diffs-column-content-width);
left: var(--diffs-column-number-width);
padding-left: 8px;
user-select: none;
cursor: default;
text-align: left;
[data-slot="diff-hunk-separator-content-span"] {
mix-blend-mode: var(--text-mix-blend-mode);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,48 @@
// @ts-nocheck
import * as mod from "./font"
const docs = `### Overview
Uses native system font stacks for sans and mono typography.
Optional compatibility component. Existing roots can keep rendering it, but it does nothing.
### API
- No props.
### Variants and states
- No variants.
### Behavior
- Compatibility wrapper only. No font assets are injected or preloaded.
### Accessibility
- Not applicable.
### Theming/tokens
- Theme tokens come from CSS variables, not this component.
`
export default {
title: "UI/Font",
id: "components-font",
component: mod.Font,
tags: ["autodocs"],
parameters: {
docs: {
description: {
component: docs,
},
},
},
}
export const Basic = {
render: () => (
<div style={{ display: "grid", gap: "8px" }}>
<mod.Font />
<div style={{ "font-family": "var(--font-family-sans)" }}>OpenCode Sans Sample</div>
<div style={{ "font-family": "var(--font-family-mono)" }}>OpenCode Mono Sample</div>
</div>
),
}

View File

@@ -0,0 +1 @@
export const Font = () => null

View File

@@ -0,0 +1,61 @@
[data-slot="hover-card-trigger"] {
display: flex;
width: 100%;
min-width: 0;
}
[data-component="hover-card-content"] {
z-index: 50;
min-width: 200px;
max-width: 320px;
max-height: calc(100vh - 1rem);
border-radius: 8px;
background-color: var(--surface-raised-stronger-non-alpha);
pointer-events: auto;
border: 1px solid color-mix(in oklch, var(--border-base) 50%, transparent);
background-clip: padding-box;
box-shadow: var(--shadow-md);
transform-origin: var(--kb-hovercard-content-transform-origin);
&:focus-within {
outline: none;
}
&[data-closed] {
animation: hover-card-close 0.15s ease-out;
}
&[data-expanded] {
animation: hover-card-open 0.15s ease-out;
}
[data-slot="hover-card-body"] {
padding: 4px;
max-height: inherit;
overflow: hidden;
}
}
@keyframes hover-card-open {
from {
opacity: 0;
transform: scale(0.96);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes hover-card-close {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.96);
}
}

View File

@@ -0,0 +1,70 @@
// @ts-nocheck
import { createSignal } from "solid-js"
import * as mod from "./hover-card"
const docs = `### Overview
Hover-triggered card for lightweight previews and metadata.
Use for short summaries; avoid dense interactive controls.
### API
- Required: \`trigger\` element.
- Children render inside the hover card body.
### Variants and states
- None; content and trigger are fully composable.
### Behavior
- Opens on hover/focus over the trigger.
### Accessibility
- TODO: confirm focus and hover intent behavior from Kobalte.
### Theming/tokens
- Uses \`data-component="hover-card-content"\` and slots for styling.
`
export default {
title: "UI/HoverCard",
id: "components-hover-card",
component: mod.HoverCard,
tags: ["autodocs"],
parameters: {
docs: {
description: {
component: docs,
},
},
},
}
export const Basic = {
render: () => (
<mod.HoverCard trigger={<span style={{ "text-decoration": "underline", cursor: "default" }}>Hover me</span>}>
<div style={{ display: "grid", gap: "6px" }}>
<div style={{ "font-weight": 600 }}>Preview</div>
<div style={{ color: "var(--text-weak)", "font-size": "12px" }}>Short supporting text.</div>
</div>
</mod.HoverCard>
),
}
export const InlineMount = {
render: () => {
const [mount, setMount] = createSignal<HTMLDivElement | undefined>(undefined)
return (
<div ref={setMount} style={{ padding: "16px", border: "1px dashed var(--border-weak)" }}>
<mod.HoverCard
mount={mount()}
trigger={<span style={{ "text-decoration": "underline", cursor: "default" }}>Hover me</span>}
>
<div style={{ display: "grid", gap: "6px" }}>
<div style={{ "font-weight": 600 }}>Mounted inside</div>
<div style={{ color: "var(--text-weak)", "font-size": "12px" }}>Uses custom mount node.</div>
</div>
</mod.HoverCard>
</div>
)
},
}

View File

@@ -0,0 +1,32 @@
import { HoverCard as Kobalte } from "@kobalte/core/hover-card"
import { ComponentProps, JSXElement, ParentProps, splitProps } from "solid-js"
export interface HoverCardProps extends ParentProps, Omit<ComponentProps<typeof Kobalte>, "children"> {
trigger: JSXElement
mount?: HTMLElement
class?: ComponentProps<"div">["class"]
classList?: ComponentProps<"div">["classList"]
}
export function HoverCard(props: HoverCardProps) {
const [local, rest] = splitProps(props, ["trigger", "mount", "class", "classList", "children"])
return (
<Kobalte gutter={4} {...rest}>
<Kobalte.Trigger as="div" data-slot="hover-card-trigger" tabIndex={-1}>
{local.trigger}
</Kobalte.Trigger>
<Kobalte.Portal mount={local.mount}>
<Kobalte.Content
data-component="hover-card-content"
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
>
<div data-slot="hover-card-body">{local.children}</div>
</Kobalte.Content>
</Kobalte.Portal>
</Kobalte>
)
}

View File

@@ -0,0 +1,181 @@
[data-component="icon-button"] {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
text-decoration: none;
user-select: none;
aspect-ratio: 1;
flex-shrink: 0;
&[data-variant="primary"] {
background-color: var(--icon-strong-base);
[data-slot="icon-svg"] {
/* color: var(--icon-weak-base); */
color: var(--icon-invert-base);
/* &:hover:not(:disabled) { */
/* color: var(--icon-weak-hover); */
/* } */
/* &:active:not(:disabled) { */
/* color: var(--icon-strong-active); */
/* } */
}
&:hover:not(:disabled) {
background-color: var(--icon-strong-hover);
}
&:focus:not(:disabled) {
background-color: var(--icon-strong-focus);
}
&:active:not(:disabled) {
background-color: var(--icon-strong-active);
}
&:disabled {
background-color: var(--icon-strong-disabled);
[data-slot="icon-svg"] {
color: var(--icon-invert-base);
}
}
}
&[data-variant="secondary"] {
border: transparent;
background-color: var(--button-secondary-base);
color: var(--text-strong);
box-shadow: var(--shadow-xs-border-base);
&:hover:not(:disabled) {
background-color: var(--button-secondary-hover);
}
&:focus:not(:disabled) {
background-color: var(--button-secondary-base);
}
&:focus-visible:not(:active) {
background-color: var(--button-secondary-base);
box-shadow: var(--shadow-xs-border-focus);
}
&:focus-visible:active {
box-shadow: none;
}
&:active:not(:disabled) {
background-color: var(--button-secondary-base);
}
[data-slot="icon-svg"] {
color: var(--icon-strong-base);
}
&:disabled {
background-color: var(--icon-strong-disabled);
color: var(--icon-invert-base);
cursor: not-allowed;
}
}
&[data-variant="ghost"] {
background-color: transparent;
/* color: var(--icon-base); */
[data-slot="icon-svg"] {
color: var(--icon-base);
}
&:hover:not(:disabled) {
background-color: var(--surface-base-hover);
/* [data-slot="icon-svg"] { */
/* color: var(--icon-hover); */
/* } */
}
&:focus-visible:not(:disabled) {
background-color: var(--surface-base-hover);
}
&:active:not(:disabled) {
background-color: var(--surface-base-active);
/* [data-slot="icon-svg"] { */
/* color: var(--icon-active); */
/* } */
}
&:selected:not(:disabled) {
background-color: var(--surface-base-active);
/* [data-slot="icon-svg"] { */
/* color: var(--icon-selected); */
/* } */
}
&:disabled {
color: var(--icon-invert-base);
cursor: not-allowed;
}
}
&[data-size="normal"] {
width: 24px;
height: 24px;
font-size: var(--font-size-small);
line-height: var(--line-height-large);
gap: calc(var(--spacing) * 0.5);
}
&[data-size="small"] {
width: 20px;
height: 20px;
}
&[data-size="large"] {
height: 32px;
/* padding: 0 8px 0 6px; */
gap: 8px;
/* text-12-medium */
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
font-style: normal;
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large); /* 166.667% */
letter-spacing: var(--letter-spacing-normal);
}
&:focus {
outline: none;
}
}
@media (prefers-reduced-motion: no-preference) {
[data-component="icon-button"][data-icon="stop"] [data-slot="icon-svg"] rect {
transform-origin: center;
transform-box: fill-box;
animation: stop-pulse 1.8s ease-in-out infinite;
}
}
@keyframes stop-pulse {
0%,
100% {
transform: scale(0.95);
}
50% {
transform: scale(1.12);
}
}
[data-component="icon-button"].titlebar-icon {
width: 32px;
height: 24px;
aspect-ratio: auto;
}
[data-component="icon-button"].titlebar-icon[data-variant="ghost"][aria-expanded="true"] {
background-color: var(--surface-raised-base-active);
}
[data-component="icon-button"].titlebar-icon[data-variant="ghost"][aria-expanded="true"] [data-slot="icon-svg"] {
color: var(--icon-strong-base);
}
[data-component="icon-button"].titlebar-icon[data-variant="ghost"][aria-expanded="true"]:hover:not(:disabled) {
background-color: var(--surface-raised-base-active);
}

View File

@@ -0,0 +1,74 @@
// @ts-nocheck
import * as mod from "./icon-button"
import { create } from "../storybook/scaffold"
const docs = `### Overview
Compact icon-only button with size and variant control.
Use \`Button\` for text labels and primary actions.
### API
- Required: \`icon\` icon name.
- Optional: \`size\`, \`iconSize\`, \`variant\`.
- Inherits Kobalte Button props and native button attributes.
### Variants and states
- Variants: primary, secondary, ghost.
- Sizes: small, normal, large.
### Behavior
- Icon size adapts to button size unless overridden.
### Accessibility
- Provide \`aria-label\` when there is no visible text.
### Theming/tokens
- Uses \`data-component="icon-button"\` and size/variant data attributes.
`
const story = create({ title: "UI/IconButton", mod, args: { icon: "check", "aria-label": "Icon" } })
export default {
title: "UI/IconButton",
id: "components-icon-button",
component: story.meta.component,
tags: ["autodocs"],
parameters: {
docs: {
description: {
component: docs,
},
},
},
}
export const Basic = story.Basic
export const Sizes = {
render: () => (
<div style={{ display: "flex", gap: "12px", "align-items": "center" }}>
<mod.IconButton icon="check" size="small" aria-label="Small" />
<mod.IconButton icon="check" size="normal" aria-label="Normal" />
<mod.IconButton icon="check" size="large" aria-label="Large" />
</div>
),
}
export const Variants = {
render: () => (
<div style={{ display: "flex", gap: "12px", "align-items": "center" }}>
<mod.IconButton icon="check" variant="primary" aria-label="Primary" />
<mod.IconButton icon="check" variant="secondary" aria-label="Secondary" />
<mod.IconButton icon="check" variant="ghost" aria-label="Ghost" />
</div>
),
}
export const IconSizeOverride = {
render: () => (
<div style={{ display: "flex", gap: "12px", "align-items": "center" }}>
<mod.IconButton icon="check" size="small" iconSize="large" aria-label="Small with large icon" />
<mod.IconButton icon="check" size="large" iconSize="small" aria-label="Large with small icon" />
</div>
),
}

View File

@@ -0,0 +1,29 @@
import { Button as Kobalte } from "@kobalte/core/button"
import { type ComponentProps, splitProps } from "solid-js"
import { Icon, IconProps } from "./icon"
export interface IconButtonProps extends ComponentProps<typeof Kobalte> {
icon: IconProps["name"]
size?: "small" | "normal" | "large"
iconSize?: IconProps["size"]
variant?: "primary" | "secondary" | "ghost"
}
export function IconButton(props: ComponentProps<"button"> & IconButtonProps) {
const [split, rest] = splitProps(props, ["variant", "size", "iconSize", "class", "classList"])
return (
<Kobalte
{...rest}
data-component="icon-button"
data-icon={props.icon}
data-size={split.size || "normal"}
data-variant={split.variant || "secondary"}
classList={{
...split.classList,
[split.class ?? ""]: !!split.class,
}}
>
<Icon name={props.icon} size={split.iconSize ?? (split.size === "large" ? "normal" : "small")} />
</Kobalte>
)
}

View File

@@ -0,0 +1,34 @@
[data-component="icon"] {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
/* resize: both; */
aspect-ratio: 1/1;
color: var(--icon-base);
&[data-size="small"] {
width: 16px;
height: 16px;
}
&[data-size="normal"] {
width: 20px;
height: 20px;
}
&[data-size="medium"] {
width: 24px;
height: 24px;
}
&[data-size="large"] {
width: 24px;
height: 24px;
}
[data-slot="icon-svg"] {
width: 100%;
height: auto;
}
}

View File

@@ -0,0 +1,170 @@
// @ts-nocheck
import * as mod from "./icon"
import { create } from "../storybook/scaffold"
const docs = `### Overview
Inline icon renderer using the built-in OpenCode icon set.
Use with \`Button\`, \`IconButton\`, and menu items.
### API
- Required: \`name\` (icon key).
- Optional: \`size\` (small | normal | medium | large).
- Accepts standard SVG props.
### Variants and states
- Size variants only.
### Behavior
- Uses an internal SVG path map.
### Accessibility
- Icons are aria-hidden by default; wrap with accessible text when needed.
### Theming/tokens
- Uses \`data-component="icon"\` with size data attributes.
`
const names = [
"align-right",
"arrow-up",
"arrow-left",
"arrow-right",
"archive",
"bubble-5",
"prompt",
"brain",
"bullet-list",
"check-small",
"chevron-down",
"chevron-left",
"chevron-right",
"chevron-grabber-vertical",
"chevron-double-right",
"circle-x",
"close",
"close-small",
"checklist",
"console",
"expand",
"collapse",
"code",
"code-lines",
"circle-ban-sign",
"edit-small-2",
"eye",
"enter",
"folder",
"file-tree",
"file-tree-active",
"magnifying-glass",
"plus-small",
"plus",
"new-session",
"pencil-line",
"mcp",
"glasses",
"magnifying-glass-menu",
"window-cursor",
"task",
"stop",
"layout-left",
"layout-left-partial",
"layout-left-full",
"layout-right",
"layout-right-partial",
"layout-right-full",
"square-arrow-top-right",
"open-file",
"speech-bubble",
"comment",
"folder-add-left",
"github",
"discord",
"layout-bottom",
"layout-bottom-partial",
"layout-bottom-full",
"dot-grid",
"circle-check",
"copy",
"check",
"photo",
"share",
"download",
"menu",
"server",
"branch",
"edit",
"help",
"settings-gear",
"dash",
"cloud-upload",
"trash",
"sliders",
"keyboard",
"selector",
"arrow-down-to-line",
"warning",
"link",
"providers",
"models",
]
const story = create({ title: "UI/Icon", mod, args: { name: "check" } })
export default {
title: "UI/Icon",
id: "components-icon",
component: story.meta.component,
tags: ["autodocs"],
parameters: {
docs: {
description: {
component: docs,
},
},
},
argTypes: {
name: {
control: "select",
options: names,
},
size: {
control: "select",
options: ["small", "normal", "medium", "large"],
},
},
}
export const Basic = story.Basic
export const Sizes = {
render: () => (
<div style={{ display: "flex", gap: "12px", "align-items": "center" }}>
<mod.Icon name="check" size="small" />
<mod.Icon name="check" size="normal" />
<mod.Icon name="check" size="medium" />
<mod.Icon name="check" size="large" />
</div>
),
}
export const Gallery = {
render: () => (
<div
style={{
display: "grid",
gap: "12px",
"grid-template-columns": "repeat(auto-fill, minmax(88px, 1fr))",
}}
>
{names.map((name) => (
<div style={{ display: "grid", gap: "6px", "justify-items": "center" }}>
<mod.Icon name={name} />
<div style={{ "font-size": "10px", color: "var(--text-weak)", "text-align": "center" }}>{name}</div>
</div>
))}
</div>
),
}

View File

@@ -0,0 +1,133 @@
import { splitProps, type ComponentProps } from "solid-js"
const icons = {
"align-right": `<path d="M12.292 6.04167L16.2503 9.99998L12.292 13.9583M2.91699 9.99998H15.6253M17.0837 3.75V16.25" stroke="currentColor" stroke-linecap="square"/>`,
"arrow-up": `<path fill-rule="evenodd" clip-rule="evenodd" d="M9.99991 2.24121L16.0921 8.33343L15.2083 9.21731L10.6249 4.63397V17.5001H9.37492V4.63398L4.7916 9.21731L3.90771 8.33343L9.99991 2.24121Z" fill="currentColor"/>`,
"arrow-left": `<path d="M8.33464 4.58398L2.91797 10.0007L8.33464 15.4173M3.33464 10.0007H17.0846" stroke="currentColor" stroke-linecap="square"/>`,
"arrow-right": `<path d="M11.6654 4.58398L17.082 10.0007L11.6654 15.4173M16.6654 10.0007H2.91536" stroke="currentColor" stroke-linecap="square"/>`,
archive: `<path d="M16.8747 6.24935H17.3747V5.74935H16.8747V6.24935ZM16.8747 16.8743V17.3743H17.3747V16.8743H16.8747ZM3.12467 16.8743H2.62467V17.3743H3.12467V16.8743ZM3.12467 6.24935V5.74935H2.62467V6.24935H3.12467ZM2.08301 2.91602V2.41602H1.58301V2.91602H2.08301ZM17.9163 2.91602H18.4163V2.41602H17.9163V2.91602ZM17.9163 6.24935V6.74935H18.4163V6.24935H17.9163ZM2.08301 6.24935H1.58301V6.74935H2.08301V6.24935ZM8.33301 9.08268H7.83301V10.0827H8.33301V9.58268V9.08268ZM11.6663 10.0827H12.1663V9.08268H11.6663V9.58268V10.0827ZM16.8747 6.24935H16.3747V16.8743H16.8747H17.3747V6.24935H16.8747ZM16.8747 16.8743V16.3743H3.12467V16.8743V17.3743H16.8747V16.8743ZM3.12467 16.8743H3.62467V6.24935H3.12467H2.62467V16.8743H3.12467ZM3.12467 6.24935V6.74935H16.8747V6.24935V5.74935H3.12467V6.24935ZM2.08301 2.91602V3.41602H17.9163V2.91602V2.41602H2.08301V2.91602ZM17.9163 2.91602H17.4163V6.24935H17.9163H18.4163V2.91602H17.9163ZM17.9163 6.24935V5.74935H2.08301V6.24935V6.74935H17.9163V6.24935ZM2.08301 6.24935H2.58301V2.91602H2.08301H1.58301V6.24935H2.08301ZM8.33301 9.58268V10.0827H11.6663V9.58268V9.08268H8.33301V9.58268Z" fill="currentColor"/>`,
"bubble-5": `<path d="M18.3327 9.99935C18.3327 5.57227 15.0919 2.91602 9.99935 2.91602C4.90676 2.91602 1.66602 5.57227 1.66602 9.99935C1.66602 11.1487 2.45505 13.1006 2.57637 13.3939C2.58707 13.4197 2.59766 13.4434 2.60729 13.4697C2.69121 13.6987 3.04209 14.9354 1.66602 16.7674C3.51787 17.6528 5.48453 16.1973 5.48453 16.1973C6.84518 16.9193 8.46417 17.0827 9.99935 17.0827C15.0919 17.0827 18.3327 14.4264 18.3327 9.99935Z" stroke="currentColor" stroke-linecap="square"/>`,
prompt: `<path d="M14.5841 12.0807H17.9193V2.91406H5.6276V6.2474M14.5859 6.2474H2.08594V15.4141H5.0026V17.4974L8.7526 15.4141H14.5859V6.2474Z" stroke="currentColor" stroke-linecap="square"/>`,
brain: `<path d="M13.332 8.7487C11.4911 8.7487 9.9987 7.25631 9.9987 5.41536M6.66536 11.2487C8.50631 11.2487 9.9987 12.7411 9.9987 14.582M9.9987 2.78209L9.9987 17.0658M16.004 15.0475C17.1255 14.5876 17.9154 13.4849 17.9154 12.1978C17.9154 11.3363 17.5615 10.5575 16.9913 9.9987C17.5615 9.43991 17.9154 8.66108 17.9154 7.79962C17.9154 6.21199 16.7136 4.90504 15.1702 4.73878C14.7858 3.21216 13.4039 2.08203 11.758 2.08203C11.1171 2.08203 10.5162 2.25337 9.9987 2.55275C9.48117 2.25337 8.88032 2.08203 8.23944 2.08203C6.59353 2.08203 5.21157 3.21216 4.82722 4.73878C3.28377 4.90504 2.08203 6.21199 2.08203 7.79962C2.08203 8.66108 2.43585 9.43991 3.00609 9.9987C2.43585 10.5575 2.08203 11.3363 2.08203 12.1978C2.08203 13.4849 2.87191 14.5876 3.99339 15.0475C4.46688 16.7033 5.9917 17.9154 7.79962 17.9154C8.61335 17.9154 9.36972 17.6698 9.9987 17.2488C10.6277 17.6698 11.384 17.9154 12.1978 17.9154C14.0057 17.9154 15.5305 16.7033 16.004 15.0475Z" stroke="currentColor"/>`,
fork: `<path d="M2.91602 7.91406L2.91602 2.91406H7.91602M12.0827 2.91406H17.0827L17.0827 7.91406M9.99935 9.9974L9.99935 17.0807M9.99935 9.9974L3.33268 3.33073M9.99935 9.9974L16.666 3.33073" stroke="currentColor" stroke-linecap="square"/>`,
"bullet-list": `<path d="M9.58329 13.7497H17.0833M9.58329 6.24967H17.0833M6.24996 6.24967C6.24996 7.17015 5.50377 7.91634 4.58329 7.91634C3.66282 7.91634 2.91663 7.17015 2.91663 6.24967C2.91663 5.3292 3.66282 4.58301 4.58329 4.58301C5.50377 4.58301 6.24996 5.3292 6.24996 6.24967ZM6.24996 13.7497C6.24996 14.6701 5.50377 15.4163 4.58329 15.4163C3.66282 15.4163 2.91663 14.6701 2.91663 13.7497C2.91663 12.8292 3.66282 12.083 4.58329 12.083C5.50377 12.083 6.24996 12.8292 6.24996 13.7497Z" stroke="currentColor" stroke-linecap="square"/>`,
"check-small": `<path d="M6.5 11.4412L8.97059 13.5L13.5 6.5" stroke="currentColor" stroke-linecap="square"/>`,
"chevron-down": `<path d="M6.6665 8.33325L9.99984 11.6666L13.3332 8.33325" stroke="currentColor" stroke-linecap="square"/>`,
"chevron-left": `<path d="M12 15L7 10L12 5" stroke="currentColor" stroke-linecap="square"/>`,
"chevron-right": `<path d="M8 15L13 10L8 5" stroke="currentColor" stroke-linecap="square"/>`,
"chevron-grabber-vertical": `<path d="M6.66675 12.4998L10.0001 15.8332L13.3334 12.4998M6.66675 7.49984L10.0001 4.1665L13.3334 7.49984" stroke="currentColor" stroke-linecap="square"/>`,
"chevron-double-right": `<path d="M11.6654 13.3346L14.9987 10.0013L11.6654 6.66797M5.83203 13.3346L9.16536 10.0013L5.83203 6.66797" stroke="currentColor" stroke-linecap="square"/>`,
"circle-x": `<path fill-rule="evenodd" clip-rule="evenodd" d="M1.6665 10.0003C1.6665 5.39795 5.39746 1.66699 9.99984 1.66699C14.6022 1.66699 18.3332 5.39795 18.3332 10.0003C18.3332 14.6027 14.6022 18.3337 9.99984 18.3337C5.39746 18.3337 1.6665 14.6027 1.6665 10.0003ZM7.49984 6.91107L6.91058 7.50033L9.41058 10.0003L6.91058 12.5003L7.49984 13.0896L9.99984 10.5896L12.4998 13.0896L13.0891 12.5003L10.5891 10.0003L13.0891 7.50033L12.4998 6.91107L9.99984 9.41107L7.49984 6.91107Z" fill="currentColor"/>`,
close: `<path d="M3.75 3.75L16.25 16.25M16.25 3.75L3.75 16.25" stroke="currentColor" stroke-linecap="square"/>`,
"close-small": `<path d="M6 6L14 14M14 6L6 14" stroke="currentColor" stroke-linecap="square"/>`,
checklist: `<path d="M9.58342 13.7498H17.0834M9.58342 6.24984H17.0834M2.91675 6.6665L4.58341 7.9165L7.08341 4.1665M2.91675 14.1665L4.58341 15.4165L7.08341 11.6665" stroke="currentColor" stroke-linecap="square"/>`,
console: `<path d="M3.75 5.4165L8.33333 9.99984L3.75 14.5832M10.4167 14.5832H16.25" stroke="currentColor" stroke-linecap="square"/>`,
terminal: `<path d="M6.5 8L8.64286 10L6.5 12M10.9286 12H13.5M2 18H18V2H2V18Z" stroke="currentColor" stroke-linecap="square"/>`,
"terminal-active": `<path d="M2 18H18V2H2V18Z" fill="currentColor" fill-opacity="0.1"/>
<path d="M6.5 8L8.64286 10L6.5 12M10.9286 12H13.5M2 18H18V2H2V18Z" stroke="currentColor" stroke-linecap="square"/>`,
review: `<path d="M7 14.5H13M7 7.99512H10.0049M10.0049 7.99512H13M10.0049 7.99512V5M10.0049 7.99512V11M18 18V2L2 2L2 18H18Z" stroke="currentColor"/>`,
"review-active": `<path d="M18 18V2L2 2L2 18H18Z" fill="currentColor" fill-opacity="0.1"/>
<path d="M7 14.5H13M7 7.99512H10.0049M10.0049 7.99512H13M10.0049 7.99512V5M10.0049 7.99512V11M18 18V2L2 2L2 18H18Z" stroke="currentColor"/>`,
expand: `<path d="M4.58301 10.4163V15.4163H9.58301M10.4163 4.58301H15.4163V9.58301" stroke="currentColor" stroke-linecap="square"/>`,
collapse: `<path d="M16.666 8.33398H11.666V3.33398" stroke="currentColor" stroke-linecap="square"/><path d="M8.33398 16.666V11.666H3.33398" stroke="currentColor" stroke-linecap="square"/>`,
code: `<path d="M8.7513 7.5013L6.2513 10.0013L8.7513 12.5013M11.2513 7.5013L13.7513 10.0013L11.2513 12.5013M2.91797 2.91797H17.0846V17.0846H2.91797V2.91797Z" stroke="currentColor"/>`,
"code-lines": `<path d="M2.08325 3.75H11.2499M14.5833 3.75H17.9166M2.08325 10L7.08325 10M10.4166 10L17.9166 10M2.08325 16.25L8.74992 16.25M12.0833 16.25L17.9166 16.25" stroke="currentColor" stroke-linecap="square" stroke-linejoin="round"/>`,
"circle-ban-sign": `<path d="M15.3675 4.63087L4.55742 15.441M17.9163 9.9987C17.9163 14.371 14.3719 17.9154 9.99967 17.9154C7.81355 17.9154 5.83438 17.0293 4.40175 15.5966C2.96911 14.164 2.08301 12.1848 2.08301 9.9987C2.08301 5.62644 5.62742 2.08203 9.99967 2.08203C12.1858 2.08203 14.165 2.96813 15.5976 4.40077C17.0302 5.8334 17.9163 7.81257 17.9163 9.9987Z" stroke="currentColor" stroke-linecap="round"/>`,
"edit-small-2": `<path d="M17.0834 17.0833V17.5833H17.5834V17.0833H17.0834ZM2.91675 17.0833H2.41675V17.5833H2.91675V17.0833ZM2.91675 2.91659V2.41659H2.41675V2.91659H2.91675ZM9.58341 3.41659H10.0834V2.41659H9.58341V2.91659V3.41659ZM17.5834 10.4166V9.91659H16.5834V10.4166H17.0834H17.5834ZM10.4167 7.08325L10.0632 6.7297L9.91675 6.87615V7.08325H10.4167ZM10.4167 9.58325H9.91675V10.0833H10.4167V9.58325ZM12.9167 9.58325V10.0833H13.1239L13.2703 9.93681L12.9167 9.58325ZM15.4167 2.08325L15.7703 1.7297L15.4167 1.37615L15.0632 1.7297L15.4167 2.08325ZM17.9167 4.58325L18.2703 4.93681L18.6239 4.58325L18.2703 4.2297L17.9167 4.58325ZM17.0834 17.0833V16.5833H2.91675V17.0833V17.5833H17.0834V17.0833ZM2.91675 17.0833H3.41675V2.91659H2.91675H2.41675V17.0833H2.91675ZM2.91675 2.91659V3.41659H9.58341V2.91659V2.41659H2.91675V2.91659ZM17.0834 10.4166H16.5834V17.0833H17.0834H17.5834V10.4166H17.0834ZM10.4167 7.08325H9.91675V9.58325H10.4167H10.9167V7.08325H10.4167ZM10.4167 9.58325V10.0833H12.9167V9.58325V9.08325H10.4167V9.58325ZM10.4167 7.08325L10.7703 7.43681L15.7703 2.43681L15.4167 2.08325L15.0632 1.7297L10.0632 6.7297L10.4167 7.08325ZM15.4167 2.08325L15.0632 2.43681L17.5632 4.93681L17.9167 4.58325L18.2703 4.2297L15.7703 1.7297L15.4167 2.08325ZM17.9167 4.58325L17.5632 4.2297L12.5632 9.2297L12.9167 9.58325L13.2703 9.93681L18.2703 4.93681L17.9167 4.58325Z" fill="currentColor"/>`,
eye: `<path d="M10 4.58325C5.83333 4.58325 2.5 9.99992 2.5 9.99992C2.5 9.99992 5.83333 15.4166 10 15.4166C14.1667 15.4166 17.5 9.99992 17.5 9.99992C17.5 9.99992 14.1667 4.58325 10 4.58325Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/><circle cx="10" cy="10" r="2.5" stroke="currentColor"/>`,
enter: `<path d="M5.83333 15.8334L2.5 12.5L5.83333 9.16671M3.33333 12.5H17.9167V4.58337H10" stroke="currentColor" stroke-linecap="square"/>`,
folder: `<path d="M2.08301 2.91675V16.2501H17.9163V5.41675H9.99967L8.33301 2.91675H2.08301Z" stroke="currentColor" stroke-linecap="round"/>`,
"file-tree": `<path d="M18 18V5H9.5L7.5 2H2L2 18H5M18 18H5M18 18V8.5H5V18" stroke="currentColor" stroke-linecap="square"/>`,
"file-tree-active": `<path d="M2 2L2 18H5L6.5 8.5H18V5H9.5L7.5 2H2Z" fill="currentColor" fill-opacity="0.1"/>
<path d="M5 18H18L19.5 8.5H18M5 18H2L2 2H7.5L9.5 5H18V8.5M5 18L6.5 8.5H18" stroke="currentColor" stroke-linecap="square"/>`,
"magnifying-glass": `<path d="M13 13L10.6418 10.6418M11.9552 7.47761C11.9552 9.95053 9.95053 11.9552 7.47761 11.9552C5.0047 11.9552 3 9.95053 3 7.47761C3 5.0047 5.0047 3 7.47761 3C9.95053 3 11.9552 5.0047 11.9552 7.47761Z" stroke="currentColor" stroke-linecap="square" vector-effect="non-scaling-stroke"/>`,
"plus-small": `<path d="M9.99984 5.41699V10.0003M9.99984 10.0003V14.5837M9.99984 10.0003H5.4165M9.99984 10.0003H14.5832" stroke="currentColor" stroke-linecap="square"/>`,
plus: `<path d="M9.9987 2.20703V9.9987M9.9987 9.9987V17.7904M9.9987 9.9987H2.20703M9.9987 9.9987H17.7904" stroke="currentColor" stroke-linecap="square"/>`,
"new-session": `<path d="M12 2H2V18H18V8M6 11.3818V14H8.61818L18 4.61818L15.3818 2L6 11.3818Z" stroke="currentColor"/>`,
"new-session-active": `<path d="M6 11.3818V14H8.61818L18 4.61818L15.3818 2L6 11.3818Z" fill="currentColor" fill-opacity="0.1"/>
<path d="M12 2H2V18H18V8M6 11.3818V14H8.61818L18 4.61818L15.3818 2L6 11.3818Z" stroke="currentColor"/>`,
"pencil-line": `<path d="M9.58301 17.9166H17.9163M17.9163 5.83325L14.1663 2.08325L2.08301 14.1666V17.9166H5.83301L17.9163 5.83325Z" stroke="currentColor" stroke-linecap="square"/>`,
mcp: `<g><path d="M0.972656 9.37176L9.5214 1.60019C10.7018 0.527151 12.6155 0.527151 13.7957 1.60019C14.9761 2.67321 14.9761 4.41295 13.7957 5.48599L7.3397 11.3552" stroke="currentColor" stroke-linecap="round"/><path d="M7.42871 11.2747L13.7957 5.48643C14.9761 4.41338 16.8898 4.41338 18.0702 5.48643L18.1147 5.52688C19.2951 6.59993 19.2951 8.33966 18.1147 9.4127L10.3831 16.4414C9.98966 16.7991 9.98966 17.379 10.3831 17.7366L11.9707 19.1799" stroke="currentColor" stroke-linecap="round"/><path d="M11.6587 3.54346L5.33619 9.29119C4.15584 10.3642 4.15584 12.1039 5.33619 13.177C6.51649 14.25 8.43019 14.25 9.61054 13.177L15.9331 7.42923" stroke="currentColor" stroke-linecap="round"/></g>`,
glasses: `<path d="M0.416626 7.91667H1.66663M19.5833 7.91667H18.3333M11.866 7.57987C11.3165 7.26398 10.6793 7.08333 9.99996 7.08333C9.32061 7.08333 8.68344 7.26398 8.13389 7.57987M8.74996 10C8.74996 12.0711 7.07103 13.75 4.99996 13.75C2.92889 13.75 1.24996 12.0711 1.24996 10C1.24996 7.92893 2.92889 6.25 4.99996 6.25C7.07103 6.25 8.74996 7.92893 8.74996 10ZM18.75 10C18.75 12.0711 17.071 13.75 15 13.75C12.9289 13.75 11.25 12.0711 11.25 10C11.25 7.92893 12.9289 6.25 15 6.25C17.071 6.25 18.75 7.92893 18.75 10Z" stroke="currentColor" stroke-linecap="square"/>`,
"magnifying-glass-menu": `<path d="M2.08325 10.0002H4.58325M2.08325 5.41683H5.41659M2.08325 14.5835H5.41659M16.4583 13.9585L18.7499 16.2502M17.9166 10.0002C17.9166 12.9917 15.4915 15.4168 12.4999 15.4168C9.50838 15.4168 7.08325 12.9917 7.08325 10.0002C7.08325 7.00862 9.50838 4.5835 12.4999 4.5835C15.4915 4.5835 17.9166 7.00862 17.9166 10.0002Z" stroke="currentColor" stroke-linecap="square"/>`,
"window-cursor": `<path d="M17.9166 10.4167V3.75H2.08325V17.0833H10.4166M17.9166 13.5897L11.6666 11.6667L13.5897 17.9167L15.032 15.0321L17.9166 13.5897Z" stroke="currentColor" stroke-width="1.07143" stroke-linecap="square"/><path d="M5.00024 6.125C5.29925 6.12518 5.54126 6.36795 5.54126 6.66699C5.54108 6.96589 5.29914 7.20783 5.00024 7.20801C4.7012 7.20801 4.45843 6.966 4.45825 6.66699C4.45825 6.36784 4.70109 6.125 5.00024 6.125ZM7.91626 6.125C8.21541 6.125 8.45825 6.36784 8.45825 6.66699C8.45808 6.966 8.21531 7.20801 7.91626 7.20801C7.61736 7.20783 7.37542 6.96589 7.37524 6.66699C7.37524 6.36795 7.61726 6.12518 7.91626 6.125ZM10.8333 6.125C11.1324 6.125 11.3752 6.36784 11.3752 6.66699C11.3751 6.966 11.1323 7.20801 10.8333 7.20801C10.5342 7.20801 10.2914 6.966 10.2913 6.66699C10.2913 6.36784 10.5341 6.125 10.8333 6.125Z" fill="currentColor" stroke="currentColor" stroke-width="0.25" stroke-linecap="square"/>`,
task: `<path d="M9.99992 2.0835V17.9168M7.08325 3.75016H2.08325V16.2502H7.08325M12.9166 16.2502H17.9166V3.75016H12.9166" stroke="currentColor" stroke-linecap="square"/>`,
stop: `<rect x="5" y="5" width="10" height="10" fill="currentColor"/>`,
status: `<path d="M2 10V18H18V10M2 10V2H18V10M2 10H18M5 6H9M5 14H9" stroke="currentColor"/>`,
"status-active": `<path d="M18 2H2V10H18V2Z" fill="currentColor" fill-opacity="0.1"/>
<path d="M2 18H18V10H2V18Z" fill="currentColor" fill-opacity="0.1"/>
<path d="M2 10V18H18V10M2 10V2H18V10M2 10H18M5 6H9M5 14H9" stroke="currentColor"/>`,
sidebar: `<path d="M7.86667 2H5.2H2V18H5.2H7.86667M7.86667 2H18V18H7.86667M7.86667 2V18" stroke="currentColor"/>`,
"sidebar-active": `<path d="M2 2V18H5.2H7.86667V2H5.2H2Z" fill="currentColor" fill-opacity="0.1"/>
<path d="M7.86667 2H5.2H2V18H5.2H7.86667M7.86667 2H18V18H7.86667M7.86667 2V18" stroke="currentColor"/>`,
"layout-left": `<path d="M2.91675 2.91699L2.91675 2.41699L2.41675 2.41699L2.41675 2.91699L2.91675 2.91699ZM17.0834 2.91699L17.5834 2.91699L17.5834 2.41699L17.0834 2.41699L17.0834 2.91699ZM17.0834 17.0837L17.0834 17.5837L17.5834 17.5837L17.5834 17.0837L17.0834 17.0837ZM2.91675 17.0837L2.41675 17.0837L2.41675 17.5837L2.91675 17.5837L2.91675 17.0837ZM7.41674 17.0837L7.41674 17.5837L8.41674 17.5837L8.41674 17.0837L7.91674 17.0837L7.41674 17.0837ZM8.41674 2.91699L8.41674 2.41699L7.41674 2.41699L7.41674 2.91699L7.91674 2.91699L8.41674 2.91699ZM2.91675 2.91699L2.91675 3.41699L17.0834 3.41699L17.0834 2.91699L17.0834 2.41699L2.91675 2.41699L2.91675 2.91699ZM17.0834 2.91699L16.5834 2.91699L16.5834 17.0837L17.0834 17.0837L17.5834 17.0837L17.5834 2.91699L17.0834 2.91699ZM17.0834 17.0837L17.0834 16.5837L2.91675 16.5837L2.91675 17.0837L2.91675 17.5837L17.0834 17.5837L17.0834 17.0837ZM2.91675 17.0837L3.41675 17.0837L3.41675 2.91699L2.91675 2.91699L2.41675 2.91699L2.41675 17.0837L2.91675 17.0837ZM7.91674 17.0837L8.41674 17.0837L8.41674 2.91699L7.91674 2.91699L7.41674 2.91699L7.41674 17.0837L7.91674 17.0837Z" fill="currentColor"/>`,
"layout-left-partial": `<path d="M2.91732 2.91602L7.91732 2.91602L7.91732 17.0827H2.91732L2.91732 2.91602Z" fill="currentColor" fill-opacity="16%" /><path d="M2.91732 2.91602L17.084 2.91602M2.91732 2.91602L2.91732 17.0827M2.91732 2.91602L7.91732 2.91602M17.084 2.91602L17.084 17.0827M17.084 2.91602L7.91732 2.91602M17.084 17.0827L2.91732 17.0827M17.084 17.0827L7.91732 17.0827M2.91732 17.0827H7.91732M7.91732 17.0827L7.91732 2.91602" stroke="currentColor" stroke-linecap="square"/>`,
"layout-left-full": `<path d="M2.91732 2.91602L7.91732 2.91602L7.91732 17.0827H2.91732L2.91732 2.91602Z" fill="currentColor"/><path d="M2.91732 2.91602L17.084 2.91602M2.91732 2.91602L2.91732 17.0827M2.91732 2.91602L7.91732 2.91602M17.084 2.91602L17.084 17.0827M17.084 2.91602L7.91732 2.91602M17.084 17.0827L2.91732 17.0827M17.084 17.0827L7.91732 17.0827M2.91732 17.0827H7.91732M7.91732 17.0827L7.91732 2.91602" stroke="currentColor" stroke-linecap="square"/>`,
"layout-right": `<path d="M2.91536 2.91406H2.36536V2.36406H2.91536V2.91406ZM2.91536 17.0807V17.6307H2.36536V17.0807H2.91536ZM17.082 17.0807H17.632V17.6307H17.082V17.0807ZM17.082 2.91406V2.36406H17.632V2.91406H17.082ZM6.9987 2.91406H6.4487V2.36406H6.9987V2.91406ZM6.9987 17.0807V17.6307H6.4487V17.0807H6.9987ZM2.91536 2.91406H3.46536V17.0807H2.91536H2.36536V2.91406H2.91536ZM2.91536 17.0807V16.5307H17.082V17.0807V17.6307H2.91536V17.0807ZM17.082 17.0807H16.532V2.91406H17.082H17.632V17.0807H17.082ZM17.082 2.91406V3.46406H2.91536V2.91406V2.36406H17.082V2.91406ZM6.9987 2.91406H7.5487V17.0807H6.9987H6.4487V2.91406H6.9987ZM17.082 17.0807L17.082 17.6307L6.9987 17.6307V17.0807V16.5307L17.082 16.5307L17.082 17.0807ZM6.9987 2.91406V2.36406H17.082V2.91406V3.46406H6.9987V2.91406Z" fill="currentColor"/>`,
"layout-right-partial": `<path d="M17.082 17.0807L6.9987 17.0807V2.91406H17.082V17.0807Z" fill="currentColor" fill-opacity="16%" /><path d="M2.91536 2.91406H2.36536V2.36406H2.91536V2.91406ZM2.91536 17.0807V17.6307H2.36536V17.0807H2.91536ZM17.082 17.0807H17.632V17.6307H17.082V17.0807ZM17.082 2.91406V2.36406H17.632V2.91406H17.082ZM6.9987 2.91406H6.4487V2.36406H6.9987V2.91406ZM6.9987 17.0807V17.6307H6.4487V17.0807H6.9987ZM2.91536 2.91406H3.46536V17.0807H2.91536H2.36536V2.91406H2.91536ZM2.91536 17.0807V16.5307H17.082V17.0807V17.6307H2.91536V17.0807ZM17.082 17.0807H16.532V2.91406H17.082H17.632V17.0807H17.082ZM17.082 2.91406V3.46406H2.91536V2.91406V2.36406H17.082V2.91406ZM6.9987 2.91406H7.5487V17.0807H6.9987H6.4487V2.91406H6.9987ZM17.082 17.0807L17.082 17.6307L6.9987 17.6307V17.0807V16.5307L17.082 16.5307L17.082 17.0807ZM6.9987 2.91406V2.36406H17.082V2.91406V3.46406H6.9987V2.91406Z" fill="currentColor" />`,
"layout-right-full": `<path d="M17.082 17.0807L6.9987 17.0807V2.91406H17.082V17.0807Z" fill="currentColor" /><path d="M2.91536 2.91406H2.36536V2.36406H2.91536V2.91406ZM2.91536 17.0807V17.6307H2.36536V17.0807H2.91536ZM17.082 17.0807H17.632V17.6307H17.082V17.0807ZM17.082 2.91406V2.36406H17.632V2.91406H17.082ZM6.9987 2.91406H6.4487V2.36406H6.9987V2.91406ZM6.9987 17.0807V17.6307H6.4487V17.0807H6.9987ZM2.91536 2.91406H3.46536V17.0807H2.91536H2.36536V2.91406H2.91536ZM2.91536 17.0807V16.5307H17.082V17.0807V17.6307H2.91536V17.0807ZM17.082 17.0807H16.532V2.91406H17.082H17.632V17.0807H17.082ZM17.082 2.91406V3.46406H2.91536V2.91406V2.36406H17.082V2.91406ZM6.9987 2.91406H7.5487V17.0807H6.9987H6.4487V2.91406H6.9987ZM17.082 17.0807L17.082 17.6307L6.9987 17.6307V17.0807V16.5307L17.082 16.5307L17.082 17.0807ZM6.9987 2.91406V2.36406H17.082V2.91406V3.46406H6.9987V2.91406Z" fill="currentColor" />`,
"square-arrow-top-right": `<path d="M7.91675 2.9165H2.91675V17.0832H17.0834V12.0832M12.0834 2.9165H17.0834V7.9165M9.58342 10.4165L16.6667 3.33317" stroke="currentColor" stroke-linecap="square"/>`,
"open-file": `<path d="M7.91602 2.91406H2.91602V17.0807H17.0827V12.0807M12.0827 2.91406H17.0827V7.91406M9.58268 10.4141L16.666 3.33073" stroke="currentColor" stroke-linecap="square"/>`,
"speech-bubble": `<path d="M18.3334 10.0003C18.3334 5.57324 15.0927 2.91699 10.0001 2.91699C4.90749 2.91699 1.66675 5.57324 1.66675 10.0003C1.66675 11.1497 2.45578 13.1016 2.5771 13.3949C2.5878 13.4207 2.59839 13.4444 2.60802 13.4706C2.69194 13.6996 3.04282 14.9364 1.66675 16.7684C3.5186 17.6538 5.48526 16.1982 5.48526 16.1982C6.84592 16.9202 8.46491 17.0837 10.0001 17.0837C15.0927 17.0837 18.3334 14.4274 18.3334 10.0003Z" stroke="currentColor" stroke-linecap="square"/>`,
comment: `<path d="M16.25 3.75H3.75V16.25L6.875 14.4643H16.25V3.75Z" stroke="currentColor" stroke-linecap="square"/>`,
"folder-add-left": `<path d="M2.08333 9.58268V2.91602H8.33333L10 5.41602H17.9167V16.2493H8.75M3.75 12.0827V14.5827M3.75 14.5827V17.0827M3.75 14.5827H1.25M3.75 14.5827H6.25" stroke="currentColor" stroke-linecap="square"/>`,
github: `<path d="M10.0001 1.62549C14.6042 1.62549 18.3334 5.35465 18.3334 9.95882C18.333 11.7049 17.785 13.4068 16.7666 14.8251C15.7482 16.2434 14.3107 17.3066 12.6563 17.8651C12.2397 17.9484 12.0834 17.688 12.0834 17.4692C12.0834 17.188 12.0938 16.2922 12.0938 15.1776C12.0938 14.3963 11.8334 13.8963 11.5313 13.6359C13.3855 13.4276 15.3334 12.7192 15.3334 9.52132C15.3334 8.60465 15.0105 7.86507 14.4792 7.28174C14.5626 7.0734 14.8542 6.21924 14.3959 5.0734C14.3959 5.0734 13.698 4.84424 12.1042 5.92757C11.4376 5.74007 10.7292 5.64632 10.0209 5.64632C9.31258 5.64632 8.60425 5.74007 7.93758 5.92757C6.34383 4.85465 5.64592 5.0734 5.64592 5.0734C5.18758 6.21924 5.47925 7.0734 5.56258 7.28174C5.03133 7.86507 4.70842 8.61507 4.70842 9.52132C4.70842 12.7088 6.64592 13.4276 8.50008 13.6359C8.2605 13.8442 8.04175 14.2088 7.96883 14.7505C7.48967 14.9692 6.29175 15.3234 5.54175 14.063C5.3855 13.813 4.91675 13.1984 4.2605 13.2088C3.56258 13.2192 3.97925 13.6047 4.27092 13.7609C4.62508 13.9588 5.03133 14.6984 5.12508 14.938C5.29175 15.4067 5.83342 16.3026 7.92717 15.9172C7.92717 16.6151 7.93758 17.2713 7.93758 17.4692C7.93758 17.688 7.78133 17.938 7.36467 17.8651C5.70491 17.3126 4.26126 16.2515 3.23851 14.8324C2.21576 13.4133 1.66583 11.7081 1.66675 9.95882C1.66675 5.35465 5.39592 1.62549 10.0001 1.62549Z" fill="currentColor"/>`,
discord: `<path d="M16.0742 4.45014C14.9244 3.92097 13.7106 3.54556 12.4638 3.3335C12.2932 3.64011 12.1388 3.95557 12.0013 4.27856C10.6732 4.07738 9.32261 4.07738 7.99451 4.27856C7.85694 3.9556 7.70257 3.64014 7.53203 3.3335C6.28441 3.54735 5.06981 3.92365 3.91889 4.45291C1.63401 7.85128 1.01462 11.1652 1.32431 14.4322C2.6624 15.426 4.16009 16.1819 5.7523 16.6668C6.11082 16.1821 6.42806 15.6678 6.70066 15.1295C6.18289 14.9351 5.68315 14.6953 5.20723 14.4128C5.33249 14.3215 5.45499 14.2274 5.57336 14.136C6.95819 14.7907 8.46965 15.1302 9.99997 15.1302C11.5303 15.1302 13.0418 14.7907 14.4266 14.136C14.5463 14.2343 14.6688 14.3284 14.7927 14.4128C14.3159 14.6957 13.8152 14.9361 13.2965 15.1309C13.5688 15.669 13.8861 16.1828 14.2449 16.6668C15.8385 16.1838 17.3373 15.4283 18.6756 14.4335C19.039 10.645 18.0549 7.36145 16.0742 4.45014ZM7.09294 12.423C6.22992 12.423 5.51693 11.6357 5.51693 10.6671C5.51693 9.69852 6.20514 8.90427 7.09019 8.90427C7.97524 8.90427 8.68272 9.69852 8.66758 10.6671C8.65244 11.6357 7.97248 12.423 7.09294 12.423ZM12.907 12.423C12.0426 12.423 11.3324 11.6357 11.3324 10.6671C11.3324 9.69852 12.0206 8.90427 12.907 8.90427C13.7934 8.90427 14.4954 9.69852 14.4803 10.6671C14.4651 11.6357 13.7865 12.423 12.907 12.423Z" fill="currentColor"/>`,
"layout-bottom": `<path d="M2.91699 17.0832L2.41699 17.0832L2.41699 17.5832L2.91699 17.5832L2.91699 17.0832ZM2.91699 2.91653L2.91699 2.41653L2.41699 2.41653L2.41699 2.91653L2.91699 2.91653ZM17.0837 2.91653L17.5837 2.91653L17.5837 2.41653L17.0837 2.41653L17.0837 2.91653ZM17.0837 17.0832L17.5837 17.0832L17.5837 17.5832L17.0837 17.5832L17.0837 17.0832ZM17.0837 12.5827L17.5837 12.5827L17.5837 11.5827L17.0837 11.5827L17.0837 12.0827L17.0837 12.5827ZM2.91699 11.5827L2.41699 11.5827L2.41699 12.5827L2.91699 12.5827L2.91699 12.0827L2.91699 11.5827ZM2.91699 17.0832L3.41699 17.0832L3.41699 2.91653L2.91699 2.91653L2.41699 2.91653L2.41699 17.0832L2.91699 17.0832ZM2.91699 2.91653L2.91699 3.41653L17.0837 3.41653L17.0837 2.91653L17.0837 2.41653L2.91699 2.41653L2.91699 2.91653ZM17.0837 2.91653L16.5837 2.91653L16.5837 17.0832L17.0837 17.0832L17.5837 17.0832L17.5837 2.91653L17.0837 2.91653ZM17.0837 17.0832L17.0837 16.5832L2.91699 16.5832L2.91699 17.0832L2.91699 17.5832L17.0837 17.5832L17.0837 17.0832ZM17.0837 12.0827L17.0837 11.5827L2.91699 11.5827L2.91699 12.0827L2.91699 12.5827L17.0837 12.5827L17.0837 12.0827Z" fill="currentColor"/>`,
"layout-bottom-partial": `<path d="M2.91732 12.0827L17.084 12.0827L17.084 17.0827H2.91732L2.91732 12.0827Z" fill="currentColor" fill-opacity="16%" /><path d="M2.91732 2.91602L17.084 2.91602M2.91732 2.91602L2.91732 17.0827M17.084 2.91602L17.084 17.0827M17.084 17.0827L2.91732 17.0827M2.91732 12.0827L17.084 12.0827" stroke="currentColor" stroke-linecap="square"/>`,
"layout-bottom-full": `<path d="M2.91732 12.0827L17.084 12.0827L17.084 17.0827H2.91732L2.91732 12.0827Z" fill="currentColor"/><path d="M2.91732 2.91602L17.084 2.91602M2.91732 2.91602L2.91732 17.0827M17.084 2.91602L17.084 17.0827M17.084 17.0827L2.91732 17.0827M2.91732 12.0827L17.084 12.0827" stroke="currentColor" stroke-linecap="square"/>`,
"dot-grid": `<path d="M2.08398 9.16602H3.75065V10.8327H2.08398V9.16602Z" fill="currentColor"/><path d="M10.834 9.16602H9.16732V10.8327H10.834V9.16602Z" fill="currentColor"/><path d="M16.2507 9.16602H17.9173V10.8327H16.2507V9.16602Z" fill="currentColor"/><path d="M2.08398 9.16602H3.75065V10.8327H2.08398V9.16602Z" stroke="currentColor"/><path d="M10.834 9.16602H9.16732V10.8327H10.834V9.16602Z" stroke="currentColor"/><path d="M16.2507 9.16602H17.9173V10.8327H16.2507V9.16602Z" stroke="currentColor"/>`,
"circle-check": `<path d="M12.4987 7.91732L8.7487 12.5007L7.08203 10.834M17.9154 10.0007C17.9154 14.3729 14.371 17.9173 9.9987 17.9173C5.62644 17.9173 2.08203 14.3729 2.08203 10.0007C2.08203 5.6284 5.62644 2.08398 9.9987 2.08398C14.371 2.08398 17.9154 5.6284 17.9154 10.0007Z" stroke="currentColor" stroke-linecap="square"/>`,
copy: `<path d="M6.2513 6.24935V2.91602H17.0846V13.7493H13.7513M13.7513 6.24935V17.0827H2.91797V6.24935H13.7513Z" stroke="currentColor" stroke-linecap="round"/>`,
check: `<path d="M5 11.9657L8.37838 14.7529L15 5.83398" stroke="currentColor" stroke-linecap="square"/>`,
photo: `<path d="M16.6665 16.6666L11.6665 11.6666L9.99984 13.3333L6.6665 9.99996L3.08317 13.5833M2.9165 2.91663H17.0832V17.0833H2.9165V2.91663ZM13.3332 7.49996C13.3332 8.30537 12.6803 8.95829 11.8748 8.95829C11.0694 8.95829 10.4165 8.30537 10.4165 7.49996C10.4165 6.69454 11.0694 6.04163 11.8748 6.04163C12.6803 6.04163 13.3332 6.69454 13.3332 7.49996Z" stroke="currentColor" stroke-linecap="square"/>`,
share: `<path d="M10.0013 12.0846L10.0013 3.33464M13.7513 6.66797L10.0013 2.91797L6.2513 6.66797M17.0846 10.418V17.0846H2.91797V10.418" stroke="currentColor" stroke-linecap="square"/>`,
shield: `<path d="M7.49935 9.3737L9.16602 11.0404L12.4994 7.70703M9.99935 2.08203L17.0827 4.3737V9.92565C17.0827 14.0694 13.3327 16.2487 9.99935 18.047C6.66602 16.2487 2.91602 14.0694 2.91602 9.92565V4.3737L9.99935 2.08203Z" stroke="currentColor" stroke-linecap="square"/>`,
download: `<path d="M13.9583 10.6257L10 14.584L6.04167 10.6257M10 2.08398V13.959M16.25 17.9173H3.75" stroke="currentColor" stroke-linecap="square"/>`,
menu: `<path d="M2.5 5H17.5M2.5 10H17.5M2.5 15H17.5" stroke="currentColor" stroke-linecap="square"/>`,
server: `<rect x="3.35547" y="1.92969" width="13.2857" height="16.1429" stroke="currentColor"/><rect x="3.35547" y="11.9297" width="13.2857" height="6.14286" stroke="currentColor"/><rect x="12.8555" y="14.2852" width="1.42857" height="1.42857" fill="currentColor"/><rect x="10" y="14.2852" width="1.42857" height="1.42857" fill="currentColor"/>`,
branch: `<path d="M14.2036 7.19987L14.2079 6.69989L13.2079 6.69132L13.2036 7.1913L13.7036 7.19559L14.2036 7.19987ZM8.14804 5.09032H7.64804C7.64804 5.75797 7.06861 6.34471 6.29619 6.34471V6.84471V7.34471C7.56926 7.34471 8.64804 6.36051 8.64804 5.09032H8.14804ZM6.29619 6.84471V6.34471C5.52376 6.34471 4.94434 5.75797 4.94434 5.09032H4.44434H3.94434C3.94434 6.36051 5.02311 7.34471 6.29619 7.34471V6.84471ZM4.44434 5.09032H4.94434C4.94434 4.42267 5.52376 3.83594 6.29619 3.83594V3.33594V2.83594C5.02311 2.83594 3.94434 3.82013 3.94434 5.09032H4.44434ZM6.29619 3.33594V3.83594C7.06861 3.83594 7.64804 4.42267 7.64804 5.09032H8.14804H8.64804C8.64804 3.82013 7.56926 2.83594 6.29619 2.83594V3.33594ZM8.14804 14.9149H7.64804C7.64804 15.5825 7.06861 16.1693 6.29619 16.1693V16.6693V17.1693C7.56926 17.1693 8.64804 16.1851 8.64804 14.9149H8.14804ZM6.29619 16.6693V16.1693C5.52376 16.1693 4.94434 15.5825 4.94434 14.9149H4.44434H3.94434C3.94434 16.1851 5.02311 17.1693 6.29619 17.1693V16.6693ZM4.44434 14.9149H4.94434C4.94434 14.2472 5.52376 13.6605 6.29619 13.6605V13.1605V12.6605C5.02311 12.6605 3.94434 13.6447 3.94434 14.9149H4.44434ZM6.29619 13.1605V13.6605C7.06861 13.6605 7.64804 14.2472 7.64804 14.9149H8.14804H8.64804C8.64804 13.6447 7.56926 12.6605 6.29619 12.6605V13.1605ZM15.5554 5.09032H15.0554C15.0554 5.75797 14.476 6.34471 13.7036 6.34471V6.84471V7.34471C14.9767 7.34471 16.0554 6.36051 16.0554 5.09032H15.5554ZM13.7036 6.84471V6.34471C12.9312 6.34471 12.3517 5.75797 12.3517 5.09032H11.8517H11.3517C11.3517 6.36051 12.4305 7.34471 13.7036 7.34471V6.84471ZM11.8517 5.09032H12.3517C12.3517 4.42267 12.9312 3.83594 13.7036 3.83594V3.33594V2.83594C12.4305 2.83594 11.3517 3.82013 11.3517 5.09032H11.8517ZM13.7036 3.33594V3.83594C14.476 3.83594 15.0554 4.42267 15.0554 5.09032H15.5554H16.0554C16.0554 3.82013 14.9767 2.83594 13.7036 2.83594V3.33594ZM13.7036 7.19559L13.2036 7.1913L13.1544 12.9277L13.6544 12.932L14.1544 12.9363L14.2036 7.19987L13.7036 7.19559ZM6.29619 6.84471H5.79619V13.1605H6.29619H6.79619V6.84471H6.29619ZM11.6545 14.9149V14.4149H8.14804V14.9149V15.4149H11.6545V14.9149ZM13.6544 12.932L13.1544 12.9277C13.1474 13.7511 12.4779 14.4149 11.6545 14.4149V14.9149V15.4149C13.0269 15.4149 14.1426 14.3086 14.1544 12.9363L13.6544 12.932Z" fill="currentColor"/>`,
edit: `<path d="M17.0832 17.0807V17.5807H17.5832V17.0807H17.0832ZM2.9165 17.0807H2.4165V17.5807H2.9165V17.0807ZM2.9165 2.91406V2.41406H2.4165V2.91406H2.9165ZM9.58317 3.41406H10.0832V2.41406H9.58317V2.91406V3.41406ZM17.5832 10.4141V9.91406H16.5832V10.4141H17.0832H17.5832ZM6.24984 11.2474L5.89628 10.8938L5.74984 11.0403V11.2474H6.24984ZM6.24984 13.7474H5.74984V14.2474H6.24984V13.7474ZM8.74984 13.7474V14.2474H8.95694L9.10339 14.101L8.74984 13.7474ZM15.2082 2.28906L15.5617 1.93551L15.2082 1.58196L14.8546 1.93551L15.2082 2.28906ZM17.7082 4.78906L18.0617 5.14262L18.4153 4.78906L18.0617 4.43551L17.7082 4.78906ZM17.0832 17.0807V16.5807H2.9165V17.0807V17.5807H17.0832V17.0807ZM2.9165 17.0807H3.4165V2.91406H2.9165H2.4165V17.0807H2.9165ZM2.9165 2.91406V3.41406H9.58317V2.91406V2.41406H2.9165V2.91406ZM17.0832 10.4141H16.5832V17.0807H17.0832H17.5832V10.4141H17.0832ZM6.24984 11.2474H5.74984V13.7474H6.24984H6.74984V11.2474H6.24984ZM6.24984 13.7474V14.2474H8.74984V13.7474V13.2474H6.24984V13.7474ZM6.24984 11.2474L6.60339 11.6009L15.5617 2.64262L15.2082 2.28906L14.8546 1.93551L5.89628 10.8938L6.24984 11.2474ZM15.2082 2.28906L14.8546 2.64262L17.3546 5.14262L17.7082 4.78906L18.0617 4.43551L15.5617 1.93551L15.2082 2.28906ZM17.7082 4.78906L17.3546 4.43551L8.39628 13.3938L8.74984 13.7474L9.10339 14.101L18.0617 5.14262L17.7082 4.78906Z" fill="currentColor"/>`,
help: `<path d="M7.91683 7.91927V6.2526H12.0835V8.7526L10.0002 10.0026V12.0859M10.0002 13.7526V13.7609M17.9168 10.0026C17.9168 14.3749 14.3724 17.9193 10.0002 17.9193C5.62791 17.9193 2.0835 14.3749 2.0835 10.0026C2.0835 5.63035 5.62791 2.08594 10.0002 2.08594C14.3724 2.08594 17.9168 5.63035 17.9168 10.0026Z" stroke="currentColor" stroke-linecap="square"/>`,
"settings-gear": `<path d="M7.62516 4.46094L5.05225 3.86719L3.86475 5.05469L4.4585 7.6276L2.0835 9.21094V10.7943L4.4585 12.3776L3.86475 14.9505L5.05225 16.138L7.62516 15.5443L9.2085 17.9193H10.7918L12.3752 15.5443L14.9481 16.138L16.1356 14.9505L15.5418 12.3776L17.9168 10.7943V9.21094L15.5418 7.6276L16.1356 5.05469L14.9481 3.86719L12.3752 4.46094L10.7918 2.08594H9.2085L7.62516 4.46094Z" stroke="currentColor"/><path d="M12.5002 10.0026C12.5002 11.3833 11.3809 12.5026 10.0002 12.5026C8.61945 12.5026 7.50016 11.3833 7.50016 10.0026C7.50016 8.62189 8.61945 7.5026 10.0002 7.5026C11.3809 7.5026 12.5002 8.62189 12.5002 10.0026Z" stroke="currentColor"/>`,
dash: `<rect x="5" y="9.5" width="10" height="1" fill="currentColor"/>`,
"cloud-upload": `<path d="M12.0833 16.25H15C17.0711 16.25 18.75 14.5711 18.75 12.5C18.75 10.5649 17.2843 8.97217 15.4025 8.77133C15.2 6.13103 12.8586 4.08333 10 4.08333C7.71532 4.08333 5.76101 5.49781 4.96501 7.49881C2.84892 7.90461 1.25 9.76559 1.25 11.6667C1.25 13.9813 3.30203 16.25 5.83333 16.25H7.91667M10 16.25V10.4167M12.0833 11.875L10 9.79167L7.91667 11.875" stroke="currentColor" stroke-linecap="square"/>`,
trash: `<path d="M4.58342 17.9134L4.58369 17.4134L4.22787 17.5384L4.22766 18.0384H4.58342V17.9134ZM15.4167 17.9134V18.0384H15.7725L15.7723 17.5384L15.4167 17.9134ZM2.08342 3.95508V3.45508H1.58342V3.95508H2.08342V4.45508V3.95508ZM17.9167 4.45508V4.95508H18.4167V4.45508H17.9167V3.95508V4.45508ZM4.16677 4.58008L3.66701 4.5996L4.22816 17.5379L4.72792 17.4934L5.22767 17.4489L4.66652 4.54055L4.16677 4.58008ZM4.58342 18.0384V17.9134H15.4167V18.0384V18.5384H4.58342V18.0384ZM15.4167 17.9134L15.8332 17.5379L16.2498 4.5996L15.7501 4.58008L15.2503 4.56055L14.8337 17.4989L15.4167 17.9134ZM15.8334 4.58008V4.08008H4.16677V4.58008V5.08008H15.8334V4.58008ZM2.08342 4.45508V4.95508H4.16677V4.58008V4.08008H2.08342V4.45508ZM15.8334 4.58008V5.08008H17.9167V4.45508V3.95508H15.8334V4.58008ZM6.83951 4.35149L7.432 4.55047C7.79251 3.47701 8.80699 2.70508 10.0001 2.70508V2.20508V1.70508C8.25392 1.70508 6.77335 2.83539 6.24702 4.15251L6.83951 4.35149ZM10.0001 2.20508V2.70508C11.1932 2.70508 12.2077 3.47701 12.5682 4.55047L13.1607 4.35149L13.7532 4.15251C13.2269 2.83539 11.7463 1.70508 10.0001 1.70508V2.20508Z" fill="currentColor"/>`,
sliders: `<path d="M3.625 6.25H10.9375M16.375 13.75H10.5625M3.625 13.75H4.9375M11.125 6.25C11.125 4.79969 12.2997 3.625 13.75 3.625C15.2003 3.625 16.375 4.79969 16.375 6.25C16.375 7.70031 15.2003 8.875 13.75 8.875C12.2997 8.875 11.125 7.70031 11.125 6.25ZM10.375 13.75C10.375 15.2003 9.20031 16.375 7.75 16.375C6.29969 16.375 5.125 15.2003 5.125 13.75C5.125 12.2997 6.29969 11.125 7.75 11.125C9.20031 11.125 10.375 12.2997 10.375 13.75Z" stroke="currentColor" stroke-linecap="square"/>`,
keyboard: `<path d="M5.125 7.375V4.375H14.875V2.875M8.3125 13.9375H11.6875M8.125 13.9375H11.875M2.125 7.375H17.875V17.125H2.125V7.375ZM5.5 10.375H5.125V10.75H5.5V10.375ZM8.5 10.375H8.125V10.75H8.5V10.375ZM11.875 10.375H11.5V10.75H11.875V10.375ZM14.875 10.375H14.5V10.75H14.875V10.375ZM14.875 13.75H14.5V14.125H14.875V13.75ZM5.5 13.75H5.125V14.125H5.5V13.75Z" stroke="currentColor" stroke-linecap="square"/>`,
selector: `<path d="M6.66626 12.5033L9.99959 15.8366L13.3329 12.5033M6.66626 7.50326L9.99959 4.16992L13.3329 7.50326" stroke="currentColor" stroke-linecap="square"/>`,
"arrow-down-to-line": `<path d="M15.2083 11.6667L10 16.875L4.79167 11.6667M10 16.25V3.125" stroke="currentColor" stroke-width="1.25" stroke-linecap="square"/>`,
warning: `<path d="M10 7.91667V11.6667M10 13.7417V13.75M10 2.5L1.875 16.25H18.125L10 2.5Z" stroke="currentColor" stroke-linecap="square"/>`,
reset: `<path d="M5.83333 4.16406L2.5 7.4974L5.83333 10.8307M3.33333 7.4974H17.9167V15.4141H10" stroke="currentColor" stroke-linecap="square"/>`,
link: `<path d="M2.08334 12.0833L1.72979 11.7298L1.37624 12.0833L1.72979 12.4369L2.08334 12.0833ZM7.91668 17.9167L7.56312 18.2702L7.91668 18.6238L8.27023 18.2702L7.91668 17.9167ZM17.9167 7.91666L18.2702 8.27022L18.6238 7.91666L18.2702 7.56311L17.9167 7.91666ZM12.0833 2.08333L12.4369 1.72977L12.0833 1.37622L11.7298 1.72977L12.0833 2.08333ZM8.39646 5.06311L8.0429 5.41666L8.75001 6.12377L9.10356 5.77021L8.75001 5.41666L8.39646 5.06311ZM5.77023 9.10355L6.12378 8.74999L5.41668 8.04289L5.06312 8.39644L5.41668 8.74999L5.77023 9.10355ZM14.2298 10.8964L13.8762 11.25L14.5833 11.9571L14.9369 11.6035L14.5833 11.25L14.2298 10.8964ZM11.6036 14.9369L11.9571 14.5833L11.25 13.8762L10.8965 14.2298L11.25 14.5833L11.6036 14.9369ZM7.14646 12.1464L6.7929 12.5L7.50001 13.2071L7.85356 12.8535L7.50001 12.5L7.14646 12.1464ZM12.8536 7.85355L13.2071 7.49999L12.5 6.79289L12.1465 7.14644L12.5 7.49999L12.8536 7.85355ZM2.08334 12.0833L1.72979 12.4369L7.56312 18.2702L7.91668 17.9167L8.27023 17.5631L2.4369 11.7298L2.08334 12.0833ZM17.9167 7.91666L18.2702 7.56311L12.4369 1.72977L12.0833 2.08333L11.7298 2.43688L17.5631 8.27022L17.9167 7.91666ZM12.0833 2.08333L11.7298 1.72977L8.39646 5.06311L8.75001 5.41666L9.10356 5.77021L12.4369 2.43688L12.0833 2.08333ZM5.41668 8.74999L5.06312 8.39644L1.72979 11.7298L2.08334 12.0833L2.4369 12.4369L5.77023 9.10355L5.41668 8.74999ZM14.5833 11.25L14.9369 11.6035L18.2702 8.27022L17.9167 7.91666L17.5631 7.56311L14.2298 10.8964L14.5833 11.25ZM7.91668 17.9167L8.27023 18.2702L11.6036 14.9369L11.25 14.5833L10.8965 14.2298L7.56312 17.5631L7.91668 17.9167ZM7.50001 12.5L7.85356 12.8535L12.8536 7.85355L12.5 7.49999L12.1465 7.14644L7.14646 12.1464L7.50001 12.5Z" fill="currentColor"/>`,
providers: `<path d="M10.0001 4.37562V2.875M13 4.37793V2.87793M7.00014 4.37793V2.875M10 17.1279V15.6279M13 17.1279V15.6279M7 17.1279V15.6279M15.625 13.0029H17.125M15.625 7.00293H17.125M15.625 10.0029H17.125M2.875 10.0029H4.375M2.875 13.0029H4.375M2.875 7.00293H4.375M4.375 4.37793H15.625V15.6279H4.375V4.37793ZM12.6241 10.0022C12.6241 11.4519 11.4488 12.6272 9.99908 12.6272C8.54934 12.6272 7.37408 11.4519 7.37408 10.0022C7.37408 8.55245 8.54934 7.3772 9.99908 7.3772C11.4488 7.3772 12.6241 8.55245 12.6241 10.0022Z" stroke="currentColor" stroke-linecap="square"/>`,
models: `<path fill-rule="evenodd" clip-rule="evenodd" d="M17.5 10C12.2917 10 10 12.2917 10 17.5C10 12.2917 7.70833 10 2.5 10C7.70833 10 10 7.70833 10 2.5C10 7.70833 12.2917 10 17.5 10Z" stroke="currentColor"/>`,
"arrow-undo-down": `<path d="M4.08333 11.0859L1.75 8.7526L4.08333 6.41927M2.33333 8.7526L12.5417 8.7526L12.5417 3.21094L7 3.21094" stroke="currentColor" stroke-width="1" stroke-linecap="square"/>`,
}
export interface IconProps extends ComponentProps<"svg"> {
name: keyof typeof icons
size?: "small" | "normal" | "medium" | "large"
}
export function Icon(props: IconProps) {
const [local, others] = splitProps(props, ["name", "size", "class", "classList"])
const viewBox = () =>
local.name === "magnifying-glass" || local.name === "arrow-undo-down" ? "0 0 16 16" : "0 0 20 20"
return (
<div data-component="icon" data-size={local.size || "normal"}>
<svg
data-slot="icon-svg"
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
fill="none"
viewBox={viewBox()}
innerHTML={icons[local.name as keyof typeof icons]}
aria-hidden="true"
{...others}
/>
</div>
)
}

View File

@@ -0,0 +1,63 @@
[data-component="image-preview"] {
position: fixed;
inset: 0;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
[data-slot="image-preview-container"] {
position: relative;
z-index: 50;
width: min(calc(100vw - 32px), 90vw);
max-width: 1200px;
height: min(calc(100vh - 32px), 90vh);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
[data-slot="image-preview-content"] {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
max-height: 100%;
border-radius: var(--radius-lg);
background: var(--surface-raised-stronger-non-alpha);
box-shadow:
0 15px 45px 0 rgba(19, 16, 16, 0.35),
0 3.35px 10.051px 0 rgba(19, 16, 16, 0.25),
0 0.998px 2.993px 0 rgba(19, 16, 16, 0.2);
overflow: hidden;
&:focus-visible {
outline: none;
}
[data-slot="image-preview-header"] {
display: flex;
padding: 8px 8px 0;
justify-content: flex-end;
align-items: center;
align-self: stretch;
}
[data-slot="image-preview-body"] {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
overflow: auto;
}
[data-slot="image-preview-image"] {
max-width: 100%;
max-height: calc(90vh - 100px);
object-fit: contain;
border-radius: var(--radius-md);
}
}
}
}

View File

@@ -0,0 +1,59 @@
// @ts-nocheck
import { onMount } from "solid-js"
import * as mod from "./image-preview"
import { Button } from "./button"
import { useDialog } from "../context/dialog"
const docs = `### Overview
Image preview content intended to render inside the dialog stack.
Use for full-size image inspection; keep images optimized.
### API
- Required: \`src\`.
- Optional: \`alt\` text.
### Variants and states
- Single layout with close action.
### Behavior
- Intended to be used via \`useDialog().show\`.
### Accessibility
- Uses localized aria-label for close button.
### Theming/tokens
- Uses \`data-component="image-preview"\` and slot attributes.
`
export default {
title: "UI/ImagePreview",
id: "components-image-preview",
component: mod.ImagePreview,
tags: ["autodocs"],
parameters: {
docs: {
description: {
component: docs,
},
},
},
}
export const Basic = {
render: () => {
const dialog = useDialog()
const src = "https://placehold.co/640x360/png"
const open = () => dialog.show(() => <mod.ImagePreview src={src} alt="Preview" />)
onMount(open)
return (
<Button variant="secondary" onClick={open}>
Open image preview
</Button>
)
},
}

View File

@@ -0,0 +1,32 @@
import { Dialog as Kobalte } from "@kobalte/core/dialog"
import { useI18n } from "../context/i18n"
import { IconButton } from "./icon-button"
export interface ImagePreviewProps {
src: string
alt?: string
}
export function ImagePreview(props: ImagePreviewProps) {
const i18n = useI18n()
return (
<div data-component="image-preview">
<div data-slot="image-preview-container">
<Kobalte.Content data-slot="image-preview-content">
<div data-slot="image-preview-header">
<Kobalte.CloseButton
data-slot="image-preview-close"
as={IconButton}
icon="close"
variant="ghost"
aria-label={i18n.t("ui.common.close")}
/>
</div>
<div data-slot="image-preview-body">
<img src={props.src} alt={props.alt ?? i18n.t("ui.imagePreview.alt")} data-slot="image-preview-image" />
</div>
</Kobalte.Content>
</div>
</div>
)
}

View File

@@ -0,0 +1,17 @@
[data-component="inline-input"] {
color: inherit;
background: transparent;
border: 0;
border-radius: var(--radius-md);
padding: 0;
min-width: 0;
font: inherit;
letter-spacing: inherit;
line-height: inherit;
box-sizing: border-box;
&:focus {
outline: none;
box-shadow: var(--inline-input-shadow, 0 0 0 1px var(--border-interactive-focus));
}
}

View File

@@ -0,0 +1,50 @@
// @ts-nocheck
import * as mod from "./inline-input"
import { create } from "../storybook/scaffold"
const docs = `### Overview
Compact inline input for short values.
Use inside text or table rows for quick edits.
### API
- Optional: \`width\` to set a fixed width.
- Accepts standard input props.
### Variants and states
- No built-in variants; style via class or width.
### Behavior
- Uses inline width when provided.
### Accessibility
- Provide a label or aria-label when used standalone.
### Theming/tokens
- Uses \`data-component="inline-input"\`.
`
const story = create({ title: "UI/InlineInput", mod, args: { placeholder: "Type...", value: "Inline" } })
export default {
title: "UI/InlineInput",
id: "components-inline-input",
component: story.meta.component,
tags: ["autodocs"],
parameters: {
docs: {
description: {
component: docs,
},
},
},
}
export const Basic = story.Basic
export const FixedWidth = {
args: {
value: "80px",
width: "80px",
},
}

View File

@@ -0,0 +1,22 @@
import type { ComponentProps } from "solid-js"
import { splitProps } from "solid-js"
export type InlineInputProps = ComponentProps<"input"> & {
width?: string
}
export function InlineInput(props: InlineInputProps) {
const [local, others] = splitProps(props, ["class", "width", "style"])
const style = () => {
if (!local.style) return { width: local.width }
if (typeof local.style === "string") {
if (!local.width) return local.style
return `${local.style};width:${local.width}`
}
if (!local.width) return local.style
return { ...local.style, width: local.width }
}
return <input data-component="inline-input" class={local.class} style={style()} {...others} />
}

View File

@@ -0,0 +1,18 @@
[data-component="keybind"] {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
height: 20px;
padding: 0 8px;
border-radius: 2px;
background: var(--surface-base);
box-shadow: var(--shadow-xxs-border);
/* text-12-regular */
font-family: var(--font-family-sans);
font-size: 12px;
font-weight: var(--font-weight-regular);
line-height: 1;
color: var(--text-weak);
}

View File

@@ -0,0 +1,43 @@
// @ts-nocheck
import * as mod from "./keybind"
import { create } from "../storybook/scaffold"
const docs = `### Overview
Keyboard shortcut pill for displaying keybindings.
Pair with menu items or command palettes.
### API
- Children render the key sequence text.
- Accepts standard span props.
### Variants and states
- Single visual style.
### Behavior
- Presentational only.
### Accessibility
- Ensure text conveys the shortcut (e.g., "Cmd+K").
### Theming/tokens
- Uses \`data-component="keybind"\`.
`
const story = create({ title: "UI/Keybind", mod, args: { children: "Cmd+K" } })
export default {
title: "UI/Keybind",
id: "components-keybind",
component: story.meta.component,
tags: ["autodocs"],
parameters: {
docs: {
description: {
component: docs,
},
},
},
}
export const Basic = story.Basic

View File

@@ -0,0 +1,20 @@
import type { ComponentProps, ParentProps } from "solid-js"
export interface KeybindProps extends ParentProps {
class?: string
classList?: ComponentProps<"span">["classList"]
}
export function Keybind(props: KeybindProps) {
return (
<span
data-component="keybind"
classList={{
...props.classList,
[props.class ?? ""]: !!props.class,
}}
>
{props.children}
</span>
)
}

View File

@@ -0,0 +1,596 @@
import { type DiffLineAnnotation, type SelectedLineRange } from "@pierre/diffs"
import { createEffect, createMemo, createSignal, onCleanup, Show, type Accessor, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { render as renderSolid } from "solid-js/web"
import { useI18n } from "../context/i18n"
import { createHoverCommentUtility } from "../pierre/comment-hover"
import { cloneSelectedLineRange, formatSelectedLineLabel, lineInSelectedRange } from "../pierre/selection-bridge"
import { LineComment, LineCommentEditor, type LineCommentEditorProps } from "./line-comment"
export type LineCommentAnnotationMeta<T> =
| { kind: "comment"; key: string; comment: T }
| { kind: "draft"; key: string; range: SelectedLineRange }
export type LineCommentAnnotation<T> = {
lineNumber: number
side?: "additions" | "deletions"
metadata: LineCommentAnnotationMeta<T>
}
type LineCommentAnnotationsProps<T> = {
comments: Accessor<T[]>
getCommentId: (comment: T) => string
getCommentSelection: (comment: T) => SelectedLineRange
draftRange: Accessor<SelectedLineRange | null>
draftKey: Accessor<string>
}
type LineCommentAnnotationsWithSideProps<T> = LineCommentAnnotationsProps<T> & {
getSide: (range: SelectedLineRange) => "additions" | "deletions"
}
type HoverCommentLine = {
lineNumber: number
side?: "additions" | "deletions"
}
type LineCommentStateProps<T> = {
opened: Accessor<T | null>
setOpened: (id: T | null) => void
selected: Accessor<SelectedLineRange | null>
setSelected: (range: SelectedLineRange | null) => void
commenting: Accessor<SelectedLineRange | null>
setCommenting: (range: SelectedLineRange | null) => void
syncSelected?: (range: SelectedLineRange | null) => void
hoverSelected?: (range: SelectedLineRange) => void
}
type LineCommentShape = {
id: string
selection: SelectedLineRange
comment: string
}
type LineCommentControllerProps<T extends LineCommentShape> = {
comments: Accessor<T[]>
draftKey: Accessor<string>
label: string
mention?: LineCommentEditorProps["mention"]
state: LineCommentStateProps<string>
onSubmit: (input: { comment: string; selection: SelectedLineRange }) => void
onUpdate?: (input: { id: string; comment: string; selection: SelectedLineRange }) => void
onDelete?: (comment: T) => void
renderCommentActions?: (comment: T, controls: { edit: VoidFunction; remove: VoidFunction }) => JSX.Element
editSubmitLabel?: string
onDraftPopoverFocusOut?: JSX.EventHandlerUnion<HTMLDivElement, FocusEvent>
getHoverSelectedRange?: Accessor<SelectedLineRange | null>
cancelDraftOnCommentToggle?: boolean
clearSelectionOnSelectionEndNull?: boolean
}
type LineCommentControllerWithSideProps<T extends LineCommentShape> = LineCommentControllerProps<T> & {
getSide: (range: SelectedLineRange) => "additions" | "deletions"
}
type CommentProps = {
id?: string
open: boolean
comment: JSX.Element
selection: JSX.Element
actions?: JSX.Element
editor?: DraftProps
onClick?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
onMouseEnter?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
}
type DraftProps = {
value: string
selection: JSX.Element
mention?: LineCommentEditorProps["mention"]
onInput: (value: string) => void
onCancel: VoidFunction
onSubmit: (value: string) => void
onPopoverFocusOut?: JSX.EventHandlerUnion<HTMLDivElement, FocusEvent>
cancelLabel?: string
submitLabel?: string
}
export function createLineCommentAnnotationRenderer<T>(props: {
renderComment: (comment: T) => CommentProps
renderDraft: (range: SelectedLineRange) => DraftProps
}) {
const nodes = new Map<
string,
{
host: HTMLDivElement
dispose: VoidFunction
setMeta: (meta: LineCommentAnnotationMeta<T>) => void
}
>()
const mount = (meta: LineCommentAnnotationMeta<T>) => {
if (typeof document === "undefined") return
const host = document.createElement("div")
host.setAttribute("data-prevent-autofocus", "")
const [current, setCurrent] = createSignal(meta)
const dispose = renderSolid(() => {
const active = current()
if (active.kind === "comment") {
const view = createMemo(() => {
const next = current()
if (next.kind !== "comment") return props.renderComment(active.comment)
return props.renderComment(next.comment)
})
return (
<Show
when={view().editor}
fallback={
<LineComment
inline
id={view().id}
open={view().open}
comment={view().comment}
selection={view().selection}
actions={view().actions}
onClick={view().onClick}
onMouseEnter={view().onMouseEnter}
/>
}
>
<LineCommentEditor
inline
id={view().id}
value={view().editor!.value}
selection={view().editor!.selection}
onInput={view().editor!.onInput}
onCancel={view().editor!.onCancel}
onSubmit={view().editor!.onSubmit}
onPopoverFocusOut={view().editor!.onPopoverFocusOut}
cancelLabel={view().editor!.cancelLabel}
submitLabel={view().editor!.submitLabel}
mention={view().editor!.mention}
/>
</Show>
)
}
const view = createMemo(() => {
const next = current()
if (next.kind !== "draft") return props.renderDraft(active.range)
return props.renderDraft(next.range)
})
return (
<LineCommentEditor
inline
value={view().value}
selection={view().selection}
onInput={view().onInput}
onCancel={view().onCancel}
onSubmit={view().onSubmit}
onPopoverFocusOut={view().onPopoverFocusOut}
mention={view().mention}
/>
)
}, host)
const node = { host, dispose, setMeta: setCurrent }
nodes.set(meta.key, node)
return node
}
const render = <A extends { metadata: LineCommentAnnotationMeta<T> }>(annotation: A) => {
const meta = annotation.metadata
const node = nodes.get(meta.key) ?? mount(meta)
if (!node) return
node.setMeta(meta)
return node.host
}
const reconcile = <A extends { metadata: LineCommentAnnotationMeta<T> }>(annotations: A[]) => {
const next = new Set(annotations.map((annotation) => annotation.metadata.key))
for (const [key, node] of nodes) {
if (next.has(key)) continue
node.dispose()
nodes.delete(key)
}
}
const cleanup = () => {
for (const [, node] of nodes) node.dispose()
nodes.clear()
}
return { render, reconcile, cleanup }
}
export function createLineCommentState<T>(props: LineCommentStateProps<T>) {
const [state, setState] = createStore({
draft: "",
editing: null as T | null,
})
const draft = () => state.draft
const setDraft = (value: string) => setState("draft", value)
const editing = () => state.editing
const setEditing = (value: T | null) => setState("editing", typeof value === "function" ? () => value : value)
const toRange = (range: SelectedLineRange | null) => (range ? cloneSelectedLineRange(range) : null)
const setSelected = (range: SelectedLineRange | null) => {
const next = toRange(range)
props.setSelected(next)
props.syncSelected?.(toRange(next))
return next
}
const setCommenting = (range: SelectedLineRange | null) => {
const next = toRange(range)
props.setCommenting(next)
return next
}
const closeComment = () => {
props.setOpened(null)
}
const cancelDraft = () => {
setDraft("")
setEditing(null)
setCommenting(null)
}
const reset = () => {
setDraft("")
setEditing(null)
props.setOpened(null)
props.setSelected(null)
props.setCommenting(null)
}
const openComment = (id: T, range: SelectedLineRange, options?: { cancelDraft?: boolean }) => {
if (options?.cancelDraft) cancelDraft()
props.setOpened(id)
setSelected(range)
}
const toggleComment = (id: T, range: SelectedLineRange, options?: { cancelDraft?: boolean }) => {
if (options?.cancelDraft) cancelDraft()
const next = props.opened() === id ? null : id
props.setOpened(next)
setSelected(range)
}
const openDraft = (range: SelectedLineRange) => {
const next = toRange(range)
setDraft("")
setEditing(null)
closeComment()
setSelected(next)
setCommenting(next)
}
const openEditor = (id: T, range: SelectedLineRange, value: string) => {
closeComment()
setSelected(range)
props.setCommenting(null)
setEditing(id)
setDraft(value)
}
const hoverComment = (range: SelectedLineRange) => {
const next = toRange(range)
if (!next) return
if (props.hoverSelected) {
props.hoverSelected(next)
return
}
setSelected(next)
}
const finishSelection = (range: SelectedLineRange) => {
closeComment()
setSelected(range)
cancelDraft()
}
return {
draft,
setDraft,
editing,
opened: props.opened,
selected: props.selected,
commenting: props.commenting,
isOpen: (id: T) => props.opened() === id,
isEditing: (id: T) => editing() === id,
closeComment,
openComment,
toggleComment,
openDraft,
openEditor,
hoverComment,
cancelDraft,
finishSelection,
select: setSelected,
reset,
}
}
export function createLineCommentController<T extends LineCommentShape>(
props: LineCommentControllerWithSideProps<T>,
): {
note: ReturnType<typeof createLineCommentState<string>>
annotations: Accessor<DiffLineAnnotation<LineCommentAnnotationMeta<T>>[]>
renderAnnotation: ReturnType<typeof createManagedLineCommentAnnotationRenderer<T>>["renderAnnotation"]
renderHoverUtility: ReturnType<typeof createLineCommentHoverRenderer>
onLineSelected: (range: SelectedLineRange | null) => void
onLineSelectionEnd: (range: SelectedLineRange | null) => void
onLineNumberSelectionEnd: (range: SelectedLineRange | null) => void
}
export function createLineCommentController<T extends LineCommentShape>(
props: LineCommentControllerProps<T>,
): {
note: ReturnType<typeof createLineCommentState<string>>
annotations: Accessor<LineCommentAnnotation<T>[]>
renderAnnotation: ReturnType<typeof createManagedLineCommentAnnotationRenderer<T>>["renderAnnotation"]
renderHoverUtility: ReturnType<typeof createLineCommentHoverRenderer>
onLineSelected: (range: SelectedLineRange | null) => void
onLineSelectionEnd: (range: SelectedLineRange | null) => void
onLineNumberSelectionEnd: (range: SelectedLineRange | null) => void
}
export function createLineCommentController<T extends LineCommentShape>(
props: LineCommentControllerProps<T> | LineCommentControllerWithSideProps<T>,
) {
const i18n = useI18n()
const note = createLineCommentState<string>(props.state)
const annotations =
"getSide" in props
? createLineCommentAnnotations({
comments: props.comments,
getCommentId: (comment) => comment.id,
getCommentSelection: (comment) => comment.selection,
draftRange: note.commenting,
draftKey: props.draftKey,
getSide: props.getSide,
})
: createLineCommentAnnotations({
comments: props.comments,
getCommentId: (comment) => comment.id,
getCommentSelection: (comment) => comment.selection,
draftRange: note.commenting,
draftKey: props.draftKey,
})
const { renderAnnotation } = createManagedLineCommentAnnotationRenderer<T>({
annotations,
renderComment: (comment) => {
const edit = () => note.openEditor(comment.id, comment.selection, comment.comment)
const remove = () => {
note.reset()
props.onDelete?.(comment)
}
return {
id: comment.id,
get open() {
return note.isOpen(comment.id) || note.isEditing(comment.id)
},
comment: comment.comment,
selection: formatSelectedLineLabel(comment.selection, i18n.t),
get actions() {
return props.renderCommentActions?.(comment, { edit, remove })
},
get editor() {
return note.isEditing(comment.id)
? {
get value() {
return note.draft()
},
selection: formatSelectedLineLabel(comment.selection, i18n.t),
mention: props.mention,
onInput: note.setDraft,
onCancel: note.cancelDraft,
onSubmit: (value: string) => {
props.onUpdate?.({
id: comment.id,
comment: value,
selection: cloneSelectedLineRange(comment.selection),
})
note.cancelDraft()
},
submitLabel: props.editSubmitLabel,
}
: undefined
},
onMouseEnter: () => note.hoverComment(comment.selection),
onClick: () => {
if (note.isEditing(comment.id)) return
note.toggleComment(comment.id, comment.selection, { cancelDraft: props.cancelDraftOnCommentToggle })
},
}
},
renderDraft: (range) => ({
get value() {
return note.draft()
},
selection: formatSelectedLineLabel(range, i18n.t),
mention: props.mention,
onInput: note.setDraft,
onCancel: note.cancelDraft,
onSubmit: (comment) => {
props.onSubmit({ comment, selection: cloneSelectedLineRange(range) })
note.cancelDraft()
},
onPopoverFocusOut: props.onDraftPopoverFocusOut,
}),
})
const renderHoverUtility = createLineCommentHoverRenderer({
label: props.label,
getSelectedRange: () => {
if (note.opened()) return null
return props.getHoverSelectedRange?.() ?? note.selected()
},
onOpenDraft: note.openDraft,
})
const onLineSelected = (range: SelectedLineRange | null) => {
if (!range) {
note.select(null)
note.cancelDraft()
return
}
note.select(range)
}
const onLineSelectionEnd = (range: SelectedLineRange | null) => {
if (!range) {
if (props.clearSelectionOnSelectionEndNull) note.select(null)
note.cancelDraft()
return
}
note.finishSelection(range)
}
const onLineNumberSelectionEnd = (range: SelectedLineRange | null) => {
if (!range) return
note.openDraft(range)
}
return {
note,
annotations,
renderAnnotation,
renderHoverUtility,
onLineSelected,
onLineSelectionEnd,
onLineNumberSelectionEnd,
}
}
export function createLineCommentAnnotations<T>(
props: LineCommentAnnotationsWithSideProps<T>,
): Accessor<DiffLineAnnotation<LineCommentAnnotationMeta<T>>[]>
export function createLineCommentAnnotations<T>(
props: LineCommentAnnotationsProps<T>,
): Accessor<LineCommentAnnotation<T>[]>
export function createLineCommentAnnotations<T>(
props: LineCommentAnnotationsProps<T> | LineCommentAnnotationsWithSideProps<T>,
) {
const line = (range: SelectedLineRange) => Math.max(range.start, range.end)
if ("getSide" in props) {
return createMemo<DiffLineAnnotation<LineCommentAnnotationMeta<T>>[]>(() => {
const list = props.comments().map((comment) => {
const range = props.getCommentSelection(comment)
return {
side: props.getSide(range),
lineNumber: line(range),
metadata: {
kind: "comment",
key: `comment:${props.getCommentId(comment)}`,
comment,
} satisfies LineCommentAnnotationMeta<T>,
}
})
const range = props.draftRange()
if (!range) return list
return [
...list,
{
side: props.getSide(range),
lineNumber: line(range),
metadata: {
kind: "draft",
key: `draft:${props.draftKey()}`,
range,
} satisfies LineCommentAnnotationMeta<T>,
},
]
})
}
return createMemo<LineCommentAnnotation<T>[]>(() => {
const list = props.comments().map((comment) => {
const range = props.getCommentSelection(comment)
const entry: LineCommentAnnotation<T> = {
lineNumber: line(range),
metadata: {
kind: "comment",
key: `comment:${props.getCommentId(comment)}`,
comment,
},
}
return entry
})
const range = props.draftRange()
if (!range) return list
const draft: LineCommentAnnotation<T> = {
lineNumber: line(range),
metadata: {
kind: "draft",
key: `draft:${props.draftKey()}`,
range,
},
}
return [...list, draft]
})
}
export function createManagedLineCommentAnnotationRenderer<T>(props: {
annotations: Accessor<LineCommentAnnotation<T>[]>
renderComment: (comment: T) => CommentProps
renderDraft: (range: SelectedLineRange) => DraftProps
}) {
const renderer = createLineCommentAnnotationRenderer<T>({
renderComment: props.renderComment,
renderDraft: props.renderDraft,
})
createEffect(() => {
renderer.reconcile(props.annotations())
})
onCleanup(() => {
renderer.cleanup()
})
return {
renderAnnotation: renderer.render,
}
}
export function createLineCommentHoverRenderer(props: {
label: string
getSelectedRange: Accessor<SelectedLineRange | null>
onOpenDraft: (range: SelectedLineRange) => void
}) {
return (getHoveredLine: () => HoverCommentLine | undefined) =>
createHoverCommentUtility({
label: props.label,
getHoveredLine,
onSelect: (hovered) => {
const current = props.getSelectedRange()
if (current && lineInSelectedRange(current, hovered.lineNumber, hovered.side)) {
props.onOpenDraft(cloneSelectedLineRange(current))
return
}
const range: SelectedLineRange = {
start: hovered.lineNumber,
end: hovered.lineNumber,
}
if (hovered.side) range.side = hovered.side
props.onOpenDraft(range)
},
})
}

View File

@@ -0,0 +1,292 @@
export const lineCommentStyles = `
[data-annotation-slot] {
padding: 12px;
box-sizing: border-box;
}
[data-component="line-comment"] {
position: absolute;
right: 24px;
z-index: var(--line-comment-z, 30);
}
[data-component="line-comment"][data-inline] {
position: relative;
right: auto;
display: flex;
width: 100%;
min-width: 0;
align-items: flex-start;
}
[data-component="line-comment"][data-open] {
z-index: var(--line-comment-open-z, 100);
}
[data-component="line-comment"] [data-slot="line-comment-button"] {
width: 20px;
height: 20px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
background: var(--icon-interactive-base);
box-shadow: var(--shadow-xs);
cursor: default;
border: none;
}
[data-component="line-comment"][data-variant="add"] [data-slot="line-comment-button"] {
background: var(--syntax-diff-add);
}
[data-component="line-comment"] [data-component="icon"] {
color: var(--white);
}
[data-component="line-comment"] [data-slot="line-comment-icon"] {
width: 12px;
height: 12px;
color: var(--white);
}
[data-component="line-comment"] [data-slot="line-comment-button"]:focus {
outline: none;
}
[data-component="line-comment"] [data-slot="line-comment-button"]:focus-visible {
box-shadow: var(--shadow-xs-border-focus);
}
[data-component="line-comment"] [data-slot="line-comment-popover"] {
position: absolute;
top: calc(100% + 4px);
right: -8px;
z-index: var(--line-comment-popover-z, 40);
min-width: 200px;
max-width: none;
box-sizing: border-box;
border-radius: 8px;
background: var(--surface-raised-stronger-non-alpha);
box-shadow: var(--shadow-xxs-border);
padding: 12px;
}
[data-component="line-comment"][data-inline] [data-slot="line-comment-popover"] {
position: relative;
top: auto;
right: auto;
margin-left: 8px;
flex: 1 1 0%;
width: auto;
max-width: 100%;
min-width: 0;
}
[data-component="line-comment"][data-inline] [data-slot="line-comment-popover"][data-inline-body] {
margin-left: 0;
}
[data-component="line-comment"][data-inline][data-variant="default"] [data-slot="line-comment-popover"][data-inline-body] {
cursor: pointer;
}
[data-component="line-comment"][data-variant="editor"] [data-slot="line-comment-popover"] {
width: 380px;
max-width: none;
padding: 8px;
border-radius: 14px;
}
[data-component="line-comment"][data-inline][data-variant="editor"] [data-slot="line-comment-popover"] {
width: 100%;
}
[data-component="line-comment"] [data-slot="line-comment-content"] {
display: flex;
flex-direction: column;
gap: 6px;
width: 100%;
min-width: 0;
}
[data-component="line-comment"] [data-slot="line-comment-head"] {
display: flex;
align-items: flex-start;
gap: 8px;
min-width: 0;
}
[data-component="line-comment"] [data-slot="line-comment-text"] {
flex: 1;
min-width: 0;
font-family: var(--font-family-sans);
font-size: var(--font-size-base);
font-weight: var(--font-weight-regular);
line-height: var(--line-height-x-large);
letter-spacing: var(--letter-spacing-normal);
color: var(--text-strong);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
[data-component="line-comment"] [data-slot="line-comment-tools"] {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: flex-end;
min-width: 0;
}
[data-component="line-comment"] [data-slot="line-comment-label"],
[data-component="line-comment"] [data-slot="line-comment-editor-label"] {
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal);
color: var(--text-weak);
min-width: 0;
white-space: normal;
overflow-wrap: anywhere;
}
[data-component="line-comment"] [data-slot="line-comment-editor"] {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
min-width: 0;
}
[data-component="line-comment"] [data-slot="line-comment-textarea"] {
width: 100%;
box-sizing: border-box;
resize: vertical;
padding: 8px;
border-radius: var(--radius-md);
background: var(--surface-base);
border: 1px solid var(--border-base);
color: var(--text-strong);
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
line-height: var(--line-height-large);
}
[data-component="line-comment"] [data-slot="line-comment-textarea"]:focus {
outline: none;
box-shadow: var(--shadow-xs-border-select);
}
[data-component="line-comment"] [data-slot="line-comment-mention-list"] {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 180px;
overflow: auto;
padding: 4px;
border: 1px solid var(--border-base);
border-radius: var(--radius-md);
background: var(--surface-base);
}
[data-component="line-comment"] [data-slot="line-comment-mention-item"] {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-width: 0;
padding: 6px 8px;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-strong);
text-align: left;
}
[data-component="line-comment"] [data-slot="line-comment-mention-item"][data-active] {
background: var(--surface-raised-base-hover);
}
[data-component="line-comment"] [data-slot="line-comment-mention-path"] {
display: flex;
align-items: center;
min-width: 0;
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
line-height: var(--line-height-large);
}
[data-component="line-comment"] [data-slot="line-comment-mention-dir"] {
min-width: 0;
color: var(--text-weak);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
[data-component="line-comment"] [data-slot="line-comment-mention-file"] {
color: var(--text-strong);
white-space: nowrap;
}
[data-component="line-comment"] [data-slot="line-comment-actions"] {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
padding-left: 8px;
min-width: 0;
}
[data-component="line-comment"] [data-slot="line-comment-editor-label"] {
flex: 1 1 220px;
margin-right: auto;
}
[data-component="line-comment"] [data-slot="line-comment-action"] {
border: 1px solid var(--border-base);
background: var(--surface-base);
color: var(--text-strong);
border-radius: var(--radius-md);
height: 28px;
padding: 0 10px;
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
font-weight: var(--font-weight-medium);
}
[data-component="line-comment"] [data-slot="line-comment-action"][data-variant="ghost"] {
background: transparent;
}
[data-component="line-comment"] [data-slot="line-comment-action"][data-variant="primary"] {
background: var(--text-strong);
border-color: var(--text-strong);
color: var(--background-base);
}
[data-component="line-comment"] [data-slot="line-comment-action"]:disabled {
opacity: 0.5;
pointer-events: none;
}
`
let installed = false
export function installLineCommentStyles() {
if (installed) return
if (typeof document === "undefined") return
const id = "opencode-line-comment-styles"
if (document.getElementById(id)) {
installed = true
return
}
const style = document.createElement("style")
style.id = id
style.textContent = lineCommentStyles
document.head.appendChild(style)
installed = true
}

View File

@@ -0,0 +1,115 @@
// @ts-nocheck
import { createSignal } from "solid-js"
import * as mod from "./line-comment"
const docs = `### Overview
Inline comment anchor and editor for code review or annotation flows.
Pair with \`Diff\` or \`Code\` to align comments to lines.
### API
- \`LineCommentAnchor\`: position with \`top\`, control \`open\`, render custom children.
- \`LineComment\`: convenience wrapper for displaying comment + selection label.
- \`LineCommentEditor\`: controlled textarea with submit/cancel handlers.
### Variants and states
- Default display and editor display variants.
### Behavior
- Anchor positions relative to a containing element.
- Editor submits on Enter (Shift+Enter for newline).
### Accessibility
- TODO: confirm ARIA labeling for comment button and editor textarea.
### Theming/tokens
- Uses \`data-component="line-comment"\` and related slots.
`
export default {
title: "UI/LineComment",
id: "components-line-comment",
component: mod.LineComment,
tags: ["autodocs"],
parameters: {
docs: {
description: {
component: docs,
},
},
},
}
export const Default = {
render: () => (
<div
style={{
position: "relative",
height: "160px",
padding: "16px 16px 16px 40px",
border: "1px solid var(--border-weak)",
"border-radius": "8px",
"font-family": "var(--font-family-mono)",
"font-size": "12px",
color: "var(--text-weak)",
}}
>
<div>12 | const total = sum(values)</div>
<div>13 | return total / values.length</div>
<mod.LineComment open top={18} comment="Consider guarding against empty arrays." selection="L12-L13" />
</div>
),
}
export const Editor = {
render: () => {
const [value, setValue] = createSignal("Add context for this change.")
return (
<div
style={{
position: "relative",
height: "220px",
padding: "16px 16px 16px 40px",
border: "1px solid var(--border-weak)",
"border-radius": "8px",
"font-family": "var(--font-family-mono)",
"font-size": "12px",
color: "var(--text-weak)",
}}
>
<div>40 | if (values.length === 0) return 0</div>
<mod.LineCommentEditor
top={24}
value={value()}
selection="L40"
onInput={setValue}
onCancel={() => setValue("")}
onSubmit={(next) => setValue(next)}
/>
</div>
)
},
}
export const AnchorOnly = {
render: () => (
<div
style={{
position: "relative",
height: "120px",
padding: "16px 16px 16px 40px",
border: "1px solid var(--border-weak)",
"border-radius": "8px",
"font-family": "var(--font-family-mono)",
"font-size": "12px",
color: "var(--text-weak)",
}}
>
<div>20 | const ready = true</div>
<mod.LineCommentAnchor top={18} open={false}>
<div data-slot="line-comment-content">Anchor content</div>
</mod.LineCommentAnchor>
</div>
),
}

View File

@@ -0,0 +1,438 @@
import { useFilteredList } from "@opencode-ai/ui/hooks"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { createSignal, For, onMount, Show, splitProps, type JSX } from "solid-js"
import { Button } from "./button"
import { FileIcon } from "./file-icon"
import { Icon } from "./icon"
import { installLineCommentStyles } from "./line-comment-styles"
import { useI18n } from "../context/i18n"
installLineCommentStyles()
export type LineCommentVariant = "default" | "editor" | "add"
function InlineGlyph(props: { icon: "comment" | "plus" }) {
return (
<svg data-slot="line-comment-icon" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<Show
when={props.icon === "comment"}
fallback={
<path
d="M10 5.41699V10.0003M10 10.0003V14.5837M10 10.0003H5.4165M10 10.0003H14.5832"
stroke="currentColor"
stroke-linecap="square"
/>
}
>
<path d="M16.25 3.75H3.75V16.25L6.875 14.4643H16.25V3.75Z" stroke="currentColor" stroke-linecap="square" />
</Show>
</svg>
)
}
export type LineCommentAnchorProps = {
id?: string
top?: number
inline?: boolean
hideButton?: boolean
open: boolean
variant?: LineCommentVariant
icon?: "comment" | "plus"
buttonLabel?: string
onClick?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
onMouseEnter?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
onPopoverFocusOut?: JSX.EventHandlerUnion<HTMLDivElement, FocusEvent>
class?: string
popoverClass?: string
children?: JSX.Element
}
export const LineCommentAnchor = (props: LineCommentAnchorProps) => {
const hidden = () => !props.inline && props.top === undefined
const variant = () => props.variant ?? "default"
const icon = () => props.icon ?? "comment"
const inlineBody = () => props.inline && props.hideButton
return (
<div
data-component="line-comment"
data-prevent-autofocus=""
data-variant={variant()}
data-comment-id={props.id}
data-open={props.open ? "" : undefined}
data-inline={props.inline ? "" : undefined}
classList={{
[props.class ?? ""]: !!props.class,
}}
style={
props.inline
? undefined
: {
top: `${props.top ?? 0}px`,
opacity: hidden() ? 0 : 1,
"pointer-events": hidden() ? "none" : "auto",
}
}
>
<Show
when={inlineBody()}
fallback={
<>
<button
type="button"
aria-label={props.buttonLabel}
data-slot="line-comment-button"
on:mousedown={(e) => e.stopPropagation()}
on:mouseup={(e) => e.stopPropagation()}
on:click={props.onClick as any}
on:mouseenter={props.onMouseEnter as any}
>
<Show
when={props.inline}
fallback={<Icon name={icon() === "plus" ? "plus-small" : "comment"} size="small" />}
>
<InlineGlyph icon={icon()} />
</Show>
</button>
<Show when={props.open}>
<div
data-slot="line-comment-popover"
classList={{
[props.popoverClass ?? ""]: !!props.popoverClass,
}}
on:mousedown={(e) => e.stopPropagation()}
on:focusout={props.onPopoverFocusOut as any}
>
{props.children}
</div>
</Show>
</>
}
>
<div
data-slot="line-comment-popover"
data-inline-body=""
classList={{
[props.popoverClass ?? ""]: !!props.popoverClass,
}}
on:mousedown={(e) => e.stopPropagation()}
on:click={props.onClick as any}
on:mouseenter={props.onMouseEnter as any}
on:focusout={props.onPopoverFocusOut as any}
>
{props.children}
</div>
</Show>
</div>
)
}
export type LineCommentProps = Omit<LineCommentAnchorProps, "children" | "variant"> & {
comment: JSX.Element
selection: JSX.Element
actions?: JSX.Element
}
export const LineComment = (props: LineCommentProps) => {
const i18n = useI18n()
const [split, rest] = splitProps(props, ["comment", "selection", "actions"])
return (
<LineCommentAnchor {...rest} variant="default" hideButton={props.inline}>
<div data-slot="line-comment-content">
<div data-slot="line-comment-head">
<div data-slot="line-comment-text">{split.comment}</div>
<Show when={split.actions}>
<div data-slot="line-comment-tools">{split.actions}</div>
</Show>
</div>
<div data-slot="line-comment-label">
{i18n.t("ui.lineComment.label.prefix")}
{split.selection}
{i18n.t("ui.lineComment.label.suffix")}
</div>
</div>
</LineCommentAnchor>
)
}
export type LineCommentAddProps = Omit<LineCommentAnchorProps, "children" | "variant" | "open" | "icon"> & {
label?: string
}
export const LineCommentAdd = (props: LineCommentAddProps) => {
const [split, rest] = splitProps(props, ["label"])
const i18n = useI18n()
return (
<LineCommentAnchor
{...rest}
open={false}
variant="add"
icon="plus"
buttonLabel={split.label ?? i18n.t("ui.lineComment.submit")}
/>
)
}
export type LineCommentEditorProps = Omit<LineCommentAnchorProps, "children" | "open" | "variant" | "onClick"> & {
value: string
selection: JSX.Element
onInput: (value: string) => void
onCancel: VoidFunction
onSubmit: (value: string) => void
placeholder?: string
rows?: number
autofocus?: boolean
cancelLabel?: string
submitLabel?: string
mention?: {
items: (query: string) => string[] | Promise<string[]>
}
}
export const LineCommentEditor = (props: LineCommentEditorProps) => {
const i18n = useI18n()
const [split, rest] = splitProps(props, [
"value",
"selection",
"onInput",
"onCancel",
"onSubmit",
"placeholder",
"rows",
"autofocus",
"cancelLabel",
"submitLabel",
"mention",
])
const refs = {
textarea: undefined as HTMLTextAreaElement | undefined,
}
const [open, setOpen] = createSignal(false)
function selectMention(item: { path: string } | undefined) {
if (!item) return
const textarea = refs.textarea
const query = currentMention()
if (!textarea || !query) return
const value = `${textarea.value.slice(0, query.start)}@${item.path} ${textarea.value.slice(query.end)}`
const cursor = query.start + item.path.length + 2
split.onInput(value)
closeMention()
requestAnimationFrame(() => {
textarea.focus()
textarea.setSelectionRange(cursor, cursor)
})
}
const mention = useFilteredList<{ path: string }>({
items: async (query) => {
if (!split.mention) return []
if (!query.trim()) return []
const paths = await split.mention.items(query)
return paths.map((path) => ({ path }))
},
key: (item) => item.path,
filterKeys: ["path"],
onSelect: selectMention,
})
const focus = () => refs.textarea?.focus()
const hold: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent> = (e) => {
e.preventDefault()
e.stopPropagation()
}
const click =
(fn: VoidFunction): JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent> =>
(e) => {
e.stopPropagation()
fn()
}
const closeMention = () => {
setOpen(false)
mention.clear()
}
const currentMention = () => {
const textarea = refs.textarea
if (!textarea) return
if (!split.mention) return
if (textarea.selectionStart !== textarea.selectionEnd) return
const end = textarea.selectionStart
const match = textarea.value.slice(0, end).match(/@(\S*)$/)
if (!match) return
return {
query: match[1] ?? "",
start: end - match[0].length,
end,
}
}
const syncMention = () => {
const item = currentMention()
if (!item) {
closeMention()
return
}
setOpen(true)
mention.onInput(item.query)
}
const selectActiveMention = () => {
const items = mention.flat()
if (items.length === 0) return
const active = mention.active()
selectMention(items.find((item) => item.path === active) ?? items[0])
}
const submit = () => {
const value = split.value.trim()
if (!value) return
split.onSubmit(value)
}
onMount(() => {
if (split.autofocus === false) return
requestAnimationFrame(focus)
})
return (
<LineCommentAnchor {...rest} open={true} variant="editor" hideButton={props.inline} onClick={() => focus()}>
<div data-slot="line-comment-editor">
<textarea
ref={(el) => {
refs.textarea = el
}}
data-slot="line-comment-textarea"
rows={split.rows ?? 3}
placeholder={split.placeholder ?? i18n.t("ui.lineComment.placeholder")}
value={split.value}
on:input={(e) => {
const value = (e.currentTarget as HTMLTextAreaElement).value
split.onInput(value)
syncMention()
}}
on:click={() => syncMention()}
on:select={() => syncMention()}
on:keydown={(e) => {
const event = e as KeyboardEvent
if (event.isComposing || event.keyCode === 229) return
event.stopPropagation()
if (open()) {
if (e.key === "Escape") {
event.preventDefault()
closeMention()
return
}
if (e.key === "Tab") {
if (mention.flat().length === 0) return
event.preventDefault()
selectActiveMention()
return
}
const nav = e.key === "ArrowUp" || e.key === "ArrowDown" || e.key === "Enter"
const ctrlNav =
event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey && (e.key === "n" || e.key === "p")
if ((nav || ctrlNav) && mention.flat().length > 0) {
mention.onKeyDown(event)
event.preventDefault()
return
}
}
if (e.key === "Escape") {
event.preventDefault()
e.currentTarget.blur()
split.onCancel()
return
}
if (e.key !== "Enter") return
if (e.shiftKey) return
event.preventDefault()
submit()
}}
/>
<Show when={open() && mention.flat().length > 0}>
<div data-slot="line-comment-mention-list">
<For each={mention.flat().slice(0, 10)}>
{(item) => {
const directory = item.path.endsWith("/") ? item.path : getDirectory(item.path)
const name = item.path.endsWith("/") ? "" : getFilename(item.path)
return (
<button
type="button"
data-slot="line-comment-mention-item"
data-active={mention.active() === item.path ? "" : undefined}
onMouseDown={(event) => event.preventDefault()}
onMouseEnter={() => mention.setActive(item.path)}
onClick={() => selectMention(item)}
>
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-4" />
<div data-slot="line-comment-mention-path">
<span data-slot="line-comment-mention-dir">{directory}</span>
<Show when={name}>
<span data-slot="line-comment-mention-file">{name}</span>
</Show>
</div>
</button>
)
}}
</For>
</div>
</Show>
<div data-slot="line-comment-actions">
<div data-slot="line-comment-editor-label">
{i18n.t("ui.lineComment.editorLabel.prefix")}
{split.selection}
{i18n.t("ui.lineComment.editorLabel.suffix")}
</div>
<Show
when={!props.inline}
fallback={
<>
<button
type="button"
data-slot="line-comment-action"
data-variant="ghost"
on:mousedown={hold as any}
on:click={click(split.onCancel) as any}
>
{split.cancelLabel ?? i18n.t("ui.common.cancel")}
</button>
<button
type="button"
data-slot="line-comment-action"
data-variant="primary"
disabled={split.value.trim().length === 0}
on:mousedown={hold as any}
on:click={click(submit) as any}
>
{split.submitLabel ?? i18n.t("ui.lineComment.submit")}
</button>
</>
}
>
<Button size="small" variant="ghost" onClick={split.onCancel}>
{split.cancelLabel ?? i18n.t("ui.common.cancel")}
</Button>
<Button size="small" variant="primary" disabled={split.value.trim().length === 0} onClick={submit}>
{split.submitLabel ?? i18n.t("ui.lineComment.submit")}
</Button>
</Show>
</div>
</div>
</LineCommentAnchor>
)
}

View File

@@ -0,0 +1,331 @@
@property --bottom-fade {
syntax: "<length>";
inherits: false;
initial-value: 0px;
}
@keyframes scroll {
0% {
--bottom-fade: 20px;
}
90% {
--bottom-fade: 20px;
}
100% {
--bottom-fade: 0;
}
}
[data-component="list"] {
display: flex;
flex-direction: column;
gap: 12px;
overflow: hidden;
padding: 0 12px;
[data-slot="list-search-wrapper"] {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 8px;
align-self: stretch;
margin-bottom: 4px;
> [data-component="icon-button"] {
width: 24px;
height: 24px;
flex-shrink: 0;
background-color: transparent;
opacity: 0.5;
transition: opacity 0.15s ease;
&:hover:not(:disabled),
&:focus-visible:not(:disabled),
&:active:not(:disabled) {
background-color: transparent;
opacity: 0.7;
}
&:hover:not(:disabled) [data-slot="icon-svg"] {
color: var(--icon-hover);
}
&:active:not(:disabled) [data-slot="icon-svg"] {
color: var(--icon-active);
}
}
}
[data-slot="list-search"] {
display: flex;
flex: 1;
padding: 8px;
align-items: center;
gap: 12px;
border-radius: var(--radius-md);
background: var(--surface-base);
[data-slot="list-search-container"] {
display: flex;
align-items: center;
gap: 8px;
flex: 1 0 0;
max-height: 20px;
[data-slot="list-search-input"] {
width: 100%;
&[data-slot="input-input"] {
line-height: 20px;
max-height: 20px;
}
}
}
> [data-component="icon-button"] {
width: 20px;
height: 20px;
background-color: transparent;
opacity: 0.5;
transition: opacity 0.15s ease;
&:hover:not(:disabled),
&:focus-visible:not(:disabled),
&:active:not(:disabled) {
background-color: transparent;
opacity: 0.7;
}
&:hover:not(:disabled) [data-slot="icon-svg"] {
color: var(--icon-hover);
}
&:active:not(:disabled) [data-slot="icon-svg"] {
color: var(--icon-active);
}
}
> [data-component="icon-button"] {
background-color: transparent;
&:hover:not(:disabled),
&:focus:not(:disabled),
&:active:not(:disabled) {
background-color: transparent;
}
&:hover:not(:disabled) [data-slot="icon-svg"] {
color: var(--icon-hover);
}
&:active:not(:disabled) [data-slot="icon-svg"] {
color: var(--icon-active);
}
}
}
[data-slot="list-scroll"] {
display: flex;
flex-direction: column;
gap: 12px;
overflow-y: auto;
overscroll-behavior: contain;
mask: linear-gradient(to bottom, #ffff calc(100% - var(--bottom-fade)), #0000);
animation: scroll;
animation-timeline: --scroll;
scroll-timeline: --scroll y;
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
[data-slot="list-empty-state"] {
display: flex;
padding: 32px 48px;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 8px;
align-self: stretch;
[data-slot="list-message"] {
display: flex;
justify-content: center;
align-items: center;
gap: 2px;
max-width: 100%;
color: var(--text-weak);
white-space: nowrap;
/* text-14-regular */
font-family: var(--font-family-sans);
font-size: 14px;
font-style: normal;
font-weight: var(--font-weight-regular);
line-height: var(--line-height-large); /* 142.857% */
letter-spacing: var(--letter-spacing-normal);
}
[data-slot="list-filter"] {
color: var(--text-strong);
overflow: hidden;
text-overflow: ellipsis;
}
}
[data-slot="list-group"] {
position: relative;
display: flex;
flex-direction: column;
&:last-child {
padding-bottom: 12px;
}
[data-slot="list-header"] {
display: flex;
z-index: 10;
padding: 8px 12px 8px 8px;
justify-content: space-between;
align-items: center;
align-self: stretch;
background: var(--surface-raised-stronger-non-alpha);
position: sticky;
top: 0;
color: var(--text-weak);
/* text-14-medium */
font-family: var(--font-family-sans);
font-size: 14px;
font-style: normal;
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large); /* 142.857% */
letter-spacing: var(--letter-spacing-normal);
&::after {
content: "";
position: absolute;
top: 100%;
left: 0;
right: 0;
height: 16px;
background: linear-gradient(to bottom, var(--surface-raised-stronger-non-alpha), transparent);
pointer-events: none;
opacity: 0;
transition: opacity 0.15s ease;
}
&[data-stuck="true"]::after {
opacity: 1;
}
}
[data-slot="list-items"] {
display: flex;
flex-direction: column;
align-items: flex-start;
align-self: stretch;
[data-slot="list-item"] {
display: flex;
position: relative;
width: 100%;
padding: 6px 8px 6px 8px;
align-items: center;
color: var(--text-strong);
scroll-margin-top: 28px;
/* text-14-medium */
font-family: var(--font-family-sans);
font-size: 14px;
font-style: normal;
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large); /* 142.857% */
letter-spacing: var(--letter-spacing-normal);
[data-slot="list-item-selected-icon"] {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
aspect-ratio: 1/1;
[data-component="icon"] {
color: var(--icon-strong-base);
}
}
[data-slot="list-item-active-icon"] {
display: none;
align-items: center;
justify-content: center;
flex-shrink: 0;
aspect-ratio: 1/1;
[data-component="icon"] {
color: var(--icon-strong-base);
}
}
[data-slot="list-item-extra-icon"] {
color: var(--icon-base);
margin-left: -4px;
}
[data-slot="list-item-divider"] {
position: absolute;
bottom: 0;
left: var(--list-divider-inset, 16px);
right: var(--list-divider-inset, 16px);
height: 1px;
background: var(--border-weak-base);
pointer-events: none;
}
[data-slot="list-item"]:last-child [data-slot="list-item-divider"] {
display: none;
}
&[data-active="true"] {
border-radius: var(--radius-md);
background: var(--surface-raised-base-hover);
[data-slot="list-item-active-icon"] {
display: inline-flex;
}
[data-slot="list-item-extra-icon"] {
display: block !important;
color: var(--icon-strong-base) !important;
}
}
&:active {
background: var(--surface-raised-base-active);
}
&:focus-visible {
outline: none;
}
}
[data-slot="list-item-add"] {
display: flex;
position: relative;
width: 100%;
padding: 6px 8px 6px 8px;
align-items: center;
color: var(--text-strong);
/* text-14-medium */
font-family: var(--font-family-sans);
font-size: 14px;
font-style: normal;
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large); /* 142.857% */
letter-spacing: var(--letter-spacing-normal);
[data-component="input"] {
width: 100%;
}
}
}
}
}
}

View File

@@ -0,0 +1,170 @@
// @ts-nocheck
import * as mod from "./list"
import { create } from "../storybook/scaffold"
const docs = `### Overview
Filterable list with keyboard navigation and optional search input.
Use within panels or popovers where keyboard navigation is expected.
### API
- Required: \`items\` and \`key\`.
- Required: \`children\` render function for items.
- Optional: \`search\`, \`filterKeys\`, \`groupBy\`, \`onSelect\`, \`onKeyEvent\`.
### Variants and states
- Optional search bar and group headers.
### Behavior
- Uses fuzzy search when \`search\` is enabled.
- Keyboard navigation via arrow keys; Enter selects.
### Accessibility
- TODO: confirm ARIA roles for list items and search input.
### Theming/tokens
- Uses \`data-component="list"\` and data slots for structure.
`
const story = create({
title: "UI/List",
mod,
args: {
items: ["One", "Two", "Three", "Four"],
key: (x: string) => x,
children: (x: string) => x,
search: true,
},
})
export default {
title: "UI/List",
id: "components-list",
component: story.meta.component,
tags: ["autodocs"],
parameters: {
docs: {
description: {
component: docs,
},
},
},
}
export const Basic = story.Basic
export const Grouped = {
render: () => {
const items = [
{ id: "a1", title: "Alpha", group: "Group A" },
{ id: "a2", title: "Bravo", group: "Group A" },
{ id: "b1", title: "Delta", group: "Group B" },
]
return (
<mod.List items={items} key={(item) => item.id} groupBy={(item) => item.group} search={true}>
{(item) => item.title}
</mod.List>
)
},
}
export const Empty = {
render: () => (
<mod.List items={[]} key={(item) => item} search={true}>
{(item) => item}
</mod.List>
),
}
export const WithAdd = {
render: () => (
<mod.List
items={["One", "Two"]}
key={(item) => item}
search={true}
add={{
render: () => (
<button type="button" data-slot="list-item">
Add item
</button>
),
}}
>
{(item) => item}
</mod.List>
),
}
export const Divider = {
render: () => (
<mod.List items={["One", "Two", "Three"]} key={(item) => item} divider={true}>
{(item) => item}
</mod.List>
),
}
export const ActiveIcon = {
render: () => (
<mod.List items={["Alpha", "Beta", "Gamma"]} key={(item) => item} activeIcon="chevron-right">
{(item) => item}
</mod.List>
),
}
export const NoSearch = {
render: () => (
<mod.List items={["One", "Two", "Three"]} key={(item) => item} search={false}>
{(item) => item}
</mod.List>
),
}
export const SearchOptions = {
render: () => (
<mod.List
items={["Apple", "Banana", "Cherry"]}
key={(item) => item}
search={{
placeholder: "Filter...",
hideIcon: true,
action: <button type="button">Action</button>,
}}
>
{(item) => item}
</mod.List>
),
}
export const ItemWrapper = {
render: () => (
<mod.List
items={["One", "Two", "Three"]}
key={(item) => item}
itemWrapper={(item, node) => (
<div style={{ border: "1px solid var(--border-weak)", "border-radius": "6px", margin: "4px 0" }}>{node}</div>
)}
>
{(item) => item}
</mod.List>
),
}
export const GroupHeader = {
render: () => {
const items = [
{ id: "a1", title: "Alpha", group: "Group A" },
{ id: "b1", title: "Beta", group: "Group B" },
]
return (
<mod.List
items={items}
key={(item) => item.id}
groupBy={(item) => item.group}
groupHeader={(group) => <strong>{group.category}</strong>}
>
{(item) => item.title}
</mod.List>
)
},
}

View File

@@ -0,0 +1,394 @@
import { type FilteredListProps, useFilteredList } from "@opencode-ai/ui/hooks"
import { createEffect, For, type JSX, on, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { useI18n } from "../context/i18n"
import { Icon, type IconProps } from "./icon"
import { IconButton } from "./icon-button"
import { TextField } from "./text-field"
function findByKey(container: HTMLElement, key: string) {
const nodes = container.querySelectorAll<HTMLElement>('[data-slot="list-item"][data-key]')
for (const node of nodes) {
if (node.getAttribute("data-key") === key) return node
}
}
export interface ListSearchProps {
placeholder?: string
autofocus?: boolean
hideIcon?: boolean
class?: string
action?: JSX.Element
}
export interface ListAddProps {
class?: string
render: () => JSX.Element
}
export interface ListAddProps {
class?: string
render: () => JSX.Element
}
export interface ListProps<T> extends FilteredListProps<T> {
class?: string
children: (item: T) => JSX.Element
emptyMessage?: string
loadingMessage?: string
onKeyEvent?: (event: KeyboardEvent, item: T | undefined) => void
onMove?: (item: T | undefined) => void
onFilter?: (value: string) => void
activeIcon?: IconProps["name"]
filter?: string
search?: ListSearchProps | boolean
itemWrapper?: (item: T, node: JSX.Element) => JSX.Element
divider?: boolean
add?: ListAddProps
groupHeader?: (group: { category: string; items: T[] }) => JSX.Element
}
export interface ListRef {
onKeyDown: (e: KeyboardEvent) => void
setScrollRef: (el: HTMLDivElement | undefined) => void
setFilter: (value: string) => void
}
export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void }) {
const i18n = useI18n()
let inputRef: HTMLInputElement | HTMLTextAreaElement | undefined
const [store, setStore] = createStore({
mouseActive: false,
scrollRef: undefined as HTMLDivElement | undefined,
internalFilter: "",
})
const scrollRef = () => store.scrollRef
const setScrollRef = (el: HTMLDivElement | undefined) => setStore("scrollRef", el)
const internalFilter = () => store.internalFilter
const setInternalFilter = (value: string) => setStore("internalFilter", value)
const scrollIntoView = (container: HTMLDivElement, node: HTMLElement, block: "center" | "nearest") => {
const containerRect = container.getBoundingClientRect()
const nodeRect = node.getBoundingClientRect()
const top = nodeRect.top - containerRect.top + container.scrollTop
const bottom = top + nodeRect.height
const viewTop = container.scrollTop
const viewBottom = viewTop + container.clientHeight
const target =
block === "center"
? top - container.clientHeight / 2 + nodeRect.height / 2
: top < viewTop
? top
: bottom > viewBottom
? bottom - container.clientHeight
: viewTop
const max = Math.max(0, container.scrollHeight - container.clientHeight)
container.scrollTop = Math.max(0, Math.min(target, max))
}
const { filter, grouped, flat, active, setActive, onKeyDown, onInput, refetch } = useFilteredList<T>(props)
const searchProps = () => (typeof props.search === "object" ? props.search : {})
const searchAction = () => searchProps().action
const addProps = () => props.add
const showAdd = () => !!addProps()
const moved = (event: MouseEvent) => event.movementX !== 0 || event.movementY !== 0
const applyFilter = (value: string, options?: { ref?: boolean }) => {
const prev = filter()
setInternalFilter(value)
onInput(value)
props.onFilter?.(value)
if (!options?.ref) return
// Force a refetch even if the value is unchanged.
// This is important for programmatic changes like Tab completion.
if (prev === value) {
void refetch()
return
}
queueMicrotask(() => refetch())
}
createEffect(() => {
if (props.filter === undefined) return
if (props.filter === internalFilter()) return
setInternalFilter(props.filter)
onInput(props.filter)
})
createEffect(
on(
filter,
() => {
scrollRef()?.scrollTo(0, 0)
},
{ defer: true },
),
)
createEffect(() => {
const scroll = scrollRef()
if (!scroll) return
if (!props.current) return
const key = props.key(props.current)
requestAnimationFrame(() => {
const element = findByKey(scroll, key)
if (!element) return
scrollIntoView(scroll, element, "center")
})
})
createEffect(() => {
const all = flat()
if (store.mouseActive || all.length === 0) return
const scroll = scrollRef()
if (!scroll) return
if (active() === props.key(all[0])) {
scroll.scrollTo(0, 0)
return
}
const key = active()
if (!key) return
const element = findByKey(scroll, key)
if (!element) return
scrollIntoView(scroll, element, "center")
})
createEffect(() => {
const all = flat()
const current = active()
const item = all.find((x) => props.key(x) === current)
props.onMove?.(item)
})
const handleSelect = (item: T | undefined, index: number) => {
props.onSelect?.(item, index)
}
const handleKey = (e: KeyboardEvent) => {
setStore("mouseActive", false)
if (e.key === "Escape") return
const all = flat()
const selected = all.find((x) => props.key(x) === active())
const index = selected ? all.indexOf(selected) : -1
props.onKeyEvent?.(e, selected)
if (e.defaultPrevented) return
if (e.key === "Enter" && !e.isComposing) {
e.preventDefault()
if (selected) handleSelect(selected, index)
} else if (props.search) {
if (e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey && (e.key === "n" || e.key === "p")) {
onKeyDown(e)
return
}
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
onKeyDown(e)
}
} else {
onKeyDown(e)
}
}
props.ref?.({
onKeyDown: handleKey,
setScrollRef,
setFilter: (value) => applyFilter(value, { ref: true }),
})
const renderAdd = () => {
const add = addProps()
if (!add) return null
return (
<div data-slot="list-item-add" classList={{ [add.class ?? ""]: !!add.class }}>
{add.render()}
</div>
)
}
function GroupHeader(groupProps: { group: { category: string; items: T[] } }): JSX.Element {
const [state, setState] = createStore({
stuck: false,
header: undefined as HTMLDivElement | undefined,
})
createEffect(() => {
const scroll = scrollRef()
const node = state.header
if (!scroll || !node) return
const handler = () => {
const rect = node.getBoundingClientRect()
const scrollRect = scroll.getBoundingClientRect()
setState("stuck", rect.top <= scrollRect.top + 1 && scroll.scrollTop > 0)
}
makeEventListener(scroll, "scroll", handler, { passive: true })
handler()
})
return (
<div data-slot="list-header" data-stuck={state.stuck} ref={(el) => setState("header", el)}>
{props.groupHeader?.(groupProps.group) ?? groupProps.group.category}
</div>
)
}
const emptyMessage = () => {
if (grouped.loading) return props.loadingMessage ?? i18n.t("ui.list.loading")
if (props.emptyMessage) return props.emptyMessage
const query = filter()
if (!query) return i18n.t("ui.list.empty")
const suffix = i18n.t("ui.list.emptyWithFilter.suffix")
return (
<>
<span>{i18n.t("ui.list.emptyWithFilter.prefix")}</span>
<span data-slot="list-filter">&quot;{query}&quot;</span>
<Show when={suffix}>
<span>{suffix}</span>
</Show>
</>
)
}
return (
<div data-component="list" classList={{ [props.class ?? ""]: !!props.class }}>
<Show when={!!props.search}>
<div data-slot="list-search-wrapper">
<div
data-slot="list-search"
classList={{ [searchProps().class ?? ""]: !!searchProps().class }}
onPointerDown={(event) => {
const container = event.currentTarget
if (!(container instanceof HTMLElement)) return
const node = container.querySelector("input, textarea")
const input = node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement ? node : inputRef
input?.focus()
// Prevent global listeners (e.g. dnd sensors) from cancelling focus.
event.stopPropagation()
}}
>
<div data-slot="list-search-container">
<Show when={!searchProps().hideIcon}>
<Icon name="magnifying-glass" />
</Show>
<TextField
autofocus={searchProps().autofocus}
variant="ghost"
data-slot="list-search-input"
type="text"
ref={(el: HTMLInputElement | HTMLTextAreaElement) => {
inputRef = el
}}
value={internalFilter()}
onChange={(value) => applyFilter(value)}
onKeyDown={handleKey}
placeholder={searchProps().placeholder}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
/>
</div>
<Show when={internalFilter()}>
<IconButton
icon="circle-x"
variant="ghost"
onClick={() => {
setInternalFilter("")
queueMicrotask(() => inputRef?.focus())
}}
aria-label={i18n.t("ui.list.clearFilter")}
/>
</Show>
</div>
{searchAction()}
</div>
</Show>
<div ref={setScrollRef} data-slot="list-scroll">
<Show
when={flat().length > 0 || showAdd()}
fallback={
<div data-slot="list-empty-state">
<div data-slot="list-message">{emptyMessage()}</div>
</div>
}
>
<For each={grouped.latest}>
{(group, groupIndex) => {
const isLastGroup = () => groupIndex() === grouped.latest.length - 1
return (
<div data-slot="list-group">
<Show when={group.category}>
<GroupHeader group={group} />
</Show>
<div data-slot="list-items">
<For each={group.items}>
{(item, i) => {
const node = (
<button
data-slot="list-item"
data-key={props.key(item)}
data-active={props.key(item) === active()}
data-selected={item === props.current}
onClick={() => handleSelect(item, i())}
onKeyDown={handleKey}
type="button"
onMouseMove={(event) => {
if (!moved(event)) return
setStore("mouseActive", true)
setActive(props.key(item))
}}
onMouseLeave={() => {
if (!store.mouseActive) return
setActive(null)
}}
>
{props.children(item)}
<Show when={item === props.current}>
<span data-slot="list-item-selected-icon">
<Icon name="check-small" />
</span>
</Show>
<Show when={props.activeIcon}>
{(icon) => (
<span data-slot="list-item-active-icon">
<Icon name={icon()} />
</span>
)}
</Show>
{props.divider && (i() !== group.items.length - 1 || (showAdd() && isLastGroup())) && (
<span data-slot="list-item-divider" />
)}
</button>
)
if (props.itemWrapper) return props.itemWrapper(item, node)
return node
}}
</For>
<Show when={showAdd() && isLastGroup()}>{renderAdd()}</Show>
</div>
</div>
)
}}
</For>
<Show when={grouped.latest.length === 0 && showAdd()}>
<div data-slot="list-group">
<div data-slot="list-items">{renderAdd()}</div>
</div>
</Show>
</Show>
</div>
</div>
)
}

View File

@@ -0,0 +1,4 @@
[data-component="logo-mark"] {
width: 16px;
aspect-ratio: 4/5;
}

View File

@@ -0,0 +1,57 @@
// @ts-nocheck
import * as mod from "./logo"
const docs = `### Overview
OpenCode logo assets: mark, splash, and wordmark.
Use Mark for compact spaces, Logo for headers, Splash for hero sections.
### API
- \`Mark\`, \`Splash\`, and \`Logo\` components accept standard SVG props.
### Variants and states
- Multiple logo variants for different contexts.
### Behavior
- Pure SVG rendering.
### Accessibility
- Provide title/aria-label when logos convey meaning.
### Theming/tokens
- Uses theme color tokens via CSS variables.
`
export default {
title: "UI/Logo",
id: "components-logo",
component: mod.Logo,
tags: ["autodocs"],
parameters: {
docs: {
description: {
component: docs,
},
},
},
}
export const Basic = {
render: () => (
<div style={{ display: "grid", gap: "16px", "align-items": "start" }}>
<div>
<div style={{ color: "var(--text-weak)", "font-size": "12px" }}>Mark</div>
<mod.Mark />
</div>
<div>
<div style={{ color: "var(--text-weak)", "font-size": "12px" }}>Splash</div>
<mod.Splash style={{ width: "80px", height: "100px" }} />
</div>
<div>
<div style={{ color: "var(--text-weak)", "font-size": "12px" }}>Logo</div>
<mod.Logo style={{ width: "200px" }} />
</div>
</div>
),
}

View File

@@ -0,0 +1,62 @@
import { ComponentProps } from "solid-js"
export const Mark = (props: { class?: string }) => {
return (
<svg
data-component="logo-mark"
classList={{ [props.class ?? ""]: !!props.class }}
viewBox="0 0 16 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path data-slot="logo-logo-mark-shadow" d="M12 16H4V8H12V16Z" fill="var(--icon-weak-base)" />
<path data-slot="logo-logo-mark-o" d="M12 4H4V16H12V4ZM16 20H0V0H16V20Z" fill="var(--icon-strong-base)" />
</svg>
)
}
export const Splash = (props: Pick<ComponentProps<"svg">, "ref" | "class">) => {
return (
<svg
ref={props.ref}
data-component="logo-splash"
classList={{ [props.class ?? ""]: !!props.class }}
viewBox="0 0 80 100"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M60 80H20V40H60V80Z" fill="var(--icon-base)" />
<path d="M60 20H20V80H60V20ZM80 100H0V0H80V100Z" fill="var(--icon-strong-base)" />
</svg>
)
}
export const Logo = (props: { class?: string }) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 234 42"
fill="none"
classList={{ [props.class ?? ""]: !!props.class }}
>
<g>
<path d="M18 30H6V18H18V30Z" fill="var(--icon-weak-base)" />
<path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" fill="var(--icon-base)" />
<path d="M48 30H36V18H48V30Z" fill="var(--icon-weak-base)" />
<path d="M36 30H48V12H36V30ZM54 36H36V42H30V6H54V36Z" fill="var(--icon-base)" />
<path d="M84 24V30H66V24H84Z" fill="var(--icon-weak-base)" />
<path d="M84 24H66V30H84V36H60V6H84V24ZM66 18H78V12H66V18Z" fill="var(--icon-base)" />
<path d="M108 36H96V18H108V36Z" fill="var(--icon-weak-base)" />
<path d="M108 12H96V36H90V6H108V12ZM114 36H108V12H114V36Z" fill="var(--icon-base)" />
<path d="M144 30H126V18H144V30Z" fill="var(--icon-weak-base)" />
<path d="M144 12H126V30H144V36H120V6H144V12Z" fill="var(--icon-strong-base)" />
<path d="M168 30H156V18H168V30Z" fill="var(--icon-weak-base)" />
<path d="M168 12H156V30H168V12ZM174 36H150V6H174V36Z" fill="var(--icon-strong-base)" />
<path d="M198 30H186V18H198V30Z" fill="var(--icon-weak-base)" />
<path d="M198 12H186V30H198V12ZM204 36H180V6H198V0H204V36Z" fill="var(--icon-strong-base)" />
<path d="M234 24V30H216V24H234Z" fill="var(--icon-weak-base)" />
<path d="M216 12V18H228V12H216ZM234 24H216V30H234V36H210V6H234V24Z" fill="var(--icon-strong-base)" />
</g>
</svg>
)
}

View File

@@ -0,0 +1,32 @@
import { describe, expect, test } from "bun:test"
import { stream } from "./markdown-stream"
describe("markdown stream", () => {
test("heals incomplete emphasis while streaming", () => {
expect(stream("hello **world", true)).toEqual([{ raw: "hello **world", src: "hello **world**", mode: "live" }])
expect(stream("say `code", true)).toEqual([{ raw: "say `code", src: "say `code`", mode: "live" }])
})
test("keeps incomplete links non-clickable until they finish", () => {
expect(stream("see [docs](https://example.com/gu", true)).toEqual([
{ raw: "see [docs](https://example.com/gu", src: "see docs", mode: "live" },
])
})
test("splits an unfinished trailing code fence from stable content", () => {
expect(stream("before\n\n```ts\nconst x = 1", true)).toEqual([
{ raw: "before\n\n", src: "before\n\n", mode: "live" },
{ raw: "```ts\nconst x = 1", src: "```ts\nconst x = 1", mode: "live" },
])
})
test("keeps reference-style markdown as one block", () => {
expect(stream("[docs][1]\n\n[1]: https://example.com", true)).toEqual([
{
raw: "[docs][1]\n\n[1]: https://example.com",
src: "[docs][1]\n\n[1]: https://example.com",
mode: "live",
},
])
})
})

View File

@@ -0,0 +1,49 @@
import { marked, type Tokens } from "marked"
import remend from "remend"
export type Block = {
raw: string
src: string
mode: "full" | "live"
}
function refs(text: string) {
return /^\[[^\]]+\]:\s+\S+/m.test(text) || /^\[\^[^\]]+\]:\s+/m.test(text)
}
function open(raw: string) {
const match = raw.match(/^[ \t]{0,3}(`{3,}|~{3,})/)
if (!match) return false
const mark = match[1]
if (!mark) return false
const char = mark[0]
const size = mark.length
const last = raw.trimEnd().split("\n").at(-1)?.trim() ?? ""
return !new RegExp(`^[\\t ]{0,3}${char}{${size},}[\\t ]*$`).test(last)
}
function heal(text: string) {
return remend(text, { linkMode: "text-only" })
}
export function stream(text: string, live: boolean) {
if (!live) return [{ raw: text, src: text, mode: "full" }] satisfies Block[]
const src = heal(text)
if (refs(text)) return [{ raw: text, src, mode: "live" }] satisfies Block[]
const tokens = marked.lexer(text)
const tail = tokens.findLastIndex((token) => token.type !== "space")
if (tail < 0) return [{ raw: text, src, mode: "live" }] satisfies Block[]
const last = tokens[tail]
if (!last || last.type !== "code") return [{ raw: text, src, mode: "live" }] satisfies Block[]
const code = last as Tokens.Code
if (!open(code.raw)) return [{ raw: text, src, mode: "live" }] satisfies Block[]
const head = tokens
.slice(0, tail)
.map((token) => token.raw)
.join("")
if (!head) return [{ raw: code.raw, src: code.raw, mode: "live" }] satisfies Block[]
return [
{ raw: head, src: heal(head), mode: "live" },
{ raw: code.raw, src: code.raw, mode: "live" },
] satisfies Block[]
}

View File

@@ -0,0 +1,266 @@
[data-component="markdown"] {
/* Reset & Base Typography */
min-width: 0;
max-width: 100%;
overflow-wrap: break-word;
color: var(--text-strong);
font-family: var(--font-family-sans);
font-size: var(--font-size-base); /* 14px */
line-height: 160%;
/* Spacing for flow */
> *:first-child {
margin-top: 0;
}
> *:last-child {
margin-bottom: 0;
}
/* Headings: Same size, distinguished by color and spacing */
h1,
h2,
h3,
h4,
h5,
h6 {
font-size: 14px;
color: var(--text-strong);
font-weight: var(--font-weight-medium);
margin-top: 0px;
margin-bottom: 24px;
line-height: var(--line-height-large);
}
/* Emphasis & Strong: Neutral strong color */
strong,
b {
color: var(--text-strong);
font-weight: var(--font-weight-medium);
}
/* Paragraphs */
p {
margin-bottom: 12px;
}
/* Links */
a {
color: var(--text-interactive-base);
text-decoration: none;
font-weight: inherit;
}
a:hover {
text-decoration: underline;
text-underline-offset: 2px;
}
/* Lists */
ul,
ol {
margin-top: 8px;
margin-bottom: 12px;
margin-left: 0;
padding-left: 32px;
list-style-position: outside;
}
ul {
list-style-type: disc;
}
ol {
list-style-type: decimal;
padding-left: 2.25rem;
}
li {
margin-bottom: 8px;
}
li > p:first-child {
display: inline;
margin: 0;
}
li > p + p {
display: block;
margin-top: 0.5rem;
}
li::marker {
color: var(--text-weak);
}
/* Nested lists spacing */
li > ul,
li > ol {
margin-top: 0.25rem;
margin-bottom: 0.25rem;
padding-left: 1rem; /* Minimal indent for nesting only */
}
li > ol {
padding-left: 1.75rem;
}
/* Blockquotes */
blockquote {
border-left: 2px solid var(--border-weak-base);
margin: 1.5rem 0;
padding-left: 0.5rem;
color: var(--text-weak);
font-style: normal;
}
/* Horizontal Rule - Invisible spacing only */
hr {
border: none;
height: 0;
margin: 40px 0;
}
.shiki {
font-size: 13px;
padding: 12px;
border-radius: 6px;
border: 0.5px solid var(--border-weak-base);
}
[data-component="markdown-code"] {
position: relative;
}
[data-slot="markdown-copy-button"] {
position: absolute;
top: 4px;
right: 4px;
opacity: 0;
transition: opacity 0.15s ease;
z-index: 1;
&::after {
content: attr(data-tooltip);
position: absolute;
left: 50%;
bottom: calc(100% + 4px);
transform: translateX(-50%);
z-index: 1000;
max-width: 320px;
border-radius: var(--radius-sm);
background: var(--surface-float-base);
color: var(--text-invert-strong);
padding: 2px 8px;
border: 1px solid var(--border-weak-base, rgba(0, 0, 0, 0.07));
box-shadow: var(--shadow-md);
pointer-events: none;
white-space: nowrap;
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
font-style: normal;
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal);
opacity: 0;
transition: opacity 0.15s ease;
}
}
[data-slot="markdown-copy-button"]:hover::after,
[data-slot="markdown-copy-button"]:focus-visible::after {
opacity: 1;
}
[data-slot="markdown-copy-button"][data-variant="secondary"] {
box-shadow: none;
border: 1px solid var(--border-weak-base);
}
[data-slot="markdown-copy-button"][data-variant="secondary"] [data-slot="icon-svg"] {
color: var(--icon-base);
}
[data-component="markdown-code"]:hover [data-slot="markdown-copy-button"] {
opacity: 1;
}
[data-slot="markdown-copy-button"] [data-slot="check-icon"] {
display: none;
}
[data-slot="markdown-copy-button"][data-copied="true"] [data-slot="copy-icon"] {
display: none;
}
[data-slot="markdown-copy-button"][data-copied="true"] [data-slot="check-icon"] {
display: inline-flex;
}
pre {
margin-top: 12px;
margin-bottom: 32px;
overflow: auto;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
:not(pre) > code {
font-family: var(--font-family-mono);
font-feature-settings: var(--font-family-mono--font-feature-settings);
color: var(--syntax-string);
font-weight: var(--font-weight-medium);
/* font-size: 13px; */
/* padding: 2px 2px; */
/* margin: 0 1.5px; */
/* border-radius: 2px; */
/* background: var(--surface-base); */
/* box-shadow: 0 0 0 0.5px var(--border-weak-base); */
}
/* Tables */
table {
width: 100%;
border-collapse: collapse;
margin: 24px 0;
font-size: var(--font-size-base);
display: block;
overflow-x: auto;
}
th,
td {
/* Minimal borders for structure, matching TUI "lines" roughly but keeping it web-clean */
border-bottom: 1px solid var(--border-weaker-base);
padding: 12px;
text-align: left;
vertical-align: top;
}
th {
color: var(--text-strong);
font-weight: var(--font-weight-medium);
border-bottom: 1px solid var(--border-weak-base);
}
/* Images */
img {
max-width: 100%;
height: auto;
border-radius: 4px;
margin: 1.5rem 0;
display: block;
}
}
[data-component="markdown"] a.external-link:hover > code {
text-decoration: underline;
text-underline-offset: 2px;
}

View File

@@ -0,0 +1,53 @@
// @ts-nocheck
import * as mod from "./markdown"
import { create } from "../storybook/scaffold"
import { markdown } from "../storybook/fixtures"
const docs = `### Overview
Render sanitized Markdown with code blocks, inline code, and safe links.
Pair with \`Code\` for standalone code views.
### API
- Required: \`text\` Markdown string.
- Uses the Marked context provider for parsing and sanitization.
### Variants and states
- Code blocks include copy buttons when rendered.
### Behavior
- Sanitizes HTML and auto-converts inline URL code to links.
- Adds copy buttons to code blocks.
### Accessibility
- Copy buttons include aria-labels from i18n.
- TODO: confirm link target behavior in sanitized output.
### Theming/tokens
- Uses \`data-component="markdown"\` and related slots for styling.
`
const story = create({
title: "UI/Markdown",
mod,
args: {
text: markdown,
},
})
export default {
title: "UI/Markdown",
id: "components-markdown",
component: story.meta.component,
tags: ["autodocs"],
parameters: {
docs: {
description: {
component: docs,
},
},
},
}
export const Basic = story.Basic

View File

@@ -0,0 +1,348 @@
import { useMarked } from "../context/marked"
import { useI18n } from "../context/i18n"
import DOMPurify from "dompurify"
import morphdom from "morphdom"
import { checksum } from "@opencode-ai/core/util/encode"
import { ComponentProps, createEffect, createResource, createSignal, onCleanup, splitProps } from "solid-js"
import { isServer } from "solid-js/web"
import { stream } from "./markdown-stream"
type Entry = {
hash: string
html: string
}
const max = 200
const cache = new Map<string, Entry>()
if (typeof window !== "undefined" && DOMPurify.isSupported) {
DOMPurify.addHook("afterSanitizeAttributes", (node: Element) => {
if (!(node instanceof HTMLAnchorElement)) return
if (node.target !== "_blank") return
const rel = node.getAttribute("rel") ?? ""
const set = new Set(rel.split(/\s+/).filter(Boolean))
set.add("noopener")
set.add("noreferrer")
node.setAttribute("rel", Array.from(set).join(" "))
})
}
const config = {
USE_PROFILES: { html: true, mathMl: true },
SANITIZE_NAMED_PROPS: true,
FORBID_TAGS: ["style"],
FORBID_CONTENTS: ["style", "script"],
}
const iconPaths = {
copy: '<path d="M6.2513 6.24935V2.91602H17.0846V13.7493H13.7513M13.7513 6.24935V17.0827H2.91797V6.24935H13.7513Z" stroke="currentColor" stroke-linecap="round"/>',
check: '<path d="M5 11.9657L8.37838 14.7529L15 5.83398" stroke="currentColor" stroke-linecap="square"/>',
}
function sanitize(html: string) {
if (!DOMPurify.isSupported) return ""
return DOMPurify.sanitize(html, config)
}
function escape(text: string) {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
}
function fallback(markdown: string) {
return escape(markdown).replace(/\r\n?/g, "\n").replace(/\n/g, "<br>")
}
type CopyLabels = {
copy: string
copied: string
}
const urlPattern = /^https?:\/\/[^\s<>()`"']+$/
function codeUrl(text: string) {
const href = text.trim().replace(/[),.;!?]+$/, "")
if (!urlPattern.test(href)) return
try {
const url = new URL(href)
return url.toString()
} catch {
return
}
}
function createIcon(path: string, slot: string) {
const icon = document.createElement("div")
icon.setAttribute("data-component", "icon")
icon.setAttribute("data-size", "small")
icon.setAttribute("data-slot", slot)
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg")
svg.setAttribute("data-slot", "icon-svg")
svg.setAttribute("fill", "none")
svg.setAttribute("viewBox", "0 0 20 20")
svg.setAttribute("aria-hidden", "true")
svg.innerHTML = path
icon.appendChild(svg)
return icon
}
function createCopyButton(labels: CopyLabels) {
const button = document.createElement("button")
button.type = "button"
button.setAttribute("data-component", "icon-button")
button.setAttribute("data-variant", "secondary")
button.setAttribute("data-size", "small")
button.setAttribute("data-slot", "markdown-copy-button")
button.setAttribute("aria-label", labels.copy)
button.setAttribute("data-tooltip", labels.copy)
button.appendChild(createIcon(iconPaths.copy, "copy-icon"))
button.appendChild(createIcon(iconPaths.check, "check-icon"))
return button
}
function setCopyState(button: HTMLButtonElement, labels: CopyLabels, copied: boolean) {
if (copied) {
button.setAttribute("data-copied", "true")
button.setAttribute("aria-label", labels.copied)
button.setAttribute("data-tooltip", labels.copied)
return
}
button.removeAttribute("data-copied")
button.setAttribute("aria-label", labels.copy)
button.setAttribute("data-tooltip", labels.copy)
}
function ensureCodeWrapper(block: HTMLPreElement, labels: CopyLabels) {
const parent = block.parentElement
if (!parent) return
const wrapped = parent.getAttribute("data-component") === "markdown-code"
if (!wrapped) {
const wrapper = document.createElement("div")
wrapper.setAttribute("data-component", "markdown-code")
parent.replaceChild(wrapper, block)
wrapper.appendChild(block)
wrapper.appendChild(createCopyButton(labels))
return
}
const buttons = Array.from(parent.querySelectorAll('[data-slot="markdown-copy-button"]')).filter(
(el): el is HTMLButtonElement => el instanceof HTMLButtonElement,
)
if (buttons.length === 0) {
parent.appendChild(createCopyButton(labels))
return
}
for (const button of buttons.slice(1)) {
button.remove()
}
}
function markCodeLinks(root: HTMLDivElement) {
const codeNodes = Array.from(root.querySelectorAll(":not(pre) > code"))
for (const code of codeNodes) {
const href = codeUrl(code.textContent ?? "")
const parentLink =
code.parentElement instanceof HTMLAnchorElement && code.parentElement.classList.contains("external-link")
? code.parentElement
: null
if (!href) {
if (parentLink) parentLink.replaceWith(code)
continue
}
if (parentLink) {
parentLink.href = href
continue
}
const link = document.createElement("a")
link.href = href
link.className = "external-link"
link.target = "_blank"
link.rel = "noopener noreferrer"
code.parentNode?.replaceChild(link, code)
link.appendChild(code)
}
}
function decorate(root: HTMLDivElement, labels: CopyLabels) {
const blocks = Array.from(root.querySelectorAll("pre"))
for (const block of blocks) {
ensureCodeWrapper(block, labels)
}
markCodeLinks(root)
}
function setupCodeCopy(root: HTMLDivElement, getLabels: () => CopyLabels) {
const timeouts = new Map<HTMLButtonElement, ReturnType<typeof setTimeout>>()
const updateLabel = (button: HTMLButtonElement) => {
const labels = getLabels()
const copied = button.getAttribute("data-copied") === "true"
setCopyState(button, labels, copied)
}
const handleClick = async (event: MouseEvent) => {
const target = event.target
if (!(target instanceof Element)) return
const button = target.closest('[data-slot="markdown-copy-button"]')
if (!(button instanceof HTMLButtonElement)) return
const code = button.closest('[data-component="markdown-code"]')?.querySelector("code")
const content = code?.textContent ?? ""
if (!content) return
const clipboard = navigator?.clipboard
if (!clipboard) return
await clipboard.writeText(content)
const labels = getLabels()
setCopyState(button, labels, true)
const existing = timeouts.get(button)
if (existing) clearTimeout(existing)
const timeout = setTimeout(() => setCopyState(button, labels, false), 2000)
timeouts.set(button, timeout)
}
const buttons = Array.from(root.querySelectorAll('[data-slot="markdown-copy-button"]'))
for (const button of buttons) {
if (button instanceof HTMLButtonElement) updateLabel(button)
}
root.addEventListener("click", handleClick)
return () => {
root.removeEventListener("click", handleClick)
for (const timeout of timeouts.values()) {
clearTimeout(timeout)
}
}
}
function touch(key: string, value: Entry) {
cache.delete(key)
cache.set(key, value)
if (cache.size <= max) return
const first = cache.keys().next().value
if (!first) return
cache.delete(first)
}
export function Markdown(
props: ComponentProps<"div"> & {
text: string
cacheKey?: string
streaming?: boolean
class?: string
classList?: Record<string, boolean>
},
) {
const [local, others] = splitProps(props, ["text", "cacheKey", "streaming", "class", "classList"])
const marked = useMarked()
const i18n = useI18n()
const [root, setRoot] = createSignal<HTMLDivElement>()
const [html] = createResource(
() => ({
text: local.text,
key: local.cacheKey,
streaming: local.streaming ?? false,
}),
async (src) => {
if (isServer) return fallback(src.text)
if (!src.text) return ""
const base = src.key ?? checksum(src.text)
return Promise.all(
stream(src.text, src.streaming).map(async (block, index) => {
const hash = checksum(block.raw)
const key = base ? `${base}:${index}:${block.mode}` : hash
if (key && hash) {
const cached = cache.get(key)
if (cached && cached.hash === hash) {
touch(key, cached)
return cached.html
}
}
const next = await Promise.resolve(marked.parse(block.src))
const safe = sanitize(next)
if (key && hash) touch(key, { hash, html: safe })
return safe
}),
)
.then((list) => list.join(""))
.catch(() => fallback(src.text))
},
{ initialValue: fallback(local.text) },
)
let copyCleanup: (() => void) | undefined
createEffect(() => {
const container = root()
const content = local.text ? (html.latest ?? html() ?? "") : ""
if (!container) return
if (isServer) return
if (!content) {
container.innerHTML = ""
return
}
const labels = {
copy: i18n.t("ui.message.copy"),
copied: i18n.t("ui.message.copied"),
}
const temp = document.createElement("div")
temp.innerHTML = content
decorate(temp, labels)
morphdom(container, temp, {
childrenOnly: true,
onBeforeElUpdated: (fromEl, toEl) => {
if (
fromEl instanceof HTMLButtonElement &&
toEl instanceof HTMLButtonElement &&
fromEl.getAttribute("data-slot") === "markdown-copy-button" &&
toEl.getAttribute("data-slot") === "markdown-copy-button" &&
fromEl.getAttribute("data-copied") === "true"
) {
setCopyState(toEl, labels, true)
}
if (fromEl.isEqualNode(toEl)) return false
return true
},
})
if (!copyCleanup)
copyCleanup = setupCodeCopy(container, () => ({
copy: i18n.t("ui.message.copy"),
copied: i18n.t("ui.message.copied"),
}))
})
onCleanup(() => {
if (copyCleanup) copyCleanup()
})
return (
<div
data-component="markdown"
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
ref={setRoot}
{...others}
/>
)
}

View File

@@ -0,0 +1,55 @@
import { describe, expect, test } from "bun:test"
import type { FilePart } from "@opencode-ai/sdk/v2"
import { attached, inline, kind } from "./message-file"
function file(part: Partial<FilePart> = {}): FilePart {
return {
id: "part_1",
sessionID: "ses_1",
messageID: "msg_1",
type: "file",
mime: "text/plain",
url: "file:///repo/README.txt",
filename: "README.txt",
...part,
}
}
describe("message-file", () => {
test("treats data URLs as attachments", () => {
expect(attached(file({ url: "data:text/plain;base64,SGVsbG8=" }))).toBe(true)
expect(attached(file())).toBe(false)
})
test("treats only non-attachment source ranges as inline references", () => {
expect(
inline(
file({
source: {
type: "file",
path: "/repo/README.txt",
text: { value: "@README.txt", start: 0, end: 11 },
},
}),
),
).toBe(true)
expect(
inline(
file({
url: "data:text/plain;base64,SGVsbG8=",
source: {
type: "file",
path: "/repo/README.txt",
text: { value: "@README.txt", start: 0, end: 11 },
},
}),
),
).toBe(false)
})
test("separates image and file attachment kinds", () => {
expect(kind(file({ mime: "image/png" }))).toBe("image")
expect(kind(file({ mime: "application/pdf" }))).toBe("file")
})
})

View File

@@ -0,0 +1,14 @@
import type { FilePart } from "@opencode-ai/sdk/v2"
export function attached(part: FilePart) {
return part.url.startsWith("data:")
}
export function inline(part: FilePart) {
if (attached(part)) return false
return part.source?.text?.start !== undefined && part.source?.text?.end !== undefined
}
export function kind(part: FilePart) {
return part.mime.startsWith("image/") ? "image" : "file"
}

View File

@@ -0,0 +1,123 @@
[data-component="message-nav"] {
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
padding-left: 0;
list-style: none;
&[data-size="normal"] {
width: 240px;
gap: 4px;
}
&[data-size="compact"] {
width: 24px;
}
}
[data-slot="message-nav-item"] {
display: flex;
align-items: center;
align-self: stretch;
justify-content: flex-end;
[data-component="message-nav"][data-size="normal"] & {
justify-content: flex-start;
}
}
[data-slot="message-nav-tick-button"] {
display: flex;
align-items: center;
justify-content: flex-start;
height: 12px;
width: 24px;
border: none;
background: none;
padding: 0;
&[data-active] [data-slot="message-nav-tick-line"] {
background-color: var(--icon-strong-base);
width: 100%;
}
}
[data-slot="message-nav-tick-line"] {
height: 1px;
width: 16px;
background-color: var(--icon-base);
transition:
width 0.2s,
background-color 0.2s;
}
[data-slot="message-nav-tick-button"]:hover [data-slot="message-nav-tick-line"] {
width: 100%;
background-color: var(--icon-strong-base);
}
[data-slot="message-nav-message-button"] {
display: flex;
align-items: center;
align-self: stretch;
width: 100%;
column-gap: 12px;
cursor: default;
border: none;
background: none;
padding: 4px 12px;
border-radius: var(--radius-sm);
}
[data-slot="message-nav-title-preview"] {
font-size: 14px; /* text-14-regular */
color: var(--text-base);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
text-align: left;
&[data-active] {
color: var(--text-strong);
}
}
[data-slot="message-nav-item"]:hover [data-slot="message-nav-message-button"] {
background-color: var(--surface-base);
}
[data-slot="message-nav-item"]:active [data-slot="message-nav-message-button"] {
background-color: var(--surface-base-active);
}
[data-slot="message-nav-item"]:active [data-slot="message-nav-title-preview"] {
color: var(--text-base);
}
.message-nav-tooltip {
z-index: 1000;
}
[data-slot="message-nav-tooltip-content"] {
display: flex;
padding: 4px 4px 6px 4px;
justify-content: center;
align-items: start;
border-radius: var(--radius-md);
background: var(--surface-raised-stronger-non-alpha);
max-height: calc(100vh - 6rem);
overflow-y: auto;
overflow-x: hidden;
/* border/shadow-xs/base */
box-shadow:
0 0 0 1px var(--border-weak-base, rgba(17, 0, 0, 0.12)),
0 1px 2px -1px rgba(19, 16, 16, 0.04),
0 1px 2px 0 rgba(19, 16, 16, 0.06),
0 1px 3px 0 rgba(19, 16, 16, 0.08);
* {
margin: 0 !important;
}
}

View File

@@ -0,0 +1,7 @@
// @ts-nocheck
import * as mod from "./message-nav"
import { create } from "../storybook/scaffold"
const story = create({ title: "UI/MessageNav", mod })
export default { title: "UI/MessageNav", id: "components-message-nav", component: story.meta.component }
export const Basic = story.Basic

View File

@@ -0,0 +1,92 @@
import { UserMessage } from "@opencode-ai/sdk/v2"
import { ComponentProps, For, Match, Show, splitProps, Switch } from "solid-js"
import { DiffChanges } from "./diff-changes"
import { Tooltip } from "./tooltip"
import { useI18n } from "../context/i18n"
export function MessageNav(
props: ComponentProps<"ul"> & {
messages: UserMessage[]
current?: UserMessage
size: "normal" | "compact"
onMessageSelect: (message: UserMessage) => void
getLabel?: (message: UserMessage) => string | undefined
},
) {
const i18n = useI18n()
const [local, others] = splitProps(props, ["messages", "current", "size", "onMessageSelect", "getLabel"])
const content = () => (
<ul role="list" data-component="message-nav" data-size={local.size} {...others}>
<For each={local.messages}>
{(message) => {
const handleClick = () => local.onMessageSelect(message)
const handleKeyPress = (event: KeyboardEvent) => {
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
local.onMessageSelect(message)
}
return (
<li data-slot="message-nav-item">
<Switch>
<Match when={local.size === "compact"}>
<div
data-slot="message-nav-tick-button"
data-active={message.id === local.current?.id || undefined}
role="button"
tabindex={0}
onClick={handleClick}
onKeyDown={handleKeyPress}
>
<div data-slot="message-nav-tick-line" />
</div>
</Match>
<Match when={local.size === "normal"}>
<button data-slot="message-nav-message-button" onClick={handleClick} onKeyDown={handleKeyPress}>
<DiffChanges changes={message.summary?.diffs ?? []} variant="bars" />
<div
data-slot="message-nav-title-preview"
data-active={message.id === local.current?.id || undefined}
>
<Show
when={local.getLabel?.(message) ?? message.summary?.title}
fallback={i18n.t("ui.messageNav.newMessage")}
>
{local.getLabel?.(message) ?? message.summary?.title}
</Show>
</div>
</button>
</Match>
</Switch>
</li>
)
}}
</For>
</ul>
)
return (
<Switch>
<Match when={local.size === "compact"}>
<Tooltip
openDelay={0}
placement="right-start"
gutter={-40}
shift={-10}
overlap
contentClass="message-nav-tooltip"
value={
<div data-slot="message-nav-tooltip-content">
<MessageNav {...props} size="normal" class="" />
</div>
}
>
{content()}
</Tooltip>
</Match>
<Match when={local.size === "normal"}>{content()}</Match>
</Switch>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
// @ts-nocheck
import * as mod from "./message-part"
import { create } from "../storybook/scaffold"
const story = create({ title: "UI/MessagePart", mod })
export default { title: "UI/MessagePart", id: "components-message-part", component: story.meta.component }
export const Basic = story.Basic

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