From ffd6f3136b00a7266922e4bd9eb9e01e997c251b Mon Sep 17 00:00:00 2001 From: jerrymares Date: Tue, 21 Jul 2026 20:57:30 -0500 Subject: [PATCH] simplify DeckMD symptom flow --- README.md | 5 +- docs/assets/app.js | 530 +++++++++++++++++++++++------------ docs/assets/favicon.svg | 5 + docs/assets/questionnaire.js | 103 ++++++- docs/assets/styles.css | 110 ++++++-- docs/index.html | 51 ++-- tests/test_runner.sh | 9 +- tests/validate_deckmd.js | 29 +- 8 files changed, 591 insertions(+), 251 deletions(-) create mode 100644 docs/assets/favicon.svg diff --git a/README.md b/README.md index 301198c..8bcd6e7 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,9 @@ broader than the small number of conditions it can safely change automatically. > Start with the [DeckDoc diagnostic center](docs/wiki/Home.md) if you have a symptom and do not yet > know which subsystem is responsible. -Or use [DeckMD](https://deucebucket.github.io/deckdoc/), the private in-browser checklist, to turn a -symptom into ranked diagnostic branches and the next evidence to collect. +Or use [DeckMD](https://deucebucket.github.io/deckdoc/), the private in-browser symptom checker. It +starts with six broad categories, reveals only the connected follow-up questions, removes conflicting +paths as you answer, and keeps the complete grouped checklist behind **Browse all checks**. ## Problems DeckDoc investigates diff --git a/docs/assets/app.js b/docs/assets/app.js index 32ed6b7..ed9e830 100644 --- a/docs/assets/app.js +++ b/docs/assets/app.js @@ -3,65 +3,73 @@ const questions = window.DECKDOC_QUESTIONNAIRE || []; const knowledge = window.DECKDOC_KNOWLEDGE || []; + const categories = window.DECKDOC_CATEGORIES || []; + const related = window.DECKDOC_RELATED_CHECKS || {}; + const conflictPairs = window.DECKDOC_CONFLICTS || []; const selected = new Set(); const optionById = new Map(); const groupByOption = new Map(); - const groupById = new Map(questions.map((q) => [q.id, q])); + const groupById = new Map(questions.map((group) => [group.id, group])); + const symptomIds = new Set((groupById.get("symptom")?.options || []).map(([id]) => id)); + const flowOrder = ["timing", "alive", "scope", "environment", "details", "evidence"]; + let activeCategory = null; + let activeSymptom = null; + let flowIndex = 0; questions.forEach((group) => group.options.forEach(([id, label]) => { optionById.set(id, label); groupByOption.set(id, group.id); })); - const exclusives = [ - ["none-unsafe", "smoke", "swelling", "liquid", "sparking", "port-damage", "hot-off"], + const exclusiveSets = [ ["lcd", "oled", "model-unknown"], ["steamos", "windows", "other-os"], ["docked", "handheld"], - ["one-title", "all-titles"] + ["one-title", "all-titles"], + ["official-dock", "third-party-dock"], + ["repro-always", "repro-rare"], + [...symptomIds] ]; + const conflictMap = new Map(); + conflictPairs.forEach(([left, right, reason]) => { + if (!conflictMap.has(left)) conflictMap.set(left, new Map()); + if (!conflictMap.has(right)) conflictMap.set(right, new Map()); + conflictMap.get(left).set(right, reason); + conflictMap.get(right).set(left, reason); + }); + const suggestionReasons = { - "sound-works": "Separates a dead/frozen system from one failed output path", - "input-works": "Shows whether the session is still accepting controls", - "ssh-works": "Confirms the kernel and network path remain alive", - "stream-works": "Tests whether rendered frames exist away from the panel", - "screen-backlight": "Narrows LCD panel power versus scanout; not applicable to OLED", - "screen-no-light": "Moves an LCD case toward backlight/panel power evidence", - "external-works": "Separates the internal panel path from the GPU/session", - "internal-works": "Separates external Alt Mode/dock from the internal display", - "during-game": "Distinguishes boot/transition failures from load or title paths", - "after-wake": "Moves device loss toward suspend/resume reinitialization", - "first-after-days": "Identifies the long-off first-start research pattern", - "second-boot-works": "Recovery on the second start is a major discriminator", - "mode-switch": "Points to Gamescope/KWin session transition", - "dock-transition": "Points to Type-C, Alt Mode, hub, or hotplug state", - "one-title": "Moves the first branch toward title/Proton/configuration", - "all-titles": "Moves the first branch toward shared OS/GPU/memory/storage", - "hard-lock": "Separates a process exit from whole-system recovery failure", - "returns-library": "Often leaves a title, Steam, or Gamescope boundary", - "sig-gpu-reset-ok": "Recovery outcome changes the severity and downstream interpretation", - "sig-gpu-reset-fail": "Repeated failed reset is an escalation boundary", - "sig-oom": "Identifies a killed process instead of guessing from swap allocation", - "connected-icon": "Separates firmware/device loss from route/DNS/service stages", - "device-missing": "Stronger than a disconnected or administratively down device", - "local-network": "Separates local link/gateway from DNS or Steam service", - "fan-zero": "Needs temperature context before it means anything", - "hot-now": "Zero RPM plus ≥70°C is a stop-load condition", - "io-errors": "Separates a Steam library path problem from block/media risk", + "sound-works": "Tells us whether only one output path failed", + "input-works": "Checks whether the session still accepts controls", + "ssh-works": "Confirms the OS is still alive remotely", + "stream-works": "Checks whether frames exist away from the panel", + "screen-backlight": "Separates LCD backlight from scanout", + "screen-no-light": "Moves an LCD case toward power or panel evidence", + "external-works": "Separates the internal panel from the GPU/session", + "during-game": "Separates boot/transition trouble from load", + "after-wake": "Points toward suspend/resume reinitialization", + "first-after-days": "Matches the long-off first-start pattern", + "second-boot-works": "A major cold-start discriminator", + "one-title": "Moves toward a title, Proton, or configuration path", + "all-titles": "Moves toward a shared OS, GPU, memory, or storage path", + "hard-lock": "Separates a process crash from a whole-session failure", + "connected-icon": "Moves beyond Wi-Fi firmware to route or DNS", + "device-missing": "Stronger than a disconnected device", + "fan-zero": "Needs live temperature context", + "hot-now": "A stopped fan while hot is a stop-load condition", + "io-errors": "Separates a library problem from media risk", "read-only": "A forced read-only transition is a data-risk signal", - "multi-dock-failure": "Several dock functions failing together reveal the common upstream path", - "known-good-accessory": "Known-good A/B moves the boundary away from the accessory", - "firmware-also-fails": "Persistence outside the installed OS raises hardware suspicion", - "previous-image-fixes": "Same hardware working on the previous image is strong regression evidence", - "plugins-off-fixes": "A reversible clean run can isolate third-party state", - "windows": "Prevents SteamOS-only signatures and fixes from being misapplied", - "lcd": "Backlight and Vangogh-specific paths may apply", - "oled": "Avoids treating OLED as an LCD with a missing backlight" + "multi-dock-failure": "Several failed dock functions reveal a shared path", + "known-good-accessory": "Moves the boundary away from the accessory", + "firmware-also-fails": "Raises hardware suspicion across environments", + "previous-image-fixes": "Strong evidence of a software regression", + "plugins-off-fixes": "Can isolate third-party state", + "windows": "Prevents SteamOS-only checks from being misapplied", + "lcd": "Enables LCD backlight and Vangogh-specific branches", + "oled": "Removes LCD-only backlight questions" }; - const related = window.DECKDOC_RELATED_CHECKS || {}; - const evidenceBoosts = { "sig-display-gap": ["live-internal-display-gap", 34], "sig-gpu-timeout": ["cross-title-gpu-session", 16], @@ -80,26 +88,26 @@ }; const genericRoutes = { - display: ["Display symptom needs its timing and survivor checks", "path", "Select what stays alive, when it goes black, LCD/OLED, and external-display behavior.", "wiki/Display-and-Gamescope-Problems"], - boot: ["Boot/power symptom needs a preboot boundary", "os", "Record LEDs/chime/BIOS reachability, update history, and whether recovery or Rescue boots.", "wiki/Recovery-and-Escalation"], - crash: ["Crash scope needs one-title versus system-wide evidence", "app", "Select when it crashes, what survives, how many titles reproduce, and current-incident log signatures.", "wiki/Crashes-GPU-and-Memory"], - audio: ["Audio symptom needs device-versus-route evidence", "os", "Select wake timing, device missing versus wrong route, model, and SOF evidence.", "wiki/Audio-Problems"], - network: ["Network symptom needs staged localization", "path", "Select device presence, link state, local gateway, wake timing, and firmware evidence.", "wiki/Network-and-Resume-Problems"], - thermal: ["Thermal symptom needs a live trend and fan context", "hardware", "Select load, temperature, RPM, suspend, and charging context. Stop load if the fan is stopped while hot.", "wiki/Power-Thermal-and-Battery-Problems"], - "charge-problem": ["Charging symptom needs direct-versus-dock evidence", "path", "Compare a known-good direct PD supply with the dock/cable path and record exported telemetry.", "wiki/Power-Thermal-and-Battery-Problems"], - storage: ["Storage symptom needs a data-risk gate", "hardware", "Select device missing/read-only/I/O errors and stop writes when data may be at risk.", "wiki/Storage-and-MicroSD-Problems"], - dock: ["Dock symptom needs topology and A/B evidence", "path", "Select which dock functions fail together and compare direct known-good paths.", "wiki/Dock-USB-C-and-External-Displays"], - input: ["Input symptom needs test-UI and cross-environment evidence", "app", "Select one title/control, firmware behavior, wake timing, and Bluetooth/touch branch.", "wiki/Controls-Bluetooth-and-Input"], - performance: ["Performance symptom needs load-correlated evidence", "os", "Select scope, load, clocks, warm/cold scene, memory pressure, storage activity, and temperature.", "wiki/Crashes-GPU-and-Memory"], - update: ["Update symptom needs slot/build comparison", "os", "Record exact builds and whether previous image, stable channel, recovery, or Rescue changes the result.", "wiki/DeckDoc-Rescue"] + display: ["Display symptom needs its timing and survivor checks", "path", "Tell us what stays alive, when the picture fails, the model, and external-display behavior.", "wiki/Display-and-Gamescope-Problems"], + boot: ["Boot or power symptom needs a preboot boundary", "os", "Record LEDs, chime, BIOS reachability, update history, and whether recovery boots.", "wiki/Recovery-and-Escalation"], + crash: ["Crash scope needs one-title versus system-wide evidence", "app", "Separate one game from the whole session, then align current logs with the failure.", "wiki/Crashes-GPU-and-Memory"], + audio: ["Audio symptom needs device-versus-route evidence", "os", "Separate a missing device from a wrong route and check wake timing.", "wiki/Audio-Problems"], + network: ["Network symptom needs staged localization", "path", "Separate device presence, link, local network, DNS, and resume behavior.", "wiki/Network-and-Resume-Problems"], + thermal: ["Thermal symptom needs a live trend and fan context", "hardware", "Correlate load, temperature, RPM, suspend, and charging context.", "wiki/Power-Thermal-and-Battery-Problems"], + "charge-problem": ["Charging symptom needs direct-versus-dock evidence", "path", "Compare a known-good direct supply with the dock and cable path.", "wiki/Power-Thermal-and-Battery-Problems"], + storage: ["Storage symptom needs a data-risk gate", "hardware", "Stop writes when needed, then separate the device, filesystem, and library path.", "wiki/Storage-and-MicroSD-Problems"], + dock: ["Dock symptom needs topology and A/B evidence", "path", "Identify which dock functions fail together and compare direct paths.", "wiki/Dock-USB-C-and-External-Displays"], + input: ["Input symptom needs test-UI and cross-environment evidence", "app", "Separate one layout or control from device loss across environments.", "wiki/Controls-Bluetooth-and-Input"], + performance: ["Performance symptom needs load-correlated evidence", "os", "Compare scope, load, clocks, memory, storage, and temperature.", "wiki/Crashes-GPU-and-Memory"], + update: ["Update symptom needs slot and build comparison", "os", "Record exact builds and whether previous image or Rescue changes the result.", "wiki/DeckDoc-Rescue"] }; + const stage = document.querySelector("#guided-stage"); const stack = document.querySelector("#question-stack"); - const suggestionRail = document.querySelector("#suggestion-rail"); const rankedResults = document.querySelector("#ranked-results"); function escapeHtml(value) { - return String(value).replace(/[&<>"]/g, (c) => ({"&":"&","<":"<",">":">",'"':"""}[c])); + return String(value).replace(/[&<>"]/g, (character) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[character])); } function guideHref(route) { @@ -107,32 +115,60 @@ return `https://github.com/deucebucket/deckdoc/${String(route).replace(/^wiki\//, "wiki/")}`; } - function renderQuestions() { - stack.innerHTML = questions.map((group, index) => ` -
- - ${escapeHtml(group.eyebrow)}

${escapeHtml(group.title)}

${escapeHtml(group.hint)}

- 0 selected -
-
- ${group.options.map(([id, label]) => ``).join("")} -
-
`).join(""); - - stack.addEventListener("click", (event) => { - const button = event.target.closest("[data-option]"); - if (!button) return; - toggleOption(button.dataset.option); - }); + function unsafeSelections() { + return ["smoke", "swelling", "liquid", "sparking", "port-damage", "hot-off"].filter((id) => selected.has(id)); + } + + function blockedReason(id) { + if (selected.has(id)) return ""; + for (const [other, reason] of conflictMap.get(id) || []) { + if (selected.has(other)) return `${reason} Clear “${optionById.get(other)}” to change this answer.`; + } + return ""; + } + + function clearForNewSymptom(nextSymptom) { + const safety = [...selected].filter((id) => groupByOption.get(id) === "safety"); + selected.clear(); + safety.forEach((id) => selected.add(id)); + if (nextSymptom) selected.add(nextSymptom); + } + + function setCategory(categoryId) { + if (activeCategory?.id !== categoryId) clearForNewSymptom(null); + activeCategory = categories.find((category) => category.id === categoryId) || null; + activeSymptom = null; + flowIndex = 0; + update(); + } + + function setSymptom(symptomId) { + clearForNewSymptom(symptomId); + activeSymptom = symptomId; + activeCategory = categories.find((category) => category.symptoms.includes(symptomId)) || activeCategory; + flowIndex = 0; + update(); } function toggleOption(id, force = null) { + if (!optionById.has(id)) return; + if (symptomIds.has(id)) { + if (selected.has(id) && force !== true) { + clearForNewSymptom(null); + activeSymptom = null; + } else { + setSymptom(id); + } + return; + } + const willSelect = force === null ? !selected.has(id) : force; if (willSelect) { - const exclusive = exclusives.find((set) => set.includes(id)); + if (blockedReason(id)) return; + if (id === "none-unsafe") ["smoke", "swelling", "liquid", "sparking", "port-damage", "hot-off"].forEach((unsafe) => selected.delete(unsafe)); + if (["smoke", "swelling", "liquid", "sparking", "port-damage", "hot-off"].includes(id)) selected.delete("none-unsafe"); + const exclusive = exclusiveSets.find((set) => set.includes(id)); if (exclusive) exclusive.forEach((other) => selected.delete(other)); - if (id === "none-unsafe") ["smoke","swelling","liquid","sparking","port-damage","hot-off"].forEach((x) => selected.delete(x)); - if (["smoke","swelling","liquid","sparking","port-damage","hot-off"].includes(id)) selected.delete("none-unsafe"); selected.add(id); } else { selected.delete(id); @@ -140,33 +176,116 @@ update(); } - function currentSuggestions() { - const candidates = []; - const seen = new Set(); - const primary = questions.find((q) => q.id === "symptom").options.map(([id]) => id).filter((id) => selected.has(id)); - primary.forEach((symptom) => (related[symptom] || []).forEach((id) => { - if (!selected.has(id) && !seen.has(id)) { seen.add(id); candidates.push(id); } - })); - knowledge.forEach((rule) => { - if (!rule.symptoms.some((id) => selected.has(id))) return; - [...(rule.requiresAll || []), ...(rule.contextsAny || [])].forEach((id) => { - if (optionById.has(id) && !selected.has(id) && !seen.has(id)) { seen.add(id); candidates.push(id); } - }); - }); - return candidates.slice(0, 10); + function flowGroups() { + if (!activeSymptom) return []; + const relevant = new Set(related[activeSymptom] || []); + return flowOrder.map((groupId) => { + const group = groupById.get(groupId); + return { ...group, options: (group?.options || []).filter(([id]) => relevant.has(id)) }; + }).filter((group) => group.options.length); + } + + function optionButton(id, label, context = "guided") { + const reason = blockedReason(id); + const selectedState = selected.has(id); + const helper = context === "guided" ? suggestionReasons[id] : ""; + return ``; + } + + function renderCategoryStage() { + stage.innerHTML = ` +
+ 01 +

Start broad

Choose the main problem

Pick the closest category. You will see only the issues nested under it.

+
+
+ ${categories.map((category) => ``).join("")} +
+ `; + } + + function renderSymptomStage() { + const symptoms = groupById.get("symptom").options.filter(([id]) => activeCategory.symptoms.includes(id)); + stage.innerHTML = ` +
+ 02 +

${escapeHtml(activeCategory.title)}

Which description is closest?

Choose one main symptom. Connected symptoms appear as follow-up questions.

+
+
${symptoms.map(([id, label]) => ``).join("")}
+
`; + } + + function renderFollowupStage(groups) { + if (flowIndex >= groups.length) { + stage.innerHTML = ` +
+ +

Guided check complete

+

Review the likely branches below

+

You can still change any answer, browse every check, or copy the case summary.

+
See results
+
`; + return; + } + const group = groups[flowIndex]; + const count = group.options.filter(([id]) => selected.has(id)).length; + stage.innerHTML = ` +
+ ${String(flowIndex + 3).padStart(2, "0")} +

Connected check

${escapeHtml(group.title)}

${escapeHtml(group.hint)} Choose any that match, or skip if you do not know.

+
+
${group.options.map(([id, label]) => optionButton(id, label)).join("")}
+
+ + ${count ? `${count} selected` : "No answer required"} + +
`; } - function renderSuggestions(ids) { - document.querySelectorAll(".option-card.suggested").forEach((el) => el.classList.remove("suggested")); - ids.forEach((id) => document.querySelector(`[data-option="${CSS.escape(id)}"]`)?.classList.add("suggested")); - if (!ids.length) { - suggestionRail.innerHTML = `Select one or more primary symptoms to reveal the most useful follow-up checks.`; + function renderGuidedStage() { + if (unsafeSelections().length) { + stage.innerHTML = `
!

Safety stop

Do not continue software troubleshooting

Disconnect power if safe and contact Steam Support. Clear the physical-danger answer only if it was selected by mistake.

`; return; } - suggestionRail.innerHTML = ids.map((id) => ` - `).join(""); + if (!activeCategory) renderCategoryStage(); + else if (!activeSymptom) renderSymptomStage(); + else renderFollowupStage(flowGroups()); + } + + function advancedGroup(category, ids, extraClass = "") { + const unique = [...new Set(ids)].filter((id) => optionById.has(id)); + const count = unique.filter((id) => selected.has(id)).length; + return `
+ ${escapeHtml(category.title)}${escapeHtml(category.description || "")}${count ? `${count} selected` : `${unique.length} checks`} +
${unique.map((id) => optionButton(id, optionById.get(id), "advanced")).join("")}
+
`; + } + + function renderAdvanced() { + const assigned = new Set(); + const safety = groupById.get("safety").options.map(([id]) => id); + safety.forEach((id) => assigned.add(id)); + const blocks = [advancedGroup({ id: "safety", title: "Physical safety", description: "Stop conditions before software troubleshooting" }, safety, "danger-group")]; + categories.forEach((category) => { + const ids = [...category.symptoms]; + category.symptoms.forEach((symptom) => ids.push(...(related[symptom] || []))); + ids.forEach((id) => assigned.add(id)); + blocks.push(advancedGroup(category, ids)); + }); + const remaining = [...optionById.keys()].filter((id) => !assigned.has(id)); + if (remaining.length) blocks.push(advancedGroup({ id: "context", title: "Additional context & logs", description: "Less common environment and evidence facts" }, remaining)); + stack.innerHTML = blocks.join(""); } function ruleScore(rule) { @@ -186,27 +305,23 @@ } function getResults() { - const results = knowledge.map(ruleScore).filter(Boolean).sort((a, b) => b.score - a.score); - const primary = questions.find((q) => q.id === "symptom").options.map(([id]) => id).filter((id) => selected.has(id)); - const matchedSymptoms = new Set(results.flatMap((r) => r.symptoms)); - primary.forEach((symptom) => { - if (matchedSymptoms.has(symptom)) return; - const generic = genericRoutes[symptom]; - if (!generic) return; - results.push({ - id: `generic-${symptom}`, title: generic[0], layer: generic[1], why: generic[2], link: generic[3], - kicker: "More discriminating answers needed", confidence: "Unranked branch", score: 12, - evidence: ["Use the adaptive next-check rail to add timing, survivor, scope, and evidence facts."], + const results = knowledge.map(ruleScore).filter(Boolean).sort((left, right) => right.score - left.score); + if (activeSymptom && !results.some((result) => result.symptoms.includes(activeSymptom))) { + const generic = genericRoutes[activeSymptom]; + if (generic) results.push({ + id: `generic-${activeSymptom}`, title: generic[0], layer: generic[1], why: generic[2], link: generic[3], + kicker: "More answers will narrow this", confidence: "Starting branch", score: 12, + evidence: ["Continue the short guided path or open Browse all checks for an uncommon detail."], steps: ["Run a full DeckDoc report while the symptom is present when safe."], - avoid: "Do not apply a fix until its model, time scope, and preconditions match.", matched: [symptom] + avoid: "Do not apply a fix until its model, time scope, and preconditions match.", matched: [activeSymptom] }); - }); - return results.sort((a, b) => b.score - a.score).slice(0, 6); + } + return results.sort((left, right) => right.score - left.score).slice(0, 3); } function layerWeights(results) { const weights = { app: 2, os: 2, path: 2, hardware: 2 }; - results.forEach((r, index) => { weights[r.layer] += Math.max(8, r.score / (index + 1)); }); + results.forEach((result, index) => { weights[result.layer] += Math.max(8, result.score / (index + 1)); }); if (selected.has("one-title")) weights.app += 18; if (selected.has("after-update") || selected.has("previous-image-fixes")) weights.os += 18; if (selected.has("sound-works") && selected.has("display")) weights.path += 18; @@ -219,68 +334,77 @@ } function renderConstellation(weights) { - const labels = {app:"Application/config", os:"Driver/OS", path:"Device/path", hardware:"Hardware suspicion"}; - Object.entries(weights).forEach(([key, value]) => { - document.querySelector(`#node-${key}`).style.setProperty("--power", String(value / 100)); - }); - document.querySelector("#layer-legend").innerHTML = Object.entries(weights).map(([key, value]) => ` -
${labels[key]}${value}
`).join(""); + const labels = { app: "Application/config", os: "Driver/OS", path: "Device/path", hardware: "Hardware suspicion" }; + Object.entries(weights).forEach(([key, value]) => document.querySelector(`#node-${key}`).style.setProperty("--power", String(value / 100))); + document.querySelector("#layer-legend").innerHTML = Object.entries(weights).map(([key, value]) => `
${labels[key]}${value}
`).join(""); } function renderResults(results) { - const primarySelected = questions.find((q) => q.id === "symptom").options.some(([id]) => selected.has(id)); - if (!primarySelected) { - rankedResults.innerHTML = `
Start with what failed.

Select a primary symptom. The ranking will become useful after timing and “what still works” answers.

`; + if (!activeSymptom) { + rankedResults.innerHTML = `
Start with one broad problem.

DeckMD will ask a short chain of connected questions instead of showing the whole checklist.

`; return; } if (!results.length) { - rankedResults.innerHTML = `
No safe pattern match yet.

Add timing, survivor, scope, and evidence checks. Unknown is better than a false diagnosis.

`; + rankedResults.innerHTML = `
No safe pattern match yet.

Unknown is better than a false diagnosis. Finish the guided questions or browse all checks.

`; return; } - rankedResults.innerHTML = results.map((r, index) => ` -
-
- ${String(index + 1).padStart(2, "0")} -
${escapeHtml(r.kicker)}

${escapeHtml(r.title)}

- ${escapeHtml(r.confidence)} -
-

${escapeHtml(r.why)}

-
- Open evidence and action boundary · ${r.matched.length} selected facts matched -
-

Collect

    ${r.evidence.map((x) => `
  • ${escapeHtml(x)}
  • `).join("")}
-

Safe next steps

    ${r.steps.map((x) => `
  • ${escapeHtml(x)}
  • `).join("")}
-

Your matched facts

    ${r.matched.map((id) => `
  • ${escapeHtml(optionById.get(id) || id)}
  • `).join("")}
-
-

Avoid: ${escapeHtml(r.avoid)}

- Open the full diagnostic guide → -
-
`).join(""); + rankedResults.innerHTML = results.map((result, index) => `
+
${String(index + 1).padStart(2, "0")}
${escapeHtml(result.kicker)}

${escapeHtml(result.title)}

${escapeHtml(result.confidence)}
+

${escapeHtml(result.why)}

+
Evidence and safe next steps · ${result.matched.length} answers matched +

Collect

    ${result.evidence.map((item) => `
  • ${escapeHtml(item)}
  • `).join("")}

Safe next steps

    ${result.steps.map((item) => `
  • ${escapeHtml(item)}
  • `).join("")}

Matched answers

    ${result.matched.map((id) => `
  • ${escapeHtml(optionById.get(id) || id)}
  • `).join("")}
+

Avoid: ${escapeHtml(result.avoid)}

Open the full diagnostic guide → +
`).join(""); } function renderSafety() { - const unsafe = ["smoke","swelling","liquid","sparking","port-damage","hot-off"].filter((id) => selected.has(id)); + const unsafe = unsafeSelections(); const banner = document.querySelector("#safety-banner"); banner.hidden = !unsafe.length; - if (unsafe.length) document.querySelector("#safety-copy").textContent = `${unsafe.map((id) => optionById.get(id)).join(", ")}. Disconnect power if safe, do not charge/open/stress-test it, and contact Steam Support.`; + if (unsafe.length) document.querySelector("#safety-copy").textContent = `${unsafe.map((id) => optionById.get(id)).join(", ")}. Disconnect power if safe, do not charge, open, or stress-test it, and contact Steam Support.`; } function renderFacts() { const facts = [...selected].filter((id) => optionById.has(id)); document.querySelector("#selection-count").textContent = `${facts.length} selected`; - document.querySelector("#selected-facts").innerHTML = facts.length ? facts.map((id) => `${escapeHtml(optionById.get(id))}`).join("") : `No incident facts selected yet.`; + document.querySelector("#selected-facts").innerHTML = facts.length ? facts.map((id) => ``).join("") : `No incident facts selected yet.`; } - function updateProgress() { - let touched = 0; - questions.forEach((group) => { - const count = group.options.filter(([id]) => selected.has(id)).length; - if (count) touched += 1; - document.querySelector(`[data-count-for="${group.id}"]`).textContent = `${count} selected`; + function renderPath(groups) { + const pieces = [``]; + if (activeCategory) pieces.push(``); + if (activeSymptom) pieces.push(``); + if (activeSymptom && flowIndex < groups.length) pieces.push(`${escapeHtml(groups[flowIndex].title)}`); + document.querySelector("#path-trail").innerHTML = pieces.join(``); + } + + function updateProgress(groups) { + let current = 0; + let total = 2; + if (activeCategory) current = 1; + if (activeSymptom) { + total += groups.length; + current = Math.min(total, 3 + flowIndex); + if (flowIndex >= groups.length) current = total; + } + document.querySelector("#progress-label").textContent = activeSymptom ? `Step ${Math.max(1, current)} of ${total}` : activeCategory ? "Choose a specific symptom" : "Choose a category to begin"; + document.querySelector("#progress-bar").style.width = `${activeSymptom ? current / total * 100 : activeCategory ? 20 : 0}%`; + } + + function syncButtons() { + document.querySelectorAll("[data-option]").forEach((button) => { + const id = button.dataset.option; + const reason = blockedReason(id); + button.setAttribute("aria-pressed", selected.has(id) ? "true" : "false"); + button.disabled = Boolean(reason); + button.setAttribute("aria-disabled", reason ? "true" : "false"); + if (reason) button.title = reason; else button.removeAttribute("title"); + }); + document.querySelectorAll(".advanced-group").forEach((group) => { + const count = [...group.querySelectorAll("[data-option]")].filter((button) => selected.has(button.dataset.option)).length; + const label = group.querySelector("summary em"); + if (count) label.textContent = `${count} selected`; }); - document.querySelector("#answered-count").textContent = String(touched); - document.querySelector("#group-count").textContent = String(questions.length); - document.querySelector("#progress-bar").style.width = `${touched / questions.length * 100}%`; } function updateUrl() { @@ -291,67 +415,104 @@ } function update() { - document.querySelectorAll("[data-option]").forEach((button) => button.setAttribute("aria-pressed", selected.has(button.dataset.option) ? "true" : "false")); - const suggestions = currentSuggestions(); - renderSuggestions(suggestions); + const groups = flowGroups(); + if (flowIndex > groups.length) flowIndex = groups.length; + renderGuidedStage(); + renderAdvanced(); + syncButtons(); renderSafety(); - updateProgress(); renderFacts(); + renderPath(groups); + updateProgress(groups); const results = getResults(); renderResults(results); renderConstellation(layerWeights(results)); + document.querySelector(".constellation").hidden = !activeSymptom; + document.querySelector(".result-grid").classList.toggle("single-column", !activeSymptom); + document.querySelector(".all-checks-count").textContent = `${optionById.size} checks`; document.querySelector("#os-warning").hidden = !selected.has("windows"); - document.querySelector("#suggestion-explainer").textContent = suggestions.length ? "These unanswered checks create the largest split between the current branches. Click one to add it." : "Choose a primary symptom and DeckMD will offer the answers that split its major branches."; updateUrl(); } - function openOption(id) { - const groupId = groupByOption.get(id); - const detail = document.querySelector(`[data-group="${CSS.escape(groupId)}"]`); - if (detail) detail.open = true; - toggleOption(id, true); - document.querySelector(`[data-option="${CSS.escape(id)}"]`)?.scrollIntoView({behavior:"smooth", block:"center"}); - } - function caseSummary() { - const lines = ["DeckMD incident checklist", "========================"]; + const lines = ["DeckMD guided symptom check", "==========================="]; + if (activeCategory) lines.push(`\nCategory\n- ${activeCategory.title}`); questions.forEach((group) => { - const facts = group.options.filter(([id]) => selected.has(id)).map(([,label]) => `- ${label}`); + const facts = group.options.filter(([id]) => selected.has(id)).map(([, label]) => `- ${label}`); if (facts.length) lines.push(`\n${group.title}`, ...facts); }); const results = getResults(); - if (results.length) lines.push("\nRanked diagnostic branches", ...results.slice(0,5).map((r, i) => `${i+1}. ${r.title} — ${r.confidence}`)); + if (results.length) lines.push("\nRanked diagnostic branches", ...results.map((result, index) => `${index + 1}. ${result.title} — ${result.confidence}`)); lines.push("\nGenerated locally by DeckMD. Rankings are triage priorities, not diagnoses."); return lines.join("\n"); } - async function copyText(text, button) { + async function copyText(value, button) { try { - await navigator.clipboard.writeText(text); + await navigator.clipboard.writeText(value); const old = button.textContent; button.textContent = "Copied"; setTimeout(() => { button.textContent = old; }, 1600); } catch (_) { const area = document.createElement("textarea"); - area.value = text; document.body.append(area); area.select(); document.execCommand("copy"); area.remove(); + area.value = value; + document.body.append(area); + area.select(); + document.execCommand("copy"); + area.remove(); } } - renderQuestions(); + renderAdvanced(); const initial = new URLSearchParams(location.search).get("s"); - if (initial) initial.split(",").forEach((id) => { if (optionById.has(id)) selected.add(id); }); + if (initial) { + initial.split(",").forEach((id) => { if (optionById.has(id)) selected.add(id); }); + activeSymptom = [...selected].find((id) => symptomIds.has(id)) || null; + if (activeSymptom) { + [...selected].filter((id) => symptomIds.has(id) && id !== activeSymptom).forEach((id) => selected.delete(id)); + activeCategory = categories.find((category) => category.symptoms.includes(activeSymptom)) || null; + } + } - suggestionRail.addEventListener("click", (event) => { - const chip = event.target.closest("[data-suggestion]"); - if (chip) openOption(chip.dataset.suggestion); + stage.addEventListener("click", (event) => { + const category = event.target.closest("[data-category]"); + const symptom = event.target.closest("[data-symptom]"); + const option = event.target.closest("[data-option]"); + if (category) setCategory(category.dataset.category); + else if (symptom) setSymptom(symptom.dataset.symptom); + else if (option) toggleOption(option.dataset.option); + else if (event.target.closest("[data-back-category]")) { activeCategory = null; activeSymptom = null; clearForNewSymptom(null); update(); } + else if (event.target.closest("[data-flow-back]")) { if (flowIndex > 0) flowIndex -= 1; else { selected.delete(activeSymptom); activeSymptom = null; } update(); } + else if (event.target.closest("[data-flow-next]")) { flowIndex += 1; update(); } + else if (event.target.closest("[data-open-safety]")) { + const all = document.querySelector("#all-checks"); all.open = true; + const safety = document.querySelector('[data-advanced-group="safety"]'); safety.open = true; + safety.scrollIntoView({ behavior: "smooth", block: "start" }); + } + }); + + stack.addEventListener("click", (event) => { + const button = event.target.closest("[data-option]"); + if (button) toggleOption(button.dataset.option); + }); + + document.querySelector("#path-trail").addEventListener("click", (event) => { + if (event.target.closest("[data-path-start]")) { activeCategory = null; activeSymptom = null; clearForNewSymptom(null); update(); } + else if (event.target.closest("[data-path-category]")) { activeSymptom = null; clearForNewSymptom(null); update(); } + else if (event.target.closest("[data-path-symptom]")) { flowIndex = 0; update(); } + }); + + document.querySelector("#selected-facts").addEventListener("click", (event) => { + const button = event.target.closest("[data-remove-fact]"); + if (button) toggleOption(button.dataset.removeFact, false); }); document.querySelector("#option-search").addEventListener("input", (event) => { const query = event.target.value.trim().toLowerCase(); - document.querySelectorAll(".question-group").forEach((group) => { + document.querySelectorAll(".advanced-group").forEach((group) => { let matches = 0; - group.querySelectorAll(".option-card").forEach((button) => { + group.querySelectorAll(".advanced-option").forEach((button) => { const visible = !query || button.dataset.label.includes(query); button.hidden = !visible; if (visible) matches += 1; @@ -362,12 +523,15 @@ }); document.querySelector("#expand-all").addEventListener("click", (event) => { - const groups = [...document.querySelectorAll(".question-group:not([hidden])")]; + const groups = [...document.querySelectorAll(".advanced-group:not([hidden])")]; const expand = groups.some((group) => !group.open); groups.forEach((group) => { group.open = expand; }); - event.currentTarget.textContent = expand ? "Collapse all sections" : "Expand all sections"; + event.currentTarget.textContent = expand ? "Collapse all groups" : "Expand all groups"; + }); + + document.querySelector("#reset-all").addEventListener("click", () => { + selected.clear(); activeCategory = null; activeSymptom = null; flowIndex = 0; update(); }); - document.querySelector("#reset-all").addEventListener("click", () => { selected.clear(); update(); }); document.querySelector("#copy-summary").addEventListener("click", (event) => copyText(caseSummary(), event.currentTarget)); document.querySelector("#copy-link").addEventListener("click", (event) => copyText(location.href, event.currentTarget)); diff --git a/docs/assets/favicon.svg b/docs/assets/favicon.svg new file mode 100644 index 0000000..a640b2b --- /dev/null +++ b/docs/assets/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/docs/assets/questionnaire.js b/docs/assets/questionnaire.js index 1c12008..f497417 100644 --- a/docs/assets/questionnaire.js +++ b/docs/assets/questionnaire.js @@ -152,19 +152,98 @@ window.DECKDOC_QUESTIONNAIRE = [ } ]; +// The guided path starts broad, then reveals only the symptom families nested +// beneath the selected category. Every primary symptom appears exactly once. +window.DECKDOC_CATEGORIES = [ + { + id: "screen_display", + code: "SCREEN", + title: "Screen & display", + description: "Black, dim, flickering, corrupted, or missing picture", + symptoms: ["display"] + }, + { + id: "power_boot", + code: "POWER", + title: "Power, boot & charging", + description: "Won't start, boot loops, heat, fan, battery, or charging", + symptoms: ["boot", "charge-problem", "thermal"] + }, + { + id: "games_performance", + code: "GAMES", + title: "Games & performance", + description: "Crashes, freezes, low FPS, stutter, or low clocks", + symptoms: ["crash", "performance"] + }, + { + id: "sound_network", + code: "LINK", + title: "Sound & connectivity", + description: "Audio, microphone, Wi-Fi, internet, or resume trouble", + symptoms: ["audio", "network"] + }, + { + id: "controls_dock", + code: "I/O", + title: "Controls & accessories", + description: "Buttons, touch, Bluetooth, docks, USB, or external gear", + symptoms: ["input", "dock"] + }, + { + id: "storage_system", + code: "SYSTEM", + title: "Storage & system updates", + description: "NVMe, microSD, corrupt data, updates, rollback, or image health", + symptoms: ["storage", "update"] + } +]; + +// Selecting one side makes the other unavailable until the first answer is +// cleared. These rules are symmetric and shared by guided and all-check views. +window.DECKDOC_CONFLICTS = [ + ["screen-backlight", "screen-no-light", "The LCD backlight cannot be both visibly on and visibly off."], + ["oled", "screen-backlight", "OLED models do not use the LCD backlight check."], + ["oled", "screen-no-light", "OLED models do not use the LCD backlight check."], + ["fan-zero", "fan-reacts", "A stopped fan conflicts with a fan that still reacts to load."], + ["device-missing", "interface-down", "A missing device cannot also be present in a DOWN state."], + ["device-missing", "connected-icon", "A missing device cannot still report connected."], + ["device-missing", "local-network", "A missing network device cannot still reach the local network."], + ["interface-down", "connected-icon", "A DOWN interface conflicts with an active connected state."], + ["interface-down", "local-network", "A DOWN interface conflicts with working local connectivity."], + ["no-response", "power-led", "No response includes no normal LED response."], + ["no-response", "sound-works", "No response conflicts with continuing audio."], + ["no-response", "input-works", "No response conflicts with controls or haptics still working."], + ["no-response", "ssh-works", "No response conflicts with a working SSH session."], + ["no-response", "stream-works", "No response conflicts with a live stream or recording."], + ["no-response", "fan-reacts", "No response conflicts with a fan reacting to load."], + ["whole-system", "sound-works", "A wholly unresponsive system conflicts with continuing audio."], + ["whole-system", "input-works", "A wholly unresponsive system conflicts with working controls."], + ["whole-system", "ssh-works", "A wholly unresponsive system conflicts with a working SSH session."], + ["whole-system", "stream-works", "A wholly unresponsive system conflicts with advancing frames."], + ["hard-lock", "input-works", "A hard lock conflicts with controls still being accepted."], + ["hard-lock", "ssh-works", "A hard lock conflicts with a working SSH session."], + ["hard-lock", "stream-works", "A hard lock conflicts with advancing frames."], + ["sig-no-errors", "sig-gpu-timeout", "No matching errors conflicts with a current-incident GPU timeout."], + ["sig-no-errors", "sig-sof", "No matching errors conflicts with a current-incident SOF failure."], + ["sig-no-errors", "sig-wifi-fw", "No matching errors conflicts with a current-incident firmware failure."], + ["sig-no-errors", "sig-ext4", "No matching errors conflicts with a current-incident filesystem error."], + ["sig-no-errors", "sig-smart", "No matching errors conflicts with a current SMART warning."] +]; + // Ordered follow-ups define the progressive checklist. The first entries are // the highest-value branch separators for each primary symptom. window.DECKDOC_RELATED_CHECKS = { - display: ["sound-works", "screen-backlight", "screen-no-light", "input-works", "ssh-works", "stream-works", "external-works", "during-game", "after-wake", "first-after-days", "second-boot-works", "mode-switch", "dock-transition", "lcd", "oled"], - boot: ["no-response", "power-led", "sound-works", "logo-freeze", "boot-loop", "first-after-days", "second-boot-works", "after-update", "firmware-also-fails", "rescue-works"], - crash: ["one-title", "all-titles", "during-game", "game-launch", "returns-library", "hard-lock", "ssh-works", "sig-gpu-timeout", "sig-gpu-reset-ok", "sig-gpu-reset-fail", "sig-oom"], - audio: ["after-wake", "device-missing", "audio-route", "sig-sof", "lcd", "oled", "bluetooth-device"], - network: ["after-wake", "device-missing", "interface-down", "connected-icon", "local-network", "sig-wifi-fw", "docked", "vpn"], - thermal: ["fan-zero", "hot-now", "under-load", "after-wake", "while-charging", "charge-limit", "sig-hot-fan"], - "charge-problem": ["docked", "third-party-dock", "slow-charge", "battery-jump", "while-charging", "battery-only", "port-damage"], - storage: ["micro-sd", "replacement-ssd", "device-missing", "read-only", "io-errors", "sig-ext4", "sig-smart", "full-disk"], - dock: ["third-party-dock", "official-dock", "multi-dock-failure", "external-display-fails", "ethernet-drops", "charge-problem", "dock-transition", "known-good-accessory", "sig-dock"], - input: ["one-title", "one-control", "firmware-also-fails", "after-wake", "bluetooth-pair", "bluetooth-latency", "touch-phantom"], - performance: ["one-title", "all-titles", "under-load", "low-clocks", "stutter-warm", "sig-live-swap", "sig-oom"], - update: ["after-update", "boot-loop", "previous-image-fixes", "stable-fixes", "immutable-root", "rescue-works"] + display: ["sound-works", "screen-backlight", "screen-no-light", "input-works", "ssh-works", "stream-works", "external-works", "whole-system", "during-game", "after-wake", "first-after-days", "second-boot-works", "mode-switch", "dock-transition", "lcd", "oled", "sig-display-gap", "sig-panel-incomplete", "sig-gpu-timeout", "sig-no-errors"], + boot: ["no-response", "power-led", "sound-works", "logo-freeze", "boot-loop", "cold-boot", "first-after-days", "second-boot-works", "after-update", "firmware-also-fails", "rescue-works", "lcd", "oled", "sig-no-errors", "sig-inaccessible"], + crash: ["one-title", "all-titles", "desktop-too", "during-game", "game-launch", "returns-library", "hard-lock", "ssh-works", "after-update", "after-plugin", "plugins-off-fixes", "sig-gpu-timeout", "sig-gpu-reset-ok", "sig-gpu-reset-fail", "sig-page-fault", "sig-gamescope-core", "sig-oom"], + audio: ["after-wake", "device-missing", "audio-route", "sig-sof", "lcd", "oled", "bluetooth-device", "restart-fixes", "sig-no-errors"], + network: ["after-wake", "device-missing", "interface-down", "connected-icon", "local-network", "sig-wifi-fw", "docked", "vpn", "restart-fixes", "sig-no-errors"], + thermal: ["fan-zero", "fan-reacts", "hot-now", "under-load", "idle", "after-wake", "while-charging", "charge-limit", "sig-hot-fan", "repro-always", "repro-rare"], + "charge-problem": ["docked", "handheld", "third-party-dock", "official-dock", "slow-charge", "battery-jump", "while-charging", "battery-only", "port-damage", "known-good-accessory", "repro-always", "repro-rare"], + storage: ["micro-sd", "replacement-ssd", "device-missing", "read-only", "io-errors", "sig-ext4", "sig-smart", "sig-btrfs", "full-disk", "repro-always", "repro-rare"], + dock: ["third-party-dock", "official-dock", "multi-dock-failure", "external-display-fails", "ethernet-drops", "charge-problem", "dock-transition", "display-hotplug", "known-good-accessory", "other-deck", "sig-dock"], + input: ["one-title", "one-control", "firmware-also-fails", "after-wake", "bluetooth-device", "bluetooth-pair", "bluetooth-latency", "touch-phantom", "restart-fixes", "repro-always", "repro-rare"], + performance: ["one-title", "all-titles", "under-load", "low-clocks", "stutter-warm", "after-update", "after-plugin", "plugins-off-fixes", "sig-live-swap", "sig-oom", "sig-gpu-timeout"], + update: ["after-update", "boot-loop", "logo-freeze", "previous-image-fixes", "stable-fixes", "immutable-root", "rescue-works", "full-disk", "sig-inaccessible"] }; diff --git a/docs/assets/styles.css b/docs/assets/styles.css index 09a3378..3c50be7 100644 --- a/docs/assets/styles.css +++ b/docs/assets/styles.css @@ -104,16 +104,57 @@ nav a:hover, nav a:focus-visible { color: var(--ink); } .safety-icon { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 50%; color: var(--bg); background: var(--coral); font-weight: 950; } .safety-banner p { margin: .25rem 0 0; color: #efb8b2; } -.suggestion-panel { margin: 2rem 0 1rem; padding: 1.1rem; border: 1px solid rgba(103,193,245,.26); border-radius: var(--radius); background: linear-gradient(120deg, rgba(103,193,245,.08), rgba(103,193,245,.01)); } -.suggestion-heading { display: flex; justify-content: space-between; gap: 2rem; align-items: end; } -.suggestion-heading h3 { margin: 0; font-size: 1.25rem; } -.suggestion-heading > p { max-width: 650px; margin: 0; color: var(--muted); font-size: .86rem; } -.suggestion-rail { display: flex; gap: .6rem; overflow-x: auto; margin-top: 1rem; padding-bottom: .25rem; scrollbar-width: thin; } -.suggestion-chip { flex: 0 0 auto; max-width: 300px; padding: .68rem .8rem; text-align: left; border: 1px solid #33454e; border-radius: 10px; color: #cfeefe; background: #111b20; cursor: pointer; } -.suggestion-chip:hover, .suggestion-chip:focus-visible { border-color: var(--blue); } -.suggestion-chip small { display: block; margin-top: .25rem; color: #7594a3; font-size: .68rem; } .suggestion-empty { color: var(--dim); font-size: .84rem; } +.guided-triage { margin-top: 2rem; border: 1px solid #344047; border-radius: 22px; background: linear-gradient(145deg, rgba(19,24,27,.98), rgba(11,14,16,.98)); overflow: hidden; } +.path-bar { min-height: 70px; display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: .9rem 1.15rem; border-bottom: 1px solid var(--line); background: rgba(255,255,255,.015); } +.path-label { display: block; margin-bottom: .25rem; color: var(--dim); font: 800 .6rem/1 ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .14em; text-transform: uppercase; } +.path-trail { display: flex; flex-wrap: wrap; align-items: center; gap: .45rem; } +.path-trail button, .path-trail span { padding: 0; border: 0; color: var(--muted); background: none; font-size: .76rem; } +.path-trail button { color: var(--blue); cursor: pointer; } +.path-trail i { color: #4c5559; font-style: normal; } +.guided-stage { min-height: 430px; padding: clamp(1.25rem, 4vw, 3rem); } +.stage-heading { display: grid; grid-template-columns: 54px minmax(0, 1fr); gap: 1rem; max-width: 900px; margin-bottom: 1.5rem; } +.stage-heading h3, .stage-complete h3 { margin: 0; font-family: "Arial Narrow", "Roboto Condensed", sans-serif; font-size: clamp(1.8rem, 3.5vw, 3rem); line-height: 1; letter-spacing: -.025em; text-transform: uppercase; } +.stage-heading p:not(.overline), .stage-complete > p:not(.overline) { max-width: 720px; margin: .55rem 0 0; color: var(--muted); } +.step-number { display: grid; place-items: center; width: 48px; height: 48px; border: 1px solid #41505a; border-radius: 50%; color: var(--blue); font: 850 .72rem/1 ui-monospace, SFMono-Regular, Menlo, monospace; } +.category-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: .75rem; } +.category-card { position: relative; min-height: 180px; padding: 1rem 2.7rem 1rem 1rem; text-align: left; border: 1px solid #30393e; border-radius: 14px; color: var(--ink); background: #0d1113; cursor: pointer; transition: transform .16s ease, border-color .16s ease, background .16s ease; } +.category-card:hover, .category-card:focus-visible { transform: translateY(-2px); border-color: var(--blue); background: #111a1f; } +.category-code { display: block; margin-bottom: 2.2rem; color: var(--blue); font: 800 .62rem/1 ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .14em; } +.category-card strong { display: block; font-size: 1.1rem; } +.category-card small { display: block; margin-top: .35rem; color: var(--muted); font-size: .76rem; } +.category-arrow { position: absolute; right: 1rem; bottom: 1rem; color: var(--blue); font-size: 1.3rem; } +.safety-shortcut { width: 100%; display: flex; justify-content: space-between; gap: 1rem; margin-top: .8rem; padding: .75rem 1rem; text-align: left; border: 1px solid rgba(255,107,94,.28); border-radius: 10px; color: #dca6a1; background: rgba(255,107,94,.035); cursor: pointer; } +.safety-shortcut span { color: #9e716d; font-size: .75rem; } +.symptom-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: .75rem; max-width: 1120px; } +.symptom-card { min-height: 150px; display: flex; flex-direction: column; justify-content: space-between; padding: 1rem; text-align: left; border: 1px solid #334047; border-radius: 14px; color: var(--ink); background: #0e1316; cursor: pointer; } +.symptom-card:hover, .symptom-card:focus-visible { border-color: var(--blue); background: #121c21; } +.symptom-card strong { font-size: 1.02rem; line-height: 1.35; } +.symptom-card span { color: var(--blue); font-size: .74rem; } +.guided-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .65rem; max-width: 1080px; } +.guided-option { min-height: 88px; } +.guided-option > span { display: block; } +.guided-option > small { display: block; margin-top: .3rem; padding-right: 1rem; color: #78919c; font-size: .68rem; } +.option-card .blocked-copy { color: #8d6562; } +.option-card:disabled { opacity: .38; cursor: not-allowed; border-style: dashed; } +.option-card:disabled::after { content: "×"; display: grid; place-items: center; border: 0; color: var(--coral); font-size: .9rem; transform: translateY(-50%); } +.stage-actions { display: flex; align-items: center; gap: .75rem; margin-top: 1.25rem; } +.stage-actions.split { max-width: 1080px; justify-content: space-between; } +.stage-actions > span { color: var(--dim); font-size: .72rem; } +.stage-complete { max-width: 760px; margin: 2rem auto; text-align: center; } +.complete-mark { display: grid; place-items: center; width: 58px; height: 58px; margin: 0 auto 1rem; border: 1px solid var(--green); border-radius: 50%; color: var(--green); font-size: 1.4rem; } +.stage-complete .stage-actions { justify-content: center; } +.danger-stage .complete-mark { border-color: var(--coral); color: var(--coral); } + +.all-checks { margin-top: 1rem; border: 1px solid var(--line); border-radius: 16px; background: #0d1012; } +.all-checks > summary { display: flex; justify-content: space-between; align-items: center; gap: 1rem; padding: 1rem 1.15rem; cursor: pointer; list-style: none; } +.all-checks > summary::-webkit-details-marker { display: none; } +.all-checks > summary span:first-child { display: grid; } +.all-checks > summary strong { font-size: .95rem; } +.all-checks > summary small { color: var(--muted); font-size: .72rem; } +.all-checks-count { color: var(--blue); font: 750 .68rem/1 ui-monospace, SFMono-Regular, Menlo, monospace; } +.all-checks-body { padding: 0 1rem 1rem; border-top: 1px solid var(--line); } .checker-tools { display: flex; gap: .75rem; align-items: center; margin: 1rem 0; } .search-box { flex: 1; display: flex; align-items: center; gap: .5rem; min-height: 42px; padding: 0 .8rem; border: 1px solid var(--line); border-radius: 10px; background: #0c0f11; } .search-box input { width: 100%; border: 0; outline: 0; color: var(--ink); background: transparent; } @@ -121,32 +162,32 @@ nav a:hover, nav a:focus-visible { color: var(--ink); } .danger-text { color: #d98e87; } .question-stack { display: grid; gap: .75rem; } -.question-group { border: 1px solid var(--line); border-radius: 16px; background: var(--panel); overflow: clip; } -.question-group[open] { border-color: #394248; } -.question-group summary { display: flex; align-items: center; justify-content: space-between; gap: 2rem; padding: 1.15rem 1.25rem; cursor: pointer; list-style: none; } -.question-group summary::-webkit-details-marker { display: none; } -.question-group summary::after { content: "+"; color: var(--blue); font-size: 1.35rem; } -.question-group[open] summary::after { content: "−"; } -.question-title h3 { margin: 0; font-size: 1.08rem; } -.question-title p { margin: .2rem 0 0; color: var(--muted); font-size: .78rem; } -.group-count { color: var(--dim); font: 700 .68rem/1 ui-monospace, SFMono-Regular, Menlo, monospace; } -.question-body { padding: 0 1rem 1rem; } -.option-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: .55rem; } +.advanced-group { border: 1px solid var(--line); border-radius: 12px; background: var(--panel); overflow: clip; } +.advanced-group[open] { border-color: #394248; } +.advanced-group > summary { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem; cursor: pointer; list-style: none; } +.advanced-group > summary::-webkit-details-marker { display: none; } +.advanced-group > summary span { display: grid; } +.advanced-group > summary b { font-size: .92rem; } +.advanced-group > summary small { color: var(--muted); font-size: .7rem; } +.advanced-group > summary em { color: var(--dim); font: 700 .66rem/1 ui-monospace, SFMono-Regular, Menlo, monospace; font-style: normal; } +.advanced-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: .55rem; padding: 0 .8rem .8rem; } +.danger-group[open] { border-color: rgba(255,107,94,.45); } .option-card { position: relative; min-height: 64px; padding: .78rem 2.2rem .78rem .8rem; text-align: left; border: 1px solid #2c3337; border-radius: 10px; color: #bac0c0; background: #0d1012; cursor: pointer; transition: border-color .14s ease, color .14s ease, background .14s ease; } .option-card::after { content: ""; position: absolute; right: .8rem; top: 50%; width: 14px; height: 14px; border: 1px solid #4b555a; border-radius: 3px; transform: translateY(-50%); } .option-card:hover, .option-card:focus-visible { border-color: #526169; color: var(--ink); } .option-card[aria-pressed="true"] { border-color: var(--blue); color: #e7f7ff; background: rgba(103,193,245,.09); } .option-card[aria-pressed="true"]::after { border-color: var(--blue); background: var(--blue); box-shadow: inset 0 0 0 3px #101a1f; } -.question-group[data-tone="danger"] .option-card[aria-pressed="true"] { border-color: var(--coral); background: rgba(255,107,94,.08); } -.question-group[data-tone="danger"] .option-card[aria-pressed="true"]::after { border-color: var(--coral); background: var(--coral); } -.option-card.suggested { box-shadow: inset 3px 0 var(--amber); } -.option-card[hidden], .question-group[hidden] { display: none; } +.danger-group .option-card[aria-pressed="true"] { border-color: var(--coral); background: rgba(255,107,94,.08); } +.danger-group .option-card[aria-pressed="true"]::after { border-color: var(--coral); background: var(--coral); } +.option-card[hidden], .advanced-group[hidden] { display: none; } .results { margin-top: 3rem; padding-top: 2.5rem; border-top: 1px solid var(--line); } .result-actions { display: flex; gap: .5rem; } .os-warning { margin: 1rem 0; padding: .85rem 1rem; border-left: 3px solid var(--amber); color: #e4cfaa; background: rgba(244,186,92,.07); } .result-grid { display: grid; grid-template-columns: 380px minmax(0, 1fr); gap: 1rem; margin-top: 1.5rem; } +.result-grid.single-column { grid-template-columns: 1fr; } .constellation { min-height: 560px; padding: 1.2rem; border: 1px solid var(--line); border-radius: var(--radius); background: #0b0e10; } +.constellation[hidden] { display: none; } .constellation h3 { margin: 0; } .orbit { position: relative; width: 270px; height: 270px; margin: 2.2rem auto; border-radius: 50%; } .orbit-ring { position: absolute; inset: 12%; border: 1px solid #263036; border-radius: 50%; } @@ -184,7 +225,9 @@ nav a:hover, nav a:focus-visible { color: var(--ink); } .case-digest summary { display: flex; justify-content: space-between; padding: .8rem 1rem; cursor: pointer; } .case-digest summary span { color: var(--muted); font-size: .74rem; } #selected-facts { display: flex; flex-wrap: wrap; gap: .4rem; padding: 0 1rem 1rem; } -.fact-chip { padding: .35rem .5rem; border: 1px solid #374045; border-radius: 6px; color: #aeb5b4; font-size: .7rem; } +.fact-chip { display: inline-flex; align-items: center; gap: .4rem; padding: .35rem .5rem; border: 1px solid #374045; border-radius: 6px; color: #aeb5b4; background: #101416; cursor: pointer; font-size: .7rem; } +.fact-chip:hover, .fact-chip:focus-visible { border-color: var(--coral); } +.fact-chip span { color: var(--coral); } .method-strip { width: min(1320px, calc(100% - 3rem)); margin: 0 auto 7rem; display: grid; grid-template-columns: repeat(4, 1fr); border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); } .method-strip > div { padding: 2rem 1.5rem; border-right: 1px solid var(--line); } @@ -201,7 +244,7 @@ footer p { margin: .35rem 0; } @media (max-width: 1000px) { .hero { grid-template-columns: 1fr; min-height: auto; padding-top: 5rem; } .hero-instrument { max-width: 620px; } - .option-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .category-grid, .advanced-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .result-grid { grid-template-columns: 1fr; } .constellation { min-height: auto; } .method-strip { grid-template-columns: repeat(2, 1fr); } @@ -218,12 +261,21 @@ footer p { margin: .35rem 0; } .trace-line { grid-template-columns: 28px 1fr; } .trace-line i, .trace-line small { display: none; } .checker-shell { width: calc(100% - 1rem); padding: 1rem; border-radius: 18px; } - .checker-topbar, .results-header, .suggestion-heading { align-items: start; flex-direction: column; gap: 1rem; } + .checker-topbar, .results-header { align-items: start; flex-direction: column; gap: 1rem; } .progress-block { width: 100%; text-align: left; } + .path-bar { align-items: flex-start; } + .guided-stage { min-height: 0; padding: 1rem; } + .stage-heading { grid-template-columns: 42px 1fr; } + .step-number { width: 38px; height: 38px; } + .category-grid, .symptom-grid, .guided-options, .advanced-grid { grid-template-columns: 1fr; } + .category-card { min-height: 142px; } + .safety-shortcut { flex-direction: column; } + .stage-actions.split { display: grid; grid-template-columns: 1fr 1fr; } + .stage-actions.split > span { grid-column: 1 / -1; grid-row: 1; } + .stage-actions.split .button { width: 100%; } .checker-tools { flex-wrap: wrap; } .search-box { flex-basis: 100%; } - .option-grid { grid-template-columns: 1fr; } - .question-group summary { gap: 1rem; padding: 1rem; } + .advanced-group summary { gap: 1rem; padding: 1rem; } .result-actions { width: 100%; } .result-actions .button { flex: 1; } .result-card-head { grid-template-columns: auto 1fr; } @@ -237,7 +289,7 @@ footer p { margin: .35rem 0; } @media print { body { color: #111; background: #fff; } - .site-header, .hero, .suggestion-panel, .checker-tools, .question-stack, .result-actions, .method-strip, footer { display: none !important; } + .site-header, .hero, .guided-triage, .all-checks, .checker-tools, .question-stack, .result-actions, .method-strip, footer { display: none !important; } .checker-shell { width: 100%; margin: 0; border: 0; box-shadow: none; background: #fff; } .results { border: 0; } .result-grid { grid-template-columns: 1fr; } diff --git a/docs/index.html b/docs/index.html index 429ecca..08e3a17 100644 --- a/docs/index.html +++ b/docs/index.html @@ -7,6 +7,7 @@ DeckMD — Steam Deck symptom checker by DeckDoc + @@ -31,11 +32,10 @@

Steam Deck symptom intelligence · runs entirely in your browser

Don’t guess the fix.
Narrow the failure.

-

A symptom can cross six layers before it reaches your screen. Check what - happened, what survived, and when it changed. DeckMD ranks the next diagnostic branch and - shows the evidence needed to confirm it.

+

Start with one broad problem. DeckMD asks only the connected questions + that help separate the likely paths, then shows what evidence would confirm them.

-
-
-

Adaptive follow-up

Next best checks

-

Choose a primary symptom and DeckMD will offer the answers that split its major branches.

+
+
+
+ Your path +
Start
+
+
-
+
-
- - - -
- -
+
+ + Browse all checksUse the complete grouped list when the guided path does not fit. + 128 checks + +
+
+ + +
+
+
+
diff --git a/tests/test_runner.sh b/tests/test_runner.sh index f52a2d2..1b43552 100755 --- a/tests/test_runner.sh +++ b/tests/test_runner.sh @@ -588,12 +588,15 @@ if command -v node >/dev/null 2>&1; then node --check "${DECKDOC_DIR}/docs/assets/knowledge.js" node --check "${DECKDOC_DIR}/docs/assets/app.js" node "${DECKDOC_DIR}/tests/validate_deckmd.js" - echo " PASS: DeckMD facts, progressive suggestions, rules, and wiki routes are internally consistent." -elif grep -Fq 'display: ["sound-works", "screen-backlight", "screen-no-light", "input-works", "ssh-works", "stream-works", "external-works", "during-game", "after-wake"' \ + echo " PASS: DeckMD categories, nested questions, contradictions, rules, and wiki routes are internally consistent." +elif grep -Fq 'window.DECKDOC_CATEGORIES = [' "${DECKDOC_DIR}/docs/assets/questionnaire.js" && \ + grep -Fq '["no-response", "ssh-works"' "${DECKDOC_DIR}/docs/assets/questionnaire.js" && \ + grep -Fq 'display: ["sound-works", "screen-backlight", "screen-no-light", "input-works", "ssh-works"' \ "${DECKDOC_DIR}/docs/assets/questionnaire.js" && \ grep -q 'const related = window.DECKDOC_RELATED_CHECKS' "${DECKDOC_DIR}/docs/assets/app.js" && \ + grep -q 'Browse all checks' "${DECKDOC_DIR}/docs/index.html" && \ grep -q 'No safe pattern match yet' "${DECKDOC_DIR}/docs/assets/app.js"; then - echo " PASS: Required progressive-display and safe-unknown contracts are present." + echo " PASS: Broad categories, contradictions, progressive display checks, all-check fallback, and safe-unknown contracts are present." echo " NOTE: Full JavaScript/schema validation skipped because Node.js is not installed." else echo " FAIL: DeckMD structural fallback contract is incomplete." diff --git a/tests/validate_deckmd.js b/tests/validate_deckmd.js index 4ca330b..e924636 100644 --- a/tests/validate_deckmd.js +++ b/tests/validate_deckmd.js @@ -15,6 +15,8 @@ for (const file of ["docs/assets/questionnaire.js", "docs/assets/knowledge.js"]) const questions = context.window.DECKDOC_QUESTIONNAIRE; const knowledge = context.window.DECKDOC_KNOWLEDGE; const related = context.window.DECKDOC_RELATED_CHECKS; +const categories = context.window.DECKDOC_CATEGORIES; +const conflicts = context.window.DECKDOC_CONFLICTS; const failures = []; if (!Array.isArray(questions) || questions.length < 8) failures.push("questionnaire must contain at least 8 sections"); @@ -23,6 +25,7 @@ if (!Array.isArray(knowledge) || knowledge.length < 12) failures.push("knowledge const primary = new Set((questions.find((group) => group.id === "symptom") || { options: [] }).options.map(([id]) => id)); const allIds = questions.flatMap((group) => group.options.map(([id]) => id)); const uniqueIds = new Set(allIds); +const groupByOption = new Map(questions.flatMap((group) => group.options.map(([id]) => [id, group.id]))); const duplicates = [...new Set(allIds.filter((id, index) => allIds.indexOf(id) !== index))]; if (duplicates.length) failures.push(`duplicate option IDs: ${duplicates.join(", ")}`); @@ -36,6 +39,30 @@ for (const rule of knowledge) { for (const [symptom, suggestions] of Object.entries(related || {})) { if (!primary.has(symptom)) failures.push(`related-check map uses non-primary symptom ${symptom}`); for (const id of suggestions) if (!uniqueIds.has(id)) failures.push(`${symptom} suggests missing ${id}`); + const guidedGroups = new Set(suggestions.map((id) => groupByOption.get(id)).filter((group) => group && group !== "symptom")); + if (guidedGroups.size < 3) failures.push(`${symptom} needs at least 3 nested follow-up groups`); +} + +if (!Array.isArray(categories) || categories.length !== 6) failures.push("guided start must contain exactly 6 broad categories"); +const categorySymptoms = (categories || []).flatMap((category) => category.symptoms || []); +for (const symptom of primary) { + const count = categorySymptoms.filter((id) => id === symptom).length; + if (count !== 1) failures.push(`${symptom} must appear in exactly one broad category; found ${count}`); +} +for (const category of categories || []) { + if (!category.title || !category.description || !category.code) failures.push(`${category.id} is missing category copy`); + if (!category.symptoms?.length || category.symptoms.length > 3) failures.push(`${category.id} must expose 1-3 nested symptoms`); + for (const id of category.symptoms || []) if (!primary.has(id)) failures.push(`${category.id} contains non-primary symptom ${id}`); +} + +if (!Array.isArray(conflicts) || conflicts.length < 20) failures.push("contradiction rules are incomplete"); +for (const [left, right, reason] of conflicts || []) { + if (!uniqueIds.has(left) || !uniqueIds.has(right)) failures.push(`conflict references missing option: ${left}/${right}`); + if (!reason) failures.push(`conflict ${left}/${right} has no explanation`); +} +const conflictKeys = new Set((conflicts || []).map(([left, right]) => [left, right].sort().join("|"))); +for (const pair of [["screen-backlight", "screen-no-light"], ["oled", "screen-backlight"], ["no-response", "ssh-works"], ["device-missing", "connected-icon"]]) { + if (!conflictKeys.has(pair.sort().join("|"))) failures.push(`required contradiction is missing: ${pair.join("/")}`); } const displayChecks = related?.display || []; @@ -49,4 +76,4 @@ if (failures.length) { console.error(failures.join("\n")); process.exit(1); } -console.log(`DeckMD schema valid: ${questions.length} sections, ${allIds.length} unique facts, ${knowledge.length} ranked branches.`); +console.log(`DeckMD schema valid: ${categories.length} broad categories, ${primary.size} nested symptoms, ${allIds.length} unique facts, ${conflicts.length} contradiction rules, ${knowledge.length} ranked branches.`);