diff --git a/admin-dashboard/README.md b/admin-dashboard/README.md index d92b368..bfe5e3a 100644 --- a/admin-dashboard/README.md +++ b/admin-dashboard/README.md @@ -104,7 +104,7 @@ Browser ## 技术与目录 -运行时直接依赖只有 Next.js、React、Ant Design、Lucide 图标与 Ant SSR 样式运行时;没有 Ant Design Pro、Redux、Axios、Mock.js、Tailwind、Radix 或图表库。Lucide 与设计系统的图标契约一致,并按组件 tree-shaking。Pages Router 使用 `_document.tsx` 提取 Ant Design CSS-in-JS 样式,避免首屏闪烁。Next 16 的开发与生产构建显式使用 Webpack,以确保 Ant Design 与提取器共享同一个样式上下文;CI 会检查产物中存在真实 Ant 组件规则,而不只检查空的 style 标签。 +运行时直接依赖只有 Next.js、React、Ant Design、Lucide 图标与 Ant SSR 样式运行时;没有 Ant Design Pro、Redux、Axios、Mock.js、Tailwind、Radix 或图表库。Lucide 与设计系统的图标契约一致,并按组件 tree-shaking。Pages Router 使用 `_document.tsx` 提取 Ant Design CSS-in-JS 样式,避免首屏闪烁;浅色与深色使用独立的 Ant CSS variable scope,避免服务端浅色变量覆盖客户端深色状态。Next 16 的开发与生产构建显式使用 Webpack,以确保 Ant Design 与提取器共享同一个样式上下文;CI 会检查真实 Ant 组件规则、主题隔离和关键暗色文字对比度。 ```text admin-dashboard/ diff --git a/admin-dashboard/scripts/check-design-contract.mjs b/admin-dashboard/scripts/check-design-contract.mjs index 38fb09e..1377653 100644 --- a/admin-dashboard/scripts/check-design-contract.mjs +++ b/admin-dashboard/scripts/check-design-contract.mjs @@ -7,6 +7,16 @@ const fail = (message) => { const expectText = (source, value, context) => { if (!source.includes(value)) fail(`${context} is missing ${value}`); }; +const hexLuminance = (hex) => { + const channels = hex.match(/[\da-f]{2}/gi)?.map((value) => Number.parseInt(value, 16) / 255); + if (!channels || channels.length !== 3) fail(`invalid contrast color ${hex}`); + const linear = channels.map((value) => value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4); + return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]; +}; +const contrast = (foreground, background) => { + const [lighter, darker] = [hexLuminance(foreground), hexLuminance(background)].sort((a, b) => b - a); + return (lighter + 0.05) / (darker + 0.05); +}; const globalCss = read('src/style/global.css'); const dashboardCss = read('src/style/dashboard.module.css'); @@ -21,7 +31,11 @@ expectText(documentSource, "matchMedia('(prefers-color-scheme: dark)')", 'pre-pa expectText(documentSource, "localStorage.getItem('waitqueue-theme')", 'legacy theme migration'); expectText(documentSource, "dataset.themeReady=mode==='dark'?'false':'true'", 'dark first-paint guard'); expectText(globalCss, "[data-theme-ready='false']", 'dark first-paint CSS guard'); -expectText(theme, 'colorTextPlaceholder: colors.foregroundSubtle', 'accessible placeholder mapping'); +expectText(theme, 'colorTextPlaceholder: supportingText', 'accessible placeholder mapping'); +expectText(theme, 'cssVar: { key: `waitqueue-${mode}` }', 'isolated light and dark Ant variable scopes'); +expectText(theme, 'defaultColor: colors.foreground', 'readable Ant button mapping'); +expectText(theme, 'itemColor: colors.foregroundMuted', 'readable Ant navigation mapping'); +expectText(theme, 'lastItemColor: colors.foreground', 'readable Ant breadcrumb mapping'); expectText(theme, "dataset.themeReady = 'true'", 'hydrated theme readiness'); const requiredTokens = [ @@ -32,6 +46,7 @@ const requiredTokens = [ '--ui-primary:', '--ui-ai:', '--ui-border:', + '--ui-control-border:', '--ui-font-sans:', '--ui-space-4:', '--ui-radius-lg:', @@ -43,6 +58,22 @@ const requiredTokens = [ ]; for (const token of requiredTokens) expectText(globalCss, token, 'semantic token contract'); +const darkBlock = globalCss.match(/:root\[data-theme='dark'\]\s*{([\s\S]*?)\n}/)?.[1]; +if (!darkBlock) fail('dark semantic token block is missing'); +const darkToken = (name) => { + const value = darkBlock.match(new RegExp(`${name}:\\s*(#[\\da-f]{6})`, 'i'))?.[1]; + if (!value) fail(`dark semantic token is missing ${name}`); + return value; +}; +const darkSurface = darkToken('--ui-surface-raised'); +for (const [role, minimum] of [['--ui-foreground', 7], ['--ui-foreground-muted', 4.5], ['--ui-primary', 4.5]]) { + const ratio = contrast(darkToken(role), darkSurface); + if (ratio < minimum) fail(`${role} dark contrast ${ratio.toFixed(2)} is below ${minimum}`); +} +if (contrast(darkToken('--ui-control-border'), darkSurface) < 3) { + fail('dark control boundary contrast is below 3'); +} + if (/#[\da-f]{3,8}\b/i.test(dashboardCss) || /#[\da-f]{3,8}\b/i.test(page)) { fail('product layout must use semantic variables instead of raw palette values'); } diff --git a/admin-dashboard/src/style/dashboard.module.css b/admin-dashboard/src/style/dashboard.module.css index a4e025e..479a9fe 100644 --- a/admin-dashboard/src/style/dashboard.module.css +++ b/admin-dashboard/src/style/dashboard.module.css @@ -101,7 +101,7 @@ width: 100%; margin: 0 !important; overflow: visible; - font-size: 10px; + font-size: 12px; font-weight: 600; line-height: 1.2; text-align: center; @@ -182,13 +182,13 @@ .catalogHeaderCopy small { margin-top: 1px; color: var(--ui-foreground-muted); - font-size: 11px; + font-size: 12px; } .catalogHeader :global(.ant-badge-count) { color: var(--ui-primary-foreground); box-shadow: none; - font-size: 11px; + font-size: 12px; } .catalogSearch { @@ -197,7 +197,7 @@ .catalogSearch :global(.ant-input-affix-wrapper) { background: var(--ui-surface-raised); - border-color: var(--ui-border); + border-color: var(--ui-control-border); } .catalogSearch :global(.ant-input-prefix) { @@ -210,7 +210,7 @@ justify-content: space-between; padding: 10px 18px 7px; color: var(--ui-foreground-muted); - font-size: 11px; + font-size: 12px; font-weight: 650; } @@ -253,7 +253,7 @@ .queueNav > button.queueNavActive { color: var(--ui-foreground); background: var(--ui-surface-raised); - border-color: var(--ui-border); + border-color: var(--ui-control-border); box-shadow: inset 2px 0 var(--ui-primary), var(--ui-shadow-sm); } @@ -305,7 +305,7 @@ overflow: hidden; margin-top: 3px; color: var(--ui-foreground-muted); - font-size: 11px; + font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } @@ -325,7 +325,7 @@ .queueNavCount small { color: var(--ui-foreground-muted); - font-size: 10px; + font-size: 12px; } .catalogEmpty { @@ -349,7 +349,7 @@ justify-content: center; gap: 5px; color: var(--ui-foreground-muted); - font-size: 11px; + font-size: 12px; } .mainLayout { @@ -406,8 +406,8 @@ } .updated { - color: var(--ui-foreground-subtle); - font-size: 11px; + color: var(--ui-foreground-muted); + font-size: 12px; white-space: nowrap; } @@ -496,7 +496,7 @@ align-items: center; gap: 10px; margin-top: 12px; - color: var(--ui-foreground-subtle); + color: var(--ui-foreground-muted); font-size: 12px; } @@ -612,7 +612,7 @@ .metricCard :global(.ant-statistic-content-suffix) { margin-left: 5px; - color: var(--ui-foreground-subtle); + color: var(--ui-foreground-muted); font-size: 12px; font-weight: 500; } @@ -620,7 +620,7 @@ .metricCard > :global(.ant-card-body) > p { min-height: 20px; margin: 8px 0 0; - color: var(--ui-foreground-subtle); + color: var(--ui-foreground-muted); font-size: 12px; } @@ -689,8 +689,8 @@ .cardHeading small { margin-top: 2px; - color: var(--ui-foreground-subtle); - font-size: 11px; + color: var(--ui-foreground-muted); + font-size: 12px; font-weight: 400; } @@ -708,6 +708,14 @@ border-start-end-radius: 0 !important; } +.queueHealthCard :global(.ant-btn-link) { + color: var(--ui-primary); +} + +.queueHealthCard :global(.ant-btn-link:hover) { + color: var(--ui-primary-hover); +} + .queueIdentity { display: flex; min-width: 0; @@ -737,8 +745,8 @@ .queueIdentity span { overflow: hidden; - color: var(--ui-foreground-subtle); - font-size: 11px; + color: var(--ui-foreground-muted); + font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } @@ -764,8 +772,8 @@ .tableMetric small { margin-top: 2px; - color: var(--ui-foreground-subtle); - font-size: 11px; + color: var(--ui-foreground-muted); + font-size: 12px; } .capacityCell { @@ -786,7 +794,7 @@ } .dangerText { - color: var(--ui-danger) !important; + color: color-mix(in srgb, var(--ui-danger) 92%, var(--ui-foreground)) !important; } .mobileQueueList, @@ -835,7 +843,7 @@ color: var(--ui-foreground-muted); background: var(--ui-surface-inset); border-radius: 10px; - font-size: 11px; + font-size: 12px; line-height: 1.5; } @@ -877,8 +885,8 @@ } .capacitySummary small { - color: var(--ui-foreground-subtle); - font-size: 11px; + color: var(--ui-foreground-muted); + font-size: 12px; } .scheduleList { @@ -989,8 +997,8 @@ justify-content: space-between; gap: 12px; margin: 13px 0; - color: var(--ui-foreground-subtle); - font-size: 11px; + color: var(--ui-foreground-muted); + font-size: 12px; } .pagination { @@ -1062,9 +1070,9 @@ .healthCopy small { overflow: hidden; margin-top: 2px; - color: var(--ui-foreground-subtle); + color: var(--ui-foreground-muted); font-family: var(--ui-font-mono); - font-size: 10px; + font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } @@ -1114,13 +1122,13 @@ gap: 20px; margin-top: 28px; padding: 14px 2px 0; - color: var(--ui-foreground-subtle); + color: var(--ui-foreground-muted); border-top: 1px solid var(--ui-border); - font-size: 11px; + font-size: 12px; } .footer code { - font-size: 10px; + font-size: 11px; } .formGrid { @@ -1379,8 +1387,8 @@ } .mobileQueueMetrics small { - color: var(--ui-foreground-subtle); - font-size: 10px; + color: var(--ui-foreground-muted); + font-size: 12px; } .mobileQueueMetrics b { diff --git a/admin-dashboard/src/style/global.css b/admin-dashboard/src/style/global.css index af68654..c280113 100644 --- a/admin-dashboard/src/style/global.css +++ b/admin-dashboard/src/style/global.css @@ -17,6 +17,7 @@ --ui-foreground-subtle: #6c6e67; --ui-border: #d9d4ca; --ui-border-strong: #c6c1b7; + --ui-control-border: #929088; --ui-primary: #5f735e; --ui-primary-hover: #50644f; --ui-primary-foreground: #fbfdf8; @@ -87,6 +88,7 @@ --ui-foreground-subtle: #93948c; --ui-border: #3f423c; --ui-border-strong: #555950; + --ui-control-border: #737b6e; --ui-primary: #a1b69d; --ui-primary-hover: #b2c6ae; --ui-primary-foreground: #1c251c; diff --git a/admin-dashboard/src/theme/control-room-theme.tsx b/admin-dashboard/src/theme/control-room-theme.tsx index 627de02..17b6911 100644 --- a/admin-dashboard/src/theme/control-room-theme.tsx +++ b/admin-dashboard/src/theme/control-room-theme.tsx @@ -21,6 +21,7 @@ interface SemanticPalette { foregroundSubtle: string; border: string; borderStrong: string; + controlBorder: string; primary: string; primaryHover: string; primaryForeground: string; @@ -43,7 +44,7 @@ const PALETTE: Record = { light: { canvas: '#f5f1e8', canvasSubtle: '#eee9df', navigation: '#e6e1d7', surface: '#fbf9f4', surfaceRaised: '#fffdf8', surfaceInset: '#f0ece3', foreground: '#282925', foregroundMuted: '#5f615b', - foregroundSubtle: '#6c6e67', border: '#d9d4ca', borderStrong: '#c6c1b7', primary: '#5f735e', + foregroundSubtle: '#6c6e67', border: '#d9d4ca', borderStrong: '#c6c1b7', controlBorder: '#929088', primary: '#5f735e', primaryHover: '#50644f', primaryForeground: '#fbfdf8', primarySoft: '#e2e9df', info: '#476e84', infoSoft: '#e1edf2', success: '#537153', successSoft: '#e0eadf', warning: '#8b672b', warningSoft: '#f4e8cf', danger: '#9a4e45', dangerSoft: '#f4dfdb', @@ -54,7 +55,7 @@ const PALETTE: Record = { dark: { canvas: '#1e201d', canvasSubtle: '#242622', navigation: '#292b27', surface: '#272925', surfaceRaised: '#2e302c', surfaceInset: '#222420', foreground: '#efede7', foregroundMuted: '#b6b5ad', - foregroundSubtle: '#93948c', border: '#3f423c', borderStrong: '#555950', primary: '#a1b69d', + foregroundSubtle: '#93948c', border: '#3f423c', borderStrong: '#555950', controlBorder: '#737b6e', primary: '#a1b69d', primaryHover: '#b2c6ae', primaryForeground: '#1c251c', primarySoft: '#344035', info: '#8eb6ca', infoSoft: '#2c4049', success: '#9dba9a', successSoft: '#314132', warning: '#d4b069', warningSoft: '#493d28', danger: '#d99488', dangerSoft: '#4d302d', @@ -72,9 +73,12 @@ const STORAGE_KEY = 'waitqueue-warm-theme'; function themeConfig(mode: ColorMode): ThemeConfig { const colors = PALETTE[mode]; const dark = mode === 'dark'; + const supportingText = dark ? colors.foregroundMuted : colors.foregroundSubtle; return { algorithm: dark ? antTheme.darkAlgorithm : antTheme.defaultAlgorithm, - cssVar: { key: 'waitqueue' }, + // Keep SSR light variables and hydrated dark variables in separate scopes. + // A shared key lets the server's later light rule override dark component vars. + cssVar: { key: `waitqueue-${mode}` }, token: { colorPrimary: colors.primary, colorPrimaryHover: colors.primaryHover, @@ -92,8 +96,18 @@ function themeConfig(mode: ColorMode): ThemeConfig { colorText: colors.foreground, colorTextSecondary: colors.foregroundMuted, colorTextTertiary: colors.foregroundSubtle, - colorTextPlaceholder: colors.foregroundSubtle, - colorBorder: colors.border, + colorTextQuaternary: colors.foregroundSubtle, + colorTextPlaceholder: supportingText, + colorTextHeading: colors.foreground, + colorTextLabel: colors.foregroundMuted, + colorTextDescription: colors.foregroundMuted, + colorTextDisabled: colors.foregroundSubtle, + colorIcon: colors.foregroundMuted, + colorIconHover: colors.foreground, + colorBgContainerDisabled: colors.surfaceInset, + colorBorderDisabled: colors.border, + colorSplit: colors.border, + colorBorder: colors.controlBorder, colorBorderSecondary: colors.border, colorLink: colors.primary, colorLinkHover: colors.primaryHover, @@ -119,16 +133,23 @@ function themeConfig(mode: ColorMode): ThemeConfig { Layout: { bodyBg: colors.canvas, headerBg: colors.canvas, + headerColor: colors.foreground, siderBg: colors.navigation, + lightSiderBg: colors.navigation, }, Card: { colorBgContainer: colors.surface, headerBg: 'transparent', + extraColor: colors.foregroundMuted, + actionsBg: colors.surfaceInset, paddingLG: 20, }, Menu: { itemBg: 'transparent', subMenuItemBg: 'transparent', + itemColor: colors.foregroundMuted, + itemDisabledColor: colors.foregroundSubtle, + groupTitleColor: colors.foregroundSubtle, itemSelectedBg: colors.surfaceRaised, itemSelectedColor: colors.primary, itemHoverBg: colors.surface, @@ -137,27 +158,60 @@ function themeConfig(mode: ColorMode): ThemeConfig { activeBarBorderWidth: 0, }, Table: { + colorText: colors.foreground, headerBg: colors.surfaceInset, headerColor: colors.foregroundMuted, - rowHoverBg: colors.primarySoft, + rowHoverBg: colors.surfaceRaised, + rowSelectedBg: colors.primarySoft, + rowSelectedHoverBg: colors.primarySoft, borderColor: colors.border, cellPaddingBlock: 13, cellPaddingInline: 14, }, Button: { primaryColor: colors.primaryForeground, + defaultColor: colors.foreground, + defaultBg: colors.surfaceRaised, + defaultBorderColor: colors.controlBorder, + defaultHoverBg: colors.surface, + defaultHoverColor: colors.primaryHover, + defaultHoverBorderColor: colors.primary, + defaultActiveBg: colors.surfaceInset, + defaultActiveColor: colors.primaryHover, + defaultActiveBorderColor: colors.primary, + textTextColor: colors.foregroundMuted, + textTextHoverColor: colors.foreground, + textTextActiveColor: colors.primary, primaryShadow: 'none', defaultShadow: 'none', dangerShadow: 'none', }, Input: { colorBgContainer: colors.surfaceRaised, + colorText: colors.foreground, + colorTextPlaceholder: supportingText, + colorTextDisabled: colors.foregroundSubtle, + colorBgContainerDisabled: colors.surfaceInset, + hoverBg: colors.surfaceRaised, + activeBg: colors.surfaceRaised, + hoverBorderColor: colors.primary, + activeBorderColor: colors.primary, activeShadow: `0 0 0 3px ${dark ? 'rgba(172,194,168,.18)' : 'rgba(113,134,111,.18)'}`, }, Select: { colorBgContainer: colors.surfaceRaised, + colorText: colors.foreground, + colorTextPlaceholder: supportingText, + colorTextDisabled: colors.foregroundSubtle, optionSelectedBg: colors.primarySoft, }, + Breadcrumb: { + itemColor: colors.foregroundMuted, + linkColor: colors.foregroundMuted, + linkHoverColor: colors.primary, + lastItemColor: colors.foreground, + separatorColor: colors.foregroundSubtle, + }, Modal: { contentBg: colors.surfaceRaised, headerBg: colors.surfaceRaised,