35 KiB
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):
- Clarity over cleverness — if you have to explain the UI, it failed
- Density over whitespace — our users are power users; do not waste screen space
- Consistency over novelty — use existing patterns before inventing new ones
- 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.
: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-shadowfilter — usebox-shadow(shadow-*) only - Never use
shadow-2xl—shadow-xlis 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
@importinside 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-3xlin application views — only marketing/landing pages - Body text line length: max 72 characters (
max-w-prose) - Numbers in tables: use
font-mono(JetBrains Mono) andtabular-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-6on all sides
Page Header
Every page has a header section directly below the top nav, inside the main area:
<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 abovehover— defined in variant aboveactive—active:scale-95 active:opacity-90focus-visible—focus-visible:outline-2 focus-visible:outline-orange-500 focus-visible:outline-offset-2disabled—disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-noneloading— spinner icon replaces or precedes label; button is disabled
Loading state rule: Show spinner and disable button immediately on click. Never allow double-submission.
<!-- 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:
<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:
<p class="mt-1 text-xs text-gray-500">格式说明文字</p>
Error message:
<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-describedbymust 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:
<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]withtitleattribute 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 "暂无数据"):
<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).
<!-- 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:
<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-trapfrom 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):
<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):
<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):
<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
<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
<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:
{
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):
<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):
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:
<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:
- Relevant icon (not a generic "no data" icon — use something contextually relevant)
- Friendly headline ("暂无房源")
- Explanation ("符合当前筛选条件的房源将出现在这里")
- CTA if the user can fix it ("新增第一条房源 →")
<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:
<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-autoonmdand below - Sidebar → collapses to icon-only or hidden on
md; bottom nav onsm - Modals → full-screen (
inset-0 rounded-none) onsm - Multi-column forms → single column on
mdand 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 fadestransform: translateY / translateX— panel slide-inmax-heightwithx-collapse— accordion expand
Never animate:
widthorheightdirectly — usemax-heightwithx-collapseortransformtop / left / right / bottom— usetransform: translateinsteadbox-shadowon hover — use opacity-layered pseudo-element trick instead- Anything if
prefers-reduced-motion: reduceis set:
@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: nonewithout a custom replacement usingfocus-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-xsorw-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(), orwindow.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.