Skip to content

Commit f81f720

Browse files
fix(admin): refine typography hierarchy (#2252)
* feat(admin): refine typography hierarchy * docs(admin): remove typography guide
1 parent e1ab8f0 commit f81f720

41 files changed

Lines changed: 308 additions & 134 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/calm-dashboard-type.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@emdash-cms/admin": patch
3+
---
4+
5+
Updates the admin Dashboard typography for clearer hierarchy and stable metric and activity values.

.changeset/calm-owls-read.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@emdash-cms/admin": patch
3+
---
4+
5+
Updates admin typography with consistent page headings, descriptions, and media library text hierarchy.

e2e/tests/accessibility.spec.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,93 @@ test.describe("Accessibility Audit", () => {
5858
expect(results.violations).toEqual([]);
5959
});
6060

61+
test("dashboard card headings and metric values share an inset", async ({ admin }) => {
62+
await admin.goToDashboard();
63+
await admin.waitForLoading();
64+
65+
const metricCards = admin.page.getByTestId("dashboard-metric");
66+
expect(await metricCards.count()).toBeGreaterThanOrEqual(3);
67+
68+
const layout = await admin.page.locator("main").evaluate((main) => {
69+
const textStart = (element: Element) => {
70+
const textNode = [...element.childNodes].find(
71+
(node) => node.nodeType === Node.TEXT_NODE && node.textContent?.trim(),
72+
);
73+
if (!textNode) throw new Error("Dashboard card text is missing");
74+
const range = document.createRange();
75+
range.selectNodeContents(textNode);
76+
const rect = range.getBoundingClientRect();
77+
return getComputedStyle(element).direction === "rtl" ? rect.right : rect.left;
78+
};
79+
const cardStart = (card: Element) => {
80+
const rect = card.getBoundingClientRect();
81+
return getComputedStyle(card).direction === "rtl" ? rect.right : rect.left;
82+
};
83+
const contentHeading = [...main.querySelectorAll("h2")].find(
84+
(heading) => heading.textContent?.trim() === "Content",
85+
);
86+
const contentCard = contentHeading?.parentElement?.parentElement;
87+
if (!contentHeading || !contentCard) throw new Error("Content card heading is missing");
88+
const headingInset = Math.abs(textStart(contentHeading) - cardStart(contentCard));
89+
90+
const metrics = Array.from(
91+
main.querySelectorAll('[data-testid="dashboard-metric"]'),
92+
(card) => {
93+
const heading = card.querySelector("h2");
94+
const value = card.querySelector('[data-testid="dashboard-metric-value"]');
95+
if (!heading || !value) throw new Error("Metric label or value is missing");
96+
97+
return {
98+
headingInset: Math.abs(textStart(heading) - cardStart(card)),
99+
labelValueGap: Math.abs(textStart(heading) - textStart(value)),
100+
};
101+
},
102+
);
103+
104+
return { headingInset, metrics };
105+
});
106+
107+
for (const metric of layout.metrics) {
108+
expect(metric.headingInset).toBeCloseTo(layout.headingInset, 1);
109+
expect(metric.labelValueGap).toBeLessThanOrEqual(0.5);
110+
}
111+
});
112+
113+
test("dashboard headings keep the font's default tracking across scripts", async ({
114+
admin,
115+
}) => {
116+
await admin.goToDashboard();
117+
await admin.waitForLoading();
118+
119+
const trackingByLocale = await admin.page.locator("main").evaluate((main) => {
120+
const title = main.querySelector("h1");
121+
const metricValues = main.querySelectorAll('[data-testid="dashboard-metric-value"]');
122+
if (!title || metricValues.length === 0) throw new Error("Dashboard typography is missing");
123+
124+
const root = document.documentElement;
125+
const originalLang = root.lang;
126+
const locales = ["en", "ja", "zh-CN"];
127+
128+
try {
129+
return locales.map((locale) => {
130+
root.lang = locale;
131+
return {
132+
locale,
133+
title: getComputedStyle(title).letterSpacing,
134+
metrics: Array.from(metricValues, (value) => getComputedStyle(value).letterSpacing),
135+
};
136+
});
137+
} finally {
138+
root.lang = originalLang;
139+
}
140+
});
141+
142+
for (const tracking of trackingByLocale) {
143+
expect(tracking.title, tracking.locale).toBe("normal");
144+
expect(tracking.metrics, tracking.locale).toEqual(tracking.metrics.map(() => "normal"));
145+
}
146+
});
147+
61148
test("content list should have no WCAG 2.x AA violations", async ({ admin }) => {
62149
await admin.goToContent("posts");
63150
await admin.waitForLoading();
@@ -125,6 +212,55 @@ test.describe("Accessibility Audit", () => {
125212
expect(results.violations).toEqual([]);
126213
});
127214

215+
test("page descriptions meet regular-text contrast in the classic light theme", async ({
216+
admin,
217+
page,
218+
}) => {
219+
await page.emulateMedia({ colorScheme: "light" });
220+
await admin.goto("/plugins-manager");
221+
await admin.waitForShell();
222+
await admin.waitForLoading();
223+
224+
const description = page.getByText(
225+
"Manage installed plugins. Enable or disable plugins to control their functionality.",
226+
{ exact: true },
227+
);
228+
await expect(description).toBeVisible();
229+
230+
const contrast = await description.evaluate((element) => {
231+
const canvas = document.createElement("canvas");
232+
canvas.width = 1;
233+
canvas.height = 1;
234+
const context = canvas.getContext("2d");
235+
if (!context) throw new Error("Canvas context unavailable");
236+
237+
const toRgb = (color: string) => {
238+
context.clearRect(0, 0, 1, 1);
239+
context.fillStyle = color;
240+
context.fillRect(0, 0, 1, 1);
241+
return context.getImageData(0, 0, 1, 1).data;
242+
};
243+
const luminance = (rgb: Uint8ClampedArray) => {
244+
const toLinear = (channel: number) => {
245+
const value = channel / 255;
246+
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
247+
};
248+
const red = toLinear(rgb[0] ?? 0);
249+
const green = toLinear(rgb[1] ?? 0);
250+
const blue = toLinear(rgb[2] ?? 0);
251+
return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
252+
};
253+
254+
const foreground = luminance(toRgb(getComputedStyle(element).color));
255+
const background = luminance(toRgb(getComputedStyle(document.body).backgroundColor));
256+
return (
257+
(Math.max(foreground, background) + 0.05) / (Math.min(foreground, background) + 0.05)
258+
);
259+
});
260+
261+
expect(contrast).toBeGreaterThanOrEqual(4.5);
262+
});
263+
128264
test("content list should be keyboard navigable", async ({ admin }) => {
129265
await admin.goToContent("posts");
130266
await admin.waitForLoading();

packages/admin/src/components/ContentList.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -322,7 +322,7 @@ export function ContentList({
322322
{/* Header */}
323323
<div className="flex items-center justify-between">
324324
<div className="flex items-center gap-4">
325-
<h1 className="text-2xl font-bold">{collectionLabel}</h1>
325+
<h1 className="text-2xl font-semibold leading-tight">{collectionLabel}</h1>
326326
{i18n && activeLocale && onLocaleChange && (
327327
<LocaleSwitcher
328328
locales={i18n.locales}

packages/admin/src/components/ContentTypeEditor.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -351,7 +351,7 @@ export function ContentTypeEditor({
351351
) : null
352352
}
353353
>
354-
<h1 className="text-2xl font-bold truncate">
354+
<h1 className="truncate text-2xl font-semibold">
355355
{isNew ? t`New Content Type` : collection?.label}
356356
</h1>
357357
{!isNew && (

packages/admin/src/components/ContentTypeList.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,10 @@ export function ContentTypeList({
3737
{/* Header */}
3838
<div className="flex items-center justify-between">
3939
<div>
40-
<h1 className="text-2xl font-bold">{t`Content Types`}</h1>
41-
<p className="text-kumo-subtle text-sm">{t`Define the structure of your content`}</p>
40+
<h1 className="text-2xl font-semibold leading-tight">{t`Content Types`}</h1>
41+
<p className="mt-1 text-sm leading-5 text-pretty text-kumo-subtle">
42+
{t`Define the structure of your content`}
43+
</p>
4244
</div>
4345
<RouterLinkButton to="/content-types/new" icon={<Plus />}>
4446
{t`New Content Type`}

0 commit comments

Comments
 (0)