Sync: add prd and ui system notes

This commit is contained in:
2026-04-24 21:43:10 +08:00
parent 31d316b096
commit d54fdb2d26
6 changed files with 1326 additions and 1 deletions

View File

@@ -0,0 +1,987 @@
> **For AI assistants**: Read this entire file before writing any frontend code or template. All decisions here are final. When in doubt about styling, spacing, color, or component behavior — the answer is in this document. Do not invent values.
---
## Design Philosophy
**Core aesthetic**: Clean, functional, low-friction.
We build tools, not experiences. Every UI element must earn its place.
**Principles** (in priority order):
1. **Clarity over cleverness** — if you have to explain the UI, it failed
2. **Density over whitespace** — our users are power users; do not waste screen space
3. **Consistency over novelty** — use existing patterns before inventing new ones
4. **Motion is functional** — animate only to communicate state change, never for decoration
**Anti-patterns we actively avoid**:
- Skeleton loaders for data that loads in < 300ms (use a spinner instead)
- Modal dialogs for destructive actions that are easily reversible
- Infinite scroll (we use pagination; users need to share URLs to specific pages)
- Tooltips on mobile
- Full-width buttons on desktop (max-width: 320px for standalone CTAs)
- Mixing card and table layouts in the same list view
- Auto-submitting forms on change without explicit confirmation
- Generic error messages ("Something went wrong" is never acceptable)
- `window.alert` — always use our Dialog/Toast components
---
## Design Tokens
All colors, spacing, radius, and shadow values must come from these tokens. **Never write raw hex values or arbitrary px values in components.**
### Color System
We use CSS custom properties (variables). Define these in `base.css` under `:root`.
```css
:root {
/* Backgrounds */
--color-bg-base: #ffffff; /* page background */
--color-bg-subtle: #f9fafb; /* card, sidebar, panel backgrounds */
--color-bg-muted: #f3f4f6; /* disabled states, placeholders, table zebra */
/* Text */
--color-text-primary: #111827; /* body text */
--color-text-secondary:#6b7280; /* labels, captions, helper text */
--color-text-disabled: #9ca3af; /* disabled text */
--color-text-inverse: #ffffff; /* text on dark/colored backgrounds */
/* Borders */
--color-border: #e5e7eb; /* default borders */
--color-border-strong: #d1d5db; /* focused, emphasized borders */
/* Brand / Accent — orange as primary action color */
--color-accent: #f97316; /* primary actions, links, active states */
--color-accent-hover: #ea6c0a; /* hover state of accent */
--color-accent-subtle: #fff7ed; /* light tint for accent backgrounds */
/* Semantic */
--color-success: #16a34a; /* confirmations, completed states */
--color-success-bg: #f0fdf4;
--color-warning: #d97706; /* non-blocking alerts */
--color-warning-bg: #fffbeb;
--color-danger: #dc2626; /* destructive actions, errors */
--color-danger-bg: #fef2f2;
--color-info: #2563eb; /* informational, neutral alerts */
--color-info-bg: #eff6ff;
}
```
**Mapping to Tailwind**: Configure `tailwind.config.js` to extend colors using these CSS variables so you can write `text-accent`, `bg-bg-subtle`, etc. If that is not yet configured, use the following Tailwind equivalents as a temporary fallback — but never use arbitrary hex:
| Token | Tailwind equivalent |
|---|---|
| `--color-accent` | `text-orange-500` / `bg-orange-500` |
| `--color-accent-hover` | `hover:bg-orange-600` |
| `--color-text-primary` | `text-gray-900` |
| `--color-text-secondary` | `text-gray-500` |
| `--color-text-disabled` | `text-gray-400` |
| `--color-border` | `border-gray-200` |
| `--color-border-strong` | `border-gray-300` |
| `--color-bg-subtle` | `bg-gray-50` |
| `--color-bg-muted` | `bg-gray-100` |
| `--color-danger` | `text-red-600` / `bg-red-600` |
| `--color-success` | `text-green-600` |
**Rule**: If you find yourself writing `text-gray-500`, stop. Ask: is this a label, a caption, or secondary content? Then use the semantic token. If you find yourself writing `text-gray-400`, that is disabled text.
---
### Spacing Scale
We use a **4px base grid**. Only these values are permitted:
| px | Tailwind |
|---|---|
| 4px | `p-1` / `m-1` / `gap-1` |
| 8px | `p-2` / `m-2` / `gap-2` |
| 12px | `p-3` / `m-3` / `gap-3` |
| 16px | `p-4` / `m-4` / `gap-4` |
| 24px | `p-6` / `m-6` / `gap-6` |
| 32px | `p-8` / `m-8` / `gap-8` |
| 48px | `p-12` / `m-12` / `gap-12` |
| 64px | `p-16` / `m-16` / `gap-16` |
| 96px | `p-24` / `m-24` / `gap-24` |
**Never use**: `p-5`, `p-7`, `p-9`, `p-10`, `p-11` — these break the grid.
---
### Border Radius
```
--radius-sm: 4px → rounded-sm (inputs, badges, table cells)
--radius-md: 8px → rounded (cards, buttons) ← default
--radius-lg: 12px → rounded-xl (modals, drawers, panels)
--radius-full: 9999px → rounded-full (avatars, pill badges, toggles)
```
**Rule**: Never mix radius sizes within the same component. A card with `rounded` should not have children with `rounded-xl`.
---
### Elevation / Shadow
```
--shadow-sm → shadow-sm (subtle card lift, focused inputs)
--shadow-md → shadow-md (dropdowns, popovers, floating panels)
--shadow-lg → shadow-xl (modals, drawers, dialogs)
```
**Rules**:
- Never use `drop-shadow` filter — use `box-shadow` (`shadow-*`) only
- Never use `shadow-2xl``shadow-xl` is the maximum
---
## Typography
**Font stack**:
- UI: `Inter` (variable weight, loaded via `<link>` from self-hosted or CDN)
- Code / monospace data: `JetBrains Mono` (used for code blocks and numeric data columns only)
- Never import fonts via Google Fonts `@import` inside CSS — use `<link>` in `<head>`
**Type scale** — use only these sizes, no arbitrary values:
| Tailwind class | Size | Weight | Line-height | Usage |
|---|---|---|---|---|
| `text-xs` | 12px | 400 | 1.5 | Labels, badges, metadata, table captions |
| `text-sm` | 14px | 400 | 1.5 | Body text, secondary content, form helpers |
| `text-base` | 16px | 400 | 1.6 | Primary body text |
| `text-lg` | 18px | 500 | 1.4 | Section headings, drawer titles |
| `text-xl` | 20px | 600 | 1.3 | Page sub-headings |
| `text-2xl` | 24px | 700 | 1.2 | Page titles |
| `text-3xl` | 30px | 700 | 1.1 | Hero headings only — never in app UI |
**Rules**:
- Max 2 font sizes per component
- `font-medium` (500) is the minimum weight for anything interactive (buttons, links, tab labels)
- Never use `text-3xl` in application views — only marketing/landing pages
- Body text line length: max 72 characters (`max-w-prose`)
- Numbers in tables: use `font-mono` (JetBrains Mono) and `tabular-nums`
---
## Page Layout
### App Shell
The standard application layout is a **fixed sidebar + scrollable main content** pattern.
```
┌─────────────────────────────────────────────────────┐
│ Top Nav Bar (h-14, fixed, z-30) │
├──────────────┬──────────────────────────────────────┤
│ │ Page Header (breadcrumb + actions) │
│ Sidebar ├──────────────────────────────────────┤
│ (w-56, │ │
│ fixed, │ Main Content Area │
│ z-20) │ (overflow-y-auto, flex-1) │
│ │ │
│ │ │
└──────────────┴──────────────────────────────────────┘
```
- **Top Nav**: `h-14`, `bg-white`, `border-b border-gray-200`, `fixed top-0 inset-x-0 z-30`
- **Sidebar**: `w-56`, `bg-gray-50`, `border-r border-gray-200`, `fixed left-0 top-14 bottom-0 z-20`, `overflow-y-auto`
- **Main**: `ml-56 mt-14`, `min-h-screen`, `bg-white`
- **Page content padding**: `p-6` on all sides
### Page Header
Every page has a header section directly below the top nav, inside the main area:
```html
<div class="px-6 pt-6 pb-4 border-b border-gray-200">
<!-- Breadcrumb -->
<nav class="text-sm text-gray-500 mb-1">
<span>房源管理</span>
<span class="mx-1">/</span>
<span class="text-gray-900">住宅出售</span>
</nav>
<!-- Page title + primary actions -->
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-gray-900">住宅出售</h1>
<div class="flex items-center gap-2">
<!-- Primary action buttons here -->
</div>
</div>
</div>
```
### Sidebar Navigation
- Active item: `bg-orange-50 text-orange-600 font-medium border-r-2 border-orange-500`
- Inactive item: `text-gray-600 hover:bg-gray-100`
- Section label: `text-xs font-semibold text-gray-400 uppercase tracking-wide px-3 mt-4 mb-1`
- Item height: `h-9` (`36px`)
- Item padding: `px-3`
- Icon size: `w-4 h-4`, `mr-2`
- Second-level items: `pl-9`
---
## Core Component Specs
### Button
**Variants**:
| Variant | Tailwind classes | Use case | Never use for |
|---|---|---|---|
| `primary` | `bg-orange-500 hover:bg-orange-600 text-white font-medium rounded` | Single main CTA per view | Destructive actions |
| `secondary` | `bg-white border border-gray-300 hover:bg-gray-50 text-gray-700 font-medium rounded` | Secondary actions | Main CTA |
| `ghost` | `bg-transparent hover:bg-gray-100 text-gray-600 font-medium rounded` | Toolbar actions, low-priority | Standalone CTAs |
| `danger` | `bg-red-600 hover:bg-red-700 text-white font-medium rounded` | Irreversible destructive actions only | Anything reversible |
| `link` | `text-orange-500 hover:text-orange-600 underline-offset-2 hover:underline` | Inline navigation links only | Form submissions |
**Sizes**:
| Size | Height | Padding | Font |
|---|---|---|---|
| `sm` | `h-7` (28px) | `px-3` | `text-xs` |
| `md` | `h-9` (36px) | `px-4` | `text-sm` — default |
| `lg` | `h-11` (44px) | `px-6` | `text-base` |
**States** (all must be handled in every button):
- `default` — base styles above
- `hover` — defined in variant above
- `active``active:scale-95 active:opacity-90`
- `focus-visible``focus-visible:outline-2 focus-visible:outline-orange-500 focus-visible:outline-offset-2`
- `disabled``disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none`
- `loading` — spinner icon replaces or precedes label; button is disabled
**Loading state rule**: Show spinner and disable button immediately on click. Never allow double-submission.
```html
<!-- Correct: HTMX handles loading state automatically with hx-indicator -->
<button hx-post="/api/save/"
hx-disabled-elt="this"
class="btn-primary"
aria-label="保存">
<span class="htmx-indicator">
<svg class="animate-spin w-4 h-4 mr-2" ...></svg>
</span>
保存
</button>
<!-- For Alpine.js-controlled loading -->
<button @click="submit()"
:disabled="loading"
:class="loading ? 'opacity-50 cursor-not-allowed' : ''"
class="btn-primary">
<svg x-show="loading" class="animate-spin w-4 h-4 mr-2" ...></svg>
<span x-text="loading ? '保存中...' : '保存'"></span>
</button>
```
**Icon buttons**: Always include `aria-label`. Never use icon-only buttons as the sole primary CTA.
---
### Form Inputs
**Anatomy** (always in this order, no exceptions):
```
[Label] ← always visible, never placeholder-only
[Input field]
[Helper text] ← optional, describes expected format
[Error message] ← replaces helper text on error
```
**Base input class**:
```
block w-full rounded border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900
placeholder:text-gray-400
focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-orange-500
disabled:bg-gray-100 disabled:text-gray-400 disabled:cursor-not-allowed
```
**States**:
| State | Additional classes |
|---|---|
| `default` | `border-gray-300` |
| `focus` | `ring-2 ring-orange-500 border-orange-500` |
| `error` | `border-red-500 ring-2 ring-red-500 focus:ring-red-500` |
| `disabled` | `bg-gray-100 text-gray-400 cursor-not-allowed` |
| `readonly` | `bg-gray-50 text-gray-600 cursor-default` |
**Label**:
```html
<label for="field-id" class="block text-sm font-medium text-gray-700 mb-1">
字段名称 <span class="text-red-500">*</span> <!-- required indicator -->
</label>
```
**Helper text**:
```html
<p class="mt-1 text-xs text-gray-500">格式说明文字</p>
```
**Error message**:
```html
<p class="mt-1 text-xs text-red-600" id="field-id-error" role="alert">
<svg class="inline w-3 h-3 mr-1" ...></svg>
具体的错误原因,可操作的
</p>
```
**Rules**:
- Label always above input, never to the side (exception: checkbox and radio)
- Placeholder text is NOT a label — both must exist
- Error messages: specific and actionable ("请输入有效的手机号", not "格式错误")
- Required fields: mark with `*` next to label; explain at top of form ("* 为必填项")
- Never disable a submit button to prevent submission — show errors inline instead
- `aria-describedby` must link input to its error element when error is shown
**Select / Dropdown**:
```
block w-full rounded border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900
focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-orange-500
```
**Textarea**: same as input, add `resize-y min-h-[80px]`. Always include character counter when there is a max length.
---
### Data Table
**Structure**:
```
[Toolbar: bulk actions + filter chips + column visibility + export]
[Table: sticky header, checkbox col, data cols, actions col]
[Pagination: count + page controls + per-page selector + jump-to]
```
**Table base classes**:
```html
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50 sticky top-0 z-10">
<tr>
<!-- Checkbox column — leftmost -->
<th class="w-10 px-3 py-3">
<input type="checkbox" class="rounded border-gray-300 text-orange-500 focus:ring-orange-500">
</th>
<!-- Sortable column -->
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wide cursor-pointer select-none hover:bg-gray-100"
hx-get="?sort=field&order=asc" hx-target="#table-body">
列名 <svg class="inline w-3 h-3 ml-1">...</svg>
</th>
</tr>
</thead>
<tbody id="table-body" class="divide-y divide-gray-100 bg-white">
<tr class="hover:bg-gray-50 transition-colors">
<td class="px-3 py-3">...</td>
<!-- Actions column — rightmost, visible on row hover only -->
<td class="px-3 py-3 opacity-0 group-hover:opacity-100 transition-opacity text-right">
...
</td>
</tr>
</tbody>
</table>
</div>
```
Add `group` class to `<tr>` to enable `group-hover` on the actions column.
**Column rules**:
- Numbers: `text-right font-mono tabular-nums`
- Dates: relative time for < 7 days ("2小时前"), absolute date for older ("2025-10-15")
- Status: always a colored badge — never plain text
- Long text: `truncate max-w-[200px]` with `title` attribute showing full value
**Default page size**: 25 rows. Options: 10 / 25 / 50 / 100.
**Pagination display**: Always show total count — "共 3,629 条,第 125 条"
**Empty state** (never just "暂无数据"):
```html
<tr>
<td colspan="[N]" class="py-16 text-center">
<div class="flex flex-col items-center gap-3">
<svg class="w-12 h-12 text-gray-300" ...></svg> <!-- relevant icon -->
<p class="text-base font-medium text-gray-500">暂无房源</p>
<p class="text-sm text-gray-400">符合条件的房源将出现在这里</p>
<a href="/property/add/" class="btn-primary btn-sm mt-2">新增房源</a>
</div>
</td>
</tr>
```
---
### Status Badge
Status must always be communicated with **color + icon + text** (never color alone).
```html
<!-- Base badge structure -->
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium">
<svg class="w-3 h-3" ...></svg>
状态文字
</span>
```
**Variant classes** (add to base):
| Status | Class |
|---|---|
| Active / 在售 | `bg-green-100 text-green-700` |
| Warning / 即将过期 | `bg-yellow-100 text-yellow-700` |
| Danger / 已删除 | `bg-red-100 text-red-700` |
| Neutral / 已下架 | `bg-gray-100 text-gray-600` |
| Info / 跟进中 | `bg-blue-100 text-blue-700` |
| Brand / 出售 | `bg-orange-100 text-orange-700` |
---
### Modal / Dialog
**Size variants**:
| Size | Max-width | Use case |
|---|---|---|
| `sm` | `max-w-sm` (400px) | Confirmation dialogs, simple alerts |
| `md` | `max-w-lg` (560px) | Forms with ≤ 5 fields |
| `lg` | `max-w-2xl` (720px) | Complex forms, detail previews |
| `xl` | `max-w-4xl` (960px) | Multi-step flows, wide content |
**Base modal structure**:
```html
<div x-data="{ open: false }">
<!-- Backdrop -->
<div x-show="open"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
@click="open = false"
class="fixed inset-0 bg-black/50 z-40"
aria-hidden="true">
</div>
<!-- Panel -->
<div x-show="open"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
role="dialog"
aria-modal="true"
x-trap="open"
class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="bg-white rounded-xl shadow-xl w-full max-w-lg flex flex-col max-h-[90vh]">
<!-- Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 shrink-0">
<h2 class="text-lg font-semibold text-gray-900">弹窗标题</h2>
<button @click="open = false" aria-label="关闭" class="text-gray-400 hover:text-gray-600">
<svg class="w-5 h-5" ...></svg>
</button>
</div>
<!-- Scrollable body -->
<div class="px-6 py-4 overflow-y-auto flex-1">
<!-- content -->
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-2 px-6 py-4 border-t border-gray-200 shrink-0">
<button @click="open = false" class="btn-secondary">取消</button>
<button class="btn-primary">确定</button>
<!-- Destructive: danger button on RIGHT, cancel on LEFT — as shown above -->
</div>
</div>
</div>
</div>
```
**Rules**:
- Always trap focus inside modal (`x-trap` from Alpine.js Focus plugin)
- ESC key always closes (Alpine handles this automatically with `x-trap`)
- Click outside backdrop closes — unless form has unsaved changes → show confirmation
- Never nest modals — use a multi-step flow instead
- Destructive confirm dialogs: danger button on RIGHT, cancel on LEFT
- Never auto-close a modal after an async action — wait for user to dismiss
---
### Drawer / Slide-over Panel
Used for editing content with many fields where the main page should remain visible for reference.
**When to use Drawer vs Modal**:
| Scenario | Component |
|---|---|
| Many fields, needs scrolling | Drawer |
| Few fields (≤ 5), simple confirm | Modal |
| User needs to reference main page data while editing | Drawer |
**Standard drawer** (slides in from the right):
```html
<div x-data="{ open: false }">
<!-- Backdrop -->
<div x-show="open"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
@click="open = false"
class="fixed inset-0 bg-black/30 z-40">
</div>
<!-- Drawer panel -->
<div x-show="open"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="translate-x-full"
x-transition:enter-end="translate-x-0"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="translate-x-0"
x-transition:leave-end="translate-x-full"
role="dialog"
aria-modal="true"
class="fixed right-0 top-0 h-full w-[480px] bg-white z-50 shadow-xl flex flex-col">
<!-- Fixed header -->
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between shrink-0">
<h2 class="text-lg font-semibold text-gray-900">抽屉标题</h2>
<button @click="open = false" aria-label="关闭" class="text-gray-400 hover:text-gray-600">
<svg class="w-5 h-5" ...></svg>
</button>
</div>
<!-- Scrollable content -->
<div class="flex-1 overflow-y-auto px-6 py-4">
<!-- content -->
</div>
<!-- Fixed footer -->
<div class="px-6 py-4 border-t border-gray-200 flex justify-end gap-2 shrink-0">
<button @click="open = false" class="btn-secondary">取消</button>
<button class="btn-primary">确定</button>
</div>
</div>
</div>
```
**Width**: `w-[480px]` default; `w-[640px]` for wide content (image management, multi-column settings).
---
### Tab Navigation
**Standard tabs** (underline style):
```html
<div x-data="{ activeTab: 'info' }">
<!-- Tab bar -->
<div class="flex border-b border-gray-200">
<button @click="activeTab = 'info'"
:class="activeTab === 'info'
? 'border-b-2 border-orange-500 text-orange-600 font-medium'
: 'text-gray-500 hover:text-gray-700'"
class="px-4 py-3 text-sm -mb-px">
基本信息
</button>
<!-- more tabs -->
</div>
<!-- Tab panels — use HTMX for content that requires server data -->
<div x-show="activeTab === 'info'" class="pt-4">
...
</div>
</div>
```
**Tab + HTMX** (for server-rendered tab content):
```html
<button hx-get="/property/123/tab/followup/"
hx-target="#tab-content"
hx-swap="innerHTML"
@click="activeTab = 'followup'"
:class="activeTab === 'followup' ? 'border-b-2 border-orange-500 text-orange-600 font-medium' : 'text-gray-500 hover:text-gray-700'"
class="px-4 py-3 text-sm -mb-px">
跟进记录
</button>
<div id="tab-content" class="pt-4">...</div>
```
**URL-syncing tabs**: Use `hx-push-url="true"` on HTMX tab requests to make tabs bookmarkable.
---
### Toggle Switch
```html
<button @click="val = !val"
:aria-checked="val.toString()"
role="switch"
:class="val ? 'bg-orange-500' : 'bg-gray-300'"
:disabled="disabled"
class="relative w-10 h-5 rounded-full transition-colors duration-200 focus-visible:outline-2 focus-visible:outline-orange-500 focus-visible:outline-offset-2 disabled:opacity-50 disabled:cursor-not-allowed">
<span :class="val ? 'translate-x-5' : 'translate-x-0.5'"
class="absolute top-0.5 left-0 w-4 h-4 bg-white rounded-full shadow transition-transform duration-200">
</span>
</button>
```
---
### Collapsible / Accordion
```html
<div x-data="{ open: false }">
<!-- Header row — clickable -->
<div @click="open = !open"
class="flex items-center justify-between px-4 py-3 cursor-pointer hover:bg-gray-50 select-none">
<span class="text-sm font-medium text-gray-700">分组标题</span>
<svg :class="open ? 'rotate-180' : ''"
class="w-4 h-4 text-gray-400 transition-transform duration-200" ...></svg>
</div>
<!-- Collapsible body — use x-collapse plugin for smooth height animation -->
<div x-show="open" x-collapse class="px-4 pb-4">
<!-- content -->
</div>
</div>
```
Requires Alpine.js `@alpinejs/collapse` plugin (official, ~1KB).
---
### Tree Select
For hierarchical data selection (org unit, staff assignment):
- **Small datasets** (< 200 nodes): Alpine.js renders full JSON tree client-side
- **Large datasets**: HTMX lazy-loads child nodes on expand
Alpine.js data structure:
```javascript
{
open: false,
query: '',
selected: null,
nodes: [/* tree JSON from Django */],
toggle(node) { node.expanded = !node.expanded },
select(node) { this.selected = node; this.open = false },
filteredNodes() {
if (!this.query) return this.nodes
// Recursive filter — preserves ancestors of matching nodes
return this.filterTree(this.nodes, this.query.toLowerCase())
}
}
```
---
### Multi-select Tag Input
For multi-value fields (e.g., property status, amenities):
```html
<div x-data="multiSelect(options)"
@click.away="open = false"
class="relative">
<!-- Tag container / trigger -->
<div @click="open = true"
:class="open ? 'ring-2 ring-orange-500 border-orange-500' : 'border-gray-300'"
class="flex flex-wrap gap-1 min-h-[36px] border rounded px-2 py-1.5 cursor-text bg-white">
<template x-for="item in selected" :key="item.value">
<span class="inline-flex items-center gap-1 bg-gray-100 text-gray-700 text-xs px-2 py-0.5 rounded">
<span x-text="item.label"></span>
<button @click.stop="remove(item)" aria-label="移除" class="text-gray-400 hover:text-gray-600">×</button>
</span>
</template>
<input x-model="query" class="outline-none text-sm flex-1 min-w-[60px] bg-transparent" placeholder="搜索或选择">
</div>
<!-- Dropdown -->
<div x-show="open" class="absolute z-20 mt-1 w-full bg-white border border-gray-200 rounded shadow-md max-h-48 overflow-y-auto">
<template x-for="option in filteredOptions()" :key="option.value">
<div @click="toggle(option)"
class="flex items-center justify-between px-4 py-2 text-sm hover:bg-gray-50 cursor-pointer">
<span x-text="option.label"></span>
<svg x-show="isSelected(option)" class="w-4 h-4 text-orange-500" ...></svg>
</div>
</template>
<div x-show="filteredOptions().length === 0" class="px-4 py-3 text-sm text-gray-400">暂无匹配选项</div>
</div>
</div>
```
---
### Date Range Picker
Use **Flatpickr** (CDN, ~16KB, zero framework dependency):
```javascript
flatpickr("#date-range-input", {
mode: "range",
showMonths: 2,
dateFormat: "Y-m-d",
locale: "zh",
});
```
Override Flatpickr default styles with Tailwind to match our design system. Never build a date picker from scratch.
---
### Photo Gallery / Image Management
- Upload: **Filepond** (~50KB, zero framework dependency) — drag-and-drop, preview, progress, multi-file queue
- Drag-to-reorder: **SortableJS** (~3KB) — use `handle: '.drag-handle'`
- Lightbox preview: **Viewer.js** (~5KB) — zoom, rotate, thumbnail strip
All three are framework-free pure JS libraries, fully compatible with HTMX + Alpine.js.
---
## State & Feedback Patterns
### Loading States
| Duration | Pattern |
|---|---|
| < 300ms | Nothing — avoid flash of spinner |
| 300ms 1s | Inline spinner (`animate-spin`) |
| 1s 3s | Spinner + "加载中..." text |
| > 3s | Progress bar + estimated time |
| Background Celery task | Subtle pulsing indicator in top nav |
HTMX automatically adds `htmx-request` class during requests — use it to show/hide indicators:
```html
<div class="htmx-indicator">
<svg class="animate-spin w-4 h-4 text-orange-500" ...></svg>
</div>
```
---
### Empty States
Every list, table, and data view must handle the empty state. Required elements:
1. Relevant icon (not a generic "no data" icon — use something contextually relevant)
2. Friendly headline ("暂无房源")
3. Explanation ("符合当前筛选条件的房源将出现在这里")
4. CTA if the user can fix it ("新增第一条房源 →")
```html
<div class="flex flex-col items-center justify-center py-16 text-center">
<svg class="w-12 h-12 text-gray-300 mb-4" ...></svg>
<p class="text-base font-medium text-gray-500 mb-1">暂无房源</p>
<p class="text-sm text-gray-400 mb-4">符合当前筛选条件的房源将出现在这里</p>
<a href="/property/add/" class="btn-primary btn-sm">新增房源</a>
</div>
```
---
### Toast Notifications
| Type | When to use | Duration | Classes |
|---|---|---|---|
| `success` | Async action completed | 3s auto-dismiss | `bg-green-50 border-green-200 text-green-800` |
| `error` | Action failed, user must retry | Persistent (manual dismiss) | `bg-red-50 border-red-200 text-red-800` |
| `warning` | Completed with caveats | 5s | `bg-yellow-50 border-yellow-200 text-yellow-700` |
| `info` | Background process started | 3s | `bg-blue-50 border-blue-200 text-blue-700` |
**Rules**:
- Max 3 toasts visible at once (queue the rest)
- Never show success toast for page navigations
- Error toasts must include a retry action button when possible
- Never use toast for validation errors — show inline instead
- Position: `fixed bottom-4 right-4 z-50 flex flex-col gap-2`
Trigger via HTMX response header: `HX-Trigger: {"showToast": {"type": "success", "message": "保存成功"}}`
---
### Inline Edit (Read/Edit Mode Toggle)
For settings pages and detail views that support in-place editing:
```html
<div x-data="{ editing: false, snapshot: null }"
@keydown.escape="editing = false; restoreSnapshot()">
<button x-show="!editing" @click="editing = true; snapshot = JSON.parse(JSON.stringify(data))"
class="btn-secondary btn-sm">编辑</button>
<div x-show="editing" class="flex gap-2">
<button @click="editing = false; restoreSnapshot()" class="btn-secondary btn-sm">取消</button>
<button hx-post="/settings/save/" hx-vals="js:data" @click="editing = false" class="btn-primary btn-sm">保存</button>
</div>
<!-- Each field toggles between read and edit mode -->
<div class="flex justify-between py-3 border-b border-gray-100">
<span class="text-sm text-gray-500">工龄计算方式</span>
<span x-show="!editing" class="text-sm text-gray-900" x-text="data.tenureBasis"></span>
<select x-show="editing" x-model="data.tenureBasis" class="input-sm">
<option>从首次入职开始计算</option>
</select>
</div>
</div>
```
**Cancel rule**: Always snapshot data before entering edit mode. On cancel, restore from snapshot (3 lines). Never leave the user with unsaved partial edits on cancel.
---
## Responsive Breakpoints
**Strategy**: Desktop-first (target users are ≥ 85% desktop/Windows Electron client).
| Breakpoint | Tailwind prefix | Viewport | Target |
|---|---|---|---|
| Desktop | (base, no prefix) | > 1280px | Primary design target |
| Laptop | `lg:` | ≥ 1024px | Minor layout adjustments |
| Tablet | `md:` | ≥ 768px | Collapsed sidebar |
| Mobile | `sm:` | ≥ 640px | Single column, no tables |
**Component-specific rules**:
- Data tables → `overflow-x-auto` on `md` and below
- Sidebar → collapses to icon-only or hidden on `md`; bottom nav on `sm`
- Modals → full-screen (`inset-0 rounded-none`) on `sm`
- Multi-column forms → single column on `md` and below (`md:grid-cols-1`)
- Drawers → full-width on `sm` (`sm:w-full`)
**Never**:
- Hide critical functionality on mobile — adapt it, do not remove it
- Use fixed px widths for layout containers (use `max-w-*` instead)
- Assume touch input on desktop
---
## Motion & Animation
**Principle**: Motion communicates state, it does not decorate.
**Duration scale**:
| Token | Duration | Use for |
|---|---|---|
| `duration-100` | 100ms | Micro-interactions (checkbox tick, toggle) |
| `duration-200` | 200ms | Most transitions (hover, focus, fade) — default |
| `duration-300` | 300ms | Larger elements (modal enter, drawer slide) |
| `duration-500` | 500ms | Page-level transitions only |
**Easing**:
- Default UI transitions: `ease-out` (enter) / `ease-in` (leave)
- Playful/spring: avoid in this product — we are a business tool
- Progress bars: `linear`
**What to animate**:
- `opacity` — enter/exit fades
- `transform: translateY / translateX` — panel slide-in
- `max-height` with `x-collapse` — accordion expand
**Never animate**:
- `width` or `height` directly — use `max-height` with `x-collapse` or `transform`
- `top / left / right / bottom` — use `transform: translate` instead
- `box-shadow` on hover — use opacity-layered pseudo-element trick instead
- Anything if `prefers-reduced-motion: reduce` is set:
```css
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
```
---
## Accessibility (Non-negotiable)
**Every component must**:
- Meet WCAG 2.1 AA contrast ratios (4.5:1 for body text, 3:1 for large text ≥ 18px)
- Be fully keyboard navigable (Tab, Shift+Tab, Enter, Space, Escape)
- Have visible focus indicators — never `outline: none` without a custom replacement using `focus-visible:`
- Work with screen readers (proper ARIA roles and labels)
**Required patterns**:
| Element | Requirement |
|---|---|
| Icon-only buttons | `aria-label="操作名称"` always |
| Form inputs | `id` attribute + `<label for="...">` pairing always |
| Images | `alt` text always (empty `alt=""` for decorative images) |
| Modals | `role="dialog"` + `aria-modal="true"` + focus trap (`x-trap`) |
| Loading states | `aria-busy="true"` on the loading container |
| Error messages | `aria-describedby` linking input `id` to error `id` |
| Toggle switches | `role="switch"` + `aria-checked` |
| Status indicators | color + icon + text (never color alone) |
**Color alone is never enough**:
- Status badges: color background + icon + text label
- Form errors: red border + error icon + text message below field
- Charts: color + pattern or direct label
---
## UI Anti-patterns — Never Do These
**Layout**:
- Body text wider than 72 characters without `max-w-prose`
- Full-width buttons on desktop (use `max-w-xs` or `w-auto`)
- Mixing card and table layouts in the same list view
**Interaction**:
- Double-click to perform actions — single click only
- Drag-and-drop as the *only* way to reorder — always provide an alternative
- Hover-only affordances (invisible until hovered)
- Auto-submitting forms on field change without explicit "保存" action
**Feedback**:
- Generic error messages: "出错了" or "Something went wrong" — always be specific
- Success messages that do not tell the user what happened ("已保存" with no context)
- Blocking the UI with a modal spinner for optimistic actions
- Using `window.alert()`, `window.confirm()`, or `window.prompt()` — use our Dialog component
**Content**:
- Lorem ipsum in any committed code or template
- Hardcoded user names, emails, or phone numbers in components
- Placeholder images — use the Avatar initials fallback component
**Spacing**:
- `p-5`, `p-7`, `p-9`, `p-10`, `p-11` — off-grid values, never use
- Arbitrary values like `p-[13px]` — always round to the nearest grid value
---
## Third-party Libraries Approved for Use
The following libraries are pre-approved. Do not introduce any library not on this list without updating this document.
| Library | Version | Purpose | CDN size |
|---|---|---|---|
| HTMX | 2.x | Partial DOM updates, server interactions | ~14KB |
| Alpine.js | 3.x | Frontend state management | ~15KB |
| Alpine `@alpinejs/collapse` | official | Smooth accordion height animation | ~1KB |
| Alpine `@alpinejs/focus` | official | Focus trapping in modals | ~3KB |
| Tailwind CSS | 3.x | Utility-first styling | (purged) |
| Flatpickr | 4.x | Date range picker | ~16KB |
| Filepond | 4.x | File upload with preview | ~50KB |
| SortableJS | 1.x | Drag-to-reorder lists and grids | ~3KB |
| Viewer.js | 1.x | Image lightbox preview | ~5KB |
**Never introduce**: React, Vue, Angular, jQuery, Lodash, Moment.js, Bootstrap, or any component library built for a JS framework.