Files
Joey-nexu f1a0b60c6c chore: unify product name display to OpenDesign repo-wide (#6998)
* chore: unify product name display to OpenDesign repo-wide

Rename the user-facing product name from "Open Design" to "OpenDesign"
across documentation, web/desktop UI strings, i18n locales, prompts,
plugin/skill/design-system content, changelogs, and specs, matching the
landing-page rename already in flight.

Deliberately excluded so installed-app identity stays untouched:
- packages/release PRODUCT_NAME and everything derived from it
  (app bundle names, installer/registry identity, updater feeds)
- tools/pack, tools/release, apps/packaged, landing page (PR #6983)
- mocks/ recorded traces and LICENSE
- test fixtures and comments that encode real on-disk install paths
  (Open Design.app, Program Files\Open Design, AppData paths)

Validated with pnpm guard, pnpm typecheck, and targeted web/desktop/host
test runs over the renamed strings.

* fix(web): keep two-letter author monogram for CamelCase brand names

authorInitials split words on whitespace only, so the renamed single-token
author name "OpenDesign" produced a one-letter monogram. Treat lower-to-upper
CamelCase boundaries as word breaks so the avatar fallback stays "OD".

* fix(release): restore spaced Windows source artifact names in win.ps1

tools/pack still derives builder outputs from the unchanged PRODUCT_NAME
("Open Design"), so the asset script must look for the spaced filenames.
Matches the mac/mac-intel/linux asset scripts, which were already kept on
the original names.

---------

Co-authored-by: Joey <236967869+joeylee12629-star@users.noreply.github.com>
2026-08-18 07:09:45 +00:00

626 lines
22 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
buildBoardCommentAttachments,
buildVisualAnnotationAttachment,
commentSnapshotOverlayEqual,
commentVisibleOnDeckSlide,
commentsToAttachments,
historyWithCommentAttachmentContext,
liveCommentTargetMapsEqual,
liveSnapshotForComment,
resolveCommentAnchor,
mergeAttachedComments,
mergePreviewCommentAttachments,
messageContentWithCommentAttachments,
overlayBoundsFromSnapshot,
queuedSlideNavTarget,
removeAttachedComment,
targetFromSnapshot,
} from '../src/comments';
import type { PreviewCommentSnapshot } from '../src/comments';
import type { ChatCommentAttachment, ChatMessage, PreviewComment } from '../src/types';
function commentAttachment(
overrides: Partial<ChatCommentAttachment> = {},
): ChatCommentAttachment {
return {
id: 'att-1',
order: 1,
filePath: 'deck.html',
elementId: 'el-1',
selector: '[data-od-id="el-1"]',
label: 'span.capsule.accent',
comment: 'make it white',
currentText: 'DTC launches',
pagePosition: { x: 0, y: 0, width: 10, height: 10 },
htmlHint: '<span></span>',
...overrides,
};
}
describe('preview comment attachment helpers', () => {
it('builds compact target context from an iframe snapshot', () => {
const target = targetFromSnapshot({
filePath: 'index.html',
elementId: 'hero-title',
selector: '[data-od-id="hero-title"]',
label: 'h1.hero-title',
text: ` ${'Title '.repeat(80)} `,
htmlHint: `<h1 class="hero-title" data-od-id="hero-title">${'x'.repeat(240)}</h1>`,
position: { x: 10.4, y: 20.5, width: 300.2, height: 88.8 },
style: {
color: 'rgb(26, 25, 22)',
fontSize: '13.5px',
fontFamily: 'Inter, sans-serif',
},
});
expect(target.text.length).toBeLessThanOrEqual(160);
expect(target.htmlHint.length).toBeLessThanOrEqual(180);
expect(target.position).toEqual({ x: 10, y: 21, width: 300, height: 89 });
expect(target.style).toMatchObject({
color: 'rgb(26, 25, 22)',
fontSize: '13.5px',
fontFamily: 'Inter, sans-serif',
});
});
it('creates ordered compact send payloads from attached comments', () => {
const attachments = commentsToAttachments([
comment({ id: 'c1', elementId: 'hero-title', note: 'Shorten this title' }),
comment({ id: 'c2', elementId: 'chart', note: 'Make it feel real' }),
]);
expect(attachments).toMatchObject([
{ id: 'c1', order: 1, elementId: 'hero-title', comment: 'Shorten this title' },
{ id: 'c2', order: 2, elementId: 'chart', comment: 'Make it feel real' },
]);
});
it('keeps saved comment images in send payloads even when the note is empty', () => {
const [attachment] = commentsToAttachments([
comment({
id: 'c1',
note: '',
attachments: [{ path: 'uploads/reference.png', name: 'reference.png' }],
}),
]);
expect(attachment).toMatchObject({
id: 'c1',
comment: 'Use the attached image as the comment reference.',
imageAttachments: [{ path: 'uploads/reference.png', name: 'reference.png' }],
});
expect(messageContentWithCommentAttachments('', attachment ? [attachment] : []))
.toContain('image.1: uploads/reference.png | reference.png');
});
it('keeps the task query out of context when a comment is promoted to message text', () => {
const [attachment] = commentsToAttachments([
comment({ id: 'c1', elementId: 'hero-title', note: 'Make the title factual' }),
]);
const contextOnly = attachment
? [{ ...attachment, comment: '', commentContext: 'query' as const }]
: [];
const content = messageContentWithCommentAttachments('Make the title factual', contextOnly);
expect(content).toContain('Make the title factual');
expect(content).toContain('selector: [data-od-id="hero-title"]');
expect(content).not.toContain('comment: Make the title factual');
});
it('merges saved preview comment image attachments without duplicates', () => {
expect(
mergePreviewCommentAttachments(
[
{ path: 'uploads/ref-a.png', name: 'ref-a.png' },
{ path: 'uploads/ref-b.png', name: 'ref-b.png' },
],
[
{ path: 'uploads/ref-b.png', name: 'duplicate.png' },
{ path: 'uploads/ref-c.png', name: '' },
],
),
).toEqual([
{ path: 'uploads/ref-a.png', name: 'ref-a.png' },
{ path: 'uploads/ref-b.png', name: 'ref-b.png' },
{ path: 'uploads/ref-c.png', name: 'ref-c.png' },
]);
});
it('builds grouped board payloads for pod selections', () => {
const attachments = buildBoardCommentAttachments({
target: {
filePath: 'atlas.html',
elementId: 'pod-1',
selector: '[data-od-id="hero"], [data-od-id="chart"]',
label: 'Hero and chart',
text: 'Hero title Chart value',
position: { x: 10, y: 20, width: 300, height: 200 },
htmlHint: '<section data-od-id="hero">',
selectionKind: 'pod',
memberCount: 2,
podMembers: [
{
elementId: 'hero',
selector: '[data-od-id="hero"]',
label: 'section.hero',
text: 'Hero title',
position: { x: 10, y: 20, width: 200, height: 100 },
htmlHint: '<section data-od-id="hero">',
},
{
elementId: 'chart',
selector: '[data-od-id="chart"]',
label: 'section.chart',
text: 'Chart value',
position: { x: 120, y: 80, width: 190, height: 120 },
htmlHint: '<section data-od-id="chart">',
},
],
},
notes: ['Tighten the hierarchy', 'Make the chart feel premium'],
});
expect(attachments).toHaveLength(2);
expect(attachments[0]).toMatchObject({
selectionKind: 'pod',
memberCount: 2,
source: 'board-batch',
comment: 'Tighten the hierarchy',
});
expect(messageContentWithCommentAttachments('', attachments)).toContain('memberCount: 2');
});
it('builds an image-only board payload when no text note was entered', () => {
const attachments = buildBoardCommentAttachments({
target: {
filePath: 'atlas.html',
elementId: 'hero-title',
selector: '[data-od-id="hero-title"]',
label: 'Hero title',
text: 'OpenDesign',
position: { x: 10, y: 20, width: 300, height: 80 },
htmlHint: '<h1 data-od-id="hero-title">',
selectionKind: 'element',
},
notes: [],
includeImageOnly: true,
imageAttachmentCount: 2,
});
expect(attachments).toHaveLength(1);
expect(attachments[0]).toMatchObject({
elementId: 'hero-title',
comment: 'Use the 2 attached images as the comment reference.',
source: 'board-batch',
});
});
it('builds visual annotation payloads without requiring a selector', () => {
const attachment = buildVisualAnnotationAttachment({
order: 1,
screenshotPath: 'uploads/drawing.png',
markKind: 'stroke',
note: '',
bounds: { x: 12, y: 24, width: 140, height: 80 },
target: {
filePath: 'index.html',
position: { x: 12, y: 24, width: 140, height: 80 },
},
});
expect(attachment).toMatchObject({
selectionKind: 'visual',
screenshotPath: 'uploads/drawing.png',
markKind: 'stroke',
selector: '',
comment: expect.stringContaining('red strokes'),
intent: expect.stringContaining('red strokes'),
});
expect(messageContentWithCommentAttachments('', [attachment])).toContain('targetKind: visual');
expect(messageContentWithCommentAttachments('', [attachment])).toContain('screenshot: uploads/drawing.png');
expect(messageContentWithCommentAttachments('', [attachment])).toContain('markKind: stroke');
expect(messageContentWithCommentAttachments('', [attachment])).not.toContain('selector: ');
});
// Issue #4084: a failed screenshot capture (#4080) must not strip the mark's
// structured location. The payload still carries bounds/markKind/filePath so
// the agent can locate the region without pixels.
it('builds visual annotation payloads without a screenshot', () => {
const attachment = buildVisualAnnotationAttachment({
order: 1,
idSeed: 'box-1',
markKind: 'stroke',
note: 'Make this part bigger',
bounds: { x: 120, y: 60, width: 300, height: 200 },
target: {
filePath: 'index.html',
position: { x: 120, y: 60, width: 300, height: 200 },
},
});
expect(attachment).toMatchObject({
selectionKind: 'visual',
markKind: 'stroke',
filePath: 'index.html',
comment: 'Make this part bigger',
});
expect(attachment.screenshotPath).toBeUndefined();
expect(attachment.pagePosition).toMatchObject({ x: 120, y: 60, width: 300, height: 200 });
expect(attachment.id).toContain('box-1');
// The prompt context must not point the agent at a screenshot that does
// not exist — it renders the structured location instead.
const content = messageContentWithCommentAttachments('', [attachment]);
expect(content).toContain('targetKind: visual');
expect(content).not.toContain('screenshot:');
expect(content).toContain('no screenshot');
expect(content).toContain('position: x120 y60 300x200');
});
it('keeps large queued board-note batches ordered in one send payload', () => {
const notes = Array.from({ length: 8 }, (_, index) => `Note ${index + 1}`);
const attachments = buildBoardCommentAttachments({
target: {
filePath: 'atlas.html',
elementId: 'pod-2',
selector: '[data-od-id="card"]',
label: 'Card pod',
text: 'Heading Body CTA',
position: { x: 20, y: 30, width: 240, height: 160 },
htmlHint: '<section data-od-id="card">',
selectionKind: 'pod',
memberCount: 3,
podMembers: [
{
elementId: 'card-heading',
selector: '[data-od-id="card-heading"]',
label: 'h2.card-heading',
text: 'Heading',
position: { x: 24, y: 34, width: 100, height: 32 },
htmlHint: '<h2 data-od-id="card-heading">',
},
{
elementId: 'card-body',
selector: '[data-od-id="card-body"]',
label: 'p.card-body',
text: 'Body',
position: { x: 24, y: 72, width: 180, height: 48 },
htmlHint: '<p data-od-id="card-body">',
},
{
elementId: 'card-cta',
selector: '[data-od-id="card-cta"]',
label: 'button.card-cta',
text: 'CTA',
position: { x: 24, y: 128, width: 96, height: 32 },
htmlHint: '<button data-od-id="card-cta">',
},
],
},
notes,
});
expect(attachments).toHaveLength(8);
expect(attachments.map((attachment) => attachment.order)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]);
expect(attachments.map((attachment) => attachment.comment)).toEqual(notes);
expect(messageContentWithCommentAttachments('', attachments)).toContain('8. pod-2');
});
it('updates and removes attached comments by saved comment id', () => {
const first = comment({ id: 'c1', elementId: 'hero-title', note: 'Original' });
const updated = comment({ id: 'c1', elementId: 'hero-title', note: 'Updated' });
const chart = comment({ id: 'c2', elementId: 'chart', note: 'Fix chart' });
const merged = mergeAttachedComments([first, chart], updated);
expect(merged).toHaveLength(2);
expect(merged[0]?.note).toBe('Updated');
const remaining = removeAttachedComment(merged, 'c1');
expect(commentsToAttachments(remaining)).toEqual([
expect.objectContaining({ id: 'c2', elementId: 'chart' }),
]);
});
it('converts iframe snapshot bounds into scaled overlay bounds', () => {
expect(overlayBoundsFromSnapshot({
filePath: 'index.html',
elementId: 'hero-title',
selector: '[data-od-id="hero-title"]',
label: 'h1.hero-title',
text: '',
htmlHint: '',
position: { x: 10, y: 20, width: 120, height: 40 },
}, 1.25)).toEqual({
left: 12.5,
top: 25,
width: 150,
height: 50,
});
});
it('only resolves saved markers from live snapshots for the same file', () => {
const saved = comment({ filePath: 'index.html', elementId: 'hero-title' });
const snapshots = new Map([
['hero-title', {
filePath: 'index.html',
elementId: 'hero-title',
selector: '[data-od-id="hero-title"]',
label: 'h1.hero-title',
text: '',
htmlHint: '',
position: { x: 1, y: 2, width: 3, height: 4 },
}],
]);
expect(liveSnapshotForComment(saved, snapshots)?.elementId).toBe('hero-title');
expect(liveSnapshotForComment(comment({ filePath: 'other.html' }), snapshots)).toBeNull();
});
it('rehydrates saved free-pin markers from persisted comment position after iframe reload', () => {
const saved = comment({
elementId: 'pin-abc123',
selector: '[data-od-pin="pin-abc123"]',
label: 'pin',
text: '',
htmlHint: '',
position: { x: 88, y: 144, width: 24, height: 24 },
});
expect(liveSnapshotForComment(saved, new Map())).toMatchObject({
filePath: 'index.html',
elementId: 'pin-abc123',
selector: '[data-od-pin="pin-abc123"]',
label: 'pin',
position: { x: 88, y: 144, width: 24, height: 24 },
});
});
it('ignores collapsed live snapshots so deck slide changes do not jump markers to 0,0', () => {
const saved = comment({ filePath: 'index.html', elementId: 'hero-title' });
const snapshots = new Map([
['hero-title', {
filePath: 'index.html',
elementId: 'hero-title',
selector: '[data-od-id="hero-title"]',
label: 'h1.hero-title',
text: '',
htmlHint: '',
position: { x: 0, y: 0, width: 0, height: 0 },
}],
]);
expect(liveSnapshotForComment(saved, snapshots)).toBeNull();
});
it('shows deck comments only on their saved slide index', () => {
expect(commentVisibleOnDeckSlide({ slideIndex: 2 }, 2)).toBe(true);
expect(commentVisibleOnDeckSlide({ slideIndex: 2 }, 1)).toBe(false);
expect(commentVisibleOnDeckSlide({}, 1)).toBe(true);
});
it('keeps overlay equality separate from stored target metadata updates', () => {
const base: PreviewCommentSnapshot = {
filePath: 'index.html',
elementId: 'hero-title',
selector: '[data-od-id="hero-title"]',
label: 'h1.hero-title',
text: 'Hello',
htmlHint: '',
position: { x: 12, y: 24, width: 120, height: 32 },
};
const current = new Map([['hero-title', base]]);
const next = new Map([['hero-title', { ...base, text: 'Hello world' }]]);
expect(commentSnapshotOverlayEqual(base, next.get('hero-title')!)).toBe(true);
expect(liveCommentTargetMapsEqual(current, next)).toBe(false);
next.set('hero-title', { ...base, position: { x: 13, y: 24, width: 120, height: 32 } });
expect(liveCommentTargetMapsEqual(current, next)).toBe(false);
});
it('serializes selected comments into API-mode prompt context without visible input', () => {
const attachments = commentsToAttachments([
comment({
id: 'c1',
elementId: 'hero-title',
note: 'Only shorten this title',
style: {
color: 'rgb(26, 25, 22)',
backgroundColor: 'rgba(255, 255, 255, 0)',
fontSize: '13.5px',
fontWeight: '500',
fontFamily: 'Inter, sans-serif',
},
}),
]);
const content = messageContentWithCommentAttachments('', attachments);
expect(content).toContain('(No extra typed instruction.)');
expect(content).toContain('<attached-preview-comments>');
expect(content).toContain('selector: [data-od-id="hero-title"]');
expect(content).toContain('computedStyle: color: rgb(26, 25, 22)');
expect(content).toContain('fontSize: 13.5px');
expect(content).toContain('comment: Only shorten this title');
// The hard-scope sentence IS the behavior change. Assert its key phrases
// so a future edit that softens or drops the directive lights the suite
// red instead of silently re-opening the over-broad edit bug.
expect(content).toContain('Hard scope: change ONLY');
expect(content).toContain('Do NOT modify sibling sub-pages, parent layout, global CSS, design tokens, or unrelated rules');
expect(content).toContain('ask the user before proceeding');
});
it('adds hidden comment context only to the current user message sent to API providers', () => {
const attachments = commentsToAttachments([
comment({ id: 'c1', elementId: 'hero-title', note: 'Make it bolder' }),
]);
const history: ChatMessage[] = [
{
id: 'old',
role: 'user',
content: 'Previous request',
createdAt: 0,
commentAttachments: attachments,
},
{
id: 'u1',
role: 'user',
content: '',
createdAt: 1,
commentAttachments: attachments,
},
{
id: 'a1',
role: 'assistant',
content: 'Ready',
createdAt: 2,
commentAttachments: attachments,
},
];
const next = historyWithCommentAttachmentContext(history, 'u1');
expect(next[0]?.content).toBe('Previous request');
expect(next[1]?.content).toContain('<attached-preview-comments>');
expect(next[1]?.content).toContain('comment: Make it bolder');
expect(next[2]?.content).toBe('Ready');
expect(history[1]?.content).toBe('');
});
});
function comment(patch: Partial<PreviewComment>): PreviewComment {
return {
id: 'c1',
projectId: 'project-1',
conversationId: 'conversation-1',
filePath: 'index.html',
elementId: 'hero-title',
selector: '[data-od-id="hero-title"]',
label: 'h1.hero-title',
text: 'Current title',
position: { x: 1, y: 2, width: 3, height: 4 },
htmlHint: '<h1 data-od-id="hero-title">',
note: 'Comment',
status: 'open',
createdAt: 1,
updatedAt: 1,
...patch,
};
}
describe('resolveCommentAnchor drift ladder', () => {
function snap(patch: Partial<PreviewCommentSnapshot> & { elementId: string }): PreviewCommentSnapshot {
return {
filePath: 'index.html',
selector: '[data-od-id="hero-title"]',
label: 'h1.hero-title',
text: 'Current title',
htmlHint: '<h1 data-od-id="hero-title">',
position: { x: 10, y: 20, width: 300, height: 80 },
...patch,
};
}
it('anchors on an exact live hit', () => {
const snapshots = new Map([['hero-title', snap({ elementId: 'hero-title' })]]);
const res = resolveCommentAnchor(comment({ elementId: 'hero-title' }), snapshots);
expect(res.state).toBe('anchored');
expect(res.snapshot?.elementId).toBe('hero-title');
});
it('flags an exact hit against an older content version as reanchored', () => {
const snapshots = new Map([['hero-title', snap({ elementId: 'hero-title' })]]);
const saved = comment({ elementId: 'hero-title', anchoredVersion: 3 });
expect(resolveCommentAnchor(saved, snapshots, 3).state).toBe('anchored');
expect(resolveCommentAnchor(saved, snapshots, 5).state).toBe('reanchored');
});
it('recovers a moved element by content (stale) when the id no longer matches', () => {
// The author restructured: the same element now lives under a different
// render-time id, but its selector + htmlHint are unchanged.
const snapshots = new Map([
['path-2-9', snap({ elementId: 'path-2-9', position: { x: 40, y: 900, width: 300, height: 80 } })],
]);
const res = resolveCommentAnchor(
comment({ elementId: 'path-1-0', lastGoodPosition: { x: 10, y: 20, width: 300, height: 80 } }),
snapshots,
);
expect(res.state).toBe('stale');
expect(res.snapshot?.elementId).toBe('path-2-9');
});
it('does not fuzzy-match on position alone without a content signal', () => {
const snapshots = new Map([
['other', snap({
elementId: 'other',
selector: '[data-od-id="unrelated"]',
htmlHint: '<section data-od-id="unrelated">',
text: 'Something else entirely',
position: { x: 10, y: 20, width: 300, height: 80 },
})],
]);
// Same position, but nothing about the content matches → lost, not stale.
const res = resolveCommentAnchor(comment({ elementId: 'gone' }), snapshots);
expect(res.state).toBe('lost');
});
it('loses the anchor and ghosts at the last-good position', () => {
const res = resolveCommentAnchor(
comment({
elementId: 'gone',
selector: '[data-od-id="gone"]',
htmlHint: '<div data-od-id="gone">',
text: 'No live match',
position: { x: 1, y: 1, width: 5, height: 5 },
lastGoodPosition: { x: 200, y: 300, width: 120, height: 40 },
}),
new Map(),
);
expect(res.state).toBe('lost');
// Ghost renders at last-good, NOT the (possibly stale) creation position.
expect(res.snapshot?.position).toEqual({ x: 200, y: 300, width: 120, height: 40 });
});
});
describe('queuedSlideNavTarget', () => {
it('returns the slide a queued send should flip the deck to', () => {
const target = queuedSlideNavTarget([
commentAttachment({ filePath: 'deck.html', slideIndex: 1 }),
]);
expect(target).toEqual({ filePath: 'deck.html', slideIndex: 1 });
});
it('uses the first attachment that names a deck file and a slide', () => {
const target = queuedSlideNavTarget([
commentAttachment({ id: 'a', filePath: 'deck.html', slideIndex: undefined }),
commentAttachment({ id: 'b', filePath: 'deck.html', slideIndex: 3 }),
commentAttachment({ id: 'c', filePath: 'deck.html', slideIndex: 5 }),
]);
expect(target).toEqual({ filePath: 'deck.html', slideIndex: 3 });
});
it('floors fractional slide indices and accepts slide zero', () => {
expect(
queuedSlideNavTarget([commentAttachment({ slideIndex: 0 })]),
).toEqual({ filePath: 'deck.html', slideIndex: 0 });
expect(
queuedSlideNavTarget([commentAttachment({ slideIndex: 2.9 })]),
).toEqual({ filePath: 'deck.html', slideIndex: 2 });
});
it('returns null when nothing is slide-scoped', () => {
expect(queuedSlideNavTarget(undefined)).toBeNull();
expect(queuedSlideNavTarget(null)).toBeNull();
expect(queuedSlideNavTarget([])).toBeNull();
expect(
queuedSlideNavTarget([commentAttachment({ slideIndex: undefined })]),
).toBeNull();
expect(
queuedSlideNavTarget([commentAttachment({ filePath: '', slideIndex: 2 })]),
).toBeNull();
expect(
queuedSlideNavTarget([commentAttachment({ slideIndex: -1 })]),
).toBeNull();
});
});