refactor(desktop): one .ref class, and the theme owns every accent

A reference had two styling systems: a Tailwind class string assembled in
TypeScript (`directiveChipClass`) and a separate `link-chip` for prose links,
each carrying its own color-mix(). Same concept, three appearances.

Now every inline reference — a composer chip, a sent message's mention, a
markdown link, a completion row's glyph — is `class="ref"` plus
`data-ref="<kind>"`, and styles.css owns the accent. No hex or color-mix()
ships from a component, so a skin restyles all of them at once.

Keying the accent on `[data-ref]` alone rather than `.ref[data-ref]` also lets
the popover's icon column take a kind's hue without inheriting its inline-text
layout.
This commit is contained in:
Brooklyn Nicholson 2026-07-30 04:35:30 -05:00
parent d83d296473
commit c999dc2e8e
11 changed files with 226 additions and 202 deletions

View file

@ -1,87 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
DIRECTIVE_CHIP_CLASS,
directiveChipClass,
SLASH_CHIP_BASE_CLASS
} from '@/components/assistant-ui/directive-text'
import { REFERENCE_STYLES, referenceStyle } from '@/components/assistant-ui/reference-kinds'
/**
* A chip is inline text, not a badge: same size as the words around it, no
* background or padding to step over, and a per-kind color + icon carrying the
* "what kind of thing is this" signal.
*/
describe('chip typography', () => {
for (const [name, cls] of [
['directive chip', DIRECTIVE_CHIP_CLASS],
['slash chip', SLASH_CHIP_BASE_CLASS]
] as const) {
it(`${name} inherits the surrounding font size`, () => {
expect(cls).not.toMatch(/\btext-\[0\.\d+em\]/)
})
it(`${name} renders as text, with no badge chrome`, () => {
for (const chrome of ['bg-', 'rounded', 'px-', 'py-', 'border']) {
expect(cls).not.toContain(chrome)
}
})
it(`${name} sits on the text baseline without a nudge`, () => {
// With no vertical padding there's nothing to cancel, so the pill needs
// no magic em offset to stop riding low.
expect(cls).toContain('align-baseline')
expect(cls).not.toMatch(/align-\[-/)
})
}
it('directive and slash chips are literally the same shape', () => {
expect(SLASH_CHIP_BASE_CLASS).toBe(DIRECTIVE_CHIP_CLASS)
})
it('resolves to the same font size as its container', () => {
const host = document.createElement('div')
host.style.fontSize = '16px'
host.innerHTML = `<span id="chip" class="${DIRECTIVE_CHIP_CLASS}">apps/desktop/</span>`
document.body.append(host)
const chip = host.querySelector('#chip') as HTMLElement
expect(getComputedStyle(chip).fontSize).toBe(getComputedStyle(host).fontSize)
host.remove()
})
})
describe('the shared reference vocabulary', () => {
it('gives every kind an icon, a color, and a label', () => {
for (const [kind, style] of Object.entries(REFERENCE_STYLES)) {
expect(style.codicon, `${kind} codicon`).toBeTruthy()
expect(style.color, `${kind} color`).toBeTruthy()
expect(style.label, `${kind} label`).toBeTruthy()
// Emoji rows render the emoji itself instead of a glyph.
if (kind !== 'emoji') {
expect(style.paths.length, `${kind} paths`).toBeGreaterThan(0)
}
}
})
it('carries the kind colour into the chip class', () => {
for (const kind of ['file', 'url', 'skill', 'command'] as const) {
expect(directiveChipClass(kind)).toContain(referenceStyle(kind).color)
}
})
it('falls back to a real style for an unknown kind', () => {
const style = referenceStyle('something-new')
expect(style).toBe(REFERENCE_STYLES.other)
expect(style.codicon).toBeTruthy()
})
it('gives commands and skills distinct accents so a list reads at a glance', () => {
expect(referenceStyle('skill').color).not.toBe(referenceStyle('command').color)
})
})

View file

@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest'
import { refAttrs, refAttrsHtml } from '@/components/assistant-ui/directive-text'
import { REFERENCE_STYLES, referenceKind, referenceStyle } from '@/components/assistant-ui/reference-kinds'
/**
* There is ONE inline-reference system: `class="ref"` + `data-ref="<kind>"`.
* A pasted link, an `@file:` chip, a `/skill`, a `@session:` the agent wrote
* all the same markup, styled by the `.ref` rules in styles.css.
*/
describe('the inline reference contract', () => {
it('marks any element as a reference of a given kind', () => {
expect(refAttrs('file')).toEqual({ className: 'ref', 'data-ref': 'file' })
expect(refAttrsHtml('skill')).toBe('class="ref" data-ref="skill"')
})
it('an unkinded reference is a plain link, not a broken one', () => {
// A bare external link has no kind — it keeps the default link colour
// rather than being tagged with a wrong one.
expect(refAttrs()).toEqual({ className: 'ref' })
expect(refAttrsHtml()).toBe('class="ref"')
})
it('normalises an unknown kind instead of emitting it raw', () => {
// A kind CSS has no rule for would silently render unstyled; coercing to
// `other` keeps it inside the system.
expect(refAttrs('wat')['data-ref']).toBe('other')
expect(referenceKind('wat')).toBe('other')
})
it('ships no colour from TypeScript — the theme owns every accent', () => {
// The whole point of keying on `data-ref`: a skin restyles all references
// at once, and no hex or color-mix() is hardcoded in a component.
for (const [kind, style] of Object.entries(REFERENCE_STYLES)) {
expect(style, `${kind} must not carry a colour`).not.toHaveProperty('color')
}
expect(JSON.stringify(refAttrs('url'))).not.toMatch(/color|#[0-9a-f]{3}/i)
})
it('gives every kind a glyph and a label', () => {
for (const [kind, style] of Object.entries(REFERENCE_STYLES)) {
expect(style.codicon, `${kind} codicon`).toBeTruthy()
expect(style.label, `${kind} label`).toBeTruthy()
// Emoji rows render the emoji itself instead of a glyph.
if (kind !== 'emoji') {
expect(style.paths.length, `${kind} paths`).toBeGreaterThan(0)
}
}
})
it('keeps commands and skills visually distinct', () => {
// Different data-ref values, so the stylesheet can accent them apart.
expect(refAttrs('skill')['data-ref']).not.toBe(refAttrs('command')['data-ref'])
expect(referenceStyle('skill').codicon).not.toBe(referenceStyle('command').codicon)
})
})
describe('references are text, not badges', () => {
it('carries no layout, padding, or background of its own', () => {
// Everything visual lives in the stylesheet. If a component starts adding
// its own chrome here, that's the drift this system exists to prevent.
const { className } = refAttrs('file')
expect(className).toBe('ref')
for (const chrome of ['bg-', 'rounded', 'px-', 'py-', 'border', 'inline-flex', 'text-[']) {
expect(className).not.toContain(chrome)
}
})
})

View file

@ -7,15 +7,15 @@
* plain-text round-trip.
*/
import {
directiveChipClass,
directiveIconElement,
directiveIconSvg,
formatRefValue,
refAttrsHtml,
refChipLabel,
slashChipClass,
type SlashChipKind,
slashIconElement
} from '@/components/assistant-ui/directive-text'
import { referenceKind } from '@/components/assistant-ui/reference-kinds'
import { slashCommandMatches, type SlashCommandScanOptions } from './slash-refs'
@ -60,42 +60,38 @@ export function refChipHtml(kind: string, rawValue: string, displayLabel?: strin
const label = displayLabel || refChipLabel(kind, id)
return `<span contenteditable="false" title="${escapeHtml(id)}" data-ref-text="${escapeHtml(text)}" data-ref-id="${escapeHtml(id)}" data-ref-kind="${escapeHtml(kind)}" class="${directiveChipClass(kind)}">${directiveIconSvg(kind)}<span class="truncate">${escapeHtml(label)}</span></span>`
return `<span contenteditable="false" title="${escapeHtml(id)}" data-ref-text="${escapeHtml(text)}" data-ref-id="${escapeHtml(id)}" data-ref-kind="${escapeHtml(kind)}" ${refAttrsHtml(kind)}>${directiveIconSvg(kind)}${escapeHtml(label)}</span>`
}
export function refChipElement(kind: string, rawValue: string, displayLabel?: string) {
const id = unquoteRef(rawValue)
const text = `@${kind}:${quoteRefValue(id)}`
const chip = document.createElement('span')
const label = document.createElement('span')
chip.contentEditable = 'false'
chip.title = id
chip.dataset.refText = text
chip.dataset.refId = id
chip.dataset.refKind = kind
chip.className = directiveChipClass(kind)
label.className = 'truncate'
label.textContent = displayLabel || refChipLabel(kind, id)
chip.append(directiveIconElement(kind), label)
chip.className = 'ref'
chip.dataset.ref = referenceKind(kind)
chip.append(directiveIconElement(kind), document.createTextNode(displayLabel || refChipLabel(kind, id)))
return chip
}
/** A non-editable pill for a picked slash command (`/skin nous`, `/tropes`).
/** A non-editable reference for a picked slash command (`/skin nous`, `/tropes`).
* `data-ref-text` carries the literal command so `composerPlainText` round-trips
* it back to the exact text that gets submitted. */
export function slashChipElement(command: string, kind: SlashChipKind, label?: string) {
const chip = document.createElement('span')
const text = document.createElement('span')
chip.contentEditable = 'false'
chip.dataset.refText = command
chip.dataset.slashKind = kind
chip.className = slashChipClass(kind)
text.className = 'truncate'
text.textContent = label || command
chip.append(slashIconElement(kind), text)
chip.className = 'ref'
chip.dataset.ref = kind
chip.append(slashIconElement(kind), document.createTextNode(label || command))
return chip
}

View file

@ -1,7 +1,7 @@
import type { Unstable_TriggerItem } from '@assistant-ui/core'
import { Fragment } from 'react'
import { referenceStyle } from '@/components/assistant-ui/reference-kinds'
import { referenceKind, referenceStyle } from '@/components/assistant-ui/reference-kinds'
import { Codicon } from '@/components/ui/codicon'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { useI18n } from '@/i18n'
@ -137,6 +137,7 @@ export function ComposerTriggerPopover({
const isFirstHeader = lastGroup === undefined
lastGroup = group || lastGroup
const active = index === activeIndex
const refKind = referenceKind(rowKind(item, isSlash))
return (
<Fragment key={item.id}>
@ -154,10 +155,8 @@ export function ComposerTriggerPopover({
<span className="min-w-0 shrink truncate leading-5 text-foreground">{display}</span>
) : (
<>
<span
className={cn('grid size-4 shrink-0 place-items-center', referenceStyle(rowKind(item, isSlash)).color)}
>
<Codicon name={referenceStyle(rowKind(item, isSlash)).codicon} size="0.875rem" />
<span className="grid size-4 shrink-0 place-items-center text-(--ref-color)" data-ref={refKind}>
<Codicon name={referenceStyle(refKind).codicon} size="0.875rem" />
</span>
<span className="min-w-0 shrink truncate font-medium leading-5 text-foreground">{display}</span>
{description && (

View file

@ -13,7 +13,7 @@ import { useSessionLinkTitle } from '@/lib/session-link-title'
import { parseSessionRefValue, sessionRefFallbackLabel } from '@/lib/session-refs'
import { cn } from '@/lib/utils'
import { referenceStyle } from './reference-kinds'
import { referenceKind, referenceStyle } from './reference-kinds'
const HERMES_REF_TYPES = ['file', 'folder', 'url', 'image', 'tool', 'line', 'terminal', 'session'] as const
type HermesRefType = (typeof HERMES_REF_TYPES)[number]
@ -26,26 +26,23 @@ const SVG_ATTRS =
'xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"'
/**
* Shared chip styling used by the rendered <DirectiveChip> and by the raw
* HTML composer chips in `rich-editor.ts`.
* The class + attributes that make any element an inline reference. Pair with
* the `.ref` rules in styles.css, which own the per-kind accent pass the kind
* and the theme decides the colour.
*
* A chip is inline TEXT, not a badge: no background, no padding, no border.
* The icon says what kind of thing it is and the color reinforces it, which is
* all the signal a reference needs when it's sitting in the middle of a
* sentence a filled pill turns every mention into a UI element the eye has
* to step over. Per-kind color comes from REFERENCE_STYLES so a `@file:` reads
* the same here as it does in the popover you picked it from.
*
* Font size is inherited rather than shrunk: a reference is content the user
* chose, and rendering it smaller than the words around it reads as a
* footnote. With no vertical padding the glyphs sit in the normal line box, so
* `align-baseline` is enough no nudge needed.
* One helper for every surface: the composer's contenteditable chips, a sent
* message's mentions, a markdown link, a completion row's glyph. If it points
* at something from inside text, it goes through here.
*/
export const DIRECTIVE_CHIP_CLASS = 'inline-flex max-w-56 items-baseline gap-1 align-baseline font-medium leading-none'
export function refAttrs(kind?: string, extra?: string): { className: string; 'data-ref'?: string } {
const className = extra ? `ref ${extra}` : 'ref'
/** Per-kind chip classes: the shared shape plus the kind's own color. */
export function directiveChipClass(type: string): string {
return `${DIRECTIVE_CHIP_CLASS} ${referenceStyle(type).color}`
return kind ? { className, 'data-ref': referenceKind(kind) } : { className }
}
/** The same thing as a raw attribute string, for HTML built by hand. */
export function refAttrsHtml(kind?: string): string {
return kind ? `class="ref" data-ref="${referenceKind(kind)}"` : 'class="ref"'
}
@ -55,12 +52,11 @@ export function directiveIconSvg(type: string) {
.map(d => `<path d="${d}"/>`)
.join('')
return `<svg ${SVG_ATTRS} class="size-[0.875em] shrink-0 opacity-80">${inner}</svg>`
return `<svg ${SVG_ATTRS}>${inner}</svg>`
}
function iconElementFromPaths(paths: string[]) {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
svg.setAttribute('class', 'size-[0.875em] shrink-0 opacity-80')
svg.setAttribute('fill', 'none')
svg.setAttribute('stroke', 'currentColor')
svg.setAttribute('stroke-linecap', 'round')
@ -82,25 +78,17 @@ export function directiveIconElement(type: string) {
return iconElementFromPaths(iconPathsFor(type))
}
/** Slash pills are references too commands, skills, and themes are just
* three more kinds in the shared vocabulary, so they share the chip shape and
* read their icon + accent from the same table. */
/** Commands, skills, and themes are three more reference kinds no separate
* pill styling, just the shared `.ref` treatment with their own accent. */
export type SlashChipKind = 'command' | 'skill' | 'theme'
export const SLASH_CHIP_BASE_CLASS = DIRECTIVE_CHIP_CLASS
export function slashChipClass(kind: SlashChipKind): string {
return directiveChipClass(kind)
}
export function slashIconElement(kind: SlashChipKind) {
return iconElementFromPaths(iconPathsFor(kind))
}
const DirectiveIcon: FC<{ type: string; className?: string }> = ({
type,
className = 'size-[0.875em] shrink-0 opacity-80'
}) => (
/** The glyph for a reference kind. Size, spacing, and opacity come from the
* `.ref > svg` rules the icon only has to say which shape it is. */
const DirectiveIcon: FC<{ type: string; className?: string }> = ({ type, className }) => (
<svg
className={className}
fill="none"
@ -498,7 +486,7 @@ export const SessionRefLink: FC<{
return (
<a
className="link-chip wrap-anywhere"
{...refAttrs('session', 'wrap-anywhere')}
href="#"
onClick={event => {
event.preventDefault()
@ -507,7 +495,7 @@ export const SessionRefLink: FC<{
}}
title={value}
>
<DirectiveIcon className="mr-1 inline size-[0.82em] align-[-0.08em] opacity-70" type="session" />
<DirectiveIcon type="session" />
{resolved}
</a>
)
@ -516,9 +504,9 @@ export const SessionRefLink: FC<{
/** A skill referenced inside a sent message the rendered twin of the
* composer's slash pill, so a picked skill stays a chip after send. */
const SlashChip: FC<{ kind: SlashChipKind; label: string; value: string }> = ({ kind, label, value }) => (
<span className={slashChipClass(kind)} data-slot="aui_slash-chip" title={value}>
<span {...refAttrs(kind)} data-slot="aui_slash-chip" title={value}>
<DirectiveIcon type={kind} />
<span className="truncate">{label}</span>
{label}
</span>
)
@ -533,14 +521,13 @@ const DirectiveChip: FC<{
const body = (
<>
<DirectiveIcon type={type} />
<span className="truncate">{label}</span>
{label}
</>
)
const props = {
className: cn(directiveChipClass(type), onClick && 'cursor-pointer transition-colors hover:text-foreground'),
...refAttrs(type, cn('wrap-anywhere', onClick && 'cursor-pointer')),
'data-directive-id': id,
'data-directive-type': type,
'data-slot': 'aui_directive-chip',
title: id
}

View file

@ -127,7 +127,7 @@ function OpenMediaButton({ kind, path }: { kind: 'audio' | 'video'; path: string
return (
<span className="block">
<button
className="mt-2 link-chip bg-transparent text-xs font-medium text-muted-foreground hover:text-foreground"
className="mt-2 ref text-xs font-medium text-muted-foreground hover:text-foreground"
onClick={open}
type="button"
>
@ -223,7 +223,7 @@ function MediaAttachment({ path }: { path: string }) {
return (
<span className="wrap-anywhere">
<a
className="link-chip wrap-anywhere"
className="ref wrap-anywhere"
href="#"
onClick={event => {
event.preventDefault()
@ -273,7 +273,7 @@ function MarkdownLink({ children, className, href, ...props }: ComponentProps<'a
if (!target || !/^https?:\/\//i.test(target)) {
return (
<a
className={cn('link-chip wrap-anywhere', className)}
className={cn('ref wrap-anywhere', className)}
href={href}
rel="noopener noreferrer"
target="_blank"
@ -370,7 +370,7 @@ function MarkdownImageContent({ className, src, alt, ...props }: ComponentProps<
<span className="my-2 block text-sm text-muted-foreground">
Couldn&apos;t load {name}.{' '}
<button
className="link-chip bg-transparent font-medium text-foreground hover:text-foreground"
className="ref font-medium text-foreground"
onClick={open}
type="button"
>

View file

@ -35,23 +35,16 @@ export type ReferenceKind =
interface ReferenceStyle {
/** Codicon name — the popover row's leading glyph. */
codicon: string
/** Tabler outline path data — the chip's inline SVG. */
/** Tabler outline path data — the inline SVG a rendered reference uses. */
paths: string[]
/** Chip text color. Chips are text-only now, so this carries the whole
* signal for "what kind of thing is this". */
color: string
/** Section label when the popover groups by this kind. */
/** Section label when a surface groups by this kind. */
label: string
}
// Accents map to the theme's own tokens rather than raw hues, so a reference
// keeps its meaning across every skin. Files/folders/paths share the neutral
// secondary text color — they're the common case and shouldn't shout; the
// things that ACT (commands, skills) take the accent colors.
const NEUTRAL = 'text-(--ui-text-secondary)'
const ACCENT = 'text-[color-mix(in_srgb,var(--ui-accent)_82%,var(--foreground))]'
const WARM = 'text-[color-mix(in_srgb,var(--ui-warm)_82%,var(--foreground))]'
const SECONDARY = 'text-[color-mix(in_srgb,var(--ui-accent-secondary)_82%,var(--foreground))]'
// Colour is NOT here. A reference's accent lives in styles.css keyed on
// `data-ref="<kind>"`, so a theme restyles every reference at once and no hex
// or color-mix() ships from TypeScript. This table owns the two things CSS
// can't express: which glyph, and what to call the kind.
const FILE_PATHS = [
'M14 3v4a1 1 0 0 0 1 1h4',
@ -64,13 +57,12 @@ const FILE_PATHS = [
const TERMINAL_PATHS = ['M5 7l5 5l-5 5', 'M12 19l7 0']
export const REFERENCE_STYLES: Record<ReferenceKind, ReferenceStyle> = {
file: { codicon: 'file', paths: FILE_PATHS, color: NEUTRAL, label: 'Files' },
file: { codicon: 'file', paths: FILE_PATHS, label: 'Files' },
folder: {
codicon: 'folder',
paths: [
'M5 19l2.757 -7.351a1 1 0 0 1 .936 -.649h12.307a1 1 0 0 1 .986 1.164l-.996 5.211a2 2 0 0 1 -1.964 1.625h-14.026a2 2 0 0 1 -2 -2v-11a2 2 0 0 1 2 -2h4l3 3h7a2 2 0 0 1 2 2v2'
],
color: NEUTRAL,
label: 'Folders'
},
url: {
@ -80,7 +72,6 @@ export const REFERENCE_STYLES: Record<ReferenceKind, ReferenceStyle> = {
'M11 6l.463 -.536a5 5 0 0 1 7.071 7.072l-.534 .464',
'M13 18l-.397 .534a5.068 5.068 0 0 1 -7.127 0a4.972 4.972 0 0 1 0 -7.071l.524 -.463'
],
color: SECONDARY,
label: 'Links'
},
image: {
@ -91,33 +82,29 @@ export const REFERENCE_STYLES: Record<ReferenceKind, ReferenceStyle> = {
'M3 16l5 -5c.928 -.893 2.072 -.893 3 0l5 5',
'M14 14l1 -1c.928 -.893 2.072 -.893 3 0l3 3'
],
color: SECONDARY,
label: 'Images'
},
tool: {
codicon: 'tools',
paths: ['M7 10h3v-3l-3.5 -3.5a6 6 0 0 1 8 8l6 6a2 2 0 0 1 -3 3l-6 -6a6 6 0 0 1 -8 -8l3.5 3.5'],
color: ACCENT,
label: 'Tools'
},
line: {
codicon: 'list-selection',
paths: ['M5 9l14 0', 'M5 15l14 0', 'M11 4l-4 16', 'M17 4l-4 16'],
color: NEUTRAL,
label: 'Lines'
},
terminal: { codicon: 'terminal', paths: TERMINAL_PATHS, color: NEUTRAL, label: 'Terminal' },
terminal: { codicon: 'terminal', paths: TERMINAL_PATHS, label: 'Terminal' },
session: {
codicon: 'comment-discussion',
paths: ['M4 4h16v2.172a2 2 0 0 1 -.586 1.414l-4.414 4.414v7l-6 2v-8.5l-4.48 -4.928a2 2 0 0 1 -.52 -1.345v-2.227'],
color: SECONDARY,
label: 'Sessions'
},
git: { codicon: 'git-branch', paths: ['M7 18l0 -12', 'M7 8a2 2 0 1 0 0 -4a2 2 0 0 0 0 4'], color: WARM, label: 'Git' },
diff: { codicon: 'diff', paths: ['M12 5l0 14', 'M5 12l14 0'], color: WARM, label: 'Changes' },
staged: { codicon: 'diff-added', paths: ['M12 5l0 14', 'M5 12l14 0'], color: WARM, label: 'Staged' },
command: { codicon: 'terminal', paths: TERMINAL_PATHS, color: ACCENT, label: 'Commands' },
skill: { codicon: 'zap', paths: ['M13 3l0 7l6 0l-8 11l0 -7l-6 0l8 -11'], color: WARM, label: 'Skills' },
git: { codicon: 'git-branch', paths: ['M7 18l0 -12', 'M7 8a2 2 0 1 0 0 -4a2 2 0 0 0 0 4'], label: 'Git' },
diff: { codicon: 'diff', paths: ['M12 5l0 14', 'M5 12l14 0'], label: 'Changes' },
staged: { codicon: 'diff-added', paths: ['M12 5l0 14', 'M5 12l14 0'], label: 'Staged' },
command: { codicon: 'terminal', paths: TERMINAL_PATHS, label: 'Commands' },
skill: { codicon: 'zap', paths: ['M13 3l0 7l6 0l-8 11l0 -7l-6 0l8 -11'], label: 'Skills' },
theme: {
codicon: 'symbol-color',
paths: [
@ -126,11 +113,10 @@ export const REFERENCE_STYLES: Record<ReferenceKind, ReferenceStyle> = {
'M21 3a16 16 0 0 1 -10.2 12.8',
'M10.6 9a9 9 0 0 1 4.4 4.4'
],
color: SECONDARY,
label: 'Themes'
},
emoji: { codicon: 'smiley', paths: [], color: NEUTRAL, label: 'Emoji' },
other: { codicon: 'symbol-misc', paths: FILE_PATHS, color: NEUTRAL, label: 'Other' }
emoji: { codicon: 'smiley', paths: [], label: 'Emoji' },
other: { codicon: 'symbol-misc', paths: FILE_PATHS, label: 'Other' }
}
const KNOWN = new Set(Object.keys(REFERENCE_STYLES))

View file

@ -42,7 +42,7 @@ function tagged<T extends keyof typeof TAG_CLASSES>(Tag: T) {
function MarkdownAnchor({ children, className, href, ...rest }: ComponentProps<'a'>) {
if (!href || !/^https?:\/\//i.test(href)) {
return (
<a className={cn('link-chip', className)} href={href} {...rest}>
<a className={cn('ref', className)} href={href} {...rest}>
{children}
</a>
)

View file

@ -96,7 +96,7 @@ export const GeneratedImage: FC<{ aspectRatio?: string; result?: unknown }> = ({
if (failed && image) {
return (
<a
className="mt-2 link-chip inline-block wrap-anywhere"
className="mt-2 ref inline-block wrap-anywhere"
href="#"
onClick={event => {
event.preventDefault()

View file

@ -239,7 +239,7 @@ export function ExternalLink({
return (
<a
className={cn('link-chip', className)}
className={cn('ref', className)}
href={target}
onClick={event => {
event.stopPropagation()

View file

@ -574,36 +574,107 @@
background: repeating-conic-gradient(currentColor 0% 25%, transparent 0% 50%) 0 0 / 0.125rem 0.125rem;
}
/* Inline content links: color at rest, a tinted chip on hover, never an
underline. Tint is currentColor-relative (the old `decoration-current/20`
idiom), so text and fill share a hue on every theme from one `color`. */
.link-chip {
/* The one knob: resting fill. 0% = color-only until hovered. */
--link-chip-tint: 0%;
/*
INLINE REFERENCES the one system for anything that points at something
from inside a run of text.
A pasted link, an `@file:` the user picked, a `/skill`, a `@session:` the
agent wrote: all the same species. They render the same way in the composer
and in a sent message, because they're the same thing before and after send.
A reference is TEXT, not a badge colour and an optional icon, no fill, no
padding, no border. A pill turns every mention into a widget the eye has to
step over, and mid-sentence that's most of the sentence.
Usage: `class="ref"` plus `data-ref="<kind>"` for the accent. Kinds live in
`components/assistant-ui/reference-kinds.ts` (icon + label); their colour
lives here, so a theme restyles every reference at once and TS never ships a
hex. No `data-ref` = an ordinary link, which keeps the primary link colour.
*/
/* Kind accent. Keyed on the attribute ALONE so anything can adopt a
reference's colour without also taking its inline-text layout (a completion
row's icon column wants the hue, not the margin). Grouped by what a
reference DOES, so the palette reads as meaning rather than decoration:
· things you point at (paths) neutral, they're the common case
· things you fetch (links, media) secondary
· things that act (commands, tools) accent
· things that change code (skills, git) warm */
[data-ref] {
--ref-color: var(--dt-primary);
}
[data-ref='file'],
[data-ref='folder'],
[data-ref='line'],
[data-ref='terminal'] {
--ref-color: var(--ui-text-secondary);
}
[data-ref='url'],
[data-ref='image'],
[data-ref='session'],
[data-ref='theme'] {
--ref-color: color-mix(in srgb, var(--ui-accent-secondary) 82%, var(--foreground));
}
[data-ref='command'],
[data-ref='tool'] {
--ref-color: color-mix(in srgb, var(--ui-accent) 82%, var(--foreground));
}
[data-ref='skill'],
[data-ref='git'],
[data-ref='diff'],
[data-ref='staged'] {
--ref-color: color-mix(in srgb, var(--ui-warm) 82%, var(--foreground));
}
.ref {
--ref-color: var(--dt-primary);
/* Prose links inherit the surrounding text's weight. `@tailwindcss/typography`
sets `prose a { font-weight: 500 }`, which outranks a utility class on the
anchor so the override belongs here, on the shared chip. */
anchor so the override belongs here, on the shared class. */
font-weight: inherit;
/* `ch`/`em` so the chip tracks the text at any size. Block padding is
asymmetric because an inline box's content area is ascent+descent equal
padding paints the glyphs high. Keep it small: `padding-block` doesn't grow
the line box, so an over-padded chip creeps into the line above. */
padding: 0.05ch 0.5ch 0.2ch;
border-radius: 0.25rem;
color: var(--dt-primary);
background: color-mix(in srgb, currentColor var(--link-chip-tint), transparent);
color: var(--ref-color);
/* Explicit: the base layer underlines every `a` (see the :where(a, …) reset). */
text-decoration: none;
/* A wrapped link gets a chip per line fragment, not one ragged box. */
/* A wrapped reference breaks per line fragment, not as one ragged box. */
-webkit-box-decoration-break: clone;
box-decoration-break: clone;
transition: background-color 0.12s ease;
}
.link-chip:hover {
--link-chip-tint: 15%;
/* Affordance without chrome: only the ones you can actually activate respond,
and they do it with an underline rather than a background. */
:where(a, button).ref:hover {
text-decoration: underline;
text-underline-offset: 0.15em;
}
/* The leading glyph. Sized in `em` so it tracks the text at any scale, and
spaced with `margin` not flex `gap` so `.ref` stays an inline box whose
label can wrap mid-word (a long URL has to break across lines like the prose
around it, which a flex container would prevent).
The margin is unconditional. A `:not(:only-child)` guard looks right and is
silently wrong: CSS counts ELEMENT siblings, so an icon followed by a bare
text-node label still matches `:only-child` which is exactly how every
chip lost its spacer. Icon-only users take the hue via `[data-ref]` instead
of this class. */
.ref > :where(svg, i.codicon) {
margin-inline-end: 0.25em;
opacity: 0.8;
}
.ref > svg {
display: inline-block;
width: 0.875em;
height: 0.875em;
vertical-align: -0.1em;
}
.ref > i.codicon {
font-size: 0.875em;
}
/* Hover-reveal suppression the shared, declarative escape hatch.