جارٍ إنشاء Chrometober.

كيف تم ابتكار كتاب التمرير لمشاركة نصائح وحيل ممتعة ومخيفة في Chrometober؟

بعد انتهاء فعالية Designcember، أردنا إنشاء فعالية Chrometober هذا العام كطريقة لتسليط الضوء على المحتوى على الويب ومشاركته من منتدى وفريق Chrome. عرضت حملة Designcember استخدام طلبات البحث عن الحاويات، ولكننا نعرض هذا العام واجهة برمجة التطبيقات CSS scroll-linked animations API.

يمكنك الاطّلاع على تجربة الكتاب القابل للتمرير على web.dev/chrometober-2022.

نظرة عامة

كان الهدف من المشروع هو تقديم تجربة غريبة تسلّط الضوء على واجهة برمجة التطبيقات Scroll-linked Animations API. ومع ذلك، كان من الضروري أن تكون التجربة سريعة الاستجابة وسهلة الاستخدام أيضًا. كان المشروع أيضًا طريقة رائعة لاختبار واجهة برمجة التطبيقات polyfill التي يتم تطويرها بشكل نشط، بالإضافة إلى تجربة تقنيات وأدوات مختلفة معًا. وكل ذلك في أجواء احتفالية بمناسبة الهالوين.

كان هيكل فريقنا على النحو التالي:

صياغة تجربة سرد قصصي تفاعلي

بدأت الأفكار حول Chrometober تتدفق في أول اجتماع لفريقنا خارج الموقع في مايو 2022. دفعتنا مجموعة من الخربشات إلى التفكير في طرق يمكن للمستخدم من خلالها التمرير على طول شكل من أشكال لوحة العمل. مستوحاة من ألعاب الفيديو، فكرنا في تجربة التمرير عبر مشاهد مثل المقابر والمنزل المسكون.

دفتر ملاحظات موضوع على مكتب يحتوي على رسومات عشوائية وخربشات مختلفة متعلقة بالمشروع.

كان من الممتع أن أحظى بالحرية الإبداعية لتوجيه مشروعي الأول في Google في اتجاه غير متوقّع. كان هذا نموذجًا أوّليًا لكيفية تنقّل المستخدم في المحتوى.

عندما يتنقّل المستخدم جانبيًا، تدور المربّعات وتتوسّع. لكنني قررت الابتعاد عن هذه الفكرة بدافع القلق بشأن كيف يمكننا جعل هذه التجربة رائعة للمستخدمين على الأجهزة من جميع الأحجام. بدلاً من ذلك، اتجهت نحو تصميم شيء صنعته في الماضي. في عام 2020، حالفني الحظ في الوصول إلى ScrollTrigger من GreenSock لإنشاء عروض توضيحية للإصدارات.

كان أحد العروض التوضيحية التي أنشأتها عبارة عن كتاب ثلاثي الأبعاد بلغة CSS، حيث يتم تقليب الصفحات أثناء الانتقال للأسفل، وكان هذا مناسبًا أكثر لما أردناه في Chrometober. وتُعدّ واجهة برمجة التطبيقات Scroll-linked animations بديلاً مثاليًا لهذه الوظيفة. كما أنّه يعمل بشكل جيد مع scroll-snap، كما سترى.

وكان رسام المشروع، تايلر ريد، رائعًا في تعديل التصميم مع تغيُّر أفكارنا. قام تايلر بعمل رائع في أخذ جميع الأفكار الإبداعية التي تم طرحها عليه وإحيائها. كان من الممتع جدًا تبادل الأفكار معًا. كان جزء كبير من الطريقة التي أردنا أن تعمل بها هذه الميزة هو تقسيم الميزات إلى مجموعات معزولة. بهذه الطريقة، يمكننا تجميعها في مشاهد ثم اختيار ما نضفي عليه الحياة.

أحد مشاهد التركيبة يضم ثعبانًا، وتابوتًا تخرج منه أذرع، وثعلبًا يحمل عصا سحرية عند مرجل، وشجرة بوجه مخيف، وغرغويل يحمل فانوس يقطين.

كانت الفكرة الرئيسية هي أنّه أثناء تصفّح المستخدم للكتاب، يمكنه الوصول إلى مجموعات من المحتوى. ويمكنهم أيضًا التفاعل مع لمسات من المرح، بما في ذلك الميزات المخفية التي أضفناها إلى التجربة، مثل صورة في منزل مسكون تتبع عيناها المؤشر، أو صور متحركة دقيقة يتم تفعيلها من خلال الاستعلام عن الوسائط. سيتم تحريك هذه الأفكار والميزات عند التمرير. كانت الفكرة الأولى هي أرنب زومبي يرتفع ويتحرك على طول المحور السيني عند تمرير المستخدم.

التعرّف على واجهة برمجة التطبيقات

قبل أن نبدأ في اللعب بالميزات الفردية ومفاجآت Easter eggs، كنا بحاجة إلى كتاب. لذلك قررنا تحويل ذلك إلى فرصة لاختبار مجموعة الميزات الخاصة بـ CSS scroll-linked animations API الناشئة. لا تتوافق واجهة برمجة التطبيقات الخاصة بالحركات المرتبطة بالتمرير حاليًا مع أي متصفّحات. ومع ذلك، أثناء تطوير واجهة برمجة التطبيقات، كان المهندسون في فريق التفاعلات يعملون على polyfill. ويوفّر ذلك طريقة لاختبار شكل واجهة برمجة التطبيقات أثناء تطويرها. هذا يعني أنّه يمكننا استخدام واجهة برمجة التطبيقات هذه اليوم، وغالبًا ما تكون المشاريع الممتعة مثل هذه مكانًا رائعًا لتجربة الميزات التجريبية وتقديم الملاحظات. يمكنك الاطّلاع على ما تعلّمناه والملاحظات التي تمكّنا من تقديمها لاحقًا في المقالة.

بشكل عام، يمكنك استخدام واجهة برمجة التطبيقات هذه لربط الصور المتحركة بالتمرير. من المهم الإشارة إلى أنّه لا يمكنك تفعيل تأثير حركي عند الانتقال للأسفل، ولكن قد يصبح ذلك ممكنًا في المستقبل. تنقسم أيضًا الصور المتحركة المرتبطة بالتمرير إلى فئتين رئيسيتين:

  1. تلك التي تتفاعل مع موضع الانتقال
  2. تلك التي تتفاعل مع موضع العنصر في حاوية الانتقال.

لإنشاء هذا الأخير، نستخدم ViewTimeline يتم تطبيقه من خلال سمة animation-timeline.

إليك مثال على كيفية استخدام ViewTimeline في CSS:

.element-moving-in-viewport {
  view-timeline-name: foo;
  view-timeline-axis: block;
}

.element-scroll-linked {
  animation: rotate both linear;
  animation-timeline: foo;
  animation-delay: enter 0%;
  animation-end-delay: cover 50%;
}

@keyframes rotate {
 to {
   rotate: 360deg;
 }
}

ننشئ ViewTimeline باستخدام view-timeline-name ونحدّد المحور له. في هذا المثال، تشير السمة block إلى block المنطقية. يتم ربط الصورة المتحركة بالتمرير باستخدام السمة animation-timeline. animation-delay وanimation-end-delay (في وقت كتابة هذا التقرير) هما الطريقة التي نحدّد بها المراحل.

تحدّد هذه المراحل النقاط التي يجب ربط الحركة بها في ما يتعلّق بموضع العنصر في حاوية الانتقال. في المثال الذي نقدّمه، نطلب بدء الحركة عندما يدخل العنصر (enter 0%) إلى حاوية الانتقال. وتنتهي عندما يغطي %50 (cover 50%) من حاوية الانتقال.

في ما يلي عرض توضيحي:

يمكنك أيضًا ربط صورة متحركة بالعنصر الذي يتحرك في إطار العرض. يمكنك إجراء ذلك من خلال ضبط السمة animation-timeline لتكون view-timeline للعنصر. ويُعدّ هذا الخيار مناسبًا لسيناريوهات مثل الصور المتحركة للقوائم. يشبه هذا السلوك طريقة إضافة تأثيرات متحركة إلى العناصر عند الدخول باستخدام IntersectionObserver.

element-moving-in-viewport {
  view-timeline-name: foo;
  view-timeline-axis: block;
  animation: scale both linear;
  animation-delay: enter 0%;
  animation-end-delay: cover 50%;
  animation-timeline: foo;
}

@keyframes scale {
  0% {
    scale: 0;
  }
}

بهذه الطريقة، يتم تكبير "Mover" عند دخوله إلى إطار العرض، ما يؤدي إلى تدوير "Spinner".

من خلال التجربة، تبيّن لي أنّ واجهة برمجة التطبيقات تعمل بشكل جيد جدًا مع محاذاة مواضع التمرير. ستكون ميزة محاذاة مواضع التمرير مع ViewTimeline مناسبة جدًا لمحاذاة الصفحات في كتاب.

إنشاء نموذج أوّلي لآليات اللعبة

بعد بعض التجارب، تمكّنت من إنشاء نموذج أوّلي للكتاب. يمكنك التمرير أفقيًا لقلب صفحات الكتاب.

في العرض التوضيحي، يمكنك رؤية العوامل المختلفة المميّزة بحدود متقطّعة.

يبدو الترميز على النحو التالي:

<body>
  <div class="book-placeholder">
    <ul class="book" style="--count: 7;">
      <li
        class="page page--cover page--cover-front"
        data-scroll-target="1"
        style="--index: 0;"
      >
        <div class="page__paper">
          <div class="page__side page__side--front"></div>
          <div class="page__side page__side--back"></div>
        </div>
      </li>
      <!-- Markup for other pages here -->
    </ul>
  </div>
  <div>
    <p>intro spacer</p>
  </div>
  <div data-scroll-intro>
    <p>scale trigger</p>
  </div>
  <div data-scroll-trigger="1">
    <p>page trigger</p>
  </div>
  <!-- Markup for other triggers here -->
</body>

أثناء الانتقال، يتم تقليب صفحات الكتاب، ولكن يتم فتحها أو إغلاقها بسرعة. يعتمد ذلك على محاذاة مواضع التمرير للمشغّلات.

html {
  scroll-snap-type: x mandatory;
}

body {
  grid-template-columns: repeat(var(--trigger-count), auto);
  overflow-y: hidden;
  overflow-x: scroll;
  display: grid;
}

body > [data-scroll-trigger] {
  height: 100vh;
  width: clamp(10rem, 10vw, 300px);
}

body > [data-scroll-trigger] {
  scroll-snap-align: end;
}

هذه المرة، لا نربط ViewTimeline في CSS، بل نستخدم Web Animations API في JavaScript. وهذا له فائدة إضافية تتمثل في القدرة على التكرار الحلقي على مجموعة من العناصر وإنشاء ViewTimeline الذي نحتاجه، بدلاً من إنشاء كل منها يدويًا.

const triggers = document.querySelectorAll("[data-scroll-trigger]")

const commonProps = {
  delay: { phase: "enter", percent: CSS.percent(0) },
  endDelay: { phase: "enter", percent: CSS.percent(100) },
  fill: "both"
}

const setupPage = (trigger, index) => {
  const target = document.querySelector(
    `[data-scroll-target="${trigger.getAttribute("data-scroll-trigger")}"]`
  );

  const viewTimeline = new ViewTimeline({
    subject: trigger,
    axis: 'inline',
  });

  target.animate(
    [
      {
        transform: `translateZ(${(triggers.length - index) * 2}px)`
      },
      {
        transform: `translateZ(${(triggers.length - index) * 2}px)`,
        offset: 0.75
      },
      {
        transform: `translateZ(${(triggers.length - index) * -1}px)`
      }
    ],
    {
      timeline: viewTimeline,
      …commonProps,
    }
  );
  target.querySelector(".page__paper").animate(
    [
      {
        transform: "rotateY(0deg)"
      },
      {
        transform: "rotateY(-180deg)"
      }
    ],
    {
      timeline: viewTimeline,
      …commonProps,
    }
  );
};

const triggers = document.querySelectorAll('[data-scroll-trigger]')
triggers.forEach(setupPage);

لكل عامل تشغيل، ننشئ ViewTimeline. بعد ذلك، نُحرّك الصفحة المرتبطة بالمشغّل باستخدام ViewTimeline. يؤدي ذلك إلى ربط الحركة في الصفحة بالتمرير. بالنسبة إلى الحركة، سنقوم بتدوير عنصر من الصفحة على المحور "ص" لقلب الصفحة. ونترجم أيضًا الصفحة نفسها على المحور z لكي تتصرف مثل الكتاب.

خلاصة ما سبق ذكره

بعد أن وضعت آلية الكتاب، تمكّنت من التركيز على إضفاء الحيوية على رسومات "تايلر".

التصوير الفلكي

استخدم الفريق Astro في Designcember في عام 2021 وكنت حريصًا على استخدامه مرة أخرى في Chrometober. إنّ تجربة المطوّرين في تقسيم العناصر إلى مكوّنات مناسبة جدًا لهذا المشروع.

الكتاب نفسه هو مكوّن. وهي أيضًا مجموعة من مكوّنات الصفحة. تحتوي كل صفحة على جانبين ولها خلفيات. العناصر الفرعية لجانب الصفحة هي مكوّنات يمكن إضافتها وإزالتها وتحديد موضعها بسهولة.

إنشاء كتاب

كان من المهم بالنسبة لي أن أجعل الكتل سهلة الإدارة. أردت أيضًا أن أسهّل على بقية أعضاء الفريق تقديم مساهماتهم.

يتم تحديد الصفحات على مستوى عالٍ من خلال مصفوفة إعدادات. يحدّد كل عنصر صفحة في المصفوفة المحتوى والخلفية والبيانات الوصفية الأخرى للصفحة.

const pages = [
  {
    front: {
      marked: true,
      content: PageTwo,
      backdrop: spreadOne,
      darkBackdrop: spreadOneDark
    },
    back: {
      content: PageThree,
      backdrop: spreadTwo,
      darkBackdrop: spreadTwoDark
    },
    aria: `page 1`
  },
  /* Obfuscated page objects */
]

ويتم تمريرها إلى المكوّن Book.

<Book pages={pages} />

يتم تطبيق آلية الانتقال وإنشاء صفحات الكتاب في المكوّن Book. يتم استخدام الآلية نفسها من النموذج الأوّلي، ولكننا نشارك نُسخًا متعددة من ViewTimeline التي يتم إنشاؤها على مستوى العالم.

window.CHROMETOBER_TIMELINES.push(viewTimeline);

بهذه الطريقة، يمكننا مشاركة المخططات الزمنية لاستخدامها في مكان آخر بدلاً من إعادة إنشائها. سنتحدّث عن ذلك بالتفصيل لاحقًا.

تكوين الصفحة

كل صفحة هي عنصر قائمة داخل قائمة:

<ul class="book">
  {
    pages.map((page, index) => {
      const FrontSlot = page.front.content
      const BackSlot = page.back.content
      return (
        <Page
          index={index}
          cover={page.cover}
          aria={page.aria}
          backdrop={
            {
              front: {
                light: page.front.backdrop,
                dark: page.front.darkBackdrop
              },
              back: {
                light: page.back.backdrop,
                dark: page.back.darkBackdrop
              }
            }
          }>
          {page.front.content && <FrontSlot slot="front" />}    
          {page.back.content && <BackSlot slot="back" />}    
        </Page>
      )
    })
  }
</ul>

ويتم تمرير الإعدادات المحدّدة إلى كل مثيل Page. تستخدم الصفحات ميزة الخانة في Astro لإدراج المحتوى في كل صفحة.

<li
  class={className}
  data-scroll-target={target}
  style={`--index:${index};`}
  aria-label={aria}
>
  <div class="page__paper">
    <div
      class="page__side page__side--front"
      aria-label={`Right page of ${index}`}
    >
      <picture>
        <source
          srcset={darkFront}
          media="(prefers-color-scheme: dark)"
          height="214"
          width="150"
        >
        <img
          src={lightFront}
          class="page__background page__background--right"
          alt=""
          aria-hidden="true"
          height="214"
          width="150"
        >
      </picture>
      <div class="page__content">
        <slot name="front" />
      </div>
    </div>
    <!-- Markup for back page -->
  </div>
</li>

يُستخدم هذا الرمز في الغالب لإعداد البنية. يمكن للمساهمين العمل على محتوى الكتاب في الغالب دون الحاجة إلى لمس هذا الرمز.

صور خلفية

إنّ التحوّل الإبداعي نحو كتاب جعل تقسيم الأقسام أسهل بكثير، وكل صفحتَين متقابلتَين في الكتاب هما مشهد مأخوذ من التصميم الأصلي.

صورة توضيحية لصفحتَين متقابلتَين من الكتاب تظهر فيهما شجرة تفاح في مقبرة تحتوي المقبرة على شواهد قبور متعددة، وهناك خفاش في السماء أمام قمر كبير.

بما أنّنا حدّدنا نسبة العرض إلى الارتفاع للكتاب، يمكن أن تتضمّن خلفية كل صفحة عنصر صورة. ويمكنك ضبط عرض هذا العنصر على% 200 واستخدام object-position استنادًا إلى جانب الصفحة.

.page__background {
  height: 100%;
  width: 200%;
  object-fit: cover;
  object-position: 0 0;
  position: absolute;
  top: 0;
  left: 0;
}

.page__background--right {
  object-position: 100% 0;
}

محتوى الصفحة

لنلقِ نظرة على إنشاء إحدى الصفحات. وتعرض الصفحة الثالثة بومة تظهر في شجرة.

ويتم ملؤه بمكوّن PageThree، كما هو محدّد في الإعدادات. وهو مكوّن Astro (PageThree.astro). تبدو هذه المكوّنات مثل ملفات HTML، ولكنّها تحتوي على سياج رمز في الأعلى يشبه البيانات الوصفية. يتيح لنا ذلك تنفيذ إجراءات مثل استيراد مكوّنات أخرى. يبدو مكوّن الصفحة الثالثة على النحو التالي:

---
import TreeOwl from '../TreeOwl/TreeOwl.astro'
import { contentBlocks } from '../../assets/content-blocks.json'
import ContentBlock from '../ContentBlock/ContentBlock.astro'
---
<TreeOwl/>
<ContentBlock {...contentBlocks[3]} id="four" />

<style is:global>
  .content-block--four {
    left: 30%;
    bottom: 10%;
  }
</style>

مرة أخرى، الصفحات ذرية بطبيعتها. ويتم إنشاؤها من مجموعة من الميزات. تتميز الصفحة الثالثة بكتلة محتوى وبومة تفاعلية، لذلك يوجد مكون لكل منهما.

مجموعات المحتوى هي الروابط المؤدية إلى المحتوى الذي يظهر في الكتاب. ويتم أيضًا تحديد هذه القيم من خلال عنصر إعدادات.

{
 "contentBlocks": [
    {
      "id": "one",
      "title": "New in Chrome",
      "blurb": "Lift your spirits with a round up of all the tools and features in Chrome.",
      "link": "https://www.youtube.com/watch?v=qwdN1fJA_d8&list=PLNYkxOF6rcIDfz8XEA3loxY32tYh7CI3m"
    },
    …otherBlocks
  ]
}

يتم استيراد هذه الإعدادات عندما تكون مجموعات المحتوى مطلوبة. بعد ذلك، يتم تمرير إعدادات الحظر ذات الصلة إلى المكوّن ContentBlock.

<ContentBlock {...contentBlocks[3]} id="four" />

هناك أيضًا مثال هنا لكيفية استخدامنا لمكون الصفحة كمكان لوضع المحتوى. هنا، يتم وضع مجموعة محتوى.

<style is:global>
  .content-block--four {
    left: 30%;
    bottom: 10%;
  }
</style>

ومع ذلك، يتم وضع الأنماط العامة لكتلة المحتوى مع رمز المكوّن.

.content-block {
  background: hsl(0deg 0% 0% / 70%);
  color: var(--gray-0);
  border-radius:  min(3vh, var(--size-4));
  padding: clamp(0.75rem, 2vw, 1.25rem);
  display: grid;
  gap: var(--size-2);
  position: absolute;
  cursor: pointer;
  width: 50%;
}

أما بالنسبة إلى البومة، فهي ميزة تفاعلية، وهي واحدة من العديد من الميزات في هذا المشروع. هذا مثال صغير وجيد يوضّح كيفية استخدام ViewTimeline المشترَك الذي أنشأناه.

على مستوى عالٍ، يستورد مكوّن البومة بعض ملفات SVG ويضمّنها باستخدام Fragment في Astro.

---
import { default as Owl } from '../Features/Owl.svg?raw'
---
<Fragment set:html={Owl} />

وأنماط تحديد موضع البومة موجودة في مكان واحد مع رمز المكوّن.

.owl {
  width: 34%;
  left: 10%;
  bottom: 34%;
}

هناك جزء إضافي من التصميم يحدّد سلوك transform للبومة.

.owl__owl {
  transform-origin: 50% 100%;
  transform-box: fill-box;
}

يؤثر استخدام transform-box في transform-origin. ويجعلها نسبية إلى المربّع المحيط بالعنصر داخل ملف SVG. تتم زيادة حجم البومة من أسفل الوسط، وبالتالي يتم استخدام transform-origin: 50% 100%.

الجزء الممتع هو عندما نربط البومة بإحدى ViewTimeline التي تم إنشاؤها:

const setUpOwl = () => {
   const owl = document.querySelector('.owl__owl');

   owl.animate([
     {
       translate: '0% 110%',
     },
     {
       translate: '0% 10%',
     },
   ], {
     timeline: CHROMETOBER_TIMELINES[1],
     delay: { phase: "enter", percent: CSS.percent(80) },
     endDelay: { phase: "enter", percent: CSS.percent(90) },
     fill: 'both' 
   });
 }

 if (window.matchMedia('(prefers-reduced-motion: no-preference)').matches)
   setUpOwl()

في مجموعة الرموز البرمجية هذه، ننفّذ إجراءَين:

  1. تحقَّق من الإعدادات المفضّلة للحركة لدى المستخدم.
  2. إذا لم يكن لديهم تفضيل، فاربط رسمًا متحركًا للبومة للتمرير.

بالنسبة إلى الجزء الثاني، تتحرّك البومة على المحور "ص" باستخدام Web Animations API. يتم استخدام سمة التحويل الفردية translate، وهي مرتبطة بـ ViewTimeline واحدة. وهو مرتبط بـ CHROMETOBER_TIMELINES[1] من خلال السمة timeline. هذا ViewTimeline يتم إنشاؤه لقلب الصفحات. يربط هذا الإجراء الصورة المتحركة للبومة بقلب الصفحة باستخدام المرحلة enter. ويحدّد أنّه عندما يتم قلب الصفحة بنسبة% 80، يبدأ تحريك البومة. عند بلوغ %90، من المفترض أن تكتمل ترجمة البومة.

ميزات الكتاب

لقد اطّلعت الآن على طريقة إنشاء صفحة وكيفية عمل بنية المشروع. يمكنك أن ترى كيف يسمح للمساهمين بالدخول والعمل على صفحة أو ميزة من اختيارهم. ترتبط الرسوم المتحركة للعديد من الميزات في الكتاب بتقليب صفحات الكتاب، مثل الخفاش الذي يطير داخل وخارج الصفحة عند تقليبها.

وتتضمّن أيضًا عناصر تستند إلى صور CSS المتحركة.

بمجرد أن أصبحت كتل المحتوى في الكتاب، كان هناك وقت للإبداع مع ميزات أخرى. وقد أتاح ذلك فرصة لإنشاء بعض التفاعلات المختلفة، وتجربة طرق مختلفة لتنفيذ الأشياء.

الحفاظ على استجابة التطبيق

تحدّد وحدات إطار العرض المتجاوبة حجم الكتاب وميزاته. ومع ذلك، كان الحفاظ على استجابة الخطوط تحديًا مثيرًا للاهتمام. تُعدّ وحدات طلبات البحث عن الحاوية مناسبة هنا. ومع ذلك، لا تتوفر هذه الميزة في كل مكان بعد. تم ضبط حجم الكتاب، لذا لا نحتاج إلى استعلام حاوية. يمكن إنشاء وحدة طلب حاوية مضمّنة باستخدام CSS calc() واستخدامها لتحديد حجم الخط.


.book-placeholder {
  --size: clamp(12rem, 72vw, 80vmin);
  --aspect-ratio: 360 / 504;
  --cqi: calc(0.01 * (var(--size) * (var(--aspect-ratio))));
}

.content-block h2 {
  color: var(--gray-0);
  font-size: clamp(0.6rem, var(--cqi) * 4, 1.5rem);
}

.content-block :is(p, a) {
  font-size: clamp(0.6rem, var(--cqi) * 3, 1.5rem);
}

قرع يضيء في الليل

ربما لاحظ أصحاب الملاحظة الدقيقة استخدام عناصر <source> عند مناقشة خلفيات الصفحة سابقًا. كانت أونا حريصة على الحصول على تفاعل يتجاوب مع تفضيل نظام الألوان. ونتيجةً لذلك، تتوافق الخلفيات مع الوضعَين الفاتح والداكن مع خيارات مختلفة. بما أنّه يمكنك استخدام الاستعلامات عن الوسائط مع العنصر <picture>، فهذه طريقة رائعة لتوفير نمطَين للخلفية. يستعلم العنصر <source> عن تفضيل نظام الألوان، ويعرض الخلفية المناسبة.

<picture>
  <source srcset={darkFront} media="(prefers-color-scheme: dark)" height="214" width="150">
  <img src={lightFront} class="page__background page__background--right" alt="" aria-hidden="true" height="214" width="150">
</picture>

ويمكنك إجراء تغييرات أخرى استنادًا إلى الألوان المفضّلة. تتفاعل ثمار اليقطين في الصفحة الثانية مع الإعدادات المفضّلة لنظام الألوان لدى المستخدم. يحتوي ملف SVG المستخدَم على دوائر تمثّل ألسنة اللهب، والتي يتم تكبيرها وتحريكها في الوضع الداكن.

.pumpkin__flame,
 .pumpkin__flame circle {
   transform-box: fill-box;
   transform-origin: 50% 100%;
 }

 .pumpkin__flame {
   scale: 0.8;
 }

 .pumpkin__flame circle {
   transition: scale 0.2s;
   scale: 0;
 }

@media(prefers-color-scheme: dark) {
   .pumpkin__flame {
     animation: pumpkin-flicker 3s calc(var(--index, 0) * -1s) infinite linear;
   }

   .pumpkin__flame circle {
     scale: 1;
   }

   @keyframes pumpkin-flicker {
     50% {
       scale: 1;
     }
   }
 }

هل تراقبك هذه اللوحة؟

إذا اطّلعت على الصفحة 10، قد تلاحظ شيئًا ما. هناك من يراقبك! ستتبع عيون الصورة مؤشرك أثناء تنقلك في الصفحة. يكمن السر هنا في ربط موقع المؤشر بقيمة ترجمة، وتمريرها إلى CSS.

const mapRange = (inputLower, inputUpper, outputLower, outputUpper, value) => {
   const INPUT_RANGE = inputUpper - inputLower
   const OUTPUT_RANGE = outputUpper - outputLower
   return outputLower + (((value - inputLower) / INPUT_RANGE) * OUTPUT_RANGE || 0)
 }

يأخذ هذا الرمز نطاقات الإدخال والإخراج، ويربط القيم المحدّدة. على سبيل المثال، سيعطي هذا الاستخدام القيمة 625.

mapRange(0, 100, 250, 1000, 50) // 625

بالنسبة إلى الصورة العمودية، تكون قيمة الإدخال هي النقطة المركزية لكل عين، زائد أو ناقص مسافة معيّنة بالبكسل. نطاق الإخراج هو مقدار ما يمكن للعينين ترجمته بالبكسل. وبعد ذلك، يتم تمرير موضع المؤشر على المحور س أو ص كقيمة. للحصول على النقطة المركزية للعينَين أثناء تحريكهما، يتم تكرار العينَين. لا تتحرك العناصر الأصلية، وتكون شفافة، وتُستخدم كمرجع.

بعد ذلك، يتم ربط هذه العناصر معًا وتعديل قيم خصائص CSS المخصّصة للعينَين لكي تتحرّكا. يتم ربط دالة بالحدث pointermove مقابل window. وعندما يتم تشغيل هذا، يتم استخدام حدود كل عين لحساب النقاط المركزية. بعد ذلك، يتم ربط موضع المؤشر بالقيم التي تم ضبطها كقيم خصائص مخصّصة للعينين.

const RANGE = 15
const LIMIT = 80
const interact = ({ x, y }) => {
   // map a range against the eyes and pass in via custom properties
   const LEFT_EYE_BOUNDS = LEFT_EYE.getBoundingClientRect()
   const RIGHT_EYE_BOUNDS = RIGHT_EYE.getBoundingClientRect()

   const CENTERS = {
     lx: LEFT_EYE_BOUNDS.left + LEFT_EYE_BOUNDS.width * 0.5,
     rx: RIGHT_EYE_BOUNDS.left + RIGHT_EYE_BOUNDS.width * 0.5,
     ly: LEFT_EYE_BOUNDS.top + LEFT_EYE_BOUNDS.height * 0.5,
     ry: RIGHT_EYE_BOUNDS.top + RIGHT_EYE_BOUNDS.height * 0.5,
   }

   Object.entries(CENTERS)
     .forEach(([key, value]) => {
       const result = mapRange(value - LIMIT, value + LIMIT, -RANGE, RANGE)(key.indexOf('x') !== -1 ? x : y)
       EYES.style.setProperty(`--${key}`, result)
     })
 }

بمجرد تمرير القيم إلى CSS، يمكن للأنماط أن تفعل ما تريد بها. والأمر الرائع هنا هو استخدام CSS clamp() لجعل السلوك مختلفًا لكل عين، بحيث يمكنك جعل كل عين تتصرف بشكل مختلف بدون لمس JavaScript مرة أخرى.

.portrait__eye--mover {
   transition: translate 0.2s;
 }

 .portrait__eye--mover.portrait__eye--left {
   translate:
     clamp(-10px, var(--lx, 0) * 1px, 4px)
     clamp(-4px, var(--ly, 0) * 0.5px, 10px);
 }

 .portrait__eye--mover.portrait__eye--right {
   translate:
     clamp(-4px, var(--rx, 0) * 1px, 10px)
     clamp(-4px, var(--ry, 0) * 0.5px, 10px);
 }

إلقاء التعاويذ

إذا اطّلعت على الصفحة السادسة، هل ستشعر بالذهول؟ تتضمّن هذه الصفحة تصميم ثعلبنا السحري الرائع. إذا حرّكت المؤشر، قد يظهر تأثير مخصّص لمسار المؤشر. يستخدم هذا الخيار تأثيرات الحركة في Canvas. يظهر عنصر <canvas> فوق بقية محتوى الصفحة مع pointer-events: none. هذا يعني أنّه سيظل بإمكان المستخدمين النقر على مجموعات المحتوى تحتها.

.wand-canvas {
  height: 100%;
  width: 200%;
  pointer-events: none;
  right: 0;
  position: fixed;
}

تمامًا مثلما يستمع عنصر الصورة العمودية إلى حدث pointermove على window، يستمع عنصر <canvas> أيضًا إلى هذا الحدث. ومع ذلك، في كل مرة يتم فيها تنشيط الحدث، ننشئ كائنًا لتحريكه على العنصر <canvas>. تمثّل هذه العناصر الأشكال المستخدَمة في ذيل المؤشر. ولها إحداثيات ودرجة لون عشوائية.

يتم استخدام دالة mapRange السابقة مرة أخرى، حيث يمكننا استخدامها لربط دلتا المؤشر بـ size وrate. يتم تخزين العناصر في مصفوفة يتم تكرارها عند رسم العناصر في عنصر <canvas>. تخبر خصائص كل كائن عنصر <canvas> الخاص بنا بالمكان الذي يجب رسم الأشياء فيه.

const blocks = []
  const createBlock = ({ x, y, movementX, movementY }) => {
    const LOWER_SIZE = CANVAS.height * 0.05
    const UPPER_SIZE = CANVAS.height * 0.25
    const size = mapRange(0, 100, LOWER_SIZE, UPPER_SIZE, Math.max(Math.abs(movementX), Math.abs(movementY)))
    const rate = mapRange(LOWER_SIZE, UPPER_SIZE, 1, 5, size)
    const { left, top, width, height } = CANVAS.getBoundingClientRect()
    
    const block = {
      hue: Math.random() * 359,
      x: x - left,
      y: y - top,
      size,
      rate,
    }
    
    blocks.push(block)
  }
window.addEventListener('pointermove', createBlock)

للرسم على لوحة الرسم، يتم إنشاء حلقة باستخدام requestAnimationFrame. يجب ألا يظهر أثر المؤشر إلا عندما تكون الصفحة معروضة. لدينا IntersectionObserver يحدّد الصفحات المعروضة ويعدّلها. إذا كانت الصفحة معروضة، يتم عرض العناصر كدوائر على لوحة العرض.

بعد ذلك، نكرّر مصفوفة blocks ونرسم كل جزء من المسار. يقلّل كل إطار حجم العنصر ويغيّر موضعه بمقدار rate. يؤدي ذلك إلى تأثير السقوط وتغيير الحجم. إذا تقلّص العنصر تمامًا، تتم إزالته من مصفوفة blocks.

let wandFrame
const drawBlocks = () => {
   ctx.clearRect(0, 0, CANVAS.width, CANVAS.height)
  
   if (PAGE_SIX.className.indexOf('in-view') === -1 && wandFrame) {
     blocks.length = 0
     cancelAnimationFrame(wandFrame)
     document.body.removeEventListener('pointermove', createBlock)
     document.removeEventListener('resize', init)
   }
  
   for (let b = 0; b < blocks.length; b++) {
     const block = blocks[b]
     ctx.strokeStyle = ctx.fillStyle = `hsla(${block.hue}, 80%, 80%, 0.5)`
     ctx.beginPath()
     ctx.arc(block.x, block.y, block.size * 0.5, 0, 2 * Math.PI)
     ctx.stroke()
     ctx.fill()

     block.size -= block.rate
     block.y += block.rate

     if (block.size <= 0) {
       blocks.splice(b, 1)
     }

   }
   wandFrame = requestAnimationFrame(drawBlocks)
 }

إذا اختفت الصفحة عن العرض، تتم إزالة متتبِّعات الأحداث وإلغاء تكرار إطار الصور المتحركة. يتم أيضًا محو مصفوفة blocks.

إليك تأثير ذيل المؤشر أثناء الحركة.

مراجعة ميزات تسهيل الاستخدام

من الجيد إنشاء تجربة ممتعة للاستكشاف، ولكن لن تكون مفيدة إذا لم تكن متاحة للمستخدمين. كانت خبرة آدم في هذا المجال قيّمة للغاية في إعداد Chrometober لمراجعة أدوات تسهيل الاستخدام قبل الإصدار.

في ما يلي بعض المجالات البارزة التي تم تناولها:

  • التأكد من أن HTML المستخدم كان دلاليًا. وشمل ذلك عناصر مثل عناصر المعالم المناسبة، مثل <main> للكتاب، واستخدام العنصر <article> لكل مجموعة محتوى، وعناصر <abbr> عند تقديم الاختصارات. التفكير مسبقًا أثناء إنشاء الكتاب جعل الأمور أكثر سهولة. يؤدي استخدام العناوين والروابط إلى تسهيل تنقّل المستخدم. ويعني استخدام قائمة للصفحات أيضًا أنّ عدد الصفحات يتم الإعلان عنه من خلال التكنولوجيا المساعِدة.
  • التأكّد من أنّ جميع الصور تستخدم سمات alt المناسبة بالنسبة إلى ملفات SVG المضمّنة، يتوفّر العنصر title عند الضرورة.
  • استخدام سمات aria في الحالات التي تحسّن فيها التجربة يُعلم استخدام aria-label للصفحات وجوانبها المستخدم بالصفحة التي يتصفحها. يشير استخدام aria-describedBy في روابط "قراءة المزيد" إلى نص مجموعة المحتوى. يؤدي ذلك إلى إزالة الغموض حول المكان الذي سينقل الرابط المستخدم إليه.
  • في ما يتعلّق بمجموعات المحتوى، يمكنك النقر على البطاقة بأكملها وليس فقط على رابط "قراءة المزيد".
  • تمت الإشارة سابقًا إلى استخدام IntersectionObserver لتتبُّع الصفحات المعروضة. ويوفّر ذلك العديد من المزايا التي لا تقتصر على الأداء فقط. سيتم إيقاف أي صور متحركة أو تفاعل مؤقتًا في الصفحات غير المعروضة. ولكن تم أيضًا تطبيق السمة inert على هذه الصفحات. هذا يعني أنّ المستخدمين الذين يستعينون بقارئ شاشة يمكنهم استكشاف المحتوى نفسه الذي يطّلع عليه المستخدمون المبصرون. يظل التركيز ضمن الصفحة المعروضة ولا يمكن للمستخدمين الانتقال إلى صفحة أخرى باستخدام مفتاح Tab.
  • أخيرًا وليس آخرًا، نستخدم الاستعلامات عن الوسائط لمراعاة تفضيلات المستخدم في ما يتعلّق بالحركة.

إليك لقطة شاشة من المراجعة تسلّط الضوء على بعض الإجراءات المتّبعة.

يتم تحديد العنصر على أنّه يحيط بالكتاب بأكمله، ما يشير إلى أنّه يجب أن يكون المَعلم الرئيسي الذي يبحث عنه مستخدمو التكنولوجيا المساعِدة. يتم توضيح المزيد في لقطة الشاشة." width="800" height="465">

لقطة شاشة لكتاب Chrometober مفتوح تظهر مربعات خضراء حول جوانب مختلفة من واجهة المستخدم، وتصف وظائف تسهيل الاستخدام المقصودة ونتائج تجربة المستخدم التي ستقدّمها الصفحة. على سبيل المثال، تحتوي الصور على نص بديل. مثال آخر هو تصنيف تسهيل الاستخدام الذي يشير إلى أنّ الصفحات غير المرئية غير نشطة. يمكنك الاطّلاع على المزيد من التفاصيل في لقطة الشاشة.

الاستنتاجات التي توصّلنا إليها

لم يكن الدافع وراء Chrometober هو تسليط الضوء على محتوى الويب من المنتدى فحسب، بل كان أيضًا وسيلة لنا لاختبار polyfill لواجهة برمجة التطبيقات الخاصة بالحركات المرتبطة بالتمرير والتي لا تزال قيد التطوير.

وقد خصّصنا جلسة أثناء قمة فريقنا في نيويورك لاختبار المشروع ومعالجة المشاكل التي ظهرت. وكانت مساهمة الفريق قيّمة للغاية. وكانت أيضًا فرصة رائعة لإدراج جميع الأمور التي يجب معالجتها قبل إطلاق الميزة.

يجلس فريق CSS وواجهة المستخدم وأدوات مطوري البرامج حول الطاولة في غرفة الاجتماعات. تقف &quot;أونا&quot; أمام لوح أبيض مغطّى بملاحظات لاصقة. يجلس أعضاء الفريق الآخرون حول الطاولة مع المرطبات وأجهزة الكمبيوتر المحمولة.

على سبيل المثال، أدّى اختبار الكتاب على الأجهزة إلى ظهور مشكلة في العرض. لن يتم عرض كتابنا على النحو المتوقع على أجهزة iOS. تحدّد وحدات إطار العرض حجم الصفحة، ولكن عند وجود نتوء، يؤثر ذلك في الكتاب. كان الحلّ هو استخدام viewport-fit=cover في إطار العرض meta:

<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />

أثارت هذه الجلسة أيضًا بعض المشاكل المتعلّقة بـ polyfill لواجهة برمجة التطبيقات. طرح Bramus هذه المشاكل في مستودع polyfill. وقد وجد لاحقًا حلولاً لهذه المشاكل ودمجها في polyfill. على سبيل المثال، حقّق طلب السحب هذا تحسّنًا في الأداء من خلال إضافة ميزة التخزين المؤقت إلى جزء من الـ polyfill.

لقطة شاشة لنسخة تجريبية مفتوحة في Chrome يتم فتح &quot;أدوات المطوّرين&quot; وعرض قياس الأداء الأساسي.

لقطة شاشة لنسخة تجريبية مفتوحة في Chrome تكون &quot;أدوات المطوّرين&quot; مفتوحة وتعرض قياسًا محسّنًا للأداء.

هذا كل شيء!

لقد كان هذا المشروع ممتعًا للغاية، وأسفر عن تجربة تصفّح رائعة تسلّط الضوء على المحتوى المذهل الذي يقدّمه المنتدى. بالإضافة إلى ذلك، كان من الرائع اختبار polyfill، وتقديم ملاحظات إلى الفريق الهندسي للمساعدة في تحسينه.

انتهت فعالية Chrometober 2022.

نأمل أن تكون قد استمتعت بها. ما هي الميزة المفضلة لديك؟ أرسِلوا إليّ تغريدة لإعلامي بذلك.

&quot;جاي&quot; يحمل ورقة ملصقات لشخصيات من Chrometober.

قد تتمكّن أيضًا من الحصول على بعض الملصقات من أحد أعضاء الفريق إذا التقيت بنا في إحدى الفعاليات.