<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>سجلّ البناء، بقلم محمد مغلس</title>
    <link>https://moghalis.com/ar/blog</link>
    <description>ما يعلّمني إياه بناء منتجات حقيقية: الأداء، والواجهات العربية، والبنية التحتية. كل مقال من عمل يعمل الآن، والأرقام فيه مقاسة لا مقدّرة.</description>
    <language>ar</language>
    <atom:link href="https://moghalis.com/ar/blog/rss.xml" rel="self" type="application/rss+xml" />
    <lastBuildDate>Mon, 19 Oct 2026 17:00:00 GMT</lastBuildDate>
    <item>
      <title>372 اختبارًا ناجحًا، ولا فاتورة بيع واحدة تصل إلى الدفتر</title>
      <link>https://moghalis.com/ar/blog/building-finyx-erp</link>
      <guid isPermaLink="true">https://moghalis.com/ar/blog/building-finyx-erp</guid>
      <pubDate>Mon, 19 Oct 2026 17:00:00 GMT</pubDate>
      <description>كيف أُبقي وحدي نظامًا محاسبيًا من 322 ألف سطر صحيحًا: بوابة حفظ واحدة، وقيود مالية لا تُعدَّل، وسجل تدقيق ببصمات متسلسلة، واختبارات على قواعد حقيقية، وتدقيق كشف ما فاتها.</description>
      <category>ERP</category>
      <category>.NET</category>
      <category>EF Core</category>
      <category>الاختبارات</category>
      <content:encoded><![CDATA[<p>في 24 سبتمبر 2026 جلست أدقّق نظامي المحاسبي بقاعدة واحدة: قراءة الكود ليست تشغيلًا للاختبار. نجح في الحزمة 372 اختبارًا من 373. وأول ما وجده التدقيق كان أسوأ أنواع العيوب، ذلك الذي لا يتعطل معه شيء. فبالإعدادات الافتراضية، لم يكن بوسع أي فاتورة بيع أن تصل إلى الدفتر.</p>
<p>أرصدة العملاء صحيحة. المخزون صحيح. درج النقد صحيح. وحده ميزان المراجعة كان يعرف: لا مبيعات فيه على الإطلاق، بينما تُرحَّل كل فاتورة شراء فورًا. على الورق، منشأة تشتري ولا تبيع.</p>
<p>FinyX-ERP نظام عربي للمحاسبة ونقاط البيع أصمّمه وأبنيه وأبيعه وحدي. في هذا المقال الآلية التي تُبقيه صحيحًا دون فريق، والسبب الدقيق الذي جعل 372 اختبارًا ناجحًا تعجز عن رؤية ذلك العيب. وحين تنتهي منه ستكون معك بوابة حفظ تنقلها إلى أي تطبيق EF Core، وسجل تدقيق يكتشف التعديل الذي يجري على ملف القاعدة مباشرة، وقائمة فحص للعيوب التي لا تراها اختباراتك بحكم تصميمها.</p>
<aside class="post-summary"><p class="callout-label">باختصار</p><ul>
<li>كل قاعدة تحمي المال تعيش في بوابة حفظ واحدة، ولا تعرض الشاشة إلا ما تقبله تلك البوابة.</li>
<li>السجلات المالية للإضافة فقط، وكل سجل تدقيق يحمل بصمة السجل الذي قبله، فيُكشف أي تعديل يجري من خارج التطبيق.</li>
<li>الاختبارات تعمل على قواعد بيانات حقيقية، ومع ذلك فاتها العيب، لأن شاشة احتفظت بنسختها الخاصة من قاعدة، والدالة المختبَرة لم يكن يستدعيها أحد.</li>
</ul>
</aside>
<div class="stat-grid"><div class="stat"><p class="stat-value" dir="ltr">322k</p><p class="stat-label">سطر برمجي دون الترحيلات المولَّدة</p></div><div class="stat"><p class="stat-value" dir="ltr">100</p><p class="stat-label">جدول في قاعدة البيانات</p></div><div class="stat"><p class="stat-value" dir="ltr">531</p><p class="stat-label">اختبار آلي اليوم</p></div><div class="stat"><p class="stat-value" dir="ltr">1</p><p class="stat-label">مطوّر</p></div></div>
<h2 id="القاعدة-التي-تعيش-في-شاشة-تنساها-الشاشة-التالية">القاعدة التي تعيش في شاشة تنساها الشاشة التالية</h2>
<p>بدأ FinyX برنامجًا محاسبيًا لويندوز. واليوم يضم المستودع نفسه كاشيرًا وبرنامج إدارة بـ WPF، وخادم ASP.NET Core لكل مطعم، ومتجر ويب بـ Next.js، وثلاثة تطبيقات جوال من كود Expo واحد. ولا يستطيع شخص واحد أن يتذكر كل قاعدة في كل هذه الأماكن، لذلك يجب ألا يعتمد التصميم على تذكّر أحد.</p>
<p>تطبيق سطح المكتب وخادم المطعم يترجمان ملفات المصدر نفسها في <code>Domain</code> و<code>Application</code> و<code>Infrastructure</code>. وكل خدمة أعمال تُسجَّل في دالة واحدة هي <code>AddFinyXBusinessServices()</code>، ويستدعيها المضيفان كلاهما. فالقاعدة التي تُكتب مرة تعمل في الاثنين.</p>
<p>هذا يحسم أين تُكتب القاعدة. أما السؤال الأصعب فهو أين تُفرض. وجوابي: في المكان الوحيد الذي يجب أن يمر به كل تغيير على البيانات.</p>
<h2 id="البوابة-التي-يعبرها-كل-حفظ">البوابة التي يعبرها كل حفظ</h2>
<p>يعامل FinyX كل حدث تجاري كمعاملة واحدة في قاعدة البيانات: الفاتورة وحركات مخزونها وقيدها المحاسبي تُحفظ معًا أو لا يُحفظ منها شيء. وكل حفظ يمر بوحدة العمل (unit of work)، ووحدة العمل ترفض كل ما يعيد كتابة التاريخ:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">public</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> async</span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994"> Task</span><span style="--shiki-light:#999999;--shiki-dark:#666666">&#x3C;</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">int</span><span style="--shiki-light:#999999;--shiki-dark:#666666">></span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> SaveChangesAsync</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">CancellationToken</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> ct</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> default</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">{</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">    EnsureAppendOnlyAccountingAndAuditRecords</span><span style="--shiki-light:#999999;--shiki-dark:#666666">();</span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD"> // القيود وسطورها والدفعات وسندات الصندوق والتدقيق: إضافة فقط</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">    EnsurePostedDocumentsAreImmutable</span><span style="--shiki-light:#999999;--shiki-dark:#666666">();</span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">         // الفاتورة المرحّلة تُصحَّح بمستند جديد</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    await</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> EnsureNoClosedFinancialPeriodWritesAsync</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">ct</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    var</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> audits</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> BusinessAuditCapture</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">Capture</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">_context</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> _operationId</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> _user</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">CurrentUserKey</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    await</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> using</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> var</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> tx</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> await</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> _context</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">Database</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">BeginTransactionAsync</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">ct</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    var</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> changed</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> await</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> _context</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">SaveChangesAsync</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">ct</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    var</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> records</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> audits</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">Select</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">audit</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> =></span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> audit</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">Materialize</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()).</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">ToArray</span><span style="--shiki-light:#999999;--shiki-dark:#666666">();</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">    AuditChain</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">Link</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">records</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> await</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> AuditChain</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">ReadTailHashAsync</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">_context</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> ct</span><span style="--shiki-light:#999999;--shiki-dark:#666666">));</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">    _context</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">BusinessAuditRecords</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">AddRange</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">records</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    await</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> _context</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">SaveChangesAsync</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">ct</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    await</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> tx</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">CommitAsync</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">ct</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">    return</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> changed</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span></code></pre></figure>
<p>هذا مختصر. فالدالة الحقيقية تنضم إلى معاملة خارجية إن وُجدت، ولا تطلب من الشاشات المفتوحة أن تعيد القراءة إلا بعد الالتزام (commit)، فلا تقرأ شاشةٌ كتابةً قد يُتراجع عنها.</p>
<p>ثلاثة فحوص، وثلاثة وعود.</p>
<p><strong>للإضافة فقط.</strong> سجلات التدقيق والقيود اليومية وسطورها والدفعات وسندات الصندوق لا تُعدَّل ولا تُحذف عبر البوابة أبدًا. الخطأ يُصحَّح بقيد عكسي، ويبقى الاثنان في السجل.</p>
<p><strong>المرحَّل ثابت.</strong> الفاتورة أو المرتجع أو المصروف لا يتغير بعد ترحيله. وتصحيحه يكون بمستند جديد.</p>
<p><strong>الفترة المقفلة تبقى مقفلة.</strong> فوق هذا الفحص تعليق يشرح لماذا يعيش هنا، ومعناه: صحة النظام لا يجوز أن تعتمد على أن تتذكر كل خدمة حالية أو قادمة أن تستدعيه. والفحص يقرأ التاريخ الأصلي للسطر المعدَّل وتاريخه الجديد معًا، فلا يُنقل قيد تاريخي إلى خارج فترة مقفلة ولا إلى داخلها. ويقفل كذلك كل ما يسبق نهاية فترة مقفلة، بما في ذلك تاريخ قبل أول فترة أو في فجوة بين فترتين. فالرصيد الافتتاحي التالي هو مجموع كل ما قبله، والسطر الذي يتسلل إلى فجوة يغيّر رقمًا وقّع عليه أحدهم من قبل.</p>
<figure class="post-figure dg dg-is-flow"><ol class="dg-flow"><li><strong>الرفض</strong><span>إعادة كتابة المال، والمستندات المرحّلة، والفترات المقفلة</span></li><li><strong>حفظ التغيير</strong><span>داخل معاملة واحدة</span></li><li><strong>ربط التدقيق</strong><span>كل سجل يحمل بصمة السجل الذي قبله</span></li><li><strong>الالتزام</strong><span>التغيير ودليله معًا، أو لا شيء</span></li></ol></figure>
<h3 id="حين-كانت-البوابة-أشدّ-مما-ينبغي">حين كانت البوابة أشدّ مما ينبغي</h3>
<p>قد تخطئ البوابة في الاتجاه المعاكس أيضًا، وبالصمت نفسه. أرشفة فاتورة البيع كانت تشترط أن تكون مرحّلة، ثم يرفض فحص المستندات المرحّلة الحفظ لأنها مرحّلة. فلم تنجح الأرشفة مرة واحدة. وعدّاد الطباعة كان يفشل بالطريقة نفسها بعد كل طباعة لفاتورة مرحّلة، فيبقى صفرًا، وتُقرأ الفاتورة «لم تُطبع قط».</p>
<p>الإصلاح قائمة سماح (allow-list) مغلقة. أربعة حقول أرشفة فقط يجوز أن تتغير على فاتورة مرحّلة:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">private</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> static</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> readonly</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> string</span><span style="--shiki-light:#999999;--shiki-dark:#666666">[]</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> SaleInvoiceFilingProperties</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">[</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    nameof</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">SaleInvoice</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">IsArchived</span><span style="--shiki-light:#999999;--shiki-dark:#666666">),</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    nameof</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">SaleInvoice</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">ArchivedAtUtc</span><span style="--shiki-light:#999999;--shiki-dark:#666666">),</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    nameof</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">SaleInvoice</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">ArchivedBy</span><span style="--shiki-light:#999999;--shiki-dark:#666666">),</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    nameof</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">SaleInvoice</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">PrintCount</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">];</span></span></code></pre></figure>
<p>وإن تغيّر في الحفظ نفسه حقل واحد خارج هذه القائمة، رُفض الحفظ كله. ومن يريد تعديل مبلغ مرحّل لن يجد بابه هنا.</p>
<aside class="callout callout-tip"><p class="callout-label">نصيحة</p><p>اكتب كل استثناء من قاعدة ثابتة قائمةَ سماح بالحقول، مغلقة افتراضيًا. أما قائمة المنع فتنفتح فيها ثغرة كلما أضاف أحدهم عمودًا.</p>
</aside>
<h2 id="سجل-تدقيق-يكتشف-من-عدّل-خلف-ظهره">سجل تدقيق يكتشف من عدّل خلف ظهره</h2>
<p>كان سجل التدقيق عندي يعد بشيء واحد: لا يمكن تعديله من داخل التطبيق. وهذا وعد ضعيف. فمن يفتح ملف SQLite بأداة قواعد بيانات يستطيع أن يحذف عملية بيع وسجل تدقيقها معًا، ولا يبقى ما يدل على شيء.</p>
<p>لذلك صار كل سجل تدقيق يحمل بصمة SHA-256 لمحتواه هو مع بصمة السجل الذي قبله. غيّر حرفًا واحدًا فتتوقف بصمته عن المطابقة. احذف سجلًا فيشير الذي بعده إلى بصمة لم تعد موجودة.</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">public</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> static</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> string</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> ComputeEntryHash</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">BusinessAuditRecord</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> record</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> string</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> previousHash</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">{</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    var</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> payload</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> string</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">Join</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">\u001F</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">            // فاصل لا تحويه أي قيمة</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">        record</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">OperationId</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">ToString</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">N</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">),</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">        record</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">OccurredAtUtc</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">ToString</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">yyyy-MM-ddTHH:mm:ss.fff</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> CultureInfo</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">InvariantCulture</span><span style="--shiki-light:#999999;--shiki-dark:#666666">),</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">        record</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">UserKey</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> record</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">Action</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> record</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">EntityType</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> record</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">EntityKey</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">        record</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">ChangedProperties</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> record</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">BeforeJson</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> record</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">AfterJson</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">        record</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">ApplicationVersion</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> record</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">MachineName</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">        previousHash</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">    return</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> Convert</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">ToHexString</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">SHA256</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">HashData</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">Encoding</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">UTF8</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">GetBytes</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">payload</span><span style="--shiki-light:#999999;--shiki-dark:#666666">))).</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">ToLowerInvariant</span><span style="--shiki-light:#999999;--shiki-dark:#666666">();</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span></code></pre></figure>
<p>اثنا عشر سطرًا، وفيها أربعة فخاخ.</p>
<p><strong>أوقات تعود مختلفة.</strong> يعمل FinyX على SQLite أو SQL Server أو PostgreSQL. وSQLite يعيد <code>DateTime</code> بلا <code>Kind</code>، وPostgreSQL يقرّبه إلى الميكروثانية. فلو بصمتَ القيمة كاملة لفشل التحقق من سجل كُتب على SQLite بمجرد انتقال البيانات إلى PostgreSQL. لذلك تأخذ البصمة الوقت بالميلي ثانية بلا لاحقة نوع، فهذه الدقة وحدها تعود سليمة من كل مزوّد.</p>
<p><strong>النص المبصوم صيغة تخزين.</strong> ترتيب الحقول والفاصل بينها ثابتان إلى الأبد. غيّر أحدهما فتصير كل بصمة كُتبت من قبل غير قابلة للتحقق، لذلك عامِل أي تغيير هناك معاملة ترحيل للبيانات.</p>
<p><strong>كاتبان وذيل واحد.</strong> على خادم مشترك قد يحفظ جهازا كاشير في اللحظة نفسها، فيربط كلاهما سجله بالسجل السابق نفسه. وهذا عمل سليم تمامًا. والفاحص الذي يشترط خطًا مستقيمًا واحدًا سيتهم العميل كل يوم، والإنذار الذي يرنّ كل يوم يُطفأ. لذلك يتجاهل التحقق الترتيب ويفحص ثلاثة أشياء: كل بصمة تطابق محتواها، وكل بصمة سابقة تشير إلى سجل موجود، وللسلسلة بداية واحدة فقط.</p>
<p><strong>الذيل بلا شاهد.</strong> احذف آخر سجل، فلن يشير إليه أحد بعده. والسلسلة وحدها لا ترى ذلك، لذلك يُحفظ عدد السجلات وآخر بصمة خارج قاعدة البيانات أيضًا. والسجل الذي قصُر يظهر بنقصان العدد.</p>
<p>والترتيب داخل البوابة مهم كذلك. تُلتقط قيم «قبل» قبل الحفظ الأول، ما دام EF Core يحتفظ بالقيم الأصلية. وتُقرأ قيم «بعد» والمفاتيح الأساسية الجديدة بعده. وتُقرأ بصمة الذيل داخل المعاملة نفسها، فالذيل الذي أربط به هو الذيل الذي يُلتزم به. ولأن الحفظين يتشاركان المعاملة، لا يُلتزم بتغيير تجاري دون دليله.</p>
<p>والاختبار يفعل تمامًا ما يفعله العابث: يكتب في ملف القاعدة بـ SQL خام، من خارج التطبيق:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">// تعديل سجل من خارج التطبيق: يُكتشف</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">ExecuteOutsideTheSystem</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">db</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">UPDATE BusinessAuditRecords SET UserKey = 'someone.else' WHERE Id = (SELECT MIN(Id) FROM BusinessAuditRecords);</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">Require</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">!</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">Verify</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">db</span><span style="--shiki-light:#999999;--shiki-dark:#666666">).</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">IsIntact</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">An edited audit record went unnoticed.</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">// إعادة القيمة: تعود السلسلة سليمة، فالفحص لا يحمل ضغينة</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">ExecuteOutsideTheSystem</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">db</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">UPDATE BusinessAuditRecords SET UserKey = 'audit.chain.tests' WHERE Id = (SELECT MIN(Id) FROM BusinessAuditRecords);</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">Require</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">Verify</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">db</span><span style="--shiki-light:#999999;--shiki-dark:#666666">).</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">IsIntact</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">Restoring the original value left the trail broken.</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">// حذف سجل من الوسط: يُكتشف</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">ExecuteOutsideTheSystem</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">db</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">DELETE FROM BusinessAuditRecords WHERE Id = (SELECT MIN(Id) + 1 FROM BusinessAuditRecords);</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">Require</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">!</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">Verify</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">db</span><span style="--shiki-light:#999999;--shiki-dark:#666666">).</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">IsIntact</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">A deleted audit record went unnoticed.</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span></code></pre></figure>
<p>وعلى PostgreSQL تحرس المشغّلات (triggers) أحد عشر جدولًا أيضًا، فيبقى القيد المرحّل وسجل التدقيق ثابتين حتى أمام من يملك أداة إدارة. واختبار آلي يقارن هذه القائمة بقائمة SQL Server، فلا يصير نقل عميل إلى خادم تخفيفًا للحماية.</p>
<h2 id="اختبارات-تفتح-قواعد-بيانات-حقيقية">اختبارات تفتح قواعد بيانات حقيقية</h2>
<p>لا يستعمل FinyX مكتبة xUnit، فاختباراته برنامج تنفيذي واحد. كل اختبار دالة اسمها جملة إنجليزية تقول الوعد الذي يحفظه، والاختبارات التي تلمس البيانات تعمل على قاعدة SQLite حقيقية في مجلد مؤقت خاص بها. ويطبع كل اختبار سطر <code>METRIC</code> بالأرقام التي أثبتها، ثم <code>PASS</code> أو <code>FAIL</code> أو <code>SKIP</code>:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">.\</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">AccountsSystem.Tests.exe</span></span>
<span class="line"><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">METRIC TRIAL_BALANCE_ROWS</span><span style="--shiki-light:#999999;--shiki-dark:#666666">=</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">3</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">BALANCED</span><span style="--shiki-light:#999999;--shiki-dark:#666666">=</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">TRUE</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">SHORTAGE</span><span style="--shiki-light:#999999;--shiki-dark:#666666">=</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">100.00</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">CASH</span><span style="--shiki-light:#999999;--shiki-dark:#666666">=</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">900.00</span></span>
<span class="line"><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">PASS TrialBalance_BalancesAndNamesWhoBearsTheCashShortage</span></span>
<span class="line"><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">METRIC AUDIT_CHAIN_EDIT_DETECTED</span><span style="--shiki-light:#999999;--shiki-dark:#666666">=</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">PASS</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">AUDIT_CHAIN_DELETE_DETECTED</span><span style="--shiki-light:#999999;--shiki-dark:#666666">=</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">PASS</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">CLEAN_TRAIL_NOT_ACCUSED</span><span style="--shiki-light:#999999;--shiki-dark:#666666">=</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">PASS</span></span>
<span class="line"><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">PASS AuditTrail_DetectsAnEditedOrDeletedRecordMadeOutsideTheSystem</span></span></code></pre></figure>
<p>سطر <code>METRIC</code> يحوّل الاختبار الأخضر إلى دليل، فتقرأ ما أثبته دون أن تفتح الكود. و<code>SKIP</code> موجود لسبب واحد: الاختبار الذي لا تسمح البيئة بتشغيله يُعلَن ويُعدّ، ولا يُحسب فشلًا أبدًا، لأن الأحمر الذي لا يعني شيئًا يعلّمك أن تتجاهل الأحمر. وسكربت الإصدار يشغّل حزمة سطح المكتب كاملة قبل أن ينشر أي شيء، ويتوقف إن فشل أي اختبار.</p>
<p>ومع كل ذلك، قالت الحزمة 372 من 373. وهذا ما لم تستطع أن تراه.</p>
<h2 id="ما-لم-تره-372-اختبارًا-ناجحًا">ما لم تره 372 اختبارًا ناجحًا</h2>
<p>شاشة المبيعات تعرض عدد الفواتير المنتظرة للترحيل، ومعه زر يرحّلها. وفي نموذج العرض (view model) كانت تلك القائمة تشترط أن تكون كل فاتورة معتمدة. والاعتماد سياسة اسمها <code class="code-long">SalesRequireApprovalBeforePosting</code>، وهي مطفأة عند الشحن. فبالإعدادات الافتراضية لم تكن أي فاتورة تصل إلى تلك القائمة، ولم يجد الزر ما يرحّله.</p>
<p>لماذا لم ينتبه أحد؟ لأن كل ما يراجعه الكاشير والمالك كل يوم كان صحيحًا. أرصدة العملاء والمخزون ودرج النقد تحسب المستندات غير المرحّلة عمدًا، لتبقى صحيحة قبل أن يرحّل المحاسب. وحدها التقارير التي تقرأ الدفتر نفسه، ميزان المراجعة وقائمة الدخل، كانت تخلو من كل المبيعات.</p>
<p>ولماذا لم تنتبه 372 اختبارًا؟ هنا الجزء الذي يستحق أن تسرقه. الخدمة كانت تملك الدالة الصحيحة من قبل: <code>GetUnpostedSaleInvoicesAsync</code>. كانت مختبَرة. ولم يكن أحد يستدعيها. الشاشة كانت تبني قائمتها بنفسها من الصفوف التي تعرضها في تلك اللحظة، وبنسختها الخاصة من القاعدة. فالاختبارات كانت تثبت صحة دالة لا يشغّلها المنتج أصلًا.</p>
<p>وللخطأ نفسه عَرَض ثانٍ. فلأن القائمة تأتي من الصفوف الظاهرة، كانت تتغير بتغيّر مرشّح التاريخ في الشاشة. وإذا اختير نطاق «اليوم»، لم يكن ممكنًا ترحيل فاتورة الأمس أصلًا.</p>
<figure class="post-figure dg dg-is-compare"><div class="dg-compare"><div class="dg-compare-head"><span>شاشة المبيعات (ما كان يعمل)</span><span>الخدمة (ما كان مختبَرًا)</span></div><div class="dg-compare-row"><span>الاعتماد مشروط دائمًا</span><span>الاعتماد مشروط حين تطلبه السياسة فقط</span></div><div class="dg-compare-row"><span>تُبنى من الصفوف المعروضة</span><span>تُقرأ من قاعدة البيانات</span></div><div class="dg-compare-row"><span>تتغير بمرشّح التاريخ</span><span>كل فاتورة غير مرحّلة</span></div><div class="dg-compare-row"><span>فارغة بالإعدادات الافتراضية</span><span>صحيحة، ولا أحد يستدعيها</span></div></div></figure>
<p>انتقلت القاعدة إلى الخدمة، والخدمة الآن تقرأ السياسة نفسها التي يقرؤها الترحيل، والشاشة تأخذ قائمتها من الخدمة:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">// AccountingJournalService.GetUnpostedSaleInvoicesAsync</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">var</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> requireApproval</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">await</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> _operationalPolicyService</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">GetAsync</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">ct</span><span style="--shiki-light:#999999;--shiki-dark:#666666">))</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    .</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">SalesRequireApprovalBeforePosting</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">if</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">requireApproval</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">{</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">    query</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> query</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">Where</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">invoice</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> =></span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> invoice</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">ApprovedBy</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> !=</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> null</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> &#x26;&#x26;</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> invoice</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">ApprovedBy</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> !=</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> ""</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span></code></pre></figure>
<p>ما تعرضه الشاشة للترحيل صار هو بالضبط ما تقبله الخدمة.</p>
<aside class="callout callout-warning"><p class="callout-label">انتبه</p><p>الدالة المختبَرة التي لا يستدعيها أحد اختبار ناجح لكود لا تشحنه. ابحث عن دوال الخدمات التي لا تستدعيها الواجهة، فكل واحدة منها قد تدلّك على شاشة تحتفظ بنسختها الخاصة من القاعدة.</p>
</aside>
<h3 id="زر-إلغاء-لا-يلمس-الدفتر-أبدًا">زر «إلغاء» لا يلمس الدفتر أبدًا</h3>
<p>ووجد التدقيق نفسه أن الضغط على «تعديل» لفاتورة مرحّلة كان يمحو قيدها أولًا ثم يفتح المحرر. فمن ضغط «إلغاء» بعدها أخرج الفاتورة من الدفاتر: الإيراد والضريبة والتكلفة والذمة تختفي، والبضاعة تبقى خارج المخزن، والفاتورة تبقى في القائمة. ولا رسالة واحدة.</p>
<p>الآن يُفتح المحرر أولًا. وإن أُغلق دون تأكيد، لا يُمسّ الدفتر إطلاقًا. ولا يُفكّ ترحيل الفاتورة إلا بعد التأكيد، قبيل تطبيق التعديل مباشرة، وأي فشل بعد ذلك يعيد الترحيل. وإن فشلت الإعادة نفسها، تظهر نافذة تسمّي الفاتورة وتقول إن قيدها غائب عن الدفتر.</p>
<p>كيف تختبر قرارًا يعيش خلف نافذة حوار، في حزمة لا شاشة فيها؟ تختبر ترتيبه في المصدر:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">var</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> body</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> MethodBody</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">File</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">ReadAllText</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">salesViewModelPath</span><span style="--shiki-light:#999999;--shiki-dark:#666666">),</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">private async Task EditSelectedInvoiceAsync()</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">var</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> dialogGuard</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> body</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">IndexOf</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">editor.ShowDialog() != true</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> StringComparison</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">Ordinal</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">var</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> unpost</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> body</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">IndexOf</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">UnpostSaleInvoiceAsync</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> StringComparison</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">Ordinal</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">Require</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">dialogGuard</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> >=</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">The editor is no longer opened before the invoice is amended.</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">Require</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">unpost</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> ></span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> dialogGuard</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">The ledger is stripped before the editor is confirmed.</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">Require</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">body</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">Contains</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">RestorePostingAfterAbandonedEditAsync</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">),</span></span>
<span class="line"><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">    "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">Nothing restores the entry when the amend fails after the invoice was unposted.</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span></code></pre></figure>
<p>طريقة بدائية، وهي تقيس بالضبط الشيء الذي انكسر.</p>
<h3 id="باب-الطوارئ-الذي-التفّ-حول-البوابة">باب الطوارئ الذي التفّ حول البوابة</h3>
<p>فكّ ترحيل فاتورة يتطلب حذف قيدها، والبوابة تمنع ذلك. لذلك كان لفكّ الترحيل والحذف الإداري طريق خاص بهما: SQL خام مع تعليق مشغّلات القاعدة. هذا الطريق لم يمر بوحدة العمل قط، فلم يلتقِ بفحص الفترة المقفلة. وكان ممكنًا عبره أن يُمحى قيد من شهر أُقفل.</p>
<p>الإصلاح يُجري فحص الفترة قبل فتح الاتصال، وقبل تعليق أي مشغّل. فالطلب الذي يمسّ فترة مقفلة يُرفض قبل أن يُلمس صف واحد.</p>
<p>كل طريق يلتفّ حول البوابة يجب أن يحمل فحوصها معه. وأسرع طريقة لتجد طرقك أنت: ابحث في الكود عن SQL خام.</p>
<h3 id="الاختبار-الأحمر-الذي-يتعلم-الجميع-تجاوزه">الاختبار الأحمر الذي يتعلم الجميع تجاوزه</h3>
<p>الاختبار الوحيد الفاشل من الـ 373 كان يفشل في كل تشغيل عادي. كان يحتاج قاعدة بيانات يجهّزها سكربت منفصل مسبقًا، فهو أحمر دائمًا عند كل من سواه. والفشل الدائم في قائمة خضراء يعلّم قارئها أن يتجاوزه، فيتجاوز معه أول فشل حقيقي. الآن يبني الاختبار قاعدته بنفسه في مجلد مؤقت جديد ويحذفه بعد انتهائه. وبعد الإصلاحات نجحت الحزمة 376 من 376.</p>
<p>وبعد أقل من شهر صار لسطح المكتب 475 اختبارًا.</p>
<figure class="post-figure dg dg-is-bars"><div class="dg-bars"><div class="dg-bar-row"><span class="dg-bar-label">سطح المكتب (WPF)</span><span class="dg-bar-track"><span class="dg-bar" style="--v:1.000"></span></span><span class="dg-bar-value" dir="ltr">475</span></div><div class="dg-bar-row"><span class="dg-bar-label">الخادم (ASP.NET Core)</span><span class="dg-bar-track"><span class="dg-bar" style="--v:0.063"></span></span><span class="dg-bar-value" dir="ltr">30</span></div><div class="dg-bar-row"><span class="dg-bar-label">الويب</span><span class="dg-bar-track"><span class="dg-bar" style="--v:0.034"></span></span><span class="dg-bar-value" dir="ltr">16</span></div><div class="dg-bar-row"><span class="dg-bar-label">مكتبة التكاملات</span><span class="dg-bar-track"><span class="dg-bar" style="--v:0.021"></span></span><span class="dg-bar-value" dir="ltr">10</span></div></div></figure>
<h2 id="قائمة-الفحص-التي-أعمل-بها">قائمة الفحص التي أعمل بها</h2>
<ul>
<li>ضع كل قاعدة ثابتة تحمي المال في مسار الحفظ. للشاشة أن تنبّه مبكرًا، ومسار الحفظ هو من يقرر.</li>
<li>لا تعرض الشاشة إلا ما تقبله الخدمة. اقرأ القائمة من الخدمة، وليس من الصفوف المعروضة.</li>
<li>اجعل السجلات المالية للإضافة فقط، وصحّحها بمستندات عكسية.</li>
<li>في فحص الفترات، افحص التاريخ الأصلي والتاريخ الجديد معًا، واقفل الفجوات التي تسبق نهاية فترة مقفلة.</li>
<li>اكتب استثناءات القواعد الثابتة قوائمَ سماح مغلقة بالحقول.</li>
<li>اربط سجلات التدقيق بسلسلة بصمات: ترتيب ثابت للحقول، وفاصل لا تحويه أي قيمة، ودقة وقت تعيدها كل قاعدة بيانات، وتحقق يتسامح مع الكتّاب المتزامنين، وشاهد على الذيل خارج القاعدة.</li>
<li>في الاختبارات، اكتب في ملف القاعدة من خارج التطبيق، وأثبت أنك تلاحظ.</li>
<li>ابحث عن SQL خام. كل طريق يلتف حول البوابة يجب أن يكرر فحوصها.</li>
<li>ابحث عن دوال خدمات مختبَرة لا يستدعيها شيء.</li>
<li>لا تقبل اختبارًا أحمر دائمًا. أعلن التخطي بصوت عالٍ، وعُدّ مرات التخطي.</li>
<li>دقّق بقاعدة واحدة: قراءة الكود ليست تشغيلًا للاختبار، ونجاح البناء لا يثبت شيئًا عن صحة المحاسبة.</li>
</ul>
<p>لو بدأت FinyX من جديد غدًا لكتبت بوابة الحفظ قبل أول شاشة. فأولى ملاحظات التدقيق كانت قاعدتين تعيشان في شاشة، وطريق SQL خام يلتف حول البوابة. ضع القاعدة حيث تتغير البيانات، ثم اكتب الاختبار الذي يثبت أن الشاشة تستعملها.</p>
<p>وهو الانضباط نفسه الذي أبني به <a href="https://moghalis.com/ar/services/business-systems">أنظمة الأعمال</a> لعملائي.</p>
]]></content:encoded>
    </item>
    <item>
      <title>إيصال عربي على طابعة حرارية، وخمسة بايتات التهمت الرول كله</title>
      <link>https://moghalis.com/ar/blog/arabic-thermal-receipts</link>
      <guid isPermaLink="true">https://moghalis.com/ar/blog/arabic-thermal-receipts</guid>
      <pubDate>Mon, 19 Oct 2026 16:00:00 GMT</pubDate>
      <description>لماذا تُفسد طابعات ESC/POS الحروف العربية، وكيف تطبعها صورةً أحادية البت بعرض الرأس الحقيقي، وخلل الأبعاد الذي حوّل إيصالًا طوله 12 سم إلى رولٍ كامل ملقى على الأرض.</description>
      <category>ESC/POS</category>
      <category>العربية</category>
      <category>RTL</category>
      <category>C#</category>
      <category>الطباعة</category>
      <content:encoded><![CDATA[<p>خرج الإيصال. ثم لم يتوقف الورق.</p>
<p>كانت طابعة Xprinter XP-370B على جهاز كاشير يعمل فعلًا، فيها رول بعرض 76 مم، وظلّت تُخرج الورق حتى انتهى الرول. وحين قستُ المهمة لاحقًا دون أن أطبعها، وجدتها تطلب 124.7 مم لا أكثر. المهمة كان لها حدّ، والطابعة لم يكن لها حدّ.</p>
<p>والسبب خمسة بايتات. كل صف في صورة الإيصال كان بعرض 72 بايتًا، على رأس لا يطبع إلا 67.</p>
<p>يطبع FinyX-ERP كل إيصالاته بالعربية، وفي أسفلها رمز QR، على طابعات بدقة 203 نقطة في البوصة، ثمنها قليل ولا تغفر شيئًا. ولا يرسل إليها حرفًا عربيًا واحدًا. وحين تنتهي من هذا المقال ستستطيع أن تطبع عربية سليمة على أي طابعة ESC/POS، بسقف صارم لما تستهلكه من ورق، وستعرف بالضبط كيف تحوّل خمسةُ بايتات البكسلاتِ إلى أوامر.</p>
<aside class="post-summary"><p class="callout-label">باختصار</p><ul>
<li>شكّل العربية على الحاسوب، وأرسل إلى الطابعة صورة أحادية البت. خطوطها المدمجة وجداول ترميزها (code pages) لا تصل الحروف ولا ترتّب السطر من اليمين إلى اليسار.</li>
<li>ركّب الصفحة بالعرض الذي يطبعه الرأس فعلًا، بالنقاط. تصغير صورة أحادية البت بعد تركيبها هو ما يجعل العربية مشعّثة.</li>
<li>اجعل كل صف في الصورة بعرض منطقة الطباعة التي تعلنها بـ <code>GS W</code> تمامًا. الصف الأعرض يُفقد البرنامج الثابت موضعه، فيبدأ بتنفيذ بكسلاتك.</li>
</ul>
</aside>
<h2 id="لماذا-لا-تعرف-الطابعة-كيف-تكتب-العربية">لماذا لا تعرف الطابعة كيف تكتب العربية</h2>
<p>الوضع النصي في طابعة الإيصالات صُمّم للحرف اللاتيني: خطوط محفوظة في ذاكرتها، وجداول ترميز تقابل كل بايت بشكل واحد. والعربية تطلب أكثر من ذلك بكثير. الحرف الواحد له حتى أربعة أشكال بحسب جيرانه، واللام والألف يندمجان في شكل واحد، والسطر يجري من اليمين إلى اليسار بينما الأرقام داخله تجري من اليسار إلى اليمين. كتبت عن قواعد الاتصال في <a href="https://moghalis.com/ar/blog/arabic-text-effects">لماذا تكسر تأثيرات النص حرفًا بحرف العربية</a>. والوضع النصي لا يفعل شيئًا من هذا.</p>
<p>وفي الواقع، جداول الترميز في البرنامج الثابت للطابعة تفصل الحروف، أو تقلب ترتيبها، أو تضع مكانها أشكالًا لا علاقة لها بالعربية، وقد يختلف سلوك جهازين من الطراز نفسه. ولا أستطيع أن أدعم سلوكًا لا أستطيع توقّعه.</p>
<p>لذلك لا تصل العربية في FinyX-ERP إلى أي طابعة نصًّا. الحاسوب يشكّلها بمحرك نصوص حقيقي، والإيصال كله يصير صورة بالأبيض والأسود، والطابعة لا تستقبل إلا صورًا نقطية. وملصقات الباركود على القاعدة نفسها: في طابعات الملصقات بلغة TSPL، يُرسل اسم الصنف العربي صورةً أيضًا.</p>
<div class="stat-grid"><div class="stat"><p class="stat-value" dir="ltr">203</p><p class="stat-label">نقطة في البوصة، وبتٌّ واحد لكل نقطة</p></div><div class="stat"><p class="stat-value" dir="ltr">536</p><p class="stat-label">نقطة يطبعها الرأس فعلًا على ورق 76 مم</p></div><div class="stat"><p class="stat-value" dir="ltr">27 mm</p><p class="stat-label">تغذية بعد آخر سطر، تُرسل 216 نقطة</p></div><div class="stat"><p class="stat-value" dir="ltr">800 mm</p><p class="stat-label">أقصى ورق يستهلكه إيصال واحد</p></div></div>
<p>الصور تحلّ مشكلة العربية حلًّا كاملًا. لكنها تحمل الخطر الذي تدور حوله هذه القصة، فالصورة عند الطابعة ليست إلا بايتات إضافية في سيل الأوامر.</p>
<h2 id="خمس-خطوات-من-البيع-إلى-النقاط-المحروقة">خمس خطوات من البيع إلى النقاط المحروقة</h2>
<p>يركّب QuestPDF الإيصال من اليمين إلى اليسار على صفحة متصلة بلا ارتفاع ثابت، بعرض منطقة الطباعة بالضبط. ثم تُرسم الصفحة بدقة 203 نقطة في البوصة، فيصير كل بكسل نقطة من نقاط الطابعة، ومن هناك إلى آخر الطريق لا شيء إلا البتّات.</p>
<figure class="post-figure dg dg-is-flow"><ol class="dg-flow is-long"><li><strong>تخطيط الإيصال</strong><span>صفحة QuestPDF من اليمين إلى اليسار بعرض 536 بكسل</span></li><li><strong>التحويل إلى صورة</strong><span>PNG بدقة 203 DPI: كل بكسل نقطة طباعة</span></li><li><strong>الفصل إلى أبيض وأسود</strong><span>الحبر تحت 180، والشفافية تُركَّب على الأبيض</span></li><li><strong>الترميز</strong><span>شرائح صورية بأوامر ESC/POS، كل شريحة 128 صفًا</span></li><li><strong>الإرسال الخام</strong><span>عبر طابور الطباعة في ويندوز، بلا إعادة محاولة</span></li></ol></figure>
<p>المُرمِّز قصير، وثلاثة أسطر فيه تقوم بالعمل كله:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">// معادلة Rec. 601 للسطوع، بأعداد صحيحة.</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">var</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> luminance</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">77</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> *</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> red</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> +</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 150</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> *</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> green</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> +</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 29</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> *</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> blue</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> >></span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 8</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">// يُركَّب البكسل الشفاف على أبيض أولًا. بدون ذلك تُقرأ الخلفية غير المرسومة</span></span>
<span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">// سوداء، فيخرج الإيصال مستطيلًا أسود.</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">luminance</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">luminance</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> *</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> alpha</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> +</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 255</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> *</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">255</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> -</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> alpha</span><span style="--shiki-light:#999999;--shiki-dark:#666666">))</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> /</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 255</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">if</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">luminance</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> &#x3C;</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> InkThreshold</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">   // 180</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">    raster</span><span style="--shiki-light:#999999;--shiki-dark:#666666">[</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">y</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> *</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> rowBytes</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> +</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> x</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> /</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 8</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> |=</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">byte</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)(</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">0x80</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> >></span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">x</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> %</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 8</span><span style="--shiki-light:#999999;--shiki-dark:#666666">));</span></span></code></pre></figure>
<p>كل نقطة بتّ واحد، وأقصى نقطة يسارًا في البايت هي أعلى بتّاته، فالنقطة <code>x</code> تقع في البايت <code>x / 8</code> تحت القناع <code>0x80 &gt;&gt; (x % 8)</code>. والصف الذي عرضه 536 نقطة يساوي 67 بايتًا. احفظ هذا الرقم.</p>
<p>الشرائح تخرج بأمر <code>GS v 0</code>، كل شريحة 128 صفًا. هذا الأمر يقدّم الورق بارتفاع الصورة بالضبط، فتلتقي الشرائح بلا خط فاصل، بينما سطر جديد بينها يفتح شريطًا أبيض. وما تحت آخر صف فيه حبر يُحذف إلا هامشًا نحو 2 مم، فلا يخرج بعد الإيصال شبرٌ من الورق الأبيض.</p>
<p>والإرسال عبر winspool مباشرة بنوع بيانات <code>RAW</code>، على أجزاء من 64 كيلوبايت. وتعريف ويندوز المثبّت لهذه الطابعة تعريفُ ملصقات، وRAW يمنعه من تحويل أوامر ESC/POS إلى لغته TSPL. وإن فشل شيء في المنتصف، يستدعي الكود <code>AbortPrinter</code> ولا يعيد المحاولة أبدًا: مهمة ناقصة غامضة تُرسل مرتين قد تكرّر الإيصال أو تُفرغ الرول.</p>
<h2 id="سبعة-أوامر-بكل-بايتاتها">سبعة أوامر، بكل بايتاتها</h2>
<p>لا يحتاج الإيصال إلى أكثر من سبعة أوامر. هذه هي ببايتاتها الدقيقة لورق 76 مم على XP-370B، والأعداد ذات البايتين بترتيب little-endian.</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>الأمر</th>
<th>البايتات</th>
<th>ما يفعله</th>
</tr>
</thead>
<tbody><tr>
<td><code>ESC @</code></td>
<td><code>1B 40</code></td>
<td>يمسح ما تركته المهمة السابقة</td>
</tr>
<tr>
<td><code>GS L</code></td>
<td><code>1D 4C 18 00</code></td>
<td>إزاحة يسرى 24 نقطة</td>
</tr>
<tr>
<td><code>GS W</code></td>
<td><code>1D 57 18 02</code></td>
<td>منطقة طباعة 536 نقطة (0x0218)</td>
</tr>
<tr>
<td><code>GS v 0</code></td>
<td><code>1D 76 30 00 43 00 80 00</code></td>
<td>شريحة عرضها 67 بايتًا وارتفاعها 128 صفًا، تتبعها بتّاتها</td>
</tr>
<tr>
<td><code>ESC J</code></td>
<td><code>1B 4A D8</code></td>
<td>تغذية 216 نقطة، أي 27 مم</td>
</tr>
<tr>
<td><code>GS V</code></td>
<td><code>1D 56 42 18</code></td>
<td>تغذية حتى القاطع ثم قطع جزئي واحد، في الطابعات التي لها قاطع</td>
</tr>
<tr>
<td><code>ESC p</code></td>
<td><code>1B 70 00 19 19</code></td>
<td>نبضة لدرج النقود (m = 0، الطرف 2 في الموصل)، خمسون ملّي ثانية تشغيلًا ومثلها إطفاءً</td>
</tr>
</tbody></table></div>
<p>انظر إلى سطر <code>GS W</code>. الرقم 536 أهم رقم في هذا المقال، ولم أختره أنا.</p>
<h2 id="الورقة-أعرض-من-الطباعة">الورقة أعرض من الطباعة</h2>
<p>عند 203 نقطة في البوصة، المليمتر نحو ثماني نقاط، لكن عند 203 وحدها. لذلك تتحوّل المليمترات إلى نقاط في دالة واحدة فقط، بدقة الطابعة نفسها، مع تقريب النصف بعيدًا عن الصفر حتى لا تنكمش وحدة الباركود.</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>الورق</th>
<th>منطقة الطباعة</th>
<th>إزاحة الرأس</th>
</tr>
</thead>
<tbody><tr>
<td>58 مم</td>
<td>384 نقطة (48 مم)</td>
<td>0</td>
</tr>
<tr>
<td>80 مم</td>
<td>576 نقطة (72 مم)</td>
<td>0</td>
</tr>
<tr>
<td>76 مم، على طابعة XP-370B التي جرّبت عليها</td>
<td>536 نقطة (67 مم)</td>
<td>24 نقطة</td>
</tr>
</tbody></table></div>
<p>الأولان ملفّان عامّان. أما أرقام XP-370B فمقيسة: عرض الورق 607 نقاط، والرأس يطبع 536 منها، وأول 24 نقطة لا يصلها ميكانيكيًا. واستعمال العرض كله كان يقصّ العمود الأيمن. وفي الإيصال العربي، الحافة اليمنى هي حيث يبدأ كل سطر، فهي أسوأ حافة يمكن أن تخسرها.</p>
<figure class="post-figure dg dg-is-bars"><div class="dg-bars"><div class="dg-bar-row"><span class="dg-bar-label">ورق 76 مم</span><span class="dg-bar-track"><span class="dg-bar" style="--v:1.000"></span></span><span class="dg-bar-value" dir="ltr">607نقطة</span></div><div class="dg-bar-row"><span class="dg-bar-label">ما يستطيع الرأس طباعته عليه</span><span class="dg-bar-track"><span class="dg-bar" style="--v:0.883"></span></span><span class="dg-bar-value" dir="ltr">536نقطة</span></div><div class="dg-bar-row"><span class="dg-bar-label">ورق 80 مم، المساحة القابلة للطباعة</span><span class="dg-bar-track"><span class="dg-bar" style="--v:0.949"></span></span><span class="dg-bar-value" dir="ltr">576نقطة</span></div><div class="dg-bar-row"><span class="dg-bar-label">ورق 58 مم، المساحة القابلة للطباعة</span><span class="dg-bar-track"><span class="dg-bar" style="--v:0.633"></span></span><span class="dg-bar-value" dir="ltr">384نقطة</span></div></div></figure>
<p>وفي الإعدادات خمس مناطق طباعة (384 و512 و536 و576 و608 نقطة)، والافتراضي 536 المحافظ. المنطقة الأضيق تُطبع دائمًا. الأعرض هي ما يكسر الأمور، وسترى بعد قليل إلى أي حد.</p>
<p>وأكبر تحسّن في الجودة جاء من قرار واحد. كانت إيصالاتي الأولى تُركَّب بعرض 80 مم، ثم يصغّرها المُرمِّز إلى 536 نقطة، بمعامل 0.84. وتصغير صورة أحادية البت هو ما يجعل العربية مشعّثة: الخطّ المصغَّر يقع بين النقاط، فتضطر العتبة إلى التخمين. الآن تُركَّب الصفحة بعرض منطقة الطباعة نفسها:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">// 536 نقطة عند 203 = 67.07 مم. كل بكسل في الصفحة نقطةٌ مطبوعة.</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">internal</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> static</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> float</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> PageWidthMm</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">DocumentPrintSettings</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> settings</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> =></span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    (</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">float</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">settings</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">ReceiptPrintAreaDots</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> *</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 25.4d</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> /</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> RasterDpi</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span></code></pre></figure>
<aside class="callout callout-tip"><p class="callout-label">نصيحة</p><p>منطقة الطباعة صفةٌ للطابعة وللورق الذي فيها. قِسها على الجهاز الحقيقي، واحفظها لكل طابعة، وأرسلها مع كل مهمة.</p>
</aside>
<p>هذا الرقم 536 جاء من رولٍ ملقى على الأرض، وسنصل إليه. لكن العربية نفسها تحتاج قواعد أولًا، فبتٌّ واحد لكل نقطة وسطٌ قاسٍ لا يرحم.</p>
<h2 id="كيف-تنجو-العربية-ببتٍّ-واحد">كيف تنجو العربية ببتٍّ واحد</h2>
<p>رأس الطباعة عند 203 لا يعرف الرمادي. كل نقطة إما تحترق أو لا، والخط الأرقّ من نقطة لا يُطبع أصلًا. ومن هنا جاءت أربع قواعد.</p>
<p><strong>اختر الخط بالتجربة.</strong> رسمت الفاتورة نفسها بخط Ping AR + LT وبخط Cairo بالمقاس الحقيقي، ومرّرتهما على العتبة نفسها، ثم قارنت. خط Ping أضيق وأكثر انتظامًا، فيتّسع السطر لاسم صنف أطول، وتبقى سيقانه بعرض نقطة كاملة عند 7.5 نقطة. أما Cairo فيبدأ بالتفتّت عند هذه المقاسات. فصار Ping الخط الافتراضي للقوالب الحرارية.</p>
<p><strong>وزن Medium للمتن.</strong> عند 8 نقاط، وصلات الوزن العادي المصمَّم للشاشات المضيئة تقلّ عن نقطة واحدة فتنكسر، ويبدو الحرف ممزّقًا.</p>
<p><strong>أرضية 6.5 نقطة.</strong> هذا نحو 18 نقطة طباعة ارتفاعًا. وما دونها تبدأ النقاط التي تفرّق الباء عن التاء عن الثاء بالذوبان في جسم الحرف.</p>
<p><strong>لا ألوان ولا رمادي.</strong> كل شيء ينهار إلى أبيض أو أسود عند العتبة، فالشريط المظلَّل الفاتح إما لا يُطبع، وإما يصير كتلة سوداء فوق نصّه. واسم المستند نصٌّ أبيض على شريط أسود مصمت.</p>
<p>المتن 8 نقاط، والعناوين الصغيرة 7.5، والإجمالي المستحق 13. ومقاسات بهذا الصغر لا تنجح إلا لأن شيئًا لا يُصغَّر في طريقه إلى الرأس.</p>
<h3 id="الأرقام-والاتجاه">الأرقام والاتجاه</h3>
<p>مع ثقافة عربية، يحوّل محرك النصوص الأرقام إلى الهندية، فيُطبع <code>76,400.00</code> هكذا: <code>٧٦٬٤٠٠٫٠٠</code>. هذا مقروء، لكن الزبون يقارن السعر بملصق الرف، والمورّد يقرأ أرقام الباركود، وكلاهما يحتاج أرقامًا غربية. لذلك الأرقام الغربية هي الافتراضي، والهندية خيار في الإعدادات. وفي مُصيِّر WPF الذي يرسم الملصقات، المفتاح كائن واحد:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">_numbers</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> new</span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994"> NumberSubstitution</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">    NumberCultureSource</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">Override</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">    CultureInfo</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">GetCultureInfo</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">arabicIndicDigits</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ?</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">ar-SA</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> :</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">en-US</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">),</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">    arabicIndicDigits</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ?</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> NumberSubstitutionMethod</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">NativeNational</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> :</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> NumberSubstitutionMethod</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">European</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span></code></pre></figure>
<p>والتاريخ مع الوقت، مثل <code>2026/09/08 14:35</code>، فيه مقطعان لاتينيان. داخل فقرة عربية يُرتَّبان من اليمين إلى اليسار، فيصل الوقت إلى الورق قبل التاريخ. لذلك توضع هذه القيم داخل عازل اتجاه من اليسار إلى اليمين (isolate):</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">// U+2066 LEFT-TO-RIGHT ISOLATE … U+2069 POP DIRECTIONAL ISOLATE</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">internal</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> static</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> string</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> Ltr</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">string</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> value</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> =></span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">    string</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">IsNullOrEmpty</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">value</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ?</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> value</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> :</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> $"</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">⁦</span><span style="--shiki-light:#999999;--shiki-dark:#666666">{</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">value</span><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">⁩</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span></code></pre></figure>
<p>العازل يثبّت ترتيب الحروف ويترك موضعها لتدفّق السطر العربي. أما الحاوية ذات الاتجاه اليساري فتدفع القيمة أيضًا إلى الحافة اليسرى من خليتها.</p>
<p>ولا تضع نصًّا عربيًا داخل العازل أبدًا: هناك تنقلب حروف رمز الريال اليمني «ر.ي». لذلك تُطبع العملة برمزها الدولي (<code>YER</code> و<code>SAR</code>)، مرةً واحدة، على الإجمالي المستحق. وخطآن من العائلة نفسها وصلا إلى الورق: سطر الدفع كان يبني نصّه دون عازل فطُبع <code>YER 75,000.00</code> مقلوبًا، والرصيد السالب طُبع <code>50,000.00-</code> لأن إشارة السالب أُضيفت خارج العازل. وكلاهما يمرّ الآن على منسّق واحد.</p>
<p>مشكَّلة ومقيسة ومرتّبة، صارت الإيصالات أخيرًا كما ينبغي. ثم طبع كاشير يعمل فعلًا واحدًا منها، ولم يتوقف.</p>
<h2 id="الرول-الملقى-على-الأرض">الرول الملقى على الأرض</h2>
<p>بدت الفرضية الأولى متماسكة. التعريف يعلن وسائط بعرض 38.1 مم والإيصال بعرض 76 مم، فبدا أن الطابعة عالقة في وضع الملصقات، تبحث عن فاصل لا وجود له في الورق المتصل.</p>
<p>أسقطتُها بسكربت PowerShell. أرسل ESC/POS خامًا إلى الطابعة نفسها بالإعدادات نفسها، فطبع نسخة واحدة سليمة وتوقف. ومهمة RAW تتجاوز التعريف كليًا، فمقاس الوسائط فيه لا يمكن أن يكون السبب. الفرق إذن في البايتات.</p>
<p>فقست المهمة دون أن أرسلها. أداة تشخيص تشغّل المركِّب والمُرمِّز الحقيقيين، وتكتب سيل الأوامر بدل الورق:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span>QuestPDF: 1 page, 639 × 1037 px = 129.8 mm</span></span>
<span class="line"><span>ESC @ · ESC a 1 · GS L 0 · GS W 576 · 8 × GS v 0 (72 bytes × 128 rows) · ESC d 3</span></span>
<span class="line"><span>paper requested: 124.7 mm</span></span></code></pre></figure>
<p>اثنا عشر سنتيمترًا، ولا بايت واحد زائد. والذي اختلف عن السكربت الناجح هو الأبعاد:</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th></th>
<th>السكربت الذي يعمل</th>
<th>FinyX-ERP قبل الإصلاح</th>
</tr>
</thead>
<tbody><tr>
<td><code>GS L</code>، الإزاحة اليسرى</td>
<td>24 نقطة</td>
<td>0</td>
</tr>
<tr>
<td><code>GS W</code>، منطقة الطباعة</td>
<td>536 نقطة</td>
<td>576</td>
</tr>
<tr>
<td>صف الصورة</td>
<td>67 بايتًا = 536 نقطة</td>
<td>72 بايتًا = 576 نقطة</td>
</tr>
<tr>
<td>المحاذاة</td>
<td>لا شيء</td>
<td><code>ESC a 1</code></td>
</tr>
<tr>
<td>النهاية</td>
<td><code>ESC J 240</code>، تغذية بالنقاط</td>
<td><code>ESC d 3</code>، تغذية بالأسطر</td>
</tr>
</tbody></table></div>
<p>هذه هي البايتات الخمسة. 576 نقطة بإزاحة صفر أعرض مما يطبعه هذا الرأس على ورق 76 مم. وحين يستقبل البرنامج الثابت صفوفًا أعرض من منطقة طباعته، يعيد المزامنة في منتصف الصورة، ومن تلك اللحظة يقرأ بايتات الصورة على أنها أوامر. وبيانات الصورة مليئة ببايتات معناها «غذِّ الورق». هكذا يصير إيصال طوله 12 سم رولًا على الأرض.</p>
<p>و<code>ESC d</code> زاد الطين بلّة: يغذّي بالأسطر، وبعض البرامج الثابتة القادرة على الملصقات تقرؤه طلبًا للانتقال إلى النموذج التالي، والورق المتصل لا نموذج تاليًا فيه.</p>
<figure class="post-figure dg dg-is-compare"><div class="dg-compare"><div class="dg-compare-head"><span>قبل</span><span>بعد</span></div><div class="dg-compare-row"><span>GS L 0 و GS W 576</span><span>GS L 24 و GS W 536</span></div><div class="dg-compare-row"><span>72 بايتًا لكل صف صورة</span><span>67 بايتًا لكل صف صورة</span></div><div class="dg-compare-row"><span>صفوف أعرض من مساحة الطباعة</span><span>صفوف بعرض مساحة الطباعة تمامًا</span></div><div class="dg-compare-row"><span>البرنامج الثابت يفقد التزامن ويقرأ بايتات الصورة أوامر تغذية</span><span>الإيصال ينتهي عند 134.9 مم</span></div></div></figure>
<p>الإصلاح ثلاثة أجزاء:</p>
<ul>
<li>كل صف في الصورة بعرض ما يعلنه <code>GS W</code> تمامًا (<code>rowBytes = printAreaDots / 8</code>)، وما يتجاوزه يُقصّ. فلا يمكن أن يختلف المُرمِّز والطابعة على عدد البايتات، وفحصٌ يشترط <code>rowBytes × 8 == width</code> بالضبط.</li>
<li><code>ESC J</code> بالنقاط مكان <code>ESC d</code>، مقسّمًا على خطوات لا تتجاوز 255 لأنه يأخذ بايتًا واحدًا.</li>
<li>حُذف <code>ESC a 1</code>. الصورة تملأ المنطقة كلها، فلم تكن المحاذاة إلا طريقًا آخر لاختلاف الطرفين.</li>
</ul>
<p>بعد الإصلاح صار السيل مطابقًا للسكربت الناجح، أمرًا بأمر:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span>ESC @ · GS L 24 · GS W 536 · 7 × GS v 0 (67 bytes × 128 rows) · ESC J 216</span></span>
<span class="line"><span>paper requested: 134.9 mm</span></span></code></pre></figure>
<aside class="callout callout-warning"><p class="callout-label">انتبه</p><p>لا ترسل أبدًا صفًا من الصورة أعرض من منطقة الطباعة التي أعلنتها. الطابعة لا ترفضه. تفقد موضعها في السيل، وتبدأ بتنفيذ بكسلاتك أوامرَ.</p>
</aside>
<p>ثم تأكدت أن أي خطأ تخطيط قادم سينتهي برسالة خطأ. الإيصال الواحد لا يتجاوز 800 مم من الورق، بالتغذية، ويُفحص ذلك قبل أن يخرج أول بايت؛ وكان السقف القديم 40,000 صف، أي نحو خمسة أمتار. وفي إعدادات الطباعة زرٌّ يُفرغ قائمة الطابعة، وسكربت يفعل الشيء نفسه دون تشغيل البرنامج:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD"># أوقف القائمة حتى لا تبدأ مهمة جديدة، ثم ألغِ ما فيها.</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">Add-Type</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> -</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">AssemblyName System.Printing</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">,</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> ReachFramework</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">$</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">server</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> New-Object</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> System.Printing.LocalPrintServer</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">$</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">queue</span><span style="--shiki-light:#999999;--shiki-dark:#666666">  =</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> $</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">server.GetPrintQueues</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> |</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> Where-Object</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> FullName </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">-eq</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">Xprinter XP-370B</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">$</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">queue.Pause</span><span style="--shiki-light:#999999;--shiki-dark:#666666">();</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> $</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">queue.Commit</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">foreach</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> ($</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">job</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> in</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> $</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">queue.GetPrintJobInfoCollection</span><span style="--shiki-light:#999999;--shiki-dark:#666666">())</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> $</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">job.Cancel</span><span style="--shiki-light:#999999;--shiki-dark:#666666">();</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> $</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">job.Dispose</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span></code></pre></figure>
<p>واعرف حدوده. الورق المتحرك الآن يأتي من ذاكرة الطابعة نفسها ويتوقف حين تنفد. فإن استمر بعد ذلك، فالطابعة تغذّي من تلقاء نفسها، ولن يوقفها إلا مفتاح الطاقة أو تصحيح وضع الوسائط.</p>
<p>وفي سجل الأخطاء نفسه طريقٌ ثانٍ لإهدار رول. <code>Process.Start</code> مع الفعل <code>print</code> يرسل ملف PDF إلى طابعة ويندوز الافتراضية. وعلى جهاز كاشير تكون فيه الطابعة الحرارية هي الافتراضية، تمرّ صفحة A4 عبر التعريف الحراري إلى رول لا حدّ لطول صفحته. الآن يُفتح ملف PDF، ويختار إنسانٌ الطابعة بنفسه.</p>
<h2 id="رمز-qr-والتغذية-والقطع-والدرج">رمز QR والتغذية والقطع والدرج</h2>
<p><strong>رمز QR.</strong> يُرسم الرمز بصيغة SVG: مربّع لكل وحدة، و<code>shape-rendering=&quot;crispEdges&quot;</code>، بلا تنعيم. الرمز النقطي حين يُعاد تحجيمه تتداخل وحداته فيعجز عنه الماسح، والمتّجه يُرسم مباشرة على شبكة الطابعة نفسها.</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">var</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> matrix</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> new</span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994"> QRCodeWriter</span><span style="--shiki-light:#999999;--shiki-dark:#666666">().</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">encode</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">content</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> BarcodeFormat</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">QR_CODE</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    new</span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994"> Dictionary</span><span style="--shiki-light:#999999;--shiki-dark:#666666">&#x3C;</span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">EncodeHintType</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> object</span><span style="--shiki-light:#999999;--shiki-dark:#666666">></span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    {</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">        [</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">EncodeHintType</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">ERROR_CORRECTION</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> ErrorCorrectionLevel</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">M</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">        [</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">EncodeHintType</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">MARGIN</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">   // الهامش الهادئ يُضاف يدويًا: 4 وحدات</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    });</span></span>
<span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">// ثم مربّع "M x,y h1v1h-1z" لكل وحدة سوداء</span></span></code></pre></figure>
<p>مستوى تصحيح الأخطاء M: رمز الإيصال نحو 550 حرفًا، والمستوى H يكبّره حتى لا يُطبع بحجم يُقرأ، وM يحتمل خدشًا أو بقعة حبر عادية. ويُطبع بمقاس 38 × 38 مم، في المنتصف بين الإجماليات والتذييل.</p>
<p><strong>التغذية.</strong> بعد آخر سطر يتقدّم الورق 27 مم، فيتجاوز التذييل حافة التمزيق. و<code>ESC J</code> يعدّ وحدات حركة، وهي نقطة واحدة في هذه الطابعات، فالـ 27 مم تساوي 216 نقطة.</p>
<p><strong>القطع.</strong> فقط إن كان للطابعة قاطع، لأن أمر القطع إلى طابعة بلا قاطع يعلّقها أو يُطبع حروفًا. تعريف XP-370B يعرض None وTear Off وPeel Off، ولا يعرض Cut، فيمزّق الكاشير الورقة بيده. وفي غيرها يقطع <code>GS V</code> قطعًا جزئيًا مرةً واحدة بعد الإيصال كله. والمسار الحالي ما زال يتعرّف على XP-370B من اسمها؛ أما المُرمِّز الجديد المقاد بملف الطابعة، الذي أنتقل إليه، فيقرأ القاطع من ملفها.</p>
<p><strong>الدرج.</strong> يرسل <code>ESC p</code> نبضة لدرج النقود. وهو في المُرمِّز الجديد، ومطفأ افتراضيًا: نبضة إلى طابعة موصولة بشيء آخر أحسن أحوالها صوت طقطقة.</p>
<h2 id="قبل-أن-تطبع-إيصالًا-واحدًا">قبل أن تطبع إيصالًا واحدًا</h2>
<ul>
<li>عامل العربية بكسلاتٍ من اليوم الأول.</li>
<li>قِس منطقة الطباعة على الطابعة الحقيقية، واحفظها لكل طابعة، وأعلنها في كل مهمة بـ <code>GS L</code> و<code>GS W</code>.</li>
<li>اجعل كل صف في الصورة <code>printAreaDots / 8</code> بايتًا بالضبط، وقُصّ ما يزيد.</li>
<li>ركّب الصفحة بعرضها النهائي. لا تصغّر صورة أحادية البت أبدًا.</li>
<li>ركّب الشفافية على الأبيض قبل العتبة.</li>
<li>غذِّ الورق بالنقاط بأمر <code>ESC J</code>، وضع سقفًا للورق قبل أن ترسل أي شيء.</li>
<li>عايِن البتات التي سيحرقها الرأس. أداة المعاينة عندي تمرّر الصفحات على المُرمِّز الحقيقي وتحفظ النتيجة PNG، وكشفت الالتفاف والانعكاس والطول الزائد قبل أن تكلّف ورقة.</li>
<li>لا تُعِد الطباعة تلقائيًا أبدًا.</li>
</ul>
<p>حين صارت العربية بكسلات، لم يبقَ من المشكلات إلا الهندسة: بأي عرض تطبع، وكم تغذّي، وأين تتوقف. وخمسة بايتات منها كلّفت رولًا كاملًا.</p>
]]></content:encoded>
    </item>
    <item>
      <title>لماذا لم تمنع مفاتيح idempotency تكرار سند واحد 105 مرات؟</title>
      <link>https://moghalis.com/ar/blog/electricity-billing</link>
      <guid isPermaLink="true">https://moghalis.com/ar/blog/electricity-billing</guid>
      <pubDate>Mon, 19 Oct 2026 15:00:00 GMT</pubDate>
      <description>فوترة محطات الكهرباء دون اتصال: معرّف واحد يرافق الدفعة من الهاتف إلى الإيصال، ولماذا لا تُجمع إجماليات الفواتير، وطابور يعرف ما يعيده، وخطأ 500 صنع حلقة لا تنتهي.</description>
      <category>الفوترة</category>
      <category>PostgreSQL</category>
      <category>Flutter</category>
      <category>NestJS</category>
      <category>العمل دون اتصال</category>
      <content:encoded><![CDATA[<p>الساعة 22:02 بتوقيت عدن، 18 أكتوبر. في محطة كهرباء في اليمن، يبدأ هاتفان بإرسال السند نفسه إلى الـ API. يجيب الـ API بـ 500. فيرسله الهاتفان من جديد، ثم من جديد. وعند 22:06 يكون السند قد وصل 105 مرات، وشاشة صحة المنصة تقرأ 9% أخطاء خادم.</p>
<p>لم يتحرك أي رصيد بريال واحد، فقاعدة البيانات رفضت كل محاولة إدخال منها.</p>
<p>وكل طلب من تلك الطلبات مرّ عبر طبقة idempotency بُنيت لهذا بالضبط: هواتف تعيد الإرسال.</p>
<p>هذه طريقة FinyxWatt في فوترة مشتركي الكهرباء من هواتف تفقد الشبكة: المعرّف الذي يرافق الدفعة من الهاتف حتى الإيصال، والسبب الذي يمنع جمع إجماليات الفواتير، والطابور الصادر الذي يقرر ما يُعاد إرساله، والقاعدة التي كانت تنقصني تلك الليلة. وحين تنتهي ستستطيع أن تبني طابورًا للمال يعمل دون اتصال، لا تستطيع فيه إعادة المحاولة أن تحصّل مرتين، ولا يستطيع فيه الرفض أن يدور في حلقة.</p>
<aside class="post-summary"><p class="callout-label">باختصار</p><ul>
<li>كل عملية تأخذ معرّفها على الهاتف لحظة حفظها، وتحتفظ به حتى يؤكدها الخادم. والخادم يحجز هذا المعرّف قبل أن يلمس أي مال.</li>
<li>الرصيد مجموع سجل مالي للإضافة فقط. وإجمالي الفاتورة يحمل دينًا أقدم، فجمع الإجماليات يحسب الدين نفسه مرتين.</li>
<li>مفتاح الـ idempotency يعيد النجاح فقط. أما في الفشل فرموز الحالة هي سياسة إعادة المحاولة، والفشل الدائم الذي يجيب بـ 500 يصير حلقة.</li>
</ul>
</aside>
<div class="stat-grid"><div class="stat"><p class="stat-value" dir="ltr">105</p><p class="stat-label">مرة وصل فيها سند واحد خلال 3.5 دقيقة</p></div><div class="stat"><p class="stat-value" dir="ltr">9%</p><p class="stat-label">أخطاء خادم على شاشة صحة المنصة</p></div><div class="stat"><p class="stat-value" dir="ltr">0</p><p class="stat-label">سندات مكررة سُجّلت</p></div><div class="stat"><p class="stat-value" dir="ltr">1</p><p class="stat-label">دالة واحدة في الإصلاح</p></div></div>
<h2 id="حين-تنقطع-الشبكة-يحتفظ-الهاتف-بالعمل">حين تنقطع الشبكة يحتفظ الهاتف بالعمل</h2>
<p>تعمل محطة الكهرباء الخاصة عبر سلسلة خطوات صغيرة: المحصّل يقرأ العداد، ثم تصدر الفاتورة، ثم تصل المشترك رسالة SMS، ثم يُحصَّل المبلغ، ثم ينتقل النقد إلى المدير. وFinyxWatt هي المنصة التي بنيتها لهذه السلسلة: أربعة تطبيقات Flutter على API واحد بـ NestJS فوق PostgreSQL. وتطبيق المحطة هو الذي في أيدي المحصّلين، والمحطات تفقد الشبكة كثيرًا.</p>
<p>لذلك لا ينتظر التطبيق الشبكة أبدًا. يعرض النسخ المحفوظة أولًا، وكل عملية كتابة تدخل طابورًا صادرًا (outbox) على الهاتف. وحين يعود الاتصال، يرفعها منسّق واحد بترتيب الاعتماد: المشتركون الجدد أولًا، ثم الفواتير والسندات التي قد تشير إليهم، ثم المحادثات. والسند الذي يخص فاتورة لا توجد بعد إلا على الهاتف ينتظر دوره. وبعد جولة فاشلة ينتظر المنسّق 30 ثانية، ويضاعفها حتى خمس دقائق، كي لا يُثقل على خادم متعثر.</p>
<p>كانت النسخة الأولى على Firebase، ونقلتها إلى خلفيتي الخاصة والمحطات تعمل، فخرجت من النقل بقاعدة واحدة: الهاتف يقترح، والخادم يقرر.</p>
<p>إعادة المحاولة هي التصميم كله، وهذا يفرض سؤالين. إذا كانت إعادة المحاولة أمرًا عاديًا، فما الذي يمنعها من التحصيل مرتين؟ وما الذي يمنعها من أن تستمر إلى ما لا نهاية؟ للسؤال الأول جواب نظيف. أما الثاني فهو ليلة 18 أكتوبر.</p>
<h2 id="أعطِ-العملية-معرّفها-لحظة-ولادتها">أعطِ العملية معرّفها لحظة ولادتها</h2>
<p>كل عملية تأخذ UUID عشوائيًا لحظة حفظها أول مرة على الهاتف، وتحتفظ به عبر كل إعادة محاولة، وكل إعادة تشغيل للتطبيق، وكل رد ضائع. والمعرّف هو أيضًا هوية ما تنشئه العملية: الفاتورة المنتظرة في الطابور تُنشأ على الخادم بهذا المعرّف، وكذلك الدفعة.</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">/// معرّف عشوائي جديد (الإصدار 4)، يُنشأ حين تدخل العملية الطابور أول مرة.</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">String</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> newOperationId</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">() {</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">  final</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> random </span><span style="--shiki-light:#999999;--shiki-dark:#666666">=</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> Random</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">secure</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">()</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">  final</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> bytes </span><span style="--shiki-light:#999999;--shiki-dark:#666666">=</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> List</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">&#x3C;</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">int</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">></span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">generate</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">(</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">16</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> (_) </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">=></span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> random</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">nextInt</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">(</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">256</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">))</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">  bytes[</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">6</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">] </span><span style="--shiki-light:#999999;--shiki-dark:#666666">=</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> (bytes[</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">6</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">] </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">&#x26;</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0x0f</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">) </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">|</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0x40</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">  bytes[</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">8</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">] </span><span style="--shiki-light:#999999;--shiki-dark:#666666">=</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> (bytes[</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">8</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">] </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">&#x26;</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0x3f</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">) </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">|</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0x80</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  return</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> _format</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">(bytes)</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">/// المعرّف المحفوظ مع العملية، أو معرّف مشتق للعناصر الأقدم.</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">String</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> operationIdFor</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">(</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">Map</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">&#x3C;</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">String</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">, </span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">dynamic</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">> payload</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> String</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> queueItemId) {</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">  final</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> stored </span><span style="--shiki-light:#999999;--shiki-dark:#666666">=</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> payload[</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">'operationId'</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">]</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  if</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> (stored </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">is</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> String</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> &#x26;&#x26;</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> isCanonicalUuid</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">(stored)) </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">return</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> stored</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">toLowerCase</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">()</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  return</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> derivedOperationId</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">(queueItemId)</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">}</span></span></code></pre></figure>
<p>الدالة الثانية تعالج فخًّا يظهر بعد الترقية. قد يصل إصدار جديد من التطبيق إلى هاتف ما زال يحمل عمليات وضعها في الطابور إصدار أقدم لم يكن يحفظ معرّفًا. ولو ولّدت معرّفًا جديدًا مع كل إعادة محاولة، لبنيت بيدك التحصيل المزدوج الذي أردت منعه. لذلك تأخذ العملية القديمة معرّفًا مشتقًا من معرّفها الدائم في الطابور، فيبقى القيمة نفسها في كل إعادة، ويُعلَّم بالإصدار 8 من UUID ليعرف من يقرؤه أنه مشتق.</p>
<p>وداخل هذا الاشتقاق فخ ثانٍ. فهو يستعمل FNV-1a بطول 32 بت، والضرب في عدد FNV الأولي مكتوب إزاحاتٍ:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">hash </span><span style="--shiki-light:#999999;--shiki-dark:#666666">=</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> (hash </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">+</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> (hash </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">&#x3C;&#x3C;</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">) </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">+</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> (hash </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">&#x3C;&#x3C;</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 4</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">) </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">+</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> (hash </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">&#x3C;&#x3C;</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 7</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">) </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">+</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> (hash </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">&#x3C;&#x3C;</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 8</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">) </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">+</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> (hash </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">&#x3C;&#x3C;</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 24</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">)) </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">&#x26;</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0xffffffff</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span></code></pre></figure>
<p>السبب أن كود Dart نفسه يُبنى للمتصفح أيضًا، والأعداد الصحيحة هناك أعداد عشرية مزدوجة (doubles). فالضرب العادي قد يتجاوز 2^53 ويفقد بتاته الدنيا، فيشتق المتصفح من عنصر الطابور نفسه معرّفًا غير الذي يشتقه الهاتف.</p>
<h2 id="احجز-المعرّف-قبل-أن-تبدأ-العمل">احجز المعرّف قبل أن تبدأ العمل</h2>
<p>على الخادم يصل المعرّف في ترويسة <code>Idempotency-Key</code>، ويحجزه الـ API قبل أن يلمس أي مال. والحجز والعملية يتشاركان معاملة واحدة:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const </span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">claimed</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> await</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> trx</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">insertInto</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">idempotency_receipts</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">).</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">values</span><span style="--shiki-light:#999999;--shiki-dark:#666666">({</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">  tenant_id</span><span style="--shiki-light:#999999;--shiki-dark:#666666">, </span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">actor_identity_id</span><span style="--shiki-light:#999999;--shiki-dark:#666666">, </span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">idempotency_key</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">key</span><span style="--shiki-light:#999999;--shiki-dark:#666666">, </span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">operation</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  request_hash</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">sha256</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">stableStringify</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">request</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)),</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  status</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">processing</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}).</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">onConflict</span><span style="--shiki-light:#999999;--shiki-dark:#666666">((</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">c</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> c</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">columns</span><span style="--shiki-light:#999999;--shiki-dark:#666666">([</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">tenant_id</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">actor_identity_id</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">idempotency_key</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]).</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">doNothing</span><span style="--shiki-light:#999999;--shiki-dark:#666666">())</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">  .</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">returning</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">idempotency_key</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">).</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">executeTakeFirst</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">if</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">claimed</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ===</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> undefined</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">  const </span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">existing</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> await</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> readReceiptForUpdate</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">trx</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> key</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD"> // SELECT … FOR UPDATE</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  if</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">existing</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ===</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> undefined</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> throw</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> conflict</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">IDEMPOTENCY_RECEIPT_UNAVAILABLE</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD"> // الفشل مغلقًا</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  if</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">existing</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">operation</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> !==</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> operation</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ||</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> existing</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">request_hash</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> !==</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> requestHash</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> throw</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> conflict</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">IDEMPOTENCY_KEY_REUSED</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  if</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">existing</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">status</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> !==</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">completed</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> throw</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> conflict</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">OPERATION_IN_PROGRESS</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  return</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> { </span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">replayed</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">true</span><span style="--shiki-light:#999999;--shiki-dark:#666666">, </span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">status</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">existing</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">response_status</span><span style="--shiki-light:#999999;--shiki-dark:#666666">, </span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">body</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">existing</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">response_body</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const </span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">result</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> await</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> execute</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">trx</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">       // أول مرة: ننفذ العمل</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">await</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> markCompleted</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">trx</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> key</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> result</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">   // ونحفظ استجابته في المعاملة نفسها</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">return</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> { </span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">replayed</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">false</span><span style="--shiki-light:#999999;--shiki-dark:#666666">, ...</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">result</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span></code></pre></figure>
<p>كل سطر هنا يغلق بابًا.</p>
<ul>
<li>نسختان من الطلب نفسه تصلان معًا لا تُنفَّذان معًا. فـ PostgreSQL يجعل <code>ON CONFLICT</code> الثانية تنتظر معاملة الأولى، ثم يقرأ الخاسر الإيصال المكتمل.</li>
<li>الإيصال الموجود الذي تتعذر قراءته يفشل مغلقًا (fail closed). فتنفيذ العملية مرة ثانية هو الشيء الوحيد الذي يجب ألا يفعله هذا الكود أبدًا.</li>
<li>المفتاح المعروف إذا جاء بجسم مختلف فذلك خطأ في العميل، فيأخذ 409 بدل الإعادة. والجسم يُبصم بعد تحويله إلى نص بترتيب ثابت للمفاتيح، فتعطي العملية نفسها البصمة نفسها دائمًا.</li>
<li>المفتاح الذي ما زال في حالة <code>processing</code> يجيب بـ <code>OPERATION_IN_PROGRESS</code>، والهاتف يضعه تحت «حاول لاحقًا».</li>
</ul>
<p>وفخ هنا احتاج ترحيلًا كاملًا لإصلاحه. كان مفتاح جدول الإيصالات الأول هو المحطة والمستخدم والعملية والمفتاح معًا. بدا ذلك مرتبًا، لكنه سمح باستعمال المفتاح الواحد مرة لكل endpoint. فلو وُجّهت إعادة محاولة يومًا إلى endpoint خاطئ، لأمكن للمفتاح نفسه أن يصنع أثرًا ثانيًا. فجعل الترحيل 0015 كل مفتاح صالحًا لمرة واحدة لكل مستخدم، عبر كل أنواع العمليات.</p>
<figure class="post-figure dg dg-is-timeline"><ol class="dg-timeline"><li><strong>دون اتصال</strong><span>الهاتف يحفظ الدفعة 7f3… في الطابور الصادر</span></li><li><strong>عاد الاتصال</strong><span>يرسل الدفعة مع Idempotency-Key 7f3…</span></li><li><strong>الـ API يلتزم</strong><span>يُسجَّل الإيصال وتُحفظ استجابته</span></li><li><strong>ضاعت الاستجابة</strong><span>لا يصل الرد إلى الهاتف</span></li><li><strong>إعادة بالمفتاح نفسه</strong><span>يعيد الـ API الاستجابة المحفوظة، معلَّمة بأنها مُعادة</span></li></ol></figure>
<p>وهكذا صار الرد الضائع بلا ضرر. لكن تذكّر تفصيلًا واحدًا من ذلك الكود: الاستجابة تُحفظ في المعاملة نفسها التي يجري فيها العمل. سنحتاجه بعد قليل.</p>
<h2 id="الخادم-هو-من-يحسب">الخادم هو من يحسب</h2>
<p>الطلب الذي لا يتكرر أثره لا يكون آمنًا إلا إذا لم يحمل مالًا اخترعه الهاتف. الهاتف يرسل قراءة العداد، والخادم يتولى الباقي.</p>
<p>قيمة الفاتورة = (القراءة الحالية − القراءة السابقة) × سعر الكيلوواط، مقرّبة إلى منزلتين عشريتين بنوع <code>numeric</code> في PostgreSQL. والقراءة السابقة تأتي من سجل العداد، ويقفله الخادم طوال المعاملة. والسعر هو سعر المشترك الخاص ما دام ضمن تواريخه، وإلا فسعر المحطة. والسعر الذي يرسله الهاتف لا يُعتدّ به إلا في محطة لم تحفظ سعرًا قط، فلا يستطيع هاتف بقي دون اتصال أسابيع أن يفوتر بسعر قديم.</p>
<p>والآثار الجانبية تحتاج الانضباط نفسه. رسالة SMS للمشترك تُنشأ في المعاملة نفسها التي تصدر فيها فاتورته، بالمفتاح <code>invoice:&lt;id&gt;:issued:v1</code>، مع فهرس فريد على المصدر والقالب والمستلم. فلا تستطيع فاتورة مُعادة أن تضيف رسالة ثانية إلى الطابور.</p>
<h2 id="لا-تجمع-إجماليات-الفواتير-أبدًا">لا تجمع إجماليات الفواتير أبدًا</h2>
<p>كل مبلغ على المشترك يعيش في جدول واحد: <code>ledger_entries</code>. قيم الفواتير وعكس الدفعات قيود مدينة، والدفعات وإلغاء الفواتير قيود دائنة، والرصيد هو المجموع. ودور الـ API في قاعدة البيانات لا يملك على هذا الجدول إلا <code>SELECT</code> و<code>INSERT</code>، وtrigger يرفض ما سواهما:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">CREATE</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> TRIGGER</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> ledger_entries_append_only</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">BEFORE</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> UPDATE</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> OR</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> DELETE</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> ON</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> ledger_entries</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">FOR</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> EACH </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">ROW</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> EXECUTE</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> FUNCTION</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> app_private</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">.</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">prevent_ledger_mutation</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">;</span></span>
<span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">-- يرفع: 'ledger entries are append-only; post a reversal entry instead'</span></span></code></pre></figure>
<p>ومع ذلك مرّ من هذا التصميم خطأ واحد، وكشفته مراجعة محاسبية. إجمالي الفاتورة يتضمن رصيد المشترك كله متأخراتٍ، وهذا ما يحتاج المشترك أن يراه على فاتورته. غير أن كل فاتورة أقدم غير مسددة بقيت قابلة للدفع، وكل دفعة كانت تُقارن بفاتورتها وحدها.</p>
<p>الفاتورة أ: 1,000 غير مسددة. الفاتورة ب: 500 + 1,000 متأخرات = 1,500. فقُبلت دفعة 1,000 على أ ودفعة 1,500 على ب، والدين الحقيقي 1,500 فقط. وكان العدادان للمشترك الواحد، أو الفاتورة الملغاة، يفتحان الثغرة نفسها.</p>
<figure class="post-figure dg dg-is-compare"><div class="dg-compare"><div class="dg-compare-head"><span>قبل الترحيل 0080</span><span>بعده</span></div><div class="dg-compare-row"><span>الفاتورة أ: 1,000، مفتوحة للدفع</span><span>الفاتورة أ مرحّلة إلى الفاتورة ب</span></div><div class="dg-compare-row"><span>الفاتورة ب: 500 + 1,000 متأخرات = 1,500، مفتوحة أيضًا</span><span>الفاتورة ب وحدها تقبل الدفع</span></div><div class="dg-compare-row"><span>دفع الاثنتين يحصّل 2,500 لدين قدره 1,500</span><span>الدفع محدود برصيد السجل، 1,500</span></div></div></figure>
<p>غيّر الترحيل 0080 ثلاث قواعد. إصدار فاتورة جديدة يُرحّل إليها كل فاتورة مفتوحة للمشترك، والفاتورة المرحّلة لا تقبل دفعًا. والدفعة محدودة بالمتبقي من الفاتورة وبرصيد المشترك في السجل. وبعد أي تغيير مالي، تضبط قاعدة البيانات حالة كل فاتورة من السجل، بدءًا بأقدم دين، فلا تُستنتج حالة السداد أبدًا من مقارنة <code>paid_amount</code> بـ <code>total_amount</code>.</p>
<p>وكان الخطأ نفسه مختبئًا في لوحة الإدارة. لوحة الموقع كانت تجمع صفحة من 250 صفًا لتعرض ما فُوتر وما بقي. الآن تجمع قاعدة البيانات كل صف يحق للعضو أن يراه، واللوحة تقرأ ذلك الرقم الواحد.</p>
<aside class="callout callout-warning"><p class="callout-label">انتبه</p><p>إجمالي الفاتورة يحمل دينًا أقدم، وجمع الإجماليات يحسب الدين نفسه أكثر من مرة. التقارير تجمع قيم الاستهلاك، والأرصدة تأتي من السجل.</p>
</aside>
<h2 id="رموز-الحالة-هي-سياسة-إعادة-المحاولة">رموز الحالة هي سياسة إعادة المحاولة</h2>
<p>نعود إلى الطابور الصادر. حين يفشل الرفع، على الهاتف أن يقرر معنى هذا الفشل، وهو يصنّف كل خطأ في أربعة أنواع:</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>النوع</th>
<th>أمثلة</th>
<th>ماذا يفعل الطابور</th>
</tr>
</thead>
<tbody><tr>
<td>مؤجَّل</td>
<td>لا شبكة، أو خروج من الحساب، أو هاتف ينتظر الاعتماد</td>
<td>يوقف هذه الجولة ويحتفظ بكل شيء لوقت لاحق</td>
</tr>
<tr>
<td>قابل للإعادة</td>
<td>انتهاء المهلة، و408 و429 و500 و502 و503 و504، و<code>OPERATION_IN_PROGRESS</code></td>
<td>يحتفظ بالعملية ويعيدها في جولة لاحقة</td>
</tr>
<tr>
<td>محجوب</td>
<td>400 و403 و404 و409 و410 و412 و422 و501</td>
<td>يتوقف عن إرسالها ويعرضها على شخص ليراجعها</td>
</tr>
<tr>
<td>مجهول</td>
<td>أي شيء آخر</td>
<td>يوقف هذه الجولة ويحاول لاحقًا</td>
</tr>
</tbody></table></div>
<p>اقرأ الـ 500 في هذا الجدول مرة أخرى. والآن، ليلة 18 أكتوبر.</p>
<p>كان السند يحمل رقم عملية من مزوّد دفع، أي مرجع حوالة. وهذا الرقم كان موجودًا من قبل على سند آخر ساري من المزوّد نفسه. ففعل الفهرس الفريد <code class="code-long">payments_provider_reference_unique</code> ما صُمّم له ورفض الإدخال. ولم يكن في الـ API ما يترجم هذا الخطأ، فخرج 500. والهواتف تقرأ الـ 500 فشلًا عابرًا، فأعادت إرسال السند.</p>
<figure class="post-figure dg dg-is-flow"><ol class="dg-flow"><li><strong>الهاتف يرسل السند</strong><span>من الطابور الصادر، بمفتاحه</span></li><li><strong>الفهرس يرفضه</strong><span>رقم العملية موجود على سند ساري</span></li><li><strong>الـ API يجيب بـ 500</strong><span>والحجز يتراجع مع كل شيء آخر</span></li><li><strong>الهاتف يعيد المحاولة</strong><span>فالـ 500 عنده فشل عابر</span></li></ol></figure>
<p>والآن سؤال البداية: لماذا لم توقفه طبقة الـ idempotency؟</p>
<p>لأن المفتاح يعيد النجاح فقط. الحجز يعيش في المعاملة نفسها التي يجري فيها العمل، وهذا بالضبط ما يجعله آمنًا: العمل واستجابته المحفوظة يُلتزم بهما معًا أو لا يُلتزم بشيء. فحين فشل الإدخال ألغت PostgreSQL المعاملة، واختفى الحجز معها. لم يبقَ ما يتذكر الفشل، فكانت كل إعادة إرسال محاولةً أولى تفشل بالطريقة نفسها. وحفظ الفشل كان سيكون أسوأ، لأن الفشل العابر، كتعارض أقفال (deadlock) أو إعادة تشغيل، كان سيُعاد عندها إلى الأبد.</p>
<p>المفتاح أدّى عمله، ورمز الحالة كان هو الخطأ. والإصلاح دالة واحدة: صار هذا الخرق للفهرس الفريد 409، يشرحه تطبيق المحطة والموقع بعربية واضحة. وعلى الهاتف يُحجب الـ 409، ويُعرض للمراجعة، ولا يُعاد إرساله أبدًا.</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">function</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> duplicateReference</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">error</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">unknown</span><span style="--shiki-light:#999999;--shiki-dark:#666666">):</span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994"> never</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">  const </span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">failure</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> error</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> as</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> code</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">?</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">unknown</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> constraint</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">?</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">unknown</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> |</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> null</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  if</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">failure</span><span style="--shiki-light:#999999;--shiki-dark:#666666">?.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">code</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ===</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">23505</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> &#x26;&#x26;</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> failure</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">constraint</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ===</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">payments_provider_reference_unique</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">    throw</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> new</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> ConflictException</span><span style="--shiki-light:#999999;--shiki-dark:#666666">({ </span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">code</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">PAYMENT_REFERENCE_DUPLICATE</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> })</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">  }</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  throw</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> error</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span></code></pre></figure>
<p>والالتزام (commit) الذي أصلحه مؤرخ بالساعة 22:29 من الليلة نفسها. واختبار التكامل فيه يرسل العملية نفسها مرتين بالمفتاح نفسه، وينتظر الـ 409 نفسه في المرتين. ويتحقق كذلك من أن مزوّدًا آخر يجوز أن يحمل الرقم نفسه.</p>
<aside class="callout callout-tip"><p class="callout-label">نصيحة</p><p>اكتب قائمة بكل قيد تفرّد (unique constraint) في مخططك. كل واحد منها 409 ينتظر من يترجمه. وإن تركته دون ترجمة أجاب بـ 500، وظل العميل الذي يعمل دون اتصال يعيد المحاولة حتى ينتبه أحد.</p>
</aside>
<h2 id="الكتابات-المتأخرة-تهبط-في-الفترة-المفتوحة">الكتابات المتأخرة تهبط في الفترة المفتوحة</h2>
<p>والهواتف التي تعمل دون اتصال تطرح سؤالًا آخر: ماذا لو رفع هاتف سندًا تاريخه داخل فترة أقفلتها المحطة من قبل؟ إقفال الفترة يكتب لكل مشترك قيد إقفال وقيد افتتاح مجموعهما صفر، ويجب أن يبقى مجموع الفترة المقفلة صفرًا. لذلك ينقل trigger القيد المتأخر إلى بداية الفترة المفتوحة:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">CREATE</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> FUNCTION</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> app_private</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">.ledger_entry_into_open_period</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> RETURNS</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> trigger </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">AS</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> $$</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">DECLARE</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> open_from </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">timestamptz</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">;</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">BEGIN</span></span>
<span class="line"><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">  PERFORM pg_advisory_xact_lock_shared(hashtextextended(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">archive:</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ||</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> NEW</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">.</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">tenant_id</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">::</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">text</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">, </span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">0</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">));</span></span>
<span class="line"><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">  open_from :</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">=</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> app_private</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">.</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">archive_boundary</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">(</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">NEW</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">.</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">tenant_id</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">);</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  IF</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> NEW</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">.</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">effective_at</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> &#x3C;</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> open_from </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">AND</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> NEW</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">.</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">entry_kind</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">::</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">text</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> &#x3C;></span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">period_close</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> THEN</span></span>
<span class="line"><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">    NEW</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">.</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">effective_at</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> :</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">=</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> open_from;</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  END</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> IF</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">;</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  RETURN</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> NEW;</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">END</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> $$ </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">LANGUAGE</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> plpgsql;</span></span></code></pre></figure>
<p>والإقفال يأخذ القفل الاستشاري (advisory lock) نفسه حصريًا، لكل محطة على حدة. فينتظر الإقفال الكتابات الجارية، وتنتظر الكتابات الجديدة الإقفال.</p>
<h2 id="قائمة-الفحص-للمال-الذي-يعمل-دون-اتصال">قائمة الفحص للمال الذي يعمل دون اتصال</h2>
<ul>
<li>أعطِ كل عملية معرّفها حين تُحفظ أول مرة على الجهاز، واحتفظ به حتى يؤكدها الخادم.</li>
<li>أعطِ العمليات التي سبقت وجود المعرّفات معرّفًا مشتقًا ثابتًا، ولا تولّد معرّفًا جديدًا مع كل إعادة.</li>
<li>اجعل المعرّف هوية ما تنشئه العملية: معرّف الفاتورة، ومعرّف الدفعة.</li>
<li>احجز المفتاح قبل العمل، في المعاملة نفسها، واحفظ الاستجابة معه.</li>
<li>اجعل المفتاح صالحًا لمرة واحدة لكل مستخدم عبر كل endpoint، وارفضه إن اختلف الجسم.</li>
<li>افشل مغلقًا حين يوجد إيصال وتتعذر قراءته.</li>
<li>دع الهاتف يرسل الوقائع، كقراءة العداد، ودع الخادم يحسب كل مبلغ.</li>
<li>أعطِ الآثار الجانبية مفاتيحها الخاصة، مثل <code>invoice:&lt;id&gt;:issued:v1</code>.</li>
<li>احفظ الأرصدة في سجل مالي للإضافة فقط، ولا تجمع إجماليات الفواتير أبدًا.</li>
<li>ترجم كل قيد تفرّد إلى 4xx، فرموز الحالة هي سياسة إعادة المحاولة.</li>
<li>انقل القيود المتأخرة إلى الفترة المفتوحة، واترك المقفلة مقفلة.</li>
</ul>
<p>الشبكة ستظل تنقطع، والهواتف ستظل تعيد الإرسال. والرصيد يجب أن يبقى صحيحًا رغم ذلك، والرفض يجب أن يبقى رفضًا.</p>
<p>وهكذا أبني <a href="https://moghalis.com/ar/services/mobile-apps">تطبيقات الجوال</a> التي يجب أن تعمل دون اتصال.</p>
]]></content:encoded>
    </item>
    <item>
      <title>أمان يصمد أمام أخطائي: ست طبقات وإعداداتها كاملة</title>
      <link>https://moghalis.com/ar/blog/securing-client-systems</link>
      <guid isPermaLink="true">https://moghalis.com/ar/blog/securing-client-systems</guid>
      <pubDate>Mon, 19 Oct 2026 14:00:00 GMT</pubDate>
      <description>ست طبقات تعمل في الإنتاج، كل واحدة مبنية ليوم تسقط فيه الطبقة التي فوقها: RLS مُجبَر، وحسابات لا تتجاوزه، وحاويات مقفلة، وشهادات للهواتف، ونسخة احتياطية تُستعاد كل أسبوع.</description>
      <category>الأمان</category>
      <category>PostgreSQL</category>
      <category>Docker</category>
      <category>Caddy</category>
      <category>mTLS</category>
      <category>النسخ الاحتياطي</category>
      <content:encoded><![CDATA[<p>تحفظ FinyxWatt، منصتي لمحطات الكهرباء والمياه، عملاء محطات كثيرة وفواتيرهم وسنداتهم في قاعدة PostgreSQL واحدة. وكل استعلام يرسله الـ API لا يفصله عن كشف عملاء محطة لمحطة أخرى إلا شرط واحد منسي: <code>WHERE tenant_id = ?</code>. أنا من يكتب هذه الاستعلامات. وسيأتي يوم أنسى فيه الشرط.</p>
<p>لذلك لا شيء هنا يعتمد على أن أتذكّره.</p>
<p>هذا دليل ميداني لطريقة حماية FinyxWatt والخادم الذي يعمل عليه هذا الموقع: ست طبقات، كل واحدة مبنية على افتراض أن الطبقة التي فوقها قد سقطت بالفعل. كل إعداد هنا مأخوذ من الملفات الحقيقية، مختصرًا، بلا أسرار ولا أسماء خوادم. وحين تنتهي ستعرف كيف تجعل PostgreSQL ترفض قراءةً نسي كودك تصفيتها، وكيف تشغّل حاويات لا تكاد تستطيع شيئًا، وكيف تمنح هاتفًا هوية لا يمكن تزويرها، وكيف تثبت كل أسبوع أن نسختك الاحتياطية تُستعاد فعلًا.</p>
<p>وفي الطريق ثلاثة فخاخ: إعداد في PostgreSQL يعود سلسلة فارغة، وترويسة يستطيع أي أحد تزويرها لو انضمت حاوية واحدة إلى الشبكة الخطأ، وقيمة افتراضية لإعادة المحاولة تجعل نسخة تالفة تحتجز اختبارها ساعات.</p>
<aside class="post-summary"><p class="callout-label">باختصار</p><ul>
<li>عزل المحطات يعيش داخل PostgreSQL: أمان على مستوى الصفوف (RLS)، مُجبَر، مربوط بعضوية نشطة، وتقابله حسابات لا تستطيع تجاوزه.</li>
<li>كل عملية وحاوية وهاتف لا يملك إلا ما تحتاجه مهمته الوحيدة. والأسرار تصل ملفات، ولا تمر بـ Git أبدًا.</li>
<li>النسخة الاحتياطية لا تُحسب حتى تُستعاد. كل جمعة تُبنى أحدث نسخة من جديد في حاوية بلا شبكة، ويُعدّ ما عاد منها.</li>
</ul>
</aside>
<figure class="post-figure dg dg-is-stack"><ol class="dg-stack"><li><strong>6. الخادم</strong><span>جدار ناري لا يفتح إلا SSH وHTTP وHTTPS، ودخول SSH بالمفاتيح فقط، وتحديثات أمنية تلقائية</span></li><li><strong>5. البوابة (Caddy)</strong><span>HTTPS، وترويسات الأمان، ومسارات إدارة مغلقة، وشهادات الهواتف</span></li><li><strong>4. الحاويات</strong><span>للقراءة فقط، بلا صلاحيات Linux، بشبكات داخلية، والأسرار ملفات</span></li><li><strong>3. الـ API</strong><span>رموز متحقَّق منها، والمحطة تُؤخذ من الرمز، لا من الطلب أبدًا</span></li><li><strong>2. أدوار قاعدة البيانات</strong><span>لا مستخدم خارق، ولا تجاوز، ودفتر وسجل تدقيق للإضافة فقط</span></li><li><strong>1. أمان الصفوف</strong><span>كل صف مربوط بعضوية نشطة في محطة المستخدم</span></li></ol></figure>
<h2 id="الطبقة-1-صفوف-ترفض-المحطة-الخطأ">الطبقة 1: صفوف ترفض المحطة الخطأ</h2>
<p>الحد المعتاد بين المستأجرين شرط <code>WHERE</code> يتكرر في كل استعلام. يصمد حتى يأتي استعلام كُتب على عجل فيُسقطه، والخطأ عندها صامت: الصفحة تعمل، لكنها تعرض أكثر مما ينبغي.</p>
<p>FinyxWatt تنقل الحد إلى داخل قاعدة البيانات. أمان الصفوف مفعّل ومُجبَر على 88 جدولًا من أصل 94 في ترحيلاتها (migrations). الجداول الستة الباقية تحمل قائمة الصلاحيات، وبيانات المخطط، وسجلات استيراد جرى مرة واحدة. وكل سياسة (policy) تتحقق من محطة الصف، ومن أن المستخدم عضو نشط فيها:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">ALTER</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> TABLE</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> invoices </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">ENABLE</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> ROW</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> LEVEL</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> SECURITY</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">;</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">ALTER</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> TABLE</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> invoices </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">FORCE</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> ROW</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> LEVEL</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> SECURITY</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">CREATE</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> POLICY</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> tenant_scope_api </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">ON</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> invoices </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">FOR</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> ALL </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">TO</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> platform_api</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  USING</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> (</span></span>
<span class="line"><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">    tenant_id </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">=</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> app_private</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">.</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">current_tenant_id</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">    AND</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> EXISTS</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> (</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">      SELECT</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> FROM</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> tenant_memberships m</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">      WHERE</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> m</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">.</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">tenant_id</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">   =</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> app_private</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">.</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">current_tenant_id</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">        AND</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> m</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">.</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">identity_id</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> =</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> app_private</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">.</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">current_identity_id</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">        AND</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> m</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">.</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">status</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> =</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">active</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span></span>
<span class="line"><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">    )</span></span>
<span class="line"><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">  )</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  WITH</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> CHECK</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> ( </span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">/* الشرط نفسه */</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> );</span></span></code></pre></figure>
<p>الترحيل الذي أضاف شرط العضوية يبدأ بسببه: &quot;خطأ برمجي في المستقبل يجب ألا يحوّل هوية متحققًا منها من المستأجر A إلى المستأجر B لمجرد ضبط خاطئ لإعداد المعاملة&quot;. معرّف محطة خاطئ لن يجد عضوية نشطة، فيعود الاستعلام فارغًا.</p>
<p>ويضبط الـ API القيمتين في بداية كل معاملة (transaction)، من الرمز الذي تحقق منه:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">async</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> withTenant</span><span style="--shiki-light:#999999;--shiki-dark:#666666">&#x3C;</span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">T</span><span style="--shiki-light:#999999;--shiki-dark:#666666">>(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">tenantId</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">: </span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">string</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> identityId</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">: </span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">string</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> callback</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">: </span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">trx</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">Transaction</span><span style="--shiki-light:#999999;--shiki-dark:#666666">&#x3C;</span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">Database</span><span style="--shiki-light:#999999;--shiki-dark:#666666">>)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> Promise</span><span style="--shiki-light:#999999;--shiki-dark:#666666">&#x3C;</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">T</span><span style="--shiki-light:#999999;--shiki-dark:#666666">>)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  return</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> this</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">db</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">transaction</span><span style="--shiki-light:#999999;--shiki-dark:#666666">().</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">execute</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">async</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">trx</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">    await</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> sql</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">`</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">select set_config('app.tenant_id', </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">${</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">tenantId</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">}</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">, true)</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">`</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">execute</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">trx</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">    await</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> sql</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">`</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">select set_config('app.identity_id', </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">${</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">identityId</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">}</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">, true)</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">`</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">execute</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">trx</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">    return</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> callback</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">trx</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">  })</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span></code></pre></figure>
<p>ثلاث تفاصيل تقرر هل ينجح هذا كله.</p>
<p><strong>الـ <code>true</code> الأخيرة</strong> تحصر الإعداد في المعاملة. أما <code>SET</code> العادية فتبقى على الاتصال، فيسلّمه الـ pool إلى الطلب التالي وهو ما زال يشير إلى المحطة A.</p>
<p><strong>السلسلة الفارغة</strong> هي الفخ الأول. على اتصال جديد، الإعداد الذي لم يُعرَّف قط يُقرأ <code>NULL</code>. أما على اتصال من الـ pool خدم طلبًا من قبل، فيُقرأ <code>''</code>، و<code>''::uuid</code> يرمي خطأ. والدالة التي تستدعيها كل سياسة تعالج الحالتين:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">CREATE</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> FUNCTION</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> app_private</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">.current_tenant_id</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> RETURNS</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> uuid</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">LANGUAGE</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> sql</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> STABLE PARALLEL </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">SAFE</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> AS</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> $</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">function</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">$</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  SELECT</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> nullif</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">(current_setting(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">app.tenant_id</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">, true), </span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">''</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">)::uuid;</span></span>
<span class="line"><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">$</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">function</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">$;</span></span></code></pre></figure>
<p>و<code>NULL</code> لا يساوي شيئًا، فالطلب الذي يصل بلا سياق يرى صفر صفوف. يفشل مغلقًا (fail closed).</p>
<p><strong><code>FORCE</code></strong> مهم لأن مالك الجدول يتخطى سياساته افتراضيًا، والمستخدم الخارق وأدوار <code>BYPASSRLS</code> تتخطاها دائمًا. وهذه مهمة الطبقة الثانية.</p>
<aside class="callout callout-note"><p class="callout-label">ملاحظة</p><p>رأس الترحيل يحدد مداه بوضوح: &quot;هذه الإعدادات تحمي من نسيان شرط المستأجر، ولا تجعل تنفيذ SQL عشوائي آمنًا&quot;. الاستعلامات ذات المعاملات (parameterized) تبقى واجبة في كل مكان.</p>
</aside>
<p>ويطبّق الـ CI كل الترحيلات على PostgreSQL جديدة، ويشغّل اختبارات SQL هجومية بدور الـ API نفسه. هذا قلب واحد منها، جاهز للنسخ:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">SET</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> ROLE</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> platform_api;</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">SET</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> LOCAL</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> app</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">.</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">tenant_id</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">   =</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">&#x3C;station A></span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">;</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">SET</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> LOCAL</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> app</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">.</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">identity_id</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> =</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">&#x3C;an active member of station A></span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">DO $test$</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">BEGIN</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  IF</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> (</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">SELECT</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> count</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">(</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">*</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">) </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">FROM</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> customers) </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">&#x3C;></span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> THEN</span></span>
<span class="line"><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">    RAISE EXCEPTION </span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">RLS expected one station-A customer</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">;</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  END</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> IF</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  BEGIN</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">    INSERT INTO</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> customers (tenant_id, subscriber_number, display_name)</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">    VALUES</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> (</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">&#x3C;station B></span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">, </span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">ILLEGAL</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">, </span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">Cross-tenant write</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">);</span></span>
<span class="line"><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">    RAISE EXCEPTION </span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">RLS accepted a cross-tenant write</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">;</span></span>
<span class="line"><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">  EXCEPTION </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">WHEN</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> insufficient_privilege </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">THEN</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> NULL</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">;</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  END</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">;</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">END</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> $test$;</span></span></code></pre></figure>
<p>كل هذا يفترض أن الـ API يتصل بدور تنطبق عليه السياسات.</p>
<h2 id="الطبقة-2-حسابات-لا-تستطيع-إطفاءه">الطبقة 2: حسابات لا تستطيع إطفاءه</h2>
<p>اتصل بصفة المالك دون <code>FORCE</code>، أو بمستخدم خارق، أو بدور <code>BYPASSRLS</code>، وتصير كل السياسات السابقة زينة.</p>
<p>قاعدة FinyxWatt تنشئ حساباتها عند أول تشغيل، &quot;قبل أن يُسمح لصورة الـ API بالاتصال أصلًا&quot;، بتعبير السكربت. مختصرًا، والسكربت الحقيقي يأخذ الأسماء من البيئة وكلمات المرور من ملفات سرية:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">CREATE</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> ROLE</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> migrator  </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">LOGIN</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> NOSUPERUSER NOCREATEDB CREATEROLE   NOREPLICATION NOBYPASSRLS;</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">CREATE</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> ROLE</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> api_login </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">LOGIN</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">ALTER</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> DATABASE</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> app </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">OWNER</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> TO</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> migrator;</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">REVOKE</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> CONNECT</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> ON</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> DATABASE</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> app </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">FROM</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> PUBLIC;</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">GRANT</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  CONNECT</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> ON</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> DATABASE</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> app </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">TO</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> migrator, api_login;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">GRANT</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> platform_api </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">TO</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> api_login;   </span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">-- الدور الذي كُتبت له كل السياسات</span></span></code></pre></figure>
<p>حساب الترحيل وحده يملك المخطط. لا يحمل أي حساب تشغيل كلمة مرور الترحيل، والـ API يعمل بـ <code>RUN_MIGRATIONS: &quot;false&quot;</code>. وخمسة أدوار تشغيل تتقاسم العمل:</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>الدور</th>
<th>يستطيع</th>
<th>لا يستطيع</th>
</tr>
</thead>
<tbody><tr>
<td><code>platform_api</code></td>
<td>بيانات المحطة عبر السياسات، والإضافة إلى الدفتر وسجل التدقيق</td>
<td>تعديل قيد في الدفتر أو حدث تدقيق، أو حذفه</td>
</tr>
<tr>
<td><code>platform_worker</code></td>
<td>تسليم الإشعارات ومهام الرسائل من الطابور</td>
<td>أن يتقاسم مع الـ API حسابه أو كلمة مروره</td>
</tr>
<tr>
<td><code>platform_control</code></td>
<td>لوحة تحكم المنصة</td>
<td>العمل دون هوية منصة في المعاملة</td>
</tr>
<tr>
<td><code>platform_auditor</code></td>
<td>قراءة سجل التدقيق وسجل التغييرات</td>
<td>كتابة أي شيء</td>
</tr>
<tr>
<td><code>platform_sms_enrollment</code></td>
<td>الرموز المؤقتة وطلبات الهواتف المعلّقة</td>
<td>رؤية مهام الرسائل أو الأجهزة أو سجلات التدقيق أو العملاء</td>
</tr>
</tbody></table></div>
<p>والدفتر وسجل التدقيق يرفضان التعديل مرتين:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">GRANT</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> SELECT</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">, </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">INSERT</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> ON</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> ledger_entries </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">TO</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> platform_api;   </span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">-- لا UPDATE ولا DELETE</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">CREATE</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> FUNCTION</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> app_private</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">.prevent_ledger_mutation</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> RETURNS</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> trigger</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">LANGUAGE</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> plpgsql </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">SECURITY</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> INVOKER </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">SET</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> search_path </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">=</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> pg_catalog, public </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">AS</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> $</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">function</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">$</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">BEGIN</span></span>
<span class="line"><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">  RAISE EXCEPTION </span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">ledger entries are append-only; post a reversal entry instead</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">    USING</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> ERRCODE </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">=</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">integrity_constraint_violation</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">;</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">END</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">;</span></span>
<span class="line"><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">$</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">function</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">$;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">CREATE</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> TRIGGER</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> ledger_entries_append_only</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">BEFORE</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> UPDATE</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> OR</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> DELETE</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> ON</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> ledger_entries</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">FOR</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> EACH </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">ROW</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> EXECUTE</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> FUNCTION</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> app_private</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">.</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">prevent_ledger_mutation</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">;</span></span></code></pre></figure>
<p>قد يوسّع ترحيل لاحق صلاحية بالخطأ. يبقى الـ trigger رافضًا، ورسالته تخبر المطوّر التالي بما يفعله بدلًا من ذلك: القيود للإضافة فقط، والتصحيح يكون بقيد عكسي.</p>
<aside class="callout callout-tip"><p class="callout-label">نصيحة</p><p>شغّل هذين الاستعلامين على قاعدتك اليوم. الأول يجب ألا يعرض إلا حسابات إدارة لا يستخدمها أي تطبيق. وكل جدول يظهر في الثاني يجب أن يكون جدولًا استثنيته عن قصد.</p>
</aside>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">-- حسابات تتخطى أمان الصفوف</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">SELECT</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> rolname </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">FROM</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> pg_roles</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">WHERE</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> rolcanlogin </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">AND</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> (rolsuper </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">OR</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> rolbypassrls);</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">-- جداول ليس عليها أمان صفوف مفعّل ومُجبَر معًا</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">SELECT</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> relname </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">FROM</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> pg_class</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">WHERE</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> relkind </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">=</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">r</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> AND</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> relnamespace </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">=</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">public</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">::regnamespace</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  AND</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> NOT</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> (relrowsecurity </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">AND</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> relforcerowsecurity);</span></span></code></pre></figure>
<p>الأدوار تحدد ما يحق للحساب فعله. أما المحطة التي يعمل باسمها فتأتي من رمز.</p>
<h2 id="الطبقة-3-api-لا-يصدّق-إلا-ما-تحقق-منه">الطبقة 3: API لا يصدّق إلا ما تحقق منه</h2>
<p>تسجّل FinyxWatt دخول المستخدمين عبر Keycloak. والـ API يتحقق من توقيع كل رمز، ومُصدِره، والجهة الموجَّه إليها، وصلاحيته الزمنية، مقابل المفاتيح التي ينشرها Keycloak:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">this</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">jwks</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ??</span><span style="--shiki-light:#999999;--shiki-dark:#666666">=</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> createRemoteJWKSet</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">new</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> URL</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">this</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">config</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">keycloakJwksUri</span><span style="--shiki-light:#999999;--shiki-dark:#666666">))</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const </span><span style="--shiki-light:#999999;--shiki-dark:#666666">{</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> payload</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> await</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> jwtVerify</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">token</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> this</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">jwks</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  issuer</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">this</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">config</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">keycloakIssuer</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  audience</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">this</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">config</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">keycloakAudience</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">})</span></span></code></pre></figure>
<p>ثم يقرأ الـ claim المتحقَّق منه <code>azp</code> ليعرف أي تطبيق حصل على الرمز. والتعليق فوق هذا الفحص هو درس الطبقة كله: &quot;لا تقبل أبدًا ترويسة تحدد نوع التطبيق: يستطيع المتصل تزويرها ليلتفّ برمز أندرويد على مسار لا يقبل إلا الـ cookies&quot;.</p>
<p>والمحطة تتبع القاعدة نفسها. الـ API لا يقبل إلا عضويات الهوية المتحقق منها، ثم يعيد قراءة العضوية تحت RLS. والجلسات قصيرة، ورموزها لمرة واحدة:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">accessTokenLifespan</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">: </span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">300</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">,</span></span>
<span class="line"><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">revokeRefreshToken</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">: </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">true</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">,</span></span>
<span class="line"><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">refreshTokenMaxReuse</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">: </span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">0</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">,</span></span>
<span class="line"><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">bruteForceProtected</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">: </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">true</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">,</span></span>
<span class="line"><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">failureFactor</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">: </span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">5</span></span></code></pre></figure>
<p>رمز الوصول يعيش خمس دقائق. ورمز التحديث يعمل مرة واحدة، فالنسخة المسروقة منه تُستهلك لحظة يستخدمه أي من الطرفين. وخمس كلمات مرور خاطئة تجعل الحساب ينتظر.</p>
<p>حتى الآن كان المهاجم خطأً في كودي أنا. الطبقات التالية تفترض أنه صار داخل حاوية.</p>
<h2 id="الطبقة-4-حاويات-لا-تكاد-تستطيع-شيئًا">الطبقة 4: حاويات لا تكاد تستطيع شيئًا</h2>
<p>يعمل هذا الموقع في حاوية Caddy واحدة، تعريفها هكذا:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">web</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  image</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> caddy:2.10.2-alpine</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  ports</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> [</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">80:80</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">443:443</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">443:443/udp</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  volumes</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    -</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> ./Caddyfile:/etc/caddy/Caddyfile:ro</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    -</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> /srv/moghalis:/srv/moghalis:ro</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    -</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> caddy_data:/data</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    -</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> caddy_config:/config</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  read_only</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> true</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  tmpfs</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> [</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">/tmp</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  cap_drop</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> [</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">ALL</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  cap_add</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> [</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">NET_BIND_SERVICE</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  security_opt</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> [</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">no-new-privileges:true</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  mem_limit</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> 128m</span></span></code></pre></figure>
<p>لا شيء يُكتب خارج <code>/tmp</code> ووحدتي تخزين الشهادات، وكل صلاحيات Linux مسحوبة إلا ربط المنافذ المنخفضة، ولا عملية تستطيع رفع صلاحياتها، والذاكرة تقف عند 128 ميغابايت.</p>
<p>وFinyxWatt تشغّل عشرين خدمة، كلها بـ <code>no-new-privileges</code>، وبوابتها Caddy للقراءة فقط وبلا أي صلاحيات. والحماية الأقوى هي خريطة الشبكات:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">networks</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  edge</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  application</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#999999;--shiki-dark:#666666">   {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> internal</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> true</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  database</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#999999;--shiki-dark:#666666">      {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> internal</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> true</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  observability</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> internal</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> true</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  sms_gateway</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#999999;--shiki-dark:#666666">   {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> internal</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> true</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  egress</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span></span></code></pre></figure>
<p>الشبكة <code>internal</code> لا طريق منها إلى الخارج، وكل خدمة لا تنضم إلا إلى ما تحتاجه:</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>الخدمة</th>
<th>الشبكات</th>
</tr>
</thead>
<tbody><tr>
<td>Caddy، البوابة</td>
<td>edge، application، sms_gateway، observability</td>
</tr>
<tr>
<td>الـ API</td>
<td>application، database، egress، observability</td>
</tr>
<tr>
<td>بوابة الرسائل</td>
<td>sms_gateway، database</td>
</tr>
<tr>
<td>تسجيل الهواتف</td>
<td>application، database</td>
</tr>
<tr>
<td>واجهة الويب، وتخزين الملفات</td>
<td>application</td>
</tr>
</tbody></table></div>
<p>البوابة لا طريق لها إلى قاعدة البيانات، وخدمات الصفوف الثلاثة الأخيرة لا طريق لها إلى الإنترنت.</p>
<p>تحتاج FinyxWatt في الإنتاج إلى 28 سرًا. كل سر ملف مستقل بقيمة عشوائية خاصة به، يُركَّب سرًّا من أسرار Docker في الحاويات التي تحتاجه فقط:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">api</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  environment</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">    DB_USER</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> ${PLATFORM_API_USER}</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">    DB_PASSWORD_FILE</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> /run/secrets/platform_api_password</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  secrets</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    -</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> platform_api_password</span></span></code></pre></figure>
<p>ودليل الأسرار يختصر القاعدة في جملة: &quot;لا تضع الأسرار في وسائط سطر الأوامر، ولا في ملفات <code>.env</code>، ولا في سجل الطرفية، ولا في صور الحاويات، ولا في نظام إدارة الكود&quot;. متغير البيئة يظهر في <code>docker inspect</code>، أما الملف تحت <code>/run/secrets</code> فلا يوجد إلا حيث رُكِّب.</p>
<h2 id="الطبقة-5-بوابة-تجيب-أقل">الطبقة 5: بوابة تجيب أقل</h2>
<p>Caddy على خادم هذا الموقع يجدّد شهادات HTTPS، ويضيف هذه الترويسات إلى كل رد:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span>header {</span></span>
<span class="line"><span>	Strict-Transport-Security "max-age=31536000; includeSubDomains"</span></span>
<span class="line"><span>	X-Content-Type-Options "nosniff"</span></span>
<span class="line"><span>	X-Frame-Options "SAMEORIGIN"</span></span>
<span class="line"><span>	Referrer-Policy "strict-origin-when-cross-origin"</span></span>
<span class="line"><span>	Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()"</span></span>
<span class="line"><span>	-Server</span></span>
<span class="line"><span>}</span></span></code></pre></figure>
<p>وبوابة FinyxWatt تغلق أيضًا كل ما لا تخدمه:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span>{</span></span>
<span class="line"><span>	admin off</span></span>
<span class="line"><span>}</span></span>
<span class="line"><span></span></span>
<span class="line"><span>(restricted_console) {</span></span>
<span class="line"><span>	@outside_trusted_networks not remote_ip {$ADMIN_TRUSTED_CIDRS}</span></span>
<span class="line"><span>	respond @outside_trusted_networks 403</span></span>
<span class="line"><span>	basic_auth {</span></span>
<span class="line"><span>		{$OBSERVABILITY_USER} {$OBSERVABILITY_PASSWORD_HASH}</span></span>
<span class="line"><span>	}</span></span>
<span class="line"><span>}</span></span></code></pre></figure>
<p><code>admin off</code> يطفئ واجهة الإدارة الخاصة بـ Caddy نفسه. ولوحات المراقبة لا تجيب إلا من شبكات موثوقة، ثم تطلب كلمة مرور لا يحفظ الخادم منها إلا تجزئة bcrypt. والمسار <code>/metrics</code> يعيد 404 من الإنترنت، ومسار تسجيل الهواتف العام لا يقبل أكثر من 32 كيلوبايت، لأنه لا يستقبل ملفًا أبدًا. وكل ترويسة أمان تأتي من مكان واحد: الـ API يضع <code>X-Frame-Options</code> بنفسه، فتتركها كتلة الـ API في Caddy.</p>
<p>أما أمتع مهام البوابة فهي فحص جواز سفر كل هاتف يرسل الفواتير.</p>
<h2 id="جواز-سفر-لكل-هاتف">جواز سفر لكل هاتف</h2>
<p>ترسل FinyxWatt فاتورة كل عميل برسالة قصيرة من هواتف أندرويد مخصصة في المحطة. والهاتف الذي يرسل باسم محطة يحتاج أكثر من كلمة مرور، فيحصل على شهادة عميل (client certificate).</p>
<figure class="post-figure dg dg-is-timeline"><ol class="dg-timeline"><li><strong>على الهاتف</strong><span>يُنشأ مفتاح داخل Android Keystore ولا يغادره أبدًا</span></li><li><strong>إلى خدمة التسجيل</strong><span>رمز لمرة واحدة وإثبات امتلاك المفتاح ينشئان طلبًا معلّقًا، لا أكثر</span></li><li><strong>مدير ثانٍ يراجع</strong><span>منشئ الرمز لا يحق له الاعتماد، والاعتماد يربط شهادة بالمفتاح المُثبَت</span></li><li><strong>عودة إلى الهاتف</strong><span>تُثبَّت الشهادة</span></li><li><strong>كل اتصال</strong><span>Caddy يشترط الشهادة ويتحقق منها، ثم يكتب ترويسة البصمة بنفسه</span></li></ol></figure>
<p>قاعدة البيانات لا تحفظ إلا HMAC للرمز، مصنوعًا بقيمة سرية (pepper)، والمخطط نفسه يفرض الحدود:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">-- HMAC-SHA-256(code-id || secret), never the raw enrollment code.</span></span>
<span class="line"><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">code_digest  </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">bytea</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> NOT NULL</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> CHECK</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> (octet_length(code_digest) </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">=</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 32</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">),</span></span>
<span class="line"><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">expires_at   </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">timestamptz</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> NOT NULL</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">,</span></span>
<span class="line"><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">max_attempts </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">smallint</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> NOT NULL</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> DEFAULT</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 5</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> CHECK</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> (max_attempts </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">BETWEEN</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> AND</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 10</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">),</span></span>
<span class="line"><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">attempts     </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">smallint</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> NOT NULL</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> DEFAULT</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> CHECK</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> (attempts </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">BETWEEN</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> AND</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 10</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">),</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">CONSTRAINT</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> sms_device_enrollment_codes_attempts_bound </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">CHECK</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> (attempts </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">&#x3C;=</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> max_attempts),</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">CONSTRAINT</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> sms_device_enrollment_codes_expiry </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">CHECK</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> (expires_at </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">></span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> created_at)</span></span></code></pre></figure>
<p>والاعتماد يمر عبر الـ API الرئيسي، الذي يرفض منشئ الرمز بالخطأ <code class="code-long">SMS_ENROLLMENT_SELF_APPROVAL_FORBIDDEN</code>، ويربط شهادة من جهة إصدار خاصة يطابق مفتاحها العام المفتاحَ الذي أثبت الهاتف امتلاكه. ومن بعدها:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span>tls {</span></span>
<span class="line"><span>	client_auth {</span></span>
<span class="line"><span>		mode require_and_verify</span></span>
<span class="line"><span>		trust_pool file /run/secrets/sms_device_ca_pem</span></span>
<span class="line"><span>	}</span></span>
<span class="line"><span>}</span></span>
<span class="line"><span>reverse_proxy sms-gateway:8081 {</span></span>
<span class="line"><span>	header_up X-Verified-Client-Certificate-Fingerprint {tls_client_fingerprint}</span></span>
<span class="line"><span>}</span></span></code></pre></figure>
<p>وهنا الفخ الثاني. بوابة الرسائل تصدّق ترويسة البصمة دون سؤال، وهذا آمن لسبب واحد: البوابة تقع على شبكة داخلية لا ينضم إليها إلا Caddy، وCaddy يكتب الترويسة فوق أي قيمة سابقة بعد أن يتحقق من الشهادة. وكود البوابة نفسه يشرح كيف يُكسر هذا: &quot;لا تنشر منفذ حاوية البوابة، ولا تضف حاويات عشوائية إلى تلك الشبكة؛ أيٌّ منهما يجعل هذه الترويسة قابلة للتزوير&quot;. سطر <code>ports:</code> واحد يُضاف لتجربة سريعة، ويصير بوسع أي أحد أن يرسل الترويسة بنفسه.</p>
<p>والـ proxy يخفي فخًا آخر. كل طلب يصل إلى البوابة بعنوان Caddy، فحدّ الطلبات لكل IP سيعطي الهواتف كلها ميزانية واحدة مشتركة. لذلك تبني البوابة حدّها على الشهادة المتحقق منها:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">await</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> app</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">register</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">fastifyRateLimit</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  max</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">120</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  timeWindow</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">1 minute</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">  keyGenerator</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">request</span><span style="--shiki-light:#999999;--shiki-dark:#666666">) => {</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    const </span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">fingerprint</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> request</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">headers</span><span style="--shiki-light:#999999;--shiki-dark:#666666">[</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">x-verified-client-certificate-fingerprint</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    const </span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">normalized</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> typeof </span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">fingerprint</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> === </span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">string</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ? </span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">fingerprint</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">trim</span><span style="--shiki-light:#999999;--shiki-dark:#666666">().</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">toLowerCase</span><span style="--shiki-light:#999999;--shiki-dark:#666666">().</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">replaceAll</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">:</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> ''</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> : </span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">''</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">    return</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> /</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">^</span><span style="--shiki-light:#999999;--shiki-dark:#666666">[</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">0-9a-f</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">{64}</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">$</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">/</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">test</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">normalized</span><span style="--shiki-light:#999999;--shiki-dark:#666666">) </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">?</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> `</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">device:</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">${</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">normalized</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">}</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">`</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> :</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> `</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">addr:</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">${</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">request</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">ip</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">}</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">`</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">  },</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">})</span></span></code></pre></figure>
<p>والمفتاح الخاص لجهة الإصدار يبقى خارج الاتصال، وشهادة كل هاتف تُلغى حين يُلغى جهازه.</p>
<h2 id="الطبقة-6-خادم-لا-يغلق-الباب-في-وجهك">الطبقة 6: خادم لا يغلق الباب في وجهك</h2>
<p>جُهّز خادم هذا الموقع بسكربت واحد، والجزء الخاص بـ SSH فيه حريص على الترتيب:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">if</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> [</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> -s</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">/home/$USER_NAME/.ssh/authorized_keys</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> ];</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> then</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">  cat</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ></span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">/etc/ssh/sshd_config.d/10-moghalis.conf</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> &#x3C;&#x3C;</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'EOF'</span></span>
<span class="line"><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">PermitRootLogin no</span></span>
<span class="line"><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">PasswordAuthentication no</span></span>
<span class="line"><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">KbdInteractiveAuthentication no</span></span>
<span class="line"><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">EOF</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">  sshd</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -t</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> &#x26;&#x26;</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> systemctl</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> reload</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> ssh</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">else</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  echo</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">!! No SSH key for $USER_NAME yet: password login left ON.</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">fi</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">ufw</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> default</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> deny</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> incoming</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">ufw</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> allow</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> OpenSSH</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">ufw</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> allow</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> 80/tcp</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">ufw</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> allow</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> 443/tcp</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">ufw</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> allow</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> 443/udp</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">ufw</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> --force</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> enable</span></span></code></pre></figure>
<p>الدخول بكلمة المرور لا يُطفأ إلا إن وُجد مفتاح، وSSH لا يُعاد تحميله إلا إن قبل <code>sshd -t</code> الإعداد، والجدار الناري يسمح بـ SSH قبل أن يُرفع. اعكس أيًّا من الثلاثة على VPS جديد، وستكون محطتك التالية شاشة الإنقاذ عند مزوّد الخادم.</p>
<p>ست طبقات، كل واحدة تفترض أن التي فوقها تسقط. والنسخة الاحتياطية تفترض أنها سقطت كلها.</p>
<h2 id="بجانب-الطبقات-نسخة-احتياطية-تثبت-أنها-تُستعاد">بجانب الطبقات: نسخة احتياطية تثبت أنها تُستعاد</h2>
<p>تؤرشف PostgreSQL في FinyxWatt سجل الكتابة المسبق (WAL) كل خمس دقائق على الأقل، وتأخذ نسخة أساسية كاملة كل ليلة عبر WAL-G، وإصدار WAL-G نفسه مثبّت ومتحقق من بصمته. كل ملف يُضغط ويُشفَّر قبل أن يُكتب. وجهاز مكتب المالك يسحب الملفات الجديدة كل أربع ساعات ويحتفظ بخمسة وأربعين يومًا منها.</p>
<p>مفتاح التشفير هو النسخة الاحتياطية نفسها: إن ضاع صار كل ملف ضجيجًا. لذلك هو مختوم مع بقية أسرار الخادم في ملف محمي بكلمة سر، على ذاكرة USB وفي مكان ثانٍ، وكلمة السر محفوظة بعيدًا عنهما.</p>
<p>ولا شيء من ذلك يثبت شيئًا حتى تنجح استعادة. كل يوم جمعة يستعيد سكربت أحدث نسخة أساسية وكل سجل مؤرشف بعدها في وحدة تخزين مؤقتة. مختصرًا:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">timeout</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> --kill-after=60</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 2700</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> docker</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> run</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> --rm</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> --network</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> none</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> --name</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$name</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> \</span></span>
<span class="line"><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">  -e</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> PITR_STORAGE_MODE=local</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -e</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> WALG_DOWNLOAD_FILE_RETRIES=</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">2</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> \</span></span>
<span class="line"><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">  -v</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$name:/restore</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> \</span></span>
<span class="line"><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">  -v</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$repository:/local-pitr:ro</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> \</span></span>
<span class="line"><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">  -v</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$key:/run/secrets/pitr_encryption_key:ro</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> \</span></span>
<span class="line"><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">  --entrypoint</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> bash</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$image</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> /restore-check.sh</span></span></code></pre></figure>
<p>وفي الداخل، تُضاف هذه الأسطر إلى إعداد النسخة قبل أن تبدأ PostgreSQL:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">cat</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> >></span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> /restore/data/postgresql.auto.conf</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> &#x3C;&#x3C;</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'CONF'</span></span>
<span class="line"><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">restore_command = '/usr/local/bin/pitr-walg wal-fetch %f %p'</span></span>
<span class="line"><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">archive_mode = off</span></span>
<span class="line"><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">listen_addresses = ''</span></span>
<span class="line"><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">CONF</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">touch</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> /restore/data/recovery.signal</span></span></code></pre></figure>
<p>كل سطر حارس. بلا شبكة، فلا تلمس النسخة قاعدة البيانات الحية. والمستودع والمفتاح للقراءة فقط. لا أرشفة، ولا منفذ يستمع. و<code>$image</code> يُقرأ من حاوية قاعدة البيانات الحية، فيستعيد الفحص بالصورة نفسها التي يعمل بها الإنتاج. وحين تنتهي الاستعادة، يعدّ ما عاد:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">SELECT</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> json_build_object(</span></span>
<span class="line"><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">  '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">migrations</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">,   (</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">SELECT</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> count</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">(</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">*</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">) </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">FROM</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> schema_migrations),</span></span>
<span class="line"><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">  '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">stations</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">,     (</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">SELECT</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> count</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">(</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">*</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">) </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">FROM</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> tenants),</span></span>
<span class="line"><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">  '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">customers</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">,    (</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">SELECT</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> count</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">(</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">*</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">) </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">FROM</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> customers),</span></span>
<span class="line"><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">  '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">bills</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">,        (</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">SELECT</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> count</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">(</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">*</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">) </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">FROM</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> invoices),</span></span>
<span class="line"><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">  '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">receipts</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">,     (</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">SELECT</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> count</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">(</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">*</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">) </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">FROM</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> payments),</span></span>
<span class="line"><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">  '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">newestChange</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">, (</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">SELECT</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> max</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">(changed_at) </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">FROM</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> change_log));</span></span></code></pre></figure>
<p>الأعداد تقول إن النسخة تحمل ما ينبغي، وأحدث تغيير يقول كم هي حديثة. ثم يُحذف كل شيء، أيًّا كانت النتيجة. وإن فشل الفحص، أو مرت تسعة أيام بلا فحص، يصلني تنبيه في تطبيق المطوّر.</p>
<p>والآن الفخ الثالث، وسبب <code>WALG_DOWNLOAD_FILE_RETRIES=2</code>. يعيد WAL-G محاولة الملف الذي يتعذر عليه قراءته 15 مرة، تفصل بينها دقائق. وجّهه إلى نسخة تالفة أو لا يمكن فك تشفيرها، ولن يفشل الفحص. سينتظر، لساعات. محاولتان وسقف 45 دقيقة يحوّلان هذا الانتظار إلى فشل واضح مع سببه.</p>
<figure class="post-figure dg dg-is-flow"><ol class="dg-flow is-long"><li><strong>النسخة المشفرة</strong><span>أحدث نسخة أساسية وكل ملفات WAL بعدها، للقراءة فقط</span></li><li><strong>حاوية مختومة</strong><span>بلا شبكة، ولا أرشفة، ولا منفذ يستمع، و45 دقيقة على الأكثر</span></li><li><strong>استعادة PostgreSQL</strong><span>تُبنى القاعدة بالصورة التي يعمل بها الإنتاج</span></li><li><strong>عدّ ما عاد</strong><span>الترحيلات والمحطات والعملاء والفواتير والسندات وأحدث تغيير</span></li><li><strong>التنظيف</strong><span>تُحذف الحاوية ووحدة التخزين، والنتيجة تذهب إلى المراقب</span></li></ol></figure>
<p>ومراقب يعمل كل عشر دقائق ويبلّغ عما يكسر النسخ الاحتياطية بصمت: قرص امتلأ 85%، وخدمة توقفت، ونسخة كاملة أقدم من 26 ساعة، وأرشفة WAL متعثرة، وجهاز مكتب لم يسحب النسخ منذ ثلاثة أيام، وشهادة أمان تقترب من انتهائها.</p>
<aside class="callout callout-warning"><p class="callout-label">انتبه</p><p>لا تَعِد بزمن استعادة لم تقسه. دليل الاستعادة في FinyxWatt يمنع إعلان أرقام الاستعادة المستهدفة حتى تنجح استعادة موقوتة على المستودع الحقيقي.</p>
</aside>
<h2 id="البوابة-أمام-كل-إصدار">البوابة أمام كل إصدار</h2>
<p>الطبقات تحمي النظام وهو يعمل. والبوابة تحميه من الإصدار القادم.</p>
<p>فحصٌ للمستودع يُفشل البناء عند وجود مفاتيح خاصة، أو رموز وصول، أو رموز JWT، أو رابط قاعدة بيانات فيه كلمة مرور، أو بريد إلكتروني أو رقم هاتف حقيقي، أو أي ملف <code>.env</code> أو <code>secrets/</code> متتبَّع:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">[</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">private key material</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> /</span><span style="--shiki-light:#AB5E3F;--shiki-dark:#C4704F">-----BEGIN</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(?:</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> [</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">A-Z</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">+</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">?</span><span style="--shiki-light:#AB5E3F;--shiki-dark:#C4704F"> PRIVATE KEY-----</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">/</span><span style="--shiki-light:#999999;--shiki-dark:#666666">],</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">[</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">GitHub access token</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> /</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">\b</span><span style="--shiki-light:#AB5E3F;--shiki-dark:#C4704F">gh</span><span style="--shiki-light:#999999;--shiki-dark:#666666">[</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">pousr</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span><span style="--shiki-light:#AB5E3F;--shiki-dark:#C4704F">_</span><span style="--shiki-light:#999999;--shiki-dark:#666666">[</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">A-Za-z0-9_</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">{30,}</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">\b</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">/</span><span style="--shiki-light:#999999;--shiki-dark:#666666">],</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">[</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">database URI with embedded password</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> /</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">\b</span><span style="--shiki-light:#AB5E3F;--shiki-dark:#C4704F">postgres</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(?:</span><span style="--shiki-light:#AB5E3F;--shiki-dark:#C4704F">ql</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">?</span><span style="--shiki-light:#AB5E3F;--shiki-dark:#C4704F">:</span><span style="--shiki-light:#BDA437;--shiki-dark:#E6CC77">\/\/</span><span style="--shiki-light:#999999;--shiki-dark:#666666">[</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">^</span><span style="--shiki-light:#5A6AA6;--shiki-dark:#6872AB">\s</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">:@/${</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">+</span><span style="--shiki-light:#AB5E3F;--shiki-dark:#C4704F">:</span><span style="--shiki-light:#999999;--shiki-dark:#666666">[</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">^</span><span style="--shiki-light:#5A6AA6;--shiki-dark:#6872AB">\s</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">@/${</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">{8,}</span><span style="--shiki-light:#AB5E3F;--shiki-dark:#C4704F">@</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">/</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">i</span><span style="--shiki-light:#999999;--shiki-dark:#666666">],</span></span></code></pre></figure>
<p>وهو يجمع الملفات بـ <code class="code-long">git ls-files --cached --others --exclude-standard</code>، فإن شغّلته محليًا التقط الملف قبل أن تضيفه إلى Git.</p>
<p>ثم يشغّل الـ CI كل الترحيلات على PostgreSQL جديدة مع اختبارات عزل المحطات، واختبارات الخلفية، واختبارات التكامل بدور قاعدة البيانات المقيّد، و<code class="code-long">npm audit --omit=dev --audit-level=high</code>. والنشر التلقائي ينتظر ذلك كله:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">on</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  workflow_run</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">    workflows</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> [</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">Independent Platform CI</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">    types</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> [</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">completed</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">    branches</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> [</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">main</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">concurrency</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  group</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> production-deploy</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  cancel-in-progress</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> false</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">jobs</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  deploy</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">    if</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> github.event.workflow_run.conclusion == 'success'</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">    steps</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">      -</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> uses</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> actions/checkout@v4</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">        with</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">          ref</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> ${{ github.event.workflow_run.head_sha }}</span></span></code></pre></figure>
<p>في هذه الأسطر ثلاثة فخاخ. <code>workflow_run</code> ينطلق حين يكتمل الـ CI، نجح أو فشل، فبدون شرط <code>if</code> ينشر البناء الأحمر. وسحب <code>main</code> بدل <code>head_sha</code> قد ينشر commit لم يختبره الـ CI قط. والنشر الذي يُلغى في منتصفه يترك خادمًا نصف مُرحَّل. والـ job يثبّت بصمة مفتاح الخادم قبل أول أمر SSH.</p>
<h2 id="هاجم-نظامك-بنفسك-على-الورق">هاجم نظامك بنفسك، على الورق</h2>
<p>مراجعة مكتوبة هاجمت FinyxWatt عبر كودها وبطلبات سلبية لا تغيّر شيئًا على الخوادم الحية، وسجّلت كل نتيجة مع الملف الذي يثبتها:</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>المحاولة</th>
<th>ما الذي يوقفها</th>
</tr>
</thead>
<tbody><tr>
<td>رمز مزوّر: <code>alg: none</code>، أو مُصدِر آخر</td>
<td><code>jose</code>، والمفاتيح المنشورة، والمُصدِر، والجهة الموجَّه إليها، و<code>azp</code></td>
</tr>
<tr>
<td>العمل باسم محطة أخرى عبر ترويسة العضوية</td>
<td>عضويات الهوية المتحقق منها فقط، تُعاد قراءتها تحت RLS</td>
</tr>
<tr>
<td>قراءة سجل تغييرات محطة أخرى</td>
<td>أمان الصفوف على سجل التغييرات</td>
</tr>
<tr>
<td>طلب POST من موقع آخر على جلسة الموقع</td>
<td>فحص الأصل (origin) وملفات تعريف <code>SameSite=Strict</code></td>
</tr>
<tr>
<td>انعكاس CORS بترويسة <code>Origin</code> معادية</td>
<td>لا <code>Access-Control-Allow-Origin</code> في الرد، بفحص حي</td>
</tr>
<tr>
<td>ترويسة بصمة شهادة مزوّرة</td>
<td>منفذ غير منشور، وCaddy يكتب الترويسة فوقها</td>
</tr>
<tr>
<td>تخمين رمز ربط الهاتف</td>
<td>خمس محاولات، وHMAC بقيمة سرية، ومدة صلاحية، وقفل للصف</td>
</tr>
<tr>
<td>الوصول إلى <code>/metrics</code> أو لوحة Keycloak من الخارج</td>
<td>404 و403، بفحص حي</td>
</tr>
</tbody></table></div>
<h2 id="قائمة-الفحص">قائمة الفحص</h2>
<ul>
<li>أمان الصفوف مُجبَر على كل جدول للمستأجرين، وسياساته تتحقق من عضوية نشطة.</li>
<li>سياق المستأجر لكل معاملة بـ <code>set_config(..., true)</code>، من الرمز، ويُقرأ عبر <code>nullif</code>.</li>
<li>لا حساب تطبيق هو المالك أو مستخدم خارق أو <code>BYPASSRLS</code>، وحساب لكل مهمة.</li>
<li>جداول الدفتر والتدقيق بلا صلاحيات <code>UPDATE</code> أو <code>DELETE</code>، ومعها trigger يرفضهما.</li>
<li>الأسرار ملفات تُركَّب حيث يلزم فقط، والـ CI يفشل عند المفاتيح والرموز وملفات <code>.env</code> المتتبَّعة.</li>
<li>حاويات للقراءة فقط، بلا صلاحيات، بحد للذاكرة، على شبكات داخلية.</li>
<li>ترويسات الهوية لا تُصدَّق إلا على شبكة مغلقة، وحدود الطلبات مبنية على تلك الهوية.</li>
<li>نسخ احتياطية مشفرة، ومفتاحها بعيد عنها، ونسخة خارج الخادم، وفحص استعادة بحد زمني وتنبيه.</li>
<li>لا نشر إلا بعد CI أخضر، ومن الـ commit المختبَر، ولا إلغاء في المنتصف.</li>
</ul>
<p>لا طبقة من هذه ذكية وحدها. القوة في الافتراض الذي تحتها كلها: الطبقة التي فوقك سقطت بالفعل، وعليك أن تصمد مع ذلك. اكتب هذا الافتراض في إعداداتك، ويصير اليوم الذي تسقط فيه إحداها يومًا عاديًا.</p>
<p>وإن كنت تفضّل أن يتولى أحد هذا كله لنظامك، فهذا ما تغطيه <a href="https://moghalis.com/ar/services/hosting-and-support">الاستضافة والدعم</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>الدفعة الأخيرة يجب أن تكون الأسهل: هكذا أدير مشاريع العملاء</title>
      <link>https://moghalis.com/ar/blog/how-i-deliver-projects</link>
      <guid isPermaLink="true">https://moghalis.com/ar/blog/how-i-deliver-projects</guid>
      <pubDate>Mon, 19 Oct 2026 13:00:00 GMT</pubDate>
      <description>كيف أدير مشاريع العملاء ليدفع العميل ويثق ويعود: عرض مكتوب بمراحل، وكل مرحلة تعمل على خادمه، واختبارات يقرؤها بلغته، وحزمة تسليم يشغّل بها النظام من دوني.</description>
      <category>العمل الحر</category>
      <category>العروض</category>
      <category>طريقة العمل</category>
      <category>الاختبارات</category>
      <category>التسليم</category>
      <content:encoded><![CDATA[<p>الأسبوع الأخير من مشروع حر. النظام يعمل على جهازك، ولا مكان غيره. العميل لم يره قط على خادمه هو. أكبر دفعة لم تُدفع بعد، وما وُعد به يعيش في محادثة واتساب عمرها شهور. كل عرض تجريبي من الآن مفاوضة.</p>
<p>بعض أصحاب المنشآت الذين يراسلونني عاشوا هذا الأسبوع من الجهة الأخرى. دفعوا ثمن برنامج ولم يستلموه في حال يستطيع موظفوهم العمل عليها.</p>
<p>أدير مشاريعي بحيث لا يأتي هذا الأسبوع أبدًا. وهذه الطريقة، مكتوبة للمطوّرين والمستقلين. حين تنتهي سيكون بين يديك هيكل عرض يحسم أغلب الخلافات قبل أن تبدأ، ودورة مراحل تضع برنامجًا يعمل على خادم العميل منذ المرحلة الأولى، وطريقة تجعل اختباراتك جزءًا مما يشتريه العميل، وحزمة تسليم، والأسئلة التي يطرحها العملاء الجيدون، مجابًا عنها كتابةً قبل أن يسألوها.</p>
<p>وفي ما يلي شيئان يعملان أكثر من غيرهما: قسم في العرض يمنع من الخلافات أكثر من أي قسم آخر، وسطر في الشروط يجعل الدفعة الأخيرة هي الأسهل.</p>
<aside class="post-summary"><p class="callout-label">باختصار</p><ul>
<li>لا يُبنى شيء قبل عرض مكتوب: المراحل، وما يشمله، وما لا يشمله، والشروط، في صفحة خاصة برقم مرجعي وتاريخ انتهاء.</li>
<li>كل مرحلة تنتهي وهي تعمل على خادم العميل، ويختبرها فريقه هو قبل أن تبدأ المرحلة التالية.</li>
<li>عند التسليم يستلم العميل الكود، والتوثيق، والسكربتات التي تشغّل النظام، وتقرير قبول مكتوبًا.</li>
</ul>
</aside>
<figure class="post-figure dg dg-is-compare"><div class="dg-compare"><div class="dg-compare-head"><span>حيث تتعثر المشاريع</span><span>كيف أديرها</span></div><div class="dg-compare-row"><span>النطاق يعيش في محادثة</span><span>عرض مكتوب بمراحل وقائمة لما لا يشمله</span></div><div class="dg-compare-row"><span>كشف كبير واحد في النهاية</span><span>كل مرحلة تعمل على خادم العميل</span></div><div class="dg-compare-row"><span>يعمل على جهاز المطوّر</span><span>فريق العميل يختبره على خادمه هو</span></div><div class="dg-compare-row"><span>الاختبارات تكلفة مخفية في التقدير</span><span>الاختبارات بند فيما يشتريه العميل</span></div><div class="dg-compare-row"><span>التسليم ملف مضغوط</span><span>كود وتوثيق وسكربتات وتقرير قبول</span></div><div class="dg-compare-row"><span>التعديلات متفق عليها في رسالة صوتية</span><span>التعديلات تُقدَّر وتُعتمد كتابيًا</span></div></div></figure>
<h2 id="المكالمة-الأولى-مقابلة-متطلبات">المكالمة الأولى مقابلة متطلبات</h2>
<p>وصف قصير يكفي للبداية: ماذا تعمل المنشأة، ومن سيستخدم النظام، وما الذي لا يعمل اليوم. ونموذج التواصل في موقعي يسأل أيضًا عن نوع المشروع والميزانية التقريبية، و&quot;لم أحدد بعد&quot; واحدة من الإجابات.</p>
<p>بعدها أرسم صورة العمل. ثلاثة أسئلة تحمل معظم الوزن:</p>
<ul>
<li><strong>من يستخدم النظام؟</strong> الاستقبال، والمحاسبون، ومديرو الفروع، والسائقون، والعملاء.</li>
<li><strong>ما البيانات المهمة؟</strong> الفواتير، والمخزون، وملفات المرضى، والقراءات، والصناديق.</li>
<li><strong>من يحق له فعل ماذا؟</strong> من يلغي الفاتورة، ومن يرى سعر التكلفة، ومن يعتمد الاسترجاع.</li>
</ul>
<p>السؤال الثالث يحدد من التصميم أكثر مما تحدده أي شاشة. في منصة لإدارة شبكات الإنترنت بنيتها، صارت الإجابة 49 صلاحية منفصلة موزعة على أربعة أدوار. وفي منصة طلبات لتاجر ملابس، كانت القاعدة الأساسية ألا يرى العميل سعر فئة غير فئته، وهذه القاعدة تُفرض داخل قاعدة البيانات نفسها، ولها اختباراتها.</p>
<p>ثم اطلب أن ترى الأدوات التي يعملون بها اليوم: الجدول المشترك، والنموذج الورقي، والبرنامج القديم. هي تُظهر سير العمل الحقيقي أفضل من وصف أي أحد له.</p>
<aside class="callout callout-tip"><p class="callout-label">نصيحة</p><p>اكتب إجابة السؤال الثالث جدولًا، الأدوار في جهة والصلاحيات في جهة، قبل أن ترسم شاشة واحدة. سيصير هذا الجدول أدوار قاعدة بياناتك، ومجموعة من الاختبارات، ولاحقًا صفحة في حزمة التسليم.</p>
</aside>
<h2 id="العرض-ستة-أقسام-وواحد-منها-يمنع-الخلاف">العرض: ستة أقسام، وواحد منها يمنع الخلاف</h2>
<p>لا يُبنى شيء قبل أن يحمل العميل عرضًا مكتوبًا. وعروضي تسير على هيكل واحد:</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>القسم</th>
<th>ما الذي يجيب عنه</th>
</tr>
</thead>
<tbody><tr>
<td>الوضع الحالي</td>
<td>ما الذي لا يعمل اليوم، بكلمات فريق العميل نفسه</td>
</tr>
<tr>
<td>ما سنبنيه</td>
<td>كل جزء من النظام، موصوفًا بما يفعله ولمن</td>
</tr>
<tr>
<td>المراحل والجدول</td>
<td>ما الذي يُسلَّم في نهاية كل مرحلة</td>
</tr>
<tr>
<td>الاستثمار</td>
<td>تكلفة كل مرحلة وموعد كل دفعة</td>
</tr>
<tr>
<td>ما يشمله العرض وما لا يشمله</td>
<td>ما يغطيه السعر، والرسوم الخارجية التي لا يغطيها</td>
</tr>
<tr>
<td>الشروط</td>
<td>لمن الكود، وكيف تُعالج التعديلات، وإلى متى يبقى العرض قائمًا</td>
</tr>
</tbody></table></div>
<p>القسم الذي يمنع من الخلافات أكثر من أي قسم آخر هو قائمة &quot;ما لا يشمله العرض&quot;. رسوم الخادم والنطاق، وحساب واتساب للأعمال، ورسوم متجري التطبيقات: كلها تُدفع لشركات أخرى. حين تُكتب قبل أن يوقّع العميل، لا يمكن أن يصله أي منها لاحقًا مفاجأةً تحمل اسمك.</p>
<p>وقائمة &quot;ما يشمله&quot; لها وزنها أيضًا. في نموذجي تقول: التصميم، والبرمجة، والاختبارات الآلية، والنشر على خادم العميل، وتدريب فريق الاستقبال، ومدة دعم بعد التسليم الأخير. الاختبارات الآلية بند يقرؤه العميل بعينه، وسيهمّنا هذا لاحقًا.</p>
<p>لكل مرحلة تكلفتها، ونموذجي يربط كل دفعة ببداية مرحلتها. فلا يدفع العميل أبدًا أكثر من مرحلة واحدة قبل برنامج استخدمه بنفسه، ولا تعمل أنت أبدًا أكثر من مرحلة واحدة قبل المال.</p>
<h3 id="صفحة-خاصة-برابط-لا-يُخمَّن">صفحة خاصة برابط لا يُخمَّن</h3>
<p>يصل العرض إلى العميل صفحةً في موقعي، بالعربية أو الإنجليزية، برقم مرجعي وتاريخ تنتهي عنده صلاحيته. ولا شيء في الموقع يشير إليها. وإن بنيت مثلها، فثلاث قواعد من نظام البناء عندي تستحق النسخ.</p>
<p>اسم الملف هو الرابط، لذلك يرفض البناء أي اسم لا ينتهي بذيل عشوائي من ستة أحرف على الأقل:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const </span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">SLUG</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> /</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">^</span><span style="--shiki-light:#999999;--shiki-dark:#666666">[</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">a-z0-9</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">+</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(?:</span><span style="--shiki-light:#AB5E3F;--shiki-dark:#C4704F">-</span><span style="--shiki-light:#999999;--shiki-dark:#666666">[</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">a-z0-9</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">+</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">*</span><span style="--shiki-light:#AB5E3F;--shiki-dark:#C4704F">-</span><span style="--shiki-light:#999999;--shiki-dark:#666666">[</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">a-z0-9</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">{6,}</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">$</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">/</span></span></code></pre></figure>
<p>والتوليد المسبق (prerender) يتوقف إن لم تكن صفحة العرض <code>noindex</code>. والعروض تبقى خارج الـ sitemap، وملف robots.txt يمنع مساراتها، وقائمة العروض لا تُضمَّن أبدًا في ما يصل إلى المتصفح.</p>
<p>والخادم يُبعد محركات البحث، ويُخفي الرابط من ترويسة المُحيل (referrer) حين ينقر العميل على رابط خارجي:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span>@proposals path /p/* /ar/p/*</span></span>
<span class="line"><span>header @proposals {</span></span>
<span class="line"><span>	X-Robots-Tag "noindex, nofollow, noarchive"</span></span>
<span class="line"><span>	Referrer-Policy "no-referrer"</span></span>
<span class="line"><span>}</span></span></code></pre></figure>
<h2 id="التصميم-وتغيير-الرأي-ما-زال-بدقائق">التصميم وتغيير الرأي ما زال بدقائق</h2>
<p>قبل الكود نتفق على أربعة أشياء: البيانات التي يحفظها النظام، والصلاحيات، والربط مع الأنظمة الأخرى، والشاشات الرئيسية.</p>
<p>هذه أرخص لحظة لأي أحد كي يغيّر رأيه. نقل حقل في رسم أولي يأخذ دقائق. أما تغيير طريقة حفظ البيانات بعد أن يعمل النظام فهندسة حقيقية: حين نقلت FinyxWatt، منصتي لمحطات الكهرباء والمياه، من Firebase إلى PostgreSQL، احتاج ذلك 107 ترحيلات مرتبة ومتحقق منها، والمحطات تعمل طوال الوقت.</p>
<h2 id="كل-مرحلة-تنتهي-وهي-تعمل-على-خادم-العميل">كل مرحلة تنتهي وهي تعمل على خادم العميل</h2>
<p>يُقسَّم العمل إلى مراحل، وكل مرحلة مفيدة بذاتها. في نموذج العرض الذي أستخدمه، يُقسَّم نظام لمجمع طبي هكذا:</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>المرحلة</th>
<th>ما يُسلَّم</th>
</tr>
</thead>
<tbody><tr>
<td>1. الأساس</td>
<td>المواعيد، وملف المريض، والصلاحيات</td>
</tr>
<tr>
<td>2. الفوترة</td>
<td>الفاتورة من الزيارة نفسها، والمدفوعات، والتقارير</td>
</tr>
<tr>
<td>3. تطبيق المرضى</td>
<td>الحجز والتذكير على أندرويد وiOS</td>
</tr>
</tbody></table></div>
<p>كل مرحلة تنتهي بنسخة تعمل على خادم العميل، أو على الاستضافة التي يسمّيها العرض، ويختبرها فريقه قبل أن تبدأ المرحلة التالية. وداخل المرحلة أعمل بدورات قصيرة، ببرنامج يعمل، واختبارات، وبيئة تجريبية (staging).</p>
<p>هذا الترتيب يحمي الطرفين. يرى العميل برنامجًا يعمل منذ المرحلة الأولى. ويكتشف موظفوه المشكلات التي تهمهم وإصلاحها ما زال رخيصًا. وإن تغيّرت أولوياته، تغيرت المرحلة التالية معها. وكل دفعة بعد الأولى تأتي بعد برنامج استخدمه العميل بنفسه.</p>
<figure class="post-figure dg dg-is-flow"><ol class="dg-flow"><li><strong>البناء</strong><span>دورات قصيرة، وبيئة تجريبية</span></li><li><strong>الاختبارات الآلية</strong><span>مع كل تغيير</span></li><li><strong>النشر</strong><span>على خادم العميل</span></li><li><strong>فريقه يختبر</strong><span>والإصلاحات تعود إلى البناء</span></li></ol></figure>
<p>تسليم كل مرحلة على خادم حقيقي لا ينجح إلا إن كان الإصدار رخيصًا وقابلًا للتراجع. سكربت النشر في منصة إدارة شبكات الإنترنت يأخذ نسخة احتياطية متحققًا منها قبل أن يوقف حاوية واحدة، ويرفض المتابعة إن فشلت، ثم يشغّل الترحيلات وينتظر فحص السلامة. وقلب التراجع في هذا الموقع خمسة أسطر:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">CURRENT</span><span style="--shiki-light:#999999;--shiki-dark:#666666">=$(</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">readlink</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -f</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$ROOT/current</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">PREVIOUS</span><span style="--shiki-light:#999999;--shiki-dark:#666666">=$(</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">ls</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -1dt</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$ROOT</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">/releases/</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">*</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">/</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> |</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> sed</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">s:/$::</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> |</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> grep</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -vx</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$CURRENT</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> |</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> head</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -n</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ||</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> true</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">[</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> -n</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$PREVIOUS</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> ]</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ||</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> echo</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">!! No earlier release to go back to.</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> exit</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">ln</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -sfn</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$PREVIOUS</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$ROOT/current.new</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">mv</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -Tf</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$ROOT/current.new</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$ROOT/current</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span></span></code></pre></figure>
<p>يستغرق أقل من ثانية. وحين يكون الرجوع بهذا الثمن، يكفّ وضع مرحلة على خادم العميل عن أن يكون مقامرة.</p>
<h2 id="الاختبارات-جزء-مما-يشتريه-العميل">الاختبارات جزء مما يشتريه العميل</h2>
<p>الاختبار الآلي برنامج صغير يتحقق من قاعدة واحدة من قواعد العمل، في كل مرة يتغير فيها الكود. كتبت منها أكثر من أربعة آلاف عبر عشرين منتجًا.</p>
<div class="stat-grid"><div class="stat"><p class="stat-value" dir="ltr">4,000+</p><p class="stat-label">اختبار آلي عبر عشرين منتجًا</p></div><div class="stat"><p class="stat-value" dir="ltr">1,580+</p><p class="stat-label">FinyxWatt: اختبارات التطبيقات والخادم</p></div><div class="stat"><p class="stat-value" dir="ltr">1,240+</p><p class="stat-label">منصة إدارة شبكات الإنترنت: اختبارات الخادم والتطبيق</p></div><div class="stat"><p class="stat-value" dir="ltr">530+</p><p class="stat-label">FinyX-ERP: المحاسبة والصلاحيات والخادم</p></div></div>
<p>من السهل أن تُعامَل الاختبارات تكلفةً مخفية داخل التقدير. في نموذجي هي في قائمة &quot;ما يشمله العرض&quot;، حيث يقرؤها العميل. والحيلة أن تصف كل اختبار بلغة العميل.</p>
<p>في FinyX-ERP، نظامي للمحاسبة ونقاط البيع، يستطيع المالك حذف فاتورة مرحَّلة عبر أداة إدارية. ومجموعة من الاختبارات تتحقق من أن الحذف يقع، ومن أن الحمايات التي تمنع أي أحد من تعديل مبلغ مرحَّل تعود للعمل بعده، ومن أنها تعود حتى حين يتعثر الحذف في منتصفه. والتعليق في رأس الملف يشرح ما على المحك: المالك طلب الحذف، فالحذف سليم. الخطر دفتر يبقى بلا حراس، بصمت، حتى يعدّل أحدهم مبلغًا مرحَّلًا.</p>
<p>وأول فحص فيه يستحق أن يُنسخ إلى كل مجموعة اختبارات:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">Require</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">before</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> ></span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">The database carries no integrity triggers, so the test proves nothing.</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span></code></pre></figure>
<p>أي: إن لم يكن في القاعدة حرّاس أصلًا، فهذا الاختبار لا يثبت شيئًا. الاختبار الذي لا يستطيع أن يفشل بلا قيمة، لذلك يتأكد هذا أولًا أنه قادر على الفشل، قبل أن يفحص أي شيء آخر. وعبارة &quot;المبلغ المرحَّل لا يُعدَّل أبدًا، حتى بعد حذف إداري&quot; جملة يريد أي مالك أن يقرأها في عرض.</p>
<p>وفي المشاريع الأكبر تعمل الاختبارات قبل خروج أي إصدار. في FinyxWatt تعمل اختبارات التطبيقات مع كل push وكل طلب دمج، ولا يبدأ النشر التلقائي للخادم إلا بعد أن تنجح اختبارات الخادم في الـ CI. وفي متجر بن مسعود يمر كل تغيير في الخلفية عبر فحص جودة الكود، وفحص الأنواع، واختبارات الوحدات مع قياس التغطية، وترحيلات قاعدة البيانات، واختبارات شاملة من طرف إلى طرف.</p>
<h3 id="تقرير-القبول">تقرير القبول</h3>
<p>واختبار العميل نفسه يأتي فوق ذلك، ويستحق وثيقة. في منصة إدارة شبكات الإنترنت، جاءت النسخة المرشحة للإطلاق مع تقرير قبول مكتوب:</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>القسم</th>
<th>ما يُريه للعميل</th>
</tr>
</thead>
<tbody><tr>
<td>نشر نظيف</td>
<td>النشر على خادم Ubuntu جديد، خطوة بخطوة</td>
</tr>
<tr>
<td>تمرين النسخ والاستعادة</td>
<td>سكربتات النسخ الاحتياطي والاستعادة، مع التحقق من البصمة</td>
</tr>
<tr>
<td>إعادة التشغيل</td>
<td>كل خدمة تعود بعد إعادة تشغيلها وبعد إعادة تشغيل الخادم كاملًا</td>
</tr>
<tr>
<td>السلامة المحاسبية</td>
<td>الدفاتر متوازنة: صفر قيود غير متوازنة، وصفر سطور يتيمة، وصفر عكس مزدوج</td>
</tr>
<tr>
<td>نتائج الاختبارات</td>
<td>لكل مشروع، ما نجح وما فشل</td>
</tr>
<tr>
<td>القيود المعروفة والمخاطر المتبقية</td>
<td>ما لم يُنجز بعد، وكل خطر مع طريقة معالجته</td>
</tr>
<tr>
<td>الحكم</td>
<td>جاهز للتجربة أم لا، وما الذي يُفعل أولًا</td>
</tr>
</tbody></table></div>
<p>الصفّان الأخيران يبنيان من الثقة أكثر من بقية الصفوف كلها. المطوّر الذي يكتب حدوده بيده يُصدَّق في كل ما عداها.</p>
<h2 id="حزمة-التسليم">حزمة التسليم</h2>
<p>والآن السطر الذي في الشروط. نموذجي يقول إن الكود المصدري ملك العميل بعد سداد الدفعة الأخيرة. وحين تحين تلك اللحظة، يكون فريقه قد استخدم كل مرحلة على خادمه هو، فالدفعة الأخيرة تنقل ملكية نظام يشغّله بالفعل. هي أسهل دفعة تطلبها، لأنه لا شيء فيها يُؤخذ على الثقة.</p>
<p>وهذا ما يستلمه:</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>البند</th>
<th>ما يحويه</th>
</tr>
</thead>
<tbody><tr>
<td>الكود المصدري</td>
<td>كل المستودعات، وتصير ملك العميل بعد الدفعة الأخيرة</td>
</tr>
<tr>
<td>التوثيق</td>
<td>دليل النشر، ودليل التشغيل اليومي، ودليل النسخ الاحتياطي والاستعادة، ومرجع الصلاحيات، ودليل الـ API، والقرارات المعمارية وأسبابها، وملاحظات الأمان، ودليل حل المشكلات، وملاحظات الإصدارات</td>
</tr>
<tr>
<td>السكربتات</td>
<td>تجهيز الخادم، والنشر، والتراجع، والنسخ الاحتياطي، والتحقق من النسخ، وتدوير المفاتيح السرية</td>
</tr>
<tr>
<td>تقرير القبول</td>
<td>الأدلة السابقة، والقيود المعروفة معها</td>
</tr>
<tr>
<td>التدريب</td>
<td>للذين يستخدمون النظام كل يوم، وهم فريق الاستقبال في نموذج المجمع الطبي</td>
</tr>
</tbody></table></div>
<p>قائمتا التوثيق والسكربتات هما ما يضمه مستودع منصة إدارة شبكات الإنترنت. واختبار الحزمة بسيط: هل يستطيع المطوّر التالي تشغيل النظام دون أن يتصل بك؟ العميل الذي يعرف أنه يستطيع الرحيل هو العميل الذي يملك ترف أن يثق بك.</p>
<h2 id="التعديلات-على-النطاق-كتابةً-في-كل-مرة">التعديلات على النطاق، كتابةً، في كل مرة</h2>
<p>المشاريع تتغير. والشرط في نموذجي سطر واحد: أي عمل خارج النطاق المتفق عليه يُقدَّر ويُعتمد كتابيًا قبل البدء به. هكذا يبقى للعرض الذي وقّعه العميل معناه، ولا تجد نفسك تعمل مجانًا في صمت.</p>
<p>والمراحل تسهّل الأمر. يمكن تقدير التعديل ووضعه في المرحلة التالية، وحين تتبدل الأولويات تتبدل المرحلة التالية معها. والتعديل المكتوب يحتاج خمسة أشياء: ما الذي يتغير، ولماذا، والتقدير، والمرحلة التي يدخلها، واعتماد العميل.</p>
<h2 id="الدعم-بعد-الإطلاق">الدعم بعد الإطلاق</h2>
<p>الاستخدام الحقيقي يكشف دائمًا ما لم تكشفه الاختبارات. النشر والنسخ الاحتياطي والمراقبة وتلك الإصلاحات جزء من العمل، ومدة الدعم بعد التسليم الأخير مكتوبة في العرض. أدير اليوم سبعة أنظمة تعمل: إصداراتها، وخوادمها، وأمانها، والإصلاحات التي تبقيها قائمة.</p>
<figure class="post-figure dg dg-is-timeline"><ol class="dg-timeline"><li><strong>المحادثة الأولى</strong><span>من يستخدمه، وما البيانات المهمة، ومن يحق له فعل ماذا</span></li><li><strong>العرض المكتوب</strong><span>المراحل، وما يشمله وما لا يشمله، والشروط، برابط خاص</span></li><li><strong>التصميم</strong><span>البيانات والصلاحيات والربط والشاشات الرئيسية، متفق عليها قبل الكود</span></li><li><strong>المراحل</strong><span>كل واحدة تعمل على خادم العميل ويختبرها فريقه</span></li><li><strong>التسليم</strong><span>الكود والتوثيق والسكربتات وتقرير القبول والتدريب</span></li><li><strong>الدعم</strong><span>مدة مكتوبة في العرض، ثم الإصلاحات التي يكشفها الاستخدام</span></li></ol></figure>
<h2 id="ما-الذي-يحرّك-التقدير">ما الذي يحرّك التقدير</h2>
<p>لا أعطي سعرًا قبل المحادثة الأولى، لأن الكلمات نفسها تصف أنظمة مختلفة جدًا. هذه أكثر ما يحرّك التقدير:</p>
<ul>
<li><strong>الأدوار والصلاحيات.</strong> دوران بصلاحيات كاملة نظام صغير. أدوار كثيرة بقواعد لكل فرع نظام أكبر.</li>
<li><strong>الربط مع الأنظمة الأخرى.</strong> بوابات الدفع مثل Moyasar، وواتساب، وسلة، والرسائل القصيرة. لكلٍّ منها قواعده وبيئة اختباره وخطوات اعتماده.</li>
<li><strong>المنصات.</strong> ويب فقط، أم معه أندرويد وiOS وبرنامج سطح مكتب على Windows.</li>
<li><strong>العمل دون اتصال.</strong> التطبيق الذي يستمر في العمل دون إنترنت يحتاج طريقة آمنة للمزامنة حين يعود الاتصال.</li>
<li><strong>البيانات الحالية.</strong> نقل سنوات من الجداول أو من قاعدة بيانات قديمة مهمة قائمة بذاتها.</li>
<li><strong>التقارير.</strong> كل تقرير استعلام وشاشة وتصدير.</li>
<li><strong>التشغيل.</strong> النسخ الاحتياطي، والمراقبة، ومن يتولى الخادم.</li>
</ul>
<p>والمراحل تفيد هنا أيضًا. يمكن أن تُحجَّم المرحلة الأولى على قدر ميزانية العميل، وتبقى مفيدة بذاتها.</p>
<h2 id="الأسئلة-التي-يطرحها-العملاء-الجيدون">الأسئلة التي يطرحها العملاء الجيدون</h2>
<p>العملاء الجيدون يطرحون هذه الأسئلة قبل التوقيع. ضع كل إجابة في العرض، ولن تضطر إلى قول أي منها في خلاف.</p>
<ul>
<li>هل سيصلني عرض مكتوب بمراحل، وبقائمة لما لا يشمله؟</li>
<li>أين سيعمل النظام، وباسم من الخادم والنطاق؟</li>
<li>هل سأختبر كل مرحلة على خادمي قبل أن تبدأ التالية؟</li>
<li>لمن الكود المصدري، ومن متى؟</li>
<li>كم عدد الاختبارات الآلية، وهل تعمل قبل كل إصدار؟</li>
<li>كيف تُؤخذ النسخ الاحتياطية، ومتى جُرّبت الاستعادة آخر مرة؟</li>
<li>ما التوثيق الذي سأستلمه عند التسليم؟</li>
<li>كيف تُسعَّر التعديلات خارج النطاق، وكيف تُعتمد؟</li>
<li>إن فشل إصدار، كيف يُتراجع عنه، وكم يستغرق ذلك؟</li>
<li>بعد الإطلاق، من يصلح المشكلات، وإلى متى؟</li>
</ul>
<p>الدفعة الأخيرة سهلة حين لا يكون فيها شيء جديد. العميل استخدم كل مرحلة على خادمه، وقرأ قائمة ما لا يشمله العرض قبل التوقيع، واعتمد كل تعديل كتابيًا، ويحمل حزمة تتيح له تشغيل النظام من دونك. ولهذا السبب نفسه يعود.</p>
<p>وكل مشروع في صفحة <a href="https://moghalis.com/ar/services">الخدمات</a> يسير بهذه الطريقة.</p>
]]></content:encoded>
    </item>
    <item>
      <title>200 حركة أبقت الخيط الرئيسي مشغولًا 98% والصفحة ساكنة، ولم أحذف واحدة</title>
      <link>https://moghalis.com/ar/blog/gpu-only-motion</link>
      <guid isPermaLink="true">https://moghalis.com/ar/blog/gpu-only-motion</guid>
      <pubDate>Mon, 19 Oct 2026 08:00:00 GMT</pubDate>
      <description>حركة واحدة لا يقدر عليها الـ GPU تجعل المتصفح يعيد بناء الصفحة كلها في كل إطار. الأسباب الخمسة التي فعلت ذلك بموقعي، والحقل في trace كروم الذي يسمّيها، والإصلاح: من 98% إلى 18%.</description>
      <category>الأداء</category>
      <category>CSS</category>
      <category>الحركة</category>
      <category>React</category>
      <content:encoded><![CDATA[<p>على شاشة الكمبيوتر بدت الصفحة مكتملة: رموز تتساقط خلف النص، وطرفية تكتب أوامر حقيقية، وحلقات كود تدور خلف صورتي، وهواتف تتمايل في ثلاثة أبعاد.</p>
<p>على الهاتف، تقطّعت الصفحة نفسها.</p>
<p>توقعت صورة ثقيلة أو حزمة JavaScript منتفخة. ما أراني إياه Chrome كان أغرب: والشاشة لا يلمسها أحد، كان الخيط الرئيسي (main thread) مشغولًا 98% من كل ثانية.</p>
<p>فبدأت أطفئ الحركات واحدة تلو الأخرى بحثًا عن المذنبة. لم يتغير شيء. وتبيّن أن هذا الطريق المسدود أثمن دليل في الرحلة كلها.</p>
<p>في آخر هذا المقال ستعرف لماذا تفرض حركة واحدة ضريبة على كل الحركات الأخرى، وكيف تجعل Chrome يسمّي الحركة المذنبة بنفسه، وستخرج بسكربتين يقيسان ذلك عنك.</p>
<aside class="post-summary"><p class="callout-label">باختصار</p><ul>
<li>حركة واحدة لا يستطيع الـ compositor تشغيلها تفرض إطارًا كاملًا على الخيط الرئيسي مع كل تحديث للشاشة، والصفحة كلها تدفع ثمنه.</li>
<li>خمسة أسباب صنعت ذلك هنا: حلقة canvas، وحالة React تتحدث بلا توقف، و<code>background-position</code>، و<code>@property</code>، وحركتا transform على عنصر واحد. ومشتبه به سادس تبيّنت براءته.</li>
<li>قِس انشغال الخيط الرئيسي والصفحة ساكنة مع إبطاء المعالج، قبل/بعد وبالوسيط. عدد الإطارات في الثانية سيكذب عليك.</li>
</ul>
</aside>
<h2 id="98-انشغال-ولا-أحد-يلمس-الصفحة">98% انشغال، ولا أحد يلمس الصفحة</h2>
<p>الاختبار بسيط: أفتح الصفحة على محاكاة هاتف (390×844، كثافة بكسل 3×، ومعالج أبطأ 4 مرات)، أتركها تُكمل التحميل، لا ألمس شيئًا، ثم أسأل Chrome: كم من كل ثانية قضاها الخيط الرئيسي في تنفيذ المهام؟</p>
<figure class="post-figure dg dg-is-bars"><div class="dg-bars"><div class="dg-bar-row"><span class="dg-bar-label">محاكاة الهاتف، قبل</span><span class="dg-bar-track"><span class="dg-bar" style="--v:1.000"></span></span><span class="dg-bar-value" dir="ltr">98%</span></div><div class="dg-bar-row"><span class="dg-bar-label">محاكاة الهاتف، بعد</span><span class="dg-bar-track"><span class="dg-bar" style="--v:0.184"></span></span><span class="dg-bar-value" dir="ltr">18%</span></div><div class="dg-bar-row"><span class="dg-bar-label">الكمبيوتر، قبل</span><span class="dg-bar-track"><span class="dg-bar" style="--v:0.439"></span></span><span class="dg-bar-value" dir="ltr">43%</span></div><div class="dg-bar-row"><span class="dg-bar-label">الكمبيوتر، بعد</span><span class="dg-bar-track"><span class="dg-bar" style="--v:0.082"></span></span><span class="dg-bar-value" dir="ltr">8%</span></div></div></figure>
<p>عند 98%، كل نقرة وكل تمرير ينتظر في الطابور خلف عمل لا يراه المستخدم. الصفحة تبدو حيّة، وتُحَسّ ميتة.</p>
<p>وعدد الإطارات في الثانية أخفى هذا كله. معظم حركاتي كانت تعمل على الـ compositor، وهو خيط منفصل ظل يحرّك الطبقات بنعومة بينما الخيط الرئيسي يغرق. عدّاد الـ FPS كان يقيس الخيط السليم.</p>
<p>الرقم يأتي من مقياس واحد في بروتوكول أدوات المطوّر (CDP) اسمه <code>TaskDuration</code>: مجموع الثواني التي قضاها الخيط الرئيسي في المهام. اقرأه، انتظر ثلاث ثوانٍ، اقرأه مجددًا، واقسم. السكربت الكامل، بالمقارنة قبل/بعد والوسيط، تجده قرب النهاية.</p>
<p>وتحذير قبل أي رقم: جهازي كان مشغولًا غالبًا بأعمال أخرى، فالقياس المنفرد يكذب. كل مقارنة في هذا المقال كانت قبل/بعد، بالتناوب، ووسيطَ عدة جولات.</p>
<h2 id="الدليل-إطفاء-الحركات-لم-يغيّر-شيئًا">الدليل: إطفاء الحركات لم يغيّر شيئًا</h2>
<p>أطفأت الحركات مجموعةً مجموعة. أزلت الـ canvas، فبقيت الصفحة عند 98%. أزلت الحلقات، فبقيت عند 98%.</p>
<p>لا مجموعة بمفردها صنعت فرقًا، فعرفت أنني أسأل السؤال الخطأ. الكلفة تصرفت كرسم ثابت، وحفنة من الحركات كانت تُلزم الصفحة كلها بدفعه.</p>
<figure class="post-figure dg dg-is-flow"><ol class="dg-flow"><li><strong>حركة واحدة تحتاج الخيط الرئيسي</strong><span>حلقة canvas، أو setState، أو background-position</span></li><li><strong>Chrome يجدول إطارًا على الخيط الرئيسي</strong><span>مع كل تحديث للشاشة</span></li><li><strong>الصفحة تؤدي عمل الرسم كاملًا</strong><span>الأنماط، والتخطيط، والرسم، وتحديث الطبقات</span></li><li><strong>النقرات والتمرير تنتظر</strong><span>خلف عمل لا يراه أحد</span></li></ol></figure>
<p>الحركة المركّبة (composited) طبقة يحرّكها كرت الشاشة وحده: يسلّمها الخيط الرئيسي مرة ثم يهدأ. أما الحركة غير المركّبة فتحتاج الخيط الرئيسي ليصنع إطارًا جديدًا مع كل تحديث للشاشة، وهو إطار كامل: حساب الأنماط، ومتابعة التخطيط، والرسم، وتحديث الطبقات، للصفحة كلها.</p>
<p>هكذا دفعت مئتا حركة، معظمها على الـ GPU أصلًا، هذه الكلفة الثابتة، لأن حفنة منها ظلت تطلب إطارًا. وإزالة مجموعة لا تفيد ما دامت أخرى ما زالت تطلب.</p>
<p>فصار السؤال الحقيقي: <strong>ما الذي يجبر الخيط الرئيسي على إطار؟</strong> وضعت قائمة بستة مشتبه بهم واستبدلتهم جميعًا. وحين أعدت اختبار كل منهم وحده، ثبتت التهمة على خمسة وبُرّئ السادس. أولهم ملف يَعِدك في سطره الأول بأنه لا يكلّف شيئًا تقريبًا.</p>
<h2 id="المشتبهون-الستة-الذين-طاردتهم-وما-حلّ-محل-كل-منهم">المشتبهون الستة الذين طاردتهم، وما حلّ محل كل منهم</h2>
<figure class="post-figure dg dg-is-compare"><div class="dg-compare"><div class="dg-compare-head"><span>قبل</span><span>بعد</span></div><div class="dg-compare-row"><span>canvas يُعاد رسمه من requestAnimationFrame</span><span>26 عمود رموز يحرّكها transform</span></div><div class="dg-compare-row"><span>setState مع كل حرف يُكتب</span><span>أربع طبقات متداخلة تتحرك بـ transform</span></div><div class="dg-compare-row"><span>ضوء ينزلق بـ background-position</span><span>خط يرسم نفسه بـ scaleX والشفافية</span></div><div class="dg-compare-row"><span>شفافية على عنصر داخل SVG</span><span>مؤشر HTML موضوع فوق الـ SVG</span></div><div class="dg-compare-row"><span>إطار مخروطي يدور بـ @property</span><span>تدرج يدور تحت قناع لا يتحرك</span></div><div class="dg-compare-row"><span>حركتا transform على عنصر واحد</span><span>حركة واحدة لكل عنصر متداخل</span></div></div></figure>
<h3 id="1-الـ-canvas-الذي-بُني-ليكلّف-شبه-لا-شيء">1. الـ canvas الذي &quot;بُني ليكلّف شبه لا شيء&quot;</h3>
<p>هكذا قال تعليق الملف القديم، وبدا كلامًا مسؤولًا: مطر الرموز كان canvas شفافًا واحدًا بحجم بكسلات CSS، بسقف 24 إطارًا في الثانية على الهاتف و30 على الكمبيوتر، ويتوقف حين يُخفى التبويب.</p>
<p>هذه حلقته:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const </span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">tick</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">t</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">number</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">  raf</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> requestAnimationFrame</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">tick</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  if</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">t</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> - </span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">last</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> &#x3C;</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> frameMs</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> return</span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD"> // مبكر جدًا: تخطَّ هذا الإطار</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">  last</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> t</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">  draw</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span></code></pre></figure>
<p>الفخ في سطر التخطي. كل استدعاء من هذه، حتى الذي يعود فورًا، طلبٌ لإطار على الخيط الرئيسي. سقف الإطارات وفّر وقت الرسم، ولم يوفّر شيئًا غيره.</p>
<p>البديل لا يرسم شيئًا أثناء التشغيل: ستة وعشرون عمودًا من الرموز، نص كل عمود يُختار مرة واحدة من بذرة ثابتة (فيتطابق الـ HTML المولَّد مسبقًا مع ما يرسمه المتصفح)، وكل عمود ينزل بـ transform في CSS:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">rune-col</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  position</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> absolute</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  top</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  left</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> var</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">--x</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  width</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">ch</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  word-break</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> break-all</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">           /* رمز في كل سطر: عمود */</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  mask-image</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> linear-gradient</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">transparent</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> #</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">000</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 80</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">%</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD"> /* الذيل يتلاشى */</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  transform</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> translate3d</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> -110</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">%</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  animation</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> rune-fall </span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">var</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">--dur</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> linear</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> infinite</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  animation-delay</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> var</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">--delay</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">   /* سالب: يتساقط منذ أول رسم */</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">@</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">keyframes</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> rune-fall</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">  to</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> transform</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> translate3d</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> calc</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">100</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">vh</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> +</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 10</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">%</span><span style="--shiki-light:#999999;--shiki-dark:#666666">),</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">@</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">media</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">max-width</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 760</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">px</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">  .</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">rune-col</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">nth-child</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">2n</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> display</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> none</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD"> /* نصف الأعمدة على الهاتف */</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span></code></pre></figure>
<p>كل عمود يأخذ <code>--x</code> خاصًا به، ومدة بين 11 و24 ثانية، وتأخيرًا سالبًا. الشكل نفسه، وصفر سكربت في كل إطار.</p>
<h3 id="2-حالة-react-تدق-كالساعة">2. حالة React تدق كالساعة</h3>
<p>الطرفية كانت تكتب باستدعاء <code>setState</code> على مؤقت: كل 42 إلى 82 ملّي ثانية أثناء الكتابة، وكل 18 أثناء المسح. كل استدعاء يعني render وإعادة حساب أنماط وتخطيطًا، لأن عرض النص يتغير في كل مرة.</p>
<p>الآن الكتابة CSS خالصة. نافذة أعرض من الأمر بحرف واحد تنزلق خطوة بحرف، والنص داخلها ينزلق عكسها بالقدر نفسه، فيظهر كل حرف في مكانه بالضبط، ولا يتحرك شيء سوى transform.</p>
<figure class="post-figure dg dg-is-stack"><ol class="dg-stack"><li><strong>.tw</strong><span>تُدخل النافذة، حرفًا في كل خطوة</span></li><li><strong>.tw-window</strong><span>تقصّ، ثم تنزلق خارجةً لتمسح</span></li><li><strong>.tw-text-out</strong><span>تُرجع النص أثناء المسح</span></li><li><strong>.tw-text</strong><span>تُرجع النص أثناء الكتابة، فتبقى الحروف مكانها</span></li></ol></figure>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">tw</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  display</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> inline-block</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  animation</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> tw-in </span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">var</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">--type</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> steps</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">var</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">--steps</span><span style="--shiki-light:#999999;--shiki-dark:#666666">),</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> jump-start</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> both</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">tw-window</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  position</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> relative</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  display</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> block</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  width</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> calc</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">var</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">--steps</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> *</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">ch</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  height</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1.5</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">em</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  overflow</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> hidden</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  animation</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> tw-out </span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">var</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">--erase</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> steps</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">var</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">--steps</span><span style="--shiki-light:#999999;--shiki-dark:#666666">),</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> jump-start</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> calc</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">var</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">--type</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> +</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> var</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">--hold</span><span style="--shiki-light:#999999;--shiki-dark:#666666">))</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> forwards</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">tw-text-out</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  display</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> block</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  animation</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> tw-text-out </span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">var</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">--erase</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> steps</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">var</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">--steps</span><span style="--shiki-light:#999999;--shiki-dark:#666666">),</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> jump-start</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> calc</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">var</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">--type</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> +</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> var</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">--hold</span><span style="--shiki-light:#999999;--shiki-dark:#666666">))</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> forwards</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">tw-text</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  display</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> block</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  width</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> calc</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">var</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">--steps</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> *</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">ch</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  line-height</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1.5</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">em</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  animation</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> tw-text-in </span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">var</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">--type</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> steps</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">var</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">--steps</span><span style="--shiki-light:#999999;--shiki-dark:#666666">),</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> jump-start</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> both</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">@</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">keyframes</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> tw-in</span><span style="--shiki-light:#999999;--shiki-dark:#666666">       {</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> from</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> transform</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> translate3d</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">-100</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">%</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">@</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">keyframes</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> tw-out</span><span style="--shiki-light:#999999;--shiki-dark:#666666">      {</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> to</span><span style="--shiki-light:#999999;--shiki-dark:#666666">   {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> transform</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> translate3d</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">-100</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">%</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">@</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">keyframes</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> tw-text-in</span><span style="--shiki-light:#999999;--shiki-dark:#666666">  {</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> from</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> transform</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> translate3d</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">100</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">%</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">@</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">keyframes</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> tw-text-out</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> to</span><span style="--shiki-light:#999999;--shiki-dark:#666666">   {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> transform</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> translate3d</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">100</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">%</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">caret</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">                          /* يركب آخر خانة في النافذة */</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  position</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> absolute</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  top</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 50</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">%</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  right</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  width</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">ch</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  height</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1.15</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">em</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  translate</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> -50</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">%</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  background</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> currentColor</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  animation</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> caret-blink </span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">1</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">s</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> steps</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">1</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> infinite</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">@</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">keyframes</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> caret-blink</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> 50%</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> opacity</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span></code></pre></figure>
<p>وReact يضبط التوقيت مرة واحدة لكل أمر:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">import</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> type</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> CSSProperties</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> from</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">react</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> TYPE_MS</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 55</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> ERASE_MS</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 18</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> HOLD_MS</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1700</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">export</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> function</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> TypeLine</span><span style="--shiki-light:#999999;--shiki-dark:#666666">({</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> text</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }: { </span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">string</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> })</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">  const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> timing</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    [</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">--steps</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> as</span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994"> string</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]:</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">length</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> +</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD"> // خطوة لكل حرف، وواحدة للمؤشر</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    [</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">--type</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> as</span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994"> string</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]:</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> `</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">${</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">length</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> *</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> TYPE_MS</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">}</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">ms</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">`</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    [</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">--hold</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> as</span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994"> string</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]:</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> `</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">${</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">HOLD_MS</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">}</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">ms</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">`</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    [</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">--erase</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> as</span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994"> string</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]:</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> `</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">${</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">length</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> *</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> ERASE_MS</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">}</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">ms</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">`</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">  }</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> as</span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994"> CSSProperties</span></span>
<span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">  // الـ key يعيد تركيب الطبقات، فيبدأ كل أمر جديد حركاته من الصفر.</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  return</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    &#x3C;</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">span</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> key</span><span style="--shiki-light:#999999;--shiki-dark:#666666">={</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> className</span><span style="--shiki-light:#999999;--shiki-dark:#666666">=</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">tw</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> style</span><span style="--shiki-light:#999999;--shiki-dark:#666666">={</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">timing</span><span style="--shiki-light:#999999;--shiki-dark:#666666">}></span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">      &#x3C;</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">span</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> className</span><span style="--shiki-light:#999999;--shiki-dark:#666666">=</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">tw-window</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">></span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">        &#x3C;</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">span</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> className</span><span style="--shiki-light:#999999;--shiki-dark:#666666">=</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">tw-text-out</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">></span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">          &#x3C;</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">span</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> className</span><span style="--shiki-light:#999999;--shiki-dark:#666666">=</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">tw-text</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">>{</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">}&#x3C;/</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">span</span><span style="--shiki-light:#999999;--shiki-dark:#666666">></span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">        &#x3C;/</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">span</span><span style="--shiki-light:#999999;--shiki-dark:#666666">></span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">        &#x3C;</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">span</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> className</span><span style="--shiki-light:#999999;--shiki-dark:#666666">=</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">caret</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> /></span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">      &#x3C;/</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">span</span><span style="--shiki-light:#999999;--shiki-dark:#666666">></span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    &#x3C;/</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">span</span><span style="--shiki-light:#999999;--shiki-dark:#666666">></span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">  )</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span></code></pre></figure>
<p>يحتاج خطًا أحادي العرض (monospace)، لأن كل خطوة <code>1ch</code>. بدّل النص بعد <code class="code-long">text.length * (TYPE_MS + ERASE_MS) + HOLD_MS</code> وفاصل قصير. أي <code>setState</code> واحد كل بضع ثوانٍ، بعد أن كانت عشرات في الثانية.</p>
<p>تأمّل هذه الطبقات الأربع. أول نسخة CSS كانت أقل منها طبقات، وهنا يكمن السبب السادس.</p>
<h3 id="3-ضوء-ينزلق-بـ-background-position">3. ضوء ينزلق بـ background-position</h3>
<p>خط كل عنوان قسم كان يمر عليه ضوء ذهبي: تدرّج أعرض من الخط ثلاث مرات، يتحرك بـ <code>background-position</code>. والـ compositor لا يستطيع تحريك خلفية داخل عنصر، فكان ذلك إعادة رسم في كل إطار، لكل عنوان.</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">/* قبل: إعادة رسم في كل إطار */</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">eyebrow</span><span style="--shiki-light:#999999;--shiki-dark:#666666">::</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">before</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  background</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> linear-gradient</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">90</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">deg</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> var</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">--gold</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 35</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">%</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> var</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">--gold-fill-2</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 50</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">%</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> var</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">--gold</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 65</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">%</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 100</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">%</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  background-size</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 300</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">%</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 100</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">%</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  animation</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> rule-scan </span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">3.4</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">s</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> linear</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> infinite</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">/* بعد: الخط يرسم نفسه امتدادًا وانكماشًا */</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">eyebrow</span><span style="--shiki-light:#999999;--shiki-dark:#666666">::</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">before</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  transform-origin</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> left</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> center</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  animation</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> rule-draw </span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">3.4</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">s</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> ease-in-out</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> infinite</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> alternate</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">html</span><span style="--shiki-light:#999999;--shiki-dark:#666666">[</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">dir</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">=</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">rtl</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> .</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">eyebrow</span><span style="--shiki-light:#999999;--shiki-dark:#666666">::</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">before</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  transform-origin</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> right</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> center</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD"> /* يبدأ من الجهة التي يبدأ منها النص */</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">@</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">keyframes</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> rule-draw</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">  from</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> transform</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> scaleX</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">0.35</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> opacity</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0.55</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">  to</span><span style="--shiki-light:#999999;--shiki-dark:#666666">   {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> transform</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> none</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> opacity</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span></code></pre></figure>
<p>تأثير مختلف، خط يتنفس امتدادًا وانكماشًا، ولا يكلّف شيئًا في أي إطار. ولاحظ سطر <code>dir='rtl'</code>: في العربية يمتد الخط من اليمين، حيث تبدأ القراءة.</p>
<h3 id="4-المشتبه-به-البريء-خط-وامض-داخل-svg">4. المشتبه به البريء: خط وامض داخل SVG</h3>
<p>المؤشر تحت حرف M في شعاري كان يومض بـ <code>opacity</code> على path داخل الـ SVG. كنت أظن أن ذلك يعيد رسم الـ SVG في كل إطار، فنقلت الوميض إلى عنصر HTML موضوع بالضبط حيث كان الـ SVG سيرسمه. والأرقام مأخوذة من هندسة الـ SVG نفسها: خط طوله 16 وحدة بنهايتين مستديرتين سُمكهما 2.6، داخل viewBox من 64 وحدة يُرسم بعرض 30px، عرضه (16 + 2.6) × 30 ÷ 64 = 8.7px.</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">logo-mark</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> position</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> relative</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">logo-caret</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  position</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> absolute</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  top</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 27.8</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">px</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  left</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 50</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">%</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  width</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 8.7</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">px</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  height</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1.3</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">px</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  translate</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> -50</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">%</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  border-radius</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">px</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  background</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> var</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">--blue</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  animation</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> caret-blink </span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">1.1</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">s</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> steps</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">1</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> infinite</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">@</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">keyframes</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> caret-blink</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> 50%</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> opacity</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span></code></pre></figure>
<p>ثم أعدت، من أجل هذا المقال، اختبار كل مشتبه به في صفحة وحده. الشعار القديم يومض وحيدًا فلم يسبب أي إطار على الخيط الرئيسي، لا في Chrome 153 ولا في Edge 155، ولم يسجّل الـ trace أي فشل له. هذا كان بريئًا.</p>
<p>المؤشر بـ HTML باقٍ، فالشفافية على عنصر HTML هي بالضبط الحالة التي بُني الـ compositor لها. أما الدرس فهو ما يستحق البقاء: اختبر كل مشتبه به وحده، وإلا نسبت الفضل لإصلاح لم يفعل شيئًا.</p>
<h3 id="5-إطار-مخروطي-يدور-بـ-property">5. إطار مخروطي يدور بـ @property</h3>
<p>نموذج التواصل كان يدور حول إطاره ضوء: تدرج مخروطي زاويته خاصية مخصّصة مسجّلة، تتحرك من 0 إلى 360 درجة.</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">/* قبل: يُحسب على الخيط الرئيسي في كل إطار */</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">@</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">property</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> --spin </span><span style="--shiki-light:#999999;--shiki-dark:#666666">{</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  syntax</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">&#x3C;angle></span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  inherits</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> false</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  initial-value</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">deg</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">contact-panel</span><span style="--shiki-light:#999999;--shiki-dark:#666666">::</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">before</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  background</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> conic-gradient</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">from</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> var</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">--spin</span><span style="--shiki-light:#999999;--shiki-dark:#666666">),</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> transparent</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 68</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">%</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> var</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">--gold</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 84</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">%</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> transparent</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 100</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">%</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  animation</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> spin-border </span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">6</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">s</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> linear</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> infinite</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span></code></pre></figure>
<p><code>@property</code> يجعل الزاوية قابلة للتحريك، ويبقى الـ compositor عاجزًا عن تشغيلها. فيعيد Chrome حساب النمط ورسم التدرج في كل إطار.</p>
<p>الشكل نفسه يتحقق بمربع كبير من التدرج المخروطي يدور بـ transform، تحت قناع حلقي عرضه بكسل واحد لا يتحرك أبدًا:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">/* العنصر الأب يحتاج position: relative و isolation: isolate */</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">spin-ring</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  position</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> absolute</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  inset</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> -1</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">px</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  z-index</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> -1</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  overflow</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> hidden</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  padding</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">px</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  border-radius</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> inherit</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">  /* لا يبقى إلا البكسل الواحد من الـ padding: حلقة */</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  -webkit-mask</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> linear-gradient</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(#</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">000</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> content-box</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> linear-gradient</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(#</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">000</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  -webkit-mask-composite</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> xor</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  mask-composite</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> exclude</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  pointer-events</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> none</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">spin-ring</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ></span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> span</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  position</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> absolute</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  top</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 50</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">%</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  left</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 50</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">%</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  width</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 150</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">%</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  aspect-ratio</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  translate</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> -50</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">%</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> -50</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">%</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  background</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> conic-gradient</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">transparent</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 68</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">%</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> var</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">--gold</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 84</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">%</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> transparent</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 100</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">%</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  animation</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> spin-ring </span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">6</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">s</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> linear</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> infinite</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">@</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">keyframes</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> spin-ring</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> to</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> transform</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> rotate</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">360</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">deg</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span></code></pre></figure>
<p>التدرج يُرسم مرة واحدة. لا يتغير إلا دورانه، والدوران من عمل الـ compositor.</p>
<h3 id="6-حركتان-عنصر-واحد-والرقم-64">6. حركتان، عنصر واحد، والرقم 64</h3>
<p>هذا السبب ما كنت لأجده بقراءة الكود أبدًا. خرج من trace في Chrome، ومعه رقم: <strong>64</strong>.</p>
<p>في أول طرفية بـ CSS، حمل عنصر واحد الحركتين <code>tw-in</code> (انزلاق للداخل أثناء الكتابة) و<code>tw-out</code> (انزلاق للخارج أثناء المسح)، و<code>tw-out</code> لا تبدأ إلا بعد أن تفرغ <code>tw-in</code> من الحركة. ومع ذلك رفض Chrome تسليم أيٍّ منهما للـ compositor. كلتاهما عملت على الخيط الرئيسي، في كل إطار، طوال ظهور الطرفية على الشاشة.</p>
<p>الرقم 64 قناع بتات (bitmask) من شيفرة Chromium المصدرية (<code>compositor_animations.h</code>)، والبت السادس فيه اسمه <code class="code-long">kTargetHasIncompatibleAnimations</code>. وChromium يسمح فعلًا بعدة حركات transform على عنصر واحد إن استهدفت كلٌّ منها خاصية مختلفة، كـ <code>rotate</code> في واحدة و<code>scale</code> في أخرى. حركتاي كلتاهما حرّكتا <code>transform</code>.</p>
<p>الحل هو الطبقات التي رأيتها: حركة transform واحدة لكل عنصر، بعمق أربع طبقات متداخلة.</p>
<p>فكيف تجعل Chrome يعطيك هذا الرقم؟</p>
<h2 id="اجعل-chrome-يسمّي-المذنب">اجعل Chrome يسمّي المذنب</h2>
<p>Chrome يسجّل سبب عجزه عن تركيب كل حركة على الـ GPU، وما عليك إلا أن تسأل. سجّل trace بفئة <code>blink.animations</code> واقرأ أحداث <code>Animation</code>:</p>
<ul>
<li>حدث البداية (<code>ph: 'b'</code>) يحمل <code>args.data.displayName</code> (اسم الـ <code>@keyframes</code>، أما <code>name</code> فيأتي فارغًا لحركات CSS) و<code>nodeName</code> و<code>nodeId</code>.</li>
<li>الأحداث اللحظية (<code>ph: 'n'</code>) تحمل <code>args.data.compositeFailed</code>، وهو bitmask، و<code class="code-long">args.data.unsupportedProperties</code>، أي خصائص CSS التي عجز عنها الـ compositor.</li>
<li>والنوعان يشتركان في <code>id2.local</code>، وبه تربط بينهما.</li>
</ul>
<p>هذا كله في سكربت واحد. أعطه رابطًا، فيطبع كل حركة رفضها الـ compositor، مع السبب بالكلمات.</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">// composite-audit.mjs   التشغيل: node composite-audit.mjs http://localhost:4173/</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">import</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> chromium</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> from</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">playwright</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">// بتات CompositorAnimations::FailureReason في Chromium (compositor_animations.h)</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> REASONS</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">  [</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">1</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> &#x3C;&#x3C;</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 3</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]:</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">unsupported timing parameters</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">  [</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">1</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> &#x3C;&#x3C;</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 5</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]:</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">target has invalid compositing state</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">  [</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">1</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> &#x3C;&#x3C;</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 6</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]:</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">target has incompatible animations</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">  [</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">1</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> &#x3C;&#x3C;</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 10</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]:</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">transform cannot be accelerated on this target</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">  [</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">1</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> &#x3C;&#x3C;</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 12</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]:</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">filter may move pixels</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">  [</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">1</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> &#x3C;&#x3C;</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 13</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]:</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">unsupported CSS property</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">  [</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">1</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> &#x3C;&#x3C;</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 17</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]:</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">no visible change</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">  [</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">1</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> &#x3C;&#x3C;</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 18</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]:</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">animates an !important property</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">  [</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">1</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> &#x3C;&#x3C;</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 19</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]:</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">SVG target has an independent transform property</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> decode</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">mask</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">  Array</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">from</span><span style="--shiki-light:#999999;--shiki-dark:#666666">({</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> length</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 22</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> },</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">_</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> bit</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> &#x3C;&#x3C;</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> bit</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    .</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">filter</span><span style="--shiki-light:#999999;--shiki-dark:#666666">((</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">flag</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> mask</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> &#x26;</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> flag</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    .</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">map</span><span style="--shiki-light:#999999;--shiki-dark:#666666">((</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">flag</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> REASONS</span><span style="--shiki-light:#999999;--shiki-dark:#666666">[</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">flag</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ??</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> `</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">bit </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">${</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">Math</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">log2</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">flag</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">}</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">`</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    .</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">join</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">, </span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> browser</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> await</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> chromium</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">launch</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> context</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> await</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> browser</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">newContext</span><span style="--shiki-light:#999999;--shiki-dark:#666666">({</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> viewport</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> width</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 390</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> height</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 844</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> },</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> deviceScaleFactor</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 3</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> isMobile</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> true</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> })</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> page</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> await</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> context</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">newPage</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">await</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> browser</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">startTracing</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">page</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> categories</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> [</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">devtools.timeline</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">blink.animations</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> })</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">await</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> page</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">goto</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">process</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">argv</span><span style="--shiki-light:#999999;--shiki-dark:#666666">[</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">2</span><span style="--shiki-light:#999999;--shiki-dark:#666666">],</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> waitUntil</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">load</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> })</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">await</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> page</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">waitForTimeout</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">5000</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> traceEvents</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> JSON</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">parse</span><span style="--shiki-light:#999999;--shiki-dark:#666666">((</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">await</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> browser</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">stopTracing</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()).</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">toString</span><span style="--shiki-light:#999999;--shiki-dark:#666666">())</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">await</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> browser</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">close</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> idOf</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">e</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> e</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">id2</span><span style="--shiki-light:#999999;--shiki-dark:#666666">?.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">local</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ??</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> e</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">args</span><span style="--shiki-light:#999999;--shiki-dark:#666666">?.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">data</span><span style="--shiki-light:#999999;--shiki-dark:#666666">?.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">id</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> started</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> new</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> Map</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">for</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> e</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> of</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> traceEvents</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> if</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">e</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">name</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ===</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">Animation</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> &#x26;&#x26;</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> e</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">ph</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ===</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">b</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> started</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">set</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">idOf</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">e</span><span style="--shiki-light:#999999;--shiki-dark:#666666">),</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> e</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">args</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">data</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> printed</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> new</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> Set</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">for</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> e</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> of</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> traceEvents</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">  const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> mask</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> e</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">name</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ===</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">Animation</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> &#x26;&#x26;</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> e</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">ph</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ===</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">n</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ?</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> e</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">args</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">data</span><span style="--shiki-light:#999999;--shiki-dark:#666666">?.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">compositeFailed</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> :</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  if</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">!</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">mask</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> continue</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">  const</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> displayName</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">?</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> nodeName</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">?</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> started</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">get</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">idOf</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">e</span><span style="--shiki-light:#999999;--shiki-dark:#666666">))</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ??</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {}</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">  const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> props</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> e</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">args</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">data</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">unsupportedProperties</span><span style="--shiki-light:#999999;--shiki-dark:#666666">?.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">join</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">, </span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">  const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> line</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> `</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">${</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">nodeName</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">}</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  ${</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">displayName</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">}</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  ${</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">mask</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">}</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">: </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">${</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">decode</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">mask</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">}${</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">props </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">?</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> `</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">  [</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">${</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">props</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">}</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">]</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">`</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> :</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> ''</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">}</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">`</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  if</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">!</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">printed</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">has</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">line</span><span style="--shiki-light:#999999;--shiki-dark:#666666">))</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">    printed</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">add</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">line</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">    console</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">log</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">line</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">  }</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">// السكربت لا يظهر فوق. عُدّ عمل الخيط الرئيسي في آخر 3 ثوانٍ بدلًا من ذلك.</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> end</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> traceEvents</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">reduce</span><span style="--shiki-light:#999999;--shiki-dark:#666666">((</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">max</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> e</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> Math</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">max</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">max</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> e</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">ts</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ??</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">),</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> recent</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">name</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> traceEvents</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">filter</span><span style="--shiki-light:#999999;--shiki-dark:#666666">((</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">e</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> e</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">name</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ===</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> name</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> &#x26;&#x26;</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> e</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">ts</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> ></span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> end</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> -</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 3e6</span><span style="--shiki-light:#999999;--shiki-dark:#666666">).</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">length</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">console</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">log</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">last 3 s:</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  FireAnimationFrame</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> recent</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">FireAnimationFrame</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">),</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  TimerFire</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> recent</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">TimerFire</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">),</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  UpdateLayoutTree</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> recent</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">UpdateLayoutTree</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">),</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">})</span></span></code></pre></figure>
<p>شغّلته على صفحة اختبار فيها كود CSS &quot;قبل&quot; من هذا المقال وحلقة الـ canvas القديمة. هذا ما طبعه Chrome 153:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span>DIV class='contact-panel'  spin-border  8192: unsupported CSS property  [--spin]</span></span>
<span class="line"><span>SPAN class='tw-window'  tw-in  64: target has incompatible animations</span></span>
<span class="line"><span>SPAN class='tw-window'  tw-out  64: target has incompatible animations</span></span>
<span class="line"><span>SPAN class='eyebrow-rule'  rule-scan  8224: target has invalid compositing state, unsupported CSS property  [background-position-x]</span></span>
<span class="line"><span>last 3 s: { FireAnimationFrame: 180, TimerFire: 0, UpdateLayoutTree: 180 }</span></span></code></pre></figure>
<p>اقرأه سطرًا سطرًا:</p>
<ul>
<li><code>[--spin]</code> و<code>[background-position-x]</code> يأتيان من <code>unsupportedProperties</code>. ولاحظ الثاني: Chrome يسمّي الخاصية المفصّلة (longhand)، لا الاختصار الذي كتبته.</li>
<li>حركتا الطرفية كلتاهما تفشلان بـ 64، ولهذا لم تعمل أيٌّ منهما على الـ GPU.</li>
<li>180 استدعاء <code>FireAnimationFrame</code> في 3 ثوانٍ هي حلقة الـ canvas بسقف إطاراتها: واحد مع كل تحديث للشاشة. السقف لم يُنقص الاستدعاءات قط.</li>
<li><code>UpdateLayoutTree</code> هو إعادة حساب الأنماط في Chrome. و180 منه في 3 ثوانٍ تعني واحدًا في كل إطار.</li>
</ul>
<p>والصفحة نفسها بكود CSS &quot;بعد&quot; لم تطبع أي فشل، ولا أي استدعاء إطار، و11 إعادة حساب أنماط فقط في 3 ثوانٍ.</p>
<aside class="callout callout-note"><p class="callout-label">ملاحظة</p><p>للقائمة نقطة عمياء واحدة: الحركة على عنصر داخل SVG لا تظهر فيها أبدًا، فشلت أو نجحت. وهنا ينقذك <code>UpdateLayoutTree</code>: في صفحة ساكنة يجب أن يبقى أقل بكثير من واحد لكل إطار (صفحتي &quot;بعد&quot;: 11 في 3 ثوانٍ، مقابل 180). فإن جارى الإطارات، فهناك ما يطلب إطارات من الخيط الرئيسي، سمّته القائمة أم لم تسمّه.</p>
</aside>
<p>سببان لا يظهران أبدًا كأحداث <code>Animation</code>، لأنهما سكربت: حلقة الـ canvas، والمؤقت خلف <code>setState</code>. <code>FireAnimationFrame</code> يعدّ استدعاءات <code>requestAnimationFrame</code>، و<code>TimerFire</code> يعدّ المؤقتات. وفي صفحة ساكنة ينبغي أن يقترب الاثنان من الصفر.</p>
<h2 id="فخّان-بدَوَا-فكرتين-جيدتين">فخّان بدَوَا فكرتين جيدتين</h2>
<p>استُبدل المشتبه بهم الستة جميعًا ونزل الرقم. ثم جاء محدِّد أنيق واحد فضاعفه.</p>
<p><strong><code>:has()</code> فوق شجرة فيها حركة.</strong> لمعة العنوان الذهبية كان عليها أن تنتظر حتى يفرغ العنوان من فك تشفيره. والطريقة الأنيقة لقول ذلك:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">hero-headline</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">has</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">decode</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">not</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">is-done</span><span style="--shiki-light:#999999;--shiki-dark:#666666">))</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> .</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">sheen-band</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  animation-play-state</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> paused</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span></code></pre></figure>
<p>نجح ذلك، وضاعف انشغال المعالج على الهاتف والصفحة ساكنة. فمع حركات تعمل داخل العنوان، صار Chrome يعيد فحص <code>:has()</code> في كل إطار.</p>
<p>الحل ممل: React يضيف كلاسًا مرة واحدة حين يستقر فك التشفير، وCSS يقرأ الكلاس:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">hero-headline</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">not</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">is-decoded</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> .</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">sheen-band</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">hero-headline</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">not</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">is-decoded</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> .</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">sheen-copy</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  animation-play-state</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> paused</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span></code></pre></figure>
<p><strong>ضبابية الخلفية فوق خلفية متحركة.</strong> الشريط العلوي على الكمبيوتر كان عليه <code class="code-long">backdrop-filter: blur(18px) saturate(140%)</code> فوق خلفية بشفافية 78%. وخلفه لا تكف الرموز عن التساقط، فكانت الضبابية تُحسب من جديد في كل إطار. رفعت الشريط إلى 94% وحذفت الضبابية، وبهذه النسبة لا يميّز أحد الفرق. أما الهاتف فكانت الضبابية مطفأة فيه أصلًا.</p>
<h2 id="أوقف-ما-لا-يراه-أحد">أوقف ما لا يراه أحد</h2>
<p>الحركات خارج الشاشة تكلّف شيئًا أيضًا، والصفحة الطويلة تُبقي معظم حركاتها خارج الشاشة. مراقب IntersectionObserver واحد يعلّم كل قسم خارج نطاق الرؤية، وCSS يوقف كل ما بداخله:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">[</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">data-offscreen</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]::</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">before</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">[</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">data-offscreen</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]::</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">after</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">[</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">data-offscreen</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> *</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">[</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">data-offscreen</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> *</span><span style="--shiki-light:#999999;--shiki-dark:#666666">::</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">before</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">[</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">data-offscreen</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> *</span><span style="--shiki-light:#999999;--shiki-dark:#666666">::</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">after</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  animation-play-state</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> paused</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> !important</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span></code></pre></figure>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">import</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> useEffect</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> from</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">react</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> TARGETS</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">section, footer</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">export</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> default</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> function</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> OffscreenPause</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">  useEffect</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(()</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">    if</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">!</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">IntersectionObserver</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> in</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> window</span><span style="--shiki-light:#999999;--shiki-dark:#666666">))</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> return</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    let</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> io</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">IntersectionObserver</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> | </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">undefined</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    let</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> mo</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">MutationObserver</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> | </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">undefined</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    let</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> cancelled</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> false</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    const</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> each</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">node</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">Node</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> fn</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">el</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">Element</span><span style="--shiki-light:#999999;--shiki-dark:#666666">) => </span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">void</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">      if</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">!</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">node</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> instanceof</span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994"> Element</span><span style="--shiki-light:#999999;--shiki-dark:#666666">))</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> return</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">      if</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">node</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">matches</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">TARGETS</span><span style="--shiki-light:#999999;--shiki-dark:#666666">))</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> fn</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">node</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">      node</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">querySelectorAll</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">TARGETS</span><span style="--shiki-light:#999999;--shiki-dark:#666666">).</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">forEach</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">fn</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    }</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    const</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> start</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> ()</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">      if</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">cancelled</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> return</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">      const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> observer</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">io</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> new</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> IntersectionObserver</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">        (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">entries</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> entries</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">forEach</span><span style="--shiki-light:#999999;--shiki-dark:#666666">((</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">e</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> e</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">target</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">toggleAttribute</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">data-offscreen</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> !</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">e</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">isIntersecting</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)),</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">        {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> rootMargin</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">160px 0px</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> },</span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD"> // يبدأ الحركة قبيل ظهور القسم</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">      ))</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">      document</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">querySelectorAll</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">TARGETS</span><span style="--shiki-light:#999999;--shiki-dark:#666666">).</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">forEach</span><span style="--shiki-light:#999999;--shiki-dark:#666666">((</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">el</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> observer</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">observe</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">el</span><span style="--shiki-light:#999999;--shiki-dark:#666666">))</span></span>
<span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">      // تغيير الصفحة يضيف أقسامًا ويحذف أخرى.</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">      mo</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> new</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> MutationObserver</span><span style="--shiki-light:#999999;--shiki-dark:#666666">((</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">records</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">        records</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">forEach</span><span style="--shiki-light:#999999;--shiki-dark:#666666">((</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">r</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">          r</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">addedNodes</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">forEach</span><span style="--shiki-light:#999999;--shiki-dark:#666666">((</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">n</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> each</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">n</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">el</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> observer</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">observe</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">el</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)))</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">          r</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">removedNodes</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">forEach</span><span style="--shiki-light:#999999;--shiki-dark:#666666">((</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">n</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> each</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">n</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">el</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> observer</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">unobserve</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">el</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)))</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">        }),</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">      )</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">      mo</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">observe</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">document</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">body</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> childList</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> true</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> subtree</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> true</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> })</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    }</span></span>
<span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">    // HTML مولَّد مسبقًا: انتظر حتى يستلم React الصفحة، وإلا رأى خصائص لم يرسمها.</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    const</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> kick</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> ()</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">      window</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">requestIdleCallback</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ?</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> window</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">requestIdleCallback</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">start</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> timeout</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 2500</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> })</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> :</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> window</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">setTimeout</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">start</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 300</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">    if</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">document</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">readyState</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ===</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">complete</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> kick</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">    else</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> window</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">addEventListener</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">load</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> kick</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> once</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> true</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> })</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">    return</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> ()</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">      cancelled</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> true</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">      window</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">removeEventListener</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">load</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> kick</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">      mo</span><span style="--shiki-light:#999999;--shiki-dark:#666666">?.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">disconnect</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">      io</span><span style="--shiki-light:#999999;--shiki-dark:#666666">?.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">disconnect</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    }</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">  },</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> [])</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  return</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> null</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span></code></pre></figure>
<p>ركّبه مرة واحدة، في أي مكان. وتفصيلتان أخذتا مني وقتًا:</p>
<ul>
<li><strong>هامش الـ 160px.</strong> الأقسام تبدأ الحركة قبل أن تظهر، فلا يرى أحد لحظة انطلاقها.</li>
<li><strong>CSS يوقف الحركات، والحركات فقط.</strong> مؤقتاتك تستمر. مؤقت الطرفية يفحص <code>closest('[data-offscreen]')</code>، فإن وجد قسمه موقوفًا انتظر ثانية ثم فحص من جديد.</li>
</ul>
<h2 id="قِسها-بنفسك-السكربت-كاملًا">قِسها بنفسك: السكربت كاملًا</h2>
<p>هذا هو قياس السكون الذي بدأ به المقال. ابنِ النسختين اللتين تريد مقارنتهما، شغّلهما على منفذين، ثم أمر واحد.</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">// idle-busy.mjs   التشغيل: node idle-busy.mjs http://localhost:4173/ http://localhost:4174/</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">import</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> chromium</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> from</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">playwright</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> RUNS</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 5</span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">          // لكل رابط، بالتناوب: A، B، A، B...</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> SETTLE_MS</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 4000</span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">  // دع التحميل ينتهي: نريد الكلفة والصفحة ساكنة</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> WINDOW_MS</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 3000</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> browser</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> await</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> chromium</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">launch</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">async</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> function</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> idleBusy</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">url</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">  const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> context</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> await</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> browser</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">newContext</span><span style="--shiki-light:#999999;--shiki-dark:#666666">({</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">    viewport</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> width</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 390</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> height</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 844</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> },</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">    deviceScaleFactor</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 3</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">    isMobile</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> true</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">    hasTouch</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> true</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">  })</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">  const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> page</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> await</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> context</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">newPage</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">  const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> cdp</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> await</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> context</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">newCDPSession</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">page</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  await</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> cdp</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">send</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">Emulation.setCPUThrottlingRate</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> rate</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 4</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> })</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  await</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> cdp</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">send</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">Performance.enable</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  await</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> page</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">goto</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">url</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> waitUntil</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">load</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> })</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  await</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> page</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">waitForTimeout</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">SETTLE_MS</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">  const</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> taskSeconds</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> async</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> ()</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    (</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">await</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> cdp</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">send</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">Performance.getMetrics</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)).</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">metrics</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">find</span><span style="--shiki-light:#999999;--shiki-dark:#666666">((</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">m</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> m</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">name</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ===</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">TaskDuration</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">).</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">value</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">  const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> before</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> await</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> taskSeconds</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  await</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> page</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">waitForTimeout</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">WINDOW_MS</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">  const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> after</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> await</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> taskSeconds</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  await</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> context</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">close</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  return</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> ((</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">after</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> -</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> before</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> /</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">WINDOW_MS</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> /</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1000</span><span style="--shiki-light:#999999;--shiki-dark:#666666">))</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> *</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 100</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> urls</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> process</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">argv</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">slice</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">2</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> runs</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> Object</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">fromEntries</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">urls</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">map</span><span style="--shiki-light:#999999;--shiki-dark:#666666">((</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">url</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#999999;--shiki-dark:#666666"> [</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">url</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> []]))</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">for</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">let</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> i</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> i</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> &#x3C;</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> RUNS</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> i</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">++</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> for</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> url</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> of</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> urls</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> runs</span><span style="--shiki-light:#999999;--shiki-dark:#666666">[</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">url</span><span style="--shiki-light:#999999;--shiki-dark:#666666">].</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">push</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">await</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> idleBusy</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">url</span><span style="--shiki-light:#999999;--shiki-dark:#666666">))</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">await</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> browser</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">close</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> median</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">xs</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#999999;--shiki-dark:#666666"> [...</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">xs</span><span style="--shiki-light:#999999;--shiki-dark:#666666">].</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">sort</span><span style="--shiki-light:#999999;--shiki-dark:#666666">((</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">a</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> b</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> a</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> -</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> b</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)[</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">Math</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">floor</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">xs</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">length</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> /</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 2</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)]</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">for</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> [</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">url</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> xs</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> of</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> Object</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">entries</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">runs</span><span style="--shiki-light:#999999;--shiki-dark:#666666">))</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">  console</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">log</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">`</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">${</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">median</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">xs</span><span style="--shiki-light:#999999;--shiki-dark:#666666">).</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">toFixed</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">}</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">% busy at rest  </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">${</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">url</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">}</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">  (runs: </span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">${</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">xs</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">map</span><span style="--shiki-light:#999999;--shiki-dark:#666666">((</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">x</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> x</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">toFixed</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)).</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">join</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">, </span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">}</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">)</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">`</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span></code></pre></figure>
<p>التثبيت: <code>npm i -D playwright</code> ثم <code class="code-long">npx playwright install chromium</code>.</p>
<aside class="callout callout-warning"><p class="callout-label">انتبه</p><p>تأكد أن المتصفح الذي تقيس به يستخدم الـ GPU. شغّلت قياساتي في Edge ‏(<code class="code-long">chromium.launch({ channel: 'msedge' })</code>)، وعلى جهازي استخدم كرت الشاشة الحقيقي حتى في الوضع headless. افحص <code>chrome://gpu</code> بالمتصفح والإعدادات نفسها قبل أن تثق بأي رقم.</p>
</aside>
<aside class="callout callout-tip"><p class="callout-label">نصيحة</p><p>غيّر شيئًا واحدًا في كل مقارنة. الجولات متناوبة كي تصيب أي لحظة انشغال في جهازك النسختين بالتساوي، والوسيط يستبعد الجولة التي صادفت تحديثًا يعمل في الخلفية.</p>
</aside>
<h2 id="ما-الذي-تغيّر">ما الذي تغيّر</h2>
<p>الصفحة نفسها، والحركات نفسها، على محاكاة الهاتف نفسها:</p>
<div class="stat-grid"><div class="stat"><p class="stat-value" dir="ltr">98% → 18%</p><p class="stat-label">انشغال الخيط الرئيسي والصفحة ساكنة</p></div><div class="stat"><p class="stat-value" dir="ltr">1.2 → 0.1 ث</p><p class="stat-label">زمن الحجب الكلي عند التحميل</p></div><div class="stat"><p class="stat-value" dir="ltr">67 → 17 ms</p><p class="stat-label">أسوأ إطار أثناء التمرير</p></div></div>
<div class="table-wrap"><table>
<thead>
<tr>
<th></th>
<th>قبل</th>
<th>بعد</th>
</tr>
</thead>
<tbody><tr>
<td>انشغال الخيط الرئيسي والصفحة ساكنة</td>
<td>98%</td>
<td><strong>18%</strong></td>
</tr>
<tr>
<td>زمن الحجب الكلي عند التحميل</td>
<td>1.2 ث</td>
<td><strong>0.1 ث</strong></td>
</tr>
<tr>
<td>أول ظهور للمحتوى</td>
<td>1.6 ث</td>
<td><strong>0.7 ث</strong></td>
</tr>
<tr>
<td>أسوأ إطار أثناء التمرير</td>
<td>67 ms</td>
<td><strong>17 ms</strong></td>
</tr>
<tr>
<td>الكمبيوتر، انشغال الخيط الرئيسي والصفحة ساكنة</td>
<td>43%</td>
<td><strong>8%</strong></td>
</tr>
</tbody></table></div>
<p>مكاسب التحميل جاءت من ثلاثة تغييرات أخرى: حذفت framer-motion ‏(42 KB مضغوطة) لصالح بضع حركات CSS. وتوقفت الصفحة الرئيسية عن حزم نصوص دراسات الحالة الكاملة، فنزل السكربت الرئيسي من 416 KB إلى 324 KB. وصارت الخطوط تُحمَّل دون أن تعطّل سكربت التطبيق.</p>
<p>ولم تُحذف حركة واحدة.</p>
<h2 id="القائمة-التي-أراجعها-الآن">القائمة التي أراجعها الآن</h2>
<ul>
<li>حرّك <code>transform</code> و<code>opacity</code> على عناصر HTML. ولا شيء غيرهما في حلقة متكررة، أبدًا.</li>
<li>لا سكربت يعمل إلى الأبد: لا حلقات <code>requestAnimationFrame</code>، ولا إعادة رسم canvas، ولا تحديث حالة على مؤقت.</li>
<li>حركة transform واحدة لكل عنصر. وإن احتجت اثنتين، أضف عنصرًا يغلّفه.</li>
<li>لا <code>:has()</code> فوق شجرة فيها حركة. ضع كلاسًا مرة واحدة.</li>
<li>لا <code>backdrop-filter</code> فوق محتوى متحرك.</li>
<li>أوقف ما هو خارج الشاشة، واجعل مؤقتاتك تنتظر معه.</li>
<li>شغّل سكربت التدقيق بعد كل تأثير جديد. كل سطر يطبعه إطار على الخيط الرئيسي تدفع ثمنه.</li>
<li>اختبر كل مشتبه به وحده قبل أن تنسب الفضل لإصلاح. واحد من إصلاحاتي الستة لم يغيّر شيئًا.</li>
<li>قِس الخيط الرئيسي والصفحة ساكنة، قبل/بعد وبالوسيط. عدد الإطارات في الثانية لا يحدّثك إلا عن الخيط السليم.</li>
</ul>
<p>الحركة لم تكن يومًا الجزء المكلف. المكلف هو الحركة الواحدة التي تطلب من الخيط الرئيسي إطارًا بصمت، والصفحة كلها تدفع الثمن.</p>
<p>والعنوان ذو اللمعة الذهبية نفسه انكسر بالعربية بطريقة لا يكشفها أي trace. تلك <a href="https://moghalis.com/ar/blog/arabic-text-effects">القصة الأخرى</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>تأثيرات النص حرفًا بحرف تكسر العربية، وIntl.Segmenter لن ينقذك</title>
      <link>https://moghalis.com/ar/blog/arabic-text-effects</link>
      <guid isPermaLink="true">https://moghalis.com/ar/blog/arabic-text-effects</guid>
      <pubDate>Mon, 19 Oct 2026 07:00:00 GMT</pubDate>
      <description>عنصر span لكل حرف شقّ «لا» في عنوان موقعي وضاعف لمعته الذهبية. لماذا لا تنقذك عناقيد الحروف، ولماذا تنقذك الكلمات، ومكوّن فك تشفير يعمل في اللغتين.</description>
      <category>العربية</category>
      <category>RTL</category>
      <category>الطباعة</category>
      <category>CSS</category>
      <category>React</category>
      <content:encoded><![CDATA[<p>العنوان الإنجليزي فكّ تشفيره بجمال: رموز برمجية تومض أمام مؤشر، وكل حرف يستقر في مكانه خلفه.</p>
<p>ثم حوّلت الصفحة إلى العربية.</p>
<p>قبل أن ينتهي التأثير، تعثّرت عيني بكلمة واحدة: «كاملًا». الـ«لا» في آخرها، الشكل الذي يعرفه كل قارئ عربي من أول نظرة، وقفت حرفين منفصلين. وبعد ثانية مرّت اللمعة الذهبية على العنوان، فبدت كل كلمة مكررة مرتين.</p>
<p>كلمة واحدة فيها خطآن، وخطأ ثالث يمتد على السطر كله. والحل الذي يمد إليه كل مطوّر يده، <code>Intl.Segmenter</code>، يعالج واحدًا منها فقط.</p>
<p>وحين أعدت بناء الخطأ في صفحة اختبار من أجل هذا المقال، صار أغرب: العناصر نفسها كانت سليمة في فقرة عادية. احتاج كسرُها إلى خاصية CSS واحدة في عنواني.</p>
<p>في آخر المقال ستعرف ما هذه الخاصية، ولماذا الكلمة أصغر وحدة يمكنك تحريكها بأمان في العربية، وكيف تعيد السطر نصًا عاديًا فينطبق عليه كل ما فوقه بالبكسل، وستخرج بمكوّن جاهز للنسخ.</p>
<aside class="post-summary"><p class="callout-label">باختصار</p><ul>
<li>حرّك العربية كلمةً كلمة. الحروف في عناصر منفصلة قد تفقد اتصالها وشكل «لا»، والكلمة الكاملة تحفظ الاثنين.</li>
<li>قسّم نصوص اللغات الأخرى حسب عناقيد الحروف (grapheme clusters) بـ <code>Intl.Segmenter</code>، ولا تقسّم حسب نقطة الترميز أبدًا. العناقيد تُبقي الحركات على حروفها، ولا تفعل شيئًا للحروف المركّبة.</li>
<li>حين ينتهي التأثير، استبدل العناصر بنص عادي، فينطبق عليه كل ما فوقه تمامًا.</li>
</ul>
</aside>
<h2 id="النسخة-التي-يكتبها-الجميع-أولًا">النسخة التي يكتبها الجميع أولًا</h2>
<p>قسّم النص إلى حروف، ضع كل حرف في span، اتركها شفافة حتى يأتي دورها، وارسم فوقها رمزًا عشوائيًا أثناء الانتظار:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">{</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">Array</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">from</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">).</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">map</span><span style="--shiki-light:#999999;--shiki-dark:#666666">((</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">char</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> i</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">  &#x3C;</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">span</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> key</span><span style="--shiki-light:#999999;--shiki-dark:#666666">={</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">i</span><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> className</span><span style="--shiki-light:#999999;--shiki-dark:#666666">=</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">dc</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">>{</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">char</span><span style="--shiki-light:#999999;--shiki-dark:#666666">}&#x3C;/</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">span</span><span style="--shiki-light:#999999;--shiki-dark:#666666">></span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">))}</span></span></code></pre></figure>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">dc</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> color</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> transparent</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">dc</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">is-scr</span><span style="--shiki-light:#999999;--shiki-dark:#666666">::</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">after</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> content</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> attr</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">data-g</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> position</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> absolute</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD"> /* … */</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">dc</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">is-on</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> color</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> inherit</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span></code></pre></figure>
<p>جزء واحد من هذا صحيح، وقد بقي. كل حرف موجود في الصفحة من أول إطار، فالسطر يأخذ عرضه النهائي ويلتف حيث سيلتف، ولا شيء يتحرك في التخطيط أثناء التأثير.</p>
<p>أما كل ما عداه فكان لا بد أن يتغير.</p>
<h2 id="ما-الذي-انكسر-في-العربية-طبقة-بعد-طبقة">ما الذي انكسر في العربية، طبقة بعد طبقة</h2>
<h3 id="الخطأ-الأول-الحركات-سقطت-عن-حروفها">الخطأ الأول: الحركات سقطت عن حروفها</h3>
<p><code>Array.from()</code> يقسّم النص حسب نقطة الترميز (code point). و«كاملًا» فيها ست نقاط:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> hex</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">s</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#999999;--shiki-dark:#666666"> [...</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">s</span><span style="--shiki-light:#999999;--shiki-dark:#666666">].</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">map</span><span style="--shiki-light:#999999;--shiki-dark:#666666">((</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">c</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> c</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">codePointAt</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">).</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">toString</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">16</span><span style="--shiki-light:#999999;--shiki-dark:#666666">).</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">toUpperCase</span><span style="--shiki-light:#999999;--shiki-dark:#666666">().</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">padStart</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">4</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">0</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">))</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">hex</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">كاملًا</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">// ['0643', '0627', '0645', '0644', '064B', '0627']   ست نقاط ترميز، ستة عناصر</span></span></code></pre></figure>
<p>الـ <code>064B</code> هو التنوين، الحركة التي تعلو اللام. ولأنه نقطة ترميز مستقلة، أخذ عنصرًا مستقلًا، بعيدًا عن الحرف الذي ينتمي إليه.</p>
<p>ولا تفترض أن نصوصك خالية من الحركات. عربية الواجهات تُسقط معظمها، لكن تنوين الفتح في كلمات مثل «كاملًا» و«أيضًا» من صلب الإملاء، والشدّة تظهر في أسماء مثل «محمّد».</p>
<p>ما يراه القارئ حرفًا واحدًا هو عنقود حروف (grapheme cluster)، والمتصفح يقسّمه لك:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> seg</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> new</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> Intl</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">Segmenter</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">undefined</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> granularity</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">grapheme</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> })</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">Array</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">from</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">seg</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">segment</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">كاملًا</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">),</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">part</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> hex</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">part</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">segment</span><span style="--shiki-light:#999999;--shiki-dark:#666666">).</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">join</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">+</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">))</span></span>
<span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">// ['0643', '0627', '0645', '0644+064B', '0627']   خمسة عناقيد: الحركة ترافق لامها</span></span></code></pre></figure>
<p>الخطأ الأول انحلّ. والآن انظر إلى آخر عنصرين.</p>
<h3 id="الخطأ-الثاني-لا-انشقّت-وintlsegmenter-عاجز-عن-منعها">الخطأ الثاني: «لا» انشقّت، وIntl.Segmenter عاجز عن منعها</h3>
<p><code>0644+064B</code> هي اللام بحركتها، و<code>0627</code> هي الألف. عنقودان، أي عنصران ما زالا منفصلين.</p>
<p>اللام متبوعة بالألف هي الحرف المركّب (ligature) الوحيد الذي تفرضه العربية. يحتفظ Unicode بالشكل الجاهز للتوافق فقط (ﻻ، U+FEFB). أما النص الحقيقي فيخزّن حرفين، والخط يرسمهما شكلًا واحدًا حين يشكّلهما معًا.</p>
<p>عناقيد الحروف تصف ما يراه القارئ حرفًا واحدًا. والحرف المركّب أكبر من ذلك: حرفان يدمجهما الخط في شكل واحد. ولا تقسيم بالعناقيد يستطيع إبقاءه سليمًا، لأنه يمتد على عنقودين.</p>
<p>فمتى يظهر الانشقاق فعلًا؟ أعدت بناء العنوان في صفحة اختبار (خط IBM Plex Sans Arabic بوزن 600، في Chrome 153 وEdge 155، وقد تطابقا بالبكسل) وقست السطر في خمسة أوضاع:</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>النص</th>
<th>CSS إضافي</th>
<th>«لا»</th>
<th>أعرض سطر</th>
</tr>
</thead>
<tbody><tr>
<td>نص عادي</td>
<td><code>text-wrap: balance</code></td>
<td>شكل واحد</td>
<td>415.7px</td>
</tr>
<tr>
<td>عنصر لكل كلمة</td>
<td><code>text-wrap: balance</code></td>
<td>شكل واحد</td>
<td>415.7px</td>
</tr>
<tr>
<td>عنصر لكل عنقود</td>
<td>لا شيء</td>
<td>شكل واحد</td>
<td>في حدود 0.4px من النص العادي</td>
</tr>
<tr>
<td>عنصر لكل عنقود</td>
<td><code>text-wrap: balance</code></td>
<td><strong>حرفان</strong></td>
<td><strong>458.9px</strong></td>
</tr>
<tr>
<td>عنصر لكل عنقود</td>
<td><code>display: inline-block</code></td>
<td><strong>حرفان، ولا اتصال إطلاقًا</strong></td>
<td><strong>أعرض بـ 33%</strong></td>
</tr>
</tbody></table></div>
<p>في سطر عادي، يشكّل Chrome الحروف عبر العناصر السطرية فتنجو الكلمة. لكن عنواني كان عليه <code>text-wrap: balance</code>، ومعها فقدت العناصر نفسها الحرف المركّب، وعددًا من نقاط الاتصال، و43 بكسلًا من العرض. وصفّ <code>inline-block</code> هو ما تحصل عليه من أي تأثير يحرّك الحروف واحدًا واحدًا، لأن الـ transform يحتاج صندوقًا: فيقف كل حرف وحيدًا.</p>
<p>وحتى في الحالة التي تنجو فيها الكلمة، هناك فخ. مع خطي، الحرف المركّب شكل واحد، وChrome يلوّنه بلون عنصر اللام. في اختباري ظهرت «لا» كاملة لحظة استقرار اللام، وما دامت اللام شفافة بقيت الألف مختفية معها. نصف شكل لا يمكن تحريكه.</p>
<aside class="callout callout-warning"><p class="callout-label">انتبه</p><p>اختبر أي تأثير حرفي بالـ CSS نفسه الذي سيحمله النص في الإنتاج. تأثيري بدا سليمًا في فقرة اختبار عادية، وانكسر بخاصية واحدة في العنوان الحقيقي.</p>
</aside>
<h3 id="الخطأ-الثالث-اللمعة-الذهبية-تضاعفت">الخطأ الثالث: اللمعة الذهبية تضاعفت</h3>
<p>فوق عنواني لمعة ذهبية: نسخة ثانية من العنوان كنص عادي، يكشفها شريط متحرك. والنص العادي والعناصر المقسّمة صارا يختلفان بـ 43 بكسلًا في أعرض سطر، فتباعدت الحروف وتوازن السطر بطريقتين، ولم تعد الحروف الذهبية فوق البيضاء.</p>
<figure class="post-figure dg dg-is-bars"><div class="dg-bars"><div class="dg-bar-row"><span class="dg-bar-label">نص عادي (النسخة الذهبية)</span><span class="dg-bar-track"><span class="dg-bar" style="--v:0.906"></span></span><span class="dg-bar-value" dir="ltr">415.7px</span></div><div class="dg-bar-row"><span class="dg-bar-label">عنصر لكل كلمة</span><span class="dg-bar-track"><span class="dg-bar" style="--v:0.906"></span></span><span class="dg-bar-value" dir="ltr">415.7px</span></div><div class="dg-bar-row"><span class="dg-bar-label">عنصر لكل عنقود</span><span class="dg-bar-track"><span class="dg-bar" style="--v:1.000"></span></span><span class="dg-bar-value" dir="ltr">458.9px</span></div></div></figure>
<figure class="post-figure dg dg-is-compare"><div class="dg-compare"><div class="dg-compare-head"><span>عنصر لكل حرف</span><span>عنصر لكل كلمة، ثم نص عادي</span></div><div class="dg-compare-row"><span>«لا» تُرسم حرفين</span><span>«لا» تبقى شكلًا واحدًا</span></div><div class="dg-compare-row"><span>التنوين في عنصر مستقل</span><span>الحركات تبقى على حروفها</span></div><div class="dg-compare-row"><span>السطر يُشكَّل عبر العناصر</span><span>السطر يُشكَّل كنص عادي</span></div><div class="dg-compare-row"><span>اللمعة الذهبية مزدوجة</span><span>اللمعة تنطبق على النص تمامًا</span></div></div></figure>
<h2 id="الكلمة-أصغر-وحدة-آمنة">الكلمة أصغر وحدة آمنة</h2>
<p>الكلمة أصغر قطعة من العربية تحمل تشكيلها كاملًا: اتصال حروفها، وحركاتها، وحروفها المركّبة. لذا تُفك العربية كلمةً كلمة. أما الإنجليزية فتبقى حرفًا حرفًا، لأنها تُقرأ جيدًا بالحروف اللاتينية ولا تكلّف شيئًا إضافيًا.</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const </span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">ARABIC</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> /</span><span style="--shiki-light:#BDA437;--shiki-dark:#E6CC77">\p</span><span style="--shiki-light:#AB5E3F;--shiki-dark:#C4704F">{Script=Arabic}</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">/</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">u</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const </span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">byWord</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> ARABIC</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">test</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">// المسافات تبقى نصًا عاديًا بين الكلمات.</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const </span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">units</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> byWord</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ? </span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">split</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">/</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> )</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">/</span><span style="--shiki-light:#999999;--shiki-dark:#666666">).</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">filter</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">Boolean</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> : </span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">graphemes</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span></code></pre></figure>
<p>موقعي يفحص كتلة العربية الأساسية، من U+0600 إلى U+06FF، وهي تغطي نصوصه. أما <code>\p{Script=Arabic}</code> فهو الخيار الأسلم لنص لا تتحكم فيه: يطابق أيضًا الكتل الملحقة والموسّعة وأشكال العرض.</p>
<p>وأي نص فيه حرف عربي واحد يُفك بالكلمات، بما فيها الكلمات اللاتينية. «مهندس برمجيات Full Stack» تُفك أربع كلمات.</p>
<p>احتاج الإيقاع إلى تعديلين، وكلاهما في المكوّن أدناه:</p>
<figure class="post-figure dg dg-is-compare"><div class="dg-compare"><div class="dg-compare-head"><span>الحروف اللاتينية</span><span>الحروف العربية</span></div><div class="dg-compare-row"><span>عنقود واحد في كل خطوة</span><span>كلمة واحدة في كل خطوة</span></div><div class="dg-compare-row"><span>20 ms لكل خطوة</span><span>60 ms لكل خطوة: ثلاث خطوات لكل كلمة</span></div><div class="dg-compare-row"><span>رمز واحد أثناء الانتظار</span><span>من 2 إلى 7 رموز، بطول الكلمة تقريبًا</span></div><div class="dg-compare-row"><span>عنواني: 49 حرفًا</span><span>عنواني: 8 كلمات</span></div></div></figure>
<p>الكلمة تأخذ ثلاث خطوات، فلا تمر خاطفة في زمن حرف واحد. وأثناء انتظارها تُظهر دفعة رموز بطولها تقريبًا، فيحتفظ السطر بنسيجه.</p>
<h2 id="أنهِ-على-نص-عادي-وإلا-لن-ينطبق-الذهب">أنهِ على نص عادي، وإلا لن ينطبق الذهب</h2>
<p>العناصر المقسّمة بالكلمات تطابق النص العادي في حدود 0.03px في صفحة اختباري. والنص العادي يطابق نفسه تمامًا، بعقدة نصية واحدة بدل عنصر لكل كلمة. لذا حين ينتهي التأثير، تختفي العناصر كلها.</p>
<figure class="post-figure dg dg-is-flow"><ol class="dg-flow"><li><strong>تستقر آخر وحدة</strong><span>تتوقف حلقة requestAnimationFrame</span></li><li><strong>بعد 200 ms</strong><span>تُستبدل العناصر بنص عادي واحد</span></li><li><strong>يُستدعى onSettled</strong><span>يضيف React الكلاس is-decoded للعنوان</span></li><li><strong>تبدأ اللمعة الذهبية</strong><span>كانت موقوفة حتى الآن</span></li></ol></figure>
<p>العنوان المستقر صار يُشكَّل تمامًا كنص عادي، بحروفه المركّبة، واللمعة الذهبية، وهي نص عادي أيضًا، تنطبق عليه بالبكسل.</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">if</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">settled</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> return</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> &#x3C;</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">span</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> className</span><span style="--shiki-light:#999999;--shiki-dark:#666666">=</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">decode is-done</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">>{</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">}&#x3C;/</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">span</span><span style="--shiki-light:#999999;--shiki-dark:#666666">></span></span></code></pre></figure>
<p>اللمعة تنتظر هذه اللحظة بكلاس، ولا تنتظرها بـ <code>:has()</code> أبدًا. ولماذا ضاعف <code>:has()</code> هناك انشغال المعالج والصفحة ساكنة، تجده في <a href="https://moghalis.com/ar/blog/gpu-only-motion">مقال الأداء</a>.</p>
<p>واللمعة نفسها حركتا transform. شريط بعرض 30% من العنوان ينزلق في اتجاه، وداخله نسخة نصية بعرض العنوان كله تنزلق عكسه بالمسافة نفسها وعلى الساعة نفسها، فتبقى الحروف الذهبية فوق البيضاء. وهندستها تستخدم <code>left</code> الفيزيائي عمدًا: في العربية يجب أن ينقلب النص الذي داخلها فقط، ويبقى مسار الشريط كما هو.</p>
<h2 id="من-اليمين-إلى-اليسار-مجانًا">من اليمين إلى اليسار، مجانًا</h2>
<p>فك التشفير يسير من اليمين إلى اليسار في العربية دون حالة خاصة واحدة.</p>
<p>الوحدات بترتيب الـ DOM، وهو الترتيب المنطقي: الترتيب الذي تكتب به وتقرأ. الفهرس 0 هو أول كلمة يراها القارئ، على اليمين. والحلقة تمشي على الفهارس، فيتحرك المؤشر كما تتحرك عين القارئ.</p>
<p>وطبقة الرموز لا تحتاج قواعد اتجاه هي الأخرى. تجلس عند <code>left: 50%</code> مع <code>translate: -50% 0</code>، في منتصف وحدتها أيًّا كان الاتجاه.</p>
<p>الأرقام هي الموضع الوحيد الذي يستحق التصريح. الأرقام داخل النص العربي تجري من اليسار إلى اليمين، لذا تجلس الأرقام تحت عنواني في عناصرها الخاصة بـ <code>dir=&quot;ltr&quot;</code>، وتُفك عبر رموز أرقام بدل الرموز البرمجية.</p>
<h2 id="إبقاؤه-خفيفًا">إبقاؤه خفيفًا</h2>
<p>هذا التأثير يشغّل حلقة <code>requestAnimationFrame</code>، وهو ما يعدّه مقال الأداء علامة خطر. الفرق أن هذه الحلقة تنتهي.</p>
<ul>
<li>حلقة واحدة لكل نص، لا تلمس إلا الوحدات داخل نافذة التبدّل. تخرج عند أول وحدة بعد النافذة، وتتوقف حين تستقر آخر وحدة.</li>
<li>رموز جديدة كل 60 ms، نحو 16 مرة في الثانية: سريعة بما يكفي لتبدو تشويشًا، وبطيئة بما يكفي لتُرى الرموز.</li>
<li>الوحدات التي تتبدل في تلك اللحظة فقط تأخذ <code>position: relative</code>، وهي ما يحتاجه الرمز. العنصر المتموضع يصير طبقة رسم مستقلة، ومئة منها جعلت كل إطار من التأثير ثقيلًا.</li>
<li>أطوال دفعات الرموز للكلمات العربية تُحسب مرة واحدة. تشغيل <code>Intl.Segmenter</code> مع كل تبديل لكل كلمة منتظرة ظهر في الـ profile.</li>
<li>ومع تفضيل تقليل الحركة (reduced motion)، يستقر النص فورًا.</li>
</ul>
<p>وبالقياس قبل/بعد على محاكاة هاتف بمعالج أبطأ أربع مرات، يكلّف التأثير كله نحو 0.35 ثانية من عمل الخيط الرئيسي موزعة على أول ثانيتين، ولا شيء بعدها.</p>
<h2 id="المكوّن-جاهزًا-للنسخ">المكوّن، جاهزًا للنسخ</h2>
<p>هذا المكوّن من موقعي، مختصرًا إلى ما يهم. يحتاج مكتبة <code>ES2022</code> أو أحدث في TypeScript من أجل <code>Intl.Segmenter</code>، ويعود إلى نقاط الترميز إن غاب عن المتصفح.</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">import</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> useEffect</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> useMemo</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> useRef</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> useState</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> from</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">react</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> GLYPHS</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">{}[]()&#x3C;>/=+-*&#x26;|!?#$%@;:01λΣ</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> ARABIC</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> /</span><span style="--shiki-light:#BDA437;--shiki-dark:#E6CC77">\p</span><span style="--shiki-light:#AB5E3F;--shiki-dark:#C4704F">{Script=Arabic}</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">/</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">u</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">/** الحروف كما يراها القارئ، فيحتفظ الحرف بحركاته. */</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">const</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> graphemes</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">string</span><span style="--shiki-light:#999999;--shiki-dark:#666666">):</span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994"> string</span><span style="--shiki-light:#999999;--shiki-dark:#666666">[]</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">  typeof</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> Intl</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">Segmenter</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ===</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">function</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    ?</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> Array</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">from</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">new</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> Intl</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">Segmenter</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">undefined</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> granularity</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">grapheme</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }).</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">segment</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">),</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">s</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> s</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">segment</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    :</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> Array</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">from</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">type</span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994"> Props</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">  text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">string</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">  delay</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">?</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">number</span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD"> // ms قبل أن تبدأ أول وحدة</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">  step</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">?</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">number</span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD"> // ms لكل حرف؛ والكلمة العربية تأخذ ثلاث خطوات</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">  window</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">?</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">number</span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD"> // كم وحدة أمام المؤشر تتبدل رموزها</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">  onSettled</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">?</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: () => </span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">void</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">export</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> default</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> function</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> DecodeText</span><span style="--shiki-light:#999999;--shiki-dark:#666666">({</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> delay</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> step</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 20</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> window</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> span</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 5</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> onSettled</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }: </span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">Props</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">  const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> ref</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> useRef</span><span style="--shiki-light:#999999;--shiki-dark:#666666">&#x3C;</span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">HTMLSpanElement</span><span style="--shiki-light:#999999;--shiki-dark:#666666">>(</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">null</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">  const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> onSettledRef</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> useRef</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">onSettled</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">  useEffect</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(()</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">    onSettledRef</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">current</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> onSettled</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">  })</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">  const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> byWord</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> ARABIC</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">test</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">  // العربية بالكلمات (المسافات تبقى نصًا عاديًا بينها)، وغيرها بالعناقيد.</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">  const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> units</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> useMemo</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(()</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">byWord</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ?</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">split</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">/</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> )</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">/</span><span style="--shiki-light:#999999;--shiki-dark:#666666">).</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">filter</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">Boolean</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> :</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> graphemes</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)),</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> [</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> byWord</span><span style="--shiki-light:#999999;--shiki-dark:#666666">])</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">  const</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> [</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">settledText</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> setSettledText</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> useState</span><span style="--shiki-light:#999999;--shiki-dark:#666666">&#x3C;</span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">string</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> |</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> null</span><span style="--shiki-light:#999999;--shiki-dark:#666666">>(</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">null</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">  const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> settled</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> settledText</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ===</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> text</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">  useEffect</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(()</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> root</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> ref</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">current</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">    if</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">!</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">root</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ||</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> settled</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> return</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> cells</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> Array</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">from</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">root</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">querySelectorAll</span><span style="--shiki-light:#999999;--shiki-dark:#666666">&#x3C;</span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">HTMLElement</span><span style="--shiki-light:#999999;--shiki-dark:#666666">>(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">.dc</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">))</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    const</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> settle</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> ()</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">      setSettledText</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">      onSettledRef</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">current</span><span style="--shiki-light:#999999;--shiki-dark:#666666">?.()</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    }</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">    if</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">window</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">matchMedia</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">(prefers-reduced-motion: reduce)</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">).</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">matches</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">      settle</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">      return</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    }</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    const</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> noise</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">n</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">number</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> Array</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">from</span><span style="--shiki-light:#999999;--shiki-dark:#666666">({</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> length</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> n</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> },</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> ()</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> GLYPHS</span><span style="--shiki-light:#999999;--shiki-dark:#666666">[(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">Math</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">random</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> *</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> GLYPHS</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">length</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> |</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]).</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">join</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">''</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">    // دفعة الكلمة بطولها تقريبًا: تُحسب مرة واحدة، لا مع كل تبديل.</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> burst</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> cells</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">map</span><span style="--shiki-light:#999999;--shiki-dark:#666666">((</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">c</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">byWord</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ?</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> Math</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">min</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">7</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> Math</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">max</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">2</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> graphemes</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">c</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">textContent</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ??</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> ''</span><span style="--shiki-light:#999999;--shiki-dark:#666666">).</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">length</span><span style="--shiki-light:#999999;--shiki-dark:#666666">))</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> :</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1</span><span style="--shiki-light:#999999;--shiki-dark:#666666">))</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> pace</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> byWord</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ?</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> step</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> *</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 3</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> :</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> step</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> start</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> performance</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">now</span><span style="--shiki-light:#999999;--shiki-dark:#666666">()</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> +</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> delay</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    let</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> raf</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    let</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> timer</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    let</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> lastShuffle</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">    const</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> tick</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">now</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">number</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">      const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> t</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> now</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> -</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> start</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">      const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> shuffle</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> now</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> -</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> lastShuffle</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> ></span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 60</span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD"> // نحو 16 رمزًا جديدًا في الثانية</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">      if</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">shuffle</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> lastShuffle</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> now</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">      let</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> landed</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">      for</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">let</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> i</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> i</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> &#x3C;</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> cells</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">length</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> i</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">++</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">        const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> cell</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> cells</span><span style="--shiki-light:#999999;--shiki-dark:#666666">[</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">i</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">        const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> at</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> i</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> *</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> pace</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">        if</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">t</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> >=</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> at</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">          if</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">!</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">cell</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">classList</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">contains</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">is-on</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">))</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">            cell</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">classList</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">remove</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">is-scr</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">            cell</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">classList</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">add</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">is-on</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">          }</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">          landed</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">++</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">        }</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> else</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> if</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">t</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> >=</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> at</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> -</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> span</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> *</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> pace</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">          if</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">shuffle</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ||</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> !</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">cell</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">classList</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">contains</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">is-scr</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">))</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">            cell</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">dataset</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">g</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> noise</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">burst</span><span style="--shiki-light:#999999;--shiki-dark:#666666">[</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">i</span><span style="--shiki-light:#999999;--shiki-dark:#666666">])</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">            cell</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">classList</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">add</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">is-scr</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">          }</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">        }</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> else</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> break</span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD"> // كل ما بعد هذه الوحدة ما زال ينتظر</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">      }</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">      if</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">landed</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> &#x3C;</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> cells</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">length</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> raf</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> requestAnimationFrame</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">tick</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">      else</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> timer</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> window</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">setTimeout</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">settle</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 200</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD"> // ثم أعِد النص عاديًا</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    }</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">    raf</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> requestAnimationFrame</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">tick</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">    return</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> ()</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">      cancelAnimationFrame</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">raf</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">      window</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">clearTimeout</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">timer</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    }</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">  },</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> [</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> units</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> byWord</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> delay</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> step</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> span</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> settled</span><span style="--shiki-light:#999999;--shiki-dark:#666666">])</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">  const</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> className</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> settled</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ?</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">decode is-done</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> :</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">decode</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span></span>
<span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">  // بعد الاستقرار: نص عادي واحد، يُشكَّل كأي نص آخر في الصفحة.</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  if</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">settled</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> return</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> &#x3C;</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">span</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> className</span><span style="--shiki-light:#999999;--shiki-dark:#666666">={</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">className</span><span style="--shiki-light:#999999;--shiki-dark:#666666">}>{</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">}&#x3C;/</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">span</span><span style="--shiki-light:#999999;--shiki-dark:#666666">></span></span>
<span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">  // المفتاح هو النص، فتبديل اللغة يركّب وحدات جديدة مخفية.</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  return</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    &#x3C;</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">span</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> key</span><span style="--shiki-light:#999999;--shiki-dark:#666666">={</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> ref</span><span style="--shiki-light:#999999;--shiki-dark:#666666">={</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">ref</span><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> className</span><span style="--shiki-light:#999999;--shiki-dark:#666666">={</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">className</span><span style="--shiki-light:#999999;--shiki-dark:#666666">}></span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">      {</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">units</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">map</span><span style="--shiki-light:#999999;--shiki-dark:#666666">((</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">unit</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> i</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">unit</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ===</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ?</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> :</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> &#x3C;</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">span</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> key</span><span style="--shiki-light:#999999;--shiki-dark:#666666">={</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">i</span><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> className</span><span style="--shiki-light:#999999;--shiki-dark:#666666">=</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">dc</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">>{</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">unit</span><span style="--shiki-light:#999999;--shiki-dark:#666666">}&#x3C;/</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">span</span><span style="--shiki-light:#999999;--shiki-dark:#666666">>))}</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    &#x3C;/</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">span</span><span style="--shiki-light:#999999;--shiki-dark:#666666">></span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">  )</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span></code></pre></figure>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">dc</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> color</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> transparent</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">/* أثناء التبدّل فقط: العنصر المتموضع طبقة رسم مستقلة. */</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">dc</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">is-scr</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> position</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> relative</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">dc</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">is-scr</span><span style="--shiki-light:#999999;--shiki-dark:#666666">::</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">after</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  content</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> attr</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">data-g</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  position</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> absolute</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  top</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  left</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 50</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">%</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  translate</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> -50</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">%</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD"> /* في منتصف وحدته، في أي اتجاه */</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  white-space</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> nowrap</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  font-family</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> ui-monospace</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> monospace</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  color</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> #</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">d6b56d</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">dc</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">is-on</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> color</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> inherit</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span></code></pre></figure>
<p>والعنوان الذي ينتظره:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">export</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> function</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> Headline</span><span style="--shiki-light:#999999;--shiki-dark:#666666">({</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> text</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }: { </span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">string</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> })</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">  const</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> [</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">decodedFor</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> setDecodedFor</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> useState</span><span style="--shiki-light:#999999;--shiki-dark:#666666">&#x3C;</span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">string</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> |</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> null</span><span style="--shiki-light:#999999;--shiki-dark:#666666">>(</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">null</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  return</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    &#x3C;</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">h1</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> className</span><span style="--shiki-light:#999999;--shiki-dark:#666666">={</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">decodedFor</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ===</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> text</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ?</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">headline is-decoded</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> :</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">headline</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">}></span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">      &#x3C;</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">DecodeText</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">={</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> delay</span><span style="--shiki-light:#999999;--shiki-dark:#666666">={</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">420</span><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> onSettled</span><span style="--shiki-light:#999999;--shiki-dark:#666666">={()</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> setDecodedFor</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)}</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> /></span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    &#x3C;/</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">h1</span><span style="--shiki-light:#999999;--shiki-dark:#666666">></span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">  )</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span></code></pre></figure>
<p><code>decodedFor</code> يحفظ النص الذي استقر. بدّل اللغة، فالعنوان الجديد لم يستقر بعد، فتتوقف اللمعة من جديد حتى يستقر.</p>
<h2 id="القاعدة-نفسها-للتأثيرات-التي-تحرّك-النص">القاعدة نفسها للتأثيرات التي تحرّك النص</h2>
<p>فك التشفير يغيّر اللون فقط، لذا يتعايش مع العناصر السطرية. أما التأثيرات التي تحرّك الحروف فتحتاج صندوقًا لكل قطعة، وكما أظهر الجدول، <code>inline-block</code> لكل حرف يترك كل حرف عربي وحيدًا.</p>
<p>حرّك الكلمات إذن. الحروف العربية لا تتصل عبر مسافة أبدًا، فصندوق لكل كلمة لا يكلّف التشكيل شيئًا. الفقرة تحت عنواني تنساب بهذه الطريقة، كل كلمة ترتفع بعد سابقتها ببضع ملّي ثوانٍ:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">import</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> Fragment</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> type</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> CSSProperties</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> from</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">react</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">/** كل كلمة ترتفع إلى مكانها بعد سابقتها ببضع ms. الكلمات تبقى كاملة، فتحتفظ العربية باتصالها. */</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">export</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> function</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> StreamText</span><span style="--shiki-light:#999999;--shiki-dark:#666666">({</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> delay</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> step</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 14</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }: { </span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">string</span><span style="--shiki-light:#999999;--shiki-dark:#666666">; </span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">delay</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">?</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">number</span><span style="--shiki-light:#999999;--shiki-dark:#666666">; </span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">step</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">?</span><span style="--shiki-light:#999999;--shiki-dark:#666666">: </span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994">number</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> })</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">  return</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    &#x3C;></span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">      {</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">split</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#999999;--shiki-dark:#666666">).</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">map</span><span style="--shiki-light:#999999;--shiki-dark:#666666">((</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">word</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> i</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> =></span><span style="--shiki-light:#999999;--shiki-dark:#666666"> (</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">        &#x3C;</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">Fragment</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> key</span><span style="--shiki-light:#999999;--shiki-dark:#666666">={</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">`</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">${</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">text</span><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">length</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">}</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">-</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">${</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">i</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">}</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">`</span><span style="--shiki-light:#999999;--shiki-dark:#666666">}></span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">          {</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">i</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> ></span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> &#x26;&#x26;</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">          &#x3C;</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">span</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> className</span><span style="--shiki-light:#999999;--shiki-dark:#666666">=</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">sw</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> style</span><span style="--shiki-light:#999999;--shiki-dark:#666666">={{</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> [</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">--sw</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> as</span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994"> string</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]:</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> `</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">${</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">delay </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">+</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> i </span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">*</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> step</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">}</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">ms</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">`</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> as</span><span style="--shiki-light:#2E8F82;--shiki-dark:#5DA994"> CSSProperties</span><span style="--shiki-light:#999999;--shiki-dark:#666666">}></span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">            {</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">word</span><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">          &#x3C;/</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">span</span><span style="--shiki-light:#999999;--shiki-dark:#666666">></span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">        &#x3C;/</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965">Fragment</span><span style="--shiki-light:#999999;--shiki-dark:#666666">></span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">      ))}</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">    &#x3C;/></span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">  )</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span></code></pre></figure>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">sw</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  display</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> inline-block</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  animation</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE"> sw-in </span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">480</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">ms</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> cubic-bezier</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">0.22</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0.36</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> both</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  animation-delay</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> var</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">--sw</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">ms</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">@</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">keyframes</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> sw-in</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">  from</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> opacity</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> transform</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> translate3d</span><span style="--shiki-light:#999999;--shiki-dark:#666666">(</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91">0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0.4</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">em</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 0</span><span style="--shiki-light:#999999;--shiki-dark:#666666">);</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">}</span></span></code></pre></figure>
<p>حركة CSS تعمل مرة واحدة، بالشفافية والـ transform فقط، ولا سكربت بعد أول render. وعلامات الترقيم تبقى ملتصقة بكلمتها، لأن التقسيم على المسافات: «كاملًا،» ترتفع قطعة واحدة مع فاصلتها.</p>
<h2 id="قبل-أن-تنشر-تأثيرًا-نصيًا-بالعربية">قبل أن تنشر تأثيرًا نصيًا بالعربية</h2>
<ul>
<li>اختبر كلمة فيها «لا» وكلمة عليها حركة. «كاملًا» فيها الاثنتان.</li>
<li>اختبر سطرًا يخلط العربية باللاتينية، وسطرًا فيه أرقام.</li>
<li>اختبر بالـ CSS الحقيقي: <code>text-wrap: balance</code>، وخطك ووزنه الفعليان، والأحجام الفعلية.</li>
<li>لا تقسّم حسب نقطة الترميز أبدًا. استخدم عناقيد الحروف، وتذكّر أنها لا تحمي الحروف المركّبة.</li>
<li>في العربية، الكلمة أصغر وحدة يمكنك تحريكها بأمان. وإن احتاج التأثير صندوقًا لكل قطعة، فأعطه صندوقًا لكل كلمة.</li>
<li>إن كان فوق النص شيء، فأنهِ الحركة على نص عادي.</li>
<li>دع ترتيب الـ DOM يعطيك الاتجاه. لا حالات خاصة لليمين إلى اليسار.</li>
<li>انظر إليه بعين قارئ عربي. &quot;الحروف كلها موجودة&quot; و&quot;يُقرأ صحيحًا&quot; اختباران مختلفان.</li>
</ul>
<p>العربية تُكتب كلمات، والتأثير لم يعمل إلا حين صار يفك تشفيره كلمات أيضًا.</p>
<p>وإن كانت نصوصك تحمل حركات لا تريد تحريكها أصلًا، فـ<a href="https://moghalis.com/ar/tools/arabic-text">أداة توحيد النص العربي</a> تحذفها قبل أن تبدأ.</p>
]]></content:encoded>
    </item>
    <item>
      <title>انشر بإعادة تسمية واحدة، وتراجع في 9 ميلي ثانية، واحذر 8 فخاخ</title>
      <link>https://moghalis.com/ar/blog/atomic-static-deploys</link>
      <guid isPermaLink="true">https://moghalis.com/ar/blog/atomic-static-deploys</guid>
      <pubDate>Mon, 19 Oct 2026 06:00:00 GMT</pubDate>
      <description>عدّة النشر التي أستخدمها لموقع ثابت على VPS صغير: مجلد لكل إصدار، وإعادة تسمية ذرية تنشره، وتراجع في 9 ميلي ثانية، وحاوية Caddy لا تملك إلا القراءة، و8 فخاخ كشفها الاختبار.</description>
      <category>DevOps</category>
      <category>Caddy</category>
      <category>Docker</category>
      <category>Linux</category>
      <category>النشر</category>
      <content:encoded><![CDATA[<p>كان الأرشيف 9.4 ميغابايت، فيه 413 مدخلًا، والملف <code>./index.html</code> في السطر 260 من قائمته. قرأه سكربت النشر ثم طبع:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span>!! /tmp/moghalis-site.tar.gz has no index.html: not a site build.</span></span></code></pre></figure>
<p>السكربت كان مخطئًا. الحارس الذي كتبته ليصدّ الرفع المعطوب صدّ للتو رفعًا سليمًا، وكان سيصدّ كل بناء حقيقي بعده.</p>
<p>اكتشفت ذلك في حاوية تجريبية وأنا أختبر عدّة النشر من أولها إلى آخرها قبل كتابة هذا المقال. وكان واحدًا من ثمانية فخاخ وجدتها بالطريقة نفسها، لا يظهر أيٌّ منها حتى تختبره.</p>
<p>في نهاية المقال ستكون معك العدّة كاملة: سكربتان، وملف Caddyfile، وملف compose، وسبب كل سطر فيها، وقائمة تحقق. تنشر بها موقعًا ثابتًا على VPS صغير في خطوة ذرية واحدة، وتعيد الإصدار السابق في 9 ميلي ثانية.</p>
<aside class="post-summary"><p class="callout-label">باختصار</p><ul>
<li>كل إصدار في مجلده الخاص. Caddy يقرأ رابطًا رمزيًا اسمه <code>current</code>، والنشر عملية <code>rename()</code> واحدة، بلا إعادة تشغيل ولا نسخة منقولة إلى نصفها.</li>
<li>اختبر كل حارس بمدخلات سليمة أيضًا. أحد حرّاسي رفض كل بناء حقيقي بسبب <code>grep -q</code> تحت <code>pipefail</code>.</li>
<li>التراجع هو إعادة التسمية نفسها موجّهةً إلى إصدار سابق. وحاوية Caddy تقرأ الموقع ولا تستطيع شيئًا غير ذلك.</li>
</ul>
</aside>
<h2 id="التصميم-كله-في-قائمة-مجلد-واحدة">التصميم كله في قائمة مجلد واحدة</h2>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span>/srv/moghalis/</span></span>
<span class="line"><span>├── stack/          compose.yaml, Caddyfile, deploy.sh, rollback.sh</span></span>
<span class="line"><span>├── releases/</span></span>
<span class="line"><span>│   ├── 20261019-142210/</span></span>
<span class="line"><span>│   ├── 20261019-171504/</span></span>
<span class="line"><span>│   └── 20261020-090031/</span></span>
<span class="line"><span>└── current -> /srv/moghalis/releases/20261020-090031</span></span></code></pre></figure>
<p>كل إصدار يُفك في مجلد جديد يحمل اسم لحظة نشره. Caddy يخدم <code>current</code> ولا شيء غيره. النشر توجيه <code>current</code> إلى مجلد جديد، والتراجع توجيهه إلى مجلد أقدم.</p>
<p>لا شيء يُكتب فوق ملف قائم، فالمجلد الذي يقرأ منه Caddy لا يكون أبدًا نصفه قديمًا ونصفه جديدًا. ولاحظ أن الرابط مطلق، فهذه التفصيلة هي التي ستحدد طريقة تركيب الحاوية لاحقًا.</p>
<figure class="post-figure dg dg-is-flow"><ol class="dg-flow is-long"><li><strong>الحزم</strong><span>tar للبناء بمسارات تبدأ بـ ./</span></li><li><strong>الرفع</strong><span>scp للأرشيف إلى /tmp على الخادم</span></li><li><strong>الفحص</strong><span>غير فارغ، وفيه ./index.html، ومقروء حتى آخره</span></li><li><strong>الفك</strong><span>في مجلد جديد releases/&lt;الوقت&gt;/</span></li><li><strong>التبديل</strong><span>إعادة تسمية current.new فوق current</span></li><li><strong>التحقق</strong><span>طلب كل صفحة أساسية من الخادم نفسه</span></li></ol></figure>
<p>من جهازي، بعد البناء، ثلاثة أوامر:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">tar</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -czf</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> deploy/out/moghalis-site.tar.gz</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -C</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> dist</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> .</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">scp</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> deploy/out/moghalis-site.tar.gz</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> moghalis@your-vps:/tmp/</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">ssh</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> moghalis@your-vps</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> /srv/moghalis/stack/deploy.sh</span></span></code></pre></figure>
<p>الجزء <code>-C dist .</code> أهم مما يبدو: يخزّن كل مسار بصيغة <code>./index.html</code> و<code>./assets/…</code>، وهي بالضبط السلسلة التي يبحث عنها الحارس.</p>
<div class="stat-grid"><div class="stat"><p class="stat-value" dir="ltr">5 ms</p><p class="stat-label">لتبديل الإصدار</p></div><div class="stat"><p class="stat-value" dir="ltr">9 ms</p><p class="stat-label">لسكربت التراجع كاملًا</p></div><div class="stat"><p class="stat-value" dir="ltr">13 MB</p><p class="stat-label">ذاكرة Caddy وهو خامل، وسقفها 128 MB</p></div><div class="stat"><p class="stat-value" dir="ltr">0</p><p class="stat-label">قراءة فاشلة من 60,488 خلال 30,000 تبديل</p></div></div>
<h2 id="ln--sfn-ذري-إلى-أن-يعمل-على-alpine"><code>ln -sfn</code> ذري، إلى أن يعمل على Alpine</h2>
<p>الطريقة المعروفة لتوجيه رابط رمزي إلى هدف جديد هي <code>ln -sfn new current</code>. هل هي آمنة؟ يتوقف ذلك على نسخة <code>ln</code> التي عندك. تتبّعت نسختين:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span># GNU coreutils 9.1 (Debian 12)</span></span>
<span class="line"><span>symlinkat("r2", AT_FDCWD, "cur")        = -1 EEXIST (File exists)</span></span>
<span class="line"><span>symlinkat("r2", AT_FDCWD, "CubQNwNN")   = 0</span></span>
<span class="line"><span>renameat(AT_FDCWD, "CubQNwNN", AT_FDCWD, "cur") = 0</span></span>
<span class="line"><span></span></span>
<span class="line"><span># BusyBox (the caddy:2.10.2-alpine image)</span></span>
<span class="line"><span>unlink("cur")                           = 0</span></span>
<span class="line"><span>symlink("r2", "cur")                    = 0</span></span></code></pre></figure>
<p>نسخة GNU تنشئ رابطًا مؤقتًا ثم تعيد تسميته فوق القديم. أما BusyBox، وهو الـ <code>ln</code> الموجود داخل الصور المبنية على Alpine، فيحذف الرابط القديم ثم ينشئ الجديد. وبين الاستدعاءين لا يوجد <code>current</code> أصلًا.</p>
<figure class="post-figure dg dg-is-compare"><div class="dg-compare"><div class="dg-compare-head"><span>GNU coreutils 9.1</span><span>BusyBox على Alpine</span></div><div class="dg-compare-row"><span>ينشئ الرابط باسم عشوائي</span><span>يحذف current</span></div><div class="dg-compare-row"><span>يعيد تسميته فوق current في استدعاء واحد</span><span>ينشئ current من جديد</span></div><div class="dg-compare-row"><span>current موجود في كل لحظة</span><span>فجوة بين استدعاءين للنظام</span></div></div></figure>
<p>كم مرة يسقط طلب في هذه الفجوة؟ بدّلت الإصدار 10,000 مرة بينما حلقةٌ تقرأ <code>current/index.html</code> بأقصى سرعتها، وكررت التجربة ثلاث مرات.</p>
<figure class="post-figure dg dg-is-bars"><div class="dg-bars"><div class="dg-bar-row"><span class="dg-bar-label">ln -sfn على BusyBox، التجربة 1</span><span class="dg-bar-track"><span class="dg-bar" style="--v:1.000"></span></span><span class="dg-bar-value" dir="ltr">357</span></div><div class="dg-bar-row"><span class="dg-bar-label">ln -sfn على BusyBox، التجربة 2</span><span class="dg-bar-track"><span class="dg-bar" style="--v:0.754"></span></span><span class="dg-bar-value" dir="ltr">269</span></div><div class="dg-bar-row"><span class="dg-bar-label">ln -sfn على BusyBox، التجربة 3</span><span class="dg-bar-track"><span class="dg-bar" style="--v:0.728"></span></span><span class="dg-bar-value" dir="ltr">260</span></div><div class="dg-bar-row"><span class="dg-bar-label">ln باسم مؤقت ثم mv -T</span><span class="dg-bar-track"><span class="dg-bar" style="--v:0.000"></span></span><span class="dg-bar-value" dir="ltr">0</span></div></div></figure>
<p>نسخة إعادة التسمية أجرت 60,488 قراءة في تجاربها الثلاث، ولم تفشل منها واحدة. النشر الحقيقي يبدّل مرة واحدة، فالفجوة ضئيلة. والضئيل يبقى أكبر من الصفر، والعلاج سطر واحد إضافي.</p>
<p>لذلك لا يثق سكربتي بذرية <code>ln</code>. ينشئ الرابط باسم مؤقت ثم يعيد تسميته بنفسه، لأن <code>rename()</code> يستبدل الاسم في خطوة واحدة على Linux:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">ln</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -sfn</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$RELEASE</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$ROOT/current.new</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">mv</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -Tf</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$ROOT/current.new</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$ROOT/current</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span></span></code></pre></figure>
<aside class="callout callout-warning"><p class="callout-label">انتبه</p><p>احذف <code>-T</code> وسيفشل النشر بصمت تام. يرى <code>mv</code> أن <code>current</code> يشير إلى مجلد، فينقل <code>current.new</code> إلى داخل الإصدار الحي. يطبع السكربت <code>==&gt; Live</code>، وتعود كل الفحوص بـ 200، ويظل الزوار أمام الموقع القديم.</p>
</aside>
<p>لا يحتاج Caddy إلى إعادة تشغيل. النواة تحل الرابط مع كل فتح لملف، فيُخدم الطلب التالي من المجلد الجديد. في تجربتي استغرق التبديل 5 ميلي ثانية، وبقي عدّاد إعادة تشغيل الحاوية على الصفر.</p>
<h2 id="الحارس-الذي-صدّ-بناءً-سليمًا">الحارس الذي صدّ بناءً سليمًا</h2>
<p>نعود إلى البداية. قبل أن يلمس <code>deploy.sh</code> أي شيء، يفحص مدخلاته:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">set</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -euo</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> pipefail</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">[</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> -s</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$ARCHIVE</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> ]</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ||</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> echo</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">!! $ARCHIVE is missing or empty: upload it first (scp).</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> exit</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">tar</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -tzf</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$ARCHIVE</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> |</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> grep</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -qx</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">./index.html</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ||</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> echo</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">!! $ARCHIVE has no index.html: not a site build.</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> exit</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span></code></pre></figure>
<p>يبدو سليمًا. أعطه الآن أرشيفًا فيه 413 مدخلًا.</p>
<p><code>grep -q</code> يتوقف عند أول تطابق. يجد <code>./index.html</code> في السطر 260 ويخرج. لكن <code>tar</code> ما زال يكتب القائمة، فتذهب كتابته التالية إلى أنبوب لا قارئ له. ترسل له النواة <code>SIGPIPE</code> فيموت بالحالة 141. وتحت <code>pipefail</code> يكفي فشل جزء واحد ليفشل الأنبوب كله، فيبلّغ الحارس عن ملف مفقود وهو أمامه.</p>
<p>الأرشيف التجريبي الصغير ينجح، لأن قائمته كلها تخرج في كتابة واحدة فينتهي <code>tar</code> قبل أن يخرج <code>grep</code>. وبنائي الأقدم ذو الـ 156 مدخلًا يفشل هو الآخر. هكذا ينجو حارس كهذا من الاختبار السريع ثم يوقف البناء الحقيقي.</p>
<p>العلاج حذف حرف واحد. بدون <code>-q</code> يقرأ <code>grep</code> حتى النهاية، وينتهي <code>tar</code> انتهاءً طبيعيًا، ويعود <code>pipefail</code> إلى عمله الحقيقي:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">tar</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -tzf</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$ARCHIVE</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> |</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> grep</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -x</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">./index.html</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ></span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">/dev/null</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ||</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> echo</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">!! $ARCHIVE has no index.html: not a site build.</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> exit</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span></code></pre></figure>
<p>جرّبت النسختين على خمسة أرشيفات:</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>الأرشيف</th>
<th>مع <code>grep -q</code></th>
<th>بدون <code>-q</code></th>
</tr>
</thead>
<tbody><tr>
<td>بناء اليوم، 413 مدخلًا</td>
<td>رُفض</td>
<td>قُبل</td>
</tr>
<tr>
<td>البناء نفسه مقطوعًا عند 4 MB، كرفعٍ انقطع</td>
<td>رُفض</td>
<td>رُفض</td>
</tr>
<tr>
<td>ملف فارغ</td>
<td>رُفض</td>
<td>رُفض</td>
</tr>
<tr>
<td>مجلد <code>assets/</code> وحده</td>
<td>رُفض</td>
<td>رُفض</td>
</tr>
<tr>
<td>محزوم من المجلد الخطأ، بصيغة <code>dist/index.html</code></td>
<td>رُفض</td>
<td>رُفض</td>
</tr>
</tbody></table></div>
<p>الحارس المعطوب نجح في كل اختبار كنت سأفكر في كتابته، لأنها كلها أعطته مدخلات سيئة. الحارس الذي يرفض كل شيء يبدو مثاليًا إلى أن تعطيه شيئًا سليمًا.</p>
<p>والأرشيف المقطوع هو الحالة التي وُجد <code>pipefail</code> من أجلها. <code>tar -tzf</code> يقرأ حتى النهاية، فيفشل مع الرفع المبتور، ويتوقف النشر قبل أن يُفك ملف واحد.</p>
<p>بعد التبديل يرتّب السكربت المكان. وسطر الترتيب هذا قادر على حذف الإصدار الذي نشره للتو.</p>
<h2 id="احتفظ-بخمسة-ورتّبها-بالاسم">احتفظ بخمسة، ورتّبها بالاسم</h2>
<p>بعد التبديل يحتفظ <code>deploy.sh</code> بأحدث خمسة إصدارات ويحذف الباقي. نسختي الأولى رتّبتها بوقت التعديل:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">ls</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -1dt</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$ROOT</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">/releases/</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">*</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">/</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> |</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> tail</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -n</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> +6</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> |</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> xargs</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -r</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> rm</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -rf</span></span></code></pre></figure>
<p>المجلد الذي أنشأته للتو يُفترض أنه الأحدث، لكنه بحسب الـ mtime ليس كذلك بالضرورة. الأرشيف يحمل مدخلًا للمجلد <code>./</code> نفسه، و<code>tar</code> يطبع توقيت هذا المدخل على مجلد الإصدار. فيصير الـ mtime لحظةَ البناء على جهازي، أيًّا كان وقت النشر.</p>
<p>أعد الآن نشر أرشيف أقدم، بناء الأسبوع الماضي مثلًا، لأنك تريده وقد خرج من الخمسة. يبدو مجلده الجديد الأقدمَ على القرص، فيختاره <code>tail -n +6</code>، ويحذف السكربت الإصدار الذي جعله حيًّا قبل لحظة.</p>
<p>أعدت إنتاج ذلك: خمسة إصدارات بُنيت من 10 إلى 14 أكتوبر، ثم بناء من 1 أكتوبر. بعدها أشار <code>current</code> إلى مجلد لم يعد موجودًا، وأجاب Caddy عن كل عنوان بـ 404 وجسم فارغ.</p>
<p>اسم المجلد هو أصلًا وقت النشر، يكتبه <code>date +%Y%m%d-%H%M%S</code>، ويترتب ترتيبًا صحيحًا كنص عادي:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">ls</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -1dr</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$ROOT</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">/releases/</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">*</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">/</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> |</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> tail</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -n</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> +6</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> |</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> xargs</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -r</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> rm</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -rf</span></span></code></pre></figure>
<p>الاسم تختاره أنت، والـ mtime يختاره <code>tar</code>. ابنِ الترتيب على ما تختاره، وأعطِ <code>rollback.sh</code> التعديل نفسه.</p>
<h2 id="اسأل-الجهاز-الذي-غيّرته-للتو">اسأل الجهاز الذي غيّرته للتو</h2>
<p>آخر ما يفعله <code>deploy.sh</code> أنه يطلب الصفحات الأساسية ويطبع رمز كل استجابة:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">code</span><span style="--shiki-light:#999999;--shiki-dark:#666666">=$(</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">curl</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -s</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -o</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> /dev/null</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -w</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">%{http_code}</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> --resolve</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> moghalis.com:443:127.0.0.1</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">https://moghalis.com$path</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ||</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> true</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span></code></pre></figure>
<p>الخيار <code>--resolve</code> يرسل الطلب إلى هذا الجهاز مع إبقاء اسم <code>moghalis.com</code> في مصافحة TLS وفي ترويسة <code>Host</code>. فيخرج من الطريق الـ DNS، وذاكرة المحلّل على جهازي، وكل ما بينهما.</p>
<p>لذلك تثبت الـ 200 هنا أربعة أمور معًا: Caddy يعمل، والشهادة صالحة للاسم (فلا يوجد <code>-k</code>)، والكتلة الصحيحة من الإعداد هي التي أجابت، والملف موجود في الإصدار الحي الآن.</p>
<p>على خادم جديد تمامًا، قبل أن يشير إليه الـ DNS، لا يملك Caddy شهادة بعد، فيقرأ كل سطر <code>000</code>. بعد أول شهادة يجب أن يقرأ كل سطر 200. و<code>|| true</code> يمنع <code>set -e</code> من قتل التقرير عند أول طلب فاشل.</p>
<p>الفحص يجري بعد التبديل، لأن Caddy لا يخدم إلا <code>current</code>. إنه تقرير، وأنا أقرؤه. وإن ظهر سطر أحمر فالأمر التالي هو <code>rollback.sh</code>.</p>
<aside class="callout callout-tip"><p class="callout-label">نصيحة</p><p>أضف مسارًا يجب ألا يوجد، وتوقّع <code>404</code>. إنه أرخص اختبار ممكن لأول خطأ إعداد ستقرؤه بعد قليل.</p>
</aside>
<h2 id="التراجع-هو-إعادة-التسمية-نفسها-إلى-الخلف">التراجع هو إعادة التسمية نفسها، إلى الخلف</h2>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">#!/usr/bin/env bash</span></span>
<span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD"># Put the previous release back live (one step, no restart).</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">set</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -euo</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> pipefail</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">ROOT</span><span style="--shiki-light:#999999;--shiki-dark:#666666">=</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">/srv/moghalis</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">CURRENT</span><span style="--shiki-light:#999999;--shiki-dark:#666666">=$(</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">readlink</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -f</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$ROOT/current</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">PREVIOUS</span><span style="--shiki-light:#999999;--shiki-dark:#666666">=$(</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">ls</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -1dr</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$ROOT</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">/releases/</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">*</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">/</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> |</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> sed</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">s:/$::</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> |</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> grep</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -vx</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$CURRENT</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> |</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> head</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -n</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ||</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> true</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">[</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> -n</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$PREVIOUS</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> ]</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ||</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> echo</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">!! No earlier release to go back to.</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> exit</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">ln</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -sfn</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$PREVIOUS</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$ROOT/current.new</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">mv</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -Tf</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$ROOT/current.new</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$ROOT/current</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">echo</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">==> Rolled back to $PREVIOUS (was $CURRENT)</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span></span></code></pre></figure>
<p>يختار أحدث إصدار ليس هو الحي، ويبدّل إليه. السكربت كله استغرق 9 ميلي ثانية في تجربتي، والدخول عبر SSH يأخذ وقتًا أطول من التراجع نفسه.</p>
<p>ثم جاءت المفاجأة. شغّله مرتين وستعود إلى حيث بدأت. بعد المرة الأولى يصير أحدث إصدار غير حي هو الذي غادرته للتو، فتعيدك المرة الثانية إلى الأمام. ومع الإصدارات A وB وC المعطوب، يتأرجح بين C وB ولا يبلغ A أبدًا.</p>
<p>والقاعدة نفسها تلدغ لاحقًا. تراجع من C إلى B، ثم انشر إصلاحًا باسم D. فإن احتاج D إلى تراجع، كان أحدث إصدار غير حي هو C، المعطوب. لذلك احذف بعد كل تراجع المجلد الذي تراجعت عنه. وللرجوع أبعد عن قصد، سمِّ المجلد بنفسك:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">ln</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -sfn</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> /srv/moghalis/releases/20261019-142210</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> /srv/moghalis/current.new</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">mv</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -Tf</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> /srv/moghalis/current.new</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> /srv/moghalis/current</span></span></code></pre></figure>
<aside class="callout callout-note"><p class="callout-label">ملاحظة</p><p>شيء واحد لا يصلحه رابط رمزي: تبويب فُتح قبل التبديل ثم حمّل لاحقًا lazy chunk من JavaScript. اسم ذلك الجزء ذو البصمة غير موجود في الإصدار الجديد، فيعود طلبه بـ 404. إن كان تطبيقك يقسّم الكود حسب المسارات، فأعد تحميل الصفحة حين يفشل تحميل جزء.</p>
</aside>
<h2 id="caddy-في-صندوق-لا-يملك-إلا-القراءة">Caddy في صندوق لا يملك إلا القراءة</h2>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">services</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  web</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">    image</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> caddy:2.10.2-alpine</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">    restart</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> unless-stopped</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">    ports</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> [</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">80:80</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">443:443</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">443:443/udp</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">    volumes</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">      -</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> ./Caddyfile:/etc/caddy/Caddyfile:ro</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">      -</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> /srv/moghalis:/srv/moghalis:ro</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">      -</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> caddy_data:/data</span></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">      -</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> caddy_config:/config</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">    read_only</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> true</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">    tmpfs</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> [</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">/tmp</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">    cap_drop</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> [</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">ALL</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">    cap_add</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> [</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">NET_BIND_SERVICE</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">    security_opt</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> [</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">no-new-privileges:true</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666">]</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">    mem_limit</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> 128m</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">    logging</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">      driver</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> json-file</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">      options</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> max-size</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> 10m</span><span style="--shiki-light:#999999;--shiki-dark:#666666">,</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> max-file</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">3</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">volumes</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  caddy_data</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  caddy_config</span><span style="--shiki-light:#999999;--shiki-dark:#666666">:</span></span></code></pre></figure>
<div class="table-wrap"><table>
<thead>
<tr>
<th>السطر</th>
<th>ما الذي ينتزعه</th>
</tr>
</thead>
<tbody><tr>
<td><code>read_only: true</code></td>
<td>الكتابة على نظام ملفات الحاوية نفسها: <code>touch /etc/x</code> يفشل بـ <code>Read-only file system</code></td>
</tr>
<tr>
<td><code>:ro</code> على <code>/srv/moghalis</code></td>
<td>تعديل أي إصدار، حتى على Caddy نفسه</td>
</tr>
<tr>
<td><code>cap_drop: [ALL]</code></td>
<td>كل صلاحيات Linux. ما زال يعمل بصفة root في الداخل، لكنه root لا يستطيع حتى تجاوز أذونات الملفات</td>
</tr>
<tr>
<td><code>cap_add: [NET_BIND_SERVICE]</code></td>
<td>يعيد شيئًا واحدًا: الاستماع على 80 و443</td>
</tr>
<tr>
<td><code>no-new-privileges</code></td>
<td>كسب صلاحيات عبر ملف setuid</td>
</tr>
<tr>
<td><code>mem_limit: 128m</code></td>
<td>أن تُسقط عمليةٌ منفلتة الخادمَ كله. استهلك Caddy خاملًا 13 MB في تجاربي</td>
</tr>
<tr>
<td>خيارات السجلات</td>
<td>امتلاء القرص بالسجلات: ثلاثة ملفات، 10 MB لكل منها كحد أقصى</td>
</tr>
</tbody></table></div>
<p>لا يكتب Caddy إلا في <code>caddy_data</code>، حيث يحفظ الشهادات التي يستخرجها ويجددها بنفسه. ومنفذ UDP على 443 لأجل HTTP/3. وقبل أول <code>docker compose up -d</code>، تحقّق من الإعداد في الصورة نفسها:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">docker</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> run</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> --rm</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -v</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$PWD/Caddyfile:/etc/caddy/Caddyfile:ro</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> caddy:2.10.2-alpine</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> \</span></span>
<span class="line"><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">  caddy</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> validate</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> --config</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> /etc/caddy/Caddyfile</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> --adapter</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> caddyfile</span></span></code></pre></figure>
<p>تفصيلتان في التركيب تحملان التصميم كله. الأولى أنه يركّب <code>/srv/moghalis</code> كله. تركيب الـ bind يحل الرابط الرمزي مرة واحدة عند بدء الحاوية، ثم يتمسك بذلك المجلد. اختبرت ذلك: بعد التبديل رأى المضيف الإصدار B، وظل الـ bind mount يعرض A. ركّب <code>current</code> وحده، وسيعلن كل نشر نجاحه بينما تخدم الحاوية الإصدار الذي بدأت به.</p>
<p>الثانية أن المجلد في المسار نفسه داخل الحاوية. الرابط مطلق، فيجب أن يوجد <code>/srv/moghalis/releases/…</code> في الداخل أيضًا. ركّبه في <code>/site</code> بدلًا من ذلك، وسيشير <code>current</code> إلى لا شيء.</p>
<p>أما الخادم نفسه فيُجهَّز مرة واحدة بـ <code>bootstrap.sh</code>: جدار ناري لا يفتح إلا SSH و80 و443، وfail2ban، وتحديثات أمنية تلقائية، وDocker من مستودعه الرسمي، و2 GB من الـ swap. ولا يطفئ الدخول بكلمة المرور إلا بعد أن يملك المستخدم الجديد مفتاح SSH، فلا يمكن أن يغلق الخادم في وجهي.</p>
<h2 id="سطران-في-الإعداد-ينجحان-في-كل-اختبار-بالمتصفح">سطران في الإعداد ينجحان في كل اختبار بالمتصفح</h2>
<p>ملف Caddyfile قصير. سطران منه كانا خطأً في إعدادي السابق، ولم يكن المتصفح قادرًا على إظهار أيٍّ منهما.</p>
<p><strong>كل عنوان كان يعيد الصفحة الرئيسية.</strong> جاء الإعداد من أيام تطبيق الصفحة الواحدة:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span>try_files {path} /index.html</span></span></code></pre></figure>
<p>كل ما ليس ملفًا يأخذ ملف HTML الوحيد في التطبيق. ثم بدأت أبني كل صفحة مسبقًا في <code>&lt;path&gt;/index.html</code>، وهي 94 صفحة، فكان هذا السطر سيجيب عنها كلها بالصفحة الرئيسية.</p>
<p>جرّبته على شجرة إصدار. <code>/ar</code> و<code>/work/saima</code> وعنوان مختلَق مثل <code>/no-such-page</code> عادت كلها بالصفحة الرئيسية، وكلٌّ منها بـ <code>200 OK</code>. في المتصفح كان التطبيق سيرسم الصفحة الصحيحة فور تشغيل JavaScript، فيبدو التنقل سليمًا. أما محرك البحث، ومعاينة الروابط، ومن يتصفح بلا JavaScript، فكانوا سيحصلون على 94 نسخة من الصفحة الرئيسية، وكل عنوان مكتوب خطأً كان سيصير soft 404.</p>
<p>الإصلاح يخدم ملف الصفحة نفسها، أو 404 حقيقيًا:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span>try_files {path} {path}/index.html</span></span>
<span class="line"><span>file_server</span></span>
<span class="line"><span></span></span>
<span class="line"><span>handle_errors {</span></span>
<span class="line"><span>	@notfound expression {err.status_code} == 404</span></span>
<span class="line"><span>	rewrite @notfound /404.html</span></span>
<span class="line"><span>	file_server</span></span>
<span class="line"><span>}</span></span></code></pre></figure>
<p><strong>قاعدة تخزين مؤقت طابقت صفحات.</strong> لقطات شاشة دراسات الحالة تقع في مجلد صفحتها نفسه: <code>/work/saima/index.html</code> بجوار <code class="code-long">/work/saima/01-voice-studio.webp</code>. وقاعدة الأسبوع الكامل على <code>/work/*</code>، التي كُتبت للصور، أمسكت الصفحة أيضًا.</p>
<p>ومع الإصدارات الذرية يصير هذا أسوأ من محتوى قديم. كل مجلد إصدار لا يحوي إلا ملفات CSS وJavaScript ذات البصمة الخاصة به. والمتصفح الذي يحتفظ بصفحة عمرها أسبوع يطلب ملفات لم تعد في الإصدار الحي، وقد تحققت: تعود بـ 404. في أحسن الأحوال يرى الزائر صفحة الأسبوع الماضي. وفي أسوئها احتفظت ذاكرته بالـ HTML وأسقطت الملفات التي يسمّيها، فتُحمَّل الصفحة بلا أي تنسيق.</p>
<p>القواعد الآن حسب نوع الملف. وكل ما ليس ملفًا ذا بصمة ولا صورة فهو صفحة:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span>@hashed path /assets/*</span></span>
<span class="line"><span>header @hashed Cache-Control "public, max-age=31536000, immutable"</span></span>
<span class="line"><span></span></span>
<span class="line"><span>@media {</span></span>
<span class="line"><span>	not path /assets/*</span></span>
<span class="line"><span>	path *.webp *.png *.jpg *.svg *.ico *.webmanifest</span></span>
<span class="line"><span>}</span></span>
<span class="line"><span>header @media Cache-Control "public, max-age=604800"</span></span>
<span class="line"><span></span></span>
<span class="line"><span>@page {</span></span>
<span class="line"><span>	not path /assets/*</span></span>
<span class="line"><span>	not path *.webp *.png *.jpg *.svg *.ico *.webmanifest</span></span>
<span class="line"><span>}</span></span>
<span class="line"><span>header @page Cache-Control "no-cache"</span></span></code></pre></figure>
<p>وهذا ما يجيب به الإعداد المصحح، مفحوصًا في الحاوية المقيّدة نفسها:</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>الطلب</th>
<th>الحالة</th>
<th><code>Cache-Control</code></th>
</tr>
</thead>
<tbody><tr>
<td><code>/work/saima</code></td>
<td>200</td>
<td><code>no-cache</code></td>
</tr>
<tr>
<td><code class="code-long">/work/saima/01-voice-studio.webp</code></td>
<td>200</td>
<td><code>public, max-age=604800</code></td>
</tr>
<tr>
<td><code>/assets/index-….js</code></td>
<td>200</td>
<td><code class="code-long">public, max-age=31536000, immutable</code></td>
</tr>
<tr>
<td><code>/no-such-page</code></td>
<td>404، بمحتوى 404.html</td>
<td><code>no-cache</code></td>
</tr>
</tbody></table></div>
<p>و<code>no-cache</code> يخزّن فعلًا: يحتفظ المتصفح بالصفحة ويسأل الخادم إن تغيّرت، فتكلّف كل زيارة تحققًا سريعًا واحدًا، ويظهر الإصدار الجديد لحظة نشره.</p>
<p>الخطآن بدوا سليمين في المتصفح. وجدتهما بقراءة رموز الاستجابة والترويسات، وهذه الحلقة هي الفحص كله:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">for</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> p</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> in</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> /</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> /work/saima</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> /work/saima/01-voice-studio.webp</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> /no-such-page</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> do</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">  curl</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -s</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -o</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> /dev/null</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -w</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">%{http_code}  %header{cache-control}  $p\n</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">https://moghalis.com$p</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">done</span></span></code></pre></figure>
<p>يحتاج <code>%header{}</code> إلى curl 7.84 أو أحدث، وUbuntu 24.04 يأتي بالإصدار 8.5.</p>
<h2 id="العدّة-جاهزة-للنسخ">العدّة جاهزة للنسخ</h2>
<p>ملف <code>deploy.sh</code> بكل إصلاحات هذا المقال:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD">#!/usr/bin/env bash</span></span>
<span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD"># Publish a new release from /tmp/moghalis-site.tar.gz.</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">set</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -euo</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> pipefail</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">ARCHIVE</span><span style="--shiki-light:#999999;--shiki-dark:#666666">=</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">${1</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">:-/</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">tmp</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676">/</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">moghalis-site</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">tar</span><span style="--shiki-light:#393A34;--shiki-dark:#DBD7CAEE">.</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">gz}</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">ROOT</span><span style="--shiki-light:#999999;--shiki-dark:#666666">=</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">/srv/moghalis</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">RELEASE</span><span style="--shiki-light:#999999;--shiki-dark:#666666">=</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">$ROOT</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">/releases/</span><span style="--shiki-light:#999999;--shiki-dark:#666666">$(</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">date</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> +%Y%m%d-%H%M%S</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#999999;--shiki-dark:#666666">[</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> -s</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$ARCHIVE</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> ]</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ||</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> echo</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">!! $ARCHIVE is missing or empty: upload it first (scp).</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> exit</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">tar</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -tzf</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$ARCHIVE</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> |</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> grep</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -x</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">./index.html</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ></span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">/dev/null</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ||</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> {</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> echo</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">!! $ARCHIVE has no index.html: not a site build.</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> exit</span><span style="--shiki-light:#2F798A;--shiki-dark:#4C9A91"> 1</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#999999;--shiki-dark:#666666"> }</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">mkdir</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -p</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$RELEASE</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">tar</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -xzf</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$ARCHIVE</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -C</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$RELEASE</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">ln</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -sfn</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$RELEASE</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$ROOT/current.new</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">mv</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -Tf</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$ROOT/current.new</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$ROOT/current</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">echo</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">==> Live: $RELEASE</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD"># Keep the five newest releases, by name: the name is the deploy time.</span></span>
<span class="line"><span style="--shiki-light:#59873A;--shiki-dark:#80A665">ls</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -1dr</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">$ROOT</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">/releases/</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076">*</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">/</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> |</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> tail</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -n</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> +6</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> |</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665"> xargs</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -r</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> rm</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -rf</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#A0ADA0;--shiki-dark:#758575DD"># Ask this server for the routes, whatever DNS says.</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">for</span><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A"> path</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> in</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> /</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> /ar</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> /work</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> /skills</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> /resume</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> /blog</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> /ar/blog</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> /tools</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> /tools/saudi-validators</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> /blog/rss.xml</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> /work/saima</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> /og-image.jpg</span><span style="--shiki-light:#999999;--shiki-dark:#666666">;</span><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375"> do</span></span>
<span class="line"><span style="--shiki-light:#B07D48;--shiki-dark:#BD976A">  code</span><span style="--shiki-light:#999999;--shiki-dark:#666666">=$(</span><span style="--shiki-light:#59873A;--shiki-dark:#80A665">curl</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -s</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -o</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> /dev/null</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> -w</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> '</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">%{http_code}</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">'</span><span style="--shiki-light:#A65E2B;--shiki-dark:#C99076"> --resolve</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D"> moghalis.com:443:127.0.0.1</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">https://moghalis.com$path</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span><span style="--shiki-light:#AB5959;--shiki-dark:#CB7676"> ||</span><span style="--shiki-light:#998418;--shiki-dark:#B8A965"> true</span><span style="--shiki-light:#999999;--shiki-dark:#666666">)</span></span>
<span class="line"><span style="--shiki-light:#998418;--shiki-dark:#B8A965">  echo</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77"> "</span><span style="--shiki-light:#B56959;--shiki-dark:#C98A7D">    $code  $path</span><span style="--shiki-light:#B5695977;--shiki-dark:#C98A7D77">"</span></span>
<span class="line"><span style="--shiki-light:#1E754F;--shiki-dark:#4D9375">done</span></span></code></pre></figure>
<p>وملف <code>Caddyfile</code>، ناقصًا كتلة واحدة لصفحات العروض الخاصة بي:</p>
<figure class="code-block" dir="ltr"><pre class="shiki shiki-themes vitesse-light vitesse-dark" style="--shiki-light:#393a34;--shiki-dark:#dbd7caee;--shiki-light-bg:#ffffff;--shiki-dark-bg:#121212" tabindex="0"><code><span class="line"><span>moghalis.com {</span></span>
<span class="line"><span>	root * /srv/moghalis/current</span></span>
<span class="line"><span>	encode zstd gzip</span></span>
<span class="line"><span></span></span>
<span class="line"><span>	header {</span></span>
<span class="line"><span>		Strict-Transport-Security "max-age=31536000; includeSubDomains"</span></span>
<span class="line"><span>		X-Content-Type-Options "nosniff"</span></span>
<span class="line"><span>		X-Frame-Options "SAMEORIGIN"</span></span>
<span class="line"><span>		Referrer-Policy "strict-origin-when-cross-origin"</span></span>
<span class="line"><span>		Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()"</span></span>
<span class="line"><span>		-Server</span></span>
<span class="line"><span>	}</span></span>
<span class="line"><span></span></span>
<span class="line"><span>	@hashed path /assets/*</span></span>
<span class="line"><span>	header @hashed Cache-Control "public, max-age=31536000, immutable"</span></span>
<span class="line"><span></span></span>
<span class="line"><span>	@media {</span></span>
<span class="line"><span>		not path /assets/*</span></span>
<span class="line"><span>		path *.webp *.png *.jpg *.svg *.ico *.webmanifest</span></span>
<span class="line"><span>	}</span></span>
<span class="line"><span>	header @media Cache-Control "public, max-age=604800"</span></span>
<span class="line"><span></span></span>
<span class="line"><span>	@page {</span></span>
<span class="line"><span>		not path /assets/*</span></span>
<span class="line"><span>		not path *.webp *.png *.jpg *.svg *.ico *.webmanifest</span></span>
<span class="line"><span>	}</span></span>
<span class="line"><span>	header @page Cache-Control "no-cache"</span></span>
<span class="line"><span></span></span>
<span class="line"><span>	try_files {path} {path}/index.html</span></span>
<span class="line"><span>	file_server</span></span>
<span class="line"><span></span></span>
<span class="line"><span>	handle_errors {</span></span>
<span class="line"><span>		@notfound expression {err.status_code} == 404</span></span>
<span class="line"><span>		rewrite @notfound /404.html</span></span>
<span class="line"><span>		file_server</span></span>
<span class="line"><span>	}</span></span>
<span class="line"><span>}</span></span></code></pre></figure>
<p>ومع <code>rollback.sh</code> و<code>compose.yaml</code> من الأعلى، هذه هي العدّة كلها. والنسخ الأصلية في <a href="https://github.com/moghalisprogrammer-dotcom/My-CV/tree/master/deploy/vps" target="_blank" rel="noopener noreferrer">المستودع</a>.</p>
<h2 id="قائمة-التحقق">قائمة التحقق</h2>
<div class="table-wrap"><table>
<thead>
<tr>
<th>متى</th>
<th>ما تتحقق منه</th>
<th>الفخ الذي يمنعه</th>
</tr>
</thead>
<tbody><tr>
<td>عند الحزم</td>
<td><code>tar -czf … -C dist .</code>، لتبدأ المسارات بـ <code>./</code></td>
<td>أرشيف من المجلد الخطأ</td>
</tr>
<tr>
<td>الإعداد الأول</td>
<td><code>caddy validate</code> في الصورة نفسها يقول <code>Valid configuration</code></td>
<td>خطأ كتابي يُسقط الموقع</td>
</tr>
<tr>
<td>الإعداد الأول</td>
<td>الجذر كله مركّب للقراءة فقط، وفي المسار نفسه</td>
<td>حاوية عالقة على إصدار واحد</td>
</tr>
<tr>
<td>في السكربتين</td>
<td>التبديل <code>ln</code> إلى اسم مؤقت ثم <code>mv -T</code></td>
<td>فجوة BusyBox، ورابط يُنقل إلى داخل الإصدار الحي</td>
</tr>
<tr>
<td>في السكربتين</td>
<td>الإصدارات مرتبة بالاسم</td>
<td>حذف الإصدار الذي نشرته للتو</td>
</tr>
<tr>
<td>مرة واحدة</td>
<td>الحارس يقبل بناءً حقيقيًا ويرفض الفارغ والمقطوع والمحزوم من المجلد الخطأ</td>
<td>حارس يرفض كل شيء</td>
</tr>
<tr>
<td>مع كل نشر</td>
<td>كل مسار يعيد 200، ومسار مختلَق يعيد 404</td>
<td>الصفحة الرئيسية لكل عنوان</td>
</tr>
<tr>
<td>مع كل نشر</td>
<td>الصفحات <code>no-cache</code>، و<code>/assets/</code> سنة، والصور أسبوع</td>
<td>صفحة مخزّنة أسبوعًا</td>
</tr>
<tr>
<td>بعد كل تراجع</td>
<td>احذف الإصدار الذي تراجعت عنه</td>
<td>العودة لاحقًا إلى الإصدار المعطوب</td>
</tr>
</tbody></table></div>
<p>هذا هو النظام كله: مجلد لكل إصدار، وإعادة تسمية واحدة، وصندوق لا يملك إلا القراءة. يكفيه VPS صغير واحد ولا بناء على الخادم، والإصدار المعطوب على بُعد 9 ميلي ثانية من الزوال. وأغلب أسطره موجودة لتمنع خطأً ما كان المتصفح ليريني إياه.</p>
<p>وإن كنت تفضّل أن يتولى أحدٌ إعداد هذا ومراقبته لك، فهذا ما تجده في <a href="https://moghalis.com/ar/services/hosting-and-support">الاستضافة والصيانة والدعم</a>.</p>
]]></content:encoded>
    </item>
  </channel>
</rss>
