devaanand commited on
Commit
dc9da41
·
1 Parent(s): 5e68150

ui: three explicit construct paths + Save gating + visible AI drafts

Browse files

PI test feedback (2026-08-07):
- Step 2 hint enumerates the three acquisition paths; two buttons next to
the picker ('+ Your own items', 'Draft with AI') replace the generic
'+ Custom construct'
- Save construct is disabled until name + items exist, with an inline
line saying what's missing (the silent dead-click read as broken - the
error banner sits at the top of the page, out of view)
- After drafting, scroll/focus the items box and say 'N items drafted -
they're in the box below' (drafts landed off-screen unnoticed)
- /guide and MANUAL_TESTING.md updated to the new button names

MANUAL_TESTING.md CHANGED
@@ -72,14 +72,14 @@ with a "Sign in (top right)" message. Sign in and retry: accepted.
72
 
73
  ### Custom construct, typed
74
 
75
- 1. "+ Custom construct" > name it, paste items one per line.
76
  2. Append `(R)` to one line: the form shows "1 item(s) marked reverse-scored".
77
  3. Save: it appears in the picker under "My custom constructs"; run metadata
78
  will carry the reverse flag (check via Results > metadata download).
79
 
80
  ### Custom construct, from file (new)
81
 
82
- 1. "+ Custom construct" > "Upload items from CSV/XLSX".
83
  2. Try a CSV with `item,reverse` columns (1/true/yes/R = reverse) or a
84
  single-column file with `(R)` markers.
85
  3. Expect: items fill the textarea ((R) appended where flagged), the filename
@@ -94,7 +94,7 @@ Needs a generation key on the instance (`GROQ_API_KEY` or `ANTHROPIC_API_KEY`);
94
  without one the "Draft with AI" tab is hidden entirely. Signed-in only, 20/day
95
  (tune locally with `CCR_USER_MAX_GENERATIONS_PER_DAY=2` to test the cap fast).
96
 
97
- 1. Happy path: sign in > "+ Custom construct" > "Draft with AI" tab. Enter a
98
  name + a 1-2 sentence description, press "Draft items". Expect ~10
99
  first-person, positively-worded items in the textarea (no (R) items - by
100
  design), a "N of 20 used today" counter, the amber "AI-generated · not
@@ -261,3 +261,13 @@ documented at /product.
261
  5. Verification: mark a scale Verified; its "unverified" flag disappears
262
  platform-wide. Statuses are applied back to the library YAML before
263
  production.
 
 
 
 
 
 
 
 
 
 
 
72
 
73
  ### Custom construct, typed
74
 
75
+ 1. "+ Your own items" > name it, paste items one per line ("Type or paste" tab).
76
  2. Append `(R)` to one line: the form shows "1 item(s) marked reverse-scored".
77
  3. Save: it appears in the picker under "My custom constructs"; run metadata
78
  will carry the reverse flag (check via Results > metadata download).
79
 
80
  ### Custom construct, from file (new)
81
 
82
+ 1. "+ Your own items" > the "Upload CSV/XLSX" tab.
83
  2. Try a CSV with `item,reverse` columns (1/true/yes/R = reverse) or a
84
  single-column file with `(R)` markers.
85
  3. Expect: items fill the textarea ((R) appended where flagged), the filename
 
94
  without one the "Draft with AI" tab is hidden entirely. Signed-in only, 20/day
95
  (tune locally with `CCR_USER_MAX_GENERATIONS_PER_DAY=2` to test the cap fast).
96
 
97
+ 1. Happy path: sign in > " Draft with AI" (next to the picker). Enter a
98
  name + a 1-2 sentence description, press "Draft items". Expect ~10
99
  first-person, positively-worded items in the textarea (no (R) items - by
100
  design), a "N of 20 used today" counter, the amber "AI-generated · not
 
261
  5. Verification: mark a scale Verified; its "unverified" flag disappears
262
  platform-wide. Statuses are applied back to the library YAML before
263
  production.
264
+
265
+ ### Step 2 layout + Save gating (PI feedback 2026-08-07)
266
+
267
+ 1. The Step 2 hint enumerates the three paths (library / own items / AI);
268
+ two buttons sit next to the picker: "+ Your own items" and "✦ Draft with
269
+ AI" (the AI one only when a generation key is configured).
270
+ 2. Save is DISABLED until the form has a name and at least one item; a line
271
+ under the button says what's missing. No more silent dead click.
272
+ 3. After "Draft items", the page scrolls to the items box, and the notice
273
+ reads "N items drafted - they're in the box below."
backend/app/guide.html CHANGED
@@ -156,7 +156,7 @@ synthetic, written for this kit; no real participant data anywhere.</p>
156
  (IDs, conditions, demographics) are fine; they pass through untouched into the
157
  results file.</li>
158
  <li><b>Pick your construct(s)</b> - search the library (94 scales from the lab's
159
- collection) or click "+ Custom construct" and paste or upload your own items.
160
  Pick several (up to 10) to score them together in a single run and see how
161
  they correlate in your texts.</li>
162
  <li><b>Run.</b> Read the warnings panel first (it tells you if something about your
@@ -284,7 +284,7 @@ are deleted after analysis. Upload
284
 
285
  <h3>Custom construct, typed</h3>
286
  <ol>
287
- <li>"+ Custom construct" &gt; name it, paste items one per line.</li>
288
  <li>Append <code>(R)</code> to one line: the form shows "1 item(s) marked reverse-scored".</li>
289
  <li>Save: it appears in the picker under "My custom constructs"; run metadata carries
290
  the reverse flag (check via Results &gt; metadata download).</li>
@@ -292,7 +292,7 @@ are deleted after analysis. Upload
292
 
293
  <h3>Custom construct, from file</h3>
294
  <ol>
295
- <li>"+ Custom construct" &gt; the "Upload CSV/XLSX" tab.</li>
296
  <li>Try <a href="/samples/construct_items_demo.csv" download>construct_items_demo.csv</a>
297
  (<code>item,reverse</code> columns - 1/true/yes/R = reverse),
298
  <a href="/samples/construct_items_marker_demo.csv" download>construct_items_marker_demo.csv</a>
@@ -310,7 +310,7 @@ point, <b>not</b> a validated scale - you review, edit, and delete before saving
310
  everything the construct touches is labeled "AI-generated · not validated".
311
  Signed-in users only, 20 drafts/day.</p>
312
  <ol>
313
- <li><b>Happy path:</b> sign in &gt; "+ Custom construct" &gt; the "Draft with AI"
314
  tab. Name: <code>Digital overwhelm</code>. Description: <i>"Feeling that
315
  screens, notifications, and online demands exceed one's capacity to keep
316
  up."</i> Press "Draft items". Expect in a few seconds: ~10 first-person,
 
156
  (IDs, conditions, demographics) are fine; they pass through untouched into the
157
  results file.</li>
158
  <li><b>Pick your construct(s)</b> - search the library (94 scales from the lab's
159
+ collection), click "+ Your own items" to type or upload a scale, or "✦ Draft with AI" to generate items for a construct with no validated questionnaire.
160
  Pick several (up to 10) to score them together in a single run and see how
161
  they correlate in your texts.</li>
162
  <li><b>Run.</b> Read the warnings panel first (it tells you if something about your
 
284
 
285
  <h3>Custom construct, typed</h3>
286
  <ol>
287
+ <li>"+ Your own items" &gt; name it, paste items one per line ("Type or paste" tab).</li>
288
  <li>Append <code>(R)</code> to one line: the form shows "1 item(s) marked reverse-scored".</li>
289
  <li>Save: it appears in the picker under "My custom constructs"; run metadata carries
290
  the reverse flag (check via Results &gt; metadata download).</li>
 
292
 
293
  <h3>Custom construct, from file</h3>
294
  <ol>
295
+ <li>"+ Your own items" &gt; the "Upload CSV/XLSX" tab.</li>
296
  <li>Try <a href="/samples/construct_items_demo.csv" download>construct_items_demo.csv</a>
297
  (<code>item,reverse</code> columns - 1/true/yes/R = reverse),
298
  <a href="/samples/construct_items_marker_demo.csv" download>construct_items_marker_demo.csv</a>
 
310
  everything the construct touches is labeled "AI-generated · not validated".
311
  Signed-in users only, 20 drafts/day.</p>
312
  <ol>
313
+ <li><b>Happy path:</b> sign in &gt; " Draft with AI" (next to the picker) &gt; the "Draft with AI"
314
  tab. Name: <code>Digital overwhelm</code>. Description: <i>"Feeling that
315
  screens, notifications, and online demands exceed one's capacity to keep
316
  up."</i> Press "Draft items". Expect in a few seconds: ~10 first-person,
backend/static/assets/index-BJ_J7JUi.js DELETED
The diff for this file is too large to render. See raw diff
 
backend/static/assets/index-T4JgSqf6.js ADDED
The diff for this file is too large to render. See raw diff
 
backend/static/index.html CHANGED
@@ -5,7 +5,7 @@
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
  <link rel="icon" type="image/svg+xml" href="/ccr-logo.svg" />
7
  <title>CCR Platform - Contextualized Construct Representations</title>
8
- <script type="module" crossorigin src="/assets/index-BJ_J7JUi.js"></script>
9
  <link rel="stylesheet" crossorigin href="/assets/index-Ckv10U9Q.css">
10
  </head>
11
  <body>
 
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
  <link rel="icon" type="image/svg+xml" href="/ccr-logo.svg" />
7
  <title>CCR Platform - Contextualized Construct Representations</title>
8
+ <script type="module" crossorigin src="/assets/index-T4JgSqf6.js"></script>
9
  <link rel="stylesheet" crossorigin href="/assets/index-Ckv10U9Q.css">
10
  </head>
11
  <body>
frontend/src/Workspace.jsx CHANGED
@@ -18,7 +18,10 @@ export default function Workspace({ project, auth, onAuthRefresh, onProjectChang
18
  const [uploading, setUploading] = useState(false);
19
  const [running, setRunning] = useState(false);
20
  const [error, setError] = useState("");
21
- const [showNewConstruct, setShowNewConstruct] = useState(false);
 
 
 
22
  const [viewJobId, setViewJobId] = useState(null);
23
  const [confirmDelete, setConfirmDelete] = useState(false);
24
  const [deleteText, setDeleteText] = useState("");
@@ -303,10 +306,12 @@ export default function Workspace({ project, auth, onAuthRefresh, onProjectChang
303
  <span className="step-badge">2</span>Construct
304
  </h3>
305
  <p className="hint">
306
- Pick one or more validated scales from the library, or define custom items. CCR
307
- scores each text by its similarity to the items. Selecting several constructs
308
- runs them together on one pass over your corpus and adds a score-correlation
309
- view of how they interrelate in your texts (up to 10 per run).
 
 
310
  </p>
311
  <div className="construct-row">
312
  <div className="grow">
@@ -316,9 +321,22 @@ export default function Workspace({ project, auth, onAuthRefresh, onProjectChang
316
  onToggle={toggleConstruct}
317
  />
318
  </div>
319
- <button className="ghost" onClick={() => setShowNewConstruct((s) => !s)}>
320
- {showNewConstruct ? "Close" : "+ Custom construct"}
 
 
 
 
 
321
  </button>
 
 
 
 
 
 
 
 
322
  </div>
323
 
324
  {selectedConstructs.map((c) => (
@@ -373,15 +391,17 @@ export default function Workspace({ project, auth, onAuthRefresh, onProjectChang
373
  </details>
374
  ))}
375
 
376
- {showNewConstruct && (
377
  <NewConstructForm
378
  auth={auth}
379
  constructs={constructs}
 
 
380
  onCreated={async (created) => {
381
  const list = await api.listConstructs();
382
  setConstructs(list);
383
  toggleConstruct(created.id);
384
- setShowNewConstruct(false);
385
  }}
386
  onError={setError}
387
  />
@@ -512,7 +532,7 @@ export default function Workspace({ project, auth, onAuthRefresh, onProjectChang
512
 
513
  const REVERSE_SUFFIX = /\s*\((r|rev|reversed)\)\s*$/i;
514
 
515
- function NewConstructForm({ auth, constructs, onCreated, onError }) {
516
  const [name, setName] = useState("");
517
  const [reference, setReference] = useState("");
518
  const [description, setDescription] = useState("");
@@ -520,9 +540,6 @@ function NewConstructForm({ auth, constructs, onCreated, onError }) {
520
  const [saving, setSaving] = useState(false);
521
  const [parsing, setParsing] = useState(false);
522
  const [parseNotes, setParseNotes] = useState([]);
523
- // Item source chooser: "type" | "upload" | "ai" - one source's controls
524
- // visible at a time (the flat everything-stacked form read as clutter).
525
- const [source, setSource] = useState("type");
526
  // AI drafting (ITEM_GENERATION.md): genInfo is the provenance stamp echoed
527
  // back on save; it survives manual edits (the seed was AI) but is cleared
528
  // when items come from a file upload instead.
@@ -532,6 +549,8 @@ function NewConstructForm({ auth, constructs, onCreated, onError }) {
532
  const [genNotes, setGenNotes] = useState("");
533
  const [genUsage, setGenUsage] = useState(null);
534
  const itemFileRef = useRef(null);
 
 
535
 
536
  // Simple library name-match (v1 guardrail, PI-approved): if a validated
537
  // scale with a similar name exists, say so before anyone generates.
@@ -563,12 +582,19 @@ function NewConstructForm({ auth, constructs, onCreated, onError }) {
563
  n_items: nItems,
564
  });
565
  setItemsText(resp.items.join("\n"));
566
- setGenInfo(resp.generation);
567
  setGenNotes(resp.notes || "");
568
  setGenUsage({
569
  used: resp.generations_used_today,
570
  max: resp.max_generations_per_day,
571
  });
 
 
 
 
 
 
 
572
  } catch (err) {
573
  onError(err.message);
574
  } finally {
@@ -628,7 +654,14 @@ function NewConstructForm({ auth, constructs, onCreated, onError }) {
628
  description: description.trim(),
629
  items: parsed.map((i) => i.text),
630
  reverse_scored: parsed.map((i) => i.reverse),
631
- generation: genInfo || undefined, // provenance stamp when AI-drafted
 
 
 
 
 
 
 
632
  });
633
  onCreated(created);
634
  } catch (err) {
@@ -759,9 +792,10 @@ function NewConstructForm({ auth, constructs, onCreated, onError }) {
759
  </p>
760
  )}
761
  {genInfo && (
762
- <p className="small muted">
763
- AI-generated · not validated - drafted by {genInfo.model}. Review and
764
- edit below before saving; the construct will be labeled as AI-generated.
 
765
  </p>
766
  )}
767
  {genNotes && <p className="small muted">Model notes: {genNotes}</p>}
@@ -771,14 +805,37 @@ function NewConstructForm({ auth, constructs, onCreated, onError }) {
771
  <label className="field">
772
  Items - one per line{source === "type" ? " (or paste a whole scale)" : ""};
773
  append (R) to mark a reverse-scored item
774
- <textarea rows={6} value={itemsText} onChange={(e) => setItemsText(e.target.value)} />
 
 
 
 
 
775
  </label>
776
  {nReverse > 0 && (
777
  <p className="small muted">{nReverse} item(s) marked reverse-scored.</p>
778
  )}
779
- <button className="primary" type="submit" disabled={saving || parsing}>
 
 
 
 
 
 
 
780
  {saving ? "Saving…" : "Save construct"}
781
  </button>
 
 
 
 
 
 
 
 
 
 
 
782
  </form>
783
  );
784
  }
 
18
  const [uploading, setUploading] = useState(false);
19
  const [running, setRunning] = useState(false);
20
  const [error, setError] = useState("");
21
+ // Custom-construct form: null = closed, else the item source it opens on
22
+ // ("type" | "upload" | "ai"). Lifted here so the Step 2 card can expose the
23
+ // three acquisition paths as explicit buttons (PI feedback 2026-08-07).
24
+ const [constructFormTab, setConstructFormTab] = useState(null);
25
  const [viewJobId, setViewJobId] = useState(null);
26
  const [confirmDelete, setConfirmDelete] = useState(false);
27
  const [deleteText, setDeleteText] = useState("");
 
306
  <span className="step-badge">2</span>Construct
307
  </h3>
308
  <p className="hint">
309
+ Three ways to add a construct: <b>1)</b> pick validated scales from our
310
+ library of {constructs.filter((c) => c.is_seed).length || "90+"} questionnaires,{" "}
311
+ <b>2)</b> type or upload your own items, or <b>3)</b> draft items with AI from
312
+ a name and description (when no validated scale exists). CCR scores each text
313
+ by its similarity to the items; selecting several constructs runs them
314
+ together on one pass and adds a score-correlation view (up to 10 per run).
315
  </p>
316
  <div className="construct-row">
317
  <div className="grow">
 
321
  onToggle={toggleConstruct}
322
  />
323
  </div>
324
+ <button
325
+ className={"ghost" + (constructFormTab && constructFormTab !== "ai" ? " seg-active" : "")}
326
+ onClick={() =>
327
+ setConstructFormTab((t) => (t && t !== "ai" ? null : "type"))
328
+ }
329
+ >
330
+ {constructFormTab && constructFormTab !== "ai" ? "Close" : "+ Your own items"}
331
  </button>
332
+ {auth?.generation_available && (
333
+ <button
334
+ className={"ghost" + (constructFormTab === "ai" ? " seg-active" : "")}
335
+ onClick={() => setConstructFormTab((t) => (t === "ai" ? null : "ai"))}
336
+ >
337
+ {constructFormTab === "ai" ? "Close" : "✦ Draft with AI"}
338
+ </button>
339
+ )}
340
  </div>
341
 
342
  {selectedConstructs.map((c) => (
 
391
  </details>
392
  ))}
393
 
394
+ {constructFormTab && (
395
  <NewConstructForm
396
  auth={auth}
397
  constructs={constructs}
398
+ source={constructFormTab}
399
+ onSourceChange={setConstructFormTab}
400
  onCreated={async (created) => {
401
  const list = await api.listConstructs();
402
  setConstructs(list);
403
  toggleConstruct(created.id);
404
+ setConstructFormTab(null);
405
  }}
406
  onError={setError}
407
  />
 
532
 
533
  const REVERSE_SUFFIX = /\s*\((r|rev|reversed)\)\s*$/i;
534
 
535
+ function NewConstructForm({ auth, constructs, source, onSourceChange, onCreated, onError }) {
536
  const [name, setName] = useState("");
537
  const [reference, setReference] = useState("");
538
  const [description, setDescription] = useState("");
 
540
  const [saving, setSaving] = useState(false);
541
  const [parsing, setParsing] = useState(false);
542
  const [parseNotes, setParseNotes] = useState([]);
 
 
 
543
  // AI drafting (ITEM_GENERATION.md): genInfo is the provenance stamp echoed
544
  // back on save; it survives manual edits (the seed was AI) but is cleared
545
  // when items come from a file upload instead.
 
549
  const [genNotes, setGenNotes] = useState("");
550
  const [genUsage, setGenUsage] = useState(null);
551
  const itemFileRef = useRef(null);
552
+ const itemsRef = useRef(null);
553
+ const setSource = onSourceChange;
554
 
555
  // Simple library name-match (v1 guardrail, PI-approved): if a validated
556
  // scale with a similar name exists, say so before anyone generates.
 
582
  n_items: nItems,
583
  });
584
  setItemsText(resp.items.join("\n"));
585
+ setGenInfo({ ...resp.generation, n: resp.items.length });
586
  setGenNotes(resp.notes || "");
587
  setGenUsage({
588
  used: resp.generations_used_today,
589
  max: resp.max_generations_per_day,
590
  });
591
+ // The drafted items land in the textarea below the controls - scroll
592
+ // there so it's obvious they arrived (PI feedback: "how can they see
593
+ // all 10 items generated under the hood?").
594
+ setTimeout(() => {
595
+ itemsRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
596
+ itemsRef.current?.focus({ preventScroll: true });
597
+ }, 50);
598
  } catch (err) {
599
  onError(err.message);
600
  } finally {
 
654
  description: description.trim(),
655
  items: parsed.map((i) => i.text),
656
  reverse_scored: parsed.map((i) => i.reverse),
657
+ // provenance stamp when AI-drafted (schema fields only)
658
+ generation: genInfo
659
+ ? {
660
+ model: genInfo.model,
661
+ prompt_version: genInfo.prompt_version,
662
+ generated_at: genInfo.generated_at,
663
+ }
664
+ : undefined,
665
  });
666
  onCreated(created);
667
  } catch (err) {
 
792
  </p>
793
  )}
794
  {genInfo && (
795
+ <p className="small">
796
+ <b>{genInfo.n} items drafted they're in the box below.</b> Review and
797
+ edit each one, then Save. AI-generated · not validated (model:{" "}
798
+ {genInfo.model}); the saved construct will carry that label.
799
  </p>
800
  )}
801
  {genNotes && <p className="small muted">Model notes: {genNotes}</p>}
 
805
  <label className="field">
806
  Items - one per line{source === "type" ? " (or paste a whole scale)" : ""};
807
  append (R) to mark a reverse-scored item
808
+ <textarea
809
+ ref={itemsRef}
810
+ rows={6}
811
+ value={itemsText}
812
+ onChange={(e) => setItemsText(e.target.value)}
813
+ />
814
  </label>
815
  {nReverse > 0 && (
816
  <p className="small muted">{nReverse} item(s) marked reverse-scored.</p>
817
  )}
818
+ {/* Save stays disabled until it can actually succeed - a clickable
819
+ button that silently fails read as broken (PI feedback 2026-08-07;
820
+ the error banner sits at the top of the page, out of view here). */}
821
+ <button
822
+ className="primary"
823
+ type="submit"
824
+ disabled={saving || parsing || !name.trim() || parseLines().length === 0}
825
+ >
826
  {saving ? "Saving…" : "Save construct"}
827
  </button>
828
+ {(!name.trim() || parseLines().length === 0) && (
829
+ <p className="small muted">
830
+ To save, this construct still needs
831
+ {!name.trim() ? " a name" : ""}
832
+ {!name.trim() && parseLines().length === 0 ? " and" : ""}
833
+ {parseLines().length === 0
834
+ ? " at least one item - type items above, upload a file, or draft with AI"
835
+ : ""}
836
+ .
837
+ </p>
838
+ )}
839
  </form>
840
  );
841
  }