static-fire 0.44.0 → 0.54.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -34
- package/package.json +2 -1
- package/src/adapters/index.ts +3 -3
- package/src/api.ts +38 -6
- package/src/cli.ts +106 -20
- package/src/core/batch.ts +16 -1
- package/src/core/commands.ts +7 -6
- package/src/core/compare.ts +4 -1
- package/src/core/coverage.ts +84 -0
- package/src/core/db.ts +36 -0
- package/src/core/facets.ts +23 -8
- package/src/core/init.ts +99 -9
- package/src/core/plan.ts +84 -0
- package/src/core/project.ts +95 -9
- package/src/core/regrade.ts +10 -1
- package/src/core/score.ts +1 -1
- package/src/core/task-page.ts +54 -0
- package/src/core/trial-detail.ts +3 -3
- package/src/core/types.ts +1 -1
- package/src/core/view-query.ts +30 -13
- package/src/core/view.ts +131 -53
- package/src/guide.md +128 -51
- package/src/server.ts +7 -3
- package/web/app.tsx +276 -133
- package/web/filter-menu.ts +35 -4
- package/web/how-it-works.tsx +92 -0
- package/web/index-switches.ts +29 -0
- package/web/levels.tsx +15 -0
- package/web/notices.ts +74 -0
- package/web/output-view.ts +61 -0
- package/web/pages.ts +26 -0
- package/web/styles.css +57 -22
- package/web/task-card.ts +6 -0
- package/web/tasks.tsx +122 -0
- package/web/suite-notes.ts +0 -20
package/web/filter-menu.ts
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
// The Results page's Filter menu and filter tags (#26), as plain data: which sections and values the menu lists,
|
|
2
2
|
// what picking a value sets in the view query, what the search lists and which tags show. The page only draws it.
|
|
3
3
|
// A config facet (Harness, Model, Effort, Profile) takes several values (#31): a pick adds or removes one value and
|
|
4
|
-
// each picked value is its own tag. Judge, Grading version and Date range take one value: a pick replaces it.
|
|
4
|
+
// each picked value is its own tag. Task, Judge, Grading version and Date range take one value: a pick replaces it.
|
|
5
|
+
// Task (#81) is the task view: a task is picked here, not by a button on the page.
|
|
5
6
|
import type { Facets, FacetValue } from "../src/core/facets";
|
|
6
7
|
import { HARNESS_NAME } from "../src/core/label";
|
|
7
8
|
import { CONFIG_FACETS, valueSet } from "../src/core/view-query";
|
|
8
9
|
import type { ConfigFacet, ViewQuery } from "../src/core/view-query";
|
|
9
10
|
|
|
10
|
-
export type SectionId = "harness" | "model" | "effort" | "profile" | "judge" | "grading" | "dates";
|
|
11
|
+
export type SectionId = "task" | "harness" | "model" | "effort" | "profile" | "judge" | "grading" | "dates";
|
|
11
12
|
export const SECTION_NAMES: Record<SectionId, string> = {
|
|
12
|
-
harness: "Harness", model: "Model", effort: "Effort", profile: "Profile", judge: "Judge", grading: "Grading version", dates: "Date range",
|
|
13
|
+
task: "Task", harness: "Harness", model: "Model", effort: "Effort", profile: "Profile", judge: "Judge", grading: "Grading version", dates: "Date range",
|
|
13
14
|
};
|
|
14
15
|
export const EFFORT_NONE = "No effort setting", PROFILE_NONE = "No Profile (user setup only)";
|
|
15
16
|
|
|
@@ -45,11 +46,15 @@ export interface MenuContext {
|
|
|
45
46
|
facets: Facets | null; query: ViewQuery;
|
|
46
47
|
/** The view's judge: set by the judge filter, or by a picked grading version. */
|
|
47
48
|
judge: { id: string; label: string };
|
|
49
|
+
/** The title of every task in the project, in the project's order: the Task section lists them all. */
|
|
48
50
|
titles: Record<string, string>;
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
export const CLEAR_ALL: Patch = { judge: undefined, grading: undefined, harness: undefined, model: undefined, effort: undefined, profile: undefined, since: undefined, until: undefined };
|
|
52
54
|
|
|
55
|
+
/** What Clear all sets: every filter, and a picked task (back to the index). A suite pick is not a filter: it stays. */
|
|
56
|
+
export const clearAll = (q: ViewQuery): Patch => (q.scope?.kind === "task" ? { ...CLEAR_ALL, scope: undefined } : CLEAR_ALL);
|
|
57
|
+
|
|
53
58
|
const plural = (n: number, one: string) => `${n} ${one}${n === 1 ? "" : "s"}`;
|
|
54
59
|
const short = (hash: string) => hash.slice(0, 8);
|
|
55
60
|
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
@@ -95,6 +100,15 @@ function facetSection(id: ConfigFacet, listed: FacetValue[], picked: string[]):
|
|
|
95
100
|
/** Every section of the menu, in order, for the facets and the query in view. */
|
|
96
101
|
export function menuSections({ facets, query, judge, titles }: MenuContext): MenuSection[] {
|
|
97
102
|
const f = facets;
|
|
103
|
+
// Task: every task in the project. Picking one opens its task view; picking it again goes back to the index.
|
|
104
|
+
const taskPicked = query.scope?.kind === "task" ? query.scope.id : undefined;
|
|
105
|
+
const taskCounts = new Map((f?.scope.tasks ?? []).map((t) => [t.value, t.count]));
|
|
106
|
+
const taskIds = Object.keys(titles);
|
|
107
|
+
if (taskPicked && !taskIds.includes(taskPicked)) taskIds.push(taskPicked); // a URL task the project does not list
|
|
108
|
+
const taskSection = section("task", taskIds.map((id): MenuValue => ({
|
|
109
|
+
section: "task", sectionName: SECTION_NAMES.task, key: id, label: titles[id] ?? id, count: taskCounts.get(id) ?? 0, picked: id === taskPicked,
|
|
110
|
+
set: { scope: { kind: "task", id }, suites: undefined }, clear: { scope: undefined },
|
|
111
|
+
})), { scope: undefined });
|
|
98
112
|
const config = CONFIG_FACETS.map((k) => facetSection(k, f?.[k] ?? [], query[k] ?? []));
|
|
99
113
|
|
|
100
114
|
// Judge: a picked grading version sets the view's judge, so that judge shows as picked.
|
|
@@ -133,12 +147,29 @@ export function menuSections({ facets, query, judge, titles }: MenuContext): Men
|
|
|
133
147
|
const range = dateRangeText(query.since, query.until);
|
|
134
148
|
const dates: MenuSection = { id: "dates", name: SECTION_NAMES.dates, summary: range ?? "Any date", picked: !!range, values: [], remove: { since: undefined, until: undefined } };
|
|
135
149
|
|
|
136
|
-
return [...config, judgeSection, gradingSection, dates];
|
|
150
|
+
return [taskSection, ...config, judgeSection, gradingSection, dates];
|
|
137
151
|
}
|
|
138
152
|
|
|
139
153
|
/** What picking `v` sets: the value, or without it when it is already picked. */
|
|
140
154
|
export const pick = (v: MenuValue): Patch => (v.picked ? v.clear : v.set);
|
|
141
155
|
|
|
156
|
+
/**
|
|
157
|
+
* The query after the page sets a grading pick (#80, DEC-019). The default set of suites has no exception for a
|
|
158
|
+
* picked grading version, and a new grading can change which configs have completed a suite, so on the index the
|
|
159
|
+
* pick also becomes an explicit suite pick: the suites in view (or the picked ones), and the first suite of the
|
|
160
|
+
* version's task when none of them has it. A view of one task needs no suite. `taskId` comes from the facets.
|
|
161
|
+
*/
|
|
162
|
+
export function withGradingPick(q: ViewQuery, taskId: string | undefined, suitesInView: string[], suites: { id: string; tasks: string[] }[]): ViewQuery {
|
|
163
|
+
if (!q.grading || q.scope?.kind === "task" || !taskId) return q;
|
|
164
|
+
const ids = new Set(q.suites?.length ? q.suites : suitesInView);
|
|
165
|
+
if (!suites.some((s) => ids.has(s.id) && s.tasks.includes(taskId))) {
|
|
166
|
+
const home = suites.find((s) => s.tasks.includes(taskId));
|
|
167
|
+
if (home) ids.add(home.id);
|
|
168
|
+
}
|
|
169
|
+
const picked = suites.filter((s) => ids.has(s.id)).map((s) => s.id); // catalog order
|
|
170
|
+
return picked.length ? { ...q, scope: { kind: "index" }, suites: picked } : q;
|
|
171
|
+
}
|
|
172
|
+
|
|
142
173
|
/**
|
|
143
174
|
* The search: every value of every section whose section name, name, task or detail line (a grading version's
|
|
144
175
|
* judge) matches the text (case ignored), as one flat list; and whether the date range matches, so it shows as one entry.
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// The How it works page (#79): a short explanation of how Static Fire gets from a trial to a number, in the terms
|
|
2
|
+
// of CONTEXT.md. Static text. Its sections and their order are HOW_IT_WORKS_SECTIONS (web/pages.ts), so another
|
|
3
|
+
// page can link to one section, for example /how-it-works#left-out.
|
|
4
|
+
import { useEffect, type ReactNode } from "react";
|
|
5
|
+
import { HOW_IT_WORKS_SECTIONS, pathOf, type HowItWorksSection } from "./pages";
|
|
6
|
+
|
|
7
|
+
const BODY: Record<HowItWorksSection, ReactNode> = {
|
|
8
|
+
trial: <>
|
|
9
|
+
<p>A <b>trial</b> is one run of one agent config on one task. The agent works in a new folder of its own, with
|
|
10
|
+
the task's prompt, its input files and its project files, and with your own setup, as it would in real work.
|
|
11
|
+
It writes its output to the path the task names. Then the output is graded.</p>
|
|
12
|
+
<p>A trial that could not run (the harness did not start, a login expired, it timed out) is <i>failed</i>. It
|
|
13
|
+
shows as a failure and is not in any mean.</p>
|
|
14
|
+
</>,
|
|
15
|
+
score: <>
|
|
16
|
+
<ul>
|
|
17
|
+
<li><b>Gates</b> are yes or no questions that every output must pass, for example "The output file exists". If
|
|
18
|
+
a gate fails, the task score is 0.</li>
|
|
19
|
+
<li>A <b>measure</b> is a group of criteria. Its score is the percent of its criteria that passed.</li>
|
|
20
|
+
<li>The <b>task score</b> is the mean of the measure scores, with each measure's weight (equal by default),
|
|
21
|
+
from 0 to 100.</li>
|
|
22
|
+
<li>The <b>Suite Index</b> is the mean task score over the tasks of a suite. A config gets one only when it
|
|
23
|
+
has a valid score on every task of the suite. Otherwise it is <i>partial</i>.</li>
|
|
24
|
+
<li>The <b>Agent Performance Index</b> is the mean Suite Index over the suites in view. It also needs a valid
|
|
25
|
+
score on every task of every suite in view.</li>
|
|
26
|
+
</ul>
|
|
27
|
+
<p>A point on a chart is the mean over every valid trial of that config in the view, from any batch, with a 95%
|
|
28
|
+
confidence interval.</p>
|
|
29
|
+
</>,
|
|
30
|
+
graders: <>
|
|
31
|
+
<p>Each criterion has one grader:</p>
|
|
32
|
+
<ul>
|
|
33
|
+
<li>A <b>check</b> is code. It gives the same answer every time: a pattern match, a word count, a field in the
|
|
34
|
+
file's front matter, or a command.</li>
|
|
35
|
+
<li>A <b>judge</b> is a model that reads the output and answers the question. The judge configuration in{" "}
|
|
36
|
+
<code>judges.yaml</code> sets which model it is. A judge does not use your own setup.</li>
|
|
37
|
+
</ul>
|
|
38
|
+
<p>A judge answers each question several times (3 by default). Each answer is a <b>vote</b>, with a pass or fail
|
|
39
|
+
and a reason. The criterion's score is the share of votes that passed, so 2 of 3 votes give 0.67.</p>
|
|
40
|
+
<p>When a grader cannot decide, its verdict is an error. That trial is left out of the means until a regrade
|
|
41
|
+
grades it again.</p>
|
|
42
|
+
</>,
|
|
43
|
+
levels: <>
|
|
44
|
+
<p>Each criterion has a level. It tells you how hard the criterion is meant to be:</p>
|
|
45
|
+
<ul>
|
|
46
|
+
<li><span className="tag floor">Floor</span> Most configurations should pass.</li>
|
|
47
|
+
<li><span className="tag strong">Strong</span> Good configurations pass.</li>
|
|
48
|
+
<li><span className="tag ceiling">Ceiling</span> Only the best configurations pass.</li>
|
|
49
|
+
</ul>
|
|
50
|
+
<p>The level does not change the score. A config that fails floor criteria is not ready for the work. Ceiling
|
|
51
|
+
criteria show which configs are the best.</p>
|
|
52
|
+
</>,
|
|
53
|
+
"left-out": <>
|
|
54
|
+
<p>A chart compares only trials that did the same job and were graded the same way. The Results page counts the
|
|
55
|
+
trials it leaves out, by reason:</p>
|
|
56
|
+
<ul>
|
|
57
|
+
<li><b>Another grading version.</b> The criteria, the references or the grading code changed after the trial
|
|
58
|
+
was graded. <i>What to do:</i> regrade, which uses judge calls only and runs no agent. Or pick the earlier
|
|
59
|
+
grading version in the Filter menu to see those trials as they were graded.</li>
|
|
60
|
+
<li><b>Another task version.</b> The prompt, the output path or the files the agent gets changed, so the
|
|
61
|
+
trial did a different job. A regrade cannot bring it in. <i>What to do:</i> switch to that version to see
|
|
62
|
+
its trials, or run new trials on the current one.</li>
|
|
63
|
+
<li><b>Another judge.</b> The trial was graded by a different judge, and the scores of two judges are never
|
|
64
|
+
mixed. <i>What to do:</i> pick that judge in the Filter menu, or regrade with the judge in view.</li>
|
|
65
|
+
<li><b>Not graded yet.</b> The trial has no finished grading. <i>What to do:</i> if its batch is still running,
|
|
66
|
+
wait. If not, regrade.</li>
|
|
67
|
+
</ul>
|
|
68
|
+
</>,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export function HowItWorksPage() {
|
|
72
|
+
// A link to one section opens the page there.
|
|
73
|
+
useEffect(() => { if (location.hash) document.getElementById(decodeURIComponent(location.hash.slice(1)))?.scrollIntoView(); }, []);
|
|
74
|
+
return (
|
|
75
|
+
<>
|
|
76
|
+
<section className="hero">
|
|
77
|
+
<div className="kicker">How it works</div>
|
|
78
|
+
<h1>From a trial to a number</h1>
|
|
79
|
+
<p className="page-sub">What each number on the Results page means. Each task's own questions are on the{" "}
|
|
80
|
+
<a href={pathOf("tasks")}>Tasks</a> page.</p>
|
|
81
|
+
</section>
|
|
82
|
+
<section className="card how-it-works">
|
|
83
|
+
{HOW_IT_WORKS_SECTIONS.map((s) => (
|
|
84
|
+
<section key={s.id} id={s.id} className="how-section">
|
|
85
|
+
<h2>{s.title}</h2>
|
|
86
|
+
{BODY[s.id]}
|
|
87
|
+
</section>
|
|
88
|
+
))}
|
|
89
|
+
</section>
|
|
90
|
+
</>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// The index buttons of the Results page (#80): the suites in view are switches, and the title follows them.
|
|
2
|
+
// Kept apart from app.tsx so the rules are tested without a browser.
|
|
3
|
+
import type { Suite } from "../src/core/types";
|
|
4
|
+
import type { Scope } from "../src/core/view-query";
|
|
5
|
+
import { suiteHref, taskHref } from "./pages";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The title of a view: the task's on a task view, the suite's when one suite is in view, else
|
|
9
|
+
* "Agent Performance Index". It never lists the suites, so it does not grow with them.
|
|
10
|
+
*/
|
|
11
|
+
export const viewTitle = (sel: Scope, suiteIds: string[], suites: Map<string, Suite>, taskTitle: (id: string) => string) =>
|
|
12
|
+
sel.kind === "task" ? taskTitle(sel.id) : suiteIds.length === 1 ? suites.get(suiteIds[0]!)?.title ?? suiteIds[0]! : "Agent Performance Index";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The suites in view after a click on a suite's switch. On a task view it opens the index on that suite alone.
|
|
16
|
+
* Otherwise it turns the suite off when it is in view, else on. Null when the click would leave no suite in view.
|
|
17
|
+
*/
|
|
18
|
+
export function toggleSuite(sel: Scope, inView: string[], id: string, catalog: Suite[]): string[] | null {
|
|
19
|
+
if (sel.kind === "task") return [id];
|
|
20
|
+
const next = inView.includes(id) ? inView.filter((x) => x !== id) : [...inView, id];
|
|
21
|
+
return next.length ? catalog.map((s) => s.id).filter((x) => next.includes(x)) : null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Where the title links (#81): a task view's title to the task's entry on the Tasks page, one suite's to the
|
|
26
|
+
* suite's entry. The Agent Performance Index over several suites is not a name, so it has no link.
|
|
27
|
+
*/
|
|
28
|
+
export const titleHref = (sel: Scope, suiteIds: string[]): string | null =>
|
|
29
|
+
sel.kind === "task" ? taskHref(sel.id) : suiteIds.length === 1 ? suiteHref(suiteIds[0]!) : null;
|
package/web/levels.tsx
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// A criterion's level where the dashboard shows one (#68), with the engine's names (DEC-014 of #71): a tag whose
|
|
2
|
+
// tooltip says what it means, and a one-line legend next to a list of tags.
|
|
3
|
+
|
|
4
|
+
const LEVEL_TEXT: Record<string, string> = { floor: "Floor", strong: "Strong", ceiling: "Ceiling" };
|
|
5
|
+
/** What each level means. */
|
|
6
|
+
export const LEVEL_MEANING: Record<string, string> = { floor: "most configurations should pass", strong: "good ones pass", ceiling: "only the best pass" };
|
|
7
|
+
|
|
8
|
+
export const LevelTag = ({ level }: { level: string }) => (
|
|
9
|
+
<span className={`tag ${level}`} title={`${LEVEL_TEXT[level] ?? level}: ${LEVEL_MEANING[level] ?? ""}`}>{LEVEL_TEXT[level] ?? level}</span>
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
/** One line that says what the levels mean, next to where they are shown. */
|
|
13
|
+
export const LevelLegend = () => (
|
|
14
|
+
<p className="level-legend"><span className="k">Levels</span>{Object.keys(LEVEL_TEXT).map((l) => <span key={l}><LevelTag level={l} /> {LEVEL_MEANING[l]}</span>)}</p>
|
|
15
|
+
);
|
package/web/notices.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// The words of the Results page's one alert and its details (#82, DEC-023): what a view does not show, why, and
|
|
2
|
+
// what to do. Display only: which trials a view leaves out comes from the view (src/core/view.ts LeftOut), and
|
|
3
|
+
// the regrade is the server's. The How it works page's #left-out section says the same in full.
|
|
4
|
+
|
|
5
|
+
/** A count of trials not shown and of the configs they belong to. */
|
|
6
|
+
export interface Counted { trials: number; configs: number }
|
|
7
|
+
|
|
8
|
+
/** One item of the details: what is not shown and why, then what to do. */
|
|
9
|
+
export interface NoticeItem { what: string; todo: string }
|
|
10
|
+
|
|
11
|
+
const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
12
|
+
|
|
13
|
+
/** The alert's short count, for example "3 trials and 2 configs not shown". Null when nothing is left out. */
|
|
14
|
+
export function alertText(trials: number, configs: number): string | null {
|
|
15
|
+
const parts = [trials ? plural(trials, "trial") : "", configs ? plural(configs, "config") : ""].filter(Boolean);
|
|
16
|
+
return parts.length ? `${parts.join(" and ")} not shown` : null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const fromConfigs = (c: Counted) => `${plural(c.trials, "trial")} from ${plural(c.configs, "config")}`;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Trials graded only under another grading version. `current`: every task in view is on its current version, so
|
|
23
|
+
* the view offers Regrade. `ungraded`: how many trials not graded yet the same Regrade button covers.
|
|
24
|
+
*/
|
|
25
|
+
export function gradingItem(c: Counted, { current, ungraded }: { current: boolean; ungraded: number }): NoticeItem {
|
|
26
|
+
const them = c.trials === 1 ? "it" : "them", their = c.trials === 1 ? "its" : "their";
|
|
27
|
+
return {
|
|
28
|
+
what: `${fromConfigs(c)} ${c.trials === 1 ? "was" : "were"} graded under ${current ? "earlier" : "other"} criteria or by another judge.`,
|
|
29
|
+
todo: current
|
|
30
|
+
? `Regrade ${them} with the button below${ungraded ? " (it also grades the trials not graded yet)" : ""}, or pick ${their} grading version in the Filter menu.`
|
|
31
|
+
: `Pick ${their} grading version in the Filter menu.`,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Trials with no finished grading. `otherGrading`: how many trials of the other item the same button covers. */
|
|
36
|
+
export function ungradedItem(c: Counted, { current, otherGrading }: { current: boolean; otherGrading: number }): NoticeItem {
|
|
37
|
+
const one = c.trials === 1;
|
|
38
|
+
const wait = `If ${one ? "its" : "their"} batch is still running, wait.`;
|
|
39
|
+
return {
|
|
40
|
+
what: `${fromConfigs(c)} ${one ? "is" : "are"} not graded yet.`,
|
|
41
|
+
todo: current
|
|
42
|
+
? `${wait} If not, regrade ${one ? "it" : "them"} with the ${otherGrading ? "same button below: one regrade covers both lists" : "button below"}.`
|
|
43
|
+
: wait,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Under the Regrade button: what it does, and no more (#80: with nothing picked, it covers the suites in view only). */
|
|
48
|
+
export function regradeNote(trials: number, suitesLeftOut: boolean): string {
|
|
49
|
+
return `Judge calls only. It grades the ${plural(trials, "trial")} above again and runs no agent.${suitesLeftOut ? " Suites left out of the index are not regraded." : ""}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A config the chart cannot plot: the suites in view it has not completed, and its score (null: no valid score). */
|
|
53
|
+
export interface NotPlotted { label: string; partial: { suite: string; have: number; of: number }[]; score: number | null }
|
|
54
|
+
|
|
55
|
+
/** The configs the chart cannot plot, under one reason. */
|
|
56
|
+
export interface NotPlottedGroup { why: string; configs: string[]; todo: string }
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The configs the chart cannot plot, grouped by reason: one group per suite they have not completed (in the order
|
|
60
|
+
* met), then no valid score, then no cost data (a score but no cost). A config partial in two suites is in both.
|
|
61
|
+
*/
|
|
62
|
+
export function notPlottedGroups(list: NotPlotted[]): NotPlottedGroup[] {
|
|
63
|
+
const suites = new Map<string, string[]>();
|
|
64
|
+
const noScore: string[] = [], noCost: string[] = [];
|
|
65
|
+
for (const c of list) {
|
|
66
|
+
if (c.partial.length) for (const p of c.partial) suites.set(p.suite, [...(suites.get(p.suite) ?? []), `${c.label} (${p.have} of ${p.of} tasks)`]);
|
|
67
|
+
else (c.score == null ? noScore : noCost).push(c.label);
|
|
68
|
+
}
|
|
69
|
+
return [
|
|
70
|
+
...[...suites].map(([suite, configs]) => ({ why: `No score on every task of ${suite}`, configs, todo: "Run the tasks they miss." })),
|
|
71
|
+
...(noScore.length ? [{ why: "No valid score", configs: noScore, todo: "Open their trials to see why." }] : []),
|
|
72
|
+
...(noCost.length ? [{ why: "No cost data", configs: noCost, todo: "Check the costs in pricing.yaml." }] : []),
|
|
73
|
+
];
|
|
74
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// How the trial drawer shows a trial's output (#68). An output is agent output and is not trusted (DEC-012 of #71):
|
|
2
|
+
// it never goes into the dashboard's own page. An HTML page runs in a sandboxed frame that may run the page's own
|
|
3
|
+
// scripts but has an opaque origin, so it cannot read or change the dashboard or run code on its origin. Markdown and
|
|
4
|
+
// other text are rendered into a frame that runs no code at all. The API refuses changes from such a frame
|
|
5
|
+
// (src/api.ts), since a sandboxed page can still send a request.
|
|
6
|
+
|
|
7
|
+
export type OutputKind = "image" | "html" | "markdown" | "text";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* How to show an output: by the server's media type for an image, else by the extension of the task's output path.
|
|
11
|
+
* Text that is neither HTML nor Markdown is shown as Markdown, as before, but named "text" so its second view is "Source".
|
|
12
|
+
*/
|
|
13
|
+
export function outputKind(outputPath: string, contentType: string | null): OutputKind {
|
|
14
|
+
if (contentType?.startsWith("image/")) return "image";
|
|
15
|
+
const ext = /\.([^./\\]+)$/.exec(outputPath)?.[1]?.toLowerCase();
|
|
16
|
+
return ext === "html" || ext === "htm" ? "html" : ext === "md" || ext === "markdown" ? "markdown" : "text";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The name of the second view, which shows the output's text as it is. */
|
|
20
|
+
export const sourceLabel = (kind: OutputKind) => (kind === "markdown" ? "Markdown" : "Source");
|
|
21
|
+
|
|
22
|
+
/** The HTML frame may run the page's own scripts. No allow-same-origin, forms, popups, modals or top navigation. */
|
|
23
|
+
export const HTML_SANDBOX = "allow-scripts";
|
|
24
|
+
/** The Markdown and text frame runs no code. */
|
|
25
|
+
export const PROSE_SANDBOX = "";
|
|
26
|
+
|
|
27
|
+
/** The rendered Markdown's frame loads only its styles and the dashboard's fonts: no script, image, frame or request. */
|
|
28
|
+
const PROSE_POLICY = "default-src 'none'; style-src 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com";
|
|
29
|
+
const FONTS = "https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400..900&family=IBM+Plex+Mono:wght@400;500;600&display=swap";
|
|
30
|
+
|
|
31
|
+
/** The dashboard's tokens the prose styles use; their values are read from the dashboard's page (web/brand). */
|
|
32
|
+
export const PROSE_TOKENS = [
|
|
33
|
+
"--fg", "--fg-2", "--outline", "--color-sky", "--surface", "--line", "--accent", "--outline-width", "--radius-xs", "--spacing-12", "--spacing-16", "--spacing-24",
|
|
34
|
+
"--font-text", "--font-heading", "--font-mono", "--weight-heading-card", "--leading-text", "--text-sm",
|
|
35
|
+
] as const;
|
|
36
|
+
|
|
37
|
+
const PROSE_CSS = `
|
|
38
|
+
html, body { margin: 0; background: transparent; color: var(--fg); font-family: var(--font-text); font-size: 15px; line-height: var(--leading-text); -webkit-font-smoothing: antialiased; }
|
|
39
|
+
.prose { line-height: 1.65; max-width: 680px; padding: var(--spacing-16) var(--spacing-16) var(--spacing-24); }
|
|
40
|
+
.prose h1, .prose h2, .prose h3 { font-family: var(--font-heading); font-weight: var(--weight-heading-card); letter-spacing: -0.02em; line-height: 1.15; }
|
|
41
|
+
.prose code { font-family: var(--font-mono); font-size: 0.9em; background: var(--surface); padding: 1px 5px; border-radius: 6px; }
|
|
42
|
+
.prose pre { background: var(--surface); border: var(--outline-width) solid var(--line); padding: var(--spacing-12); border-radius: var(--radius-xs); overflow-x: auto; }
|
|
43
|
+
.prose pre code { background: none; padding: 0; }
|
|
44
|
+
.prose blockquote { border-left: 4px solid var(--accent); margin: 0; padding-left: var(--spacing-16); color: var(--fg-2); }
|
|
45
|
+
.prose img { display: none; }
|
|
46
|
+
.prose table { font-size: var(--text-sm); border-collapse: collapse; }
|
|
47
|
+
.prose td, .prose th { border: 1px solid var(--line); padding: 4px 8px; }
|
|
48
|
+
.img-ph { display: inline-block; background: var(--color-sky); border: var(--outline-width) dashed var(--outline); border-radius: var(--radius-xs); padding: 2px 8px; font-size: var(--text-sm); }
|
|
49
|
+
`;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The document for the Markdown and text frame: its policy first, so the output cannot loosen it, then the prose
|
|
53
|
+
* styles with the dashboard's token values, then the rendered output. A token value that could end the style
|
|
54
|
+
* element is left out.
|
|
55
|
+
*/
|
|
56
|
+
export function proseDoc(html: string, tokens: Record<string, string>): string {
|
|
57
|
+
const vars = Object.entries(tokens).filter(([, v]) => !/[<>{};]/.test(v)).map(([k, v]) => `${k}: ${v};`).join(" ");
|
|
58
|
+
return `<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="${PROSE_POLICY}">`
|
|
59
|
+
+ `<link rel="stylesheet" href="${FONTS}"><style>:root { ${vars} }${PROSE_CSS}</style></head>`
|
|
60
|
+
+ `<body><div class="prose">${html}</div></body></html>`;
|
|
61
|
+
}
|
package/web/pages.ts
CHANGED
|
@@ -5,6 +5,8 @@ export const PAGES = [
|
|
|
5
5
|
{ id: "results", path: "/", title: "Results" },
|
|
6
6
|
{ id: "runs", path: "/runs", title: "Runs" },
|
|
7
7
|
{ id: "compare", path: "/compare", title: "Compare" },
|
|
8
|
+
{ id: "tasks", path: "/tasks", title: "Tasks" },
|
|
9
|
+
{ id: "how", path: "/how-it-works", title: "How it works" },
|
|
8
10
|
] as const;
|
|
9
11
|
|
|
10
12
|
export type PageId = (typeof PAGES)[number]["id"];
|
|
@@ -14,3 +16,27 @@ export const pageOf = (pathname: string): PageId =>
|
|
|
14
16
|
PAGES.find((p) => p.path === (pathname.replace(/\/+$/, "") || "/"))?.id ?? "results";
|
|
15
17
|
|
|
16
18
|
export const pathOf = (id: PageId): string => PAGES.find((p) => p.id === id)!.path;
|
|
19
|
+
|
|
20
|
+
// Addresses inside a page, so another page can link to one entry (#79). An entry's element id is its fragment.
|
|
21
|
+
/** The element id of a suite on the Tasks page. */
|
|
22
|
+
export const suiteAnchor = (id: string) => `suite-${id}`;
|
|
23
|
+
/** The element id of a task on the Tasks page. */
|
|
24
|
+
export const taskAnchor = (id: string) => `task-${id}`;
|
|
25
|
+
/** The address of a suite on the Tasks page: `/tasks#suite-<id>`. */
|
|
26
|
+
export const suiteHref = (id: string) => `${pathOf("tasks")}#${encodeURIComponent(suiteAnchor(id))}`;
|
|
27
|
+
/** The address of a task on the Tasks page: `/tasks#task-<id>`. */
|
|
28
|
+
export const taskHref = (id: string) => `${pathOf("tasks")}#${encodeURIComponent(taskAnchor(id))}`;
|
|
29
|
+
|
|
30
|
+
/** The sections of the How it works page, in reading order. A section's address is `/how-it-works#<id>`. */
|
|
31
|
+
export const HOW_IT_WORKS_SECTIONS = [
|
|
32
|
+
{ id: "trial", title: "A trial" },
|
|
33
|
+
{ id: "score", title: "How a score is made" },
|
|
34
|
+
{ id: "graders", title: "Checks and judges" },
|
|
35
|
+
{ id: "levels", title: "Levels" },
|
|
36
|
+
{ id: "left-out", title: "Why trials are left out of a chart" },
|
|
37
|
+
] as const;
|
|
38
|
+
|
|
39
|
+
export type HowItWorksSection = (typeof HOW_IT_WORKS_SECTIONS)[number]["id"];
|
|
40
|
+
|
|
41
|
+
/** The address of a section of the How it works page, for example `/how-it-works#left-out`. */
|
|
42
|
+
export const howItWorksHref = (section: HowItWorksSection) => `${pathOf("how")}#${section}`;
|
package/web/styles.css
CHANGED
|
@@ -19,7 +19,7 @@ code { font-family: var(--font-mono); font-size: 0.9em; }
|
|
|
19
19
|
.spacer { flex: 1; }
|
|
20
20
|
|
|
21
21
|
/* page navigation (#24): one pill per page, the current one filled */
|
|
22
|
-
.nav { display: flex; gap: var(--spacing-4, 4px); }
|
|
22
|
+
.nav { display: flex; flex-wrap: wrap; gap: var(--spacing-4, 4px); }
|
|
23
23
|
.nav a { font-weight: 800; color: var(--fg-3); text-decoration: none; border: var(--outline-width) solid transparent; border-radius: var(--radius-pill); padding: 4px var(--spacing-16); }
|
|
24
24
|
.nav a:hover { color: var(--fg); }
|
|
25
25
|
.nav a.on { color: var(--fg); background: var(--accent); border-color: var(--outline); }
|
|
@@ -36,6 +36,8 @@ select, .btn, input[type="number"] { border: var(--outline-width) solid var(--ou
|
|
|
36
36
|
/* hero */
|
|
37
37
|
.hero { margin-bottom: var(--spacing-24); }
|
|
38
38
|
.hero h1 { font-family: var(--font-display); font-weight: var(--weight-display); letter-spacing: var(--tracking-display); line-height: var(--leading-display); font-size: clamp(2rem, 5vw, var(--text-5xl)); margin: var(--spacing-8) 0 var(--spacing-12); }
|
|
39
|
+
.hero h1 .title-link { color: inherit; text-decoration: none; }
|
|
40
|
+
.hero h1 .title-link:hover, .hero h1 .title-link:focus-visible { text-decoration: underline; text-decoration-thickness: 3px; text-underline-offset: 6px; }
|
|
39
41
|
.meta { display: flex; flex-wrap: wrap; gap: var(--spacing-8) var(--spacing-24); color: var(--fg-3); font-size: var(--text-sm); margin-bottom: var(--spacing-16); }
|
|
40
42
|
.meta b { color: var(--fg); }
|
|
41
43
|
.progress { display: flex; align-items: center; gap: var(--spacing-12); }
|
|
@@ -99,6 +101,7 @@ select, .btn, input[type="number"] { border: var(--outline-width) solid var(--ou
|
|
|
99
101
|
.grid3 .card { margin-bottom: 0; }
|
|
100
102
|
@media (max-width: 900px) { .grid3 { grid-template-columns: 1fr; } }
|
|
101
103
|
.note { color: var(--fg-3); font-size: var(--text-sm); margin: var(--spacing-12) 0 0; }
|
|
104
|
+
.note.coverage { margin: 0 0 var(--spacing-8); } /* the one line under the index buttons: what the index covers (#80) */
|
|
102
105
|
.empty { text-align: center; padding: var(--spacing-64) var(--spacing-24); }
|
|
103
106
|
.empty .kicker { justify-content: center; }
|
|
104
107
|
.empty h1 { font-family: var(--font-display); font-weight: var(--weight-display); letter-spacing: var(--tracking-display); font-size: var(--text-4xl); margin: var(--spacing-8) 0; }
|
|
@@ -179,11 +182,21 @@ td.q .tag { margin-left: var(--spacing-8); }
|
|
|
179
182
|
.drawer { position: fixed; top: 0; right: 0; bottom: 0; width: min(1120px, 100vw); background: var(--ground); border-left: var(--outline-width) solid var(--outline); z-index: 11; display: flex; flex-direction: column; }
|
|
180
183
|
.drawer-head { padding: var(--spacing-16) var(--spacing-24); border-bottom: var(--outline-width) solid var(--outline); display: flex; align-items: flex-start; gap: var(--spacing-12); background: var(--raised); }
|
|
181
184
|
.drawer-head h2 { font-family: var(--font-heading); font-weight: var(--weight-heading); letter-spacing: var(--tracking-heading); font-size: var(--text-2xl); margin: 4px 0 0; }
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
.drawer-
|
|
185
|
-
.drawer-
|
|
186
|
-
.drawer-
|
|
185
|
+
/* Three tabs under the head (#68); each panel takes the drawer's whole width and scrolls on its own. */
|
|
186
|
+
.drawer-tabs { display: flex; gap: var(--spacing-4, 4px); padding: var(--spacing-8) var(--spacing-24) 0; border-bottom: var(--outline-width) solid var(--outline); background: var(--raised); }
|
|
187
|
+
.drawer-tab { border: var(--outline-width) solid transparent; border-bottom: none; background: none; border-radius: var(--radius-xs) var(--radius-xs) 0 0; padding: var(--spacing-8) var(--spacing-16); font-weight: 800; color: var(--fg-3); margin-bottom: calc(-1 * var(--outline-width)); }
|
|
188
|
+
.drawer-tab:hover { color: var(--fg); }
|
|
189
|
+
.drawer-tab.on { color: var(--fg); background: var(--ground); border-color: var(--outline); }
|
|
190
|
+
.drawer-panel { flex: 1; min-height: 0; overflow-y: auto; padding: var(--spacing-24); }
|
|
191
|
+
.drawer-panel.output { display: flex; flex-direction: column; }
|
|
192
|
+
.drawer-panel.output > * { flex-shrink: 0; }
|
|
193
|
+
/* An output in a sandboxed frame (web/output-view.ts): it fills the rest of the panel, like a browser window. */
|
|
194
|
+
.output-frame { display: block; width: 100%; flex: 1 0 480px !important; border: var(--outline-width) solid var(--outline); border-radius: var(--radius-xs); background: #fff; }
|
|
195
|
+
.output-frame.prose-frame { background: var(--ground); border-color: var(--line); }
|
|
196
|
+
.level-legend { display: flex; flex-wrap: wrap; align-items: center; gap: var(--spacing-8) var(--spacing-16); font-size: var(--text-sm); color: var(--fg-2); margin: 0 0 var(--spacing-16); }
|
|
197
|
+
.level-legend .k { font-family: var(--font-kicker); font-weight: var(--weight-kicker); text-transform: uppercase; letter-spacing: var(--tracking-kicker); font-size: 11px; color: var(--fg-3); }
|
|
198
|
+
.level-legend .tag { margin-left: 0; }
|
|
199
|
+
.drawer-panel h3 { font-family: var(--font-heading); font-weight: var(--weight-heading); letter-spacing: var(--tracking-heading); font-size: var(--text-xl); margin: 0 0 var(--spacing-12); }
|
|
187
200
|
.stats { display: flex; gap: var(--spacing-24); flex-wrap: wrap; margin-top: var(--spacing-8); }
|
|
188
201
|
.stat .k { color: var(--fg-3); font-size: 12px; }
|
|
189
202
|
.stat .v { font-size: var(--text-lg); font-weight: 800; font-variant-numeric: tabular-nums; }
|
|
@@ -191,16 +204,7 @@ td.q .tag { margin-left: var(--spacing-8); }
|
|
|
191
204
|
.tabs { display: flex; gap: 6px; margin-bottom: var(--spacing-16); }
|
|
192
205
|
.tab { border: var(--outline-width) solid var(--outline); background: var(--raised); border-radius: var(--radius-pill); padding: 2px var(--spacing-12); font-size: var(--text-sm); font-weight: 700; }
|
|
193
206
|
.tab.on { background: var(--accent); }
|
|
194
|
-
|
|
195
|
-
.prose h1, .prose h2, .prose h3 { font-family: var(--font-heading); font-weight: var(--weight-heading-card); letter-spacing: -0.02em; line-height: 1.15; }
|
|
196
|
-
.prose code { background: var(--surface); padding: 1px 5px; border-radius: 6px; }
|
|
197
|
-
.prose pre { background: var(--surface); border: var(--outline-width) solid var(--line); padding: var(--spacing-12); border-radius: var(--radius-xs); overflow-x: auto; }
|
|
198
|
-
.prose pre code { background: none; padding: 0; }
|
|
199
|
-
.prose blockquote { border-left: 4px solid var(--accent); margin: 0; padding-left: var(--spacing-16); color: var(--fg-2); }
|
|
200
|
-
.prose img { display: none; }
|
|
201
|
-
.prose table { font-size: var(--text-sm); }
|
|
202
|
-
.prose td, .prose th { border: 1px solid var(--line); }
|
|
203
|
-
/* An image output in the trial drawer: never wider than the drawer column. */
|
|
207
|
+
/* An image output in the trial drawer: never wider than the drawer. The rendered Markdown is styled in its frame (web/output-view.ts). */
|
|
204
208
|
.output-image { display: block; max-width: 100%; height: auto; border: var(--outline-width) solid var(--outline); border-radius: var(--radius-xs); }
|
|
205
209
|
pre.raw { white-space: pre-wrap; font-family: var(--font-mono); font-size: 12.5px; }
|
|
206
210
|
.verdict-group { margin-bottom: var(--spacing-24); }
|
|
@@ -245,7 +249,6 @@ pre.raw { white-space: pre-wrap; font-family: var(--font-mono); font-size: 12.5p
|
|
|
245
249
|
.envelope { background: var(--surface); border: var(--outline-width) solid var(--outline); border-radius: var(--radius-sm); padding: var(--spacing-12) var(--spacing-16); margin-bottom: var(--spacing-16); font-size: var(--text-sm); }
|
|
246
250
|
.envelope .k { font-family: var(--font-kicker); font-weight: var(--weight-kicker); text-transform: uppercase; letter-spacing: var(--tracking-kicker); font-size: 11px; color: var(--fg-3); margin-top: 4px; }
|
|
247
251
|
.envelope .subject { font-weight: 800; font-size: var(--text-lg); }
|
|
248
|
-
.img-ph { display: inline-block; background: var(--color-sky); border: var(--outline-width) dashed var(--outline); border-radius: var(--radius-xs); padding: 2px var(--spacing-8); font-size: var(--text-sm); }
|
|
249
252
|
|
|
250
253
|
/* batches panel (#13) */
|
|
251
254
|
.batches { margin-bottom: var(--spacing-24); }
|
|
@@ -277,15 +280,26 @@ pre.raw { white-space: pre-wrap; font-family: var(--font-mono); font-size: 12.5p
|
|
|
277
280
|
.status.done { color: var(--fg); }
|
|
278
281
|
.status.failed, .status.invalid { font-weight: 700; }
|
|
279
282
|
|
|
280
|
-
/*
|
|
281
|
-
.
|
|
282
|
-
.
|
|
283
|
-
.
|
|
284
|
-
.
|
|
283
|
+
/* the one alert in the chart card (#82): a button, and details that float over the page so the card does not grow */
|
|
284
|
+
.notice { position: relative; margin-top: var(--spacing-12); }
|
|
285
|
+
.notice-btn { display: inline-flex; align-items: center; gap: var(--spacing-8); font: inherit; font-size: var(--text-sm); font-weight: 700; color: var(--fg); background: var(--color-cream); border: var(--outline-width) solid var(--outline); border-radius: var(--radius-sm); padding: 4px var(--spacing-12); cursor: pointer; }
|
|
286
|
+
.notice-btn:hover, .notice-btn.on { background: var(--surface); }
|
|
287
|
+
.notice-btn:focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
|
|
288
|
+
.notice-more { font-weight: 400; color: var(--fg-3); text-decoration: underline; }
|
|
289
|
+
.notice-details { position: absolute; z-index: 20; top: calc(100% + 8px); left: 0; width: min(560px, calc(100vw - 32px)); max-height: min(60vh, 520px); overflow: auto; box-sizing: border-box; background: var(--raised); border: var(--outline-width) solid var(--outline); border-radius: var(--radius-sm); box-shadow: var(--shadow-md); padding: var(--spacing-12) var(--spacing-16); font-size: var(--text-sm); text-align: left; }
|
|
290
|
+
.notice-details > ul { margin: 0; padding-left: 1.2em; display: grid; gap: var(--spacing-8); }
|
|
291
|
+
.notice-details .notice-action { list-style: none; margin-left: -1.2em; }
|
|
292
|
+
.notice-details .notice-action .btn-quiet { margin-top: 0; }
|
|
293
|
+
.notice-details .notice-action .note { margin-top: 4px; }
|
|
294
|
+
.notice-details .error { margin-top: 4px; }
|
|
295
|
+
.notice-groups { margin: 4px 0; padding-left: 1.2em; }
|
|
296
|
+
.notice-how { display: inline-block; margin-top: var(--spacing-12); color: var(--fg); }
|
|
297
|
+
.empty .notice { text-align: left; max-width: 72ch; margin: var(--spacing-24) auto 0; }
|
|
285
298
|
.linkish { background: none; border: 0; padding: 0; font: inherit; color: var(--fg); text-decoration: underline; cursor: pointer; }
|
|
286
299
|
|
|
287
300
|
/* chart card: Chart / Table toggle and the config table */
|
|
288
301
|
.card-head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--spacing-8) var(--spacing-16); }
|
|
302
|
+
.card-head .task-menu { max-width: 360px; font-weight: 700; font-size: var(--text-sm); }
|
|
289
303
|
.view-toggle { display: inline-flex; border: var(--outline-width) solid var(--outline); border-radius: var(--radius-pill); overflow: hidden; background: var(--raised); }
|
|
290
304
|
.view-toggle button { font: inherit; font-weight: 700; font-size: var(--text-sm); color: var(--fg); border: 0; background: transparent; padding: 4px var(--spacing-16); cursor: pointer; }
|
|
291
305
|
.view-toggle button + button { border-left: var(--outline-width) solid var(--outline); }
|
|
@@ -331,3 +345,24 @@ table.configs .why { color: var(--fg-3); font-size: 12px; margin-top: 2px; }
|
|
|
331
345
|
.run-status .mini { flex: none; width: 70px; height: 7px; border: 1.5px solid var(--outline); border-radius: 4px; overflow: hidden; background: var(--raised); }
|
|
332
346
|
.run-status .mini b { display: block; height: 100%; background: var(--color-sun); transition: width .4s ease; }
|
|
333
347
|
.run-status-count { white-space: nowrap; font-variant-numeric: tabular-nums; }
|
|
348
|
+
|
|
349
|
+
/* Tasks and How it works pages (#79) */
|
|
350
|
+
.page-sub { color: var(--fg-3); margin: 0; max-width: 72ch; }
|
|
351
|
+
.task-entry { padding-top: var(--spacing-24); margin-top: var(--spacing-24); border-top: 2px solid var(--line); scroll-margin-top: var(--spacing-16); }
|
|
352
|
+
.task-entry h3 { font-size: var(--text-xl); }
|
|
353
|
+
.task-entry h4 { font-size: var(--text-sm); text-transform: uppercase; letter-spacing: var(--tracking-kicker); color: var(--fg-3); margin: var(--spacing-16) 0 var(--spacing-8); }
|
|
354
|
+
.task-version { font-size: var(--text-sm); font-weight: 500; }
|
|
355
|
+
.task-desc { margin: var(--spacing-4, 4px) 0 0; max-width: 72ch; }
|
|
356
|
+
.task-prompt { white-space: pre-wrap; font-family: var(--font-mono); font-size: 13px; background: var(--surface); border: 1.5px solid var(--line); border-radius: var(--radius-xs); padding: var(--spacing-12); margin: 0; max-width: 90ch; }
|
|
357
|
+
.task-facts { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 6px var(--spacing-16); margin: var(--spacing-12) 0 0; font-size: var(--text-sm); }
|
|
358
|
+
.task-facts dt { font-weight: 700; color: var(--fg-3); }
|
|
359
|
+
.task-facts dd { margin: 0; display: flex; flex-wrap: wrap; gap: 4px var(--spacing-8); overflow-wrap: anywhere; }
|
|
360
|
+
.task-criteria td.question { text-align: left; }
|
|
361
|
+
.task-criteria .group-note { text-transform: none; letter-spacing: 0; font-family: var(--font-text); font-weight: 500; }
|
|
362
|
+
.task-suite, .how-section { scroll-margin-top: var(--spacing-16); }
|
|
363
|
+
.how-it-works { max-width: 80ch; }
|
|
364
|
+
.how-section + .how-section { margin-top: var(--spacing-24); padding-top: var(--spacing-16); border-top: 2px solid var(--line); }
|
|
365
|
+
.how-section p, .how-section li { line-height: 1.55; }
|
|
366
|
+
.how-section ul { padding-left: var(--spacing-24); }
|
|
367
|
+
.how-section li + li { margin-top: 6px; }
|
|
368
|
+
.how-section .tag { margin: 0 var(--spacing-8) 0 0; }
|
package/web/task-card.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// The per-task cards of the Results page (#81, DEC-022 of #71): "Where the points come from" and "Trials" are
|
|
2
|
+
// one card each, with a menu of the tasks in view, so the page does not grow with the number of tasks.
|
|
3
|
+
|
|
4
|
+
/** The task a card shows: the picked one while it is in view, else the first task in view. */
|
|
5
|
+
export const shownTask = (taskIds: string[], picked: string | null): string | null =>
|
|
6
|
+
picked && taskIds.includes(picked) ? picked : taskIds[0] ?? null;
|