Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* ccAgentSessionViewThemeContract.test.ts
* ---------------------------------------------------------------------------
* 源契约守卫(DESIGN.md §10 双模式交付门槛 / design-governance.md §6 Level 1):
* CCAgentSessionView 的 handoff pill 与 context 环不再裸写颜色——
* 1. handoff pill 文字走 text-muted-foreground;
* 2. context 环阈值走 error-flat / warning-fg 语义 token;
* 3. 消费的 token 槽位同时具备 light / dark 双模式值。
*/

import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';

import { colorRegistry } from '../themes/color-registry';
// 触发整表 registerColor 注册(与 tokenRegistry.test.ts 同款做法)。
import '../themes/colors';

const viewSource = readFileSync(
resolve(__dirname, '..', 'features', 'cc-agent', 'CCAgentSessionView.tsx'),
'utf8',
).replace(/\r\n?/g, '\n');

/** 动态拼 hex 字面量,避免测试源自身被 hardcoded-color-audit 命中。 */
const hex = (digits: string) => '#' + digits;

describe('CCAgentSessionView 主题契约(语义 token,双模式)', () => {
it('handoff pill 不再裸写灰字色,改走 text-muted-foreground', () => {
expect(viewSource).not.toContain('text-[' + hex('595959') + ']');
expect(viewSource).toContain('text-muted-foreground');
});

it('context 环阈值色不再裸写红/橙,改走 error-flat / warning-fg', () => {
expect(viewSource).not.toContain(hex('EF4444'));
expect(viewSource).not.toContain(hex('F59E0B'));
expect(viewSource).toContain("pct > 90 ? 'var(--error-flat)' :");
expect(viewSource).toContain("'var(--warning-fg)' : 'var(--msg-tool-card-chevron)'");
});

it('消费的 token 槽位同时具备 light / dark 双模式值', () => {
for (const id of ['muted-foreground', 'error-flat', 'warning-fg', 'msg-tool-card-chevron']) {
expect(
colorRegistry.resolveDefault(id, 'light'),
'token "' + id + '" 缺 light 槽位',
).not.toBeNull();
expect(
colorRegistry.resolveDefault(id, 'dark'),
'token "' + id + '" 缺 dark 槽位',
).not.toBeNull();
}
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* makerExperimentalThemeContract.test.ts
* ---------------------------------------------------------------------------
* 源契约守卫(DESIGN.md §10 双模式交付门槛 / design-governance.md §6 Level 1):
* MakerExperimentalView 诊断页必须全量走语义 token——
* 1. 零裸色字面量(hex / rgb() / hsl());
* 2. 消费的每个 var(--xxx) 都已在 themes/colors.ts 注册;
* 3. 注册槽位同时给出 light / dark 双模式值(缺任一槽位即某一模式渲染失效)。
*/

import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';

import { colorRegistry } from '../themes/color-registry';
// 触发整表 registerColor 注册(与 tokenRegistry.test.ts 同款做法)。
import '../themes/colors';

const viewSource = readFileSync(
resolve(__dirname, '..', 'features', 'maker-experimental', 'MakerExperimentalView.tsx'),
'utf8',
).replace(/\r\n?/g, '\n');
const colorsSource = readFileSync(
resolve(__dirname, '..', 'themes', 'colors.ts'),
'utf8',
).replace(/\r\n?/g, '\n');

/** 裸色字面量:hex、带数值入参的 rgb()/rgba()/hsl()/hsla()。 */
const RAW_COLOR_RE = /#[0-9a-fA-F]{3,8}\b|rgba?\(\s*\d|hsla?\(\s*\d/g;
const TOKEN_REF_RE = /var\(--([a-z0-9-]+)\)/g;

function referencedTokens(): string[] {
return [...viewSource.matchAll(TOKEN_REF_RE)].map((match) => match[1]);
}

describe('MakerExperimentalView 主题契约(语义 token,双模式)', () => {
it('零裸色:诊断页不出现 hex / rgb() / hsl() 字面量', () => {
const raw = viewSource.match(RAW_COLOR_RE) ?? [];
expect(raw).toEqual([]);
});

it('消费的 token 全部已在 colors.ts 注册(防幽灵 token)', () => {
const registered = new Set(
[...colorsSource.matchAll(/registerColor\('([a-z0-9-]+)'/g)].map((m) => m[1]),
);
const referenced = referencedTokens();
expect(referenced.length).toBeGreaterThan(0);
const unregistered = [...new Set(referenced.filter((id) => !registered.has(id)))];
expect(unregistered).toEqual([]);
});

it('消费的 token 槽位同时具备 light / dark 双模式值', () => {
const referenced = [...new Set(referencedTokens())];
for (const id of referenced) {
expect(
colorRegistry.resolveDefault(id, 'light'),
'token "' + id + '" 缺 light 槽位',
).not.toBeNull();
expect(
colorRegistry.resolveDefault(id, 'dark'),
'token "' + id + '" 缺 dark 槽位',
).not.toBeNull();
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -5328,7 +5328,7 @@ function HandoffSourcePill({
<div
className={cn(
'flex h-10 w-full items-center rounded-[12px] border border-[var(--cmd-palette-border)] bg-[hsl(var(--content-area))]',
'text-13 leading-none text-[#595959]',
'text-13 leading-none text-muted-foreground',
)}
>
<button
Expand All @@ -5350,7 +5350,7 @@ function HandoffSourcePill({
aria-label={t('ccAgent.handoff.pill.dismissAria')}
className={cn(
'mr-2 flex h-7 w-7 shrink-0 items-center justify-center rounded-[8px]',
'text-[#595959] transition-colors hover:bg-[var(--cmd-palette-bg)] hover:text-foreground',
'text-muted-foreground transition-colors hover:bg-[var(--cmd-palette-bg)] hover:text-foreground',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground',
)}
>
Expand Down Expand Up @@ -5752,7 +5752,7 @@ function ContextCapacityRing({
const dashOffset = circumference - (circumference * pct) / 100;

// Color thresholds per spec
const fillColor = pct > 90 ? '#EF4444' : pct > 70 ? '#F59E0B' : 'var(--msg-tool-card-chevron)';
const fillColor = pct > 90 ? 'var(--error-flat)' : pct > 70 ? 'var(--warning-fg)' : 'var(--msg-tool-card-chevron)';

const usedTokens = Math.min(contextTokens, contextWindow || Infinity);
const tooltipText =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export function MakerExperimentalView(): ReactElement {

if (!m.isReady) {
return (
<div style={{ padding: 24, color: '#d4d4d4' }}>
<div style={{ padding: 24, color: 'var(--text-primary)' }}>
<h2>{t('makerExperimental.notReadyHeading')}</h2>
<p>{t('makerExperimental.notReadyBody')}</p>
</div>
Expand Down Expand Up @@ -124,15 +124,15 @@ export function MakerExperimentalView(): ReactElement {
const displayError = sendError ?? m.error;

return (
<div style={{ padding: 24, color: '#d4d4d4', fontFamily: 'sans-serif', maxWidth: 1200 }}>
<div style={{ padding: 24, color: 'var(--text-primary)', fontFamily: 'sans-serif', maxWidth: 1200 }}>
<h2 style={{ marginTop: 0 }}>{t('makerExperimental.title')}</h2>
<p style={{ color: '#888', fontSize: 13 }}>
<p style={{ color: 'var(--text-secondary)', fontSize: 13 }}>
{t('makerExperimental.description')}
</p>

{/* 配置区 */}
<div style={{
background: '#1a1a1a', padding: 16, borderRadius: 8, marginBottom: 16,
background: 'var(--surface-elevated)', padding: 16, borderRadius: 8, marginBottom: 16,
display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '8px 12px', alignItems: 'center',
}}>
<div>{t('makerExperimental.agentLabel')}</div>
Expand Down Expand Up @@ -165,7 +165,7 @@ export function MakerExperimentalView(): ReactElement {
value={workingDir}
onChange={(e) => setWorkingDir(e.target.value)}
disabled={!!m.session}
style={{ flex: 1, padding: '4px 8px', background: '#262626', color: '#d4d4d4', border: '1px solid #333' }}
style={{ flex: 1, padding: '4px 8px', background: 'var(--surface-elevated)', color: 'var(--text-primary)', border: '1px solid var(--border-default)' }}
placeholder={t('makerExperimental.workingDirPlaceholder')}
/>
<button
Expand All @@ -184,9 +184,9 @@ export function MakerExperimentalView(): ReactElement {
}}
style={{
padding: '4px 12px',
background: '#404040',
color: '#d4d4d4',
border: '1px solid #333',
background: 'var(--surface-chip-alt)',
color: 'var(--text-primary)',
border: '1px solid var(--border-default)',
borderRadius: 4,
cursor: m.session ? 'default' : 'pointer',
opacity: m.session ? 0.5 : 1,
Expand All @@ -202,7 +202,7 @@ export function MakerExperimentalView(): ReactElement {
value={model}
onChange={(e) => setModel(e.target.value)}
disabled={!!m.session}
style={{ padding: '4px 8px', background: '#262626', color: '#d4d4d4', border: '1px solid #333' }}
style={{ padding: '4px 8px', background: 'var(--surface-elevated)', color: 'var(--text-primary)', border: '1px solid var(--border-default)' }}
>
{models.map((m_) => (<option key={m_} value={m_}>{m_}</option>))}
</select>
Expand All @@ -213,7 +213,7 @@ export function MakerExperimentalView(): ReactElement {
value={effort}
onChange={(e) => setEffort(e.target.value as Effort)}
disabled={!!m.session}
style={{ padding: '4px 8px', background: '#262626', color: '#d4d4d4', border: '1px solid #333' }}
style={{ padding: '4px 8px', background: 'var(--surface-elevated)', color: 'var(--text-primary)', border: '1px solid var(--border-default)' }}
>
{[...(capabilities?.effortLevels ?? [
{ id: 'low' as Effort, displayName: 'low' },
Expand All @@ -230,7 +230,7 @@ export function MakerExperimentalView(): ReactElement {
value={permissionMode}
onChange={(e) => setPermissionMode(e.target.value as PermissionMode)}
disabled={!!m.session}
style={{ padding: '4px 8px', background: '#262626', color: '#d4d4d4', border: '1px solid #333' }}
style={{ padding: '4px 8px', background: 'var(--surface-elevated)', color: 'var(--text-primary)', border: '1px solid var(--border-default)' }}
>
{(capabilities?.permissionModes ?? [
{ id: 'auto' as PermissionMode, displayName: 'auto' },
Expand All @@ -243,7 +243,7 @@ export function MakerExperimentalView(): ReactElement {
</select>

<div></div>
<div style={{ fontSize: 11, color: '#737373', lineHeight: 1.4 }}>
<div style={{ fontSize: 11, color: 'var(--text-secondary)', lineHeight: 1.4 }}>
{t('makerExperimental.warningHint')}
</div>

Expand All @@ -253,24 +253,24 @@ export function MakerExperimentalView(): ReactElement {
<button
type="button"
onClick={handleCreate}
style={{ padding: '6px 16px', background: '#2563eb', color: 'white', border: 'none', borderRadius: 4, cursor: 'pointer' }}
style={{ padding: '6px 16px', background: 'var(--accent-cta-bg)', color: 'var(--accent-pure-cta-fg)', border: 'none', borderRadius: 4, cursor: 'pointer' }}
>{t('makerExperimental.createSession')}</button>
) : (
<button
type="button"
onClick={() => void m.close()}
style={{ padding: '6px 16px', background: '#dc2626', color: 'white', border: 'none', borderRadius: 4, cursor: 'pointer' }}
style={{ padding: '6px 16px', background: 'var(--error-flat)', color: 'var(--accent-pure-cta-fg)', border: 'none', borderRadius: 4, cursor: 'pointer' }}
>{t('makerExperimental.closeSession')}</button>
)}
</div>
</div>

{/* Session 状态 */}
{m.session && (
<div style={{ background: '#1a1a1a', padding: 12, borderRadius: 8, marginBottom: 16, fontSize: 12 }}>
<div style={{ background: 'var(--surface-elevated)', padding: 12, borderRadius: 8, marginBottom: 16, fontSize: 12 }}>
<div><strong>{t('makerExperimental.sessionField')}</strong> {m.session.sessionId}</div>
<div><strong>{t('makerExperimental.agentField')}</strong> {m.session.agentKind} | <strong>{t('makerExperimental.statusField')}</strong> {m.status}</div>
{displayError && <div style={{ color: '#dc2626' }}><strong>{t('makerExperimental.errorField')}</strong> {displayError}</div>}
{displayError && <div style={{ color: 'var(--error-flat)' }}><strong>{t('makerExperimental.errorField')}</strong> {displayError}</div>}
</div>
)}

Expand All @@ -283,8 +283,8 @@ export function MakerExperimentalView(): ReactElement {
placeholder={t('makerExperimental.messagePlaceholder')}
rows={3}
style={{
width: '100%', padding: 8, background: '#262626', color: '#d4d4d4',
border: '1px solid #333', borderRadius: 4, boxSizing: 'border-box',
width: '100%', padding: 8, background: 'var(--surface-elevated)', color: 'var(--text-primary)',
border: '1px solid var(--border-default)', borderRadius: 4, boxSizing: 'border-box',
}}
/>
<div style={{ marginTop: 8, display: 'flex', gap: 8, alignItems: 'center' }}>
Expand All @@ -294,24 +294,24 @@ export function MakerExperimentalView(): ReactElement {
onChange={(e) => setImagePath(e.target.value)}
placeholder={t('makerExperimental.imagePathPlaceholder')}
style={{
flex: 1, padding: '4px 8px', background: '#262626', color: '#d4d4d4',
border: '1px solid #333', borderRadius: 4,
flex: 1, padding: '4px 8px', background: 'var(--surface-elevated)', color: 'var(--text-primary)',
border: '1px solid var(--border-default)', borderRadius: 4,
}}
/>
<button
type="button"
onClick={handleSend}
style={{ padding: '6px 16px', background: '#16a34a', color: 'white', border: 'none', borderRadius: 4, cursor: 'pointer' }}
style={{ padding: '6px 16px', background: 'var(--accent-cta-bg)', color: 'var(--accent-pure-cta-fg)', border: 'none', borderRadius: 4, cursor: 'pointer' }}
>{t('makerExperimental.send')}</button>
<button
type="button"
onClick={() => void m.abort()}
style={{ padding: '6px 16px', background: '#737373', color: 'white', border: 'none', borderRadius: 4, cursor: 'pointer' }}
style={{ padding: '6px 16px', background: 'var(--surface-chip)', color: 'var(--text-primary)', border: 'none', borderRadius: 4, cursor: 'pointer' }}
>{t('makerExperimental.abort')}</button>
<button
type="button"
onClick={m.clearEvents}
style={{ padding: '6px 16px', background: '#404040', color: 'white', border: 'none', borderRadius: 4, cursor: 'pointer' }}
style={{ padding: '6px 16px', background: 'var(--surface-chip-alt)', color: 'var(--text-primary)', border: 'none', borderRadius: 4, cursor: 'pointer' }}
>{t('makerExperimental.clear')}</button>
</div>
</div>
Expand All @@ -321,16 +321,16 @@ export function MakerExperimentalView(): ReactElement {
<div>
<h3 style={{ marginBottom: 8 }}>{t('makerExperimental.eventsHeading', { count: m.events.length })}</h3>
<div style={{
background: '#0a0a0a', padding: 12, borderRadius: 8, maxHeight: 600, overflowY: 'auto',
fontFamily: 'monospace', fontSize: 11, color: '#a3a3a3',
background: 'var(--surface-elevated)', padding: 12, borderRadius: 8, maxHeight: 600, overflowY: 'auto',
fontFamily: 'monospace', fontSize: 11, color: 'var(--text-secondary)',
}}>
{m.events.length === 0 ? (
<div style={{ color: '#525252' }}>{t('makerExperimental.noEvents')}</div>
<div style={{ color: 'var(--text-tertiary)' }}>{t('makerExperimental.noEvents')}</div>
) : (
m.events.map((e) => (
<div key={e.id} style={{ marginBottom: 8, paddingBottom: 8, borderBottom: '1px solid #262626' }}>
<div style={{ color: '#fbbf24' }}>
#{e.id} <strong>{e.event.type}</strong> <span style={{ color: '#525252' }}>({e.event.source})</span>
<div key={e.id} style={{ marginBottom: 8, paddingBottom: 8, borderBottom: '1px solid var(--border-default)' }}>
<div style={{ color: 'var(--text-secondary)' }}>
#{e.id} <strong>{e.event.type}</strong> <span style={{ color: 'var(--text-tertiary)' }}>({e.event.source})</span>
</div>
<pre style={{ margin: '4px 0 0 0', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
{JSON.stringify(e.event.data, null, 2)}
Expand Down
2 changes: 1 addition & 1 deletion docs/design-rules/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -579,7 +579,7 @@ No gaps are currently open.

Resolved items (G1–G4, 2026-06 — button-text drift, border drift, placeholder unification, radius tiering) are archived in [`design-decision-log.md`](./design-decision-log.md); each entry records the drift, the ruling, and where the conclusion was folded back (§2 / §4 / §5 / §10).

Known but deliberately-deferred cleanups (e.g. hardcoded hex in `MakerExperimentalView.tsx`) are tracked in the decision log's backlog section, not here.
Known but deliberately-deferred cleanups are tracked in the decision log's backlog section, not here.

## 14. Interaction Conventions(交互约定)

Expand Down
15 changes: 14 additions & 1 deletion docs/design-rules/design-decision-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,19 @@

## Backlog(已知、刻意搁置)

- `MakerExperimentalView.tsx` 通篇裸 hardcode hex(#404040 / #d4d4d4 / #262626 / #333),违反 DESIGN.md 第 10 节 token 规则。因是 experimental 视图,暂不清理,仅备忘(原 §13 旁注,2026-06)。
- [x] **MakerExperimentalView.tsx 裸 hardcode hex 清理**(已解决 2026-08-31)
现状:诊断页通篇裸 hardcode 暗色 hex(页面文字、卡片、输入框、按钮、事件流等),
违反 DESIGN.md 第 10 节 token 规则;路由生产包按 URL 可达,Light 主题下不可读。
处理:全量改成语义 token(text-primary / text-secondary / text-tertiary /
surface-elevated / border-default / accent-cta-bg / accent-pure-cta-fg /
error-flat / surface-chip / surface-chip-alt),
双模式由 token 体系自动覆盖;新增源契约守卫 `makerExperimentalThemeContract.test.ts`
锁「零裸色 + token 已注册 + light/dark 双槽位」。
合并范围(与 PR 3 同一「renderer 硬编码颜色清理」):CCAgentSessionView handoff pill
两处裸灰文字色改 text-muted-foreground;context 环阈值红/橙改 error-flat /
warning-fg 语义 token,双模式由 token 体系覆盖。
review 修订(PR 3686 auto-review P1):事件流标题从 warning-accent 改为
text-secondary——11px 小字在 Light 白色 surface-elevated 上对比度不足,且
warning-accent 语义限定运行/警告状态表面,普通诊断事件标题不在其列。
- R2 §4.3 Project_List 五点差异(2026-07-17 lead 裁决本轮不做,出处为设计阶段工作文件 `2026-07-17-r2-ui-specs.md` §4.3,不入仓库):① Project_List 三态拆分(active-task-pill / project-card / flat-list-row 不共用 `sidebar-item-active`);② 项目 header / list card 选中应中性底(`#312F2F`/`#F6F6F6`,非 `#DF0C27` 大红);③ 去 Project_List 选中组 `focus-ring-soft` 蓝 ring,改 card stroke `#DCDFE3`/`#434343`;④ 小箭头 `#A61629` 强调(非整行红底);⑤ 本轮收敛不扩战线,后续另开。
- splash 渐变辉光层未实现(2026-07-18 backlog,待用户表态)。
Loading