Mejora progresivamente tu app web progresiva

Compila para navegadores modernos y mejora de forma progresiva como si fuera 2003

En marzo de 2003, Nick Finck y Steve Champeon sorprendieron al mundo del diseño web con el concepto de mejora progresiva, una estrategia de diseño web que enfatiza la carga del contenido principal de la página web primero y, luego, agrega progresivamente capas de presentación y funciones más detalladas y técnicamente rigurosas sobre el contenido. Mientras que, en 2003, la mejora progresiva se trataba de usar, en ese momento, funciones modernas de CSS, JavaScript discreto y hasta solo gráficos vectoriales escalables. La mejora progresiva en 2020 y más allá consiste en usar funciones de navegadores modernos.

Diseño web inclusivo para el futuro con mejora progresiva. Diapositiva del título de la presentación original de Finck y Champeon.
Diapositiva: Diseño web inclusivo para el futuro con mejora progresiva. (Fuente)

JavaScript moderno

A propósito de JavaScript, la situación de compatibilidad del navegador con las funciones principales más recientes de JavaScript ES 2015 es excelente. El nuevo estándar incluye promesas, módulos, clases, literales de plantilla, funciones de flecha, let y const, parámetros predeterminados, generadores, la asignación de destructuración, resto y propagación, Map/Set, WeakMap/WeakSet y muchos más. Todos son compatibles.

La tabla de compatibilidad de CanIUse para las funciones de ES6 que muestra la compatibilidad en todos los navegadores principales
Tabla de compatibilidad de navegadores con ECMAScript 2015 (ES6). (Fuente)

Las funciones asíncronas, una función de ES 2017 y una de mis favoritas, se pueden usar en todos los navegadores principales. Las palabras clave async y await permiten que el comportamiento asíncrono basado en promesas se escriba de una manera más clara, lo que evita la necesidad de configurar cadenas de promesas de forma explícita.

La tabla de compatibilidad de CanIUse para funciones asíncronas que muestra la compatibilidad en todos los navegadores principales
Tabla de compatibilidad de navegadores de funciones asíncronas. (Fuente)

Incluso las incorporaciones más recientes del lenguaje ES 2020, como la cadena opcional y la coalición nula, se implementaron con rapidez. Puedes ver una muestra de código a continuación. En lo que respecta a las funciones principales de JavaScript, el panorama no podría ser mejor.

const adventurer = {
  name: 'Alice',
  cat: {
    name: 'Dinah',
  },
};
console.log(adventurer.dog?.name);
// Expected output: undefined
console.log(0 ?? 42);
// Expected output: 0
La icónica imagen de fondo de hierba verde de Windows XP.
Cuando se trata de las funciones principales de JavaScript, el panorama es positivo. (Captura de pantalla del producto de Microsoft, que se usa con permiso).

La app de ejemplo: Fugu Greetings

En este artículo, trabajo con una AWP simple, llamada Fugu Greetings (GitHub). El nombre de esta app es un homenaje al proyecto Fugu 🐡, un esfuerzo por darle a la Web todas las virtudes de las aplicaciones para Android, iOS y computadoras de escritorio. Puedes obtener más información sobre el proyecto en su página de destino.

Fugu Greetings es una app de dibujo que te permite crear tarjetas de felicitación virtuales y enviarlas a tus seres queridos. En él, se ejemplifican los conceptos básicos de las AWP. Es confiable y está completamente habilitada para usar sin conexión, por lo que puedes usarla incluso si no tienes una red. También se puede instalar en la pantalla principal de un dispositivo y se integra sin problemas con el sistema operativo como una aplicación independiente.

AWP de Fugu Greetings con un dibujo que se asemeja al logotipo de la comunidad de AWP.
La app de ejemplo Fugu Greetings.

Mejora progresiva

Ahora que tenemos esto claro, es hora de hablar sobre la mejora progresiva. El Glosario de MDN Web Docs define el concepto de la siguiente manera:

La mejora progresiva es una filosofía de diseño que proporciona un modelo de referencia de contenido y funcionalidad esenciales a la mayor cantidad posible de usuarios, a la vez que ofrece la mejor experiencia posible solo a los usuarios de los navegadores más modernos que pueden ejecutar todo el código requerido.

La detección de funciones generalmente se usa para determinar si los navegadores pueden controlar funciones más modernas, mientras que los polyfills suelen usarse para agregar funciones faltantes con JavaScript.

[…]

La mejora progresiva es una técnica útil que permite a los desarrolladores web enfocarse en desarrollar los mejores sitios web posibles y, al mismo tiempo, hacer que esos sitios web funcionen en varios agentes de usuario desconocidos. La degradación elegante está relacionada, pero no es lo mismo y, a menudo, se considera que va en la dirección opuesta a la mejora progresiva. En realidad, ambos enfoques son válidos y, a menudo, pueden complementarse entre sí.

Colaboradores de MDN

Comenzar cada tarjeta de felicitación desde cero puede ser muy engorroso. Entonces, ¿por qué no tener una función que permita a los usuarios importar una imagen y comenzar desde allí? Con un enfoque tradicional, habrías usado un elemento <input type=file> para lograrlo. Primero, crearías el elemento, establecerías su type en 'file' y agregarías tipos de MIME a la propiedad accept. Luego, lo “harías clic” de forma programática y escucharías los cambios. Cuando seleccionas una imagen, esta se importa directamente en el lienzo.

const importImage = async () => {
  return new Promise((resolve) => {
    const input = document.createElement('input');
    input.type = 'file';
    input.accept = 'image/*';
    input.addEventListener('change', () => {
      resolve(input.files[0]);
    });
    input.click();
  });
};

Cuando hay una función de importación, es probable que también haya una de exportación para que los usuarios puedan guardar sus tarjetas de felicitación de forma local. La forma tradicional de guardar archivos es crear un vínculo de anclaje con un atributo download y con una URL de blob como href. También deberías hacer clic en él de forma programática para activar la descarga y, para evitar fugas de memoria, no olvides revocar la URL del objeto blob.

const exportImage = async (blob) => {
  const a = document.createElement('a');
  a.download = 'fugu-greeting.png';
  a.href = URL.createObjectURL(blob);
  a.addEventListener('click', (e) => {
    setTimeout(() => URL.revokeObjectURL(a.href), 30 * 1000);
  });
  a.click();
};

Pero espera un momento. Mentalmente, no “descargaste” una tarjeta de felicitación, sino que la “guardaste”. En lugar de mostrarte un diálogo de “guardar” que te permita elegir dónde colocar el archivo, el navegador descargó directamente la tarjeta de felicitación sin interacción del usuario y la colocó directamente en la carpeta Descargas. Esto no es lo mejor.

¿Y si hubiera una manera mejor? ¿Qué pasaría si pudieras abrir un archivo local, editarlo y, luego, guardar las modificaciones, ya sea en un archivo nuevo o en el archivo original que abriste inicialmente? Resulta que sí. La API de File System Access te permite abrir y crear archivos y directorios, así como modificarlos y guardarlos .

Entonces, ¿cómo detecto atributos de una API? La API de File System Access expone un nuevo método window.chooseFileSystemEntries(). Por lo tanto, debo cargar condicionalmente diferentes módulos de importación y exportación según si este método está disponible. A continuación, te muestro cómo hacerlo.

const loadImportAndExport = () => {
  if ('chooseFileSystemEntries' in window) {
    Promise.all([
      import('./import_image.mjs'),
      import('./export_image.mjs'),
    ]);
  } else {
    Promise.all([
      import('./import_image_legacy.mjs'),
      import('./export_image_legacy.mjs'),
    ]);
  }
};

Pero antes de analizar los detalles de la API de File System Access, permite que destaque rápidamente el patrón de mejora progresiva. En los navegadores que actualmente no admiten la API de File System Access, cargo las secuencias de comandos heredadas. Puedes ver las pestañas de red de Firefox y Safari a continuación.

Inspector web de Safari que muestra los archivos heredados que se cargan.
La pestaña de red del Inspector web de Safari.
Herramientas para desarrolladores de Firefox que muestran los archivos heredados que se cargan.
La pestaña de red de las herramientas para desarrolladores de Firefox.

Sin embargo, en Chrome, un navegador que admite la API, solo se cargan las secuencias de comandos nuevas. Esto es posible gracias a la import() dinámica, que todos los navegadores modernos admiten. Como dije antes, el césped está bastante verde en estos días.

Herramientas para desarrolladores de Chrome que muestran los archivos modernos que se cargan.
Pestaña de red de las Herramientas para desarrolladores de Chrome.

La API de File System Access

Ahora que lo mencioné, es hora de analizar la implementación real basada en la API de File System Access. Para importar una imagen, llamo a window.chooseFileSystemEntries() y le paso una propiedad accepts en la que digo que quiero archivos de imagen. Se admiten tanto las extensiones de archivo como los tipos MIME. Esto genera un identificador de archivo, desde el que puedo obtener el archivo real llamando a getFile().

const importImage = async () => {
  try {
    const handle = await window.chooseFileSystemEntries({
      accepts: [
        {
          description: 'Image files',
          mimeTypes: ['image/*'],
          extensions: ['jpg', 'jpeg', 'png', 'webp', 'svg'],
        },
      ],
    });
    return handle.getFile();
  } catch (err) {
    console.error(err.name, err.message);
  }
};

Exportar una imagen es casi lo mismo, pero esta vez necesito pasar un parámetro de tipo de 'save-file' al método chooseFileSystemEntries(). De esta manera, obtengo un diálogo de guardado de archivos. Con el archivo abierto, esto no era necesario, ya que 'open-file' es el valor predeterminado. Establecí el parámetro accepts de manera similar a antes, pero esta vez solo se limitó a las imágenes PNG. Una vez más, obtengo un identificador de archivo, pero en lugar de obtener el archivo, esta vez llamo a createWritable() para crear un flujo de escritura. A continuación, escribo el blob, que es la imagen de mi tarjeta de felicitación, en el archivo. Por último, cierro el flujo de escritura.

Siempre puede fallar todo: es posible que el disco no tenga espacio, que haya un error de lectura o escritura, o que el usuario simplemente cancele el diálogo del archivo. Por este motivo, siempre une las llamadas en una sentencia try...catch.

const exportImage = async (blob) => {
  try {
    const handle = await window.chooseFileSystemEntries({
      type: 'save-file',
      accepts: [
        {
          description: 'Image file',
          extensions: ['png'],
          mimeTypes: ['image/png'],
        },
      ],
    });
    const writable = await handle.createWritable();
    await writable.write(blob);
    await writable.close();
  } catch (err) {
    console.error(err.name, err.message);
  }
};

Con la mejora progresiva con la API de File System Access, puedo abrir un archivo como antes. El archivo importado se dibuja directamente en el lienzo. Puedo hacer mis ediciones y, por último, guardarlas con un cuadro de diálogo de guardado real, en el que puedo elegir el nombre y la ubicación de almacenamiento del archivo. Ahora el archivo está listo para conservarse para siempre.

App de Fugu Greetings con un diálogo de apertura de archivo.
El diálogo de apertura de archivos.
La app de Fugu Greetings ahora tiene una imagen importada.
La imagen importada.
App de Fugu Greetings con la imagen modificada.
Guarda la imagen modificada en un archivo nuevo.

Las APIs de Web Share y Web Share Target

Además de guardarla para siempre, tal vez quiera compartir mi tarjeta de felicitación. Esto es algo que me permiten hacer la API de Web Share y la API de Web Share Target. Los sistemas operativos para dispositivos móviles y, más recientemente, para computadoras de escritorio, han adquirido mecanismos de uso compartido integrados. Por ejemplo, a continuación, se muestra la hoja compartida de Safari para computadoras de escritorio en macOS activada desde un artículo en mi blog. Cuando haces clic en el botón Compartir artículo, puedes compartir un vínculo al artículo con un amigo, por ejemplo, a través de la app de Mensajes de macOS.

Hoja compartida de Safari para computadoras en macOS activada desde el botón Compartir de un artículo
La API de Web Share en Safari para computadoras de escritorio en macOS.

El código para que esto suceda es bastante sencillo. Llamo a navigator.share() y le paso un title, text y url opcionales en un objeto. Pero ¿qué sucede si quiero adjuntar una imagen? El nivel 1 de la API de Web Share aún no lo admite. La buena noticia es que el nivel 2 de uso compartido web agregó capacidades de uso compartido de archivos.

try {
  await navigator.share({
    title: 'Check out this article:',
    text: `"${document.title}" by @tomayac:`,
    url: document.querySelector('link[rel=canonical]').href,
  });
} catch (err) {
  console.warn(err.name, err.message);
}

Te mostraré cómo hacer que esto funcione con la aplicación de tarjetas de felicitación de Fugu. Primero, debo preparar un objeto data con un array files que consta de un blob y, luego, un title y un text. A continuación, como práctica recomendada, uso el nuevo método navigator.canShare(), que hace lo que su nombre sugiere: me indica si el navegador puede compartir técnicamente el objeto data que intento compartir. Si navigator.canShare() me dice que se pueden compartir los datos, ya puedo llamar a navigator.share() como antes. Como todo puede fallar, vuelvo a usar un bloque try...catch.

const share = async (title, text, blob) => {
  const data = {
    files: [
      new File([blob], 'fugu-greeting.png', {
        type: blob.type,
      }),
    ],
    title: title,
    text: text,
  };
  try {
    if (!(navigator.canShare(data))) {
      throw new Error("Can't share data.", data);
    }
    await navigator.share(data);
  } catch (err) {
    console.error(err.name, err.message);
  }
};

Como antes, uso la mejora progresiva. Si 'share' y 'canShare' existen en el objeto navigator, solo entonces sigo adelante y cargo share.mjs a través de import() dinámico. En navegadores como Safari para dispositivos móviles que solo cumplen una de las dos condiciones, no se carga la funcionalidad.

const loadShare = () => {
  if ('share' in navigator && 'canShare' in navigator) {
    import('./share.mjs');
  }
};

En Fugu Greetings, si presiono el botón Compartir en un navegador compatible, como Chrome en Android, se abre la hoja compartida integrada. Por ejemplo, puedo elegir Gmail, y aparecerá el widget del compilador de correos electrónicos con la imagen adjunta.

Hoja compartida a nivel del SO que muestra varias apps con las que se puede compartir la imagen.
Elige una app para compartir el archivo.
Widget de redacción de correo electrónico de Gmail con la imagen adjunta.
El archivo se adjunta a un correo electrónico nuevo en el editor de Gmail.

La API de Contact Picker

A continuación, quiero hablar sobre los contactos, es decir, la agenda de un dispositivo o la app de administrador de contactos. Cuando escribes una tarjeta de felicitación, es posible que no siempre sea fácil escribir correctamente el nombre de alguien. Por ejemplo, tengo un amigo llamado Sergey que prefiere que su nombre se escriba en letras cirílicas. Estoy usando un teclado QWERTZ alemán y no tengo idea de cómo escribir su nombre. Este es un problema que la API de Contact Picker puede resolver. Como tengo a mi amigo almacenado en la app de Contactos del teléfono, a través de la API de Contacts Picker, puedo acceder a mis contactos desde la Web.

Primero, debo especificar la lista de propiedades a las que quiero acceder. En este caso, solo quiero los nombres, pero para otros casos de uso, podría interesarme obtener números de teléfono, correos electrónicos, íconos de avatares o direcciones físicas. A continuación, configuro un objeto options y configuro multiple en true para poder seleccionar más de una entrada. Por último, puedo llamar a navigator.contacts.select(), que muestra las propiedades deseadas para los contactos que seleccionó el usuario.

const getContacts = async () => {
  const properties = ['name'];
  const options = { multiple: true };
  try {
    return await navigator.contacts.select(properties, options);
  } catch (err) {
    console.error(err.name, err.message);
  }
};

Y, a estas alturas, es probable que hayas aprendido el patrón: Solo cargo el archivo cuando la API es realmente compatible.

if ('contacts' in navigator) {
  import('./contacts.mjs');
}

En Fugu Greeting, cuando presiono el botón Contactos y selecciono a mis dos mejores amigos, Сергей Михайлович Брин y 劳伦斯·爱德华·"拉里"·佩奇, puedes ver cómo el selector de contactos se limita a mostrar solo sus nombres, pero no sus direcciones de correo electrónico ni otra información, como sus números de teléfono. Luego, sus nombres se dibujan en mi tarjeta de felicitación.

Selector de contactos que muestra los nombres de dos contactos en la libreta de direcciones.
Selección de dos nombres con el selector de contactos de la libreta de direcciones.
Los nombres de los dos contactos elegidos anteriormente dibujados en la tarjeta de felicitación.
Luego, los dos nombres se dibujan en la tarjeta de felicitación.

La API de Asynchronous Clipboard

A continuación, veremos cómo copiar y pegar. Una de nuestras operaciones favoritas como desarrolladores de software es copiar y pegar. Como autor de tarjetas de felicitación, a veces, me gustaría hacer lo mismo. Es posible que desee pegar una imagen en una tarjeta de felicitación en la que esté trabajando o copiarla para poder seguir editándola desde otro lugar. La API de Async Clipboard admite texto e imágenes. Te explicaré cómo agregué la compatibilidad con copiar y pegar a la app de Fugu Greetings.

Para copiar algo en el portapapeles del sistema, debo escribir en él. El método navigator.clipboard.write() toma un array de elementos del portapapeles como parámetro. Cada elemento del portapapeles es, en esencia, un objeto con un blob como valor y el tipo del blob como clave.

const copy = async (blob) => {
  try {
    await navigator.clipboard.write([
      new ClipboardItem({
        [blob.type]: blob,
      }),
    ]);
  } catch (err) {
    console.error(err.name, err.message);
  }
};

Para pegar, debo recorrer los elementos del portapapeles que obtengo llamando a navigator.clipboard.read(). El motivo es que es posible que haya varios elementos del portapapeles en el portapapeles en representaciones diferentes. Cada elemento del portapapeles tiene un campo types que me indica los tipos de MIME de los recursos disponibles. Llamo al método getType() del elemento del portapapeles y paso el tipo de MIME que obtuve antes.

const paste = async () => {
  try {
    const clipboardItems = await navigator.clipboard.read();
    for (const clipboardItem of clipboardItems) {
      try {
        for (const type of clipboardItem.types) {
          const blob = await clipboardItem.getType(type);
          return blob;
        }
      } catch (err) {
        console.error(err.name, err.message);
      }
    }
  } catch (err) {
    console.error(err.name, err.message);
  }
};

Y ya casi no hace falta decirlo. Solo lo hago en navegadores compatibles.

if ('clipboard' in navigator && 'write' in navigator.clipboard) {
  import('./clipboard.mjs');
}

¿Cómo funciona esto en la práctica? Tengo una imagen abierta en la app de Vista previa de macOS y la copio en el portapapeles. Cuando hago clic en Paste, la app de Fugu Greetings me pregunta si quiero permitir que vea el texto y las imágenes del portapapeles.

App de Fugu Greetings que muestra el mensaje de permiso del portapapeles.
El mensaje de permiso del portapapeles.

Por último, después de aceptar el permiso, la imagen se pega en la aplicación. También funciona al revés. Permíteme copiar una tarjeta de felicitación en el portapapeles. Cuando abro Vista previa y hago clic en Archivo y, luego, en Nuevo desde el portapapeles, la tarjeta de felicitación se pega en una nueva imagen sin título.

La app de Vista previa de macOS con una imagen sin título que se acaba de pegar.
Una imagen pegada en la app de Vista previa de macOS.

La API de Badging

Otra API útil es la API de Badging. Como PWA instalable, Fugu Greetings, por supuesto, tiene un ícono de app que los usuarios pueden colocar en la estación de carga de la app o en la pantalla principal. Una forma divertida y fácil de demostrar la API es (abusar) de ella en Fugu Greetings como un contador de trazos de pluma. Agregué un objeto de escucha de eventos que incrementa el contador de trazos de la pluma cada vez que ocurre el evento pointerdown y, luego, establece la insignia del ícono actualizada. Cada vez que se borra el lienzo, se restablece el contador y se quita la insignia.

let strokes = 0;

canvas.addEventListener('pointerdown', () => {
  navigator.setAppBadge(++strokes);
});

clearButton.addEventListener('click', () => {
  strokes = 0;
  navigator.setAppBadge(strokes);
});

Esta función es una mejora progresiva, por lo que la lógica de carga es la habitual.

if ('setAppBadge' in navigator) {
  import('./badge.mjs');
}

En este ejemplo, dibujé los números del uno al siete con un trazo de pluma por número. El contador de insignias en el ícono ahora es siete.

Los números del uno al siete dibujados en la tarjeta de felicitación, cada uno con un solo trazo de pluma.
Dibujar los números del 1 al 7 con siete trazos de pluma.
Ícono de insignia en la app de Fugu Greetings que muestra el número 7.
El contador de trazos de la pluma en forma de insignia del ícono de la app.

La API de Periodic Background Sync

¿Quieres comenzar cada día con algo nuevo? Una función interesante de la app de Fugu Greetings es que puede inspirarte cada mañana con una nueva imagen de fondo para comenzar tu tarjeta de felicitación. La app usa la API de Periodic Background Sync para lograrlo.

El primer paso es register un evento de sincronización periódico en el registro del service worker. Escucha una etiqueta de sincronización llamada 'image-of-the-day' y tiene un intervalo mínimo de un día para que el usuario pueda obtener una nueva imagen de fondo cada 24 horas.

const registerPeriodicBackgroundSync = async () => {
  const registration = await navigator.serviceWorker.ready;
  try {
    registration.periodicSync.register('image-of-the-day-sync', {
      // An interval of one day.
      minInterval: 24 * 60 * 60 * 1000,
    });
  } catch (err) {
    console.error(err.name, err.message);
  }
};

El segundo paso es escuchar el evento periodicsync en el service worker. Si la etiqueta del evento es 'image-of-the-day', es decir, la que se registró antes, la imagen del día se recupera a través de la función getImageOfTheDay() y el resultado se propaga a todos los clientes para que puedan actualizar sus lienzos y cachés.

self.addEventListener('periodicsync', (syncEvent) => {
  if (syncEvent.tag === 'image-of-the-day-sync') {
    syncEvent.waitUntil(
      (async () => {
        const blob = await getImageOfTheDay();
        const clients = await self.clients.matchAll();
        clients.forEach((client) => {
          client.postMessage({
            image: blob,
          });
        });
      })()
    );
  }
});

Una vez más, esta es una mejora progresiva, por lo que el código solo se carga cuando el navegador es compatible con la API. Esto se aplica tanto al código del cliente como al del trabajador de servicio. En los navegadores que no son compatibles, no se carga ninguno de ellos. Ten en cuenta que, en el service worker, en lugar de un import() dinámico (que aún no se admite en un contexto de service worker ), uso el importScripts() clásico.

// In the client:
const registration = await navigator.serviceWorker.ready;
if (registration && 'periodicSync' in registration) {
  import('./periodic_background_sync.mjs');
}
// In the service worker:
if ('periodicSync' in self.registration) {
  importScripts('./image_of_the_day.mjs');
}

En Fugu Greetings, presionar el botón Wallpaper muestra la imagen de la tarjeta de felicitación del día que se actualiza todos los días a través de la API de Periodic Background Sync.

App de Fugu Greetings con una nueva imagen de tarjeta de felicitación del día.
Si presionas el botón Fondo de pantalla, se mostrará la imagen del día.

API de Notification Triggers

A veces, incluso con mucha inspiración, necesitas un empujón para terminar una tarjeta de felicitación que empezaste. Esta es una función que habilita la API de Notification Triggers. Como usuario, puedo ingresar un horario en el que quiero que se me recuerde que debo terminar mi tarjeta de felicitación. Cuando llegue ese momento, recibiré una notificación de que mi tarjeta de felicitación está esperando.

Después de solicitar la hora objetivo, la aplicación programa la notificación con un showTrigger. Puede ser un TimestampTrigger con la fecha objetivo seleccionada anteriormente. La notificación del recordatorio se activará de forma local, no se necesita la red ni el servidor.

const targetDate = promptTargetDate();
if (targetDate) {
  const registration = await navigator.serviceWorker.ready;
  registration.showNotification('Reminder', {
    tag: 'reminder',
    body: "It's time to finish your greeting card!",
    showTrigger: new TimestampTrigger(targetDate),
  });
}

Al igual que con todo lo que mostré hasta ahora, esta es una mejora progresiva, por lo que el código solo se carga de forma condicional.

if ('Notification' in window && 'showTrigger' in Notification.prototype) {
  import('./notification_triggers.mjs');
}

Cuando marco la casilla de verificación Recordatorio en Fugu Greetings, aparece un mensaje que me pregunta cuándo quiero que se me recuerde que debo terminar mi tarjeta de felicitación.

App de Fugu Greetings con un mensaje que le pregunta al usuario cuándo quiere que se le recuerde que termine su tarjeta de felicitación.
Programa una notificación local para que te recuerde terminar una tarjeta de felicitación.

Cuando se activa una notificación programada en Fugu Greetings, se muestra como cualquier otra notificación, pero, como escribí antes, no requiere una conexión de red.

Centro de notificaciones de macOS que muestra una notificación activada de Fugu Greetings.
La notificación activada aparece en el Centro de notificaciones de macOS.

La API de Wake Lock

También quiero incluir la API de Wake Lock. A veces, solo debes mirar la pantalla el tiempo suficiente hasta que la inspiración te bese. Lo peor que puede suceder es que la pantalla se apague. La API de Wake Lock puede evitar que esto suceda.

El primer paso es obtener un bloqueo de activación con navigator.wakelock.request method(). Le paso la cadena 'screen' para obtener un bloqueo de activación de pantalla. Luego, agrego un objeto de escucha de eventos para recibir información cuando se libere el bloqueo de activación. Esto puede ocurrir, por ejemplo, cuando cambia la visibilidad de la pestaña. Si esto sucede, cuando la pestaña vuelva a estar visible, podré volver a obtener el bloqueo de activación.

let wakeLock = null;
const requestWakeLock = async () => {
  wakeLock = await navigator.wakeLock.request('screen');
  wakeLock.addEventListener('release', () => {
    console.log('Wake Lock was released');
  });
  console.log('Wake Lock is active');
};

const handleVisibilityChange = () => {
  if (wakeLock !== null && document.visibilityState === 'visible') {
    requestWakeLock();
  }
};

document.addEventListener('visibilitychange', handleVisibilityChange);
document.addEventListener('fullscreenchange', handleVisibilityChange);

Sí, esta es una mejora progresiva, por lo que solo necesito cargarla cuando el navegador sea compatible con la API.

if ('wakeLock' in navigator && 'request' in navigator.wakeLock) {
  import('./wake_lock.mjs');
}

En Fugu Greetings, hay una casilla de verificación Insomnia que, cuando está marcada, mantiene la pantalla activa.

Si se marca la casilla de verificación Insomnio, la pantalla permanecerá activa.
La casilla de verificación Insomnia mantiene activa la app.

La API de Idle Detection

A veces, incluso si miras la pantalla durante horas, es inútil y no se te ocurre ni la más mínima idea de qué hacer con la tarjeta de felicitación. La API de Idle Detection permite que la app detecte el tiempo inactivo del usuario. Si el usuario está inactivo durante demasiado tiempo, la app se restablece al estado inicial y borra el lienzo. Actualmente, esta API está protegida por el permiso de notificaciones, ya que muchos casos de uso de producción de la detección de inactividad están relacionados con las notificaciones, por ejemplo, para enviar solo una notificación a un dispositivo que el usuario está usando de forma activa.

Después de asegurarme de que se otorgue el permiso de notificaciones, creo una instancia del detector de inactividad. Registro un objeto de escucha de eventos que detecta cambios de inactividad, lo que incluye al usuario y el estado de la pantalla. El usuario puede estar activo o inactivo, y la pantalla puede estar desbloqueada o bloqueada. Si el usuario está inactivo, se borra el lienzo. Le doy al detector de inactividad un umbral de 60 segundos.

const idleDetector = new IdleDetector();
idleDetector.addEventListener('change', () => {
  const userState = idleDetector.userState;
  const screenState = idleDetector.screenState;
  console.log(`Idle change: ${userState}, ${screenState}.`);
  if (userState === 'idle') {
    clearCanvas();
  }
});

await idleDetector.start({
  threshold: 60000,
  signal,
});

Y, como siempre, solo cargo este código cuando el navegador lo admite.

if ('IdleDetector' in window) {
  import('./idle_detection.mjs');
}

En la app de Fugu Greetings, el lienzo se borra cuando se marca la casilla de verificación Efímero y el usuario está inactivo durante demasiado tiempo.

App de Fugu Greetings con un lienzo despejado después de que el usuario estuvo inactivo durante demasiado tiempo.
Cuando se marca la casilla de verificación Efímero y el usuario estuvo inactivo durante demasiado tiempo, se borra el lienzo.

Closing

¡Uf, qué viaje! Tantas APIs en una sola app de ejemplo. Y, recuerda, nunca hago que el usuario pague el costo de descarga por una función que su navegador no admite. Cuando uso la mejora progresiva, me aseguro de que solo se cargue el código relevante. Y, como con HTTP/2, las solicitudes son económicas, este patrón debería funcionar bien para muchas aplicaciones, aunque te recomendamos que consideres un empaquetador para apps realmente grandes.

Panel Network de Chrome DevTools que muestra solo las solicitudes de archivos con código que admite el navegador actual.
La pestaña Red de las Herramientas para desarrolladores de Chrome muestra solo las solicitudes de archivos con código que admite el navegador actual.

Es posible que la app se vea un poco diferente en cada navegador, ya que no todas las plataformas admiten todas las funciones, pero la funcionalidad principal siempre está presente y se mejora de forma progresiva según las capacidades del navegador en particular. Ten en cuenta que estas funciones pueden cambiar incluso en un mismo navegador, según si la app se ejecuta como una app instalada o en una pestaña del navegador.

Fugu Greetings se ejecuta en Chrome para Android y muestra muchas funciones disponibles.
Fugu Greetings en ejecución en Chrome para Android.
Fugu Greetings se ejecuta en Safari para computadoras de escritorio y muestra menos funciones disponibles.
Fugu Greetings ejecutándose en Safari para computadoras.
Fugu Greetings se ejecuta en Chrome para computadoras de escritorio y muestra muchas funciones disponibles.
Fugu Greetings ejecutándose en Chrome para computadoras.

Si te interesa la app de Fugu Greetings, búscala y crea una bifurcación en GitHub.

Repositorio de Fugu Greetings en GitHub.
App de Fugu Greetings en GitHub.

El equipo de Chromium está trabajando arduamente para mejorar las APIs avanzadas de Fugu. Cuando aplico la mejora progresiva en el desarrollo de mi app, me aseguro de que todos tengan una experiencia de referencia buena y sólida, pero que las personas que usan navegadores que admiten más APIs de la plataforma web tengan una experiencia aún mejor. Espero ver lo que haces con la mejora progresiva en tus apps.

Agradecimientos

Agradezco a Christian Liebel y a Hemanth HM, quienes contribuyeron a Fugu Greetings. Joe Medley y Kayce Basques revisaron este artículo. Jake Archibald me ayudó a descubrir la situación con import() dinámico en un contexto de servicio trabajador.