↑↓ para moverte · Enter para abrir · Esc para cerrar

    Todas las entradas
    Artículo, · 6 min de lectura

    Nativa, sin app store

    Hacer que RedCheck se sienta como una app móvil nativa siendo una PWA: gestos, hojas inferiores y los bugs que solo aparecen en un móvil de verdad.

    Most people open a productivity app on their phone, many times a day, for a few seconds at a time. Until September, RedCheck was a desktop app that technically loaded on a phone: the calendar alone had a hard-coded minimum width of 700 pixels.

    This post covers the work to make it feel like an app you’d install rather than a website you’d tolerate, without writing a native app or going through an app store.

    Step one: actually responsive

    Below 640 px, the dashboard used to try to fit a sidebar, a calendar, a task list and a stats panel side by side. It now works like a phone app:

    • The sidebar becomes a drawer, opened with a hamburger button or an edge swipe and closed with a swipe back.
    • The three main panels (calendar, tasks, progress) are shown one at a time, switched from a bottom tab bar.
    • Every layout above 640 px stayed exactly as it was. Mobile got its own layout, desktop wasn’t redesigned around it.

    A small but important detail: the app height uses 100dvh instead of 100vh. On mobile browsers, 100vh ignores the address bar collapsing and expanding, which pushed the bottom tab bar off screen.

    Four phone screenshots of RedCheck in dark mode: the task list with a bottom tab bar, the sidebar drawer, Settings as a bottom sheet with a drag handle, and the month calendar.
    Tasks, the drawer, a bottom sheet and the calendar on a phone.

    Why a PWA and not a native app

    The request that started this was simple: “can we hide the browser’s address bar?”. A web page can’t, on purpose. If a script could hide the real URL bar, a phishing page could draw a fake one.

    The legitimate way to get there is to make the app installable. RedCheck now ships a web app manifest, so “Add to Home Screen” gives you an icon that launches it full screen, with no browser chrome. On top of that:

    • Shortcuts on the home-screen icon (long-press: Today, Calendar, Settings).
    • An icon badge with the number of pending tasks, using the Badging API where the platform supports it.
    • A status bar that follows the light or dark theme.
    • Safe-area insets, so nothing hides under the notch or the gesture bar.

    The trade-off versus a native app is reach versus capability. A PWA is one codebase, deployed instantly, with no review process. In exchange, some things are harder, push notifications being the obvious one. More on that below.

    Gestures, and getting them to feel right

    Native apps feel native mostly because of gestures, so RedCheck now has them:

    • Long-press a task or subject to enter selection mode, then tap to add more, like a photo gallery. Deselecting the last one exits the mode.
    • Swipe a task right to complete it, left to delete it.
    • Every modal is a bottom sheet on mobile, dismissed by dragging its handle down. The exception is the command palette: a bottom sheet fights the on-screen keyboard, so it stays centred.
    • Pull to refresh on the task list, because an installed PWA has no browser reload gesture at all.

    Each of these took more iterations than I expected:

    • The first swipe-to-dismiss stored the sheet’s position in React state. Every touchmove triggered a full re-render, and on a heavy modal like Settings it visibly stuttered. The fix was to bypass React for the high-frequency part: move the sheet by writing its transform directly through a ref, batched with requestAnimationFrame, and only go back to React when the gesture ends.
    • Dragging a sheet down also triggered Chrome’s own pull-to-refresh and reloaded the app. touch-action: none on the handle wasn’t enough, because pull-to-refresh is a document-level gesture. overscroll-behavior-y: none plus locking the page scroll while a modal is open finally fixed it.
    • A long-press on a task’s text raced the browser’s native text selection, and often lost. Disabling text selection on those rows, only on touch devices, fixed it.
    • React registers touch listeners as passive, so preventDefault() in an onTouchMove prop silently does nothing. Gestures that need to take over scrolling attach a real non-passive listener instead.

    I also learned to gate touch-specific behaviour on capability, not screen width. A narrow desktop window still has a mouse; a large tablet still has no hover. Media queries like (hover: none) describe the input device, which is what actually matters.

    The bugs only a real phone shows

    Some bugs simply don’t exist in desktop dev tools.

    Vibration. Completing a task triggers a small haptic tick. It worked on desktop emulation and did nothing on a real Android phone. The cause: the Vibration API requires an active user gesture, and that gesture context doesn’t survive an await. The vibration was being called after the API request resolved. Moving it to the first line of the click handler fixed that. It still didn’t buzz on one real phone, and from the outside two causes look identical: the browser rejecting the call, or accepting a 15 ms pulse too short for the motor to respond. So the pulse went up to 40 ms, and instead of guessing again, Settings got a “Test vibration” button that reports whether the browser accepted or rejected the call.

    The colour-blind filter. I added colour-blindness filters using SVG colour matrices referenced from CSS. They shipped to production twice with no visible effect at all. I never fully pinned down why (most likely how browsers resolve SVG filter references on the root element), and replaced them with plain CSS filter functions. That’s not a satisfying answer, but it is a working one, and I’d rather say that than pretend otherwise.

    Font size. The accessibility font-size setting changed the root font size. Since almost every Tailwind spacing utility is also in rem, it scaled padding, icons and card widths along with the text. It now scales only the text-size tokens through a CSS variable.

    Push notifications: the honest version

    Task reminders exist, but they’re client-side: while RedCheck is open in a tab or as an installed app, it checks every minute for tasks due in the next 30 minutes and shows a notification. Real push notifications, which reach you when the app is closed, need a service worker, VAPID keys and a backend that triggers them. That’s a proper project of its own, so I shipped the lighter version and labelled it as such instead of pretending otherwise.

    The rest of the polish

    The same push brought a lot of smaller things, most of them on desktop too: a command palette (Ctrl/Cmd+K), toasts instead of alert(), an “Undo” toast on delete (which was cheap to build because every delete already goes to the recycle bin from part 3), skeleton loading states, a first-run tour, focus trapping in modals, prefers-reduced-motion support, and personalised checkbox styles. Removing framer-motion, which was used in exactly one place and whose exit animations never actually ran, saved about 120 KB.

    Not everything landed. One batch of spacing and size tweaks to the login screen looked worse on a real phone and was reverted the same day.

    The bug that taught me the most

    A CSS comment containing animate-*/transition-* closed itself early at the */, leaving the rest of the sentence to be parsed as CSS. The dev server, the linter, the type checker and the tests all passed. Only the production build failed. Every deploy after that commit failed silently inside the Docker build, while I thought everything was shipping.

    Since then, npm run build is part of the checklist before every push, not something I trust the pipeline to tell me about.

    What I took away

    • A PWA gets you most of the way to “native” for a fraction of the cost. Know which parts it can’t do.
    • Gestures need to bypass React for the frames that matter.
    • Gate behaviour on input capability, not screen width.
    • Test on a real device. Emulators don’t vibrate.
    • If the production build isn’t in your local checklist, you don’t know whether you’re shipping.

    Open redcheckapp.com on your phone, install it to your home screen, and try to break it.

    La mayoría de la gente abre una app de productividad en el móvil, muchas veces al día y unos pocos segundos cada vez. Hasta septiembre, RedCheck era una app de escritorio que técnicamente cargaba en un móvil: solo el calendario tenía un ancho mínimo fijo de 700 píxeles.

    Este post cuenta el trabajo para que se sienta como una app que instalarías, no como una web que toleras, sin escribir una app nativa ni pasar por una tienda de aplicaciones.

    Paso uno: que sea responsive de verdad

    Por debajo de 640 px, el dashboard intentaba meter una barra lateral, un calendario, una lista de tareas y un panel de estadísticas uno al lado del otro. Ahora funciona como una app móvil:

    • La barra lateral se convierte en un cajón que se abre con un botón de menú o deslizando desde el borde, y se cierra deslizando de vuelta.
    • Los tres paneles principales (calendario, tareas y progreso) se muestran de uno en uno, y se cambia entre ellos con una barra de pestañas inferior.
    • Todas las vistas por encima de 640 px se quedaron exactamente igual. El móvil recibió su propio diseño; el de escritorio no se rehízo alrededor de él.

    Un detalle pequeño pero importante: la altura de la app usa 100dvh en lugar de 100vh. En los navegadores móviles, 100vh ignora que la barra de direcciones se esconde y reaparece, y eso dejaba la barra de pestañas inferior fuera de la pantalla.

    Cuatro capturas de RedCheck en un móvil en modo oscuro: la lista de tareas con la barra de pestañas inferior, el cajón lateral, Ajustes como hoja inferior con asa y el calendario mensual.
    Tareas, el cajón lateral, una hoja inferior y el calendario en un móvil.

    Por qué una PWA y no una app nativa

    La petición que lo empezó todo era simple: “¿podemos ocultar la barra de direcciones del navegador?”. Una página web no puede, a propósito. Si un script pudiera ocultar la barra de direcciones real, una página de phishing podría dibujar una falsa.

    La forma legítima de conseguirlo es hacer que la app sea instalable. RedCheck incluye ahora un manifiesto de aplicación web, así que “Añadir a pantalla de inicio” te da un icono que la abre a pantalla completa, sin nada del navegador. Además:

    • Accesos directos en el icono de la pantalla de inicio (pulsación larga: Hoy, Calendario, Ajustes).
    • Un globo en el icono con el número de tareas pendientes, usando la Badging API donde la plataforma la soporta.
    • Una barra de estado que sigue el tema claro u oscuro.
    • Márgenes de zona segura, para que nada quede debajo del notch o de la barra de gestos.

    El compromiso frente a una app nativa es alcance frente a capacidad. Una PWA es un único código, se despliega al instante y no pasa por ninguna revisión. A cambio, algunas cosas son más difíciles, y las notificaciones push son la más evidente. Más sobre eso abajo.

    Gestos, y conseguir que se sientan bien

    Las apps nativas se sienten nativas sobre todo por los gestos, así que RedCheck ya los tiene:

    • Pulsación larga sobre una tarea o asignatura para entrar en modo selección, y luego tocar para añadir más, como en una galería de fotos. Al deseleccionar la última, se sale del modo.
    • Deslizar una tarea a la derecha para completarla y a la izquierda para borrarla.
    • Todos los modales son hojas inferiores en móvil, y se cierran arrastrando su asa hacia abajo. La excepción es la paleta de comandos: una hoja inferior choca con el teclado en pantalla, así que se queda centrada.
    • Deslizar para recargar en la lista de tareas, porque una PWA instalada no tiene el gesto de recarga del navegador.

    Cada uno necesitó más iteraciones de las que esperaba:

    • La primera versión de arrastrar para cerrar guardaba la posición de la hoja en el estado de React. Cada touchmove provocaba un renderizado completo, y en un modal pesado como Ajustes se notaban tirones. La solución fue saltarse React en la parte de alta frecuencia: mover la hoja escribiendo su transform directamente a través de una ref, agrupado con requestAnimationFrame, y volver a React solo cuando termina el gesto.
    • Arrastrar una hoja hacia abajo también activaba el “deslizar para recargar” de Chrome y recargaba la app. touch-action: none en el asa no bastaba, porque ese gesto es a nivel de documento. overscroll-behavior-y: none más bloquear el scroll de la página mientras hay un modal abierto lo arregló por fin.
    • Una pulsación larga sobre el texto de una tarea competía con la selección de texto nativa del navegador, y a menudo perdía. Desactivar la selección de texto en esas filas, solo en dispositivos táctiles, lo arregló.
    • React registra los listeners táctiles como pasivos, así que preventDefault() dentro de una prop onTouchMove no hace nada en silencio. Los gestos que necesitan quedarse con el scroll registran un listener no pasivo de verdad.

    También aprendí a condicionar el comportamiento táctil a la capacidad del dispositivo, no al ancho de pantalla. Una ventana estrecha de escritorio sigue teniendo ratón; una tablet grande sigue sin hover. Las media queries como (hover: none) describen el dispositivo de entrada, que es lo que importa de verdad.

    Los bugs que solo enseña un móvil de verdad

    Algunos bugs simplemente no existen en las herramientas de desarrollo del escritorio.

    La vibración. Completar una tarea produce una pequeña vibración. Funcionaba en la emulación de escritorio y no hacía nada en un móvil Android real. La causa: la Vibration API exige un gesto activo del usuario, y ese contexto no sobrevive a un await. La vibración se lanzaba después de que respondiera la petición a la API. Moverla a la primera línea del manejador del clic lo arregló. Aun así, en un móvil real seguía sin vibrar, y desde fuera dos causas se ven idénticas: que el navegador rechace la llamada o que acepte un pulso de 15 ms demasiado corto para que el motor responda. Así que el pulso subió a 40 ms y, en lugar de volver a adivinar, Ajustes tiene ahora un botón de “Probar vibración” que indica si el navegador aceptó o rechazó la llamada.

    El filtro para daltonismo. Añadí filtros para distintos tipos de daltonismo usando matrices de color SVG referenciadas desde CSS. Llegaron a producción dos veces sin ningún efecto visible. Nunca di del todo con el porqué (lo más probable, cómo resuelven los navegadores las referencias a filtros SVG en el elemento raíz), y los sustituí por funciones de filtro CSS normales. No es una respuesta satisfactoria, pero es una que funciona, y prefiero decirlo así que fingir lo contrario.

    El tamaño de letra. El ajuste de accesibilidad de tamaño de letra cambiaba el tamaño de fuente raíz. Como casi todas las utilidades de espaciado de Tailwind también van en rem, escalaba márgenes, iconos y anchos de tarjeta junto con el texto. Ahora solo escala los tokens de tamaño de texto mediante una variable CSS.

    Notificaciones push: la versión honesta

    Los recordatorios de tareas existen, pero en el cliente: mientras RedCheck está abierta en una pestaña o como app instalada, comprueba cada minuto si hay tareas que vencen en los próximos 30 minutos y muestra una notificación. Las notificaciones push de verdad, las que te llegan con la app cerrada, necesitan un service worker, claves VAPID y un backend que las dispare. Eso es un proyecto en sí mismo, así que publiqué la versión ligera y la llamé por su nombre en lugar de fingir otra cosa.

    El resto del pulido

    La misma tanda trajo muchas cosas más pequeñas, la mayoría también en escritorio: una paleta de comandos (Ctrl/Cmd+K), toasts en lugar de alert(), un toast de “Deshacer” al borrar (que fue barato de construir porque todo borrado ya va a la papelera de la parte 3), estados de carga con esqueleto, un tour inicial, foco atrapado dentro de los modales, soporte para prefers-reduced-motion y casillas personalizables. Quitar framer-motion, que se usaba en un único sitio y cuyas animaciones de salida en realidad nunca se ejecutaban, ahorró unos 120 KB.

    No todo salió bien. Una tanda de ajustes de espaciado y tamaño en la pantalla de login quedaba peor en un móvil real y se revirtió el mismo día.

    El bug que más me enseñó

    Un comentario CSS que contenía animate-*/transition-* se cerraba antes de tiempo en el */, y el resto de la frase se interpretaba como CSS. El servidor de desarrollo, el linter, el comprobador de tipos y los tests pasaban. Solo fallaba el build de producción. Cada despliegue posterior a ese commit falló en silencio dentro del build de Docker, mientras yo creía que todo se estaba publicando.

    Desde entonces, npm run build forma parte de la lista antes de cada push, no es algo que confíe en que me avise el pipeline.

    Con qué me quedo

    • Una PWA te lleva casi hasta lo “nativo” por una fracción del coste. Ten claro qué partes no puede hacer.
    • Los gestos tienen que saltarse React en los fotogramas que importan.
    • Condiciona el comportamiento a la capacidad de entrada, no al ancho de pantalla.
    • Prueba en un dispositivo real. Los emuladores no vibran.
    • Si el build de producción no está en tu lista local, no sabes si estás publicando.

    Abre redcheckapp.com en el móvil, instálala en la pantalla de inicio e intenta romperla.

    • Frontend