toolroll 0.9.14 → 0.9.16
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 +34 -0
- package/dist/assignment-brief.d.ts +10 -0
- package/dist/assignment-brief.js +49 -26
- package/dist/assignment-ui.d.ts +1 -0
- package/dist/assignment-ui.js +6 -4
- package/dist/assignment.d.ts +34 -1
- package/dist/assignment.js +86 -7
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +38 -38
- package/dist/browser-crew.d.ts +2 -1
- package/dist/browser-crew.js +3 -1
- package/dist/browser-workspace.d.ts +93 -18
- package/dist/chat-actions.d.ts +1 -1
- package/dist/chat-actions.js +6 -4
- package/dist/chat-ask.d.ts +28 -0
- package/dist/chat-ask.js +77 -0
- package/dist/chat-channel.js +6 -1
- package/dist/chat-controls.d.ts +8 -0
- package/dist/chat-controls.js +3 -0
- package/dist/chat-delivery-state.d.ts +7 -2
- package/dist/chat-delivery-state.js +15 -2
- package/dist/chat-delivery.js +20 -3
- package/dist/chat-quiet.js +4 -2
- package/dist/chat-voice.d.ts +6 -9
- package/dist/chat-voice.js +11 -5
- package/dist/demo.js +82 -2
- package/dist/disclosure.d.ts +6 -0
- package/dist/disclosure.js +13 -0
- package/dist/discord-chat.js +10 -0
- package/dist/lead-commitments.d.ts +84 -0
- package/dist/lead-commitments.js +208 -0
- package/dist/lead-context.d.ts +32 -3
- package/dist/lead-context.js +133 -19
- package/dist/lead-follow.js +3 -0
- package/dist/lead-identity.d.ts +23 -0
- package/dist/lead-identity.js +33 -0
- package/dist/lead-status.js +2 -2
- package/dist/lead-voice.d.ts +12 -9
- package/dist/lead-voice.js +25 -16
- package/dist/ledger-view.js +3 -2
- package/dist/mate-cli.d.ts +1 -1
- package/dist/mate-cli.js +8 -2
- package/dist/mate-contract.d.ts +1 -1
- package/dist/mate-contract.js +11 -4
- package/dist/mate-progress.js +5 -0
- package/dist/mate-tools.d.ts +18 -1
- package/dist/mate-tools.js +250 -6
- package/dist/mate.d.ts +9 -0
- package/dist/mate.js +34 -38
- package/dist/needs-you.d.ts +48 -0
- package/dist/needs-you.js +93 -1
- package/dist/operate.js +2 -1
- package/dist/project-delete.js +3 -1
- package/dist/result-acts.d.ts +63 -5
- package/dist/result-acts.js +53 -3
- package/dist/result-review.d.ts +4 -3
- package/dist/result-review.js +7 -3
- package/dist/retention.js +6 -0
- package/dist/serve.d.ts +3 -0
- package/dist/serve.js +1220 -694
- package/dist/slack-chat.js +11 -1
- package/dist/store.d.ts +51 -0
- package/dist/store.js +115 -1
- package/dist/surface.js +2 -2
- package/dist/task-status.d.ts +26 -19
- package/dist/task-status.js +59 -21
- package/dist/team-runtime.js +2 -1
- package/dist/teams-chat.js +4 -1
- package/dist/telegram-mate.d.ts +4 -1
- package/dist/telegram-mate.js +17 -1
- package/dist/telegram-progress.js +3 -2
- package/dist/telegram.js +43 -3
- 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 +24 -2
- package/dist/when-html.js +98 -11
- package/dist/work-index.d.ts +5 -1
- package/dist/work-index.js +22 -9
- package/dist/work-summary.d.ts +1 -1
- package/dist/workspace-motion.js +3 -3
- package/dist/workspace-ui.d.ts +44 -20
- package/dist/workspace-ui.js +63 -37
- package/package.json +1 -1
package/dist/teams-chat.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** Microsoft Teams on the shared chat layer: activities in, Adaptive Cards
|
|
2
2
|
* out, the same durable receipts, rooms and commands as every other channel. */
|
|
3
3
|
import { chatQuestionButtons } from "./teammate-question.js";
|
|
4
|
+
import { chatAskButtons } from "./chat-ask.js";
|
|
4
5
|
import { ChatState, chatHash } from "./chat-delivery-state.js";
|
|
5
6
|
import { channelAccess, chatObject as object, planChatNotifications, planRoomMessages, processChatEvent } from "./chat-delivery.js";
|
|
6
7
|
import { roomCommand } from "./chat-rooms.js";
|
|
@@ -195,7 +196,9 @@ export async function deliverTeamsPart(options) {
|
|
|
195
196
|
const flow = content.flow ? chatFlowButtons(state, row.id, now).map(one => ({ type: "Action.Submit", title: one.label, data: { so: one.token }, ...(one.action === "approve" ? { style: "positive" } : {}) })) : [];
|
|
196
197
|
// A teammate's question (v93): its options, then "Answer in words".
|
|
197
198
|
const asked = content.question ? chatQuestionButtons(state, row.id, now).map(one => ({ type: "Action.Submit", title: one.label.slice(0, 80), data: { so: one.token } })) : [];
|
|
198
|
-
|
|
199
|
+
// The lead's question to its owner: its options, then "Something else".
|
|
200
|
+
const owner = content.ask ? chatAskButtons(state, row.id, now).map(one => ({ type: "Action.Submit", title: one.label.slice(0, 80), data: { so: one.token } })) : [];
|
|
201
|
+
actions = [...flow, ...asked, ...owner, ...openUrlAction(options.origin(), content.link), ...(content.also ?? []).flatMap(one => openUrlAction(options.origin(), one))];
|
|
199
202
|
}
|
|
200
203
|
const target = content.edit ?? row.message;
|
|
201
204
|
const body = actions.length || content.proposal ? teamsCard(text, actions) : { type: "message", text, textFormat: "plain" };
|
package/dist/telegram-mate.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type PhoneLink } from "./chat-channel.js";
|
|
2
2
|
export { channelRepos as telegramConversationRepos, resolveChannelMate as resolveTelegramMate, tooLongText, whichTaskText, replyContextFor, NO_PHONE_LINK, NO_TASK_LINK, proposalLink, confirmedLink, proposalPreview, proposalOutcomeText, handoffCardText, confirmedCardText, CHAT_ACTION_PARITY as TELEGRAM_ACTION_PARITY, parityGaps, type PhoneLink, type ResolvedMate, type ParitySupport } from "./chat-channel.js";
|
|
3
3
|
import { type DoorOptions } from "./mate-doors.js";
|
|
4
|
-
import type
|
|
4
|
+
import { type MateAsk, type Store, type TelegramBinding } from "./store.js";
|
|
5
5
|
import type { SubscriptionMateRunner } from "./subscription-chat.js";
|
|
6
6
|
import type { TelegramTransport } from "./telegram.js";
|
|
7
7
|
/** How long one claimed turn may go without a heartbeat before another poller may take it over. */
|
|
@@ -59,6 +59,9 @@ export declare function telegramChannelProblem(store: Store, expected: {
|
|
|
59
59
|
}), readProjects: () => Promise<readonly string[]>): Promise<string | null>;
|
|
60
60
|
export declare function phoneLinkButton(origin: string | null, link: PhoneLink | null): InlineButton[] | null;
|
|
61
61
|
/** One inline button: an opaque callback token (a real in-chat act) or a url (navigation, never authority). */
|
|
62
|
+
/** The buttons under the lead's question: options two to a row, then "Something else". Each names the turn and the
|
|
63
|
+
* option's place, nothing more; a tap re-proves that the question is still open and is the tapper's own. */
|
|
64
|
+
export declare function askKeyboard(ask: MateAsk): CallbackKeyboard;
|
|
62
65
|
export type InlineButton = {
|
|
63
66
|
text: string;
|
|
64
67
|
callback_data: string;
|
package/dist/telegram-mate.js
CHANGED
|
@@ -26,6 +26,7 @@ import { createHash, randomBytes } from "node:crypto";
|
|
|
26
26
|
import { MATE_MESSAGE_MAX_CHARS, runMateTurn } from "./mate.js";
|
|
27
27
|
import { confirmMateProposal, dismissMateProposal } from "./mate-doors.js";
|
|
28
28
|
import { ceilingDigestOf, verifyApproverStanding } from "./principal.js";
|
|
29
|
+
import { MATE_ASK_OTHER } from "./store.js";
|
|
29
30
|
import { phoneText } from "./telegram-status.js";
|
|
30
31
|
import { TeamLeads } from "./team-leads.js";
|
|
31
32
|
import { chatResultHref } from "./chat-controls.js";
|
|
@@ -100,6 +101,17 @@ function keyboardWith(callbacks, link) {
|
|
|
100
101
|
const rows = [...(callbacks ?? []), ...(link === null ? [] : [link])];
|
|
101
102
|
return rows.length === 0 ? undefined : rows;
|
|
102
103
|
}
|
|
104
|
+
// ---- proposal cards on the wire ------------------------------------------------------
|
|
105
|
+
/** One inline button: an opaque callback token (a real in-chat act) or a url (navigation, never authority). */
|
|
106
|
+
/** The buttons under the lead's question: options two to a row, then "Something else". Each names the turn and the
|
|
107
|
+
* option's place, nothing more; a tap re-proves that the question is still open and is the tapper's own. */
|
|
108
|
+
export function askKeyboard(ask) {
|
|
109
|
+
const rows = [];
|
|
110
|
+
for (let at = 0; at < ask.options.length; at += 2)
|
|
111
|
+
rows.push(ask.options.slice(at, at + 2).map((label, offset) => ({ text: label.slice(0, 60), callback_data: `ask:${ask.turn}:${at + offset}` })));
|
|
112
|
+
rows.push([{ text: MATE_ASK_OTHER, callback_data: `ask:${ask.turn}:x` }]);
|
|
113
|
+
return rows;
|
|
114
|
+
}
|
|
103
115
|
/** Mint the card's tokens before the send, so a tap can never name a token that does not exist. */
|
|
104
116
|
export function mintCardTokens(store, binding, proposal, now, messageId, chatId = binding.chatId) {
|
|
105
117
|
const confirm = randomBytes(16).toString("hex");
|
|
@@ -579,6 +591,10 @@ async function runTelegramConversation(row, args) {
|
|
|
579
591
|
return store.transact(() => {
|
|
580
592
|
const now = clock();
|
|
581
593
|
const parts = splitParts(reply).map((text, index) => ({ kind: "reply", text, replyTo: index === 0 ? row.messageId : null }));
|
|
594
|
+
// The lead's question to its owner: one tap per option, then "Something else".
|
|
595
|
+
const ask = store.mateAsk(turn);
|
|
596
|
+
if (ask !== null)
|
|
597
|
+
parts.push({ kind: "reply", text: phoneText(ask.question, 1_000), keyboard: askKeyboard(ask) });
|
|
582
598
|
// Only a turn that answered may have its selection sent: a failed or revoked turn's rows were deleted with its drafts, and the state is read again here.
|
|
583
599
|
const selected = store.getMateTurn(turn)?.state === "answered" ? store.listMateTurnEvidence(turn) : [];
|
|
584
600
|
for (const image of selected)
|
|
@@ -694,7 +710,7 @@ async function runTelegramConversation(row, args) {
|
|
|
694
710
|
store, who, session, thread, config, key: null, message: row.text, requestId: row.request,
|
|
695
711
|
...(row.context === null ? {} : { context: row.context }),
|
|
696
712
|
...(options.subscriptionRunner === undefined ? {} : { subscriptionRunner: options.subscriptionRunner }),
|
|
697
|
-
clock, evidenceRoot: options.evidenceRoot, revalidate, mediaDelivery: "documents",
|
|
713
|
+
clock, evidenceRoot: options.evidenceRoot, revalidate, mediaDelivery: "documents", channel: "telegram",
|
|
698
714
|
});
|
|
699
715
|
}
|
|
700
716
|
finally {
|
|
@@ -9,6 +9,7 @@ import { chatResultHref, chatControlHref } from "./chat-controls.js";
|
|
|
9
9
|
import { phoneText, projectLabel } from "./telegram-status.js";
|
|
10
10
|
import { assignmentStatusFacts, headlineEmoji, pullRequestFactOf, requirementsOf, statusDetailLines, unverifiedWhenRefuted, taskStatusOf } from "./task-status.js";
|
|
11
11
|
import { leadClaimOf } from "./lead-voice.js";
|
|
12
|
+
import { leadNameOf } from "./lead-identity.js";
|
|
12
13
|
import { chatTitle } from "./chat-voice.js";
|
|
13
14
|
import { manualReviewOnly } from "./proof.js";
|
|
14
15
|
import { failedCheckExit } from "./workspace-ui.js";
|
|
@@ -79,11 +80,11 @@ export function telegramProgressCard(store, run, taskId, project, now = new Date
|
|
|
79
80
|
const completer = assignment?.completion?.actor.replace(/^(?:operator|coordinator|lead):/, "") ?? null;
|
|
80
81
|
if (viewer !== undefined && facts.completedBy != null && (completer === viewer || facts.completedBy === viewer))
|
|
81
82
|
facts = { ...facts, completedBy: null };
|
|
82
|
-
// The person's lead took it on: "
|
|
83
|
+
// The person's lead took it on: "<name> is on it" rather than waiting for them (lead-voice.ts).
|
|
83
84
|
if (facts.lead === undefined) {
|
|
84
85
|
const lead = leadClaimOf(store, taskId, now, viewer);
|
|
85
86
|
if (lead !== null)
|
|
86
|
-
facts = { ...facts, lead: lead.state };
|
|
87
|
+
facts = { ...facts, lead: lead.state, leadName: leadNameOf(store, lead.owner) };
|
|
87
88
|
}
|
|
88
89
|
const status = taskStatusOf(facts);
|
|
89
90
|
const checkProgress = store.checkProgress(run.id);
|
package/dist/telegram.js
CHANGED
|
@@ -31,7 +31,7 @@ import { isLifecycleNotification, isTelegramProgressNotification, TELEGRAM_HOLD_
|
|
|
31
31
|
import { telegramProgressCard } from "./telegram-progress.js";
|
|
32
32
|
import { enqueueEveningDigests, finishedView, isTaskFact, joinsBatch, needsPerson, quietCardView } from "./chat-quiet.js";
|
|
33
33
|
import { BATCH_MS, chatText, chatTitle, factLinkLabel, mentions, nameTelegramBot } from "./chat-voice.js";
|
|
34
|
-
import { LEAD_SAY_KIND, enqueueLeadLapses, leadSayEarlier, leadSayText } from "./lead-voice.js";
|
|
34
|
+
import { LEAD_SAY_KIND, enqueueLeadLapses, leadSayEarlier, leadSayText, leadSubjectOf } from "./lead-voice.js";
|
|
35
35
|
import { applyTeamInbound, deliverTeamChats, teamCommand } from "./telegram-team.js";
|
|
36
36
|
import { applyFlowReply, applyFlowTap, FLOW_DECIDE_KEY, flowButtons, flowDecisionAt } from "./telegram-flow.js";
|
|
37
37
|
import { connectChannel, FLOW_WORDS, takeChannelMessage, watchedChannel } from "./chat-inbox.js";
|
|
@@ -920,7 +920,7 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
|
|
|
920
920
|
};
|
|
921
921
|
/** The lead's words (lead-voice.ts): one message; a later say within two minutes edits it in place. */
|
|
922
922
|
const leadSayOutcome = async (fact) => {
|
|
923
|
-
const words = leadSayText(fact);
|
|
923
|
+
const words = leadSayText(store, fact);
|
|
924
924
|
const button = fact.link === null ? null : factButton(phoneOrigin, fact.link);
|
|
925
925
|
const keyboard = button === null ? [] : [button];
|
|
926
926
|
const earlier = leadSayEarlier(store, fact).map(id => store.telegramMessageOf(id, fact.destination)).find(one => one !== null) ?? null;
|
|
@@ -1064,7 +1064,7 @@ async function deliverOne(store, botId, binding, sender, notification, clock, ph
|
|
|
1064
1064
|
// A short title, never the task's id or a "— revision" suffix (chat-voice.ts).
|
|
1065
1065
|
const title = notification.taskId === null ? undefined : chatTitle(store, notification.taskId);
|
|
1066
1066
|
const task = notification.taskId === null ? [] : [{ id: notification.taskId, ...(title === undefined ? {} : { title }) }];
|
|
1067
|
-
const words = chatText(`${alreadyAccepted ? "Acceptance recorded" : notification.
|
|
1067
|
+
const words = chatText(`${alreadyAccepted ? "Acceptance recorded" : leadSubjectOf(store, notification, binding.approver)}${body === "" ? "" : `\n\n${body}`}`, task);
|
|
1068
1068
|
// The task's title once: in front, unless the words already name it.
|
|
1069
1069
|
const parts = split(`${notificationIdentity(notification, title !== undefined && mentions(words, title) ? undefined : title)}${words}`);
|
|
1070
1070
|
const button = notification.link !== null && !alreadyAccepted && current ? factButton(phoneOrigin, notification.link) : null;
|
|
@@ -1970,6 +1970,46 @@ function applyCallback(context, update, effects) {
|
|
|
1970
1970
|
}
|
|
1971
1971
|
return;
|
|
1972
1972
|
}
|
|
1973
|
+
// The lead's question to its owner: an option is sent as their next message; "Something else" asks them to type it.
|
|
1974
|
+
const askTap = /^ask:([1-9][0-9]{0,14}):([0-3]|x)$/.exec(token);
|
|
1975
|
+
if (askTap !== null) {
|
|
1976
|
+
if (tapChat !== binding.chatId || context.conversation === undefined) {
|
|
1977
|
+
report.ignored++;
|
|
1978
|
+
return;
|
|
1979
|
+
}
|
|
1980
|
+
const turn = Number(askTap[1]);
|
|
1981
|
+
const found = store.getMateTurn(turn)?.approver === binding.approver ? store.mateAskState(turn, clock()) : { state: "expired" };
|
|
1982
|
+
if (found.state !== "open") {
|
|
1983
|
+
const line = found.state === "answered" ? "That question was already answered." : "That question expired. Send your answer as a message.";
|
|
1984
|
+
ack(line);
|
|
1985
|
+
editText(`${message.text ?? ""}\n\n${line}`.trim().slice(0, 4000));
|
|
1986
|
+
return;
|
|
1987
|
+
}
|
|
1988
|
+
const ask = found.ask;
|
|
1989
|
+
if (store.telegramConversationWaitingOn(binding.id, String(message.message_id))) {
|
|
1990
|
+
ack("Your answer is on its way.");
|
|
1991
|
+
return;
|
|
1992
|
+
}
|
|
1993
|
+
if (askTap[2] === "x") {
|
|
1994
|
+
ack("Type your answer as a message.");
|
|
1995
|
+
return;
|
|
1996
|
+
}
|
|
1997
|
+
const option = ask.options[Number(askTap[2])];
|
|
1998
|
+
if (option === undefined) {
|
|
1999
|
+
ack("That option is no longer there.");
|
|
2000
|
+
return;
|
|
2001
|
+
}
|
|
2002
|
+
const focused = store.chatFocus("telegram", binding.id);
|
|
2003
|
+
store.enqueueTelegramConversation({
|
|
2004
|
+
binding, updateId: update.update_id, messageId: String(message.message_id), replyTo: null,
|
|
2005
|
+
request: telegramRequestId(botId, binding.id, update.update_id), text: option,
|
|
2006
|
+
context: focused === null ? null : focusContextFor(focused), taskId: focused, sourceRun: null,
|
|
2007
|
+
}, clock());
|
|
2008
|
+
report.chatQueued = (report.chatQueued ?? 0) + 1;
|
|
2009
|
+
ack(`Sent: ${option}`.slice(0, 190));
|
|
2010
|
+
editText(`${message.text ?? ask.question}\n\nYou chose: ${option}`.slice(0, 4000));
|
|
2011
|
+
return;
|
|
2012
|
+
}
|
|
1973
2013
|
const action = store.getTelegramAction(token);
|
|
1974
2014
|
if (action === null && context.conversation !== undefined && store.getTelegramProposalAction(token) !== null) {
|
|
1975
2015
|
// A proposal card's button: the shared confirm door, inside this
|
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-", "lead-promises-",
|
|
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,27 @@
|
|
|
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. An exact stamp
|
|
17
|
+
* (`exactWhenHtml`'s `data-when="exact"`, the ledger's seconds) keeps its seconds, in the viewer's zone, and its
|
|
18
|
+
* ISO title. The server's UTC words stay only without script. Times React writes and relative ages ("3 min ago")
|
|
19
|
+
* carry no `data-when` and are never touched. */
|
|
20
|
+
export declare function localizeTimes(root: ParentNode, now?: Date, zone?: string): void;
|
|
21
|
+
/** The exact second in `zone` ("2026-09-30 16:39:12", with " UTC" when it is UTC): an audit record's stamp. */
|
|
22
|
+
export declare function exactWhen(iso: string, zone?: string): string;
|
|
23
|
+
/** The `<time>` for an exact stamp: its seconds in UTC words on the server, the viewer's zone in a browser. */
|
|
24
|
+
export declare function exactWhenHtml(iso: string): string;
|
|
3
25
|
/** The `<time>` for a stamp: `full` is the words a desk shows ("2026-09-30 16:39 UTC"). Empty for no stamp. */
|
|
4
26
|
export declare function whenHtml(iso: string | null, full: string, now?: Date): string;
|
|
5
27
|
/** "2026-09-30 16:39 UTC" — the desk's words for a stamp. */
|
package/dist/when-html.js
CHANGED
|
@@ -1,34 +1,121 @@
|
|
|
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. An exact stamp
|
|
79
|
+
* (`exactWhenHtml`'s `data-when="exact"`, the ledger's seconds) keeps its seconds, in the viewer's zone, and its
|
|
80
|
+
* ISO title. The server's UTC words stay only without script. Times React writes and relative ages ("3 min ago")
|
|
81
|
+
* carry no `data-when` and are never touched. */
|
|
82
|
+
export function localizeTimes(root, now = new Date(), zone = viewerZone()) {
|
|
83
|
+
root.querySelectorAll("time[data-when][datetime]").forEach(node => {
|
|
84
|
+
const iso = node.getAttribute("datetime") ?? "";
|
|
85
|
+
if (iso === "" || Number.isNaN(new Date(iso).getTime()))
|
|
86
|
+
return;
|
|
87
|
+
if (node.getAttribute("data-when") === "exact") {
|
|
88
|
+
const words = exactWhen(iso, zone);
|
|
89
|
+
if (node.textContent !== words)
|
|
90
|
+
node.textContent = words;
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const words = shortWhen(iso, now, zone), full = fullWhen(iso, zone);
|
|
94
|
+
if (node.title !== full)
|
|
95
|
+
node.title = full;
|
|
96
|
+
if (node.textContent !== words)
|
|
97
|
+
node.textContent = words;
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
/** The exact second in `zone` ("2026-09-30 16:39:12", with " UTC" when it is UTC): an audit record's stamp. */
|
|
101
|
+
export function exactWhen(iso, zone = "UTC") {
|
|
102
|
+
const at = new Date(iso);
|
|
103
|
+
if (Number.isNaN(at.getTime()))
|
|
104
|
+
return iso;
|
|
105
|
+
const seconds = String(at.getUTCSeconds()).padStart(2, "0");
|
|
106
|
+
const minute = fullWhen(iso, zone);
|
|
107
|
+
return zone === "UTC" ? minute.replace(/ UTC$/, `:${seconds} UTC`) : `${minute}:${seconds}`;
|
|
108
|
+
}
|
|
109
|
+
/** The `<time>` for an exact stamp: its seconds in UTC words on the server, the viewer's zone in a browser. */
|
|
110
|
+
export function exactWhenHtml(iso) {
|
|
111
|
+
return `<time data-when="exact" datetime="${escape(iso)}" title="${escape(iso)}">${escape(exactWhen(iso))}</time>`;
|
|
25
112
|
}
|
|
26
113
|
/** The `<time>` for a stamp: `full` is the words a desk shows ("2026-09-30 16:39 UTC"). Empty for no stamp. */
|
|
27
114
|
export function whenHtml(iso, full, now = new Date()) {
|
|
28
115
|
if (iso === null || iso === "")
|
|
29
116
|
return "";
|
|
30
117
|
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>`;
|
|
118
|
+
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
119
|
}
|
|
33
120
|
/** "2026-09-30 16:39 UTC" — the desk's words for a stamp. */
|
|
34
121
|
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;
|
|
@@ -32,6 +34,8 @@ export type WorkIndexItem = {
|
|
|
32
34
|
digest: string;
|
|
33
35
|
} | null;
|
|
34
36
|
evidence: 'recorded';
|
|
37
|
+
/** The viewer's own lead took it on (lead-voice.ts): "<name> is on it.", by the name they gave it, read now. */
|
|
38
|
+
lead?: string;
|
|
35
39
|
};
|
|
36
40
|
export type WorkIndexOptions = {
|
|
37
41
|
view?: WorkView;
|
package/dist/work-index.js
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
/** Paginated, permission-bound summaries of saved task facts. These reads do
|
|
2
2
|
* not verify artifacts or authorize task operations; a build Toolroll can't
|
|
3
3
|
* yet confirm stopped is read through the store's own read-only probe. */
|
|
4
|
+
import { leadNameOf } from './lead-identity.js';
|
|
4
5
|
import { createHash } from 'node:crypto';
|
|
5
6
|
import { existsSync } from 'node:fs';
|
|
6
7
|
import { join } from 'node:path';
|
|
7
8
|
import { readProjectAccess } from './project-access.js';
|
|
8
9
|
import { pauseOnTaskProviders, providerName, signInReason } from './provider-auth.js';
|
|
9
10
|
import { scopeTermsProblem, stopFactOf } from './store.js';
|
|
10
|
-
import { plainReasonOf, replacedWords, stageOfCode, taskStatusOf, workToneOf } from './task-status.js';
|
|
11
|
+
import { leadOnIt, plainReasonOf, replacedWords, stageOfCode, taskStatusOf, workToneOf } from './task-status.js';
|
|
11
12
|
import { isCheckLevel } from './check-levels.js';
|
|
12
13
|
import { MARKER } from './worktree.js';
|
|
13
14
|
import { withFollowUps } from './result-follow-ups.js';
|
|
14
|
-
import { ASKS, NEED_ASK, NEEDS, failedAttemptSentence, processNeedOf } from './needs-you.js';
|
|
15
|
+
import { ASKS, NEED_ASK, NEEDS, askChipOf, failedAttemptSentence, processNeedOf, resultHoldUpSentence } from './needs-you.js';
|
|
15
16
|
import { LEAD_CLAIM_SQL, LEAD_IDLE_MS } from './lead-voice.js';
|
|
16
17
|
export const WORK_INDEX_PAGE_LIMIT = 40;
|
|
17
18
|
export const WORK_INDEX_MAX_LIMIT = 100;
|
|
@@ -379,7 +380,8 @@ export function workIndexPage(store, now, access, options = {}) {
|
|
|
379
380
|
'proof_verdict',(SELECT COALESCE(machine_verdict,verdict) FROM proof_verdict WHERE run=page_result_id),
|
|
380
381
|
'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
382
|
'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),'
|
|
383
|
+
'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),
|
|
384
|
+
'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
385
|
'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
386
|
'completed_by_lead',(SELECT lead FROM task_act WHERE task_ref=page.root_ref AND act='completed' ORDER BY id DESC LIMIT 1),
|
|
385
387
|
'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`)
|
|
@@ -394,7 +396,9 @@ export function workIndexPage(store, now, access, options = {}) {
|
|
|
394
396
|
const ref = store.lookupRef(taskId);
|
|
395
397
|
return ref === null ? null : custody.readings.get(ref.id) ?? null;
|
|
396
398
|
};
|
|
397
|
-
|
|
399
|
+
// A claimed row says the viewer's own name for their lead.
|
|
400
|
+
const leadName = page.some(row => n(row, 'lead_claim') !== 0) && access.principal === 'operator' && access.viewer != null ? leadNameOf(store, access.viewer) : undefined;
|
|
401
|
+
return { items: page.map(row => itemOf(row, access.principal, probe, leadName)), projects, totals: rows[0] === undefined ? { ...EMPTY } : counts(rows[0]),
|
|
398
402
|
groups: rows[0] === undefined ? { ...NO_GROUPS } : groupCounts(rows[0]), nextCursor, limit, view };
|
|
399
403
|
}
|
|
400
404
|
export function workCountsByProject(store, now, access) {
|
|
@@ -440,13 +444,13 @@ function listChecksOf(own, level, follows) {
|
|
|
440
444
|
return null;
|
|
441
445
|
return { status: read.status, exitCode: null, head: null, level: read.level, running: read.running };
|
|
442
446
|
}
|
|
443
|
-
function itemOf(row, principal, probe) {
|
|
447
|
+
function itemOf(row, principal, probe, leadName) {
|
|
444
448
|
const code = String(row['code']), id = String(row['id']);
|
|
445
449
|
const custody = code === 'process-needs-attention' || (code === 'result-needs-attention' && n(row, 'custody_unresolved') === 1);
|
|
446
450
|
const process = custody && probe !== undefined ? processNeedOf(probe(id)) : null;
|
|
447
451
|
const waiting = process !== null && 'wait' in process;
|
|
448
452
|
const need = n(row, 'needs') === 1 && !waiting, running = n(row, 'family_running') === 1;
|
|
449
|
-
// The lead's claim (lead-voice.ts): the row still reads its own stage, worded "
|
|
453
|
+
// The lead's claim (lead-voice.ts): the row still reads its own stage, worded "<name> is on it".
|
|
450
454
|
const lead = n(row, 'lead_claim') === 1 ? 'on-it' : n(row, 'lead_claim') === 2 ? 'lapsed' : null;
|
|
451
455
|
const personNeed = (n(row, 'person_need') === 1 || need) && !waiting;
|
|
452
456
|
const assignmentState = code === 'complete' ? 'complete' : code === 'ready-to-check' ? 'ready-to-check'
|
|
@@ -460,7 +464,8 @@ function itemOf(row, principal, probe) {
|
|
|
460
464
|
running: ['Running now', '', null, ''],
|
|
461
465
|
'waiting-decision': ['Needs your decision', 'An unanswered question is waiting.', n(row, 'question_id') ? 'answer-decision' : 'inspect-decisions', 'Answer question'],
|
|
462
466
|
'decision-queue': ['Needs your decision', 'The decision queue is full.', 'inspect-decisions', 'Review open questions'],
|
|
463
|
-
|
|
467
|
+
// Its own reason replaces this below (resultHoldUpSentence).
|
|
468
|
+
'result-needs-attention': ['Needs your decision', '', 'inspect-run', 'Inspect run'],
|
|
464
469
|
'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
470
|
failed: ['Needs your decision', 'The last attempt stopped.', 'retry-task', 'Review and retry'],
|
|
466
471
|
'vanished-run': ['Needs your decision', 'An unfinished attempt has no current live claim.', 'reconcile-run', 'Check the unfinished attempt'],
|
|
@@ -505,6 +510,13 @@ function itemOf(row, principal, probe) {
|
|
|
505
510
|
// A failed task says the latest finished attempt across its whole family, whatever its outcome: its recorded reason, or that none was recorded.
|
|
506
511
|
if (code === 'failed')
|
|
507
512
|
detail = failedAttemptSentence(s(row, 'attempt_reason'));
|
|
513
|
+
// A saved result that can't be accepted yet says its own reason, never one line for every such row.
|
|
514
|
+
const planChanged = code === 'result-needs-attention' && n(row, 'plan_changed') === 1;
|
|
515
|
+
if (code === 'result-needs-attention')
|
|
516
|
+
detail = resultHoldUpSentence({ hold: s(row, 'hold_id') === null ? null : s(row, 'hold_reason') ?? '', planChanged,
|
|
517
|
+
unapproved: n(row, 'approved') !== 1, question: s(row, 'question_id') !== null, running: n(row, 'live_lease') === 1, unfinished: s(row, 'unfinished') !== null,
|
|
518
|
+
noCommit: !['built', 'no-change'].includes(s(row, 'result_outcome') ?? '') || (s(row, 'result_role') === 'builder' && n(row, 'head_ok') !== 1),
|
|
519
|
+
report: s(row, 'result_role') === 'scout' });
|
|
508
520
|
if (code === 'result-needs-attention' && n(row, 'custody_unresolved'))
|
|
509
521
|
detail = 'A process exit is not recorded. Open the result to check whether its work has stopped.';
|
|
510
522
|
if (code === 'result-needs-attention' && ['built', 'no-change'].includes(s(row, 'result_outcome') ?? '')) {
|
|
@@ -550,7 +562,7 @@ function itemOf(row, principal, probe) {
|
|
|
550
562
|
reason: finished ? null : plainReasonOf(reading.stage, code, detail || null), report: s(row, 'result_role') === 'scout',
|
|
551
563
|
checks: !finished ? null : listChecksOf(own, s(row, 'check_level'), row['check_follows']),
|
|
552
564
|
completedBy: code !== 'complete' ? null : n(row, 'completed_by_lead') === 1 ? 'the lead' : String(row['checked_actor']).replace(/^(?:operator|coordinator|lead):/, ''),
|
|
553
|
-
...(lead === null ? {} : { lead }) });
|
|
565
|
+
...(lead === null ? {} : { lead, ...(leadName === undefined ? {} : { leadName }) }) });
|
|
554
566
|
label = shared.headline;
|
|
555
567
|
detail = shared.sentence;
|
|
556
568
|
// Every Needs you action wears its need's own words.
|
|
@@ -573,6 +585,7 @@ function itemOf(row, principal, probe) {
|
|
|
573
585
|
status: { token: `assignment-${assignmentState}`, label, detail, tone: workToneOf(shared.headline),
|
|
574
586
|
action: primaryAction === null ? null : { label: actionLabel, kind: actionCode === 'open-result' ? 'open-result' : 'open-task' }, views, rank: n(row, 'rank') },
|
|
575
587
|
ask: need ? ASKS[n(row, 'ask_rank')] ?? 'unblock' : null,
|
|
588
|
+
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,
|
|
576
589
|
primaryAction, completion: code === 'complete' ? { actor: String(row['checked_actor']), at: String(row['checked_at']), digest: String(row['checked_digest']) } : null,
|
|
577
|
-
evidence: 'recorded' };
|
|
590
|
+
evidence: 'recorded', ...(lead === 'on-it' ? { lead: leadOnIt(leadName) } : {}) };
|
|
578
591
|
}
|
package/dist/work-summary.d.ts
CHANGED
|
@@ -42,7 +42,7 @@ export declare function workDecisionAction(taskId: string, decision: NonNullable
|
|
|
42
42
|
/** Pure counterpart for callers already holding the same facts. */
|
|
43
43
|
export declare function workSummaryOf(facts: WorkFacts, principal: WorkPrincipal, resultDisplay?: DisplayStatus): WorkSummary;
|
|
44
44
|
export type WorkSummaryAccess =
|
|
45
|
-
/** `viewer`: the person reading, whose own lead's claim (lead-voice.ts) a task reads as "
|
|
45
|
+
/** `viewer`: the person reading, whose own lead's claim (lead-voice.ts) a task reads as "<name> is on it". */
|
|
46
46
|
{
|
|
47
47
|
principal: "operator";
|
|
48
48
|
repos: readonly string[] | null;
|
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;
|