> **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 `` 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 `` in `
`
**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
住宅出售
```
### 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
```
**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
```
**Helper text**:
```html
格式说明文字
```
**Error message**:
```html
具体的错误原因,可操作的
```
**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
列名
...
...
```
Add `group` class to `
` 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 条,第 1–25 条"
**Empty state** (never just "暂无数据"):
```html
```
---
### Status Badge
Status must always be communicated with **color + icon + text** (never color alone).
```html
状态文字
```
**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
弹窗标题
```
**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
```
**URL-syncing tabs**: Use `hx-push-url="true"` on HTMX tab requests to make tabs bookmarkable.
---
### Toggle Switch
```html
```
---
### Collapsible / Accordion
```html
分组标题
```
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
暂无匹配选项
```
---
### 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
```
---
### 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