Merge pull request #6662 from deepseek-harness/fix/iphone-button-outline

fix(client): stabilize shared button outlines in Safari
This commit is contained in:
imccyu authored and GitHub committed 2026-10-20 14:07:02 +08:00
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
+2 -2
View File
@@ -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.
+2 -2
View File
@@ -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) {
+1 -1
View File
@@ -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);
}
+2 -2
View File
@@ -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
+2
View File
@@ -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.
+2
View File
@@ -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` 负责语法颜色。
+25 -1
View File
@@ -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,