Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
adb2474
feat: add RTL direction infrastructure and migrate primitives to logi…
garrity-miepub Jul 22, 2026
f4419d8
refactor(rtl-scan): replace lookbehind with boundary group for clarity
garrity-miepub Jul 22, 2026
baabb79
fix(preset): safelist LTR data-[state=checked] Switch thumb classes
garrity-miepub Jul 23, 2026
3c1385a
Merge branch 'main' into feat/rtl-direction-switch
garrity-miepub Jul 23, 2026
1994303
fix(rtl): migrate MediaEditor/TranscriptView to logical properties; a…
garrity-miepub Jul 23, 2026
1736597
test(useDirection): cover isRtlLocale and direction resolution/observ…
garrity-miepub Jul 23, 2026
9e1991e
feat(rtl): migrate layout & navigation batch to logical properties
garrity-miepub Jul 23, 2026
4d9d244
Merge remote-tracking branch 'origin/main' into feat/rtl-direction-sw…
garrity-miepub Jul 24, 2026
a5120c4
chore(rtl): tighten baseline after main merge (507 -> 488 matches)
garrity-miepub Jul 24, 2026
20b030f
feat(rtl): add physical->logical codemod, pilot on CookieConsent
garrity-miepub Jul 25, 2026
0feb04c
feat(rtl): migrate DocumentScanner, Messaging, AI to logical properties
garrity-miepub Jul 25, 2026
36a8ffa
Merge remote-tracking branch 'origin/main' into feat/rtl-direction-sw…
garrity-miepub Jul 25, 2026
5a9fbc8
fix(rtl): reconcile media safelist after main merge
garrity-miepub Jul 25, 2026
4457b26
fix(rtl): sync document lang with locale global; filter explicit code…
garrity-miepub Jul 25, 2026
bf90b2b
fix(rtl): format codemod-touched files; re-sync useDirection on ref swap
garrity-miepub Jul 25, 2026
a8b7c31
Merge remote-tracking branch 'origin/main' into feat/rtl-direction-sw…
garrity-miepub Jul 27, 2026
52444a1
feat(rtl): migrate ProviderSearchFilters, InvoiceView, InventoryManag…
garrity-miepub Jul 27, 2026
0e6132c
Merge remote-tracking branch 'origin/main' into feat/rtl-direction-sw…
garrity-miepub Jul 27, 2026
04d2e84
feat(rtl): migrate SiteHeader, PermissionsEditor, CommandPalette, Aut…
garrity-miepub Jul 27, 2026
6240b4c
fix(rtl): mirror select chevron in RTL and safelist Sidebar off-canva…
garrity-miepub Jul 28, 2026
fa64e8a
feat(rtl): migrate Batch D components to logical properties
garrity-miepub Jul 28, 2026
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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 44 additions & 3 deletions .storybook/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, BrandConfig> = {
Expand All @@ -29,6 +30,16 @@ const brands: Record<string, BrandConfig> = {
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<string, unknown>): '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.
Expand All @@ -55,6 +66,10 @@ function applyGlobalTheme(globals: Record<string, unknown>) {
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));
Comment thread
garrity-miepub marked this conversation as resolved.

document.body.style.backgroundColor = semanticColors.background;
document.body.style.color = semanticColors.foreground;
applyBrandStyles(brand, isDark);
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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 (
<CodeLookupProvider component={CodeLookup} indexUrl="/codify" locale={locale}>
<CodeLookupProvider component={CodeLookup} indexUrl="/codify" locale={lookupLocale}>
<Story />
</CodeLookupProvider>
);
Expand All @@ -264,6 +290,7 @@ const preview: Preview = {
theme: 'light',
density: 'standard',
locale: 'en',
direction: 'auto',
},
globalTypes: {
brand: {
Expand Down Expand Up @@ -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,
},
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
122 changes: 122 additions & 0 deletions scripts/rtl-baseline.json
Original file line number Diff line number Diff line change
@@ -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
}
140 changes: 140 additions & 0 deletions scripts/rtl-scan.mjs
Original file line number Diff line number Diff line change
@@ -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'
);
Comment thread
garrity-miepub marked this conversation as resolved.

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`
);
}
Loading
Loading