diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c36ae72ff..dc88cc5da 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/.storybook/preview.tsx b/.storybook/preview.tsx index 3edfc78d2..c56ef81ad 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,16 @@ 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)); + // Keep the document language in sync with the locale global so screen + // readers and locale-sensitive text shaping reflect the selected locale. + document.documentElement.setAttribute( + 'lang', + (globals?.locale as string) || 'en' + ); + document.body.style.backgroundColor = semanticColors.background; document.body.style.color = semanticColors.foreground; applyBrandStyles(brand, isDark); @@ -84,7 +105,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 +228,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 +280,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 +296,7 @@ const preview: Preview = { theme: 'light', density: 'standard', locale: 'en', + direction: 'auto', }, globalTypes: { brand: { @@ -316,6 +349,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 2680a0d57..4270a98ae 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 000000000..26144fd5f --- /dev/null +++ b/scripts/rtl-baseline.json @@ -0,0 +1,98 @@ +{ + "src/components/AGGrid/EnhancedCellRenderers.tsx": 2, + "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/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": 2, + "src/components/CheckrIntegration/CheckrIntegration.tsx": 7, + "src/components/ClaimProviderForm/ClaimProviderForm.tsx": 2, + "src/components/CodeLookup/CodeLookup.tsx": 5, + "src/components/CommandPalette/CommandPalette.tsx": 10, + "src/components/ConditionEditor/ConditionEditor.tsx": 3, + "src/components/ConnectionStatus/ConnectionStatus.tsx": 4, + "src/components/CountBadge/CountBadge.tsx": 9, + "src/components/CountryCodeDropdown/CountryCodeDropdown.tsx": 1, + "src/components/CreateInvoiceModal/CreateInvoiceModal.tsx": 3, + "src/components/CreateReferralModal/CreateReferralModal.tsx": 4, + "src/components/DashboardWidget/DashboardWidget.tsx": 9, + "src/components/DateInput/DateInput.tsx": 3, + "src/components/DateRangePicker/DateRangePicker.tsx": 7, + "src/components/Dropdown/Dropdown.tsx": 1, + "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/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": 3, + "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/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/TableOfContents/TableOfContents.tsx": 4, + "src/components/Timeline/Timeline.tsx": 1, + "src/components/Toast/Toast.tsx": 4, + "src/components/WebChartReportViewer/WebChartReportViewer.tsx": 2 +} diff --git a/scripts/rtl-codemod.mjs b/scripts/rtl-codemod.mjs new file mode 100644 index 000000000..2650a9b39 --- /dev/null +++ b/scripts/rtl-codemod.mjs @@ -0,0 +1,214 @@ +#!/usr/bin/env node +/** + * RTL codemod: rewrites physical-direction Tailwind classes to their CSS + * logical-property equivalents inside component source files. + * + * Companion to rtl-scan.mjs (the ratchet guard): the codemod rewrites, the + * scan verifies. Run the scan after every codemod pass. + * + * Token mapping (variant prefixes like `md:`/`hover:` and negative `-` are + * preserved): + * ml- / mr- -> ms- / me- (also scroll-ml/scroll-mr) + * pl- / pr- -> ps- / pe- (also scroll-pl/scroll-pr) + * left- / right- -> start- / end- + * text-left/right -> text-start/text-end + * rounded-l / -r -> rounded-s / -e + * rounded-tl/tr/bl/br -> rounded-ss/se/es/ee + * border-l / -r -> border-s / -e + * + * NOT rewritten (printed as NEEDS-MANUAL β€” they require judgment): + * space-x- (prefer flex gap-; else add rtl:space-x-reverse) + * divide-x- (needs added rtl:divide-x-reverse) + * + * Usage: + * node scripts/rtl-codemod.mjs # dry run: print replacements + * node scripts/rtl-codemod.mjs --write # apply changes + * + * are files or directories (searched recursively for .tsx, skipping + * .stories.tsx / .test.tsx β€” same scope as rtl-scan.mjs). + */ +import { readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs'; +import { join, relative, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); + +// Ordered: longer/more-specific prefixes first so e.g. `scroll-ml-` and +// `rounded-tl-` win over `ml-` / `rounded-l-`. +const PREFIX_MAP = [ + ['scroll-ml-', 'scroll-ms-'], + ['scroll-mr-', 'scroll-me-'], + ['scroll-pl-', 'scroll-ps-'], + ['scroll-pr-', 'scroll-pe-'], + ['rounded-tl-', 'rounded-ss-'], + ['rounded-tr-', 'rounded-se-'], + ['rounded-bl-', 'rounded-es-'], + ['rounded-br-', 'rounded-ee-'], + ['rounded-l-', 'rounded-s-'], + ['rounded-r-', 'rounded-e-'], + ['border-l-', 'border-s-'], + ['border-r-', 'border-e-'], + ['text-left', 'text-start'], + ['text-right', 'text-end'], + ['left-', 'start-'], + ['right-', 'end-'], + ['ml-', 'ms-'], + ['mr-', 'me-'], + ['pl-', 'ps-'], + ['pr-', 'pe-'], +]; + +// Bare (suffix-less) forms. +const BARE_MAP = { + 'rounded-tl': 'rounded-ss', + 'rounded-tr': 'rounded-se', + 'rounded-bl': 'rounded-es', + 'rounded-br': 'rounded-ee', + 'rounded-l': 'rounded-s', + 'rounded-r': 'rounded-e', + 'border-l': 'border-s', + 'border-r': 'border-e', +}; + +function mapToken(token) { + if (BARE_MAP[token]) return BARE_MAP[token]; + for (const [from, to] of PREFIX_MAP) { + if (token === from) return to; + if (token.startsWith(from)) return to + token.slice(from.length); + } + return null; +} + +// Same detection pattern as rtl-scan.mjs: leading boundary group, optional +// negative sign, variant prefixes, then the physical utility token (group 3). +// Spacing/inset suffixes are restricted to real Tailwind values (numbers, +// fractions, px, auto, full, arbitrary [..]) so prose like "right-click" in +// comments and copy is never rewritten. +const VALUE = + '(?:\\d+/\\d+|\\d+(?:\\.\\d+)?|px|auto|full|\\[[^\\]\\s]+\\])(?![-\\w])'; +const PHYSICAL_UTILITIES = new RegExp( + '(^|[\\s\'"`{:])(-?(?:[a-z-]+:)*)(' + + [ + '(?:scroll-)?m[lr]-' + VALUE, + '(?:scroll-)?p[lr]-' + VALUE, + '(?:left|right)-' + VALUE, + 'text-(?:left|right)(?![-\\w])', + 'rounded-(?:[lr]|[tb][lr])(?:-[^\\s\'"`]+)?(?![-\\w])', + 'border-[lr](?:-[^\\s\'"`]+)?(?![-\\w])', + ].join('|') + + ')', + 'g' +); + +// `left-1/2` (or `right-1/2`) paired with `translate-x-1/2` on the same line +// is the physical centering idiom: a centered element renders identically in +// LTR and RTL, so it is skipped (rtl-scan.mjs exempts it for the same reason). +function isCenteringIdiom(token, lineText) { + return ( + /^(?:left|right)-1\/2$/.test(token) && lineText.includes('translate-x-1/2') + ); +} + +const NEEDS_MANUAL = new RegExp( + '(^|[\\s\'"`{:])-?(?:[a-z-]+:)*((?:space-x|divide-x)(?:-(?!reverse)[^\\s\'"`]+)?)', + 'g' +); + +// A `space-x-*`/`divide-x-*` utility accompanied by its rtl:*-reverse remedy +// on the same line is already RTL-correct β€” no manual attention needed. +function isHandledReverse(token, lineText) { + return ( + (/^space-x-/.test(token) && lineText.includes('rtl:space-x-reverse')) || + (/^divide-x/.test(token) && lineText.includes('rtl:divide-x-reverse')) + ); +} + +// Component sources only: .tsx, excluding stories and tests β€” applied to both +// walked directory entries and explicitly-passed file paths. +function isComponentSource(name) { + return /\.tsx$/.test(name) && !/\.(stories|test)\.tsx$/.test(name); +} + +function walk(path, files = []) { + if (statSync(path).isFile()) { + if (isComponentSource(path)) files.push(path); + return files; + } + for (const entry of readdirSync(path, { withFileTypes: true })) { + const p = join(path, entry.name); + if (entry.isDirectory()) walk(p, files); + else if (isComponentSource(entry.name)) files.push(p); + } + return files; +} + +const args = process.argv.slice(2); +const write = args.includes('--write'); +const paths = args.filter((a) => a !== '--write'); + +if (paths.length === 0) { + console.error( + 'Usage: node scripts/rtl-codemod.mjs [--write] ' + ); + process.exit(1); +} + +let totalReplacements = 0; +let totalManual = 0; +let changedFiles = 0; + +for (const file of paths.flatMap((p) => walk(join(root, p)))) { + const rel = relative(root, file); + const src = readFileSync(file, 'utf8'); + const lines = src.split('\n'); + const replacements = []; + const manual = []; + + const out = lines + .map((text, i) => { + let updated = text.replace( + PHYSICAL_UTILITIES, + (full, boundary, variants, token) => { + if (isCenteringIdiom(token, text)) return full; + const mapped = mapToken(token); + if (!mapped) return full; + replacements.push({ + line: i + 1, + from: variants + token, + to: variants + mapped, + }); + return boundary + variants + mapped; + } + ); + for (const m of updated.matchAll(NEEDS_MANUAL)) { + if (isHandledReverse(m[2], updated)) continue; + manual.push({ line: i + 1, token: m[2] }); + } + return updated; + }) + .join('\n'); + + if (replacements.length === 0 && manual.length === 0) continue; + + if (replacements.length > 0) { + changedFiles++; + totalReplacements += replacements.length; + console.log(`${rel}:`); + for (const { line, from, to } of replacements) { + console.log(` ${line}: ${from} -> ${to}`); + } + if (write && out !== src) writeFileSync(file, out); + } + for (const { line, token } of manual) { + totalManual++; + console.log(` NEEDS-MANUAL ${rel}:${line}: ${token}`); + } +} + +console.log( + `\n${write ? 'Applied' : 'Dry run:'} ${totalReplacements} replacement(s) in ${changedFiles} file(s).` + + (totalManual ? ` ${totalManual} site(s) need manual attention.` : '') +); +if (!write && totalReplacements > 0) { + console.log('Re-run with --write to apply.'); +} diff --git a/scripts/rtl-scan.mjs b/scripts/rtl-scan.mjs new file mode 100644 index 000000000..0b8735073 --- /dev/null +++ b/scripts/rtl-scan.mjs @@ -0,0 +1,167 @@ +#!/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. +// Note: a leading boundary group is matched (not a lookbehind) to keep the +// pattern portable and obvious; the utility token itself is capture group 2. +// Spacing/inset suffixes are restricted to real Tailwind values (numbers, +// fractions, px, auto, full, arbitrary [..]) so prose like "right-click" or +// "left-border" in comments and copy is not flagged. +const VALUE = + '(?:\\d+/\\d+|\\d+(?:\\.\\d+)?|px|auto|full|\\[[^\\]\\s]+\\])(?![-\\w])'; +const PHYSICAL_UTILITIES = new RegExp( + '(^|[\\s\'"`{:])-?(?:[a-z-]+:)*(' + + [ + '(?:scroll-)?m[lr]-' + VALUE, // ml-/mr- β†’ ms-/me- + '(?:scroll-)?p[lr]-' + VALUE, // pl-/pr- β†’ ps-/pe- + '(?:left|right)-' + VALUE, // 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-(?!reverse)[^\\s\'"`]+', // β†’ gap- (or rtl:space-x-reverse) + 'divide-x(?:-(?!reverse)[^\\s\'"`]+)?(?![-\\w])', // needs rtl:divide-x-reverse + ].join('|') + + ')', + 'g' +); + +// A `space-x-*`/`divide-x-*` utility accompanied by its rtl:*-reverse remedy +// on the same line is already RTL-correct. +function isHandledReverse(token, lineText) { + return ( + (/^space-x-/.test(token) && lineText.includes('rtl:space-x-reverse')) || + (/^divide-x/.test(token) && lineText.includes('rtl:divide-x-reverse')) + ); +} + +// `left-1/2` (or `right-1/2`) paired with `translate-x-1/2` on the same line +// is the physical centering idiom β€” a centered element renders identically in +// LTR and RTL, so it is direction-neutral and exempt from the guard. +function isCenteringIdiom(token, lineText) { + return ( + /^(?:left|right)-1\/2$/.test(token) && lineText.includes('translate-x-1/2') + ); +} + +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 (isCenteringIdiom(match[2], text)) continue; + if (isHandledReverse(match[2], text)) continue; + if (!results.has(rel)) results.set(rel, []); + results.get(rel).push({ line: i + 1, token: match[2] }); + } + }); + } + 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/AI/AIChatModal.tsx b/src/components/AI/AIChatModal.tsx index dc8fa9edb..b34b156d9 100644 --- a/src/components/AI/AIChatModal.tsx +++ b/src/components/AI/AIChatModal.tsx @@ -40,10 +40,10 @@ export function AIChatTrigger({ className, }: AIChatTriggerProps) { const positionClasses = { - 'bottom-right': 'bottom-4 right-4', - 'bottom-left': 'bottom-4 left-4', - 'top-right': 'top-4 right-4', - 'top-left': 'top-4 left-4', + 'bottom-right': 'bottom-4 end-4', + 'bottom-left': 'bottom-4 start-4', + 'top-right': 'top-4 end-4', + 'top-left': 'top-4 start-4', }; return ( @@ -72,7 +72,7 @@ export function AIChatTrigger({ {badge && badge > 0 && ( {badge > 9 ? '9+' : badge} @@ -129,8 +129,8 @@ export function AIChatModal({ if (!open) return null; const positionClasses = { - 'bottom-right': 'bottom-20 right-4', - 'bottom-left': 'bottom-20 left-4', + 'bottom-right': 'bottom-20 end-4', + 'bottom-left': 'bottom-20 start-4', center: 'top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2', }; diff --git a/src/components/AI/AIMessage.tsx b/src/components/AI/AIMessage.tsx index 0c97844e3..c81144276 100644 --- a/src/components/AI/AIMessage.tsx +++ b/src/components/AI/AIMessage.tsx @@ -219,7 +219,7 @@ function ContentBlock({ > - + {readOnly ? 'read-only' : dirty ? 'edited' : 'live'}