Implement comprehensive dark/light mode overhaul for admin panel

- Refactor color palette in `ui-design-spec.md` to utilize CSS variables exclusively, eliminating fixed hex values and Tailwind utility classes.
- Complete dark mode implementation in `uiStore.ts`, ensuring proper theme application via `applyTheme()` and `applyBrand()`.
- Create `admin-theme-dark-light-audit.md` to document the transition process, outlining issues with inline styles and fixed colors.
- Introduce `theme-tokens.test.ts` to enforce rules against fixed hex colors and ensure compliance with the design system.
- Update various components and styles to replace inline styles and fixed colors with CSS variables, ensuring consistent theming across light and dark modes.
- Ensure all changes maintain visual integrity in both light and dark modes, with a focus on accessibility and contrast standards.
This commit is contained in:
hamed
2026-07-27 16:41:52 +03:30
parent c29035c21e
commit 55ab2f5dfc
73 changed files with 1485 additions and 1110 deletions
@@ -56,20 +56,20 @@ export function NewAppointmentsTable({ rows, loading, queryKey, emptyText }: Pro
}
if (!rows.length) {
return (
<p className="text-center py-[32px] text-[13.5px] text-[#7E7E7E] dark:text-[#A1A1A1]">
<p className="text-center py-[32px] text-[13.5px] text-[var(--text-2)]">
{emptyText ?? 'نوبتی برای امروز ثبت نشده'}
</p>
);
}
return (
<div className="w-full overflow-x-auto rounded-[8px] border border-solid border-[#E7E7E7] dark:border-[#35343D]">
<div className="w-full overflow-x-auto rounded-[8px] border border-solid border-[var(--border)]">
<table className="w-full border-collapse">
<thead>
<tr className="bg-[#EFEFEF] dark:bg-[#35343D]">
<tr className="bg-[var(--surface-2)]">
{HEAD.map((h, i) => (
<th
key={h}
className={`text-[#616161] dark:text-[#D7D8ED] text-[14px] font-normal py-[10px] px-[18px] whitespace-nowrap ${i === HEAD.length - 1 ? 'text-center' : 'text-start'}`}
className={`text-[var(--text-2)] dark:text-[var(--text)] text-[14px] font-normal py-[10px] px-[18px] whitespace-nowrap ${i === HEAD.length - 1 ? 'text-center' : 'text-start'}`}
>
{h}
</th>
@@ -80,7 +80,7 @@ export function NewAppointmentsTable({ rows, loading, queryKey, emptyText }: Pro
{rows.map((r, idx) => (
<tr
key={r.uuid}
className="border-b border-[#DBDBDB] dark:border-transparent hover:bg-[#f4f5fd] dark:hover:bg-[#2a2c3d] transition-colors"
className="border-b border-[var(--border-2)] dark:border-transparent hover:bg-[var(--primary-soft)] dark:hover:bg-[var(--surface-2)] transition-colors"
>
<Cell>{new Intl.NumberFormat('fa-IR').format(idx + 1)}</Cell>
<Cell>{r.patient_name || '—'}</Cell>
@@ -97,7 +97,7 @@ export function NewAppointmentsTable({ rows, loading, queryKey, emptyText }: Pro
<td className="py-[10px] px-[18px] text-center">
<Link
to={isoDay(r.slot_start) ? `/admin/appointments?date=${isoDay(r.slot_start)}` : '/admin/appointments'}
className="text-[#5559ce] text-[12px] font-medium hover:underline whitespace-nowrap"
className="text-[var(--primary)] text-[12px] font-medium hover:underline whitespace-nowrap"
>
مشاهده
</Link>
@@ -112,7 +112,7 @@ export function NewAppointmentsTable({ rows, loading, queryKey, emptyText }: Pro
/** نمایش فقط‌خواندنی وضعیت — وقتی version یا queryKey در دسترس نیست. */
function StatusPill({ status }: { status: string }) {
const meta = STATUS_META[status] ?? { label: status, color: '#9ca3af' };
const meta = STATUS_META[status] ?? { label: status, color: 'var(--text-3)' };
return (
<span
className="inline-flex items-center gap-[5px] px-[10px] py-[3px] rounded-full text-[12px] font-bold whitespace-nowrap"
@@ -128,7 +128,7 @@ function StatusPill({ status }: { status: string }) {
function Cell({ children, className = '' }: { children: React.ReactNode; className?: string }) {
return (
<td
className={`text-[#616161] dark:text-[#A1A1A1] text-[16px] font-medium py-[10px] px-[18px] whitespace-nowrap text-start ${className}`}
className={`text-[var(--text-2)] text-[16px] font-medium py-[10px] px-[18px] whitespace-nowrap text-start ${className}`}
>
{children}
</td>
@@ -31,7 +31,7 @@ describe('TauriBarChart', () => {
it('renders one bar per day of a full Jalali month', () => {
const month: ChartPoint[] = Array.from({ length: 31 }, (_, i) => ({ label: String(i + 1), value: i % 5 }));
const { container } = render(<TauriBarChart data={month} />);
expect(container.querySelectorAll('.bg-\\[\\#5559CE\\]')).toHaveLength(31);
expect(container.querySelectorAll('[class*="bg-[var(--primary)]"]')).toHaveLength(31);
});
/**
@@ -44,7 +44,7 @@ describe('TauriBarChart', () => {
const month: ChartPoint[] = Array.from({ length: 31 }, (_, i) => ({ label: String(i + 1), value: i % 5 }));
const { container } = render(<TauriBarChart data={month} />);
const bar = container.querySelector('.bg-\\[\\#5559CE\\]') as HTMLElement;
const bar = container.querySelector('[class*="bg-[var(--primary)]"]') as HTMLElement;
// کسری از ستون، نه تمام آن — وگرنه میله‌ها بدون فاصله به هم می‌چسبند
expect(bar.className).toMatch(/\bw-\[\d+%\]/);
expect(bar.className.split(/\s+/)).not.toContain('w-full');
@@ -91,7 +91,7 @@ function ChartFrame({
<div className="flex-1 flex min-h-0" style={{ alignItems: 'stretch' }}>
{/* y-axis ticks, aligned to gridlines */}
<div
className="flex flex-col justify-between text-[14px] font-normal text-[#858D9D] text-left pl-[4px] shrink-0"
className="flex flex-col justify-between text-[14px] font-normal text-[var(--text-2)] text-left pl-[4px] shrink-0"
style={{ width: yWidth, alignItems: 'flex-start' }}
>
{ticks.map((t, i) => (
@@ -106,7 +106,7 @@ function ChartFrame({
{ticks.map((_, i) => (
<div
key={i}
className="absolute left-0 right-0 border-t border-dashed border-[#E7E7E7] dark:border-[#35343D]"
className="absolute left-0 right-0 border-t border-dashed border-[var(--border)]"
style={{ top: `${(i / (ticks.length - 1)) * 100}%` }}
/>
))}
@@ -116,7 +116,7 @@ function ChartFrame({
{/* x-axis labels */}
<div className="flex pt-[8px]" style={{ paddingRight: yWidth, alignItems: 'flex-start' }}>
{labels.map((l, i) => (
<span key={i} className="flex-1 min-w-0 overflow-hidden text-center text-[10px] font-normal text-[#7E7E7E] whitespace-nowrap">
<span key={i} className="flex-1 min-w-0 overflow-hidden text-center text-[10px] font-normal text-[var(--text-2)] whitespace-nowrap">
{l}
</span>
))}
@@ -145,7 +145,7 @@ export function TauriBarChart({ data }: { data: ChartPoint[] }) {
<div key={i} className="flex-1 min-w-0 flex items-end justify-center" style={{ alignItems: 'flex-end' }}>
<div
title={faNum.format(d.value)}
className="w-[60%] max-w-[24px] rounded-t-[3px] bg-[#5559CE]"
className="w-[60%] max-w-[24px] rounded-t-[3px] bg-[var(--primary)]"
style={{
height: `${(d.value / top) * 100}%`,
minHeight: d.value > 0 ? 4 : 0,
@@ -278,7 +278,7 @@ export function TauriLineChart({ data, actualCount }: { data: ChartPoint[]; actu
top: `${(pts[i][1] / H) * 100}%`,
transform: 'translate(-50%, calc(-100% - 12px))',
background: isForecast ? 'var(--accent)' : 'var(--text)',
color: '#fff',
color: 'var(--on-primary)',
boxShadow: 'var(--shadow)',
}}
>
@@ -302,7 +302,7 @@ export function TauriLineChart({ data, actualCount }: { data: ChartPoint[]; actu
function EmptyChart() {
return (
<div className="h-[300px] w-full grid place-items-center text-[13px] text-[#7E7E7E]">
<div className="h-[300px] w-full grid place-items-center text-[13px] text-[var(--text-2)]">
دادهای برای نمایش نیست
</div>
);
@@ -31,7 +31,7 @@ function SmSelector({ options, value, onChange }: { options: SelectorOption[]; v
}
function ChartTitle({ text }: { text: string }) {
return <p className="text-[#525252] dark:text-[#D7D8ED] text-[14px] md:text-[16px] font-bold">{text}</p>;
return <p className="text-[var(--text)] text-[14px] md:text-[16px] font-bold">{text}</p>;
}
function ChartCard({
@@ -48,7 +48,7 @@ function ChartCard({
children: React.ReactNode;
}) {
return (
<div className="w-full rounded-[8px] border border-solid border-[#EFEFEF] dark:border-transparent bg-[#FFF] dark:bg-[#222433]">
<div className="w-full rounded-[8px] border border-solid border-[var(--border)] dark:border-transparent bg-[var(--surface)]">
<div className="flex px-[20px] py-[12px] md:py-[14px] lg:py-[16px] items-center justify-between gap-[12px]">
<ChartTitle text={title} />
<SmSelector options={selectorOptions} value={selectorValue} onChange={onSelectorChange} />
@@ -141,12 +141,12 @@ export function TauriDashboardView({
</div>
{/* New appointments list */}
<div className="w-full rounded-[8px] border border-solid border-transparent md:border-[#EFEFEF] dark:border-transparent bg-transparent md:bg-[#FFF] md:dark:bg-[#222433]">
<div className="w-full rounded-[8px] border border-solid border-transparent md:border-[var(--border)] dark:border-transparent bg-transparent md:bg-[var(--surface)] md:dark:bg-[var(--surface)]">
<div className="md:pt-[18px] md:px-[24px] md:pb-[20px] flex items-center justify-between">
<p className="text-[#525252] dark:text-[#D7D8ED] text-[14px] md:text-[16px] font-bold">لیست نوبتهای جدید</p>
<p className="text-[var(--text)] text-[14px] md:text-[16px] font-bold">لیست نوبتهای جدید</p>
<Link
to="/admin/appointments"
className="flex items-center gap-[4px] p-1 text-[12px] md:text-[14px] text-[#616161] dark:text-[#A1A1A1] font-normal hover:text-[#5559ce]"
className="flex items-center gap-[4px] p-1 text-[12px] md:text-[14px] text-[var(--text-2)] font-normal hover:text-[var(--primary)]"
>
نوبتها
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
@@ -19,7 +19,7 @@ export interface StatCardModel {
function TauriStatCard({ data }: { data: StatCardModel }) {
return (
<li
className={`px-[8px] md:px-[16px] lg:px-[24px] py-[18px] sm:py-[20px] md:py-[22px] lg:py-[24px] rounded-[8px] flex items-center justify-start gap-[4px] md:gap-[8px] lg:gap-[12px] ${data.cardColor} dark:bg-[#222433]`}
className={`px-[8px] md:px-[16px] lg:px-[24px] py-[18px] sm:py-[20px] md:py-[22px] lg:py-[24px] rounded-[8px] flex items-center justify-start gap-[4px] md:gap-[8px] lg:gap-[12px] ${data.cardColor} dark:bg-[var(--surface)]`}
>
<div
className={`p-[8px] w-[calc(18px+8px)] sm:w-[calc(20px+8px)] md:w-[calc(22px+8px)] lg:w-[calc(24px+8px)] min-w-[calc(18px+8px)] sm:min-w-[calc(20px+8px)] md:min-w-[calc(22px+8px)] lg:min-w-[calc(24px+8px)] h-[calc(18px+8px)] sm:h-[calc(20px+8px)] md:h-[calc(22px+8px)] lg:h-[calc(24px+8px)] grid place-items-center rounded-full ${data.iconColor}`}
@@ -27,10 +27,10 @@ function TauriStatCard({ data }: { data: StatCardModel }) {
{data.icon}
</div>
<div className="flex flex-col w-full items-start justify-center gap-[8px]">
<p className="text-[#3B3B3B] text-wrap dark:text-[#D7D8ED] text-[14px] sm:text-[16px] md:text-[18px] lg:text-[20px] font-medium">
<p className="text-[var(--text)] text-wrap text-[14px] sm:text-[16px] md:text-[18px] lg:text-[20px] font-medium">
{data.value}
</p>
<p className="text-[#616161] dark:text-[#A1A1A1] text-[12px] md:text-[14px] lg:text-[16px] font-normal">
<p className="text-[var(--text-2)] text-[12px] md:text-[14px] lg:text-[16px] font-normal">
{data.label}
</p>
</div>
@@ -65,28 +65,28 @@ export function TauriStatCards({
const cards: StatCardModel[] = [
{
cardColor: 'bg-[rgba(241,119,50,0.10)]',
iconColor: 'bg-[#F17732]',
iconColor: 'bg-[var(--accent)]',
icon: <UserAddIcon />,
value: `${formatNumber(stats.totalPatients)}+`,
label: 'تعداد کل مراجعین',
},
{
cardColor: 'bg-[rgba(0,157,121,0.10)]',
iconColor: 'bg-[#009D79]',
iconColor: 'bg-[var(--success)]',
icon: <CardIcon />,
value: formatRial(stats.totalPaymentsRials),
label: 'کل پرداختی‌ها',
},
{
cardColor: 'bg-[rgba(85,89,206,0.08)]',
iconColor: 'bg-[#5559CE]',
iconColor: 'bg-[var(--primary)]',
icon: <CardTickIcon />,
value: formatRial(stats.todayPaymentsRials),
label: 'پرداختی‌های امروز',
},
{
cardColor: 'bg-[rgba(255,192,81,0.11)]',
iconColor: 'bg-[#FFC051]',
iconColor: 'bg-[var(--warning)]',
icon: <CalendarIcon />,
value: `${formatNumber(stats.todayAppointments)}+`,
label: 'تعداد نوبت‌های امروز',
@@ -68,10 +68,10 @@ export function CalendarIcon() {
}
/** فلش رو به پایین — status chip chevron (source: ArrowDownBlueP) */
export function ChevronDownIcon({ color = '#5559CE' }: { color?: string }) {
export function ChevronDownIcon({ color = 'currentColor' }: { color?: string }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none">
<path d="M16.6004 7.4585L11.1671 12.8918C10.5254 13.5335 9.47539 13.5335 8.83372 12.8918L3.40039 7.4585" stroke={color} strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none" style={{ color }}>
<path d="M16.6004 7.4585L11.1671 12.8918C10.5254 13.5335 9.47539 13.5335 8.83372 12.8918L3.40039 7.4585" stroke="currentColor" strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}