Saltar al contenido
Development

Construyendo Greenhouse: las primeras pantallas

Por Victor Da Luz
rusttaurisveltedev-loggreenhouse

Construyendo el último paso de un wizard que no existía

Me senté a construir el paso final del wizard de onboarding de Greenhouse: capturar un primer lote de ideas para que el ciclo diario tenga algo con qué trabajar. Abrí el issue, leí la especificación, y fui a buscar el wizard donde meter el paso.

No había wizard. Todo el frontend era un solo archivo de Svelte de prueba humo que demostraba que el ida y vuelta de IPC con Rust funcionaba. Sin router, sin pasos, sin comando de captura. El issue describía “el paso final,” pero sus tres hermanos mayores (el recorrido de las reglas, la configuración de la carpeta, el armazón del wizard en sí) todavía estaban en el backlog. Estaba a punto de construir el techo de una casa sin paredes.

Así que me detuve y giré hacia el armazón. Ese reordenamiento es todo el punto de esta entrada: lo más útil que hice en este issue fue no construir lo que pedía el ticket.

Un armazón se trata de las costuras, no de las pantallas. El trabajo del armazón es hacer que los próximos issues sean aburridos. Si las costuras quedan bien, cada paso posterior se vuelve “abrir este archivo, llenar el cuerpo” en vez de “repensar todo el flujo.” Así que gasté el presupuesto de diseño en el contrato, no en los píxeles.

Cada paso es un registro de datos más un componente de Svelte:

export const ONBOARDING_STEPS = [
  { id: "welcome", title: "Welcome to Greenhouse", component: Welcome },
  { id: "rules",   title: "The rules, and why",    component: RulesStep },
  { id: "folder",  title: "Choose your vault",      component: FolderStep },
  { id: "seed",    title: "Plant your first seeds", component: SeedStep },
  { id: "done",    title: "You're set",             component: Done },
];

Los tres pasos del medio son stubs por ahora, pero son componentes reales conectados a la navegación. Cada uno muestra una nota punteada de “viene en un issue posterior.” El flujo funciona de punta a punta hoy; los issues posteriores solo cambian un archivo. Folder va antes de seed a propósito, porque no se pueden capturar ideas en un vault que todavía no existe.

Dos cosas de Svelte 5 me mordieron. Renderizar un componente desde ese registro no fue lo primero que probé: <svelte:component> está obsoleto en Svelte 5. El reemplazo es guardar el componente en una variable con mayúscula inicial y usarla como etiqueta:

let StepComponent = $derived(step.component);
// ...
{#key current}
  <StepComponent {setReady} />
{/key}

El {#key} vuelve a montar el paso en cada cambio, así que su estado y sus efectos se vuelven a ejecutar limpiamente.

El segundo me costó más rascarme la cabeza. Tipé cada entrada del registro como Component<StepProps> para que el contenedor le pueda pasar a cada paso un callback setReady que controla el botón Next. Pero un paso que ignora las props (la intro, la salida) infiere un tipo de prop {}, y svelte-check lo rechazó: pasar setReady a un componente que no declara props es un error. La corrección es un modismo diminuto que parece un error de tipeo:

let {}: StepProps = $props();  // accept the contract, bind nothing

Desestructuración vacía, anotación de tipo presente. El componente ahora acepta las props que pretende ignorar, y no queda ninguna variable sin usar con la que tropezar.

Una más: controlo el “se puede avanzar” reiniciando la disponibilidad de forma síncrona en el manejador de Next antes de subir el índice del paso, no en un efecto ligado al índice. Si se hace en un efecto, el setReady(false) del momento de montaje de un paso restrictivo compite con el reinicio y a veces pierde. Reiniciar en el manejador hace que el orden sea determinístico.

Funcionó. También se veía como una app de teléfono que alguien arrastró a un escritorio. Puse el build en verde, la lancé, y la entregué para que le echaran un vistazo. El veredicto: “técnicamente funciona, un poco pelado, parece una app de teléfono portada.” La captura lo dejó obvio. Una columna angosta de 34rem de texto flotando justo en el centro de una ventana ancha de escritorio, océanos de blanco a ambos lados. Todos los chequeos headless que tenía pasaron, y ninguno pudo ver eso.

Esa es la trampa de la verificación de frontend: svelte-check, el build de producción, y un log de proceso limpio dicen que el código es correcto, no que se ve como una app de escritorio. El render necesita ojos.

La corrección fueron primitivas de layout en vez de una columna centrada: un fondo a todo el viewport con un gradiente verde suave que llena la ventana, y una sola tarjeta acotada centrada en él, vertical y horizontalmente. La tarjeta del wizard obtuvo una altura mínima estable para que avanzar de paso no haga saltar todo. Mismo tratamiento en el dashboard y en las vistas de carga y error, para que la app se lea como una sola pieza.

Y la accesibilidad, mientras la estructura estaba fresca, más barato hacerla ahora que retroadaptarla después en cuatro issues de pasos distintos. El título del paso es el h1 de la página dentro de un landmark <main>, no un h2 huérfano sin encabezado arriba. El foco se mueve al encabezado del paso en cada cambio para que las personas que usan teclado o lector de pantalla lleguen al contenido nuevo. El contador de progreso es un progressbar real con los valores aria. Y detecté un fallo de contraste en el texto tenue de ayuda, por debajo de la línea 4.5:1, así que oscurecí ese token hasta que pasó AA.

Cuando una idea no es una casilla de texto

Estaba terminando la última pantalla del wizard de onboarding: la parte que pide capturar un primer lote de ideas antes de que la app deje trabajar con libertad. La tenía funcionando. Un campo de texto, un botón “Add,” un pequeño contador “3 de 5 capturadas” que activaba el botón Next al llegar a la cuota. Pruebas en verde, tipos limpios. Escribí el backend, el IPC, el paso de Svelte, y un comentario prolijo en el issue diciendo que estaba listo, a la espera de un recorrido con clics.

Después lo miré con el otro sombrero puesto, el que es dueño del producto, y la respuesta fue: esto está mal. Arrancarlo.

Dos cosas que una casilla de texto rompe en silencio. Greenhouse es un gestor de proceso creativo. Una idea ahí no es una línea de texto. Es un boceto: una melodía tarareada, una imagen tosca, una nota garabateada. Todo el punto es capturar la chispa en cualquier forma en la que aparezca. Un campo de una sola línea redefine “idea” hacia abajo, a “tarea pendiente,” sin que nadie lo haya decidido. El modelo de datos se filtra hacia el concepto.

El segundo problema es peor. Capturar ideas es el ciclo diario central de la app, no un trámite de configuración. Poner una versión reducida y falsa de eso dentro del wizard enseña lo incorrecto desde la primerísima corrida. Se aprendería que capturar es una casilla que se llena para pasar una barrera, que es lo opuesto de para qué sirve la app.

Así que la corrección no fue “hacer que el campo se viera mejor.” Fue “este paso no debería existir acá.”

Lo que en realidad costó el giro: menos de lo esperado, porque el trabajo ya estaba dividido a lo largo de una costura sin haberlo planeado realmente. El backend era una pequeña función pura, capture_idea, que inserta una idea y arranca su temporizador de maduración. Esa parte es real y reutilizable sin importar cómo se vea la UI de captura. Lo específico del wizard, un comando de progreso y una barrera de cuota y el paso de Svelte, era un armazón delgado encima.

Así que el giro fue: conservar la función del motor, eliminar el armazón. El paso de cierre del wizard se convirtió en una simple entrega que dice, en efecto, “todo listo, ahora a capturar las primeras ideas como bocetos en la app.” La captura real, la que maneja audio, imágenes y texto, se movió a su propio issue, donde corresponde. Dejé una nota ahí apuntando a la función del motor para que la próxima sesión arranque desde la primitiva en vez de un archivo en blanco.

Una cosa más se desprendió de esto. El wizard tenía una “cuota de semillas” que desbloqueaba el ciclo diario, una barrera dura. Con la captura saliendo del wizard, esa barrera no tenía dónde pararse. Se convirtió en lo que debería haber sido desde el principio: un empujón suave en el dashboard para acumular un lote inicial, no un candado. La especificación decía “desbloquea,” así que también actualicé la especificación. Un documento de diseño que no coincide con la app es solo un futuro reporte de error.

Construir la versión equivocada fue la forma más rápida de ver que estaba equivocada. Quedarse mirando la casilla de texto funcionando fue lo que hizo obvio el problema de “las ideas son bocetos” de una manera que la especificación escrita nunca logró. El costo de descubrirlo fue unos pocos archivos, y prefiero pagar eso antes que publicar un wizard que maleduca a todos desde el primer día. (Hay una victoria más silenciosa acá también: el paso de configuración de carpeta en sí, un selector nativo del sistema operativo conectado a través del plugin de diálogo de Tauri, entró limpio y es el primer lugar donde la app en verdad toca el sistema de archivos.)

La pantalla de reglas, y un layout que seguía haciendo mal

El trabajo era pequeño en el papel: construir el paso de onboarding que recorre las reglas de Greenhouse, cada restricción emparejada con la razón exacta por la que existe. Capturar un poco cada día. Trabajar, y después enfriar. Las ideas germinan antes de ser juzgadas. Vault, nunca borrar. El trabajo descuidado sube primero. Cinco tarjetas, cinco razones. Las puse en un solo paso en vez de cinco subpantallas, porque un paginador anidado dentro del propio Back y Next del wizard son dos botones “next” peleando entre sí.

Después recibí dos correcciones seguidas, y ambas son del tipo que vale la pena escribir porque ninguna era en realidad sobre esta pantalla.

El texto: había escrito las tarjetas de reglas con guiones largos por todos lados y una línea sobre cómo la incubación evitaba que el entusiasmo “se disfrazara de calidad.” Ambas cosas están en mi propia lista de cosas que no hay que escribir. Las reglas existían, pero vivían bajo un encabezado de “Blog Writing,” así que en silencio había decidido que solo aplicaban a las entradas de blog, no al texto de producto ni al chat. Esa es una lectura conveniente y equivocada. La corrección fue subir la regla un nivel para que cubra todo lo que se produce, y después sacar los guiones largos y la palabrería vacía del wizard. Una regla de estilo que solo aplica donde es más obvia no es una regla de estilo.

El layout, el más grande: el wizard era una tarjeta angosta flotando en el medio de una ventana ancha de escritorio, océanos de espacio vacío a ambos lados. Se leía como una pantalla de teléfono estirada. Lo curioso es que esto ya había salido antes. Solo que nunca quedó escrito en ningún lugar que la próxima sesión de trabajo fuera a ver, así que seguía resurgiendo como sorpresa.

Rehice el wizard como un layout de dos paneles a ventana completa: un riel verde oscuro a la izquierda con la marca y una lista de pasos en vivo, el contenido y la navegación llenando el resto. Ahora parece una app de escritorio en vez de un sitio web apretado en una columna. Pero el cambio que en realidad importó no fue el CSS. Fue registrar la preferencia como una nota durable para que deje de tener que reaprenderse. La retroalimentación que solo vive en una conversación tiene una vida media de una sesión.

Verificando un frontend de Tauri sin lanzarlo nunca

Después construí el dashboard principal: la pantalla de inicio del flujo diario que muestra qué está maduro para una decisión hoy, el worklist activo, qué se está enfriando, y una racha de captura. La parte interesante no fue la pantalla. Fue cómo verifiqué el trabajo.

La mayor parte del riesgo del dashboard vivía en un solo lugar: el layout. Me habían dicho, más de una vez, que la app no debía renderizarse como una columna angosta centrada flotando en una ventana ancha. Es una app de escritorio, así que debía usar toda la ventana. Esa es una propiedad visual. No se puede afirmar en una prueba unitaria, hay que mirarla.

Así que quería una captura. El movimiento obvio es lanzar la app y capturar la ventana. Con Electron hay una forma clara de hacer eso. Tauri no es Electron. Renderiza en el webview del sistema operativo, WKWebView en macOS, y manejar eso mediante programación no es confiable. Lanzar la ventana nativa y capturarla desde un contexto automatizado es, en el mejor de los casos, delicado.

Me quedé mirando esto un minuto antes de que cayera lo obvio: el frontend es solo una web app. Habla con Rust a través de invoke, y nada más en él le importa si Tauri está corriendo. Si se puede simular invoke, se puede renderizar el componente real como una página web común y tomarle una captura.

Ya había canalizado cada llamada IPC a través de un solo módulo, ipc.ts. Hay exactamente una función que el dashboard llama: getDailyState(). Y Tauri incluye mockIPC en @tauri-apps/api/mocks justo para esto. Por debajo, invoke lee window.__TAURI_INTERNALS__.invoke en el momento de la llamada, y mockIPC reemplaza eso.

Así que escribí un arnés descartable, guardado en tmp/, que importa el Dashboard.svelte real, simula get_daily_state para que devuelva un fixture armado a mano, y lo monta. Un parámetro de query alterna entre un vault vacío y recién creado y un día sembrado con proyectos de aspecto real. Después Chrome headless contra el servidor de desarrollo de Vite, viewport de escritorio, captura:

"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
  --headless=new --window-size=1280,860 --force-device-scale-factor=1 \
  --virtual-time-budget=2500 --screenshot=out.png \
  "http://localhost:1420/tmp/verify/harness.html?s=seeded"

Eso es todo. El componente real, el CSS real, la lógica de layout real corriendo contra datos controlados, renderizado en un navegador al que se le puede tomar una captura. Las versiones de vault vacío y sembrado salieron exactamente como estaban diseñadas, tres zonas de ancho completo a lo largo de la ventana, ninguna columna angosta a la vista.

Dos cosas me mordieron. Primero, nombré una variable local derived dentro de un script de Svelte 5. Svelte 5 tiene una runa $derived, y el compilador se atragantó con “used before its declaration.” Renombrarla lo arregló al instante, pero el mensaje de error apunta a lo que no es. La misma trampa espera con state, props, y effect.

Segundo, la tipografía de la captura parecía Times New Roman. Pánico leve, después recordé: Chrome headless resuelve system-ui distinto a como lo hace WKWebView. En la app real es San Francisco. Es un artefacto de renderizado del método de verificación, no un error. Hay que juzgar el layout a partir de estas capturas, no la tipografía.

Esto verifica la capa web: layout, CSS, cómo se comporta el componente con datos específicos. No ejercita el comando real de Rust, la lectura de SQLite, ni si el fixture coincide con la forma serializada real. Mantuve cargo test para el backend, y me aseguré de que los datos falsos coincidieran con la salida de serde, incluyendo un enum que serializa a snake_case, para no estar verificando un layout que la app real nunca podría producir. Es una herramienta angosta para una pregunta angosta: ¿se ve bien esta pantalla con estos datos? Para eso, le ganó a lanzar toda la app, y va a ser la misma receta de tres líneas para cada pantalla que construya después.

El diálogo de captura que juraba que no hacía nada

Toda la premisa de Greenhouse es capturar un poco, cada día. Así que el flujo de captura tenía que ser sin fricción: un nombre, tal vez una nota, listo. El giro en el modelo es que una idea no es una línea de texto, es un proyecto con archivos. Se bosqueja en Logic o Procreate, se exporta un rebote. Entonces, en vez de pedir que se adjunte un archivo, Greenhouse crea una carpeta con fecha para la idea y simplemente indica dónde guardar el trabajo. Captura, y después un panel: “Guardar el archivo del proyecto y su exportación en esta carpeta,” con la ruta y un botón de Copiar.

Lo construí, tomé una captura del diálogo aislado con un backend simulado, y se veía perfecto. Formulario de un lado, confirmación con la ruta del otro. Me lo envié a mí mismo para probarlo en la app real.

Escribí un nombre, apreté Capture, y… nada. El diálogo se quedó ahí mostrando el formulario vacío otra vez. Sin confirmación, sin ruta, sin señal de que algo hubiera pasado. Excepto que la idea se había capturado, la carpeta existía en el disco. La UI simplemente lo negaba de plano.

Por qué la captura mentía: el componente del diálogo estaba bien. El error vivía en el espacio entre el diálogo y la pantalla detrás de él, exactamente el espacio que una captura aislada no puede ver.

El dashboard renderiza su contenido dentro de una rama controlada por un estado de carga: mientras carga, muestra un spinner; una vez cargado, muestra el dashboard (y cualquier diálogo abierto dentro de él). En una captura exitosa, el diálogo le pedía al dashboard que refrescara sus números, la racha, el empujoncito de “los semilleros están vacíos.” Y ese refresco reutilizaba la misma función que usaba la carga inicial de la página, la cual devuelve el estado a “cargando” mientras vuelve a traer los datos.

Ese giro es el cuchillo. Por unos milisegundos el dashboard cambió a su vista de carga, lo que desmontó todo lo que había debajo, incluyendo el diálogo que estaba abierto. Cuando los datos volvieron y el dashboard se volvió a renderizar, la bandera de “¿está abierto el diálogo de captura?” seguía en verdadero, así que un diálogo completamente nuevo se montó desde cero, con su estado interno de “acá está la ruta guardada” borrado por completo. El formulario vacío, renacido. Parecía que no había pasado nada porque, hasta donde sabía el diálogo recién montado, no había pasado nada.

La corrección es una sola idea: un refresco en segundo plano debe ser silencioso. No hay que reutilizar el camino de carga para eso. Actualizar los datos en su lugar, sin tocar nunca el estado que controla el montaje, y el diálogo sobrevive al refresco con su confirmación intacta. Dos funciones pequeñas en vez de una, la carga inicial es dueña del spinner; el refresco posterior a la acción solo intercambia los datos.

La lección es la que sigo reaprendiendo: probar un componente aislado demuestra el componente, no el sistema. La captura simulada del diálogo era genuinamente correcta y genuinamente inútil para este error. Lo que lo detectó fue manejar la interacción real: montar el dashboard de verdad, hacer clic en Capture, escribir, enviar, y verificar que la confirmación se mantenga. La corrección de un modal incluye cómo se comporta la pantalla detrás de él mientras está abierto. Agregué eso como una nota permanente.

(Hay una segunda trampa, más silenciosa, acá también: la migración de esquema que agrega una columna. CREATE TABLE IF NOT EXISTS felizmente se salta una tabla que ya existe, así que una columna nueva agregada a esa tabla nunca llega a ninguna base de datos existente, solo a las nuevas, como cada base de datos de prueba. Esa la detecté antes de publicar, probando contra una base de datos armada a mano con el esquema viejo en vez de una limpia. La misma forma de lección: el camino feliz y el camino real no son el mismo camino.)

Dos formas en que un botón puede mentir

Esta semana conecté la decisión diaria: una idea madura, y se la promueve a un proyecto real (“Work on it”) o se la guarda en el vault. También agregué un pequeño botón de carpeta para poder abrir los archivos de una idea y de verdad mirarlos antes de decidir. Cosas simples. Tomó dos rondas de “no hace nada” para que quedara bien.

Ronda uno: el binario desactualizado. Me lo envié a mí mismo, atrasé la fecha de algunas ideas para que contaran como maduras, hice clic en “Work on it.” La idea se deslizó a la columna Cooling, mal. Promover un proyecto no debería ponerlo en pausa; decidir trabajar en algo no es lo mismo que haber trabajado en algo. El enfriamiento es un descanso después del esfuerzo, no un peaje por empezar. Así que la corrección fue fácil: no registrar un touch en la promoción. Hice el cambio, las pruebas pasaron en verde, dije que estaba listo.

La respuesta: “¿en serio se hizo algo?” Seguía enfriando.

Fui a leer la base de datos real. Ahí estaba, un touch de “Promoted to active” en cada elemento, la fila exacta que el código nuevo ya no escribía. El código era correcto; simplemente no estaba corriendo. El servidor de desarrollo había recargado en caliente el frontend (los botones nuevos estaban ahí, clicables) pero nunca recompiló el Rust de abajo. Había estado haciendo clic en botones nuevos conectados a un cerebro viejo. Un reinicio completo del servidor de desarrollo, y Work mandaba las cosas al worklist como correspondía.

Ronda dos: el permiso sin alcance. Después el botón de carpeta. Clic, nada. Sin carpeta, sin error, sin nada. Esta vez el código definitivamente estaba corriendo.

El botón llama al plugin opener para abrir una ruta, y había agregado con cuidado el permiso allow-open-path. Lo que no había leído con atención era la descripción del propio permiso: “Enables the open_path command without any pre-configured scope.” El plugin revisa cada ruta que se le entrega contra una lista de permitidos. El permiso activa el comando pero deja esa lista vacía, y una lista de permitidos vacía significa que todo queda denegado. Así que la llamada se rechazaba antes de hacer nada, en silencio, en una capa que ni el build ni las pruebas tocan nunca.

La corrección limpia no fue ampliar el alcance, fue dejar de pasar por esa puerta. El opener también tiene una API de Rust, y la verificación de alcance solo vive en el manejador de comandos de JavaScript. Abrir la carpeta desde el propio comando de Rust hace que toda la cuestión de ACL se evapore: no hace falta permiso, y de regalo abre la carpeta en sí en vez de solo resaltarla dentro de su carpeta padre.

En el fondo, los dos errores son el mismo error. Los chequeos verdes: pruebas unitarias, un build limpio, una captura de un componente simulado, todos viven de un lado de la línea. El binario desactualizado, el alcance de ACL vacío: esos viven del otro lado, el lado al que solo se llega lanzando la app real en la máquina real. Todo lo que se puede automatizar demuestra que el código tiene la forma correcta. Solo correrlo demuestra que está conectado correctamente. Sigo reaprendiendo que los chequeos y la verdad no son lo mismo, y la brecha entre ambos es exactamente donde viven los errores vergonzosos.

La función que ya estaba hecha

Todo el ritmo de Greenhouse descansa en una acción: tocar un elemento y que empiece a enfriarse. No verlo, no abrirlo, un explícito “trabajé en esto,” opcionalmente con una nota para el propio yo futuro sobre en qué punto se quedó. Fui a construir la pantalla para esa acción esperando un issue de funcionalidad normal: función de motor, comando IPC, diálogo, conectarlo todo. Lo que encontré en cambio fue que tres cuartas partes ya existían.

record_touch llevaba ahí en el motor desde uno de los primerísimos issues, completamente implementada, completamente probada, insertando una fila de touch y actualizando la marca de tiempo del último touch del elemento dentro de una transacción. Simplemente nunca se había conectado a nada. Ningún comando de Tauri la envolvía. Ninguna entrada en ipc.ts apuntaba a ella. Ningún botón en la UI podía alcanzarla. Una función completa, correcta, exhaustivamente probada, sin ningún llamador fuera de su propio módulo de pruebas.

La versión perezosa de este issue es: escribir un comando de Tauri, escribir un diálogo de Svelte, listo. Pero tres funciones hermanas en el mismo archivo, promote_idea, vault_item, move_project_to_stage, comparten todas un patrón que a record_touch le faltaba en silencio. Cada una de ellas, después de que su escritura en la base de datos se confirma, regenera project.md, el espejo en markdown que vive en la propia carpeta del proyecto. Y project.md no solo muestra el estado, renderiza el historial completo de touches, una línea por touch, nota de entrega incluida.

Así que la pregunta se volvió obvia en cuanto la busqué: si record_touch es la única función cuyo trabajo entero es crear un touch con una nota de entrega, y project.md existe específicamente para mostrar el historial de touches, ¿por qué era record_touch la única función de la familia que nunca le avisaba nada al espejo? Registrar un touch con una nota cuidadosa para el propio yo futuro, y el archivo que ese yo futuro en realidad abriría no mostraría ningún cambio, hasta que en algún momento se promoviera o guardara alguna otra cosa y el espejo se regenerara como efecto secundario de un trabajo sin relación.

Ese no es un error que nadie habría encontrado probando la UI nueva de forma aislada. Solo aparece si ya se conoce el trabajo del espejo y se va a buscar si el código nuevo lo cumple. Agregué la misma regeneración de mejor esfuerzo que ya tenían las otras tres funciones: escribir el espejo, registrar y tragarse el error si falla, nunca dejar que una escritura de markdown bloquee el cambio de estado real, y escribí una prueba que promueve una idea, la toca con una nota, y vuelve a leer project.md del disco para confirmar que la nota en verdad está ahí.

El diálogo se puede dar el lujo de ser simple. El diálogo de captura (unas secciones atrás) tiene que mantener abierta una pantalla de confirmación después, Copy, Show in Finder, Capture another, porque hay una ruta de carpeta que la persona usuaria necesita. Un touch no tiene nada de eso. Es una nota, o ninguna nota, y después se termina. Así que el nuevo TouchDialog se salta todo el vaivén de dos estados que necesitaba el flujo de captura: enviar, cerrar, refrescar el dashboard en silencio por debajo. Una sola máquina de estados en vez de dos ahorró la complejidad de un componente entero, y solo se volvió obvio una vez que se preguntó qué necesitaba en realidad el diálogo para seguir mostrándose después del éxito. Nada. Así que no lo hace.

El texto se apoya en la metáfora de las plantas a la que el producto ya se compromete en otros lugares, “no regar de más, dejar descansar”, en vez de imprimir la duración real del enfriamiento. La app deliberadamente mantiene los valores de temporizador fuera de la UI en v1; la metáfora lleva el significado sin exponer un número que nadie debería andar ajustando.

Lecturas relacionadas