Skip to content
CelPlume
select · Enteropen · Escclose Powered by Pagefind

Frontend Design System

Semantic tokens, color palette, dark mode strategy, unified components (InfoBox, TabBar, PickerPopover, CodeEditor), tab system conventions, and dark-mode lessons learned.

SDNUChronoSync Frontend Design System (Design System / DESIGN.md)

Section titled “SDNUChronoSync Frontend Design System (Design System / DESIGN.md)”

This document is the single source of truth for the frontend visual, interaction, and theme design. It records: the semantic token system, dark mode strategy, color palette, typography, radii/shadows, shared component classes, unified components (InfoBox/TabBar/PickerPopover/CodeEditor), conventions for the two kinds of tabs and their smooth switching, a full component inventory, and every pitfall we have hit.

Rule: before adding or changing any color, font size, radius, shadow, dark-mode behavior, or interactive component, read this document first and stay consistent with the existing system.

Companion docs: Full changelog; Architecture; Deployment guide; Development guide.

  1. Purpose and Conventions
  2. Theme Strategy Overview
  3. Semantic Token System (CSS Variables)
  4. Color System (Tailwind Palette)
  5. Typography
  6. Radius / Shadows / Spacing
  7. Shared Component Classes (addComponents)
  8. Unified Component 1: InfoBox
  9. Unified Component 2: TabBar
  10. Unified Component 3: PickerPopover (Date/Time Picker)
  11. Unified Component 4: CodeEditor
  12. Tab System Conventions (Two Tab Types + Smooth Switching)
  13. Avatar and Team Cover Palette
  14. Backend Integration (availability / Avatar Upload)
  15. Component Inventory (by Page/Feature)
  16. Dark Mode Lessons Learned
  17. Verification Methodology (Browser Audit / Build)
  18. Commit Conventions and Workflow

This project’s dark mode, token system, and unified components were converged through many iterations and a lot of pitfalls. Problems we have hit:

  • Every component hardcoded its own colors → light/dark inconsistencies and repeated oversights;
  • :global(.dark) does not compile inside Vue scoped styles → dark mode silently never applied and the cause was untraceable;
  • Active tab was the same color as its container → “cannot tell which tab is active” in dark mode;
  • Primary button hover turned near-white → white text became invisible;
  • Nested modals closed on a single click;
  • A repo-wide fix script misfired → deleted correct dark variants.

This document freezes those lessons so that future changes can be “right the first time”, and so components stay searchable and reusable.

  1. Use tokens whenever possible (CSS variables); add dark: variants only for local tweaks; never hardcode hex colors per component.
  2. Always use InfoBox for callouts; always use TabBar for tabs/segments, or follow its active-state baseline.
  3. Dark-mode active-state baseline: container dark:bg-neutral-800, active dark:bg-neutral-700, text dark:text-neutral-50.
  4. Primary button hover uses hover:bg-primary-500 — never hover:bg-primary-50, never the invalid opacity /300.
  5. Dark overrides in scoped styles must live in a non-scoped <style> block:global(html.dark) inside <style scoped> does not compile.
  6. Use tokens for z-index (--layer-*), not magic numbers like z-[9999].
  7. After changes: grep -rnE "\]\s+dark:" must be empty → vue-tsceslintastro build → headless-browser per-page audit.

  • tailwind.config.mjs: darkMode: 'class'. Adding the dark class to <html> enters dark mode.
  • Store: frontend/src/stores/theme.ts is the single read/write point. localStorage key: app-theme, values dark / light.
  • Theme toggle button (Navigation.vue): to avoid SSR/client hydration mismatch (the Vue warn caused by different d paths on the sun/moon icons), render both the sun and moon icons simultaneously and toggle visibility with the CSS classes dark:block / dark:hidden instead of v-if="isDark". This keeps the SSR and client DOM identical.
<SunIcon class="h-5 w-5 hidden dark:block" aria-hidden="true" />
<MoonIcon class="h-5 w-5 dark:hidden" aria-hidden="true" />

There was historically a filter: invert() hack — it has been deleted. It inverted images, brand colors, and the logo along with everything else, and could not be controlled precisely.

MechanismUse caseNotes
① Semantic tokens (CSS variables)Page-level large surfaces/borders/text/accentDefined in BaseLayout.astro: :root light, html.dark dark; components consume var(--...) and switch automatically
② Tailwind dark: variantsSmall color patches inside a single componentAppend dark:bg-…, dark:text-… etc. to class names

All six showcase mock components (*Showcase.vue, PerspectiveSchedule.vue) have been migrated to consume tokens (mechanism ①).


Defined in the global <style is:global> of frontend/src/layouts/BaseLayout.astro.

VariableValuePurpose
--layer-dropdown1200Dropdown menus
--layer-popover1250Popovers/pickers
--layer-modal1400Modals
--layer-toast1500Toast notifications (topmost)

Usage: style="z-index: var(--layer-modal)". Never hardcode magic numbers like z-[9999] (they fight with the toast/modal layers).

VariableLightDark (html.dark)Purpose
--bg-page#f8fafc#0f172aPage background
--bg-card#ffffff#1e293bCard/panel surface
--bg-muted#f1f5f9#334155Secondary surface (input background)
--bg-subtle#e2e8f0#475569Weaker surface (divider/hover background)
--border#e2e8f0#334155Regular borders
--border-strong#cbd5e1#475569Emphasized borders
--text#0f172a#f8fafcPrimary text
--text-2#475569#cbd5e1Secondary text
--text-3#64748b#94a3b8Weak text/placeholder
--text-4#94a3b8#64748bWeakest text
--accent#0ea5e9#38bdf8Accent (primary color)
--accent-strong#0284c7#0ea5e9Accent (pressed/solid)
--accent-soft#e0f2fe#0c4a6eAccent light background

Consumption example:

.card {
background: var(--bg-card);
color: var(--text);
border-color: var(--border);
}

frontend/tailwind.config.mjstheme.extend.colors.

The only accent color on the site. There was historically a secondary (fuchsia) palette — deleted; sky has also been folded into primary.

StepValue
50#f0f9ff
100#e0f2fe
200#bae6fd
300#7dd3fc
400#38bdf8
500#0ea5e9
600#0284c7
700#0369a1
800#075985
900#0c4a6e

⚠️ primary only has 50–900, no 950. Any primary-950 class is invalid and silently dropped by Tailwind (dark mode will not apply). For dark light-backgrounds use primary-900, e.g. dark:bg-primary-900/30.

The site uniformly uses neutral instead of scattered gray/slate. Do not introduce gray-*/slate-*.

StepValue
50#f8fafc
100#f1f5f9
200#e2e8f0
300#cbd5e1
400#94a3b8
500#64748b
600#475569
700#334155
800#1e293b
900#0f172a
950#020617

neutral-800 (#1e293b) is the standard dark for cards/panels; neutral-700 (#334155) is the standard for active states; neutral-900 (#0f172a) is the standard for pages/inset tracks.

Uses Tailwind’s default blue/emerald/amber/red (all include 950). Unified dark convention:

SemanticsLightDark
Infobg-blue-50 text-blue-800 border-blue-200dark:bg-blue-900/30 dark:text-blue-200 dark:border-blue-800
Successbg-green-50 text-green-800dark:bg-green-900/25 dark:text-green-200
Warningbg-amber-50 text-amber-800dark:bg-amber-900/25 dark:text-amber-200
Dangerbg-red-50 text-red-800dark:bg-red-900/25 dark:text-red-200
Accent (purple)bg-purple-50 text-purple-800dark:bg-purple-900/30 dark:text-purple-200
Accent (orange)bg-orange-50 text-orange-800dark:bg-orange-900/25 dark:text-orange-200

tailwind.config.mjstheme.extend.fontFamily.

FamilyFont stackPurpose
sansSystem sans-serif stack (-apple-system, PingFang SC, Microsoft YaHei, etc.)Body/UI
displaySerif: STSong, Songti SC, Noto Serif SC, SimSun, etc.Display/headings

BaseLayout.astro sets font-family: -apple-system, ... globally on html for consistent Chinese rendering.


PurposeValue
Small buttons / badgesrounded-md / rounded-lg
Cards / panelsrounded-xl / rounded-2xl
Large panels / modalsrounded-[28px] (e.g. ShareScheduleView)
Pills (multi-select/segments)rounded-full
  • Card shadows are uniformly shadow-sm / shadow-xl (modals).
  • In dark mode, most card shadows are turned off with dark:shadow-none (shadows are barely visible on dark backgrounds and look dirty).
  • Primary button glow shadow-[0_16px_36px_-18px_rgba(2,132,199,0.95)] is light-mode only; dark mode uses dark:shadow-none.
  • Page padding p-4 md:p-6; modal inner padding px-4 pb-4 pt-5 sm:p-6.
  • Tab/segment container padding p-1.

7. Shared Component Classes (addComponents)

Section titled “7. Shared Component Classes (addComponents)”

Defined in tailwind.config.mjspluginsaddComponents. All have dark: variants built in (unless noted).

ClassPurposeDark
.input-baseStandard input
.select-baseStandard select
.input-searchSearch input with left icon
.dropdown-button / .dropdown-menu / .dropdown-item / .dropdown-search / .dropdown-search-input / .dropdown-checkHeadless dropdown
.picker-input-base / .picker-trigger-buttonPicker input/trigger button
.btn-secondary / .btn-primarySecondary/primary buttons
.warning-surface / .warning-surface-softAmber callout (legacy class; dark covered by .dark .warning-surface override)
.btn-clear-selectionClear-selection small button⚠️ still uses slate-*, not yet migrated to neutral
.scrollbar-customCustom scrollbar

Prefer reusing these classes in new code instead of hand-writing a whole set of @apply. ⚠️ Custom classes in addComponents have no automatic dark: variants — you must add .dark .xxx { … } overrides manually.


File: frontend/src/components/InfoBox.vue.

There used to be many scattered semantic callouts (“Important”, “Note”, “Dangerous operation”, “Temporary class booking”, “Logged into the academic system”, “Code injection”, etc.), each component hardcoding its own colors → light/dark inconsistencies and repeated oversights. Now converged into a single component with three variants:

variantSemanticsLightDark
infoExplanation/notebg-primary-50 text-primary-900 border-primary-200dark:bg-primary-900/25 dark:text-primary-100 dark:border-primary-800/70
warningCautionbg-amber-50 text-amber-900 border-amber-200dark:bg-amber-900/25 dark:text-amber-100 dark:border-amber-800/70
dangerDangerous operationbg-red-50 text-red-900 border-red-200dark:bg-red-900/25 dark:text-red-100 dark:border-red-800/70
<InfoBox variant="warning" title="注意事项">
<ul class="list-disc list-inside">
<li>转让后您将不再是团队创建者</li>
<li>此操作不可撤销</li>
</ul>
</InfoBox>

TransferTeamModal (caution), DissolveTeamModal (dangerous operation), TemporaryTeamDrawer (temporary class booking notice), ScheduleImporter (important notice). Any new callout of the same kind must use InfoBoxdo not write hardcoded color blocks again.


File: frontend/src/components/TabBar.vue.

Tab bars in system settings, team management, etc. were each written separately with inconsistent active states across light/dark (at one point the active tab and its container were both dark:bg-neutral-800, making it “impossible to see which tab is active” in dark mode). Now converged into a single component.

  • tabs: { id, name|label }[], modelValue (active id), stretch?: boolean (buttons split evenly to fill the width).
  • Two-way binding via v-model.
<TabBar :tabs="tabs" v-model="activeTab" />
<TabBar stretch :tabs="[{id:'swap',name:'对调工作日'},{id:'holiday',name:'设置假期'}]" v-model="mode" />

Following Tabs.vue from ~/AinOfficialWiki, implements a sliding indicator pill:

  • Container is relative, with an absolutely positioned pill <span> (bg-white shadow dark:bg-neutral-700) laid under the buttons.
  • Use getBoundingClientRect to measure the active button’s left/width and write them into transform: translate3d(...) and width.
  • CSS transition-[width,transform,opacity] duration-200 ease-out makes it glide smoothly to the active tab.
  • Re-sync on onMounted/resize/modelValue/tabs changes.
  • Buttons only keep the text color (active dark:text-neutral-50); the pill handles the background.

admin/SystemSettings.vue (site/storage/email/code injection), TeamEditorModal.vue (team info/member management/visibility/actions), ScheduleAdjuster.vue (swap workdays/set holidays, stretch). Any new tabs must use TabBar.


10. Unified Component 3: PickerPopover (Date/Time Picker)

Section titled “10. Unified Component 3: PickerPopover (Date/Time Picker)”

File: frontend/src/components/PickerPopover.vue.

  • Trigger button uses the shared classes .picker-input-base / .picker-trigger-button (addComponents has dark built in).
  • The panel panelClass is a class string built in a computed property; it used to be the only light-mode leak (bg-white without dark) → now fixed with dark:border-neutral-700 dark:bg-neutral-800 dark:shadow-none dark:ring-neutral-700.
  • Calendar cells/time/minutes/clear/today/confirm buttons all have dark variants.
  • ⚠️ When concatenating classes in a JS string, do not forget the dark variants (.vue module checks cannot see styles — verify against the build output).

File: frontend/src/components/CodeEditor.vue (used by system settings “code injection”).

  • The scoped classes (.code-editor-container/.line-numbers/.code-textarea) hardcode light backgrounds and have no automatic dark variants.
  • Fix: after the <style scoped> block, add a non-scoped <style> with html.dark .code-editor-container { … } overrides covering background, line numbers, text, error states, and scrollbar.
  • ⚠️ This is the positive fix for the “:global(html.dark) does not compile in scoped styles” pitfall — see §16 item 9.

12. Tab System Conventions (Two Tab Types + Smooth Switching)

Section titled “12. Tab System Conventions (Two Tab Types + Smooth Switching)”

This project has two kinds of tabs/switchers with different conventions — do not confuse them:

12.1 Scenario 1: Page Option Switching (TabBar / ProfilePage)

Section titled “12.1 Scenario 1: Page Option Switching (TabBar / ProfilePage)”

For “switching options on the same page”: personal center, system settings, team management.

  • Component: TabBar.vue (with sliding indicator).
  • Container bg-neutral-100 dark:bg-neutral-800; the active state is handled by the sliding pill background, text dark:text-neutral-50.
  • No focus glow ring allowed (focus:ring-2 focus:ring-primary-500 causes “glows forever after clicking until you click elsewhere”) — keep only focus:outline-none. Baseline: ProfilePage.vue (no glow).
  • Dark active-state baseline: container dark:bg-neutral-800, active pill dark:bg-neutral-700, text dark:text-neutral-50.

12.2 Scenario 2: Selector Switching (Select All/Clear, Multi-Select, Smart Scheduling Switcher)

Section titled “12.2 Scenario 2: Selector Switching (Select All/Clear, Multi-Select, Smart Scheduling Switcher)”

For “select all/multi-select/clear” or mode switching (week/day, week/date, permission select, etc.), scattered across forms (EmptyClassroomQuery, BatchTeamEventModal, TeamScheduleTaskModal, AllTeamsViewPage, SystemSettings storage switcher).

  • Container bg-neutral-100 dark:bg-neutral-900 (one step darker than the card’s neutral-800, forming an inset track — otherwise it is indistinguishable from the card).
  • Active: bg-white dark:bg-neutral-700 + dark:text-neutral-50 (text must be light, otherwise black text on a dark surface is unreadable).
  • Inactive: text-neutral-600 dark:text-neutral-300, hover dark:hover:bg-neutral-800.
  • Smooth: transition-all / transition-colors are already in place; color changes are smooth.
  1. Active text must have dark:text-neutral-50 — a script once deleted it, leaving black text on a dark surface unreadable.
  2. Active background must not equal the container color (both dark:bg-neutral-800 makes the active state invisible).
  3. The container background must differ from the card it sits in (same dark:bg-neutral-800 makes the track invisible) → selector tracks use dark:bg-neutral-900.
  4. Page tabs must not have a focus glow.

  • The 17 DiceBear-style avatarPresets groups are converged to a single primary/neutral anchor tone (no more rainbow colors).
  • Preset avatar upload: avatarPresetSvgToFile rasterizes the generated SVG to PNG before uploading. Reason: the backend ALLOWED_IMAGE_EXTENSIONS only accepts jpg/jpeg/png/gif/webp, not SVG; and serving SVG statically is an XSS vector.
  • Remote avatars (e.g. gastigado.cnies.org) without CORS headers block browser fetch; <img> display is unaffected.

14. Backend Integration (availability / Avatar Upload)

Section titled “14. Backend Integration (availability / Avatar Upload)”
  • The frontend getWeekNumber returns a calendar week (max 53 per year); the backend availability routes originally limited week ≤ 30 → returned 422.
  • Fix: both the team and temporary availability routes raised the week cap le=30 → le=53 (reference_date is the real basis for week resolution; week is only a fallback).
  • Backend ALLOWED_IMAGE_EXTENSIONS = {jpg,jpeg,png,gif,webp}, no SVG.
  • Frontend preset avatars are rasterized from SVG to PNG before upload, bypassing the backend validation and avoiding SVG XSS.

The complete inventory for looking up “which component does this UI use, and is dark mode covered”. All are dark-adapted unless noted.

ComponentPurposeNotes
BaseLayout.astroGlobal token variables, fonts, body backgroundDark-mode foundation
Navigation.vueDesktop sidebarTheme toggle button (dual-rendered icons, dark:block/hidden)
MobileDrawer.vueMobile drawer
MobileBottomTabBar.vueMobile bottom bar
Footer.vueFooter
ComponentPurpose
MySchedulePage.vueMy schedule main page (week/day/calendar/list views, schedule selection, share management, stats cards)
ScheduleGanttWeekView.vue / ScheduleDayListView.vue / ScheduleCalendar.vueWeek/day/calendar views (ScheduleCalendar’s .week-view/.time-column use non-scoped <style> dark overrides)
ScheduleEditor.vue / EventModal.vue / EventDetailModal.vue / StackedEventsModal.vue / TeamEventDetailModal.vueEditor/event modals
ScheduleAdjuster.vueSchedule adjustment (swap workdays/set holidays, TabBar stretch)
ScheduleImporter.vue / ImportScheduleModal.vue / JwxtConnectionModeSelector.vue / ImportOptionsModal.vueSchedule import (WebVPN/academic-system selection, step hints)
ScheduleList.vueSchedule list
ExportOptionsModal.vue / ShareOptionsModal.vue / ShareScheduleView.vue / TeamAvailabilityShareModal.vueExport/share
PickerPopover.vueDate/time picker
EmptyClassroomQuery.vue / CsvImportModal.vue / PersonalizationModal.vueEmpty classroom query/CSV import/personalization
ComponentPurpose
MyTeamsPage.vue / TeamList.vue / AllTeamsViewPage.vueMy teams/team list/team view
TeamViewPage.vueTeam view (week/month/heatmap, filter bar)
TeamEditorModal.vueTeam management (team info/members/visibility/team actions; TabBar; batch-add schedule/smart scheduling/transfer/dissolve buttons)
DissolveTeamModal.vue / TransferTeamModal.vue / LeaveTeamModal.vueDissolve/transfer/leave confirmation modals
TeamHeatmapDrawer.vue / TeamAvailabilityGrid.vue / TeamSlotDetailDrawer.vueHeatmap/common free time
TeamScheduleTaskModal.vue / BatchTeamEventModal.vue / BatchOperationsLog.vue / TemporaryTeamDrawer.vueSmart scheduling/batch add/batch operation log/temporary booking
CreateTeam.vue / JoinTeam.vue / AvatarPresetDrawer.vue / TutorialEntry.vueCreate/join team, avatar presets
UserScheduleViewer.vue / TeamMemberSchedulePanel.vue / TeamMemberStrip.vue / ActionConfirmModal.vue / FilterSidebar.vueMember schedule/member strip/confirm/filter
ComponentPurpose
admin/UserManagementPage.vueUser management (table/dropdown/ban)
admin/AdminTeamManagement.vueTeam management (edit/delete/members)
admin/SystemSettings.vueSystem settings (site/storage/email/code injection; TabBar + CodeEditor)
admin/UserEditModal.vue / admin/BanUserModal.vue / admin/UserScheduleModal.vue / admin/ConfirmDeleteModal.vue / admin/BatchRestoreConfirmToast.vueSub-modals/confirmations/batch restore
ComponentPurpose
LoginForm.vue / RegisterForm.vue / ForgetPasswordForm.vue / ForceBindEmailModal.vue / FirstStartAdminModal.vueLogin/register/forgot password/email binding/initial admin
LandingNavbar.vue / CTASection.vue / FeatureSection.vue / 6 *Showcase.vue / PerspectiveSchedule.vueLanding page
ChangelogModal.vueChangelog (dark:prose-invert)
InAppBrowserPrompt.vueIn-app browser prompt
  1. CalloutsInfoBox.vue
  2. Tabs/segmentsTabBar.vue; selector switchers follow the “container dark:bg-neutral-900 + active dark:bg-neutral-700” baseline
  3. Inputs/selects/pickers.input-base/.select-base/.dropdown-*/.picker-* (addComponents, dark built in)
  4. Semantic color blocks → the unified bg-*-50 dark:bg-*-900/N + text-*-800 dark:text-*-200 convention
  5. Primary button hoverbg-primary-600 hover:bg-primary-500 (not hover:bg-primary-50, not the invalid opacity /300)

Each of these once caused “dark mode not applying”, “build failure”, or “interaction bug” — keep them in mind.

  1. primary-950 does not exist. dark:bg-primary-950/30 is silently a no-op → the surface stays light in dark mode. Always use primary-900.
  2. :global(.dark) does not compile into Vue scoped styles. The correct approaches: Tailwind dark: variants or CSS variables.
  3. Appending dark: outside a :class binding = build error. Check: grep -rnE "\]\s+dark:" components/ must be empty (the ] inside transition-[...] arbitrary values is a false positive).
  4. addComponents custom classes have no automatic dark variants — you must add .dark .xxx { … } overrides manually (e.g. .warning-surface).
  5. hover:bg-white/hover:bg-neutral-50 brightens in dark mode — add dark:hover:bg-neutral-800.
  6. WSL2 file watching is unreliable; the dev server goes stale. Verify modules with curl, delete caches and restart clean, and trust astro build as the source of truth.
  7. Use tokens for z-index, not magic numbers.
  8. Headless-browser audit: record a stray light color when luminance 0.299r+0.587g+0.114b > 215 and area >40×40.
  9. :global(html.dark) is not compiled inside scoped styles → use a non-scoped <style> with html.dark .xxx (ScheduleCalendar/CodeEditor/PersonalizationModal).
  10. Active tab = container color = invisible → active uses dark:bg-neutral-700.
  11. Primary button hover:bg-primary-50 makes white text disappear; invalid opacity /300 breaks the dark hover → use hover:bg-primary-500.
  12. Nested modals close on a single click: the sub-modal Teleport to="body" lives outside the parent panel’s DOM, so clicking the sub-modal triggers the parent’s outside-click. Fix: add the sub-modal’s open state to the parent Dialog’s :static and to the early-return guard in handleClose.
  13. Multiple dev servers fight over the port: a stale process holds 4322 → the browser gets stale modules. Run ss -tlnp | grep :4322 to confirm a single instance.
  14. Unified semantic color-block convention: bg-red-50 dark:bg-red-900/25, bg-blue-50 dark:bg-blue-900/30 etc. + text-*-800 dark:text-*-200.
  15. hover:dark: is a non-standard ordering and gets misjudged: functionally equivalent to dark:hover:, but regexes matching dark:bg-* will false-match. Always write dark:hover:.
  16. A repo-wide “add dark per line” script misfires: when a line already has another dark: variant, do not append a default color again; and do not treat hover:dark:bg-* as a conflicting background.
  17. JS strings concatenating classes must also carry dark (e.g. PickerPopover’s panelClass); .vue module checks cannot see styles — verify against the build output.

17. Verification Methodology (Browser Audit / Build)

Section titled “17. Verification Methodology (Browser Audit / Build)”

After any dark-mode/interaction change, run in order:

cd frontend
grep -rnE "\]\s+dark:" src/components/ # 必须为空
bun run type-check # vue-tsc 0 错误
bun run lint # 0 错误(存量 warning 可忽略)
bun run build # astro build 14 页成功

Open every page with Playwright/puppeteer, inject access_token + app-theme=dark, and iterate over all elements:

const lum = (c) => { const m = c.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); return m ? 0.299*m[1]+0.587*m[2]+0.114*m[3] : -1; };
// 对每个元素:opacity>0.05 && width>40 && height>40 && lum(bgColor)>215 → 漏网浅色

Fix each occurrence in order of area.

  • Backend uses PostgreSQL (DATABASE_URL=postgresql+psycopg://chronosync:...@localhost:5432/chronosync), not SQLite. Users inserted into SQLite are invalid for the running backend.
  • Admin account: admin / 975280hc (test only).
  • JWT is valid for 15 minutes; if the test interval is long, re-login to refresh the token.
  • Confirm only one astro dev process holds 4322: ss -tlnp | grep :4322.
  • Is the module current: curl http://localhost:4322/src/components/<File>.vue.
  • Is the style in the build: after astro build, grep the CSS/JS in dist/ (<style> is injected separately, not in the .vue module).

Group commits by feature/module, format type(scope): subject:

typePurpose
featNew feature / dark-mode adaptation
fixBug fix
docsDocumentation
refactorRefactor (no behavior change)
choreMiscellaneous

Example commits grouped by module:

feat(dark-mode): establish design token system and unified UI components
feat(dark-mode): adapt schedule and classroom views
feat(dark-mode): adapt team, heatmap and temporary-scheduling views
feat(dark-mode): adapt admin user, team and system pages
feat(dark-mode): adapt auth, landing, navigation and showcase views
feat(avatar): converge preset accents to primary palette and rasterize to PNG
fix(api): accept calendar-week in availability queries
  • Only commit your own changes. If you see things in git that are not yours (e.g. _apkwork/*, server-sync/ in .gitignore, or someone else’s uncommitted files), leave them alone; stage precisely with git add <explicit files>, never git add ..
  • One commit per module; the body explains “why changed, constraints, trade-offs”.
  • Before committing, make sure type-check + build pass.
  1. First read this DESIGN.md + tailwind.config.mjs + BaseLayout.astro.
  2. Use tokens when possible; use dark: for local tweaks; use InfoBox for callouts; use TabBar for tabs.
  3. After changes, run the §17.1 required checklist + headless-browser per-page audit.
  4. Commit per module with Conventional Commits; git add only your own files.

19. Dark Mode Implementation Details (Correct vs Wrong)

Section titled “19. Dark Mode Implementation Details (Correct vs Wrong)”
<!-- ✅ 正确:卡片在深色用 neutral-800,阴影关掉 -->
<div class="rounded-xl bg-white shadow-sm dark:bg-neutral-800 dark:shadow-none">
<!-- ❌ 错误:深色没补背景 → 白卡片 -->
<div class="rounded-xl bg-white shadow-sm">
<!-- ✅ 正确:hover 到 primary-500(仍够深,白字可见) -->
<button class="bg-primary-600 text-white hover:bg-primary-500">
<!-- ❌ 错误:hover 到 primary-50(近白)→ 白字看不见 -->
<button class="bg-primary-600 text-white hover:bg-primary-50">
<!-- ❌ 错误:非法透明度 /300(仅支持 /0–/100)→ 深色 hover 失效 -->
<button class="bg-primary-600 text-white dark:hover:bg-primary-900/300">
<!-- ✅ TabBar:激活由滑动胶囊负责,文字浅色,无 focus 光晕 -->
<TabBar :tabs="tabs" v-model="activeTab" />

19.4 Selector Switchers (Select All/Clear, etc.)

Section titled “19.4 Selector Switchers (Select All/Clear, etc.)”
<!-- ✅ 容器凹陷轨道 neutral-900(区别于卡片 800),激活 neutral-700 + 浅文字 -->
<div class="rounded-2xl bg-neutral-100 p-1 dark:bg-neutral-900">
<button :class="selected
? 'bg-white text-neutral-900 shadow dark:bg-neutral-700 dark:text-neutral-50'
: 'text-neutral-600 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-800'">全选</button>
<!-- ❌ 错误:激活=容器同色(都 800)→ 看不出激活;激活文字没 dark:text → 黑字不可读 -->
<!-- ✅ 信息:bg-blue-50 dark:bg-blue-900/30 + text-blue-800 dark:text-blue-200 -->
<div class="rounded-md bg-blue-50 p-4 text-blue-800 dark:bg-blue-900/30 dark:text-blue-200">
<!-- ❌ 错误:只写浅色 -->
<div class="rounded-md bg-blue-50 p-4 text-blue-800">
<!-- ✅ 用共享类 .input-base(addComponents 内置 dark) -->
<input class="input-base" />
<!-- ❌ 错误:手写 bg-white 忘 dark -->
<input class="bg-white border-neutral-200" />

20. Dark Mode Implementation Playbook (Step by Step)

Section titled “20. Dark Mode Implementation Playbook (Step by Step)”

The complete steps for making a page/component dark:

  1. Read the conventions: first check DESIGN.md §4 palette, §7 shared classes, §12 tab conventions.
  2. Find light colors: grep -nE "bg-white|bg-neutral-50|text-neutral-900|border-neutral-200" <File>.vue.
  3. Split into categories:
    • Page-level large surfaces/cards → tokens (var(--bg-card)) or dark:bg-neutral-800/900;
    • Semantic color blocks → per the §4.3 convention add dark:bg-*-900/N + dark:text-*-200;
    • Buttons/interactions → the correct patterns in §19.
  4. Colors hardcoded in scoped styles → add a separate non-scoped <style> with html.dark .xxx.
  5. Verify:
    • grep -rnE "\]\s+dark:" is empty;
    • vue-tsc and astro build pass;
    • headless-browser per-page audit (§17.2) confirms 0 leftover light colors, visible active states, and normal hovers.
  6. Commit: per-module Conventional Commit feat(dark-mode): ...; git add only this module’s files.

Q: A block is still white in dark mode even though the code has dark:? A: Most likely a stale dev server (§16 items 6 and 13). Verify the module with curl, confirm a single instance, hard-refresh; trust the astro build output.

Q: Cannot see the active tab? A: The active background equals the container color (both dark:bg-neutral-800). Change the active state to dark:bg-neutral-700 (§16 item 10).

Q: There is a glowing border around the tab after clicking it? A: That is focus:ring-2 focus:ring-primary-500. Remove it, keep only focus:outline-none (§12.1).

Q: The selected text is black and hard to read? A: The active state is missing dark:text-neutral-50 (§16 items 10/15).

Q: :global(html.dark) in scoped styles does not work? A: Vue scoped styles do not compile it. Use a non-scoped <style> block (§16 item 9).

Q: Nested modal (dissolve) closes on a single click? A: The sub-modal teleports to body and triggers the parent modal’s outside-click. Add the sub-modal’s open state to the parent’s :static and to the handleClose guard (§16 item 12).

Q: Is hover:dark:bg-* correct? A: Functionally equivalent to dark:hover:bg-*, but it is non-standard and gets misjudged by tooling — always write dark:hover: (§16 item 15).

Q: Backend availability returns 422? A: The frontend sends calendar weeks (max 53); the backend originally limited it to 30. Now relaxed to 53 (§14.1).

Q: Preset avatar upload returns 400? A: SVG is not accepted by the backend. The frontend rasterizes it to PNG before upload (§13).


22. Dark Mode Implementation Notes per Module (Deep Retrospective)

Section titled “22. Dark Mode Implementation Notes per Module (Deep Retrospective)”
  • ScheduleCalendar.vue’s .week-view/.time-column/.time-column-header are scoped classes with hardcoded white backgrounds — dark overrides were added with a non-scoped <style> using html.dark (§16 item 9). The scrollbar track/thumb are dark too.
  • MySchedulePage.vue’s print/export views (inline background:#ffffff) are intentionally white (for printing) — do not touch.
  • ScheduleAdjuster.vue’s “swap workdays/set holidays” has been migrated to TabBar stretch (two tabs split evenly).
  • EmptyClassroomQuery.vue’s week/day/period selector: container dark:bg-neutral-900 inset track + active dark:bg-neutral-700.
  • TeamEditorModal.vue’s batch-add schedule/smart scheduling/transfer/dissolve buttons: bg-white dark:bg-neutral-800 + border-*-300 dark:border-*-800 + text-*-700 dark:text-*-300 + hover:bg-*-50 dark:hover:bg-*-900/30.
  • The root cause of DissolveTeamModal closing on one click was the parent TeamEditorModal’s handleClose guard missing showDissolveModal; fixed: the :static list and the handleClose early return both include showDissolveModal (§16 item 12).
  • Team member badges: creator bg-blue-100 dark:bg-blue-900/40, admin bg-purple-100 dark:bg-purple-900/40.
  • Table row-action dropdown z-[260]; row focus-within:z-[220] keeps the row with an open menu above adjacent rows (z-index convention).
  • User list action cell sticky right-0 z-[90]; the dropdown menu lives inside the z-[260] container.
  • SystemSettings.vue’s code injection uses CodeEditor.vue (non-scoped dark overrides).
  • All three admin pages are fully dark: user-management (142 dark occurrences), team-management (139), system-settings (102).
  • The theme toggle icon uses dual rendering + dark:block/hidden to eliminate the hydration mismatch (§2.1).
  • The changelog ChangelogModal uses dark:prose-invert so the markdown body lightens in dark mode.
  • The six showcase mocks consume tokens (var(--bg-card) etc.) and switch automatically.
  • The 17 preset accents converge to primary/neutral.
  • Upload path: SVG→PNG rasterization (§13).