toolroll 0.9.13 → 0.9.15
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/CHANGELOG.md +23 -0
- package/dist/assignment-ui.d.ts +1 -0
- package/dist/assignment-ui.js +8 -5
- package/dist/assignment.d.ts +32 -0
- package/dist/assignment.js +81 -7
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +38 -38
- package/dist/browser-crew.d.ts +2 -1
- package/dist/browser-crew.js +4 -2
- package/dist/browser-workspace.d.ts +109 -11
- package/dist/chat-actions.d.ts +1 -1
- package/dist/chat-actions.js +6 -4
- package/dist/chat-delivery-state.d.ts +1 -1
- package/dist/chat-voice.d.ts +3 -3
- package/dist/chat-voice.js +3 -3
- package/dist/demo.js +82 -2
- package/dist/disclosure.d.ts +6 -0
- package/dist/disclosure.js +13 -0
- package/dist/dispatch.js +7 -2
- package/dist/lead-status.js +2 -2
- package/dist/needs-you.d.ts +72 -0
- package/dist/needs-you.js +193 -1
- package/dist/result-acts.d.ts +51 -5
- package/dist/result-acts.js +36 -3
- package/dist/result-review.d.ts +4 -3
- package/dist/result-review.js +7 -3
- package/dist/serve.d.ts +3 -0
- package/dist/serve.js +1235 -711
- package/dist/slack-chat.js +1 -1
- package/dist/task-status.d.ts +24 -3
- package/dist/task-status.js +48 -17
- package/dist/telegram-progress.js +2 -2
- package/dist/test-temp.js +1 -1
- package/dist/transitions-recipes.d.ts +1 -1
- package/dist/transitions-recipes.js +5 -9
- package/dist/when-html.d.ts +19 -2
- package/dist/when-html.js +78 -11
- package/dist/work-index.d.ts +3 -1
- package/dist/work-index.js +18 -5
- package/dist/workspace-motion.js +3 -3
- package/dist/workspace-ui.d.ts +86 -6
- package/dist/workspace-ui.js +143 -17
- package/package.json +1 -1
package/dist/slack-chat.js
CHANGED
|
@@ -360,7 +360,7 @@ export async function deliverSlackPart(options) {
|
|
|
360
360
|
}))
|
|
361
361
|
: []),
|
|
362
362
|
...linkButton(options.origin(), content.link),
|
|
363
|
-
// [Look first] beside [Merge] or [
|
|
363
|
+
// [Look first] beside [Merge] or [Accept and finish]: each button's own action id.
|
|
364
364
|
...(content.also ?? []).flatMap((one, index) => linkButton(options.origin(), one).map(button => ({ ...button, action_id: `toolroll_link_${index + 2}` }))),
|
|
365
365
|
];
|
|
366
366
|
const target = content.edit ?? row.message;
|
package/dist/task-status.d.ts
CHANGED
|
@@ -20,6 +20,11 @@ export declare const HEADLINES: readonly ["Queued", "Planning", "Needs you", "Wa
|
|
|
20
20
|
export type Headline = (typeof HEADLINES)[number];
|
|
21
21
|
export type HeadlineTone = "neutral" | "live" | "attention" | "ready" | "success" | "danger";
|
|
22
22
|
export declare const HEADLINE_TONE: Readonly<Record<Headline, HeadlineTone>>;
|
|
23
|
+
/** The project's own checks and the pull request's CI are two different things, never one word (Checks beside CI). */
|
|
24
|
+
export declare const CHECKS_LABEL = "Project checks";
|
|
25
|
+
export declare const PR_CI_LABEL = "PR CI";
|
|
26
|
+
/** The one verb for opening a saved result, on every surface. */
|
|
27
|
+
export declare const OPEN_RESULT = "Open result";
|
|
23
28
|
export type DetailKey = "checks" | "pull-request" | "requirements" | "evidence";
|
|
24
29
|
/** The row's small icon: the only coloured part of a detail. `failed` is red
|
|
25
30
|
* and only ever appears under the Failed headline; `note` is amber. */
|
|
@@ -86,10 +91,14 @@ export type TaskStatusFacts = {
|
|
|
86
91
|
report?: boolean;
|
|
87
92
|
checks?: ChecksFact | null;
|
|
88
93
|
pullRequest?: PullRequestFact | null;
|
|
94
|
+
/** `unverified`: the report is refuted, so no requirement counts as met however it was marked. `missed`: the ones it
|
|
95
|
+
* failed or left unanswered. */
|
|
89
96
|
requirements?: {
|
|
90
97
|
met: number;
|
|
91
98
|
total: number;
|
|
92
99
|
yours: number;
|
|
100
|
+
missed?: number;
|
|
101
|
+
unverified?: boolean;
|
|
93
102
|
} | null;
|
|
94
103
|
evidence?: {
|
|
95
104
|
shortened: number;
|
|
@@ -151,14 +160,21 @@ export declare function assignmentStageOf(assignment: Pick<AssignmentSnapshot, "
|
|
|
151
160
|
need?: TaskStatusFacts["need"];
|
|
152
161
|
wait?: WaitKey;
|
|
153
162
|
};
|
|
154
|
-
|
|
155
|
-
export declare function requirementsOf(matrix: readonly {
|
|
163
|
+
type RequirementRow = {
|
|
156
164
|
state: string;
|
|
157
165
|
assessment?: {
|
|
158
166
|
evidenceState?: string;
|
|
159
167
|
} | undefined;
|
|
160
168
|
review?: unknown;
|
|
161
|
-
}
|
|
169
|
+
};
|
|
170
|
+
/** One requirement's state in words: the card's Requirements row and the Checks tab read this one source, so they
|
|
171
|
+
* can't disagree. A refuted report verifies none of its requirements, so each reads Unverified, as the card does. */
|
|
172
|
+
export type RequirementWord = "Met" | "You check" | "Not shown yet" | "Not met" | "Unverified";
|
|
173
|
+
export declare function requirementWordOf(row: RequirementRow, verdict?: string | null): RequirementWord;
|
|
174
|
+
/** Requirements from the stored matrix: met, the ones only a person confirms, and the total. */
|
|
175
|
+
export declare function requirementsOf(matrix: readonly RequirementRow[] | null | undefined): NonNullable<TaskStatusFacts["requirements"]> | null;
|
|
176
|
+
/** A refuted report verifies none of its requirements, whatever it marked met (a person's acceptance doesn't change that). */
|
|
177
|
+
export declare function unverifiedWhenRefuted(requirements: NonNullable<TaskStatusFacts["requirements"]> | null, verdict: string | null | undefined): NonNullable<TaskStatusFacts["requirements"]> | null;
|
|
162
178
|
/** Saved evidence health from the receipt's artifact list and its caveats. */
|
|
163
179
|
export declare function evidenceOf(receipt: AssignmentSnapshot["receipt"], problems?: readonly string[]): NonNullable<TaskStatusFacts["evidence"]> | null;
|
|
164
180
|
/** The status facts of one assignment, as every surface reads them. */
|
|
@@ -184,6 +200,8 @@ export declare function headlineEmoji(headline: Headline): string;
|
|
|
184
200
|
export declare const statusIconSvg: (mark: DetailMark) => string;
|
|
185
201
|
/** The detail rows as server HTML: quiet rows, colour only on the icon. */
|
|
186
202
|
export declare function statusDetailsHtml(status: TaskStatus): string;
|
|
203
|
+
/** The status card's fold of exact reasons: "More", never a third thing called Details. */
|
|
204
|
+
export declare const STATUS_MORE = "More";
|
|
187
205
|
/** The technical reasons, one tap away. */
|
|
188
206
|
export declare function statusWhyHtml(status: TaskStatus, extra?: readonly string[], diagnostics?: readonly {
|
|
189
207
|
token: string;
|
|
@@ -207,3 +225,6 @@ export declare function stageOfDispatch(d: {
|
|
|
207
225
|
stage: TaskStage;
|
|
208
226
|
need?: TaskStatusFacts["need"];
|
|
209
227
|
};
|
|
228
|
+
/** The demo runs no checks: a Checks row that would offer to run one (or open one that never ran) says so instead. */
|
|
229
|
+
export declare const DEMO_CHECKS = "Can't run in the demo";
|
|
230
|
+
export declare function demoChecksOf(status: TaskStatus): TaskStatus;
|
package/dist/task-status.js
CHANGED
|
@@ -8,6 +8,11 @@ export const HEADLINE_TONE = {
|
|
|
8
8
|
Queued: "neutral", Planning: "live", "Needs you": "attention", Waiting: "neutral", Building: "live",
|
|
9
9
|
"Ready for review": "ready", Complete: "success", Failed: "danger", Stopped: "neutral",
|
|
10
10
|
};
|
|
11
|
+
/** The project's own checks and the pull request's CI are two different things, never one word (Checks beside CI). */
|
|
12
|
+
export const CHECKS_LABEL = "Project checks";
|
|
13
|
+
export const PR_CI_LABEL = "PR CI";
|
|
14
|
+
/** The one verb for opening a saved result, on every surface. */
|
|
15
|
+
export const OPEN_RESULT = "Open result";
|
|
11
16
|
const short = (sha) => sha !== null && /^[a-f0-9]{7,40}$/.test(sha) ? sha.slice(0, 7) : null;
|
|
12
17
|
export function headlineOf(facts) {
|
|
13
18
|
switch (facts.stage) {
|
|
@@ -76,19 +81,20 @@ function detailsOf(headline, facts) {
|
|
|
76
81
|
const runChecks = facts.links?.runChecks === undefined ? null : { label: "Run checks", href: facts.links.runChecks };
|
|
77
82
|
const quick = checks.level === "quick";
|
|
78
83
|
if (checks.running != null)
|
|
79
|
-
row("checks",
|
|
84
|
+
row("checks", CHECKS_LABEL, `${checks.running === "quick" ? "Quick" : "Full"} checks running`, "running", { href: checksHref });
|
|
80
85
|
else if (checks.status === "passed")
|
|
81
|
-
row("checks",
|
|
86
|
+
row("checks", CHECKS_LABEL, `${quick ? "Quick checks passed" : "Passed"}${sha === null ? "" : ` on ${sha}`}`, "ok", quick && runChecks !== null && headline !== "Complete" ? { href: checksHref, action: { label: "Run full checks", href: runChecks.href } } : { href: checksHref });
|
|
82
87
|
else if (checks.status === "failed")
|
|
83
|
-
row("checks",
|
|
88
|
+
row("checks", CHECKS_LABEL, `${quick ? "Quick checks failed" : "Failed"}${checks.exitCode === null ? "" : ` (exit ${checks.exitCode})`}`, problem(headline), headline === "Failed" ? { href: checksHref } : { action: { label: "See what failed", href: checksHref } });
|
|
84
89
|
else if (checks.status === "running")
|
|
85
|
-
row("checks",
|
|
90
|
+
row("checks", CHECKS_LABEL, "Running", "running");
|
|
86
91
|
else if (checks.level === "off")
|
|
87
|
-
row("checks",
|
|
92
|
+
row("checks", CHECKS_LABEL, "Off for this project", "none", runChecks === null ? {} : { action: runChecks });
|
|
88
93
|
else if (checks.status === "not-run")
|
|
89
|
-
row("checks",
|
|
94
|
+
row("checks", CHECKS_LABEL, "Didn't run", "none", runChecks === null ? {} : { action: runChecks });
|
|
95
|
+
// A saved check that can't be read is run again where that is possible.
|
|
90
96
|
else
|
|
91
|
-
row("checks",
|
|
97
|
+
row("checks", CHECKS_LABEL, "Couldn't be read", "note", { action: runChecks ?? { label: OPEN_RESULT, href: facts.links?.result ?? null } });
|
|
92
98
|
}
|
|
93
99
|
const pr = facts.pullRequest;
|
|
94
100
|
if (pr != null) {
|
|
@@ -105,16 +111,21 @@ function detailsOf(headline, facts) {
|
|
|
105
111
|
else if (pr.state === "failed")
|
|
106
112
|
row("pull-request", "Pull request", "Couldn't open", "note", { action: pr.compareUrl ? { label: "Open it on GitHub", href: pr.compareUrl } : { label: "See why", href: facts.links?.pullRequest ?? null }, why: pr.error === null ? "Publishing gave up. The commit is safe locally." : `${pr.error} The commit is safe locally.` });
|
|
107
113
|
else if (pr.ci === "running")
|
|
108
|
-
row("pull-request", "Pull request", `${name} open
|
|
114
|
+
row("pull-request", "Pull request", `${name} open · ${PR_CI_LABEL} running`, "running", { href: github });
|
|
109
115
|
else if (pr.ci === "passing")
|
|
110
|
-
row("pull-request", "Pull request", `${name} open
|
|
116
|
+
row("pull-request", "Pull request", `${name} open · ${PR_CI_LABEL} passed`, "ok", { href: github });
|
|
111
117
|
else if (pr.ci === "failing")
|
|
112
|
-
row("pull-request", "Pull request", `${name} open
|
|
118
|
+
row("pull-request", "Pull request", `${name} open · ${PR_CI_LABEL} failing`, "note", { action: { label: "Open it on GitHub", href: github } });
|
|
113
119
|
else
|
|
114
120
|
row("pull-request", "Pull request", `${name} open`, "none", { href: github });
|
|
115
121
|
}
|
|
116
122
|
const req = facts.requirements;
|
|
117
|
-
|
|
123
|
+
// A failed result names how many it missed, refuted or not: that is what failed.
|
|
124
|
+
if (req != null && req.total > 0 && headline === "Failed" && (req.missed ?? 0) > 0)
|
|
125
|
+
row("requirements", "Requirements", `${req.missed} missed`, "failed");
|
|
126
|
+
else if (req != null && req.total > 0 && req.unverified === true)
|
|
127
|
+
row("requirements", "Requirements", "Unverified", "none");
|
|
128
|
+
else if (req != null && req.total > 0) {
|
|
118
129
|
const unmet = req.total - req.met - req.yours;
|
|
119
130
|
const text = `${req.met} of ${req.total} met${req.yours > 0 ? ` · You check ${req.yours}` : ""}`;
|
|
120
131
|
row("requirements", "Requirements", text, unmet > 0 ? problem(headline) : req.met === req.total ? "ok" : "none", unmet > 0 && headline !== "Failed" ? { action: { label: "See which", href: facts.links?.checks ?? facts.links?.result ?? null } } : {});
|
|
@@ -256,13 +267,25 @@ export function assignmentStageOf(assignment, work, planning = false) {
|
|
|
256
267
|
}
|
|
257
268
|
}
|
|
258
269
|
}
|
|
270
|
+
/** Evidence that passed and only awaits the retired assessment step is met. */
|
|
271
|
+
const requirementMet = (row) => row.state === "pass" || (row.assessment?.evidenceState === "pass" && (row.review ?? null) === null);
|
|
272
|
+
export function requirementWordOf(row, verdict) {
|
|
273
|
+
if (verdict === "refuted")
|
|
274
|
+
return "Unverified";
|
|
275
|
+
if (requirementMet(row))
|
|
276
|
+
return "Met";
|
|
277
|
+
return row.state === "manual-review" ? "You check" : row.state === "missing" ? "Not shown yet" : "Not met";
|
|
278
|
+
}
|
|
259
279
|
/** Requirements from the stored matrix: met, the ones only a person confirms, and the total. */
|
|
260
280
|
export function requirementsOf(matrix) {
|
|
261
281
|
if (!matrix || matrix.length === 0)
|
|
262
282
|
return null;
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
283
|
+
return { met: matrix.filter(requirementMet).length, total: matrix.length, yours: matrix.filter(row => requirementWordOf(row) === "You check").length,
|
|
284
|
+
missed: matrix.filter(row => !requirementMet(row) && (row.state === "failed" || row.state === "missing")).length };
|
|
285
|
+
}
|
|
286
|
+
/** A refuted report verifies none of its requirements, whatever it marked met (a person's acceptance doesn't change that). */
|
|
287
|
+
export function unverifiedWhenRefuted(requirements, verdict) {
|
|
288
|
+
return requirements === null || verdict !== "refuted" ? requirements : { ...requirements, unverified: true };
|
|
266
289
|
}
|
|
267
290
|
/** Saved evidence health from the receipt's artifact list and its caveats. */
|
|
268
291
|
export function evidenceOf(receipt, problems = receipt?.caveats ?? []) {
|
|
@@ -297,7 +320,7 @@ export function assignmentStatusFacts(assignment, options = {}) {
|
|
|
297
320
|
stage, ...(need === undefined ? {} : { need }), ...(wait === undefined ? {} : { wait }), ...(build === null ? {} : { needContext: { build } }), reason, report,
|
|
298
321
|
checks,
|
|
299
322
|
pullRequest: withResult ? pullRequest : null,
|
|
300
|
-
requirements: withResult ? requirementsOf(receipt?.proof?.matrix) : null,
|
|
323
|
+
requirements: withResult ? unverifiedWhenRefuted(requirementsOf(receipt?.proof?.matrix), receipt?.proof?.verdict) : null,
|
|
301
324
|
evidence: withResult ? options.evidence ?? evidenceOf(receipt, [...(receipt?.caveats ?? []), ...assignment.attention]) : null,
|
|
302
325
|
completedBy: assignment.completion === null ? null : assignment.completion.lead === true ? "the lead" : assignment.completion.actor.replace(/^(?:operator|coordinator|lead):/, ""),
|
|
303
326
|
action: options.action ?? null,
|
|
@@ -341,18 +364,20 @@ export const statusIconSvg = (mark) => `<svg class="status-icon status-icon--${m
|
|
|
341
364
|
export function statusDetailsHtml(status) {
|
|
342
365
|
if (status.details.length === 0)
|
|
343
366
|
return "";
|
|
344
|
-
return `<ul class="status-details" aria-label="
|
|
367
|
+
return `<ul class="status-details" aria-label="Status">` + status.details.map(one => {
|
|
345
368
|
const text = one.href === null ? escapeHtml(one.text) : `<a href="${escapeHtml(one.href)}">${escapeHtml(one.text)}</a>`;
|
|
346
369
|
const action = one.action === null ? "" : one.action.href === null ? `<span class="status-detail-act">${escapeHtml(one.action.label)}</span>` : `<a class="status-detail-act" href="${escapeHtml(one.action.href)}">${escapeHtml(one.action.label)}</a>`;
|
|
347
370
|
return `<li class="status-detail status-detail--${one.mark}" data-status-detail="${one.key}" data-mark="${one.mark}">${statusIconSvg(one.mark)}<span class="status-detail-label">${escapeHtml(one.label)}</span><span class="status-detail-text"><span>${text}</span>${action}</span></li>`;
|
|
348
371
|
}).join("") + `</ul>`;
|
|
349
372
|
}
|
|
373
|
+
/** The status card's fold of exact reasons: "More", never a third thing called Details. */
|
|
374
|
+
export const STATUS_MORE = "More";
|
|
350
375
|
/** The technical reasons, one tap away. */
|
|
351
376
|
export function statusWhyHtml(status, extra = [], diagnostics = []) {
|
|
352
377
|
const lines = [...new Set([...status.details.flatMap(one => one.why === null ? [] : [`${one.label}: ${one.why}`]), ...status.why, ...extra])];
|
|
353
378
|
if (lines.length === 0 && diagnostics.length === 0)
|
|
354
379
|
return "";
|
|
355
|
-
return `<details class="status-why"><summary
|
|
380
|
+
return `<details class="status-why"><summary>${STATUS_MORE}</summary>${lines.map(one => `<p class="meta">${escapeHtml(one)}</p>`).join("")}` +
|
|
356
381
|
diagnostics.map(one => `<p class="meta" data-work-diagnostic="${escapeHtml(one.token)}">${escapeHtml(one.label)} · ${escapeHtml(one.detail)}</p>`).join("") + `</details>`;
|
|
357
382
|
}
|
|
358
383
|
/** Shared CSS for the server-rendered status: neutral rows, coloured icons. */
|
|
@@ -371,3 +396,9 @@ export function stageOfDispatch(d, options = {}) {
|
|
|
371
396
|
return { stage: "needs-you", need: "review-result" };
|
|
372
397
|
return stageOfCode(d.code, { needsPerson: d.condition === "waiting" && d.action !== null, planning: d.condition === "running" && d.role === "planner", operatorHold: d.action === "unhold" });
|
|
373
398
|
}
|
|
399
|
+
/** The demo runs no checks: a Checks row that would offer to run one (or open one that never ran) says so instead. */
|
|
400
|
+
export const DEMO_CHECKS = "Can't run in the demo";
|
|
401
|
+
export function demoChecksOf(status) {
|
|
402
|
+
return { ...status, details: status.details.map(one => one.key !== "checks" || one.mark === "ok" || one.mark === "failed" || one.mark === "running" ? one
|
|
403
|
+
: { ...one, text: DEMO_CHECKS, mark: "none", href: null, action: null, why: null }) };
|
|
404
|
+
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { assignmentOf } from "./assignment.js";
|
|
8
8
|
import { chatResultHref, chatControlHref } from "./chat-controls.js";
|
|
9
9
|
import { phoneText, projectLabel } from "./telegram-status.js";
|
|
10
|
-
import { assignmentStatusFacts, headlineEmoji, pullRequestFactOf, requirementsOf, statusDetailLines, taskStatusOf } from "./task-status.js";
|
|
10
|
+
import { assignmentStatusFacts, headlineEmoji, pullRequestFactOf, requirementsOf, statusDetailLines, unverifiedWhenRefuted, taskStatusOf } from "./task-status.js";
|
|
11
11
|
import { leadClaimOf } from "./lead-voice.js";
|
|
12
12
|
import { chatTitle } from "./chat-voice.js";
|
|
13
13
|
import { manualReviewOnly } from "./proof.js";
|
|
@@ -49,7 +49,7 @@ export function telegramProgressCard(store, run, taskId, project, now = new Date
|
|
|
49
49
|
: exit !== null ? { status: "failed", exitCode: exit, head: run.headRevision }
|
|
50
50
|
: machine === "verified" ? { status: "passed", exitCode: null, head: run.headRevision }
|
|
51
51
|
: machine === "attested" ? { status: "not-run", exitCode: null, head: null } : null;
|
|
52
|
-
facts = { stage: "finished", report: run.role === "scout", checks, requirements: requirementsOf(proof?.matrix),
|
|
52
|
+
facts = { stage: "finished", report: run.role === "scout", checks, requirements: unverifiedWhenRefuted(requirementsOf(proof?.matrix), proof?.verdict),
|
|
53
53
|
...(pullRequest === undefined ? {} : { pullRequest }) };
|
|
54
54
|
}
|
|
55
55
|
else if (accessBlocked) {
|
package/dist/test-temp.js
CHANGED
|
@@ -18,7 +18,7 @@ export const TEST_TEMP_PREFIXES = [
|
|
|
18
18
|
"flow-goal-", "flow-scratch", "flows-chat-", "invoke-attest-", "knowledge-test-", "learning-", "peek-", "phone-origin-",
|
|
19
19
|
"proposal-repo-", "race-", "refresh-migration-", "refresh-refusal-", "release-check-", "shared-chat-", "skills-test-",
|
|
20
20
|
"task-status-surfaces-", "template-repo-", "template-test-", "v14-migrate-", "v17-migrate-", "watch-anchor-",
|
|
21
|
-
"needs-you-", "deps-lock-", "deps-state-", "upgrade-fresh-", "upgrade-path-", "deploy-restore-", "result-review-", "one-result-page-",
|
|
21
|
+
"needs-you-", "deps-lock-", "deps-state-", "upgrade-fresh-", "upgrade-path-", "deploy-restore-", "result-review-", "one-result-page-", "deploy-before-swap-", "one-vocabulary-", "accept-finishes-",
|
|
22
22
|
// Playwright's browser profiles and artifacts.
|
|
23
23
|
"playwright_chromiumdev_profile-", "playwright_firefoxdev_profile-", "playwright-artifacts-",
|
|
24
24
|
];
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const TRANSITIONS_CSS = "\n/* Transitions.dev \u2014 05-menu-dropdown.md */\n:root {\n --dropdown-open-dur: 250ms;\n --dropdown-close-dur: 150ms;\n --dropdown-pre-scale: 0.97;\n --dropdown-closing-scale: 0.99;\n --dropdown-ease: cubic-bezier(0.22, 1, 0.36, 1);\n}\n\n.t-dropdown {\n transform-origin: top left;\n transform: scale(var(--dropdown-pre-scale));\n opacity: 0;\n pointer-events: none;\n transition:\n transform var(--dropdown-open-dur) var(--dropdown-ease),\n opacity var(--dropdown-open-dur) var(--dropdown-ease);\n will-change: transform, opacity;\n}\n.t-dropdown[data-origin=\"top-right\"] { transform-origin: top right; }\n.t-dropdown[data-origin=\"top-center\"] { transform-origin: top center; }\n.t-dropdown[data-origin=\"bottom-left\"] { transform-origin: bottom left; }\n.t-dropdown[data-origin=\"bottom-center\"] { transform-origin: bottom center; }\n.t-dropdown[data-origin=\"bottom-right\"] { transform-origin: bottom right; }\n\n.t-dropdown.is-open {\n transform: scale(1);\n opacity: 1;\n pointer-events: auto;\n}\n.t-dropdown.is-closing {\n transform: scale(var(--dropdown-closing-scale));\n opacity: 0;\n pointer-events: none;\n transition:\n transform var(--dropdown-close-dur) var(--dropdown-ease),\n opacity var(--dropdown-close-dur) var(--dropdown-ease);\n}\n\n@media (prefers-reduced-motion: reduce) {\n .t-dropdown { transition: none !important; }\n}\n\n/* Transitions.dev \u2014 07-panel-reveal.md */\n:root {\n --panel-open-dur: 400ms;\n --panel-close-dur: 350ms;\n --panel-translate-y: 100px;\n --panel-blur: 2px;\n --panel-ease: cubic-bezier(0.22, 1, 0.36, 1);\n}\n\n.t-panel-slide {\n transform: translateY(var(--panel-translate-y));\n opacity: 0;\n filter: blur(var(--panel-blur));\n pointer-events: none;\n transition:\n transform var(--panel-close-dur) var(--panel-ease),\n opacity var(--panel-close-dur) var(--panel-ease),\n filter var(--panel-close-dur) var(--panel-ease);\n will-change: transform, opacity, filter;\n}\n.t-panel-slide[data-open=\"true\"] {\n transform: translateY(0);\n opacity: 1;\n filter: blur(0);\n pointer-events: auto;\n transition:\n transform var(--panel-open-dur) var(--panel-ease),\n opacity var(--panel-open-dur) var(--panel-ease),\n filter var(--panel-open-dur) var(--panel-ease);\n}\n\n@media (prefers-reduced-motion: reduce) {\n .t-panel-slide { transition: none !important; }\n}\n\n/* Transitions.dev \u2014 16-tabs-sliding.md */\n:root {\n --tabs-dur: 250ms;\n --tabs-ease: cubic-bezier(0.22, 1, 0.36, 1);\n --tabs-text-muted: rgba(15, 15, 15, 0.8);\n --tabs-text-active: #0f0f0f;\n --tabs-bar-bg: #f1f1f1;\n --tabs-pill-bg: #ffffff;\n}\n\n/* The bar is just a flex container with padding for the pill\n to sit inside. Tabs sit on z-index: 1, the pill on z-index: 0,\n so labels read above the pill background. */\n.t-tabs {\n position: relative;\n display: inline-flex;\n align-items: center;\n gap: 3px;\n padding: 3px;\n border-radius: 48px;\n background: var(--tabs-bar-bg);\n}\n.t-tab {\n position: relative;\n appearance: none;\n border: 0;\n background: transparent;\n height: 30px;\n padding: 4px 12px;\n color: var(--tabs-text-muted);\n cursor: pointer;\n border-radius: 48px;\n z-index: 1;\n transition: color var(--tabs-dur) var(--tabs-ease);\n}\n.t-tab:not([aria-selected=\"true\"]):hover,\n.t-tab[aria-selected=\"true\"] {\n color: var(--tabs-text-active);\n}\n\n/* The pill: width + transform are written inline by JS so\n the transition tweens between the previous and next\n measured positions. */\n.t-tabs-pill {\n position: absolute;\n top: 3px;\n left: 0;\n height: 30px;\n width: 0;\n background: var(--tabs-pill-bg);\n border-radius: 48px;\n transform: translateX(0);\n transition:\n transform var(--tabs-dur) var(--tabs-ease),\n width var(--tabs-dur) var(--tabs-ease);\n will-change: transform, width;\n z-index: 0;\n pointer-events: none;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .t-tabs-pill, .t-tab { transition: none !important; }\n}\n\n/* Transitions.dev \u2014 21-accordion.md */\n:root {\n --acc-expand: 250ms;\n --acc-collapse: 250ms;\n --acc-chevron: 250ms;\n --acc-ease: cubic-bezier(0.22, 1, 0.36, 1);\n}\n\n/* grid-template-rows 0fr \u2192 1fr gives a clean height animation\n with no JS measurement; the inner element clips overflow. */\n.t-acc-panel {\n display: grid;\n grid-template-rows: 0fr;\n transition: grid-template-rows var(--acc-collapse) var(--acc-ease);\n}\n.t-acc[data-open=\"true\"] .t-acc-panel {\n grid-template-rows: 1fr;\n transition: grid-template-rows var(--acc-expand) var(--acc-ease);\n}\n.t-acc-panel-inner {\n overflow: hidden;\n opacity: 0;\n filter: blur(2px);\n transition:\n opacity var(--acc-collapse) var(--acc-ease),\n filter var(--acc-collapse) var(--acc-ease);\n}\n.t-acc[data-open=\"true\"] .t-acc-panel-inner {\n opacity: 1;\n filter: blur(0);\n transition:\n opacity var(--acc-expand) var(--acc-ease),\n filter var(--acc-expand) var(--acc-ease);\n}\n/*
|
|
1
|
+
export declare const TRANSITIONS_CSS = "\n/* Transitions.dev \u2014 05-menu-dropdown.md */\n:root {\n --dropdown-open-dur: 250ms;\n --dropdown-close-dur: 150ms;\n --dropdown-pre-scale: 0.97;\n --dropdown-closing-scale: 0.99;\n --dropdown-ease: cubic-bezier(0.22, 1, 0.36, 1);\n}\n\n.t-dropdown {\n transform-origin: top left;\n transform: scale(var(--dropdown-pre-scale));\n opacity: 0;\n pointer-events: none;\n transition:\n transform var(--dropdown-open-dur) var(--dropdown-ease),\n opacity var(--dropdown-open-dur) var(--dropdown-ease);\n will-change: transform, opacity;\n}\n.t-dropdown[data-origin=\"top-right\"] { transform-origin: top right; }\n.t-dropdown[data-origin=\"top-center\"] { transform-origin: top center; }\n.t-dropdown[data-origin=\"bottom-left\"] { transform-origin: bottom left; }\n.t-dropdown[data-origin=\"bottom-center\"] { transform-origin: bottom center; }\n.t-dropdown[data-origin=\"bottom-right\"] { transform-origin: bottom right; }\n\n.t-dropdown.is-open {\n transform: scale(1);\n opacity: 1;\n pointer-events: auto;\n}\n.t-dropdown.is-closing {\n transform: scale(var(--dropdown-closing-scale));\n opacity: 0;\n pointer-events: none;\n transition:\n transform var(--dropdown-close-dur) var(--dropdown-ease),\n opacity var(--dropdown-close-dur) var(--dropdown-ease);\n}\n\n@media (prefers-reduced-motion: reduce) {\n .t-dropdown { transition: none !important; }\n}\n\n/* Transitions.dev \u2014 07-panel-reveal.md */\n:root {\n --panel-open-dur: 400ms;\n --panel-close-dur: 350ms;\n --panel-translate-y: 100px;\n --panel-blur: 2px;\n --panel-ease: cubic-bezier(0.22, 1, 0.36, 1);\n}\n\n.t-panel-slide {\n transform: translateY(var(--panel-translate-y));\n opacity: 0;\n filter: blur(var(--panel-blur));\n pointer-events: none;\n transition:\n transform var(--panel-close-dur) var(--panel-ease),\n opacity var(--panel-close-dur) var(--panel-ease),\n filter var(--panel-close-dur) var(--panel-ease);\n will-change: transform, opacity, filter;\n}\n.t-panel-slide[data-open=\"true\"] {\n transform: translateY(0);\n opacity: 1;\n filter: blur(0);\n pointer-events: auto;\n transition:\n transform var(--panel-open-dur) var(--panel-ease),\n opacity var(--panel-open-dur) var(--panel-ease),\n filter var(--panel-open-dur) var(--panel-ease);\n}\n\n@media (prefers-reduced-motion: reduce) {\n .t-panel-slide { transition: none !important; }\n}\n\n/* Transitions.dev \u2014 16-tabs-sliding.md */\n:root {\n --tabs-dur: 250ms;\n --tabs-ease: cubic-bezier(0.22, 1, 0.36, 1);\n --tabs-text-muted: rgba(15, 15, 15, 0.8);\n --tabs-text-active: #0f0f0f;\n --tabs-bar-bg: #f1f1f1;\n --tabs-pill-bg: #ffffff;\n}\n\n/* The bar is just a flex container with padding for the pill\n to sit inside. Tabs sit on z-index: 1, the pill on z-index: 0,\n so labels read above the pill background. */\n.t-tabs {\n position: relative;\n display: inline-flex;\n align-items: center;\n gap: 3px;\n padding: 3px;\n border-radius: 48px;\n background: var(--tabs-bar-bg);\n}\n.t-tab {\n position: relative;\n appearance: none;\n border: 0;\n background: transparent;\n height: 30px;\n padding: 4px 12px;\n color: var(--tabs-text-muted);\n cursor: pointer;\n border-radius: 48px;\n z-index: 1;\n transition: color var(--tabs-dur) var(--tabs-ease);\n}\n.t-tab:not([aria-selected=\"true\"]):hover,\n.t-tab[aria-selected=\"true\"] {\n color: var(--tabs-text-active);\n}\n\n/* The pill: width + transform are written inline by JS so\n the transition tweens between the previous and next\n measured positions. */\n.t-tabs-pill {\n position: absolute;\n top: 3px;\n left: 0;\n height: 30px;\n width: 0;\n background: var(--tabs-pill-bg);\n border-radius: 48px;\n transform: translateX(0);\n transition:\n transform var(--tabs-dur) var(--tabs-ease),\n width var(--tabs-dur) var(--tabs-ease);\n will-change: transform, width;\n z-index: 0;\n pointer-events: none;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .t-tabs-pill, .t-tab { transition: none !important; }\n}\n\n/* Transitions.dev \u2014 21-accordion.md */\n:root {\n --acc-expand: 250ms;\n --acc-collapse: 250ms;\n --acc-chevron: 250ms;\n --acc-ease: cubic-bezier(0.22, 1, 0.36, 1);\n}\n\n/* grid-template-rows 0fr \u2192 1fr gives a clean height animation\n with no JS measurement; the inner element clips overflow. */\n.t-acc-panel {\n display: grid;\n grid-template-rows: 0fr;\n transition: grid-template-rows var(--acc-collapse) var(--acc-ease);\n}\n.t-acc[data-open=\"true\"] .t-acc-panel {\n grid-template-rows: 1fr;\n transition: grid-template-rows var(--acc-expand) var(--acc-ease);\n}\n.t-acc-panel-inner {\n overflow: hidden;\n opacity: 0;\n filter: blur(2px);\n transition:\n opacity var(--acc-collapse) var(--acc-ease),\n filter var(--acc-collapse) var(--acc-ease);\n}\n.t-acc[data-open=\"true\"] .t-acc-panel-inner {\n opacity: 1;\n filter: blur(0);\n transition:\n opacity var(--acc-expand) var(--acc-ease),\n filter var(--acc-expand) var(--acc-ease);\n}\n/* The one disclosure glyph (disclosure.ts): a right chevron that\n turns down when the fold opens; non-scaling-stroke keeps the\n stroke width constant through the turn. */\n.t-acc-chevron {\n display: inline-flex;\n transform: rotate(0deg);\n transform-origin: center;\n transition: transform var(--acc-chevron) var(--acc-ease);\n}\n.t-acc-chevron path { vector-effect: non-scaling-stroke; }\n.t-acc[data-open=\"true\"] .t-acc-chevron {\n transform: rotate(90deg);\n}\n\n@media (prefers-reduced-motion: reduce) {\n .t-acc-panel, .t-acc-panel-inner, .t-acc-chevron {\n transition: none !important;\n }\n}\n";
|
|
@@ -177,22 +177,18 @@ export const TRANSITIONS_CSS = `
|
|
|
177
177
|
opacity var(--acc-expand) var(--acc-ease),
|
|
178
178
|
filter var(--acc-expand) var(--acc-ease);
|
|
179
179
|
}
|
|
180
|
-
/*
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
in every browser, unlike CSS \`d:\` morphing (Chromium only).
|
|
184
|
-
The chevron path is symmetric about the 16x16 viewBox
|
|
185
|
-
centre, so the flip lands exactly on the "^"; non-scaling
|
|
186
|
-
-stroke keeps the stroke width constant through the flip. */
|
|
180
|
+
/* The one disclosure glyph (disclosure.ts): a right chevron that
|
|
181
|
+
turns down when the fold opens; non-scaling-stroke keeps the
|
|
182
|
+
stroke width constant through the turn. */
|
|
187
183
|
.t-acc-chevron {
|
|
188
184
|
display: inline-flex;
|
|
189
|
-
transform:
|
|
185
|
+
transform: rotate(0deg);
|
|
190
186
|
transform-origin: center;
|
|
191
187
|
transition: transform var(--acc-chevron) var(--acc-ease);
|
|
192
188
|
}
|
|
193
189
|
.t-acc-chevron path { vector-effect: non-scaling-stroke; }
|
|
194
190
|
.t-acc[data-open="true"] .t-acc-chevron {
|
|
195
|
-
transform:
|
|
191
|
+
transform: rotate(90deg);
|
|
196
192
|
}
|
|
197
193
|
|
|
198
194
|
@media (prefers-reduced-motion: reduce) {
|
package/dist/when-html.d.ts
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
|
-
/**
|
|
2
|
-
export declare function
|
|
1
|
+
/** The viewer's own time zone (a browser's); UTC where there is none to read. */
|
|
2
|
+
export declare function viewerZone(): string;
|
|
3
|
+
/** The one formatter for a time a person reads, on every surface: "16:39" today, "Yesterday 16:39" /
|
|
4
|
+
* "Tomorrow 16:39" beside it, "Sep 28" otherwise ("Sep 28 2025" in another year), counted in `zone`. With `clock`,
|
|
5
|
+
* a date further out keeps its time of day too ("Sep 28 16:39"), for deadlines and histories where the hour matters. */
|
|
6
|
+
export declare function shortWhen(iso: string, now?: Date, zone?: string, clock?: boolean): string;
|
|
7
|
+
/** A flow card's deadline in the viewer's zone, always with its time of day: "No reply by 16:30" today,
|
|
8
|
+
* "No reply by Tomorrow 16:30", "Moves on Oct 2 16:30" further out (a label's trailing " at" reads wrong before a date). */
|
|
9
|
+
export declare function deadlineWords(deadline: {
|
|
10
|
+
at: string;
|
|
11
|
+
label: string;
|
|
12
|
+
}, now?: Date, zone?: string): string;
|
|
13
|
+
/** The exact minute in `zone` ("2026-09-30 16:39", with " UTC" when it is UTC): a time's title, one hover away. */
|
|
14
|
+
export declare function fullWhen(iso: string, zone?: string): string;
|
|
15
|
+
/** Every server-rendered stamp (`whenHtml`'s `<time data-when>`) inside `root`, reworded by the one formatter in the
|
|
16
|
+
* viewer's zone: the same words on a desk and a phone, the exact minute in the title. The server's UTC words stay
|
|
17
|
+
* only without script. Times React writes, relative ages ("3 min ago") and exact stamps (the ledger's seconds) carry
|
|
18
|
+
* no `data-when` and are never touched. */
|
|
19
|
+
export declare function localizeTimes(root: ParentNode, now?: Date, zone?: string): void;
|
|
3
20
|
/** The `<time>` for a stamp: `full` is the words a desk shows ("2026-09-30 16:39 UTC"). Empty for no stamp. */
|
|
4
21
|
export declare function whenHtml(iso: string | null, full: string, now?: Date): string;
|
|
5
22
|
/** "2026-09-30 16:39 UTC" — the desk's words for a stamp. */
|
package/dist/when-html.js
CHANGED
|
@@ -1,34 +1,101 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* A time a person reads. A desk shows the full stamp, as before; a phone (760px and narrower) shows the short
|
|
3
3
|
* one — "16:39" today, "Yesterday 16:39", "Sep 28" otherwise — and the full stamp stays in the title. Stamps are
|
|
4
|
-
* UTC to the minute, so the
|
|
4
|
+
* UTC to the minute, so the server counts in UTC; in the browser `localizeTimes` rewords each of these with the same
|
|
5
|
+
* formatter in the viewer's own zone, as the React views do. The CSS that picks one lives in the shared page
|
|
5
6
|
* stylesheet (`.so-when-*` in serve.ts).
|
|
6
7
|
*/
|
|
7
8
|
const escape = (value) => value.replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
8
9
|
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
9
10
|
const DAY_MS = 86_400_000;
|
|
10
|
-
/**
|
|
11
|
-
|
|
11
|
+
/** One `Intl.DateTimeFormat` per zone: building one is the slow part, and every time on a page shares a few zones. */
|
|
12
|
+
const formats = new Map();
|
|
13
|
+
function formatIn(zone) {
|
|
14
|
+
let format = formats.get(zone);
|
|
15
|
+
if (format === undefined) {
|
|
16
|
+
format = new Intl.DateTimeFormat("en-US", { timeZone: zone, year: "numeric", month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit", hourCycle: "h23" });
|
|
17
|
+
formats.set(zone, format);
|
|
18
|
+
}
|
|
19
|
+
return format;
|
|
20
|
+
}
|
|
21
|
+
/** A stamp's calendar day, clock and month in a time zone ("UTC" on the server, the viewer's own zone in a browser). */
|
|
22
|
+
function partsIn(at, zone) {
|
|
23
|
+
const read = (z) => Object.fromEntries(formatIn(z).formatToParts(at).map(part => [part.type, part.value]));
|
|
24
|
+
let parts;
|
|
25
|
+
try {
|
|
26
|
+
parts = read(zone);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
parts = read("UTC");
|
|
30
|
+
}
|
|
31
|
+
return { year: Number(parts["year"]), month: Number(parts["month"]), day: Number(parts["day"]), clock: `${parts["hour"]}:${parts["minute"]}` };
|
|
32
|
+
}
|
|
33
|
+
const dayNumber = (p) => Math.floor(Date.UTC(p.year, p.month - 1, p.day) / DAY_MS);
|
|
34
|
+
/** The viewer's own time zone (a browser's); UTC where there is none to read. */
|
|
35
|
+
export function viewerZone() {
|
|
36
|
+
try {
|
|
37
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return "UTC";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** The one formatter for a time a person reads, on every surface: "16:39" today, "Yesterday 16:39" /
|
|
44
|
+
* "Tomorrow 16:39" beside it, "Sep 28" otherwise ("Sep 28 2025" in another year), counted in `zone`. With `clock`,
|
|
45
|
+
* a date further out keeps its time of day too ("Sep 28 16:39"), for deadlines and histories where the hour matters. */
|
|
46
|
+
export function shortWhen(iso, now = new Date(), zone = "UTC", clock = false) {
|
|
12
47
|
const at = new Date(iso);
|
|
13
48
|
if (Number.isNaN(at.getTime()))
|
|
14
49
|
return iso;
|
|
15
|
-
const
|
|
16
|
-
const days =
|
|
50
|
+
const then = partsIn(at, zone), today = partsIn(now, zone);
|
|
51
|
+
const days = dayNumber(then) - dayNumber(today);
|
|
17
52
|
if (days === 0)
|
|
18
|
-
return clock;
|
|
53
|
+
return then.clock;
|
|
19
54
|
if (days === -1)
|
|
20
|
-
return `Yesterday ${clock}`;
|
|
55
|
+
return `Yesterday ${then.clock}`;
|
|
21
56
|
if (days === 1)
|
|
22
|
-
return `Tomorrow ${clock}`;
|
|
23
|
-
const date = `${MONTHS[
|
|
24
|
-
return
|
|
57
|
+
return `Tomorrow ${then.clock}`;
|
|
58
|
+
const date = `${MONTHS[then.month - 1]} ${then.day}`;
|
|
59
|
+
return `${then.year === today.year ? date : `${date} ${then.year}`}${clock ? ` ${then.clock}` : ""}`;
|
|
60
|
+
}
|
|
61
|
+
/** A flow card's deadline in the viewer's zone, always with its time of day: "No reply by 16:30" today,
|
|
62
|
+
* "No reply by Tomorrow 16:30", "Moves on Oct 2 16:30" further out (a label's trailing " at" reads wrong before a date). */
|
|
63
|
+
export function deadlineWords(deadline, now = new Date(), zone = viewerZone()) {
|
|
64
|
+
if (Number.isNaN(new Date(deadline.at).getTime()))
|
|
65
|
+
return deadline.label;
|
|
66
|
+
const words = shortWhen(deadline.at, now, zone, true);
|
|
67
|
+
return /^(?:\d|Tomorrow|Yesterday)/.test(words) ? `${deadline.label} ${words}` : `${deadline.label.replace(/ at$/, "")} ${words}`;
|
|
68
|
+
}
|
|
69
|
+
/** The exact minute in `zone` ("2026-09-30 16:39", with " UTC" when it is UTC): a time's title, one hover away. */
|
|
70
|
+
export function fullWhen(iso, zone = "UTC") {
|
|
71
|
+
const at = new Date(iso);
|
|
72
|
+
if (Number.isNaN(at.getTime()))
|
|
73
|
+
return iso;
|
|
74
|
+
const p = partsIn(at, zone);
|
|
75
|
+
return `${p.year}-${String(p.month).padStart(2, "0")}-${String(p.day).padStart(2, "0")} ${p.clock}${zone === "UTC" ? " UTC" : ""}`;
|
|
76
|
+
}
|
|
77
|
+
/** Every server-rendered stamp (`whenHtml`'s `<time data-when>`) inside `root`, reworded by the one formatter in the
|
|
78
|
+
* viewer's zone: the same words on a desk and a phone, the exact minute in the title. The server's UTC words stay
|
|
79
|
+
* only without script. Times React writes, relative ages ("3 min ago") and exact stamps (the ledger's seconds) carry
|
|
80
|
+
* no `data-when` and are never touched. */
|
|
81
|
+
export function localizeTimes(root, now = new Date(), zone = viewerZone()) {
|
|
82
|
+
root.querySelectorAll("time[data-when][datetime]").forEach(node => {
|
|
83
|
+
const iso = node.getAttribute("datetime") ?? "";
|
|
84
|
+
if (iso === "" || Number.isNaN(new Date(iso).getTime()))
|
|
85
|
+
return;
|
|
86
|
+
const words = shortWhen(iso, now, zone), full = fullWhen(iso, zone);
|
|
87
|
+
if (node.title !== full)
|
|
88
|
+
node.title = full;
|
|
89
|
+
if (node.textContent !== words)
|
|
90
|
+
node.textContent = words;
|
|
91
|
+
});
|
|
25
92
|
}
|
|
26
93
|
/** The `<time>` for a stamp: `full` is the words a desk shows ("2026-09-30 16:39 UTC"). Empty for no stamp. */
|
|
27
94
|
export function whenHtml(iso, full, now = new Date()) {
|
|
28
95
|
if (iso === null || iso === "")
|
|
29
96
|
return "";
|
|
30
97
|
const words = escape(full);
|
|
31
|
-
return `<time datetime="${escape(iso)}" title="${words}"><span class="so-when-full">${words}</span><span class="so-when-short">${escape(shortWhen(iso, now))}</span></time>`;
|
|
98
|
+
return `<time data-when datetime="${escape(iso)}" title="${words}"><span class="so-when-full">${words}</span><span class="so-when-short">${escape(shortWhen(iso, now))}</span></time>`;
|
|
32
99
|
}
|
|
33
100
|
/** "2026-09-30 16:39 UTC" — the desk's words for a stamp. */
|
|
34
101
|
export const utcMinute = (iso) => `${iso.slice(0, 16).replace("T", " ")} UTC`;
|
package/dist/work-index.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { type Store, type TaskState } from './store.js';
|
|
|
2
2
|
import type { AssignmentSnapshot } from './assignment.js';
|
|
3
3
|
import type { WorkAction, WorkSummaryAccess } from './work-summary.js';
|
|
4
4
|
import type { WorkStatus, WorkView } from './workspace-ui.js';
|
|
5
|
-
import { type Ask } from './needs-you.js';
|
|
5
|
+
import { type Ask, type AskChip } from './needs-you.js';
|
|
6
6
|
export type WorkIndexCounts = Record<WorkView, number>;
|
|
7
7
|
export type WorkIndexItem = {
|
|
8
8
|
rootId: string;
|
|
@@ -25,6 +25,8 @@ export type WorkIndexItem = {
|
|
|
25
25
|
status: WorkStatus;
|
|
26
26
|
/** What a Needs you row asks of a person (needs-you.ts); null for every other row. */
|
|
27
27
|
ask: Ask | null;
|
|
28
|
+
/** The row's chip under its group: the specific ask (needs-you.ts askChipOf), or none. */
|
|
29
|
+
chip: AskChip | null;
|
|
28
30
|
primaryAction: WorkAction | null;
|
|
29
31
|
completion: {
|
|
30
32
|
actor: string;
|
package/dist/work-index.js
CHANGED
|
@@ -11,7 +11,7 @@ import { plainReasonOf, replacedWords, stageOfCode, taskStatusOf, workToneOf } f
|
|
|
11
11
|
import { isCheckLevel } from './check-levels.js';
|
|
12
12
|
import { MARKER } from './worktree.js';
|
|
13
13
|
import { withFollowUps } from './result-follow-ups.js';
|
|
14
|
-
import { ASKS, NEED_ASK, NEEDS, processNeedOf } from './needs-you.js';
|
|
14
|
+
import { ASKS, NEED_ASK, NEEDS, askChipOf, failedAttemptSentence, processNeedOf, resultHoldUpSentence } from './needs-you.js';
|
|
15
15
|
import { LEAD_CLAIM_SQL, LEAD_IDLE_MS } from './lead-voice.js';
|
|
16
16
|
export const WORK_INDEX_PAGE_LIMIT = 40;
|
|
17
17
|
export const WORK_INDEX_MAX_LIMIT = 100;
|
|
@@ -369,7 +369,7 @@ export function workIndexPage(store, now, access, options = {}) {
|
|
|
369
369
|
'running_count',running_count,'completed_count',completed_count,'queued',queued,'done_recently',done_recently)) FROM project_totals) row_json FROM totals
|
|
370
370
|
UNION ALL SELECT 1,0,0,0,0,0,0,0,0,0,json_object('root_ref',root_ref,'root_id',root_id,'id',id,'root_title',root_title,'repo',repo,
|
|
371
371
|
'state',state,'created_at',created_at,'family_updated',family_updated,'version_count',version_count,'earlier_active',earlier_active,
|
|
372
|
-
'earlier_id',earlier_id,'broken',broken,'live_run',live_run,'result_id',page_result_id,'result_outcome',(SELECT outcome FROM run WHERE id=page_result_id),
|
|
372
|
+
'earlier_id',earlier_id,'broken',broken,'live_run',live_run,'result_id',page_result_id,'result_outcome',(SELECT outcome FROM run WHERE id=page_result_id),'attempt_reason',(SELECT substr(reason,1,1000) FROM run WHERE task_ref IN (SELECT ref_id FROM members WHERE root_ref=page.root_ref) AND (finished_at IS NOT NULL OR outcome IS NOT NULL) AND role NOT IN ('planner','reviewer') ORDER BY id DESC LIMIT 1),
|
|
373
373
|
'publication_url',(SELECT pr_url FROM publication WHERE run=page_result_id),
|
|
374
374
|
'result_role',(SELECT role FROM run WHERE id=page_result_id),'live_role',(SELECT role FROM run WHERE id=page.live_run),
|
|
375
375
|
'check_status',(SELECT status FROM run_check WHERE run=page_result_id),
|
|
@@ -379,7 +379,8 @@ export function workIndexPage(store, now, access, options = {}) {
|
|
|
379
379
|
'proof_verdict',(SELECT COALESCE(machine_verdict,verdict) FROM proof_verdict WHERE run=page_result_id),
|
|
380
380
|
'proof_failed_check',(SELECT verdict='refuted' AND reasons_json LIKE '%approved verification command exited%' FROM proof_verdict WHERE run=page_result_id),'code',code,'needs',needs,'person_need',person_need,'lead_claim',lead_claim,'family_running',family_running,'rank',rank,'ask_rank',ask_rank,'list_order',${ORDER},'sort_at',sort_at,
|
|
381
381
|
'checked_actor',checked_actor,'checked_at',checked_at,'checked_digest',checked_digest,'question_id',question_id,
|
|
382
|
-
'dependency_id',dependency_id,'dependency_state',dependency_state,'custody_unresolved',custody_unresolved,'hold_kind',hold_kind,'hold_reason',substr(hold_reason,1,300),'
|
|
382
|
+
'dependency_id',dependency_id,'dependency_state',dependency_state,'custody_unresolved',custody_unresolved,'hold_kind',hold_kind,'hold_reason',substr(hold_reason,1,300),'hold_id',hold_id,'live_lease',live_lease IS NOT NULL,'approved',COALESCE(approved,0),
|
|
383
|
+
'plan_changed',scope_digest IS NOT NULL AND digest IS NOT scope_digest,'head_ok',length(head_revision)=40 AND head_revision NOT GLOB '*[^a-f0-9]*','stop_run',stop_run,'unfinished',unfinished,'signed_out',signed_out,
|
|
383
384
|
'question_run',(SELECT run FROM decision WHERE id=page.question_id),'replaced_by',(SELECT successor FROM task_replacement WHERE task_ref=page.ref_id),
|
|
384
385
|
'completed_by_lead',(SELECT lead FROM task_act WHERE task_ref=page.root_ref AND act='completed' ORDER BY id DESC LIMIT 1),
|
|
385
386
|
'question_task',(SELECT r.external_id FROM decision d JOIN run ON run.id=d.run JOIN task_ref r ON r.id=run.task_ref WHERE d.id=page.question_id)) FROM page`)
|
|
@@ -460,9 +461,10 @@ function itemOf(row, principal, probe) {
|
|
|
460
461
|
running: ['Running now', '', null, ''],
|
|
461
462
|
'waiting-decision': ['Needs your decision', 'An unanswered question is waiting.', n(row, 'question_id') ? 'answer-decision' : 'inspect-decisions', 'Answer question'],
|
|
462
463
|
'decision-queue': ['Needs your decision', 'The decision queue is full.', 'inspect-decisions', 'Review open questions'],
|
|
463
|
-
|
|
464
|
+
// Its own reason replaces this below (resultHoldUpSentence).
|
|
465
|
+
'result-needs-attention': ['Needs your decision', '', 'inspect-run', 'Inspect run'],
|
|
464
466
|
'process-needs-attention': ['Needs your decision', 'A process exit is not recorded. Open the attempt to check whether its work has stopped.', 'inspect-run', 'Inspect run'],
|
|
465
|
-
failed: ['Needs your decision', 'The last attempt stopped
|
|
467
|
+
failed: ['Needs your decision', 'The last attempt stopped.', 'retry-task', 'Review and retry'],
|
|
466
468
|
'vanished-run': ['Needs your decision', 'An unfinished attempt has no current live claim.', 'reconcile-run', 'Check the unfinished attempt'],
|
|
467
469
|
stopping: ['Needs your decision', 'Stopping. The work so far is kept.', 'inspect-stop', 'View stop details'],
|
|
468
470
|
stopped: ['Needs your decision', 'Paused by a person. The work so far is kept.', 'resume-run', 'Review pause'],
|
|
@@ -502,6 +504,16 @@ function itemOf(row, principal, probe) {
|
|
|
502
504
|
}
|
|
503
505
|
if (code === 'terminal-dependency' && s(row, 'dependency_id') !== null)
|
|
504
506
|
detail = `${s(row, 'dependency_id')} ${s(row, 'dependency_state') === 'cancelled' ? 'was cancelled' : 'failed'} before it finished.`;
|
|
507
|
+
// A failed task says the latest finished attempt across its whole family, whatever its outcome: its recorded reason, or that none was recorded.
|
|
508
|
+
if (code === 'failed')
|
|
509
|
+
detail = failedAttemptSentence(s(row, 'attempt_reason'));
|
|
510
|
+
// A saved result that can't be accepted yet says its own reason, never one line for every such row.
|
|
511
|
+
const planChanged = code === 'result-needs-attention' && n(row, 'plan_changed') === 1;
|
|
512
|
+
if (code === 'result-needs-attention')
|
|
513
|
+
detail = resultHoldUpSentence({ hold: s(row, 'hold_id') === null ? null : s(row, 'hold_reason') ?? '', planChanged,
|
|
514
|
+
unapproved: n(row, 'approved') !== 1, question: s(row, 'question_id') !== null, running: n(row, 'live_lease') === 1, unfinished: s(row, 'unfinished') !== null,
|
|
515
|
+
noCommit: !['built', 'no-change'].includes(s(row, 'result_outcome') ?? '') || (s(row, 'result_role') === 'builder' && n(row, 'head_ok') !== 1),
|
|
516
|
+
report: s(row, 'result_role') === 'scout' });
|
|
505
517
|
if (code === 'result-needs-attention' && n(row, 'custody_unresolved'))
|
|
506
518
|
detail = 'A process exit is not recorded. Open the result to check whether its work has stopped.';
|
|
507
519
|
if (code === 'result-needs-attention' && ['built', 'no-change'].includes(s(row, 'result_outcome') ?? '')) {
|
|
@@ -570,6 +582,7 @@ function itemOf(row, principal, probe) {
|
|
|
570
582
|
status: { token: `assignment-${assignmentState}`, label, detail, tone: workToneOf(shared.headline),
|
|
571
583
|
action: primaryAction === null ? null : { label: actionLabel, kind: actionCode === 'open-result' ? 'open-result' : 'open-task' }, views, rank: n(row, 'rank') },
|
|
572
584
|
ask: need ? ASKS[n(row, 'ask_rank')] ?? 'unblock' : null,
|
|
585
|
+
chip: need ? askChipOf({ headline: shared.headline, need: shared.need?.key ?? null, planChanged, mismatch: verdict === 'refuted' && n(row, 'proof_failed_check') !== 1, ask: ASKS[n(row, 'ask_rank')] ?? 'unblock' }) : null,
|
|
573
586
|
primaryAction, completion: code === 'complete' ? { actor: String(row['checked_actor']), at: String(row['checked_at']), digest: String(row['checked_digest']) } : null,
|
|
574
587
|
evidence: 'recorded' };
|
|
575
588
|
}
|
package/dist/workspace-motion.js
CHANGED
|
@@ -21,7 +21,7 @@ export const WORKSPACE_MOTION_CSS = String.raw `
|
|
|
21
21
|
.t-acc[data-open="false"] > .t-acc-panel { grid-template-rows: 0fr; }
|
|
22
22
|
.t-acc[data-open="false"] > .t-acc-panel > .t-acc-panel-inner { opacity: 0; }
|
|
23
23
|
.t-acc > summary { cursor: pointer; }
|
|
24
|
-
.t-acc > summary .t-acc-chevron { width: 14px; height: 14px; margin-
|
|
24
|
+
.t-acc > summary .t-acc-chevron { width: 14px; height: 14px; margin-right: .375rem; flex: none; }
|
|
25
25
|
.t-acc > summary .t-acc-chevron svg { width: 100%; height: 100%; }
|
|
26
26
|
.t-acc > summary::before, .t-acc > summary::after { content: none !important; }
|
|
27
27
|
.t-acc > summary::-webkit-details-marker { display: none; }
|
|
@@ -60,9 +60,9 @@ export const WORKSPACE_MOTION_SCRIPT = String.raw `
|
|
|
60
60
|
Array.from(details.childNodes).forEach(function(node){if(node!==head)inner.appendChild(node);});
|
|
61
61
|
panel.appendChild(inner);details.appendChild(panel);details.classList.add('t-acc');
|
|
62
62
|
var chevron=document.createElement('span');chevron.className='t-acc-chevron';chevron.setAttribute('aria-hidden','true');
|
|
63
|
-
chevron.innerHTML='<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="
|
|
63
|
+
chevron.innerHTML='<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M6 4L10 8L6 12"/></svg>';
|
|
64
64
|
// Use the existing icon when present, rather than rendering two.
|
|
65
|
-
var old=head.querySelector(':scope > svg');if(old)old.remove();head.
|
|
65
|
+
var old=head.querySelector(':scope > svg');if(old)old.remove();head.prepend(chevron);
|
|
66
66
|
}
|
|
67
67
|
details.dataset.motionReady='true';
|
|
68
68
|
var timer=null,closing=false;
|