mirror of
https://github.com/dsh-market/dsh-market.git
synced 2026-09-28 05:03:07 +08:00
fix(client): the diagnostics badges were painted with an ink token (#731)
`--dsw-alias-brand-primary` is an INK colour: the host resolves it to #0f1115 in the light theme and to #f9fafb in the dark one, and uses it for text and strokes while the surface underneath is a different token. The market had it as a badge *background* with a hardcoded `#fff` label, so in dark mode on 0.1.7 the fill and the text were both #f9fafb — white on white. The bundle "official" badge and every row of the override list rendered as empty white pills, which is what the report showed; the numbers were still there, invisible. Verified on a real 0.1.7-rc.2 host, both themes, computed styles: - before: `ovByTag` / `diagBadgeOfficial` bg rgb(249,250,251), colour rgb(255,255,255) — identical channels, nothing to read - after: dark bg rgb(249,250,251) colour rgb(15,17,21); light bg rgb(15,17,21) colour rgb(255,255,255) The host's own filled pair is `button-primary-fill` + `label-primary-foreground` (its primary Button and Pill use exactly that) — both with the old pair as the fallback, so hosts that predate those tokens are unchanged. The same sweep found four more surfaces hardcoding white on a themed fill, and the avatar letter that was white on `bg-layer-2` — which is white in the light theme. `tests/style-tokens.spec.ts` keeps it from coming back: no rule may set a themed background and a literal text colour together. Deliberately not checked: an ink token *as* a background — that is the correct fill for a progress bar and for the 9% tints the market already uses.
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
# AGENTS.md — 产品设计原则
|
||||
|
||||
这份文档是**约束**,不是参考。市场里的任何产品设计、功能设计、界面改动,先按这里过一遍。
|
||||
代码规范见 `TESTING.md`,安全边界见 `SECURITY.md`;这里只管一件事:**给普通用户做的东西。**
|
||||
|
||||
---
|
||||
|
||||
## 一、三条根本原则
|
||||
|
||||
### 1. 自然 —— 从用户的意图出发,不从实现出发
|
||||
|
||||
用户是带着一件事来的(「我想让 DSH 能发通知」),他不知道也不需要知道系统内部怎么分工。
|
||||
界面要顺着他的意图走,不能要求他先理解我们的模型(插件 / bundle / profile / 层 / 依赖树)才能用。
|
||||
|
||||
- 用户要做的事 → 一步就是一步,别为了对应内部结构而拆成三步。
|
||||
- 界面上出现的概念,必须是**用户已有的概念**。新造的词只有在无法避免时才出现,且第一次出现就要用人话解释。
|
||||
- 系统的复杂度由系统吸收:排序、格式化、本地化、缓存、降级、重试,都是我们的事,不是让用户去选。
|
||||
|
||||
### 2. 好理解 —— 说人话,别让用户猜
|
||||
|
||||
- 用日常词:不说「凭据」「解析依赖」「幂等」「实例」「装载」,说「密钥」「准备依赖」「重复执行也没问题」「本次运行」「加载」。
|
||||
- 一句话说一件事;按钮 2–4 字;正文一句不超过 20 字。
|
||||
- 每一处文案都要能回答「所以呢?」——说完现象要说后果,说完后果要说下一步。
|
||||
- **报错必须是三件事**:发生了什么、为什么、现在怎么办。只有错误码、只有「失败了」而没有出路的,都不合格。
|
||||
- 不确定就说不确定。宁可说「不知道」,也不要用一句听起来合理的话把空缺填上。
|
||||
|
||||
### 3. 面向普通用户 —— 替他做决定,而不是把决定推给他
|
||||
|
||||
- **默认值就是产品**:绝大多数人不会改设置。默认要选那个「不读文档也对」的。
|
||||
- **不把判断责任推给没有能力判断的人**:给用户看的要么是他能行动的,要么是让他放心的,不能是「你自己看着办」。
|
||||
- **常态安静,罕见才打扰**(渐进披露):
|
||||
- 大多数情况下都会出现的信息,是**陈述**,不是**警告**;
|
||||
- 只有在**罕见**并且**用户此刻必须做点什么**时,才允许变醒目;
|
||||
- 一个随处可见的警告,训练出来的是「不看警告」——包括真出事的那一次;
|
||||
- 高级/专业信息默认收起,但**可发现**:需要它的那一刻能找到,不需要的时候不占位置。
|
||||
- **不替用户做无谓的确认**:可撤销的动作直接做,配一个撤销;只有不可撤销、或有真实后果的动作才打断他,并且说清后果。
|
||||
- **反馈与确定性**:每个动作都要有即时反应和明确落点(成功 / 失败 / 去哪看)。「点了没反应」是最差的状态。
|
||||
|
||||
## 二、苹果那套做法,落到这个项目的可检验规则
|
||||
|
||||
苹果的产品哲学里真正有用的是几条很朴素的判断,下面每条都写成本项目能检查的形式:
|
||||
|
||||
| 原则 | 在本项目里意味着 |
|
||||
|---|---|
|
||||
| 看不见的复杂度 | 用户不该看到内部模型;需要暴露时,先翻译成他的语言 |
|
||||
| 直接操作 | 一个动作一个结果;不要为了「安全」加一层只有开发者懂的中间态 |
|
||||
| 渐进披露 | 常态安静、罕见打扰、高级可发现(见上) |
|
||||
| 少即是多 | 「要不要加这个信息」的默认答案是**不加**;能去掉的先去掉 |
|
||||
| 一致性 | 同一个概念全篇同一个词;同一个控件同一个行为;同一份事实在不同卡片上用同一段渲染 |
|
||||
| 恰当 | 语气跟场合走:出错时克制并给出路,成功时简短,危险操作时严肃 |
|
||||
| 细节即产品 | 空状态、错误状态、深浅色、窄屏、长文本、加载中——都是要设计的界面,不是边界情况 |
|
||||
| 不为不可能的错误设计 | 不要为了理论上可能的情况,给所有人加步骤 |
|
||||
|
||||
## 三、动手前先问自己(验收清单)
|
||||
|
||||
1. 这句话,一个不懂技术的用户能读懂吗?读不懂的替换成日常词。
|
||||
2. 这条信息他看完能做什么?**不能行动、也不能让他放心的,就不进主流程。**
|
||||
3. 这个提醒是常态还是罕见?常态 → 降级为陈述。
|
||||
4. 这个确认能撤销吗?能撤销就别问。
|
||||
5. 这个功能默认展开还是收起?普通用户真的需要默认看到它吗?
|
||||
6. 出错了之后他知道下一步吗?
|
||||
7. 深浅色、窄屏、空列表、加载中、失败态,都过了一遍吗?
|
||||
8. 中英两份文案说的是同一件事吗?
|
||||
|
||||
## 四、已经犯过、不要再犯
|
||||
|
||||
这些是本项目真实发生过并修掉的,写在这是为了不再重演:
|
||||
|
||||
- **把常态渲染成警告**:目录里 483 条「红色提示」中 354 条是同一句「同时读凭据并联网」——插件调模型本来就要用 key,把它标黄的结果是用户学会无视这一行。
|
||||
- **把能力清单挂在每张卡片上**:列表里没人靠「会读文件」挑插件;它的位置在详情页,且默认收起。
|
||||
- **让用户读到系统内部**:卡片上直接显示 `uses literal IP 198.18.0.0 for network access`;诊断页显示 `bundle`、`profile`、`disable-carrier`。
|
||||
- **只有现象没有出路**:`Error: fetch failed`(真实原因在 `cause` 里)、「操作失败」而不说重试。
|
||||
- **把「没查到」说成「没问题」**:`未检出` 要写成「没有扫描到。这不等于安全。」
|
||||
- **同一句标签重复铺满**:每行前面都加「值得看一眼:」。
|
||||
- **为不可能发生的错误加步骤**:装前用一屏能力清单拦人,而其中没有一条是用户能采纳的。
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -267,7 +267,9 @@
|
||||
.row1{display:flex;align-items:flex-start;gap:10px;min-width:0}
|
||||
.cardAction{flex-shrink:0;display:inline-flex;align-items:center}
|
||||
.installBtn.installBtn{min-width:64px}
|
||||
.av{width:16px;height:16px;border-radius:50%;display:grid;place-items:center;font-weight:700;color:#fff;font-size:9px;flex-shrink:0;object-fit:cover;background:var(--dsw-alias-bg-layer-2,#f3f4f6)}
|
||||
/* The avatar letter sits on `bg-layer-2`, which is white in the light theme:
|
||||
a hardcoded white was invisible there. Ink on a surface is `label-primary`. */
|
||||
.av{width:16px;height:16px;border-radius:50%;display:grid;place-items:center;font-weight:700;color:var(--dsw-alias-label-primary,#1f2328);font-size:9px;flex-shrink:0;object-fit:cover;background:var(--dsw-alias-bg-layer-2,#f3f4f6)}
|
||||
/* The plugin name carries the card, so it is the one thing sized up; the
|
||||
author sits under it as a signature. Same reading order as a post
|
||||
header — you take in who wrote it in one glance and spend the rest on
|
||||
@@ -572,7 +574,7 @@
|
||||
same way .installBtn and .catsToggle do. */
|
||||
.dangerBtn.dangerBtn{color:var(--dsw-alias-state-error-primary,#dc2626);border-color:var(--dsw-alias-state-error-primary,#dc2626)}
|
||||
.dangerBtn.dangerBtn:hover:not(:disabled){background:var(--dsw-alias-state-error-primary,#dc2626);color:#fff}
|
||||
.dangerArmed.dangerArmed{background:var(--dsw-alias-state-error-primary,#dc2626);border-color:var(--dsw-alias-state-error-primary,#dc2626);color:#fff}
|
||||
.dangerArmed.dangerArmed{background:var(--dsw-alias-state-error-primary,#dc2626);border-color:var(--dsw-alias-state-error-primary,#dc2626);color:var(--dsw-alias-label-primary-foreground,#fff)}
|
||||
.retryBtn{margin-top:4px}
|
||||
.irow{background:var(--dsw-alias-bg-layer-1,#fff);border:1px solid var(--dsw-alias-border-l2,#e5e7eb);
|
||||
border-radius:10px;padding:12px 14px;display:flex;flex-direction:column;gap:10px;min-width:0}
|
||||
@@ -752,10 +754,19 @@
|
||||
.diagVal{font-size:12px;font-weight:500;color:var(--dsw-alias-label-primary,#1f2328);font-family:ui-monospace,Menlo,monospace;overflow-wrap:anywhere;min-width:0}
|
||||
.diagIndex{display:inline-flex;align-items:center;justify-content:center;min-width:18px;height:18px;border-radius:9px;background:var(--dsw-alias-bg-layer-2,#f3f4f6);color:var(--dsw-alias-label-secondary,#6b7280);font-size:11px;font-weight:600;flex-shrink:0}
|
||||
.diagArrow{color:var(--dsw-alias-label-tertiary,#9ca3af);font-size:12px;flex-shrink:0}
|
||||
.diagBadgeOfficial{display:inline-flex;align-items:center;padding:0 8px;height:18px;border-radius:9px;background:var(--dsw-alias-brand-primary,#4f6ef7);color:#fff;font-size:11px;font-weight:600;flex-shrink:0}
|
||||
/* The filled badge pair (#731). `--dsw-alias-brand-primary` is an INK token:
|
||||
near-black in the light theme, near-white in the dark one — the host uses it
|
||||
for text and strokes, never as a surface. The market had it as a background
|
||||
with a hardcoded #fff, so in dark mode the badge and its label were both
|
||||
#f9fafb: white on white, invisible, and only on the one host line whose theme
|
||||
resolves that token that way. The host's own filled primary pair is
|
||||
`button-primary-fill` + `label-primary-foreground`, which is what a filled
|
||||
badge has to use — both fall back to the old pair for hosts that predate
|
||||
them. */
|
||||
.diagBadgeOfficial{display:inline-flex;align-items:center;padding:0 8px;height:18px;border-radius:9px;background:var(--dsw-alias-button-primary-fill,var(--dsw-alias-brand-primary,#4f6ef7));color:var(--dsw-alias-label-primary-foreground,#fff);font-size:11px;font-weight:600;flex-shrink:0}
|
||||
.diagBadgeCommunity{display:inline-flex;align-items:center;padding:0 8px;height:18px;border-radius:9px;background:var(--dsw-alias-bg-layer-2,#f3f4f6);color:var(--dsw-alias-label-secondary,#6b7280);font-size:11px;flex-shrink:0}
|
||||
.diagBadgeShadow{display:inline-flex;align-items:center;padding:0 8px;height:18px;border-radius:9px;background:var(--dsw-alias-state-error-primary,#dc2626);color:#fff;font-size:11px;font-weight:600;flex-shrink:0}
|
||||
.diagBadgeWarn{display:inline-flex;align-items:center;padding:0 8px;height:18px;border-radius:9px;background:var(--dsw-alias-state-warn-primary,#b45309);color:#fff;font-size:11px;font-weight:600;flex-shrink:0;white-space:nowrap}
|
||||
.diagBadgeShadow{display:inline-flex;align-items:center;padding:0 8px;height:18px;border-radius:9px;background:var(--dsw-alias-state-error-primary,#dc2626);color:var(--dsw-alias-label-primary-foreground,#fff);font-size:11px;font-weight:600;flex-shrink:0}
|
||||
.diagBadgeWarn{display:inline-flex;align-items:center;padding:0 8px;height:18px;border-radius:9px;background:var(--dsw-alias-state-warn-primary,#b45309);color:var(--dsw-alias-label-primary-foreground,#fff);font-size:11px;font-weight:600;flex-shrink:0;white-space:nowrap}
|
||||
.diagBadgeInfo{display:inline-flex;align-items:center;padding:0 8px;height:18px;border-radius:9px;background:var(--dsw-alias-bg-layer-2,#f3f4f6);color:var(--dsw-alias-label-secondary,#6b7280);font-size:11px;flex-shrink:0;white-space:nowrap}
|
||||
.diagList{display:flex;flex-direction:column;gap:6px}
|
||||
|
||||
@@ -764,10 +775,10 @@
|
||||
.diagAlert{color:var(--dsw-alias-state-warn-primary,#b45309)}
|
||||
.ovRow{display:flex;align-items:center;flex-wrap:wrap;gap:8px;font-size:12px;line-height:18px;background:var(--dsw-alias-bg-layer-2,#f7f8fa);border-radius:8px;padding:6px 10px;min-width:0}
|
||||
.ovArrow{color:var(--dsw-alias-label-tertiary,#9ca3af);font-size:12px;flex-shrink:0}
|
||||
.ovByTag{display:inline-flex;align-items:center;padding:0 8px;height:18px;border-radius:9px;background:var(--dsw-alias-brand-primary,#4f6ef7);color:#fff;font-size:11px;font-weight:600;flex-shrink:0;max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.ovByTag{display:inline-flex;align-items:center;padding:0 8px;height:18px;border-radius:9px;background:var(--dsw-alias-button-primary-fill,var(--dsw-alias-brand-primary,#4f6ef7));color:var(--dsw-alias-label-primary-foreground,#fff);font-size:11px;font-weight:600;flex-shrink:0;max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.ovFrom{color:var(--dsw-alias-label-secondary,#6b7280);font-size:12px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.orphRow{display:flex;align-items:center;flex-wrap:wrap;gap:8px;font-size:12px;line-height:18px;background:var(--dsw-alias-bg-layer-2,#f7f8fa);border-radius:8px;padding:6px 10px;min-width:0}
|
||||
.orphBadge{display:inline-flex;align-items:center;padding:0 8px;height:18px;border-radius:9px;background:var(--dsw-alias-state-warn-primary,#b45309);color:#fff;font-size:11px;font-weight:600;flex-shrink:0;white-space:nowrap}
|
||||
.orphBadge{display:inline-flex;align-items:center;padding:0 8px;height:18px;border-radius:9px;background:var(--dsw-alias-state-warn-primary,#b45309);color:var(--dsw-alias-label-primary-foreground,#fff);font-size:11px;font-weight:600;flex-shrink:0;white-space:nowrap}
|
||||
.dragHandle{display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;color:var(--dsw-alias-label-tertiary,#9ca3af);cursor:grab;flex-shrink:0;user-select:none;font-size:12px;line-height:20px}
|
||||
.dragOver{outline:2px dashed var(--dsw-alias-brand-primary,#4f6ef7);outline-offset:2px;border-radius:8px;background:var(--dsw-alias-bg-layer-2,#f0f2f8)}
|
||||
.dragging{opacity:.45;background:var(--dsw-alias-bg-layer-2,#f3f4f6)}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Ink tokens are not surfaces (#731).
|
||||
*
|
||||
* `--dsw-alias-brand-primary` and `--dsw-alias-label-primary` resolve to an INK
|
||||
* colour: near-black in the light theme, near-white in the dark one. The
|
||||
* host uses them for text and strokes; the surface they sit on is a different
|
||||
* token. The market had `brand-primary` as a badge *background* with a
|
||||
* hardcoded #fff label, so on 0.1.7 — where brand-primary is #f9fafb — the
|
||||
* diagnostics badges were white on white and simply invisible. The same rule
|
||||
* was hiding an avatar letter on `bg-layer-2`, which is white in the light
|
||||
* theme.
|
||||
*
|
||||
* The host ships `button-primary-fill` + `label-primary-foreground` for a
|
||||
* filled surface, and `label-primary` for ink on a plain one. This test keeps
|
||||
* the market's stylesheet from hardcoding the text colour of a themed surface:
|
||||
* that is the shape the bug took, and it is the shape a theme bug always
|
||||
* takes — it looks right in the theme the author was looking at.
|
||||
*
|
||||
* Deliberately NOT checked: a `brand-primary` *background* on its own. The ink
|
||||
* colour is the correct fill for a progress bar or a 9% tint, and the market
|
||||
* uses it that way; only pairing it with an assumed text colour is wrong.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const CSS = readFileSync(new URL('../src/client/Market.module.css', import.meta.url), 'utf8')
|
||||
|
||||
/** Declarations of one rule body, as `property: value` pairs. */
|
||||
function declarations(body: string): Array<[string, string]> {
|
||||
return body.split(';').filter(Boolean).map(declaration => {
|
||||
const colon = declaration.indexOf(':')
|
||||
return [declaration.slice(0, colon).trim(), declaration.slice(colon + 1).trim()]
|
||||
})
|
||||
}
|
||||
|
||||
const RULES = [...CSS.matchAll(/\.([A-Za-z0-9_]+)\{([^}]*)\}/gu)].map(match => ({
|
||||
selector: match[1]!,
|
||||
body: match[2]!,
|
||||
}))
|
||||
|
||||
const LITERAL_INK = /^(#fff(?:fff)?|#000(?:000)?|white|black)$/i
|
||||
|
||||
describe('the stylesheet does not paint with ink', () => {
|
||||
it('never hardcodes the text colour of a themed surface', () => {
|
||||
// A literal colour survives any theme whose surface happens to suit it, and
|
||||
// disappears on the first one that does not.
|
||||
const offenders = RULES.flatMap(({ selector, body }) => {
|
||||
const decls = declarations(body)
|
||||
const surface = decls.some(([property, value]) => /^background(-color)?$/.test(property)
|
||||
&& /var\(--dsw-alias-/.test(value))
|
||||
const literal = decls.some(([property, value]) => property === 'color' && LITERAL_INK.test(value))
|
||||
return surface && literal ? [selector] : []
|
||||
})
|
||||
expect(offenders).toEqual([])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user