Merge pull request #6662 from deepseek-harness/fix/iphone-button-outline
fix(client): stabilize shared button outlines in Safari
This commit is contained in:
commit
89b9e5b1e8
21 files changed
+267
-32
No files matched your search
@@ -63,5 +63,5 @@
|
||||
.reject:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover-danger);
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
border-color: transparent;
|
||||
--dsh-button-outline-color: transparent;
|
||||
}
|
||||
@@ -21,8 +21,8 @@
|
||||
en: 18e10884b02f288f
|
||||
zh: 06debd0a4a88bdac
|
||||
/deepseek-ai-dsh-client-ui-directory-picker-browse/understand-the-implementation:
|
||||
en: fb217b7d152731db
|
||||
zh: 9ee3c0a9f15a5a29
|
||||
en: 66780e5a328376eb
|
||||
zh: 7c58a39492a70967
|
||||
/deepseek-ai-dsh-client-ui-directory-picker-browse/further-exploration:
|
||||
en: 1aa7d7ba154532ed
|
||||
zh: 5bda0ac2de26758c
|
||||
|
||||
@@ -41,6 +41,8 @@ Step through folders, edit the path directly, or filter the last pane by prefix;
|
||||
|
||||
The dialog is a 680×500 Miller-column view (clamped on short or narrow viewports), driven by the host `listDirectory` and `createDirectory` primitives through `ctx.workspaces`. Both registrations install as one transactional effect through nested `ctx.slots.inject()` calls, because either declaring entry may activate later or replace its declaration; the dialog's copy lives in this package's own locale namespace so the two dictionaries land as a unit. Browse failures stay inside the dialog's own alert surfaces, so this occupant never drives the owner's `onError` arm. The node half is an empty `apply` that keeps the plugin on the host roster.
|
||||
|
||||
The New folder and Cancel actions use the shared [Button](../ui-primitives/README.md)'s inset hairline, which preserves their layout border and keeps the stroke complete at fractional button widths.
|
||||
|
||||
</details>
|
||||
|
||||
-----
|
||||
|
||||
@@ -41,6 +41,8 @@ kind: "package-reference"
|
||||
|
||||
对话框是 680×500 的 Miller 分栏视图(在较矮或较窄的视口中限制尺寸),经 `ctx.workspaces` 驱动宿主的 `listDirectory` 与 `createDirectory` 原语。两处注册经嵌套的 `ctx.slots.inject()` 调用作为一次事务性效果安装,因为任一声明条目都可能晚些激活或替换其声明;对话框文案注册在本包自己的 locale 命名空间下,让两份字典作为一个单元落地。浏览类失败留在对话框自己的提示区内,因此本填充从不驱动持有方的 `onError` 分支。node 半部是一个空 `apply`,让插件留在宿主名单上。
|
||||
|
||||
New folder 和 Cancel 操作使用共享 [Button](../ui-primitives/README.zh.md) 的内侧细描边,保留布局边框,并使小数宽度的按钮保持完整描边。
|
||||
|
||||
</details>
|
||||
|
||||
-----
|
||||
|
||||
@@ -467,7 +467,7 @@
|
||||
and the outline hover rebinds to a red wash instead of the gray one. */
|
||||
.danger {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
border-color: color-mix(in srgb, var(--dsw-alias-state-error-primary) 30%, transparent);
|
||||
--dsh-button-outline-color: color-mix(in srgb, var(--dsw-alias-state-error-primary) 30%, transparent);
|
||||
--dsw-alias-interactive-bg-hover: color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
en: 7bae72c97d0449f5
|
||||
zh: 4589ecc2b216edc5
|
||||
/deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog:
|
||||
en: 03388f40dc593990
|
||||
zh: 5f1541a6e20c8cfe
|
||||
en: adaaabb712de1a3d
|
||||
zh: 45d6286e39880274
|
||||
/deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons:
|
||||
en: 28ac8f0b1edbcf81
|
||||
zh: 5c4c324bf84a282f
|
||||
@@ -30,8 +30,8 @@
|
||||
en: 4aef65dea11de083
|
||||
zh: abc22cd7c65cdf38
|
||||
/deepseek-ai-dsh-client-ui-primitives/understand-the-implementation:
|
||||
en: 0b4082dbb778476f
|
||||
zh: b6daaaf9de675b57
|
||||
en: 846ac0f563a53a64
|
||||
zh: bb89d7647bdcc1ab
|
||||
/deepseek-ai-dsh-client-ui-primitives/understand-the-implementation/source-map:
|
||||
en: 44eb32370cbeafe8
|
||||
zh: 728009c66375c6bb
|
||||
|
||||
@@ -40,7 +40,7 @@ Check this table before writing a control in a feature package. A plugin cannot
|
||||
|
||||
| Export | What it is |
|
||||
|---|---|
|
||||
| `Button` | Clickable action; `variant` selects `primary`, `ghost`, `outline`, or `toolbar`. Its ref targets the native button for focus and overlay anchoring. |
|
||||
| `Button` | Clickable action; `variant` selects `primary`, `ghost`, `outline`, or `toolbar`. Outlined actions use an inset hairline. Its ref targets the native button for focus and overlay anchoring. |
|
||||
| `Switch` | Two-state toggle, 36×20. The off-state thumb reads `--dsw-alias-switch-thumb` so it stays light in both themes, and the on-state thumb contrasts with the brand track. Disabled controls use half opacity. `label` is required, so the control cannot ship unnamed. |
|
||||
| `SegmentedControl` | Tablist of two or more equal-width segments with one sliding indicator, for switching a card or panel between a few modes; the owner holds the selection and `label` names the list. `id` seeds each tab's id (`<id>-<value>`) and the panel it controls (`<id>-<value>-panel`), which the owner renders and points back at the tab with `aria-labelledby`; a segment may be `disabled` with a `title`, and `disabled` on the control locks every segment while the shown panel has work in flight. |
|
||||
| `Checkbox` | Labeled native checkbox with controlled state, keyboard interaction, and disabled styling; the caller supplies localized `label` text. |
|
||||
@@ -121,7 +121,7 @@ The atoms cannot read the application locale, so every piece of user-facing copy
|
||||
<a id="understand-the-implementation"></a>
|
||||
## Understand the implementation
|
||||
|
||||
`Button` uses H36/R12 for `md` and H28/R8 for `sm`, including outlined controls. Menus and cards follow the [shared radius rules](../../../docs/web-styling.md#corner-radii-and-settings-cards); feature classes preserve control geometry.
|
||||
`Button` uses H36/R12 for `md` and H28/R8 for `sm`, including outlined controls. The `outline` variant preserves a transparent 0.5px layout border and paints its stroke with an inset shadow. The stroke reads ui-theme's DPR-aware `--dsh-hairline-width`, with a 0.5px fallback outside the theme. Feature classes customize its color through `--dsh-button-outline-color` and preserve the border width, transparent border color, and box-shadow. Menus and cards follow the [shared radius rules](../../../docs/web-styling.md#corner-radii-and-settings-cards); feature classes preserve control geometry.
|
||||
|
||||
`Menu.listClassName` styles the menu card independently of the anchor wrapper, including in portal mode. Leading icons use the `--dsw-alias-menu-icon` color; destructive icons retain their error color.
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ kind: "package-library"
|
||||
|
||||
| 导出 | 是什么 |
|
||||
|---|---|
|
||||
| `Button` | 可点击操作;`variant` 选择 `primary`、`ghost`、`outline` 或 `toolbar`。ref 指向原生按钮,供焦点控制与浮层锚定使用。 |
|
||||
| `Button` | 可点击操作;`variant` 选择 `primary`、`ghost`、`outline` 或 `toolbar`。描边操作使用内侧细线。ref 指向原生按钮,供焦点控制与浮层锚定使用。 |
|
||||
| `Switch` | 36×20 的双态开关。关闭态滑块读取 `--dsw-alias-switch-thumb`,在两种主题下均保持亮色;开启态滑块与品牌色轨道形成对比。禁用控件使用半透明样式。`label` 必填,控件不可能在没有名称的情况下发布。 |
|
||||
| `SegmentedControl` | 两段或更多等宽分段加一个滑动指示块的 tablist,用于在几种模式间切换一张卡片或面板;选中项由调用方持有,`label` 为列表命名。`id` 派生每个 tab 的 id(`<id>-<value>`)及其控制的面板 id(`<id>-<value>-panel`),面板由调用方渲染并用 `aria-labelledby` 指回 tab;分段可 `disabled` 并带 `title`,控件级 `disabled` 在当前面板有进行中的操作时锁住全部分段。 |
|
||||
| `Checkbox` | 带标签的原生复选框,支持受控状态、键盘交互和禁用样式;调用方提供本地化的 `label` 文本。 |
|
||||
@@ -121,7 +121,7 @@ kind: "package-library"
|
||||
<a id="understand-the-implementation"></a>
|
||||
## 理解实现
|
||||
|
||||
`Button` 的 `md` 使用 H36/R12,`sm` 使用 H28/R8,包含描边控件。菜单与卡片遵循[共享圆角规则](../../../docs/web-styling.zh.md#corner-radii-and-settings-cards);功能样式保留控件几何。
|
||||
`Button` 的 `md` 使用 H36/R12,`sm` 使用 H28/R8,包含描边控件。`outline` 变体保留 0.5px 透明边框用于布局,并通过内阴影绘制描边。描边宽度读取 ui-theme 随设备像素比更新的 `--dsh-hairline-width`,未安装主题时回退到 0.5px。功能样式通过 `--dsh-button-outline-color` 定制描边颜色,并保留边框宽度、透明边框颜色和 box-shadow。菜单与卡片遵循[共享圆角规则](../../../docs/web-styling.zh.md#corner-radii-and-settings-cards);功能样式保留控件几何。
|
||||
|
||||
`Menu.listClassName` 独立控制菜单卡片样式,不影响入口容器,也适用于 portal 模式。前置图标使用 `--dsw-alias-menu-icon` 文本色;破坏性操作图标保留错误色。
|
||||
|
||||
|
||||
@@ -50,10 +50,11 @@
|
||||
background: var(--dsw-alias-interactive-bg-active);
|
||||
}
|
||||
|
||||
/* Outlined actions use the same neutral stroke as settings links. */
|
||||
/* The transparent border preserves layout; the inset stroke stays complete at fractional widths. */
|
||||
.outline {
|
||||
border: 0.5px solid var(--dsw-alias-border-l3);
|
||||
border: 0.5px solid transparent;
|
||||
background: transparent;
|
||||
box-shadow: inset 0 0 0 var(--dsh-hairline-width, 0.5px) var(--dsh-button-outline-color, var(--dsw-alias-border-l3));
|
||||
}
|
||||
|
||||
.outline:hover:not(:disabled) {
|
||||
|
||||
@@ -18,7 +18,7 @@ type ButtonProps = {
|
||||
|
||||
/**
|
||||
* Render a button.
|
||||
* @param props.variant - visual family (default 'ghost').
|
||||
* @param props.variant - visual family (default 'ghost'); outlined buttons use a DPR-aware inset stroke.
|
||||
* @param props.size - 'md' 36px control with 12px corners or 'sm' 28px control with 8px corners.
|
||||
* @param props.icon - optional leading 16px icon node.
|
||||
* @param ref - native button for focus management and overlay anchors.
|
||||
|
||||
@@ -4,6 +4,7 @@ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Button, ConnectionIndicator, Input, Menu, MenuItemButton, Modal, Pill } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { POINTER_GRACE_MS } from '../src/pointer-grace.ts'
|
||||
import buttonCss from '../src/Button.module.css'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
@@ -34,9 +35,27 @@ describe('Button', () => {
|
||||
expect(onClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('outline variant renders a bordered cancel-style button', () => {
|
||||
render(<Button variant="outline">Cancel</Button>)
|
||||
expect(screen.getByRole('button', { name: 'Cancel' })).toBeDefined()
|
||||
it('selects the outline stylesheet for outlined actions only', () => {
|
||||
const { rerender } = render(<Button variant="outline">Cancel</Button>)
|
||||
const button = screen.getByRole('button', { name: 'Cancel' })
|
||||
expect(button.classList.contains(buttonCss.outline!)).toBe(true)
|
||||
for (const variant of ['primary', 'ghost', 'toolbar'] as const) {
|
||||
rerender(<Button variant={variant}>Cancel</Button>)
|
||||
expect(button.classList.contains(buttonCss.outline!)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the native element and focus when the variant changes', () => {
|
||||
const ref = createRef<HTMLButtonElement>()
|
||||
const { rerender } = render(<Button ref={ref} variant="outline">Open</Button>)
|
||||
const button = screen.getByRole('button')
|
||||
button.focus()
|
||||
rerender(<Button ref={ref} variant="primary">Open</Button>)
|
||||
expect(ref.current).toBe(button)
|
||||
expect(document.activeElement).toBe(button)
|
||||
rerender(<Button ref={ref} variant="outline">Open</Button>)
|
||||
expect(ref.current).toBe(button)
|
||||
expect(document.activeElement).toBe(button)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/** Shared Button geometry and inset stroke declarations. */
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const css = readFileSync(fileURLToPath(new URL('../src/Button.module.css', import.meta.url)), 'utf8')
|
||||
|
||||
function declarations(selector: string): string {
|
||||
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const rule = new RegExp(`\\.${escaped}\\s*\\{([^}]*)\\}`).exec(css)
|
||||
if (rule === null) throw new Error(`Button.module.css has no .${selector} rule`)
|
||||
return rule[1] ?? ''
|
||||
}
|
||||
|
||||
describe('Button styles', () => {
|
||||
it('retains a transparent layout border and paints the DPR-aware stroke inside it', () => {
|
||||
const outline = declarations('outline')
|
||||
expect(outline).toContain('border: 0.5px solid transparent;')
|
||||
expect(outline).toContain('box-shadow: inset 0 0 0 var(--dsh-hairline-width, 0.5px)')
|
||||
expect(outline).toContain('var(--dsh-button-outline-color, var(--dsw-alias-border-l3))')
|
||||
})
|
||||
|
||||
it('limits the inset stroke to outlined buttons', () => {
|
||||
for (const selector of ['button', 'primary', 'ghost:hover:not(:disabled)', 'toolbar']) {
|
||||
expect(declarations(selector)).not.toContain('box-shadow')
|
||||
}
|
||||
expect(declarations('outline:hover:not(:disabled)')).not.toContain('box-shadow')
|
||||
})
|
||||
|
||||
it('keeps the shared medium and small control heights', () => {
|
||||
expect(declarations('md')).toContain('height: 36px;')
|
||||
expect(declarations('sm')).toContain('height: 28px;')
|
||||
})
|
||||
})
|
||||
@@ -92,7 +92,7 @@
|
||||
height: 32px;
|
||||
min-width: 58px;
|
||||
padding: 0 12px;
|
||||
border: 0.5px solid var(--dsw-alias-border-l2);
|
||||
--dsh-button-outline-color: var(--dsw-alias-border-l2);
|
||||
border-radius: var(--dsw-radius-md);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
.actions { box-sizing: border-box; display: flex; justify-content: flex-end; align-items: center; width: 100%; padding: 0 24px; gap: 8px; }
|
||||
.primaryButton, .secondaryButton { box-sizing: border-box; min-width: 72px; height: 36px; border-radius: var(--dsw-radius-md); font-size: 14px; line-height: 22px; }
|
||||
.primaryButton { padding: 0 16px; font-weight: 500; }
|
||||
.secondaryButton { padding: 0 14px; border: 0.5px solid var(--dsw-alias-border-l2); }
|
||||
.secondaryButton { padding: 0 14px; --dsh-button-outline-color: var(--dsw-alias-border-l2); }
|
||||
.description { margin: 0; padding: 0 24px; font-size: 14px; line-height: 22px; color: var(--dsw-alias-label-primary); }
|
||||
.link { border: 0; padding: 0; background: transparent; color: inherit; font: inherit; text-decoration: underline; cursor: pointer; }
|
||||
.spinner { flex: none; animation: spin 1s linear infinite; }
|
||||
|
||||
@@ -740,7 +740,7 @@ select.input {
|
||||
}
|
||||
|
||||
.deleteConfirm:not(:disabled) {
|
||||
border-color: var(--dsw-alias-state-error-primary);
|
||||
--dsh-button-outline-color: var(--dsw-alias-state-error-primary);
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
|
||||
@@ -33,8 +33,8 @@
|
||||
en: 3eb9b0e3365b2db3
|
||||
zh: c73acc55dd897266
|
||||
/deepseek-ai-dsh-client-ui-theme/understand-the-implementation/stylesheets:
|
||||
en: c47fd726c865cdd3
|
||||
zh: 7c62b6e5857c29a7
|
||||
en: e0f0bce3e166e71a
|
||||
zh: bf5a68e7f440aca0
|
||||
/deepseek-ai-dsh-client-ui-theme/understand-the-implementation/scrollbar-rebinding:
|
||||
en: bf76e7c34a331595
|
||||
zh: 5accd0260ef7c61e
|
||||
|
||||
@@ -59,6 +59,8 @@ The service owns theme, font-size, and font-family state and publishes snapshots
|
||||
|
||||
### Stylesheets
|
||||
|
||||
The style installer publishes `--dsh-hairline-width` on the document root and updates it through one device-pixel-ratio listener when the document has a window. Documents without a window receive the stylesheets and use the CSS fallback width. Its width matches a 0.5px border rounded down to whole device pixels, with a one-device-pixel minimum. Only styles that explicitly consume this variable use the width; plugin disposal releases the listener and restores the previous root declaration.
|
||||
|
||||
`base.css` owns the shared radius scale, the font tokens, and settings-card material aliases. `--dsh-font-family-text-default` and `--dsh-font-family-code-default` hold the built-in stacks on `:root`; on `body`, `--dsw-font-family` and `--ds-font-family-code` place `--dsh-font-family-text` and `--dsh-font-family-code` ahead of those stacks, and an unset list resolves to the built-in stack (repeated once, which font matching ignores). The material aliases resolve on `body`, alongside the active palette. Follow [Web styling](../../../docs/web-styling.md#corner-radii-and-settings-cards) when choosing component radii.
|
||||
|
||||
`src/styles/` holds eight sheets imported in order by ui-theme's dynamic client entry: `base.css`, `corner-shape.css`, `design-platform.css`, `focus.css`, `onboarding.css`, `scrollbar.css`, `gradient-shadow-text.css`, and `shiki.css`. The client bundle compiles and injects them as plugin-owned global styles, so unload and HMR remove them with ui-theme. `scrollbar.css` consumes the `--dsw-alias-scrollbar-*` tokens and must follow `design-platform.css`, which declares them. Status marks use their own semantic state tokens. `--dsw-alias-bg-document-selection` uses blue-500 at 40% opacity in both themes for selections over original document colors. `design-platform.css` also owns the code-diff fill aliases and their static alpha palette entries, plus the `--dsw-alias-file-diff-*` code, gutter, and marker palette for file comparisons; `shiki.css` owns syntax colors.
|
||||
|
||||
@@ -59,6 +59,8 @@ Desktop 的固定快捷键 `Cmd`/`Ctrl` + `+`(或 `=`)和 `Cmd`/`Ctrl` + `-`
|
||||
|
||||
### 样式表
|
||||
|
||||
当 document 有关联的 window 时,样式安装器在 document 根节点发布 `--dsh-hairline-width`,并通过一个设备像素比监听器更新它。没有 window 的文档仍安装样式表,并使用 CSS fallback 宽度。该宽度对应 0.5px 边框向下取整到整物理像素后的结果,最小为一个物理像素。只有显式读取该变量的样式会使用这一宽度;插件卸载时释放监听器并恢复根节点上原有的声明。
|
||||
|
||||
`base.css` 持有共享圆角尺度、字体 token 与设置卡片材质别名。`--dsh-font-family-text-default` 与 `--dsh-font-family-code-default` 在 `:root` 上保存内置字体栈;在 `body` 上,`--dsw-font-family` 与 `--ds-font-family-code` 把 `--dsh-font-family-text` 与 `--dsh-font-family-code` 排在这些字体栈之前,未设置的列表解析为内置字体栈(重复一次,字体匹配会忽略重复项)。材质别名在 `body` 上随当前色板解析。组件圆角选择遵循 [Web 样式参考](../../../docs/web-styling.zh.md#corner-radii-and-settings-cards)。
|
||||
|
||||
`src/styles/` 下有八张样式表,由 ui-theme 的动态客户端 entry 依次导入:`base.css`、`corner-shape.css`、`design-platform.css`、`focus.css`、`onboarding.css`、`scrollbar.css`、`gradient-shadow-text.css` 与 `shiki.css`。客户端 bundle 将其编译并注入为插件持有的全局样式,因此卸载与 HMR(热模块替换)会随 ui-theme 一同移除。`scrollbar.css` 消费 `--dsw-alias-scrollbar-*` token,必须排在声明这些 token 的 `design-platform.css` 之后。状态标记使用各自的语义状态 token。`--dsw-alias-bg-document-selection` 在两种主题中均使用 40% 不透明度的 blue-500,供保留文档原色的选区使用。`design-platform.css` 还负责代码差异底色的别名及其静态透明度色阶,以及文件对比所用的 `--dsw-alias-file-diff-*` 代码区、行号区和标记配色;`shiki.css` 负责语法颜色。
|
||||
|
||||
@@ -9,6 +9,7 @@ import gradientShadowText from '../styles/gradient-shadow-text.css?inline'
|
||||
import shiki from '../styles/shiki.css?inline'
|
||||
|
||||
const PLUGIN_ID = '@deepseek-ai/dsh-client-ui-theme'
|
||||
const HAIRLINE_WIDTH = '--dsh-hairline-width'
|
||||
|
||||
const STYLES = [
|
||||
['base.css', base],
|
||||
@@ -22,11 +23,34 @@ const STYLES = [
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Mount the global theme sheets for exactly the owning plugin lifetime.
|
||||
* Mount global theme sheets; window-backed documents also publish the DPR-aware stroke width.
|
||||
* @param ctx - Owning plugin context.
|
||||
*/
|
||||
export function installThemeStyles(ctx: Context): void {
|
||||
if (typeof document === 'undefined') return
|
||||
const view = document.defaultView
|
||||
if (view !== null) ctx.effect(() => {
|
||||
const root = document.documentElement
|
||||
const previousWidth = root.style.getPropertyValue(HAIRLINE_WIDTH)
|
||||
const previousPriority = root.style.getPropertyPriority(HAIRLINE_WIDTH)
|
||||
const matchMedia = typeof view.matchMedia === 'function' ? view.matchMedia.bind(view) : undefined
|
||||
let resolution: MediaQueryList | undefined
|
||||
const updateStroke = (): void => {
|
||||
// Positive borders round down to whole device pixels, with a one-pixel minimum.
|
||||
const dpr = view.devicePixelRatio
|
||||
const width = Math.max(1, Math.floor(0.5 * dpr)) / dpr
|
||||
root.style.setProperty(HAIRLINE_WIDTH, `${width}px`)
|
||||
resolution?.removeEventListener('change', updateStroke)
|
||||
resolution = matchMedia?.(`(resolution: ${dpr}dppx)`)
|
||||
resolution?.addEventListener('change', updateStroke)
|
||||
}
|
||||
updateStroke()
|
||||
return () => {
|
||||
resolution?.removeEventListener('change', updateStroke)
|
||||
if (previousWidth === '') root.style.removeProperty(HAIRLINE_WIDTH)
|
||||
else root.style.setProperty(HAIRLINE_WIDTH, previousWidth, previousPriority)
|
||||
}
|
||||
}, 'ui-theme: DPR-aware hairline width')
|
||||
for (const [name, css] of STYLES) {
|
||||
ctx.effect(() => {
|
||||
const tag = document.createElement('style')
|
||||
|
||||
@@ -1,22 +1,41 @@
|
||||
// @vitest-environment jsdom
|
||||
/** Dynamic ui-theme entry owns the global styles in dependency order. */
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { installThemeStyles } from '../src/client/styles.ts'
|
||||
|
||||
const PLUGIN_ID = '@deepseek-ai/dsh-client-ui-theme'
|
||||
const HAIRLINE_WIDTH = '--dsh-hairline-width'
|
||||
const contexts = new Set<Context>()
|
||||
let previousWidth: string
|
||||
let previousPriority: string
|
||||
|
||||
afterEach(() => {
|
||||
document.head.querySelectorAll(`style[data-plugin="${PLUGIN_ID}"]`).forEach((node) => { node.remove() })
|
||||
beforeEach(() => {
|
||||
previousWidth = document.documentElement.style.getPropertyValue(HAIRLINE_WIDTH)
|
||||
previousPriority = document.documentElement.style.getPropertyPriority(HAIRLINE_WIDTH)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
for (const ctx of contexts) await ctx.fiber.dispose()
|
||||
contexts.clear()
|
||||
document.head.querySelectorAll(`style[data-plugin="${PLUGIN_ID}"]`).forEach((node) => { node.remove() })
|
||||
if (previousWidth === '') document.documentElement.style.removeProperty(HAIRLINE_WIDTH)
|
||||
else document.documentElement.style.setProperty(HAIRLINE_WIDTH, previousWidth, previousPriority)
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
async function mountStyles() {
|
||||
const ctx = new Context()
|
||||
contexts.add(ctx)
|
||||
const fiber = ctx.plugin({ apply(scope) { installThemeStyles(scope) } })
|
||||
await fiber.await()
|
||||
return fiber
|
||||
}
|
||||
|
||||
describe('ui-theme client styles', () => {
|
||||
it('mounts every global sheet in dependency order and removes them on dispose', async () => {
|
||||
const ctx = new Context()
|
||||
const fiber = ctx.plugin({
|
||||
apply(scope) { installThemeStyles(scope) },
|
||||
})
|
||||
await fiber.await()
|
||||
const fiber = await mountStyles()
|
||||
|
||||
const styles = [...document.head.querySelectorAll<HTMLStyleElement>(`style[data-plugin="${PLUGIN_ID}"]`)]
|
||||
expect(styles.map(style => style.dataset.pluginCss)).toEqual([
|
||||
@@ -31,5 +50,108 @@ describe('ui-theme client styles', () => {
|
||||
])
|
||||
await fiber.dispose()
|
||||
expect(document.head.querySelectorAll(`style[data-plugin="${PLUGIN_ID}"]`)).toHaveLength(0)
|
||||
expect(document.documentElement.style.getPropertyValue(HAIRLINE_WIDTH)).toBe(previousWidth)
|
||||
})
|
||||
|
||||
it('calculates the stroke when resolution notifications are unavailable', async () => {
|
||||
vi.stubGlobal('matchMedia', undefined)
|
||||
vi.stubGlobal('devicePixelRatio', 2)
|
||||
await mountStyles()
|
||||
expect(document.documentElement.style.getPropertyValue(HAIRLINE_WIDTH)).toBe('0.5px')
|
||||
})
|
||||
|
||||
it('mounts and releases styles without publishing a DPR width for a detached document', async () => {
|
||||
const detached = document.implementation.createHTMLDocument('')
|
||||
vi.stubGlobal('document', detached)
|
||||
try {
|
||||
const fiber = await mountStyles()
|
||||
expect(detached.defaultView).toBeNull()
|
||||
expect(detached.head.querySelectorAll(`style[data-plugin="${PLUGIN_ID}"]`)).toHaveLength(8)
|
||||
expect(detached.documentElement.style.getPropertyValue(HAIRLINE_WIDTH)).toBe('')
|
||||
await fiber.dispose()
|
||||
expect(detached.head.querySelectorAll(`style[data-plugin="${PLUGIN_ID}"]`)).toHaveLength(0)
|
||||
} finally {
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
})
|
||||
|
||||
it('acquires nothing without a browser document', () => {
|
||||
const ctx = new Context()
|
||||
contexts.add(ctx)
|
||||
vi.stubGlobal('document', undefined)
|
||||
try {
|
||||
installThemeStyles(ctx)
|
||||
} finally {
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
expect(document.head.querySelectorAll(`style[data-plugin="${PLUGIN_ID}"]`)).toHaveLength(0)
|
||||
expect(document.documentElement.style.getPropertyValue(HAIRLINE_WIDTH)).toBe(previousWidth)
|
||||
})
|
||||
|
||||
describe('DPR-aware hairline width', () => {
|
||||
let queries: MediaQueryList[]
|
||||
|
||||
beforeEach(() => {
|
||||
queries = []
|
||||
vi.stubGlobal('devicePixelRatio', 3)
|
||||
vi.stubGlobal('matchMedia', (media: string): MediaQueryList => {
|
||||
const events = new EventTarget()
|
||||
const query: MediaQueryList = {
|
||||
media, matches: true, onchange: null,
|
||||
// oxlint-disable-next-line typescript/no-deprecated -- The platform interface requires both legacy members.
|
||||
addListener: vi.fn(), removeListener: vi.fn(),
|
||||
addEventListener: events.addEventListener.bind(events),
|
||||
removeEventListener: events.removeEventListener.bind(events),
|
||||
dispatchEvent: events.dispatchEvent.bind(events),
|
||||
}
|
||||
queries.push(query)
|
||||
return query
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ dpr: 1, width: 1 },
|
||||
{ dpr: 1.25, width: 0.8 },
|
||||
{ dpr: 2, width: 0.5 },
|
||||
{ dpr: 3, width: 1 / 3 },
|
||||
{ dpr: 4, width: 0.5 },
|
||||
])('matches a 0.5px border at DPR $dpr', async ({ dpr, width }) => {
|
||||
vi.stubGlobal('devicePixelRatio', dpr)
|
||||
await mountStyles()
|
||||
expect(document.documentElement.style.getPropertyValue(HAIRLINE_WIDTH)).toBe(`${width}px`)
|
||||
expect(queries.map(query => query.media)).toEqual([`(resolution: ${dpr}dppx)`])
|
||||
})
|
||||
|
||||
it('updates one document value through successive DPR changes and detaches old queries', async () => {
|
||||
await mountStyles()
|
||||
vi.stubGlobal('devicePixelRatio', 2)
|
||||
queries[0]!.dispatchEvent(new Event('change'))
|
||||
expect(document.documentElement.style.getPropertyValue(HAIRLINE_WIDTH)).toBe('0.5px')
|
||||
vi.stubGlobal('devicePixelRatio', 1)
|
||||
queries[1]!.dispatchEvent(new Event('change'))
|
||||
expect(document.documentElement.style.getPropertyValue(HAIRLINE_WIDTH)).toBe('1px')
|
||||
expect(queries.map(query => query.media)).toEqual(['(resolution: 3dppx)', '(resolution: 2dppx)', '(resolution: 1dppx)'])
|
||||
queries[0]!.dispatchEvent(new Event('change'))
|
||||
expect(queries).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('restores the prior declaration and releases its listener before reinstalling', async () => {
|
||||
const root = document.documentElement
|
||||
root.style.setProperty(HAIRLINE_WIDTH, '0.75px', 'important')
|
||||
const first = await mountStyles()
|
||||
expect(root.style.getPropertyPriority(HAIRLINE_WIDTH)).toBe('')
|
||||
await first.dispose()
|
||||
expect(root.style.getPropertyValue(HAIRLINE_WIDTH)).toBe('0.75px')
|
||||
expect(root.style.getPropertyPriority(HAIRLINE_WIDTH)).toBe('important')
|
||||
queries[0]!.dispatchEvent(new Event('change'))
|
||||
expect(queries).toHaveLength(1)
|
||||
vi.stubGlobal('devicePixelRatio', 2)
|
||||
const second = await mountStyles()
|
||||
expect(root.style.getPropertyValue(HAIRLINE_WIDTH)).toBe('0.5px')
|
||||
await second.dispose()
|
||||
queries[1]!.dispatchEvent(new Event('change'))
|
||||
expect(queries).toHaveLength(2)
|
||||
expect(root.style.getPropertyValue(HAIRLINE_WIDTH)).toBe('0.75px')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -202,6 +202,15 @@ function wideNeutralBorders(css: string, exempt: Set<string> = new Set()): strin
|
||||
return wide
|
||||
}
|
||||
|
||||
/** Centered, zero-blur inset strokes with literal widths and neutral border colors. */
|
||||
function wideNeutralInsetShadows(css: string): string[] {
|
||||
return parseRules(css).flatMap(rule => rule.declarations
|
||||
.filter(([property, value]) => property === 'box-shadow'
|
||||
&& [...value.matchAll(/\binset\s+(?:0(?:px)?\s+){3}(\d*\.?\d+)([a-z%]+)\s+var\(--dsw-alias-border-[\w-]+\)/g)]
|
||||
.some(([, width, unit]) => unit !== 'px' || Number(width) > 0.5))
|
||||
.map(([property, value]) => `${rule.selectors.join(', ')} ${property}: ${value}`))
|
||||
}
|
||||
|
||||
/**
|
||||
* Filled divider lines (a border-token background on a 1px-tall or 1px-wide
|
||||
* box) that keep the pre-hairline weight.
|
||||
@@ -255,6 +264,24 @@ describe('neutral solid borders are hairlines', () => {
|
||||
expect(wide).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects thick neutral inset strokes while allowing hairlines and state colors', () => {
|
||||
expect(wideNeutralInsetShadows('.a { box-shadow: inset 0 0 0 1px var(--dsw-alias-border-l2); }'))
|
||||
.toEqual(['.a box-shadow: inset 0 0 0 1px var(--dsw-alias-border-l2)'])
|
||||
expect(wideNeutralInsetShadows('.a { box-shadow: inset 0 0 0 1em var(--dsw-alias-border-l2); }'))
|
||||
.toEqual(['.a box-shadow: inset 0 0 0 1em var(--dsw-alias-border-l2)'])
|
||||
expect(wideNeutralInsetShadows('.a { box-shadow: inset 0px 0px 0px 0.5px var(--dsw-alias-border-l2); }')).toEqual([])
|
||||
expect(wideNeutralInsetShadows('.a { box-shadow: inset 0 0 0 var(--dsh-hairline-width, 0.5px) var(--dsw-alias-border-l2); }')).toEqual([])
|
||||
expect(wideNeutralInsetShadows('.a { box-shadow: inset 0 0 0 2px var(--dsw-alias-state-business-primary); }')).toEqual([])
|
||||
expect(wideNeutralInsetShadows('.a { box-shadow: inset 0 0 0 0.5px var(--dsw-alias-border-l2), inset 0 0 0 2px var(--dsw-alias-border-l3); }'))
|
||||
.toEqual(['.a box-shadow: inset 0 0 0 0.5px var(--dsw-alias-border-l2), inset 0 0 0 2px var(--dsw-alias-border-l3)'])
|
||||
})
|
||||
|
||||
it('keeps literal neutral inset strokes at or below 0.5px under packages/', () => {
|
||||
const wide = packageStylesheets().flatMap(file =>
|
||||
wideNeutralInsetShadows(readFileSync(file, 'utf8')).map(line => `${file} ${line}`))
|
||||
expect(wide).toEqual([])
|
||||
})
|
||||
|
||||
it('draws every filled divider line at 0.5px under packages/', () => {
|
||||
// A separator drawn as a filled box — 1px tall or wide with a border-token
|
||||
// background (menu separators, the conversation header seam, markdown hr,
|
||||
|
||||
Reference in new issue
Block a user