diff --git a/artifacts/api-server/src/lib/pdf-renderer.ts b/artifacts/api-server/src/lib/pdf-renderer.ts index 3b8ff9c7..f07707db 100644 --- a/artifacts/api-server/src/lib/pdf-renderer.ts +++ b/artifacts/api-server/src/lib/pdf-renderer.ts @@ -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 = { system: { arabicRegular: "naskhRegular", @@ -110,7 +119,7 @@ const FAMILY_MAP: Record = { latinRegular: "latinRegular", latinBold: "latinBold", }, - Cairo: { + "DIN Next LT Arabic": { arabicRegular: "arabicSansRegular", arabicBold: "arabicSansBold", latinRegular: "latinRegular", @@ -122,13 +131,19 @@ const FAMILY_MAP: Record = { 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 = [ - "Cairo", + "DIN Next LT Arabic", "Tajawal", - "Noto Naskh Arabic", - "Amiri", + "Helvetica Neue LT Arabic", + "Majalla", ]; // Bytes are loaded lazily on first render and reused. diff --git a/artifacts/api-server/src/lib/sanitize.ts b/artifacts/api-server/src/lib/sanitize.ts index ddc859f5..6c640d54 100644 --- a/artifacts/api-server/src/lib/sanitize.ts +++ b/artifacts/api-server/src/lib/sanitize.ts @@ -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})*$`, ); diff --git a/artifacts/api-server/src/routes/executive-meetings.ts b/artifacts/api-server/src/routes/executive-meetings.ts index 99e5ffff..d82a6ac9 100644 --- a/artifacts/api-server/src/routes/executive-meetings.ts +++ b/artifacts/api-server/src/routes/executive-meetings.ts @@ -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; diff --git a/artifacts/api-server/tests/executive-meetings.test.mjs b/artifacts/api-server/tests/executive-meetings.test.mjs index b658c46d..9c1b7336 100644 --- a/artifacts/api-server/tests/executive-meetings.test.mjs +++ b/artifacts/api-server/tests/executive-meetings.test.mjs @@ -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"); diff --git a/artifacts/tx-os/index.html b/artifacts/tx-os/index.html index 419df421..070aed7b 100644 --- a/artifacts/tx-os/index.html +++ b/artifacts/tx-os/index.html @@ -5,9 +5,13 @@ Tx OS - - - +
diff --git a/artifacts/tx-os/src/index.css b/artifacts/tx-os/src/index.css index 5a6f6a16..557d4fc2 100644 --- a/artifacts/tx-os/src/index.css +++ b/artifacts/tx-os/src/index.css @@ -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; } diff --git a/artifacts/tx-os/src/pages/executive-meetings.tsx b/artifacts/tx-os/src/pages/executive-meetings.tsx index ecac993c..50cf5b6a 100644 --- a/artifacts/tx-os/src/pages/executive-meetings.tsx +++ b/artifacts/tx-os/src/pages/executive-meetings.tsx @@ -6382,12 +6382,25 @@ function FontSettingsSection({ + {/* + 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) => ( {f} diff --git a/attached_assets/image_1777664764914.png b/attached_assets/image_1777664764914.png new file mode 100644 index 00000000..a2d9ebf8 Binary files /dev/null and b/attached_assets/image_1777664764914.png differ diff --git a/attached_assets/image_1777664776320.png b/attached_assets/image_1777664776320.png new file mode 100644 index 00000000..a2d9ebf8 Binary files /dev/null and b/attached_assets/image_1777664776320.png differ diff --git a/attached_assets/image_1777664828219.png b/attached_assets/image_1777664828219.png new file mode 100644 index 00000000..a733954f Binary files /dev/null and b/attached_assets/image_1777664828219.png differ diff --git a/replit.md b/replit.md index 8fd92794..031d0205 100644 --- a/replit.md +++ b/replit.md @@ -43,7 +43,7 @@ The project is structured as a pnpm monorepo. - **Executive Meetings Module**: A comprehensive module with scheduling, CRUD operations for meetings, change requests, approvals, tasks, notifications, and an audit log. RBAC is enforced via five role sets (READ/MUTATE/APPROVE/REQUEST/ADMIN_AUDIT). All mutations are wrapped in database transactions to ensure data consistency and atomic audit logging. - **Optimistic Locking**: Implemented for Executive Meeting postponements to prevent concurrent updates from silently overwriting changes, using `expectedUpdatedAt` and returning a 409 conflict on mismatch. - **Upcoming Meeting Alert**: A global, draggable alert component appears when an Executive Meeting is within five minutes of starting, providing options to postpone, reschedule, or cancel the meeting. -- **Custom Editor Fonts**: The in-place rich-text cell editor (attendee names, meeting titles, etc.) ships a curated set of self-hosted Arabic + Latin font families (DIN Next LT Arabic, Tajawal, Helvetica Neue LT Arabic, Helvetica Neue, Majalla) declared in `artifacts/tx-os/src/custom-fonts.css` with `font-display: swap`. Files live under `artifacts/tx-os/public/fonts/`. +- **Custom Editor Fonts**: The in-place rich-text cell editor (attendee names, meeting titles, etc.) ships a curated set of self-hosted Arabic + Latin font families (DIN Next LT Arabic, Tajawal, Helvetica Neue LT Arabic, Helvetica Neue, Majalla) declared in `artifacts/tx-os/src/custom-fonts.css` with `font-display: swap`. Files live under `artifacts/tx-os/public/fonts/`. The site default body font is **DIN Next LT Arabic** (`--app-font-sans` in `index.css`), and Google Fonts is no longer fetched at page load. The Executive Meetings **Font Settings** page exposes the same five families plus `system` as the user/global picker; the values are kept in lockstep with the backend Zod allowlist (`FONT_FAMILIES` in `routes/executive-meetings.ts`), the rich-text sanitizer's font-family allowlist (`FONT_NAME_PART` in `lib/sanitize.ts`), and the PDF renderer's family map (`FAMILY_MAP` in `lib/pdf-renderer.ts`). When adding or removing a family, update all four locations together. - **Tab Quick-Add Attendees**: Pressing Tab inside an attendee name cell commits the current value and immediately opens a new pending attendee row right after, so users can keep typing names without using the mouse. Implemented via an `onTabNext` prop on `EditableCell` and a `chainStartAdd` prop on `AttendeeFlow` that bypasses the single-pending UI gate (the parent's state-level guard still prevents truly overlapping pendings). ### Feature Specifications