From e2b682c9864625e368d1a7c52f1a6ef18fc6e3b1 Mon Sep 17 00:00:00 2001 From: Acbox Date: Wed, 29 Jul 2026 15:32:54 +0800 Subject: [PATCH 1/8] fix(button): land the as-child contract on the caller's element MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `` never produced a button. reka's Slot merges the component's props into the FIRST non-comment child it receives, and this component always handed it a `` wrapper — so `inline-flex` / `h-9` / `px-4` / `rounded-md` and, worse, the `[data-button]` anchor that every hover/press/focus rule in style.css keys off, all landed on a span with `display: contents`. It generates no box, so the caller's `` / `` rendered as bare text and the `::before` fill lost its positioning ancestor. Silent by construction: nothing type-checks wrong, no class string is illegal, and the guard reads source text. Long-standing, NOT a regression — the wrapper predates the LabelSwap `[gap:inherit]` change, which only added a property to an already-wrapping span. Fix: the asChild branch emits `` and nothing else. The wrapper's two jobs don't survive the move and don't need to — the button classes (gap included) are now on the caller's element, so a nested LabelSwap's `[gap:inherit]` reads the real gap from its actual parent; and `loading` degrades to `loadingMode="manual"` (busy chrome, caller-rendered indicator), reported in the DOM and warned about in dev rather than silently dropped. Also fixes `` and ``, which forward straight into Button and are both documented as the way to wrap a RouterLink. The other as-child direction — `` +// type-checked, and every class string involved was legal — the defect was that +// they all landed on an internal `display:contents` wrapper instead of on the +// caller's element, which only the rendered DOM can show. So these assertions +// are deliberately about WHICH ELEMENT carries the contract; they never inspect +// what the class list says. + +// [data-button] is the chrome anchor every hover/press/focus rule in style.css +// keys off (see the comment in Button.vue), so "the button" means "the one node +// wearing it". Exactly one may exist — a second would mean a wrapper is also +// claiming to be the button. +function theButton(wrapper: ReturnType) { + const anchored = wrapper.findAll('[data-button]') + expect(anchored).toHaveLength(1) + const el = anchored[0]!.element + // Box-generating layout classes are the tell: on a `display:contents` wrapper + // these compute to nothing, which is exactly how the bug stayed invisible. + // Only size-invariant base classes belong here — a size rung can legitimately + // merge away e.g. rounded-md (size="text" swaps in rounded-sm). + expect(el.className).toContain('inline-flex') + expect(el.className).toContain('items-center') + return el +} + +describe('', + }) + + const el = theButton(wrapper) + expect(el.tagName).toBe('BUTTON') + expect(el.getAttribute('data-slot')).toBe('pagination-item') + }) +}) diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..aaca05e --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,18 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import viteConfig from './vite.config' + +// Tests live in `src/**/__tests__/` — the path tsconfig.json already excludes +// from `vue-tsc --build`, so a test file can never widen the shipped surface. +// +// Scope note: this is NOT a "test every component" harness. The library's three +// enforcement layers (tokens / this contract / the guard) all read SOURCE TEXT, +// which makes them blind to one specific class of defect: a composition that +// type-checks and whose class strings are all legal, but whose RENDERED DOM +// puts them on the wrong element. Reach for a test here only when a contract +// promise is invisible to the other three layers — see Button's as-child test. +export default mergeConfig(viteConfig, defineConfig({ + test: { + environment: 'happy-dom', + include: ['src/**/__tests__/*.test.ts'], + }, +})) From 0796214fcd303a70afc32e0ecd31eafc7e01fbb0 Mon Sep 17 00:00:00 2001 From: Acbox Date: Wed, 29 Jul 2026 15:33:11 +0800 Subject: [PATCH 2/8] fix(empty): draw the frame it always claimed to have MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Empty shipped `rounded-lg border-dashed`. `border-dashed` sets border-STYLE only — with no width the edge never rendered, so every standalone empty state was centered gray text floating in whitespace. The class string looked like it framed something, which is why it survived review. The fix is not `border`: the page contract (skills/web) is explicit that dashed is NOT an empty-state look — it's reserved for the "+ Add another" tile beside real items in a populated list. A fully-empty surface takes the SOLID frame its populated form has. Both documented placements exist, and they want opposite frames, so the frame becomes an enumerated prop instead of a class pages inject: - variant="framed" (default) — stands in for the card/grid that appears once data exists: one solid `border-border` hairline at the Card radius (rounded-xl, per the radius role map — the dead `rounded-lg` was the control rung and would have visibly mismatched adjacent cards once the edge rendered). - variant="bare" — nested inside a SettingsSection / Card that already draws the hairline; a second stroke there is card-in-card. No caller in this repo depended on the borderless rendering; `bare` reproduces it exactly for hosts that did. Co-Authored-By: Claude Opus 5 (1M context) --- showcase/specs/empty.ts | 84 ++++++++++++++++++++++++++++++++++ src/components/empty/Empty.vue | 46 +++++++++++++++++-- src/components/empty/index.ts | 8 ++++ 3 files changed, 135 insertions(+), 3 deletions(-) create mode 100644 showcase/specs/empty.ts diff --git a/showcase/specs/empty.ts b/showcase/specs/empty.ts new file mode 100644 index 0000000..bde7ddc --- /dev/null +++ b/showcase/specs/empty.ts @@ -0,0 +1,84 @@ +import type { ComponentSpec, SpecState } from '../lib/spec' +import { h } from 'vue' +import { Plus } from 'lucide-vue-next' +import { Button } from '#/components/button' +import { Card, CardContent, CardHeader, CardTitle } from '#/components/card' +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyTitle, + emptyVariantKeys, +} from '#/components/empty' + +const TITLE = 'No API keys yet' +const DESCRIPTION = 'Keys let a service call this workspace on your behalf.' + +function renderEmpty(state: SpecState) { + return h(Empty, { variant: state.variant as never }, () => [ + h(EmptyHeader, null, () => [ + h(EmptyTitle, null, () => TITLE), + h(EmptyDescription, null, () => DESCRIPTION), + ]), + state.withAction + ? h(EmptyContent, null, () => + h(Button, { variant: 'outline' }, () => [h(Plus), 'New key'])) + : null, + ]) +} + +export const emptySpec: ComponentSpec = { + id: 'empty', + name: 'Empty', + description: + 'The "no rows yet" surface. It is the same page with nothing in it, so it keeps the frame the populated state has — a solid hairline when it stands alone, none when a card already frames it.', + descriptionZh: + '"还没有数据"的表面。它就是同一个页面的空版本,因此保留有数据时的那个框——独立摆放时是一道实线发丝边,已被卡片包住时则不再自带边。', + controls: [ + { kind: 'enum', key: 'variant', label: 'Variant', options: emptyVariantKeys, default: 'framed', display: 'segmented' }, + { kind: 'boolean', key: 'withAction', label: 'Guiding action', default: true }, + ], + examples: [ + { + name: 'Standalone', + nameZh: '独立摆放', + note: 'The frame is SOLID. border-dashed reads "drop zone / add here" and is reserved for the "+ Add another" tile beside real items.', + noteZh: '框是实线。border-dashed 读作"拖放区 / 在此新增",只留给已有内容旁边的"+ 再加一个"格子。', + state: { variant: 'framed' }, + }, + { + name: 'Inside a card', + nameZh: '卡片内', + note: 'bare drops the hairline — a card already frames it, and two strokes on one unit is card-in-card.', + noteZh: 'bare 去掉发丝边——卡片已经框住它了,一个视觉单元上两道描边就是卡中卡。', + state: { variant: 'bare', withAction: true }, + render: state => h(Card, { class: 'w-96' }, () => [ + h(CardHeader, null, () => h(CardTitle, null, () => 'API keys')), + h(CardContent, null, () => renderEmpty(state)), + ]), + }, + { + name: 'Message only', + nameZh: '仅消息', + note: 'Drop the action when the user cannot create the thing from here.', + noteZh: '当用户无法在此处创建该对象时,就不要放动作按钮。', + state: { variant: 'framed', withAction: false }, + }, + ], + render: state => renderEmpty(state), + usage: `An empty state keeps the populated skeleton — entering an empty page and a full one must not jolt the layout. Compose EmptyHeader (EmptyTitle + EmptyDescription) plus, when the user can act, one EmptyContent action. + +- framed (default) stands in for the card or grid that will be there once data exists: one SOLID border-border hairline at the card radius. Never dashed — dashed is the "+ Add another" tile beside real items, not "nothing yet". +- bare is for an Empty nested in a surface that already draws the hairline (a Card, a SettingsSection). A second stroke there is card-in-card. +- No decorative icon. EmptyMedia variant="icon" is a bordered tile — inside a card it is card-in-card, and a big glyph above the title is the icon abuse this page type attracts. Title + description + one action. +- One guiding action, and it is the same action the populated page offers ("New key"), not a special empty-only affordance. +- A CONDITIONAL section vanishes when it is empty instead of drawing an empty frame; only always-present content earns an Empty.`, + usageZh: `空态保留有数据时的骨架——进入空页面和进入满页面不应让布局跳一下。组合 EmptyHeader(EmptyTitle + EmptyDescription),以及在用户确实能动手时加一个 EmptyContent 动作。 + +- framed(默认)顶替有数据后会出现的卡片或网格:一道实线 border-border 发丝边,取卡片圆角。绝不用虚线——虚线是已有内容旁边那个"+ 再加一个"格子,不是"还没有"。 +- bare 用于嵌在已经画了发丝边的表面里(Card、SettingsSection)。在那里再加一道描边就是卡中卡。 +- 不要装饰性图标。EmptyMedia variant="icon" 自带描边,放进卡片就是卡中卡;标题上方压一个大字形则是这类页面最容易招来的图标滥用。标题 + 描述 + 一个动作,就够了。 +- 只放一个引导动作,而且就是有数据时页面提供的那个动作("新建密钥"),不要为空态另造一个。 +- 条件性区块在为空时应整块消失,而不是画一个空框;只有始终存在的内容才配一个 Empty。`, +} diff --git a/src/components/empty/Empty.vue b/src/components/empty/Empty.vue index 7416ddb..97efd59 100644 --- a/src/components/empty/Empty.vue +++ b/src/components/empty/Empty.vue @@ -1,17 +1,57 @@