From 9887f6957e0e28dd240163f11179415627794820 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Valent=C3=ADn=20Quiroz=20de=20la=20Sierra?=
<85706426+vqsierra@users.noreply.github.com>
Date: Fri, 13 Nov 2026 12:37:15 -0500
Subject: [PATCH] Console wording, no browser agent tools, and a roadmap that
matches the app
- The console asked every researcher to keep the second copy of the key
file in their Teams channel files. It now asks for a secure place the
study has approved.
- Removed lib/board-tools.ts, which registered tools on
document.modelContext so that an AI agent in the participant's browser
could read the cards and piles and move cards. Nothing else used it.
- ROADMAP listed three workflows, contribution review and Sites hosting as
working, and a DOI as still to do. It now describes this release.
---
app/components/console/console-page.tsx | 4 +-
app/components/console/publish-steps.tsx | 12 +-
app/components/console/session-view.tsx | 2 +-
app/components/participant/sort-workspace.tsx | 4 -
app/lib/board-tools.ts | 112 ------------------
app/tests/console-smoke.mjs | 2 +-
docs/ROADMAP.md | 15 +--
7 files changed, 18 insertions(+), 133 deletions(-)
delete mode 100644 app/lib/board-tools.ts
diff --git a/app/components/console/console-page.tsx b/app/components/console/console-page.tsx
index b368d70..ff4101f 100644
--- a/app/components/console/console-page.tsx
+++ b/app/components/console/console-page.tsx
@@ -88,8 +88,8 @@ export default function ConsolePage() {
- The key file can open every response in its session. Keep it on this computer and in your study team's
- Teams channel files only.
+ The key file can open every response in its session. Keep it on this computer and in one secure place your
+ study has approved for its files, and nowhere else.
diff --git a/app/components/console/publish-steps.tsx b/app/components/console/publish-steps.tsx
index fda205e..ed098d6 100644
--- a/app/components/console/publish-steps.tsx
+++ b/app/components/console/publish-steps.tsx
@@ -17,7 +17,7 @@ import { useUnsavedGuard } from "./use-unsaved-guard";
// Save the key file (twice), then publish. Publishing is safe to retry: the request is rebuilt from the file.
export default function PublishSteps({ file, onPublished }: { file: SessionFile; onPublished: () => void }) {
const [saved, setSaved] = useState(false);
- const [teamsCopy, setTeamsCopy] = useState(false);
+ const [secondCopy, setSecondCopy] = useState(false);
const [creationKey, setCreationKey] = useState(() => readKey(CREATION_KEY_STORAGE) ?? "");
const [remember, setRemember] = useState(true);
const [busy, setBusy] = useState(false);
@@ -71,9 +71,9 @@ export default function PublishSteps({ file, onPublished }: { file: SessionFile;
setTeamsCopy(e.target.checked)}
- label="I also saved a copy in our study team's Teams channel files."
+ checked={secondCopy}
+ onChange={(e) => setSecondCopy(e.target.checked)}
+ label="I also saved a second copy in a secure place our study has approved for its files."
/>
diff --git a/app/components/console/session-view.tsx b/app/components/console/session-view.tsx
index baf0509..0cd913d 100644
--- a/app/components/console/session-view.tsx
+++ b/app/components/console/session-view.tsx
@@ -120,7 +120,7 @@ export default function SessionView({
- The participant list changed. Save the updated key file, and replace the copy in Teams.
+ The participant list changed. Save the updated key file, and replace your second copy with it.
diff --git a/app/components/participant/sort-workspace.tsx b/app/components/participant/sort-workspace.tsx
index cad3c8c..21d90aa 100644
--- a/app/components/participant/sort-workspace.tsx
+++ b/app/components/participant/sort-workspace.tsx
@@ -12,7 +12,6 @@ import { Content, Header } from "@jetbrains/ring-ui-built/components/island/isla
import ProgressBar from "@jetbrains/ring-ui-built/components/progress-bar/progress-bar";
import Text from "@jetbrains/ring-ui-built/components/text/text";
import { ConfirmDialog, ErrorMessage } from "@malverdesoftware/ui/controls";
-import { useBoardTools } from "@/lib/board-tools";
import { checkResponse, orderCards, type SortDefinition } from "@/lib/session";
import type { ResponseBody } from "@/lib/model";
import SortBoard, { count, type BoardView } from "./sort-board";
@@ -43,9 +42,6 @@ export default function SortWorkspace({ session, definition, initial, seed, onSu
const { state, saveNow } = useAutosave(session, body, !busy && !closed);
const editable = !busy && !closed && state !== "closed" && state !== "conflict";
const cards = useMemo(() => orderCards(definition.cards, definition.randomize, seed), [definition, seed]);
- const move = (card: string, pile: string | null) =>
- setBody((b) => ({ ...b, assignments: { ...b.assignments, [card]: pile } }));
- useBoardTools(definition.cards, body, editable, move);
const assigned = cards.filter((c) => body.assignments[c.id]).length;
async function submit() {
diff --git a/app/lib/board-tools.ts b/app/lib/board-tools.ts
deleted file mode 100644
index 8e0b321..0000000
--- a/app/lib/board-tools.ts
+++ /dev/null
@@ -1,112 +0,0 @@
-"use client";
-import { useEffect, useLayoutEffect, useRef } from "react";
-import { flushSync } from "react-dom";
-import type { Card, ResponseBody } from "./model";
-type Tool = {
- name: string;
- title: string;
- description: string;
- inputSchema: object;
- annotations: { readOnlyHint: boolean; untrustedContentHint: boolean };
- execute: (input: unknown) => unknown;
-};
-type Context = {
- registerTool: (
- tool: Tool,
- options: { signal: AbortSignal },
- ) => void | Promise;
-};
-export function useBoardTools(
- cards: Card[],
- body: ResponseBody,
- editable: boolean,
- onMove: (cardId: string, pileId: string | null) => void,
-) {
- const state = useRef({ cards, body, editable, onMove });
- useLayoutEffect(() => {
- state.current = { cards, body, editable, onMove };
- }, [cards, body, editable, onMove]);
- useEffect(() => {
- const context = (document as Document & { modelContext?: Context })
- .modelContext;
- if (!context?.registerTool) return;
- const lifecycle = new AbortController();
- const tools: Tool[] = [
- {
- name: "get_current_sort",
- title: "Read the current card sort",
- description:
- "Read the visible participant card set, piles, and assignments. Does not read other participants, access keys, or facilitator results.",
- inputSchema: {
- type: "object",
- properties: {},
- additionalProperties: false,
- },
- annotations: { readOnlyHint: true, untrustedContentHint: true },
- execute(input) {
- if (!input || typeof input !== "object" || Object.keys(input).length)
- throw new Error("Expected an empty object.");
- const s = state.current;
- return {
- cards: s.cards,
- piles: s.body.piles,
- assignments: s.body.assignments,
- editable: s.editable,
- };
- },
- },
- {
- name: "move_card_to_pile",
- title: "Move a card in this response",
- description:
- "Move one visible card into an existing participant-created pile, or leave it unsorted using null. Updates the same board as the menu and autosaves a draft. Does not submit the response.",
- inputSchema: {
- type: "object",
- properties: {
- cardId: { type: "string" },
- pileId: { type: ["string", "null"] },
- },
- required: ["cardId", "pileId"],
- additionalProperties: false,
- },
- annotations: { readOnlyHint: false, untrustedContentHint: false },
- execute(input) {
- if (!input || typeof input !== "object")
- throw new Error("Expected cardId and pileId.");
- const i = input as { cardId?: unknown; pileId?: unknown };
- if (
- Object.keys(i).some((k) => k !== "cardId" && k !== "pileId") ||
- typeof i.cardId !== "string" ||
- !(typeof i.pileId === "string" || i.pileId === null)
- )
- throw new Error("Expected cardId and a pileId or null.");
- const s = state.current;
- if (!s.editable)
- throw new Error("This response is not open for editing.");
- if (
- !s.cards.some((c) => c.id === i.cardId) ||
- !(i.pileId === null || s.body.piles.some((p) => p.id === i.pileId))
- )
- throw new Error("The card or pile is not on this board.");
- flushSync(() =>
- s.onMove(i.cardId as string, i.pileId as string | null),
- );
- return {
- cardId: i.cardId,
- pileId: i.pileId,
- saved: false,
- message: "Board updated; draft save is pending.",
- };
- },
- },
- ];
- for (const tool of tools) {
- try {
- void Promise.resolve(
- context.registerTool(tool, { signal: lifecycle.signal }),
- ).catch(() => {});
- } catch {}
- }
- return () => lifecycle.abort();
- }, []);
-}
diff --git a/app/tests/console-smoke.mjs b/app/tests/console-smoke.mjs
index 7d4e79d..78ce09b 100644
--- a/app/tests/console-smoke.mjs
+++ b/app/tests/console-smoke.mjs
@@ -51,7 +51,7 @@ try {
const savedKeyFile = await download(keyFile);
const file = parseSessionFile(readFileSync(savedKeyFile, "utf8"));
assert.deepEqual(file.participants.map((p) => p.label), ["Y01", "Y02"]);
- await tap("label", "Teams channel files");
+ await tap("label", "saved a second copy");
await type('input[type="password"]', creationKey);
await screenshot("console-prepared");
await tap("button", "Publish the session");
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 8027208..1b98c95 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -2,22 +2,23 @@
## Working in 0.1
-- Text cards, CSV import, and three configurable workflows
-- Separate links for each participant, with saved drafts and final submissions
+- Text cards, typed in or imported from a CSV, and one activity: each participant sorts the same set of cards on their own
+- A personal link and six-digit passcode for each participant, with saved drafts and one final submission
- Participant-defined piles, optional explanations, and unsorted cards
-- Contribution review and a fixed shared set of cards, with where each card came from
+- Responses sealed in the participant's browser, which only the researcher's key file can open; the site stores sealed data and deletes it when the session is deleted or expires
+- A researcher console that runs on the researcher's own computer: create a session, follow progress, add or remove participants, collect, close and delete
- CSV and JSON exports, a co-sorting matrix, a circular connection map, and average-linkage clustering
-- Local development, a standalone Cloudflare deployment setup, and Sites hosting
+- Local development and a standalone Cloudflare deployment setup
## Next
- Pilot with synthetic data and a small group of community members and researchers
+- Bring back the card-generation and collect-review-sort activities described in [methods](METHODS.md), with contribution review and a record of where each card came from
- Decide whether participants need to correct or withdraw a submission afterward
-- Add saved review drafts for facilitators, access-key rotation and recovery, and study deletion
+- Add key rotation and recovery
- Add optional verified invitation tokens and deployment-level abuse controls for large public use
- Try collaborative live sorting as its own mode, clearly separate from independent responses
- Try researcher-reviewed category crosswalks and optional multidimensional scaling, with documented stress diagnostics
- Try a multilingual interface, offline facilitation, images and audio, and more accessibility testing
-- Mint a DOI at the first public release and add it to NOTICE, CITATION.cff and the README (the license, funding acknowledgment and `CITATION.cff` were added 2026-11-12)
-None of these are in the current app.
+None of the items under Next are in the current app.