From df13ae2d4498dbf9dfd463298b2f9a809cc8af4f Mon Sep 17 00:00:00 2001 From: Joseph Dale Banares Date: Thu, 1 Oct 2026 00:09:54 +0800 Subject: [PATCH 1/7] fix(core): give joiners, variation selectors and combining marks no width in the card-fit measure --- packages/core/src/text-measure.ts | 4 +++- packages/core/test/text-measure.test.ts | 8 ++++++++ packages/viewer/e2e/metrics.spec.ts | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/core/src/text-measure.ts b/packages/core/src/text-measure.ts index 3eef2c1..4a77ea0 100644 --- a/packages/core/src/text-measure.ts +++ b/packages/core/src/text-measure.ts @@ -9,6 +9,8 @@ export type { FontFace }; const FALLBACK_EM = 1.1; const EMOJI_EM = 1.3; const EMOJI = /[\p{Extended_Pictographic}\p{Regional_Indicator}]/u; +// Joiners, variation selectors and combining marks draw nothing themselves (ZWJ emoji, ❤️, é). +const ZERO_WIDTH = /[\p{Mn}\p{Me}\p{Cf}]/u; const decoded = new Map(); /** A face's pair adjustments in 1/1000 em, decoded from the shipped table on first use. */ @@ -38,7 +40,7 @@ export function textWidths(text: string, face: FontFace, sizePx: number): { exac let prev = -1; for (const ch of text) { const cp = ch.codePointAt(0)!; - const advance = ((advances[cp] ?? (EMOJI.test(ch) ? EMOJI_EM : FALLBACK_EM) * em) * sizePx) / em; + const advance = ((advances[cp] ?? (EMOJI.test(ch) ? EMOJI_EM : ZERO_WIDTH.test(ch) ? 0 : FALLBACK_EM) * em) * sizePx) / em; // Kerning pairs are ASCII-only, so the packed key never collides. const kern = prev < 0 || prev > 0x7e ? 0 : ((kerning[prev * 0x10000 + cp] ?? 0) * sizePx) / em; exact += advance + kern; diff --git a/packages/core/test/text-measure.test.ts b/packages/core/test/text-measure.test.ts index c46d3fa..6ccffd0 100644 --- a/packages/core/test/text-measure.test.ts +++ b/packages/core/test/text-measure.test.ts @@ -39,6 +39,14 @@ describe('measureText', () => { expect(exact('a🚀', 'sans400', 10)).toBeCloseTo(13 + (faces.sans400[97]! * 10) / 1000, 6); }); + it('gives joiners, variation selectors and combining marks no width of their own', () => { + // Worst case, a sequence the system font can't join renders as its parts side by side. + expect(exact('👩‍👩‍👧', 'sans400', 10)).toBeCloseTo(3 * 13, 6); + expect(exact('❤️', 'sans400', 10)).toBeCloseTo(13, 6); + expect(exact('a​b', 'sans400', 10)).toBeCloseTo(exact('a', 'sans400', 10) + exact('b', 'sans400', 10), 6); + expect(exact('é', 'sans400', 10)).toBeCloseTo(exact('e', 'sans400', 10), 6); + }); + // Linux Chromium snaps every glyph advance to whole pixels and skips kerning; these are the widths it // rendered in CI (Playwright v1.63 image). The measure must never come in under either platform. it.each([ diff --git a/packages/viewer/e2e/metrics.spec.ts b/packages/viewer/e2e/metrics.spec.ts index c485244..2476127 100644 --- a/packages/viewer/e2e/metrics.spec.ts +++ b/packages/viewer/e2e/metrics.spec.ts @@ -108,7 +108,7 @@ test('a card slot never truncates text the measure said fits, and the measure st test('text outside the Latin subset is never measured narrower than it renders', async ({ page }) => { await page.goto('/?page=gallery'); await page.evaluate(() => document.fonts.ready); - const corpus = ['🚀🚀🚀 deploy', '✅ healthy', '🇩🇪 Frankfurt', '支付服务', '注文サービス', '결제 서비스', 'naïve café', 'Zürich — Ørsted']; + const corpus = ['🚀🚀🚀 deploy', '✅ healthy', '🇩🇪 Frankfurt', '支付服务', '注文サービス', '결제 서비스', 'naïve café', 'Zürich — Ørsted', '👩‍👩‍👧‍👦 family', '❤️ loved', '👍🏽 ok', 'cafe\u0301', 'zero\u200bwidth']; const real = await page.evaluate((corpus) => { const span = document.createElement('span'); span.style.cssText = "font:500 13.5px 'Geist';white-space:pre;position:absolute"; From 4c3e98e421882dee9cf9e3cd7f3eeb57d9f100e2 Mon Sep 17 00:00:00 2001 From: Joseph Dale Banares Date: Thu, 1 Oct 2026 00:21:46 +0800 Subject: [PATCH 2/7] fix(viewer): keep the canvas usable on narrow screens and give the identity card's button a job --- packages/viewer/e2e/explore.spec.ts | 22 +++++++++++++++ packages/viewer/src/chrome/ExportMenu.tsx | 5 ++-- packages/viewer/src/chrome/IdentityCard.tsx | 8 +++--- packages/viewer/src/chrome/Inspector.tsx | 9 +++++-- packages/viewer/src/chrome/Toolbar.tsx | 2 +- .../viewer/src/chrome/ViewerShell.test.tsx | 10 +++++++ packages/viewer/src/chrome/ViewerShell.tsx | 27 ++++++++++++++----- 7 files changed, 68 insertions(+), 15 deletions(-) diff --git a/packages/viewer/e2e/explore.spec.ts b/packages/viewer/e2e/explore.spec.ts index 2dc75e0..4042c9c 100644 --- a/packages/viewer/e2e/explore.spec.ts +++ b/packages/viewer/e2e/explore.spec.ts @@ -281,6 +281,28 @@ test('the inspector starts collapsed on a narrow window and can be opened', asyn await expect(inspector(page).getByRole('list', { name: 'Legend' })).toBeVisible(); }); +test('on a phone the inspector opens over the canvas, and the toolbar keeps to one row', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.reload(); + await expect(page.locator('.sm-card')).toHaveCount(6); + const stage = page.getByRole('tabpanel', { name: 'Diagram' }); + const full = (await stage.boundingBox())!; + expect(full.width).toBeGreaterThan(350); + const [first, last] = await Promise.all([page.getByRole('button', { name: 'Search nodes (/)' }).boundingBox(), page.getByRole('button', { name: 'Export' }).boundingBox()]); + expect(Math.abs(first!.y + first!.height / 2 - (last!.y + last!.height / 2))).toBeLessThan(2); + await inspector(page).getByRole('button', { name: 'Show inspector' }).click(); + await expect(inspector(page).getByRole('list', { name: 'Legend' })).toBeVisible(); + expect((await stage.boundingBox())!.width).toBe(full.width); + const sheet = (await inspector(page).boundingBox())!; + expect(sheet.x).toBeGreaterThanOrEqual(full.x); + expect(sheet.x + sheet.width).toBeLessThanOrEqual(full.x + full.width); + expect(sheet.y + sheet.height).toBeLessThanOrEqual(full.y + full.height); + await card(page, 'orders').click({ force: true }); + await expect(inspector(page).getByRole('heading', { level: 2, name: 'Orders' })).toBeVisible(); + await inspector(page).getByRole('button', { name: 'Hide inspector' }).click(); + await expect(inspector(page).getByRole('button', { name: 'Show inspector' })).toBeFocused(); +}); + test('with reduced motion the camera jumps instead of animating', async ({ page }) => { await page.emulateMedia({ reducedMotion: 'reduce' }); await page.reload(); diff --git a/packages/viewer/src/chrome/ExportMenu.tsx b/packages/viewer/src/chrome/ExportMenu.tsx index 072d35a..da1ebb6 100644 --- a/packages/viewer/src/chrome/ExportMenu.tsx +++ b/packages/viewer/src/chrome/ExportMenu.tsx @@ -137,10 +137,11 @@ export function ExportMenu() { setOpen(true); } }} - className="sm-press flex h-9 items-center gap-2 rounded-full bg-primary px-4 text-[14px] font-medium text-primary-fg disabled:opacity-60" + className="sm-press flex h-9 items-center gap-2 rounded-full bg-primary px-4 text-[14px] font-medium text-primary-fg disabled:opacity-60 @max-md:px-2.5" >