{"manifest":{"id":"who-does-the-dishes","version":"1.0.0","name":"Who does the dishes?","description":"Pick who's home, press Continue, and let the app choose tonight's dishwasher.","entrypoint":"index.html","data_access":{"reads":["family.members"],"writes":[]},"permissions":{"default_audience":"everyone","requires_approval":false},"runtime":"static","icon":"🍽️","nav":{"label":"Dishes"}},"files":{"index.html":"<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"UTF-8\" />\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n<title>Who does the dishes?</title>\n<style>\n  * { box-sizing: border-box; margin: 0; padding: 0; }\n  body {\n    background: var(--hub-bg, #f9fafb); color: var(--hub-text, #111);\n    font-family: var(--hub-font, -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif);\n    font-size: var(--hub-font-size, 16px); line-height: 1.5;\n  }\n  .app { max-width: 560px; margin: 0 auto; padding: 20px 16px 32px; display: grid; gap: 16px; }\n  h1 { font-size: 1.4rem; }\n  .hint { color: var(--hub-text-muted, #6b7280); }\n  .card {\n    background: var(--hub-surface, #fff); border: 1px solid var(--hub-border, #e5e7eb);\n    border-radius: var(--hub-radius, 12px); padding: 14px 16px;\n  }\n  .people { display: grid; gap: 6px; }\n  label.person {\n    display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-radius: 10px; cursor: pointer;\n    border: 1px solid transparent;\n  }\n  label.person:hover { background: var(--hub-bg, #f9fafb); }\n  label.person input { width: 20px; height: 20px; accent-color: var(--hub-primary, #111827); }\n  label.person .name { font-weight: 600; font-size: 1.05rem; }\n  label.person .role { color: var(--hub-text-muted, #6b7280); font-size: .9rem; margin-left: auto; }\n  label.person.away .name { color: var(--hub-text-muted, #6b7280); text-decoration: line-through; }\n  .actions { display: flex; justify-content: flex-end; gap: 10px; }\n  button {\n    font: inherit; font-weight: 700; border: 0; border-radius: 10px; padding: 10px 18px; cursor: pointer;\n    background: var(--hub-primary, #111827); color: var(--hub-primary-fg, #fff);\n  }\n  button.secondary { background: transparent; color: var(--hub-text, #111); border: 1px solid var(--hub-border, #e5e7eb); }\n  button:disabled { opacity: .5; cursor: default; }\n\n  .wheel { display: grid; gap: 10px; }\n  .slot {\n    padding: 16px 18px; border-radius: 12px; font-size: 1.25rem; font-weight: 700; text-align: center;\n    background: var(--hub-bg, #f9fafb); border: 2px solid var(--hub-border, #e5e7eb); color: var(--hub-text-muted, #6b7280);\n    transition: background .08s, color .08s, transform .08s, border-color .08s;\n  }\n  .slot.lit {\n    background: var(--hub-primary, #111827); color: var(--hub-primary-fg, #fff); border-color: var(--hub-primary, #111827);\n    transform: scale(1.03);\n  }\n  .slot.winner {\n    background: var(--hub-tint-success-bg, #dcfce7); color: var(--hub-tint-success-fg, #166534);\n    border-color: var(--hub-tint-success-fg, #166534); transform: scale(1.06);\n  }\n  .result { text-align: center; font-size: 1.5rem; font-weight: 800; min-height: 2.2em; }\n  .result small { display: block; font-size: 1rem; font-weight: 500; color: var(--hub-text-muted, #6b7280); }\n  .error { color: var(--hub-tint-danger-fg, #b91c1c); }\n</style>\n</head>\n<body>\n<div class=\"app\" id=\"root\">Loading…</div>\n<script type=\"module\">\nimport { esc } from \"/hub-sdk.js\";\n\nconst CONTEXT = window.__CONTEXT_URL ?? \"\";\n// The house rule. Lowercase, trimmed, so \"Tim\" and \"tim \" both count.\nconst ALWAYS = \"tim\";\n\nlet members = [];\nlet present = new Set();\nlet view = \"pick\";   // pick | spin | done\nlet winner = null;\nlet error = \"\";\n\nasync function load() {\n  try {\n    if (CONTEXT) {\n      const res = await fetch(CONTEXT + \"?keys=family.members\");\n      members = (await res.json())[\"family.members\"] ?? [];\n    }\n    present = new Set(members.map((m) => m.id));\n  } catch (e) {\n    error = e.message;\n  }\n  render();\n}\n\nfunction roleLabel(role) { return role === \"child\" ? \"kid\" : \"adult\"; }\n\nfunction renderPick() {\n  const rows = members.map((m) =>\n    '<label class=\"person' + (present.has(m.id) ? \"\" : \" away\") + '\">' +\n      '<input type=\"checkbox\" data-id=\"' + esc(m.id) + '\"' + (present.has(m.id) ? \" checked\" : \"\") + ' />' +\n      '<span class=\"name\">' + esc(m.name) + '</span>' +\n      '<span class=\"role\">' + esc(roleLabel(m.role)) + '</span>' +\n    '</label>').join(\"\");\n  return '<h1>Who does the dishes?</h1>' +\n    '<p class=\"hint\">Everyone is in by default. Uncheck anyone who is not here tonight.</p>' +\n    '<div class=\"card\"><div class=\"people\">' + rows + '</div></div>' +\n    '<div class=\"actions\"><button id=\"continue\"' + (present.size === 0 ? \" disabled\" : \"\") + '>Continue</button></div>';\n}\n\nfunction renderWheel(litId, winnerId) {\n  const names = members.filter((m) => present.has(m.id));\n  return '<h1>Who does the dishes?</h1>' +\n    '<div class=\"wheel\">' + names.map((m) =>\n      '<div class=\"slot' + (m.id === winnerId ? \" winner\" : m.id === litId ? \" lit\" : \"\") + '\" data-slot=\"' + esc(m.id) + '\">' + esc(m.name) + '</div>').join(\"\") +\n    '</div>' +\n    '<div class=\"result\" id=\"result\">' + (winnerId\n      ? \"It's \" + esc(names.find((m) => m.id === winnerId).name) + \"'s turn to do the dishes.\" + '<small>No arguments. The app has spoken.</small>'\n      : '<small>Choosing…</small>') + '</div>' +\n    (winnerId ? '<div class=\"actions\"><button class=\"secondary\" id=\"again\">Pick again</button></div>' : \"\");\n}\n\nfunction render() {\n  const root = document.getElementById(\"root\");\n  const err = error ? '<p class=\"error\">' + esc(error) + \"</p>\" : \"\";\n  if (view === \"pick\") root.innerHTML = err + renderPick();\n  else if (view === \"spin\") root.innerHTML = err + renderWheel(null, null);\n  else root.innerHTML = err + renderWheel(null, winner);\n}\n\nfunction chooseWinner(candidates) {\n  const rigged = candidates.find((m) => (m.name || \"\").trim().toLowerCase() === ALWAYS);\n  if (rigged) return rigged;\n  return candidates[Math.floor(Math.random() * candidates.length)];\n}\n\nconst sleep = (ms) => new Promise((r) => setTimeout(r, ms));\n\nasync function spin() {\n  const candidates = members.filter((m) => present.has(m.id));\n  if (candidates.length === 0) return;\n  const target = chooseWinner(candidates);\n  // The spin is script-driven motion, so it is ours to skip for anyone who\n  // asked the hub for reduced motion: go straight to the answer.\n  if (document.documentElement.classList.contains(\"hub-reduce-motion\")) {\n    winner = target.id; view = \"done\"; render();\n    return;\n  }\n  view = \"spin\"; winner = null; render();\n\n  // Fast laps first, then a long deceleration that ends exactly on the target.\n  // The lap count varies so two spins never look alike.\n  const laps = 3 + Math.floor(Math.random() * 3);\n  const targetIndex = candidates.indexOf(target);\n  const total = laps * candidates.length + targetIndex;\n  const slowSteps = Math.min(12, candidates.length * 3);\n  for (let step = 0; step <= total; step++) {\n    const lit = candidates[step % candidates.length].id;\n    for (const el of document.querySelectorAll(\".slot\")) el.classList.toggle(\"lit\", el.dataset.slot === lit);\n    const remaining = total - step;\n    const delay = remaining > slowSteps ? 70 : 70 + Math.pow((slowSteps - remaining) / slowSteps, 2) * 620;\n    await sleep(delay);\n  }\n  await sleep(350);\n  winner = target.id; view = \"done\"; render();\n}\n\ndocument.addEventListener(\"change\", (event) => {\n  const box = event.target.closest(\"input[type=checkbox][data-id]\");\n  if (!box) return;\n  if (box.checked) present.add(box.dataset.id); else present.delete(box.dataset.id);\n  render();\n});\ndocument.addEventListener(\"click\", (event) => {\n  const button = event.target.closest(\"button\");\n  if (!button) return;\n  if (button.id === \"continue\") spin();\n  else if (button.id === \"again\") { view = \"pick\"; winner = null; render(); }\n});\n\nload();\n</script>\n</body>\n</html>\n"}}
