Compare commits

...
17 Commits
Author SHA1 Message Date
luriasb 85cf67c1b5 Update 02-kids-list-and-profile.md 2026-10-23 00:52:21 -06:00
luriasb cc0180087c paso 7 2026-10-20 11:44:44 -06:00
luriasb ea81e01a0a paso 6 2026-10-20 11:20:49 -06:00
luriasb 0ff530aa22 paso 5 2026-10-20 11:16:58 -06:00
luriasb edcba1a7e4 paso 4 2026-10-20 11:16:21 -06:00
luriasb e2bfbab713 paso 03 2026-10-20 11:10:49 -06:00
luriasb 4ab73ade40 paso 02 2026-10-20 08:39:46 -06:00
luriasb b9be17db97 step 01 2026-10-20 08:35:47 -06:00
luriasb 787522e4ca spec 02 aprobada 2026-10-20 02:30:01 -06:00
luriasb f19fbc5c5d add spec verifier 2026-10-20 02:23:16 -06:00
luriasb b862b85fd6 spec 01 implementada 2026-10-19 01:32:32 -06:00
luriasb 4aced1227d paso 06 2026-10-19 01:03:45 -06:00
luriasb edc17fb13c paso 05 2026-10-19 00:58:45 -06:00
luriasb a6b0ea91cd Create Sidebar.tsx 2026-10-19 00:53:21 -06:00
luriasb e9586acbaa Create feed.ts 2026-10-19 00:50:37 -06:00
luriasb 63a6523483 Update globals.css 2026-10-19 00:49:06 -06:00
luriasb ddc74afc2c step 01 2026-10-19 00:44:50 -06:00
20 changed files with 1248 additions and 89 deletions
+2
View File
@@ -39,3 +39,5 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
.playwright-mcp/*
+67
View File
@@ -0,0 +1,67 @@
---
description: Verifica, corrige y marca los criterios de aceptación de un spec (specs/*.md) usando Context7 (docs Next.js) y Playwright (comparación visual de pantallas con visión)
mode: all
model: zai-coding-plan/glm-5.3-flash
---
Eres el agente verificador de criterios de aceptación de specs de este proyecto (OpenDayCare, Next.js). Tu misión: revisar, corregir y marcar los checks de la sección "Criterios de aceptación" de un archivo de especificación en `specs/`. Un check se marca **solo con verificación real**, nunca por suposición.
# Flujo de trabajo
## 1. Leer el spec
- Si no se indica cuál, lista los archivos en `specs/` y pregunta cuál verificar.
- Lee el spec completo: "Alcance" (In / Out of scope), "Modelo de datos", "Plan de implementación", "Criterios de aceptación" y "Decisiones".
- No amplíes el alcance: lo que está en "Out of scope" no se verifica ni se implementa, aunque lo detectes.
## 2. Verificar cada criterio
Clasifica cada criterio `- [ ]` según su tipo y verifica con la herramienta adecuada:
### Criterios de Next.js / código
- Usa **Context7** para validar contra la documentación actual de Next.js: primero `resolve-library-id` con "Next.js", luego `query-docs` con el concepto específico del criterio (fuentes con `next/font`, layout, metadata, `Link`, etc.). No confíes en tu memoria: las APIs pueden haber cambiado.
- Lee además la guía relevante en `node_modules/next/dist/docs/`: esta versión del proyecto puede tener breaking changes respecto a tu entrenamiento.
- Revisa el código fuente real en `app/` con read/grep/glob y compáralo con lo que exige el criterio.
### Criterios de pantallas / UI
- Levanta el dev server si no está corriendo (`npm run dev` en background) y espera a que compile.
- Navega con el MCP de Playwright hasta la página implicada.
- **Todos los screenshots y artefactos de Playwright van OBLIGATORIAMENTE en `.playwright-mcp/`** (regla del AGENTS.md del proyecto).
- Toma screenshot a ancho ≥1280px (los specs piden desktop) y compáralo **visualmente** contra la plantilla de referencia: abre `references/pantallas/*.html` con Playwright y captúrala también.
- Usa tu capacidad de visión: lee ambas imágenes y compara concretamente:
- Layout y dimensiones (ej. sidebar de 248px sticky, contenido centrado de 760px, según el spec).
- Colores hex exactos que mencione el spec (fondos, badges, bordes, textos).
- Tipografías y pesos, textos, badges, contadores, iconos, placeholders.
- Solo sentencia una diferencia si la identificaste en la imagen; describe qué y dónde antes de corregir.
### Criterios de build / runtime
- `npm run build` debe completar sin errores.
- `npm run dev` debe compilar y servir la página sin errores de compilación, consola ni hidratación.
## 3. Corregir
- Si un criterio falla, corrige el código respetando las "Decisiones" del spec y las convenciones del repo (ej.: solo utilities Tailwind con valores arbitrarios exactos, sin atributos `style`, `<Link>` para toda navegación, fuentes vía `next/font/google`).
- Tras corregir, **re-verifica el criterio completo** con evidencia nueva (no vale la corrección sin re-test).
## 4. Marcar
- Cambia `- [ ]` → `- [x]` en el archivo del spec **solo** cuando el criterio quedó verificado con evidencia: comando exitoso, screenshot comparado o docs consultadas.
- Si un criterio no se puede verificar (depende de algo out of scope, o falla por algo ajeno al spec), déjalo sin marcar y explícalo en el reporte.
- No edits ninguna otra sección del spec salvo que el usuario lo pida explícitamente.
## 5. Reportar
Cierra siempre con un resumen por criterio:
- ✅ **Verificado**: criterio + evidencia (comando, screenshot en `.playwright-mcp/`, docs).
- 🔧 **Corregido**: qué fallaba, qué cambiaste (archivos) y cómo quedó verificado.
- ⛔ **Bloqueado**: motivo exacto y qué hace falta para desbloquear.
# Reglas del proyecto (AGENTS.md)
- Screenshots y cualquier salida de Playwright → carpeta `.playwright-mcp/`.
- Context7 es la fuente de documentación de frameworks; úsalo siempre para Next.js.
- Lee `node_modules/next/dist/docs/` antes de escribir o corregir código Next.js: puede diferir de lo que conoces.
+14
View File
@@ -0,0 +1,14 @@
---
description: Verifica, corrige y marca los criterios de aceptación de un spec usando el agente spec-verifier (Context7 + Playwright con visión)
agent: spec-verifier
---
Verifica los criterios de aceptación del spec: $ARGUMENTS
Reglas de ejecución:
- Si `$ARGUMENTS` viene vacío, lista los specs disponibles en `specs/` y pregunta cuál verificar. No continúes sin respuesta.
- Sigue tu flujo completo del agente: clasificar y verificar cada criterio (Context7 + `node_modules/next/dist/docs/` para Next.js, Playwright con screenshots en `.playwright-mcp/` para pantallas, `npm run dev` / `npm run build` para build/runtime).
- Corrige lo que falle respetando el "Alcance" y las "Decisiones" del spec, re-verifica y recién entonces marca el check (`- [ ]` → `- [x]`).
- Si TODOS los criterios quedan verificados, actualiza además el **Estado** del spec a "Implementada" (o el equivalente usado en el archivo).
- Cierra con el reporte ✅ verificados · 🔧 corregidos · ⛔ bloqueados (con evidencia y motivo).
+34
View File
@@ -7,3 +7,37 @@ This version has breaking changes — APIs, conventions, and file structure may
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules --> <!-- END:nextjs-agent-rules -->
## MCPs
- Playwright Scheenshots y cualquier cosa relacionada a Playwright tiene que estar en la carpeta .playwright-mcp.
- Context7 Usaremos este MCP para traer la documentación actualizada del framework.
## Workflow de specs (spec-driven)
El proyecto usa un flujo spec-driven con tres pasos. Cada spec vive en `specs/NN-slug.md` y tiene un **Estado** (`Draft` → `Approved` → `Implementada`):
| Paso | Comando | Definición |
| --- | --- | --- |
| 1. Diseñar | `/spec <descripción>` | Skill en `.agents/skills/spec/` — redacta el spec en `specs/NN-slug.md` (estado `Draft`) |
| 2. Implementar | `/spec-impl <NN-slug>` | Skill en `.agents/skills/spec-impl/` — crea la rama `spec-NN-slug` e implementa paso a paso |
| 3. Verificar | `/spec-verify <NN-slug>` | Comando en `.opencode/commands/spec-verify.md` — ejecuta el agente `spec-verifier` sobre el spec |
### Agente `spec-verifier`
Definición: `.opencode/agents/spec-verifier.md` (a nivel de proyecto, no global).
- **Modelo:** `zai-coding-plan/glm-5.3-flash` (soporta visión, necesario para comparar screenshots). **Modo:** `all` (agente principal o subagente).
- **Qué hace:** revisa, corrige y marca (`- [ ]` → `- [x]`) los "Criterios de aceptación" de un spec en `specs/`.
- **Criterios de Next.js/código** → Context7 (docs actualizadas de Next.js) + guías locales en `node_modules/next/dist/docs/`.
- **Criterios de pantallas/UI** → Playwright con screenshots obligatoriamente en `.playwright-mcp/`, comparación visual (visión) contra las plantillas de `references/pantallas/*.html` a ancho ≥1280px.
- **Criterios de build/runtime** → `npm run dev` / `npm run build` con salida limpia.
- **Reglas:** nunca marca un check sin evidencia real; nunca implementa nada que esté en "Out of scope" del spec; corrige respetando las "Decisiones" del spec.
- **Uso directo:** seleccionarlo con Tab como agente principal, o lanzarlo como subagente ("usa spec-verifier para verificar el spec 01").
### Comando `/spec-verify`
- Ejecuta el agente `spec-verifier` sobre el spec indicado: `/spec-verify 01-feed-home` (también acepta solo el número o el slug).
- Si se invoca sin argumentos, lista los specs de `specs/` y pregunta cuál verificar.
- Si todos los criterios quedan verificados, actualiza el **Estado** del spec a "Implementada".
- Cierra siempre con un reporte por criterio: ✅ verificados · 🔧 corregidos · ⛔ bloqueados (con evidencia y motivo).
+11
View File
@@ -0,0 +1,11 @@
export default function FeedHeader() {
return (
<div className="mb-6">
<div className="mb-1 text-[12.5px] font-extrabold tracking-[.8px] text-[#D9583C]">
GUARDERÍA · SALA SOLES
</div>
<h1 className="font-display text-[30px] font-semibold text-[#3F362E]">Buenas, Caro</h1>
<p className="mt-[5px] text-[14.5px] text-[#94887B]">12 niños · martes 17 jun</p>
</div>
);
}
+69
View File
@@ -0,0 +1,69 @@
import Link from "next/link";
import type { Kid } from "@/app/data/kids";
/**
* Colores dinámicos → utilities Tailwind con valores exactos.
* El set de pares del mock es cerrado; nunca se usan atributos style.
*/
const AVATAR_COLORS: Record<string, string> = {
"A9D9E8-1F7A93": "bg-[#A9D9E8] text-[#1F7A93]",
"F4B8CC-C44A7A": "bg-[#F4B8CC] text-[#C44A7A]",
"B9DEC4-3E8B62": "bg-[#B9DEC4] text-[#3E8B62]",
"F4DC8E-9A7B1E": "bg-[#F4DC8E] text-[#9A7B1E]",
"C9B6E8-7B5FC0": "bg-[#C9B6E8] text-[#7B5FC0]",
};
export function avatarClasses(kid: Kid): string {
const key = `${kid.avatar.bg.replace("#", "")}-${kid.avatar.text.replace("#", "")}`;
return AVATAR_COLORS[key] ?? "";
}
/** "2 padres vinculados" | "1 padre vinculado" | "sin padres vinculados". */
function parentsLabel(kid: Kid): string {
if (kid.parents.length === 0) return "sin padres vinculados";
return `${kid.parents.length} ${kid.parents.length === 1 ? "padre" : "padres"} vinculados`;
}
export default function KidCard({ kid }: { kid: Kid }) {
return (
<Link
href={`/kids/${kid.slug}`}
className="flex min-w-0 items-center gap-[14px] rounded-[18px] border border-[#ECE0D0] bg-[#FFFDF9] p-4 shadow-[0_4px_14px_-12px_rgba(120,90,60,.5)] transition duration-150 hover:-translate-y-[2px] hover:border-[#F2A78E]"
>
<div
className={`flex h-12 w-12 flex-none items-center justify-center rounded-full font-display text-[19px] font-semibold ${avatarClasses(kid)}`}
>
{kid.initial}
</div>
<div className="min-w-0 flex-1">
<div className="truncate font-display text-base font-semibold text-[#3F362E]">{kid.name}</div>
<div className="text-[13px] text-[#A89A8B]">
{kid.age} años · {parentsLabel(kid)}
</div>
</div>
{kid.parents.length === 0 ? (
<span className="flex-none rounded-full bg-[#F9D2DE] px-[9px] py-[5px] text-[11px] font-extrabold text-[#C56486]">
VINCULAR
</span>
) : kid.allergies.length > 0 ? (
<span className="flex-none rounded-full bg-[#FBD8CC] px-[9px] py-[5px] text-[11px] font-extrabold text-[#D9684A]">
{kid.allergies[0]}
</span>
) : (
<svg
className="flex-none"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="#CBB89F"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m9 18 6-6-6-6" />
</svg>
)}
</Link>
);
}
+67
View File
@@ -0,0 +1,67 @@
"use client";
import { useState } from "react";
import type { Kid } from "@/app/data/kids";
import KidCard from "@/app/components/KidCard";
/** Compara sin mayúsculas ni acentos: "sofia" encuentra a "Sofía Méndez". */
function normalize(value: string): string {
return value
.normalize("NFD")
.replace(/\p{Diacritic}/gu, "")
.toLowerCase();
}
export default function KidsBrowser({ kids }: { kids: Kid[] }) {
const [query, setQuery] = useState("");
const filtered = kids.filter((kid) => normalize(kid.name).includes(normalize(query)));
return (
<div>
{/* Buscador */}
<div className="mb-[22px] flex items-center gap-[11px] rounded-[14px] border border-[#ECE0D0] bg-[#FFFDF9] px-4 py-3">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="#B0A290"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="11" cy="11" r="7" />
<path d="m21 21-4.3-4.3" />
</svg>
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Buscar niño…"
className="flex-1 border-none bg-transparent text-[15px] text-[#3F362E] outline-none placeholder:text-[#B6A99B]"
/>
</div>
{/* Encabezado de sección: sala + contador + divisor */}
<div className="mb-[14px] flex items-center gap-3">
<span className="text-[12.5px] font-extrabold tracking-[.8px] text-[#3F362E]">SALA SOLES</span>
<span className="text-[13px] text-[#A89A8B]">
{filtered.length} {filtered.length === 1 ? "niño" : "niños"}
</span>
<span className="h-px flex-1 bg-[#E7DAC8]" />
</div>
{/* Grilla filtrada o estado vacío */}
{filtered.length > 0 ? (
<div className="grid grid-cols-2 gap-[14px]">
{filtered.map((kid) => (
<KidCard key={kid.slug} kid={kid} />
))}
</div>
) : (
<div className="rounded-[18px] border border-dashed border-[#DBCDBA] bg-[#FFFDF9] px-4 py-10 text-center text-[14.5px] text-[#94887B]">
No hay niños que coincidan con la búsqueda.
</div>
)}
</div>
);
}
+100
View File
@@ -0,0 +1,100 @@
import Link from "next/link";
import type { Post, PostCategory } from "@/app/data/feed";
/** Badge por categoría: colores exactos de la plantilla. */
const BADGES: Record<PostCategory, { label: string; classes: string }> = {
logro: { label: "LOGRO", classes: "bg-[#CFEBD8] text-[#3E9B6C]" },
actividad: { label: "ACTIVIDAD", classes: "bg-[#C7E7F1] text-[#2E89A6]" },
anuncio: { label: "ANUNCIO", classes: "bg-[#CCD8F4] text-[#4E72C8]" },
};
/**
* Colores dinámicos → utilities Tailwind con valores exactos.
* El set de pares del mock es cerrado; nunca se usan atributos style.
*/
const AVATAR_COLORS: Record<string, string> = {
"A9D9E8-1F7A93": "bg-[#A9D9E8] text-[#1F7A93]",
"CCD8F4-4E72C8": "bg-[#CCD8F4] text-[#4E72C8]",
};
function avatarClasses(post: Post): string {
const key = `${post.authorColors.bg.replace("#", "")}-${post.authorColors.text.replace("#", "")}`;
return AVATAR_COLORS[key] ?? "";
}
export default function PostCard({ post }: { post: Post }) {
const badge = BADGES[post.category];
return (
<article className="rounded-[20px] border border-[#ECE0D0] bg-[#FFFDF9] px-[22px] py-5 shadow-[0_4px_16px_-12px_rgba(120,90,60,.5)]">
{/* Encabezado: avatar + autor + badge */}
<div className="mb-[14px] flex items-center gap-3">
<div
className={`flex h-11 w-11 flex-none items-center justify-center rounded-full font-display text-[17px] font-semibold ${avatarClasses(post)}`}
>
{post.authorInitial ? (
post.authorInitial
) : (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m3 11 18-5v12L3 14v-3zM11.6 16.8a3 3 0 1 1-5.8-1.6" />
</svg>
)}
</div>
<div className="flex-1">
<div className="font-display text-[16.5px] font-semibold text-[#3F362E]">{post.authorName}</div>
<div className="text-[12.5px] text-[#A89A8B]">
{post.timeLabel} · {post.publishedBy}
</div>
</div>
<div className={`flex items-center gap-[7px] rounded-full px-3 py-[6px] ${badge.classes}`}>
<span className="h-2 w-2 rounded-full bg-current" />
<span className="text-xs font-extrabold tracking-[.5px]">{badge.label}</span>
</div>
</div>
{/* Destinatario */}
<div className="mb-2.5 text-[12.5px] text-[#A89A8B]">Para: {post.audience}</div>
{/* Contenido */}
<p className="text-[15.5px] leading-[1.55] text-[#4A4038]">{post.body}</p>
{/* Placeholder de foto (solo posts de actividad con foto) */}
{post.photoLabel && (
<Link
href="/foto"
className="mt-[14px] flex h-[200px] flex-col items-center justify-center gap-2 rounded-2xl border-[1.5px] border-dashed border-[#DBCDBA] bg-[#F4ECE1] text-[#B0A290]"
>
<svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="9" cy="9" r="2" />
<path d="m21 15-3.6-3.6a2 2 0 0 0-2.8 0L6 21" />
</svg>
<span className="text-[13.5px]">Foto · {post.photoLabel}</span>
</Link>
)}
{/* Acciones */}
<div className="mt-4 flex items-center gap-[18px] border-t border-[#F0E6D8] pt-[14px]">
<span className="flex items-center gap-[7px] text-sm font-bold text-[#E0654A]">
<svg width="19" height="19" viewBox="0 0 24 24" fill="#E0654A" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.7l-1-1.1a5.5 5.5 0 0 0-7.8 7.8l1 1L12 21.2l7.8-7.8 1-1a5.5 5.5 0 0 0 0-7.8z" />
</svg>
{post.likes}
</span>
<Link
href="/detalle-publicacion"
className="flex items-center gap-[7px] text-sm font-bold text-[#94887B]"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8z" />
</svg>
{post.comments}
</Link>
<span className="flex-1" />
<Link href="/crear-publicacion" className="text-sm font-extrabold text-[#C5503A]">
Editar
</Link>
</div>
</article>
);
}
+21
View File
@@ -0,0 +1,21 @@
import Link from "next/link";
export default function ShareBox() {
return (
<Link
href="/crear-publicacion"
className="mb-6 flex items-center gap-[14px] rounded-[18px] border border-[#ECE0D0] bg-[#FFFDF9] px-[18px] py-[14px] shadow-[0_4px_14px_-10px_rgba(120,90,60,.4)]"
>
<div className="flex h-10 w-10 flex-none items-center justify-center rounded-full bg-[#F2937A] font-display text-base font-semibold text-white">
C
</div>
<span className="flex-1 text-[15px] text-[#A89A8B]">Compartí un momento…</span>
<span className="flex h-[38px] w-[38px] items-center justify-center rounded-[12px] bg-[#FBE3D8] text-[#E0654A]">
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" />
<circle cx="12" cy="13" r="4" />
</svg>
</span>
</Link>
);
}
+123
View File
@@ -0,0 +1,123 @@
import Link from "next/link";
type NavItem = {
href: string;
label: string;
icon: React.ReactNode;
};
const NAV_ITEMS: NavItem[] = [
{
href: "/",
label: "Feed",
icon: (
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 9.5 12 3l9 6.5V20a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z" />
</svg>
),
},
{
href: "/kids",
label: "Niños",
icon: (
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="9" cy="7" r="3" />
<circle cx="17" cy="9" r="2.4" />
<path d="M2.5 20a6.5 6.5 0 0 1 13 0M16 20a5 5 0 0 1 5.5-4.9" />
</svg>
),
},
{
href: "/avisos",
label: "Avisos",
icon: (
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9M13.7 21a2 2 0 0 1-3.4 0" />
</svg>
),
},
{
href: "/mi-cuenta",
label: "Mi cuenta",
icon: (
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
),
},
];
export default function Sidebar({ activeHref = "/" }: { activeHref?: string }) {
return (
<aside className="sticky top-0 flex h-screen w-[248px] flex-none flex-col border-r border-[#ECE0D0] bg-[#FFFDF9] px-4 py-6">
{/* Logo */}
<Link href="/" className="flex items-center gap-[11px] px-2 pb-[22px] pt-1">
<div className="flex h-[38px] w-[38px] flex-none items-center justify-center rounded-[12px] bg-[linear-gradient(155deg,#F8C3A8,#F2937A)]">
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
</svg>
</div>
<div>
<div className="font-display text-[17px] font-semibold leading-none text-[#3F362E]">OpenDayCare</div>
<div className="mt-[2px] text-[11.5px] text-[#A89A8B]">Sala Soles</div>
</div>
</Link>
{/* Nueva publicación */}
<Link
href="/crear-publicacion"
className="mb-[18px] flex w-full items-center justify-center gap-2 rounded-[14px] bg-[linear-gradient(180deg,#F4977E,#EE8164)] py-3 text-[14.5px] font-extrabold text-white shadow-[0_8px_18px_-8px_rgba(238,129,100,.75)]"
>
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 5v14M5 12h14" />
</svg>
Nueva publicación
</Link>
{/* Navegación */}
<nav className="flex flex-1 flex-col gap-1">
{NAV_ITEMS.map((item) => {
const active = item.href === activeHref;
return (
<Link
key={item.href}
href={item.href}
className={`flex items-center gap-3 rounded-xl px-3 py-[11px] text-[14.5px] ${
active
? "bg-[#FBE3D8] font-extrabold text-[#D9583C]"
: "bg-transparent font-semibold text-[#6E6359]"
}`}
>
{item.icon}
{item.label}
</Link>
);
})}
</nav>
{/* Usuario */}
<div className="mt-[10px] border-t border-[#ECE0D0] pt-[14px]">
<div className="flex items-center gap-[11px] px-2 py-[6px]">
<div className="flex h-[38px] w-[38px] flex-none items-center justify-center rounded-full bg-[#F2937A] font-display text-base font-semibold text-white">
C
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-extrabold text-[#3F362E]">Caro Giménez</div>
<div className="text-xs text-[#A89A8B]">Maestra · Soles</div>
</div>
<Link
href="/login"
title="Cerrar sesión"
className="flex h-8 w-8 flex-none items-center justify-center rounded-[10px] bg-[#F6ECDF] text-[#94887B]"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9" />
</svg>
</Link>
</div>
</div>
</aside>
);
}
+64
View File
@@ -0,0 +1,64 @@
export type PostCategory = "logro" | "actividad" | "anuncio";
export interface Post {
id: string;
authorName: string;
/** Inicial para el avatar circular (solo posts de niño; el anuncio usa ícono). */
authorInitial: string;
/** Colores del avatar, exactos a la plantilla. */
authorColors: { bg: string; text: string };
timeLabel: string;
publishedBy: string;
/** Define el badge y sus colores vía mapa en PostCard. */
category: PostCategory;
/** Texto tras "Para: " (sin el prefijo). */
audience: string;
body: string;
/** Si existe, se muestra el placeholder dashed de foto con este rótulo. */
photoLabel?: string;
likes: number;
comments: number;
}
export const posts: Post[] = [
{
id: "mateo-logro",
authorName: "Mateo",
authorInitial: "M",
authorColors: { bg: "#A9D9E8", text: "#1F7A93" },
timeLabel: "14:20",
publishedBy: "publicado por vos",
category: "logro",
audience: "familia de Mateo",
body: "¡Usó el orinal solito por primera vez! Estaba feliz de contárselo a todos. Un gran paso.",
likes: 3,
comments: 1,
},
{
id: "mateo-actividad",
authorName: "Mateo",
authorInitial: "M",
authorColors: { bg: "#A9D9E8", text: "#1F7A93" },
timeLabel: "09:40",
publishedBy: "publicado por vos",
category: "actividad",
audience: "familia de Mateo",
body: "Pintamos con témperas esta mañana. Mateo eligió el azul para todo y se concentró un montón mezclando colores.",
photoLabel: "pintando con témperas",
likes: 5,
comments: 2,
},
{
id: "anuncio-parque",
authorName: "Anuncio general",
authorInitial: "",
authorColors: { bg: "#CCD8F4", text: "#4E72C8" },
timeLabel: "07:50",
publishedBy: "publicado por vos",
category: "anuncio",
audience: "toda la sala",
body: "El viernes salimos al parque por la mañana. Recuerden mandar gorra y una botellita de agua.",
likes: 8,
comments: 0,
},
];
+151
View File
@@ -0,0 +1,151 @@
export type ParentStatus = "activa" | "pendiente";
export interface Parent {
name: string;
/** "Mamá" | "Papá" */
role: string;
/** Inicial para el avatar circular. */
initial: string;
/** Color de fondo del avatar, exacto a la plantilla. */
bg: string;
/** Texto tras "Rol · " en el perfil: "activa" | "invitación enviada". */
note: string;
/** Define el badge ACTIVA (verde) / PENDIENTE (amarillo). */
status: ParentStatus;
}
export interface Kid {
slug: string;
name: string;
/** Inicial para el avatar circular. */
initial: string;
/** Colores del avatar, exactos a la plantilla. */
avatar: { bg: string; text: string };
/** Se renderiza como "X años". */
age: number;
/** Badges de la lista; vacío = chevron. La primera se muestra en la tarjeta. */
allergies: string[];
/** Texto del recuadro naranja del perfil (si no hay, no se muestra). */
allergyNote?: string;
birthDate: string;
room: string;
admission: string;
parents: Parent[];
}
export const kids: Kid[] = [
{
slug: "mateo-fernandez",
name: "Mateo Fernández",
initial: "M",
avatar: { bg: "#A9D9E8", text: "#1F7A93" },
age: 3,
allergies: ["MANÍ"],
allergyNote: "Alergia al maní. Evitar frutos secos. Lleva inhalador en la mochila.",
birthDate: "12 mar 2022",
room: "Soles",
admission: "feb 2025",
parents: [
{ name: "Lucía Fernández", role: "Mamá", initial: "L", bg: "#C9B6E8", note: "activa", status: "activa" },
{ name: "Diego Fernández", role: "Papá", initial: "D", bg: "#A9C7E8", note: "invitación enviada", status: "pendiente" },
],
},
{
slug: "sofia-mendez",
name: "Sofía Méndez",
initial: "S",
avatar: { bg: "#F4B8CC", text: "#C44A7A" },
age: 2,
allergies: [],
birthDate: "3 jul 2023",
room: "Soles",
admission: "mar 2025",
parents: [
{ name: "Valeria Méndez", role: "Mamá", initial: "V", bg: "#F4B8CC", note: "activa", status: "activa" },
],
},
{
slug: "benjamin-ruiz",
name: "Benjamín Ruiz",
initial: "B",
avatar: { bg: "#B9DEC4", text: "#3E8B62" },
age: 3,
allergies: [],
birthDate: "28 nov 2022",
room: "Soles",
admission: "ago 2024",
parents: [
{ name: "Marcela Ruiz", role: "Mamá", initial: "M", bg: "#B9DEC4", note: "activa", status: "activa" },
{ name: "Javier Ruiz", role: "Papá", initial: "J", bg: "#A9C7E8", note: "activa", status: "activa" },
],
},
{
slug: "valentina-soto",
name: "Valentina Soto",
initial: "V",
avatar: { bg: "#F4DC8E", text: "#9A7B1E" },
age: 2,
allergies: [],
birthDate: "9 may 2023",
room: "Soles",
admission: "feb 2025",
parents: [],
},
{
slug: "tomas-diaz",
name: "Tomás Díaz",
initial: "T",
avatar: { bg: "#C9B6E8", text: "#7B5FC0" },
age: 3,
allergies: ["LACTOSA"],
allergyNote: "Intolerancia a la lactosa. Toma leche deslactosada y evita los lácteos en la merienda.",
birthDate: "17 sep 2022",
room: "Soles",
admission: "sep 2023",
parents: [
{ name: "Andrés Díaz", role: "Papá", initial: "A", bg: "#C9B6E8", note: "activa", status: "activa" },
],
},
{
slug: "emma-castro",
name: "Emma Castro",
initial: "E",
avatar: { bg: "#F4B8CC", text: "#C44A7A" },
age: 2,
allergies: [],
birthDate: "2 ene 2023",
room: "Soles",
admission: "mar 2025",
parents: [
{ name: "Camila Castro", role: "Mamá", initial: "C", bg: "#F2937A", note: "activa", status: "activa" },
],
},
{
slug: "lucas-romero",
name: "Lucas Romero",
initial: "L",
avatar: { bg: "#A9D9E8", text: "#1F7A93" },
age: 3,
allergies: [],
birthDate: "30 abr 2022",
room: "Soles",
admission: "feb 2024",
parents: [
{ name: "Martín Romero", role: "Papá", initial: "M", bg: "#A9C7E8", note: "activa", status: "activa" },
],
},
{
slug: "olivia-vega",
name: "Olivia Vega",
initial: "O",
avatar: { bg: "#B9DEC4", text: "#3E8B62" },
age: 2,
allergies: [],
birthDate: "21 oct 2023",
room: "Soles",
admission: "ago 2025",
parents: [
{ name: "Julieta Vega", role: "Mamá", initial: "J", bg: "#F4DC8E", note: "activa", status: "activa" },
],
},
];
+19 -19
View File
@@ -1,26 +1,26 @@
@import "tailwindcss"; @import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline { @theme inline {
--color-background: var(--background); --font-sans: var(--font-nunito), system-ui, sans-serif;
--color-foreground: var(--foreground); --font-display: var(--font-fredoka), system-ui, sans-serif;
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
} }
/* Tema claro fijo: la plantilla no define modo oscuro */
body { body {
background: var(--background); background: #F6ECDF;
color: var(--foreground); color: #3F362E;
font-family: Arial, Helvetica, sans-serif; font-family: var(--font-nunito), system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
}
/* Scrollbar estilo plantilla */
::-webkit-scrollbar {
width: 10px;
}
::-webkit-scrollbar-thumb {
background: #E4D6C4;
border-radius: 8px;
border: 3px solid transparent;
background-clip: content-box;
} }
+212
View File
@@ -0,0 +1,212 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import Sidebar from "@/app/components/Sidebar";
import { avatarClasses } from "@/app/components/KidCard";
import type { Kid, Parent, ParentStatus } from "@/app/data/kids";
import { kids } from "@/app/data/kids";
/** Página 100% estática que deriva de params: permitido bloquear, sin Suspense. */
export const instant = false;
/** Los 8 slugs del mock se prerenderizan; cualquier otro cae en notFound(). */
export function generateStaticParams() {
return kids.map((kid) => ({ slug: kid.slug }));
}
/**
* Colores dinámicos → utilities Tailwind con valores exactos.
* El set de fondos del mock es cerrado; nunca se usan atributos style.
*/
const PARENT_BG: Record<string, string> = {
C9B6E8: "bg-[#C9B6E8]",
A9C7E8: "bg-[#A9C7E8]",
F4B8CC: "bg-[#F4B8CC]",
B9DEC4: "bg-[#B9DEC4]",
F2937A: "bg-[#F2937A]",
F4DC8E: "bg-[#F4DC8E]",
};
const STATUS_BADGE: Record<ParentStatus, { label: string; classes: string }> = {
activa: { label: "ACTIVA", classes: "bg-[#CFEBD8] text-[#3E9B6C]" },
pendiente: { label: "PENDIENTE", classes: "bg-[#F7E7A6] text-[#9A7B1E]" },
};
function parentClasses(parent: Parent): string {
return PARENT_BG[parent.bg.replace("#", "")] ?? "";
}
function ParentRow({ parent }: { parent: Parent }) {
const badge = STATUS_BADGE[parent.status];
return (
<div className="flex items-center gap-3">
<div
className={`flex h-10 w-10 flex-none items-center justify-center rounded-full font-display text-base font-semibold text-white ${parentClasses(parent)}`}
>
{parent.initial}
</div>
<div className="min-w-0 flex-1">
<div className="text-[14.5px] font-extrabold text-[#3F362E]">{parent.name}</div>
<div className="text-[12.5px] text-[#A89A8B]">
{parent.role} · {parent.note}
</div>
</div>
<span className={`flex-none rounded-full px-[9px] py-1 text-[10.5px] font-extrabold ${badge.classes}`}>
{badge.label}
</span>
</div>
);
}
export default async function KidProfilePage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const kid: Kid | undefined = kids.find((candidate) => candidate.slug === slug);
if (!kid) notFound();
return (
<div className="flex min-h-screen bg-[#F6ECDF]">
<Sidebar activeHref="/kids" />
<main className="h-screen min-w-0 flex-1 overflow-y-auto">
<div className="mx-auto w-full max-w-[820px] px-10 pb-20 pt-[34px]">
{/* Breadcrumb */}
<Link
href="/kids"
className="mb-5 flex items-center gap-[7px] text-[14px] font-bold text-[#94887B]"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m15 18-6-6 6-6" />
</svg>
Volver a Niños
</Link>
<div className="flex flex-wrap items-start gap-[26px]">
{/* Columna izquierda */}
<div className="flex min-w-[300px] flex-1 flex-col gap-[18px]">
{/* Identidad */}
<div className="flex items-center gap-[18px]">
<div
className={`flex h-[84px] w-[84px] flex-none items-center justify-center rounded-full font-display text-[34px] font-semibold ${avatarClasses(kid)}`}
>
{kid.initial}
</div>
<div className="flex-1">
<h1 className="font-display text-[28px] font-semibold leading-[1.2] text-[#3F362E]">{kid.name}</h1>
<p className="mt-[3px] text-[15px] text-[#94887B]">
{kid.age} años · Sala {kid.room}
</p>
</div>
<Link
href="/agregar-nino"
className="rounded-[12px] border-[1.5px] border-[#ECE0D0] bg-[#FFFDF9] px-4 py-[9px] text-sm font-bold text-[#6E6359]"
>
Editar
</Link>
</div>
{/* Alergias y notas (solo si tiene) */}
{kid.allergyNote && (
<div className="flex gap-[14px] rounded-[16px] bg-[#FBDAD6] px-[18px] py-4">
<div className="flex h-10 w-10 flex-none items-center justify-center rounded-[11px] bg-[#F4A8A0]">
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="#fff"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z" />
<path d="M12 9v4M12 17h.01" />
</svg>
</div>
<div>
<div className="mb-[2px] text-[15px] font-extrabold text-[#C5413A]">Alergias y notas</div>
<div className="text-[14.5px] leading-[1.5] text-[#B25249]">{kid.allergyNote}</div>
</div>
</div>
)}
{/* Datos */}
<div className="overflow-hidden rounded-[16px] border border-[#ECE0D0] bg-[#FFFDF9]">
<div className="flex justify-between border-b border-[#F0E6D8] px-[18px] py-[15px]">
<span className="text-[14.5px] text-[#94887B]">Fecha de nacimiento</span>
<span className="text-[14.5px] font-extrabold text-[#3F362E]">{kid.birthDate}</span>
</div>
<div className="flex justify-between border-b border-[#F0E6D8] px-[18px] py-[15px]">
<span className="text-[14.5px] text-[#94887B]">Sala</span>
<span className="text-[14.5px] font-extrabold text-[#3F362E]">{kid.room}</span>
</div>
<div className="flex justify-between px-[18px] py-[15px]">
<span className="text-[14.5px] text-[#94887B]">Ingreso</span>
<span className="text-[14.5px] font-extrabold text-[#3F362E]">{kid.admission}</span>
</div>
</div>
</div>
{/* Columna derecha */}
<div className="flex w-[300px] flex-none flex-col gap-[14px]">
<Link
href="/resumen-dia"
className="flex w-full items-center justify-center gap-[9px] rounded-[14px] bg-[#3F362E] py-[13px] text-[15px] font-extrabold text-white"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="#fff"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
</svg>
Resumen del día
</Link>
{/* Padres vinculados */}
<div className="rounded-[16px] border border-[#ECE0D0] bg-[#FFFDF9] px-[18px] py-4">
<div className="mb-[14px] text-[12.5px] font-extrabold tracking-[.8px] text-[#8A7C6D]">
PADRES VINCULADOS
</div>
<div className="flex flex-col gap-[14px]">
{kid.parents.map((parent) => (
<ParentRow key={parent.name} parent={parent} />
))}
<Link href="/vincular-padre" className="flex items-center gap-3 pt-2">
<span className="flex h-10 w-10 flex-none items-center justify-center rounded-full border-[1.5px] border-dashed border-[#D8CBBA] text-[#B0A290]">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 5v14M5 12h14" />
</svg>
</span>
<span className="text-[14.5px] font-extrabold text-[#C5503A]">Vincular otro padre</span>
</Link>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
);
}
+43
View File
@@ -0,0 +1,43 @@
import Link from "next/link";
import KidsBrowser from "@/app/components/KidsBrowser";
import Sidebar from "@/app/components/Sidebar";
import { kids } from "@/app/data/kids";
export default function KidsPage() {
return (
<div className="flex min-h-screen bg-[#F6ECDF]">
<Sidebar activeHref="/kids" />
<main className="h-screen min-w-0 flex-1 overflow-y-auto">
<div className="mx-auto w-full max-w-[880px] px-10 pb-20 pt-[34px]">
{/* Encabezado */}
<div className="mb-[22px] flex items-end justify-between gap-4">
<div>
<div className="mb-1 text-[12.5px] font-extrabold tracking-[.8px] text-[#D9583C]">GESTIÓN</div>
<h1 className="font-display text-[30px] font-semibold leading-[1.2] text-[#3F362E]">Niños</h1>
</div>
<Link
href="/agregar-nino"
className="flex items-center gap-2 rounded-[14px] bg-[linear-gradient(180deg,#F4977E,#EE8164)] px-[18px] py-[11px] text-[14.5px] font-extrabold text-white shadow-[0_8px_18px_-8px_rgba(238,129,100,.7)]"
>
<svg
width="17"
height="17"
viewBox="0 0 24 24"
fill="none"
stroke="#fff"
strokeWidth="2.4"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 5v14M5 12h14" />
</svg>
Agregar niño
</Link>
</div>
<KidsBrowser kids={kids} />
</div>
</main>
</div>
);
}
+9 -9
View File
@@ -1,27 +1,27 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Fredoka, Nunito } from "next/font/google";
import "./globals.css"; import "./globals.css";
const geistSans = Geist({ const fredoka = Fredoka({
variable: "--font-geist-sans", variable: "--font-fredoka",
subsets: ["latin"], subsets: ["latin"],
}); });
const geistMono = Geist_Mono({ const nunito = Nunito({
variable: "--font-geist-mono", variable: "--font-nunito",
subsets: ["latin"], subsets: ["latin"],
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Create Next App", title: "OpenDayCare",
description: "Generated by create next app", description: "Feed de la guardería OpenDayCare · Sala Soles",
}; };
export default function RootLayout({ children }: LayoutProps<"/">) { export default function RootLayout({ children }: LayoutProps<"/">) {
return ( return (
<html <html
lang="en" lang="es"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} className={`${fredoka.variable} ${nunito.variable} h-full antialiased`}
> >
<body className="min-h-full flex flex-col">{children}</body> <body className="min-h-full flex flex-col">{children}</body>
</html> </html>
+24 -61
View File
@@ -1,67 +1,30 @@
import Image from "next/image"; import FeedHeader from "@/app/components/FeedHeader";
import PostCard from "@/app/components/PostCard";
import ShareBox from "@/app/components/ShareBox";
import Sidebar from "@/app/components/Sidebar";
import { posts } from "@/app/data/feed";
export default function Home() { export default function Home() {
return ( return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black"> <div className="flex min-h-screen bg-[#F6ECDF]">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start"> <Sidebar activeHref="/" />
<Image <main className="h-screen min-w-0 flex-1 overflow-y-auto">
className="dark:invert h-5 w-[100px]" <div className="mx-auto w-full max-w-[760px] px-10 pb-20 pt-[34px]">
src="/next.svg" <FeedHeader />
alt="Next.js logo" <ShareBox />
width={100}
height={20} <div className="mb-[14px] flex items-center gap-[14px]">
priority <span className="text-[12.5px] font-extrabold tracking-[.8px] text-[#8A7C6D]">
/> PUBLICADO HOY
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left"> </span>
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50"> <span className="h-px flex-1 bg-[#E7DAC8]" />
To get started, edit the{" "} </div>
<code className="rounded bg-black/[.06] px-1.5 py-0.5 font-mono text-[0.9em] dark:bg-white/[.08]">
page.tsx <div className="flex flex-col gap-4">
</code>{" "} {posts.map((post) => (
file. <PostCard key={post.id} post={post} />
</h1> ))}
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400"> </div>
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert h-[14px] w-4"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={14}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div> </div>
</main> </main>
</div> </div>
+6
View File
@@ -0,0 +1,6 @@
# spec workflow configuration
#
# AutoCreateBranch — controls whether /spec-impl creates the git branch automatically.
# true (default) → /spec-impl creates and switches to spec-NN-slug without asking
# false → /spec-impl asks for [y/N] confirmation before creating the branch
AutoCreateBranch: true
+98
View File
@@ -0,0 +1,98 @@
# SPEC 01 — Home: feed de la guardería (plantilla feed.dc.html)
> **Estado:** Verificada (2026-10-20)
> **Depende de:** —
> **Fecha:** 2026-10-19
> **Objetivo:** Implementar la plantilla `references/pantallas/feed.dc.html` como página home (`/`) en Next.js, visualmente idéntica al original, sin autenticación ni base de datos.
## Alcance
**In:**
- Página `/` (`app/page.tsx`) con: sidebar sticky de 248px (logo OpenDayCare · Sala Soles, botón "Nueva publicación", nav con Feed activo + Niños/Avisos/Mi cuenta, tarjeta de usuario "Caro Giménez" con logout), saludo ("GUARDERÍA · SALA SOLES" / "Buenas, Caro" / "12 niños · martes 17 jun"), caja "Compartí un momento…", separador "PUBLICADO HOY" y los 3 posts de la plantilla (logro, actividad con placeholder de foto, anuncio).
- Datos mock tipados en `app/data/feed.ts` con los 3 posts exactos de la plantilla.
- Componentes en `app/components/`: `Sidebar.tsx`, `FeedHeader.tsx`, `ShareBox.tsx`, `PostCard.tsx`.
- Cambio de fuentes en `app/layout.tsx` (Fredoka + Nunito vía `next/font/google`), `lang="es"` y metadata "OpenDayCare".
- Estilos base en `app/globals.css` (fondo #F6ECDF, texto #3F362E, scrollbar custom, tema claro fijo).
- Todos los links como `<Link>` a rutas futuras (`/crear-publicacion`, `/ninos`, `/avisos`, `/mi-cuenta`, `/detalle-publicacion`, `/login`, `/foto` — este último en el placeholder de foto, como `foto.dc.html` en la plantilla).
**Out of scope (para specs futuras):**
- Las páginas destino del nav y sus funcionalidades (hoy darán 404 y está bien).
- Autenticación y logout funcional.
- Base de datos y persistencia.
- Interactividad real de likes, comentarios y editar (solo visual, contadores fijos).
- Responsive móvil.
- Dark mode.
- Foto real en el post de actividad (se mantiene el placeholder dashed de la plantilla).
## Modelo de datos
```ts
// app/data/feed.ts
export type PostCategory = "logro" | "actividad" | "anuncio";
export interface Post {
id: string;
authorName: string; // "Mateo" | "Anuncio general"
authorInitial: string; // "M" (solo posts de niño)
authorColors: { bg: string; text: string }; // p.ej. { bg: "#A9D9E8", text: "#1F7A93" }
timeLabel: string; // "14:20"
publishedBy: string; // "publicado por vos"
category: PostCategory; // define badge y colores vía mapa en PostCard
audience: string; // "familia de Mateo" | "toda la sala"
body: string;
photoLabel?: string; // "pintando con témperas" → placeholder dashed
likes: number;
comments: number;
}
export const posts: Post[] = [
// Los 3 posts de la plantilla: logro (3/1), actividad con foto (5/2), anuncio (8/0)
];
```
Convenciones: los colores van como hex exactos de la plantilla; el mapa `category → colores del badge` vive en `PostCard.tsx`.
## Plan de implementación
1. **Fuentes y layout** — `app/layout.tsx`: reemplazar Geist por Fredoka y Nunito como fuentes variables con `next/font/google` (variables `--font-fredoka` / `--font-nunito`), `lang="es"`, title "OpenDayCare". Test manual: `npm run dev` sigue compilando.
2. **Estilos globales** — `app/globals.css`: body con `background:#F6ECDF`, `color:#3F362E`, `font-family: var(--font-nunito), system-ui, sans-serif`; scrollbar custom (thumb `#E4D6C4`); eliminar el bloque `prefers-color-scheme` y las variables Geist. Test manual: la app ya se ve crema.
3. **Mock del feed** — crear `app/data/feed.ts` con el tipo `Post` y los 3 posts de la plantilla.
4. **Sidebar** — crear `app/components/Sidebar.tsx` con logo, botón "Nueva publicación" (→ `/crear-publicacion`), nav (Feed activo, Niños, Avisos, Mi cuenta) y usuario + logout (→ `/login`).
5. **Componentes del feed** — crear `FeedHeader.tsx`, `ShareBox.tsx` y `PostCard.tsx` con utilities Tailwind de valores exactos.
6. **Página home** — reemplazar `app/page.tsx`: flex con Sidebar + `<main>` de scroll propio, contenido centrado `max-width:760px`, mapeando `posts`. Test manual: `http://localhost:3000` muestra el feed completo.
7. **Fidelidad visual** — comparación lado a lado con `references/pantallas/feed.dc.html` a ancho ≥1280px; ajustar diferencias. Captura de verificación en `.playwright-mcp/`.
## Criterios de aceptación
- [x] `npm run dev` levanta sin errores y `/` muestra el feed completo: sidebar + saludo + caja + separador + 3 posts. *(Verificado 2026-10-20 en `localhost:3001` a 1440×900; consola sin errores de compilación ni hidratación. Capturas: `.playwright-mcp/verify-feed-app-1440.png`, `.playwright-mcp/verify-feed-app-bottom.png`.)*
- [x] `npm run build` completa sin errores. *(Next.js 16.4.2 + Turbopack: compilación, TypeScript y prerender de `/` OK.)*
- [x] A ancho ≥1280px el layout coincide con la plantilla: sidebar 248px sticky, main con scroll propio y contenido centrado de 760px. *(Computed styles: `aside` 248px sticky top-0 h-screen; `main` h-screen overflow-y auto; contenedor max-width 760px centrado — márgenes 211px/211px a 1440px.)*
- [x] Fredoka en logo, saludo y nombres de autor; Nunito (pesos 400–800) en el resto. *(Computed: Fredoka 17px/600 logo, 30px/600 h1, 16.5px/600 autores; Nunito 400 texto, 600 nav inactivo, 700 likes, 800 eyebrow/nav activo. Fuentes variables self-hosted vía `next/font/google`.)*
- [x] Post 1: badge LOGRO (fondo `#CFEBD8`, texto `#3E9B6C`), ♥ 3, 💬 1, link "Editar". *(Computed: `rgb(207,235,216)` / `rgb(62,155,108)`; acciones 3 / 1 / Editar → `/crear-publicacion`.)*
- [x] Post 2: badge ACTIVIDAD (fondo `#C7E7F1`, texto `#2E89A6`), placeholder dashed "Foto · pintando con témperas", ♥ 5, 💬 2. *(Computed: `rgb(199,231,241)` / `rgb(46,137,166)`; placeholder dashed 1.5px `#DBCDBA`, 200px alto, link a `/foto`; acciones 5 / 2.)*
- [x] Post 3: avatar megáfono (fondo `#CCD8F4`), badge ANUNCIO, "Para: toda la sala", ♥ 8, 💬 0. *(Computed: avatar 44px `rgb(204,216,244)` con SVG megáfono idéntico a la plantilla; badge `rgb(204,216,244)` / `rgb(78,114,200)`; acciones 8 / 0.)*
- [x] Todos los links navegan con `<Link>`: `/crear-publicacion` (botón, caja y Editar), `/ninos`, `/avisos`, `/mi-cuenta`, `/detalle-publicacion` (íconos de comentarios), `/login` (logout), `/foto` (placeholder de foto). *(Hrefs del DOM verificados; `next/link` importado en Sidebar, ShareBox y PostCard; sin `<a>` planos.)*
- [x] Colores base exactos: fondo `#F6ECDF`, tarjetas `#FFFDF9` con borde `#ECE0D0`, sombras y radios según plantilla. *(Computed: body `rgb(246,236,223)`; tarjetas `rgb(255,253,249)`, borde 1px `rgb(236,224,208)`, radio 20px, sombra `rgba(120,90,60,.5) 0 4px 16px -12px`; caja 18px.)*
- [x] Con el sistema en modo oscuro la página se mantiene en tema claro (sin dark mode). *(Emulación `prefers-color-scheme: dark` en Playwright: body, aside y tarjetas mantienen sus colores claros. Captura: `.playwright-mcp/verify-feed-darkmode.png`.)*
- [x] Los componentes nuevos no usan atributos `style`; todo está en utilities Tailwind con valores arbitrarios exactos. *(Grep `style=` en `app/**/*.{tsx,css}`: 0 resultados; los únicos `[style]` del DOM son internos de Next.js — `nextjs-portal`, `next-route-announcer` — fuera de los componentes.)*
## Decisiones
- **Sí:** Tailwind 4 con valores arbitrarios exactos (`bg-[#F6ECDF]`, `shadow-[0_4px_16px_-12px_rgba(120,90,60,.5)]`…) — consistente con el proyecto y pixel-perfect.
- **No:** estilos inline literales ni CSS Modules — divergen de la convención del repo.
- **Sí:** componentes separados en `app/components/` — el sidebar se reutiliza en Niños, Avisos y Mi cuenta.
- **Sí:** mock tipado en `app/data/feed.ts` — deja el terreno listo para la BD futura.
- **Sí:** `<Link>` a rutas futuras aunque hoy den 404 — la navegación queda cableada desde el día uno.
- **Sí:** solo desktop, réplica exacta — el móvil merece decisiones de diseño propias (otra spec).
- **Sí:** `next/font/google` para Fredoka y Nunito como fuentes variables — misma familia y pesos que el `<link>` de la plantilla, self-hosted y sin FOUT.
- **Sí:** tema claro fijo, eliminando el dark mode automático de `globals.css` — la plantilla no define modo oscuro.
- **No:** autenticación, BD, likes/comentarios/editar funcionales ni foto real — excluidos explícitamente por el usuario.
## Qué **no** está en esta spec
- Otras páginas (Niños, Avisos, Mi cuenta, crear/editar publicación, detalle, login) — cada una irá en su propia spec.
- Autenticación y base de datos.
- Likes, comentarios y edición funcionales.
- Responsive móvil y dark mode.
+114
View File
@@ -0,0 +1,114 @@
# SPEC 02 — Niños: listado y perfil (`/kids` y `/kids/[slug]`)
> **Estado:** Verificada (2026-10-20)
> **Depende de:** SPEC 01
> **Fecha:** 2026-10-20
> **Objetivo:** Implementar las plantillas `references/pantallas/ninos.dc.html` y `references/pantallas/perfil-nino.dc.html` como `/kids` y `/kids/[slug]`, visualmente idénticas, con mock de 8 niños con ficha completa y buscador funcional.
## Alcance
**In:**
- Página `/kids` (`app/kids/page.tsx`): sidebar con "Niños" activo, encabezado (eyebrow "GESTIÓN" + título "Niños"), botón "Agregar niño" (→ `/agregar-nino`), buscador y grilla de 2 columnas con las 8 tarjetas de la plantilla.
- Buscador **funcional** (filtro client-side): filtra por nombre mientras se escribe, el contador "8 niños" se actualiza y muestra estado vacío si no hay coincidencias.
- Página `/kids/[slug]` (`app/kids/[slug]/page.tsx`): breadcrumb "Volver a Niños" (→ `/kids`), avatar 84px + nombre + "3 años · Sala Soles", botón "Editar" (→ `/agregar-nino`), recuadro naranja "Alergias y notas" (solo si el niño tiene notas), tarjeta de datos (nacimiento / sala / ingreso), columna derecha con botón "Resumen del día" (→ `/resumen-dia`), tarjeta "PADRES VINCULADOS" (badges ACTIVA/PENDIENTE) y link "Vincular otro padre" (→ `/vincular-padre`).
- Las 8 fichas de perfil existen: cada slug renderiza los datos propios del niño (nombre, avatar, alergias, fechas y padres), coherentes con la lista.
- Mock tipado en `app/data/kids.ts` con los 8 niños de la plantilla (Mateo con los datos exactos del perfil; los otros 7 con datos inventados coherentes con su fila de la lista).
- Componentes nuevos en `app/components/`: `KidCard.tsx` (tarjeta de la lista) y `KidsBrowser.tsx` (cliente: buscador + contador + grilla + estado vacío).
- Update de `app/components/Sidebar.tsx`: el link "Niños" pasa de `/ninos` a `/kids` (única referencia existente; SPEC 01 la cableó así).
- Hover de tarjeta según plantilla: borde `#F2A78E` + `translateY(-2px)` con transición de 150ms.
**Out of scope (para specs futuras):**
- Páginas `/agregar-nino`, `/vincular-padre` y `/resumen-dia` (hoy dan 404 y está bien).
- Alta/edición real de niños y vinculación real de padres.
- Autenticación, base de datos y persistencia.
- Responsive móvil y dark mode.
## Modelo de datos
```ts
// app/data/kids.ts
export type ParentStatus = "activa" | "pendiente";
export interface Parent {
name: string; // "Lucía Fernández"
role: string; // "Mamá" | "Papá"
initial: string; // "L"
bg: string; // "#C9B6E8"
status: ParentStatus; // badge ACTIVA (verde) | PENDIENTE (amarillo)
}
export interface Kid {
slug: string; // "mateo-fernandez"
name: string; // "Mateo Fernández"
initial: string; // "M"
avatar: { bg: string; text: string }; // { bg: "#A9D9E8", text: "#1F7A93" }
age: number; // 3 → se renderiza "3 años"
allergies: string[]; // ["MANÍ"] — badges de la lista (vacos = sin badge)
allergyNote?: string; // texto del recuadro del perfil (si existe → se muestra)
birthDate: string; // "12 mar 2022"
room: string; // "Soles"
admission: string; // "feb 2025"
parents: Parent[];
}
```
- Los 8 niños con sus colores exactos de la plantilla: Mateo `#A9D9E8/#1F7A93`, Sofía `#F4B8CC/#C44A7A`, Benjamín `#B9DEC4/#3E8B62`, Valentina `#F4DC8E/#9A7B1E`, Tomás `#C9B6E8/#7B5FC0`, Emma `#F4B8CC/#C44A7A`, Lucas `#A9D9E8/#1F7A93`, Olivia `#B9DEC4/#3E8B62`.
- Regla del badge lateral en la lista (deducida de la plantilla): sin padres → badge "VINCULAR" (`#F9D2DE`/`#C56486`); con alergia → primera alergia (`#FBD8CC`/`#D9684A`); resto → chevron `#CBB89F`.
- Padres de Mateo (exactos de la plantilla): Lucía Fernández (Mamá, `#C9B6E8`, activa) y Diego Fernández (Papá, `#A9C7E8`, pendiente "invitación enviada").
Convenciones heredadas de SPEC 01: colores como hex exactos en utilities Tailwind arbitrarias; cero atributos `style`; todo server components salvo `KidsBrowser`.
## Plan de implementación
1. **Mock** — crear `app/data/kids.ts` con los tipos y los 8 niños. Test manual: `npm run dev` compila.
2. **KidCard** — crear `app/components/KidCard.tsx`: avatar circular, nombre, "X años · N padre(s) vinculado(s)", badge según la regla y link a `/kids/[slug]` con hover de plantilla. Test manual: se ve correcta suelta en cualquier página temporal.
3. **KidsBrowser (cliente)** — crear `app/components/KidsBrowser.tsx`: input "Buscar niño…", fila "SALA SOLES + contador + línea divisoria", grilla `grid-cols-2` filtrada por nombre (comparación sin mayúsculas ni acentos vía `normalize("NFD")`) y mensaje de estado vacío.
4. **Página /kids** — crear `app/kids/page.tsx`: Sidebar `activeHref="/kids"`, encabezado, botón "Agregar niño" y `<KidsBrowser kids={kids}>`. Test manual: `http://localhost:3000/kids` muestra las 8 tarjetas y el filtro funciona.
5. **Sidebar** — en `app/components/Sidebar.tsx` cambiar el href de "Niños" de `/ninos` a `/kids`. Test manual: el link navega y ninguna página queda rota.
6. **Página /kids/[slug]** — crear `app/kids/[slug]/page.tsx` con `generateStaticParams` y `notFound()`: en Next.js 16 `params` es una Promise (`const { slug } = await params`). Layout de dos columnas (columna izquierda flexible + derecha fija de 300px), ficha completa, recuadro de alergias condicionado y tarjeta de padres con badges ACTIVA/PENDIENTE. Test manual: `/kids/mateo-fernandez` replica la plantilla y `/kids/valentina-soto` muestra su ficha sin recuadro de alergias ni padres.
7. **Fidelidad visual** — comparación lado a lado de ambas pantallas contra las plantillas a ancho ≥1280px; ajustar diferencias. Capturas de verificación en `.playwright-mcp/`.
## Criterios de aceptación
- [x] `npm run dev` levanta sin errores y `/kids` muestra eyebrow "GESTIÓN", título "Niños", botón "Agregar niño", buscador y las 8 tarjetas en grilla de 2 columnas. *(Verificado 2026-10-20 en `localhost:3001` a 1440×900; consola limpia tras el opt-out `instant = false`. Computed: h1 "Niños" Fredoka 30px, `grid-template-columns: 393px 393px`, 8 tarjetas. Captura: `.playwright-mcp/verify-kids-final.png`.)*
- [x] `npm run build` completa sin errores, incluyendo el prerender de `/kids` y de los 8 slugs. *(Next.js 16.4.2 + Turbopack: compilación y TypeScript OK, 14 páginas estáticas — `/kids` ○ y los 8 slugs listados como estáticos.)*
- [x] Escribir "sof" en el buscador deja solo la tarjeta de Sofía Méndez, el contador se actualiza y escribir algo sin coincidencias muestra el estado vacío. *(Computed: "sof" → 1 tarjeta "Sofía Méndez" y contador "1 niño"; "zzzqx" → 0 tarjetas, mensaje "No hay niños que coincidan con la búsqueda." y "0 niños".)*
- [x] Escribir "sofia" (sin acento) también encuentra a Sofía Méndez. *(Computed: "sofia" sin acento devuelve exactamente esa tarjeta.)*
- [x] Cada tarjeta enlaza con `<Link>` al slug de su niño (8 hrefs distintos) y el badge es correcto: Mateo "MANÍ", Tomás "LACTOSA", Valentina "VINCULAR", resto chevron. *(Computed: 8 hrefs `/kids/<slug>` únicos; badges correctos; `next/link` importado en `KidCard` y 0 `<a>` planos en los archivos nuevos.)*
- [x] `/kids/mateo-fernandez` replica la plantilla: breadcrumb, avatar 84px "M", nombre, "3 años · Sala Soles", botón "Editar", recuadro "Alergias y notas" con el texto exacto ("Alergia al maní. Evitar frutos secos. Lleva inhalador en la mochila."), filas nacimiento/sala/ingreso, botón "Resumen del día" y tarjeta "PADRES VINCULADOS" con Lucía (ACTIVA, verde) y Diego (PENDIENTE, amarillo) + "Vincular otro padre". *(Computed: breadcrumb → `/kids`, avatar 84×84 Fredoka "M", texto de alergias literal, filas "12 mar 2022 / Soles / feb 2025", badges `rgb(207,235,216)`/`rgb(62,155,108)` y `rgb(247,231,166)`/`rgb(154,123,30)`. Captura: `.playwright-mcp/verify-perfil-final.png`; comparación visual contra `perfil-nino.dc.html` sin diferencias.)*
- [x] Los otros 7 slugs muestran la ficha propia de cada niño (nombre, avatar, edad, padres y alergias coherentes con la lista); Valentina no tiene recuadro de alergias ni padres listados. *(Fetch del HTML SSR de cada slug: nombre, inicial de avatar y edad·sala correctos; recuadro de alergias solo en Tomás; filas de padres 1/2/0/1/1/1/1 según la lista; Valentina sin recuadro y sin badges.)*
- [x] `/kids/no-existe` responde 404. *(HTTP 404 real con página de not-found; patrón `generateStaticParams` + `notFound()` tras `await params` validado contra la doc de Next.js (Context7) y `node_modules/next/dist/docs/`.)*
- [x] En el sidebar, "Niños" apunta a `/kids` y aparece activo (fondo `#FBE3D8`, texto `#D9583C`) tanto en `/kids` como en `/kids/[slug]`; en `/` sigue activo "Feed". *(Computed en `/kids`, `/kids/tomas-diaz` y `/`: activo "Niños" con `rgb(251,227,216)`/`rgb(217,88,60)` en lista y ficha; en `/` el activo es "Feed" y "Niños" queda inactivo `rgb(110,99,89)`.)*
- [x] Los links futuros del perfil navegan con `<Link>`: Editar → `/agregar-nino`, Resumen del día → `/resumen-dia`, Vincular otro padre → `/vincular-padre` (hoy 404, aceptado). *(Computed: los 3 hrefs presentes en el DOM vía `next/link`; destinos 404 según lo acordado.)*
- [x] A ancho ≥1280px ambas pantallas coinciden con las plantillas: lista con contenido centrado de 880px y perfil de 820px con columna derecha de 300px. *(Computed a 1440px: lista `max-width: 880px` centrada (left 404 = 248 de sidebar + 156), perfil 820px y columna 300px. Comparación visual lado a lado contra `ninos.dc.html` y `perfil-nino.dc.html` sin diferencias: `.playwright-mcp/spec02-ref-ninos.png` vs `.playwright-mcp/spec02-app-ninos-v2.png`, `.playwright-mcp/spec02-ref-perfil.png` vs `.playwright-mcp/spec02-app-perfil.png`.)*
- [x] Los archivos nuevos no usan atributos `style`; todo es Tailwind con valores arbitrarios exactos. *(Grep `style=` en `app/`: 0 resultados; `tsc --noEmit` y `eslint` limpios sobre los 6 archivos.)*
## Decisiones
- **Sí:** rutas `/kids` y `/kids/[slug]` — decisión explícita del usuario aunque el sidebar decía `/ninos`; se actualiza `Sidebar.tsx` (única referencia, así ningún criterio de SPEC 01 queda huérfano sin notarlo).
- **Sí:** los 8 niños con ficha completa en `app/data/kids.ts` — el perfil es una página por slug real, no un placeholder clonado.
- **Sí:** datos inventados para los otros 7 perfiles, pero coherentes con la lista (misma edad, misma cantidad de padres, mismas alergias); solo Mateo conserva los datos literales de la plantilla.
- **Sí:** buscador funcional con un único componente cliente (`KidsBrowser`) — es la primera pieza interactiva del repo y queda aislada; el resto sigue siendo server components.
- **Sí:** búsqueda solo por nombre, insensible a mayúsculas y acentos — la plantilla dice "Buscar niño…" y en español los acentos son el caso de uso obvio.
- **Sí:** regla de badge deducida de la plantilla (sin padres → VINCULAR, con alergia → alergia, resto → chevron) — los 3 casos existen en el diseño original.
- **Sí:** recuadro de alergias solo cuando `allergyNote` existe — la plantilla no define cómo se ve un perfil sin alergias y omitirlo es lo más fiel.
- **Sí:** perfil compuesto directo en `page.tsx` sin componentes de un solo uso — solo se extraen `KidCard` y `KidsBrowser`, que se reutilizan o aíslan estado.
- **Sí:** `generateStaticParams` + `notFound()` — mock estático: los 8 slugs se prerenderizan y cualquier otro slug da 404 real.
- **No:** filtrar por sala, edad o alergia — no está en la plantilla.
- **No:** páginas `/agregar-nino`, `/vincular-padre` ni `/resumen-dia` — specs futuras.
## Riesgos
| Riesgo | Mitigación |
| --- | --- |
| El buscador cliente rompe la convención server-only del repo | `KidsBrowser` aísla el estado; `KidCard` sigue siendo puro y reutilizable |
| Búsqueda lenta o hidratación inconsistente al filtrar | Filtrado en memoria sobre 8 items; el grid se deriva del mismo array tipado |
| Links a páginas inexistentes confunden en la verificación | Documentado en Out of scope y en criterios: 404 esperado, igual que SPEC 01 |
## Qué **no** está en esta spec
- Páginas agregar/editar niño, vincular padre y resumen del día — cada una en su propia spec.
- Autenticación, base de datos y persistencia.
- Responsive móvil y dark mode.