بناء نظام تصميم يتعامل مع اتجاهي الكتابة LTR وRTL دون قاعدتي شيفرة منفصلتين
دليل عملي لبناء رموز التصميم (design tokens) وواجهات المكوّنات وقواعد الأيقونات واستراتيجية الاختبار، بحيث يكون دعم الاتجاه من اليمين إلى اليسار جزءًا بنيويًا من النظام لا إضافة لاحقة عليه.
يمكن لقائمة تدقيق تشخيصية أن تخبرك أن صفحة منشورة تعرض النص العربي بترتيب معكوس. لكنها لا تستطيع أن تخبرك لماذا تعود المشكلة نفسها كل مرة يُضاف فيها مكوّن جديد. تلك المشكلة الثانية بنيوية، وتكمن في طبقة أعمق من الصفحة: في الرموز (tokens) وواجهات المكوّنات ومجموعات الأيقونات التي يُبنى عليها المنتج بأكمله.
إذا كان الاتجاه أمرًا يجب على كل من يكتب مكوّنًا أن يتذكره بنفسه، فستظل تُصلح المجموعة نفسها من الأخطاء إلى الأبد، لأن كل مهندس جديد يبدأ من العادات نفسها في كتابة CSS الفيزيائي (physical) التي بدأ منها من سبقه. أما إذا كان الاتجاه خاصية في النظام نفسه، تُحسم مرة واحدة في طبقة الرموز والمكوّنات الأساسية، فيمكن حتى لمن لم يفكر يومًا في اللغة العربية أن يُطلق ميزة تعمل بها. هذا الدليل يشرح كيفية بناء النوع الثاني من الأنظمة: نظام تكون فيه dir="rtl" حالة مدعومة في كل مكوّن منذ اليوم الأول، لا "ثيمًا" أُلحق بعد إطلاق النسخة الإنجليزية.
أبرز النقاط
- خصائص CSS المنطقية (logical properties) مثل
margin-inline-startوinset-inline-endوtext-align: startتتيح لك كتابة قواعد التخطيط مرة واحدة؛ وقد وُضعت مواصفة CSS Logical Properties and Values خصيصًا حتى يتوقف المطورون عن تحويل "اليسار" إلى "البداية" يدويًا في كل مرة.- مهمة نظام التصميم أن يجعل الخيار الصحيح لا يتطلب أي جهد إضافي من الشخص الذي يبني المكوّن التالي — لا أن يوثّق الخيار الصحيح ويأمل أن يتذكره الجميع.
- يجب ألا تحمل واجهات المكوّنات كلمتي "يسار" أو "يمين" أبدًا. الخاصيتان
iconStart/iconEndوالقيمةalign="start" | "end"تبقيان صحيحتين بعد انعكاس الاتجاه؛ أماiconLeft/iconRightفلا.- انعكاس الأيقونة قرار تصميمي خاص بكل أيقونة على حدة، وليس تحويل CSS عام. أيقونات التسلسل والتنقّل تنعكس، أما أيقونات الوسائط والزمن والهوية فلا، لأن فريق هندسة سبوتيفاي وجد أنها تعبّر عن اتجاه الشريط أو عقارب الساعة لا عن اتجاه القراءة.
- عزل النصوص ثنائية الاتجاه (bidi isolation) يجب أن يكون جزءًا من مكوّن نصي أساسي، لا مسؤولية متروكة لكل فريق ميزة على حدة. فإذا كانت خطوة العزل شيئًا يمكن نسيانه، فسيُنسى فعلًا.
- افرض استخدام الخصائص المنطقية عبر أداة تدقيق (linter) تمامًا كما تفرض عدم وجود متغيرات غير مستخدمة. أدوات مثل stylelint-use-logical تحوّل "الرجاء استخدام الخصائص المنطقية" من ملاحظة في مراجعة الشيفرة إلى شرط فشل في البناء (build).
لماذا يفشل دائمًا "ثيم" RTL المُلحَق لاحقًا
النمط الشائع في الشركات الناشئة التي تُطلق بالإنجليزية أولًا ثم تضيف العربية لاحقًا هو ورقة أنماط [dir="rtl"] تُضاف فوق الأصلية، تعكس فيها left إلى right محدّدًا تلو الآخر. تنجح هذه الطريقة مع المكوّنات الموجودة يوم الإطلاق. لكنها تفشل بصمت مع كل مكوّن يُبنى بعد ذلك، لأن لا شيء في النظام يُنبّه المهندس التالي إلى أن خاصية margin-left جديدة تحتاج إلى تجاوز مطابق في ملف آخر. تتباعد أنماط RTL عن أنماط LTR شيئًا فشيئًا، ويبقى هذا التباعد غير مرئي إلى أن يفتح متحدث عربي أصلي الصفحة.
الحل ليس ورقة أنماط تجاوز أكبر، بل إزالة الحاجة إليها من الأساس. تملك CSS نموذج خصائص غير مرتبط بالاتجاه منذ وحدة CSS Logical Properties and Values، التي تصف نفسها بأنها تمنح "المؤلف... القدرة على التحكم في التخطيط عبر تخطيطات منطقية (logical) بدلًا من تخطيطات فيزيائية (physical) للاتجاه والبُعد". فحين يُكتب المكوّن بالكامل بخصائص منطقية، لا تعود dir="rtl" قاعدة شيفرة ثانية يجب صيانتها، بل قيمة يحلّها المتصفح بشكل صحيح تلقائيًا، لأنك لم تُخبره بكلمة "يسار" أصلًا.
ضع الاتجاه في طبقة الرموز (tokens)، لا في طبقة المكوّنات
رموز التصميم (design tokens) هي المكان الصحيح لحسم هذا القرار مرة واحدة. فإذا كانت رموز التباعد والموضع والمحاذاة فيزيائية، فسيرث كل مكوّن يستخدمها الخطأ نفسه. وإذا كانت منطقية، فسيرث كل مكوّن يستخدمها الإصلاح نفسه.
يصف دليل MDN للخصائص المنطقية النموذج الكامن بأنه بُعدان لا أربعة اتجاهات: البُعد الخطي (inline)، الذي يجري في الاتجاه الذي يسير فيه سطر النص، والبُعد الكتلي (block)، الذي تتراكم فيه الأسطر فوق بعضها. في الإنجليزية والعربية على حد سواء، يكون البُعد الكتلي عموديًا لأن كلتيهما نظام كتابة أفقي؛ ولا ينعكس سوى المحور الخطي. ولهذا تتحول margin-inline-start إلى جهة فيزيائية مختلفة بحسب قيمة dir، بينما لا تتغير margin-block-start أبدًا — وهذا التطابق هو بالضبط التجريد الذي يحتاجه الرمز (token).
/* رموز فيزيائية: صحيحة لاتجاه واحد فقط */
:root {
--card-padding-left: 1rem;
--card-padding-right: 1.5rem;
--icon-margin-right: 0.5rem;
}
/* رموز منطقية: صحيحة للاتجاهين، لأنه لا وجود لكلمة "يسار" لتخطئ فيها */
:root {
--card-padding-inline-start: 1rem;
--card-padding-inline-end: 1.5rem;
--icon-margin-inline-end: 0.5rem;
}
.card {
padding-inline-start: var(--card-padding-inline-start);
padding-inline-end: var(--card-padding-inline-end);
}
هناك أمران مهمان في تطبيق هذا التغيير على مستوى طبقة الرموز بدل ترقيع المكوّنات واحدًا واحدًا. أولًا، إنها تكلفة ترحيل تُدفع مرة واحدة لا تتكرر: تُعيد تسمية الرموز مرة واحدة، فيُصحَّح كل مكوّن يعتمد عليها في اللحظة نفسها. ثانيًا، يتغيّر شكل "الصواب" في مراجعة الفروقات (diff): طلب سحب (pull request) يُعيد إدخال margin-left داخل مكوّن لا يستخدم إلا رموزًا منطقية يبدو خطأً واضحًا للعين في المراجعة، بينما يمرّ السطر نفسه دون أن يُلاحظه أحد في قاعدة شيفرة تخلط بين الخصائص الفيزيائية والمنطقية في كل مكان.
قواعد التسمية مهمة أيضًا خارج نطاق خصائص CSS. يدافع دليل أحمد شديد المرجعي حول تنسيق RTL عن إعادة تسمية أصناف مكانية مثل .header__left و.header__right إلى .header__start و.header__end للسبب نفسه: الاسم الذي يشفّر جهة فيزيائية لا بد أن يكون خطأً في أحد الاتجاهين، بينما الاسم الذي يشفّر دورًا منطقيًا لا يخطئ أبدًا. طبّق القاعدة نفسها على أصناف BEM لديك، وعلى أدوات Tailwind المساعدة إن كنت تكتبها بنفسك، وعلى أسماء خصائص المكوّنات — وهي الطبقة التالية.
يجب ألا تُشير واجهات المكوّنات إلى اليسار أو اليمين أبدًا
الرموز تُصلح التخطيط، لكنها لا تُصلح واجهة برمجية (API) تُسرّب الاتجاه الفيزيائي إلى كل مكوّن يستخدمها. زرّ بخاصية iconLeft يُجبر كل من يستخدمه على معرفة الجهة الصحيحة للاتجاه الحالي وإعادة تحديدها — وهي بالضبط الخطوة اليدوية التي يُفترض بالنظام أن يزيلها.
// يُسرّب الاتجاه إلى كل موضع استخدام. صحيح في الإنجليزية،
// وخاطئ بصمت في اليوم الذي تُعرض فيه الصفحة بـ RTL.
<Button iconLeft={<ArrowIcon />}>Continue</Button>
// المكوّن يحسم قرار الاتجاه مرة واحدة، داخليًا.
<Button iconStart={<ArrowIcon />}>Continue</Button>
داخل المكوّن، تُترجَم iconStart إلى margin-inline-end على غلاف الأيقونة (لأن الأيقونة تسبق التسمية في ترتيب القراءة، أيًا كانت الجهة التي تمثّل "قبل")، ولا يحتاج المستخدم بعدها إلى التفكير في الأمر مجددًا. ينطبق التبديل نفسه على كل خاصية تُشفّر حاليًا جهة على الشاشة: تصبح align="left" | "right" هي align="start" | "end"، وتصبح dropdownPosition="right" هي dropdownPosition="end"، وهكذا. هذا هو المعادل على مستوى المكوّن لإعادة تسمية الرموز أعلاه، ويجب أن يحدث في الوقت نفسه — فخاصية iconStart التي لا تزال تكتب margin-left داخليًا لم تُصلح الخطأ، بل نقلته فقط.
قواعد اتجاه الأيقونات والأصول تنتمي إلى مجموعة الأيقونات، لا إلى CSS
الاختصار الأكثر شيوعًا، والأكثر ضررًا ظاهريًا، هو قاعدة عامة تعكس كل عنصر SVG تحت [dir="rtl"]:
/* تعكس كل شيء، بما في ذلك الأيقونات التي يجب ألا تنعكس أبدًا */
[dir="rtl"] svg { transform: scaleX(-1); }
تُطبَّق هذه القاعدة بسرعة لأنها تُصلح كل سهم وكل رمز شيفرون في الواجهة بسطر واحد. لكنها تعكس أيضًا زر التشغيل، وعلامات الصح، وشكل الساعة، لأن محدد CSS لا يفهم معنى الأيقونة، بل يعرف فقط أنها SVG داخل حاوية RTL.
قرار انعكاس الأيقونة من عدمه هو قرار يتعلق بما تمثّله الأيقونة، ويجب أن يُتخذ مرة واحدة، من قِبل مصمم، ويُشفّر كبيانات وصفية (metadata) على الأيقونة نفسها بدلًا من استنتاجه من محدد في ورقة أنماط. المنطق هنا راسخ: إرشادات Material Design المؤرشفة حول ثنائية الاتجاه — ولا تزال أوضح تفصيل منشور رغم أنها تسبق Material 3 وموسومة بأنها مؤرشفة — تعكس كل ما يعبّر عن تسلسل في ترتيب القراءة، مثل أسهم الرجوع والتقدّم، ومسارات التنقّل (breadcrumbs)، والتصفح بالصفحات، ومؤشرات التقدّم، بينما تستثني صراحة الأيقونات التي يتحدد اتجاهها بما تمثّله لا بترتيب القراءة. وقد توصّل فريق هندسة سبوتيفاي إلى استنتاج مشابه بشكل مستقل أثناء إطلاق دعم اللغة العربية: يذكر مقالهم التقني أن أدوات التشغيل وشريط التقدّم "لا تحتاج إلى انعكاس لأنها تشير إلى اتجاه الشريط الذي يُشغَّل"، لا إلى اتجاه قراءة النص.
ابنِ هذا القرار كبيانات ضمن مجموعة الأيقونات، لا كقاعدة CSS تنطبق على كل SVG افتراضيًا:
{
"arrow-forward": { "mirrorRtl": true },
"chevron-right": { "mirrorRtl": true },
"play": { "mirrorRtl": false },
"clock": { "mirrorRtl": false },
"checkmark": { "mirrorRtl": false }
}
/* فعّل الانعكاس لكل أيقونة على حدة، بحسب البيانات أعلاه، لا عبر محدد عام أبدًا */
[dir="rtl"] [data-mirror-rtl="true"] {
transform: scaleX(-1);
}
هذا يمنحك أيضًا مكانًا لتوثيق الخلاف، لأن هذا العرف ليس موحّدًا حتى بين المنتجات التي تملك قاعدة مستخدمين عربية حقيقية. يشير دليل شديد إلى أن واتساب يعكس أيقونة الإرسال في RTL بينما لا تفعل تطبيقات فيسبوك وتويتر ذلك — فلا توجد إجابة واحدة صحيحة لكل رمز يشبه السهم، وهذا بالضبط سبب وجوب اتخاذ القرار صراحةً، مرة واحدة، في بيانات الأيقونات الخاصة بك، بدلًا من توريثه ضمنيًا مما يصادف أن يلتقطه تحويل CSS.
امنح المحتوى مختلط الاتجاه مكوّنًا واحدًا، لا مجرد عرف متعارف عليه
كل منتج يحتوي محتوى عربيًا سيعرض في النهاية اسم مستخدم أو سعرًا أو رقم طلب داخل جملة عربية، وخوارزمية اليونيكود ثنائية الاتجاه ستعيد ترتيب هذا المحتوى بشكل غير متوقع ما لم يُعزَل، لأن الأرقام والحروف اللاتينية "ضعيفة" أو "محايدة" بالنسبة إلى الحروف العربية القوية من اليمين إلى اليسار المحيطة بها. تملك HTML عنصرًا نصيًا مصممًا خصيصًا لهذه الحالة: <bdi>.
الحل، بحسب توثيق MDN لعنصر <bdi>، هو تغليف القيمة المُدرجة بحيث "لا يؤثر اتجاه النص المضمَّن في <bdi> في اتجاه النص المحيط به" والعكس صحيح. توثيق قاعدة "غلّف القيم المُدرجة بـ <bdi>" في دليل أسلوب هو إصلاح حقيقي، لكنه هش أيضًا، لأنه يعتمد على أن يتذكره كل مساهم في كل نص، إلى الأبد. النسخة البنيوية من هذا الحل هي إزالة الخيار تمامًا: ابنِ مكوّنًا نصيًا أساسيًا يعزل تلقائيًا، واشترط استخدامه — عبر أداة تدقيق أو مراجعة الشيفرة، لا التوثيق وحده — في أي مكان تحمل فيه جملة مترجمة متغيرًا.
function BidiText({ children }: { children: React.ReactNode }) {
return <bdi>{children}</bdi>;
}
// لا يقرر المستخدم أبدًا ما إذا كان سيعزل النص؛ المكوّن الأساسي يفعل ذلك مسبقًا
<p>{t('welcome_message', { username: <BidiText>{user.name}</BidiText> })}</p>
هذه هي الخطوة البنيوية نفسها التي رأيناها في تغييرات الرموز وتسمية الخصائص أعلاه: أخذ قرار كان يعيش سابقًا في ذهن كل مساهم على حدة، ونقله إلى مكوّن أساسي يأتي مع النظام نفسه، بحيث يصبح نسيانه مستحيلًا.
اختبر الاتجاه كما تختبر نقاط الكسر (breakpoints)
نظام تصميم لا يُختبر إلا بالإنجليزية سيراكم أخطاء RTL بالمعدل نفسه الذي يُصدر فيه مكوّنات جديدة، لأن لا شيء في مراجعة اعتيادية يلتقط margin-right لا يكسر تخطيطًا إلا لم يفحصه أحد بالعربية. ثلاثة تغييرات تجعل هذا الخطأ مرئيًا قبل أن يصل إلى الإنتاج بدل أن يُكتشف بعده.
افرض إزالة الخصائص الفيزيائية من الشيفرة الجديدة عبر أداة تدقيق. أداة stylelint-use-logical تفرض استخدام الخصائص والقيم المنطقية في CSS وتُبلّغ عن الخصائص الفيزيائية — مثل top وleft وmargin-left — كمخالفات، محوّلةً بذلك تذكيرًا في مراجعة الشيفرة إلى فشل في البناء. فعّلها على الشيفرة الجديدة للمكوّنات حتى لو تعذّر ترحيل قاعدة الشيفرة كاملة دفعة واحدة؛ فكل ملف تحميه هذه الأداة هو ملف لن يتراجع.
{
"plugins": ["stylelint-use-logical"],
"rules": {
"csstools/use-logical": "always"
}
}
اعرض كل قصة مكوّن (story) بالاتجاهين. إذا كان فريقك يستخدم Storybook أو Ladle أو أداة مشابهة لعرض المكوّنات، أضِف مفتاحًا عامًا لتبديل الاتجاه يعكس قيمة dir على جذر المعاينة ويعيد عرض القصة الحالية. هذا يحوّل سؤال "هل يبدو هذا صحيحًا بالعربية؟" من تدقيق يدوي يُجرى أحيانًا إلى فحص بنقرة واحدة يُجرى على كل مكوّن، في كل مرة.
ضع علامة على الفروقات (diffs) الأكثر خطورة لمراجعة إضافية. بنى فريق سبوتيفاي، أثناء تطوير دعمه للغة العربية، روبوتًا يُعلّم طلبات السحب التي تمسّ التباعد أو المحاذاة أو تحويلات CSS باعتبارها طلبات ينبغي فحصها بـ RTL قبل الدمج — لا كبوابة مانعة، بل كتذكير عند النقطة التي تظهر فيها عادةً أخطاء الخصائص الفيزيائية. الفئات الجديرة بوضع علامة عليها ضيقة وثابتة: أي تغيير يمسّ margin أو padding أو float أو text-align أو التموضع أو transform مرشّح لذلك؛ ومعظم التغييرات الأخرى ليست كذلك.
لا يلتقط أي من هذه التغييرات الثلاثة كل خطأ ممكن. لكنها مجتمعة تلتقط النمطين اللذين يتكرران فعليًا: خاصية فيزيائية أُعيد إدخالها في شيفرة جديدة، وتخطيط لم يفحصه أحد في الاتجاه الذي لم يُبنَ من أجله.
أخطاء شائعة
كتابة ورقة أنماط تجاوز لـ RTL بدل إزالة الحاجة إليها. ملف التجاوز يُصلح ما هو موجود يوم كتابته، ثم يتباعد عن كل مكوّن يُبنى بعده. أما الخصائص المنطقية في طبقة الرموز فلا تتباعد أبدًا، لأنه لا يوجد ملف ثانٍ يجب إبقاؤه متزامنًا.
تسمية الخصائص بحسب جهة الشاشة. iconLeft وalign="right" و.header__left جميعها تُشفّر افتراضًا لا بد أن يكون خاطئًا في أحد الاتجاهين. أما iconStart وalign="end" و.header__end فلا.
عكس كل SVG بمحدد واحد. يُصلح هذا عشرين سهمًا، ويكسر زر التشغيل وعلامات الصح وشعارك في التزام (commit) واحد، لأن تحويل CSS لا يستطيع قراءة معنى الأيقونة.
توثيق عزل النصوص ثنائية الاتجاه بدل بنائه داخل مكوّن. القاعدة التي تعيش فقط في دليل أسلوب تُنسى بالمعدل نفسه الذي يتبدّل فيه المساهمون. أما القاعدة المبنية داخل مكوّن أساسي مثل BidiText فلا يمكن تخطّيها بالخطأ.
اختبار RTL مرة واحدة فقط، عند الإطلاق. نظام يُختبر فقط عند إطلاق دعم العربية سيبدو صحيحًا في اليوم الأول وخاطئًا بحلول المكوّن العاشر الذي يُضاف بعده، لأن لا شيء التقط الانحرافات فيما بينهما.
كيف يبدو العمل الجيد
- رموز التباعد والموضع والمحاذاة منطقية (
inline-start/inline-end،block-start/block-end)، ولا يعتمد أي مكوّن على رمز فيزيائي مباشرة. - لا تحتوي أي خاصية مكوّن على كلمة "يسار" أو "يمين"؛ الخصائص الحسّاسة للاتجاه تستخدم
start/end. - انعكاس الأيقونة قيمة منطقية (boolean) مسجّلة لكل أيقونة ضمن بيانات مجموعة الأيقونات نفسها، لا قاعدة CSS تنطبق على كل SVG افتراضيًا.
- القيم المُدرجة داخل النصوص المترجمة تمرّ عبر مكوّن نصي أساسي يعزلها، بدلًا من الاعتماد على تذكّر كل مساهم لعنصر
<bdi>. - قاعدة في أداة تدقيق CSS تُفشل البناء عند ظهور خاصية فيزيائية جديدة، حتى إن لم تُرحَّل الشيفرة القديمة بعد.
- يُعرض كل مكوّن في بيئة عرض المكوّنات لديك (Storybook أو ما يعادله) بشكل صحيح بضغطة واحدة لتبديل الاتجاه، في كلا الاتجاهين، دون تدقيق يدوي.
معالجة هذا الأمر على مستوى النظام لا تُصلح العربية فحسب. إنها أيضًا ما يجعل الفحوصات على مستوى الصفحة في الدليل التشخيصي المرافق سريعة الإجراء ومملّة في اجتيازها — فسمة dir الصحيحة مسبقًا، وعزل النصوص ثنائية الاتجاه المبني مسبقًا داخل مكوّناتك النصية، والأيقونات التي حُسم أمرها عمدًا لا بالصدفة، هي بالضبط ما يبحث عنه تدقيق يوم الإطلاق.