Browse Source

feat(fonts): align typography and @font-face system 1:1 with flutter and hosseinieh-app

master
mortezaei 2 weeks ago
parent
commit
4ce46b865d
  1. BIN
      public/assets/fonts/ArabicYekanXRegular.ttf
  2. BIN
      public/assets/fonts/Faminela.otf
  3. BIN
      public/assets/fonts/NotoNastaliqUrdu.ttf
  4. BIN
      public/assets/fonts/SegoeUIBold.ttf
  5. BIN
      public/assets/fonts/YekanXFaNum-R.ttf
  6. BIN
      public/assets/fonts/faminela.ttf
  7. BIN
      public/assets/fonts/segoeui.ttf
  8. 8
      src/app/[lang]/layout.tsx
  9. 148
      src/app/globals.css
  10. 57
      src/app/layout.tsx
  11. 53
      src/components/Componentes/information-sheet.test.tsx
  12. 178
      src/components/Componentes/information-sheet.tsx
  13. 10
      src/lib/view-paddings.ts

BIN
public/assets/fonts/ArabicYekanXRegular.ttf

BIN
public/assets/fonts/Faminela.otf

BIN
public/assets/fonts/NotoNastaliqUrdu.ttf

BIN
public/assets/fonts/SegoeUIBold.ttf

BIN
public/assets/fonts/YekanXFaNum-R.ttf

BIN
public/assets/fonts/faminela.ttf

BIN
public/assets/fonts/segoeui.ttf

8
src/app/[lang]/layout.tsx

@ -21,7 +21,13 @@ export default async function LocaleLayout({
return ( return (
<I18nProvider locale={lang}> <I18nProvider locale={lang}>
<div dir={localeDirections[lang]}>{children}</div>
<div
dir={localeDirections[lang]}
data-lang={lang}
className={`lang-${lang}`}
>
{children}
</div>
</I18nProvider> </I18nProvider>
); );
} }

148
src/app/globals.css

@ -1,5 +1,49 @@
@import "tailwindcss"; @import "tailwindcss";
/* ─── App fonts matching Flutter Najm & Hosseinieh ─── */
@font-face {
font-family: 'Faminela';
src: url('/assets/fonts/faminela.ttf') format('truetype');
font-weight: 400 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'YekanX';
src: url('/assets/fonts/YekanXFaNum-R.ttf') format('truetype');
font-weight: 400 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'YekanXArabic';
src: url('/assets/fonts/ArabicYekanXRegular.ttf') format('truetype');
font-weight: 400 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'NastaliqUrdu';
src: url('/assets/fonts/NotoNastaliqUrdu.ttf') format('truetype');
font-weight: 400 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Segoeui';
src: url('/assets/fonts/segoeui.ttf') format('truetype');
font-weight: 400 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Segoeui';
src: url('/assets/fonts/SegoeUIBold.ttf') format('truetype');
font-weight: 600 900;
font-style: normal;
font-display: swap;
}
:root { :root {
--background: #F5F5F5; --background: #F5F5F5;
--default-page-background-image: url("/assets/images/home-Checkups-List.svg"); --default-page-background-image: url("/assets/images/home-Checkups-List.svg");
@ -8,6 +52,10 @@
--safe-left: env(safe-area-inset-left, 0px); --safe-left: env(safe-area-inset-left, 0px);
--safe-right: env(safe-area-inset-right, 0px); --safe-right: env(safe-area-inset-right, 0px);
/* Default body font follows Flutter AppTheme: Segoeui for non-RTL app locales */
--marriage-font-sans: 'Segoeui', 'YekanXArabic', 'YekanX', system-ui, sans-serif;
--marriage-font-brand: 'Faminela', 'Segoeui', 'YekanXArabic', 'YekanX', system-ui, sans-serif;
/* ─── Semantic Tokens (Light) ─── */ /* ─── Semantic Tokens (Light) ─── */
--semantic-neutral-bg: #f1f5f9; --semantic-neutral-bg: #f1f5f9;
--semantic-neutral-text: #475569; --semantic-neutral-text: #475569;
@ -72,16 +120,44 @@
--semantic-ai-icon: #c4b5fd; --semantic-ai-icon: #c4b5fd;
} }
html[lang='fa'],
body[data-lang='fa'],
.lang-fa {
--marriage-font-sans: 'YekanX', 'Segoeui', 'YekanXArabic', system-ui, sans-serif;
--marriage-font-brand: 'Faminela', 'YekanX', 'Segoeui', 'YekanXArabic', system-ui, sans-serif;
line-height: 1.5;
}
html[lang='ar'],
body[data-lang='ar'],
.lang-ar {
--marriage-font-sans: 'YekanXArabic', 'Segoeui', 'YekanX', system-ui, sans-serif;
--marriage-font-brand: 'Faminela', 'YekanXArabic', 'Segoeui', 'YekanX', system-ui, sans-serif;
line-height: 1.5;
}
html[lang='ur'],
body[data-lang='ur'],
.lang-ur,
html[lang='ks'],
body[data-lang='ks'],
.lang-ks {
--marriage-font-sans: 'NastaliqUrdu', 'Segoeui', 'YekanXArabic', 'YekanX', system-ui, sans-serif;
--marriage-font-brand: 'Faminela', 'NastaliqUrdu', 'Segoeui', 'YekanXArabic', 'YekanX', system-ui, sans-serif;
line-height: 2;
}
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--font-sans: var(--font-segoeui), "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
--font-sans: var(--marriage-font-sans);
--font-brand: var(--marriage-font-brand);
--font-faminela: var(--marriage-font-brand);
--font-mono: var(--font-geist-mono); --font-mono: var(--font-geist-mono);
--font-arabic: var(--font-yekanx-arabic), var(--font-segoeui), "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
--font-persian: var(--font-yekanx), var(--font-yekanx-arabic), var(--font-segoeui), "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
--font-urdu: var(--font-nastaliq-urdu), var(--font-yekanx-arabic), var(--font-segoeui), "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
--font-ryling: var(--font-segoeui), "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
--font-faminela: var(--font-faminela-local), "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
--font-arabic: 'YekanXArabic', 'Segoeui', 'YekanX', system-ui, sans-serif;
--font-persian: 'YekanX', 'Segoeui', 'YekanXArabic', system-ui, sans-serif;
--font-urdu: 'NastaliqUrdu', 'Segoeui', 'YekanXArabic', 'YekanX', system-ui, sans-serif;
--font-ryling: var(--marriage-font-sans);
--color-semantic-neutral-bg: var(--semantic-neutral-bg); --color-semantic-neutral-bg: var(--semantic-neutral-bg);
--color-semantic-neutral-text: var(--semantic-neutral-text); --color-semantic-neutral-text: var(--semantic-neutral-text);
@ -190,6 +266,27 @@ textarea,
user-select: text !important; user-select: text !important;
} }
html,
body,
button,
input,
optgroup,
select,
textarea,
h1,
h2,
h3,
h4,
h5,
h6,
p,
span,
a,
label,
.app-shell {
font-family: var(--marriage-font-sans);
}
body { body {
width: 100%; width: 100%;
height: 100%; height: 100%;
@ -198,49 +295,26 @@ body {
justify-content: center; justify-content: center;
overflow: hidden; overflow: hidden;
color: var(--foreground); color: var(--foreground);
font-family: var(--font-segoeui), "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
overscroll-behavior: none; overscroll-behavior: none;
font-size: 14px;
word-spacing: 0.25px;
} }
/* ─── Multi-Language Typography matching Flutter Najm AppTheme ─── */
/* 1. Arabic (ar) -> Flutter yekanxArabic (ArabicYekanXRegular.ttf) */
html:lang(ar) body,
html[lang="ar"] body,
body[data-lang="ar"],
:lang(ar),
/* Specific class helpers */
.font-arabic { .font-arabic {
font-family: var(--font-yekanx-arabic), var(--font-segoeui), "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
font-family: 'YekanXArabic', 'Segoeui', 'YekanX', system-ui, sans-serif;
} }
/* 2. Persian (fa) -> Flutter yekanx (YekanXFaNum-R.ttf) */
html:lang(fa) body,
html[lang="fa"] body,
body[data-lang="fa"],
:lang(fa),
.font-persian { .font-persian {
font-family: var(--font-yekanx), var(--font-yekanx-arabic), var(--font-segoeui), "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
}
/* 3. Urdu (ur) & Kashmiri (ks) -> Flutter NastaliqUrdu (NotoNastaliqUrdu.ttf) */
html:lang(ur) body,
html[lang="ur"] body,
body[data-lang="ur"],
:lang(ur),
html:lang(ks) body,
html[lang="ks"] body,
body[data-lang="ks"],
:lang(ks),
.font-urdu {
font-family: var(--font-nastaliq-urdu), var(--font-yekanx-arabic), var(--font-segoeui), "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
font-family: 'YekanX', 'Segoeui', 'YekanXArabic', system-ui, sans-serif;
} }
/* 4. Global / Latin / Cyrillic / Asian languages -> Flutter Segoeui */
html:not([lang="ar"]):not([lang="fa"]):not([lang="ur"]):not([lang="ks"]) body {
font-family: var(--font-segoeui), "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
.font-urdu {
font-family: 'NastaliqUrdu', 'Segoeui', 'YekanXArabic', 'YekanX', system-ui, sans-serif;
} }
.font-faminela { .font-faminela {
font-family: var(--font-faminela-local), var(--font-segoeui), "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
font-family: var(--marriage-font-brand);
} }
.app-shell { .app-shell {

57
src/app/layout.tsx

@ -1,5 +1,4 @@
import type { Metadata, Viewport } from "next"; import type { Metadata, Viewport } from "next";
import localFont from "next/font/local";
import { cookies, headers } from "next/headers"; import { cookies, headers } from "next/headers";
import Providers from "./providers"; import Providers from "./providers";
import "./globals.css"; import "./globals.css";
@ -13,57 +12,6 @@ import {
import { getInitialMarriageProfile } from "@/hooks/marriage/use-profile-main"; import { getInitialMarriageProfile } from "@/hooks/marriage/use-profile-main";
import type { MarriageProfileResponse } from "@/hooks/marriage/types"; import type { MarriageProfileResponse } from "@/hooks/marriage/types";
const yekanxArabic = localFont({
src: "../../public/fonts/yekanx-arabic/ArabicYekanXRegular.ttf",
variable: "--font-yekanx-arabic",
display: "swap",
preload: true,
fallback: ["Segoe UI", "Tahoma", "Geneva", "Verdana", "sans-serif"],
});
const yekanx = localFont({
src: "../../public/fonts/yekanx/YekanXFaNum-R.ttf",
variable: "--font-yekanx",
display: "swap",
preload: true,
fallback: ["Segoe UI", "Tahoma", "Geneva", "Verdana", "sans-serif"],
});
const notoNastaliqUrdu = localFont({
src: "../../public/fonts/urdu/NotoNastaliqUrdu.ttf",
variable: "--font-nastaliq-urdu",
display: "swap",
preload: true,
fallback: ["Segoe UI", "Tahoma", "Geneva", "Verdana", "sans-serif"],
});
const segoeui = localFont({
src: [
{
path: "../../public/fonts/segoeui/segoeui.ttf",
weight: "400",
style: "normal",
},
{
path: "../../public/fonts/segoeui/SegoeUIBold.ttf",
weight: "700",
style: "normal",
},
],
variable: "--font-segoeui",
display: "swap",
preload: true,
fallback: ["Segoe UI", "Tahoma", "Geneva", "Verdana", "sans-serif"],
});
const faminela = localFont({
src: "../../public/fonts/Faminela/Faminela.otf",
variable: "--font-faminela-local",
display: "swap",
preload: false,
fallback: ["Arial", "sans-serif"],
});
const isDevelopment = process.env.NODE_ENV !== "production"; const isDevelopment = process.env.NODE_ENV !== "production";
export const metadata: Metadata = { export const metadata: Metadata = {
@ -420,10 +368,7 @@ export default async function RootLayout({
}} }}
/> />
</head> </head>
<body
className={`${segoeui.variable} ${yekanxArabic.variable} ${yekanx.variable} ${notoNastaliqUrdu.variable} ${faminela.variable}`}
suppressHydrationWarning
>
<body suppressHydrationWarning>
<Providers initialProfile={initialProfile}> <Providers initialProfile={initialProfile}>
{isDevelopment ? <TokenSwitcher /> : null} {isDevelopment ? <TokenSwitcher /> : null}
<div className="app-shell">{children}</div> <div className="app-shell">{children}</div>

53
src/components/Componentes/information-sheet.test.tsx

@ -1,8 +1,7 @@
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
import { describe, expect, it, vi, afterEach, beforeEach } from "vitest";
import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react";
import { describe, expect, it, vi, afterEach } from "vitest";
import React from "react"; import React from "react";
import { InformationSheet } from "./information-sheet"; import { InformationSheet } from "./information-sheet";
import { viewPaddingsBridge } from "@/lib/view-paddings";
describe("InformationSheet", () => { describe("InformationSheet", () => {
afterEach(() => { afterEach(() => {
@ -22,7 +21,7 @@ describe("InformationSheet", () => {
expect(screen.getByText("Test Sheet Description")).toBeDefined(); expect(screen.getByText("Test Sheet Description")).toBeDefined();
}); });
it("should apply smooth keyboard lift when an input inside the sheet receives focus and keyboard height is set", () => {
it("should apply smooth keyboard lift when an input inside the sheet receives focus and keyboard height is set", async () => {
render( render(
<InformationSheet <InformationSheet
isOpen={true} isOpen={true}
@ -39,11 +38,22 @@ describe("InformationSheet", () => {
const section = document.querySelector("section"); const section = document.querySelector("section");
expect(section).toBeDefined(); expect(section).toBeDefined();
// Focus input and simulate Flutter bridge keyboard event
act(() => {
if (section) {
vi.spyOn(section, "getBoundingClientRect").mockReturnValue({
top: 300,
bottom: 800,
left: 0,
right: 400,
width: 400,
height: 500,
x: 0,
y: 300,
toJSON: () => {},
});
}
input.focus(); input.focus();
fireEvent.focusIn(input); fireEvent.focusIn(input);
// Simulate flutter keyboard bridge event
window.dispatchEvent( window.dispatchEvent(
new MessageEvent("message", { new MessageEvent("message", {
data: JSON.stringify({ data: JSON.stringify({
@ -52,12 +62,13 @@ describe("InformationSheet", () => {
}), }),
}), }),
); );
});
expect(section?.style.marginBottom).toBe("280px");
await waitFor(() => {
expect(section?.style.transform).toContain("translate3d");
});
}); });
it("should reset keyboard lift when input loses focus", async () => {
it("should reset keyboard lift when keyboard closes or input loses focus", async () => {
render( render(
<InformationSheet <InformationSheet
isOpen={true} isOpen={true}
@ -73,19 +84,21 @@ describe("InformationSheet", () => {
const input = screen.getByTestId("test-input"); const input = screen.getByTestId("test-input");
const section = document.querySelector("section"); const section = document.querySelector("section");
act(() => {
input.focus(); input.focus();
fireEvent.focusIn(input); fireEvent.focusIn(input);
});
act(() => {
input.blur();
fireEvent.focusOut(input);
});
// Wait for focusout settlement
await new Promise((r) => setTimeout(r, 60));
window.dispatchEvent(
new MessageEvent("message", {
data: JSON.stringify({
action: "keyboard_changed",
height: 0,
}),
}),
);
expect(section?.style.marginBottom).toBe("");
await waitFor(() => {
expect(section?.style.transform).toBe("");
}); });
}); });
});

178
src/components/Componentes/information-sheet.tsx

@ -328,35 +328,70 @@ export function InformationSheet({
return; return;
} }
let bridgeKbHeight = viewPaddingsBridge.getConfig().keyboardHeight || 0;
let hasFocusedInput = false;
const updateSheetLift = () => {
if (!sheetRef.current) return;
const visualViewport = window.visualViewport;
const vpReduction = visualViewport
? Math.max(0, window.innerHeight - visualViewport.height)
: 0;
const detectedHeight = Math.max(bridgeKbHeight, vpReduction);
const defaultHeight =
typeof window !== "undefined" && window.innerHeight > 0
? Math.round(window.innerHeight * 0.375)
: 285;
const effectiveHeight =
detectedHeight > 20
? detectedHeight
: hasFocusedInput
? defaultHeight
: 0;
if (effectiveHeight > 20 && hasFocusedInput) {
setKeyboardLift(Math.round(effectiveHeight));
} else if (!hasFocusedInput) {
setKeyboardLift(0);
const KEYBOARD_THRESHOLD = 20;
let keyboardVisible = false;
let keyboardHeight = 0;
let activeInput: HTMLElement | null = null;
let currentLift = 0;
let animFrame: number | null = null;
let closedVpHeight = window.visualViewport?.height ?? window.innerHeight;
const setLift = (nextLift: number) => {
currentLift = Math.max(0, Math.round(nextLift));
setKeyboardLift(currentLift);
};
const updateLift = () => {
const sheet = sheetRef.current;
if (!sheet || !keyboardVisible || !activeInput) {
setLift(0);
return;
}
// 1. Calculate keyboard visible bottom limit
const flutterTop =
keyboardHeight > 0 ? window.innerHeight - keyboardHeight : null;
const viewport = window.visualViewport;
const viewportBottom = viewport
? viewport.offsetTop + viewport.height
: window.innerHeight;
const visibleBottom =
flutterTop !== null
? Math.min(flutterTop, viewportBottom)
: viewportBottom;
// 2. Calculate sheet base geometry (without current lift)
const sheetRect = sheet.getBoundingClientRect();
const baseBottom = sheetRect.bottom + currentLift;
const baseTop = sheetRect.top + currentLift;
const safeTop =
parseFloat(
getComputedStyle(document.documentElement).getPropertyValue(
"--safe-top",
) || "0",
) || 16;
const visibleTop = safeTop + 8;
// 3. Compute required lift to ensure sheet bottom clears keyboard
const overlap = baseBottom - visibleBottom;
if (overlap <= 0) {
setLift(0);
return;
} }
const maxShift = Math.max(0, baseTop - visibleTop);
const computedLift = Math.min(overlap, maxShift);
setLift(computedLift);
};
const scheduleUpdate = () => {
if (animFrame !== null) cancelAnimationFrame(animFrame);
animFrame = requestAnimationFrame(() => {
animFrame = null;
updateLift();
});
updateLift();
}; };
const handleFocusIn = (e: FocusEvent) => { const handleFocusIn = (e: FocusEvent) => {
@ -364,45 +399,89 @@ export function InformationSheet({
sheetRef.current?.contains(e.target as Node) && sheetRef.current?.contains(e.target as Node) &&
isKeyboardInputTarget(e.target) isKeyboardInputTarget(e.target)
) { ) {
hasFocusedInput = true;
updateSheetLift();
setTimeout(() => {
if (e.target instanceof HTMLElement) {
e.target.scrollIntoView({ behavior: "smooth", block: "center" });
activeInput = e.target;
const currentVpHeight =
window.visualViewport?.height ?? window.innerHeight;
if (
keyboardHeight > 0 ||
closedVpHeight - currentVpHeight > KEYBOARD_THRESHOLD
) {
keyboardVisible = true;
} }
}, 150);
scheduleUpdate();
} }
}; };
const handleFocusOut = () => { const handleFocusOut = () => {
window.setTimeout(() => { window.setTimeout(() => {
const active = document.activeElement; const active = document.activeElement;
hasFocusedInput = Boolean(
sheetRef.current?.contains(active) && isKeyboardInputTarget(active),
);
updateSheetLift();
activeInput =
sheetRef.current?.contains(active) && isKeyboardInputTarget(active)
? (active as HTMLElement)
: null;
if (!activeInput) {
keyboardVisible = false;
setLift(0);
} else {
scheduleUpdate();
}
}, 50); }, 50);
}; };
const unsubscribeConfig = viewPaddingsBridge.subscribeConfig((config) => { const unsubscribeConfig = viewPaddingsBridge.subscribeConfig((config) => {
bridgeKbHeight = config.keyboardHeight;
updateSheetLift();
const nextHeight = Math.max(0, config.keyboardHeight);
if (nextHeight > 0) {
keyboardHeight = nextHeight;
const active = document.activeElement as HTMLElement | null;
if (
!activeInput &&
active &&
sheetRef.current?.contains(active) &&
isKeyboardInputTarget(active)
) {
activeInput = active;
}
if (activeInput) {
keyboardVisible = true;
}
} else {
keyboardHeight = 0;
keyboardVisible = false;
}
scheduleUpdate();
}); });
const handleVpResize = () => {
updateSheetLift();
const handleViewportResize = () => {
const vpHeight = window.visualViewport?.height ?? window.innerHeight;
const reduction = closedVpHeight - vpHeight;
if (reduction > KEYBOARD_THRESHOLD) {
if (activeInput) {
keyboardVisible = true;
keyboardHeight = reduction;
}
} else if (reduction <= KEYBOARD_THRESHOLD) {
keyboardVisible = false;
keyboardHeight = 0;
if (!activeInput) {
closedVpHeight = vpHeight;
}
}
scheduleUpdate();
}; };
document.addEventListener("focusin", handleFocusIn); document.addEventListener("focusin", handleFocusIn);
document.addEventListener("focusout", handleFocusOut); document.addEventListener("focusout", handleFocusOut);
window.visualViewport?.addEventListener("resize", handleVpResize);
window.visualViewport?.addEventListener("scroll", handleVpResize);
window.visualViewport?.addEventListener("resize", handleViewportResize);
return () => { return () => {
if (animFrame !== null) cancelAnimationFrame(animFrame);
document.removeEventListener("focusin", handleFocusIn); document.removeEventListener("focusin", handleFocusIn);
document.removeEventListener("focusout", handleFocusOut); document.removeEventListener("focusout", handleFocusOut);
window.visualViewport?.removeEventListener("resize", handleVpResize);
window.visualViewport?.removeEventListener("scroll", handleVpResize);
window.visualViewport?.removeEventListener(
"resize",
handleViewportResize,
);
unsubscribeConfig(); unsubscribeConfig();
}; };
}, [isRendered]); }, [isRendered]);
@ -444,12 +523,11 @@ export function InformationSheet({
ref={sheetRef} ref={sheetRef}
{...props} {...props}
style={{ style={{
marginBottom: keyboardLift > 0 ? `${keyboardLift}px` : undefined,
transition: "margin-bottom 200ms cubic-bezier(0.16, 1, 0.3, 1)",
maxHeight:
transform:
keyboardLift > 0 keyboardLift > 0
? `calc(100vh - ${keyboardLift + 20}px)`
? `translate3d(0, -${keyboardLift}px, 0)`
: undefined, : undefined,
transition: "transform 200ms cubic-bezier(0.16, 1, 0.3, 1)",
...props.style, ...props.style,
}} }}
className={[ className={[

10
src/lib/view-paddings.ts

@ -136,6 +136,16 @@ class ViewPaddingsBridge {
string, string,
any any
>; >;
if (
envelope.action === "keyboard_changed" ||
data.action === "keyboard_changed"
) {
const height = num(data.height ?? envelope.height);
this.config = { ...this.config, keyboardHeight: height };
this.applyKeyboard(height);
this.notifyConfigListeners();
return;
}
if (this.hasInitialConfig) { if (this.hasInitialConfig) {
if (data.locale) this.applyLocale(data.locale); if (data.locale) this.applyLocale(data.locale);
return; return;

Loading…
Cancel
Save