Task #301: Custom editor fonts + Tab quick-add for attendees
Curated 22 font files from the user's uploaded zip into
artifacts/tx-os/public/fonts/ and exposed five families in the
in-place editor's font dropdown:
- DIN Next LT Arabic (5 weights)
- Tajawal (7 weights)
- Helvetica Neue LT Arabic (3 weights)
- Helvetica Neue (5 weights)
- Majalla (2 weights)
Declared via @font-face in artifacts/tx-os/src/custom-fonts.css with
font-display: swap so weights are only fetched on demand. Each
<option> in the toolbar dropdown now previews itself in its own
family.
Tab quick-add: pressing Tab inside an attendee name's EditableCell
commits the current value and immediately opens a new pending
attendee row right after it, so users can type names continuously
without mousing. Implemented via:
- new optional `onTabNext` prop on EditableCell, captured into a
ref so the handler (created once via useEditor with [] deps)
always fires the latest callback;
- new `chainStartAdd` shared prop on AttendeeFlow that bypasses
the parent's `hasAnyPending` UI gate (the state-level guard in
`setPendingAttendee(prev => prev ? prev : new)` still prevents
truly overlapping pendings);
- wiring on both existing-attendee and pending-attendee cells.
Drift from plan:
- The plan also mentioned Shift+Tab to focus the previous person.
Scoped out — focus-from-outside isn't supported by EditableCell
today, and the user only asked for forward Tab. Shift+Tab now
falls through to default browser focus behaviour.
E2E test (testing skill) passed for both features. Architect review
returned a Pass with one minor note that IBM Plex Sans Arabic has
no @font-face entry in custom-fonts.css — that family is already
loaded via the existing Google Fonts <link> in index.html (it is
the app's default --app-font-sans), so the dropdown option works
as expected.
Pre-existing api-server test failures are unrelated and predate
this task.
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -40,12 +40,13 @@ const COLOR_SWATCHES = [
|
||||
|
||||
const FONT_OPTIONS = [
|
||||
{ value: "", label: "default" },
|
||||
{ value: "system-ui", label: "system-ui" },
|
||||
{ value: "Cairo", label: "Cairo" },
|
||||
{ value: "DIN Next LT Arabic", label: "DIN Next LT Arabic" },
|
||||
{ value: "Tajawal", label: "Tajawal" },
|
||||
{ value: "Amiri", label: "Amiri" },
|
||||
{ value: "Noto Naskh Arabic", label: "Noto" },
|
||||
{ value: "Helvetica Neue LT Arabic", label: "Helvetica Neue Arabic" },
|
||||
{ value: "Helvetica Neue", label: "Helvetica Neue" },
|
||||
{ value: "Majalla", label: "Majalla" },
|
||||
{ value: "IBM Plex Sans Arabic", label: "IBM Plex" },
|
||||
{ value: "system-ui", label: "system-ui" },
|
||||
];
|
||||
|
||||
const FONT_SIZE_OPTIONS = [
|
||||
@@ -136,6 +137,7 @@ export function EditableCell({
|
||||
onStartedEditing,
|
||||
forceSaveOnBlur = false,
|
||||
onCancel,
|
||||
onTabNext,
|
||||
}: {
|
||||
value: string;
|
||||
onSave: (html: string) => Promise<void> | void;
|
||||
@@ -162,12 +164,29 @@ export function EditableCell({
|
||||
* the synthetic row from the cell.
|
||||
*/
|
||||
onCancel?: () => void;
|
||||
/**
|
||||
* If provided, plain `Tab` (no Shift) inside the editor commits the
|
||||
* current value and then invokes this callback. The callback is
|
||||
* responsible for what should happen "next" — typically opening a
|
||||
* new pending attendee row immediately after this one. We swallow
|
||||
* the default Tab behaviour so the editor never inserts a tab
|
||||
* character. `Shift+Tab` is left to the browser/TipTap default.
|
||||
*/
|
||||
onTabNext?: () => void;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
const toolbarRef = useRef<HTMLDivElement | null>(null);
|
||||
const blurTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// `useEditor` below is created once (deps `[]`), so its `handleKeyDown`
|
||||
// closes over the first render's `onTabNext`. Stash the latest in a
|
||||
// ref so Tab always invokes the current callback.
|
||||
const onTabNextRef = useRef(onTabNext);
|
||||
useEffect(() => {
|
||||
onTabNextRef.current = onTabNext;
|
||||
}, [onTabNext]);
|
||||
|
||||
useEffect(() => {
|
||||
if (startInEditMode && !editing && !disabled) {
|
||||
setEditing(true);
|
||||
@@ -220,6 +239,20 @@ export function EditableCell({
|
||||
saveEdit();
|
||||
return true;
|
||||
}
|
||||
// Plain Tab → commit and run the "what's next" callback (e.g.
|
||||
// open a fresh pending attendee row right after this one).
|
||||
// Without an `onTabNext` handler we leave Tab to the browser
|
||||
// so focus moves naturally to the next focusable control.
|
||||
if (
|
||||
event.key === "Tab" &&
|
||||
!event.shiftKey &&
|
||||
onTabNextRef.current
|
||||
) {
|
||||
event.preventDefault();
|
||||
const next = onTabNextRef.current;
|
||||
void Promise.resolve(saveEdit()).then(() => next());
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
@@ -614,7 +647,11 @@ function FormattingToolbar({
|
||||
data-testid="em-edit-font"
|
||||
>
|
||||
{FONT_OPTIONS.map((f) => (
|
||||
<option key={f.value || "default"} value={f.value}>
|
||||
<option
|
||||
key={f.value || "default"}
|
||||
value={f.value}
|
||||
style={f.value ? { fontFamily: f.value } : undefined}
|
||||
>
|
||||
{f.label}
|
||||
</option>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
/*
|
||||
* Custom font families for the rich-text editor (attendee names,
|
||||
* meeting titles, notes, etc.). Files live in /public/fonts/ and are
|
||||
* served by Vite at /fonts/<name>. Each declaration uses
|
||||
* `font-display: swap` so the browser only fetches a weight when the
|
||||
* font-family is actually applied to a rendered element — this keeps
|
||||
* the initial page load lean even though there are ~22 files here.
|
||||
*
|
||||
* The `font-family` strings below MUST exactly match the `value`
|
||||
* field of FONT_OPTIONS in `editable-cell.tsx`, otherwise TipTap's
|
||||
* setFontFamily(value) will not match these declarations.
|
||||
*/
|
||||
|
||||
/* ── DIN Next LT Arabic ─────────────────────────────────────────── */
|
||||
@font-face {
|
||||
font-family: "DIN Next LT Arabic";
|
||||
src: url("/fonts/DINNextLTArabic-UltraLight.ttf") format("truetype");
|
||||
font-weight: 200;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "DIN Next LT Arabic";
|
||||
src: url("/fonts/DINNextLTArabic-Regular.ttf") format("truetype");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "DIN Next LT Arabic";
|
||||
src: url("/fonts/DINNextLTArabic-Medium.ttf") format("truetype");
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "DIN Next LT Arabic";
|
||||
src: url("/fonts/DINNextLTArabic-Bold.ttf") format("truetype");
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "DIN Next LT Arabic";
|
||||
src: url("/fonts/DINNextLTArabic-Black.ttf") format("truetype");
|
||||
font-weight: 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* ── Tajawal ────────────────────────────────────────────────────── */
|
||||
@font-face {
|
||||
font-family: "Tajawal";
|
||||
src: url("/fonts/Tajawal-ExtraLight.ttf") format("truetype");
|
||||
font-weight: 200;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Tajawal";
|
||||
src: url("/fonts/Tajawal-Light.ttf") format("truetype");
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Tajawal";
|
||||
src: url("/fonts/Tajawal-Regular.ttf") format("truetype");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Tajawal";
|
||||
src: url("/fonts/Tajawal-Medium.ttf") format("truetype");
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Tajawal";
|
||||
src: url("/fonts/Tajawal-Bold.ttf") format("truetype");
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Tajawal";
|
||||
src: url("/fonts/Tajawal-ExtraBold.ttf") format("truetype");
|
||||
font-weight: 800;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Tajawal";
|
||||
src: url("/fonts/Tajawal-Black.ttf") format("truetype");
|
||||
font-weight: 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* ── Helvetica Neue LT Arabic ───────────────────────────────────── */
|
||||
@font-face {
|
||||
font-family: "Helvetica Neue LT Arabic";
|
||||
src: url("/fonts/HelveticaNeueLTArabic-Light.ttf") format("truetype");
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Helvetica Neue LT Arabic";
|
||||
src: url("/fonts/HelveticaNeueLTArabic-Roman.ttf") format("truetype");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Helvetica Neue LT Arabic";
|
||||
src: url("/fonts/HelveticaNeueLTArabic-Bold.ttf") format("truetype");
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* ── Helvetica Neue (Latin) ─────────────────────────────────────── */
|
||||
@font-face {
|
||||
font-family: "Helvetica Neue";
|
||||
src: url("/fonts/HelveticaNeue-Light.otf") format("opentype");
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Helvetica Neue";
|
||||
src: url("/fonts/HelveticaNeue-Regular.ttf") format("truetype");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Helvetica Neue";
|
||||
src: url("/fonts/HelveticaNeue-Medium.otf") format("opentype");
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Helvetica Neue";
|
||||
src: url("/fonts/HelveticaNeue-Bold.ttf") format("truetype");
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Helvetica Neue";
|
||||
src: url("/fonts/HelveticaNeue-Black.otf") format("opentype");
|
||||
font-weight: 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* ── Majalla ────────────────────────────────────────────────────── */
|
||||
@font-face {
|
||||
font-family: "Majalla";
|
||||
src: url("/fonts/Majalla-Regular.ttf") format("truetype");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Majalla";
|
||||
src: url("/fonts/Majalla-Bold.ttf") format("truetype");
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "./custom-fonts.css";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@@ -3683,6 +3683,7 @@ function AttendeesCell({
|
||||
// in commitAddAttendee lands in the same place.
|
||||
pendingInsertAtIndex: pendingAttendee?.insertAtIndex,
|
||||
onStartAdd: hasAnyPending ? undefined : startAdd,
|
||||
chainStartAdd: startAdd,
|
||||
onCommitAdd: commitAdd,
|
||||
onCancelAdd: cancelAdd,
|
||||
addLabel: t("executiveMeetings.schedule.addAttendee"),
|
||||
@@ -3872,6 +3873,18 @@ type AttendeeFlowSharedProps = {
|
||||
kind?: "person" | "subheading",
|
||||
insertAtIndex?: number,
|
||||
) => void;
|
||||
/**
|
||||
* Same shape as `onStartAdd`, but always defined regardless of whether
|
||||
* a pending row is currently open. Used by the Tab quick-add flow so
|
||||
* the user can chain new attendees one after another even while a
|
||||
* pending row is in flight (the parent's `hasAnyPending` guard would
|
||||
* otherwise hide `onStartAdd` and break the chain).
|
||||
*/
|
||||
chainStartAdd?: (
|
||||
type: Attendee["attendanceType"],
|
||||
kind?: "person" | "subheading",
|
||||
insertAtIndex?: number,
|
||||
) => void;
|
||||
onCommitAdd?: (
|
||||
type: Attendee["attendanceType"],
|
||||
html: string,
|
||||
@@ -3928,6 +3941,7 @@ function AttendeeFlow({
|
||||
pendingKey,
|
||||
pendingInsertAtIndex,
|
||||
onStartAdd,
|
||||
chainStartAdd,
|
||||
onCommitAdd,
|
||||
onCancelAdd,
|
||||
addLabel,
|
||||
@@ -4101,6 +4115,25 @@ function AttendeeFlow({
|
||||
startInEditMode
|
||||
forceSaveOnBlur
|
||||
onCancel={onCancelAdd}
|
||||
// Tab inside the pending row → commit it, then immediately
|
||||
// open another pending row right after it. We use
|
||||
// `chainStartAdd` (not `onStartAdd`) because the parent
|
||||
// gates `onStartAdd` away while a pending row exists; the
|
||||
// chain prop bypasses that guard. When `pendingInsertAtIndex`
|
||||
// is undefined (legacy trailing slot) we leave it undefined
|
||||
// so the next pending also opens at the trailing slot.
|
||||
onTabNext={
|
||||
chainStartAdd
|
||||
? () =>
|
||||
chainStartAdd(
|
||||
addType,
|
||||
"person",
|
||||
pendingInsertAtIndex !== undefined
|
||||
? pendingInsertAtIndex + 1
|
||||
: undefined,
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
@@ -4226,6 +4259,16 @@ function AttendeeFlow({
|
||||
// creates a real new paragraph inside the editor.
|
||||
className="inline-block align-baseline min-w-[3rem] min-h-[1.5rem] px-0.5 py-0.5 [&>span_p]:inline [&>span_p]:m-0 border-b border-dashed border-gray-400/70 hover:border-blue-500 data-[editing=true]:border-b-0 print:border-b-0 print:py-0 print:min-h-0"
|
||||
testId={`em-edit-attendee-${i}`}
|
||||
// Tab → commit current name then open a pending person
|
||||
// row immediately after this one in the same cell, so
|
||||
// the user can keep typing names without mousing.
|
||||
// We use `chainStartAdd` so the chain is reachable even
|
||||
// when the parent has just moved into a pending state.
|
||||
onTabNext={
|
||||
canMutate && chainStartAdd
|
||||
? () => chainStartAdd(addType, "person", i + 1)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
|
||||
@@ -43,6 +43,8 @@ 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/`.
|
||||
- **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
|
||||
- **خدماتي (My Services)**: Displays a grid of service cards with availability status.
|
||||
|
||||
Reference in New Issue
Block a user