From 773ce6f6913493465c4c59c8d69fe0412d9b84cd Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Mon, 19 Oct 2026 21:41:12 +0800 Subject: [PATCH] fix(ui-schedule): strengthen illustration palette contracts --- .../src/client/ScheduleEmptyIllustration.tsx | 12 +- .../src/client/TaskManagerPage.module.css | 1 - .../tests/task-manager-page.client.spec.tsx | 38 ++--- packages/client/ui-theme/README.i18n.yaml | 4 +- packages/client/ui-theme/README.md | 2 +- packages/client/ui-theme/README.zh.md | 2 +- .../ui-theme/src/styles/design-platform.css | 24 ++- .../tests/illustration-colors.client.spec.ts | 155 +++++++++++++++--- 8 files changed, 173 insertions(+), 65 deletions(-) diff --git a/packages/client/ui-schedule/src/client/ScheduleEmptyIllustration.tsx b/packages/client/ui-schedule/src/client/ScheduleEmptyIllustration.tsx index 9d8693453d..e6cee2a839 100644 --- a/packages/client/ui-schedule/src/client/ScheduleEmptyIllustration.tsx +++ b/packages/client/ui-schedule/src/client/ScheduleEmptyIllustration.tsx @@ -1,11 +1,15 @@ /** Decorative document-and-clock artwork with theme-owned colors. */ +interface ScheduleEmptyIllustrationProps { + readonly className?: string | undefined +} + /** * Render the empty-state illustration without adding an accessible announcement. - * @param props - page-owned illustration sizing and spacing. - * @returns the same vector paths in either theme. + * @param props - optional CSS class applied to the root SVG. + * @returns the decorative inline SVG element. */ -export function ScheduleEmptyIllustration({ className }: { className: string | undefined }) { +export function ScheduleEmptyIllustration({ className }: ScheduleEmptyIllustrationProps) { return ( ) diff --git a/packages/client/ui-schedule/src/client/TaskManagerPage.module.css b/packages/client/ui-schedule/src/client/TaskManagerPage.module.css index 7fea4b416f..ebdb77b4b8 100644 --- a/packages/client/ui-schedule/src/client/TaskManagerPage.module.css +++ b/packages/client/ui-schedule/src/client/TaskManagerPage.module.css @@ -389,7 +389,6 @@ } .emptyIllustration { - display: block; flex: none; width: 110px; max-width: 100%; diff --git a/packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx b/packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx index 8c96b8dfc2..a7892edc19 100644 --- a/packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx +++ b/packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx @@ -891,6 +891,24 @@ describe('Task manager catalog', () => { expect(screen.getByRole('button', { name: en['statusFilter.all'] }).getAttribute('aria-pressed')).toBe('true') }) + it('renders a decorative document-and-clock illustration in an empty catalog', () => { + mount({ records: [], status: 'ready' }) + const illustration = screen.getByRole('status').querySelector('svg') + expect(illustration?.getAttribute('aria-hidden')).toBe('true') + expect(illustration?.getAttribute('focusable')).toBe('false') + expect(illustration?.getAttribute('viewBox')).toBe('0 0 170 170') + const paths = illustration?.querySelectorAll('path') + expect(paths).toHaveLength(20) + // Roles follow the approved artwork's paint order, including overlapping paper and clock layers. + expect(paths?.[0]?.getAttribute('fill')).toBe('var(--dsw-alias-illustration-background)') + expect(paths?.[2]?.getAttribute('fill')).toBe('var(--dsw-alias-illustration-backing)') + expect(paths?.[3]?.getAttribute('fill')).toBe('var(--dsw-alias-illustration-surface)') + expect(paths?.[10]?.getAttribute('fill')).toBe('var(--dsw-alias-illustration-shadow)') + expect(paths?.[17]?.getAttribute('fill')).toBe('var(--dsw-alias-illustration-detail)') + expect(paths?.[18]?.getAttribute('fill')).toBe('var(--dsw-alias-illustration-inset)') + expect(emptyNewTaskButton().textContent).toBe(en['empty.action']) + }) + it('distinguishes an empty catalog, loading, and a failed read', () => { const h = mount({ records: [], status: 'loading' }) expect(screen.getByRole('status', { name: en['list.loading'] }).textContent).toBe('') @@ -903,26 +921,6 @@ describe('Task manager catalog', () => { expect(h.props.onRetry).toHaveBeenCalledOnce() h.update({ status: 'ready' }) expect(screen.getByRole('status').textContent).toContain(en['list.empty']) - const illustration = screen.getByRole('status').querySelector('svg') - expect(illustration?.getAttribute('aria-hidden')).toBe('true') - expect(illustration?.getAttribute('focusable')).toBe('false') - expect(illustration?.getAttribute('viewBox')).toBe('0 0 170 170') - const illustrationPaths = illustration?.querySelectorAll('path') - expect(illustrationPaths).toHaveLength(20) - expect(illustrationPaths?.[0]?.getAttribute('fill')).toBe('var(--dsw-alias-illustration-background)') - expect(illustrationPaths?.[2]?.getAttribute('fill')).toBe('var(--dsw-alias-illustration-backing)') - expect(illustrationPaths?.[3]?.getAttribute('fill')).toBe('var(--dsw-alias-illustration-surface)') - expect(illustrationPaths?.[10]?.getAttribute('fill')).toBe('var(--dsw-alias-illustration-shadow)') - expect(illustrationPaths?.[17]?.getAttribute('fill')).toBe('var(--dsw-alias-illustration-detail)') - expect(illustrationPaths?.[18]?.getAttribute('fill')).toBe('var(--dsw-alias-illustration-clock-face)') - expect(new Set(Array.from(illustration?.querySelectorAll('path') ?? [], path => path.getAttribute('fill')))).toEqual(new Set([ - 'var(--dsw-alias-illustration-background)', - 'var(--dsw-alias-illustration-surface)', - 'var(--dsw-alias-illustration-backing)', - 'var(--dsw-alias-illustration-shadow)', - 'var(--dsw-alias-illustration-clock-face)', - 'var(--dsw-alias-illustration-detail)', - ])) // The mock keeps one creation entry in the empty state whether or not a // query is active, so the action never disappears behind a filter. expect(emptyNewTaskButton().textContent).toBe(en['empty.action']) diff --git a/packages/client/ui-theme/README.i18n.yaml b/packages/client/ui-theme/README.i18n.yaml index f8eff08261..c1ba11cb4d 100644 --- a/packages/client/ui-theme/README.i18n.yaml +++ b/packages/client/ui-theme/README.i18n.yaml @@ -33,8 +33,8 @@ en: 3eb9b0e3365b2db3 zh: c73acc55dd897266 /deepseek-ai-dsh-client-ui-theme/understand-the-implementation/stylesheets: - en: ceb4c9fc94b29f20 - zh: 990da98c95093353 + en: c47fd726c865cdd3 + zh: 7c62b6e5857c29a7 /deepseek-ai-dsh-client-ui-theme/understand-the-implementation/scrollbar-rebinding: en: bf76e7c34a331595 zh: 5accd0260ef7c61e diff --git a/packages/client/ui-theme/README.md b/packages/client/ui-theme/README.md index 819a259712..512c7b7b12 100644 --- a/packages/client/ui-theme/README.md +++ b/packages/client/ui-theme/README.md @@ -69,7 +69,7 @@ In pointer modality, `html[data-input-modality='pointer'] body :focus-visible:no Menu icons use `--dsw-alias-menu-icon`: neutral-bluish 800 in light mode and `label-primary-dimmed` in dark mode. -The `--dsw-alias-illustration-*` tokens separate decorative artwork backgrounds, foreground surfaces, backing layers, shadows, clock faces, and details. The dark palette keeps foreground surfaces brighter than backing layers and backgrounds; clock faces and shadows share the backing tone. +The `--dsw-alias-illustration-*` tokens separate layered decorative artwork backgrounds, foreground surfaces, backing layers, shadows, inset fills, and details. Clock faces use the inset fill. The dark palette keeps foreground surfaces brighter than backing layers and backgrounds; inset fills and shadows share the backing tone. `base.css` suppresses only the outline of focused elements marked `data-dsh-automatic-focus` by the [primitive focus helper](../ui-primitives/README.md); ordinary keyboard focus styling, borders, shadows, and error states remain intact. diff --git a/packages/client/ui-theme/README.zh.md b/packages/client/ui-theme/README.zh.md index 099d87cb40..01cba2458a 100644 --- a/packages/client/ui-theme/README.zh.md +++ b/packages/client/ui-theme/README.zh.md @@ -69,7 +69,7 @@ Desktop 的固定快捷键 `Cmd`/`Ctrl` + `+`(或 `=`)和 `Cmd`/`Ctrl` + `-` 菜单图标使用 `--dsw-alias-menu-icon`:浅色模式为 neutral-bluish 800,深色模式为 `label-primary-dimmed`。 -`--dsw-alias-illustration-*` token 分别定义装饰插画的背景、前层表面、后层、投影、表盘与细节配色。深色配色让前层表面比后层和背景更亮;表盘与投影沿用后层色调。 +`--dsw-alias-illustration-*` token 分别定义分层装饰插画的背景、前层表面、后层、投影、内嵌填充与细节配色。表盘使用内嵌填充。深色配色让前层表面比后层和背景更亮;内嵌填充与投影沿用后层色调。 `base.css` 仅抑制[基础控件焦点工具](../ui-primitives/README.zh.md)通过 `data-dsh-automatic-focus` 标记的聚焦元素外轮廓线;正常键盘焦点样式、边框、阴影及错误状态保持不变。 diff --git a/packages/client/ui-theme/src/styles/design-platform.css b/packages/client/ui-theme/src/styles/design-platform.css index f5fa8450b2..3d49d226b3 100644 --- a/packages/client/ui-theme/src/styles/design-platform.css +++ b/packages/client/ui-theme/src/styles/design-platform.css @@ -59,9 +59,11 @@ body { --dsw-static-neutral-bluish-150: rgb(233, 236, 242); --dsw-static-neutral-bluish-200: rgb(225, 229, 238); --dsw-static-neutral-bluish-300: rgb(207, 211, 214); + --dsw-static-neutral-bluish-310: rgb(202, 210, 219); --dsw-static-neutral-bluish-400: rgb(173, 178, 184); --dsw-static-neutral-bluish-500: rgb(151, 157, 166); --dsw-static-neutral-bluish-50: rgb(249, 250, 251); + --dsw-static-neutral-bluish-55: rgb(245, 247, 250); --dsw-static-neutral-bluish-600: rgb(129, 133, 140); --dsw-static-neutral-bluish-60: rgb(245, 246, 247); --dsw-static-neutral-bluish-700: rgb(97, 102, 107); @@ -71,6 +73,7 @@ body { --dsw-static-neutral-bluish-850: rgb(44, 44, 46); --dsw-static-neutral-bluish-875: rgb(35, 35, 36); --dsw-static-neutral-bluish-900: rgb(27, 27, 28); + --dsw-static-neutral-bluish-90: rgb(237, 240, 244); --dsw-static-neutral-bluish-950: rgb(21, 21, 23); --dsw-static-red-100: rgb(254, 226, 226); --dsw-static-red-400: rgb(242, 90, 90); @@ -140,9 +143,11 @@ body[data-ds-dark-theme] { --dsw-static-neutral-bluish-150: rgb(233, 236, 242); --dsw-static-neutral-bluish-200: rgb(225, 229, 238); --dsw-static-neutral-bluish-300: rgb(207, 211, 214); + --dsw-static-neutral-bluish-310: rgb(202, 210, 219); --dsw-static-neutral-bluish-400: rgb(173, 178, 184); --dsw-static-neutral-bluish-500: rgb(151, 157, 166); --dsw-static-neutral-bluish-50: rgb(249, 250, 251); + --dsw-static-neutral-bluish-55: rgb(245, 247, 250); --dsw-static-neutral-bluish-600: rgb(129, 133, 140); --dsw-static-neutral-bluish-60: rgb(249, 250, 251); --dsw-static-neutral-bluish-700: rgb(97, 102, 107); @@ -152,6 +157,7 @@ body[data-ds-dark-theme] { --dsw-static-neutral-bluish-850: rgb(44, 44, 46); --dsw-static-neutral-bluish-875: rgb(35, 35, 36); --dsw-static-neutral-bluish-900: rgb(27, 27, 28); + --dsw-static-neutral-bluish-90: rgb(237, 240, 244); --dsw-static-neutral-bluish-950: rgb(21, 21, 23); --dsw-static-red-100: rgb(254, 226, 226); --dsw-static-red-400: rgb(242, 90, 90); @@ -206,12 +212,12 @@ body { --dsw-alias-button-tool-bar-fill-invisible: rgba(31, 31, 31, 0.36); --dsw-alias-button-tool-bar-fill: rgba(84, 85, 87, 0.5); --dsw-alias-button-tool-bar-hover: rgba(84, 85, 87, 0.6); - --dsw-alias-illustration-background: rgb(245, 247, 250); - --dsw-alias-illustration-surface: rgb(237, 240, 244); - --dsw-alias-illustration-clock-face: var(--dsw-alias-illustration-surface); - --dsw-alias-illustration-backing: rgb(202, 210, 219); - --dsw-alias-illustration-shadow: rgb(237, 240, 244); - --dsw-alias-illustration-detail: rgb(202, 210, 219); + --dsw-alias-illustration-background: var(--dsw-static-neutral-bluish-55); + --dsw-alias-illustration-backing: var(--dsw-static-neutral-bluish-310); + --dsw-alias-illustration-detail: var(--dsw-static-neutral-bluish-310); + --dsw-alias-illustration-inset: var(--dsw-alias-illustration-surface); + --dsw-alias-illustration-shadow: var(--dsw-alias-illustration-surface); + --dsw-alias-illustration-surface: var(--dsw-static-neutral-bluish-90); --dsw-alias-interactive-bg-active: rgba(38, 49, 72, 0.1); --dsw-alias-interactive-bg-hover-accent: rgba(38, 49, 72, 0.14); --dsw-alias-interactive-bg-hover-danger: rgba(236, 19, 19, 0.05); @@ -331,11 +337,11 @@ body[data-ds-dark-theme] { --dsw-alias-button-tool-bar-fill: rgba(84, 85, 87, 0.5); --dsw-alias-button-tool-bar-hover: rgba(84, 85, 87, 0.6); --dsw-alias-illustration-background: var(--dsw-static-neutral-bluish-850); - --dsw-alias-illustration-surface: var(--dsw-static-neutral-bluish-700); - --dsw-alias-illustration-clock-face: var(--dsw-alias-illustration-backing); --dsw-alias-illustration-backing: var(--dsw-static-neutral-bluish-750); - --dsw-alias-illustration-shadow: var(--dsw-static-neutral-bluish-750); --dsw-alias-illustration-detail: var(--dsw-static-neutral-bluish-600); + --dsw-alias-illustration-inset: var(--dsw-alias-illustration-backing); + --dsw-alias-illustration-shadow: var(--dsw-alias-illustration-backing); + --dsw-alias-illustration-surface: var(--dsw-static-neutral-bluish-700); --dsw-alias-interactive-bg-active: rgba(255, 255, 255, 0.14); --dsw-alias-interactive-bg-hover-accent: rgba(255, 255, 255, 0.24); --dsw-alias-interactive-bg-hover-danger: rgba(242, 90, 90, 0.15); diff --git a/packages/client/ui-theme/tests/illustration-colors.client.spec.ts b/packages/client/ui-theme/tests/illustration-colors.client.spec.ts index adc0a7939a..1be6166c8f 100644 --- a/packages/client/ui-theme/tests/illustration-colors.client.spec.ts +++ b/packages/client/ui-theme/tests/illustration-colors.client.spec.ts @@ -1,39 +1,140 @@ -// @vitest-environment jsdom -/** Decorative artwork keeps separate foreground, backing, and detail colors. */ +/** Decorative artwork resolves theme tokens to distinct foreground, backing, and detail colors. */ import { readFileSync } from 'node:fs' import { resolve } from 'node:path' -import { afterEach, beforeEach, expect, it } from 'vitest' +import { expect, it } from 'vitest' +import { parseRules, varReferences } from './stylesheet-scan.ts' const stylesheet = readFileSync(resolve(import.meta.dirname, '../src/styles/design-platform.css'), 'utf8') -let style: HTMLStyleElement +const illustration = readFileSync(resolve(import.meta.dirname, '../../ui-schedule/src/client/ScheduleEmptyIllustration.tsx'), 'utf8') +const rules = parseRules(stylesheet) +const PREFIX = '--dsw-alias-illustration-' +const ROLES = ['background', 'backing', 'detail', 'inset', 'shadow', 'surface'] as const -beforeEach(() => { - style = document.createElement('style') - style.textContent = stylesheet - document.head.append(style) +type Rgb = [number, number, number] + +function definitions(dark: boolean): Map { + const selector = dark ? 'body[data-ds-dark-theme]' : 'body' + return new Map(rules.filter(rule => rule.selectors.includes(selector)).flatMap(rule => rule.declarations)) +} + +function resolveColor(palette: Map, name: string): Rgb { + const seen = new Set() + let current = name + while (palette.has(current) && !seen.has(current)) { + seen.add(current) + const value = palette.get(current)! + const [reference] = varReferences(value) + if (reference !== undefined) { + expect(value, current).toMatch(/^var\(\s*--[\w-]+\s*\)$/) + current = reference + continue + } + const rgb = /^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/.exec(value) + expect(rgb, `${name}: ${current} = ${value}`).not.toBeNull() + return [Number(rgb![1]), Number(rgb![2]), Number(rgb![3])] + } + throw new Error(`Unresolved color ${name}: ${current}`) +} + +function illustrationColors(palette: Map) { + return Object.fromEntries(ROLES.map(role => [role, resolveColor(palette, `${PREFIX}${role}`)])) +} + +function luminance(rgb: Rgb): number { + const linear = rgb.map((channel) => { + const value = channel / 255 + return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4 + }) + return linear[0]! * 0.2126 + linear[1]! * 0.7152 + linear[2]! * 0.0722 +} + +function expectDarkLayers(palette: Map): void { + const color = (role: string): Rgb => resolveColor(palette, `${PREFIX}${role}`) + expect(luminance(color('surface'))).toBeGreaterThan(luminance(color('backing'))) + expect(luminance(color('backing'))).toBeGreaterThan(luminance(color('background'))) + for (const role of ['surface', 'backing', 'background']) expect(color('detail')).not.toEqual(color(role)) + expect(color('inset')).toEqual(color('backing')) + expect(color('shadow')).toEqual(color('backing')) +} + +function expectConsumerColors(source: string, palette: Map): void { + const consumed = new Set(varReferences(source)) + const defined = [...palette.keys()].filter(name => name.startsWith(PREFIX)).sort() + expect([...consumed].sort()).toEqual(defined) + expect(consumed.size).toBe(ROLES.length) + for (const name of consumed) resolveColor(palette, name) +} + +const lightColors = { + background: [245, 247, 250], + backing: [202, 210, 219], + detail: [202, 210, 219], + inset: [237, 240, 244], + shadow: [237, 240, 244], + surface: [237, 240, 244], +} + +it('preserves the original light artwork colors, including surface-colored insets and shadows', () => { + expect(illustrationColors(definitions(false))).toEqual(lightColors) }) -afterEach(() => { - style.remove() - document.body.removeAttribute('data-ds-dark-theme') +it.each([false, true])('defines the approved light static steps in the dark=%s palette', (dark) => { + const palette = definitions(dark) + expect(resolveColor(palette, '--dsw-static-neutral-bluish-55')).toEqual([245, 247, 250]) + expect(resolveColor(palette, '--dsw-static-neutral-bluish-90')).toEqual([237, 240, 244]) + expect(resolveColor(palette, '--dsw-static-neutral-bluish-310')).toEqual([202, 210, 219]) }) -it('preserves the light illustration palette and paper-colored clock face', () => { - const colors = getComputedStyle(document.body) - expect(colors.getPropertyValue('--dsw-alias-illustration-background').trim()).toBe('rgb(245,247,250)') - expect(colors.getPropertyValue('--dsw-alias-illustration-surface').trim()).toBe('rgb(237,240,244)') - expect(colors.getPropertyValue('--dsw-alias-illustration-backing').trim()).toBe('rgb(202,210,219)') - expect(colors.getPropertyValue('--dsw-alias-illustration-detail').trim()).toBe('rgb(202,210,219)') - expect(colors.getPropertyValue('--dsw-alias-illustration-clock-face').trim()).toBe('var(--dsw-alias-illustration-surface)') +it('resolves the dark artwork colors through the actual static palette', () => { + expect(illustrationColors(definitions(true))).toEqual({ + background: [44, 44, 46], + backing: [67, 69, 74], + detail: [129, 133, 140], + inset: [67, 69, 74], + shadow: [67, 69, 74], + surface: [97, 102, 107], + }) }) -it('separates dark paper layers from details and uses the middle tone for the clock face', () => { - document.body.setAttribute('data-ds-dark-theme', '') - const colors = getComputedStyle(document.body) - expect(colors.getPropertyValue('--dsw-alias-illustration-surface').trim()).toBe('var(--dsw-static-neutral-bluish-700)') - expect(colors.getPropertyValue('--dsw-alias-illustration-backing').trim()).toBe('var(--dsw-static-neutral-bluish-750)') - expect(colors.getPropertyValue('--dsw-alias-illustration-background').trim()).toBe('var(--dsw-static-neutral-bluish-850)') - expect(colors.getPropertyValue('--dsw-alias-illustration-detail').trim()).toBe('var(--dsw-static-neutral-bluish-600)') - expect(colors.getPropertyValue('--dsw-alias-illustration-shadow').trim()).toBe('var(--dsw-static-neutral-bluish-750)') - expect(colors.getPropertyValue('--dsw-alias-illustration-clock-face').trim()).toBe('var(--dsw-alias-illustration-backing)') +it('keeps dark surfaces brighter than backing and background, with distinct details and backing-colored insets and shadows', () => { + expectDarkLayers(definitions(true)) +}) + +it.each([false, true])('resolves the illustration consumer names in the dark=%s palette', (dark) => { + expectConsumerColors(illustration, definitions(dark)) +}) + +it('rejects light static-color drift even when alias names stay unchanged', () => { + const palette = definitions(false) + palette.set('--dsw-static-neutral-bluish-90', 'rgb(235, 238, 242)') + expect(() => { expect(illustrationColors(palette)).toEqual(lightColors) }).toThrow() +}) + +it.each([ + ['surface', 'backing'], + ['backing', 'surface'], + ['background', 'backing'], + ['detail', 'surface'], + ['inset', 'surface'], + ['shadow', 'surface'], +])('rejects a dark %s role changed to %s', (role, target) => { + const palette = definitions(true) + palette.set(`${PREFIX}${role}`, `var(${PREFIX}${target})`) + expect(() => { expectDarkLayers(palette) }).toThrow() +}) + +it.each([false, true])('rejects renamed consumers and missing definitions in the dark=%s palette', (dark) => { + const palette = definitions(dark) + const renamed = illustration.replaceAll(`${PREFIX}inset`, `${PREFIX}clock-face`) + expect(() => { expectConsumerColors(renamed, palette) }).toThrow() + const misspelled = illustration.replace(PREFIX, '--dsw-alias-illustation-') + expect(() => { expectConsumerColors(misspelled, palette) }).toThrow() + palette.delete(`${PREFIX}inset`) + expect(() => { expectConsumerColors(illustration, palette) }).toThrow() +}) + +it.each(['--dsw-static-missing', `${PREFIX}inset`])('rejects an unresolved or cyclic inset reference to %s', (target) => { + const palette = definitions(true) + palette.set(`${PREFIX}inset`, `var(${target})`) + expect(() => resolveColor(palette, `${PREFIX}inset`)).toThrow('Unresolved color') })