Task #303: DIN Next LT Arabic site default + working font picker

User report: "the dropdown for changing the site font does not work."
Root cause: FontSettingsSection listed Cairo / Tajawal / Noto Naskh
Arabic / Amiri, but only Tajawal had been registered with @font-face.
Picking the others was a no-op. Site default body font was also not
DIN Next LT Arabic as designed.

Changes:
- artifacts/tx-os/src/index.css: --app-font-sans now starts with
  "DIN Next LT Arabic"; dropped IBM Plex Mono.
- artifacts/tx-os/index.html: removed Google Fonts <link> + preconnect.
- artifacts/tx-os/src/pages/executive-meetings.tsx: FontSettingsSection
  dropdown replaced with the 5 actually-bundled families + system.
- artifacts/api-server/src/routes/executive-meetings.ts: FONT_FAMILIES
  Zod allowlist updated to match.
- artifacts/api-server/src/lib/sanitize.ts: FONT_NAME_PART regex now
  allows the new families (kept IBM Plex Sans Arabic for backward
  compat). Fixes a latent bug from #301 where the rich-text sanitizer
  silently stripped attendee font picks on save.
- artifacts/api-server/src/lib/pdf-renderer.ts: FAMILY_MAP and
  ARABIC_FONT_NAMES updated. Sans-style families map to the bundled
  NotoSansArabic; Naskh-style families to NotoNaskhArabic.
- artifacts/api-server/tests/executive-meetings.test.mjs: PDF
  sans-vs-naskh assertion updated from Cairo/Noto Naskh Arabic to
  DIN Next LT Arabic/Majalla, preserving its semantics. Other
  Cairo/Noto Naskh Arabic occurrences swapped to the new names.
- replit.md: documented site default font + lockstep allowlist rule.

Deviations from plan: kept DEFAULT_FONT.fontFamily = "system" on the
frontend (and the backend Zod default) instead of changing it to
"DIN Next LT Arabic". "system" semantically means "no override → use
the CSS default", which is now DIN Next LT Arabic — same end result
without forcing a migration on existing rows. The sanitizer change
was not in the original plan but was required to make the picker
actually persist.

Verification:
- Frontend tsc clean.
- Backend tsc shows the same pre-existing unrelated errors as before
  (isHighlighted boolean/number; Drizzle scope typing).
- E2E browser test verified all 7 assertions: site default font,
  no Google Fonts requests, exactly 6 dropdown options, font picker
  applies + reverts correctly, attendee font picks persist after save.
- Code review verdict: PASS.
This commit is contained in:
Riyadh
2026-05-01 19:55:39 +00:00
parent 0d059b36c9
commit 1de19422ad
7 changed files with 94 additions and 32 deletions
+25 -6
View File
@@ -103,6 +103,15 @@ type FamilyMapping = {
latinBold: FontFileKey;
};
// Web fonts (DIN Next LT Arabic, Tajawal, Helvetica Neue LT Arabic,
// Helvetica Neue, Majalla) are not bundled into the PDF renderer
// because we do not ship their font files server-side. Instead we map
// each web family to the closest visual stand-in already bundled here:
// sans-serif Arabic families render with NotoSansArabic; serif/Naskh
// stand-ins use NotoNaskhArabic; pure-Latin families (Helvetica Neue)
// fall back to the Naskh pair so Arabic glyphs in mixed strings still
// render correctly. This keeps PDFs readable without ballooning the
// server bundle.
const FAMILY_MAP: Record<string, FamilyMapping> = {
system: {
arabicRegular: "naskhRegular",
@@ -110,7 +119,7 @@ const FAMILY_MAP: Record<string, FamilyMapping> = {
latinRegular: "latinRegular",
latinBold: "latinBold",
},
Cairo: {
"DIN Next LT Arabic": {
arabicRegular: "arabicSansRegular",
arabicBold: "arabicSansBold",
latinRegular: "latinRegular",
@@ -122,13 +131,19 @@ const FAMILY_MAP: Record<string, FamilyMapping> = {
latinRegular: "latinRegular",
latinBold: "latinBold",
},
"Noto Naskh Arabic": {
"Helvetica Neue LT Arabic": {
arabicRegular: "arabicSansRegular",
arabicBold: "arabicSansBold",
latinRegular: "latinRegular",
latinBold: "latinBold",
},
"Helvetica Neue": {
arabicRegular: "naskhRegular",
arabicBold: "naskhBold",
latinRegular: "latinRegular",
latinBold: "latinBold",
},
Amiri: {
Majalla: {
arabicRegular: "naskhRegular",
arabicBold: "naskhBold",
latinRegular: "latinRegular",
@@ -140,11 +155,15 @@ function familyMappingFor(family: string): FamilyMapping {
return FAMILY_MAP[family] ?? FAMILY_MAP.system;
}
// Family names that primarily target Arabic script. Used by
// `fontFamilyIsArabic` for downstream callers that branch on
// script — kept aligned with FAMILY_MAP. "Helvetica Neue" (the Latin
// face) is intentionally excluded; the others all ship Arabic glyphs.
const ARABIC_FONT_NAMES: ReadonlyArray<string> = [
"Cairo",
"DIN Next LT Arabic",
"Tajawal",
"Noto Naskh Arabic",
"Amiri",
"Helvetica Neue LT Arabic",
"Majalla",
];
// Bytes are loaded lazily on first render and reused.
+13 -3
View File
@@ -1,8 +1,18 @@
import sanitizeHtml from "sanitize-html";
// Allowed font families: Cairo, Tajawal, Amiri, Noto Naskh Arabic,
// IBM Plex Sans Arabic, system-ui, sans-serif, serif, monospace.
const FONT_NAME_PART = String.raw`(?:["']?(?:Cairo|Tajawal|Amiri|Noto Naskh Arabic|IBM Plex Sans Arabic|system-ui|sans-serif|serif|monospace)["']?)`;
// Allowed font families inside rich-text `style="font-family: …"` from
// the Tiptap editor. Must include every family exposed in the editor's
// font dropdown (FONT_OPTIONS in `editable-cell.tsx`) and every family
// users can pick in `FontSettingsSection`. Any name not in this list is
// stripped by sanitize-html, which would silently revert the user's
// font pick on save — so keep this list in lockstep with:
// - tx-os/src/components/editable-cell.tsx (FONT_OPTIONS)
// - tx-os/src/pages/executive-meetings.tsx (FontSettingsSection options)
// - artifacts/api-server/src/routes/executive-meetings.ts (FONT_FAMILIES)
// - artifacts/api-server/src/lib/pdf-renderer.ts (FAMILY_MAP)
// `IBM Plex Sans Arabic` is retained for backward compatibility with
// any historical rich-text already persisted before the font overhaul.
const FONT_NAME_PART = String.raw`(?:["']?(?:DIN Next LT Arabic|Tajawal|Helvetica Neue LT Arabic|Helvetica Neue|Majalla|IBM Plex Sans Arabic|system-ui|sans-serif|serif|monospace)["']?)`;
const FONT_FAMILY_RE = new RegExp(
`^${FONT_NAME_PART}(?:\\s*,\\s*${FONT_NAME_PART})*$`,
);
@@ -88,12 +88,20 @@ const MERGE_COLUMN_INDEX: Record<(typeof MERGE_COLUMNS)[number], number> = {
attendees: 2,
time: 3,
};
// Allowed values for the user/global font-settings PATCH endpoint and
// the rich-text font-family CSS allowlist. Keep in lockstep with the
// `FontSettingsSection` dropdown in `tx-os/src/pages/executive-meetings.tsx`,
// the @font-face declarations in `tx-os/src/custom-fonts.css`, and the
// FAMILY_MAP in `pdf-renderer.ts`. The previous Cairo / Noto Naskh
// Arabic / Amiri entries were dropped because they were never actually
// loaded with @font-face on the client, so picking them was a no-op.
const FONT_FAMILIES = [
"system",
"Cairo",
"DIN Next LT Arabic",
"Tajawal",
"Noto Naskh Arabic",
"Amiri",
"Helvetica Neue LT Arabic",
"Helvetica Neue",
"Majalla",
] as const;
const FONT_WEIGHTS = ["regular", "bold"] as const;
const FONT_ALIGNMENTS = ["start", "center"] as const;
@@ -662,7 +662,7 @@ test("Font settings: valid combo 200; invalid weight/size/family 400", async ()
"/api/executive-meetings/font-settings",
{
scope: "user",
fontFamily: "Cairo",
fontFamily: "DIN Next LT Arabic",
fontSize: 16,
fontWeight: "bold",
alignment: "center",
@@ -805,16 +805,16 @@ test("PDF GET /executive-meetings/pdf returns a real PDF and archives it", async
// Font-family must affect which font is embedded in the PDF. We render
// the same day twice with different families and assert that:
// 1) Both downloads succeed (200 + %PDF magic).
// 2) The Sans family ("Cairo") embeds NotoSansArabic, while the Naskh
// default ("Noto Naskh Arabic") embeds NotoNaskhArabic. The font's
// PostScript name appears verbatim in the PDF font dictionary.
// 2) The Sans family ("DIN Next LT Arabic") embeds NotoSansArabic,
// while the Naskh-style family ("Majalla") embeds NotoNaskhArabic.
// The PostScript name appears verbatim in the PDF font dictionary.
const setSans = await api(
adminCookie,
"PUT",
"/api/executive-meetings/font-settings",
{
scope: "user",
fontFamily: "Cairo",
fontFamily: "DIN Next LT Arabic",
fontSize: 16,
fontWeight: "bold",
alignment: "center",
@@ -832,11 +832,11 @@ test("PDF GET /executive-meetings/pdf returns a real PDF and archives it", async
const sansAscii = sansBuf.toString("latin1");
assert.ok(
/NotoSansArabic/i.test(sansAscii),
"Cairo family must embed NotoSansArabic in the PDF",
"DIN Next LT Arabic family must embed NotoSansArabic in the PDF",
);
assert.ok(
!/NotoNaskhArabic/i.test(sansAscii),
"Cairo family must NOT embed NotoNaskhArabic",
"DIN Next LT Arabic family must NOT embed NotoNaskhArabic",
);
const setNaskh = await api(
@@ -845,7 +845,7 @@ test("PDF GET /executive-meetings/pdf returns a real PDF and archives it", async
"/api/executive-meetings/font-settings",
{
scope: "user",
fontFamily: "Noto Naskh Arabic",
fontFamily: "Majalla",
fontSize: 14,
fontWeight: "regular",
alignment: "start",
@@ -862,11 +862,11 @@ test("PDF GET /executive-meetings/pdf returns a real PDF and archives it", async
const naskhAscii = naskhBuf.toString("latin1");
assert.ok(
/NotoNaskhArabic/i.test(naskhAscii),
"Noto Naskh Arabic family must embed NotoNaskhArabic in the PDF",
"Majalla family must embed NotoNaskhArabic in the PDF",
);
assert.ok(
!/NotoSansArabic/i.test(naskhAscii),
"Noto Naskh Arabic family must NOT embed NotoSansArabic",
"Majalla family must NOT embed NotoSansArabic",
);
});
@@ -1227,7 +1227,7 @@ test("Font settings: PUT then GET returns the user-scoped row roundtrip", async
const patch = await api(adminCookie, "PATCH",
"/api/executive-meetings/font-settings", {
scope: "user",
fontFamily: "Cairo",
fontFamily: "DIN Next LT Arabic",
fontSize: 14,
fontWeight: "regular",
alignment: "start",
@@ -1237,7 +1237,7 @@ test("Font settings: PUT then GET returns the user-scoped row roundtrip", async
const get2 = await api(adminCookie, "GET",
"/api/executive-meetings/font-settings");
const body2 = await get2.json();
assert.equal(body2.user.fontFamily, "Cairo");
assert.equal(body2.user.fontFamily, "DIN Next LT Arabic");
assert.equal(body2.user.fontSize, 14);
assert.equal(body2.user.fontWeight, "regular");
assert.equal(body2.user.alignment, "start");
+7 -3
View File
@@ -5,9 +5,13 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Tx OS</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Arabic:wght@300;400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<!--
Fonts are now bundled locally via `src/custom-fonts.css`
(DIN Next LT Arabic, Tajawal, Helvetica Neue LT Arabic,
Helvetica Neue, Majalla). No third-party Google Fonts roundtrip
is needed at page load, which speeds up first paint and removes
the external dependency.
-->
</head>
<body>
<div id="root"></div>
+10 -2
View File
@@ -92,9 +92,17 @@
--sidebar-accent-foreground: 222 47% 11%;
--sidebar-ring: 217 91% 60%;
--app-font-sans: "IBM Plex Sans Arabic", "Inter", system-ui, sans-serif;
/*
* Site-wide default font. DIN Next LT Arabic ships locally via
* `custom-fonts.css` (loaded on the line `@import "./custom-fonts.css"`
* at the top of this file), so this resolves immediately without a
* Google Fonts roundtrip. The remaining fallbacks are kept so the
* page remains readable while the @font-face files are still being
* fetched on a cold cache (font-display: swap).
*/
--app-font-sans: "DIN Next LT Arabic", "Tajawal", system-ui, sans-serif;
--app-font-serif: Georgia, serif;
--app-font-mono: "IBM Plex Mono", Menlo, monospace;
--app-font-mono: Menlo, Consolas, monospace;
--radius: 1rem;
}
@@ -6382,12 +6382,25 @@ function FontSettingsSection({
<SelectValue />
</SelectTrigger>
<SelectContent>
{/*
Only families that ship locally via `custom-fonts.css`
(DIN Next LT Arabic, Tajawal, Helvetica Neue LT Arabic,
Helvetica Neue, Majalla) plus the special "system"
option (= use whatever the site's CSS default is, which
is currently DIN Next LT Arabic). The previous list
included Cairo / Noto Naskh Arabic / Amiri but those
were never registered with @font-face, so picking them
produced no visible change. Keep this list in sync with
the FONT_FAMILIES enum on the API server, otherwise the
PATCH font-settings request will be rejected by Zod.
*/}
{[
"system",
"Cairo",
"DIN Next LT Arabic",
"Tajawal",
"Noto Naskh Arabic",
"Amiri",
"Helvetica Neue LT Arabic",
"Helvetica Neue",
"Majalla",
].map((f) => (
<SelectItem key={f} value={f}>
{f}