Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 33 additions & 10 deletions apps/mobile/src/components/home/section-header.mounted.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,6 @@ describe('SectionHeader mounted layout', () => {
'max-w-full',
'font-mono-medium',
'text-[10px]',
'tracking-[1.5px]',
'uppercase',
'text-muted-foreground',
])
);
Expand All @@ -81,21 +79,46 @@ describe('SectionHeader mounted layout', () => {
expect.arrayContaining(['grow', 'max-w-full'])
);
expect((text.props.className as string).split(' ')).toEqual(
expect.arrayContaining([
alignment,
'font-mono-medium',
'text-[11px]',
'tracking-[1.5px]',
'uppercase',
'text-primary',
])
expect.arrayContaining([alignment, 'font-mono-medium', 'text-[11px]', 'text-primary'])
);
expect(text.props.numberOfLines).toBeUndefined();
expect(text.props.allowFontScaling).not.toBe(false);
expect(text.props.maxFontSizeMultiplier).toBeUndefined();
expect(text.children).toEqual(['See all']);
});

// Finding home-ar-loading: the Arabic section labels carried the Latin
// uppercase letter-spacing, whose glyph gaps break a cursive script's joins
// ('ال جلسا ت'). The display treatment is LTR-only.
it.each([
{ isRTL: false, tracked: true },
{ isRTL: true, tracked: false },
])('letterspaces the section labels only outside RTL (RTL=$isRTL)', ({ isRTL, tracked }) => {
i18nManager.isRTL = isRTL;
const root = mount(
createElement(SectionHeader, {
label: 'الجلسات الجارية الآن',
actionLabel: 'عرض الكل',
onActionPress: () => undefined,
})
);
const action = root.findByProps({ accessibilityRole: 'button' });
const text = action.find(node => Object.is(node.type, 'Text'));
const label = root.find(
node => Object.is(node.type, 'Text') && node.children.includes('الجلسات الجارية الآن')
);

for (const node of [label, text]) {
const classes = (node.props.className as string).split(' ');
if (tracked) {
expect(classes).toEqual(expect.arrayContaining(['uppercase', 'tracking-[1.5px]']));
} else {
expect(classes).not.toContain('uppercase');
expect(classes.some(name => name.startsWith('tracking'))).toBe(false);
}
}
});

it('keeps the complete accessible action name and activates the supplied destination', () => {
function Destination() {
const [showAll, setShowAll] = useState(false);
Expand Down
9 changes: 7 additions & 2 deletions apps/mobile/src/components/home/section-header.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { I18nManager, Pressable, View } from 'react-native';

import { Text } from '@/components/ui/text';
import { EYEBROW_LATIN_DISPLAY, Text } from '@/components/ui/text';
import { cn } from '@/lib/utils';

type SectionHeaderProps = {
Expand All @@ -26,7 +26,12 @@ export function SectionHeader({ label, actionLabel, onActionPress }: Readonly<Se
>
<Text
className={cn(
'shrink font-mono-medium text-[11px] uppercase tracking-[1.5px] text-primary',
'shrink font-mono-medium text-[11px] text-primary',
// LTR-only: the letterspaced capitals break a cursive script's
// joins, so an RTL action label drops them (home-ar-loading).
// The class string is the eyebrow variant's, so the two labels
// cannot drift apart.
!I18nManager.isRTL && EYEBROW_LATIN_DISPLAY,
I18nManager.isRTL ? 'text-left' : 'text-right'
)}
>
Expand Down
9 changes: 6 additions & 3 deletions apps/mobile/src/components/ui/eyebrow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,12 @@ import { cn } from '@/lib/utils';
type EyebrowProps = Omit<ComponentProps<typeof Text>, 'variant'>;

/**
* Eyebrow label: mono, uppercase, 10px, letter-spaced.
* Defaults to muted color; pass a `className` with a `text-*` token to
* override (e.g. agent hue).
* Eyebrow label: mono, 10px. Defaults to muted color; pass a `className` with
* a `text-*` token to override (e.g. agent hue).
*
* The uppercase, letterspaced display treatment is LTR-only: `letter-spacing`
* breaks a cursive script's joins, so an RTL eyebrow renders the same copy
* without it (see `Text`'s eyebrow variant).
*/
export function Eyebrow({ className, ...props }: EyebrowProps) {
return <Text variant="eyebrow" className={cn(className)} {...props} />;
Expand Down
80 changes: 80 additions & 0 deletions apps/mobile/src/components/ui/text.mounted.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { createElement, type ReactElement } from 'react';
import { act, TestRenderer } from '@/test/renderer';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { Eyebrow } from '@/components/ui/eyebrow';
import { Text } from '@/components/ui/text';

const i18nManager = vi.hoisted(() => ({ isRTL: false }));
// `Text` reads the native direction at render time, so the mutable flag drives
// each render; the host text element is the assertion target.
vi.mock('react-native', () => ({
I18nManager: i18nManager,
Text: 'Text',
}));
// `@rn-primitives/slot` ships untranspiled JSX and is only reached by `asChild`.
vi.mock('@rn-primitives/slot', () => ({ Text: 'Slot.Text' }));

let renderer: TestRenderer.ReactTestRenderer | undefined = undefined;
function mount(element: ReactElement) {
act(() => {
renderer = TestRenderer.create(element);
});
if (!renderer) {
throw new Error('Missing Text renderer');
}
return renderer.root;
}

function hostClasses(root: TestRenderer.ReactTestInstance): string[] {
const node = root.find(candidate => Object.is(candidate.type, 'Text'));
return String(node.props.className).split(' ');
}

beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
i18nManager.isRTL = false;
});
afterEach(() => {
act(() => renderer?.unmount());
renderer = undefined;
});

describe('Text eyebrow letterspacing', () => {
// Finding home-ar-loading: an Arabic section label carried the Latin
// uppercase letter-spacing and broke apart mid-word ('ال جلسا ت').
it.each([false, true])('keeps the eyebrow display treatment in LTR only (RTL=%s)', isRTL => {
i18nManager.isRTL = isRTL;
const classes = hostClasses(mount(createElement(Text, { variant: 'eyebrow' }, 'Live now')));
expect(classes).toEqual(
expect.arrayContaining(['font-mono-medium', 'text-[10px]', 'text-muted-foreground'])
);
if (isRTL) {
expect(classes).not.toContain('uppercase');
expect(classes.some(name => name.startsWith('tracking'))).toBe(false);
} else {
expect(classes).toEqual(expect.arrayContaining(['uppercase', 'tracking-[1.5px]']));
}
});

it('leaves a non-eyebrow variant untouched in either direction', () => {
i18nManager.isRTL = true;
const classes = hostClasses(mount(createElement(Text, null, 'Live now')));
expect(classes).not.toContain('uppercase');
expect(classes.some(name => name.startsWith('tracking'))).toBe(false);
});

it.each([false, true])('applies the same rule to the Eyebrow component (RTL=%s)', isRTL => {
i18nManager.isRTL = isRTL;
const classes = hostClasses(
mount(createElement(Eyebrow, null, isRTL ? 'الجلسات الجارية الآن' : 'LIVE NOW'))
);
expect(classes).toEqual(expect.arrayContaining(['font-mono-medium', 'text-[10px]']));
if (isRTL) {
expect(classes).not.toContain('uppercase');
expect(classes.some(name => name.startsWith('tracking'))).toBe(false);
} else {
expect(classes).toEqual(expect.arrayContaining(['uppercase', 'tracking-[1.5px]']));
}
});
});
21 changes: 19 additions & 2 deletions apps/mobile/src/components/ui/text.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const textVariants = cva('text-foreground text-base font-medium', {
small: 'text-sm font-medium leading-none',
muted: 'text-muted-foreground text-sm',
mono: 'font-mono-medium text-sm',
eyebrow: 'font-mono-medium text-[10px] uppercase tracking-[1.5px] text-muted-foreground',
eyebrow: 'font-mono-medium text-[10px] text-muted-foreground',
},
},
defaultVariants: {
Expand All @@ -48,6 +48,18 @@ const ARIA_LEVEL = {
h4: '4',
} satisfies Partial<Record<TextVariant, string>>;

/**
* The eyebrow's Latin display treatment: full capitals, letterspaced. It is an
* LTR-only addition to the variant because `letter-spacing` pulls a cursive
* script apart — an Arabic eyebrow renders 'الجلسات' as 'ال جلسا ت'. An RTL
* interface keeps the mono family, size and color and drops both classes.
*
* Exported so the eyebrow-scale labels rendered outside the variant — the
* `SectionHeader` action link — carry the identical treatment instead of a
* second copy of the class string that can drift.
*/
export const EYEBROW_LATIN_DISPLAY = 'uppercase tracking-[1.5px]';

const TextClassContext = React.createContext<string | undefined>(undefined);

function Text({
Expand All @@ -64,7 +76,12 @@ function Text({
const Component = asChild ? Slot.Text : RNText;
return (
<Component
className={cn(textVariants({ variant }), textClass, className)}
className={cn(
textVariants({ variant }),
variant === 'eyebrow' && !I18nManager.isRTL && EYEBROW_LATIN_DISPLAY,
textClass,
className
)}
role={variant ? ROLE[variant as keyof typeof ROLE] : undefined}
aria-level={variant ? ARIA_LEVEL[variant as keyof typeof ARIA_LEVEL] : undefined}
{...props}
Expand Down
77 changes: 75 additions & 2 deletions apps/web/playwright.setup-smoke.config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,80 @@
import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { defineConfig, devices } from '@playwright/test';
import { parse } from 'dotenv';

const port = process.env.PORT ? Number(process.env.PORT) : 3000;
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? `http://localhost:${port}`;
/** The files `scripts/dev.sh` reads the app origin from, in its order. */
const APP_URL_OVERRIDE_FILES = [
'.env.development.local',
'.env.local',
'../../.env.development.local',
'../../.env.local',
];

/** The origin `APP_URL_OVERRIDE` names, normalised the way `resolveAppUrl` does. */
function appUrlOrigin(raw: string | undefined): string | undefined {
if (!raw) return undefined;
try {
return new URL(raw).origin;
} catch {
return undefined;
}
}

/**
* The `APP_URL_OVERRIDE` the dev stack for this worktree runs with.
*
* `scripts/dev.sh` exports its copy into the `next dev` process only, and
* Playwright loads no dotenv file, so a value that lives in `.env.local` never
* reaches this process on its own. Read the same files in the same order, so a
* worktree that points the override at a LAN IP or 127.0.0.1 still matches the
* origin the server canonicalises to.
*/
function appUrlOverrideFor(
dir: string,
env: Record<string, string | undefined>
): string | undefined {
if (env.APP_URL_OVERRIDE) return env.APP_URL_OVERRIDE;
for (const file of APP_URL_OVERRIDE_FILES) {
const path = resolve(dir, file);
if (!existsSync(path)) continue;
let value: string | undefined;
try {
value = parse(readFileSync(path, 'utf-8')).APP_URL_OVERRIDE;
} catch {
continue;
}
if (value) return value;
}
return undefined;
}

/**
* The base URL that drives the dev stack the smoke test runs against.
*
* The dev stack serves the origin its own env names: `resolveAppUrl` builds
* every absolute auth redirect from APP_URL_OVERRIDE (`src/lib/constants`),
* and a worktree's .env.local points that at a LAN IP for phone testing or at
* 127.0.0.1 for the device harness. A NextAuth session cookie is host-scoped,
* so browsing localhost while the server redirects to that other origin drops
* the session mid-sign-in and profile.spec.ts spends its whole timeout in
* page.waitForURL — the same localhost/127.0.0.1 trap playwright.config.ts
* documents. Follow the origin the server canonicalises to; an explicit
* PLAYWRIGHT_BASE_URL still wins.
*/
export function resolveBaseUrl(
dir: string,
env: Record<string, string | undefined> = process.env
): string {
const port = env.PORT ? Number(env.PORT) : 3000;
return (
env.PLAYWRIGHT_BASE_URL ??
appUrlOrigin(appUrlOverrideFor(dir, env)) ??
`http://localhost:${port}`
);
}

const baseURL = resolveBaseUrl(__dirname);

export default defineConfig({
testDir: './tests/setup-smoke',
Expand Down
Loading
Loading