Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,18 @@ which silently leaves the SVG at its 24px intrinsic size.)
of hand-rolling a hover on text. For an underlined inline link inside running
prose use `variant="link"`; for a control-sized action use a normal `<Button>`.

### "An ⓘ that only explains" = `<InfoHint text="…">`

A glyph whose whole job is to reveal a short explanation is NOT an action, so it
is not an icon `<Button>` (no hover chip, no pointer cursor) and not a
hand-rolled span. `<InfoHint>` is a muted 14px Info glyph that opens a Tooltip
on hover, keyboard focus or a tap, upward by default (`side`). It sits one rung
above the 12px text rung on purpose: a thin outline circle reads smaller than
solid text of the same height, so it is sized to look level with the label
beside it, not to match its cap height. Its hit area is widened to 26px for
touch. The text doubles as the glyph's accessible name. Tooltip rules still
apply: supplementary information only.

### "A card that DOES something / goes somewhere" = `<ActionCard>`

The recurring need to offer an **entry point** — a card that, when clicked, takes
Expand Down Expand Up @@ -332,7 +344,8 @@ peers", not "one door to somewhere else".
ramp (`--btn-primary` → `-hover` → `-active`); `variant="default"`/`"primary"`
use it. Brand purple (`--brand`) is the SCARCE accent — it ships only via
`<Button variant="brand">` (rare brand CTAs like chat Send) and a handful of
reserved tokens (`--sidebar-primary`, capability/event accents). Never make
reserved tokens (`--sidebar-primary`, capability/event accents, the dark
`--field-edge-brand-tint`). Never make
purple a default surface or a large fill; the skeleton stays black/white/gray.
- **Destructive is filled, not ghost.** `variant="destructive"` renders a solid
`bg-destructive text-destructive-foreground` button — a filled red CTA. Never
Expand Down Expand Up @@ -368,6 +381,15 @@ peers", not "one door to somewhere else".
hairline whose color swaps per state (never an outer ring):
`--field-edge-rest` → `--field-edge-solid` (focus) / `--field-edge-engaged`
(subtle) / `--destructive` (invalid).
- An in-place editor that must render exactly like the content it replaced
(the chat prompt edited inside its own bubble) drops the resting hairline
with `<Textarea rest="bare">`; focus and invalid still draw the edge. Forms
and ordinary fields keep the default `rest="edge"`.
- A Textarea painted on a brand-tinted surface (that same bubble) declares it
with `surface="brand-tint"`: its solid edge becomes `--field-edge-brand-tint`
— the neutral near-black in light (a brand edge muddies into a pale tint),
`--brand` in dark (the neutral gray sinks into a deep tint). Rest and
invalid are unchanged. Never pick an edge color per call site.
- **Focus ring** is `--ring` via `focus-visible:ring-2 ring-ring/<alpha>` (or the
segmented's 2px-track + 4px-ring inset). Keyboard focus only — not a resting
border. Controls that keep DOM focus after a mouse action (Select trigger,
Expand Down
2 changes: 2 additions & 0 deletions showcase/specs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { checkboxSpec } from './checkbox'
import { dialogSpec } from './dialog'
import { dropdownMenuSpec } from './dropdown-menu'
import { fieldSpec } from './field'
import { infoHintSpec } from './info-hint'
import { inputSpec } from './input'
import { inputGroupSpec } from './input-group'
import { nativeSelectSpec } from './native-select'
Expand Down Expand Up @@ -51,5 +52,6 @@ export const componentSpecs: ComponentSpec[] = [
popoverSpec,
dropdownMenuSpec,
tooltipSpec,
infoHintSpec,
sonnerSpec,
]
48 changes: 48 additions & 0 deletions showcase/specs/info-hint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import type { ComponentSpec } from '../lib/spec'
import { h } from 'vue'
import { Button } from '#/components/button'
import { InfoHint } from '#/components/info-hint'
import { tooltipSideKeys } from '#/components/tooltip'

export const infoHintSpec: ComponentSpec = {
id: 'info-hint',
name: 'InfoHint',
interactive: true,
description:
'A muted ⓘ glyph that only explains: hover, keyboard focus or a tap opens a short Tooltip. Not an action, so not a button.',
descriptionZh:
'只负责解释的灰色 ⓘ 图标:悬停、键盘聚焦或轻点时弹出简短的 Tooltip。它不是操作,所以不是按钮。',
controls: [
{ kind: 'enum', key: 'side', label: 'Side', options: tooltipSideKeys, default: 'top', display: 'segmented' },
{
kind: 'string',
key: 'text',
label: 'Text',
default: 'Saving replaces this message and the reply after it.',
},
],
examples: [
{
name: 'Beside an action row',
nameZh: '放在操作行旁边',
// Real placement: it explains what the buttons next to it will do,
// sitting at the text scale of their labels.
render: state => h('div', { class: 'flex items-center gap-1.5' }, [
h(InfoHint, { text: String(state.text), side: state.side as never }),
h(Button, { variant: 'ghost', tone: 'muted', size: 'sm' }, () => 'Cancel'),
h(Button, { variant: 'primary', size: 'sm' }, () => 'Save'),
]),
},
],
render: state => h(InfoHint, { text: String(state.text), side: state.side as never }),
usage: `InfoHint carries supplementary explanation only — a reader has to go looking for it, so nothing essential may live only inside it.

- Use it when a glyph's whole job is to explain. If clicking should DO something, that is a Button.
- The glyph is 14px and muted — one rung above the text rung, because a thin outline circle looks smaller than text of the same height. Do not resize or recolor it per call site.
- The text is also the accessible name — write it as a full sentence.`,
usageZh: `InfoHint 只放补充说明——读者得主动去看才能看到,所以关键信息绝不能只放在里面。

- 图标的唯一作用是解释时用它;点了要执行操作的,应该用 Button。
- 图标固定 14px、灰色——比文字档高一档,因为细线圆圈在同样高度下看起来比文字小。不要在调用处改尺寸或颜色。
- 提示文字同时是无障碍名称,写成完整的一句话。`,
}
42 changes: 38 additions & 4 deletions showcase/specs/textarea.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { ComponentSpec } from '../lib/spec'
import { h } from 'vue'
import { Textarea } from '#/components/textarea'
import { Textarea, textareaRestKeys, textareaSurfaceKeys } from '#/components/textarea'

const SIZES = ['default', 'sm', 'lg'] as const

Expand All @@ -13,16 +13,50 @@ export const textareaSpec: ComponentSpec = {
'多行文本输入,与 Input 共享 field-edge 契约:一条内嵌发丝线,聚焦时原位换色。',
controls: [
{ kind: 'enum', key: 'size', label: 'Size', options: SIZES, default: 'default' },
{
kind: 'enum',
key: 'rest',
label: 'At rest',
options: textareaRestKeys,
default: 'edge',
display: 'segmented',
},
{
kind: 'enum',
key: 'surface',
label: 'Surface',
options: textareaSurfaceKeys,
default: 'plain',
display: 'segmented',
},
{ kind: 'string', key: 'placeholder', label: 'Placeholder', default: 'Tell the bot what to do…' },
{ kind: 'number', key: 'rows', label: 'Rows', default: 3, min: 2, max: 10 },
{ kind: 'boolean', key: 'disabled', label: 'Disabled', default: false },
],
render: state =>
h(Textarea, {
render: (state) => {
const field = h(Textarea, {
size: state.size as never,
rest: state.rest as never,
surface: state.surface as never,
placeholder: String(state.placeholder),
rows: Number(state.rows),
disabled: Boolean(state.disabled),
class: 'w-80',
}),
})
// surface only picks the edge; the tint is the caller's, so paint one here
// to judge that edge against.
return state.surface === 'brand-tint'
? h('div', { class: 'rounded-lg bg-brand-soft p-3' }, [field])
: field
},
usage: `Like every field, a Textarea engages on FOCUS only.

- rest="bare" drops the resting hairline, so the field renders exactly like the content it replaced until it is focused. Use it only for an in-place editor (a chat prompt edited inside its own bubble).
- Forms, dialogs and composers keep the default "edge".
- surface="brand-tint" declares that the field is painted on a brand-tinted fill. The solid edge then comes from --field-edge-brand-tint: near-black in light, the brand in dark. The fill stays the caller's.`,
usageZh: `和所有输入框一样,Textarea 只在聚焦时激活。

- rest="bare" 去掉未聚焦时的细边,聚焦前和被替换的内容渲染得完全一样。只用于原位编辑框(例如在聊天气泡里直接编辑提问)。
- 表单、对话框和输入框组件保持默认的 "edge"。
- surface="brand-tint" 表示输入框画在品牌色底上。此时激活边框取 --field-edge-brand-tint:浅色下是近黑,深色下是品牌色。底色仍由调用方负责。`,
}
7 changes: 7 additions & 0 deletions skills/ui-owners/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,12 @@ i18n)、`width: 'fit' | 'full'`(fit 贴合代码,聊天用;full 撑满行,对话
式的状态开关按钮用 —— 不要再用 min-w 魔法数定宽或手写叠格量宽(grid 轨道按最宽
label 定尺寸、靠左锚定会单侧裁字,这些坑都已收进组件,见其头注)。

**InfoHint**(`@felinic/ui` 原语,非 apps/web owner,顺带登记防重造)
只负责解释的 ⓘ:14px(比文字档高一档,细线圆圈同高时显小)、灰色,悬停、键盘聚焦或轻点
弹出 Tooltip(默认向上)。它不是按钮,点了要执行操作的用 Button;读者得主动去看,关键信息
不能只放在里面。Props: `text`(同时是无障碍名称,写成完整句子)、`side?`。不要再手写 `Info` 图标 +
TooltipTrigger,也不要在调用处改它的尺寸或颜色。

**MetricReadout** · `settings/metric-readout.vue`
One metric tile: caption label + tabular value (or a status dot + label). Props: `label`,
`value`, `sub`, `framed` (default `true` — draws the tile box; `false` for a bare stat on
Expand Down Expand Up @@ -426,6 +432,7 @@ An error / warning / info notice? → CalloutBanner (bare inside a
A device-code "enter this code" moment? → DeviceCodePanel
Read-only code / a command to copy? → CodePanel
A label that swaps with state (Connect↔Cancel)? → LabelSwap (@felinic/ui)
A ⓘ that only explains something nearby? → InfoHint (@felinic/ui)
The page frame itself? → PageShell
A pane with nothing to show (loading/empty)? → PanePlaceholder (centered fill)
An in-flow loading row (spinner + text)? → InlineLoadingRow
Expand Down
69 changes: 69 additions & 0 deletions src/components/info-hint/InfoHint.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<script setup lang="ts">
import type { InfoHintSide } from '.'
import { Info } from 'lucide-vue-next'
import { ref } from 'vue'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../tooltip'

const props = withDefaults(defineProps<{
// The explanation itself: shown in the tooltip and read as the glyph's
// accessible name.
text: string
side?: InfoHintSide
}>(), {
side: 'top',
})

const open = ref(false)
// The Tooltip opens on hover and keyboard focus but ignores touch (it skips
// touch pointermove and closes on every trigger press), so a tap toggles it
// here. pointerdown records how the following click was made; a mouse click
// leaves the hover-driven state alone.
let pointerType = ''
function onPointerDown(event: PointerEvent) {
pointerType = event.pointerType
}
function onClick() {
if (pointerType === 'touch' || pointerType === 'pen') open.value = !open.value
}
</script>

<template>
<!-- An ⓘ that only explains — not an action, so not a <Button>: no hover
chip, no pointer cursor. The glyph takes the icon ladder's 14px rung:
a thin, muted outline circle reads smaller than solid text of the same
height, so it sits one notch above the label's cap height to look level
with it. It is still a tab stop so keyboard users can open the hint; its
focus ring is the standard --ring recipe. The tooltip opens without
delay: showing the hint is the glyph's whole job. The trigger keeps the
tooltip open on press (disable-closing-trigger) so the tap toggle above
is the only thing a tap does; a tap elsewhere, blur or scroll closes it.
The ::after widens the hit area by 6px each side, to 26px — past the
24px minimum target — without moving the glyph; 6px is also the
action-row gap, so it never reaches into a neighbouring button. -->
<TooltipProvider :delay-duration="0">
<Tooltip
v-model:open="open"
disable-closing-trigger
>
<TooltipTrigger as-child>
<span
data-slot="info-hint"
tabindex="0"
role="img"
:aria-label="props.text"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid announcing the hint text twice

When a screen-reader user focuses this trigger, Reka's TooltipTrigger adds an aria-describedby reference to TooltipContent; assigning the identical text as aria-label therefore exposes the sentence as both the accessible name and description, which can cause it to be announced twice. Keep the required accessible name, but prevent the tooltip body from redundantly describing the trigger or provide separate label and explanation values.

AGENTS.md reference: AGENTS.md:L233-L234

Useful? React with 👍 / 👎.

class="relative inline-flex shrink-0 cursor-default rounded-full text-muted-foreground outline-none after:absolute after:-inset-1.5 after:content-[''] focus-visible:ring-2 focus-visible:ring-ring/30"
@pointerdown="onPointerDown"
@click="onClick"
>
<Info
class="size-3.5"
aria-hidden="true"
/>
</span>
</TooltipTrigger>
<TooltipContent :side="props.side">
{{ props.text }}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</template>
6 changes: 6 additions & 0 deletions src/components/info-hint/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type { tooltipSideKeys } from '../tooltip'

export { default as InfoHint } from './InfoHint.vue'

// The hint opens through Tooltip, so its sides are Tooltip's.
export type InfoHintSide = typeof tooltipSideKeys[number]
14 changes: 14 additions & 0 deletions src/components/textarea/Textarea.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import type { TextareaRest, TextareaSurface } from '.'
import { useVModel } from '@vueuse/core'
import { computed } from 'vue'
import { cn } from '#/lib/utils'
Expand All @@ -9,8 +10,19 @@ const props = withDefaults(defineProps<{
defaultValue?: string | number
modelValue?: string | number
size?: 'sm' | 'default' | 'lg'
// The edge while the field is not focused:
// - 'edge' (default): the resting hairline every form field wears
// - 'bare': no edge, so an in-place editor renders exactly like the content
// it replaced until it is focused; focus and invalid still draw the edge
rest?: TextareaRest
// The surface the field is painted on, which picks the solid edge that reads
// against it: 'plain' (default) or 'brand-tint' (--field-edge-brand-tint).
// The fill itself stays the caller's.
surface?: TextareaSurface
}>(), {
size: 'default',
rest: 'edge',
surface: 'plain',
})

const emits = defineEmits<{
Expand All @@ -34,6 +46,8 @@ const sizeClass = computed(() => ({
v-model="modelValue"
data-slot="textarea"
:data-size="props.size"
:data-rest="props.rest === 'bare' ? 'bare' : undefined"
:data-surface="props.surface === 'brand-tint' ? 'brand-tint' : undefined"
:class="cn(
'flex field-sizing-content w-full rounded-md tracking-[0.01em] text-foreground',
sizeClass,
Expand Down
7 changes: 7 additions & 0 deletions src/components/textarea/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,8 @@
export { default as Textarea } from './Textarea.vue'

// Single sources of truth for the rest and surface axes (see Textarea.vue);
// consumed by the showcase spec so its controls never hand-maintain the lists.
export const textareaRestKeys = ['edge', 'bare'] as const
export type TextareaRest = typeof textareaRestKeys[number]
export const textareaSurfaceKeys = ['plain', 'brand-tint'] as const
export type TextareaSurface = typeof textareaSurfaceKeys[number]
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export * from './components/field/index'
export * from './components/form/index'
export * from './components/form-dialog-shell/index'
export * from './components/hover-card/index'
export * from './components/info-hint/index'
export * from './components/inline-loading-row/index'
export * from './components/input/index'
export * from './components/input-group/index'
Expand Down
18 changes: 18 additions & 0 deletions src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,17 @@
[data-slot="input-otp-slot"][data-active="true"] {
--field-edge: var(--field-edge-solid);
}
/* Textarea rest="bare": no resting hairline, so an in-place editor renders
exactly like the content it replaced while unfocused. Only the rest color
is re-bound; focus and invalid still swap in their edges. */
[data-slot="textarea"][data-rest="bare"] {
--field-edge-rest: transparent;
}
/* Textarea surface="brand-tint" re-binds the solid edge on the element, so
focus picks it up; rest and invalid are untouched. */
[data-slot="textarea"][data-surface="brand-tint"] {
--field-edge-solid: var(--field-edge-brand-tint);
}
/* SUBTLE: the faintest deepen of the hairline — search / low-chrome, and the
tags field. The near-black solid edge is a strong, decided signal we reserve
for single-line fields; on a big tag container it reads as a heavy box, so
Expand Down Expand Up @@ -1325,10 +1336,14 @@
--field-edge-rest light resting hairline (lighter than the button edge)
--field-edge-engaged SUBTLE focus — barely deeper than rest
--field-edge-solid SOLID focus — the edge turns near-black in place
--field-edge-brand-tint SOLID on a brand-tinted surface (Textarea
surface="brand-tint"); light keeps near-black, since
a brand edge muddies into a pale tint
--field-placeholder soft hint color — lighter/greyer than muted text */
--field-edge-rest: oklch(0 0 0 / 0.11);
--field-edge-engaged: oklch(0 0 0 / 0.17);
--field-edge-solid: oklch(0 0 0 / 0.78);
--field-edge-brand-tint: var(--field-edge-solid);
--field-placeholder: oklch(0.66 0.004 95);
/* ── Accent palette ────────────────────────────────────────────────────
Unified hue ramps for colored surfaces (menu items, tags, status,
Expand Down Expand Up @@ -1732,6 +1747,9 @@
--field-edge-rest: oklch(0.318 0 0);
--field-edge-engaged: oklch(0.42 0 0);
--field-edge-solid: oklch(0.60 0 0);
/* Dark: on a deep brand tint the solid gray sinks into the fill; the brand
itself is the edge that lifts off it. */
--field-edge-brand-tint: var(--brand);
/* Dark: segmented control labels. The thumb chip is the primary "selected"
signal, so idle text can be fully bright. idle ≈ foreground; hover pushes
toward white; active also uses --foreground (same as idle, chip distinguishes). */
Expand Down
Loading