Score 1–10 on each criterion. Your scores are hidden from the team until you submit.
{votersCount > 0 && ` ${votersCount} ${votersCount === 1 ? "teammate has" : "teammates have"} already voted.`}
{[1,2,3,4,5,6,7,8,9,10].map(n => {n === 1 || n === 10 ? n : ""})}
{v == null ? "—" : v}
);
})}
))}
{alreadySubmitted
? "You already submitted a vote on this idea. Updating will overwrite your previous scores."
: complete
? "All criteria scored. You're ready to submit."
: `Score the remaining ${totalAxes - filled} criteria to submit.`
}
Actionable recommendations generated from the lowest-scoring axes.
{aiInsights.map((ins, i) => (
{ins.axis}{ins.score.toFixed(1)}/10
{ins.body}
{ins.suggestion && (
Suggestion{ins.suggestion}
)}
))}
);
}
function buildInsights(hypothesis, teamAvg, axes) {
const recipes = {
three_sec: {
tone: "warn",
body: "Players don't read the core action within the first three seconds. That's the hyper-casual death zone — drop-off before the first feedback loop fires.",
suggestion: "Simplify the visual language: strong color contrast on the interactable, hide most UI on first run, and make the core action a single swipe or tap.",
},
playable_ad: {
tone: "warn",
body: "Translating the loop into a 15-second playable creative looks expensive. Without that, paid UA can't validate at scale.",
suggestion: "Identify a 5-second 'wow' moment and storyboard a creative around just that beat — even if it skips the meta layer entirely.",
},
one_screen: {
tone: "warn",
body: "The loop currently spans multiple screens. Test cycles will be slow and signal will be noisy.",
suggestion: "Compress the entire core loop onto one screen for the greybox build. Save navigation for after the prototype gate.",
},
ltv: {
tone: "info",
body: "Meta depth scored low. The game can hook but may not retain past D7 without more economy or progression hooks.",
suggestion: "Sketch the second-week loop now — what does the player chase on day 8? Define currencies and unlock cadence before scale.",
},
viral: {
tone: "info",
body: "Shareability is below threshold. Without organic creators, paid UA absorbs the entire growth budget.",
suggestion: "Add one moment per session that a player would film: a slow-mo, a chain reaction, an unexpected fail state.",
},
satisfying: {
tone: "info",
body: "The core action lacks tactile satisfaction. The metric most strongly correlated with D1 retention in our archive.",
suggestion: "Pass the build to audio next sprint. Layered SFX, screen shake, and chunky particles before more features.",
},
ai_assets: {
tone: "info",
body: "Asset pipeline isn't AI-friendly — the genre or art direction blocks generative tools.",
suggestion: "Consider a stylization pass: low-poly, flat-shaded, or hand-drawn looks reduce iteration cost dramatically.",
},
innovation: {
tone: "info",
body: "The team rates this as familiar. That's not necessarily bad, but it sets a high bar for execution polish.",
suggestion: "Double down on a single distinguishing element. If you can't name it in one sentence, the player can't either.",
},
positioning: {
tone: "info",
body: "Positioning is unclear — the team can't predict why a player would download this over a closest comp.",
suggestion: "Write the App Store subtitle now (max 30 chars). If three teammates write three different subtitles, the concept needs sharpening.",
},
};
const sorted = axes.slice().sort((a, b) => (teamAvg[a.id] ?? 0) - (teamAvg[b.id] ?? 0));
const out = [];
for (const ax of sorted) {
if (out.length >= 2) break;
const score = teamAvg[ax.id] ?? 0;
if (score >= 6.5) break;
const r = recipes[ax.id];
if (r) out.push({ axis: ax.label, score, ...r });
}
const sortedDesc = axes.slice().sort((a, b) => (teamAvg[b.id] ?? 0) - (teamAvg[a.id] ?? 0));
const top = sortedDesc[0];
if (top && (teamAvg[top.id] ?? 0) >= 8) {
out.push({
axis: top.label,
score: teamAvg[top.id],
tone: "good",
body: `${top.label} scored highest across the team — this is the load-bearing pillar of the pitch.`,
suggestion: "Build the next prototype iteration around amplifying this. Cut anything that distracts from it.",
});
}
if (out.length === 0) {
out.push({
axis: "Overall",
score: axes.reduce((s, ax) => s + (teamAvg[ax.id] ?? 0), 0) / axes.length,
tone: "info",
body: "Scores are middling across the board — no single weakness, but no breakout signal either.",
suggestion: "Schedule a half-day pitch revision. Pick one criterion and aim for a 9 on it specifically.",
});
}
return out;
}
// =================================================================
// Container — switches between Vote and Report
// =================================================================
function EvaluatorView({ data }) {
const evalData = window.EVALUATOR_DATA;
// Build the candidate list: real hypotheses in evaluable stages, ordered by
// the pinned list first (mock votes refer to those), then the rest.
const evaluable = useMemo(() => {
const all = data.hypotheses.filter(h => EVAL_STAGES.includes(h.stage));
const pinIndex = new Map(evalData.evaluable.map((id, i) => [id, i]));
return all.slice().sort((a, b) => {
const ai = pinIndex.has(a.id) ? pinIndex.get(a.id) : Infinity;
const bi = pinIndex.has(b.id) ? pinIndex.get(b.id) : Infinity;
if (ai !== bi) return ai - bi;
return (b.updated || "").localeCompare(a.updated || "");
});
}, [data.hypotheses]);
const [selectedId, setSelectedId] = useState(evaluable[0]?.id || null);
const [tab, setTab] = useState("vote");
const [drafts, setDrafts] = useState(() => {
const seed = {};
Object.entries(evalData.drafts || {}).forEach(([id, d]) => { seed[id] = d ? { ...d } : {}; });
return seed;
});
const [submittedSet, setSubmittedSet] = useState(() => new Set(evalData.submitted));
// Author map: real authors merged with synthetic mock voter authors so
// avatars / names resolve for the pre-baked teammate votes.
const authorMap = useMemo(() => {
const m = Object.fromEntries(data.authors.map(a => [a.id, a]));
(evalData.voterAuthors || []).forEach(a => { if (!m[a.id]) m[a.id] = a; });
return m;
}, [data.authors]);
const hypothesis = evaluable.find(h => h.id === selectedId) || null;
const author = hypothesis ? authorMap[hypothesis.author] : null;
const axes = useMemo(() => evalData.groups.flatMap(g => g.axes), []);
const allVotes = evalData.votes.filter(v => v.h === selectedId);
const teamVotes = allVotes.filter(v => v.v !== evalData.currentVoter);
const voterIds = [...new Set(teamVotes.map(v => v.v))];
const voters = voterIds.map(id => authorMap[id]).filter(Boolean);
const teamAvg = useMemo(() => {
const out = {};
axes.forEach(ax => {
const vals = teamVotes.map(v => v.s[ax.id]).filter(n => typeof n === "number");
out[ax.id] = vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : 0;
});
return out;
}, [selectedId]);
const draft = drafts[selectedId] || {};
const alreadySubmitted = submittedSet.has(selectedId);
const yourScores = alreadySubmitted ? draft : null;
const handleChange = (axisId, value) => {
setDrafts(prev => ({ ...prev, [selectedId]: { ...(prev[selectedId] || {}), [axisId]: value } }));
};
const handleSubmit = () => {
setSubmittedSet(prev => new Set([...prev, selectedId]));
setTab("report");
};
const handleSelect = (id) => {
setSelectedId(id);
setTab(submittedSet.has(id) ? "report" : "vote");
};
if (!hypothesis) return
No hypotheses to evaluate.
;
return (
{hypothesis.id} · Pitched by {author?.name || "—"}