diff --git a/packages/core/src/card-text.ts b/packages/core/src/card-text.ts index 0268423..c60f5b2 100644 --- a/packages/core/src/card-text.ts +++ b/packages/core/src/card-text.ts @@ -56,6 +56,7 @@ export function cardTextSlots(card: CardData): TextSlot[] { if (item) add(`/footer/${side}/text`, item.text, 'sans400', 12, half - (item.icon ? 13 + 6 : 0)); } - add('/cta/label', card.cta?.label, 'sans500', 13, PANEL - 2 * 12 - 15); // px-3 link, 15px arrow + // px-3 tile; a linked CTA also shows a 15px arrow. + add('/cta/label', card.cta?.label, 'sans500', 13, PANEL - 2 * 12 - (card.cta?.href ? 15 : 0)); return slots; } diff --git a/packages/core/src/samples/index.ts b/packages/core/src/samples/index.ts index bfe83f7..e2068b5 100644 --- a/packages/core/src/samples/index.ts +++ b/packages/core/src/samples/index.ts @@ -49,7 +49,7 @@ export const commerceApi: DiagramDraft = { ], statsNote: '2 replication links', footer: { left: { text: 'EU West', icon: 'region' }, right: { text: '3 members', icon: 'members' } }, - cta: { label: 'Open cluster' }, + cta: { label: 'Open cluster', href: 'https://console.example.com/clusters/orders' }, }, }, { @@ -65,7 +65,7 @@ export const commerceApi: DiagramDraft = { ], statsNote: '3 replication links', footer: { left: { text: 'EU West', icon: 'region' }, right: { text: '6 members', icon: 'members' } }, - cta: { label: 'Open cluster' }, + cta: { label: 'Open cluster', href: 'https://console.example.com/clusters/sessions' }, }, }, ], diff --git a/packages/core/test/card-text.test.ts b/packages/core/test/card-text.test.ts index 825ebfe..868f763 100644 --- a/packages/core/test/card-text.test.ts +++ b/packages/core/test/card-text.test.ts @@ -44,7 +44,8 @@ describe('cardTextSlots', () => { expect(slot(card, '/footer/right/text')!.maxWidth).toBe(118); }); - it('the CTA label leaves room for its arrow', () => { - expect(slot({ title: 't', cta: { label: 'Open cluster' } }, '/cta/label')).toMatchObject({ face: 'sans500', size: 13, maxWidth: 217 }); + it('a linked CTA label leaves room for its arrow; an unlinked one (no arrow) gets it back', () => { + expect(slot({ title: 't', cta: { label: 'Open', href: 'https://x.dev' } }, '/cta/label')).toMatchObject({ face: 'sans500', size: 13, maxWidth: 217 }); + expect(slot({ title: 't', cta: { label: 'Open' } }, '/cta/label')).toMatchObject({ maxWidth: 232 }); }); }); diff --git a/packages/schema/test/validate.test.ts b/packages/schema/test/validate.test.ts index 27664a6..9f45c3a 100644 --- a/packages/schema/test/validate.test.ts +++ b/packages/schema/test/validate.test.ts @@ -115,6 +115,15 @@ describe('validateDiagram', () => { expect(id!.allowedFixes).toEqual([expect.stringMatching(/^remove "id"/)]); }); + it('an edge label limit counts characters, not UTF-16 units (emoji)', () => { + const emoji = '🚀'.repeat(13); // 13 characters, 26 UTF-16 units + expect(diags(base({ edges: [{ id: 'ab', from: 'a', to: 'b', label: emoji }] }))).toEqual([]); + expect(diags(base({ edges: [{ id: 'ab', from: 'a', to: 'b', label: '🚀'.repeat(25) }] }))[0]).toMatchObject({ + subject: '/edges/0/label', + allowedFixes: ['shorten "label" to at most 24 characters'], + }); + }); + it('limits say how far to cut', () => { const stats = Array.from({ length: 4 }, (_, i) => ({ value: String(i), label: 'x' })); expect(diags(base({ nodes: [node('a', { card: { title: 'a', stats } })] }))[0]).toMatchObject({ diff --git a/packages/viewer/e2e/explore.spec.ts b/packages/viewer/e2e/explore.spec.ts index 9abbb75..78354bc 100644 --- a/packages/viewer/e2e/explore.spec.ts +++ b/packages/viewer/e2e/explore.spec.ts @@ -303,3 +303,56 @@ test('a view fits its members clear of the toolbar and zoom bar', async ({ page } } }); + +test('holding an arrow key pans the full distance for every repeat', async ({ page }) => { + const start = await viewportOf(page); + await page.locator('.sm-stage').focus(); + await page.keyboard.down('ArrowRight'); + for (let i = 0; i < 4; i++) await page.keyboard.down('ArrowRight'); // auto-repeat + await page.keyboard.up('ArrowRight'); + await settle(page); + expect((await viewportOf(page)).x).toBeCloseTo(start.x - 5 * 80, 0); +}); + +test('dragging on the minimap pans the canvas with the pointer', async ({ page }) => { + await page.getByRole('button', { name: 'Toggle minimap' }).click(); + const map = (await page.locator('svg.sm-minimap').boundingBox())!; + const before = await viewportOf(page); + await page.mouse.move(map.x + map.width * 0.3, map.y + map.height / 2); + await page.mouse.down(); + await page.mouse.move(map.x + map.width * 0.7, map.y + map.height / 2, { steps: 6 }); + const mid = await viewportOf(page); + await page.mouse.up(); + expect(mid.x).toBeLessThan(before.x - 50); // moved during the drag, not only on release + expect(mid.k).toBeCloseTo(before.k, 5); +}); + +test('the canvas neither selects text nor scrolls the page on touch', async ({ page }) => { + const style = await page.locator('.sm-stage').evaluate((el) => { + const s = getComputedStyle(el); + return { select: s.userSelect, touch: s.touchAction }; + }); + expect(style).toEqual({ select: 'none', touch: 'none' }); +}); + +test('a canvas that mounts at zero size fits once it gets one, without errors', async ({ page }) => { + const errors: string[] = []; + page.on('console', (m) => m.type() === 'error' && errors.push(m.text())); + // A constructed sheet survives document parsing (a