{
  "schema": "app-studio/canvas@1",
  "name": "Decision Matrix.html",
  "code": "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\" />\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n<title>Decision Matrix — a KIT App by Halison</title>\n<!--\n  Decision Matrix · a KIT App by Halison\n\n  Weighted scoring for a decision between a handful of options, with a\n  sensitivity check: how far the weights have to move before the ranking\n  flips. The arithmetic is trivial; the sensitivity number is the part\n  worth having.\n\n  Self-contained: one file, no dependencies, no network. Work is kept in\n  localStorage so a reopened app still has the decision in it. ?embed=1\n  skips the splash for the in-article preview.\n-->\n<style>\n  :root {\n    --bg:#07070a; --panel:#101018; --line:rgba(255,255,255,0.09);\n    --fg:#f4f4f6; --dim:#9a9aa8; --indigo:#6E97FF; --win:#4ade80;\n    --font: ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif;\n    --mono: var(--font);\n  }\n  * { box-sizing:border-box; }\n  html { color-scheme: dark; }\n  * { scrollbar-width: thin; scrollbar-color: rgba(255,255,255,0.2) transparent; }\n  ::-webkit-scrollbar { width: 8px; height: 8px; }\n  ::-webkit-scrollbar-track { background: transparent; }\n  ::-webkit-scrollbar-thumb {\n    background: rgba(255,255,255,0.16); border-radius: 99px;\n    border: 2px solid transparent; background-clip: content-box;\n  }\n  ::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.28); background-clip: content-box; }\n  ::-webkit-scrollbar-corner { background: transparent; }\n  html.embed, html.embed body { font-variant-numeric: tabular-nums; height: auto; min-height: 0; overflow: visible; }\n  html.embed #splash { display: none !important; }\n  html.embed header { display: none; }\n  html.embed .app, html.embed .app.on { min-height: 0; height: auto; }\n  html.embed .wrap { overflow: visible; }\n  @media (max-width: 760px) {\n    html.embed .wrap { padding: 14px; }\n  }\n  html, body { height:100%; }\n  body { margin:0; background:var(--bg); color:var(--fg); font-family:var(--font); font-size:14px; line-height:1.5; -webkit-font-smoothing:antialiased; }\n  button { font:inherit; color:inherit; cursor:pointer; }\n  input { font:inherit; }\n\n  #splash { position:fixed; inset:0; z-index:50; display:grid; place-items:center; text-align:center; padding:24px;\n    background: radial-gradient(900px 500px at 50% 22%, rgba(110,151,255,0.16), transparent 70%), var(--bg); }\n  #splash h1 { font-size:clamp(24px,5vw,34px); font-weight:800; letter-spacing:-0.03em; text-transform:uppercase; margin:0 0 14px; }\n  #splash p { color:var(--dim); max-width:460px; margin:0 auto 30px; }\n  .by { font-size:11px; letter-spacing:0.22em; text-transform:uppercase; color:var(--dim); margin-bottom:24px; }\n  .btn-primary { background:var(--indigo); color:#07070a; border:0; font-weight:700; padding:11px 30px; border-radius:999px; }\n  .btn-primary:hover { filter:brightness(1.1); }\n\n  .app { display:none; min-height:100%; flex-direction:column; }\n  .app.on { display:flex; }\n  header { display:flex; align-items:center; gap:16px; padding:14px 20px; border-bottom:1px solid var(--line); flex-wrap:wrap; }\n  header h2 { margin:0; font-size:15px; font-weight:700; }\n  header .sub { color:var(--dim); font-size:12px; }\n  .spacer { flex:1 1 auto; }\n  .attrib { font-size:10px; letter-spacing:0.18em; text-transform:uppercase; color:var(--dim); background:none; border:1px solid var(--line); padding:5px 11px; border-radius:999px; }\n  .attrib:hover { color:var(--fg); border-color:rgba(255,255,255,0.25); }\n\n  .wrap { padding:20px; overflow:auto; flex:1 1 auto; }\n  .verdict { margin:0 0 22px; }\n  .verdict .lead { font-size:clamp(22px,4vw,30px); font-weight:800; letter-spacing:-0.025em; margin:0 0 8px; }\n  .verdict .lead b { color:var(--win); }\n  .verdict p { color:var(--dim); font-size:13px; margin:0; max-width:64ch; }\n\n  table { border-collapse:collapse; width:100%; min-width:520px; }\n  th, td { padding:9px 10px; border-top:1px solid var(--line); text-align:left; vertical-align:middle; }\n  thead th { border-top:0; font-size:10px; letter-spacing:0.18em; text-transform:uppercase; color:var(--dim); font-weight:600; }\n  .scroll { overflow-x:auto; }\n  input[type=text] {\n    background:transparent; border:1px solid transparent; color:var(--fg);\n    padding:5px 8px; border-radius:7px; width:100%; min-width:110px;\n  }\n  input[type=text]:hover { border-color:var(--line); }\n  input[type=text]:focus { outline:none; border-color:var(--indigo); background:var(--panel); }\n  input[type=number] {\n    background:var(--panel); border:1px solid var(--line); color:var(--fg);\n    padding:5px 7px; border-radius:7px; width:62px; font-family:var(--mono); font-size:12.5px; text-align:right;\n  }\n  input[type=number]:focus { outline:none; border-color:var(--indigo); }\n  .wt { display:flex; align-items:center; gap:8px; }\n  .wt input[type=range] { width:92px; accent-color:var(--indigo); }\n  .pctw { font-family:var(--mono); font-size:11px; color:var(--dim); min-width:38px; }\n\n  .del { background:none; border:0; color:var(--dim); padding:3px 7px; border-radius:6px; font-size:15px; line-height:1; }\n  .del:hover { color:#f87171; background:rgba(248,113,113,0.1); }\n  .add { background:var(--panel); border:1px solid var(--line); color:var(--dim); padding:7px 14px; border-radius:999px; font-size:12.5px; }\n  .add:hover { color:var(--fg); border-color:rgba(255,255,255,0.25); }\n  .toolbar { display:flex; gap:9px; flex-wrap:wrap; margin:14px 0 26px; }\n\n  .ranking { margin-top:8px; }\n  .ranking h3, .sens h3 { font-size:10px; letter-spacing:0.2em; text-transform:uppercase; color:var(--dim); margin:0 0 12px; font-weight:600; }\n  .rank-row { display:flex; align-items:center; gap:12px; padding:9px 0; border-top:1px solid var(--line); }\n  .rank-row .n { font-family:var(--mono); font-size:11px; color:var(--dim); width:16px; }\n  .rank-row .nm { width:32%; min-width:110px; font-size:13.5px; }\n  .rank-row .track { flex:1 1 auto; height:8px; background:rgba(255,255,255,0.05); border-radius:5px; overflow:hidden; }\n  .rank-row .fill { height:100%; background:var(--indigo); border-radius:5px; }\n  .rank-row:first-of-type .fill { background:var(--win); }\n  .rank-row .sc { font-family:var(--mono); font-size:12.5px; width:52px; text-align:right; }\n\n  .sens { margin-top:26px; padding-top:18px; border-top:1px solid var(--line); }\n  .sens p { color:var(--dim); font-size:12.5px; margin:0; max-width:64ch; }\n  .sens b { color:var(--fg); }\n\n  #about { position:fixed; inset:0; z-index:60; display:none; place-items:center; background:rgba(0,0,0,0.72); padding:20px; }\n  #about.on { display:grid; }\n  .card { background:#16161f; border:1px solid var(--line); border-radius:18px; padding:26px; max-width:440px; width:100%; }\n  .card h3 { margin:0 0 4px; font-size:17px; }\n  .card .kicker { font-size:10px; letter-spacing:0.22em; text-transform:uppercase; color:var(--dim); margin-bottom:14px; }\n  .card p { color:var(--dim); font-size:13px; margin:0 0 14px; }\n  .card dl { display:grid; grid-template-columns:auto 1fr; gap:6px 18px; font-size:12.5px; margin:0 0 18px; }\n  .card dt { color:var(--dim); } .card dd { margin:0; font-family:var(--mono); }\n  .card a { color:var(--indigo); text-decoration:none; } .card a:hover { text-decoration:underline; }\n  .actions { display:flex; gap:10px; flex-wrap:wrap; }\n  .btn-ghost { background:none; border:1px solid var(--line); padding:8px 16px; border-radius:999px; font-size:13px; }\n</style>\n</head>\n<body>\n\n<div id=\"splash\">\n  <div>\n    <div class=\"by\">A KIT App by Halison</div>\n    <h1>Decision Matrix</h1>\n    <p>Score the options against criteria you name first, then find out how much the answer depends on weights you half-guessed.</p>\n    <button class=\"btn-primary\" id=\"start\">Start</button>\n  </div>\n</div>\n\n<div class=\"app\" id=\"app\">\n  <header>\n    <div>\n      <h2>Decision Matrix</h2>\n      <div class=\"sub\">Weighted scoring with a sensitivity check</div>\n    </div>\n    <div class=\"spacer\"></div>\n    <button class=\"attrib\" id=\"openAbout\">KIT Apps · Halison</button>\n  </header>\n\n  <div class=\"wrap\">\n    <div class=\"verdict\" id=\"verdict\"></div>\n    <div class=\"scroll\"><table id=\"grid\"></table></div>\n    <div class=\"toolbar\">\n      <button class=\"add\" id=\"addOption\">Add option</button>\n      <button class=\"add\" id=\"addCriterion\">Add criterion</button>\n      <button class=\"add\" id=\"reset\">Reset</button>\n    </div>\n    <div class=\"ranking\" id=\"ranking\"></div>\n    <div class=\"sens\" id=\"sens\"></div>\n  </div>\n</div>\n\n<div id=\"about\" role=\"dialog\" aria-modal=\"true\">\n  <div class=\"card\">\n    <div class=\"kicker\">About this KIT App</div>\n    <h3>Decision Matrix</h3>\n    <p>A weighted sum, plus the number that actually matters: how far the weights have to move before the winner changes. Your work stays on this machine.</p>\n    <dl><dt>Version</dt><dd>1.0.0</dd><dt>Publisher</dt><dd>Halison</dd><dt>Format</dt><dd>.kitapp</dd><dt>Runs</dt><dd>locally</dd></dl>\n    <div class=\"actions\">\n      <a class=\"btn-ghost\" href=\"https://halison.io/resources/tools\" target=\"_blank\" rel=\"noopener\">More KIT Apps</a>\n      <button class=\"btn-ghost\" id=\"closeAbout\">Close</button>\n    </div>\n  </div>\n</div>\n\n<script>\n(() => {\n  \"use strict\";\n  const $ = (id) => document.getElementById(id);\n  const KEY = \"halison.kitapp.decision-matrix.v1\";\n\n  const SEED = {\n    criteria: [\n      { name: \"Cost to run\", weight: 3 },\n      { name: \"Time to deliver\", weight: 4 },\n      { name: \"Risk if it goes wrong\", weight: 5 },\n      { name: \"Fit with the team\", weight: 2 },\n    ],\n    options: [\n      { name: \"Build it ourselves\", scores: [4, 3, 5, 8] },\n      { name: \"Buy the platform\", scores: [6, 8, 7, 5] },\n      { name: \"Do nothing for now\", scores: [9, 9, 3, 4] },\n    ],\n  };\n\n  let state = load() || structuredClone(SEED);\n\n  function load() {\n    try {\n      const raw = localStorage.getItem(KEY);\n      if (!raw) return null;\n      const parsed = JSON.parse(raw);\n      // Shape check rather than blind trust: a stored value from an older\n      // build should fall back to the seed, not crash the app on open.\n      if (!Array.isArray(parsed?.criteria) || !Array.isArray(parsed?.options)) return null;\n      return parsed;\n    } catch { return null; }\n  }\n  function save() {\n    try { localStorage.setItem(KEY, JSON.stringify(state)); } catch { /* private mode: run without persistence */ }\n  }\n\n  if (new URLSearchParams(location.search).has(\"embed\")) {\n    document.documentElement.classList.add(\"embed\");\n    $(\"splash\").style.display = \"none\";\n    $(\"app\").classList.add(\"on\");\n    const sendHeight = () => {\n      const h = Math.max(document.body.scrollHeight, document.documentElement.scrollHeight);\n      parent.postMessage({ type: \"kitapp:height\", height: Math.ceil(h) }, \"*\");\n    };\n    if (typeof ResizeObserver !== \"undefined\") new ResizeObserver(sendHeight).observe(document.body);\n    window.addEventListener(\"load\", sendHeight);\n    sendHeight();\n    setTimeout(sendHeight, 60);\n    setTimeout(sendHeight, 250);\n    setTimeout(sendHeight, 800);\n  }\n  $(\"start\").addEventListener(\"click\", () => { $(\"splash\").style.display = \"none\"; $(\"app\").classList.add(\"on\"); });\n  $(\"openAbout\").addEventListener(\"click\", () => $(\"about\").classList.add(\"on\"));\n  $(\"closeAbout\").addEventListener(\"click\", () => $(\"about\").classList.remove(\"on\"));\n  $(\"about\").addEventListener(\"click\", (e) => { if (e.target.id === \"about\") $(\"about\").classList.remove(\"on\"); });\n  document.addEventListener(\"keydown\", (e) => { if (e.key === \"Escape\") $(\"about\").classList.remove(\"on\"); });\n\n  const totalWeight = () => state.criteria.reduce((a, c) => a + c.weight, 0) || 1;\n\n  /** Weighted score for every option, using the given weights. */\n  function scoreWith(weights) {\n    const tw = weights.reduce((a, w) => a + w, 0) || 1;\n    return state.options.map((o) => ({\n      name: o.name,\n      score: o.scores.reduce((acc, s, i) => acc + s * (weights[i] ?? 0), 0) / tw,\n    }));\n  }\n\n  /**\n   * How far one weight has to move before the ranking flips.\n   *\n   * Brute force: for each criterion, walk its weight up and down in small\n   * steps and note the first point where the leader changes. With a handful\n   * of criteria this is a few hundred evaluations and finishes instantly,\n   * which is cheaper than deriving the crossover analytically and far easier\n   * to read six months from now.\n   */\n  function sensitivity() {\n    const base = scoreWith(state.criteria.map((c) => c.weight)).sort((a, b) => b.score - a.score);\n    if (base.length < 2) return null;\n    const leader = base[0].name;\n    let closest = null;\n\n    state.criteria.forEach((c, i) => {\n      for (const dir of [1, -1]) {\n        for (let step = 1; step <= 100; step++) {\n          const weights = state.criteria.map((x) => x.weight);\n          weights[i] = Math.max(0, c.weight + dir * step * 0.1);\n          const ranked = scoreWith(weights).sort((a, b) => b.score - a.score);\n          if (ranked[0].name !== leader) {\n            const delta = Math.abs(weights[i] - c.weight);\n            if (!closest || delta < closest.delta) {\n              closest = { criterion: c.name, delta, to: ranked[0].name, direction: dir > 0 ? \"up\" : \"down\" };\n            }\n            break;\n          }\n        }\n      }\n    });\n    return { leader, margin: base[0].score - base[1].score, closest };\n  }\n\n  function esc(s) { return String(s).replace(/[&<>\"]/g, (c) => ({ \"&\": \"&amp;\", \"<\": \"&lt;\", \">\": \"&gt;\", '\"': \"&quot;\" }[c])); }\n\n  function render() {\n    const tw = totalWeight();\n\n    /* Grid: criteria down the side, options across. */\n    let head = '<thead><tr><th style=\"min-width:150px\">Criterion</th><th style=\"min-width:180px\">Weight</th>';\n    state.options.forEach((o, oi) => {\n      head += '<th><div style=\"display:flex;align-items:center;gap:4px\">' +\n        '<input type=\"text\" data-opt=\"' + oi + '\" value=\"' + esc(o.name) + '\" aria-label=\"Option name\" />' +\n        (state.options.length > 1 ? '<button class=\"del\" data-delopt=\"' + oi + '\" title=\"Remove option\">&times;</button>' : '') +\n        '</div></th>';\n    });\n    head += \"</tr></thead>\";\n\n    let body = \"<tbody>\";\n    state.criteria.forEach((c, ci) => {\n      body += '<tr><td><div style=\"display:flex;align-items:center;gap:4px\">' +\n        '<input type=\"text\" data-crit=\"' + ci + '\" value=\"' + esc(c.name) + '\" aria-label=\"Criterion name\" />' +\n        (state.criteria.length > 1 ? '<button class=\"del\" data-delcrit=\"' + ci + '\" title=\"Remove criterion\">&times;</button>' : '') +\n        '</div></td>' +\n        '<td><div class=\"wt\"><input type=\"range\" min=\"0\" max=\"10\" step=\"1\" value=\"' + c.weight + '\" data-wt=\"' + ci + '\" aria-label=\"Weight\" />' +\n        '<span class=\"pctw\">' + Math.round((c.weight / tw) * 100) + '%</span></div></td>';\n      state.options.forEach((o, oi) => {\n        body += '<td><input type=\"number\" min=\"0\" max=\"10\" step=\"1\" value=\"' + o.scores[ci] + '\" data-sc=\"' + oi + \":\" + ci + '\" aria-label=\"Score\" /></td>';\n      });\n      body += \"</tr>\";\n    });\n    body += \"</tbody>\";\n    $(\"grid\").innerHTML = head + body;\n\n    /* Ranking */\n    const ranked = scoreWith(state.criteria.map((c) => c.weight)).sort((a, b) => b.score - a.score);\n    const max = ranked[0]?.score || 1;\n    $(\"ranking\").innerHTML = \"<h3>Ranking</h3>\" + ranked.map((r, i) =>\n      '<div class=\"rank-row\"><span class=\"n\">' + (i + 1) + '</span>' +\n      '<span class=\"nm\">' + esc(r.name) + '</span>' +\n      '<span class=\"track\"><span class=\"fill\" style=\"width:' + Math.max(2, (r.score / max) * 100) + '%\"></span></span>' +\n      '<span class=\"sc\">' + r.score.toFixed(2) + '</span></div>'\n    ).join(\"\");\n\n    /* Verdict + sensitivity */\n    const s = sensitivity();\n    if (!s) {\n      $(\"verdict\").innerHTML = '<p class=\"lead\">Add a second option.</p><p>A matrix with one option is a description, not a decision.</p>';\n      $(\"sens\").innerHTML = \"\";\n      return;\n    }\n    $(\"verdict\").innerHTML =\n      '<p class=\"lead\"><b>' + esc(s.leader) + '</b> comes first.</p>' +\n      '<p>Ahead of second place by ' + s.margin.toFixed(2) + ' points out of 10. ' +\n      (s.margin < 0.5 ? \"That is close enough that the ranking is really a tie.\" : \"\") + '</p>';\n\n    $(\"sens\").innerHTML = \"<h3>How solid is that</h3>\" + (s.closest\n      ? '<p>Moving <b>' + esc(s.closest.criterion) + '</b> ' + s.closest.direction + ' by <b>' +\n        s.closest.delta.toFixed(1) + '</b> on the 0–10 weight scale is enough to put <b>' + esc(s.closest.to) +\n        '</b> in front instead. ' +\n        (s.closest.delta <= 1\n          ? \"That is well inside the range you were guessing in, so the numbers are not making this decision for you — they are showing you it is genuinely close.\"\n          : \"That is a real change of priorities, so the ranking is holding up on more than rounding.\") + '</p>'\n      : '<p>No single weight change within the 0–10 range reverses the ranking. On these criteria the result is not close.</p>');\n  }\n\n  /* Delegated input handling: the grid is re-rendered on every change, so\n     listeners live on the container rather than on cells that stop existing. */\n  $(\"grid\").addEventListener(\"input\", (e) => {\n    const t = e.target;\n    if (t.dataset.wt !== undefined) state.criteria[+t.dataset.wt].weight = +t.value;\n    else if (t.dataset.sc !== undefined) {\n      const [oi, ci] = t.dataset.sc.split(\":\").map(Number);\n      state.options[oi].scores[ci] = Math.max(0, Math.min(10, +t.value || 0));\n    }\n    else if (t.dataset.opt !== undefined) { state.options[+t.dataset.opt].name = t.value; save(); return; }\n    else if (t.dataset.crit !== undefined) { state.criteria[+t.dataset.crit].name = t.value; save(); return; }\n    else return;\n    save(); render();\n  });\n\n  $(\"grid\").addEventListener(\"click\", (e) => {\n    const t = e.target;\n    if (t.dataset.delopt !== undefined) state.options.splice(+t.dataset.delopt, 1);\n    else if (t.dataset.delcrit !== undefined) {\n      const ci = +t.dataset.delcrit;\n      state.criteria.splice(ci, 1);\n      state.options.forEach((o) => o.scores.splice(ci, 1));\n    } else return;\n    save(); render();\n  });\n\n  $(\"addOption\").addEventListener(\"click\", () => {\n    state.options.push({ name: \"Option \" + (state.options.length + 1), scores: state.criteria.map(() => 5) });\n    save(); render();\n  });\n  $(\"addCriterion\").addEventListener(\"click\", () => {\n    state.criteria.push({ name: \"New criterion\", weight: 3 });\n    state.options.forEach((o) => o.scores.push(5));\n    save(); render();\n  });\n  $(\"reset\").addEventListener(\"click\", () => { state = structuredClone(SEED); save(); render(); });\n\n  render();\n})();\n</script>\n</body>\n</html>\n",
  "packaging": {
    "appName": "Decision Matrix",
    "windowTitle": "Decision Matrix",
    "bundleId": "com.halisonworks.decisionmatrix",
    "iconPath": null
  },
  "category": {
    "name": "Decision making",
    "tint": "indigo"
  },
  "sharedAt": "2026-09-16T00:00:00.000Z",
  "watermark": "Made with KIT Apps by Halison.io"
}
