<!--
  canonical-url: https://www.avonture.be/fr/blog/docusaurus-shake-easter-egg
  published:     2026-08-27
  This is a static plain-Markdown mirror generated at build time.
  Visit the canonical URL above for the fully rendered page, with images and interactive components.
-->

# Secouez votre téléphone, surprenez le suricate

> Un neuvième easter egg pour ce blog, pensé pour les lecteurs sur téléphone : secouez-le — dans Chrome ou dans la PWA installée — et la mascotte suricate prend tout l'écran, surprise, pendant environ deux secondes. On y parle du calcul du « jerk » via devicemotion derrière la détection de secousse, de l'animation de l'overlay en trois phases, de la façon de tester le tout depuis une console DevTools sur desktop sans le moindre accéléromètre, et d'un vrai bug découvert en chemin : un générateur d'images IA qui a dessiné le damier censé représenter la transparence sous forme de pixels réellement opaques au lieu d'un vrai canal alpha.

---
![Secouez votre téléphone, surprenez le suricate](/img/v2/shaked_meerkat.webp)

**TL;DR**

Ce blog cache déjà [huit petits easter eggs](/blog/docusaurus-easter-eggs), mais chacun d'eux suppose un clavier ou un panneau DevTools ouvert — inutile pour un lecteur sur téléphone. En voici un neuvième : secouez le téléphone et la mascotte apparaît en plein écran, surprise, pendant environ deux secondes. Au programme : le calcul de la détection de secousse (un seuil de « jerk » calculé entre deux échantillons `devicemotion` consécutifs), l'animation de l'overlay en trois phases (apparition, tremblement, disparition), comment la déclencher et la tester depuis une console DevTools sur desktop sans aucun accéléromètre, et un vrai bug découvert en chemin : un générateur d'images IA qui a dessiné le damier utilisé pour *représenter* la transparence sous forme de pixels réellement opaques, repéré en lisant les octets bruts du PNG plutôt qu'en se fiant à un aperçu. **Android uniquement**, délibérément et définitivement — la demande de permission de mouvement d'iOS Safari exige un tap, et toutes les façons d'en fournir un entrent en conflit avec la règle « invisible jusqu'à ce que vous tombiez dessus » que ce site applique à ses easter eggs.

Jusqu'ici, chaque easter egg de ce blog suppose un clavier : `CTRL`+`U` pour afficher la source, une séquence Konami à dix touches, un `console.log` qui n'apparaît qu'avec les DevTools ouverts. Tout à fait raisonnable sur un portable — et invisible pour qui lit sur un téléphone, ce qui, sur un blog technique, représente encore une part non négligeable du trafic. Un téléphone n'a pas de touches fléchées, mais il a quelque chose qu'un portable n'a pas : un accéléromètre, à un `window.addEventListener("devicemotion", …)` de distance, sur n'importe quelle page servie en HTTPS.

Donc : secouez le téléphone, et la mascotte réagit.

## Secouez votre téléphone et voyez ce qui se passe {#shake-your-phone-see-what-happens}

Visitez ce blog sur Android — dans Chrome, ou dans la PWA installée — et secouez vraiment le téléphone. Voici ce qui apparaît, en plein écran, pendant environ deux secondes :

> Screenshot — https\://www\.avonture.be/
>
> ![Un suricate surpris qui remplit l'écran après une secousse du téléphone](./images/shake_overlay.webp)

Pas de rechargement de page, rien à activer d'abord — le capteur de mouvement du téléphone a fait tout le travail. Voici le peu de code que ça demande réellement.

> **Rien ne se passe quand vous secouez ?:**
>
> Vérifiez que ce site est autorisé à lire les **capteurs de mouvement** — tapez sur l'icône à gauche de la barre d'adresse → Autorisations. Chrome pour Android peut bloquer ça silencieusement site par site ; voyez [Comment débugger si la secousse ne fait rien](#how-to-debug-if-the-shake-does-nothing) plus bas.

## Pourquoi ça fonctionne {#why-it-works}

* **La détection de secousse a besoin d'un seul nombre, pas de trois.** Un événement `devicemotion` rapporte l'accélération sur trois axes (x, y, z) ; comparer chacun à un seuil fonctionne à peine, parce qu'une manipulation ordinaire fait déjà osciller les trois en permanence. Ce qui marque réellement une secousse volontaire, c'est *la vitesse à laquelle* ces trois valeurs changent entre deux relevés consécutifs — sommée sur les axes et normalisée par le temps écoulé entre les échantillons, elle se réduit à une seule valeur de « jerk » avec un unique seuil propre à régler.
* **Android n'a pas de prompt `requestPermission()` ; iOS, si.** Résultat : livré uniquement pour Android — délibérément, pas temporairement ; voyez [pourquoi](#why-ios-is-left-out-on-purpose) plus bas. Le fonctionnement des permissions d'Android a de toute façon sa propre subtilité, détaillée dans [Comment débugger si la secousse ne fait rien](#how-to-debug-if-the-shake-does-nothing).
* **C'est un cooldown, pas un booléen, qui empêche le redéclenchement.** L'overlay reste à l'écran plus de deux secondes ; sans un écart minimum entre deux déclenchements, la fin du même geste de secousse le relancerait immédiatement. Comparer des timestamps est plus simple qu'un verrou, et ça ne peut pas rester bloqué en position « actif » si une étape de nettoyage est un jour sautée.
* **C'est un seul composant, monté une seule fois, globalement** — [le même pattern que celui déjà utilisé pour le code Konami et le changement de favicon au changement d'onglet](/blog/docusaurus-easter-eggs) : pas de nouvelle route, pas de câblage page par page, une ligne de plus dans `Root.js`.
* **La vibration haptique est un bonus, pas une exigence.** Un court appel à `navigator.vibrate()` ajoute un « cri » physique au « cri » visuel — et sur les navigateurs qui l'ignorent silencieusement en dehors d'un geste utilisateur (ce qu'un événement de capteur n'est pas), l'easter egg fonctionne très bien, juste sans la vibration.

## Pourquoi iOS est volontairement laissé de côté {#why-ios-is-left-out-on-purpose}

iOS Safari 13+ exige un appel explicite à `DeviceMotionEvent.requestPermission()`, lié à un vrai tap, avant de livrer le moindre relevé `devicemotion` — et cet appel ne peut pas être fait de façon proactive au chargement de la page. Quelque chose doit le déclencher, ce qui laisse exactement deux options, toutes deux rejetées.

La première est un contrôle visible : un petit bouton ou bandeau « Activer les effets de secousse », affiché une fois sur iOS. Ça fonctionnerait — mais ce serait aussi le premier élément d'interface de tout ce blog qui existe uniquement pour demander quelque chose avant que le lecteur ait fait quoi que ce soit pour le mériter. Tous les autres easter eggs ici suivent une règle, énoncée noir sur blanc dans l'article qui présentait les huit premiers :

> **La règle à laquelle ce blog soumet chaque easter egg:**
>
> Chaque easter egg vit dans la console, dans la source, dans les en-têtes de réponse, ou dans un état que le visiteur déclenche volontairement. Aucun n'apparaît jamais dans le flux de lecture normal. **Si vous ne le cherchez pas, vous ne le verrez jamais.**

Un bouton demandant l'accès au capteur de mouvement casse ça à vue, pour chaque visiteur iOS, qu'il se soucie ou non d'un suricate secoué — un coût payé d'avance par 100 % d'entre eux, pour une récompense que seule une minorité curieuse déclencherait. C'est un mauvais échange avant même d'être un choix de design.

La seconde option est pire d'une autre manière : supprimer le bouton visible et déclencher `requestPermission()` au tout premier tap du visiteur n'importe où sur la page — un lien de navigation, une icône de recherche, peu importe. Techniquement invisible dans l'interface de *ce* site, puisque rien de rendu par ce composant n'apparaîtrait — mais iOS affiche quand même sa propre boîte de dialogue système « Autoriser l'accès aux mouvements et à l'orientation ? » la première fois que l'appel est fait pour une origine. Un lecteur qui a tapé sur un élément de menu se voit interrompu par une demande d'autorisation, sans aucun contexte expliquant pourquoi un blog l'interroge soudain sur les capteurs de mouvement. Ce n'est pas la surprise amusante que ce site recherche ; c'est un non-sens qui dégrade la toute première interaction d'une visite sans rapport.

Les deux chemins dépensent quelque chose qui appartient à tous les visiteurs — l'attention, la confiance, ou un premier tap propre — pour financer une fonctionnalité que seuls certains voudraient. Un easter egg gagne son droit d'exister en ne coûtant rien à tous ceux qui ne le cherchent pas ; une demande d'autorisation, sous n'importe quelle forme, échoue à ce test avant même que le suricate ait une chance de surprendre. Android ne demandait rien, donc c'est sur Android que ça sort. iOS reste dehors, non pas parce que le code est difficile, mais parce que chaque façon de le rendre possible là-bas fait payer un loyer à un lecteur venu ici pour lire, pas pour accorder des permissions.

## Installation {#installation}

Le tout tient en deux fichiers plus un import. En partant d'une image à fond transparent de la mascotte surprise — plus de détails sur la façon dont cette image a réellement été produite, et sur l'erreur qui a failli passer en production, dans [Sous le capot](#under-the-hood-skip-this-if-you-just-want-to-use-it) plus bas — la détection et l'overlay vivent entièrement dans `index.tsx` :

**src/components/ShakeEasterEgg/index.tsx**

```typescript
import { useCallback, useEffect, useRef, useState, type JSX } from "react";
import clsx from "clsx";
import scaredImg from "@site/static/img/meerkat/suricate_scared.webp";
import styles from "./styles.module.css";
import { translate } from "@docusaurus/Translate";

// "Jerk" (rate of change of acceleration) threshold that counts as a shake.
// History: 28 (initial) fired from a mere tilt; 60 (first field fix) still
// fired too easily across a full day of real-world phone use. Raise further
// if it still fires too easily, lower it if a real shake stops triggering it.
const SHAKE_JERK_THRESHOLD = 100;

// Ignore devicemotion samples firing faster than this: some devices report
// at 60Hz+, which would otherwise make every tiny jitter look like a spike.
const MIN_SAMPLE_INTERVAL_MS = 100;

// Minimum time between two triggers, so one shake gesture doesn't retrigger
// itself while the overlay is still showing.
const COOLDOWN_MS = 4000;

// How long the overlay stays fully visible before it starts fading out.
const VISIBLE_DURATION_MS = 2200;

// Fade-out duration; kept in sync with the `overlay-out` keyframes below.
const EXIT_DURATION_MS = 250;

type Phase = "hidden" | "visible" | "leaving";

export default function ShakeEasterEgg(): JSX.Element | null {
  const [phase, setPhase] = useState<Phase>("hidden");
  const lastAcceleration = useRef<{ x: number; y: number; z: number } | null>(null);
  const lastSampleTime = useRef(0);
  const lastTriggerTime = useRef(0);
  const leaveTimeoutRef = useRef<number | undefined>(undefined);
  const hideTimeoutRef = useRef<number | undefined>(undefined);

  const clearTimers = useCallback(() => {
    window.clearTimeout(leaveTimeoutRef.current);
    window.clearTimeout(hideTimeoutRef.current);
  }, []);

  const dismiss = useCallback(() => {
    clearTimers();
    setPhase("leaving");
    hideTimeoutRef.current = window.setTimeout(
      () => setPhase("hidden"),
      EXIT_DURATION_MS,
    );
  }, [clearTimers]);

  const trigger = useCallback(() => {
    clearTimers();
    setPhase("visible");
    // Best-effort haptic feedback. Browsers that require a user gesture for
    // navigator.vibrate() (devicemotion isn't one) will just ignore this.
    navigator.vibrate?.([40, 30, 40]);
    leaveTimeoutRef.current = window.setTimeout(() => {
      setPhase("leaving");
      hideTimeoutRef.current = window.setTimeout(
        () => setPhase("hidden"),
        EXIT_DURATION_MS,
      );
    }, VISIBLE_DURATION_MS);
  }, [clearTimers]);

  useEffect(() => {
    if (typeof window === "undefined" || !window.DeviceMotionEvent) {
      return undefined;
    }

    const handleMotion = (event: DeviceMotionEvent) => {
      const acceleration = event.accelerationIncludingGravity;
      // All three axes must be present: DeviceMotionEventAcceleration types each
      // as `number | null`, and the jerk maths below reads x, y and z.
      if (
        !acceleration ||
        acceleration.x === null ||
        acceleration.y === null ||
        acceleration.z === null
      )
        return;

      const now = Date.now();
      const deltaTime = now - lastSampleTime.current;
      if (deltaTime < MIN_SAMPLE_INTERVAL_MS) return;

      const { x, y, z } = acceleration;
      const previous = lastAcceleration.current;

      if (previous) {
        const jerk =
          ((Math.abs(x - previous.x) +
            Math.abs(y - previous.y) +
            Math.abs(z - previous.z)) /
            deltaTime) *
          1000;

        if (jerk > SHAKE_JERK_THRESHOLD && now - lastTriggerTime.current > COOLDOWN_MS) {
          lastTriggerTime.current = now;
          trigger();
        }
      }

      lastAcceleration.current = { x, y, z };
      lastSampleTime.current = now;
    };

    window.addEventListener("devicemotion", handleMotion);
    return () => window.removeEventListener("devicemotion", handleMotion);
  }, [trigger]);

  useEffect(() => clearTimers, [clearTimers]);

  if (phase === "hidden") {
    return null;
  }

  return (
    <button
      type="button"
      className={clsx(styles.overlay, phase === "leaving" && styles.leaving)}
      onClick={dismiss}
      aria-label={translate({ id: "common.dismiss", message: "Dismiss" })}
    >
      <img
        src={scaredImg}
        alt={translate({
          id: "shakeEasterEgg.alt",
          message: "A startled meerkat, arms flailing, as if the ground just shook",
        })}
        className={styles.meerkat}
      />
    </button>
  );
}
```

Et l'overlay plein écran, son animation apparition/tremblement/disparition, ainsi que le repli `prefers-reduced-motion` vivent dans le module CSS :

**src/components/ShakeEasterEgg/styles.module.css**

```css
.overlay {
  position: fixed;
  inset: 0;
  z-index: 10001;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 100%;
  margin: 0;
  padding: 0;
  border: none;
  background: radial-gradient(
    circle at center,
    rgb(0, 0, 0, 0.65) 0%,
    rgb(0, 0, 0, 0.88) 100%
  );
  cursor: pointer;
  animation: overlay-in 0.2s ease-out both;
}

.overlay.leaving {
  animation: overlay-out 0.25s ease-in both;
}

.meerkat {
  width: min(88vw, 80vh);
  height: auto;
  filter: drop-shadow(0 16px 40px rgb(0, 0, 0, 0.6));
  animation:
    pop-in 0.35s cubic-bezier(0.34, 1.56, 0.64, 1) both,
    tremble 0.3s ease-in-out 0.35s 6;
}

@keyframes overlay-in {
  from {
    opacity: 0;
  }

  to {
    opacity: 1;
  }
}

@keyframes overlay-out {
  from {
    opacity: 1;
  }

  to {
    opacity: 0;
  }
}

@keyframes pop-in {
  from {
    transform: scale(0.4) rotate(-8deg);
    opacity: 0;
  }

  to {
    transform: scale(1) rotate(0deg);
    opacity: 1;
  }
}

@keyframes tremble {
  0%,
  100% {
    transform: translate(0, 0) rotate(0deg);
  }

  20% {
    transform: translate(-6px, 3px) rotate(-3deg);
  }

  40% {
    transform: translate(5px, -4px) rotate(3deg);
  }

  60% {
    transform: translate(-4px, -2px) rotate(-2deg);
  }

  80% {
    transform: translate(6px, 3px) rotate(2deg);
  }
}

@media (prefers-reduced-motion: reduce) {
  .overlay {
    animation: none;
  }

  .overlay.leaving {
    animation: none;
    opacity: 0;
  }

  .meerkat {
    animation: none;
  }
}
```

Monté une fois, globalement, depuis `src/theme/Root.js` — juste à côté du composant du code Konami dont il emprunte la forme :

```jsx title="src/theme/Root.js (excerpt)"
import ShakeEasterEgg from "@site/src/components/ShakeEasterEgg";

// ...

return (
  <>
    {children}
    <KonamiEasterEgg />
    <ShakeEasterEgg />
  </>
);
```

C'est tout — pas de nouvelle route, pas de flag de configuration, pas d'étape de build.

## Tester sans rien secouer {#testing-it-without-shaking-anything}

Se lever de son bureau pour secouer physiquement un téléphone, vingt fois de suite, pour vérifier un ajustement de timing CSS, lasse très vite. `devicemotion` n'est qu'un événement — rien n'empêche d'en envoyer un de façon synthétique depuis une console de navigateur, sur desktop, sans aucun capteur dans la boucle :

**Paste in DevTools console**

```
// Paste in your browser's DevTools console — desktop Chrome works fine,
// no physical accelerometer needed — to trigger the shake overlay without
// shaking anything. This is the exact snippet used to test the effect
// before a single real device was ever involved.
const fire = (x, y, z) => {
  window.dispatchEvent(
    new DeviceMotionEvent("devicemotion", {
      accelerationIncludingGravity: { x, y, z },
      acceleration: { x, y, z },
      interval: 16,
    }),
  );
};

fire(0, 0, 9.8);
setTimeout(() => fire(25, -20, 15), 150);
```

Deux appels, espacés de 150 ms, avec un écart suffisamment grand entre les deux relevés d'accélération pour franchir le seuil de jerk — voilà tout le geste « physique », réduit à deux nombres. C'est aussi, honnêtement, comme ça que la capture d'écran ci-dessus a été produite : un navigateur headless, un viewport mobile, et exactement ce snippet envoyé dans la page — pas un vrai téléphone.

## Comment débugger si la secousse ne fait rien {#how-to-debug-if-the-shake-does-nothing}

Deux vérifications, dans l'ordre — chacune prend moins d'une minute et aucune ne demande de toucher au code.

### 1. Autoriser les capteurs de mouvement pour le site {#1-allow-motion-sensors-for-the-site}

Chrome pour Android a sa propre autorisation **Capteurs de mouvement** par site, indépendante de tout ce que ce composant contrôle. Tapez sur l'icône à gauche de la barre d'adresse (cadenas, « i », ou un triangle d'avertissement si le certificat est auto-signé) → **Autorisations** → assurez-vous que **Capteurs de mouvement** est réglé sur *Autoriser*, pas sur *Bloquer*. Si ça n'y figure pas, vérifiez plutôt **Chrome ⋮ → Paramètres → Paramètres des sites → Capteurs de mouvement** pour un blocage global. Rechargez la page après modification — à elle seule, cette manip était le correctif dont l'unique appareil réel de test avait besoin.

### 2. Observer les données brutes du capteur {#2-watch-the-raw-sensor-data}

Quand l'étape 1 n'explique rien, une petite page de diagnostic autonome règle le reste — pas d'étape de build, pas de configuration de DevTools distants, juste des chiffres en direct sur l'écran du téléphone :

**static/shake-debug.html**

```html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Shake debug</title>
    <style>
      body {
        margin: 0;
        padding: 16px;
        background: #111;
        color: #0f0;
        font-family: monospace;
        font-size: 16px;
        line-height: 1.5;
      }
      h1 {
        font-size: 18px;
        color: #fff;
      }
      .row {
        display: flex;
        justify-content: space-between;
        border-bottom: 1px solid #333;
        padding: 4px 0;
      }
      .warn {
        color: #f55;
        font-weight: bold;
      }
      .ok {
        color: #5f5;
        font-weight: bold;
      }
      #jerk {
        font-size: 28px;
        font-weight: bold;
      }
      #status {
        margin-top: 12px;
        padding: 8px;
        border: 1px solid #555;
      }
    </style>
  </head>
  <body>
    <h1>Shake debug</h1>

    <div class="row"><span>DeviceMotionEvent</span><span id="hasApi">?</span></div>
    <div class="row"><span>isSecureContext</span><span id="secure">?</span></div>
    <div class="row"><span>location</span><span id="loc">?</span></div>
    <div class="row">
      <span>requestPermission (iOS-style)</span><span id="hasPerm">?</span>
    </div>
    <div class="row"><span>Permissions API query</span><span id="permQuery">?</span></div>

    <div id="status">Waiting for the first devicemotion event…</div>

    <div class="row"><span>events received</span><span id="count">0</span></div>
    <div class="row"><span>x</span><span id="x">-</span></div>
    <div class="row"><span>y</span><span id="y">-</span></div>
    <div class="row"><span>z</span><span id="z">-</span></div>
    <div class="row"><span>jerk (threshold 100)</span><span id="jerk">-</span></div>
    <div class="row"><span>max jerk seen</span><span id="maxJerk">0</span></div>

    <script>
      document.getElementById("hasApi").textContent = window.DeviceMotionEvent
        ? "present"
        : "MISSING";
      document.getElementById("hasApi").className = window.DeviceMotionEvent
        ? "ok"
        : "warn";

      document.getElementById("secure").textContent = window.isSecureContext;
      document.getElementById("secure").className = window.isSecureContext
        ? "ok"
        : "warn";

      document.getElementById("loc").textContent =
        location.protocol + "//" + location.host;

      document.getElementById("hasPerm").textContent =
        window.DeviceMotionEvent &&
        typeof DeviceMotionEvent.requestPermission === "function"
          ? "present (unexpected on Android)"
          : "absent (normal on Android)";

      if (navigator.permissions && navigator.permissions.query) {
        navigator.permissions
          .query({ name: "accelerometer" })
          .then((r) => {
            document.getElementById("permQuery").textContent = r.state;
            document.getElementById("permQuery").className =
              r.state === "denied" ? "warn" : "ok";
          })
          .catch((e) => {
            document.getElementById("permQuery").textContent =
              "not queryable (" + e.message + ")";
          });
      } else {
        document.getElementById("permQuery").textContent = "Permissions API unavailable";
      }

      let count = 0;
      let lastSample = null;
      let lastTime = 0;
      let maxJerk = 0;
      let timeoutId = window.setTimeout(() => {
        if (count === 0) {
          const status = document.getElementById("status");
          status.textContent =
            "No devicemotion event received after 3s. The sensor is blocked, unsupported, " +
            "or this device/browser needs a permission grant.";
          status.className = "warn";
        }
      }, 3000);

      window.addEventListener("devicemotion", (event) => {
        window.clearTimeout(timeoutId);
        const status = document.getElementById("status");
        status.textContent = "Receiving devicemotion events ✓";
        status.className = "ok";

        const acceleration = event.accelerationIncludingGravity;
        if (!acceleration || acceleration.x === null) {
          status.textContent =
            "Events fire, but accelerationIncludingGravity is null — check the " +
            "'Motion sensors' site permission (tap the icon left of the address bar).";
          status.className = "warn";
          return;
        }

        count += 1;
        document.getElementById("count").textContent = count;

        const { x, y, z } = acceleration;
        document.getElementById("x").textContent = x.toFixed(2);
        document.getElementById("y").textContent = y.toFixed(2);
        document.getElementById("z").textContent = z.toFixed(2);

        const now = Date.now();
        if (lastSample) {
          const deltaTime = now - lastTime;
          if (deltaTime > 0) {
            const jerk =
              ((Math.abs(x - lastSample.x) +
                Math.abs(y - lastSample.y) +
                Math.abs(z - lastSample.z)) /
                deltaTime) *
              1000;
            document.getElementById("jerk").textContent = jerk.toFixed(1);
            if (jerk > maxJerk) {
              maxJerk = jerk;
              document.getElementById("maxJerk").textContent = maxJerk.toFixed(1);
            }
          }
        }
        lastSample = { x, y, z };
        lastTime = now;
      });
    </script>
  </body>
</html>
```

Visitez `/shake-debug.html` sur la même origine que le reste du site et la page rapporte, en direct : si `DeviceMotionEvent` existe tout simplement, l'état de `navigator.permissions.query({ name: "accelerometer" })` (`"granted"` / `"denied"` / `"prompt"`), un compteur d'événements reçus, les relevés `x`/`y`/`z` en direct, et la valeur de jerk calculée face au seuil actuel. Trois signatures d'échec distinctes à y lire :

* **Aucun événement, compteur bloqué à 0** — le navigateur ou l'OS bloque totalement le capteur, ou `DeviceMotionEvent` n'est pas supporté (iOS sans le flux de permission, voyez la limitation ci-dessus).
* **Les événements arrivent, mais `x`/`y`/`z` restent à `null`** — c'est l'autorisation Capteurs de mouvement de l'étape 1, confirmée : le navigateur émet l'événement par habitude mais retient les données.
* **De vrais chiffres, mais le jerk maximum ne franchit jamais le seuil** — pas un bug, juste un seuil calibré pour une autre main ; voyez la note de réglage plus bas.

C'est aussi, autant l'admettre franchement, comme ça que le bug de l'étape 1 a été trouvé au départ : la secousse ne faisait rien sur un vrai téléphone, cette page montrait le compteur d'événements grimper avec `x`/`y`/`z` bloqués à `null`, et la requête de permission expliquait exactement pourquoi en un seul mot — `"denied"`.

## Sous le capot (passez si vous voulez juste l'utiliser) {#under-the-hood-skip-this-if-you-just-want-to-use-it}

### L'image était la partie difficile, pas le code {#the-image-was-the-hard-part-not-the-code}

Le composant lui-même s'est écrit d'un seul jet. Obtenir une **vraie** image à fond transparent du suricate surpris a demandé plus de soin que le code — bon à savoir si vous générez des visuels avec un outil d'image IA pour quoi que ce soit qui doit se composer par-dessus d'autres contenus.

Les visuels de la mascotte du site sont [générés avec Google Gemini](/blog/gemini-meerkat), en fournissant une image de référence existante pour que les nouvelles poses restent visuellement cohérentes. Pour un overlay plein écran, « fond transparent » n'est pas une préférence esthétique, c'est une exigence stricte : le fichier a besoin d'un vrai canal alpha, pas juste de quelque chose qui *paraît* transparent dans un aperçu. Le prompt demandait explicitement un export PNG, avec une ligne indiquant au modèle de ne pas dessiner le damier gris et blanc que les éditeurs utilisent pour *représenter* la transparence comme s'il faisait partie de l'image — ce motif est une convention d'interface, pas des pixels à peindre.

Deux faits liés aux formats rendent le résultat facile à vérifier, plutôt que de se fier à un aperçu :

* Le JPEG ne peut structurellement pas stocker la transparence — un export PNG (ou WebP) est non négociable dès le départ.
* Le chunk `IHDR` d'un PNG porte un octet dédié exactement à ça : `6` signifie RGBA avec un vrai canal alpha, `2` signifie du RGB simple sans alpha. Lire cet octet — ou juste échantillonner un pixel dans un coin et vérifier que son alpha vaut réellement `0` — règle la question sans ouvrir d'éditeur.

Le fichier final a passé les deux contrôles : alpha `6` dans le chunk `IHDR`, coins à alpha `0`, personnage entièrement opaque — vérifié pixel par pixel plutôt qu'à l'œil.

### Régler le seuil de jerk sur un vrai téléphone {#tuning-the-jerk-threshold-on-a-real-phone}

La première valeur livrée pour `SHAKE_JERK_THRESHOLD` (`28`) a été choisie au feeling, avec une note admettant qu'elle n'avait pas été mesurée sur un appareil physique. Elle n'a pas survécu au premier contact : sur un vrai téléphone Android, une simple *inclinaison* — sans aucune secousse — suffisait déjà à la franchir. Une première correction l'a portée à `60`, ce qui a réglé le déclenchement à l'inclinaison — mais une journée entière avec le téléphone porté normalement le déclenchait encore régulièrement, ce que `60` seul n'avait pas révélé en cinq minutes de test. C'est `100` qui a fini par être retenu. Aucune formule ne permet de trouver ça depuis un bureau, et apparemment pas non plus en cinq minutes sur un vrai téléphone ; un seuil de « jerk » doit survivre au port et à la manipulation réels dans le temps, pas seulement à une secousse de test volontaire, avant qu'on puisse lui faire confiance.

## Conclusion {#conclusion}

Un neuvième easter egg, et le premier que ce blog conçoit pour un téléphone plutôt que pour un clavier : une détection de secousse réduite à un seul nombre de « jerk », un overlay plein écran qui respecte `prefers-reduced-motion` et se ferme d'un tap, et une façon de tester tout ça sans quitter son bureau. La vraie leçon n'était pas dans le code de détection — elle était dans le fait de ne pas faire confiance à un aperçu d'image : un fichier peut *paraître* transparent et être totalement opaque, et le seul moyen d'en être sûr est de vérifier les octets.

iOS reste dehors définitivement, [pas par manque d'essais mais par choix](#why-ios-is-left-out-on-purpose), et c'est en soi le rappel à garder : un easter egg n'a qu'à ravir ceux qui le cherchent, et ne jamais rien coûter à ceux qui ne le cherchent pas — une barre sous laquelle une vraie fonctionnalité ne peut pas passer, mais une récompense cachée, si. Si vous faites tourner votre propre blog Docusaurus avec sa mascotte, le [code Konami et le changement de favicon au changement d'onglet](/blog/docusaurus-easter-eggs) sont les cousins desktop de celui-ci — même forme « monté une fois dans `Root.js` », pas de capteur, pas de compromis sur les permissions.

**Ce qu'il faut régler**

* **`SHAKE_JERK_THRESHOLD`** (par défaut `100`, relevé depuis `28` puis `60` après un usage réel où ça se déclenchait trop facilement) — augmentez-le encore si l'easter egg se déclenche toujours trop facilement, baissez-le si une secousse volontaire ne fait rien

* **`COOLDOWN_MS`** (par défaut `4000`) — temps minimum entre deux déclenchements

* **`VISIBLE_DURATION_MS`** (par défaut `2200`) / **`EXIT_DURATION_MS`** (par défaut `250`) — combien de temps l'overlay reste affiché et à quelle vitesse il disparaît ; gardez la durée CSS `overlay-out` synchronisée avec la seconde

* Les quatre se trouvent en haut de `src/components/ShakeEasterEgg/index.tsx`
