Merge pull request #6610 from deepseek-harness/ux/schedule-empty-svg

feat(ui-schedule): 更新自动化任务空状态插画
This commit is contained in:
Yifffan authored and GitHub committed 2026-10-19 22:51:11 +08:00
commit 829c2d3913
12 files changed
+237 -7

No files matched your search

+2 -2
View File
@@ -15,8 +15,8 @@
en: d152484eb41ac6b4
zh: 405b30feaf48c087
/deepseek-ai-dsh-client-ui-schedule/use-this-package:
en: edba0e1573f3ce42
zh: 77875b469d94cc62
en: 7d44cd1640bebfad
zh: 1d6b6c28a3c27115
/deepseek-ai-dsh-client-ui-schedule/use-this-package/time-zone-data-source:
en: 6bd6882dbbbefcb0
zh: 03aa15f1a65d13dc
+1 -1
View File
@@ -25,7 +25,7 @@ Manage active and inactive tasks from the Automation tasks page and the right Si
<a id="use-this-package"></a>
## Use this package
The shipped Web composition mounts `ui-schedule` with the Host Schedule service. The clock context that supplies the model's current time is preset-owned: `standard`, `cordis`, and `ptc` declare it, and `minimal` declares neither it nor the reminder tools. The Automation tasks sidebar entry opens a global page without selecting or activating a Session. The page header's New action starts a new Session, where the reminder is created by asking the model; the page itself has no creation form. Its task list supports text search and one All, Enabled, and Inactive status filter row; search matches the stored task name, the instruction, and the internal Session id, not a resolved Session title, and the empty state keeps the heading's New action beside the notice. Selecting an active or inactive task opens a second detail level with Rules and Records tabs. Every list row shows the task's stored title with its status, frequency, and next run and never the original Session name; the detail's name control shows the stored title. Rules contains the complete reminder, status, next target for active tasks, original recurrence rule, and task id. Daily rule labels show the saved local time and exact stored IANA zone, preserving nonzero seconds and milliseconds rather than substituting the browser's zone. The Linked session entry in the detail tab strip names the linked Session by its catalog title, falling back to the Session id when no title is available. When the strip cannot fit the tab list beside the labelled entry, the entry becomes an icon button with the same button style as More task actions, with the Session chat icon and a Linked session tooltip that also shows while the entry is disabled; the entry keeps focus when it switches layouts; the tab list scrolls without a visible scrollbar. It opens an available, unarchived Session after metadata loads; pending Session or Workspace metadata, an archived Session, missing Session membership, or a Workspace query error keeps it disabled with an explanation. The click rechecks availability; browsing does not unarchive the Session. Only this action requests Session activation. Deletion is available from the More task actions menu in the detail's top strip, separate from navigation.
The shipped Web composition mounts `ui-schedule` with the Host Schedule service. The clock context that supplies the model's current time is preset-owned: `standard`, `cordis`, and `ptc` declare it, and `minimal` declares neither it nor the reminder tools. The Automation tasks sidebar entry opens a global page without selecting or activating a Session. The page header's New action starts a new Session, where the reminder is created by asking the model; the page itself has no creation form. Its task list supports text search and one All, Enabled, and Inactive status filter row; search matches the stored task name, the instruction, and the internal Session id, not a resolved Session title, and the empty state shows a decorative document-and-clock illustration above its notice and New action, with colors that follow the light or dark theme. Selecting an active or inactive task opens a second detail level with Rules and Records tabs. Every list row shows the task's stored title with its status, frequency, and next run and never the original Session name; the detail's name control shows the stored title. Rules contains the complete reminder, status, next target for active tasks, original recurrence rule, and task id. Daily rule labels show the saved local time and exact stored IANA zone, preserving nonzero seconds and milliseconds rather than substituting the browser's zone. The Linked session entry in the detail tab strip names the linked Session by its catalog title, falling back to the Session id when no title is available. When the strip cannot fit the tab list beside the labelled entry, the entry becomes an icon button with the same button style as More task actions, with the Session chat icon and a Linked session tooltip that also shows while the entry is disabled; the entry keeps focus when it switches layouts; the tab list scrolls without a visible scrollbar. It opens an available, unarchived Session after metadata loads; pending Session or Workspace metadata, an archived Session, missing Session membership, or a Workspace query error keeps it disabled with an explanation. The click rechecks availability; browsing does not unarchive the Session. Only this action requests Session activation. Deletion is available from the More task actions menu in the detail's top strip, separate from navigation.
The page header keeps the title at its natural width and moves New to another line when the two no longer fit together. The action label stays on one line.
+1 -1
View File
@@ -25,7 +25,7 @@ kind: "package-reference"
<a id="use-this-package"></a>
## 使用本包
随发行版交付的 Web 组合把 `ui-schedule` 与 Host Schedule 服务一起挂载。为模型提供当前时间的时钟上下文归 preset 所有:`standard`、`cordis` 与 `ptc` 声明它,`minimal` 既不声明它,也不声明提醒工具。侧栏的自动化任务入口打开全局页面,不选择或激活会话。页面页头的“新建”操作会打开一个新会话,提醒由模型在该会话中创建;页面本身没有创建表单。列表支持文本搜索和一行“全部/已开启/未运行”状态筛选;搜索匹配任务已存储的名称、指令和内部会话 id,不匹配解析出的会话标题,空状态在提示旁保留页头的“新建”操作。选择活动或未运行任务后,进入含“规则”和“任务运行记录”页签的第二层详情。每条列表行显示任务已存储的标题及其状态、频率和下次运行,不显示原会话名称;详情中的名称输入框显示任务已存储的标题。“规则”显示完整提醒、状态、活动任务的下一目标时间、原始重复规则和任务 id。每天的规则标签显示已保存的本地时间与精确的存储 IANA 时区,保留非零秒与毫秒,而不替换为浏览器时区。详情页签条内的“关联会话”入口按会话标题显示,取不到标题时回退显示会话 id。页签条放不下页签列表和带文字的入口时,入口收为与“更多任务操作”同样式的图标按钮,使用会话对话图标,悬停提示“关联会话”,入口禁用时也会显示;切换形态时保留焦点;页签列表可滚动但不显示滚动条。元数据加载后,它可打开可用且未归档的会话;会话或 Workspace 元数据尚未就绪、会话已归档、会话不在列表中或 Workspace 查询失败时,入口保持禁用并显示原因。点击时会重新检查可用性;浏览不会取消会话归档。仅此操作请求激活会话。删除位于详情顶部条带的“更多任务操作”菜单。
随发行版交付的 Web 组合把 `ui-schedule` 与 Host Schedule 服务一起挂载。为模型提供当前时间的时钟上下文归 preset 所有:`standard`、`cordis` 与 `ptc` 声明它,`minimal` 既不声明它,也不声明提醒工具。侧栏的自动化任务入口打开全局页面,不选择或激活会话。页面页头的“新建”操作会打开一个新会话,提醒由模型在该会话中创建;页面本身没有创建表单。列表支持文本搜索和一行“全部/已开启/未运行”状态筛选;搜索匹配任务已存储的名称、指令和内部会话 id,不匹配解析出的会话标题,空状态在提示和“新建”操作上方显示装饰性的文档与时钟插画,配色跟随浅色或深色主题。选择活动或未运行任务后,进入含“规则”和“任务运行记录”页签的第二层详情。每条列表行显示任务已存储的标题及其状态、频率和下次运行,不显示原会话名称;详情中的名称输入框显示任务已存储的标题。“规则”显示完整提醒、状态、活动任务的下一目标时间、原始重复规则和任务 id。每天的规则标签显示已保存的本地时间与精确的存储 IANA 时区,保留非零秒与毫秒,而不替换为浏览器时区。详情页签条内的“关联会话”入口按会话标题显示,取不到标题时回退显示会话 id。页签条放不下页签列表和带文字的入口时,入口收为与“更多任务操作”同样式的图标按钮,使用会话对话图标,悬停提示“关联会话”,入口禁用时也会显示;切换形态时保留焦点;页签列表可滚动但不显示滚动条。元数据加载后,它可打开可用且未归档的会话;会话或 Workspace 元数据尚未就绪、会话已归档、会话不在列表中或 Workspace 查询失败时,入口保持禁用并显示原因。点击时会重新检查可用性;浏览不会取消会话归档。仅此操作请求激活会话。删除位于详情顶部条带的“更多任务操作”菜单。
页头按标题的自然宽度排布;标题和“新建”无法并排容纳时,将按钮移到下一行。操作文案保持单行。
@@ -0,0 +1,37 @@
/** 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 - optional CSS class applied to the root SVG.
* @returns the decorative inline SVG element.
*/
export function ScheduleEmptyIllustration({ className }: ScheduleEmptyIllustrationProps) {
return (
<svg className={className} width="170" height="170" viewBox="0 0 170 170" fill="none" aria-hidden="true" focusable="false">
<path d="M163.819 97.9382C163.819 126.572 144.48 135.329 115.847 135.329C87.2129 135.329 60.1276 126.572 60.1276 97.9382C60.1276 69.3047 101.003 46.0927 129.637 46.0927C158.27 46.0927 163.819 69.3047 163.819 97.9382Z" fill="var(--dsw-alias-illustration-background)" />
<path d="M9.49521 88.6735C20.8278 111.218 39.5801 110.429 62.2137 99.0519C84.8474 87.6748 102.791 70.018 91.4589 47.4731C80.1263 24.9282 38.6291 22.8935 15.9955 34.2706C-6.63818 45.6478 -1.83735 66.1286 9.49521 88.6735Z" fill="var(--dsw-alias-illustration-background)" />
<path d="M25.2636 34.442C24.727 29.8132 28.0445 25.6258 32.6733 25.0893L101.373 17.126C106.002 16.5895 110.189 19.9069 110.726 24.5358L120.107 105.466C120.643 110.095 117.326 114.282 112.697 114.818L43.9972 122.782C39.3684 123.318 35.181 120.001 34.6445 115.372L25.2636 34.442Z" fill="var(--dsw-alias-illustration-backing)" />
<path d="M40.169 25.1368C40.169 20.477 43.9466 16.6995 48.6064 16.6995H117.766C122.426 16.6995 126.204 20.477 126.204 25.1368V106.609C126.204 111.268 122.426 115.046 117.766 115.046H48.6064C43.9466 115.046 40.169 111.268 40.169 106.609V25.1368Z" fill="var(--dsw-alias-illustration-surface)" />
<path d="M110.258 44.9883V50.3516H72.0831V44.9883H110.258Z" fill="var(--dsw-alias-illustration-detail)" />
<path d="M110.258 63.2803V68.6436H72.0831V63.2803H110.258Z" fill="var(--dsw-alias-illustration-detail)" />
<path d="M91.171 81.5713V86.9346H72.0831V81.5713H91.171Z" fill="var(--dsw-alias-illustration-detail)" />
<path d="M56.1124 43.4998H64.0964V51.4838H56.1124V43.4998Z" fill="var(--dsw-alias-illustration-detail)" />
<path d="M56.1124 61.97H64.0964V69.954H56.1124V61.97Z" fill="var(--dsw-alias-illustration-detail)" />
<path d="M56.1124 80.2607H64.0964V88.2448H56.1124V80.2607Z" fill="var(--dsw-alias-illustration-detail)" />
<path d="M126.205 148.002C126.205 149.615 105.321 150.923 79.5592 150.923C53.7973 150.923 32.9132 149.615 32.9132 148.002C32.9132 146.388 53.7973 145.08 79.5592 145.08C105.321 145.08 126.205 146.388 126.205 148.002Z" fill="var(--dsw-alias-illustration-shadow)" />
<path d="M13.1544 119.318C14.7101 119.318 16.2657 119.43 17.8214 119.656C17.9942 119.682 18.1671 119.709 18.3399 119.737V120.238C18.1671 120.266 17.9942 120.293 17.8214 120.319C16.2657 120.545 14.7101 120.657 13.1544 120.657C11.5986 120.657 10.0423 120.545 8.48643 120.319C8.31357 120.293 8.14073 120.266 7.96787 120.238V119.737C8.14073 119.709 8.31357 119.682 8.48643 119.656C10.0423 119.43 11.5986 119.318 13.1544 119.318Z" fill="var(--dsw-alias-illustration-detail)" />
<path d="M125.197 2.96338C126.287 2.96338 127.378 3.076 128.468 3.30225C128.589 3.32738 128.71 3.3544 128.831 3.38232V3.88428C128.71 3.9122 128.589 3.93922 128.468 3.96436C127.378 4.1906 126.287 4.30322 125.197 4.30322C124.108 4.30319 123.018 4.19057 121.928 3.96436C121.807 3.93922 121.686 3.91221 121.565 3.88428V3.38232C121.686 3.3544 121.807 3.32738 121.928 3.30225C123.018 3.07603 124.108 2.96341 125.197 2.96338Z" fill="var(--dsw-alias-illustration-detail)" />
<path d="M145.187 32.6399C146.742 32.6399 148.298 32.7525 149.854 32.9788C150.026 33.0039 150.199 33.0309 150.372 33.0588V33.5608C150.199 33.5887 150.026 33.6157 149.854 33.6409C148.298 33.8671 146.742 33.9797 145.187 33.9797C143.631 33.9797 142.074 33.8671 140.519 33.6409C140.346 33.6157 140.173 33.5887 140 33.5608V33.0588C140.173 33.0309 140.346 33.0039 140.519 32.9788C142.074 32.7525 143.631 32.6399 145.187 32.6399Z" fill="var(--dsw-alias-illustration-detail)" />
<path d="M12.4776 119.987C12.4777 118.431 12.5903 116.875 12.8165 115.32C12.8416 115.147 12.8687 114.974 12.8966 114.801H13.3985C13.4265 114.974 13.4535 115.147 13.4786 115.32C13.7048 116.875 13.8175 118.431 13.8175 119.987C13.8175 121.542 13.7049 123.099 13.4786 124.654C13.4535 124.827 13.4265 125 13.3985 125.173H12.8966C12.8687 125 12.8416 124.827 12.8165 124.654C12.5903 123.099 12.4776 121.542 12.4776 119.987Z" fill="var(--dsw-alias-illustration-detail)" />
<path d="M124.522 3.63379C124.522 2.54381 124.634 1.45326 124.86 0.363281C124.886 0.242199 124.913 0.121083 124.941 2.19411e-08L125.442 0C125.47 0.121082 125.497 0.242199 125.523 0.363281C125.749 1.45326 125.861 2.54381 125.861 3.63379C125.861 4.72362 125.749 5.81349 125.523 6.90332C125.497 7.02442 125.47 7.1455 125.442 7.2666H124.941C124.913 7.1455 124.886 7.02442 124.86 6.90332C124.634 5.81349 124.522 4.72362 124.522 3.63379Z" fill="var(--dsw-alias-illustration-detail)" />
<path d="M144.509 33.3101C144.509 31.7544 144.622 30.1987 144.848 28.6431C144.873 28.4702 144.9 28.2974 144.928 28.1245H145.43C145.458 28.2974 145.485 28.4702 145.51 28.6431C145.736 30.1987 145.849 31.7544 145.849 33.3101C145.849 34.8659 145.736 36.4222 145.51 37.978C145.485 38.1509 145.458 38.3237 145.43 38.4966H144.928C144.9 38.3237 144.873 38.1509 144.848 37.978C144.622 36.4222 144.509 34.8659 144.509 33.3101Z" fill="var(--dsw-alias-illustration-detail)" />
<path d="M149.05 98.8527C149.05 112.66 137.857 123.853 124.05 123.853C110.242 123.853 99.0496 112.66 99.0496 98.8527C99.0496 85.0455 110.242 73.8527 124.05 73.8527C137.857 73.8527 149.05 85.0455 149.05 98.8527Z" fill="var(--dsw-alias-illustration-detail)" />
<path d="M144.192 98.8529C144.192 109.978 135.174 118.997 124.049 118.997C112.923 118.997 103.905 109.978 103.905 98.8529C103.905 87.7278 112.923 78.709 124.049 78.709C135.174 78.709 144.192 87.7278 144.192 98.8529Z" fill="var(--dsw-alias-illustration-inset)" />
<path d="M125.124 86.7281L125.123 99.2824L134.923 104.132L132.824 108.375L122.76 103.395C121.309 102.677 120.39 101.197 120.39 99.5782L120.389 86.7275L125.124 86.7281Z" fill="var(--dsw-alias-illustration-detail)" />
</svg>
)
}
@@ -388,6 +388,14 @@
color: var(--dsw-alias-label-caption);
}
.emptyIllustration {
flex: none;
width: 110px;
max-width: 100%;
height: auto;
margin-bottom: 12px;
}
/* The mock's `.empty h3 + .btn`: the empty state's action keeps the mock's 16px
above. */
.emptyAction {
@@ -13,6 +13,7 @@ import { TaskDetail, useTaskDetail, type TaskDetailInjected } from './TaskDetail
import { useRelativeClock } from './relative-clock.ts'
import { formatScheduleFrequency, nextRunParts, taskName, zoneLabel } from './schedule-format.ts'
import type { FrequencyZone } from './schedule-format.ts'
import { ScheduleEmptyIllustration } from './ScheduleEmptyIllustration.tsx'
import css from './TaskManagerPage.module.css'
/** Injected catalog and task actions for the management page. */
@@ -151,7 +152,7 @@ export function TaskManagerPage(props: TaskManagerPageProps) {
<div className={css.list}>
{selected === undefined && <CatalogFeedback status={status} populated={rows.length > 0} onRetry={onRetry} t={t} />}
{status === 'ready' && rows.length === 0 && <div className={css.empty} role="status">
<IconClockOutlineRegular size={24} className={css.emptyGlyph} />
<ScheduleEmptyIllustration className={css.emptyIllustration} />
<h2>{t(emptyTitle)}</h2>
<Button variant="outline" className={css.emptyAction} onClick={onNewTask}>
{t('empty.action')}
@@ -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('')
@@ -3761,6 +3779,10 @@ describe('Mock detail metrics shared with the task list', () => {
expect(heading).toMatch(/font-size:\s*14px;/)
expect(heading).toMatch(/font-weight:\s*400;/)
expect(rule('emptyGlyph')).toMatch(/margin-bottom:\s*12px;/)
expect(rule('emptyIllustration')).toMatch(/width:\s*110px;/)
expect(rule('emptyIllustration')).toMatch(/max-width:\s*100%;/)
expect(rule('emptyIllustration')).toMatch(/height:\s*auto;/)
expect(rule('emptyIllustration')).toMatch(/margin-bottom:\s*12px;/)
// The mock's `.empty h3 + .btn`.
expect(rule('emptyAction')).toMatch(/margin-top:\s*16px;/)
})
+2 -2
View File
@@ -33,8 +33,8 @@
en: 3eb9b0e3365b2db3
zh: c73acc55dd897266
/deepseek-ai-dsh-client-ui-theme/understand-the-implementation/stylesheets:
en: 2da5b2af3a965090
zh: 883c91afc6bf105d
en: c47fd726c865cdd3
zh: 7c62b6e5857c29a7
/deepseek-ai-dsh-client-ui-theme/understand-the-implementation/scrollbar-rebinding:
en: bf76e7c34a331595
zh: 5accd0260ef7c61e
+2
View File
@@ -69,6 +69,8 @@ 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 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.
System toasts use `--dsw-alias-toast-bg` and `--dsw-alias-toast-label` for a shared background and text color across callers. Document previews pair `--dsw-alias-bg-document-preview` with `--dsw-alias-label-document-preview` so the backdrop and status text follow the same theme. Tooltip keycaps use `--dsw-alias-tooltip-key-bg`, a lighter fill derived from the tooltip background in each palette. Switch thumbs read `--dsw-alias-switch-thumb`: white in light mode and neutral-bluish 400 in dark mode, so an off switch stays lighter than its track without the glare of pure white.
+2
View File
@@ -69,6 +69,8 @@ Desktop 的固定快捷键 `Cmd`/`Ctrl` + `+`(或 `=`)和 `Cmd`/`Ctrl` + `-`
菜单图标使用 `--dsw-alias-menu-icon`:浅色模式为 neutral-bluish 800,深色模式为 `label-primary-dimmed`。
`--dsw-alias-illustration-*` token 分别定义分层装饰插画的背景、前层表面、后层、投影、内嵌填充与细节配色。表盘使用内嵌填充。深色配色让前层表面比后层和背景更亮;内嵌填充与投影沿用后层色调。
`base.css` 仅抑制[基础控件焦点工具](../ui-primitives/README.zh.md)通过 `data-dsh-automatic-focus` 标记的聚焦元素外轮廓线;正常键盘焦点样式、边框、阴影及错误状态保持不变。
系统提示使用 `--dsw-alias-toast-bg` 和 `--dsw-alias-toast-label`,在各调用方之间统一背景与文字颜色。文档预览配对使用 `--dsw-alias-bg-document-preview` 与 `--dsw-alias-label-document-preview`,使底色与状态文字遵循相同主题。Tooltip 键帽使用 `--dsw-alias-tooltip-key-bg`,由各主题的 tooltip 背景派生稍浅的填充。开关滑块读取 `--dsw-alias-switch-thumb`:浅色模式为纯白,深色模式为 neutral-bluish 400,使关闭态滑块比轨道更亮,同时避免纯白在暗色下过亮。
@@ -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,6 +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: 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);
@@ -324,6 +336,12 @@ body[data-ds-dark-theme] {
--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: var(--dsw-static-neutral-bluish-850);
--dsw-alias-illustration-backing: 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);
@@ -0,0 +1,140 @@
/** Decorative artwork resolves theme tokens to distinct foreground, backing, and detail colors. */
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
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')
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
type Rgb = [number, number, number]
function definitions(dark: boolean): Map<string, string> {
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<string, string>, name: string): Rgb {
const seen = new Set<string>()
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<string, string>) {
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<string, string>): 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<string, string>): 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)
})
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('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('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')
})