From db25e5b9c8c36f9956862eb630d1948f17d558f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Apr 2026 03:59:45 +0000 Subject: [PATCH 1/3] feat(openspec): propose empty-state-component change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the full OpenSpec change for the EmptyState component — the first unchecked item in the JOURNAL.md roadmap. Includes proposal, design, specs (empty-state + localisation-copy delta), and implementation tasks. https://claude.ai/code/session_01TuJoeXX7tM7UcNBK9Kn5UA --- .../empty-state-component/.openspec.yaml | 2 + .../changes/empty-state-component/design.md | 74 +++++++++++++++++ .../changes/empty-state-component/proposal.md | 30 +++++++ .../specs/empty-state/spec.md | 79 +++++++++++++++++++ .../specs/localisation-copy/spec.md | 18 +++++ .../changes/empty-state-component/tasks.md | 44 +++++++++++ 6 files changed, 247 insertions(+) create mode 100644 openspec/changes/empty-state-component/.openspec.yaml create mode 100644 openspec/changes/empty-state-component/design.md create mode 100644 openspec/changes/empty-state-component/proposal.md create mode 100644 openspec/changes/empty-state-component/specs/empty-state/spec.md create mode 100644 openspec/changes/empty-state-component/specs/localisation-copy/spec.md create mode 100644 openspec/changes/empty-state-component/tasks.md diff --git a/openspec/changes/empty-state-component/.openspec.yaml b/openspec/changes/empty-state-component/.openspec.yaml new file mode 100644 index 0000000..0f52803 --- /dev/null +++ b/openspec/changes/empty-state-component/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-01 diff --git a/openspec/changes/empty-state-component/design.md b/openspec/changes/empty-state-component/design.md new file mode 100644 index 0000000..0e1ae55 --- /dev/null +++ b/openspec/changes/empty-state-component/design.md @@ -0,0 +1,74 @@ +## Context + +The Mori component library has Button, Input, Card, Badge, Skeleton, Dialog, and Navigation, but no standard pattern for zero-data screens. Every time a ShiftMate or FaxBridge view renders an empty list (no shifts scheduled, no faxes received), the product team has to write one-off layouts. This produces inconsistent icon sizes, copy tone, and spacing across views. A shared `EmptyState` component fixes this by codifying the visual pattern once. + +The existing `localisation-copy` spec already includes `empty.default` and `empty.search` keys; we extend it with `empty.description` to provide a generic fallback description. + +## Goals / Non-Goals + +**Goals:** +- Single `EmptyState` component covering all zero-data scenarios in both product repos +- Follows the existing Mori copy-paste component model (shadcn/ui pattern — products own the file) +- All design tokens referenced via CSS variables; no hardcoded values +- Locale-aware default copy via `useCopy()` (heading and description fallbacks) +- Optional Lucide icon slot with consistent sizing (48×48px, `--color-ink-tertiary`) +- Optional CTA using the existing `Button` component +- Vitest tests covering all prop combinations and locale fallbacks +- Showcase section demonstrating icon / no-icon and CTA / no-CTA variants + +**Non-Goals:** +- Animated illustrations or Lottie files (MVP uses icons only) +- Context-specific copy built into the component (products pass their own heading/description) +- Full-page error states (separate concern; `EmptyState` is for zero-data, not error conditions) + +## Decisions + +### 1. Explicit `heading` prop vs. locale-derived default + +**Decision:** `heading` is a required prop. Products always pass their own Japanese heading (e.g., `まだシフトがありません`). The component does NOT fall back to `empty.default` copy — that copy is used by product code directly when a generic message is acceptable. + +**Rationale:** Empty-state messages are data-domain specific ("No shifts yet" vs. "No faxes received"). Baking a generic fallback into the component would encourage lazy, context-free copy. Requiring `heading` forces product teams to write meaningful messages at each call site. + +**Alternative considered:** `heading` optional with `useCopy().empty.default` fallback. Rejected because it hides poor copy behind a reasonable-looking default; code review can't tell whether the heading was intentional. + +--- + +### 2. Icon type — `LucideIcon` component vs. string name + +**Decision:** Accept a Lucide icon component reference (`icon?: LucideIcon`) rather than a string icon name. + +**Rationale:** Component references are tree-shakeable and type-safe. String names would require a runtime registry or dynamic import — unnecessary complexity for MVP. + +**Alternative considered:** `iconName: string` with a Lucide dynamic import. Rejected: adds runtime complexity and breaks tree-shaking. + +--- + +### 3. CTA — render `Button` internally vs. accept ReactNode + +**Decision:** Accept a structured `action?: { label: string; onClick: () => void }` prop and render an internal `Button` with `variant="primary"` and `size="md"`. + +**Rationale:** Constraining the CTA to a `Button` keeps the visual contract tight — no accidental link-style or ghost CTAs in empty states. Products that need exotic CTA behaviour can omit the prop and render their own button below the component. + +**Alternative considered:** `action?: React.ReactNode` — maximum flexibility. Rejected for MVP because it opens the door to inconsistent styling. + +--- + +### 4. `localisation-copy` extension — `empty.description` + +**Decision:** Add `empty.description` to `MoriCopy` across all three locales, providing a generic "nothing here yet" message for contexts where a description is needed but no product-specific copy exists. + +**Rationale:** The existing `empty.default` serves the heading role. A separate description key avoids overloading the same string in two UI positions with different typographic treatment. + +## Risks / Trade-offs + +- [Heading always required] → Product teams may pass empty strings to bypass the requirement. Mitigated by TypeScript `string` type (not `string | undefined`) which at least ensures intention, and code-review convention. +- [Fixed CTA variant] → A future design requirement may need a ghost or secondary CTA in empty states. Mitigation: the `action` prop struct can be extended with `variant` later without breaking the interface. +- [Single CTA only] → Some empty states may need two actions. Out of scope for MVP; product can render a second `Button` below the component. + +## Migration Plan + +No existing code to migrate — this is a net-new component. Products copy `src/components/ui/empty-state.tsx` into their own `components/ui/` directory per the standard Mori copy-paste model. + +## Open Questions + +None — all decisions are resolved above. diff --git a/openspec/changes/empty-state-component/proposal.md b/openspec/changes/empty-state-component/proposal.md new file mode 100644 index 0000000..7bc78d2 --- /dev/null +++ b/openspec/changes/empty-state-component/proposal.md @@ -0,0 +1,30 @@ +## Why + +Every data view in ShiftMate and FaxBridge — shift lists, fax logs, search results — can return zero items, and there is currently no standard pattern for rendering that state. Without a shared component, each product team would invent ad-hoc "no data" screens that break visual consistency and miss the Japanese UX expectations (friendly, encouraging tone rather than a cold system message). + +## What Changes + +- New `EmptyState` component in `src/components/ui/empty-state.tsx` +- Optional Lucide icon slot, required heading, optional description, optional CTA button +- Locale-aware copy via `useCopy()` (ja / en / zh-Hans) +- Vitest unit tests covering all prop combinations and locale output +- New `EmptyState` section added to the component showcase + +## Capabilities + +### New Capabilities + +- `empty-state`: A composable empty-state display component with icon, heading, description, and optional CTA — used whenever a data view returns zero results. + +### Modified Capabilities + +- `localisation-copy`: Add empty-state copy keys (`emptyState.heading` and `emptyState.description`) to the `MoriCopy` catalogue for all three locales. + +## Impact + +- **New file**: `src/components/ui/empty-state.tsx` +- **New test file**: `src/components/ui/__tests__/empty-state.test.tsx` +- **Modified**: `src/lib/locale/copy.ts` — extend `MoriCopy` type with `emptyState` copy keys +- **Modified**: `src/index.ts` — export `EmptyState` and its props type +- **Modified**: `showcase/src/App.tsx` and `showcase/src/sections/` — add EmptyState demo section +- **Dependencies**: Lucide React (already installed), existing `Button` component, existing `useCopy()` hook diff --git a/openspec/changes/empty-state-component/specs/empty-state/spec.md b/openspec/changes/empty-state-component/specs/empty-state/spec.md new file mode 100644 index 0000000..f2412b6 --- /dev/null +++ b/openspec/changes/empty-state-component/specs/empty-state/spec.md @@ -0,0 +1,79 @@ +## ADDED Requirements + +### Requirement: EmptyState component renders a centred zero-data layout +`src/components/ui/empty-state.tsx` SHALL export an `EmptyState` React functional component and an `EmptyStateProps` TypeScript interface. The component SHALL render a vertically and horizontally centred layout containing an optional icon, a required heading, an optional description, and an optional CTA button. All colour and spacing values SHALL reference CSS custom property tokens — no hardcoded hex or px values. + +#### Scenario: Minimal render with heading only +- **WHEN** `` is rendered +- **THEN** the heading text SHALL be visible in the DOM +- **THEN** no icon, description, or button SHALL be rendered + +#### Scenario: Full render with all props +- **WHEN** `` is rendered +- **THEN** an SVG icon, the heading, the description, and a button labeled "追加する" SHALL all be present in the DOM + +--- + +### Requirement: Icon slot accepts a Lucide icon component +The `icon` prop SHALL accept a `LucideIcon` component reference (i.e., `React.ComponentType>`). When provided, the icon SHALL render at 48×48px using `--color-ink-tertiary`. When omitted, no icon element SHALL be rendered. + +#### Scenario: Icon renders with correct dimensions +- **WHEN** `` is rendered +- **THEN** the rendered SVG element SHALL have `width="48"` and `height="48"` attributes (or equivalent CSS) + +#### Scenario: No icon when prop omitted +- **WHEN** `` is rendered +- **THEN** no `` element SHALL appear in the output + +--- + +### Requirement: Heading is required and rendered as a prominent text element +The `heading` prop SHALL be a required `string`. It SHALL render as a visible text node styled with `--color-ink` at the `--text-lg` size and `--font-weight-semibold` weight. Line height SHALL use `--leading-snug`. + +#### Scenario: Heading text appears in DOM +- **WHEN** `` is rendered +- **THEN** `getByText("テスト見出し")` SHALL resolve successfully + +--- + +### Requirement: Description is optional and rendered as secondary text +The `description` prop SHALL be an optional `string`. When provided, it SHALL render below the heading with `--color-ink-secondary` and `--text-sm` size. When omitted, no description element SHALL appear. + +#### Scenario: Description renders when provided +- **WHEN** `` is rendered +- **THEN** `getByText("説明文")` SHALL resolve successfully + +#### Scenario: Description absent when omitted +- **WHEN** `` is rendered +- **THEN** no element with a description role or description-class SHALL appear + +--- + +### Requirement: CTA renders as a primary Button when action prop is provided +The `action` prop SHALL be an optional object `{ label: string; onClick: () => void }`. When provided, the component SHALL render a `Button` with `variant="primary"` and `size="md"` labelled with `action.label`. Clicking the button SHALL invoke `action.onClick`. + +#### Scenario: CTA button is clickable +- **WHEN** `` is rendered and the button is clicked +- **THEN** `mockFn` SHALL have been called exactly once + +#### Scenario: No button when action omitted +- **WHEN** `` is rendered +- **THEN** no `
+ {Icon && ( +
+ ) +} diff --git a/src/index.ts b/src/index.ts index 62fa2eb..fb02cc5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -45,3 +45,6 @@ export type { SidebarNavItem, IconComponent, } from './components/ui/navigation' + +export { EmptyState } from './components/ui/empty-state' +export type { EmptyStateProps } from './components/ui/empty-state' diff --git a/src/lib/locale/copy.ts b/src/lib/locale/copy.ts index 3024899..31816d2 100644 --- a/src/lib/locale/copy.ts +++ b/src/lib/locale/copy.ts @@ -22,6 +22,7 @@ export interface CopyKeys { empty: { default: string search: string + description: string } confirm: { destructiveHint: string @@ -58,6 +59,7 @@ export const MoriCopy: Record = { empty: { default: 'まだデータがありません', search: '検索結果がありません', + description: 'まだ項目が登録されていません。', }, confirm: { destructiveHint: 'この操作は取り消せません。', @@ -93,6 +95,7 @@ export const MoriCopy: Record = { empty: { default: 'No data yet', search: 'No results found', + description: 'No items have been added yet.', }, confirm: { destructiveHint: 'This action cannot be undone.', @@ -145,6 +148,8 @@ export const MoriCopy: Record = { default: '暂无数据', // TODO: zh-Hans review search: '未找到结果', + // TODO: zh-Hans review + description: '还没有添加任何项目。', }, confirm: { // TODO: zh-Hans review From a1cfb76ba632ce7eb0fb384acb7d4be720ff26a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Apr 2026 04:13:16 +0000 Subject: [PATCH 3/3] chore(openspec): archive empty-state-component change Syncs delta specs to main (new empty-state spec, localisation-copy extended with empty.description) and archives the completed change to openspec/changes/archive/2026-04-01-empty-state-component/. https://claude.ai/code/session_01TuJoeXX7tM7UcNBK9Kn5UA --- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/empty-state/spec.md | 0 .../specs/localisation-copy/spec.md | 0 .../tasks.md | 0 openspec/specs/empty-state/spec.md | 84 +++++++++++++++++++ openspec/specs/localisation-copy/spec.md | 19 +++++ 8 files changed, 103 insertions(+) rename openspec/changes/{empty-state-component => archive/2026-04-01-empty-state-component}/.openspec.yaml (100%) rename openspec/changes/{empty-state-component => archive/2026-04-01-empty-state-component}/design.md (100%) rename openspec/changes/{empty-state-component => archive/2026-04-01-empty-state-component}/proposal.md (100%) rename openspec/changes/{empty-state-component => archive/2026-04-01-empty-state-component}/specs/empty-state/spec.md (100%) rename openspec/changes/{empty-state-component => archive/2026-04-01-empty-state-component}/specs/localisation-copy/spec.md (100%) rename openspec/changes/{empty-state-component => archive/2026-04-01-empty-state-component}/tasks.md (100%) create mode 100644 openspec/specs/empty-state/spec.md diff --git a/openspec/changes/empty-state-component/.openspec.yaml b/openspec/changes/archive/2026-04-01-empty-state-component/.openspec.yaml similarity index 100% rename from openspec/changes/empty-state-component/.openspec.yaml rename to openspec/changes/archive/2026-04-01-empty-state-component/.openspec.yaml diff --git a/openspec/changes/empty-state-component/design.md b/openspec/changes/archive/2026-04-01-empty-state-component/design.md similarity index 100% rename from openspec/changes/empty-state-component/design.md rename to openspec/changes/archive/2026-04-01-empty-state-component/design.md diff --git a/openspec/changes/empty-state-component/proposal.md b/openspec/changes/archive/2026-04-01-empty-state-component/proposal.md similarity index 100% rename from openspec/changes/empty-state-component/proposal.md rename to openspec/changes/archive/2026-04-01-empty-state-component/proposal.md diff --git a/openspec/changes/empty-state-component/specs/empty-state/spec.md b/openspec/changes/archive/2026-04-01-empty-state-component/specs/empty-state/spec.md similarity index 100% rename from openspec/changes/empty-state-component/specs/empty-state/spec.md rename to openspec/changes/archive/2026-04-01-empty-state-component/specs/empty-state/spec.md diff --git a/openspec/changes/empty-state-component/specs/localisation-copy/spec.md b/openspec/changes/archive/2026-04-01-empty-state-component/specs/localisation-copy/spec.md similarity index 100% rename from openspec/changes/empty-state-component/specs/localisation-copy/spec.md rename to openspec/changes/archive/2026-04-01-empty-state-component/specs/localisation-copy/spec.md diff --git a/openspec/changes/empty-state-component/tasks.md b/openspec/changes/archive/2026-04-01-empty-state-component/tasks.md similarity index 100% rename from openspec/changes/empty-state-component/tasks.md rename to openspec/changes/archive/2026-04-01-empty-state-component/tasks.md diff --git a/openspec/specs/empty-state/spec.md b/openspec/specs/empty-state/spec.md new file mode 100644 index 0000000..4b69ef6 --- /dev/null +++ b/openspec/specs/empty-state/spec.md @@ -0,0 +1,84 @@ +# empty-state Specification + +## Purpose +Define the requirements for the Mori `EmptyState` component — a centred zero-data layout used across all products when a list or view has no content to display. + +## Requirements + +### Requirement: EmptyState component renders a centred zero-data layout +`src/components/ui/empty-state.tsx` SHALL export an `EmptyState` React functional component and an `EmptyStateProps` TypeScript interface. The component SHALL render a vertically and horizontally centred layout containing an optional icon, a required heading, an optional description, and an optional CTA button. All colour and spacing values SHALL reference CSS custom property tokens — no hardcoded hex or px values. + +#### Scenario: Minimal render with heading only +- **WHEN** `` is rendered +- **THEN** the heading text SHALL be visible in the DOM +- **THEN** no icon, description, or button SHALL be rendered + +#### Scenario: Full render with all props +- **WHEN** `` is rendered +- **THEN** an SVG icon, the heading, the description, and a button labeled "追加する" SHALL all be present in the DOM + +--- + +### Requirement: Icon slot accepts a Lucide icon component +The `icon` prop SHALL accept a `LucideIcon` component reference (i.e., `React.ComponentType>`). When provided, the icon SHALL render at 48×48px using `--color-ink-tertiary`. When omitted, no icon element SHALL be rendered. + +#### Scenario: Icon renders with correct dimensions +- **WHEN** `` is rendered +- **THEN** the rendered SVG element SHALL have `width="48"` and `height="48"` attributes (or equivalent CSS) + +#### Scenario: No icon when prop omitted +- **WHEN** `` is rendered +- **THEN** no `` element SHALL appear in the output + +--- + +### Requirement: Heading is required and rendered as a prominent text element +The `heading` prop SHALL be a required `string`. It SHALL render as a visible text node styled with `--color-ink` at the `--text-lg` size and `--font-weight-semibold` weight. Line height SHALL use `--leading-snug`. + +#### Scenario: Heading text appears in DOM +- **WHEN** `` is rendered +- **THEN** `getByText("テスト見出し")` SHALL resolve successfully + +--- + +### Requirement: Description is optional and rendered as secondary text +The `description` prop SHALL be an optional `string`. When provided, it SHALL render below the heading with `--color-ink-secondary` and `--text-sm` size. When omitted, no description element SHALL appear. + +#### Scenario: Description renders when provided +- **WHEN** `` is rendered +- **THEN** `getByText("説明文")` SHALL resolve successfully + +#### Scenario: Description absent when omitted +- **WHEN** `` is rendered +- **THEN** no element with a description role or description-class SHALL appear + +--- + +### Requirement: CTA renders as a primary Button when action prop is provided +The `action` prop SHALL be an optional object `{ label: string; onClick: () => void }`. When provided, the component SHALL render a `Button` with `variant="primary"` and `size="md"` labelled with `action.label`. Clicking the button SHALL invoke `action.onClick`. + +#### Scenario: CTA button is clickable +- **WHEN** `` is rendered and the button is clicked +- **THEN** `mockFn` SHALL have been called exactly once + +#### Scenario: No button when action omitted +- **WHEN** `` is rendered +- **THEN** no `