読み込み中
豆知識
Forward-looking spec. For historical audits (Jan 2026 → Apr 2026), see git log.
製品ティア (Product tier): Tier-D(高密度な会計/財務バックオフィスUI) Philosophy: Firm, bold, simple. Dense but human-readable. Non-generic. Stack: React 19 · Tailwind CSS v4 · Radix UI · Framer Motion Last rewritten: May 2026 (Phase 9–19 AWWWARDS Adoption & Reversibility)
audit_logs.is_revertable + inverse_payload にエンドツーエンドで紐づく。<HistoryPanel entityType=...> が機能ごとのタイムラインを置き換える。flowchart TD
Action[ユーザーが破壊的/編集を発火]
Action --> Classify{リスククラス}
Classify -->|low: archive, soft-delete, unlink| Optimistic[楽観的ミューテーション + Sonner Undo トースト]
Classify -->|medium: post, void, bulk-edit| Confirm["ConfirmationDialog<br/>dontAskAgainKey<br/>variant=warning"]
Classify -->|high: hard-delete, close-period, override-lock| StrongConfirm["ConfirmationDialog<br/>requireTypedConfirmation<br/>variant=danger"]
Optimistic --> Backend["Mutation w/ X-Idempotency-Key + If-Match"]
Confirm --> Backend
StrongConfirm --> Backend
Backend --> AuditLog["audit_logs row<br/>is_revertable=true<br/>inverse_payload"]
AuditLog --> RevertFeed["GET /api/audit-log?revertable=true<br/>(global Recent Activity rail)"]
RevertFeed --> RevertEndpoint["POST /api/audit-log/:id/revert"]
RevertEndpoint --> Restore[状態を復元 + 新しい変更履歴行]
Optimistic -.->|onError| Rollback[キャッシュロールバック + toast.error]
src/lib/destructive-action-registry.ts)| リスク | アクションキー | UX 形 | audit_logs.action |
|---|---|---|---|
| low | archive-document |
楽観的 + 5 秒 Sonner Undo トースト | document.archived |
| low | soft-delete-document |
楽観的 + 5 秒 Sonner Undo トースト | document.deleted |
| low | unlink-transaction |
楽観的 + 5 秒 Sonner Undo トースト | link.transaction.removed |
| medium | post-journal-entry |
モーダル w/ dontAskAgainKey(warning) |
journal_entry.posted |
| medium | void-document |
モーダル w/ dontAskAgainKey(warning) |
document.voided |
| medium | bulk-edit |
モーダル w/ dontAskAgainKey(warning) |
bulk.edited |
| high | hard-delete-permanent |
タイプ確認ゲート(削除する、danger) |
hard_delete.permanent |
| high | close-fiscal-period |
タイプ確認ゲート(締める、danger) |
fiscal_period.closed |
| high | delete-company |
タイプ確認ゲート(DELETE、danger) |
company.deleted |
| プリミティブ | Phase | 場所 | 用途 |
|---|---|---|---|
useConfirmationDialog(dontAskAgainKey + typed 拡張) |
11.1 | src/components/ui/use-confirmation-dialog.ts |
命令型の確認フロー |
useUndoableMutation |
11.2 | src/hooks/use-undoable-mutation.ts |
楽観的 + Undo の low risk アクション |
<DiscardGuard> |
11.3 | src/components/ui/discard-guard.tsx |
フォーム dirty ガード(route + beforeunload) |
destructive-action-registry |
11.4 | src/lib/destructive-action-registry.ts |
全破壊的アクションの単一レジストリ |
<RecentActivityRail> |
12.2 | src/components/ui/recent-activity-rail.tsx |
グローバルな「いつでも戻せる」ヘッダー UI |
<HistoryPanel entityType=...> |
13 | src/components/ui/history-panel.tsx |
エンティティ別ポリモーフィック履歴 |
<ConflictResolutionDialog> + If-Match |
14 | src/components/ui/conflict-resolution-dialog.tsx |
409 衝突解決(diff 表示) |
<KPIHero> |
15.1 | src/components/ui/kpi-hero.tsx |
ページの主役となる数字(1 ページ 1 つまで) |
| Finance-status トークン | 16.1 | src/index.css --color-status-{posted,draft,voided,...} |
データ行のステータスチップ |
<ModalFooterKit> |
18.1 | src/components/ui/modal-footer-kit.tsx |
標準的なモーダルフッターのボタン配置 |
リポジトリ直下の .design-system-baseline.json が採用ギャップを記録する。
CI は push のたびに npm run check:design-system を走らせ、いずれかの軸の
違反数が増えるとビルドが失敗する。意図的な後退でベースラインを更新する場合は
node scripts/check-design-system.mjs --write を実行する。
flowchart LR
CSS["src/index.css @theme<br/>(canonical)"] --> Tailwind["Tailwind v4 @utility"]
CSS --> ExtractScript["scripts/extract-design-tokens.ts"]
ExtractScript --> SharedJS["src/shared/tokens/*.ts"]
Tailwind --> WebUI["src/components/** + src/features/**"]
SharedJS --> WebUI
SharedJS -.-> FutureRN["apps/mobile/ (future RN)"]
ZenDeprecated["src/lib/zen-design-constants.ts<br/>(deprecated)"] -.->|migrate| WebUI
ルール:
src/index.css でのみ編集する。npx tsx scripts/extract-design-tokens.ts で再生成する。
src/shared/tokens/colors.generated.ts を手で書き換えてはならない。src/lib/zen-design-constants.ts は ESLint で凍結済み。新規 import は
no-restricted-imports で error になる。src/index.css's
@theme block. The deprecated ZEN_DESIGN JS mirror is ESLint-frozen.z-[9999],
rounded-2xl, or #059669 in a className. Use the semantic utility.Button, one Stepper, one
DateField, one AppPageLayout. No parallel implementations..density-compact (applied on <html> in src/main.tsx).
Pages that want roomier treatment opt out via density="comfortable".<TerminalCancelButton /> は destructive-outline(赤枠+赤文字)が
デフォルトで、useConfirmationDialog() による AlertDialog 確認が入る。
データを失わない閉じる操作のみ safe={true} を付ける。src/index.css @theme)| Token | Purpose |
|---|---|
--color-fg |
Primary foreground text |
--color-fg-muted |
Secondary labels |
--color-fg-subtle |
Tertiary / placeholder |
--color-surface |
Primary surface (card/modal) |
--color-surface-raised |
Elevated surface (hover/popover) |
--color-surface-sunk |
Recessed surface (inputs) |
--color-success |
Confirmation, positive delta |
--color-warning |
Warnings, amber signals |
--color-danger |
Destructive actions, errors |
--color-info |
Informational |
--color-deposit |
Money in (banking views) |
--color-withdrawal |
Money out (banking views) |
Tailwind auto-generates matching utilities: text-fg, bg-surface-raised,
border-danger, etc.
Signal utilities (Wave 2.4) — use for any financial signal, not raw emerald/rose/amber/blue:
| Utility | Meaning |
|---|---|
text-signal-in / bg-signal-in / border-signal-in |
Money in, deposit, success |
text-signal-out / bg-signal-out / border-signal-out |
Money out, withdrawal, error |
text-signal-warn/ bg-signal-warn/ border-signal-warn |
Warning, review, pending |
text-signal-info/ bg-signal-info/ border-signal-info |
Neutral information |
Signal tokens are kept separate from branding emerald (used for chrome, nav, decorative) so semantic meaning can't be broken by theme swaps.
| Utility | Size | Use |
|---|---|---|
text-compact-micro |
8px | Chart axis labels only |
text-compact-tiny |
9px | Inline badges |
text-compact-mini |
10px | Micro-copy, tooltip annotations |
text-compact-small |
11px | Dense table rows |
text-xs |
12px | Secondary labels, hint text |
text-sm |
14px | Body default |
text-base |
16px | Emphasized body, form inputs |
text-lg |
18px | Section headings |
text-xl → text-3xl |
... | Page titles, KPI values |
Font weights: font-normal (body) · font-medium (primary copy) ·
font-semibold (emphasis, headings). font-bold is banned — too heavy
for financial UI.
prose-zen body-copy helper (Wave 9.5, Apr 2026)Long-form descriptions inside dialogs, panels, and modal bodies should opt into
the prose-zen utility (defined in src/index.css) so inline emphasis renders
consistently without per-call className soup. React components can wrap children
in <TextFormatted as="p"> from src/components/ui/text-formatted.tsx.
| Inline element | Rendered style |
|---|---|
<strong> |
text-fg font-medium (no bold) |
<em> |
text-signal-info-strong not-italic — italics replaced with hue shift |
<code> |
font-mono text-xs px-1.5 py-0.5 rounded-sm bg-surface-sunk text-signal-warn-strong ring-1 ring-border |
<mark> |
bg-signal-warn text-signal-warn-strong px-1 rounded-sm |
<kbd> |
font-mono text-xs px-1.5 py-0.5 rounded-sm bg-surface ring-1 ring-border |
| numeric runs | inherit tabular-nums from ancestor |
Already wired into Dialog/DialogCompact headers, ConfirmationDialog body,
PaidFeatureDialog, the Journaling Assistant overwrite confirmation, and the
Document Linking modal results. New modals should reach for <TextFormatted>
rather than redefining inline <code> / <strong> styles.
Landing (/, /pricing, /security, /legal/*, /tools) and public docs hub
(/docs) use an expressive marketing tier on top of the Tier-D role scale.
Authenticated app chrome keeps text-display / text-title — do not reuse
marketing utilities inside AuthenticatedShell routes.
| Utility | Use |
|---|---|
text-marketing-hero |
LP hero h1 (MarketingDisplay size="hero") |
text-marketing-section |
Section h2 headings on LP folds |
text-marketing-hub |
Docs hub titles + supporting page heroes (/pricing, /security) |
text-marketing-kpi-hero |
KPI band primary numerals |
text-marketing-kpi-support |
KPI band secondary numerals |
shadow-marketing-card |
Public doc cards + LP elevated tiles |
Canonical React primitives: MarketingDisplay, MarketingEyebrow,
MarketingLead in src/features/landing/components/marketing-prose.tsx.
New public headings must use these (or the utilities above) — not ad-hoc
text-[clamp(...)] outside marketing-prose.tsx / legacy ChapterMarker.
Light-only policy: Public marketing and docs shells intentionally omit
dark: variants and set [color-scheme:light] so the sepia canvas reads
consistently for visitors. App dark mode is out of scope on these routes.
Living doctrine (Sharp-Precise Ledger): controls 6px, cards/panels 8px. See docs/design-system/SHARP-PRECISE-LEDGER.md. Token utility names may still say zen-* (historical prefix).
| Utility | Use |
|---|---|
rounded-sm |
Badges, chips, small inputs |
rounded-md |
Buttons, form fields (≈6px) |
rounded-lg |
Cards, modals (≈8px) |
rounded-full |
Avatars, status dots |
rounded-xl, rounded-2xl, rounded-3xl are banned (ESLint).
Use the 15-step semantic ladder (--z-* / @utility z-*). Arbitrary
z-[NNN] and raw numeric zIndex are banned (local/no-literal-z-index).
| Token | Value | Own this layer |
|---|---|---|
z-base … z-sticky |
0–20 | Content / in-page sticky |
z-dock |
30 | Mobile bottom dock |
z-page-chrome |
32 | Hub sticky under the app shell |
z-app-chrome |
35 | Sticky header, banner stack, advisor FAB |
z-app-chrome-raised |
38 | Header while a shell dropdown is open (< overlay) |
z-overlay |
40 | Dialog / Sheet scrims |
z-modal |
50 | Dialog / Sheet content, advisor dock |
z-popover |
60 | Popover, Select, menus (above modals) |
z-toast |
70 | Sonner (zIndex: "var(--z-toast)") |
z-tooltip |
80 | Tooltips |
z-spotlight / z-critical |
90 / 100 | Tours / skip-links & emergencies — not network banners |
Do not put app chrome or side docks on z-popover (that paints over open Dialogs).
Duration and easing are now first-class @theme tokens so feature code
can write duration-motion-reveal / ease-motion-firm instead of
arbitrary duration-[160ms] / ease-[cubic-bezier(…)]. The spec is
"firm settle, no overshoot" — see §11 Motion Contract for the full
rulebook.
| Token | Value | Use |
|---|---|---|
--duration-motion-tap |
100ms |
Button press, hover fill, badge pop |
--duration-motion-reveal |
160ms |
Dropdown, popover, card lift, tab switch |
--duration-motion-drawer |
240ms |
Modal open, sheet slide, side panel |
--duration-motion-page |
400ms (ceiling) |
Full-page transitions — used sparingly |
--ease-motion-firm |
cubic-bezier(0.2, 0, 0, 1) |
Default settle curve (no overshoot) |
--ease-motion-out |
cubic-bezier(0, 0, 0.2, 1) |
Entrances (fades, slides in) |
Plus two convenience utilities defined in src/styles/zen-interactions.css:
.motion-tap → transition-[timing] duration-motion-tap ease-motion-firm.motion-reveal → transition-[timing] duration-motion-reveal ease-motion-firmIntentionally not tokenized: ease-bounce. Overshoot on financial
data reads as "the number isn't certain." Firm settle only.
Primitives read their padding / row-height from CSS variables so the
[data-density="compact|cozy|ultra"] attribute on a parent container
cascades into every child without per-component prop drilling.
| Token | Default | Compact | Dense | Ultra | Roomy | Use |
|---|---|---|---|---|---|---|
--card-padding |
1rem |
0.625rem |
0.625rem |
0.5rem |
1.5rem |
Card / modal body padding |
--card-padding-sm |
0.75rem |
0.5rem |
0.5rem |
0.375rem |
1.25rem |
Card compact variant |
--cell-padding-x |
0.625rem |
0.5rem |
0.4375rem |
0.375rem |
1rem |
Table cell horizontal padding |
--cell-padding-y |
0.5rem |
0.25rem |
0.21875rem |
0.1875rem |
0.75rem |
Table cell vertical padding |
--row-dense |
28px |
— | — | — | — | Journal/ledger/trial-balance rows |
--row-comfortable |
36px |
— | — | — | — | Default table rows |
--row-roomy |
44px |
— | — | — | — | Mobile/touch rows (WCAG min) |
--leading-dense |
1.45 |
— | — | — | — | 13 px statement rows (CJK) |
--leading-body |
1.60 |
— | — | — | — | Body copy (CJK) |
--leading-tight |
1.25 |
— | — | — | — | Headings (pairs with tracking-tight) |
Rule: never reach for py-1 / px-1.5 etc. in feature code to "make
it more compact." Put data-density="compact" (or ultra) on the
container and let the tokens flow. One axis of control; one place to
audit.
Import each from its own module. Barrel imports from @/components/ui
are banned (ESLint enforces).
| Primitive | Source |
|---|---|
Button, AnimatedButton |
@/components/ui/button |
Input |
@/components/ui/input |
DateField |
@/components/ui/date-field |
Stepper |
@/components/ui/stepper |
FormField |
@/components/ui/form-field-component |
Card + subparts |
@/components/ui/card |
Table* + TableCellCurrency |
@/components/ui/table |
InteractiveDataTable |
@/components/ui/interactive-data-table-component |
Dialog* |
@/components/ui/dialog |
TerminalDialog(旧 MacTerminalModalWrapper) |
@/components/ui/terminal-dialog — ラッパーモジュールは2026年8月に削除。local/no-bare-mac-terminal-modal は error |
AppPageLayout |
@/components/ui/app-page-layout |
Tooltip* |
@/components/ui/tooltip |
Tabs* |
@/components/ui/tabs |
EmptyState + presets |
@/components/ui/empty-state-component |
InlineNoData |
@/components/ui/empty-state-component |
WorkflowCanvas |
@/components/ui/workflow-canvas |
Mobile screens follow the same primitives, but with stricter interaction defaults. The goal is not a separate mobile UI; it is the same accounting surface with fewer accidental taps, no browser-chrome jumps, and better keyboard affordances.
| Rule | Contract |
|---|---|
| Form keyboards | Use Input or FormField; both call getMobileFormHints() so email, tel, search, password, numeric-looking fields, postal codes, and one-time codes get inputMode, enterKeyHint, autoComplete, and autocorrect/capitalization defaults. Explicit props always win. |
| Numeric fields | Prefer type="text" inputMode="numeric" / "decimal" for identifiers, postal codes, account numbers, and yen fields. Keep type="number" only where native min/max or React Hook Form valueAsNumber is required. |
| iOS zoom prevention | On mobile, input, select, and textarea font-size is clamped to at least 16px in src/index.css. Do not override it with smaller mobile-only input text. |
| Viewport height | Use 100dvh, h-dvh-full, min-h-dvh-full, or max-h-dvh-full. Do not add new 100vh for app chrome, drawers, panels, sheets, or scroll containers. |
| Bottom chrome | Sticky action bars and bottom sheets must use opaque chrome (bg-chrome-elevated / bg-card) rather than translucent bg-background/80; fixed chrome sits over dense tables and translucency causes text bleeding. |
| Touch targets | Minimum target is 44px, preferred target is 48px. Icon-only buttons need aria-label; mobile CSS expands labeled button hit areas. |
| Search inputs | Use type="search" inputMode="search" enterKeyHint="search" for command palettes and mobile overlays. |
| Camera uploads | Receipt/document scan affordances should expose capture="environment" through a camera-specific input. Do not attach capture to mixed PDF/document pickers unless there is also a normal file-picker fallback. |
| Active navigation | Active bottom-dock tabs must set aria-current="page" in addition to visual state. |
| Table density | At ≤ sm, tables prioritise their highest-information column. Numeric / status columns drop redundant suffixes (the column header already names the unit); action buttons collapse to icon-only 28 × 28 squares (h-7 w-7 p-0 + aria-label). At ≥ sm action icons additionally hover-reveal on the row (sm:opacity-0 sm:group-hover/row:opacity-100) — the row carries a group/row named class injected via getRowClassName so the action cell can react to row hover without colliding with in-cell unnamed group wrappers. The named-group pattern is the canonical way to wire row-scoped hover affordances into InteractiveDataTable-rendered rows, since <tr> does not get a group class on its own. Tooltips replace the inline label in this state, so the column collapses from ~120 px to ~40 px. Reference: src/features/document-list/components/document-management-table.tsx reclaims ~80–100 px of name-column width at 360 px and ~80 px of action-column width at every viewport. |
| List-row density | At ≤ sm, list-row cards (side-panel rows, drawer items) trim hover-only chrome. Per-row "ghost" buttons that use opacity-0 group-hover:opacity-100 become sm:opacity-0 sm:group-hover:opacity-100 (always visible on touch) or hidden sm:inline-flex (desktop-only quick action). Empty-state favorite stars hide on mobile when not set. Reserved actions-column width drops from w-[80px] to w-8 on mobile. Workflow / linkage chips with Japanese labels collapse to icon-only with the label kept as aria-label + title. Reference: src/features/document-list/components/DocumentListItem.tsx; the same transform now applies to JournalingSessionList, bank-statement-session-list, client-list (sales-invoice), DocumentLinkingStep, activity-row, and the rest of the peer list-rows (Apr 2026 mobile sweep). |
| Modal hit area + dvh | Modal/drawer wrappers MUST use dvh not vh for height (avoids the iOS/Android URL bar cropping the bottom action row): dialog.tsx (max-h-[95dvh] / h-[90dvh]), universal-side-panel.tsx (h-dvh w-dvw), breadcrumb.tsx dropdown (max-h-[70dvh]). Close-buttons take a 44 px hit area on <sm (WCAG 2.5.5) via min-h-11 min-w-11 sm:min-h-0 sm:min-w-0 while keeping their original 24 / 28 / 36 px desktop chrome — see terminal-dialog.tsx, sheet.tsx, modal-close-button.tsx, universal-side-panel.tsx. |
DialogCompactTabs |
Tab labels MUST always render; the parent strip is overflow-x-auto scrollbar-hide so labels stay discoverable on touch. The previous hidden sm:inline pattern hid the label behind a tooltip mobile users couldn't fire (dialog.tsx lines ~360). |
| Tooltip not-only-source | Critical state (validation reasons, lock/voided/deleted detail, restore affordance hints, etc.) MUST surface inline as a sub-line on <sm — the desktop tooltip is enrichment, not the only path to the information. Reference: src/features/bank-statement/components/preview-table.tsx (validation-warning ribbon under the row) and src/features/reimbursment-dock/components/dock.tsx (lock/voided/deleted explanatory line under the title row). When you write a <TooltipContent> whose copy is action-driving, pair it with an inline sm:hidden echo. Remaining offenders that have not yet been migrated should carry a // TODO(mobile-tooltip-inline) comment so the audit stays trackable. |
| Breadcrumb primitives | Both the global shell row 2 and the document-viewer breadcrumb consume <BreadcrumbCrumb>, <BreadcrumbSeparator>, and <BreadcrumbOverflow> from src/components/ui/breadcrumb-primitives.tsx. New breadcrumb call-sites MUST use these primitives — no per-crumb pills, no inline ChevronRight. See section 3.6 below. |
At <sm the heading ramp is explicitly redefined, not just rescaled
from desktop tokens. This stops sub-page hierarchy from collapsing into a
single weight on small viewports while still hitting the 16 px iOS-zoom
floor for inputs.
h1 → text-display leading-tight font-semibold (page title)h2 → text-subtitle leading-tight font-semibold (section)h3 → text-body-role leading-snug font-semibold (group)h4 → text-sm leading-snug font-medium (sub-group)--text-body: 15px, --text-ui: 14px,
--text-caption: 13px (NN/g + Tailwind v4 best practice). Authors
MUST stop reaching for text-mini / text-xs interchangeably.src/index.css (@media (max-width: 639.98px) block) and
src/components/ui/mobile-responsive-data-table.tsx (consumes the
text-caption / text-ui tokens for card stack rendering).<AuthenticatedShell> automatically applies data-density="ultra"
(via the ultra class) when useViewport().isMobile is true and the
user has NOT explicitly chosen a density in useUiPreferences. Density
tokens cascade through the existing data-density rules in §2.6, so
every primitive tightens without per-component changes.
Override hierarchy (highest wins):
useUiPreferences().preferences.density (if not default)ultra at <sm)Reference: src/components/layout/authenticated-shell.tsx,
src/hooks/use-ui-preferences.ts. Backed by the
user_profiles.ui_preferences JSONB column so the choice survives
across devices and into the native shell.
MobileResponsiveDataTable task-typed layouts (Wave 12, Apr 2026)Tables on mobile pick one of three canonical presentations driven by
the mobileLayout prop on MobileResponsiveDataTable. There is no
universal default; the choice is task-driven per NN/g + uxpatterns.dev:
mobileLayout |
When to use | Source of truth |
|---|---|---|
cards |
Glanceable single-record tasks (file lists, document inboxes). | Renders MobileDataCardList from the same columns[]. |
priority |
Scannable list with one anchor + ≤ 2 short meta columns (homepage). | CSS [data-priority] rules in src/index.css. |
scroll-frozen |
Comparison tables where every column matters (bank statement, ledger). | .mrt-scroll-frozen sticky-first-column rule + scroll fade. |
auto |
Default — picks priority when ≤ 3 priority/meta columns, else cards. |
Column metadata (meta.priority, meta.weight, meta.mobileLabel) is
declared on the ColumnDef. See ColumnMetaShape in
src/components/ui/interactive-data-table-component.tsx. Reference
migration:
src/features/document-list/components/document-management-table.tsx
(homepage) +
src/features/bank-statement/components/transactions-tab/transaction-columns.tsx
(bank statement).
MobileButtonGroup use cases (Wave 12, Apr 2026)MobileButtonGroup (in src/components/ui/mobile-button-group.tsx)
replaces ad-hoc flex gap-2 button rows that drift into different
widths because each Button's intrinsic text width differs. Used for:
orientation="stack").Layout contract: 1-row CSS grid with grid-cols-N and min-w-0 cells
so long Japanese labels truncate inside their cell instead of pushing
siblings off-screen. Every cell honours WCAG 2.5.5 (44 px hit area).
dvh interplay (Wave 12, Apr 2026)The dvh token reserves the visible viewport height; the safe-area
inset reserves the home-indicator strip under that visible viewport.
Both are required on iPhone X+, Android edge-to-edge, and PWA + native
shells.
index.html declares <meta viewport-fit="cover">.src/index.css re-exports env(safe-area-inset-*) as
--sat / --sar / --sab / --sal so the
@capacitor-community/safe-area polyfill on Android Chromium < 140
can write into the same custom-property names.MobileBottomDock, MobileStickyActionBar,
MobileBottomSheet, TerminalDialog) consumes --sab for
bottom padding via the utility classes in src/styles/zen-responsive.css
(.pb-safe, .dock-spacing, .dock-spacing-lg).dvh. Do not stack a new
100vh rule on top — see §3.0 row "Viewport height".Wave 13 upgrades the mobile foundation from responsive web to native-feeling interaction. These primitives are canonical; do not recreate them per page.
| Primitive | File | Use |
|---|---|---|
BottomSheet |
src/components/ui/bottom-sheet.tsx |
Vaul-backed sheet with drag handle, snap points, scrim, keyboard inset, and safe-area padding. |
SwipeableRow |
src/components/ui/swipeable-row.tsx |
Mail-style left/right row actions, driven by real domain actions. |
LongPressMenu |
src/components/ui/long-press-menu.tsx |
Touch context actions without relying on hover/tooltips. |
MobileFAB |
src/components/ui/mobile-fab.tsx |
One thumb-zone primary action surface; hide when a sheet/keyboard owns the bottom edge. |
BulkActionBar |
src/components/ui/bulk-action-bar.tsx |
Bottom action bar when selection is active; do not stack with MobileBottomDock. |
MobileOmnibar |
src/components/ui/mobile-omnibar.tsx |
Thumb-reachable search/navigation surface for phone widths. |
Native bridge hooks live in src/hooks/use-haptics.ts,
use-keyboard-inset.ts, use-status-bar.ts, and use-app-lifecycle.ts.
Mount them once in AuthenticatedShell; feature code should consume the
resulting primitives/tokens rather than calling Capacitor directly.
The backend user_profiles.ui_preferences contract now includes haptics,
color scheme, amount/date formatting, large text, hidden dock items, quick
actions, home feed order, swipe actions, and pinned table columns. Unknown
keys are stripped by backend validation, so add fields to the schema before
shipping UI that writes them.
Document view pages (/view/:docType/:docId) render a compact two-row
header: shell row 1 (company + search + nav) and shell row 2 (breadcrumbs +
PageActionsPortal hosting the document toolbar).
The document toolbar itself has two distinct modes to keep reading surfaces calm and editing surfaces powerful (Jakob's Law — Notion / Linear / Google Docs pattern):
| Mode | Primary anchors (left → right) | Count |
|---|---|---|
| VIEW (default) | SaveStatusChip · 履歴 · filename ▾ · 編集 · 操作 ▾ · ⋯ More |
≤ 7 |
| EDIT (wysiwyg on) | SaveStatusChip · ↩ ↪ · filename ▾ · 編集中 badge · 完了 · 操作 ▾ · ⋯ More |
≤ 7 |
Rules:
編集
(pencil) button; EDIT mode's primary is an outlined 完了 (check) button.
Both are 32 px outline buttons placed immediately before the 操作 +
⋯ More cluster. No other button in row 2 may compete for primary weight.h-8). Icon buttons
use size="icon-sm" (h-8 w-8). No mixed 34 / 36 px heights.SaveStatusChip component (@/features/document/components/save-status-chip).
It renders as a muted 保存済 · x分前 chip on published docs, a prominent
amber 保存して作成 button on drafts, a 保存中… chip while saving, and a
保存失敗・再試行 button on error. Never pair a status chip with a
separate Save icon button — users read that as "two different things to
do" and stall. ⌘S / Ctrl+S is always live as a keyboard shortcut.gap-1.5 inside a cluster, gap-3 between clusters, with
w-px h-5 bg-border/60 dividers between semantically distinct clusters
(status · actions · primary · overflow).操作 + ⋯ More stay in identical positions across
both modes; EDIT swaps 編集 for 完了 in place. Ctrl+Z / Ctrl+Y /
Ctrl+S shortcuts are always live.⋯ More popover — never into a second
row. In EDIT mode: ガイド, 添付, 印刷, JSON, Clear Data all
live inside ⋯ More so the ribbon doesn't bounce widths between modes.ホーム crumb; the shell's
global home icon is the one and only home affordance./view/* pages
the shell emits [category ▾, type ▾, filename]. Category and type
crumbs route through DocumentTypeBreadcrumbDropdown — category lists
every doc in the same group (税務 / 会計 / …); type lists every sibling
type in the same category and highlights the active one. The filename
dropdown stays as the per-instance switcher. Never render the document
category or type as plain non-clickable text.?, global refresh) sit after the PageActionsPortal target in
AppHeaderNavigation, so page-specific actions read first. Tour button is
always icon-only (32 px) — never a labeled primary-looking action.PopoverContent in row 2 use align="end" sideOffset={8} collisionPadding={16} avoidCollisions plus
max-w-[calc(100vw-2rem)]. Non-Radix custom dropdowns on right-side
triggers use absolute top-full right-0 + the same max-w clamp.items-center gap-2.5 px-3 py-1.5, a 14 px icon (no boxed background), and a single
text-body-role font-medium label. No two-line name + description stacks —
demote descriptions to the tooltip or drop them entirely. Popover widths
max out at w-60 (240 px) for action menus and w-56 (224 px) for list
pickers; anything wider reads as a dialog masquerading as a menu.操作 popover must not contain
an 操作 header). The trigger provides the context; the popover body
goes straight to actions.ガイド vs ツアー — never the same word (Wave 6.4, revised 6.5, Apr 2026).
ガイド is reserved for document-level content guidance (the
per-document help surface that explains how to fill in fields and what
fields exist). ツアー is reserved for shell-level product
walkthroughs (the dispatchStartTourEvent system that highlights page
features). Product tours live only inside the avatar profile dropdown
(user-profile-indicator-component.tsx → ヘルプ・ガイド section), which
pulls the contextual tour label from TOUR_DISPLAY_NAMES. Row 2 of the
shell no longer carries a standalone ツアー button (Wave 6.5): one
affordance, one location. The doc-level guidance trigger inside the
⋮ More popover renders the labeled menu-row variant
(<GuidanceTriggerButton variant="menu-row" />) showing
この書類のガイド with the HelpCircle icon. Icon-only renders inside
text menus are considered a bug.doc-type:<id> breadcrumb (e.g. 損益計算書 (PL)) is hidden below
xl (1280 px) because the document toolbar's filename dropdown carries
the same context. Without this collapse, the breadcrumb chain collides
with the right-side action cluster on laptop-class viewports. The
category crumb (税務) always stays for upward navigation. Implement
via a sibling <div className="hidden xl:flex"> wrapper — avoid
xl:contents (Tailwind v4 Oxide scanner has an intermittent issue
with contents under responsive prefixes in this codebase).ホーム › 税務 › 損益計算書 (PL) › 📝 2026-01-09_書類 ▾), freeing
the right cluster for primary actions + status. Implementation: the
shell renders an empty <div data-breadcrumbs-tail-portal empty:hidden/>
after the breadcrumbs .map; pages portal page-scoped "what-am-I-editing"
choosers into it via <PageActionsPortal slot="breadcrumb-tail"/>. The
document viewer's portalled child prepends its own <ChevronRight/> so
the separator only appears when a filename is present. Rule: do not
render multiple "what-am-I-editing" choosers in one page — this slot is
single-tenant per page.税務 → 損益計算書 (PL) → filename → ⋮ More.
The canonical rung is:
p-2.5 / px-2.5 py-2.text-compact-mini font-medium uppercase tracking-wider
in text-muted-foreground. Never text-xs font-medium for a
section header — that label scale collides with menu item text.px-2.5 py-1.5, gap-2, icons h-3.5 w-3.5 or
size={12}, label text-xs or text-mini.h-8 px-2.5 text-xs. Leading search icons at
size={12} with left-2.5.text-compact-mini,
rounded px-1 py-0 badges. Never text-xs for meta — it competes
with the primary label.w-56; rich list with meta → w-72 → sm:w-[320px] → md:w-[340px]. Never w-[420px] for a menu — a menu
that wide is secretly a sidebar.
The file selector panel (ViewerFileSelector) was the regression that
prompted this rule — it had been rendering at w-[420px] with p-4
sections, text-sm doc names, h-9 search, and w-8 h-8 avatar
cells, visibly one scale larger than BreadcrumbCategoryDropdown,
DocumentTypeBreadcrumbDropdown, the ⋮ More menu, and the 操作
toolbox. BreadcrumbCategoryDropdown was simultaneously harmonized
(py-2 → py-1.5, h-4 → h-3.5, text-sm → text-body-role, text-xs → text-compact-mini header) to match its neighbour
DocumentTypeBreadcrumbDropdown in the same breadcrumb chain.
Implication: when building a new popover, start from this rung. Only
deviate with a documented reason (e.g. global-search palette is full-
screen and intentionally larger).SaveStatusChip is the rightmost anchor (Wave 6.6, Apr 2026). The
unified save/autosave chip sits at the far right of the action cluster
(after ⋮ More), not in the middle of primary actions. Rationale: it is
a passive status indicator, not a primary action, so it parks at the
edge where the eye lands last. In DRAFT / new-document state the same
surface becomes an amber 下書きを確定 commit button — same rightmost
anchor so users always know where to look for save feedback regardless
of state. Do not intersperse it between primary actions.SaveStatusChip draft label = 下書きを確定 (Wave 6.5, Apr 2026).
The earlier 保存して作成 was misleading: the draft is already
autosaved every keystroke, so "save and create" double-counts the save
step. The button performs a confirmCreate mutation that finalises
the draft. Accurate copy: 下書きを確定. Tooltip must state that edits
are already autosaved and this action finalises the draft.h-8 / text-xs scale (Wave 6.5, Apr 2026).
The document filename dropdown (ViewerFileSelector) previously used
text-sm px-3 py-2 and rendered visibly taller than the neighbouring
h-8 action buttons. It now uses h-8 px-2.5 text-xs gap-1.5, status
icons at size={12} and chevron at size={13}, matching 編集 /
操作 / ⋮. Max trigger width is 180–240px.操作 toolbox) may stay neutrally branded so the
primary row is calm. But AI-backed rows inside those triggers MUST be
honestly labeled:
AI (書類をAIで作成, 整合性をAIチェック).signal-in [AI] micro-badge sits next to the label.signal-in left-accent bar groups all AI-backed rows in the
menu, visually separating them from non-AI utilities.空の書類をAIが下書き).
Never use Sparkles + violet gradient surfaces. Never hide AI identity.
The 編集 primary button stays AI-free — it is the human-hands path,
visually distinct from the AI-backed 操作 menu rows.signal-warn consistently, one cue per surface
(Wave 6.4 → 6.6e, Apr 2026). The whole edit surface uses one amber tint,
and each surface has a distinct non-overlapping job:
<main> gets a bright 3px inset ring —
shadow-[inset_0_0_0_3px_var(--color-warning)], transition-shadow
duration-normal. This is the primary "you are editing this canvas" cue and
wraps the whole editing surface on all four sides. (Wave 6.6e: replaced
the easy-to-miss 2px left-only rail.)<main>: 編集中 — 書類をクリックして直接 編集。Esc または右上の「完了」で終了。 Pure status + keyboard-out hint; no
action button inside the banner (Wave 6.6e: removed the duplicate 完了
pill). Survives the PageActionsPortal collapse where the toolbar's own
amber background is lost.完了 button. It is the
primary mode-exit CTA and sits in the same location as 編集 in VIEW
mode for positional muscle-memory.Esc is a global shortcut to exit edit mode.編集中 status Badge, or
any other amber-decorated status chip to the row once the viewport ring
and banner are in place — three amber cues in one row is noise, not
signal. One cue per surface, each with a distinct purpose.Reference implementation:
document-viewer-header.tsx.
<EmptyState/> (or one of the
presets: NoDataEmptyState, ErrorEmptyState, LoadingEmptyState,
SearchEmptyState). Covers 95% of cases.<InlineNoData/>. No
custom "データなし" micro-rows.EmptyTransactionsState — bank-statement onboarding with ghost chart.DocumentListEmptyState — document list with context-aware CTA.UploadEmptyState — drag-and-drop zone with session restoration.Any new empty-state surface must wrap EmptyState unless explicitly
added to the approved-bespoke list above.
<WorkflowCanvas/>. Feature passes
nodeKinds[] + optional edgeKinds[]; canvas owns store, undo/redo,
DnD, autosave, reduced-motion fallback. Reference surface:
AccountingPipelineVisualizer (live, read-only mode, stages wired to
useDockSummaryQuery + useFinancialSummaryQuery).UniversalTerminalFloatingDock
primitive directly. The ConfigurableDock wrapper was retired in
Wave 4.4 — no consumers ever materialised and the pattern had not
repeated 3+ times (see user rule "no premature abstraction"). If a
third bespoke dock lands, revisit.Retired in Wave 4.4 — do not reintroduce (honest-code cleanup):
BankRulesCanvas (src/features/bank-statement/components/bank-rules-canvas/*) —
deleted. Rationale: flat ReconciliationRule REST shape (single
{pattern, matchType, action, accountCode} record) doesn't map to a
graph paradigm without lossy adapter. Rules CRUD hooks
(useReconciliationRulesQuery, useCreateRuleMutation, etc. in
src/features/bank-reconciliation/queries.ts) already exist and
should drive a flat form-based editor when a product surface needs one.ConfigurableDock (src/components/ui/configurable-dock.tsx) —
deleted. Zero consumers; violated premature-abstraction rule.Every renderer that outputs an A4-sized paper surface (financial
statements, tax forms, expense claims, invoices) must use the
<DocumentCanvas/> primitive at the viewer level and the <A4Paper/>
primitive at the renderer level. Renderers must NOT wrap themselves
in a second DocumentCanvas — the viewer owns the viewport / zoom
/ chrome; the renderer owns the paper body.
| Surface category | Adoption status | Owner module |
|---|---|---|
| Financial statements (React) | ✅ A4Paper via viewer |
features/document/components/react-renderers/* |
tax-balance-sheet-react-renderer |
✅ A4Paper |
same |
tax-profit-loss-statement-* |
✅ A4Paper |
same |
tax-cash-flow-statement-* |
✅ A4Paper |
same |
tax-trial-balance-* |
✅ A4Paper (Wave 8.3.a cleaned rogue hex + inline styles) |
same |
| Tax-detail renderers | ✅ A4Paper via viewer |
features/document/components/react-renderers/tax-* |
tax-gaikyo-detail-* |
✅ A4Paper |
same |
tax-beppyo-* |
✅ A4Paper |
same |
| Transactional renderers | ✅ A4Paper via viewer |
features/document/components/react-renderers/* |
sales-invoice-react-renderer |
✅ A4Paper |
same |
receipt-react-renderer |
✅ A4Paper |
same |
expense-claims-react-renderer |
✅ A4Paper (Wave 8.2.a rewrite) |
features/expenses/components/claims.tsx |
| Viewer shell | ⏳ Migration pending | features/document/components/document-viewer.tsx |
Pending migration (Wave 8.3.d, deferred): document-viewer.tsx
(2556 LOC) still composes its own viewport + zoom + header shell. The
split to <DocumentCanvas.Header> / <DocumentCanvas.Viewport> /
<DocumentCanvas.EditModeBanner> is a dedicated PR-B / PR-C / PR-D
sequence — tracked in §10 Wave 7 Deferred.
Print-path rule: DocumentCanvas.Viewport carries print:hidden
on its ZoomControls so the only thing that renders in print output
is the <A4Paper/> body. Renderers must not add their own @page
rules — the canonical @page definition lives in
src/styles/zen-print.css (Wave 8.2.d consolidation).
Two canonical primitives own the "overlay" slot; feature code must
route through one of them and never import @radix-ui/react-dialog
directly (ESLint local/no-raw-radix-dialog enforces).
| Shape | Primitive | When |
|---|---|---|
| Centered modal | Dialog (@/components/ui/dialog) |
Short confirmations, single-step forms |
| Centered, terminal UI | TerminalDialog(@/components/ui/terminal-dialog) |
Multi-step forms, OCR/AI pipelines, attach flows |
| Right side-sheet | Sheet (@/components/ui/sheet) |
Reference data (証跡 / provenance), filters, multi-select lists |
Sheet and Dialog share the same z-stack: scrim on z-overlay (40)
under content on z-modal (50), plus the same fade-only overlay;
only the content-entrance animation differs (translateX vs translate +
scale). The showClose chrome, focus trap, Esc handling, and
role="dialog" attribute are identical.
Deprecated — do not reintroduce:
app-page-layout-component.tsx — consolidated into app-page-layout.tsx.unified-zen-modal-wrapper.tsx — use terminal-dialog.tsx.src/components/ui/index.ts (barrel) — deleted; import direct.The global shell row 2 (src/components/ui/app-header-navigation-component.tsx)
and the document-viewer breadcrumb (src/components/ui/breadcrumb.tsx) both
consume the same primitives so the design language is unified:
| Primitive | Source | Purpose |
|---|---|---|
<BreadcrumbCrumb> |
src/components/ui/breadcrumb-primitives.tsx |
Renders a single crumb. Three modes: text-only link (intermediate href), brand-tinted nameplate with aria-current="page" (isCurrent), or plain span (label-only). |
<BreadcrumbSeparator> |
src/components/ui/breadcrumb-primitives.tsx |
ChevronRight on ≥sm, calmer typographic slash on <sm. Replaces inline ChevronRight and › glyphs. |
<BreadcrumbOverflow> |
src/components/ui/breadcrumb-primitives.tsx |
Stripe-style "…" disclosure on <sm only. 44 px hit area; opens a MobileBottomSheet listing the full crumb path. Hidden on ≥sm (the inline chain still shows). Use whenever ≥ 2 intermediate crumbs would be hidden on mobile. |
Rules:
min-h-11 min-w-11 -my-1); desktop reverts to the 28 px sm:size-7
chrome. The shell row 2 outer container is min-h-11 sm:h-9.isCurrent carries aria-current="page", the
brand-tinted nameplate (bg-card border-brand), and a 150 ms
enter-from-right micro-animation on mount (gated by
useReducedMotion). The desktop nameplate keeps its inset shadow;
the mobile nameplate drops it because the row recess already supplies
depth.<BreadcrumbOverflow> immediately after Home so the visible chain
reads Home / … / CurrentPage on phones. The "…" sheet's rows are
min-h-11 tappable links.ChevronRight to draw a
separator — use <BreadcrumbSeparator> so the slash-on-mobile swap
is consistent everywhere.whileHover / whileTap are gated to
≥sm so the chrome doesn't jitter under a thumb on touch devices.Concept JSX (matches the implementation in
app-header-navigation-component.tsx and breadcrumb.tsx):
<nav
aria-label="パンくずリスト"
className="flex min-h-11 sm:h-9 items-center gap-1 sm:gap-2 text-sm"
>
<Link
to="/"
aria-label="ホーム"
className="grid place-items-center min-h-11 min-w-11 -my-1 sm:min-h-0 sm:min-w-0 sm:size-7 rounded-sm hover:text-brand transition-colors"
>
<Home className="h-4 w-4 sm:h-3.5 sm:w-3.5" />
</Link>
{hasOverflow && (
<span className="sm:hidden inline-flex items-center gap-1">
<BreadcrumbSeparator />
<BreadcrumbOverflow crumbs={hiddenCrumbs} />
</span>
)}
<BreadcrumbSeparator />
<BreadcrumbCrumb crumb={parent} />
<BreadcrumbSeparator />
<BreadcrumbCrumb crumb={current} isCurrent animateOnMount />
</nav>
| 🚫 Pattern | ✅ Replacement | Severity |
|---|---|---|
| ad-hoc 11px text | text-compact-small |
error |
| ad-hoc 14px text | text-sm |
error |
font-bold |
font-semibold / font-medium |
error |
rounded-xl, rounded-2xl |
rounded-lg |
error |
z-[9999] |
z-modal / z-toast |
error |
text-slate-600 / bg-emerald-50 / border-amber-200 / text-rose-500 / any {prefix}-{palette}-{N} |
Semantic tokens: text-fg · text-fg-muted · bg-surface · bg-surface-raised · border-border · text-signal-in · bg-signal-warn · border-signal-out · text-signal-info · <Button variant="primary"> |
warn¹ |
#059669 hex in className |
text-success / --color-success |
error |
value.toLocaleString('ja-JP') |
formatJPYFull(value) |
error |
raw toast from sonner |
businessToast from @/components/ui/sonner |
error |
import { X } from '@/components/ui' |
import { X } from '@/components/ui/x' |
error |
ZEN_DESIGN.* from @/lib/zen-design-constants |
Tailwind utility backed by a CSS token | error |
bg-signal-in text-signal-in (same-token bg + fg, any signal family) |
bg-signal-in text-signal-in-strong (or any neutral bg with raw signal text) — see §4.2 |
warn² |
¹ Raw-palette ban stays at warn permanently.
² Same-token bg + fg ban (Wave 9.3, Apr 2026). bg-signal-* is a 12 %
hue tint and text-signal-* is the raw signal hue, so pairing them on the
same element collapses contrast to ~1.5 : 1 — failing WCAG AA. The
user-visible symptom was washed-out green pills in the claims renderer
header and the reimbursement dock cards. Use the matching *-strong text
variant when you need a tinted background; see §4.2 for the full contract. The residual is
drained passively: engineers clean up rogue classes when they touch a
file, and npm run lint:palette-budget (wired into CI) enforces that the
total can only go DOWN. The rule is local/no-raw-tailwind-palette in
eslint-rules/no-raw-tailwind-palette.cjs and covers every color-bearing
utility prefix (text, bg, border, ring, outline, divide,
from, to, via, fill, stroke, placeholder, decoration,
accent, caret, shadow) against every Tailwind palette family.
Exempt files (legitimate raw-palette owners): components/ui/button.tsx,
components/ui/button-showcase.tsx, pages/design-system-showcase/**,
pages/dev-reference-*, components/dev-reference-*,
lib/zen-design-constants.ts (deprecated), lib/seasonal-theme-system.ts
(explicit seasonal palette), and token declaration sites.
When your editor (or CI) flags a raw palette class, walk the list below. Almost every case is covered by step 1 or 2.
node scripts/codemod-semantic-colors.mjs
Commit the diff, re-run npm run lint:palette-budget, commit the new
.palette-budget.json if the count dropped.
Brand chrome → primitives. Raw emerald-500/600 or red-500/600
as button / CTA fill or text is always a missing primitive:
<Button variant="primary"><Button variant="destructive"><Button> → bg-brand,
text-brand, border-brand-strong (defined in src/index.css)Charts → chart tokens. Raw palette classes on Recharts elements
(fill-blue-500, stroke-emerald-600, etc.) should become entries
from CHART_COLORS in src/lib/chart-color-tokens.ts:
import { CHART_COLORS } from "@/lib/chart-color-tokens"
<Bar fill={CHART_COLORS.series[0]} />
<Line stroke={CHART_COLORS.positive} />
Gradient utilities → solid fill. The system is monochrome by
default; gradient bg classes (bg-gradient-to-r, from-*, via-*,
to-*) should usually collapse to a single semantic fill
(bg-signal-warn, bg-brand-soft, bg-surface-raised). If the
gradient is genuinely decorative (celebration, moon-phase) and there
is no sensible token, see step 5.
Intentional decorative accent. Celebrations, seasonal themes, gamification badges, and character moods sometimes need raw palette shades that do not map to any semantic token. Silence the warning inline with a one-line justification:
// eslint-disable-next-line local/no-raw-tailwind-palette -- celebration confetti accent, not part of design system
<span className="bg-fuchsia-400/30" />
Keep these rare — every eslint-disable is reviewed in PR.
npm run lint:palette-budget locally. If
the count dropped, regenerate and commit the baseline: node scripts/count-palette-warnings.mjs --write
git add .palette-budget.json
The CI ratchet will fail any PR that INCREASES the count, so adding a new raw palette class without a corresponding cleanup elsewhere will block the build.
bg-signal-* is defined as color-mix(in srgb, var(--color-*) 12%, transparent)
— a soft, 12 %-tinted background that lets pages stay calm. text-signal-*
is the raw signal hue at full saturation. Pairing them on the same element
(bg-signal-in text-signal-in, bg-signal-out text-signal-out, etc.)
collapses contrast to ~1.5 : 1 and fails WCAG AA. This was the root cause
of the "low-contrast green-on-green pill" bug surfaced in the claims renderer
header and the reimbursement dock card rows.
For tinted-background callsites, pair with the matching *-strong text
variant (deepens the hue ~88 % toward --color-fg, which clears AA on the
12 % tint without becoming generic black):
// ❌ FORBIDDEN — same-token bg + fg, fails WCAG AA
<Badge className="bg-signal-in text-signal-in">{count}件</Badge>
// ✅ ALLOWED — tinted bg + deepened text
<Badge className="bg-signal-in text-signal-in-strong">{count}件</Badge>
// ✅ ALLOWED — heavier tint + deepened text
<Badge className="bg-signal-in/30 text-signal-in-strong border-signal-in">
{count}件
</Badge>
// ✅ ALLOWED — neutral bg + raw signal text (the canonical Badge variants)
<Badge variant="success">{label}</Badge> // glass.medium + text-signal-in
The text-signal-*-strong utilities live in src/index.css (look for the
"Wave 9.3" comment in the @utility block). The contract is also pinned at
the top of src/components/ui/badge.tsx
so anyone composing a one-off badge sees the rule before they reach for
bg-signal-in text-signal-in.
The four canonical Badge variants (success / warning / destructive /
info) already pair glass.backgrounds.medium (a neutral surface) with the
raw text-signal-* token — so they pass AA without *-strong overrides.
Reach for *-strong only when you must tint the background with a signal
color.
The app runs in a shareable demo mode where all writes are rejected by the
backend (DEMO_READ_ONLY). Three rules:
_silentDemoReadOnly: true on their axios
config. Autosaves and prefetches must not open upgrade dialogs.isReadOnlyDemoSession() from
@/stores/demo-mode-store. Rule 1 stays as the safety net for every other
caller. The predicate is a prefix match on the access_token cookie
(demo-access-token-, via isDemoAccessToken), not isDemoMode:
password login of the demo account is client-demo but server-writable
(docs/demo-mode.md, doors A vs B/C), and skipping there would silently
stop recording in local dev and Playwright.See src/features/kpi-dashboard/components/sparkline-chart.tsx,
src/services/api-client-service.ts (_silentDemoReadOnly) and
src/api/user-activity.ts (both rules together) as reference.
formatJPYFull / formatJPYCompact from
@/utils/japanese-currency-formatter. Never toLocaleString('ja-JP').Noto Sans JP — fallback to system-ui. Do not override.yyyy-MM-dd for inputs, yyyy年M月d日 for display.local/no-english-in-jsx rule
catches English literals in JSX.aria-label.<label htmlFor> pairing, aria-describedby for hints and errors,
aria-invalid on invalid fields. DateField and FormField handle this
automatically — prefer them over hand-rolled <input>.#main-content, #primary-nav, #global-search)
are set up in App.tsx. Pages must use <main> wrapping.MotionConfig reducedMotion="user" is global. Respect it
in custom animations.--color-fg-muted (not --color-fg-subtle) for non-decorative text.Adding a new primitive?
src/components/ui/.)FormField + Input, etc.).Adding a new token?
@theme in src/index.css. Tailwind generates the utility
automatically.ZEN_DESIGN.* token, mark the JS field @deprecated
and plan its removal.Consolidated from the live audit + self-review. Every entry below points to the PR that closed it so regressions can be traced back.
| # | Finding | Resolution | Wave |
|---|---|---|---|
| 1 | Multiple toast surfaces drifted out of sync | sonner → businessToast codemod |
1.2 |
| 2 | Raw greys / white used across 600+ files | semantic-token codemod (11,412 swaps) | 1.4 |
| 3 | Pages inconsistent density (some cramped) | density prop on AppPageLayout |
2.1 |
| 4 | Japanese text too tight, headings too heavy | typography baseline (body + h1-h6) | 2.3 |
| 5 | Sign-colors scattered (emerald-500 etc.) | text/bg/border-signal-* utilities |
2.4 |
| 6 | Bouncy hover on buttons felt "AI generated" | AnimatedButton tightened (0.97 tap) |
2.5 |
| 7 | Empty-state chaos (bespoke per feature) | canonical EmptyState + allow-list |
2.6 |
| 8 | Mobile horizontal scroll on data pages | overflow-x: clip + min-w-0-safe |
2.7 |
| 9 | Static, non-interactive workflow diagrams | <WorkflowCanvas/> primitive + AccountingPipelineVisualizer live surface |
3.* |
| 10 | Pipeline nodes showed mock / mislabelled data | Rewired to useDockSummaryQuery + useFinancialSummaryQuery; detail card shows real {transactionCount, journalizedCount, isBalanced, netIncome} |
4.4 |
| 11 | Dead scaffolds: BankRulesCanvas, ConfigurableDock |
Deleted — see §3.2 retirement notes | 4.4 |
ESLint now warns (not errors) on files > 800 LOC via max-lines. The
current offender list, sorted by size, is the triage queue. Each split
should follow the same shape:
feature-x.tsx (~200 LOC shell — layout + state composition)
├── feature-x-hooks.ts (useFeatureX* hooks — data & derived state)
├── components/feature-x-header.tsx (header + actions bar)
├── components/feature-x-toolbar.tsx
├── components/feature-x-list.tsx
├── components/feature-x-detail.tsx
└── components/feature-x-empty.tsx (wraps canonical <EmptyState/>)
| File | LOC | Priority |
|---|---|---|
features/journaling-assistant/.../JournalingAssistantModal |
3128 | P0 |
features/document/ai-assistant/.../ai-create-modal |
3108 | P0 |
features/reimbursment-dock/components/dock |
2670 | P0 |
features/document/components/document-viewer-header |
2499 | P1 |
features/fixed-assets/components/fixed-assets |
2305 | P1 |
components/ui/fiscal-year-progress-bar |
2274 | P1 |
components/ui/transaction-processing-panel/.../DocumentLinkingStep |
2258 | P1 |
features/document/components/document-viewer |
2254 | P2 |
features/settings/company-info/.../company-tab |
2228 | P2 |
components/dev-reference-test-results-content |
2133 | P2 |
pages/financial-reports-page |
2116 | P2 |
components/ui/global-search-command-palette |
2095 | P2 |
Split contract:
useEffect for mount-only sync.*-hooks.ts (one hook per data cluster).components/, never deeper than one
folder (avoid components/x/y/z).Stepper (one lighthouse done in StepIndicator).<Input type="date"> callsites to DateField.establishment-workflow-diagram to <WorkflowCanvas/> once
the primitive grows sticky-group + collapsible-column support
(deferred from Wave 3.4a).futuristic-workflow-node.tsx after the migration above.*-dock* variants via composition on UniversalTerminalDock.ZEN_DESIGN JS constants file entirely once remaining consumers
move to CSS tokens.npm run lint before push.Production-readiness sweep focused on "firm, bold, simple, non-AI-generated" with condensed Japanese-style density and guided workflows.
@theme (600-tier) and :root
(500-tier) in src/index.css. Signal colors now all resolve to the
600-tier consistently across both blocks.bg-signal-{info|warning|danger|success}-strong utility classes
for high-contrast status chips (previously ad-hoc /60 mixes).ring-signal-{info|warning|danger|success} utilities for
selection/focus halos that respect the semantic palette.:focus-visible ring → var(--color-success) so focus
state is consistent across every interactive element.src/styles/zen-animations.css:
retired char-glitch, wiggle, bounce-x, zen-float, button-glow,
subtle-jiggle, pulse-scale, zen-focus-ring, zen-focus-glow,
zen-pulse-slow-shadow. Remaining animations use semantic surface
tokens so skeletons/shimmers adapt to theme.zen-focus-glow from Input/Textarea — global focus ring
is the single source of truth.container-highlight-overlay, login-page,
zen-design-constants.ambient onto animate-zen-pulse.interactive hover: removed scale (1.01) and halved lift
(y: -2 → y: -1). Faster, stiffer spring (420/32). No more
"bouncy AI card" feel — just a firm settle.font-medium tracking-wide →
font-medium tracking-tight for firmer, more compact CJK readability.scale 0.985 → 0.99 (tighter confirm cue).New capabilities added to src/components/ui/workflow-canvas/:
| Capability | Implementation |
|---|---|
| NodeToolbar on selected node | 複製 / 固定 / 削除 actions via reactflow NodeToolbar (default-node.tsx) |
| Lock canvas | locked state in use-workflow-store.ts, toolbar toggle (固定/自由), auto-locks in live / read-only mode |
| Auto-layout | Kahn's-algorithm topological sort in utils/auto-layout.ts (zero external deps, no dagre) |
| Connection validation | createConnectionValidator (utils/connection-validation.ts) — rejects self-loops, duplicates, cycles, locked targets, port type mismatches. Ships CONNECTION_REJECTION_LABEL_JA for toast copy. |
| Animated edges on execution | Edge turns animated when source node status is running, uses signal-info-toned stroke with pulse |
| Edge hover-delete | Wider invisible hit path + EdgeLabelRenderer X button at midpoint, respects lock state |
| Handle tooltips | title on every source/target Handle |
| Status-aware node rings | ring-signal-{info|danger|out} on selected/error nodes |
| Context exposure | WorkflowStoreContext — nodes/edges read store without prop drilling |
Public API surface additions (via workflow-canvas/index.ts):
export {
computeAutoLayout,
createConnectionValidator,
validateConnectionVerbose,
CONNECTION_REJECTION_LABEL_JA,
}
Verified against accounting-pipeline-visualizer.tsx (KPI dashboard) in
live mode: no toolbar, locked, animated edges on running stages — all
expected.
FE foundations for the "accounting-specialized" pipeline visualizer called out in uiux-elevation-wave-6:
| Capability | Implementation |
|---|---|
| Per-node timing chip | WorkflowNodeData.timing = { startedAt, finishedAt, durationMs }; footer renders 実行 23.4s with a live tick while status === "running" |
| Drill-through deep links | WorkflowNodeData.deepLinkHref + deepLinkLabel; node footer renders a 詳細 ↗ button. <WorkflowCanvas onNodeDrillThrough> intercepts, else falls back to window.location.assign |
| Right-click context menu hook | <WorkflowCanvas onNodeContextMenu> — consumers wire their own Radix menu; canvas suppresses the native browser menu only when registered |
Keyboard: Cmd/Ctrl+D duplicate, Esc deselect, Enter drill-through |
Wired in workflow-canvas.tsx alongside existing undo/redo/delete |
mode="preview" |
Replaces the previously-typed-but-unimplemented "guided" mode. Click-through, read-only, selection + context-menu + drill-through still work |
AccountingPipelineVisualizer adopts the drill-through chevron — clicking 開く on a pipeline node jumps straight to its stage page without requiring the overlay CTA |
|
| README drift fix | Autosave debounce documented correctly as 800ms (was 1.2s). New onNodeDrillThrough / onNodeContextMenu / keyboard-shortcut docs |
Deferred backend / high-risk follow-ups (owning bucket: uiux-elevation-wave-6):
/api/accounting/pipeline-stream) — needs backend endpoint.ap-monorepo/apps/mcp/workflow-v2.EstablishmentWorkflowDiagram migration onto <WorkflowCanvas/> — blocked on primitive growing sticky-group + collapsible-column support; deleting futuristic-workflow-node.tsx blocked on that migration.<DocumentCanvas/> shellNew primitive at src/components/ui/document-canvas/ owning the
viewport + zoom + paper chrome shared across financial statements,
beppyo, gaikyo and 地方税 forms.
| Export | Role |
|---|---|
DocumentCanvas |
Root provider, composes .Header + .Viewport |
DocumentCanvas.Header |
Slot-based chrome: title / company / period / titleBadge / actions / meta |
DocumentCanvas.Viewport |
Scroll + transform: scale(zoom) container; respects prefers-reduced-motion |
A4Paper |
Aspect-ratio-locked page (210/297 portrait, 297/210 landscape), clamps to --doc-max-width / --doc-landscape-width |
useDocumentCanvasViewport |
Hook for consumer-authored toolbars (hideControls + custom chrome) |
Built-in ZoomControls render 縮小 / 拡大 / 幅に合わせる / 100% as a
role="toolbar" pinned bottom-right, print:hidden so printouts contain
only the <A4Paper/> body.
Deferred follow-ups (owning bucket: uiux-elevation-wave-6):
DocumentCanvas.PrintController slot unifying window.print + /api/tax-filing/print-bundle.DocumentCanvas.ThumbnailStrip + DocumentCanvas.Spread for multi-page docs.services/financial-export-service.ts (html2canvas + jsPDF) — needs product sign-off because it's the default download path for several reports.src/features/document/components/react-renderers/ onto <DocumentCanvas/>.PrintOutputHub.tsx split (1163 LOC — extract CoverPreview, FormCheckList, SubmissionOfficeForm, GenerateActions).LOC audit (April 2026): top 6 offenders sit at 2095–3128 LOC (not the
3532–5121 range cited in the Wave-6 plan — plan drew from a stale
snapshot). §9.1 backlog table remains the tracking queue; no new splits
shipped in this pass because the extraction contract (≥40% parent
reduction + one effect hoisted) requires integration-test coverage that
is outside this wave's scope. Any candidate split should land as its own
PR using the *-hooks.ts + components/*.tsx + shell ≤300 LOC template
in §9.1.
establishment-workflow-diagram → WorkflowCanvas (needs
sticky-group + collapsible-column support first).futuristic-workflow-node.tsx after above migration.useToast).April 2026 — a nine-sub-wave pass that cashed in the ambient Pipeline
Rail paradigm, wired DocumentCanvas for adoption, fixed the
LinearPipelineCanvas node-type bug, and ratcheted the palette budget
down after eliminating 700+ dead dark:* classes and 1.6k raw
violet/purple-* classes in favor of semantic ai-* tokens.
Shipped primitives
PipelineRail (src/components/ui/pipeline-rail/) — 40 px ambient
strip with three states (ambient / expanded / pinned). Replaces the
"floating dock that overlaps the data table" form factor. 6-stage cap
enforces Miller's Law; g p toggles pin, Esc collapses expanded;
pin state persists via Zustand persist. See
@_pipeline-rail-README.md for the full rollout map.DocumentCanvas.PrintController — single print surface for
React-rendered docs and backend-stamped tax bundles.DocumentCanvas.EditModeBanner — amber bg-signal-warn/10 banner
slot that supersedes the bespoke banner in document-viewer.tsx.StatementRow / StatementSubtotalRow / StatementTotalRow /
StatementCompositionBar (in financial-statement-primitives.tsx) —
Bloomberg-terminal honest-density line-item rows with optional inline
composition bar. Replaces ad-hoc text-mini rows.EdgeAnimationProvider + useEdgeAnimation in workflow-canvas/utils
— execution-aware edge tinting without prop drilling. Ported from the
ap-monorepo MCP pattern.ShortcutPalette (src/components/ui/shortcut-palette.tsx) — global
? cheat sheet, fed by src/lib/keyboard-shortcut-registry.ts.
Mounted once in App.tsx. Also hosts useGlobalNavigationShortcuts()
which wires the advertised leader-key routes (g h → /, g j →
/tax-journal, g b → /tax-balance-sheet, g l →
/tax-profit-loss-statement, g t → /tax-trial-balance) with a
1.2s leader timeout and input-focus guard, so the palette's promises
match runtime behavior.Bug fixes
LinearPipelineCanvas emitted type: "linear-stage" on nodes which
was never registered in buildNodeTypes(), silently falling back to
the base React Flow node. Fixed to emit type: "workflow" and the
edges now use type: "workflow" so DefaultWorkflowEdge renders.accounting-pipeline-visualizer in live mode showed the
read-only toolbar (元に戻す / やり直す / 全体表示) — now passes
showControls={false}.var(--doc-*) — every literal now has a token, and the store
emits matching --doc-paper-bg, --doc-muted-color,
--doc-strong-color, --doc-highlight-bg, --doc-editable-* vars.NodeToolbar gained the missing
開く drill-through affordance when onDrillThrough / deepLinkHref
is provided.ESLint enforcement
local/no-literal-z-index — bans z-40 / z-50 / z-[999] and raw
numeric zIndex; forces the full 15-token semantic ladder (see §2.4).
Escape hatch: zIndex: "var(--z-toast)".local/no-motion-zoo — bans hover:scale-*, hover:-translate-y-*,
and animate-bounce. Prefer firm hover tiers: T1 .interactive-surface
(default), T2 @utility hover-lift in src/index.css (content cards),
T3 solid CTA + zen-btn-fill. Keeps Wave 5.2 / 7.1 + Jul 2026 hover
retune permanent.New CSS tokens (in src/index.css)
--color-editor-bg, --color-editor-panel, --color-editor-gutter,
--color-editor-line, --color-editor-text, --color-editor-border
— dark code-editor palette. Replaces raw #21252b / #282c34
literals in JSON editor, JSON edit modal, and document-viewer JSON
pane.--color-paper-subtle — subtle paper tint for zebra rows and inkan
boxes. Replaces bg-[#f7f8fa] / bg-[#fafafa] literals.--text-statement (13px) — dense financial report typography.
Replaces text-mini across P/L, B/S, CF, journal, financial
statement primitives.--color-ai, --color-ai-soft, --color-ai-muted,
--color-ai-border, --color-ai-strong — AI feature palette.
Replaces 1.6k+ violet-* / purple-* literals migrated by
scripts/codemod-violet-to-ai.mjs.Palette budget
Dropped from the pre-Wave-7 baseline through three codemod passes:
scripts/codemod-semantic-colors.mjs — top-offender file pass.scripts/codemod-strip-dead-dark.mjs — 703 dead dark:* removals.scripts/codemod-violet-to-ai.mjs — 1655 violet/purple → ai migrations.Current baseline: 2417 matches across 448 files (ratcheted via
scripts/count-palette-warnings.mjs --write).
document-viewer.tsx (2556 LOC) consumer migration to
DocumentCanvas.Header / .Viewport / .EditModeBanner. Primitive
is ready; the migration itself is PR-B / PR-C / PR-D per that file's
own split plan.PipelineRail rollout across 7 pages (accounting-workflow-dock,
financial-statements-floating-dock, reimbursement-link-dock,
withholding-tax-dock, sales-workflow-dock). Each replacement
needs visual verification against the live page.? keyboard overlay integration into the canvas./api/accounting/pipeline-stream).futuristic-workflow-node.tsx deletion (after establishment-workflow
migration to WorkflowCanvas).ai-create-modal (3131 LOC),
document-viewer-header (2480 LOC), reimbursment-dock/dock (~2500
LOC) — each a dedicated 1-day refactor PR.April 2026 — a six-sub-wave pass focused on (a) catching the last of
the "rogue modal" / "weird span text" regressions the user flagged
during the live audit, (b) making the @theme block carry motion +
density as first-class tokens, and (c) ratcheting the automation that
prevents new rogue patterns from landing.
Shipped tokens
--duration-motion-* + --ease-motion-* (§2.5) — firm settle
durations promoted to @theme. Convenience utilities:
.motion-tap / .motion-reveal in zen-interactions.css.--card-padding* / --cell-padding-* / --row-{dense,comfortable,roomy}
--leading-{dense,body,tight} (§2.6) — density is now a
container-scoped cascade via data-density="compact|cozy|ultra".Shipped primitives
Sheet (src/components/ui/sheet.tsx) — canonical side-anchored
drawer. Re-exports Radix Dialog under a Sheet namespace so feature
code no longer reaches into @radix-ui/react-dialog for drawer
shapes. First consumer: DataProvenanceSheet.Shipped cleanups
expense-item-attachment-modal.tsx — migrated onto
MacTerminalModalWrapper; killed the custom fixed inset-0
backdrop, arbitrary z-40/z-50 layers, amber gradient shimmer
(from-transparent via-amber-200/50), and raw <button type="button">
close X.claims.tsx expense-claim renderer — fully rewritten onto
A4Paper + semantic <dl>/<dt>/<dd> blocks. Removed all inline
fontFamily / fontSize / lineHeight / color /
fontWeight: "bold" attributes and all raw hex literals. The
"weird span text" the user called out on /claims/create came from
this renderer's inline typography overrides.tax-trial-balance-react-renderer.tsx — removed categoryColors
hex map; every category chrome now routes through the
account-category-{key} CSS class. Replaced inline
inline style color / fontSize / fontFamily / fontWeight blocks
with semantic Tailwind (font-mono, text-compact-*, tabular-nums,
font-semibold). Balance-status chips now use
border-l-[3px] border-signal-{in|out}.data-provenance-sheet.tsx — migrated off direct
@radix-ui/react-dialog onto the new Sheet primitive._print.css (untracked) — deleted; every rule folded into
src/styles/zen-print.css. Single canonical @page source.learn-more-modal-component.tsx + document-attachment-selection-modal.tsx —
retired their ZEN_DESIGN.* JS imports (last two migration targets
from the Wave 4.3 retirement queue scoped to this PR).Shipped codemods (scripts/codemod-*.mjs)
| Codemod | Swaps |
|---|---|
codemod-z-index-tokens.mjs |
253 literal z-[NNN] → z-dropdown/popover/modal/toast/... |
codemod-warn-arbitrary-text-px.mjs |
31 text-[Npx] → semantic text-compact-* / text-statement |
codemod-clamp-rounded.mjs |
17 rounded-xl/2xl/3xl → rounded-lg |
codemod-animate-spin-to-pulse.mjs |
8 animate-spin → animate-pulse (PrintController exempted) |
codemod-semantic-colors.mjs (re-run) |
76 extra palette → token swaps |
Shipped ESLint rules
local/no-inline-style-colors — bans literal hex / rgb / hsl /
common named colours in JSX inline style color / background props.
Dynamic expressions + var(--color-*) remain allowed. Exempts
chart libraries + inkan-stamp surface.local/no-raw-radix-dialog — bans direct imports of
@radix-ui/react-dialog / react-tooltip / react-popover /
react-toast in feature code. Only components/ui/dialog.tsx,
sheet.tsx, tooltip.tsx, popover.tsx, terminal-dialog.tsx,
and sonner.tsx may own the raw Radix module.Deferred to future waves
document-viewer.tsx (2556 LOC) split onto DocumentCanvas.Header
/ .Viewport / .EditModeBanner — staying in Wave 7 deferred
bucket (still PR-B/C/D). Wave 8 confirmed renderers inside the
viewer already use A4Paper; the remaining work is shell-level.ZEN_DESIGN.* retirement (≈180 consumers) — tackled
file-by-file in Wave 9, deleting the file at the end.scripts/codemod-semantic-colors.mjs: 0 further swaps available).The Wave 5.2 / 7.1 / 8.1 spec, restated in one place so reviewers can point to a single anchor.
Under-100ms interactions (hover tints, focus rings) should not animate — the human visual system cannot resolve animation inside the Doherty threshold. Anything between 100 ms and 1 s uses one of the following three bands:
| Band | Duration | Token | Typical use |
|---|---|---|---|
| Tap | 100 ms | --duration-motion-tap |
Button press, hover fill, badge pop, tab switch |
| Reveal | 160 ms | --duration-motion-reveal |
Dropdown, popover, card lift, collapse |
| Drawer | 240 ms | --duration-motion-drawer |
Modal open, Sheet slide, side panel |
| Page (ceiling) | 400 ms | --duration-motion-page |
Full-page transitions — reserved |
| Curve | Value | Use |
|---|---|---|
| Firm settle | cubic-bezier(0.2, 0, 0, 1) |
Default — settle, no bounce |
| Out | cubic-bezier(0, 0, 0.2, 1) |
Pure entrances |
| In-out | cubic-bezier(0.4, 0, 0.2, 1) |
State swaps (tab change) |
Banned: ease-bounce, ease-elastic, spring configurations with
visible overshoot. On financial data, overshoot reads as "the number
isn't certain yet." Firm settle only. local/no-motion-zoo ESLint
rule flags hover:scale-*, hover:-translate-y-*, animate-bounce.
| Action | Max translation | Max scale delta |
|---|---|---|
| Card hover lift | y: -1px |
1.00 (no scale) |
| Card active tap | y: 0 |
0.99 |
| Button tap | y: 0 |
0.97 |
| Modal entrance | y: +4px |
0.98 |
| Sheet side slide | x: 100% |
1.00 |
Never exceed y: -2px on hover. Never scale above 1.01 on hover.
Per-row table animations are banned (Wave 8.2.a) — the combined cost
of even opacity: 0 → 1 on every row in a 500-row journal destroys
scroll performance on mid-range hardware.
Every primitive respects prefers-reduced-motion: reduce. In practice:
useReducedMotion() hook or rely on
the global * { animation-duration: 0.01ms !important; ... } fallback
in src/styles/zen-animations.css.@keyframes animations — gated by @media (prefers-reduced-motion: no-preference) where the animation is decorative (e.g. ambient
pulse). Functional animations (loading, progress) stay on.What: product hover uses three firm tiers — not stacked lift + elevation + shimmer on every pressable.
Why: dense accounting chrome felt loud when global T1 lift, card
hover-lift, button fill, and auto zen-shimmer-hover all fired
together. Craft stays inside the firm contract (no-motion-zoo);
EXPRESSIVE springs (簿記 / magnetic) are unchanged.
How — pick one tier:
| Tier | Class | Hover cue | Use on |
|---|---|---|---|
| T1 | .interactive-surface |
brand-mixed border + soft bg wash — no lift, no elevation | Default chrome, rows, outlined controls, dense hubs |
| T2 | hover-lift |
-1px + --elevation-hover (crafted layered shadow in src/index.css) |
Content cards only |
| T3 | solid CTA + zen-btn-fill |
colour deepen + fill sweep + --shadow-cta-hover |
primary / success / destructive buttons only |
Do not auto-stack zen-shimmer-hover on product buttons (opt-in
only). Agent SSOT: .cursor/rules/motion-system.md. Retrieval mirror:
docs/design-system/motion-system.md.
Convenience utilities
.motion-tap (in zen-interactions.css) —
transition-colors duration-motion-tap ease-motion-firm.motion-reveal —
transition duration-motion-reveal ease-motion-firmhover-lift — T2 only (@utility in src/index.css): -1px +
var(--elevation-hover). Prefer this over per-component
hover:scale-* / hover:-translate-y-* (banned by
local/no-motion-zoo). Legacy .zen-hover-lift aliases to the same
token.Use duration/easing utilities anywhere you'd otherwise reach for
duration-[160ms] or ease-[cubic-bezier(…)]. Arbitrary motion
values in className are flagged by local/no-motion-zoo and should
be rewritten to tokens + utilities.
Home dashboard tiles, alert rows, tax schedule quarters, and settings
list rows compose HOME_PRESSABLE_SURFACE_CLASS from
src/lib/page-surface-tokens.ts:
motion-reveal + soft wash + hover:border-brand (no
hover-lift; dense hubs stay flat)interactive-focus-ring — keyboard focus eases incursor-pointer + muted bg wash on hoverSETTINGS_LIST_ROW_PRESSABLE_CLASSDo not use bare transition-colors on these exports — it snaps
without a perceptible hover delta. Reserve T2 hover-lift for
content cards, not home rail density. ESLint:
local/require-pressable-affordance (warn). Baseline:
home-pressable-token-adoption, framer-hover-scale-violations.
The web app ships unchanged into a Capacitor 8 native shell. Every web build is a valid native build; there is no "mobile-only" code path.
capacitor.config.ts at the repo root holds the canonical config:
appId: "com.solobossxcloud.app", appName: "一人社長".webDir: "dist" — Capacitor copies the Vite output into native
projects on npx cap sync.SafeArea, StatusBar, Keyboard. Plugin runtime modules
live under @capacitor/* and are tree-shaken on the web build.iOS and Android scaffolds (ios/, android/) are committed so the
shells stay reproducible. Per-machine build artifacts (*/build,
*/Pods, local.properties, etc.) are gitignored — see .gitignore.
useViewport().isNativeRuntime-conditional UI MUST go through useViewport()
(src/hooks/use-viewport.ts). The hook delegates to
getPlatformInfo() from src/lib/platform-detection.ts, which is
the canonical entry-point for Capacitor / PWA / web detection. Examples:
getPlatformInfo().shell === "web").shell === "web" and display-mode: browser.Do NOT call Capacitor.isNativePlatform() directly from feature code.
The wrapper memoises the result for the session lifetime, exposes
shell: "native" | "pwa" | "web", and stays no-op safe in SSR + pre-
Capacitor builds.
shell === "native" && platform === "ios". iOS users expect the edge
swipe; an in-app back button reads as redundant.StatusBar.overlaysWebView: false in capacitor.config.ts) so the
page background never bleeds into the system clock area. Bottom
chrome consumes --sab (see §3.0.e).App.addListener("backButton")
in the shell mount. Sheets / dialogs MUST register a backButton
handler that closes the topmost surface before falling through to
router navigation.Keyboard.resize: "native" so the WebView
shrinks under the keyboard instead of letting 100vh overflow.
Combined with the existing 100dvh discipline this keeps every
modal action row visible while typing.Local development:
npm run build # builds the web bundle into dist/
npm run cap:sync # copies dist/ into ios/ + android/, runs plugin install
npm run cap:open:ios # opens Xcode (requires macOS)
npm run cap:open:android # opens Android Studio
CI verifies the scaffolds stay in sync via
.github/workflows/capacitor-sync.yml: every PR that touches src/,
capacitor.config.ts, package.json, or the native scaffolds runs
npm run build && npx cap sync and fails if git status ios android
is non-empty (indicating a contributor forgot to commit a sync). This
catches the "I added a plugin but forgot to run cap sync" footgun
before it lands on main.
server.url override is intentionally not wired in CI.読み込み中
豆知識