Skip to content
Draft
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
27 changes: 27 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -633,6 +633,23 @@ grows (clipped controls, cramped rows, same-row controls that stop matching heig

- Every interactive control sets `cursor-pointer` (Button, Switch, segmented
item, …). Disabled flips to `cursor-not-allowed`.
- **It must be set explicitly — there is no safety net.** Tailwind v4's
preflight dropped the v3-era `button, [role=button] { cursor: pointer }`
rule, so every clickable element falls back to the UA default (the plain
arrow) unless the component sets the class itself. When adding a new
interactive component, `cursor-pointer` is part of the base class, not an
afterthought — a shared trigger class (e.g. `lib/trigger.ts`
`selectTriggerClass`) carries it once for all consumers.
- **Deliberate non-pointer exceptions** (do not "fix" these):
- **Menu rows and menu labels** (`lib/menu.ts`) use `cursor-default` — the
macOS menu convention: an open menu is a transient command surface the
pointer glides over, not a page of buttons.
- **Select scroll up/down buttons** — part of the menu surface, same
convention.
- **Text-field addons** (`InputGroup` inline text/icons) use `cursor-text` —
they belong to the field's text zone.
- **Resize rails** (`SidebarRail`) use the resize cursor; **the ScrollArea
thumb** keeps its default (drag affordance is its own vocabulary).

## Dirty patterns — anti-examples (do NOT copy)

Expand Down Expand Up @@ -842,3 +859,13 @@ When you lock a new cross-cutting decision (a color role, a duration, an icon
rule, a shape law): (1) add/identify the token in `style.css`, (2) document it
here, (3) add a guard check in `scripts/check-ui-contract.mjs` if it is
mechanically detectable. A decision that is not written here will be re-invented.

## Nested menu alignment

DropdownMenuSubContent 与 ContextMenuSubContent 默认将子菜单首个条目的垂直中心对齐父触发条目的中心,而非对齐两个矩形的顶边。共享 useSubmenuAlignment 测量条目、边框与内框,适应字体缩放和异步内容;调用层不添加补偿偏移。显式 alignOffset 仍可覆盖默认规则。碰撞处理继续由 Reka 负责:空间不足时允许翻转或移动以保持可操作,不以绝对对齐强迫内容溢出。子菜单复用锚定菜单的进入/退出动画与变换原点。

## Menu leading icons

菜单首图标的外框尺寸与间距由菜单组件统一管理,调用方直接传入图标,不添加 `mr-*`、`ml-*`、尺寸或偏移修正。共享样式兼容旧调用的 margin/size 类;SVG 内部几何仍由图标层负责。DropdownMenu 和 ContextMenu 的菜单项遵循同一规则;根 ContextMenu 在光标锚点使用共享淡入缩放与退出动画,不使用方向滑动。

普通操作菜单与 Select 共用 `--menu-min-width`(10rem)作为舒展的基础宽度,窄屏允许收缩。Select 仍不得窄于触发器;更长内容可扩展。菜单比例通过宽度和内容组织调整,不通过压低共享行高补偿。业务确需不同宽度时使用已有 class 覆盖,不为短文案逐页设置最小宽度。
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"reka-ui": "^2.10.1",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.3.3",
"tw-animate-css": "^1.4.0",
"vee-validate": "^4.15.1",
"vue-input-otp": "^0.3.2",
"zod": "3.25.76"
Expand All @@ -50,7 +51,6 @@
"misans-vf": "1.0.0",
"npm-run-all2": "^8.0.4",
"tsx": "^4.21.0",
"tw-animate-css": "^1.4.0",
"typescript": "~6.0.3",
"unplugin-dts": "1.0.0-beta.6",
"vite": "^8.1.5",
Expand Down
32 changes: 32 additions & 0 deletions showcase/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,38 @@ spec has `usage`).
- **A spec opts into the matrix** by declaring `matrix: { rows, cols }` with
control keys — only axes a reviewer actually scans (Button: variant × size).

## Examples pages — the example IS the page

`pages/examples/` holds whole-page specimens of classic product surfaces
(a settings page, …) — the surfaces every product has, where library-wide
passes (cursor, motion, width) are judged on a real interface instead of
single-component playgrounds. They are a third page kind beside component
pages and foundation pages, with their own law:

- **No doc frame.** The example fills the content area directly — its own
`PageShell` IS the page. Never wrap it in a documentation spine, a
`STAGE_FRAME_CLASS` frame, or a `CanvasStage`: the point is to feel the
components in a real surface, and a stage would turn them back into
boxed specimens.
- **Owners only, stricter than anywhere else.** An example page is the
strongest copy-precedent surface in the repo — compose it exclusively
from owner vocabulary (`PageShell` / `SettingsSection` / `SettingsRow` /
the controls themselves). If a shape the page needs has no owner, that
is a vocabulary gap to legislate, not a class to hand-write.
- **No width pinning, no state seeding beyond defaults.** Controls render
at their library defaults (`Select` at `w-fit`, `Input` at `w-full`, …)
so the page exposes true default behavior in real rows. If a row looks
wrong, the fix belongs in the library, not in a class on the page.
- **Overlays uncontrolled, state local** (per the overlay invariants
below): each `Select`/`Dialog` owns its open state; page state is plain
refs. No fake backends, no demo data plumbing.
- **Bilingual, generic copy.** All strings go through `tt()` and read as
"any product's" copy — an example names no concrete product and teaches
no Memoh-specific business wording.

Register each page in `registry.ts` under the `examples` nav group via the
`example()` helper (id prefix `examples/`).

## Overlay invariants (dead-locked three times — do not re-try)

- **Overlay specs render UNCONTROLLED — `interactive: true`, NO `open`
Expand Down
221 changes: 221 additions & 0 deletions showcase/pages/examples/SettingsPageExample.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Button } from '#/components/button'
import { Input } from '#/components/input'
import { SegmentedControl } from '#/components/segmented'
import {
Select,
SelectContent,
SelectItem,
SelectItemText,
SelectTrigger,
SelectValue,
} from '#/components/select'
import { PageShell, SettingsRow, SettingsSection } from '#/components/settings'
import { Switch } from '#/components/switch'
import { tt } from '../../lib/i18n'

// Examples / Settings — a whole-page specimen of the classic product settings
// surface, composed ONLY from the library's owner vocabulary (PageShell /
// SettingsSection / SettingsRow + the controls themselves). This page is the
// shared testbed for the library-wide cursor / motion / width passes: those
// rounds are judged on a real surface, not on single-component playgrounds.
//
// Two deliberate rules, do not "fix" them:
// 1. NO width is pinned on any control. The page renders every control at its
// library default (Select trigger is w-fit, Input is w-full, …) on purpose —
// exposing the current defaults in a real row layout is the point of the
// width pass. If a row looks wrong, the fix belongs in the library, not in
// a class on this page.
// 2. Overlays are uncontrolled (each Select owns its open state) and all state
// is local refs — this is an interaction specimen, not a data demo.
//
// Copy is bilingual via tt() and deliberately generic: the page must read as
// "any product's settings", so strings name no concrete product.

const language = ref('default')
const themeItems = computed(() => [
{ value: 'system', label: tt('System', '跟随系统') },
{ value: 'light', label: tt('Light', '浅色') },
{ value: 'dark', label: tt('Dark', '深色') },
])
const theme = ref('system')
const notifications = ref(true)
const autosuggest = ref(true)
const model = ref('auto')
const imageGeneration = ref(false)
const displayName = ref('Felinic')
const inlineDisplayName = ref('Felinic')
</script>

<template>
<PageShell
:title="tt('Settings', '设置')"
width="md"
>
<div class="flex flex-col gap-8">
<SettingsSection :title="tt('Preferences', '偏好')">
<SettingsRow
:label="tt('Language', '语言')"
:description="tt('The language the interface displays in', '界面显示的语言')"
>
<Select v-model="language">
<SelectTrigger
size="sm"
>
<SelectValue />
</SelectTrigger>
<SelectContent
align="end"
:align-offset="0"
>
<SelectItem value="default">
<SelectItemText>Default</SelectItemText>
</SelectItem>
<SelectItem value="en">
<SelectItemText>English</SelectItemText>
</SelectItem>
<SelectItem value="zh">
<SelectItemText>中文</SelectItemText>
</SelectItem>
<SelectItem value="ja">
<SelectItemText>日本語</SelectItemText>
</SelectItem>
<!-- Long fake languages: stress data so the menu widens far past
the trigger and the align="end" trial is actually visible. -->
<SelectItem value="ro">
<SelectItemText>română (România)</SelectItemText>
</SelectItem>
<SelectItem value="sk">
<SelectItemText>slovenčina (Slovensko)</SelectItemText>
</SelectItem>
<SelectItem value="sr-cyrl">
<SelectItemText>српски (ћирилица, Црна Гора)</SelectItemText>
</SelectItem>
<SelectItem value="sv">
<SelectItemText>svenska (Sverige)</SelectItemText>
</SelectItem>
<SelectItem value="ta">
<SelectItemText>தமிழ் (இந்தியா)</SelectItemText>
</SelectItem>
<!-- Short-value stress set: extreme narrow values against the
long ones above, so the width/animation pass can be judged
across the full text-length range in one menu. -->
<SelectItem value="el">
<SelectItemText>ελ</SelectItemText>
</SelectItem>
<SelectItem value="id">
<SelectItemText>ID</SelectItemText>
</SelectItem>
<SelectItem value="ms">
<SelectItemText>ms</SelectItemText>
</SelectItem>
</SelectContent>
</Select>
</SettingsRow>
<SettingsRow :label="tt('Theme', '主题')">
<SegmentedControl
v-model="theme"
:items="themeItems"
:aria-label="tt('Theme', '主题')"
/>
</SettingsRow>
<SettingsRow
:label="tt('Notifications', '通知')"
:description="tt('Get notified when a task finishes or needs your input', '任务完成或需要你处理时收到通知')"
>
<Switch v-model="notifications" />
</SettingsRow>
<SettingsRow
:label="tt('Autosuggest', '输入建议')"
:description="tt('Show suggestions while you type', '输入时显示建议')"
>
<Switch v-model="autosuggest" />
</SettingsRow>
</SettingsSection>

<SettingsSection :title="tt('Model', '模型')">
<SettingsRow
:label="tt('Default model', '默认模型')"
:description="tt('The model used for new conversations', '新对话使用的模型')"
>
<Select v-model="model">
<SelectTrigger
size="sm"
>
<SelectValue />
</SelectTrigger>
<SelectContent
align="end"
:align-offset="0"
>
<SelectItem value="auto">
<SelectItemText>Auto</SelectItemText>
</SelectItem>
<SelectItem value="fast">
<SelectItemText>Fast</SelectItemText>
</SelectItem>
<SelectItem value="thinking">
<SelectItemText>Thinking</SelectItemText>
</SelectItem>
</SelectContent>
</Select>
</SettingsRow>
<SettingsRow
:label="tt('Image generation', '图像生成')"
:description="tt('Allow the model to generate images', '允许模型生成图像')"
>
<Switch v-model="imageGeneration" />
</SettingsRow>
</SettingsSection>

<SettingsSection :title="tt('Account', '账户')">
<SettingsRow
:label="tt('Display name', '显示名称')"
stack="sm"
>
<Input v-model="displayName" />
</SettingsRow>
<SettingsRow :label="tt('Password', '密码')">
<Button
variant="outline"
size="sm"
>
{{ tt('Change', '修改') }}
</Button>
</SettingsRow>
<SettingsRow
:label="tt('Delete account', '删除账户')"
:description="tt('This action is irreversible', '此操作不可撤销')"
>
<Button
variant="destructive"
size="sm"
>
{{ tt('Delete', '删除') }}
</Button>
</SettingsRow>
</SettingsSection>
<SettingsSection :title="tt('Inline controls', '同行控件')">
<SettingsRow
:label="tt('Display name', '显示名称')"
stack="sm"
>
<div class="flex min-w-0 items-center gap-2">
<Input
v-model="inlineDisplayName"
size="sm"
:aria-label="tt('Inline display name', '同行显示名称')"
/>
<Button
size="sm"
variant="outline"
>
{{ tt('Save', '保存') }}
</Button>
</div>
</SettingsRow>
</SettingsSection>
</div>
</PageShell>
</template>
13 changes: 13 additions & 0 deletions showcase/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import RadiusPage from './pages/foundations/RadiusPage.vue'
import SpacingPage from './pages/foundations/SpacingPage.vue'
import TypographyPage from './pages/foundations/TypographyPage.vue'
import OverviewPage from './pages/OverviewPage.vue'
import SettingsPageExample from './pages/examples/SettingsPageExample.vue'

// Single manifest: drives the sidebar groups, the hash routes, AND prev/next
// order — one list, three consumers, so they can never drift.
Expand All @@ -31,6 +32,10 @@ function foundation(id: string, title: string, titleZh: string, component: Compo
return { kind: 'static', id: `foundations/${id}`, title, titleZh, component }
}

function example(id: string, title: string, titleZh: string, component: Component): PageEntry {
return { kind: 'static', id: `examples/${id}`, title, titleZh, component }
}

export const navGroups: NavGroup[] = [
{
id: 'foundations',
Expand Down Expand Up @@ -59,6 +64,14 @@ export const navGroups: NavGroup[] = [
spec,
})),
},
{
id: 'examples',
label: 'Examples',
labelZh: '示例',
pages: [
example('settings', 'Settings', '设置', SettingsPageExample),
],
},
]

export const flatPages: PageEntry[] = navGroups.flatMap(g => g.pages)
Expand Down
2 changes: 1 addition & 1 deletion src/components/accordion/AccordionTrigger.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const forwarded = useForwardProps(delegatedProps)
data-slot="accordion-trigger"
v-bind="forwarded"
:class="cn(
'flex flex-1 items-center justify-between gap-2 py-3.5 text-label font-medium outline-none transition-colors hover:underline disabled:pointer-events-none disabled:opacity-40 [&[data-state=open]>svg]:rotate-180',
'flex flex-1 cursor-pointer items-center justify-between gap-2 py-3.5 text-label font-medium outline-none transition-colors hover:underline disabled:pointer-events-none disabled:opacity-40 [&[data-state=open]>svg]:rotate-180',
props.class,
)"
>
Expand Down
4 changes: 4 additions & 0 deletions src/components/action-card/ActionCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ const props = withDefaults(defineProps<PrimitiveProps & {
/** Optional one-line supporting text (truncates — never wraps). Grows the row
* past the 48px single-line height. The #description slot overrides. */
description?: string
/** Keep a boundary when nested inside another card-colored surface. */
bordered?: boolean
class?: HTMLAttributes['class']
}>(), {
as: 'button',
Expand All @@ -78,11 +80,13 @@ const props = withDefaults(defineProps<PrimitiveProps & {
<template>
<Primitive
data-slot="action-card"
:data-bordered="bordered || undefined"
:as="as"
:as-child="asChild"
:class="cn(
'group/action relative isolate flex w-full min-h-[3rem] items-center gap-3 border bg-card px-4 py-3.5 text-left',
'cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-ring/50',
!bordered && 'dark:border-0',
props.class,
)"
>
Expand Down
Loading
Loading