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:
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user