Files
nexus/Project/fonrey/UI&UX/UI_SYSTEM.md

35 KiB
Raw Blame History

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.

: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-2xlshadow-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:

<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
  • activeactive:scale-95 active:opacity-90
  • focus-visiblefocus-visible:outline-2 focus-visible:outline-orange-500 focus-visible:outline-offset-2
  • disableddisabled: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.

<!-- 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-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:

<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 "暂无数据"):

<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-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):

<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.


  • 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:

  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 ("新增第一条房源 →")
<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-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:
@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.