From adb2474c2baf94a2f95f509d6173a60e3e982938 Mon Sep 17 00:00:00 2001 From: william garrity Date: Tue, 21 Jul 2026 20:48:03 -0400 Subject: [PATCH 01/12] feat: add RTL direction infrastructure and migrate primitives to logical properties - Storybook Direction toolbar (auto/ltr/rtl) wired into applyGlobalTheme; Arabic RTL sample locale - useDirection() hook + isRtlLocale() for JS-level direction logic - cn(): physical/logical utilities merge as conflicts so consumer overrides (ml-2) still replace internal logical classes (ms-1); config exported for consumers - scripts/rtl-scan.mjs ratchet guard (baseline: 540 matches/120 files) wired into CI - Badge, Input, Label, Text, Switch migrated to logical properties / rtl: variants - Tailwind safelists updated for new class strings --- .github/workflows/ci.yml | 3 + .gitignore | 1 + .storybook/preview.tsx | 47 ++++++++++- package.json | 2 + scripts/rtl-baseline.json | 122 +++++++++++++++++++++++++++ scripts/rtl-scan.mjs | 140 +++++++++++++++++++++++++++++++ src/components/Badge/Badge.tsx | 2 +- src/components/Input/Input.tsx | 2 +- src/components/Label/Label.tsx | 2 +- src/components/Switch/Switch.tsx | 6 +- src/components/Text/Text.tsx | 4 +- src/hooks/index.ts | 6 ++ src/hooks/useDirection.ts | 63 ++++++++++++++ src/tailwind-preset.cjs | 13 ++- src/tailwind-preset.ts | 10 +++ src/utils/cn.test.ts | 44 ++++++++++ src/utils/cn.ts | 53 +++++++++++- src/utils/index.ts | 2 +- 18 files changed, 508 insertions(+), 14 deletions(-) create mode 100644 scripts/rtl-baseline.json create mode 100644 scripts/rtl-scan.mjs create mode 100644 src/hooks/useDirection.ts create mode 100644 src/utils/cn.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c36ae72f..dc88cc5d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,9 @@ jobs: - name: Lint run: pnpm lint + - name: RTL guard (no new physical-direction classes) + run: pnpm rtl:scan + - name: Type check run: pnpm typecheck diff --git a/.gitignore b/.gitignore index 3a7c23ae..428082ed 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ playwright-results/ test-results/*.swp TODO.local.md RESTORE-OZWELL.local.md +RTL-PLAN.local.md # MedicalCodify shards (.storybook/public/codify) are committed via git-lfs; # the extraction TSV + sqlite db live (gitignored) in packages/codify/data/ # The codify build pipeline lives in a separate (not-yet-published) repo and is diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 4003b8ad..ff98123d 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -16,6 +16,7 @@ import { webchartBrand } from '../src/brands/webchart'; import type { BrandConfig } from '../src/brands/types'; import { CodeLookup } from '../src/components/CodeLookup'; import { CodeLookupProvider } from '../src/components/CodeLookup/context'; +import { isRtlLocale } from '../src/hooks/useDirection'; // Map of available brands const brands: Record = { @@ -29,6 +30,16 @@ const brands: Record = { webchart: webchartBrand, }; +/* + * Resolve the effective text direction from the direction/locale globals. + * 'auto' derives it from the locale (rtl for ar/he/fa/ur). + */ +function resolveGlobalDirection(globals: Record): 'ltr' | 'rtl' { + const direction = (globals?.direction as string) || 'auto'; + if (direction === 'ltr' || direction === 'rtl') return direction; + return isRtlLocale((globals?.locale as string) || 'en') ? 'rtl' : 'ltr'; +} + /* * Global theme listener β€” ensures data-theme and brand styles are applied * even on docs-only MDX pages (like Introduction) where no story decorator runs. @@ -55,6 +66,10 @@ function applyGlobalTheme(globals: Record) { document.body.classList.remove('condensed'); } + // Apply text direction (RTL preview) at the document level so CSS logical + // properties and `rtl:` variants respond everywhere, including docs pages. + document.documentElement.setAttribute('dir', resolveGlobalDirection(globals)); + document.body.style.backgroundColor = semanticColors.background; document.body.style.color = semanticColors.foreground; applyBrandStyles(brand, isDark); @@ -84,7 +99,13 @@ try { const [key, value] = pair.split(':'); if (key && value) globals[key] = value; } - if (globals.theme || globals.brand || globals.density) { + if ( + globals.theme || + globals.brand || + globals.density || + globals.direction || + globals.locale + ) { applyGlobalTheme(globals); } } catch { @@ -201,11 +222,13 @@ const withBrand: Decorator = (Story, context) => { const semanticColors = isDark ? brand.colors.dark : brand.colors.light; const isCondensed = context.globals.density === 'condensed'; + const direction = context.globals.direction as string | undefined; + const locale = context.globals.locale as string | undefined; useEffect(() => { // Delegate to shared applyGlobalTheme to keep a single source of truth applyGlobalTheme(context.globals); - }, [brand, isDark, isCondensed, semanticColors]); + }, [brand, isDark, isCondensed, semanticColors, direction, locale]); // Load Google Fonts for the brand const fontLink = useMemo(() => { @@ -251,8 +274,11 @@ const withBrand: Decorator = (Story, context) => { // context); pass `codeLookup={false}` in a story to demo the plain-text opt-out. const withCodeLookup: Decorator = (Story, context) => { const locale = (context.globals.locale as string) || 'en'; + // Codify shards only exist for these locales; fall back to English for + // preview-only locales (e.g. the RTL Arabic sample). + const lookupLocale = ['en', 'es'].includes(locale) ? locale : 'en'; return ( - + ); @@ -264,6 +290,7 @@ const preview: Preview = { theme: 'light', density: 'standard', locale: 'en', + direction: 'auto', }, globalTypes: { brand: { @@ -316,6 +343,20 @@ const preview: Preview = { items: [ { value: 'en', title: 'πŸ‡ΊπŸ‡Έ English' }, { value: 'es', title: 'πŸ‡ͺπŸ‡Έ EspaΓ±ol (sample)' }, + { value: 'ar', title: 'πŸ‡ΈπŸ‡¦ Ψ§Ω„ΨΉΨ±Ψ¨ΩŠΨ© (RTL sample)' }, + ], + dynamicTitle: true, + }, + }, + direction: { + name: 'Direction', + description: 'Text direction (LTR/RTL preview)', + toolbar: { + icon: 'transfer', + items: [ + { value: 'auto', title: 'Auto (from language)' }, + { value: 'ltr', title: 'LTR' }, + { value: 'rtl', title: 'RTL' }, ], dynamicTitle: true, }, diff --git a/package.json b/package.json index 7b748a20..9ea49d16 100644 --- a/package.json +++ b/package.json @@ -216,6 +216,8 @@ "typecheck": "tsc --noEmit", "lint": "eslint \"src/**/*.{ts,tsx}\"", "lint:fix": "eslint \"src/**/*.{ts,tsx}\" --fix", + "rtl:scan": "node scripts/rtl-scan.mjs", + "rtl:scan:update": "node scripts/rtl-scan.mjs --update", "format": "prettier --check \"src/**/*.{ts,tsx,css}\"", "format:fix": "prettier --write \"src/**/*.{ts,tsx,css}\"", "test": "vitest run", diff --git a/scripts/rtl-baseline.json b/scripts/rtl-baseline.json new file mode 100644 index 00000000..c085e148 --- /dev/null +++ b/scripts/rtl-baseline.json @@ -0,0 +1,122 @@ +{ + "src/components/AGGrid/EnhancedCellRenderers.tsx": 2, + "src/components/AI/AIChatModal.tsx": 8, + "src/components/AI/AIMessage.tsx": 2, + "src/components/AI/HeyOzwell/HandsFreeChat.tsx": 2, + "src/components/AI/HeyOzwell/HeyOzwellToggle.tsx": 7, + "src/components/AI/HeyOzwell/OzwellSettingsMenu.tsx": 3, + "src/components/AI/HeyOzwell/VisitScribe.tsx": 2, + "src/components/AI/HeyOzwell/VoiceManager.tsx": 1, + "src/components/AI/HeyOzwell/VoiceSetup.tsx": 1, + "src/components/AI/Reconciliation.tsx": 1, + "src/components/AddContactModal/AddContactModal.tsx": 2, + "src/components/AdditionalFields/AdditionalFields.tsx": 2, + "src/components/Alert/Alert.tsx": 4, + "src/components/AllergyList/AllergyList.tsx": 1, + "src/components/AppHeader/AppHeader.tsx": 5, + "src/components/Assessment/Assessment.tsx": 6, + "src/components/AudioPlayer/AudioPlayer.tsx": 1, + "src/components/AuthDialog/AuthDialog.tsx": 9, + "src/components/Autocomplete/Autocomplete.tsx": 1, + "src/components/Avatar/Avatar.tsx": 1, + "src/components/BookingDialog/BookingDialog.tsx": 3, + "src/components/BusinessHours/BusinessHours.tsx": 2, + "src/components/BusinessHoursEditor/BusinessHoursEditor.tsx": 5, + "src/components/Card/Card.tsx": 6, + "src/components/CheckrIntegration/CheckrIntegration.tsx": 7, + "src/components/ClaimProviderForm/ClaimProviderForm.tsx": 2, + "src/components/CodeLookup/CodeLookup.tsx": 7, + "src/components/CommandPalette/CommandPalette.tsx": 10, + "src/components/ConditionEditor/ConditionEditor.tsx": 3, + "src/components/ConnectionStatus/ConnectionStatus.tsx": 4, + "src/components/CookieConsent/CookieConsent.tsx": 10, + "src/components/CountBadge/CountBadge.tsx": 11, + "src/components/CountryCodeDropdown/CountryCodeDropdown.tsx": 3, + "src/components/CreateInvoiceModal/CreateInvoiceModal.tsx": 3, + "src/components/CreateReferralModal/CreateReferralModal.tsx": 4, + "src/components/DashboardWidget/DashboardWidget.tsx": 9, + "src/components/DateInput/DateInput.tsx": 4, + "src/components/DateRangePicker/DateRangePicker.tsx": 11, + "src/components/DocumentScanner/DocumentDetectionOverlay.tsx": 16, + "src/components/DocumentScanner/FilePreview.tsx": 1, + "src/components/DocumentScanner/WebcamModal.tsx": 15, + "src/components/Dropdown/Dropdown.tsx": 7, + "src/components/EditUserRoleModal/EditUserRoleModal.tsx": 2, + "src/components/EmployeeProfile/EmployeeProfile.tsx": 1, + "src/components/EmployerContactCard/EmployerContactCard.tsx": 1, + "src/components/EmployerList/EmployerList.tsx": 3, + "src/components/EmployerPricingCard/EmployerPricingCard.tsx": 3, + "src/components/EmployerServiceModal/EmployerServiceModal.tsx": 11, + "src/components/EmployerView/EmployerView.tsx": 2, + "src/components/ErrorPage/ErrorPage.tsx": 1, + "src/components/FileManager/FileManager.tsx": 2, + "src/components/FloatingWindow/FloatingWindow.tsx": 13, + "src/components/HRISProviderSelector/HRISProviderSelector.tsx": 5, + "src/components/HealthSurveillance/HealthSurveillance.tsx": 3, + "src/components/HelpSupportPanel/HelpSupportPanel.tsx": 3, + "src/components/InventoryManager/InventoryManager.tsx": 11, + "src/components/InviteUserModal/InviteUserModal.tsx": 2, + "src/components/InvoiceList/InvoiceList.tsx": 9, + "src/components/InvoicePaymentPage/InvoicePaymentPage.tsx": 5, + "src/components/InvoiceView/InvoiceView.tsx": 12, + "src/components/LanguageSelector/LanguageSelector.tsx": 7, + "src/components/LoadingPage/LoadingPage.tsx": 1, + "src/components/Markdown/CsvBlock.tsx": 2, + "src/components/Markdown/SurveyBlock.tsx": 1, + "src/components/MedicationList/MedicationList.tsx": 1, + "src/components/Messaging/AttachmentPicker.tsx": 3, + "src/components/Messaging/ConversationHeader.tsx": 4, + "src/components/Messaging/MessageBubble.tsx": 7, + "src/components/Messaging/MessageComposer.tsx": 8, + "src/components/Messaging/MessageList.tsx": 4, + "src/components/Messaging/MessageThread.tsx": 3, + "src/components/OnboardingWizard/OnboardingWizard.tsx": 6, + "src/components/OrderCard/OrderCard.tsx": 2, + "src/components/OrderConfirmationWizard/OrderConfirmationWizard.tsx": 5, + "src/components/OrderList/OrderList.tsx": 4, + "src/components/OrderLookupForm/OrderLookupForm.tsx": 2, + "src/components/OrderSidebar/OrderSidebar.tsx": 3, + "src/components/PatientHeader/PatientHeader.tsx": 4, + "src/components/PaymentHistoryTable/PaymentHistoryTable.tsx": 8, + "src/components/PendingClaimsTable/PendingClaimsTable.tsx": 5, + "src/components/PermissionsEditor/PermissionsEditor.tsx": 10, + "src/components/PresentingProblems/PresentingProblems.tsx": 3, + "src/components/ProblemList/ProblemList.tsx": 7, + "src/components/ProductVersion/ProductVersion.tsx": 1, + "src/components/ProviderSearchBar/ProviderSearchBar.tsx": 2, + "src/components/ProviderSearchFilters/ProviderSearchFilters.tsx": 15, + "src/components/ProviderSelector/ProviderSelector.tsx": 5, + "src/components/ProviderUsersTable/ProviderUsersTable.tsx": 2, + "src/components/QuickAction/QuickAction.tsx": 1, + "src/components/QuickLinksCard/QuickLinksCard.tsx": 3, + "src/components/RecurringServiceCard/RecurringServiceCard.tsx": 3, + "src/components/RejectionModal/RejectionModal.tsx": 3, + "src/components/ReportDashboard/ReportDashboard.tsx": 1, + "src/components/ResultsEntryForm/ResultsEntryForm.tsx": 9, + "src/components/RichTextEditor/RichTextEditor.tsx": 1, + "src/components/RowActionToolbar/RowActionToolbar.tsx": 5, + "src/components/ScheduleCalendar/ScheduleCalendar.tsx": 7, + "src/components/ServiceAccordion/ServiceAccordion.tsx": 4, + "src/components/ServiceBadge/ServiceBadge.tsx": 5, + "src/components/ServiceCard/ServiceCard.tsx": 1, + "src/components/ServiceGeneralSettings/ServiceGeneralSettings.tsx": 5, + "src/components/ServicePicker/ServicePicker.tsx": 6, + "src/components/ServicePricingManager/ServicePricingManager.tsx": 6, + "src/components/SetupServiceModal/SetupServiceModal.tsx": 8, + "src/components/Sheet/Sheet.tsx": 4, + "src/components/Sidebar/Sidebar.tsx": 14, + "src/components/SiteFooter/SiteFooter.tsx": 2, + "src/components/SiteHeader/SiteHeader.tsx": 10, + "src/components/Slider/Slider.tsx": 1, + "src/components/SuperChat/SuperChat.tsx": 1, + "src/components/SuperChat/SuperChatConversations.tsx": 2, + "src/components/SuperChat/parts.tsx": 1, + "src/components/SuperChat/plugins/code.tsx": 1, + "src/components/SuperChat/plugins/nitroTable.tsx": 1, + "src/components/SuperChat/render/createMarkdownRenderer.tsx": 1, + "src/components/Table/Table.tsx": 3, + "src/components/TableOfContents/TableOfContents.tsx": 4, + "src/components/Timeline/Timeline.tsx": 1, + "src/components/Toast/Toast.tsx": 6, + "src/components/WebChartReportViewer/WebChartReportViewer.tsx": 2 +} diff --git a/scripts/rtl-scan.mjs b/scripts/rtl-scan.mjs new file mode 100644 index 00000000..5ee7ea94 --- /dev/null +++ b/scripts/rtl-scan.mjs @@ -0,0 +1,140 @@ +#!/usr/bin/env node +/** + * RTL guard: scans src/components for physical-direction Tailwind classes + * (ml-/mr-/pl-/pr-/left-/right-/text-left/rounded-l/border-r/space-x-/…) + * that break right-to-left layouts. Use CSS logical properties instead + * (ms-/me-/ps-/pe-/start-/end-/text-start/rounded-s/border-e/gap-…). + * + * Works as a ratchet: existing offenders are recorded in rtl-baseline.json. + * The scan fails only when a file's count INCREASES or a new file offends, + * so the migration can proceed batch-by-batch without breaking CI. + * + * Usage: + * node scripts/rtl-scan.mjs # scan + compare against baseline (CI) + * node scripts/rtl-scan.mjs --update # rewrite baseline after a migration batch + * node scripts/rtl-scan.mjs --list # print every match (file:line: token) + */ +import { readFileSync, writeFileSync, readdirSync } from 'node:fs'; +import { join, relative, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const COMPONENTS_DIR = join(root, 'src', 'components'); +const BASELINE_PATH = join(root, 'scripts', 'rtl-baseline.json'); + +// Physical-direction utilities (with optional variant prefixes like `hover:`, +// `md:` and optional negative `-`). Logical equivalents in comments. +const PHYSICAL_UTILITIES = new RegExp( + '(?<=^|[\\s\'"`{:])-?(?:[a-z-]+:)*(' + + [ + '(?:scroll-)?m[lr]-[^\\s\'"`]+', // ml-/mr- β†’ ms-/me- + '(?:scroll-)?p[lr]-[^\\s\'"`]+', // pl-/pr- β†’ ps-/pe- + '(?:left|right)-[^\\s\'"`]+', // left-/right- β†’ start-/end- + 'text-(?:left|right)(?![-\\w])', // β†’ text-start/text-end + 'rounded-(?:[lr]|[tb][lr])(?:-[^\\s\'"`]+)?(?![-\\w])', // β†’ rounded-s/e/ss/se/es/ee + 'border-[lr](?:-[^\\s\'"`]+)?(?![-\\w])', // β†’ border-s/border-e + 'space-x-[^\\s\'"`]+', // β†’ gap- (or rtl:space-x-reverse) + 'divide-x(?:-[^\\s\'"`]+)?(?![-\\w])', // needs rtl:divide-x-reverse + ].join('|') + + ')', + 'g' +); + +function walk(dir, files = []) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) walk(path, files); + else if ( + /\.tsx$/.test(entry.name) && + !/\.(stories|test)\.tsx$/.test(entry.name) + ) + files.push(path); + } + return files; +} + +function scan() { + const results = new Map(); // relPath -> [{ line, token }] + for (const file of walk(COMPONENTS_DIR)) { + const rel = relative(root, file); + const lines = readFileSync(file, 'utf8').split('\n'); + lines.forEach((text, i) => { + for (const match of text.matchAll(PHYSICAL_UTILITIES)) { + if (!results.has(rel)) results.set(rel, []); + results.get(rel).push({ line: i + 1, token: match[1] }); + } + }); + } + return results; +} + +const results = scan(); +const counts = Object.fromEntries( + [...results.entries()].map(([file, matches]) => [file, matches.length]).sort() +); +const total = Object.values(counts).reduce((a, b) => a + b, 0); + +const arg = process.argv[2]; + +if (arg === '--update') { + writeFileSync(BASELINE_PATH, JSON.stringify(counts, null, 2) + '\n'); + console.log( + `Baseline updated: ${Object.keys(counts).length} files, ${total} matches.` + ); + process.exit(0); +} + +if (arg === '--list') { + for (const [file, matches] of results) { + for (const { line, token } of matches) + console.log(`${file}:${line}: ${token}`); + } + console.log(`\nTotal: ${total} matches in ${results.size} files.`); + process.exit(0); +} + +let baseline = {}; +try { + baseline = JSON.parse(readFileSync(BASELINE_PATH, 'utf8')); +} catch { + console.error(`No baseline found at ${relative(root, BASELINE_PATH)}.`); + console.error('Run: node scripts/rtl-scan.mjs --update'); + process.exit(1); +} + +const failures = []; +let improved = 0; +for (const [file, count] of Object.entries(counts)) { + const allowed = baseline[file] ?? 0; + if (count > allowed) failures.push({ file, count, allowed }); + else if (count < allowed) improved++; +} +const cleaned = Object.keys(baseline).filter((f) => !(f in counts)).length; + +if (failures.length > 0) { + console.error( + 'RTL guard: new physical-direction Tailwind classes detected.\n' + ); + for (const { file, count, allowed } of failures) { + console.error(` ${file}: ${count} matches (baseline allows ${allowed})`); + for (const { line, token } of results.get(file)) { + console.error(` ${file}:${line}: ${token}`); + } + } + console.error( + '\nUse CSS logical properties instead (ms-/me-/ps-/pe-/start-/end-/text-start/rounded-s/border-e/gap-).' + ); + console.error( + 'See RTL plan. To inspect all matches: node scripts/rtl-scan.mjs --list' + ); + process.exit(1); +} + +console.log( + `RTL guard passed: ${total} matches in ${results.size} files (within baseline).` +); +if (improved > 0 || cleaned > 0) { + console.log( + `${improved + cleaned} file(s) improved β€” tighten the ratchet: node scripts/rtl-scan.mjs --update` + ); +} diff --git a/src/components/Badge/Badge.tsx b/src/components/Badge/Badge.tsx index 2a21c065..46bd1d81 100644 --- a/src/components/Badge/Badge.tsx +++ b/src/components/Badge/Badge.tsx @@ -63,7 +63,7 @@ const Badge = React.forwardRef( className={cn(badgeVariants({ variant, size }), className)} {...props} > - {icon && {icon}} + {icon && {icon}} {children} ); diff --git a/src/components/Input/Input.tsx b/src/components/Input/Input.tsx index 3d15ebc3..9a98f652 100644 --- a/src/components/Input/Input.tsx +++ b/src/components/Input/Input.tsx @@ -103,7 +103,7 @@ const Input = React.forwardRef( > {label} {required && ( -