From 8457323fe0566357f660a15e144573fa77dbfbc8 Mon Sep 17 00:00:00 2001
From: Aaron Steven White
Date: Sat, 7 Feb 2026 13:18:10 -0500
Subject: [PATCH 01/11] Adds span highlighting and tokenization.
---
bead/deployment/jspsych/config.py | 50 ++
bead/deployment/jspsych/generator.py | 111 +++-
bead/deployment/jspsych/package.json | 2 +-
.../jspsych/src/lib/span-renderer.test.ts | 373 ++++++++++++
.../jspsych/src/lib/span-renderer.ts | 296 ++++++++++
.../jspsych/src/lib/wikidata-search.ts | 124 ++++
.../jspsych/src/plugins/cloze-dropdown.ts | 2 +-
.../jspsych/src/plugins/forced-choice.ts | 2 +-
.../jspsych/src/plugins/plugins.test.ts | 44 ++
bead/deployment/jspsych/src/plugins/rating.ts | 2 +-
.../jspsych/src/plugins/span-label.ts | 554 ++++++++++++++++++
.../jspsych/templates/experiment.css | 43 ++
bead/deployment/jspsych/templates/index.html | 9 +
bead/deployment/jspsych/trials.py | 404 ++++++++++++-
bead/deployment/jspsych/tsup.config.ts | 4 +
bead/items/__init__.py | 19 +
bead/items/item.py | 76 ++-
bead/items/item_template.py | 11 +
bead/items/span_labeling.py | 418 +++++++++++++
bead/items/spans.py | 407 +++++++++++++
bead/tokenization/__init__.py | 32 +
bead/tokenization/alignment.py | 105 ++++
bead/tokenization/config.py | 45 ++
bead/tokenization/tokenizers.py | 360 ++++++++++++
docs/api/items.md | 16 +-
docs/api/tokenization.md | 24 +
docs/user-guide/api/deployment.md | 74 +++
docs/user-guide/api/items.md | 156 ++++-
mkdocs.yml | 1 +
pyproject.toml | 6 +-
tests/deployment/jspsych/test_span_trials.py | 356 +++++++++++
tests/items/test_span_labeling.py | 315 ++++++++++
tests/items/test_spans.py | 403 +++++++++++++
tests/tokenization/__init__.py | 0
tests/tokenization/test_tokenizers.py | 199 +++++++
35 files changed, 5024 insertions(+), 19 deletions(-)
create mode 100644 bead/deployment/jspsych/src/lib/span-renderer.test.ts
create mode 100644 bead/deployment/jspsych/src/lib/span-renderer.ts
create mode 100644 bead/deployment/jspsych/src/lib/wikidata-search.ts
create mode 100644 bead/deployment/jspsych/src/plugins/span-label.ts
create mode 100644 bead/items/span_labeling.py
create mode 100644 bead/items/spans.py
create mode 100644 bead/tokenization/__init__.py
create mode 100644 bead/tokenization/alignment.py
create mode 100644 bead/tokenization/config.py
create mode 100644 bead/tokenization/tokenizers.py
create mode 100644 docs/api/tokenization.md
create mode 100644 tests/deployment/jspsych/test_span_trials.py
create mode 100644 tests/items/test_span_labeling.py
create mode 100644 tests/items/test_spans.py
create mode 100644 tests/tokenization/__init__.py
create mode 100644 tests/tokenization/test_tokenizers.py
diff --git a/bead/deployment/jspsych/config.py b/bead/deployment/jspsych/config.py
index f8ccd5f..88af49e 100644
--- a/bead/deployment/jspsych/config.py
+++ b/bead/deployment/jspsych/config.py
@@ -20,6 +20,7 @@
"slider_rating",
"binary_choice",
"forced_choice",
+ "span_labeling",
]
# Type alias for UI themes
@@ -37,6 +38,51 @@ def _empty_instruction_pages() -> list[InstructionPage]:
return []
+def _default_span_color_palette() -> list[str]:
+ """Return default span highlight color palette."""
+ return [
+ "#BBDEFB",
+ "#C8E6C9",
+ "#FFE0B2",
+ "#F8BBD0",
+ "#D1C4E9",
+ "#B2EBF2",
+ "#DCEDC8",
+ "#FFD54F",
+ ]
+
+
+class SpanDisplayConfig(BaseModel):
+ """Visual configuration for span rendering in experiments.
+
+ Attributes
+ ----------
+ highlight_style : Literal["background", "underline", "border"]
+ How to visually indicate spans.
+ color_palette : list[str]
+ CSS color values for span highlighting.
+ show_labels : bool
+ Whether to show span labels inline.
+ show_tooltips : bool
+ Whether to show tooltips on hover.
+ token_delimiter : str
+ Delimiter between tokens in display.
+ label_position : Literal["inline", "below", "tooltip"]
+ Where to display span labels.
+ """
+
+ model_config = ConfigDict(extra="forbid", frozen=True)
+
+ highlight_style: Literal["background", "underline", "border"] = "background"
+ color_palette: list[str] = Field(
+ default_factory=_default_span_color_palette
+ )
+ show_labels: bool = True
+ show_tooltips: bool = True
+ token_delimiter: str = " "
+ label_position: Literal["inline", "below", "tooltip"] = "inline"
+
+
class DemographicsFieldConfig(BaseModel):
"""Configuration for a single demographics form field.
@@ -333,6 +379,10 @@ class ExperimentConfig(BaseModel):
default_factory=SlopitIntegrationConfig,
description="Slopit behavioral capture integration (opt-in, disabled)",
)
+ span_display: SpanDisplayConfig | None = Field(
+ default=None,
+ description="Span display config (auto-enabled when items have spans)",
+ )
class RatingScaleConfig(BaseModel):
diff --git a/bead/deployment/jspsych/generator.py b/bead/deployment/jspsych/generator.py
index 8e52d62..aa4232d 100644
--- a/bead/deployment/jspsych/generator.py
+++ b/bead/deployment/jspsych/generator.py
@@ -193,8 +193,12 @@ def generate(
self._write_distribution_config()
self._write_trials_json(lists, items, templates)
+ # Detect span usage for HTML template
+ span_enabled = self._detect_span_usage(items, templates)
+ span_wikidata = self._detect_wikidata_usage(templates)
+
# Generate HTML/CSS/JS files
- self._generate_html()
+ self._generate_html(span_enabled, span_wikidata)
self._generate_css()
self._generate_experiment_script()
self._generate_config_file()
@@ -204,6 +208,10 @@ def generate(
if self.config.slopit.enabled:
self._copy_slopit_bundle()
+ # Copy span plugin scripts if needed
+ if span_enabled:
+ self._copy_span_plugin_scripts(span_wikidata)
+
return self.output_dir
def _validate_item_references(
@@ -427,9 +435,15 @@ def _create_directory_structure(self) -> None:
self.output_dir.mkdir(parents=True, exist_ok=True)
(self.output_dir / "css").mkdir(exist_ok=True)
(self.output_dir / "js").mkdir(exist_ok=True)
+ (self.output_dir / "js" / "plugins").mkdir(parents=True, exist_ok=True)
+ (self.output_dir / "js" / "lib").mkdir(parents=True, exist_ok=True)
(self.output_dir / "data").mkdir(exist_ok=True)
- def _generate_html(self) -> None:
+ def _generate_html(
+ self,
+ span_enabled: bool = False,
+ span_wikidata: bool = False,
+ ) -> None:
"""Generate index.html file."""
template = self.jinja_env.get_template("index.html")
@@ -438,6 +452,8 @@ def _generate_html(self) -> None:
ui_theme=self.config.ui_theme,
use_jatos=self.config.use_jatos,
slopit_enabled=self.config.slopit.enabled,
+ span_enabled=span_enabled,
+ span_wikidata=span_wikidata,
)
output_file = self.output_dir / "index.html"
@@ -596,3 +612,94 @@ def _copy_slopit_bundle(self) -> None:
f"Failed to copy slopit bundle to {output_path}: {e}. "
f"Check write permissions."
) from e
+
+ def _detect_span_usage(
+ self,
+ items: dict[UUID, Item],
+ templates: dict[UUID, ItemTemplate],
+ ) -> bool:
+ """Detect whether any items or templates use span features.
+
+ Parameters
+ ----------
+ items : dict[UUID, Item]
+ Items dictionary.
+ templates : dict[UUID, ItemTemplate]
+ Templates dictionary.
+
+ Returns
+ -------
+ bool
+ True if spans are used.
+ """
+ # Check experiment type
+ if self.config.experiment_type == "span_labeling":
+ return True
+
+ # Check items for span data
+ for item in items.values():
+ if item.spans or item.tokenized_elements:
+ return True
+
+ # Check templates for span_spec
+ for template in templates.values():
+ if template.task_spec.span_spec is not None:
+ return True
+
+ return False
+
+ def _detect_wikidata_usage(
+ self,
+ templates: dict[UUID, ItemTemplate],
+ ) -> bool:
+ """Detect whether any templates use Wikidata label source.
+
+ Parameters
+ ----------
+ templates : dict[UUID, ItemTemplate]
+ Templates dictionary.
+
+ Returns
+ -------
+ bool
+ True if Wikidata is used.
+ """
+ for template in templates.values():
+ if template.task_spec.span_spec is not None:
+ spec = template.task_spec.span_spec
+ if spec.label_source == "wikidata":
+ return True
+ if spec.relation_label_source == "wikidata":
+ return True
+ return False
+
+ def _copy_span_plugin_scripts(self, include_wikidata: bool = False) -> None:
+ """Copy span plugin scripts from compiled dist/ to js/ directory.
+
+ Parameters
+ ----------
+ include_wikidata : bool
+ Whether to include the Wikidata search script.
+ """
+ dist_dir = Path(__file__).parent / "dist"
+
+ # Create subdirectories
+ (self.output_dir / "js" / "plugins").mkdir(parents=True, exist_ok=True)
+ (self.output_dir / "js" / "lib").mkdir(parents=True, exist_ok=True)
+
+ scripts = [
+ ("plugins/span-label.js", "js/plugins/span-label.js"),
+ ("lib/span-renderer.js", "js/lib/span-renderer.js"),
+ ]
+
+ if include_wikidata:
+ scripts.append(
+ ("lib/wikidata-search.js", "js/lib/wikidata-search.js")
+ )
+
+ for src_name, dest_name in scripts:
+ src_path = dist_dir / src_name
+ dest_path = self.output_dir / dest_name
+ if src_path.exists():
+ dest_path.write_text(src_path.read_text())
+ # Silently skip if not built yet (TypeScript may not be compiled)
diff --git a/bead/deployment/jspsych/package.json b/bead/deployment/jspsych/package.json
index 1523da5..0286eaf 100644
--- a/bead/deployment/jspsych/package.json
+++ b/bead/deployment/jspsych/package.json
@@ -1,6 +1,6 @@
{
"name": "@bead/jspsych-deployment",
- "version": "0.1.0",
+ "version": "0.2.0",
"description": "TypeScript plugins and utilities for bead jsPsych experiment deployment",
"private": true,
"type": "module",
diff --git a/bead/deployment/jspsych/src/lib/span-renderer.test.ts b/bead/deployment/jspsych/src/lib/span-renderer.test.ts
new file mode 100644
index 0000000..cdb80f6
--- /dev/null
+++ b/bead/deployment/jspsych/src/lib/span-renderer.test.ts
@@ -0,0 +1,373 @@
+/**
+ * Unit tests for span-renderer.ts
+ *
+ * Tests token-span mapping, color assignment, and rendering utilities.
+ *
+ * @vitest-environment jsdom
+ */
+
+import { describe, expect, test } from "vitest";
+import {
+ type SpanData,
+ type SpanDisplayConfig,
+ assignSpanColors,
+ computeTokenSpanMap,
+ renderTokenizedText,
+ renderRelationArcs,
+} from "./span-renderer.js";
+
+const DEFAULT_CONFIG: SpanDisplayConfig = {
+ highlight_style: "background",
+ color_palette: ["#BBDEFB", "#C8E6C9", "#FFE0B2", "#F8BBD0"],
+ show_labels: true,
+ show_tooltips: true,
+ token_delimiter: " ",
+ label_position: "inline",
+};
+
+describe("computeTokenSpanMap", () => {
+ test("returns empty lists for tokens with no spans", () => {
+ const tokens = ["The", "cat", "sat"];
+ const map = computeTokenSpanMap(tokens, []);
+
+ expect(map.get(0)).toEqual([]);
+ expect(map.get(1)).toEqual([]);
+ expect(map.get(2)).toEqual([]);
+ });
+
+ test("maps single span to covered tokens", () => {
+ const tokens = ["The", "cat", "sat"];
+ const spans: SpanData[] = [
+ {
+ span_id: "span_0",
+ segments: [{ element_name: "text", indices: [0, 1] }],
+ },
+ ];
+
+ const map = computeTokenSpanMap(tokens, spans);
+
+ expect(map.get(0)).toEqual(["span_0"]);
+ expect(map.get(1)).toEqual(["span_0"]);
+ expect(map.get(2)).toEqual([]);
+ });
+
+ test("handles overlapping spans", () => {
+ const tokens = ["The", "big", "cat"];
+ const spans: SpanData[] = [
+ {
+ span_id: "span_0",
+ segments: [{ element_name: "text", indices: [0, 1] }],
+ },
+ {
+ span_id: "span_1",
+ segments: [{ element_name: "text", indices: [1, 2] }],
+ },
+ ];
+
+ const map = computeTokenSpanMap(tokens, spans);
+
+ expect(map.get(0)).toEqual(["span_0"]);
+ expect(map.get(1)).toEqual(["span_0", "span_1"]);
+ expect(map.get(2)).toEqual(["span_1"]);
+ });
+
+ test("filters by element name", () => {
+ const tokens = ["Hello"];
+ const spans: SpanData[] = [
+ {
+ span_id: "span_0",
+ segments: [{ element_name: "context", indices: [0] }],
+ },
+ ];
+
+ const map = computeTokenSpanMap(tokens, spans, "text");
+ expect(map.get(0)).toEqual([]);
+ });
+
+ test("ignores out-of-bounds indices", () => {
+ const tokens = ["Hello"];
+ const spans: SpanData[] = [
+ {
+ span_id: "span_0",
+ segments: [{ element_name: "text", indices: [0, 99] }],
+ },
+ ];
+
+ const map = computeTokenSpanMap(tokens, spans);
+ expect(map.get(0)).toEqual(["span_0"]);
+ expect(map.has(99)).toBe(false);
+ });
+});
+
+describe("assignSpanColors", () => {
+ test("assigns colors from palette", () => {
+ const spans: SpanData[] = [
+ { span_id: "span_0", segments: [], label: { label: "Person" } },
+ { span_id: "span_1", segments: [], label: { label: "Location" } },
+ ];
+
+ const colors = assignSpanColors(spans, ["#FF0000", "#00FF00"]);
+
+ expect(colors.get("span_0")).toBe("#FF0000");
+ expect(colors.get("span_1")).toBe("#00FF00");
+ });
+
+ test("reuses color for same label", () => {
+ const spans: SpanData[] = [
+ { span_id: "span_0", segments: [], label: { label: "Person" } },
+ { span_id: "span_1", segments: [], label: { label: "Person" } },
+ ];
+
+ const colors = assignSpanColors(spans, ["#FF0000", "#00FF00"]);
+
+ expect(colors.get("span_0")).toBe("#FF0000");
+ expect(colors.get("span_1")).toBe("#FF0000");
+ });
+
+ test("cycles palette for many labels", () => {
+ const spans: SpanData[] = [
+ { span_id: "s0", segments: [], label: { label: "A" } },
+ { span_id: "s1", segments: [], label: { label: "B" } },
+ { span_id: "s2", segments: [], label: { label: "C" } },
+ ];
+
+ const colors = assignSpanColors(spans, ["#FF0000", "#00FF00"]);
+
+ expect(colors.get("s0")).toBe("#FF0000");
+ expect(colors.get("s1")).toBe("#00FF00");
+ expect(colors.get("s2")).toBe("#FF0000"); // cycles
+ });
+
+ test("respects explicit label color overrides", () => {
+ const spans: SpanData[] = [
+ { span_id: "span_0", segments: [], label: { label: "Person" } },
+ { span_id: "span_1", segments: [], label: { label: "Location" } },
+ ];
+
+ const colors = assignSpanColors(
+ spans,
+ ["#000000"],
+ { "Person": "#CUSTOM1" },
+ );
+
+ expect(colors.get("span_0")).toBe("#CUSTOM1");
+ expect(colors.get("span_1")).toBe("#000000"); // from palette
+ });
+
+ test("handles spans without labels", () => {
+ const spans: SpanData[] = [
+ { span_id: "span_0", segments: [] },
+ { span_id: "span_1", segments: [] },
+ ];
+
+ const colors = assignSpanColors(spans, ["#FF0000", "#00FF00"]);
+
+ expect(colors.get("span_0")).toBe("#FF0000");
+ expect(colors.get("span_1")).toBe("#00FF00");
+ });
+});
+
+describe("renderTokenizedText", () => {
+ test("renders tokens as span elements", () => {
+ const el = renderTokenizedText(
+ ["Hello", "world"],
+ [true, false],
+ [],
+ DEFAULT_CONFIG,
+ );
+
+ const tokens = el.querySelectorAll(".bead-token");
+ expect(tokens).toHaveLength(2);
+ expect(tokens[0]?.textContent).toBe("Hello");
+ expect(tokens[1]?.textContent).toBe("world");
+ });
+
+ test("adds space after tokens with space_after=true", () => {
+ const el = renderTokenizedText(
+ ["Hello", "world"],
+ [true, false],
+ [],
+ DEFAULT_CONFIG,
+ );
+
+ // Container should have: Hello " " world
+ const textContent = el.textContent;
+ expect(textContent).toContain("Hello");
+ expect(textContent).toContain("world");
+ });
+
+ test("marks highlighted tokens with span data", () => {
+ const spans: SpanData[] = [
+ {
+ span_id: "span_0",
+ segments: [{ element_name: "text", indices: [0] }],
+ label: { label: "Person" },
+ },
+ ];
+
+ const el = renderTokenizedText(
+ ["John", "sat"],
+ [true, false],
+ spans,
+ DEFAULT_CONFIG,
+ );
+
+ const highlighted = el.querySelectorAll(".highlighted");
+ expect(highlighted).toHaveLength(1);
+ expect(highlighted[0]?.getAttribute("data-span-ids")).toBe("span_0");
+ expect(highlighted[0]?.getAttribute("data-span-count")).toBe("1");
+ });
+
+ test("sets tooltip on highlighted tokens", () => {
+ const spans: SpanData[] = [
+ {
+ span_id: "span_0",
+ segments: [{ element_name: "text", indices: [0] }],
+ label: { label: "Person" },
+ },
+ ];
+
+ const el = renderTokenizedText(
+ ["John"],
+ [false],
+ spans,
+ DEFAULT_CONFIG,
+ );
+
+ const token = el.querySelector(".bead-token");
+ expect(token?.getAttribute("title")).toBe("Person");
+ });
+
+ test("sets data-index on each token", () => {
+ const el = renderTokenizedText(
+ ["a", "b", "c"],
+ [true, true, false],
+ [],
+ DEFAULT_CONFIG,
+ );
+
+ const tokens = el.querySelectorAll(".bead-token");
+ expect(tokens[0]?.getAttribute("data-index")).toBe("0");
+ expect(tokens[1]?.getAttribute("data-index")).toBe("1");
+ expect(tokens[2]?.getAttribute("data-index")).toBe("2");
+ });
+
+ test("does not add space between tokens with space_after=false", () => {
+ const el = renderTokenizedText(
+ ["don", "'t"],
+ [false, false],
+ [],
+ DEFAULT_CONFIG,
+ );
+
+ // Should be "don't" with no space
+ const spans = el.querySelectorAll(".bead-token");
+ expect(spans).toHaveLength(2);
+ // No text node between them
+ const firstToken = spans[0];
+ const nextSibling = firstToken?.nextSibling;
+ expect(nextSibling?.nodeName).toBe("SPAN"); // directly adjacent
+ });
+});
+
+describe("renderRelationArcs", () => {
+ test("creates SVG element", () => {
+ const svg = renderRelationArcs([], new Map(), DEFAULT_CONFIG);
+
+ expect(svg.tagName).toBe("svg");
+ expect(svg.classList.contains("bead-relation-layer")).toBe(true);
+ });
+
+ test("includes arrowhead marker definition", () => {
+ const svg = renderRelationArcs([], new Map(), DEFAULT_CONFIG);
+
+ const marker = svg.querySelector("#arrowhead");
+ expect(marker).not.toBeNull();
+ });
+
+ test("renders directed relation with marker-end", () => {
+ const positions = new Map();
+ positions.set("span_0", new DOMRect(10, 50, 40, 20));
+ positions.set("span_1", new DOMRect(100, 50, 40, 20));
+
+ const relations = [
+ {
+ relation_id: "rel_0",
+ source_span_id: "span_0",
+ target_span_id: "span_1",
+ label: { label: "agent-of" },
+ directed: true,
+ },
+ ];
+
+ const svg = renderRelationArcs(relations, positions, DEFAULT_CONFIG);
+
+ const path = svg.querySelector("path");
+ expect(path).not.toBeNull();
+ expect(path?.classList.contains("directed")).toBe(true);
+ expect(path?.getAttribute("marker-end")).toBe("url(#arrowhead)");
+ });
+
+ test("renders undirected relation without marker", () => {
+ const positions = new Map();
+ positions.set("span_0", new DOMRect(10, 50, 40, 20));
+ positions.set("span_1", new DOMRect(100, 50, 40, 20));
+
+ const relations = [
+ {
+ relation_id: "rel_0",
+ source_span_id: "span_0",
+ target_span_id: "span_1",
+ directed: false,
+ },
+ ];
+
+ const svg = renderRelationArcs(relations, positions, DEFAULT_CONFIG);
+
+ const path = svg.querySelector("path");
+ expect(path).not.toBeNull();
+ expect(path?.classList.contains("directed")).toBe(false);
+ expect(path?.getAttribute("marker-end")).toBeNull();
+ });
+
+ test("renders relation label text", () => {
+ const positions = new Map();
+ positions.set("span_0", new DOMRect(10, 50, 40, 20));
+ positions.set("span_1", new DOMRect(100, 50, 40, 20));
+
+ const relations = [
+ {
+ relation_id: "rel_0",
+ source_span_id: "span_0",
+ target_span_id: "span_1",
+ label: { label: "agent-of" },
+ directed: true,
+ },
+ ];
+
+ const svg = renderRelationArcs(relations, positions, DEFAULT_CONFIG);
+
+ const text = svg.querySelector("text");
+ expect(text?.textContent).toBe("agent-of");
+ });
+
+ test("skips relations with missing span positions", () => {
+ const positions = new Map();
+ positions.set("span_0", new DOMRect(10, 50, 40, 20));
+ // span_1 missing
+
+ const relations = [
+ {
+ relation_id: "rel_0",
+ source_span_id: "span_0",
+ target_span_id: "span_1",
+ directed: true,
+ },
+ ];
+
+ const svg = renderRelationArcs(relations, positions, DEFAULT_CONFIG);
+
+ const paths = svg.querySelectorAll("path");
+ expect(paths).toHaveLength(0);
+ });
+});
diff --git a/bead/deployment/jspsych/src/lib/span-renderer.ts b/bead/deployment/jspsych/src/lib/span-renderer.ts
new file mode 100644
index 0000000..5e86c2b
--- /dev/null
+++ b/bead/deployment/jspsych/src/lib/span-renderer.ts
@@ -0,0 +1,296 @@
+/**
+ * Shared span rendering utilities.
+ *
+ * Provides functions for rendering tokenized text with span highlights,
+ * assigning colors, computing token-span maps, and rendering relation arcs.
+ *
+ * @author Bead Project
+ * @version 0.2.0
+ */
+
+/** Span data structure (matches Python Span model serialization) */
+export interface SpanData {
+ span_id: string;
+ segments: Array<{
+ element_name: string;
+ indices: number[];
+ }>;
+ head_index?: number;
+ label?: {
+ label: string;
+ label_id?: string;
+ };
+ span_type?: string;
+}
+
+/** Relation data structure */
+export interface RelationData {
+ relation_id: string;
+ source_span_id: string;
+ target_span_id: string;
+ label?: {
+ label: string;
+ label_id?: string;
+ };
+ directed: boolean;
+}
+
+/** Display configuration */
+export interface SpanDisplayConfig {
+ highlight_style: "background" | "underline" | "border";
+ color_palette: string[];
+ show_labels: boolean;
+ show_tooltips: boolean;
+ token_delimiter: string;
+ label_position: "inline" | "below" | "tooltip";
+}
+
+const DEFAULT_PALETTE = [
+ "#BBDEFB", "#C8E6C9", "#FFE0B2", "#F8BBD0",
+ "#D1C4E9", "#B2EBF2", "#DCEDC8", "#FFD54F",
+];
+
+/**
+ * Compute which spans cover each token index.
+ *
+ * @param tokens Token array for a single element
+ * @param spans All span data
+ * @param elementName Name of the element to compute for
+ * @returns Map from token index to list of span_ids covering that token
+ */
+export function computeTokenSpanMap(
+ tokens: string[],
+ spans: SpanData[],
+ elementName: string = "text",
+): Map {
+ const map: Map = new Map();
+
+ for (let i = 0; i < tokens.length; i++) {
+ map.set(i, []);
+ }
+
+ for (const span of spans) {
+ for (const segment of span.segments) {
+ if (segment.element_name === elementName) {
+ for (const idx of segment.indices) {
+ if (idx < tokens.length) {
+ const list = map.get(idx);
+ if (list) {
+ list.push(span.span_id);
+ }
+ }
+ }
+ }
+ }
+ }
+
+ return map;
+}
+
+/**
+ * Assign colors to spans from palette, respecting per-label overrides.
+ *
+ * @param spans Span data array
+ * @param palette Color palette
+ * @param labelColors Optional per-label color overrides
+ * @returns Map from span_id to CSS color string
+ */
+export function assignSpanColors(
+ spans: SpanData[],
+ palette: string[] = DEFAULT_PALETTE,
+ labelColors?: Record,
+): Map {
+ const colorMap: Map = new Map();
+ const labelToColor: Map = new Map();
+ let colorIdx = 0;
+
+ for (const span of spans) {
+ const label = span.label?.label;
+
+ // Check for explicit label color
+ if (label && labelColors?.[label]) {
+ colorMap.set(span.span_id, labelColors[label] ?? palette[0] ?? "#BBDEFB");
+ continue;
+ }
+
+ // Reuse color for same label
+ if (label && labelToColor.has(label)) {
+ colorMap.set(span.span_id, labelToColor.get(label) ?? palette[0] ?? "#BBDEFB");
+ continue;
+ }
+
+ // Assign next color from palette
+ const color = palette[colorIdx % palette.length] ?? "#BBDEFB";
+ colorMap.set(span.span_id, color);
+ if (label) {
+ labelToColor.set(label, color);
+ }
+ colorIdx++;
+ }
+
+ return colorMap;
+}
+
+/**
+ * Render tokenized text into a DOM element with correct spacing and span highlights.
+ *
+ * @param tokens Token strings for a single element
+ * @param spaceAfter Per-token space_after flags
+ * @param spans Span data
+ * @param config Display configuration
+ * @param elementName Element name for span matching
+ * @returns Container HTMLElement with highlighted tokens
+ */
+export function renderTokenizedText(
+ tokens: string[],
+ spaceAfter: boolean[],
+ spans: SpanData[],
+ config: SpanDisplayConfig,
+ elementName: string = "text",
+): HTMLElement {
+ const container = document.createElement("div");
+ container.className = "bead-span-container";
+ container.setAttribute("data-element", elementName);
+
+ const tokenSpanMap = computeTokenSpanMap(tokens, spans, elementName);
+ const colorMap = assignSpanColors(spans, config.color_palette);
+
+ for (let i = 0; i < tokens.length; i++) {
+ const tokenEl = document.createElement("span");
+ tokenEl.className = "bead-token";
+ tokenEl.textContent = tokens[i] ?? "";
+ tokenEl.setAttribute("data-index", String(i));
+ tokenEl.setAttribute("data-element", elementName);
+
+ const spanIds = tokenSpanMap.get(i) ?? [];
+ if (spanIds.length > 0) {
+ tokenEl.classList.add("highlighted");
+ tokenEl.setAttribute("data-span-count", String(spanIds.length));
+ tokenEl.setAttribute("data-span-ids", spanIds.join(","));
+
+ // Apply color
+ if (config.highlight_style === "background") {
+ if (spanIds.length === 1) {
+ tokenEl.style.backgroundColor = colorMap.get(spanIds[0] ?? "") ?? "#BBDEFB";
+ } else {
+ const colors = spanIds.map(id => colorMap.get(id) ?? "#BBDEFB");
+ tokenEl.style.background = `linear-gradient(${colors.join(", ")})`;
+ }
+ } else if (config.highlight_style === "underline") {
+ const color = colorMap.get(spanIds[0] ?? "") ?? "#BBDEFB";
+ tokenEl.style.textDecoration = "underline";
+ tokenEl.style.textDecorationColor = color;
+ } else if (config.highlight_style === "border") {
+ const color = colorMap.get(spanIds[0] ?? "") ?? "#BBDEFB";
+ tokenEl.style.border = `1px solid ${color}`;
+ }
+
+ // Tooltip
+ if (config.show_tooltips && spanIds.length > 0) {
+ const labels = spanIds
+ .map(id => {
+ const span = spans.find(s => s.span_id === id);
+ return span?.label?.label ?? id;
+ })
+ .join(", ");
+ tokenEl.title = labels;
+ }
+ }
+
+ container.appendChild(tokenEl);
+
+ // Add spacing
+ if (i < spaceAfter.length && spaceAfter[i]) {
+ container.appendChild(document.createTextNode(" "));
+ }
+ }
+
+ return container;
+}
+
+/**
+ * Render relation arcs as an SVG overlay.
+ *
+ * @param relations Relation data
+ * @param spanPositions Map from span_id to bounding rect
+ * @param config Display configuration
+ * @returns SVG element with relation arcs
+ */
+export function renderRelationArcs(
+ relations: RelationData[],
+ spanPositions: Map,
+ config: SpanDisplayConfig,
+): SVGSVGElement {
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
+ svg.classList.add("bead-relation-layer");
+ svg.setAttribute("width", "100%");
+ svg.setAttribute("height", "100%");
+ svg.style.position = "absolute";
+ svg.style.top = "0";
+ svg.style.left = "0";
+ svg.style.pointerEvents = "none";
+
+ // Arrowhead marker for directed relations
+ const defs = document.createElementNS("http://www.w3.org/2000/svg", "defs");
+ const marker = document.createElementNS("http://www.w3.org/2000/svg", "marker");
+ marker.setAttribute("id", "arrowhead");
+ marker.setAttribute("markerWidth", "10");
+ marker.setAttribute("markerHeight", "7");
+ marker.setAttribute("refX", "10");
+ marker.setAttribute("refY", "3.5");
+ marker.setAttribute("orient", "auto");
+ const polygon = document.createElementNS("http://www.w3.org/2000/svg", "polygon");
+ polygon.setAttribute("points", "0 0, 10 3.5, 0 7");
+ polygon.setAttribute("fill", "#424242");
+ marker.appendChild(polygon);
+ defs.appendChild(marker);
+ svg.appendChild(defs);
+
+ const palette = config.color_palette.length > 0 ? config.color_palette : DEFAULT_PALETTE;
+
+ for (let i = 0; i < relations.length; i++) {
+ const rel = relations[i];
+ if (!rel) continue;
+
+ const sourceRect = spanPositions.get(rel.source_span_id);
+ const targetRect = spanPositions.get(rel.target_span_id);
+ if (!sourceRect || !targetRect) continue;
+
+ const x1 = sourceRect.left + sourceRect.width / 2;
+ const x2 = targetRect.left + targetRect.width / 2;
+ const y1 = sourceRect.top;
+ const y2 = targetRect.top;
+
+ // Draw arc
+ const midX = (x1 + x2) / 2;
+ const arcHeight = Math.abs(x2 - x1) * 0.3 + 20;
+ const midY = Math.min(y1, y2) - arcHeight;
+
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
+ path.setAttribute("d", `M ${x1} ${y1} Q ${midX} ${midY} ${x2} ${y2}`);
+ path.classList.add("bead-relation-arc");
+ path.setAttribute("stroke", palette[i % palette.length] ?? "#424242");
+ path.setAttribute("fill", "none");
+ path.setAttribute("stroke-width", "1.5");
+
+ if (rel.directed) {
+ path.classList.add("directed");
+ path.setAttribute("marker-end", "url(#arrowhead)");
+ }
+
+ svg.appendChild(path);
+
+ // Label text
+ if (rel.label?.label) {
+ const text = document.createElementNS("http://www.w3.org/2000/svg", "text");
+ text.setAttribute("x", String(midX));
+ text.setAttribute("y", String(midY - 4));
+ text.setAttribute("text-anchor", "middle");
+ text.classList.add("bead-relation-label-text");
+ text.textContent = rel.label.label;
+ svg.appendChild(text);
+ }
+ }
+
+ return svg;
+}
diff --git a/bead/deployment/jspsych/src/lib/wikidata-search.ts b/bead/deployment/jspsych/src/lib/wikidata-search.ts
new file mode 100644
index 0000000..8fc0b3e
--- /dev/null
+++ b/bead/deployment/jspsych/src/lib/wikidata-search.ts
@@ -0,0 +1,124 @@
+/**
+ * Client-side Wikidata entity search.
+ *
+ * Uses the Wikidata API (wbsearchentities) for autocomplete typeahead
+ * on span labels and relation labels when label_source is "wikidata".
+ *
+ * Features: 300ms debouncing, LRU result caching, origin=* for CORS,
+ * configurable language/limit/entity types.
+ *
+ * @author Bead Project
+ * @version 0.2.0
+ */
+
+/** Wikidata entity result */
+export interface WikidataEntity {
+ id: string;
+ label: string;
+ description: string;
+ aliases: string[];
+}
+
+/** Search options */
+export interface WikidataSearchOptions {
+ language: string;
+ limit: number;
+ entityTypes?: string[];
+}
+
+const WIKIDATA_API = "https://www.wikidata.org/w/api.php";
+const CACHE_SIZE = 100;
+const DEBOUNCE_MS = 300;
+
+// Simple LRU cache
+const cache: Map = new Map();
+
+function cacheKey(query: string, opts: WikidataSearchOptions): string {
+ return `${opts.language}:${query}:${opts.limit}:${(opts.entityTypes ?? []).join(",")}`;
+}
+
+function putCache(key: string, value: WikidataEntity[]): void {
+ if (cache.size >= CACHE_SIZE) {
+ const firstKey = cache.keys().next().value;
+ if (firstKey !== undefined) {
+ cache.delete(firstKey);
+ }
+ }
+ cache.set(key, value);
+}
+
+/**
+ * Search Wikidata entities.
+ */
+export async function searchWikidata(
+ query: string,
+ options: WikidataSearchOptions,
+): Promise {
+ if (!query || query.trim().length === 0) {
+ return [];
+ }
+
+ const key = cacheKey(query, options);
+ const cached = cache.get(key);
+ if (cached) {
+ return cached;
+ }
+
+ const params = new URLSearchParams({
+ action: "wbsearchentities",
+ search: query.trim(),
+ language: options.language,
+ limit: String(options.limit),
+ format: "json",
+ origin: "*",
+ });
+
+ if (options.entityTypes && options.entityTypes.length > 0) {
+ params.set("type", options.entityTypes[0] ?? "item");
+ }
+
+ const url = `${WIKIDATA_API}?${params.toString()}`;
+
+ try {
+ const response = await fetch(url);
+ if (!response.ok) {
+ return [];
+ }
+
+ const data = await response.json();
+ const results: WikidataEntity[] = (data.search ?? []).map(
+ (item: Record) => ({
+ id: String(item["id"] ?? ""),
+ label: String(item["label"] ?? ""),
+ description: String(item["description"] ?? ""),
+ aliases: Array.isArray(item["aliases"]) ? item["aliases"].map(String) : [],
+ }),
+ );
+
+ putCache(key, results);
+ return results;
+ } catch {
+ return [];
+ }
+}
+
+// Debounce utility
+let debounceTimer: ReturnType | null = null;
+
+/**
+ * Debounced Wikidata search.
+ */
+export function debouncedSearchWikidata(
+ query: string,
+ options: WikidataSearchOptions,
+ callback: (results: WikidataEntity[]) => void,
+): void {
+ if (debounceTimer !== null) {
+ clearTimeout(debounceTimer);
+ }
+
+ debounceTimer = setTimeout(async () => {
+ const results = await searchWikidata(query, options);
+ callback(results);
+ }, DEBOUNCE_MS);
+}
diff --git a/bead/deployment/jspsych/src/plugins/cloze-dropdown.ts b/bead/deployment/jspsych/src/plugins/cloze-dropdown.ts
index 44ec570..3058f58 100644
--- a/bead/deployment/jspsych/src/plugins/cloze-dropdown.ts
+++ b/bead/deployment/jspsych/src/plugins/cloze-dropdown.ts
@@ -13,7 +13,7 @@
* - Preserves all item and template metadata
*
* @author Bead Project
- * @version 0.1.0
+ * @version 0.2.0
*/
import type { JsPsych, JsPsychPlugin, PluginInfo } from "../types/jspsych.js";
diff --git a/bead/deployment/jspsych/src/plugins/forced-choice.ts b/bead/deployment/jspsych/src/plugins/forced-choice.ts
index 38e44a3..7553ec6 100644
--- a/bead/deployment/jspsych/src/plugins/forced-choice.ts
+++ b/bead/deployment/jspsych/src/plugins/forced-choice.ts
@@ -11,7 +11,7 @@
* - Preserves all item and template metadata
*
* @author Bead Project
- * @version 0.1.0
+ * @version 0.2.0
*/
import type { JsPsych, JsPsychPlugin, KeyboardResponseInfo, PluginInfo } from "../types/jspsych.js";
diff --git a/bead/deployment/jspsych/src/plugins/plugins.test.ts b/bead/deployment/jspsych/src/plugins/plugins.test.ts
index 523ff2e..bd2fa90 100644
--- a/bead/deployment/jspsych/src/plugins/plugins.test.ts
+++ b/bead/deployment/jspsych/src/plugins/plugins.test.ts
@@ -9,6 +9,7 @@ import type { JsPsych } from "../types/jspsych.js";
import { BeadClozeMultiPlugin } from "./cloze-dropdown.js";
import { BeadForcedChoicePlugin } from "./forced-choice.js";
import { BeadRatingPlugin } from "./rating.js";
+import { BeadSpanLabelPlugin } from "./span-label.js";
// Mock jsPsych instance
function createMockJsPsych(): JsPsych {
@@ -103,6 +104,49 @@ describe("bead-forced-choice plugin", () => {
});
});
+describe("bead-span-label plugin", () => {
+ describe("info structure", () => {
+ test("has correct plugin name", () => {
+ expect(BeadSpanLabelPlugin.info.name).toBe("bead-span-label");
+ });
+
+ test("has required parameters", () => {
+ const params = BeadSpanLabelPlugin.info.parameters;
+ expect(params["tokens"]).toBeDefined();
+ expect(params["space_after"]).toBeDefined();
+ expect(params["spans"]).toBeDefined();
+ expect(params["relations"]).toBeDefined();
+ expect(params["span_spec"]).toBeDefined();
+ expect(params["display_config"]).toBeDefined();
+ expect(params["prompt"]).toBeDefined();
+ expect(params["button_label"]).toBeDefined();
+ expect(params["require_response"]).toBeDefined();
+ expect(params["metadata"]).toBeDefined();
+ });
+
+ test("has correct parameter defaults", () => {
+ const params = BeadSpanLabelPlugin.info.parameters;
+ expect(params["require_response"]?.default).toBe(true);
+ expect(params["button_label"]?.default).toBe("Continue");
+ expect(params["prompt"]?.default).toBe("Select and label spans");
+ });
+ });
+
+ describe("plugin instantiation", () => {
+ test("can be instantiated", () => {
+ const mockJsPsych = createMockJsPsych();
+ const plugin = new BeadSpanLabelPlugin(mockJsPsych);
+ expect(plugin).toBeDefined();
+ });
+
+ test("has trial method", () => {
+ const mockJsPsych = createMockJsPsych();
+ const plugin = new BeadSpanLabelPlugin(mockJsPsych);
+ expect(typeof plugin.trial).toBe("function");
+ });
+ });
+});
+
describe("bead-cloze-multi plugin", () => {
describe("info structure", () => {
test("has correct plugin name", () => {
diff --git a/bead/deployment/jspsych/src/plugins/rating.ts b/bead/deployment/jspsych/src/plugins/rating.ts
index a78deeb..0544423 100644
--- a/bead/deployment/jspsych/src/plugins/rating.ts
+++ b/bead/deployment/jspsych/src/plugins/rating.ts
@@ -12,7 +12,7 @@
* - Preserves all item and template metadata
*
* @author Bead Project
- * @version 0.1.0
+ * @version 0.2.0
*/
import type { JsPsych, JsPsychPlugin, KeyboardResponseInfo, PluginInfo } from "../types/jspsych.js";
diff --git a/bead/deployment/jspsych/src/plugins/span-label.ts b/bead/deployment/jspsych/src/plugins/span-label.ts
new file mode 100644
index 0000000..c14be1d
--- /dev/null
+++ b/bead/deployment/jspsych/src/plugins/span-label.ts
@@ -0,0 +1,554 @@
+/**
+ * bead-span-label plugin
+ *
+ * jsPsych plugin for span selection and labeling. Supports:
+ * - Token-level span selection (click, drag, shift+click)
+ * - Static span display with overlapping highlights
+ * - Fixed label sets and Wikidata entity search
+ * - Relation annotation between spans
+ * - Keyboard shortcuts for labels (1-9)
+ *
+ * @author Bead Project
+ * @version 0.2.0
+ */
+
+import type { JsPsych, JsPsychPlugin, PluginInfo } from "../types/jspsych.js";
+
+/** Span segment data */
+interface SpanSegmentData {
+ element_name: string;
+ indices: number[];
+}
+
+/** Span label data */
+interface SpanLabelData {
+ label: string;
+ label_id?: string;
+}
+
+/** Span data */
+interface SpanData {
+ span_id: string;
+ segments: SpanSegmentData[];
+ head_index?: number;
+ label?: SpanLabelData;
+ span_type?: string;
+}
+
+/** Relation data */
+interface RelationData {
+ relation_id: string;
+ source_span_id: string;
+ target_span_id: string;
+ label?: SpanLabelData;
+ directed: boolean;
+}
+
+/** Span specification from Python */
+interface SpanSpecData {
+ index_mode: "token" | "character";
+ interaction_mode: "static" | "interactive";
+ label_source: "fixed" | "wikidata";
+ labels?: string[];
+ label_colors?: Record;
+ allow_overlapping: boolean;
+ min_spans?: number;
+ max_spans?: number;
+ enable_relations: boolean;
+ relation_label_source: "fixed" | "wikidata";
+ relation_labels?: string[];
+ relation_directed: boolean;
+ min_relations?: number;
+ max_relations?: number;
+ wikidata_language: string;
+ wikidata_entity_types?: string[];
+ wikidata_result_limit: number;
+}
+
+/** Display configuration */
+interface SpanDisplayConfigData {
+ highlight_style: "background" | "underline" | "border";
+ color_palette: string[];
+ show_labels: boolean;
+ show_tooltips: boolean;
+ token_delimiter: string;
+ label_position: "inline" | "below" | "tooltip";
+}
+
+/** Bead metadata */
+interface BeadMetadata {
+ spans?: SpanData[];
+ span_relations?: RelationData[];
+ tokenized_elements?: Record;
+ token_space_after?: Record;
+ span_spec?: SpanSpecData;
+ [key: string]: unknown;
+}
+
+/** Span event for logging */
+interface SpanEvent {
+ type: "select" | "deselect" | "label" | "delete" | "relation_create" | "relation_delete";
+ timestamp: number;
+ span_id?: string;
+ relation_id?: string;
+ indices?: number[];
+ label?: string;
+}
+
+/** Trial parameters */
+export interface SpanLabelTrialParams {
+ tokens: Record;
+ space_after: Record;
+ spans: SpanData[];
+ relations: RelationData[];
+ span_spec: SpanSpecData | null;
+ display_config: SpanDisplayConfigData | null;
+ prompt: string;
+ button_label: string;
+ require_response: boolean;
+ metadata: BeadMetadata;
+}
+
+/** Plugin info constant */
+const info: PluginInfo = {
+ name: "bead-span-label",
+ parameters: {
+ tokens: {
+ type: 12, // OBJECT
+ default: {},
+ },
+ space_after: {
+ type: 12, // OBJECT
+ default: {},
+ },
+ spans: {
+ type: 12, // OBJECT
+ default: [],
+ array: true,
+ },
+ relations: {
+ type: 12, // OBJECT
+ default: [],
+ array: true,
+ },
+ span_spec: {
+ type: 12, // OBJECT
+ default: null,
+ },
+ display_config: {
+ type: 12, // OBJECT
+ default: null,
+ },
+ prompt: {
+ type: 8, // HTML_STRING
+ default: "Select and label spans",
+ },
+ button_label: {
+ type: 1, // STRING
+ default: "Continue",
+ },
+ require_response: {
+ type: 0, // BOOL
+ default: true,
+ },
+ metadata: {
+ type: 12, // OBJECT
+ default: {},
+ },
+ },
+};
+
+const DEFAULT_PALETTE = [
+ "#BBDEFB", "#C8E6C9", "#FFE0B2", "#F8BBD0",
+ "#D1C4E9", "#B2EBF2", "#DCEDC8", "#FFD54F",
+];
+
+/**
+ * BeadSpanLabelPlugin - jsPsych plugin for span annotation
+ */
+class BeadSpanLabelPlugin implements JsPsychPlugin {
+ static info = info;
+
+ private jsPsych: JsPsych;
+
+ constructor(jsPsych: JsPsych) {
+ this.jsPsych = jsPsych;
+ }
+
+ trial(display_element: HTMLElement, trial: SpanLabelTrialParams): void {
+ const start_time = performance.now();
+ const events: SpanEvent[] = [];
+
+ // Resolve config from metadata or parameters
+ const tokens = Object.keys(trial.tokens).length > 0
+ ? trial.tokens
+ : (trial.metadata.tokenized_elements ?? {});
+ const spaceAfter = Object.keys(trial.space_after).length > 0
+ ? trial.space_after
+ : (trial.metadata.token_space_after ?? {});
+ const spanSpec = trial.span_spec ?? trial.metadata.span_spec ?? null;
+ const preSpans = trial.spans.length > 0
+ ? trial.spans
+ : (trial.metadata.spans ?? []);
+ const preRelations = trial.relations.length > 0
+ ? trial.relations
+ : (trial.metadata.span_relations ?? []);
+
+ const palette = trial.display_config?.color_palette ?? DEFAULT_PALETTE;
+ const isInteractive = spanSpec?.interaction_mode === "interactive";
+
+ // Working state
+ const activeSpans: SpanData[] = [...preSpans];
+ const activeRelations: RelationData[] = [...preRelations];
+ let selectionStart: number | null = null;
+ let selectedIndices: number[] = [];
+ let nextSpanId = activeSpans.length;
+
+ // Build HTML
+ let html = '';
+
+ if (trial.prompt) {
+ html += `
${trial.prompt}
`;
+ }
+
+ // Render tokens for each element
+ const elementNames = Object.keys(tokens).sort();
+ for (const elemName of elementNames) {
+ const elemTokens = tokens[elemName] ?? [];
+ const elemSpaceAfter = spaceAfter[elemName] ?? [];
+
+ html += `
`;
+ for (let i = 0; i < elemTokens.length; i++) {
+ const tokenText = elemTokens[i];
+ const interactive = isInteractive ? " interactive" : "";
+ html += `${tokenText} `;
+ if (i < elemSpaceAfter.length && elemSpaceAfter[i]) {
+ html += " ";
+ }
+ }
+ html += "
";
+ }
+
+ // Label selector (for interactive mode with fixed labels)
+ if (isInteractive && spanSpec?.labels && spanSpec.labels.length > 0) {
+ html += '
';
+ for (let i = 0; i < spanSpec.labels.length; i++) {
+ const label = spanSpec.labels[i];
+ const shortcut = i < 9 ? ` [${i + 1}]` : "";
+ const color = palette[i % palette.length];
+ html += `${label}${shortcut} `;
+ }
+ html += "
";
+ }
+
+ // Span list
+ html += '
';
+
+ // Relation list
+ if (spanSpec?.enable_relations) {
+ html += '
';
+ }
+
+ // Continue button
+ html += `
+
+
+ ${trial.button_label}
+
+
+ `;
+
+ html += "
";
+ display_element.innerHTML = html;
+
+ // Apply static span highlights
+ applySpanHighlights();
+
+ // Render span list
+ renderSpanList();
+
+ if (isInteractive) {
+ setupInteractiveHandlers();
+ }
+
+ // Continue button
+ const continueBtn = display_element.querySelector("#bead-span-continue");
+ if (continueBtn) {
+ continueBtn.addEventListener("click", () => {
+ endTrial();
+ });
+ }
+
+ function applySpanHighlights(): void {
+ // Clear existing highlights
+ const allTokens = display_element.querySelectorAll(".bead-token");
+ for (const t of allTokens) {
+ t.classList.remove("highlighted");
+ t.removeAttribute("data-span-ids");
+ t.removeAttribute("data-span-count");
+ t.style.removeProperty("background-color");
+ t.style.removeProperty("background");
+ }
+
+ // Build token -> span mapping
+ const tokenSpanMap: Map = new Map();
+ for (const span of activeSpans) {
+ for (const seg of span.segments) {
+ for (const idx of seg.indices) {
+ const key = `${seg.element_name}:${idx}`;
+ if (!tokenSpanMap.has(key)) {
+ tokenSpanMap.set(key, []);
+ }
+ tokenSpanMap.get(key)?.push(span.span_id);
+ }
+ }
+ }
+
+ // Apply highlights
+ const spanColorMap = assignColors();
+ for (const t of allTokens) {
+ const elemName = t.getAttribute("data-element") ?? "";
+ const idx = t.getAttribute("data-index") ?? "";
+ const key = `${elemName}:${idx}`;
+ const spanIds = tokenSpanMap.get(key) ?? [];
+
+ if (spanIds.length > 0) {
+ t.classList.add("highlighted");
+ t.setAttribute("data-span-ids", spanIds.join(","));
+ t.setAttribute("data-span-count", String(spanIds.length));
+
+ if (spanIds.length === 1) {
+ t.style.backgroundColor = spanColorMap.get(spanIds[0] ?? "") ?? palette[0] ?? "#BBDEFB";
+ } else {
+ const colors = spanIds.map(id => spanColorMap.get(id) ?? palette[0] ?? "#BBDEFB");
+ t.style.background = `linear-gradient(${colors.join(", ")})`;
+ }
+ }
+ }
+ }
+
+ function assignColors(): Map {
+ const colorMap: Map = new Map();
+ const labelColors = spanSpec?.label_colors ?? {};
+ let colorIdx = 0;
+
+ for (const span of activeSpans) {
+ if (span.label?.label && labelColors[span.label.label]) {
+ colorMap.set(span.span_id, labelColors[span.label.label] ?? "#BBDEFB");
+ } else {
+ colorMap.set(span.span_id, palette[colorIdx % palette.length] ?? "#BBDEFB");
+ colorIdx++;
+ }
+ }
+ return colorMap;
+ }
+
+ function renderSpanList(): void {
+ const listEl = display_element.querySelector("#bead-span-list");
+ if (!listEl) return;
+
+ listEl.innerHTML = "";
+ const colorMap = assignColors();
+
+ for (const span of activeSpans) {
+ const badge = document.createElement("span");
+ badge.className = "bead-span-badge";
+ const color = colorMap.get(span.span_id) ?? palette[0];
+ badge.style.backgroundColor = color ?? "";
+ const labelText = span.label?.label ?? "unlabeled";
+ const spanText = getSpanText(span);
+ badge.textContent = `${labelText}: "${spanText}"`;
+ badge.setAttribute("data-span-id", span.span_id);
+
+ if (isInteractive) {
+ const deleteBtn = document.createElement("button");
+ deleteBtn.textContent = "\u00d7";
+ deleteBtn.style.cssText = "margin-left:4px;border:none;background:none;cursor:pointer;font-weight:bold;";
+ deleteBtn.addEventListener("click", () => {
+ deleteSpan(span.span_id);
+ });
+ badge.appendChild(deleteBtn);
+ }
+
+ listEl.appendChild(badge);
+ }
+ }
+
+ function getSpanText(span: SpanData): string {
+ const parts: string[] = [];
+ for (const seg of span.segments) {
+ const elemTokens = tokens[seg.element_name] ?? [];
+ for (const idx of seg.indices) {
+ if (idx < elemTokens.length) {
+ parts.push(elemTokens[idx] ?? "");
+ }
+ }
+ }
+ return parts.join(" ");
+ }
+
+ function setupInteractiveHandlers(): void {
+ const tokenEls = display_element.querySelectorAll(".bead-token.interactive");
+
+ for (const tokenEl of tokenEls) {
+ tokenEl.addEventListener("click", (e) => {
+ const idx = Number.parseInt(tokenEl.getAttribute("data-index") ?? "0", 10);
+ const elemName = tokenEl.getAttribute("data-element") ?? "";
+
+ if (e.shiftKey && selectionStart !== null) {
+ // Range selection
+ const start = Math.min(selectionStart, idx);
+ const end = Math.max(selectionStart, idx);
+ selectedIndices = [];
+ for (let i = start; i <= end; i++) {
+ selectedIndices.push(i);
+ }
+ } else {
+ // Toggle single token
+ const existingIdx = selectedIndices.indexOf(idx);
+ if (existingIdx >= 0) {
+ selectedIndices.splice(existingIdx, 1);
+ } else {
+ selectedIndices.push(idx);
+ }
+ selectionStart = idx;
+ }
+
+ // Update selection UI
+ updateSelectionUI(elemName);
+
+ // Show label panel if we have a selection
+ const labelPanel = display_element.querySelector("#bead-label-panel");
+ if (labelPanel) {
+ if (selectedIndices.length > 0) {
+ (labelPanel as HTMLElement).style.display = "flex";
+ } else {
+ (labelPanel as HTMLElement).style.display = "none";
+ }
+ }
+ });
+ }
+
+ // Label button handlers
+ const labelButtons = display_element.querySelectorAll(".bead-label-button");
+ for (const btn of labelButtons) {
+ btn.addEventListener("click", () => {
+ const label = btn.getAttribute("data-label") ?? "";
+ if (selectedIndices.length > 0 && label) {
+ createSpanFromSelection(label);
+ }
+ });
+ }
+
+ // Keyboard shortcuts for labels
+ document.addEventListener("keydown", handleKeyDown);
+ }
+
+ function handleKeyDown(e: KeyboardEvent): void {
+ const num = Number.parseInt(e.key, 10);
+ if (!Number.isNaN(num) && num >= 1 && num <= 9) {
+ const labels = spanSpec?.labels ?? [];
+ if (num <= labels.length && selectedIndices.length > 0) {
+ createSpanFromSelection(labels[num - 1] ?? "");
+ }
+ }
+ }
+
+ function updateSelectionUI(elementName: string): void {
+ const tokenEls = display_element.querySelectorAll(
+ `.bead-token[data-element="${elementName}"]`
+ );
+ for (const t of tokenEls) {
+ const idx = Number.parseInt(t.getAttribute("data-index") ?? "0", 10);
+ if (selectedIndices.includes(idx)) {
+ t.classList.add("selecting");
+ } else {
+ t.classList.remove("selecting");
+ }
+ }
+ }
+
+ function createSpanFromSelection(label: string): void {
+ const elemName = elementNames[0] ?? "text";
+ const spanId = `span_${nextSpanId++}`;
+
+ const newSpan: SpanData = {
+ span_id: spanId,
+ segments: [{
+ element_name: elemName,
+ indices: [...selectedIndices].sort((a, b) => a - b),
+ }],
+ label: { label },
+ };
+
+ activeSpans.push(newSpan);
+ events.push({
+ type: "select",
+ timestamp: performance.now() - start_time,
+ span_id: spanId,
+ indices: [...selectedIndices],
+ label,
+ });
+
+ // Clear selection
+ selectedIndices = [];
+ selectionStart = null;
+
+ // Update UI
+ applySpanHighlights();
+ renderSpanList();
+ updateContinueButton();
+
+ // Clear selection UI
+ const allTokens = display_element.querySelectorAll(".bead-token");
+ for (const t of allTokens) {
+ t.classList.remove("selecting");
+ }
+
+ // Hide label panel
+ const labelPanel = display_element.querySelector("#bead-label-panel");
+ if (labelPanel) {
+ (labelPanel as HTMLElement).style.display = "none";
+ }
+ }
+
+ function deleteSpan(spanId: string): void {
+ const idx = activeSpans.findIndex(s => s.span_id === spanId);
+ if (idx >= 0) {
+ activeSpans.splice(idx, 1);
+ events.push({
+ type: "delete",
+ timestamp: performance.now() - start_time,
+ span_id: spanId,
+ });
+ applySpanHighlights();
+ renderSpanList();
+ updateContinueButton();
+ }
+ }
+
+ function updateContinueButton(): void {
+ if (!continueBtn || !isInteractive) return;
+ const minSpans = spanSpec?.min_spans ?? 0;
+ continueBtn.disabled = activeSpans.length < minSpans;
+ }
+
+ const endTrial = (): void => {
+ // Remove keyboard listener
+ document.removeEventListener("keydown", handleKeyDown);
+
+ const trial_data: Record = {
+ ...trial.metadata,
+ spans: activeSpans,
+ relations: activeRelations,
+ span_events: events,
+ rt: performance.now() - start_time,
+ };
+
+ display_element.innerHTML = "";
+ this.jsPsych.finishTrial(trial_data);
+ };
+ }
+}
+
+export { BeadSpanLabelPlugin };
diff --git a/bead/deployment/jspsych/templates/experiment.css b/bead/deployment/jspsych/templates/experiment.css
index d358203..8f38978 100644
--- a/bead/deployment/jspsych/templates/experiment.css
+++ b/bead/deployment/jspsych/templates/experiment.css
@@ -1 +1,44 @@
/* Minimal custom styles - let jsPsych handle the layout */
+
+/* === Span labeling styles === */
+
+/* Token grid */
+.bead-span-container { display: flex; flex-wrap: wrap; gap: 2px; line-height: 2; }
+.bead-token { display: inline; padding: 2px 4px; border-radius: 3px; cursor: default; transition: background-color 0.15s; }
+.bead-token.interactive { cursor: pointer; }
+.bead-token.interactive:hover { background-color: rgba(0,0,0,0.08); }
+
+/* Span highlights - layered for overlaps */
+.bead-token.highlighted { position: relative; }
+.bead-token[data-span-count="1"] { background-color: var(--span-color-0); }
+.bead-token[data-span-count="2"] { background: linear-gradient(var(--span-color-0), var(--span-color-1)); }
+
+/* Selection feedback */
+.bead-token.selecting { background-color: #C8E6C9; }
+.bead-token.selecting.invalid { background-color: #FFCDD2; }
+
+/* Label panel */
+.bead-label-selector { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 8px; }
+.bead-label-button { padding: 4px 12px; border-radius: 16px; border: 1px solid #ccc; cursor: pointer; background: white; font-size: 0.9em; }
+.bead-label-button:hover, .bead-label-button.active { border-color: #1976D2; background: #E3F2FD; }
+
+/* Span list */
+.bead-span-list { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 12px; }
+.bead-span-badge { padding: 2px 8px; border-radius: 12px; font-size: 0.85em; }
+
+/* Relation arcs (SVG overlay) */
+.bead-relation-layer { position: absolute; top: 0; left: 0; width: 100%; pointer-events: none; }
+.bead-relation-arc { fill: none; stroke-width: 1.5; }
+.bead-relation-arc.directed { marker-end: url(#arrowhead); }
+.bead-relation-label-text { font-size: 0.75em; fill: #424242; }
+.bead-relation-list { margin-top: 8px; }
+.bead-relation-entry { display: flex; align-items: center; gap: 4px; padding: 2px 0; }
+
+/* Wikidata autocomplete */
+.bead-wikidata-search { position: relative; }
+.bead-wikidata-search input { width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px; }
+.bead-wikidata-results { position: absolute; z-index: 10; width: 100%; max-height: 200px; overflow-y: auto; border: 1px solid #e0e0e0; border-radius: 4px; background: white; box-shadow: 0 2px 8px rgba(0,0,0,0.15); }
+.bead-wikidata-result { padding: 6px 8px; cursor: pointer; }
+.bead-wikidata-result:hover { background: #F5F5F5; }
+.bead-wikidata-result .qid { color: #9E9E9E; font-size: 0.85em; }
+.bead-wikidata-result .description { color: #757575; font-size: 0.85em; }
diff --git a/bead/deployment/jspsych/templates/index.html b/bead/deployment/jspsych/templates/index.html
index 84e8d36..e61f60a 100644
--- a/bead/deployment/jspsych/templates/index.html
+++ b/bead/deployment/jspsych/templates/index.html
@@ -41,6 +41,15 @@
{% endif %}
+
+ {% if span_enabled %}
+
+
+
+ {% if span_wikidata %}
+
+ {% endif %}
+ {% endif %}
diff --git a/bead/deployment/jspsych/trials.py b/bead/deployment/jspsych/trials.py
index 15ae59f..e547541 100644
--- a/bead/deployment/jspsych/trials.py
+++ b/bead/deployment/jspsych/trials.py
@@ -2,7 +2,8 @@
This module provides functions to generate jsPsych trial objects from
Item models. It supports various trial types including rating scales,
-forced choice, and binary choice trials.
+forced choice, binary choice, and span labeling trials. Composite tasks
+(e.g., rating with span highlights) are also supported.
"""
from __future__ import annotations
@@ -15,6 +16,7 @@
ExperimentConfig,
InstructionsConfig,
RatingScaleConfig,
+ SpanDisplayConfig,
)
from bead.items.item import Item
from bead.items.item_template import ItemTemplate
@@ -150,6 +152,84 @@ def _serialize_item_metadata(
"presentation_order": template.presentation_order,
# Template metadata
"template_metadata": dict(template.template_metadata),
+ # Span annotation data
+ "spans": [
+ {
+ "span_id": span.span_id,
+ "segments": [
+ {
+ "element_name": seg.element_name,
+ "indices": seg.indices,
+ }
+ for seg in span.segments
+ ],
+ "head_index": span.head_index,
+ "label": (
+ {
+ "label": span.label.label,
+ "label_id": span.label.label_id,
+ "confidence": span.label.confidence,
+ }
+ if span.label
+ else None
+ ),
+ "span_type": span.span_type,
+ "span_metadata": dict(span.span_metadata),
+ }
+ for span in item.spans
+ ],
+ "span_relations": [
+ {
+ "relation_id": rel.relation_id,
+ "source_span_id": rel.source_span_id,
+ "target_span_id": rel.target_span_id,
+ "label": (
+ {
+ "label": rel.label.label,
+ "label_id": rel.label.label_id,
+ "confidence": rel.label.confidence,
+ }
+ if rel.label
+ else None
+ ),
+ "directed": rel.directed,
+ "relation_metadata": dict(rel.relation_metadata),
+ }
+ for rel in item.span_relations
+ ],
+ "tokenized_elements": dict(item.tokenized_elements),
+ "token_space_after": {
+ k: list(v) for k, v in item.token_space_after.items()
+ },
+ "span_spec": (
+ {
+ "index_mode": template.task_spec.span_spec.index_mode,
+ "interaction_mode": template.task_spec.span_spec.interaction_mode,
+ "label_source": template.task_spec.span_spec.label_source,
+ "labels": template.task_spec.span_spec.labels,
+ "label_colors": template.task_spec.span_spec.label_colors,
+ "allow_overlapping": template.task_spec.span_spec.allow_overlapping,
+ "min_spans": template.task_spec.span_spec.min_spans,
+ "max_spans": template.task_spec.span_spec.max_spans,
+ "enable_relations": template.task_spec.span_spec.enable_relations,
+ "relation_label_source": (
+ template.task_spec.span_spec.relation_label_source
+ ),
+ "relation_labels": template.task_spec.span_spec.relation_labels,
+ "relation_directed": template.task_spec.span_spec.relation_directed,
+ "min_relations": template.task_spec.span_spec.min_relations,
+ "max_relations": template.task_spec.span_spec.max_relations,
+ "wikidata_language": template.task_spec.span_spec.wikidata_language,
+ "wikidata_entity_types": (
+ template.task_spec.span_spec.wikidata_entity_types
+ ),
+ "wikidata_result_limit": (
+ template.task_spec.span_spec.wikidata_result_limit
+ ),
+ }
+ if template.task_spec.span_spec
+ else None
+ ),
}
@@ -214,22 +294,49 @@ def create_trial(
>>> trial["type"]
'html-slider-response'
"""
+ # Standalone span_labeling experiment type
+ if experiment_config.experiment_type == "span_labeling":
+ span_display = experiment_config.span_display or SpanDisplayConfig()
+ return _create_span_labeling_trial(
+ item, template, span_display, trial_number
+ )
+
+ # For composite tasks: detect spans and use span-enhanced stimulus HTML
+ has_spans = bool(item.spans) and bool(
+ template.task_spec.span_spec if template.task_spec else False
+ )
+
+ # Resolve span display config for composite tasks with spans
+ span_display = experiment_config.span_display or SpanDisplayConfig()
+
if experiment_config.experiment_type == "likert_rating":
if rating_config is None:
raise ValueError("rating_config required for likert_rating experiments")
- return _create_likert_trial(item, template, rating_config, trial_number)
+ return _create_likert_trial(
+ item, template, rating_config, trial_number,
+ has_spans=has_spans, span_display=span_display,
+ )
elif experiment_config.experiment_type == "slider_rating":
if rating_config is None:
raise ValueError("rating_config required for slider_rating experiments")
- return _create_slider_trial(item, template, rating_config, trial_number)
+ return _create_slider_trial(
+ item, template, rating_config, trial_number,
+ has_spans=has_spans, span_display=span_display,
+ )
elif experiment_config.experiment_type == "binary_choice":
if choice_config is None:
raise ValueError("choice_config required for binary_choice experiments")
- return _create_binary_choice_trial(item, template, choice_config, trial_number)
+ return _create_binary_choice_trial(
+ item, template, choice_config, trial_number,
+ has_spans=has_spans, span_display=span_display,
+ )
elif experiment_config.experiment_type == "forced_choice":
if choice_config is None:
raise ValueError("choice_config required for forced_choice experiments")
- return _create_forced_choice_trial(item, template, choice_config, trial_number)
+ return _create_forced_choice_trial(
+ item, template, choice_config, trial_number,
+ has_spans=has_spans, span_display=span_display,
+ )
else:
raise ValueError(
f"Unknown experiment type: {experiment_config.experiment_type}"
@@ -241,6 +348,8 @@ def _create_likert_trial(
template: ItemTemplate,
config: RatingScaleConfig,
trial_number: int,
+ has_spans: bool = False,
+ span_display: SpanDisplayConfig | None = None,
) -> dict[str, JsonValue]:
"""Create a Likert rating trial.
@@ -254,6 +363,10 @@ def _create_likert_trial(
Rating scale configuration.
trial_number : int
The trial number.
+ has_spans : bool
+ Whether the item has span annotations.
+ span_display : SpanDisplayConfig | None
+ Span display configuration.
Returns
-------
@@ -261,7 +374,10 @@ def _create_likert_trial(
A jsPsych html-button-response trial object.
"""
# Generate stimulus HTML from rendered elements
- stimulus_html = _generate_stimulus_html(item)
+ if has_spans and span_display:
+ stimulus_html = _generate_span_stimulus_html(item, span_display)
+ else:
+ stimulus_html = _generate_stimulus_html(item)
# Generate button labels for Likert scale
labels: list[str] = []
@@ -298,6 +414,8 @@ def _create_slider_trial(
template: ItemTemplate,
config: RatingScaleConfig,
trial_number: int,
+ has_spans: bool = False,
+ span_display: SpanDisplayConfig | None = None,
) -> dict[str, JsonValue]:
"""Create a slider rating trial.
@@ -311,13 +429,20 @@ def _create_slider_trial(
Rating scale configuration.
trial_number : int
The trial number.
+ has_spans : bool
+ Whether the item has span annotations.
+ span_display : SpanDisplayConfig | None
+ Span display configuration.
Returns
-------
dict[str, JsonValue]
A jsPsych html-slider-response trial object.
"""
- stimulus_html = _generate_stimulus_html(item)
+ if has_spans and span_display:
+ stimulus_html = _generate_span_stimulus_html(item, span_display)
+ else:
+ stimulus_html = _generate_stimulus_html(item)
# Serialize complete metadata
metadata = _serialize_item_metadata(item, template)
@@ -342,6 +467,8 @@ def _create_binary_choice_trial(
template: ItemTemplate,
config: ChoiceConfig,
trial_number: int,
+ has_spans: bool = False,
+ span_display: SpanDisplayConfig | None = None,
) -> dict[str, JsonValue]:
"""Create a binary choice trial.
@@ -355,13 +482,20 @@ def _create_binary_choice_trial(
Choice configuration.
trial_number : int
The trial number.
+ has_spans : bool
+ Whether the item has span annotations.
+ span_display : SpanDisplayConfig | None
+ Span display configuration.
Returns
-------
dict[str, JsonValue]
A jsPsych html-button-response trial object.
"""
- stimulus_html = _generate_stimulus_html(item)
+ if has_spans and span_display:
+ stimulus_html = _generate_span_stimulus_html(item, span_display)
+ else:
+ stimulus_html = _generate_stimulus_html(item)
# Serialize complete metadata
metadata = _serialize_item_metadata(item, template)
@@ -383,6 +517,8 @@ def _create_forced_choice_trial(
template: ItemTemplate,
config: ChoiceConfig,
trial_number: int,
+ has_spans: bool = False,
+ span_display: SpanDisplayConfig | None = None,
) -> dict[str, JsonValue]:
"""Create a forced choice trial.
@@ -397,6 +533,10 @@ def _create_forced_choice_trial(
Choice configuration.
trial_number : int
The trial number.
+ has_spans : bool
+ Whether the item has span annotations.
+ span_display : SpanDisplayConfig | None
+ Span display configuration.
Returns
-------
@@ -721,3 +861,251 @@ def create_instructions_trial(
"trial_type": "instructions",
},
}
+
+
+def _generate_span_stimulus_html(
+ item: Item,
+ span_display: SpanDisplayConfig,
+) -> str:
+ """Generate HTML with span-highlighted tokens for composite tasks.
+
+ Renders tokens as individually wrapped ```` elements with
+ highlight classes and data attributes for span identification.
+
+ Parameters
+ ----------
+ item : Item
+ Item with spans and tokenized_elements.
+ span_display : SpanDisplayConfig
+ Visual configuration.
+
+ Returns
+ -------
+ str
+ HTML string with span-highlighted token elements.
+ """
+ if not item.tokenized_elements:
+ return _generate_stimulus_html(item)
+
+ html_parts: list[str] = ['']
+
+ sorted_keys = sorted(item.tokenized_elements.keys())
+ for element_name in sorted_keys:
+ tokens = item.tokenized_elements[element_name]
+ space_flags = item.token_space_after.get(element_name, [])
+
+ # Build token-to-span mapping
+ token_spans: dict[int, list[str]] = {}
+ for span in item.spans:
+ for segment in span.segments:
+ if segment.element_name == element_name:
+ for idx in segment.indices:
+ if idx not in token_spans:
+ token_spans[idx] = []
+ token_spans[idx].append(span.span_id)
+
+ # Assign colors
+ span_colors: dict[str, str] = {}
+ palette = span_display.color_palette
+ color_idx = 0
+ for span in item.spans:
+ if span.label and span.label.label:
+ # Use label_colors if available
+ if (
+ span_display.show_labels
+ and hasattr(span, "label")
+ and span.label
+ ):
+ label_name = span.label.label
+ if label_name not in span_colors:
+ span_colors[label_name] = palette[
+ color_idx % len(palette)
+ ]
+ color_idx += 1
+ span_colors[span.span_id] = span_colors[label_name]
+ else:
+ span_colors[span.span_id] = palette[
+ color_idx % len(palette)
+ ]
+ color_idx += 1
+
+ html_parts.append(
+ f'
'
+ )
+
+ for i, token_text in enumerate(tokens):
+ span_ids = token_spans.get(i, [])
+ n_spans = len(span_ids)
+
+ classes = ["bead-token"]
+ if n_spans > 0:
+ classes.append("highlighted")
+
+ style_parts: list[str] = []
+ if n_spans == 1:
+ color = span_colors.get(span_ids[0], palette[0])
+ style_parts.append(f"background-color: {color}")
+ elif n_spans > 1:
+ # Layer multiple spans
+ colors = [
+ span_colors.get(sid, palette[0]) for sid in span_ids
+ ]
+ gradient = ", ".join(colors)
+ style_parts.append(
+ f"background: linear-gradient({gradient})"
+ )
+
+ style_attr = f' style="{"; ".join(style_parts)}"' if style_parts else ""
+ span_id_attr = (
+ f' data-span-ids="{",".join(span_ids)}"' if span_ids else ""
+ )
+ count_attr = (
+ f' data-span-count="{n_spans}"' if n_spans > 0 else ""
+ )
+
+ html_parts.append(
+ f'"
+ f"{token_text} "
+ )
+
+ # Add spacing
+ if i < len(space_flags) and space_flags[i]:
+ html_parts.append(" ")
+
+ html_parts.append("
")
+
+ html_parts.append("
")
+ return "".join(html_parts)
+
+
+def _create_span_labeling_trial(
+ item: Item,
+ template: ItemTemplate,
+ span_display: SpanDisplayConfig,
+ trial_number: int,
+) -> dict[str, JsonValue]:
+ """Create a standalone span labeling trial.
+
+ Uses the ``bead-span-label`` plugin for interactive or static span
+ annotation.
+
+ Parameters
+ ----------
+ item : Item
+ Item with span data.
+ template : ItemTemplate
+ Item template with span_spec.
+ span_display : SpanDisplayConfig
+ Visual configuration.
+ trial_number : int
+ Trial number.
+
+ Returns
+ -------
+ dict[str, JsonValue]
+ A jsPsych trial object using the bead-span-label plugin.
+ """
+ metadata = _serialize_item_metadata(item, template)
+ metadata["trial_number"] = trial_number
+ metadata["trial_type"] = "span_labeling"
+
+ prompt = (
+ template.task_spec.prompt
+ if template.task_spec
+ else "Select and label spans"
+ )
+
+ # Serialize span data for the plugin
+ spans_data = [
+ {
+ "span_id": span.span_id,
+ "segments": [
+ {"element_name": seg.element_name, "indices": seg.indices}
+ for seg in span.segments
+ ],
+ "head_index": span.head_index,
+ "label": (
+ {
+ "label": span.label.label,
+ "label_id": span.label.label_id,
+ "confidence": span.label.confidence,
+ }
+ if span.label
+ else None
+ ),
+ "span_type": span.span_type,
+ }
+ for span in item.spans
+ ]
+
+ relations_data = [
+ {
+ "relation_id": rel.relation_id,
+ "source_span_id": rel.source_span_id,
+ "target_span_id": rel.target_span_id,
+ "label": (
+ {
+ "label": rel.label.label,
+ "label_id": rel.label.label_id,
+ "confidence": rel.label.confidence,
+ }
+ if rel.label
+ else None
+ ),
+ "directed": rel.directed,
+ }
+ for rel in item.span_relations
+ ]
+
+ # Serialize span_spec
+ span_spec_data = None
+ if template.task_spec.span_spec:
+ ss = template.task_spec.span_spec
+ span_spec_data = {
+ "index_mode": ss.index_mode,
+ "interaction_mode": ss.interaction_mode,
+ "label_source": ss.label_source,
+ "labels": ss.labels,
+ "label_colors": ss.label_colors,
+ "allow_overlapping": ss.allow_overlapping,
+ "min_spans": ss.min_spans,
+ "max_spans": ss.max_spans,
+ "enable_relations": ss.enable_relations,
+ "relation_label_source": ss.relation_label_source,
+ "relation_labels": ss.relation_labels,
+ "relation_directed": ss.relation_directed,
+ "min_relations": ss.min_relations,
+ "max_relations": ss.max_relations,
+ "wikidata_language": ss.wikidata_language,
+ "wikidata_entity_types": ss.wikidata_entity_types,
+ "wikidata_result_limit": ss.wikidata_result_limit,
+ }
+
+ # Serialize display config
+ display_config_data = {
+ "highlight_style": span_display.highlight_style,
+ "color_palette": span_display.color_palette,
+ "show_labels": span_display.show_labels,
+ "show_tooltips": span_display.show_tooltips,
+ "token_delimiter": span_display.token_delimiter,
+ "label_position": span_display.label_position,
+ }
+
+ return {
+ "type": "bead-span-label",
+ "tokens": dict(item.tokenized_elements),
+ "space_after": {
+ k: list(v) for k, v in item.token_space_after.items()
+ },
+ "spans": spans_data,
+ "relations": relations_data,
+ "span_spec": span_spec_data,
+ "display_config": display_config_data,
+ "prompt": prompt,
+ "button_label": "Continue",
+ "require_response": True,
+ "data": metadata,
+ }
diff --git a/bead/deployment/jspsych/tsup.config.ts b/bead/deployment/jspsych/tsup.config.ts
index 411e47f..5b74f63 100644
--- a/bead/deployment/jspsych/tsup.config.ts
+++ b/bead/deployment/jspsych/tsup.config.ts
@@ -6,9 +6,13 @@ export default defineConfig({
"plugins/rating": "src/plugins/rating.ts",
"plugins/forced-choice": "src/plugins/forced-choice.ts",
"plugins/cloze-dropdown": "src/plugins/cloze-dropdown.ts",
+ // Span labeling
+ "plugins/span-label": "src/plugins/span-label.ts",
// Library
"lib/list-distributor": "src/lib/list-distributor.ts",
"lib/randomizer": "src/lib/randomizer.ts",
+ "lib/span-renderer": "src/lib/span-renderer.ts",
+ "lib/wikidata-search": "src/lib/wikidata-search.ts",
// Slopit bundle (behavioral capture)
"slopit-bundle": "src/slopit/index.ts",
},
diff --git a/bead/items/__init__.py b/bead/items/__init__.py
index c44e02f..caa6245 100644
--- a/bead/items/__init__.py
+++ b/bead/items/__init__.py
@@ -16,6 +16,16 @@
TaskType,
TimingParams,
)
+from bead.items.spans import (
+ LabelSourceType,
+ Span,
+ SpanIndexMode,
+ SpanInteractionMode,
+ SpanLabel,
+ SpanRelation,
+ SpanSegment,
+ SpanSpec,
+)
__all__ = [
# Item template types
@@ -37,4 +47,13 @@
"ItemCollection",
"ModelOutput",
"UnfilledSlot",
+ # Span types
+ "LabelSourceType",
+ "Span",
+ "SpanIndexMode",
+ "SpanInteractionMode",
+ "SpanLabel",
+ "SpanRelation",
+ "SpanSegment",
+ "SpanSpec",
]
diff --git a/bead/items/item.py b/bead/items/item.py
index 6e54a9f..e226083 100644
--- a/bead/items/item.py
+++ b/bead/items/item.py
@@ -4,9 +4,10 @@
from uuid import UUID
-from pydantic import Field, field_validator
+from pydantic import Field, field_validator, model_validator
from bead.data.base import BeadBaseModel
+from bead.items.spans import Span, SpanRelation
# Type aliases for JSON-serializable metadata values
type MetadataValue = (
@@ -55,6 +56,26 @@ def _empty_str_list() -> list[str]:
return []
+def _empty_tokenized_dict() -> dict[str, list[str]]:
+ """Return empty tokenized elements dict."""
+ return {}
+
+
+def _empty_space_after_dict() -> dict[str, list[bool]]:
+ """Return empty space_after dict."""
+ return {}
+
+
+def _empty_span_list() -> list[Span]:
+ """Return empty Span list."""
+ return []
+
+
+def _empty_span_relation_list() -> list[SpanRelation]:
+ """Return empty SpanRelation list."""
+ return []
+
+
class UnfilledSlot(BeadBaseModel):
"""An unfilled slot in a cloze task item.
@@ -263,6 +284,59 @@ class Item(BeadBaseModel):
item_metadata: dict[str, MetadataValue] = Field(
default_factory=_empty_metadata_dict, description="Additional metadata"
)
+ # Span annotation fields (all default empty, backward compatible)
+ spans: list[Span] = Field(
+ default_factory=_empty_span_list,
+ description="Span annotations for this item",
+ )
+ span_relations: list[SpanRelation] = Field(
+ default_factory=_empty_span_relation_list,
+ description="Relations between spans (directed or undirected)",
+ )
+ tokenized_elements: dict[str, list[str]] = Field(
+ default_factory=_empty_tokenized_dict,
+ description="Tokenized text for span indexing (element_name -> tokens)",
+ )
+ token_space_after: dict[str, list[bool]] = Field(
+ default_factory=_empty_space_after_dict,
+ description="Per-token space_after flags for artifact-free rendering",
+ )
+
+ @model_validator(mode="after")
+ def validate_span_relations(self) -> Item:
+ """Validate all span_relations reference valid span_ids from spans.
+
+ Returns
+ -------
+ Item
+ Validated item.
+
+ Raises
+ ------
+ ValueError
+ If a relation references a span_id not present in spans.
+ """
+ if self.span_relations:
+ if not self.spans:
+ raise ValueError(
+ "Item has span_relations but no spans. "
+ "All relations must reference existing spans."
+ )
+ valid_ids = {s.span_id for s in self.spans}
+ for rel in self.span_relations:
+ if rel.source_span_id not in valid_ids:
+ raise ValueError(
+ f"SpanRelation '{rel.relation_id}' references "
+ f"source_span_id '{rel.source_span_id}' not found "
+ f"in item spans. Valid span_ids: {valid_ids}"
+ )
+ if rel.target_span_id not in valid_ids:
+ raise ValueError(
+ f"SpanRelation '{rel.relation_id}' references "
+ f"target_span_id '{rel.target_span_id}' not found "
+ f"in item spans. Valid span_ids: {valid_ids}"
+ )
+ return self
def get_model_output(
self,
diff --git a/bead/items/item_template.py b/bead/items/item_template.py
index 6f6f283..d7fd251 100644
--- a/bead/items/item_template.py
+++ b/bead/items/item_template.py
@@ -8,6 +8,8 @@
from pydantic import Field, ValidationInfo, field_validator
from bead.data.base import BeadBaseModel
+from bead.items.spans import SpanSpec
+from bead.tokenization.config import TokenizerConfig
# Type aliases for JSON-serializable metadata values
type MetadataValue = (
@@ -49,6 +51,7 @@ def _empty_uuid_list() -> list[UUID]:
"plausibility", # Likelihood/plausibility of events or statements
"comprehension", # Understanding/recall of content
"preference", # Subjective preference between alternatives
+ "extraction", # Extracting structured info (labeled spans) from text
]
TaskType = Literal[
@@ -60,6 +63,7 @@ def _empty_uuid_list() -> list[UUID]:
"categorical", # Pick from unordered categories (UI: dropdown, radio)
"free_text", # Open-ended text (UI: text input, textarea)
"cloze", # Fill-in-the-blank with unfilled slots (UI: inferred)
+ "span_labeling", # Select and label text spans (UI: token selection)
]
ElementRefType = Literal["text", "filled_template_ref"]
@@ -273,6 +277,9 @@ class TaskSpec(BeadBaseModel):
default=None, description="Regex pattern for text validation"
)
max_length: int | None = Field(default=None, description="Maximum text length")
+ span_spec: SpanSpec | None = Field(
+ default=None, description="Span labeling specification"
+ )
@field_validator("prompt")
@classmethod
@@ -360,6 +367,10 @@ class PresentationSpec(BeadBaseModel):
default_factory=_empty_display_format_dict,
description="Display formatting options",
)
+ tokenizer_config: TokenizerConfig | None = Field(
+ default=None,
+ description="Display tokenizer config for span annotation",
+ )
class ItemElement(BeadBaseModel):
diff --git a/bead/items/span_labeling.py b/bead/items/span_labeling.py
new file mode 100644
index 0000000..8726238
--- /dev/null
+++ b/bead/items/span_labeling.py
@@ -0,0 +1,418 @@
+"""Utilities for creating span labeling experimental items.
+
+This module provides language-agnostic utilities for creating items with
+span annotations. Spans can be added to any existing item type (composability)
+or used as standalone span labeling tasks.
+
+Integration Points
+------------------
+- Active Learning: bead/active_learning/ (via alignment module)
+- Deployment: bead/deployment/jspsych/ (span-label plugin)
+- Tokenization: bead/tokenization/ (display-level tokens)
+"""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from uuid import UUID, uuid4
+
+from bead.items.item import Item, MetadataValue
+from bead.items.spans import (
+ LabelSourceType,
+ Span,
+ SpanSpec,
+)
+from bead.tokenization.config import TokenizerConfig
+from bead.tokenization.tokenizers import TokenizedText, create_tokenizer
+
+
+def tokenize_item(
+ item: Item,
+ tokenizer_config: TokenizerConfig | None = None,
+) -> Item:
+ """Tokenize an item's rendered_elements.
+
+ Populates ``tokenized_elements`` and ``token_space_after`` using the
+ configured tokenizer. Returns a new ``Item`` (does not mutate).
+
+ Parameters
+ ----------
+ item : Item
+ Item to tokenize.
+ tokenizer_config : TokenizerConfig | None
+ Tokenizer configuration. If None, uses default (spaCy English).
+
+ Returns
+ -------
+ Item
+ New item with populated tokenized_elements and token_space_after.
+ """
+ if tokenizer_config is None:
+ tokenizer_config = TokenizerConfig()
+
+ tokenize = create_tokenizer(tokenizer_config)
+
+ tokenized_elements: dict[str, list[str]] = {}
+ token_space_after: dict[str, list[bool]] = {}
+
+ for name, text in item.rendered_elements.items():
+ result: TokenizedText = tokenize(text)
+ tokenized_elements[name] = result.token_texts
+ token_space_after[name] = result.space_after_flags
+
+ # Create new item with tokenization data
+ data = item.model_dump()
+ data["tokenized_elements"] = tokenized_elements
+ data["token_space_after"] = token_space_after
+ return Item(**data)
+
+
+def _validate_span_indices(
+ spans: list[Span],
+ tokenized_elements: dict[str, list[str]],
+) -> None:
+ """Validate span indices are within token bounds.
+
+ Parameters
+ ----------
+ spans : list[Span]
+ Spans to validate.
+ tokenized_elements : dict[str, list[str]]
+ Tokenized element data.
+
+ Raises
+ ------
+ ValueError
+ If any span index is out of bounds or references an unknown element.
+ """
+ for span in spans:
+ for segment in span.segments:
+ if segment.element_name not in tokenized_elements:
+ raise ValueError(
+ f"Span '{span.span_id}' segment references element "
+ f"'{segment.element_name}' which is not in "
+ f"tokenized_elements. Available: "
+ f"{list(tokenized_elements.keys())}"
+ )
+ n_tokens = len(tokenized_elements[segment.element_name])
+ for idx in segment.indices:
+ if idx >= n_tokens:
+ raise ValueError(
+ f"Span '{span.span_id}' has index {idx} in element "
+ f"'{segment.element_name}' but element only has "
+ f"{n_tokens} tokens"
+ )
+
+
+def create_span_item(
+ text: str,
+ spans: list[Span],
+ prompt: str,
+ tokenizer_config: TokenizerConfig | None = None,
+ tokens: list[str] | None = None,
+ labels: list[str] | None = None,
+ span_spec: SpanSpec | None = None,
+ item_template_id: UUID | None = None,
+ metadata: dict[str, MetadataValue] | None = None,
+) -> Item:
+ """Create a standalone span labeling item.
+
+ Tokenizes text using config, validates span indices against tokens.
+
+ Parameters
+ ----------
+ text : str
+ The stimulus text.
+ spans : list[Span]
+ Pre-defined span annotations.
+ prompt : str
+ Question or instruction for the participant.
+ tokenizer_config : TokenizerConfig | None
+ Tokenizer configuration. Ignored if ``tokens`` is provided.
+ tokens : list[str] | None
+ Pre-tokenized text (overrides tokenizer).
+ labels : list[str] | None
+ Fixed label set for span labeling.
+ span_spec : SpanSpec | None
+ Span specification. If None, creates a default static spec.
+ item_template_id : UUID | None
+ Template ID. If None, generates a new UUID.
+ metadata : dict[str, MetadataValue] | None
+ Additional item metadata.
+
+ Returns
+ -------
+ Item
+ Span labeling item.
+
+ Raises
+ ------
+ ValueError
+ If text is empty or span indices are out of bounds.
+ """
+ if not text or not text.strip():
+ raise ValueError("text cannot be empty")
+
+ if item_template_id is None:
+ item_template_id = uuid4()
+
+ if span_spec is None:
+ span_spec = SpanSpec(
+ interaction_mode="static",
+ labels=labels,
+ )
+
+ # Store span_spec in item metadata for downstream access
+ span_spec_data: dict[str, MetadataValue] = {}
+ for k, v in span_spec.model_dump(mode="json").items():
+ span_spec_data[k] = v
+
+ # Tokenize
+ if tokens is not None:
+ tokenized_elements = {"text": tokens}
+ # Infer space_after from text
+ token_space_after = {"text": _infer_space_after(tokens, text)}
+ else:
+ if tokenizer_config is None:
+ tokenizer_config = TokenizerConfig()
+ tokenize = create_tokenizer(tokenizer_config)
+ result = tokenize(text)
+ tokenized_elements = {"text": result.token_texts}
+ token_space_after = {"text": result.space_after_flags}
+
+ # Validate spans
+ _validate_span_indices(spans, tokenized_elements)
+
+ item_metadata: dict[str, MetadataValue] = {"_span_spec": span_spec_data}
+ if metadata:
+ item_metadata.update(metadata)
+
+ return Item(
+ item_template_id=item_template_id,
+ rendered_elements={"text": text, "prompt": prompt},
+ spans=spans,
+ tokenized_elements=tokenized_elements,
+ token_space_after=token_space_after,
+ item_metadata=item_metadata,
+ )
+
+
+def create_interactive_span_item(
+ text: str,
+ prompt: str,
+ tokenizer_config: TokenizerConfig | None = None,
+ tokens: list[str] | None = None,
+ label_set: list[str] | None = None,
+ label_source: LabelSourceType = "fixed",
+ item_template_id: UUID | None = None,
+ metadata: dict[str, MetadataValue] | None = None,
+) -> Item:
+ """Create an item for interactive span selection by participants.
+
+ Parameters
+ ----------
+ text : str
+ The stimulus text.
+ prompt : str
+ Instruction for the participant.
+ tokenizer_config : TokenizerConfig | None
+ Tokenizer configuration.
+ tokens : list[str] | None
+ Pre-tokenized text (overrides tokenizer).
+ label_set : list[str] | None
+ Fixed label set (when label_source is "fixed").
+ label_source : LabelSourceType
+ Label source type ("fixed" or "wikidata").
+ item_template_id : UUID | None
+ Template ID. If None, generates a new UUID.
+ metadata : dict[str, MetadataValue] | None
+ Additional item metadata.
+
+ Returns
+ -------
+ Item
+ Interactive span labeling item (no pre-defined spans).
+ """
+ if not text or not text.strip():
+ raise ValueError("text cannot be empty")
+
+ if item_template_id is None:
+ item_template_id = uuid4()
+
+ # Build span spec from label parameters
+ span_spec = SpanSpec(
+ interaction_mode="interactive",
+ label_source=label_source,
+ labels=label_set,
+ )
+ span_spec_data: dict[str, MetadataValue] = {}
+ for k, v in span_spec.model_dump(mode="json").items():
+ span_spec_data[k] = v
+
+ # Tokenize
+ if tokens is not None:
+ tokenized_elements = {"text": tokens}
+ token_space_after = {"text": _infer_space_after(tokens, text)}
+ else:
+ if tokenizer_config is None:
+ tokenizer_config = TokenizerConfig()
+ tokenize = create_tokenizer(tokenizer_config)
+ result = tokenize(text)
+ tokenized_elements = {"text": result.token_texts}
+ token_space_after = {"text": result.space_after_flags}
+
+ item_metadata: dict[str, MetadataValue] = {"_span_spec": span_spec_data}
+ if metadata:
+ item_metadata.update(metadata)
+
+ return Item(
+ item_template_id=item_template_id,
+ rendered_elements={"text": text, "prompt": prompt},
+ spans=[],
+ tokenized_elements=tokenized_elements,
+ token_space_after=token_space_after,
+ item_metadata=item_metadata,
+ )
+
+
+def add_spans_to_item(
+ item: Item,
+ spans: list[Span],
+ tokenizer_config: TokenizerConfig | None = None,
+ span_spec: SpanSpec | None = None,
+) -> Item:
+ """Add span annotations to any existing item.
+
+ This is the key composability function: any item (rating, forced choice,
+ binary, etc.) can have spans added as an overlay. Tokenizes
+ rendered_elements if not already tokenized. Returns a new Item.
+
+ Parameters
+ ----------
+ item : Item
+ Existing item to add spans to.
+ spans : list[Span]
+ Span annotations to add.
+ tokenizer_config : TokenizerConfig | None
+ Tokenizer configuration (used only if item lacks tokenization).
+ span_spec : SpanSpec | None
+ Span specification.
+
+ Returns
+ -------
+ Item
+ New item with spans added.
+
+ Raises
+ ------
+ ValueError
+ If span indices are out of bounds.
+ """
+ # Tokenize if needed
+ if not item.tokenized_elements:
+ item = tokenize_item(item, tokenizer_config)
+
+ # Validate spans
+ _validate_span_indices(spans, item.tokenized_elements)
+
+ # Build new item with spans
+ data = item.model_dump()
+ # Merge existing spans with new ones
+ existing_spans = data.get("spans", [])
+ data["spans"] = existing_spans + [s.model_dump() for s in spans]
+
+ # Store span_spec in item metadata if provided
+ if span_spec is not None:
+ item_metadata = dict(data.get("item_metadata", {}))
+ span_spec_data: dict[str, MetadataValue] = {}
+ for k, v in span_spec.model_dump().items():
+ span_spec_data[k] = v
+ item_metadata["_span_spec"] = span_spec_data
+ data["item_metadata"] = item_metadata
+
+ return Item(**data)
+
+
+def create_span_items_from_texts(
+ texts: list[str],
+ span_extractor: Callable[[str, list[str]], list[Span]],
+ prompt: str,
+ tokenizer_config: TokenizerConfig | None = None,
+ labels: list[str] | None = None,
+ item_template_id: UUID | None = None,
+) -> list[Item]:
+ """Batch create span items with automatic tokenization.
+
+ Parameters
+ ----------
+ texts : list[str]
+ List of stimulus texts.
+ span_extractor : Callable[[str, list[str]], list[Span]]
+ Function that takes (text, tokens) and returns spans.
+ prompt : str
+ Question or instruction for the participant.
+ tokenizer_config : TokenizerConfig | None
+ Tokenizer configuration.
+ labels : list[str] | None
+ Fixed label set.
+ item_template_id : UUID | None
+ Shared template ID. If None, generates one per item.
+
+ Returns
+ -------
+ list[Item]
+ Span labeling items.
+ """
+ if tokenizer_config is None:
+ tokenizer_config = TokenizerConfig()
+ tokenize = create_tokenizer(tokenizer_config)
+
+ items: list[Item] = []
+ for text in texts:
+ result = tokenize(text)
+ tokens = result.token_texts
+ spans = span_extractor(text, tokens)
+ item = create_span_item(
+ text=text,
+ spans=spans,
+ prompt=prompt,
+ tokens=tokens,
+ labels=labels,
+ item_template_id=item_template_id,
+ )
+ items.append(item)
+
+ return items
+
+
+def _infer_space_after(tokens: list[str], text: str) -> list[bool]:
+ """Infer space_after flags from pre-tokenized text.
+
+ Attempts to locate each token in the original text and check if a
+ space follows. Falls back to True for all tokens if alignment fails.
+
+ Parameters
+ ----------
+ tokens : list[str]
+ Token strings.
+ text : str
+ Original text.
+
+ Returns
+ -------
+ list[bool]
+ Per-token space_after flags.
+ """
+ flags: list[bool] = []
+ offset = 0
+ for token in tokens:
+ idx = text.find(token, offset)
+ if idx == -1:
+ # Can't find token; assume space after
+ flags.append(True)
+ else:
+ end = idx + len(token)
+ space_after = end < len(text) and text[end] == " "
+ flags.append(space_after)
+ offset = end
+ return flags
diff --git a/bead/items/spans.py b/bead/items/spans.py
new file mode 100644
index 0000000..b082611
--- /dev/null
+++ b/bead/items/spans.py
@@ -0,0 +1,407 @@
+"""Core span annotation models.
+
+Provides data models for labeled spans, span segments, span labels,
+span relations, and span specifications. Supports discontiguous spans,
+overlapping spans (nested and intersecting), static and interactive modes,
+and two label sources (fixed sets and Wikidata entity search).
+"""
+
+from __future__ import annotations
+
+from typing import Literal
+
+from pydantic import Field, field_validator
+
+from bead.data.base import BeadBaseModel
+
+# Same recursive type as in item.py and item_template.py; duplicated here
+# to avoid circular imports (item.py imports Span from this module).
+type MetadataValue = (
+ str | int | float | bool | None | dict[str, MetadataValue] | list[MetadataValue]
+)
+
+SpanIndexMode = Literal["token", "character"]
+SpanInteractionMode = Literal["static", "interactive"]
+LabelSourceType = Literal["fixed", "wikidata"]
+
+
+# Factory functions for default values
+def _empty_span_segment_list() -> list[SpanSegment]:
+ """Return empty SpanSegment list."""
+ return []
+
+
+def _empty_span_metadata() -> dict[str, MetadataValue]:
+ """Return empty metadata dict."""
+ return {}
+
+
+def _empty_relation_metadata() -> dict[str, MetadataValue]:
+ """Return empty metadata dict."""
+ return {}
+
+
+class SpanSegment(BeadBaseModel):
+ """Contiguous or discontiguous indices within a single element.
+
+ Attributes
+ ----------
+ element_name : str
+ Which rendered element this segment belongs to.
+ indices : list[int]
+ Token or character indices within the element.
+ """
+
+ element_name: str = Field(..., description="Rendered element name")
+ indices: list[int] = Field(..., description="Token or character indices")
+
+ @field_validator("element_name")
+ @classmethod
+ def validate_element_name(cls, v: str) -> str:
+ """Validate element name is not empty.
+
+ Parameters
+ ----------
+ v : str
+ Element name to validate.
+
+ Returns
+ -------
+ str
+ Validated element name.
+
+ Raises
+ ------
+ ValueError
+ If element name is empty.
+ """
+ if not v or not v.strip():
+ raise ValueError("element_name cannot be empty")
+ return v.strip()
+
+ @field_validator("indices")
+ @classmethod
+ def validate_indices(cls, v: list[int]) -> list[int]:
+ """Validate indices are not empty and non-negative.
+
+ Parameters
+ ----------
+ v : list[int]
+ Indices to validate.
+
+ Returns
+ -------
+ list[int]
+ Validated indices.
+
+ Raises
+ ------
+ ValueError
+ If indices are empty or contain negative values.
+ """
+ if not v:
+ raise ValueError("indices cannot be empty")
+ if any(i < 0 for i in v):
+ raise ValueError("indices must be non-negative")
+ return v
+
+
+class SpanLabel(BeadBaseModel):
+ """Label applied to a span or relation.
+
+ Attributes
+ ----------
+ label : str
+ Human-readable label text.
+ label_id : str | None
+ External identifier (e.g. Wikidata QID "Q5").
+ confidence : float | None
+ Confidence score for model-assigned labels.
+ """
+
+ label: str = Field(..., description="Human-readable label text")
+ label_id: str | None = Field(
+ default=None, description="External ID (e.g. Wikidata QID)"
+ )
+ confidence: float | None = Field(
+ default=None, description="Confidence for model-assigned labels"
+ )
+
+ @field_validator("label")
+ @classmethod
+ def validate_label(cls, v: str) -> str:
+ """Validate label is not empty.
+
+ Parameters
+ ----------
+ v : str
+ Label to validate.
+
+ Returns
+ -------
+ str
+ Validated label.
+
+ Raises
+ ------
+ ValueError
+ If label is empty.
+ """
+ if not v or not v.strip():
+ raise ValueError("label cannot be empty")
+ return v.strip()
+
+
+class Span(BeadBaseModel):
+ """Labeled span across one or more elements.
+
+ Supports discontiguous, overlapping, and nested spans.
+
+ Attributes
+ ----------
+ span_id : str
+ Unique identifier within the item.
+ segments : list[SpanSegment]
+ Index segments composing this span.
+ head_index : int | None
+ Syntactic head token index.
+ label : SpanLabel | None
+ Label applied to this span (None = to-be-labeled).
+ span_type : str | None
+ Semantic category (e.g. "entity", "event", "role").
+ span_metadata : dict[str, MetadataValue]
+ Additional span-specific metadata.
+ """
+
+ span_id: str = Field(..., description="Unique span ID within item")
+ segments: list[SpanSegment] = Field(
+ default_factory=_empty_span_segment_list, description="Index segments"
+ )
+ head_index: int | None = Field(
+ default=None, description="Syntactic head token index"
+ )
+ label: SpanLabel | None = Field(
+ default=None, description="Span label (None = to-be-labeled)"
+ )
+ span_type: str | None = Field(
+ default=None, description="Semantic category"
+ )
+ span_metadata: dict[str, MetadataValue] = Field(
+ default_factory=_empty_span_metadata, description="Span metadata"
+ )
+
+ @field_validator("span_id")
+ @classmethod
+ def validate_span_id(cls, v: str) -> str:
+ """Validate span_id is not empty.
+
+ Parameters
+ ----------
+ v : str
+ Span ID to validate.
+
+ Returns
+ -------
+ str
+ Validated span ID.
+
+ Raises
+ ------
+ ValueError
+ If span_id is empty.
+ """
+ if not v or not v.strip():
+ raise ValueError("span_id cannot be empty")
+ return v.strip()
+
+
+class SpanRelation(BeadBaseModel):
+ """A typed, directed relation between two spans.
+
+ Used for semantic role labeling, relation extraction, entity linking,
+ coreference, and similar tasks.
+
+ Attributes
+ ----------
+ relation_id : str
+ Unique identifier within the item.
+ source_span_id : str
+ ``span_id`` of the source span.
+ target_span_id : str
+ ``span_id`` of the target span.
+ label : SpanLabel | None
+ Relation label (reuses SpanLabel for consistency).
+ directed : bool
+ Whether the relation is directed (A->B) or undirected (A--B).
+ relation_metadata : dict[str, MetadataValue]
+ Additional relation-specific metadata.
+ """
+
+ relation_id: str = Field(..., description="Unique relation ID within item")
+ source_span_id: str = Field(..., description="Source span ID")
+ target_span_id: str = Field(..., description="Target span ID")
+ label: SpanLabel | None = Field(
+ default=None, description="Relation label"
+ )
+ directed: bool = Field(
+ default=True, description="Whether relation is directed"
+ )
+ relation_metadata: dict[str, MetadataValue] = Field(
+ default_factory=_empty_relation_metadata,
+ description="Relation metadata",
+ )
+
+ @field_validator("relation_id")
+ @classmethod
+ def validate_relation_id(cls, v: str) -> str:
+ """Validate relation_id is not empty.
+
+ Parameters
+ ----------
+ v : str
+ Relation ID to validate.
+
+ Returns
+ -------
+ str
+ Validated relation ID.
+
+ Raises
+ ------
+ ValueError
+ If relation_id is empty.
+ """
+ if not v or not v.strip():
+ raise ValueError("relation_id cannot be empty")
+ return v.strip()
+
+ @field_validator("source_span_id", "target_span_id")
+ @classmethod
+ def validate_span_ids(cls, v: str) -> str:
+ """Validate span IDs are not empty.
+
+ Parameters
+ ----------
+ v : str
+ Span ID to validate.
+
+ Returns
+ -------
+ str
+ Validated span ID.
+
+ Raises
+ ------
+ ValueError
+ If span ID is empty.
+ """
+ if not v or not v.strip():
+ raise ValueError("span ID cannot be empty")
+ return v.strip()
+
+
+class SpanSpec(BeadBaseModel):
+ """Specification for span labeling behavior.
+
+ Configures how spans are displayed, created, and labeled in an
+ experiment. Supports both fixed label sets and Wikidata entity search
+ for both span labels and relation labels.
+
+ Attributes
+ ----------
+ index_mode : SpanIndexMode
+ Whether spans index by token or character position.
+ interaction_mode : SpanInteractionMode
+ "static" for read-only highlights, "interactive" for participant
+ annotation.
+ label_source : LabelSourceType
+ Source of span labels ("fixed" or "wikidata").
+ labels : list[str] | None
+ Fixed span label set (when label_source is "fixed").
+ label_colors : dict[str, str] | None
+ CSS colors keyed by label name.
+ allow_overlapping : bool
+ Whether overlapping spans are permitted.
+ min_spans : int | None
+ Minimum number of spans required (interactive mode).
+ max_spans : int | None
+ Maximum number of spans allowed (interactive mode).
+ enable_relations : bool
+ Whether relation annotation is enabled.
+ relation_label_source : LabelSourceType
+ Source of relation labels.
+ relation_labels : list[str] | None
+ Fixed relation label set.
+ relation_label_colors : dict[str, str] | None
+ CSS colors keyed by relation label name.
+ relation_directed : bool
+ Default directionality for new relations.
+ min_relations : int | None
+ Minimum number of relations required (interactive mode).
+ max_relations : int | None
+ Maximum number of relations allowed (interactive mode).
+ wikidata_language : str
+ Language for Wikidata entity search.
+ wikidata_entity_types : list[str] | None
+ Restrict Wikidata search to these entity types.
+ wikidata_result_limit : int
+ Maximum number of Wikidata search results.
+ """
+
+ index_mode: SpanIndexMode = Field(
+ default="token", description="Span indexing mode"
+ )
+ interaction_mode: SpanInteractionMode = Field(
+ default="static", description="Span interaction mode"
+ )
+ # Span label config
+ label_source: LabelSourceType = Field(
+ default="fixed", description="Span label source"
+ )
+ labels: list[str] | None = Field(
+ default=None, description="Fixed span label set"
+ )
+ label_colors: dict[str, str] | None = Field(
+ default=None, description="CSS colors per span label"
+ )
+ allow_overlapping: bool = Field(
+ default=True, description="Whether overlapping spans are allowed"
+ )
+ min_spans: int | None = Field(
+ default=None, description="Minimum required spans (interactive)"
+ )
+ max_spans: int | None = Field(
+ default=None, description="Maximum allowed spans (interactive)"
+ )
+ # Relation config
+ enable_relations: bool = Field(
+ default=False, description="Whether relation annotation is enabled"
+ )
+ relation_label_source: LabelSourceType = Field(
+ default="fixed", description="Relation label source"
+ )
+ relation_labels: list[str] | None = Field(
+ default=None, description="Fixed relation label set"
+ )
+ relation_label_colors: dict[str, str] | None = Field(
+ default=None, description="CSS colors per relation label"
+ )
+ relation_directed: bool = Field(
+ default=True, description="Default directionality for relations"
+ )
+ min_relations: int | None = Field(
+ default=None, description="Minimum required relations (interactive)"
+ )
+ max_relations: int | None = Field(
+ default=None, description="Maximum allowed relations (interactive)"
+ )
+ # Wikidata config (shared by span labels and relation labels)
+ wikidata_language: str = Field(
+ default="en", description="Language for Wikidata entity search"
+ )
+ wikidata_entity_types: list[str] | None = Field(
+ default=None, description="Restrict Wikidata entity types"
+ )
+ wikidata_result_limit: int = Field(
+ default=10, description="Max Wikidata search results"
+ )
diff --git a/bead/tokenization/__init__.py b/bead/tokenization/__init__.py
new file mode 100644
index 0000000..da26859
--- /dev/null
+++ b/bead/tokenization/__init__.py
@@ -0,0 +1,32 @@
+"""Configurable multilingual tokenization for span annotation.
+
+This package provides display-level tokenization that splits text into
+word-level tokens for span annotation and UI display. Supports multiple
+NLP backends (spaCy, Stanza, whitespace) for multilingual coverage.
+
+Display tokens are distinct from model (subword) tokens used in active
+learning. The alignment module maps between the two.
+"""
+
+from __future__ import annotations
+
+from bead.tokenization.config import TokenizerBackend, TokenizerConfig
+from bead.tokenization.tokenizers import (
+ DisplayToken,
+ SpacyTokenizer,
+ StanzaTokenizer,
+ TokenizedText,
+ WhitespaceTokenizer,
+ create_tokenizer,
+)
+
+__all__ = [
+ "DisplayToken",
+ "SpacyTokenizer",
+ "StanzaTokenizer",
+ "TokenizedText",
+ "TokenizerBackend",
+ "TokenizerConfig",
+ "WhitespaceTokenizer",
+ "create_tokenizer",
+]
diff --git a/bead/tokenization/alignment.py b/bead/tokenization/alignment.py
new file mode 100644
index 0000000..10c40e3
--- /dev/null
+++ b/bead/tokenization/alignment.py
@@ -0,0 +1,105 @@
+"""Alignment between display tokens and subword model tokens.
+
+Maps display-token-level span indices to subword-token indices so that
+active learning models can consume span annotations created in
+display-token space.
+"""
+
+from __future__ import annotations
+
+
+def align_display_to_subword(
+ display_tokens: list[str],
+ subword_tokenizer: _PreTrainedTokenizerProtocol,
+) -> list[list[int]]:
+ """Map each display token index to its corresponding subword token indices.
+
+ Parameters
+ ----------
+ display_tokens : list[str]
+ Display-level token strings (word-level).
+ subword_tokenizer : PreTrainedTokenizerBase
+ A HuggingFace tokenizer with ``encode`` and ``convert_ids_to_tokens``
+ methods.
+
+ Returns
+ -------
+ list[list[int]]
+ A list where ``entry[i]`` is the list of subword token indices
+ for display token ``i``. Special tokens (CLS, SEP, etc.) are
+ excluded.
+ """
+ alignment: list[list[int]] = []
+ # Tokenize each display token individually to get the mapping
+ subword_offset = 0
+
+ # First, tokenize the full text to get the complete subword sequence
+ full_text = " ".join(display_tokens)
+ full_encoding = subword_tokenizer(full_text, add_special_tokens=False)
+ full_ids: list[int] = full_encoding["input_ids"]
+ full_subword_tokens = subword_tokenizer.convert_ids_to_tokens(full_ids)
+
+ # Now align by tokenizing each display token
+ for display_token in display_tokens:
+ token_encoding = subword_tokenizer(
+ display_token, add_special_tokens=False
+ )
+ token_ids: list[int] = token_encoding["input_ids"]
+ n_subwords = len(token_ids)
+
+ # Map to indices in the full subword sequence
+ indices = list(range(subword_offset, subword_offset + n_subwords))
+ # Clamp to valid range
+ indices = [i for i in indices if i < len(full_subword_tokens)]
+ alignment.append(indices)
+ subword_offset += n_subwords
+
+ return alignment
+
+
+def convert_span_indices(
+ span_indices: list[int],
+ alignment: list[list[int]],
+) -> list[int]:
+ """Convert display-token span indices to subword-token indices.
+
+ Parameters
+ ----------
+ span_indices : list[int]
+ Display-token indices forming the span.
+ alignment : list[list[int]]
+ Alignment from ``align_display_to_subword``.
+
+ Returns
+ -------
+ list[int]
+ Corresponding subword-token indices.
+
+ Raises
+ ------
+ IndexError
+ If any span index is out of range of the alignment.
+ """
+ subword_indices: list[int] = []
+ for idx in span_indices:
+ if idx < 0 or idx >= len(alignment):
+ raise IndexError(
+ f"Span index {idx} is out of range. "
+ f"Alignment covers {len(alignment)} display tokens."
+ )
+ subword_indices.extend(alignment[idx])
+ return sorted(set(subword_indices))
+
+
+class _PreTrainedTokenizerProtocol:
+ """Structural typing protocol for HuggingFace tokenizers."""
+
+ def __call__(
+ self,
+ text: str,
+ add_special_tokens: bool = True,
+ ) -> dict[str, list[int]]: ...
+
+ def convert_ids_to_tokens(
+ self, ids: list[int]
+ ) -> list[str]: ...
diff --git a/bead/tokenization/config.py b/bead/tokenization/config.py
new file mode 100644
index 0000000..c471f8d
--- /dev/null
+++ b/bead/tokenization/config.py
@@ -0,0 +1,45 @@
+"""Tokenizer configuration model.
+
+Aligned with the existing ChunkingSpec pattern in bead.items.item_template,
+which already supports ``parser: Literal["stanza", "spacy"]``.
+"""
+
+from __future__ import annotations
+
+from typing import Literal
+
+from pydantic import BaseModel, ConfigDict, Field
+
+TokenizerBackend = Literal["spacy", "stanza", "whitespace"]
+
+
+class TokenizerConfig(BaseModel):
+ """Configuration for display-level tokenization.
+
+ Controls how text is split into word-level tokens for span annotation
+ and UI display. Supports multiple NLP backends for multilingual coverage.
+
+ Attributes
+ ----------
+ backend : TokenizerBackend
+ Tokenization backend to use. "spacy" (default) supports 49+ languages
+ and is fast and production-grade. "stanza" supports 80+ languages
+ with better coverage for low-resource and morphologically rich
+ languages. "whitespace" is a simple fallback for pre-tokenized text.
+ language : str
+ ISO 639 language code (e.g. "en", "zh", "de", "ar").
+ model_name : str | None
+ Explicit model name (e.g. "en_core_web_sm", "zh_core_web_sm").
+ When None, auto-resolved from language and backend.
+ """
+
+ model_config = ConfigDict(extra="forbid", frozen=True)
+
+ backend: TokenizerBackend = Field(
+ default="spacy", description="Tokenization backend"
+ )
+ language: str = Field(default="en", description="ISO 639 language code")
+ model_name: str | None = Field(
+ default=None,
+ description="Explicit model name; auto-resolved when None",
+ )
diff --git a/bead/tokenization/tokenizers.py b/bead/tokenization/tokenizers.py
new file mode 100644
index 0000000..eb69d6f
--- /dev/null
+++ b/bead/tokenization/tokenizers.py
@@ -0,0 +1,360 @@
+"""Concrete tokenizer implementations.
+
+Provides display-level tokenizers for span annotation. Each tokenizer
+converts raw text into a sequence of ``DisplayToken`` objects that carry
+rendering metadata (``space_after``) for artifact-free reconstruction.
+"""
+
+from __future__ import annotations
+
+import re
+from collections.abc import Callable, Iterator
+
+from pydantic import BaseModel, ConfigDict
+
+from bead.tokenization.config import TokenizerConfig
+
+
+class DisplayToken(BaseModel):
+ """A word-level token with rendering metadata.
+
+ Attributes
+ ----------
+ text : str
+ The token text.
+ space_after : bool
+ Whether whitespace follows this token in the original text.
+ start_char : int
+ Character offset of the token start in the original text.
+ end_char : int
+ Character offset of the token end in the original text.
+ """
+
+ model_config = ConfigDict(extra="forbid", frozen=True)
+
+ text: str
+ space_after: bool = True
+ start_char: int
+ end_char: int
+
+
+class TokenizedText(BaseModel):
+ """Result of display-level tokenization.
+
+ Attributes
+ ----------
+ tokens : list[DisplayToken]
+ The sequence of display tokens.
+ original_text : str
+ The original input text.
+ """
+
+ model_config = ConfigDict(extra="forbid", frozen=True)
+
+ tokens: list[DisplayToken]
+ original_text: str
+
+ @property
+ def token_texts(self) -> list[str]:
+ """Plain token strings (for ``Item.tokenized_elements``).
+
+ Returns
+ -------
+ list[str]
+ List of token text strings.
+ """
+ return [t.text for t in self.tokens]
+
+ @property
+ def space_after_flags(self) -> list[bool]:
+ """Per-token space_after flags (for ``Item.token_space_after``).
+
+ Returns
+ -------
+ list[bool]
+ List of boolean flags.
+ """
+ return [t.space_after for t in self.tokens]
+
+ def render(self) -> str:
+ """Reconstruct display text from tokens with correct spacing.
+
+ Guarantees identical rendering to original when round-tripped.
+
+ Returns
+ -------
+ str
+ Reconstructed text.
+ """
+ parts: list[str] = []
+ for token in self.tokens:
+ parts.append(token.text)
+ if token.space_after:
+ parts.append(" ")
+ return "".join(parts).rstrip()
+
+
+class WhitespaceTokenizer:
+ """Simple whitespace-split tokenizer.
+
+ Fallback for pre-tokenized text or languages not supported by spaCy
+ or Stanza. Splits on whitespace boundaries and infers ``space_after``
+ from the original character offsets.
+ """
+
+ def __call__(self, text: str) -> TokenizedText:
+ """Tokenize text by splitting on whitespace.
+
+ Parameters
+ ----------
+ text : str
+ Input text.
+
+ Returns
+ -------
+ TokenizedText
+ Tokenized result.
+ """
+ tokens: list[DisplayToken] = []
+ for match in re.finditer(r"\S+", text):
+ start = match.start()
+ end = match.end()
+ # space_after is True if there is whitespace after this token
+ space_after = end < len(text) and text[end] == " "
+ tokens.append(
+ DisplayToken(
+ text=match.group(),
+ space_after=space_after,
+ start_char=start,
+ end_char=end,
+ )
+ )
+ return TokenizedText(tokens=tokens, original_text=text)
+
+
+class SpacyTokenizer:
+ """spaCy-based tokenizer.
+
+ Supports 49+ languages. Auto-resolves model from language code if
+ ``model_name`` is not specified. Handles punctuation attachment and
+ multi-word token (MWT) expansion correctly.
+
+ Parameters
+ ----------
+ language : str
+ ISO 639 language code.
+ model_name : str | None
+ Explicit spaCy model name. When None, uses ``{language}_core_web_sm``
+ for common languages, falling back to a blank model.
+ """
+
+ def __init__(self, language: str = "en", model_name: str | None = None) -> None:
+ self._language = language
+ self._model_name = model_name
+ self._nlp: Callable[..., _SpacyDocProtocol] | None = None
+
+ def _load(self) -> Callable[..., _SpacyDocProtocol]:
+ if self._nlp is not None:
+ return self._nlp
+
+ try:
+ import spacy # noqa: PLC0415
+ except ImportError as e:
+ raise ImportError(
+ "spaCy is required for SpacyTokenizer. "
+ "Install it with: pip install 'bead[tokenization]'"
+ ) from e
+
+ model = self._model_name
+ if model is None:
+ model = f"{self._language}_core_web_sm"
+
+ try:
+ self._nlp = spacy.load(model)
+ except OSError:
+ # Fall back to blank model
+ self._nlp = spacy.blank(self._language)
+
+ return self._nlp
+
+ def __call__(self, text: str) -> TokenizedText:
+ """Tokenize text using spaCy.
+
+ Parameters
+ ----------
+ text : str
+ Input text.
+
+ Returns
+ -------
+ TokenizedText
+ Tokenized result with correct ``space_after`` metadata.
+ """
+ nlp = self._load()
+ doc = nlp(text)
+ tokens: list[DisplayToken] = []
+ for token in doc:
+ tokens.append(
+ DisplayToken(
+ text=token.text,
+ space_after=token.whitespace_ != "",
+ start_char=token.idx,
+ end_char=token.idx + len(token.text),
+ )
+ )
+ return TokenizedText(tokens=tokens, original_text=text)
+
+
+class StanzaTokenizer:
+ """Stanza-based tokenizer.
+
+ Supports 80+ languages. Handles multi-word token (MWT) expansion for
+ languages like German, French, and Arabic. Better coverage for
+ low-resource and morphologically rich languages.
+
+ Parameters
+ ----------
+ language : str
+ ISO 639 language code.
+ model_name : str | None
+ Explicit Stanza model/package name. When None, uses the default
+ package for the language.
+ """
+
+ def __init__(self, language: str = "en", model_name: str | None = None) -> None:
+ self._language = language
+ self._model_name = model_name
+ self._nlp: _StanzaPipelineProtocol | None = None
+
+ def _load(self) -> _StanzaPipelineProtocol:
+ if self._nlp is not None:
+ return self._nlp
+
+ try:
+ import stanza # noqa: PLC0415
+ except ImportError as e:
+ raise ImportError(
+ "Stanza is required for StanzaTokenizer. "
+ "Install it with: pip install 'bead[tokenization]'"
+ ) from e
+
+ kwargs: dict[str, str | bool] = {
+ "lang": self._language,
+ "processors": "tokenize",
+ "verbose": False,
+ }
+ if self._model_name is not None:
+ kwargs["package"] = self._model_name
+
+ try:
+ self._nlp = stanza.Pipeline(**kwargs)
+ except Exception:
+ # Download model and retry
+ stanza.download(self._language, verbose=False)
+ self._nlp = stanza.Pipeline(**kwargs)
+
+ return self._nlp
+
+ def __call__(self, text: str) -> TokenizedText:
+ """Tokenize text using Stanza.
+
+ Parameters
+ ----------
+ text : str
+ Input text.
+
+ Returns
+ -------
+ TokenizedText
+ Tokenized result with correct ``space_after`` metadata.
+ """
+ nlp = self._load()
+ doc = nlp(text)
+ tokens: list[DisplayToken] = []
+ for sentence in doc.sentences:
+ for token in sentence.tokens:
+ start_char = token.start_char
+ end_char = token.end_char
+ # Stanza tokens have a misc field; space_after can be
+ # inferred from character offsets or the SpaceAfter=No
+ # annotation in the misc field.
+ space_after = True
+ if hasattr(token, "misc") and token.misc:
+ if "SpaceAfter=No" in token.misc:
+ space_after = False
+ elif end_char < len(text):
+ space_after = text[end_char] == " "
+
+ tokens.append(
+ DisplayToken(
+ text=token.text,
+ space_after=space_after,
+ start_char=start_char,
+ end_char=end_char,
+ )
+ )
+ return TokenizedText(tokens=tokens, original_text=text)
+
+
+def create_tokenizer(config: TokenizerConfig) -> Callable[[str], TokenizedText]:
+ """Return a tokenization function for the given config.
+
+ Lazy-loads the NLP backend (spaCy/Stanza) on first call.
+
+ Parameters
+ ----------
+ config : TokenizerConfig
+ Tokenizer configuration.
+
+ Returns
+ -------
+ Callable[[str], TokenizedText]
+ A callable that tokenizes text.
+
+ Raises
+ ------
+ ValueError
+ If the backend is not recognized.
+ """
+ if config.backend == "whitespace":
+ return WhitespaceTokenizer()
+ elif config.backend == "spacy":
+ return SpacyTokenizer(
+ language=config.language, model_name=config.model_name
+ )
+ elif config.backend == "stanza":
+ return StanzaTokenizer(
+ language=config.language, model_name=config.model_name
+ )
+ else:
+ raise ValueError(f"Unknown tokenizer backend: {config.backend}")
+
+
+# Structural typing protocols for spaCy/Stanza (avoids hard imports)
+class _SpacyTokenProtocol:
+ text: str
+ whitespace_: str
+ idx: int
+
+
+class _SpacyDocProtocol:
+ def __iter__(self) -> Iterator[_SpacyTokenProtocol]: ... # noqa: D105
+
+
+class _StanzaTokenProtocol:
+ text: str
+ start_char: int
+ end_char: int
+ misc: str | None
+
+
+class _StanzaSentenceProtocol:
+ tokens: list[_StanzaTokenProtocol]
+
+
+class _StanzaDocProtocol:
+ sentences: list[_StanzaSentenceProtocol]
+
+
+class _StanzaPipelineProtocol:
+ def __call__(self, text: str) -> _StanzaDocProtocol: ... # noqa: D102
diff --git a/docs/api/items.md b/docs/api/items.md
index b73e894..182d784 100644
--- a/docs/api/items.md
+++ b/docs/api/items.md
@@ -1,6 +1,6 @@
# bead.items
-Stage 3 of the bead pipeline: experimental item construction with 8 task types.
+Stage 3 of the bead pipeline: experimental item construction with 9 task types.
## Core Classes
@@ -56,6 +56,20 @@ Stage 3 of the bead pipeline: experimental item construction with 8 task types.
show_root_heading: true
show_source: false
+## Span Annotation Models
+
+::: bead.items.spans
+ options:
+ show_root_heading: true
+ show_source: false
+
+## Span Labeling Utilities
+
+::: bead.items.span_labeling
+ options:
+ show_root_heading: true
+ show_source: false
+
## Item Construction
::: bead.items.constructor
diff --git a/docs/api/tokenization.md b/docs/api/tokenization.md
new file mode 100644
index 0000000..eab394b
--- /dev/null
+++ b/docs/api/tokenization.md
@@ -0,0 +1,24 @@
+# bead.tokenization
+
+Configurable multilingual tokenization for span annotation and UI display.
+
+## Configuration
+
+::: bead.tokenization.config
+ options:
+ show_root_heading: true
+ show_source: false
+
+## Tokenizers
+
+::: bead.tokenization.tokenizers
+ options:
+ show_root_heading: true
+ show_source: false
+
+## Display-to-Subword Alignment
+
+::: bead.tokenization.alignment
+ options:
+ show_root_heading: true
+ show_source: false
diff --git a/docs/user-guide/api/deployment.md b/docs/user-guide/api/deployment.md
index aae4739..428cc87 100644
--- a/docs/user-guide/api/deployment.md
+++ b/docs/user-guide/api/deployment.md
@@ -272,6 +272,80 @@ When slopit is enabled, behavioral data is included in the trial results:
}
```
+## Span Labeling Experiments
+
+Generate span labeling experiments where participants annotate text spans.
+
+**Basic span labeling experiment**:
+
+```python
+from bead.deployment.distribution import (
+ DistributionStrategyType,
+ ListDistributionStrategy,
+)
+from bead.deployment.jspsych.config import ExperimentConfig, SpanDisplayConfig
+
+# configure a span labeling experiment
+config = ExperimentConfig(
+ experiment_type="span_labeling",
+ title="Named Entity Annotation",
+ description="Annotate named entities in text",
+ instructions="Select spans of text and assign entity labels.",
+ distribution_strategy=ListDistributionStrategy(
+ strategy_type=DistributionStrategyType.BALANCED
+ ),
+ randomize_trial_order=True,
+ show_progress_bar=True,
+ use_jatos=True,
+ span_display=SpanDisplayConfig(
+ highlight_style="background",
+ show_labels=True,
+ label_position="inline",
+ ),
+)
+```
+
+**Customizing span display**:
+
+```python
+from bead.deployment.jspsych.config import SpanDisplayConfig
+
+# configure visual appearance for span highlights
+span_display = SpanDisplayConfig(
+ highlight_style="underline",
+ color_palette=["#BBDEFB", "#C8E6C9", "#FFE0B2", "#F8BBD0"],
+ show_labels=True,
+ show_tooltips=True,
+ label_position="tooltip",
+)
+```
+
+**Composing spans with other task types**: span annotations can be added to any experiment type. When items contain span data, the span display renders automatically as an overlay on the existing task. For example, a rating experiment can show highlighted spans while participants rate sentences:
+
+```python
+from bead.deployment.distribution import (
+ DistributionStrategyType,
+ ListDistributionStrategy,
+)
+from bead.deployment.jspsych.config import ExperimentConfig, SpanDisplayConfig
+
+# rating experiment with span highlights
+config = ExperimentConfig(
+ experiment_type="likert_rating",
+ title="Acceptability with Entity Highlights",
+ description="Rate sentences with highlighted entities",
+ instructions="Rate how natural each sentence sounds. Entities are highlighted.",
+ distribution_strategy=ListDistributionStrategy(
+ strategy_type=DistributionStrategyType.BALANCED
+ ),
+ use_jatos=True,
+ span_display=SpanDisplayConfig(
+ highlight_style="background",
+ show_labels=True,
+ ),
+)
+```
+
## Experiment Configuration
**ExperimentConfig** parameters:
diff --git a/docs/user-guide/api/items.md b/docs/user-guide/api/items.md
index 0e1c29a..71cce6d 100644
--- a/docs/user-guide/api/items.md
+++ b/docs/user-guide/api/items.md
@@ -4,7 +4,7 @@ The `bead.items` module provides task-type-specific utilities for creating exper
## Task-Type Utilities
-The items module provides 8 task-type-specific utilities for programmatic item creation. All utilities follow a consistent API pattern.
+The items module provides 9 task-type-specific utilities for programmatic item creation. All utilities follow a consistent API pattern.
### Forced Choice
@@ -200,6 +200,157 @@ item = create_magnitude_item(
print(f"Created magnitude item with unit: {item.item_metadata.get('unit')}")
```
+### Span Labeling
+
+Create items with span annotations for entity labeling, relation extraction, and similar tasks. Spans can be added as standalone items or composed onto any existing task type.
+
+**Standalone span item with pre-defined spans**:
+
+```python
+from bead.items.span_labeling import create_span_item
+from bead.items.spans import Span, SpanSegment, SpanLabel
+from bead.tokenization.config import TokenizerConfig
+
+# create a span item with pre-tokenized text and labeled spans
+item = create_span_item(
+ text="The quick brown fox jumps over the lazy dog",
+ spans=[
+ Span(
+ span_id="s1",
+ segments=[SpanSegment(element_name="text", indices=[1, 2])],
+ label=SpanLabel(label="ADJ"),
+ ),
+ Span(
+ span_id="s2",
+ segments=[SpanSegment(element_name="text", indices=[3])],
+ label=SpanLabel(label="NOUN"),
+ ),
+ ],
+ prompt="Review the highlighted spans:",
+ tokenizer_config=TokenizerConfig(backend="whitespace"),
+)
+
+print(f"Created span item with {len(item.spans)} spans")
+print(f"Tokens: {item.tokenized_elements['text']}")
+```
+
+**Interactive span item for participant annotation**:
+
+```python
+from bead.items.span_labeling import create_interactive_span_item
+from bead.tokenization.config import TokenizerConfig
+
+# create an interactive item where participants select and label spans
+item = create_interactive_span_item(
+ text="Marie Curie discovered radium in Paris.",
+ prompt="Select all named entities and assign a label:",
+ tokenizer_config=TokenizerConfig(backend="whitespace"),
+ label_set=["PERSON", "LOCATION", "SUBSTANCE"],
+ label_source="fixed",
+)
+
+print(f"Created interactive span item")
+print(f"Tokens: {item.tokenized_elements['text']}")
+```
+
+**Composing spans onto an existing item** (any task type):
+
+```python
+from bead.items.ordinal_scale import create_ordinal_scale_item
+from bead.items.span_labeling import add_spans_to_item
+from bead.items.spans import Span, SpanSegment, SpanLabel
+from bead.tokenization.config import TokenizerConfig
+
+# start with a rating item
+rating_item = create_ordinal_scale_item(
+ text="The scientist discovered a new element.",
+ scale_bounds=(1, 7),
+ prompt="Rate the naturalness of this sentence:",
+)
+
+# add span annotations as an overlay
+item_with_spans = add_spans_to_item(
+ item=rating_item,
+ spans=[
+ Span(
+ span_id="agent",
+ segments=[SpanSegment(element_name="text", indices=[0, 1])],
+ label=SpanLabel(label="AGENT"),
+ ),
+ ],
+ tokenizer_config=TokenizerConfig(backend="whitespace"),
+)
+
+print(f"Original spans: {len(rating_item.spans)}")
+print(f"After adding: {len(item_with_spans.spans)}")
+```
+
+**Adding tokenization to an existing item**:
+
+```python
+from bead.items.binary import create_binary_item
+from bead.items.span_labeling import tokenize_item
+from bead.tokenization.config import TokenizerConfig
+
+# create a binary item without tokenization
+binary_item = create_binary_item(
+ text="The cat sat on the mat.",
+ prompt="Is this sentence grammatical?",
+)
+
+# add tokenization data
+tokenized = tokenize_item(
+ binary_item,
+ tokenizer_config=TokenizerConfig(backend="whitespace"),
+)
+
+print(f"Tokenized elements: {list(tokenized.tokenized_elements.keys())}")
+print(f"Tokens for 'text': {tokenized.tokenized_elements.get('text')}")
+```
+
+**Batch creation with a span extractor**:
+
+```python
+from bead.items.span_labeling import create_span_items_from_texts
+from bead.items.spans import Span, SpanSegment, SpanLabel
+from bead.tokenization.config import TokenizerConfig
+
+
+# define a span extractor function
+def find_capitalized_spans(text: str, tokens: list[str]) -> list[Span]:
+ """Extract spans for capitalized words (simple NER heuristic)."""
+ spans: list[Span] = []
+ for i, token in enumerate(tokens):
+ if token[0].isupper() and i > 0:
+ spans.append(
+ Span(
+ span_id=f"cap_{i}",
+ segments=[SpanSegment(element_name="text", indices=[i])],
+ label=SpanLabel(label="ENTITY"),
+ )
+ )
+ return spans
+
+
+sentences = [
+ "Marie Curie was born in Warsaw.",
+ "Albert Einstein developed relativity in Berlin.",
+ "Ada Lovelace wrote the first algorithm.",
+]
+
+items = create_span_items_from_texts(
+ texts=sentences,
+ span_extractor=find_capitalized_spans,
+ prompt="Review the detected entities:",
+ tokenizer_config=TokenizerConfig(backend="whitespace"),
+ labels=["ENTITY"],
+)
+
+print(f"Created {len(items)} span items")
+for item in items:
+ print(f" {item.rendered_elements['text']}: {len(item.spans)} spans")
+```
+
## Language Model Scoring
Score items with language models:
@@ -319,7 +470,7 @@ print(f"Created {len(afc_items)} 2AFC items")
1. **NO Silent Fallbacks**: All errors raise `ValueError` with descriptive messages
2. **Strict Validation**: Use `zip(..., strict=True)`, explicit parameter checks
-3. **Consistent API**: Same pattern across all 8 task types
+3. **Consistent API**: Same pattern across all 9 task types
4. **Automatic Metadata**: Utilities populate task-specific metadata (n_options, scale_min/max, etc.)
## Task Type Summary
@@ -334,6 +485,7 @@ print(f"Created {len(afc_items)} 2AFC items")
| `cloze` | Fill-in-blank | `create_cloze_item()` |
| `multi_select` | Checkboxes | `create_multi_select_item()` |
| `magnitude` | Numeric | `create_magnitude_item()` |
+| `span_labeling` | Entity/span annotation | `create_span_item()` |
## Next Steps
diff --git a/mkdocs.yml b/mkdocs.yml
index c6803fa..98cbbfc 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -93,6 +93,7 @@ nav:
- bead.items: api/items.md
- bead.lists: api/lists.md
- bead.deployment: api/deployment.md
+ - bead.tokenization: api/tokenization.md
- bead.active_learning: api/active_learning.md
- bead.config: api/config.md
- bead.data: api/data.md
diff --git a/pyproject.toml b/pyproject.toml
index cde3739..edeb938 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "bead"
-version = "0.1.0"
+version = "0.2.0"
description = "Lexicon and Template Collection Construction Pipeline for Acceptability and Inference Judgment Data"
authors = [{name = "Aaron Steven White", email = "aaron.white@rochester.edu"}]
readme = "README.md"
@@ -88,6 +88,10 @@ ui = [
behavioral-analysis = [
"slopit>=0.1.0",
]
+tokenization = [
+ "spacy>=3.7",
+ "stanza>=1.8",
+]
[project.scripts]
bead = "bead.cli.main:cli"
diff --git a/tests/deployment/jspsych/test_span_trials.py b/tests/deployment/jspsych/test_span_trials.py
new file mode 100644
index 0000000..bf85da5
--- /dev/null
+++ b/tests/deployment/jspsych/test_span_trials.py
@@ -0,0 +1,356 @@
+"""Tests for span-aware trial generation."""
+
+from __future__ import annotations
+
+from uuid import uuid4
+
+import pytest
+
+from bead.deployment.distribution import (
+ DistributionStrategyType,
+ ListDistributionStrategy,
+)
+from bead.deployment.jspsych.config import (
+ ExperimentConfig,
+ RatingScaleConfig,
+ SpanDisplayConfig,
+)
+from bead.deployment.jspsych.trials import (
+ _generate_span_stimulus_html,
+ _create_span_labeling_trial,
+ _serialize_item_metadata,
+ create_trial,
+)
+from bead.items.item import Item
+from bead.items.item_template import ItemTemplate, PresentationSpec, TaskSpec
+from bead.items.spans import (
+ Span,
+ SpanLabel,
+ SpanRelation,
+ SpanSegment,
+ SpanSpec,
+)
+
+
+def _make_strategy() -> ListDistributionStrategy:
+ """Create a test distribution strategy."""
+ return ListDistributionStrategy(
+ strategy_type=DistributionStrategyType.BALANCED
+ )
+
+
+class TestSpanMetadataSerialization:
+ """Test span data in _serialize_item_metadata."""
+
+ def test_spans_serialized(self) -> None:
+ """Test that spans are included in metadata."""
+ span = Span(
+ span_id="span_0",
+ segments=[SpanSegment(element_name="text", indices=[0, 1])],
+ label=SpanLabel(label="Person"),
+ )
+
+ item = Item(
+ item_template_id=uuid4(),
+ rendered_elements={"text": "John Smith"},
+ spans=[span],
+ tokenized_elements={"text": ["John", "Smith"]},
+ token_space_after={"text": [True, False]},
+ )
+
+ template = ItemTemplate(
+ name="test",
+ judgment_type="extraction",
+ task_type="span_labeling",
+ task_spec=TaskSpec(prompt="Label entities"),
+ presentation_spec=PresentationSpec(mode="static"),
+ )
+
+ metadata = _serialize_item_metadata(item, template)
+
+ assert "spans" in metadata
+ assert len(metadata["spans"]) == 1
+ assert metadata["spans"][0]["span_id"] == "span_0"
+ assert metadata["spans"][0]["label"]["label"] == "Person"
+
+ def test_tokenized_elements_serialized(self) -> None:
+ """Test that tokenized_elements are included."""
+ item = Item(
+ item_template_id=uuid4(),
+ tokenized_elements={"text": ["Hello", "world"]},
+ token_space_after={"text": [True, False]},
+ )
+
+ template = ItemTemplate(
+ name="test",
+ judgment_type="acceptability",
+ task_type="ordinal_scale",
+ task_spec=TaskSpec(prompt="Rate this"),
+ presentation_spec=PresentationSpec(mode="static"),
+ )
+
+ metadata = _serialize_item_metadata(item, template)
+
+ assert metadata["tokenized_elements"] == {"text": ["Hello", "world"]}
+ assert metadata["token_space_after"] == {"text": [True, False]}
+
+ def test_span_relations_serialized(self) -> None:
+ """Test that span_relations are serialized."""
+ spans = [
+ Span(
+ span_id="span_0",
+ segments=[SpanSegment(element_name="text", indices=[0])],
+ ),
+ Span(
+ span_id="span_1",
+ segments=[SpanSegment(element_name="text", indices=[2])],
+ ),
+ ]
+
+ rel = SpanRelation(
+ relation_id="rel_0",
+ source_span_id="span_0",
+ target_span_id="span_1",
+ label=SpanLabel(label="agent-of"),
+ directed=True,
+ )
+
+ item = Item(
+ item_template_id=uuid4(),
+ spans=spans,
+ span_relations=[rel],
+ )
+
+ template = ItemTemplate(
+ name="test",
+ judgment_type="extraction",
+ task_type="span_labeling",
+ task_spec=TaskSpec(prompt="Label"),
+ presentation_spec=PresentationSpec(mode="static"),
+ )
+
+ metadata = _serialize_item_metadata(item, template)
+
+ assert len(metadata["span_relations"]) == 1
+ assert metadata["span_relations"][0]["directed"] is True
+ assert metadata["span_relations"][0]["source_span_id"] == "span_0"
+
+ def test_span_spec_serialized(self) -> None:
+ """Test that span_spec from template is serialized."""
+ item = Item(item_template_id=uuid4())
+
+ span_spec = SpanSpec(
+ interaction_mode="interactive",
+ labels=["PER", "ORG"],
+ min_spans=1,
+ )
+
+ template = ItemTemplate(
+ name="test",
+ judgment_type="extraction",
+ task_type="span_labeling",
+ task_spec=TaskSpec(prompt="Label", span_spec=span_spec),
+ presentation_spec=PresentationSpec(mode="static"),
+ )
+
+ metadata = _serialize_item_metadata(item, template)
+
+ assert metadata["span_spec"] is not None
+ assert metadata["span_spec"]["interaction_mode"] == "interactive"
+ assert metadata["span_spec"]["labels"] == ["PER", "ORG"]
+
+ def test_no_span_spec_is_none(self) -> None:
+ """Test that span_spec is None when not set."""
+ item = Item(item_template_id=uuid4())
+
+ template = ItemTemplate(
+ name="test",
+ judgment_type="acceptability",
+ task_type="ordinal_scale",
+ task_spec=TaskSpec(prompt="Rate"),
+ presentation_spec=PresentationSpec(mode="static"),
+ )
+
+ metadata = _serialize_item_metadata(item, template)
+ assert metadata["span_spec"] is None
+
+
+class TestSpanStimulusHtml:
+ """Test span-highlighted stimulus HTML generation."""
+
+ def test_static_spans_markup(self) -> None:
+ """Test that static spans produce highlighted tokens."""
+ span = Span(
+ span_id="span_0",
+ segments=[SpanSegment(element_name="text", indices=[0, 1])],
+ label=SpanLabel(label="Person"),
+ )
+
+ item = Item(
+ item_template_id=uuid4(),
+ spans=[span],
+ tokenized_elements={"text": ["John", "Smith", "is", "here"]},
+ token_space_after={"text": [True, True, True, False]},
+ )
+
+ config = SpanDisplayConfig()
+ html = _generate_span_stimulus_html(item, config)
+
+ assert "bead-token" in html
+ assert "highlighted" in html
+ assert 'data-index="0"' in html
+ assert "John" in html
+
+ def test_no_tokenization_fallback(self) -> None:
+ """Test fallback when no tokenized_elements."""
+ item = Item(
+ item_template_id=uuid4(),
+ rendered_elements={"text": "Hello world"},
+ )
+
+ config = SpanDisplayConfig()
+ html = _generate_span_stimulus_html(item, config)
+
+ assert "stimulus-container" in html
+
+ def test_space_after_rendering(self) -> None:
+ """Test that space_after controls spacing in output."""
+ item = Item(
+ item_template_id=uuid4(),
+ spans=[],
+ tokenized_elements={"text": ["don", "'t"]},
+ token_space_after={"text": [False, False]},
+ )
+
+ config = SpanDisplayConfig()
+ html = _generate_span_stimulus_html(item, config)
+
+ # Tokens should be adjacent (no space between don and 't)
+ assert "don 't" in html or "don " in html
+
+
+class TestSpanLabelingTrial:
+ """Test standalone span labeling trial creation."""
+
+ def test_trial_structure(self) -> None:
+ """Test span labeling trial has correct structure."""
+ item = Item(
+ item_template_id=uuid4(),
+ tokenized_elements={"text": ["The", "cat"]},
+ token_space_after={"text": [True, False]},
+ )
+
+ template = ItemTemplate(
+ name="test",
+ judgment_type="extraction",
+ task_type="span_labeling",
+ task_spec=TaskSpec(prompt="Select entities"),
+ presentation_spec=PresentationSpec(mode="static"),
+ )
+
+ config = SpanDisplayConfig()
+ trial = _create_span_labeling_trial(item, template, config, 0)
+
+ assert trial["type"] == "bead-span-label"
+ assert trial["prompt"] == "Select entities"
+ assert trial["button_label"] == "Continue"
+ assert trial["data"]["trial_type"] == "span_labeling"
+
+ def test_trial_metadata(self) -> None:
+ """Test span labeling trial includes metadata."""
+ item = Item(
+ item_template_id=uuid4(),
+ rendered_elements={"text": "Hello"},
+ tokenized_elements={"text": ["Hello"]},
+ )
+
+ template = ItemTemplate(
+ name="test",
+ judgment_type="extraction",
+ task_type="span_labeling",
+ task_spec=TaskSpec(prompt="Label"),
+ presentation_spec=PresentationSpec(mode="static"),
+ )
+
+ config = SpanDisplayConfig()
+ trial = _create_span_labeling_trial(item, template, config, 5)
+
+ assert trial["data"]["trial_number"] == 5
+ assert trial["data"]["item_id"] == str(item.id)
+
+ def test_trial_includes_span_data(self) -> None:
+ """Test span labeling trial includes spans, relations, spec, config."""
+ span = Span(
+ span_id="s0",
+ segments=[SpanSegment(element_name="text", indices=[0])],
+ label=SpanLabel(label="PER", confidence=0.95),
+ )
+ item = Item(
+ item_template_id=uuid4(),
+ spans=[span],
+ tokenized_elements={"text": ["Alice", "ran"]},
+ token_space_after={"text": [True, False]},
+ )
+
+ span_spec = SpanSpec(
+ interaction_mode="interactive",
+ labels=["PER", "ORG"],
+ )
+ template = ItemTemplate(
+ name="test",
+ judgment_type="extraction",
+ task_type="span_labeling",
+ task_spec=TaskSpec(prompt="Label", span_spec=span_spec),
+ presentation_spec=PresentationSpec(mode="static"),
+ )
+
+ config = SpanDisplayConfig()
+ trial = _create_span_labeling_trial(item, template, config, 0)
+
+ # Span data
+ assert len(trial["spans"]) == 1
+ assert trial["spans"][0]["span_id"] == "s0"
+ assert trial["spans"][0]["label"]["confidence"] == 0.95
+
+ # Relations (empty)
+ assert trial["relations"] == []
+
+ # Span spec
+ assert trial["span_spec"] is not None
+ assert trial["span_spec"]["interaction_mode"] == "interactive"
+ assert trial["span_spec"]["labels"] == ["PER", "ORG"]
+
+ # Display config
+ assert trial["display_config"] is not None
+ assert trial["display_config"]["highlight_style"] == "background"
+
+
+class TestSpanCompositeTrial:
+ """Test composite trials (e.g., rating + spans)."""
+
+ def test_span_labeling_experiment_type(self) -> None:
+ """Test create_trial routes to span labeling."""
+ item = Item(
+ item_template_id=uuid4(),
+ tokenized_elements={"text": ["Hello"]},
+ )
+
+ template = ItemTemplate(
+ name="test",
+ judgment_type="extraction",
+ task_type="span_labeling",
+ task_spec=TaskSpec(prompt="Label"),
+ presentation_spec=PresentationSpec(mode="static"),
+ )
+
+ config = ExperimentConfig(
+ experiment_type="span_labeling",
+ title="Test",
+ description="Test",
+ instructions="Test",
+ distribution_strategy=_make_strategy(),
+ )
+
+ trial = create_trial(item, template, config, 0)
+
+ assert trial["type"] == "bead-span-label"
diff --git a/tests/items/test_span_labeling.py b/tests/items/test_span_labeling.py
new file mode 100644
index 0000000..05e0eea
--- /dev/null
+++ b/tests/items/test_span_labeling.py
@@ -0,0 +1,315 @@
+"""Tests for span labeling item creation utilities."""
+
+from __future__ import annotations
+
+from uuid import uuid4
+
+import pytest
+
+from bead.items.item import Item
+from bead.items.span_labeling import (
+ add_spans_to_item,
+ create_interactive_span_item,
+ create_span_item,
+ create_span_items_from_texts,
+ tokenize_item,
+)
+from bead.items.spans import (
+ Span,
+ SpanLabel,
+ SpanSegment,
+ SpanSpec,
+)
+from bead.tokenization.config import TokenizerConfig
+
+
+class TestCreateSpanItem:
+ """Test create_span_item() function."""
+
+ def test_create_basic(self) -> None:
+ """Test creating a basic span item."""
+ spans = [
+ Span(
+ span_id="span_0",
+ segments=[SpanSegment(element_name="text", indices=[0, 1])],
+ label=SpanLabel(label="Person"),
+ ),
+ ]
+
+ item = create_span_item(
+ text="John Smith is here.",
+ spans=spans,
+ prompt="Identify the entities.",
+ tokenizer_config=TokenizerConfig(backend="whitespace"),
+ )
+
+ assert isinstance(item, Item)
+ assert item.rendered_elements["text"] == "John Smith is here."
+ assert item.rendered_elements["prompt"] == "Identify the entities."
+ assert len(item.spans) == 1
+ assert item.tokenized_elements["text"] == ["John", "Smith", "is", "here."]
+
+ def test_with_pre_tokenized(self) -> None:
+ """Test creating span item with pre-tokenized text."""
+ tokens = ["John", "Smith", "is", "here", "."]
+ spans = [
+ Span(
+ span_id="span_0",
+ segments=[SpanSegment(element_name="text", indices=[0, 1])],
+ label=SpanLabel(label="Person"),
+ ),
+ ]
+
+ item = create_span_item(
+ text="John Smith is here.",
+ spans=spans,
+ prompt="Identify the entities.",
+ tokens=tokens,
+ )
+
+ assert item.tokenized_elements["text"] == tokens
+
+ def test_empty_text_raises(self) -> None:
+ """Test that empty text raises error."""
+ with pytest.raises(ValueError, match="text cannot be empty"):
+ create_span_item(text="", spans=[], prompt="Test")
+
+ def test_invalid_span_index_raises(self) -> None:
+ """Test that out-of-bounds span index raises error."""
+ spans = [
+ Span(
+ span_id="span_0",
+ segments=[SpanSegment(element_name="text", indices=[99])],
+ ),
+ ]
+
+ with pytest.raises(ValueError, match="index 99"):
+ create_span_item(
+ text="Short text.",
+ spans=spans,
+ prompt="Test",
+ tokenizer_config=TokenizerConfig(backend="whitespace"),
+ )
+
+ def test_with_labels(self) -> None:
+ """Test creating span item with label set."""
+ item = create_span_item(
+ text="The cat sat.",
+ spans=[],
+ prompt="Label spans.",
+ labels=["Person", "Location"],
+ tokenizer_config=TokenizerConfig(backend="whitespace"),
+ )
+
+ assert isinstance(item, Item)
+
+ def test_with_metadata(self) -> None:
+ """Test creating span item with metadata."""
+ item = create_span_item(
+ text="Hello world.",
+ spans=[],
+ prompt="Test",
+ metadata={"source": "test"},
+ tokenizer_config=TokenizerConfig(backend="whitespace"),
+ )
+
+ assert item.item_metadata["source"] == "test"
+
+
+class TestCreateInteractiveSpanItem:
+ """Test create_interactive_span_item() function."""
+
+ def test_create_basic(self) -> None:
+ """Test creating interactive span item."""
+ item = create_interactive_span_item(
+ text="The cat sat on the mat.",
+ prompt="Select all entities.",
+ tokenizer_config=TokenizerConfig(backend="whitespace"),
+ )
+
+ assert isinstance(item, Item)
+ assert item.spans == [] # No pre-defined spans
+ assert "text" in item.tokenized_elements
+
+ def test_with_label_set(self) -> None:
+ """Test interactive item with fixed label set."""
+ item = create_interactive_span_item(
+ text="Hello world.",
+ prompt="Select spans.",
+ label_set=["PER", "ORG", "LOC"],
+ tokenizer_config=TokenizerConfig(backend="whitespace"),
+ )
+
+ assert isinstance(item, Item)
+
+ def test_empty_text_raises(self) -> None:
+ """Test that empty text raises error."""
+ with pytest.raises(ValueError, match="text cannot be empty"):
+ create_interactive_span_item(text="", prompt="Test")
+
+
+class TestAddSpansToItem:
+ """Test add_spans_to_item() function."""
+
+ def test_add_to_ordinal_item(self) -> None:
+ """Test adding spans to an ordinal scale item."""
+ # Create base ordinal item
+ item = Item(
+ item_template_id=uuid4(),
+ rendered_elements={"text": "The cat sat.", "prompt": "Rate this."},
+ item_metadata={"scale_min": 1, "scale_max": 7},
+ )
+
+ spans = [
+ Span(
+ span_id="span_0",
+ segments=[SpanSegment(element_name="text", indices=[1])],
+ label=SpanLabel(label="Entity"),
+ ),
+ ]
+
+ result = add_spans_to_item(
+ item,
+ spans,
+ tokenizer_config=TokenizerConfig(backend="whitespace"),
+ )
+
+ assert len(result.spans) == 1
+ assert result.item_metadata["scale_min"] == 1 # preserved
+ assert result.rendered_elements["text"] == "The cat sat." # preserved
+
+ def test_add_to_already_tokenized(self) -> None:
+ """Test adding spans to already tokenized item."""
+ item = Item(
+ item_template_id=uuid4(),
+ rendered_elements={"text": "Hello world"},
+ tokenized_elements={"text": ["Hello", "world"]},
+ token_space_after={"text": [True, False]},
+ )
+
+ spans = [
+ Span(
+ span_id="span_0",
+ segments=[SpanSegment(element_name="text", indices=[0])],
+ ),
+ ]
+
+ result = add_spans_to_item(item, spans)
+
+ assert len(result.spans) == 1
+ # Token data preserved
+ assert result.tokenized_elements["text"] == ["Hello", "world"]
+
+ def test_preserves_existing_fields(self) -> None:
+ """Test that adding spans preserves all existing fields."""
+ template_id = uuid4()
+ item = Item(
+ item_template_id=template_id,
+ rendered_elements={"text": "Test text"},
+ options=["A", "B"],
+ item_metadata={"key": "value"},
+ )
+
+ result = add_spans_to_item(
+ item,
+ spans=[],
+ tokenizer_config=TokenizerConfig(backend="whitespace"),
+ )
+
+ assert result.item_template_id == template_id
+ assert result.options == ["A", "B"]
+ assert result.item_metadata["key"] == "value"
+
+ def test_invalid_span_raises(self) -> None:
+ """Test that invalid span index raises error."""
+ item = Item(
+ item_template_id=uuid4(),
+ rendered_elements={"text": "Hi"},
+ tokenized_elements={"text": ["Hi"]},
+ )
+
+ spans = [
+ Span(
+ span_id="span_0",
+ segments=[SpanSegment(element_name="text", indices=[99])],
+ ),
+ ]
+
+ with pytest.raises(ValueError, match="index 99"):
+ add_spans_to_item(item, spans)
+
+
+class TestTokenizeItem:
+ """Test tokenize_item() function."""
+
+ def test_whitespace_tokenizer(self) -> None:
+ """Test tokenizing with whitespace backend."""
+ item = Item(
+ item_template_id=uuid4(),
+ rendered_elements={"text": "Hello world"},
+ )
+
+ result = tokenize_item(
+ item, TokenizerConfig(backend="whitespace")
+ )
+
+ assert result.tokenized_elements["text"] == ["Hello", "world"]
+ assert result.token_space_after["text"] == [True, False]
+
+ def test_multiple_elements(self) -> None:
+ """Test tokenizing item with multiple rendered elements."""
+ item = Item(
+ item_template_id=uuid4(),
+ rendered_elements={
+ "context": "The cat sat.",
+ "target": "The dog ran.",
+ },
+ )
+
+ result = tokenize_item(
+ item, TokenizerConfig(backend="whitespace")
+ )
+
+ assert "context" in result.tokenized_elements
+ assert "target" in result.tokenized_elements
+ assert result.tokenized_elements["context"] == ["The", "cat", "sat."]
+ assert result.tokenized_elements["target"] == ["The", "dog", "ran."]
+
+ def test_default_config(self) -> None:
+ """Test tokenizing with default config."""
+ item = Item(
+ item_template_id=uuid4(),
+ rendered_elements={"text": "Hello"},
+ )
+
+ # Should not raise (uses spacy by default, or falls back)
+ result = tokenize_item(item)
+ assert "text" in result.tokenized_elements
+
+
+class TestCreateSpanItemsFromTexts:
+ """Test create_span_items_from_texts() function."""
+
+ def test_batch_create(self) -> None:
+ """Test batch creating span items."""
+
+ def extractor(text: str, tokens: list[str]) -> list[Span]:
+ return [
+ Span(
+ span_id="span_0",
+ segments=[SpanSegment(element_name="text", indices=[0])],
+ label=SpanLabel(label="First"),
+ ),
+ ]
+
+ items = create_span_items_from_texts(
+ texts=["Hello world.", "Goodbye world."],
+ span_extractor=extractor,
+ prompt="Label first word.",
+ tokenizer_config=TokenizerConfig(backend="whitespace"),
+ )
+
+ assert len(items) == 2
+ assert all(len(item.spans) == 1 for item in items)
+ assert items[0].rendered_elements["text"] == "Hello world."
+ assert items[1].rendered_elements["text"] == "Goodbye world."
diff --git a/tests/items/test_spans.py b/tests/items/test_spans.py
new file mode 100644
index 0000000..e95a6b7
--- /dev/null
+++ b/tests/items/test_spans.py
@@ -0,0 +1,403 @@
+"""Tests for span annotation models."""
+
+from __future__ import annotations
+
+from uuid import uuid4
+
+import pytest
+
+from bead.items.item import Item
+from bead.items.spans import (
+ LabelSourceType,
+ Span,
+ SpanIndexMode,
+ SpanInteractionMode,
+ SpanLabel,
+ SpanRelation,
+ SpanSegment,
+ SpanSpec,
+)
+
+
+class TestSpanSegment:
+ """Test SpanSegment model."""
+
+ def test_create(self) -> None:
+ """Test creating a SpanSegment."""
+ segment = SpanSegment(element_name="text", indices=[0, 1, 2])
+
+ assert segment.element_name == "text"
+ assert segment.indices == [0, 1, 2]
+
+ def test_empty_element_name_raises(self) -> None:
+ """Test that empty element_name raises error."""
+ with pytest.raises(ValueError, match="element_name cannot be empty"):
+ SpanSegment(element_name="", indices=[0])
+
+ def test_empty_indices_raises(self) -> None:
+ """Test that empty indices raises error."""
+ with pytest.raises(ValueError, match="indices cannot be empty"):
+ SpanSegment(element_name="text", indices=[])
+
+ def test_negative_indices_raises(self) -> None:
+ """Test that negative indices raises error."""
+ with pytest.raises(ValueError, match="indices must be non-negative"):
+ SpanSegment(element_name="text", indices=[-1, 0])
+
+ def test_discontiguous_indices(self) -> None:
+ """Test discontiguous indices are valid."""
+ segment = SpanSegment(element_name="text", indices=[0, 2, 5])
+ assert segment.indices == [0, 2, 5]
+
+
+class TestSpanLabel:
+ """Test SpanLabel model."""
+
+ def test_create_basic(self) -> None:
+ """Test creating a basic SpanLabel."""
+ label = SpanLabel(label="Person")
+
+ assert label.label == "Person"
+ assert label.label_id is None
+ assert label.confidence is None
+
+ def test_create_with_id(self) -> None:
+ """Test creating SpanLabel with external ID."""
+ label = SpanLabel(label="human", label_id="Q5")
+
+ assert label.label == "human"
+ assert label.label_id == "Q5"
+
+ def test_create_with_confidence(self) -> None:
+ """Test creating SpanLabel with confidence."""
+ label = SpanLabel(label="Person", confidence=0.95)
+
+ assert label.confidence == 0.95
+
+ def test_empty_label_raises(self) -> None:
+ """Test that empty label raises error."""
+ with pytest.raises(ValueError, match="label cannot be empty"):
+ SpanLabel(label="")
+
+
+class TestSpan:
+ """Test Span model."""
+
+ def test_create_basic(self) -> None:
+ """Test creating a basic Span."""
+ span = Span(
+ span_id="span_0",
+ segments=[SpanSegment(element_name="text", indices=[0, 1])],
+ )
+
+ assert span.span_id == "span_0"
+ assert len(span.segments) == 1
+ assert span.label is None
+ assert span.head_index is None
+
+ def test_create_with_label(self) -> None:
+ """Test creating Span with label."""
+ span = Span(
+ span_id="span_0",
+ segments=[SpanSegment(element_name="text", indices=[0, 1])],
+ label=SpanLabel(label="Person"),
+ )
+
+ assert span.label is not None
+ assert span.label.label == "Person"
+
+ def test_discontiguous_segments(self) -> None:
+ """Test span with discontiguous segments."""
+ span = Span(
+ span_id="span_0",
+ segments=[
+ SpanSegment(element_name="text", indices=[0, 1]),
+ SpanSegment(element_name="text", indices=[5, 6]),
+ ],
+ )
+
+ assert len(span.segments) == 2
+
+ def test_cross_element_segments(self) -> None:
+ """Test span with segments across elements."""
+ span = Span(
+ span_id="span_0",
+ segments=[
+ SpanSegment(element_name="context", indices=[0, 1]),
+ SpanSegment(element_name="target", indices=[2, 3]),
+ ],
+ )
+
+ assert span.segments[0].element_name == "context"
+ assert span.segments[1].element_name == "target"
+
+ def test_with_metadata(self) -> None:
+ """Test span with metadata."""
+ span = Span(
+ span_id="span_0",
+ segments=[SpanSegment(element_name="text", indices=[0])],
+ span_metadata={"source": "manual"},
+ )
+
+ assert span.span_metadata["source"] == "manual"
+
+ def test_empty_span_id_raises(self) -> None:
+ """Test that empty span_id raises error."""
+ with pytest.raises(ValueError, match="span_id cannot be empty"):
+ Span(span_id="")
+
+
+class TestSpanRelation:
+ """Test SpanRelation model."""
+
+ def test_create_directed(self) -> None:
+ """Test creating a directed relation."""
+ rel = SpanRelation(
+ relation_id="rel_0",
+ source_span_id="span_0",
+ target_span_id="span_1",
+ label=SpanLabel(label="agent-of"),
+ )
+
+ assert rel.relation_id == "rel_0"
+ assert rel.directed is True
+ assert rel.label is not None
+ assert rel.label.label == "agent-of"
+
+ def test_create_undirected(self) -> None:
+ """Test creating an undirected relation."""
+ rel = SpanRelation(
+ relation_id="rel_0",
+ source_span_id="span_0",
+ target_span_id="span_1",
+ directed=False,
+ )
+
+ assert rel.directed is False
+
+ def test_with_wikidata_label(self) -> None:
+ """Test relation with Wikidata label_id."""
+ rel = SpanRelation(
+ relation_id="rel_0",
+ source_span_id="span_0",
+ target_span_id="span_1",
+ label=SpanLabel(label="instance of", label_id="P31"),
+ )
+
+ assert rel.label is not None
+ assert rel.label.label_id == "P31"
+
+ def test_empty_relation_id_raises(self) -> None:
+ """Test that empty relation_id raises error."""
+ with pytest.raises(ValueError, match="relation_id cannot be empty"):
+ SpanRelation(
+ relation_id="",
+ source_span_id="span_0",
+ target_span_id="span_1",
+ )
+
+ def test_empty_span_id_raises(self) -> None:
+ """Test that empty source/target span_id raises error."""
+ with pytest.raises(ValueError, match="span ID cannot be empty"):
+ SpanRelation(
+ relation_id="rel_0",
+ source_span_id="",
+ target_span_id="span_1",
+ )
+
+
+class TestSpanOnItem:
+ """Test span fields on Item model."""
+
+ def test_item_with_no_spans(self) -> None:
+ """Test item defaults have empty span fields."""
+ item = Item(item_template_id=uuid4())
+
+ assert item.spans == []
+ assert item.span_relations == []
+ assert item.tokenized_elements == {}
+ assert item.token_space_after == {}
+
+ def test_item_with_spans(self) -> None:
+ """Test item with span annotations."""
+ span = Span(
+ span_id="span_0",
+ segments=[SpanSegment(element_name="text", indices=[0, 1])],
+ label=SpanLabel(label="Person"),
+ )
+
+ item = Item(
+ item_template_id=uuid4(),
+ rendered_elements={"text": "The cat"},
+ spans=[span],
+ tokenized_elements={"text": ["The", "cat"]},
+ token_space_after={"text": [True, False]},
+ )
+
+ assert len(item.spans) == 1
+ assert item.spans[0].span_id == "span_0"
+ assert item.tokenized_elements["text"] == ["The", "cat"]
+
+ def test_item_with_relations(self) -> None:
+ """Test item with span relations."""
+ spans = [
+ Span(
+ span_id="span_0",
+ segments=[SpanSegment(element_name="text", indices=[0])],
+ ),
+ Span(
+ span_id="span_1",
+ segments=[SpanSegment(element_name="text", indices=[2])],
+ ),
+ ]
+
+ rel = SpanRelation(
+ relation_id="rel_0",
+ source_span_id="span_0",
+ target_span_id="span_1",
+ label=SpanLabel(label="agent-of"),
+ )
+
+ item = Item(
+ item_template_id=uuid4(),
+ rendered_elements={"text": "The cat chased the mouse"},
+ spans=spans,
+ span_relations=[rel],
+ tokenized_elements={"text": ["The", "cat", "chased", "the", "mouse"]},
+ )
+
+ assert len(item.span_relations) == 1
+ assert item.span_relations[0].source_span_id == "span_0"
+
+ def test_relation_invalid_span_id_raises(self) -> None:
+ """Test that relation referencing invalid span_id raises error."""
+ spans = [
+ Span(
+ span_id="span_0",
+ segments=[SpanSegment(element_name="text", indices=[0])],
+ ),
+ ]
+
+ rel = SpanRelation(
+ relation_id="rel_0",
+ source_span_id="span_0",
+ target_span_id="span_99", # does not exist
+ )
+
+ with pytest.raises(ValueError, match="target_span_id 'span_99' not found"):
+ Item(
+ item_template_id=uuid4(),
+ spans=spans,
+ span_relations=[rel],
+ )
+
+ def test_relations_without_spans_raises(self) -> None:
+ """Test that relations without any spans raises error."""
+ rel = SpanRelation(
+ relation_id="rel_0",
+ source_span_id="span_0",
+ target_span_id="span_1",
+ )
+
+ with pytest.raises(ValueError, match="has span_relations but no spans"):
+ Item(
+ item_template_id=uuid4(),
+ span_relations=[rel],
+ )
+
+ def test_serialization_round_trip(self) -> None:
+ """Test Item with spans serializes and deserializes correctly."""
+ span = Span(
+ span_id="span_0",
+ segments=[SpanSegment(element_name="text", indices=[0, 1])],
+ label=SpanLabel(label="Person", label_id="Q5"),
+ )
+
+ item = Item(
+ item_template_id=uuid4(),
+ rendered_elements={"text": "John Smith"},
+ spans=[span],
+ tokenized_elements={"text": ["John", "Smith"]},
+ token_space_after={"text": [True, False]},
+ )
+
+ # Serialize and deserialize
+ data = item.model_dump()
+ restored = Item(**data)
+
+ assert len(restored.spans) == 1
+ assert restored.spans[0].span_id == "span_0"
+ assert restored.spans[0].label is not None
+ assert restored.spans[0].label.label == "Person"
+ assert restored.spans[0].label.label_id == "Q5"
+ assert restored.tokenized_elements == {"text": ["John", "Smith"]}
+ assert restored.token_space_after == {"text": [True, False]}
+
+
+class TestSpanSpec:
+ """Test SpanSpec model."""
+
+ def test_default_values(self) -> None:
+ """Test SpanSpec default values."""
+ spec = SpanSpec()
+
+ assert spec.index_mode == "token"
+ assert spec.interaction_mode == "static"
+ assert spec.label_source == "fixed"
+ assert spec.labels is None
+ assert spec.allow_overlapping is True
+ assert spec.enable_relations is False
+ assert spec.wikidata_language == "en"
+ assert spec.wikidata_result_limit == 10
+
+ def test_interactive_with_labels(self) -> None:
+ """Test interactive span spec with fixed labels."""
+ spec = SpanSpec(
+ interaction_mode="interactive",
+ label_source="fixed",
+ labels=["Person", "Organization", "Location"],
+ min_spans=1,
+ max_spans=10,
+ )
+
+ assert spec.interaction_mode == "interactive"
+ assert spec.labels == ["Person", "Organization", "Location"]
+ assert spec.min_spans == 1
+ assert spec.max_spans == 10
+
+ def test_wikidata_config(self) -> None:
+ """Test Wikidata label source configuration."""
+ spec = SpanSpec(
+ label_source="wikidata",
+ wikidata_language="de",
+ wikidata_entity_types=["item"],
+ wikidata_result_limit=20,
+ )
+
+ assert spec.label_source == "wikidata"
+ assert spec.wikidata_language == "de"
+ assert spec.wikidata_entity_types == ["item"]
+
+ def test_relation_config(self) -> None:
+ """Test relation annotation configuration."""
+ spec = SpanSpec(
+ enable_relations=True,
+ relation_label_source="fixed",
+ relation_labels=["agent-of", "patient-of"],
+ relation_directed=True,
+ min_relations=0,
+ max_relations=5,
+ )
+
+ assert spec.enable_relations is True
+ assert spec.relation_labels == ["agent-of", "patient-of"]
+ assert spec.relation_directed is True
+
+ def test_label_colors(self) -> None:
+ """Test label color configuration."""
+ spec = SpanSpec(
+ labels=["PER", "ORG"],
+ label_colors={"PER": "#FF0000", "ORG": "#00FF00"},
+ )
+
+ assert spec.label_colors == {"PER": "#FF0000", "ORG": "#00FF00"}
diff --git a/tests/tokenization/__init__.py b/tests/tokenization/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/tokenization/test_tokenizers.py b/tests/tokenization/test_tokenizers.py
new file mode 100644
index 0000000..d7a0a34
--- /dev/null
+++ b/tests/tokenization/test_tokenizers.py
@@ -0,0 +1,199 @@
+"""Tests for tokenizer implementations."""
+
+from __future__ import annotations
+
+import pytest
+
+from bead.tokenization.config import TokenizerConfig
+from bead.tokenization.tokenizers import (
+ DisplayToken,
+ TokenizedText,
+ WhitespaceTokenizer,
+ create_tokenizer,
+)
+
+
+class TestWhitespaceTokenizer:
+ """Test WhitespaceTokenizer."""
+
+ def test_simple_sentence(self) -> None:
+ """Test tokenizing a simple English sentence."""
+ tokenizer = WhitespaceTokenizer()
+ result = tokenizer("The cat sat on the mat.")
+
+ assert isinstance(result, TokenizedText)
+ assert result.token_texts == ["The", "cat", "sat", "on", "the", "mat."]
+
+ def test_empty_string(self) -> None:
+ """Test tokenizing empty string."""
+ tokenizer = WhitespaceTokenizer()
+ result = tokenizer("")
+
+ assert result.tokens == []
+ assert result.token_texts == []
+
+ def test_single_word(self) -> None:
+ """Test tokenizing single word."""
+ tokenizer = WhitespaceTokenizer()
+ result = tokenizer("Hello")
+
+ assert result.token_texts == ["Hello"]
+ assert result.tokens[0].space_after is False
+
+ def test_space_after_flags(self) -> None:
+ """Test space_after flags are correct."""
+ tokenizer = WhitespaceTokenizer()
+ result = tokenizer("The cat sat.")
+
+ assert result.tokens[0].space_after is True # "The "
+ assert result.tokens[1].space_after is True # "cat "
+ assert result.tokens[2].space_after is False # "sat." (end)
+
+ def test_multiple_spaces(self) -> None:
+ """Test handling of multiple spaces."""
+ tokenizer = WhitespaceTokenizer()
+ result = tokenizer("The cat")
+
+ # Whitespace tokenizer treats any whitespace as delimiter
+ assert len(result.tokens) == 2
+
+ def test_character_offsets(self) -> None:
+ """Test character offsets are correct."""
+ tokenizer = WhitespaceTokenizer()
+ result = tokenizer("The cat")
+
+ assert result.tokens[0].start_char == 0
+ assert result.tokens[0].end_char == 3
+ assert result.tokens[1].start_char == 4
+ assert result.tokens[1].end_char == 7
+
+ def test_round_trip(self) -> None:
+ """Test that render() reproduces the original text."""
+ tokenizer = WhitespaceTokenizer()
+ text = "The cat sat on the mat."
+ result = tokenizer(text)
+
+ assert result.render() == text
+
+ def test_round_trip_trailing_space(self) -> None:
+ """Test round trip strips trailing space."""
+ tokenizer = WhitespaceTokenizer()
+ result = tokenizer("Hello world")
+
+ assert result.render() == "Hello world"
+
+ def test_pre_tokenized(self) -> None:
+ """Test with pre-tokenized text (tab-separated)."""
+ tokenizer = WhitespaceTokenizer()
+ result = tokenizer("word1\tword2\tword3")
+
+ assert len(result.tokens) == 3
+
+
+class TestDisplayToken:
+ """Test DisplayToken model."""
+
+ def test_create(self) -> None:
+ """Test creating a DisplayToken."""
+ token = DisplayToken(
+ text="hello",
+ space_after=True,
+ start_char=0,
+ end_char=5,
+ )
+
+ assert token.text == "hello"
+ assert token.space_after is True
+ assert token.start_char == 0
+ assert token.end_char == 5
+
+ def test_default_space_after(self) -> None:
+ """Test default space_after is True."""
+ token = DisplayToken(text="hello", start_char=0, end_char=5)
+ assert token.space_after is True
+
+
+class TestTokenizedText:
+ """Test TokenizedText model."""
+
+ def test_token_texts(self) -> None:
+ """Test token_texts property."""
+ result = TokenizedText(
+ tokens=[
+ DisplayToken(text="The", start_char=0, end_char=3),
+ DisplayToken(text="cat", start_char=4, end_char=7),
+ ],
+ original_text="The cat",
+ )
+
+ assert result.token_texts == ["The", "cat"]
+
+ def test_space_after_flags(self) -> None:
+ """Test space_after_flags property."""
+ result = TokenizedText(
+ tokens=[
+ DisplayToken(text="The", space_after=True, start_char=0, end_char=3),
+ DisplayToken(text="cat", space_after=False, start_char=4, end_char=7),
+ ],
+ original_text="The cat",
+ )
+
+ assert result.space_after_flags == [True, False]
+
+ def test_render(self) -> None:
+ """Test render reconstructs text."""
+ result = TokenizedText(
+ tokens=[
+ DisplayToken(text="The", space_after=True, start_char=0, end_char=3),
+ DisplayToken(text="cat", space_after=True, start_char=4, end_char=7),
+ DisplayToken(
+ text="sat.", space_after=False, start_char=8, end_char=12
+ ),
+ ],
+ original_text="The cat sat.",
+ )
+
+ assert result.render() == "The cat sat."
+
+ def test_render_no_trailing_space(self) -> None:
+ """Test render strips trailing spaces."""
+ result = TokenizedText(
+ tokens=[
+ DisplayToken(text="hello", space_after=True, start_char=0, end_char=5),
+ ],
+ original_text="hello ",
+ )
+
+ assert result.render() == "hello"
+
+
+class TestCreateTokenizer:
+ """Test create_tokenizer factory."""
+
+ def test_whitespace_backend(self) -> None:
+ """Test creating whitespace tokenizer."""
+ config = TokenizerConfig(backend="whitespace")
+ tokenizer = create_tokenizer(config)
+
+ result = tokenizer("Hello world")
+ assert result.token_texts == ["Hello", "world"]
+
+ def test_unknown_backend_raises(self) -> None:
+ """Test that unknown backend raises ValueError."""
+ # Pydantic validation will reject invalid Literal values
+ with pytest.raises(Exception):
+ TokenizerConfig(backend="unknown")
+
+ def test_spacy_backend_without_install(self) -> None:
+ """Test that spaCy backend works or raises ImportError gracefully."""
+ config = TokenizerConfig(backend="spacy", language="en")
+ tokenizer = create_tokenizer(config)
+ # Just test that the factory returns something callable
+ assert callable(tokenizer)
+
+ def test_default_config(self) -> None:
+ """Test default config uses spacy."""
+ config = TokenizerConfig()
+ assert config.backend == "spacy"
+ assert config.language == "en"
+ assert config.model_name is None
From 80a2f65b942f788f08f3620e8c735a177356c31d Mon Sep 17 00:00:00 2001
From: Aaron Steven White
Date: Sat, 7 Feb 2026 17:44:50 -0500
Subject: [PATCH 02/11] Adds binary-choice, categorical, free-text, magnitude,
multi-select, and slider-rating plugins with IIFE gallery bundle.
---
bead/deployment/jspsych/package.json | 1 +
.../jspsych/src/gallery/gallery-bundle.ts | 47 +++++
.../jspsych/src/plugins/binary-choice.ts | 146 ++++++++++++++
.../jspsych/src/plugins/categorical.ts | 163 +++++++++++++++
.../jspsych/src/plugins/free-text.ts | 182 +++++++++++++++++
.../jspsych/src/plugins/magnitude.ts | 179 +++++++++++++++++
.../jspsych/src/plugins/multi-select.ts | 189 ++++++++++++++++++
.../jspsych/src/plugins/slider-rating.ts | 185 +++++++++++++++++
.../deployment/jspsych/tsup.gallery.config.ts | 24 +++
9 files changed, 1116 insertions(+)
create mode 100644 bead/deployment/jspsych/src/gallery/gallery-bundle.ts
create mode 100644 bead/deployment/jspsych/src/plugins/binary-choice.ts
create mode 100644 bead/deployment/jspsych/src/plugins/categorical.ts
create mode 100644 bead/deployment/jspsych/src/plugins/free-text.ts
create mode 100644 bead/deployment/jspsych/src/plugins/magnitude.ts
create mode 100644 bead/deployment/jspsych/src/plugins/multi-select.ts
create mode 100644 bead/deployment/jspsych/src/plugins/slider-rating.ts
create mode 100644 bead/deployment/jspsych/tsup.gallery.config.ts
diff --git a/bead/deployment/jspsych/package.json b/bead/deployment/jspsych/package.json
index 0286eaf..ac2e8ae 100644
--- a/bead/deployment/jspsych/package.json
+++ b/bead/deployment/jspsych/package.json
@@ -16,6 +16,7 @@
},
"scripts": {
"build": "tsup",
+ "build:gallery": "tsup --config tsup.gallery.config.ts",
"build:watch": "tsup --watch",
"typecheck": "tsc --noEmit",
"lint": "biome lint src",
diff --git a/bead/deployment/jspsych/src/gallery/gallery-bundle.ts b/bead/deployment/jspsych/src/gallery/gallery-bundle.ts
new file mode 100644
index 0000000..e34d6d2
--- /dev/null
+++ b/bead/deployment/jspsych/src/gallery/gallery-bundle.ts
@@ -0,0 +1,47 @@
+/**
+ * Gallery bundle entry point
+ *
+ * Registers all bead jsPsych plugins as window globals so they can be
+ * loaded via a single
-## eng/argument_structure
+---
-[Content to be added: walkthrough of gallery/eng/argument_structure example]
+## Judgment Tasks
-## Adding Your Own Examples
+### Likert Rating Scale
-[Content to be added]
+Rate a sentence on a discrete scale with labeled endpoints. This example tests the verb *hope* in an NP-to-VP raising frame from [MegaAcceptability](https://megaattitude.io) (White & Rawlins, 2016). The dataset tests every English clause-embedding verb across syntactic frames using generic NPs like *someone* and *something*.
+
+=== "Demo"
+
+
+
+=== "Python"
+
+ ```python
+ from bead.items.ordinal_scale import create_ordinal_scale_item
+
+ item = create_ordinal_scale_item(
+ text="Someone hoped someone to leave.",
+ prompt="How acceptable is this sentence?",
+ scale_bounds=(1, 7),
+ scale_labels={
+ 1: "Completely unacceptable",
+ 4: "Neutral",
+ 7: "Completely acceptable",
+ },
+ )
+ ```
+
+=== "Trial JSON"
+
+ ```json
+ {
+ "type": "bead-rating",
+ "prompt": "How acceptable is this sentence?",
+ "scale_min": 1,
+ "scale_max": 7,
+ "scale_labels": {
+ "1": "Completely unacceptable",
+ "4": "Neutral",
+ "7": "Completely acceptable"
+ },
+ "metadata": {"verb": "hope", "frame": "NP_to_VP"}
+ }
+ ```
+
+### Slider Rating
+
+Continuous rating on a slider scale. This example tests the factive verb *forget* from [MegaVeridicality](https://megaattitude.io) (White & Rawlins, 2018). The task asks whether the embedded event (someone leaving) actually happened given the matrix verb.
+
+=== "Demo"
+
+
+
+=== "Python"
+
+ ```python
+ from bead.items.ordinal_scale import create_ordinal_scale_item
+
+ item = create_ordinal_scale_item(
+ text="Someone forgot that someone left.",
+ prompt="Based on this sentence, did someone leave?",
+ scale_bounds=(0, 100),
+ scale_labels={
+ 0: "Certainly did not happen",
+ 100: "Certainly happened",
+ },
+ )
+ ```
+
+=== "Trial JSON"
+
+ ```json
+ {
+ "type": "bead-slider-rating",
+ "prompt": "Based on this sentence, did someone leave?",
+ "slider_min": 0,
+ "slider_max": 100,
+ "slider_start": 50,
+ "labels": ["Certainly did not happen", "Certainly happened"],
+ "metadata": {"verb": "forget", "frame": "that_S"}
+ }
+ ```
+
+### Forced Choice
+
+Choose between two alternatives. This example contrasts *want* (which permits NP-to-VP) against *hope* (which does not) from [MegaAcceptability](https://megaattitude.io) (White & Rawlins, 2016).
+
+=== "Demo"
+
+
+
+=== "Python"
+
+ ```python
+ from bead.items.forced_choice import create_forced_choice_item
+
+ item = create_forced_choice_item(
+ alternatives=[
+ "Someone wanted someone to leave.",
+ "Someone hoped someone to leave.",
+ ],
+ prompt="Which sentence sounds more acceptable?",
+ )
+ ```
+
+=== "Trial JSON"
+
+ ```json
+ {
+ "type": "bead-forced-choice",
+ "prompt": "Which sentence sounds more acceptable?",
+ "alternatives": [
+ "Someone wanted someone to leave.",
+ "Someone hoped someone to leave."
+ ],
+ "metadata": {"verbs": ["want", "hope"], "frame": "NP_to_VP"}
+ }
+ ```
+
+### Binary Judgment
+
+Yes/No acceptability judgment. This example tests the verb *persuade* in an NP-to-VP object-control frame from [MegaAcceptability](https://megaattitude.io) (White & Rawlins, 2016).
+
+=== "Demo"
+
+
+
+=== "Python"
+
+ ```python
+ from bead.items.binary import create_binary_item
+
+ item = create_binary_item(
+ text="Someone persuaded someone to leave.",
+ prompt="Is this sentence acceptable?",
+ options=["Acceptable", "Unacceptable"],
+ )
+ ```
+
+=== "Trial JSON"
+
+ ```json
+ {
+ "type": "bead-binary-choice",
+ "prompt": "Is this sentence acceptable?",
+ "stimulus": "Someone persuaded someone to leave.",
+ "choices": ["Acceptable", "Unacceptable"],
+ "metadata": {"verb": "persuade", "frame": "NP_to_VP"}
+ }
+ ```
+
+### Categorical Classification
+
+Select one category from an unordered set. This example uses a factivity recast from the [Diverse Natural Language Inference Corpus](https://decomp.io) (White et al., 2018). DNC recasts existing annotations (FrameNet, factuality, etc.) into NLI premise-hypothesis format.
+
+=== "Demo"
+
+
+
+=== "Python"
+
+ ```python
+ from bead.items.categorical import create_categorical_item
+
+ item = create_categorical_item(
+ text=(
+ "Premise: The doctor managed to treat the patient.\n"
+ "Hypothesis: The patient was treated."
+ ),
+ prompt="What is the relationship between the premise and hypothesis?",
+ categories=["Entailment", "Neutral", "Contradiction"],
+ )
+ ```
+
+=== "Trial JSON"
+
+ ```json
+ {
+ "type": "bead-categorical",
+ "prompt": "What is the relationship between the premise and hypothesis?",
+ "categories": ["Entailment", "Neutral", "Contradiction"],
+ "metadata": {"recast_type": "factivity"}
+ }
+ ```
+
+### Magnitude Estimation
+
+Enter a numeric value with optional bounds and unit. This example uses magnitude estimation for acceptability, testing the verb *believe* in an NP-to-be-NP frame from [MegaAcceptability](https://megaattitude.io) (White & Rawlins, 2016).
+
+=== "Demo"
+
+
+
+=== "Python"
+
+ ```python
+ from bead.items.magnitude import create_magnitude_item
+
+ item = create_magnitude_item(
+ text="Someone believed someone to be a fool.",
+ prompt="On a scale of 0 to 100, how acceptable is this sentence?",
+ input_min=0,
+ input_max=100,
+ )
+ ```
+
+=== "Trial JSON"
+
+ ```json
+ {
+ "type": "bead-magnitude",
+ "prompt": "On a scale of 0 to 100, how acceptable is this sentence?",
+ "input_min": 0,
+ "input_max": 100,
+ "step": 1,
+ "metadata": {"verb": "believe", "frame": "NP_to_be_NP"}
+ }
+ ```
+
+### Free Text Response
+
+Open-ended text response, single-line or multiline. This example elicits event structure descriptions for the verb *remember* in a to-VP frame, following the decomposition methodology of [UDS](https://decomp.io) (White et al., 2016).
+
+=== "Demo"
+
+
+
+=== "Python"
+
+ ```python
+ from bead.items.free_text import create_free_text_item
+
+ item = create_free_text_item(
+ text="Someone remembered to leave.",
+ prompt="What event, if any, does this sentence describe?",
+ multiline=True,
+ min_length=5,
+ max_length=200,
+ )
+ ```
+
+=== "Trial JSON"
+
+ ```json
+ {
+ "type": "bead-free-text",
+ "prompt": "What event, if any, does this sentence describe?",
+ "multiline": true,
+ "rows": 3,
+ "min_length": 5,
+ "max_length": 200,
+ "metadata": {"verb": "remember", "frame": "to_VP"}
+ }
+ ```
+
+---
+
+## Selection Tasks
+
+### Cloze (Fill-in-the-Blank)
+
+Dropdown selection for fill-in-the-blank gaps. This example tests clause-embedding verb frame selection from [MegaAcceptability](https://megaattitude.io) (White & Rawlins, 2016). The verb options include factive (*knew*), non-factive (*believed*), and implicative (*managed*) verbs.
+
+=== "Demo"
+
+
+
+=== "Python"
+
+ ```python
+ from bead.items.cloze import create_cloze_item
+
+ item = create_cloze_item(
+ text="Someone {{verb1}} that someone left and {{verb2}} to go.",
+ constraints={
+ "verb1": ["knew", "believed", "forgot", "hoped", "denied", "doubted"],
+ "verb2": ["wanted", "managed", "tried", "decided", "refused", "failed"],
+ },
+ )
+ ```
+
+=== "Trial JSON"
+
+ ```json
+ {
+ "type": "bead-cloze-multi",
+ "text": "Someone %% that someone left and %% to go.",
+ "fields": [
+ {"type": "dropdown", "options": ["knew", "believed", "forgot", "hoped", "denied", "doubted"]},
+ {"type": "dropdown", "options": ["wanted", "managed", "tried", "decided", "refused", "failed"]}
+ ],
+ "require_all": true
+ }
+ ```
+
+### Multi-Select
+
+Select one or more options from a set using checkboxes. This example uses the nine proto-role properties from [Semantic Proto-Roles](https://decomp.io) (Reisinger et al., 2015) applied to the predicate *broke*. Annotators select which properties apply to each argument.
+
+=== "Demo"
+
+
+
+=== "Python"
+
+ ```python
+ from bead.items.multi_select import create_multi_select_item
+
+ item = create_multi_select_item(
+ text="Someone broke something.",
+ prompt='Which properties apply to "someone" (arg0)?',
+ options=[
+ "instigation: caused the event",
+ "volition: chose to be involved",
+ "sentience: was aware of being involved",
+ "change of state: changed state as a result",
+ "existed before: existed before the event",
+ "existed after: existed after the event",
+ "change of location: changed location",
+ "stationary: was stationary during the event",
+ "physical contact: made physical contact",
+ ],
+ min_selections=1,
+ )
+ ```
+
+=== "Trial JSON"
+
+ ```json
+ {
+ "type": "bead-multi-select",
+ "prompt": "Which properties apply to \"someone\" (arg0)?",
+ "options": [
+ "instigation: caused the event",
+ "volition: chose to be involved",
+ "sentience: was aware of being involved",
+ "change of state: changed state as a result",
+ "existed before: existed before the event",
+ "existed after: existed after the event",
+ "change of location: changed location",
+ "stationary: was stationary during the event",
+ "physical contact: made physical contact"
+ ],
+ "metadata": {"predicate": "broke", "argument": "arg0"}
+ }
+ ```
+
+---
+
+## Span Annotation
+
+### Interactive Span Labeling (Fixed Labels)
+
+Select token ranges and assign labels from a searchable fixed set. Type to filter labels or use keyboard shortcuts 1-9.
+
+=== "Demo"
+
+
+
+=== "Python"
+
+ ```python
+ from bead.items.span_labeling import create_interactive_span_item
+
+ item = create_interactive_span_item(
+ text="The committee unanimously approved the new budget proposal "
+ "after reviewing the evidence.",
+ prompt="Select and label semantic roles.",
+ label_set=[
+ "Agent", "Patient", "Theme", "Experiencer",
+ "Instrument", "Beneficiary", "Location", "Time",
+ "Manner", "Cause", "Purpose", "Source",
+ "Goal", "Stimulus", "Result", "Predicate",
+ ],
+ )
+ ```
+
+=== "Trial JSON"
+
+ ```json
+ {
+ "type": "bead-span-label",
+ "tokens": {
+ "text": ["The", "committee", "unanimously", "approved", "the",
+ "new", "budget", "proposal", "after", "reviewing",
+ "the", "evidence", "."]
+ },
+ "span_spec": {
+ "interaction_mode": "interactive",
+ "label_source": "fixed",
+ "labels": ["Agent", "Patient", "Theme", "Experiencer",
+ "Instrument", "Beneficiary", "Location", "Time",
+ "Manner", "Cause", "Purpose", "Source",
+ "Goal", "Stimulus", "Result", "Predicate"]
+ }
+ }
+ ```
+
+### Wikidata Entity Labeling
+
+Interactive span labeling with Wikidata autocomplete search for labels. Select entities and search Wikidata to link them.
+
+=== "Demo"
+
+
+
+=== "Python"
+
+ ```python
+ from bead.items.span_labeling import create_interactive_span_item
+
+ item = create_interactive_span_item(
+ text="Albert Einstein developed the theory of relativity "
+ "at the Institute for Advanced Study in Princeton.",
+ prompt="Select entities and search Wikidata to assign labels.",
+ label_source="wikidata",
+ )
+ ```
+
+=== "Trial JSON"
+
+ ```json
+ {
+ "type": "bead-span-label",
+ "tokens": {
+ "text": ["Albert", "Einstein", "developed", "the", "theory",
+ "of", "relativity", "at", "the", "Institute",
+ "for", "Advanced", "Study", "in", "Princeton", "."]
+ },
+ "span_spec": {
+ "interaction_mode": "interactive",
+ "label_source": "wikidata",
+ "wikidata_language": "en"
+ }
+ }
+ ```
+
+---
+
+## Composite Tasks
+
+Span highlights work as an orthogonal overlay on any existing task type. The same item can have both span annotations and a rating scale, forced choice, or binary judgment.
+
+### Span + Likert Rating
+
+SPR change-of-state property rating with highlighted arguments. From [Semantic Proto-Roles](https://decomp.io) (Reisinger et al., 2015). Annotators rate individual proto-role properties on a Likert scale for each predicate-argument pair.
+
+=== "Demo"
+
+
+
+=== "Python"
+
+ ```python
+ from bead.items.ordinal_scale import create_ordinal_scale_item
+ from bead.items.span_labeling import add_spans_to_item
+ from bead.items.spans import Span, SpanLabel, SpanSegment
+
+ item = create_ordinal_scale_item(
+ text="Someone broke something.",
+ prompt='How likely is it that arg1 ("something") changed state?',
+ scale_bounds=(1, 5),
+ scale_labels={1: "Very unlikely", 3: "Neutral", 5: "Very likely"},
+ )
+
+ item = add_spans_to_item(item, spans=[
+ Span(span_id="span_0",
+ segments=[SpanSegment(element_name="text", indices=[0])],
+ label=SpanLabel(label="arg0")),
+ Span(span_id="span_1",
+ segments=[SpanSegment(element_name="text", indices=[2])],
+ label=SpanLabel(label="arg1")),
+ ])
+ ```
+
+### Span + Slider Rating
+
+Veridicality inference with highlighted predicate and embedded clause. The factive verb *confirm* from [MegaVeridicality](https://megaattitude.io) (White & Rawlins, 2018) presupposes the truth of the embedded event.
+
+=== "Demo"
+
+
+
+=== "Python"
+
+ ```python
+ from bead.items.ordinal_scale import create_ordinal_scale_item
+ from bead.items.span_labeling import add_spans_to_item
+ from bead.items.spans import Span, SpanLabel, SpanSegment
+
+ item = create_ordinal_scale_item(
+ text="Someone confirmed that someone left.",
+ prompt="Based on this sentence, did someone leave?",
+ scale_bounds=(0, 100),
+ )
+
+ item = add_spans_to_item(item, spans=[
+ Span(span_id="span_0",
+ segments=[SpanSegment(element_name="text", indices=[1])],
+ label=SpanLabel(label="predicate")),
+ Span(span_id="span_1",
+ segments=[SpanSegment(element_name="text", indices=[3, 4])],
+ label=SpanLabel(label="embedded clause")),
+ ])
+ ```
+
+### Span + Forced Choice
+
+Compare the instigation property across predicates. From [Semantic Proto-Roles](https://decomp.io) (Reisinger et al., 2015): *threw* has high instigation for arg0, while *received* has low instigation.
+
+=== "Demo"
+
+
+
+=== "Python"
+
+ ```python
+ from bead.items.forced_choice import create_forced_choice_item
+
+ item = create_forced_choice_item(
+ alternatives=[
+ "Someone threw something.",
+ "Someone received something.",
+ ],
+ prompt="In which sentence is arg0 more likely to have caused the event?",
+ )
+ ```
+
+### Span + Binary Judgment
+
+SPR volition property with highlighted arguments in a ditransitive frame. From [Semantic Proto-Roles](https://decomp.io) (Reisinger et al., 2015). The three-argument predicate *gave* lets annotators judge whether arg0 chose to be involved.
+
+=== "Demo"
+
+
+
+=== "Python"
+
+ ```python
+ from bead.items.binary import create_binary_item
+ from bead.items.span_labeling import add_spans_to_item
+ from bead.items.spans import Span, SpanLabel, SpanSegment
+
+ item = create_binary_item(
+ text="Someone gave something to someone.",
+ prompt=(
+ 'Does arg0 ("someone") have the property volition: '
+ "did they choose to be involved in this event?"
+ ),
+ options=["Yes", "No"],
+ )
+
+ item = add_spans_to_item(item, spans=[
+ Span(span_id="span_0",
+ segments=[SpanSegment(element_name="text", indices=[0])],
+ label=SpanLabel(label="arg0")),
+ Span(span_id="span_1",
+ segments=[SpanSegment(element_name="text", indices=[2])],
+ label=SpanLabel(label="arg1")),
+ Span(span_id="span_2",
+ segments=[SpanSegment(element_name="text", indices=[4])],
+ label=SpanLabel(label="arg2")),
+ ])
+ ```
+
+---
+
+## Relation Annotation
+
+### Span Relations (Fixed Labels)
+
+Interactive span and relation annotation with searchable fixed label sets. Create spans, then use "Add Relation" to draw directed relations between them. From UDS Semantic Role Labeling.
+
+=== "Demo"
+
+
+
+=== "Python"
+
+ ```python
+ from bead.items.span_labeling import create_interactive_span_item
+ from bead.items.spans import SpanSpec
+
+ item = create_interactive_span_item(
+ text="The scientist presented the findings to the committee "
+ "at the annual conference.",
+ prompt="Create spans and relations for semantic role labeling.",
+ label_set=[
+ "Agent", "Patient", "Theme", "Recipient",
+ "Instrument", "Location", "Time", "Predicate",
+ "Stimulus", "Goal",
+ ],
+ span_spec=SpanSpec(
+ interaction_mode="interactive",
+ label_source="fixed",
+ enable_relations=True,
+ relation_label_source="fixed",
+ relation_labels=[
+ "ARG0", "ARG1", "ARG2", "ARG3",
+ "ARG-LOC", "ARG-TMP", "ARG-MNR",
+ "ARG-PRP", "ARG-CAU",
+ ],
+ relation_directed=True,
+ ),
+ )
+ ```
+
+=== "Trial JSON"
+
+ ```json
+ {
+ "type": "bead-span-label",
+ "tokens": {
+ "text": ["The", "scientist", "presented", "the", "findings",
+ "to", "the", "committee", "at", "the", "annual",
+ "conference", "."]
+ },
+ "span_spec": {
+ "interaction_mode": "interactive",
+ "label_source": "fixed",
+ "labels": ["Agent", "Patient", "Theme", "Recipient",
+ "Instrument", "Location", "Time", "Predicate",
+ "Stimulus", "Goal"],
+ "enable_relations": true,
+ "relation_label_source": "fixed",
+ "relation_labels": ["ARG0", "ARG1", "ARG2", "ARG3",
+ "ARG-LOC", "ARG-TMP", "ARG-MNR",
+ "ARG-PRP", "ARG-CAU"],
+ "relation_directed": true
+ }
+ }
+ ```
+
+### Span Relations (Wikidata)
+
+Interactive entity linking and relation annotation with Wikidata search for both entity and relation labels. Useful for knowledge graph construction.
+
+=== "Demo"
+
+
+
+=== "Python"
+
+ ```python
+ from bead.items.span_labeling import create_interactive_span_item
+ from bead.items.spans import SpanSpec
+
+ item = create_interactive_span_item(
+ text="Marie Curie was born in Warsaw and later became "
+ "a professor at the University of Paris.",
+ prompt="Link entities via Wikidata and draw relations between them.",
+ label_source="wikidata",
+ span_spec=SpanSpec(
+ interaction_mode="interactive",
+ label_source="wikidata",
+ enable_relations=True,
+ relation_label_source="wikidata",
+ relation_directed=True,
+ ),
+ )
+ ```
+
+=== "Trial JSON"
+
+ ```json
+ {
+ "type": "bead-span-label",
+ "tokens": {
+ "text": ["Marie", "Curie", "was", "born", "in", "Warsaw",
+ "and", "later", "became", "a", "professor", "at",
+ "the", "University", "of", "Paris", "."]
+ },
+ "span_spec": {
+ "interaction_mode": "interactive",
+ "label_source": "wikidata",
+ "enable_relations": true,
+ "relation_label_source": "wikidata",
+ "relation_directed": true,
+ "wikidata_language": "en"
+ }
+ }
+ ```
diff --git a/docs/gallery/.DS_Store b/docs/gallery/.DS_Store
new file mode 100644
index 0000000000000000000000000000000000000000..443a1782bf1fe541943b41a3d134141c459dc6dd
GIT binary patch
literal 6148
zcmeHKISv9b4752UBpOP}e1RWC2wt!spa9WuNFb=U;$1wA@zJbSbWniCk~4|pNt7wp
zYZ1}qZMzVeiO2+QC=VO@X8Yzn8)QU*aGY_yH<#1leA@SN-vx|2maXjMDCZ8}c4$<9
z3Qz$mKn1A4rxnNwJDGm^V4g<>sKC!FVBd!VH>`Bxq5@RluN2VHs#z`Zq^zyI$62i{@D1E@o^UhFor1y3G0@8~7FLeuo)me-=Gd=^
UZJ^T;cRG+i1Evd&3Vd6E7rNsV(EtDd
literal 0
HcmV?d00001
diff --git a/docs/gallery/css/gallery.css b/docs/gallery/css/gallery.css
new file mode 100644
index 0000000..971b0a1
--- /dev/null
+++ b/docs/gallery/css/gallery.css
@@ -0,0 +1,1042 @@
+/* Gallery demo styles - loaded by each standalone demo HTML page */
+
+/* Reset and base */
+*, *::before, *::after {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+
+body {
+ font-family: "Roboto", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ font-size: 15px;
+ line-height: 1.6;
+ color: #212121;
+ background: #fafafa;
+ padding: 24px;
+}
+
+/* Demo container */
+.gallery-demo {
+ max-width: 720px;
+ margin: 0 auto;
+}
+
+/* jspsych overrides for iframe embedding */
+#jspsych-target {
+ font-family: inherit;
+}
+
+.jspsych-display-element {
+ font-family: inherit;
+ font-size: inherit;
+ min-height: 0 !important;
+}
+
+.jspsych-content-wrapper {
+ min-height: 0 !important;
+}
+
+.jspsych-content {
+ max-width: 100% !important;
+}
+
+/* Ensure jsPsych standard plugin buttons are visible */
+.jspsych-html-button-response-btngroup {
+ display: flex;
+ justify-content: center;
+ gap: 8px;
+ margin-top: 20px;
+}
+
+.jspsych-html-slider-response-container {
+ margin-top: 16px;
+}
+
+/* ── Rating plugin ───────────────────────────────────── */
+
+.bead-rating-container {
+ text-align: center;
+ padding: 20px 0;
+}
+
+.bead-rating-prompt {
+ font-size: 1.1em;
+ margin-bottom: 24px;
+ color: #424242;
+}
+
+.bead-rating-scale {
+ display: flex;
+ justify-content: center;
+ gap: 8px;
+ margin-bottom: 24px;
+ flex-wrap: wrap;
+}
+
+.bead-rating-option {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 4px;
+}
+
+.bead-rating-button {
+ width: 44px;
+ height: 44px;
+ border-radius: 50%;
+ border: 2px solid #bdbdbd;
+ background: white;
+ font-size: 1em;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.15s ease;
+ color: #424242;
+}
+
+.bead-rating-button:hover {
+ border-color: #5c6bc0;
+ background: #e8eaf6;
+}
+
+.bead-rating-button.selected {
+ border-color: #3f51b5;
+ background: #3f51b5;
+ color: white;
+}
+
+.bead-rating-label {
+ font-size: 0.75em;
+ color: #757575;
+ max-width: 64px;
+ text-align: center;
+}
+
+/* ── Forced choice plugin ────────────────────────────── */
+
+.bead-forced-choice-container {
+ padding: 20px 0;
+}
+
+.bead-forced-choice-prompt {
+ text-align: center;
+ font-size: 1.1em;
+ margin-bottom: 24px;
+ color: #424242;
+}
+
+.bead-forced-choice-alternatives {
+ display: grid;
+ gap: 12px;
+ margin: 0 auto;
+}
+
+.bead-forced-choice-alternatives.bead-layout-horizontal {
+ grid-template-columns: 1fr 1fr;
+ max-width: 600px;
+}
+
+.bead-forced-choice-alternatives.bead-layout-vertical {
+ grid-template-columns: 1fr;
+ max-width: 480px;
+}
+
+.bead-card {
+ border: 2px solid #e0e0e0;
+ border-radius: 8px;
+ padding: 20px;
+ background: white;
+ transition: all 0.15s ease;
+}
+
+.bead-alternative {
+ cursor: pointer;
+ text-align: center;
+}
+
+.bead-alternative:hover {
+ border-color: #5c6bc0;
+ box-shadow: 0 2px 8px rgba(63, 81, 181, 0.12);
+}
+
+.bead-alternative.selected {
+ border-color: #3f51b5;
+ background: #e8eaf6;
+}
+
+.bead-alternative-label {
+ font-size: 0.8em;
+ font-weight: 500;
+ color: #9e9e9e;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ margin-bottom: 8px;
+}
+
+.bead-alternative-content {
+ font-size: 1em;
+ color: #212121;
+ margin-bottom: 12px;
+ line-height: 1.5;
+}
+
+.bead-choice-button {
+ display: none;
+}
+
+/* ── Cloze plugin ────────────────────────────────────── */
+
+.bead-cloze-container {
+ padding: 20px 0;
+ text-align: center;
+}
+
+.bead-cloze-text {
+ font-size: 1.1em;
+ line-height: 2;
+ margin-bottom: 24px;
+ color: #424242;
+}
+
+.bead-dropdown {
+ padding: 4px 8px;
+ border: 2px solid #5c6bc0;
+ border-radius: 4px;
+ font-size: 0.95em;
+ background: #e8eaf6;
+ cursor: pointer;
+ appearance: auto;
+}
+
+.bead-text-field {
+ padding: 4px 8px;
+ border: 2px solid #5c6bc0;
+ border-radius: 4px;
+ font-size: 0.95em;
+ width: 120px;
+ text-align: center;
+}
+
+/* ── Binary choice plugin ────────────────────────────── */
+
+.bead-binary-choice-container {
+ text-align: center;
+ padding: 20px 0;
+}
+
+.bead-binary-choice-prompt {
+ font-size: 1.1em;
+ margin-bottom: 16px;
+ color: #424242;
+}
+
+.bead-binary-choice-stimulus {
+ font-size: 1.15em;
+ padding: 16px 24px;
+ background: white;
+ border: 1px solid #e0e0e0;
+ border-radius: 6px;
+ display: inline-block;
+ margin-bottom: 20px;
+}
+
+.bead-binary-choice-buttons {
+ display: flex;
+ justify-content: center;
+ gap: 12px;
+}
+
+.bead-binary-button {
+ padding: 10px 32px;
+ border: 2px solid #e0e0e0;
+ border-radius: 4px;
+ background: white;
+ font-size: 0.95em;
+ cursor: pointer;
+ transition: all 0.15s ease;
+}
+
+.bead-binary-button:hover {
+ border-color: #5c6bc0;
+ background: #e8eaf6;
+}
+
+.bead-binary-button.selected {
+ border-color: #3f51b5;
+ background: #3f51b5;
+ color: white;
+}
+
+/* ── Slider rating plugin ────────────────────────────── */
+
+.bead-slider-container {
+ text-align: center;
+ padding: 20px 0;
+}
+
+.bead-slider-prompt {
+ font-size: 1.1em;
+ margin-bottom: 24px;
+ color: #424242;
+}
+
+.bead-slider-wrapper {
+ max-width: 480px;
+ margin: 0 auto 16px;
+}
+
+.bead-slider-labels {
+ display: flex;
+ justify-content: space-between;
+ margin-bottom: 8px;
+ font-size: 0.85em;
+ color: #757575;
+}
+
+.bead-slider-input {
+ width: 100%;
+ margin: 0;
+ cursor: pointer;
+ accent-color: #3f51b5;
+}
+
+.bead-slider-value {
+ margin-top: 8px;
+ font-size: 1.1em;
+ font-weight: 500;
+ color: #3f51b5;
+}
+
+.bead-slider-button-container {
+ margin-top: 16px;
+ text-align: center;
+}
+
+/* ── Shared button styles ────────────────────────────── */
+
+.bead-button {
+ padding: 10px 32px;
+ border: none;
+ border-radius: 4px;
+ font-size: 0.95em;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.15s ease;
+}
+
+.bead-continue-button {
+ background: #3f51b5;
+ color: white;
+}
+
+.bead-continue-button:hover:not(:disabled) {
+ background: #303f9f;
+}
+
+.bead-continue-button:disabled {
+ background: #bdbdbd;
+ cursor: not-allowed;
+}
+
+.bead-rating-button-container,
+.bead-cloze-button-container {
+ margin-top: 16px;
+ text-align: center;
+}
+
+/* ── Span labeling ───────────────────────────────────── */
+
+.bead-span-label-container {
+ text-align: left;
+}
+
+.bead-span-label-container > .bead-rating-prompt {
+ font-size: 0.9em;
+ color: #757575;
+ font-weight: 400;
+ margin-bottom: 16px;
+ padding-bottom: 12px;
+ border-bottom: 1px solid #e0e0e0;
+}
+
+.bead-span-container {
+ display: inline;
+ line-height: 2.6;
+ font-size: 1.1em;
+}
+
+.bead-token {
+ display: inline;
+ padding: 3px 1px;
+ border-radius: 4px;
+ cursor: default;
+ transition: background-color 0.15s;
+}
+
+.bead-space {
+ display: inline;
+}
+
+/* Contiguous span positions: first/middle/last get merged radius */
+.bead-token.highlighted.span-single {
+ border-radius: 4px;
+ padding: 3px 4px;
+}
+
+.bead-token.highlighted.span-first {
+ border-radius: 4px 0 0 4px;
+ padding: 3px 0 3px 4px;
+}
+
+.bead-token.highlighted.span-middle {
+ border-radius: 0;
+ padding: 3px 0;
+}
+
+.bead-token.highlighted.span-last {
+ border-radius: 0 4px 4px 0;
+ padding: 3px 4px 3px 0;
+}
+
+.bead-space.highlighted {
+ border-radius: 0;
+}
+
+.bead-token.interactive {
+ cursor: pointer;
+}
+
+.bead-token.interactive:hover {
+ background-color: rgba(0, 0, 0, 0.08);
+}
+
+.bead-token.highlighted {
+ position: relative;
+}
+
+.bead-token.selecting {
+ background-color: #c8e6c9;
+ border-radius: 4px;
+ padding: 3px 4px;
+}
+
+.bead-token.selecting.invalid {
+ background-color: #ffcdd2;
+}
+
+.bead-label-selector {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+ margin-top: 8px;
+}
+
+.bead-label-button {
+ padding: 4px 12px;
+ border-radius: 16px;
+ border: 1px solid #ccc;
+ cursor: pointer;
+ background: white;
+ font-size: 0.9em;
+}
+
+.bead-label-button:hover,
+.bead-label-button.active {
+ border-color: #1976d2;
+ background: #e3f2fd;
+}
+
+/* Subscript span labels (positioned below last token of each span) */
+.bead-span-subscript {
+ position: absolute;
+ bottom: -0.6rem;
+ right: -2px;
+ display: inline-flex;
+ align-items: center;
+ gap: 2px;
+ padding: 0px 5px;
+ border-radius: 0.6rem;
+ font-size: 0.6rem;
+ font-weight: 500;
+ color: white;
+ white-space: nowrap;
+ z-index: 1;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
+ line-height: 1.5;
+ cursor: default;
+}
+
+.bead-subscript-delete {
+ border: none;
+ background: none;
+ color: rgba(255, 255, 255, 0.6);
+ cursor: pointer;
+ font-size: 1.1em;
+ font-weight: bold;
+ padding: 0 1px;
+ line-height: 1;
+}
+
+.bead-subscript-delete:hover {
+ color: white;
+}
+
+/* Legacy span list (for composite demos) */
+.bead-span-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin-top: 12px;
+}
+
+.bead-span-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 2px 10px;
+ border-radius: 12px;
+ font-size: 0.72em;
+ font-weight: 500;
+ color: white;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
+ line-height: 1.6;
+}
+
+/* Relation arcs */
+.bead-relation-arc-area {
+ position: relative;
+ width: 100%;
+}
+
+.bead-relation-layer {
+ width: 100%;
+ pointer-events: none;
+}
+
+.bead-relation-arc {
+ fill: none;
+ stroke-width: 1.5;
+}
+
+.bead-relation-label-text {
+ font-size: 0.75em;
+ fill: #424242;
+}
+
+.bead-relation-list {
+ margin-top: 8px;
+}
+
+.bead-relation-entry {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 2px 0;
+}
+
+/* Wikidata autocomplete */
+.bead-wikidata-panel {
+ flex-direction: column;
+}
+
+.bead-wikidata-search {
+ position: relative;
+ width: 100%;
+ max-width: 400px;
+}
+
+.bead-wikidata-search input {
+ width: 100%;
+ padding: 8px 12px;
+ border: 2px solid #5c6bc0;
+ border-radius: 4px;
+ font-size: 0.95em;
+ outline: none;
+}
+
+.bead-wikidata-search input:focus {
+ border-color: #3f51b5;
+ box-shadow: 0 0 0 2px rgba(63, 81, 181, 0.12);
+}
+
+.bead-wikidata-results {
+ position: absolute;
+ z-index: 10;
+ width: 100%;
+ max-height: 200px;
+ overflow-y: auto;
+ border: 1px solid #e0e0e0;
+ border-radius: 4px;
+ background: white;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
+}
+
+.bead-wikidata-result {
+ padding: 6px 8px;
+ cursor: pointer;
+}
+
+.bead-wikidata-result:hover {
+ background: #f5f5f5;
+}
+
+.bead-wikidata-result .qid {
+ color: #9e9e9e;
+ font-size: 0.85em;
+}
+
+.bead-wikidata-result .description {
+ color: #757575;
+ font-size: 0.85em;
+}
+
+/* ── Response display ────────────────────────────────── */
+
+.gallery-response {
+ margin-top: 16px;
+ padding: 12px 16px;
+ background: #263238;
+ color: #b2dfdb;
+ border-radius: 6px;
+ font-family: "JetBrains Mono", "Fira Code", monospace;
+ font-size: 0.82em;
+ white-space: pre-wrap;
+ word-break: break-word;
+ max-height: 200px;
+ overflow-y: auto;
+}
+
+.gallery-response-label {
+ font-family: "Roboto", sans-serif;
+ font-size: 0.75em;
+ font-weight: 500;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ color: #78909c;
+ margin-bottom: 4px;
+}
+
+/* Reset button */
+.gallery-reset {
+ display: inline-block;
+ margin-top: 12px;
+ padding: 6px 16px;
+ border: 1px solid #bdbdbd;
+ border-radius: 4px;
+ background: white;
+ color: #616161;
+ font-size: 0.85em;
+ cursor: pointer;
+ transition: all 0.15s ease;
+}
+
+.gallery-reset:hover {
+ border-color: #3f51b5;
+ color: #3f51b5;
+}
+
+/* ── Categorical plugin ─────────────────────────────── */
+
+.bead-categorical-container {
+ text-align: center;
+ padding: 20px 0;
+}
+
+.bead-categorical-prompt {
+ font-size: 1.1em;
+ margin-bottom: 16px;
+ color: #424242;
+}
+
+.bead-categorical-stimulus {
+ font-size: 1.15em;
+ padding: 16px 24px;
+ background: white;
+ border: 1px solid #e0e0e0;
+ border-radius: 6px;
+ display: inline-block;
+ margin-bottom: 20px;
+}
+
+.bead-categorical-options {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ gap: 8px;
+ margin-bottom: 20px;
+}
+
+.bead-categorical-button {
+ padding: 10px 24px;
+ border: 2px solid #e0e0e0;
+ border-radius: 4px;
+ background: white;
+ font-size: 0.95em;
+ cursor: pointer;
+ transition: all 0.15s ease;
+}
+
+.bead-categorical-button:hover {
+ border-color: #5c6bc0;
+ background: #e8eaf6;
+}
+
+.bead-categorical-button.selected {
+ border-color: #3f51b5;
+ background: #3f51b5;
+ color: white;
+}
+
+.bead-categorical-button-container {
+ margin-top: 16px;
+ text-align: center;
+}
+
+/* ── Magnitude plugin ──────────────────────────────── */
+
+.bead-magnitude-container {
+ text-align: center;
+ padding: 20px 0;
+}
+
+.bead-magnitude-prompt {
+ font-size: 1.1em;
+ margin-bottom: 16px;
+ color: #424242;
+}
+
+.bead-magnitude-stimulus {
+ font-size: 1.15em;
+ padding: 16px 24px;
+ background: white;
+ border: 1px solid #e0e0e0;
+ border-radius: 6px;
+ display: inline-block;
+ margin-bottom: 20px;
+}
+
+.bead-magnitude-input-wrapper {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ margin-bottom: 20px;
+}
+
+.bead-magnitude-input {
+ width: 120px;
+ padding: 8px 12px;
+ border: 2px solid #bdbdbd;
+ border-radius: 4px;
+ font-size: 1.1em;
+ text-align: center;
+ outline: none;
+ transition: border-color 0.15s ease;
+}
+
+.bead-magnitude-input:focus {
+ border-color: #3f51b5;
+ box-shadow: 0 0 0 2px rgba(63, 81, 181, 0.12);
+}
+
+.bead-magnitude-unit {
+ font-size: 1em;
+ color: #757575;
+}
+
+.bead-magnitude-button-container {
+ margin-top: 16px;
+ text-align: center;
+}
+
+/* ── Free text plugin ──────────────────────────────── */
+
+.bead-free-text-container {
+ text-align: center;
+ padding: 20px 0;
+}
+
+.bead-free-text-prompt {
+ font-size: 1.1em;
+ margin-bottom: 16px;
+ color: #424242;
+}
+
+.bead-free-text-stimulus {
+ font-size: 1.15em;
+ padding: 16px 24px;
+ background: white;
+ border: 1px solid #e0e0e0;
+ border-radius: 6px;
+ display: inline-block;
+ margin-bottom: 20px;
+}
+
+.bead-free-text-input {
+ width: 100%;
+ max-width: 480px;
+ padding: 8px 12px;
+ border: 2px solid #bdbdbd;
+ border-radius: 4px;
+ font-size: 1em;
+ font-family: inherit;
+ outline: none;
+ transition: border-color 0.15s ease;
+ resize: vertical;
+}
+
+.bead-free-text-input:focus {
+ border-color: #3f51b5;
+ box-shadow: 0 0 0 2px rgba(63, 81, 181, 0.12);
+}
+
+.bead-free-text-counter {
+ font-size: 0.8em;
+ color: #9e9e9e;
+ margin-top: 4px;
+}
+
+.bead-free-text-button-container {
+ margin-top: 16px;
+ text-align: center;
+}
+
+/* ── Multi-select plugin ───────────────────────────── */
+
+.bead-multi-select-container {
+ text-align: center;
+ padding: 20px 0;
+}
+
+.bead-multi-select-prompt {
+ font-size: 1.1em;
+ margin-bottom: 16px;
+ color: #424242;
+}
+
+.bead-multi-select-stimulus {
+ font-size: 1.15em;
+ padding: 16px 24px;
+ background: white;
+ border: 1px solid #e0e0e0;
+ border-radius: 6px;
+ display: inline-block;
+ margin-bottom: 20px;
+}
+
+.bead-multi-select-options {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 6px;
+ margin-bottom: 20px;
+}
+
+.bead-multi-select-option {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 16px;
+ border: 1px solid #e0e0e0;
+ border-radius: 4px;
+ background: white;
+ cursor: pointer;
+ transition: all 0.15s ease;
+ min-width: 280px;
+ text-align: left;
+}
+
+.bead-multi-select-option:hover {
+ border-color: #5c6bc0;
+ background: #f5f5f5;
+}
+
+.bead-multi-select-checkbox {
+ accent-color: #3f51b5;
+ width: 18px;
+ height: 18px;
+ cursor: pointer;
+}
+
+.bead-multi-select-checkbox:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+.bead-multi-select-label {
+ font-size: 0.95em;
+ color: #424242;
+}
+
+.bead-multi-select-button-container {
+ margin-top: 16px;
+ text-align: center;
+}
+
+/* ── Searchable label selector ─────────────────────── */
+
+.bead-label-search-panel {
+ flex-direction: column;
+}
+
+.bead-label-search-wrapper {
+ position: relative;
+ width: 100%;
+ max-width: 400px;
+}
+
+.bead-label-search-wrapper input {
+ width: 100%;
+ padding: 8px 12px;
+ border: 2px solid #5c6bc0;
+ border-radius: 4px;
+ font-size: 0.95em;
+ outline: none;
+}
+
+.bead-label-search-wrapper input:focus {
+ border-color: #3f51b5;
+ box-shadow: 0 0 0 2px rgba(63, 81, 181, 0.12);
+}
+
+.bead-label-search-results {
+ position: absolute;
+ z-index: 10;
+ width: 100%;
+ max-height: 200px;
+ overflow-y: auto;
+ border: 1px solid #e0e0e0;
+ border-radius: 4px;
+ background: white;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
+}
+
+.bead-label-search-result {
+ padding: 6px 12px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.bead-label-search-result:hover,
+.bead-label-search-result.highlighted {
+ background: #f5f5f5;
+}
+
+.bead-label-search-result .label-color {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+.bead-label-search-result .label-name {
+ font-size: 0.95em;
+ color: #212121;
+}
+
+.bead-label-search-result .label-shortcut {
+ font-size: 0.8em;
+ color: #9e9e9e;
+ margin-left: auto;
+}
+
+/* ── Relation creation UI ──────────────────────────── */
+
+.bead-relation-controls {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-top: 12px;
+ flex-wrap: wrap;
+}
+
+.bead-add-relation-button {
+ padding: 6px 16px;
+ border: 1px solid #5c6bc0;
+ border-radius: 4px;
+ background: white;
+ color: #5c6bc0;
+ font-size: 0.85em;
+ cursor: pointer;
+ transition: all 0.15s ease;
+}
+
+.bead-add-relation-button:hover {
+ background: #e8eaf6;
+}
+
+.bead-add-relation-button:disabled {
+ border-color: #bdbdbd;
+ color: #bdbdbd;
+ cursor: not-allowed;
+}
+
+.bead-relation-status {
+ font-size: 0.85em;
+ color: #757575;
+ font-style: italic;
+}
+
+.bead-relation-cancel {
+ padding: 4px 12px;
+ border: 1px solid #e57373;
+ border-radius: 4px;
+ background: white;
+ color: #e57373;
+ font-size: 0.8em;
+ cursor: pointer;
+}
+
+.bead-relation-cancel:hover {
+ background: #ffebee;
+}
+
+.bead-span-subscript.relation-source {
+ outline: 2px solid #ff9800;
+ outline-offset: 1px;
+}
+
+.bead-span-subscript.relation-target-candidate:hover {
+ outline: 2px dashed #2196f3;
+ outline-offset: 1px;
+}
+
+.bead-relation-entry {
+ font-size: 0.85em;
+}
+
+.bead-relation-delete {
+ border: none;
+ background: none;
+ color: #e57373;
+ cursor: pointer;
+ font-size: 1em;
+ padding: 0 4px;
+}
+
+.bead-relation-delete:hover {
+ color: #c62828;
+}
+
+/* ── Stimulus display (for composite tasks) ──────────── */
+
+.stimulus-container {
+ padding: 16px;
+ margin-bottom: 16px;
+ background: white;
+ border: 1px solid #e0e0e0;
+ border-radius: 6px;
+ font-size: 1.05em;
+ line-height: 1.8;
+}
+
+.stimulus-container .element-label {
+ font-size: 0.75em;
+ font-weight: 500;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ color: #9e9e9e;
+ margin-bottom: 4px;
+}
diff --git a/docs/gallery/demos/binary-choice.html b/docs/gallery/demos/binary-choice.html
new file mode 100644
index 0000000..837942e
--- /dev/null
+++ b/docs/gallery/demos/binary-choice.html
@@ -0,0 +1,53 @@
+
+
+
+
+
+ Binary Judgment
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/gallery/demos/categorical.html b/docs/gallery/demos/categorical.html
new file mode 100644
index 0000000..9551b99
--- /dev/null
+++ b/docs/gallery/demos/categorical.html
@@ -0,0 +1,53 @@
+
+
+
+
+
+ Categorical Classification
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/gallery/demos/cloze-dropdown.html b/docs/gallery/demos/cloze-dropdown.html
new file mode 100644
index 0000000..178ece8
--- /dev/null
+++ b/docs/gallery/demos/cloze-dropdown.html
@@ -0,0 +1,56 @@
+
+
+
+
+
+ Cloze (Fill-in-the-Blank)
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/gallery/demos/forced-choice.html b/docs/gallery/demos/forced-choice.html
new file mode 100644
index 0000000..4b715a4
--- /dev/null
+++ b/docs/gallery/demos/forced-choice.html
@@ -0,0 +1,58 @@
+
+
+
+
+
+ Forced Choice
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/gallery/demos/free-text.html b/docs/gallery/demos/free-text.html
new file mode 100644
index 0000000..2b75121
--- /dev/null
+++ b/docs/gallery/demos/free-text.html
@@ -0,0 +1,58 @@
+
+
+
+
+
+ Free Text Response
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/gallery/demos/magnitude.html b/docs/gallery/demos/magnitude.html
new file mode 100644
index 0000000..0a7683f
--- /dev/null
+++ b/docs/gallery/demos/magnitude.html
@@ -0,0 +1,58 @@
+
+
+
+
+
+ Magnitude Estimation
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/gallery/demos/multi-select.html b/docs/gallery/demos/multi-select.html
new file mode 100644
index 0000000..7193c51
--- /dev/null
+++ b/docs/gallery/demos/multi-select.html
@@ -0,0 +1,66 @@
+
+
+
+
+
+ Multi-Select
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/gallery/demos/rating-likert.html b/docs/gallery/demos/rating-likert.html
new file mode 100644
index 0000000..c07c1fd
--- /dev/null
+++ b/docs/gallery/demos/rating-likert.html
@@ -0,0 +1,56 @@
+
+
+
+
+
+ Likert Rating Scale
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/gallery/demos/rating-slider.html b/docs/gallery/demos/rating-slider.html
new file mode 100644
index 0000000..41f52a6
--- /dev/null
+++ b/docs/gallery/demos/rating-slider.html
@@ -0,0 +1,57 @@
+
+
+
+
+
+ Slider Rating
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/gallery/demos/span-interactive.html b/docs/gallery/demos/span-interactive.html
new file mode 100644
index 0000000..06b7e02
--- /dev/null
+++ b/docs/gallery/demos/span-interactive.html
@@ -0,0 +1,74 @@
+
+
+
+
+
+ Interactive Span Labeling
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/gallery/demos/span-relations-fixed.html b/docs/gallery/demos/span-relations-fixed.html
new file mode 100644
index 0000000..dd1ac0f
--- /dev/null
+++ b/docs/gallery/demos/span-relations-fixed.html
@@ -0,0 +1,75 @@
+
+
+
+
+
+ Span Relations (Fixed Labels)
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/gallery/demos/span-relations-wikidata.html b/docs/gallery/demos/span-relations-wikidata.html
new file mode 100644
index 0000000..f6ec98f
--- /dev/null
+++ b/docs/gallery/demos/span-relations-wikidata.html
@@ -0,0 +1,73 @@
+
+
+
+
+
+ Span Relations (Wikidata)
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/gallery/demos/span-wikidata.html b/docs/gallery/demos/span-wikidata.html
new file mode 100644
index 0000000..346ff6c
--- /dev/null
+++ b/docs/gallery/demos/span-wikidata.html
@@ -0,0 +1,73 @@
+
+
+
+
+
+ Wikidata Entity Labeling
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/gallery/demos/span-with-binary.html b/docs/gallery/demos/span-with-binary.html
new file mode 100644
index 0000000..9d67fa7
--- /dev/null
+++ b/docs/gallery/demos/span-with-binary.html
@@ -0,0 +1,158 @@
+
+
+
+
+
+ Span + Binary Judgment
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/gallery/demos/span-with-choice.html b/docs/gallery/demos/span-with-choice.html
new file mode 100644
index 0000000..ce95606
--- /dev/null
+++ b/docs/gallery/demos/span-with-choice.html
@@ -0,0 +1,167 @@
+
+
+
+
+
+ Span + Forced Choice
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/gallery/demos/span-with-rating.html b/docs/gallery/demos/span-with-rating.html
new file mode 100644
index 0000000..27ea5e3
--- /dev/null
+++ b/docs/gallery/demos/span-with-rating.html
@@ -0,0 +1,159 @@
+
+
+
+
+
+ Span + Likert Rating
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/gallery/demos/span-with-slider.html b/docs/gallery/demos/span-with-slider.html
new file mode 100644
index 0000000..f524fb8
--- /dev/null
+++ b/docs/gallery/demos/span-with-slider.html
@@ -0,0 +1,159 @@
+
+
+
+
+
+ Span + Slider Rating
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/gallery/js/gallery-bundle.js b/docs/gallery/js/gallery-bundle.js
new file mode 100644
index 0000000..f0ed801
--- /dev/null
+++ b/docs/gallery/js/gallery-bundle.js
@@ -0,0 +1,2307 @@
+(function () {
+ 'use strict';
+
+ /* @bead/jspsych-gallery - Interactive demo bundle */
+ var __defProp = Object.defineProperty;
+ var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
+ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
+
+ // src/plugins/rating.ts
+ var info = {
+ name: "bead-rating",
+ parameters: {
+ prompt: {
+ type: 8,
+ // ParameterType.HTML_STRING
+ default: null
+ },
+ scale_min: {
+ type: 2,
+ // ParameterType.INT
+ default: 1
+ },
+ scale_max: {
+ type: 2,
+ // ParameterType.INT
+ default: 7
+ },
+ scale_labels: {
+ type: 12,
+ // ParameterType.OBJECT
+ default: {}
+ },
+ require_response: {
+ type: 0,
+ // ParameterType.BOOL
+ default: true
+ },
+ button_label: {
+ type: 1,
+ // ParameterType.STRING
+ default: "Continue"
+ },
+ metadata: {
+ type: 12,
+ // ParameterType.OBJECT
+ default: {}
+ }
+ }
+ };
+ var BeadRatingPlugin = class {
+ constructor(jsPsych) {
+ __publicField(this, "jsPsych");
+ this.jsPsych = jsPsych;
+ }
+ trial(display_element, trial) {
+ const response = {
+ rating: null,
+ rt: null
+ };
+ const start_time = performance.now();
+ let html = '';
+ if (trial.prompt !== null) {
+ html += `
${trial.prompt}
`;
+ }
+ html += '
';
+ for (let i = trial.scale_min; i <= trial.scale_max; i++) {
+ const label = trial.scale_labels[i] ?? i;
+ html += `
+
+ `;
+ }
+ html += "
";
+ html += `
+
+
+ ${trial.button_label}
+
+
+ `;
+ html += "
";
+ display_element.innerHTML = html;
+ const rating_buttons = display_element.querySelectorAll(".bead-rating-button");
+ for (const button of rating_buttons) {
+ button.addEventListener("click", (e) => {
+ const target = e.target;
+ const valueAttr = target.getAttribute("data-value");
+ if (valueAttr !== null) {
+ const value = Number.parseInt(valueAttr, 10);
+ select_rating(value);
+ }
+ });
+ }
+ const keyboard_listener = this.jsPsych.pluginAPI.getKeyboardResponse({
+ callback_function: (info11) => {
+ const key = info11.key;
+ const num = Number.parseInt(key, 10);
+ if (!Number.isNaN(num) && num >= trial.scale_min && num <= trial.scale_max) {
+ select_rating(num);
+ }
+ },
+ valid_responses: "ALL_KEYS",
+ rt_method: "performance",
+ persist: true,
+ allow_held_key: false
+ });
+ const continue_button = display_element.querySelector("#bead-rating-continue");
+ if (continue_button) {
+ continue_button.addEventListener("click", () => {
+ if (response.rating !== null || !trial.require_response) {
+ end_trial();
+ }
+ });
+ }
+ const select_rating = (value) => {
+ response.rating = value;
+ response.rt = performance.now() - start_time;
+ for (const btn of rating_buttons) {
+ btn.classList.remove("selected");
+ }
+ const selected_button = display_element.querySelector(
+ `[data-value="${value}"]`
+ );
+ if (selected_button) {
+ selected_button.classList.add("selected");
+ }
+ if (continue_button) {
+ continue_button.disabled = false;
+ }
+ };
+ const end_trial = () => {
+ if (keyboard_listener) {
+ this.jsPsych.pluginAPI.cancelKeyboardResponse(keyboard_listener);
+ }
+ const trial_data = {
+ ...trial.metadata,
+ // Spread all metadata
+ rating: response.rating,
+ rt: response.rt
+ };
+ display_element.innerHTML = "";
+ this.jsPsych.finishTrial(trial_data);
+ };
+ }
+ };
+ __publicField(BeadRatingPlugin, "info", info);
+
+ // src/plugins/forced-choice.ts
+ var info2 = {
+ name: "bead-forced-choice",
+ parameters: {
+ prompt: {
+ type: 8,
+ // ParameterType.HTML_STRING
+ default: "Which do you prefer?"
+ },
+ alternatives: {
+ type: 1,
+ // ParameterType.STRING
+ default: [],
+ array: true
+ },
+ layout: {
+ type: 1,
+ // ParameterType.STRING
+ default: "horizontal"
+ },
+ randomize_position: {
+ type: 0,
+ // ParameterType.BOOL
+ default: true
+ },
+ enable_keyboard: {
+ type: 0,
+ // ParameterType.BOOL
+ default: true
+ },
+ require_response: {
+ type: 0,
+ // ParameterType.BOOL
+ default: true
+ },
+ button_label: {
+ type: 1,
+ // ParameterType.STRING
+ default: "Continue"
+ },
+ metadata: {
+ type: 12,
+ // ParameterType.OBJECT
+ default: {}
+ }
+ }
+ };
+ var BeadForcedChoicePlugin = class {
+ constructor(jsPsych) {
+ __publicField(this, "jsPsych");
+ this.jsPsych = jsPsych;
+ }
+ trial(display_element, trial) {
+ const response = {
+ choice: null,
+ choice_index: null,
+ position: null,
+ rt: null
+ };
+ const start_time = performance.now();
+ let left_index = 0;
+ let right_index = 1;
+ if (trial.randomize_position && Math.random() < 0.5) {
+ left_index = 1;
+ right_index = 0;
+ }
+ let html = '';
+ if (trial.prompt) {
+ html += `
${trial.prompt}
`;
+ }
+ html += `
`;
+ html += `
+
+
Option 1
+
${trial.alternatives[left_index] ?? "Alternative A"}
+
+ Select
+
+
+ `;
+ html += `
+
+
Option 2
+
${trial.alternatives[right_index] ?? "Alternative B"}
+
+ Select
+
+
+ `;
+ html += "
";
+ html += "
";
+ display_element.innerHTML = html;
+ const choice_buttons = display_element.querySelectorAll(".bead-choice-button");
+ for (const button of choice_buttons) {
+ button.addEventListener("click", (e) => {
+ const target = e.target;
+ const indexAttr = target.getAttribute("data-index");
+ const positionAttr = target.getAttribute("data-position");
+ if (indexAttr !== null && positionAttr !== null) {
+ const index = Number.parseInt(indexAttr, 10);
+ select_choice(index, positionAttr);
+ }
+ });
+ }
+ let keyboard_listener = null;
+ if (trial.enable_keyboard) {
+ keyboard_listener = this.jsPsych.pluginAPI.getKeyboardResponse({
+ callback_function: (info11) => {
+ const key = info11.key;
+ if (key === "1" || key === "ArrowLeft") {
+ select_choice(left_index, "left");
+ } else if (key === "2" || key === "ArrowRight") {
+ select_choice(right_index, "right");
+ }
+ },
+ valid_responses: ["1", "2", "ArrowLeft", "ArrowRight"],
+ rt_method: "performance",
+ persist: false,
+ allow_held_key: false
+ });
+ }
+ const select_choice = (index, position) => {
+ response.choice = trial.alternatives[index] ?? null;
+ response.choice_index = index;
+ response.position = position;
+ response.rt = performance.now() - start_time;
+ const alternative_cards = display_element.querySelectorAll(".bead-alternative");
+ for (const card of alternative_cards) {
+ card.classList.remove("selected");
+ }
+ const selected_card = display_element.querySelector(
+ `.bead-alternative[data-position="${position}"]`
+ );
+ if (selected_card) {
+ selected_card.classList.add("selected");
+ }
+ setTimeout(() => {
+ end_trial();
+ }, 300);
+ };
+ const end_trial = () => {
+ if (keyboard_listener) {
+ this.jsPsych.pluginAPI.cancelKeyboardResponse(keyboard_listener);
+ }
+ const trial_data = {
+ ...trial.metadata,
+ // Spread all metadata
+ choice: response.choice,
+ choice_index: response.choice_index,
+ position_chosen: response.position,
+ left_index,
+ right_index,
+ rt: response.rt
+ };
+ display_element.innerHTML = "";
+ this.jsPsych.finishTrial(trial_data);
+ };
+ }
+ };
+ __publicField(BeadForcedChoicePlugin, "info", info2);
+
+ // src/plugins/binary-choice.ts
+ var info3 = {
+ name: "bead-binary-choice",
+ parameters: {
+ prompt: {
+ type: 8,
+ // ParameterType.HTML_STRING
+ default: "Is this sentence acceptable?"
+ },
+ stimulus: {
+ type: 8,
+ // ParameterType.HTML_STRING
+ default: ""
+ },
+ choices: {
+ type: 1,
+ // ParameterType.STRING
+ default: ["Yes", "No"],
+ array: true
+ },
+ require_response: {
+ type: 0,
+ // ParameterType.BOOL
+ default: true
+ },
+ metadata: {
+ type: 12,
+ // ParameterType.OBJECT
+ default: {}
+ }
+ }
+ };
+ var BeadBinaryChoicePlugin = class {
+ constructor(jsPsych) {
+ __publicField(this, "jsPsych");
+ this.jsPsych = jsPsych;
+ }
+ trial(display_element, trial) {
+ let response_index = null;
+ let rt = null;
+ const start_time = performance.now();
+ let html = '';
+ if (trial.prompt) {
+ html += `
${trial.prompt}
`;
+ }
+ if (trial.stimulus) {
+ html += `
${trial.stimulus}
`;
+ }
+ html += '
';
+ for (let i = 0; i < trial.choices.length; i++) {
+ html += `${trial.choices[i]} `;
+ }
+ html += "
";
+ html += "
";
+ display_element.innerHTML = html;
+ const buttons = display_element.querySelectorAll(".bead-binary-button");
+ for (const button of buttons) {
+ button.addEventListener("click", (e) => {
+ const target = e.target;
+ const indexAttr = target.getAttribute("data-index");
+ if (indexAttr !== null) {
+ select_choice(Number.parseInt(indexAttr, 10));
+ }
+ });
+ }
+ const select_choice = (index) => {
+ response_index = index;
+ rt = performance.now() - start_time;
+ for (const btn of buttons) {
+ btn.classList.remove("selected");
+ }
+ const selected = display_element.querySelector(
+ `.bead-binary-button[data-index="${index}"]`
+ );
+ if (selected) {
+ selected.classList.add("selected");
+ }
+ setTimeout(() => {
+ end_trial();
+ }, 200);
+ };
+ const end_trial = () => {
+ const trial_data = {
+ ...trial.metadata,
+ response: response_index,
+ response_label: response_index !== null ? trial.choices[response_index] : null,
+ rt
+ };
+ display_element.innerHTML = "";
+ this.jsPsych.finishTrial(trial_data);
+ };
+ }
+ };
+ __publicField(BeadBinaryChoicePlugin, "info", info3);
+
+ // src/plugins/slider-rating.ts
+ var info4 = {
+ name: "bead-slider-rating",
+ parameters: {
+ prompt: {
+ type: 8,
+ // ParameterType.HTML_STRING
+ default: null
+ },
+ slider_min: {
+ type: 2,
+ // ParameterType.INT
+ default: 0
+ },
+ slider_max: {
+ type: 2,
+ // ParameterType.INT
+ default: 100
+ },
+ step: {
+ type: 2,
+ // ParameterType.INT
+ default: 1
+ },
+ slider_start: {
+ type: 2,
+ // ParameterType.INT
+ default: 50
+ },
+ labels: {
+ type: 1,
+ // ParameterType.STRING
+ default: [],
+ array: true
+ },
+ require_movement: {
+ type: 0,
+ // ParameterType.BOOL
+ default: true
+ },
+ button_label: {
+ type: 1,
+ // ParameterType.STRING
+ default: "Continue"
+ },
+ metadata: {
+ type: 12,
+ // ParameterType.OBJECT
+ default: {}
+ }
+ }
+ };
+ var BeadSliderRatingPlugin = class {
+ constructor(jsPsych) {
+ __publicField(this, "jsPsych");
+ this.jsPsych = jsPsych;
+ }
+ trial(display_element, trial) {
+ let slider_value = trial.slider_start;
+ let has_moved = false;
+ const start_time = performance.now();
+ let html = '';
+ if (trial.prompt !== null) {
+ html += `
${trial.prompt}
`;
+ }
+ html += '
';
+ if (trial.labels.length > 0) {
+ html += '
';
+ for (const label of trial.labels) {
+ html += `${label} `;
+ }
+ html += "
";
+ }
+ html += `
`;
+ html += `
${trial.slider_start}
`;
+ html += "
";
+ const disabled = trial.require_movement ? "disabled" : "";
+ html += `
+
+
+ ${trial.button_label}
+
+
+ `;
+ html += "
";
+ display_element.innerHTML = html;
+ const slider = display_element.querySelector(".bead-slider-input");
+ const value_display = display_element.querySelector(".bead-slider-value");
+ const continue_button = display_element.querySelector("#bead-slider-continue");
+ if (slider) {
+ slider.addEventListener("input", () => {
+ slider_value = Number.parseFloat(slider.value);
+ has_moved = true;
+ if (value_display) {
+ value_display.textContent = String(slider_value);
+ }
+ if (continue_button && trial.require_movement) {
+ continue_button.disabled = false;
+ }
+ });
+ }
+ if (continue_button) {
+ continue_button.addEventListener("click", () => {
+ if (!trial.require_movement || has_moved) {
+ end_trial();
+ }
+ });
+ }
+ const end_trial = () => {
+ const rt = performance.now() - start_time;
+ const trial_data = {
+ ...trial.metadata,
+ response: slider_value,
+ rt
+ };
+ display_element.innerHTML = "";
+ this.jsPsych.finishTrial(trial_data);
+ };
+ }
+ };
+ __publicField(BeadSliderRatingPlugin, "info", info4);
+
+ // src/plugins/cloze-dropdown.ts
+ var info5 = {
+ name: "bead-cloze-multi",
+ parameters: {
+ text: {
+ type: 8,
+ // ParameterType.HTML_STRING
+ default: null
+ },
+ fields: {
+ type: 13,
+ // ParameterType.COMPLEX
+ default: [],
+ array: true
+ },
+ require_all: {
+ type: 0,
+ // ParameterType.BOOL
+ default: true
+ },
+ button_label: {
+ type: 1,
+ // ParameterType.STRING
+ default: "Continue"
+ },
+ metadata: {
+ type: 12,
+ // ParameterType.OBJECT
+ default: {}
+ }
+ }
+ };
+ var BeadClozeMultiPlugin = class {
+ constructor(jsPsych) {
+ __publicField(this, "jsPsych");
+ this.jsPsych = jsPsych;
+ }
+ trial(display_element, trial) {
+ const responses = {};
+ const response_times = {};
+ const field_start_times = {};
+ const start_time = performance.now();
+ if (trial.fields.length === 0 && trial.metadata.unfilled_slots) {
+ trial.fields = trial.metadata.unfilled_slots.map((slot) => ({
+ slot_name: slot.slot_name,
+ position: slot.position,
+ type: slot.constraint_ids.length > 0 ? "dropdown" : "text",
+ options: [],
+ // Would be populated from constraints in real implementation
+ placeholder: slot.slot_name
+ }));
+ }
+ let html = '';
+ if (trial.text) {
+ let processed_text = trial.text;
+ trial.fields.forEach((field, index) => {
+ const field_id = `bead-cloze-field-${index}`;
+ let field_html;
+ if (field.type === "dropdown" && field.options && field.options.length > 0) {
+ const optionsHtml = field.options.map((opt) => `
${opt} `).join("");
+ field_html = `
+
+ Select...
+ ${optionsHtml}
+
+ `;
+ } else {
+ field_html = `
+
+ `;
+ }
+ const placeholder = field.slot_name ? `{{${field.slot_name}}}` : "%%";
+ processed_text = processed_text.replace(placeholder, field_html);
+ });
+ html += `
${processed_text}
`;
+ }
+ html += `
+
+
+ ${trial.button_label}
+
+
+ `;
+ html += "
";
+ display_element.innerHTML = html;
+ const input_fields = display_element.querySelectorAll(
+ ".bead-cloze-field"
+ );
+ for (const field of input_fields) {
+ const field_index = field.getAttribute("data-field");
+ if (field_index === null) continue;
+ field.addEventListener("focus", () => {
+ if (field_start_times[field_index] === void 0) {
+ field_start_times[field_index] = performance.now();
+ }
+ });
+ field.addEventListener("change", () => {
+ responses[field_index] = field.value;
+ const startTime = field_start_times[field_index];
+ if (startTime !== void 0) {
+ response_times[field_index] = performance.now() - startTime;
+ }
+ check_completion();
+ });
+ field.addEventListener("input", () => {
+ responses[field_index] = field.value;
+ check_completion();
+ });
+ }
+ const continue_button = display_element.querySelector("#bead-cloze-continue");
+ if (continue_button) {
+ continue_button.addEventListener("click", () => {
+ end_trial();
+ });
+ }
+ const check_completion = () => {
+ if (trial.require_all && continue_button) {
+ const all_filled = trial.fields.every((_field, index) => {
+ const response = responses[index.toString()];
+ return response !== void 0 && response.trim() !== "";
+ });
+ continue_button.disabled = !all_filled;
+ }
+ };
+ const end_trial = () => {
+ const trial_data = {
+ ...trial.metadata,
+ // Preserve all metadata
+ responses,
+ response_times,
+ total_rt: performance.now() - start_time
+ };
+ display_element.innerHTML = "";
+ this.jsPsych.finishTrial(trial_data);
+ };
+ }
+ };
+ __publicField(BeadClozeMultiPlugin, "info", info5);
+
+ // src/lib/wikidata-search.ts
+ var WIKIDATA_API = "https://www.wikidata.org/w/api.php";
+ var CACHE_SIZE = 100;
+ var DEBOUNCE_MS = 300;
+ var cache = /* @__PURE__ */ new Map();
+ function cacheKey(query, opts) {
+ return `${opts.language}:${query}:${opts.limit}:${(opts.entityTypes ?? []).join(",")}`;
+ }
+ function putCache(key, value) {
+ if (cache.size >= CACHE_SIZE) {
+ const firstKey = cache.keys().next().value;
+ if (firstKey !== void 0) {
+ cache.delete(firstKey);
+ }
+ }
+ cache.set(key, value);
+ }
+ async function searchWikidata(query, options) {
+ if (!query || query.trim().length === 0) {
+ return [];
+ }
+ const key = cacheKey(query, options);
+ const cached = cache.get(key);
+ if (cached) {
+ return cached;
+ }
+ const params = new URLSearchParams({
+ action: "wbsearchentities",
+ search: query.trim(),
+ language: options.language,
+ limit: String(options.limit),
+ format: "json",
+ origin: "*"
+ });
+ if (options.entityTypes && options.entityTypes.length > 0) {
+ params.set("type", options.entityTypes[0] ?? "item");
+ }
+ const url = `${WIKIDATA_API}?${params.toString()}`;
+ try {
+ const response = await fetch(url);
+ if (!response.ok) {
+ return [];
+ }
+ const data = await response.json();
+ const results = (data.search ?? []).map(
+ (item) => ({
+ id: String(item["id"] ?? ""),
+ label: String(item["label"] ?? ""),
+ description: String(item["description"] ?? ""),
+ aliases: Array.isArray(item["aliases"]) ? item["aliases"].map(String) : []
+ })
+ );
+ putCache(key, results);
+ return results;
+ } catch {
+ return [];
+ }
+ }
+ var debounceTimer = null;
+ function debouncedSearchWikidata(query, options, callback) {
+ if (debounceTimer !== null) {
+ clearTimeout(debounceTimer);
+ }
+ debounceTimer = setTimeout(async () => {
+ const results = await searchWikidata(query, options);
+ callback(results);
+ }, DEBOUNCE_MS);
+ }
+
+ // src/plugins/span-label.ts
+ var info6 = {
+ name: "bead-span-label",
+ parameters: {
+ tokens: {
+ type: 12,
+ // OBJECT
+ default: {}
+ },
+ space_after: {
+ type: 12,
+ // OBJECT
+ default: {}
+ },
+ spans: {
+ type: 12,
+ // OBJECT
+ default: [],
+ array: true
+ },
+ relations: {
+ type: 12,
+ // OBJECT
+ default: [],
+ array: true
+ },
+ span_spec: {
+ type: 12,
+ // OBJECT
+ default: null
+ },
+ display_config: {
+ type: 12,
+ // OBJECT
+ default: null
+ },
+ prompt: {
+ type: 8,
+ // HTML_STRING
+ default: "Select and label spans"
+ },
+ button_label: {
+ type: 1,
+ // STRING
+ default: "Continue"
+ },
+ require_response: {
+ type: 0,
+ // BOOL
+ default: true
+ },
+ metadata: {
+ type: 12,
+ // OBJECT
+ default: {}
+ }
+ }
+ };
+ var DEFAULT_PALETTE = [
+ "#BBDEFB",
+ "#C8E6C9",
+ "#FFE0B2",
+ "#F8BBD0",
+ "#D1C4E9",
+ "#B2EBF2",
+ "#DCEDC8",
+ "#FFD54F"
+ ];
+ var DARK_PALETTE = [
+ "#1565C0",
+ "#2E7D32",
+ "#E65100",
+ "#AD1457",
+ "#4527A0",
+ "#00838F",
+ "#558B2F",
+ "#F9A825"
+ ];
+ var BeadSpanLabelPlugin = class {
+ constructor(jsPsych) {
+ __publicField(this, "jsPsych");
+ this.jsPsych = jsPsych;
+ }
+ trial(display_element, trial) {
+ const start_time = performance.now();
+ const events = [];
+ const tokens = Object.keys(trial.tokens).length > 0 ? trial.tokens : trial.metadata.tokenized_elements ?? {};
+ const spaceAfter = Object.keys(trial.space_after).length > 0 ? trial.space_after : trial.metadata.token_space_after ?? {};
+ const spanSpec = trial.span_spec ?? trial.metadata.span_spec ?? null;
+ const preSpans = trial.spans.length > 0 ? trial.spans : trial.metadata.spans ?? [];
+ const preRelations = trial.relations.length > 0 ? trial.relations : trial.metadata.span_relations ?? [];
+ const palette = trial.display_config?.color_palette ?? DEFAULT_PALETTE;
+ const isInteractive = spanSpec?.interaction_mode === "interactive";
+ const activeSpans = [...preSpans];
+ const activeRelations = [...preRelations];
+ let selectionStart = null;
+ let selectedIndices = [];
+ let nextSpanId = activeSpans.length;
+ let nextRelationId = activeRelations.length;
+ let relationState = "IDLE";
+ let relationSource = null;
+ let relationTarget = null;
+ let html = '';
+ if (trial.prompt) {
+ html += `
${trial.prompt}
`;
+ }
+ const elementNames = Object.keys(tokens).sort();
+ for (const elemName of elementNames) {
+ const elemTokens = tokens[elemName] ?? [];
+ const elemSpaceAfter = spaceAfter[elemName] ?? [];
+ html += `
`;
+ for (let i = 0; i < elemTokens.length; i++) {
+ const tokenText = elemTokens[i];
+ const interactive = isInteractive ? " interactive" : "";
+ html += `${tokenText} `;
+ if (i < elemSpaceAfter.length && elemSpaceAfter[i]) {
+ html += ` `;
+ }
+ }
+ html += "
";
+ }
+ if (isInteractive && spanSpec?.label_source === "wikidata") {
+ html += '
';
+ html += '
';
+ html += '
';
+ html += '
';
+ html += "
";
+ } else if (isInteractive && spanSpec?.labels && spanSpec.labels.length > 0) {
+ html += '
';
+ html += '
';
+ html += '
';
+ html += '
';
+ html += "
";
+ }
+ if (spanSpec?.enable_relations) {
+ if (isInteractive) {
+ html += '
';
+ html += 'Add Relation ';
+ html += ' ';
+ html += 'Cancel ';
+ html += "
";
+ if (spanSpec.relation_label_source === "wikidata") {
+ html += '
';
+ html += '
';
+ html += '
';
+ html += '
';
+ html += "
";
+ } else if (spanSpec.relation_labels && spanSpec.relation_labels.length > 0) {
+ html += '
';
+ html += '
';
+ html += '
';
+ html += '
';
+ html += "
";
+ }
+ }
+ html += '
';
+ }
+ html += `
+
+
+ ${trial.button_label}
+
+
+ `;
+ html += "
";
+ display_element.innerHTML = html;
+ applySpanHighlights();
+ renderSpanList();
+ if (isInteractive) {
+ setupInteractiveHandlers();
+ if (spanSpec?.label_source === "wikidata") {
+ setupWikidataSearch();
+ } else if (spanSpec?.labels && spanSpec.labels.length > 0) {
+ setupFixedLabelSearch();
+ }
+ if (spanSpec?.enable_relations) {
+ setupRelationHandlers();
+ }
+ }
+ renderRelationArcsOverlay();
+ renderRelationList();
+ const continueBtn = display_element.querySelector("#bead-span-continue");
+ if (continueBtn) {
+ continueBtn.addEventListener("click", () => {
+ endTrial();
+ });
+ }
+ function applySpanHighlights() {
+ const allTokens = display_element.querySelectorAll(".bead-token");
+ for (const t of allTokens) {
+ t.classList.remove("highlighted", "span-first", "span-middle", "span-last", "span-single");
+ t.removeAttribute("data-span-ids");
+ t.removeAttribute("data-span-count");
+ t.style.removeProperty("background-color");
+ t.style.removeProperty("background");
+ }
+ const allSpaces = display_element.querySelectorAll(".bead-space");
+ for (const s of allSpaces) {
+ s.classList.remove("highlighted");
+ s.style.removeProperty("background-color");
+ s.style.removeProperty("background");
+ }
+ const tokenSpanMap = /* @__PURE__ */ new Map();
+ for (const span of activeSpans) {
+ for (const seg of span.segments) {
+ for (const idx of seg.indices) {
+ const key = `${seg.element_name}:${idx}`;
+ if (!tokenSpanMap.has(key)) {
+ tokenSpanMap.set(key, []);
+ }
+ tokenSpanMap.get(key)?.push(span.span_id);
+ }
+ }
+ }
+ const spanColorMap = assignColors();
+ for (const t of allTokens) {
+ const elemName = t.getAttribute("data-element") ?? "";
+ const idx = t.getAttribute("data-index") ?? "";
+ const key = `${elemName}:${idx}`;
+ const spanIds = tokenSpanMap.get(key) ?? [];
+ if (spanIds.length > 0) {
+ t.classList.add("highlighted");
+ t.setAttribute("data-span-ids", spanIds.join(","));
+ t.setAttribute("data-span-count", String(spanIds.length));
+ applySpanColor(t, spanIds, spanColorMap);
+ }
+ }
+ for (const elemName of elementNames) {
+ const elemTokens = tokens[elemName] ?? [];
+ for (let i = 0; i < elemTokens.length; i++) {
+ const key = `${elemName}:${i}`;
+ const spanIds = tokenSpanMap.get(key) ?? [];
+ if (spanIds.length === 0) continue;
+ const t = display_element.querySelector(
+ `.bead-token[data-element="${elemName}"][data-index="${i}"]`
+ );
+ if (!t) continue;
+ const leftKey = `${elemName}:${i - 1}`;
+ const leftSpanIds = tokenSpanMap.get(leftKey) ?? [];
+ const hasLeftNeighbor = spanIds.some((id) => leftSpanIds.includes(id));
+ const rightKey = `${elemName}:${i + 1}`;
+ const rightSpanIds = tokenSpanMap.get(rightKey) ?? [];
+ const hasRightNeighbor = spanIds.some((id) => rightSpanIds.includes(id));
+ if (hasLeftNeighbor && hasRightNeighbor) {
+ t.classList.add("span-middle");
+ } else if (hasLeftNeighbor) {
+ t.classList.add("span-last");
+ } else if (hasRightNeighbor) {
+ t.classList.add("span-first");
+ } else {
+ t.classList.add("span-single");
+ }
+ if (hasRightNeighbor) {
+ const spaceEl = display_element.querySelector(
+ `.bead-space[data-element="${elemName}"][data-after="${i}"]`
+ );
+ if (spaceEl) {
+ spaceEl.classList.add("highlighted");
+ const sharedIds = spanIds.filter((id) => rightSpanIds.includes(id));
+ applySpanColor(spaceEl, sharedIds.length > 0 ? sharedIds : spanIds, spanColorMap);
+ }
+ }
+ }
+ }
+ }
+ function applySpanColor(el, spanIds, colorMap) {
+ if (spanIds.length === 1) {
+ el.style.backgroundColor = colorMap.get(spanIds[0] ?? "") ?? palette[0] ?? "#BBDEFB";
+ } else if (spanIds.length > 1) {
+ const colors = spanIds.map((id) => colorMap.get(id) ?? palette[0] ?? "#BBDEFB");
+ const stripeWidth = 100 / colors.length;
+ const stops = colors.map(
+ (c, ci) => `${c} ${ci * stripeWidth}%, ${c} ${(ci + 1) * stripeWidth}%`
+ ).join(", ");
+ el.style.background = `linear-gradient(135deg, ${stops})`;
+ }
+ }
+ function assignColors() {
+ const colorMap = /* @__PURE__ */ new Map();
+ const labelColors = spanSpec?.label_colors ?? {};
+ const labelToColor = /* @__PURE__ */ new Map();
+ let colorIdx = 0;
+ for (const span of activeSpans) {
+ const label = span.label?.label;
+ if (label && labelColors[label]) {
+ colorMap.set(span.span_id, labelColors[label] ?? "#BBDEFB");
+ } else if (label && labelToColor.has(label)) {
+ colorMap.set(span.span_id, labelToColor.get(label) ?? "#BBDEFB");
+ } else {
+ const color = palette[colorIdx % palette.length] ?? "#BBDEFB";
+ colorMap.set(span.span_id, color);
+ if (label) labelToColor.set(label, color);
+ colorIdx++;
+ }
+ }
+ return colorMap;
+ }
+ function renderSpanList() {
+ const existing = display_element.querySelectorAll(".bead-span-subscript");
+ for (const el of existing) el.remove();
+ const darkColorMap = assignDarkColors();
+ for (const span of activeSpans) {
+ if (!span.label?.label) continue;
+ const allIndices = [];
+ for (const seg of span.segments) {
+ for (const idx of seg.indices) {
+ allIndices.push({ elem: seg.element_name, idx });
+ }
+ }
+ if (allIndices.length === 0) continue;
+ const lastToken = allIndices[allIndices.length - 1];
+ if (!lastToken) continue;
+ const tokenEl = display_element.querySelector(
+ `.bead-token[data-element="${lastToken.elem}"][data-index="${lastToken.idx}"]`
+ );
+ if (!tokenEl) continue;
+ tokenEl.style.position = "relative";
+ const badge = document.createElement("span");
+ badge.className = "bead-span-subscript";
+ const darkColor = darkColorMap.get(span.span_id) ?? DARK_PALETTE[0] ?? "#1565C0";
+ badge.style.backgroundColor = darkColor;
+ badge.setAttribute("data-span-id", span.span_id);
+ const labelSpan = document.createElement("span");
+ labelSpan.textContent = span.label.label;
+ badge.appendChild(labelSpan);
+ if (isInteractive) {
+ const deleteBtn = document.createElement("button");
+ deleteBtn.className = "bead-subscript-delete";
+ deleteBtn.textContent = "\xD7";
+ deleteBtn.addEventListener("click", (e) => {
+ e.stopPropagation();
+ deleteSpan(span.span_id);
+ });
+ badge.appendChild(deleteBtn);
+ }
+ tokenEl.appendChild(badge);
+ }
+ adjustSubscriptPositions();
+ }
+ function adjustSubscriptPositions() {
+ const badges = Array.from(
+ display_element.querySelectorAll(".bead-span-subscript")
+ );
+ if (badges.length < 2) return;
+ for (const b of badges) b.style.transform = "";
+ badges.sort(
+ (a, b) => a.getBoundingClientRect().left - b.getBoundingClientRect().left
+ );
+ const placed = [];
+ for (const badge of badges) {
+ let rect = badge.getBoundingClientRect();
+ let shift = 0;
+ let hasOverlap = true;
+ let iterations = 0;
+ while (hasOverlap && iterations < 10) {
+ hasOverlap = false;
+ for (const p of placed) {
+ const hOverlap = rect.left < p.rect.right + 3 && rect.right > p.rect.left - 3;
+ const vOverlap = rect.top < p.rect.bottom + 1 && rect.bottom > p.rect.top - 1;
+ if (hOverlap && vOverlap) {
+ shift += p.rect.bottom - rect.top + 2;
+ badge.style.transform = `translateY(${shift}px)`;
+ rect = badge.getBoundingClientRect();
+ hasOverlap = true;
+ break;
+ }
+ }
+ iterations++;
+ }
+ placed.push({ el: badge, rect: badge.getBoundingClientRect() });
+ }
+ }
+ function assignDarkColors() {
+ const colorMap = /* @__PURE__ */ new Map();
+ let colorIdx = 0;
+ const labelToColor = /* @__PURE__ */ new Map();
+ for (const span of activeSpans) {
+ const label = span.label?.label;
+ if (label && labelToColor.has(label)) {
+ colorMap.set(span.span_id, labelToColor.get(label) ?? DARK_PALETTE[0] ?? "#1565C0");
+ } else {
+ const color = DARK_PALETTE[colorIdx % DARK_PALETTE.length] ?? "#1565C0";
+ colorMap.set(span.span_id, color);
+ if (label) labelToColor.set(label, color);
+ colorIdx++;
+ }
+ }
+ return colorMap;
+ }
+ function getSpanText(span) {
+ const parts = [];
+ for (const seg of span.segments) {
+ const elemTokens = tokens[seg.element_name] ?? [];
+ for (const idx of seg.indices) {
+ if (idx < elemTokens.length) {
+ parts.push(elemTokens[idx] ?? "");
+ }
+ }
+ }
+ return parts.join(" ");
+ }
+ function setupInteractiveHandlers() {
+ const tokenEls = display_element.querySelectorAll(".bead-token.interactive");
+ let isDragging = false;
+ let dragStartIdx = null;
+ let dragElemName = null;
+ for (const tokenEl of tokenEls) {
+ tokenEl.addEventListener("mousedown", (e) => {
+ e.preventDefault();
+ const idx = Number.parseInt(tokenEl.getAttribute("data-index") ?? "0", 10);
+ const elemName = tokenEl.getAttribute("data-element") ?? "";
+ isDragging = true;
+ dragStartIdx = idx;
+ dragElemName = elemName;
+ if (e.shiftKey && selectionStart !== null) {
+ const start = Math.min(selectionStart, idx);
+ const end = Math.max(selectionStart, idx);
+ selectedIndices = [];
+ for (let i = start; i <= end; i++) {
+ selectedIndices.push(i);
+ }
+ } else {
+ selectedIndices = [idx];
+ selectionStart = idx;
+ }
+ updateSelectionUI(elemName);
+ showLabelPanel();
+ });
+ tokenEl.addEventListener("mouseover", () => {
+ if (!isDragging || dragStartIdx === null || dragElemName === null) return;
+ const idx = Number.parseInt(tokenEl.getAttribute("data-index") ?? "0", 10);
+ const elemName = tokenEl.getAttribute("data-element") ?? "";
+ if (elemName !== dragElemName) return;
+ const start = Math.min(dragStartIdx, idx);
+ const end = Math.max(dragStartIdx, idx);
+ selectedIndices = [];
+ for (let i = start; i <= end; i++) {
+ selectedIndices.push(i);
+ }
+ updateSelectionUI(elemName);
+ });
+ }
+ document.addEventListener("mouseup", () => {
+ if (isDragging) {
+ isDragging = false;
+ showLabelPanel();
+ }
+ });
+ const labelButtons = display_element.querySelectorAll(".bead-label-button");
+ for (const btn of labelButtons) {
+ btn.addEventListener("click", () => {
+ const label = btn.getAttribute("data-label") ?? "";
+ if (selectedIndices.length > 0 && label) {
+ createSpanFromSelection(label);
+ }
+ });
+ }
+ document.addEventListener("keydown", handleKeyDown);
+ }
+ function showLabelPanel() {
+ const labelPanel = display_element.querySelector("#bead-label-panel");
+ if (labelPanel) {
+ const show = selectedIndices.length > 0;
+ labelPanel.style.display = show ? "flex" : "none";
+ if (show) {
+ const searchInput = labelPanel.querySelector("input");
+ if (searchInput) {
+ setTimeout(() => searchInput.focus(), 0);
+ }
+ }
+ }
+ }
+ function handleKeyDown(e) {
+ const num = Number.parseInt(e.key, 10);
+ if (!Number.isNaN(num) && num >= 1 && num <= 9) {
+ const labels = spanSpec?.labels ?? [];
+ if (num <= labels.length && selectedIndices.length > 0) {
+ createSpanFromSelection(labels[num - 1] ?? "");
+ }
+ }
+ }
+ function updateSelectionUI(elementName) {
+ const tokenEls = display_element.querySelectorAll(
+ `.bead-token[data-element="${elementName}"]`
+ );
+ for (const t of tokenEls) {
+ const idx = Number.parseInt(t.getAttribute("data-index") ?? "0", 10);
+ if (selectedIndices.includes(idx)) {
+ t.classList.add("selecting");
+ } else {
+ t.classList.remove("selecting");
+ }
+ }
+ }
+ function createSpanFromSelection(label, labelId) {
+ const elemName = elementNames[0] ?? "text";
+ const spanId = `span_${nextSpanId++}`;
+ const spanLabel = labelId ? { label, label_id: labelId } : { label };
+ const newSpan = {
+ span_id: spanId,
+ segments: [{
+ element_name: elemName,
+ indices: [...selectedIndices].sort((a, b) => a - b)
+ }],
+ label: spanLabel
+ };
+ activeSpans.push(newSpan);
+ events.push({
+ type: "select",
+ timestamp: performance.now() - start_time,
+ span_id: spanId,
+ indices: [...selectedIndices],
+ label
+ });
+ selectedIndices = [];
+ selectionStart = null;
+ applySpanHighlights();
+ renderSpanList();
+ renderRelationList();
+ updateContinueButton();
+ const allTokens = display_element.querySelectorAll(".bead-token");
+ for (const t of allTokens) {
+ t.classList.remove("selecting");
+ }
+ const labelPanel = display_element.querySelector("#bead-label-panel");
+ if (labelPanel) {
+ labelPanel.style.display = "none";
+ }
+ }
+ function deleteSpan(spanId) {
+ const idx = activeSpans.findIndex((s) => s.span_id === spanId);
+ if (idx >= 0) {
+ activeSpans.splice(idx, 1);
+ for (let ri = activeRelations.length - 1; ri >= 0; ri--) {
+ const rel = activeRelations[ri];
+ if (rel && (rel.source_span_id === spanId || rel.target_span_id === spanId)) {
+ activeRelations.splice(ri, 1);
+ }
+ }
+ events.push({
+ type: "delete",
+ timestamp: performance.now() - start_time,
+ span_id: spanId
+ });
+ applySpanHighlights();
+ renderSpanList();
+ renderRelationList();
+ updateContinueButton();
+ }
+ }
+ function setupWikidataSearch() {
+ const input = display_element.querySelector("#bead-wikidata-input");
+ const resultsDiv = display_element.querySelector("#bead-wikidata-results");
+ if (!input || !resultsDiv) return;
+ const searchOptions = {
+ language: spanSpec?.wikidata_language ?? "en",
+ limit: spanSpec?.wikidata_result_limit ?? 10,
+ entityTypes: spanSpec?.wikidata_entity_types
+ };
+ input.addEventListener("input", () => {
+ const query = input.value.trim();
+ if (query.length === 0) {
+ resultsDiv.style.display = "none";
+ resultsDiv.innerHTML = "";
+ return;
+ }
+ debouncedSearchWikidata(query, searchOptions, (results) => {
+ resultsDiv.innerHTML = "";
+ if (results.length === 0) {
+ resultsDiv.style.display = "none";
+ return;
+ }
+ resultsDiv.style.display = "block";
+ for (const entity of results) {
+ const item = document.createElement("div");
+ item.className = "bead-wikidata-result";
+ item.innerHTML = `${entity.label} ${entity.id}
` + (entity.description ? `${entity.description}
` : "");
+ item.addEventListener("click", () => {
+ createSpanFromSelection(entity.label, entity.id);
+ input.value = "";
+ resultsDiv.style.display = "none";
+ resultsDiv.innerHTML = "";
+ });
+ resultsDiv.appendChild(item);
+ }
+ });
+ });
+ }
+ function setupFixedLabelSearch() {
+ const input = display_element.querySelector("#bead-label-search-input");
+ const resultsDiv = display_element.querySelector("#bead-label-search-results");
+ if (!input || !resultsDiv) return;
+ const allLabels = spanSpec?.labels ?? [];
+ let highlightedIdx = -1;
+ function renderResults(query) {
+ resultsDiv.innerHTML = "";
+ const lower = query.toLowerCase();
+ const filtered = lower === "" ? allLabels : allLabels.filter((l) => l.toLowerCase().includes(lower));
+ if (filtered.length === 0) {
+ resultsDiv.style.display = "none";
+ return;
+ }
+ resultsDiv.style.display = "block";
+ highlightedIdx = -1;
+ for (let fi = 0; fi < filtered.length; fi++) {
+ const label = filtered[fi] ?? "";
+ const globalIdx = allLabels.indexOf(label);
+ palette[globalIdx % palette.length] ?? "#BBDEFB";
+ const darkColor = DARK_PALETTE[globalIdx % DARK_PALETTE.length] ?? "#1565C0";
+ const shortcut = globalIdx < 9 ? `${globalIdx + 1}` : "";
+ const item = document.createElement("div");
+ item.className = "bead-label-search-result";
+ item.setAttribute("data-label", label);
+ item.setAttribute("data-fi", String(fi));
+ item.innerHTML = `${label} ` + (shortcut ? `${shortcut} ` : "");
+ item.addEventListener("click", () => {
+ if (selectedIndices.length > 0) {
+ createSpanFromSelection(label);
+ input.value = "";
+ resultsDiv.style.display = "none";
+ }
+ });
+ resultsDiv.appendChild(item);
+ }
+ }
+ input.addEventListener("focus", () => {
+ if (selectedIndices.length > 0) {
+ renderResults(input.value);
+ }
+ });
+ input.addEventListener("input", () => {
+ renderResults(input.value);
+ });
+ input.addEventListener("keydown", (e) => {
+ const items = resultsDiv.querySelectorAll(".bead-label-search-result");
+ if (items.length === 0) return;
+ if (e.key === "ArrowDown") {
+ e.preventDefault();
+ highlightedIdx = Math.min(highlightedIdx + 1, items.length - 1);
+ updateHighlight(items);
+ } else if (e.key === "ArrowUp") {
+ e.preventDefault();
+ highlightedIdx = Math.max(highlightedIdx - 1, 0);
+ updateHighlight(items);
+ } else if (e.key === "Enter") {
+ e.preventDefault();
+ if (highlightedIdx >= 0 && highlightedIdx < items.length) {
+ const label = items[highlightedIdx]?.getAttribute("data-label") ?? "";
+ if (label && selectedIndices.length > 0) {
+ createSpanFromSelection(label);
+ input.value = "";
+ resultsDiv.style.display = "none";
+ }
+ }
+ } else if (e.key === "Escape") {
+ resultsDiv.style.display = "none";
+ }
+ });
+ function updateHighlight(items) {
+ for (let i = 0; i < items.length; i++) {
+ items[i]?.classList.toggle("highlighted", i === highlightedIdx);
+ }
+ items[highlightedIdx]?.scrollIntoView({ block: "nearest" });
+ }
+ document.addEventListener("click", (e) => {
+ if (!input.contains(e.target) && !resultsDiv.contains(e.target)) {
+ resultsDiv.style.display = "none";
+ }
+ });
+ }
+ function setupRelationHandlers() {
+ const addBtn = display_element.querySelector("#bead-add-relation");
+ const cancelBtn = display_element.querySelector("#bead-relation-cancel");
+ const statusEl = display_element.querySelector("#bead-relation-status");
+ if (addBtn) {
+ addBtn.addEventListener("click", () => {
+ relationState = "WAITING_SOURCE";
+ relationSource = null;
+ relationTarget = null;
+ updateRelationUI();
+ });
+ }
+ if (cancelBtn) {
+ cancelBtn.addEventListener("click", () => {
+ cancelRelationCreation();
+ });
+ }
+ function updateRelationUI() {
+ if (!addBtn || !cancelBtn || !statusEl) return;
+ addBtn.disabled = relationState !== "IDLE" || activeSpans.length < 2;
+ addBtn.style.display = relationState === "IDLE" ? "" : "none";
+ cancelBtn.style.display = relationState !== "IDLE" ? "" : "none";
+ if (relationState === "WAITING_SOURCE") {
+ statusEl.textContent = "Click a span label to select the source.";
+ } else if (relationState === "WAITING_TARGET") {
+ statusEl.textContent = "Click a span label to select the target.";
+ } else if (relationState === "WAITING_LABEL") {
+ statusEl.textContent = "Choose a relation label.";
+ } else {
+ statusEl.textContent = "";
+ }
+ const badges = display_element.querySelectorAll(".bead-span-subscript");
+ for (const badge of badges) {
+ badge.classList.remove("relation-source", "relation-target-candidate");
+ const spanId = badge.getAttribute("data-span-id");
+ if (relationState === "WAITING_SOURCE" || relationState === "WAITING_TARGET") {
+ badge.style.cursor = "pointer";
+ if (spanId === relationSource) {
+ badge.classList.add("relation-source");
+ } else if (relationState === "WAITING_TARGET") {
+ badge.classList.add("relation-target-candidate");
+ }
+ } else {
+ badge.style.cursor = "default";
+ }
+ }
+ const labelPanel = display_element.querySelector("#bead-relation-label-panel");
+ if (labelPanel) {
+ labelPanel.style.display = relationState === "WAITING_LABEL" ? "flex" : "none";
+ if (relationState === "WAITING_LABEL") {
+ const searchInput = labelPanel.querySelector("input");
+ if (searchInput) setTimeout(() => searchInput.focus(), 0);
+ }
+ }
+ }
+ display_element._updateRelationUI = updateRelationUI;
+ display_element.addEventListener("click", (e) => {
+ const badge = e.target.closest(".bead-span-subscript");
+ if (!badge) return;
+ const spanId = badge.getAttribute("data-span-id");
+ if (!spanId) return;
+ if (relationState === "WAITING_SOURCE") {
+ relationSource = spanId;
+ relationState = "WAITING_TARGET";
+ updateRelationUI();
+ } else if (relationState === "WAITING_TARGET") {
+ if (spanId === relationSource) return;
+ relationTarget = spanId;
+ relationState = "WAITING_LABEL";
+ updateRelationUI();
+ if (!spanSpec?.relation_labels?.length && spanSpec?.relation_label_source !== "wikidata") {
+ createRelation(void 0);
+ }
+ }
+ });
+ if (spanSpec?.relation_labels && spanSpec.relation_labels.length > 0 && spanSpec.relation_label_source !== "wikidata") {
+ setupRelationLabelSearch();
+ }
+ if (spanSpec?.relation_label_source === "wikidata") {
+ setupRelationWikidataSearch();
+ }
+ function setupRelationLabelSearch() {
+ const input = display_element.querySelector("#bead-relation-label-input");
+ const resultsDiv = display_element.querySelector("#bead-relation-label-results");
+ if (!input || !resultsDiv) return;
+ const allLabels = spanSpec?.relation_labels ?? [];
+ let highlightedIdx = -1;
+ function renderResults(query) {
+ resultsDiv.innerHTML = "";
+ const lower = query.toLowerCase();
+ const filtered = lower === "" ? allLabels : allLabels.filter((l) => l.toLowerCase().includes(lower));
+ if (filtered.length === 0) {
+ resultsDiv.style.display = "none";
+ return;
+ }
+ resultsDiv.style.display = "block";
+ highlightedIdx = -1;
+ for (const label of filtered) {
+ const item = document.createElement("div");
+ item.className = "bead-label-search-result";
+ item.setAttribute("data-label", label);
+ item.innerHTML = `${label} `;
+ item.addEventListener("click", () => {
+ createRelation({ label });
+ input.value = "";
+ resultsDiv.style.display = "none";
+ });
+ resultsDiv.appendChild(item);
+ }
+ }
+ input.addEventListener("focus", () => renderResults(input.value));
+ input.addEventListener("input", () => renderResults(input.value));
+ input.addEventListener("keydown", (e) => {
+ const items = resultsDiv.querySelectorAll(".bead-label-search-result");
+ if (items.length === 0) return;
+ if (e.key === "ArrowDown") {
+ e.preventDefault();
+ highlightedIdx = Math.min(highlightedIdx + 1, items.length - 1);
+ for (let i = 0; i < items.length; i++) items[i]?.classList.toggle("highlighted", i === highlightedIdx);
+ items[highlightedIdx]?.scrollIntoView({ block: "nearest" });
+ } else if (e.key === "ArrowUp") {
+ e.preventDefault();
+ highlightedIdx = Math.max(highlightedIdx - 1, 0);
+ for (let i = 0; i < items.length; i++) items[i]?.classList.toggle("highlighted", i === highlightedIdx);
+ items[highlightedIdx]?.scrollIntoView({ block: "nearest" });
+ } else if (e.key === "Enter") {
+ e.preventDefault();
+ if (highlightedIdx >= 0 && highlightedIdx < items.length) {
+ const label = items[highlightedIdx]?.getAttribute("data-label") ?? "";
+ if (label) {
+ createRelation({ label });
+ input.value = "";
+ resultsDiv.style.display = "none";
+ }
+ }
+ } else if (e.key === "Escape") {
+ cancelRelationCreation();
+ }
+ });
+ }
+ function setupRelationWikidataSearch() {
+ const input = display_element.querySelector("#bead-relation-wikidata-input");
+ const resultsDiv = display_element.querySelector("#bead-relation-wikidata-results");
+ if (!input || !resultsDiv) return;
+ const searchOptions = {
+ language: spanSpec?.wikidata_language ?? "en",
+ limit: spanSpec?.wikidata_result_limit ?? 10,
+ entityTypes: ["property"]
+ };
+ input.addEventListener("input", () => {
+ const query = input.value.trim();
+ if (query.length === 0) {
+ resultsDiv.style.display = "none";
+ resultsDiv.innerHTML = "";
+ return;
+ }
+ debouncedSearchWikidata(query, searchOptions, (results) => {
+ resultsDiv.innerHTML = "";
+ if (results.length === 0) {
+ resultsDiv.style.display = "none";
+ return;
+ }
+ resultsDiv.style.display = "block";
+ for (const entity of results) {
+ const item = document.createElement("div");
+ item.className = "bead-wikidata-result";
+ item.innerHTML = `${entity.label} ${entity.id}
` + (entity.description ? `${entity.description}
` : "");
+ item.addEventListener("click", () => {
+ createRelation({ label: entity.label, label_id: entity.id });
+ input.value = "";
+ resultsDiv.style.display = "none";
+ resultsDiv.innerHTML = "";
+ });
+ resultsDiv.appendChild(item);
+ }
+ });
+ });
+ }
+ function createRelation(label) {
+ if (!relationSource || !relationTarget) return;
+ const relId = `rel_${nextRelationId++}`;
+ const newRelation = {
+ relation_id: relId,
+ source_span_id: relationSource,
+ target_span_id: relationTarget,
+ label,
+ directed: spanSpec?.relation_directed ?? true
+ };
+ activeRelations.push(newRelation);
+ events.push({
+ type: "relation_create",
+ timestamp: performance.now() - start_time,
+ relation_id: relId,
+ label: label?.label
+ });
+ relationState = "IDLE";
+ relationSource = null;
+ relationTarget = null;
+ renderRelationArcsOverlay();
+ renderRelationList();
+ updateRelationUI();
+ updateContinueButton();
+ }
+ function cancelRelationCreation() {
+ relationState = "IDLE";
+ relationSource = null;
+ relationTarget = null;
+ updateRelationUI();
+ }
+ }
+ function deleteRelation(relId) {
+ const idx = activeRelations.findIndex((r) => r.relation_id === relId);
+ if (idx >= 0) {
+ activeRelations.splice(idx, 1);
+ events.push({
+ type: "relation_delete",
+ timestamp: performance.now() - start_time,
+ relation_id: relId
+ });
+ renderRelationArcsOverlay();
+ renderRelationList();
+ updateContinueButton();
+ }
+ }
+ function renderRelationList() {
+ const listEl = display_element.querySelector("#bead-relation-list");
+ if (!listEl) return;
+ listEl.innerHTML = "";
+ for (const rel of activeRelations) {
+ const sourceSpan = activeSpans.find((s) => s.span_id === rel.source_span_id);
+ const targetSpan = activeSpans.find((s) => s.span_id === rel.target_span_id);
+ if (!sourceSpan || !targetSpan) continue;
+ const entry = document.createElement("div");
+ entry.className = "bead-relation-entry";
+ const sourceText = getSpanText(sourceSpan);
+ const targetText = getSpanText(targetSpan);
+ const labelText = rel.label?.label ?? "(no label)";
+ const arrow = rel.directed ? " \u2192 " : " \u2014 ";
+ entry.innerHTML = `${sourceText}${arrow}${labelText} ${arrow}${targetText} `;
+ if (isInteractive) {
+ const delBtn = document.createElement("button");
+ delBtn.className = "bead-relation-delete";
+ delBtn.textContent = "\xD7";
+ delBtn.addEventListener("click", () => deleteRelation(rel.relation_id));
+ entry.appendChild(delBtn);
+ }
+ listEl.appendChild(entry);
+ }
+ const updateUI = display_element._updateRelationUI;
+ if (typeof updateUI === "function") {
+ updateUI();
+ }
+ }
+ function computeSpanPositions() {
+ const positions = /* @__PURE__ */ new Map();
+ const container = display_element.querySelector(".bead-span-container");
+ if (!container) return positions;
+ const containerRect = container.getBoundingClientRect();
+ for (const span of activeSpans) {
+ let minLeft = Infinity;
+ let minTop = Infinity;
+ let maxRight = -Infinity;
+ let maxBottom = -Infinity;
+ for (const seg of span.segments) {
+ for (const idx of seg.indices) {
+ const tokenEl = display_element.querySelector(
+ `.bead-token[data-element="${seg.element_name}"][data-index="${idx}"]`
+ );
+ if (tokenEl) {
+ const rect = tokenEl.getBoundingClientRect();
+ minLeft = Math.min(minLeft, rect.left - containerRect.left);
+ minTop = Math.min(minTop, rect.top - containerRect.top);
+ maxRight = Math.max(maxRight, rect.right - containerRect.left);
+ maxBottom = Math.max(maxBottom, rect.bottom - containerRect.top);
+ }
+ }
+ }
+ if (minLeft !== Infinity) {
+ positions.set(span.span_id, new DOMRect(minLeft, minTop, maxRight - minLeft, maxBottom - minTop));
+ }
+ }
+ return positions;
+ }
+ function renderRelationArcsOverlay() {
+ if (activeRelations.length === 0) return;
+ const container = display_element.querySelector(".bead-span-container");
+ if (!container) return;
+ const existingArcDiv = display_element.querySelector(".bead-relation-arc-area");
+ if (existingArcDiv) existingArcDiv.remove();
+ const spanPositions = computeSpanPositions();
+ if (spanPositions.size === 0) return;
+ const arcArea = document.createElement("div");
+ arcArea.className = "bead-relation-arc-area";
+ arcArea.style.position = "relative";
+ arcArea.style.width = "100%";
+ const baseHeight = 28;
+ const levelSpacing = 28;
+ const totalHeight = baseHeight + (activeRelations.length - 1) * levelSpacing + 12;
+ arcArea.style.height = `${totalHeight}px`;
+ arcArea.style.marginBottom = "4px";
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
+ svg.classList.add("bead-relation-layer");
+ svg.setAttribute("width", "100%");
+ svg.setAttribute("height", String(totalHeight));
+ svg.style.overflow = "visible";
+ const defs = document.createElementNS("http://www.w3.org/2000/svg", "defs");
+ const marker = document.createElementNS("http://www.w3.org/2000/svg", "marker");
+ marker.setAttribute("id", "rel-arrow");
+ marker.setAttribute("markerWidth", "8");
+ marker.setAttribute("markerHeight", "6");
+ marker.setAttribute("refX", "8");
+ marker.setAttribute("refY", "3");
+ marker.setAttribute("orient", "auto");
+ const polygon = document.createElementNS("http://www.w3.org/2000/svg", "polygon");
+ polygon.setAttribute("points", "0 0, 8 3, 0 6");
+ polygon.setAttribute("fill", "#546e7a");
+ marker.appendChild(polygon);
+ defs.appendChild(marker);
+ svg.appendChild(defs);
+ container.getBoundingClientRect();
+ arcArea.getBoundingClientRect();
+ for (let i = 0; i < activeRelations.length; i++) {
+ const rel = activeRelations[i];
+ if (!rel) continue;
+ const sourceRect = spanPositions.get(rel.source_span_id);
+ const targetRect = spanPositions.get(rel.target_span_id);
+ if (!sourceRect || !targetRect) continue;
+ const x1 = sourceRect.x + sourceRect.width / 2;
+ const x2 = targetRect.x + targetRect.width / 2;
+ const bottomY = totalHeight;
+ const railY = totalHeight - baseHeight - i * levelSpacing;
+ const r = 5;
+ const strokeColor = "#546e7a";
+ const dir = x2 > x1 ? 1 : -1;
+ const d = [
+ `M ${x1} ${bottomY}`,
+ `L ${x1} ${railY + r}`,
+ `Q ${x1} ${railY} ${x1 + r * dir} ${railY}`,
+ `L ${x2 - r * dir} ${railY}`,
+ `Q ${x2} ${railY} ${x2} ${railY + r}`,
+ `L ${x2} ${bottomY}`
+ ].join(" ");
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
+ path.setAttribute("d", d);
+ path.setAttribute("stroke", strokeColor);
+ path.setAttribute("fill", "none");
+ path.setAttribute("stroke-width", "1.5");
+ if (rel.directed) {
+ path.setAttribute("marker-end", "url(#rel-arrow)");
+ }
+ svg.appendChild(path);
+ if (rel.label?.label) {
+ const midX = (x1 + x2) / 2;
+ const labelText = rel.label.label;
+ const fo = document.createElementNS("http://www.w3.org/2000/svg", "foreignObject");
+ const labelWidth = labelText.length * 7 + 16;
+ fo.setAttribute("x", String(midX - labelWidth / 2));
+ fo.setAttribute("y", String(railY - 10));
+ fo.setAttribute("width", String(labelWidth));
+ fo.setAttribute("height", "20");
+ const labelDiv = document.createElement("div");
+ labelDiv.style.cssText = `
+ font-size: 11px;
+ font-family: inherit;
+ color: #455a64;
+ background: #fafafa;
+ padding: 1px 6px;
+ border-radius: 3px;
+ text-align: center;
+ line-height: 18px;
+ white-space: nowrap;
+ `;
+ labelDiv.textContent = labelText;
+ fo.appendChild(labelDiv);
+ svg.appendChild(fo);
+ }
+ }
+ arcArea.appendChild(svg);
+ container.parentNode?.insertBefore(arcArea, container);
+ }
+ function updateContinueButton() {
+ if (!continueBtn || !isInteractive) return;
+ const minSpans = spanSpec?.min_spans ?? 0;
+ continueBtn.disabled = activeSpans.length < minSpans;
+ }
+ const endTrial = () => {
+ document.removeEventListener("keydown", handleKeyDown);
+ const trial_data = {
+ ...trial.metadata,
+ spans: activeSpans,
+ relations: activeRelations,
+ span_events: events,
+ rt: performance.now() - start_time
+ };
+ display_element.innerHTML = "";
+ this.jsPsych.finishTrial(trial_data);
+ };
+ }
+ };
+ __publicField(BeadSpanLabelPlugin, "info", info6);
+
+ // src/plugins/categorical.ts
+ var info7 = {
+ name: "bead-categorical",
+ parameters: {
+ prompt: {
+ type: 8,
+ // ParameterType.HTML_STRING
+ default: "Select a category:"
+ },
+ stimulus: {
+ type: 8,
+ // ParameterType.HTML_STRING
+ default: ""
+ },
+ categories: {
+ type: 1,
+ // ParameterType.STRING
+ default: [],
+ array: true
+ },
+ require_response: {
+ type: 0,
+ // ParameterType.BOOL
+ default: true
+ },
+ button_label: {
+ type: 1,
+ // ParameterType.STRING
+ default: "Continue"
+ },
+ metadata: {
+ type: 12,
+ // ParameterType.OBJECT
+ default: {}
+ }
+ }
+ };
+ var BeadCategoricalPlugin = class {
+ constructor(jsPsych) {
+ __publicField(this, "jsPsych");
+ this.jsPsych = jsPsych;
+ }
+ trial(display_element, trial) {
+ let selected_index = null;
+ const start_time = performance.now();
+ let html = '';
+ if (trial.prompt) {
+ html += `
${trial.prompt}
`;
+ }
+ if (trial.stimulus) {
+ html += `
${trial.stimulus}
`;
+ }
+ html += '
';
+ for (let i = 0; i < trial.categories.length; i++) {
+ html += `${trial.categories[i]} `;
+ }
+ html += "
";
+ const disabled = trial.require_response ? "disabled" : "";
+ html += `
+
+
+ ${trial.button_label}
+
+
+ `;
+ html += "
";
+ display_element.innerHTML = html;
+ const buttons = display_element.querySelectorAll(".bead-categorical-button");
+ const continueBtn = display_element.querySelector("#bead-categorical-continue");
+ for (const button of buttons) {
+ button.addEventListener("click", (e) => {
+ const target = e.currentTarget;
+ const indexAttr = target.getAttribute("data-index");
+ if (indexAttr !== null) {
+ selected_index = Number.parseInt(indexAttr, 10);
+ for (const btn of buttons) {
+ btn.classList.remove("selected");
+ }
+ target.classList.add("selected");
+ if (continueBtn) {
+ continueBtn.disabled = false;
+ }
+ }
+ });
+ }
+ if (continueBtn) {
+ continueBtn.addEventListener("click", () => {
+ if (!trial.require_response || selected_index !== null) {
+ end_trial();
+ }
+ });
+ }
+ const end_trial = () => {
+ const rt = performance.now() - start_time;
+ const trial_data = {
+ ...trial.metadata,
+ response: selected_index !== null ? trial.categories[selected_index] : null,
+ response_index: selected_index,
+ rt
+ };
+ display_element.innerHTML = "";
+ this.jsPsych.finishTrial(trial_data);
+ };
+ }
+ };
+ __publicField(BeadCategoricalPlugin, "info", info7);
+
+ // src/plugins/magnitude.ts
+ var info8 = {
+ name: "bead-magnitude",
+ parameters: {
+ prompt: {
+ type: 8,
+ // ParameterType.HTML_STRING
+ default: "Enter a value:"
+ },
+ stimulus: {
+ type: 8,
+ // ParameterType.HTML_STRING
+ default: ""
+ },
+ unit: {
+ type: 1,
+ // ParameterType.STRING
+ default: ""
+ },
+ input_min: {
+ type: 3,
+ // ParameterType.FLOAT
+ default: null
+ },
+ input_max: {
+ type: 3,
+ // ParameterType.FLOAT
+ default: null
+ },
+ step: {
+ type: 3,
+ // ParameterType.FLOAT
+ default: null
+ },
+ placeholder: {
+ type: 1,
+ // ParameterType.STRING
+ default: ""
+ },
+ require_response: {
+ type: 0,
+ // ParameterType.BOOL
+ default: true
+ },
+ button_label: {
+ type: 1,
+ // ParameterType.STRING
+ default: "Continue"
+ },
+ metadata: {
+ type: 12,
+ // ParameterType.OBJECT
+ default: {}
+ }
+ }
+ };
+ var BeadMagnitudePlugin = class {
+ constructor(jsPsych) {
+ __publicField(this, "jsPsych");
+ this.jsPsych = jsPsych;
+ }
+ trial(display_element, trial) {
+ const start_time = performance.now();
+ let html = '';
+ if (trial.prompt) {
+ html += `
${trial.prompt}
`;
+ }
+ if (trial.stimulus) {
+ html += `
${trial.stimulus}
`;
+ }
+ html += '
';
+ html += ' ";
+ if (trial.unit) {
+ html += `${trial.unit} `;
+ }
+ html += "
";
+ const disabled = trial.require_response ? "disabled" : "";
+ html += `
+
+
+ ${trial.button_label}
+
+
+ `;
+ html += "
";
+ display_element.innerHTML = html;
+ const input = display_element.querySelector("#bead-magnitude-input");
+ const continueBtn = display_element.querySelector("#bead-magnitude-continue");
+ if (input) {
+ input.addEventListener("input", () => {
+ if (continueBtn) {
+ continueBtn.disabled = trial.require_response && input.value.trim() === "";
+ }
+ });
+ input.focus();
+ }
+ if (continueBtn) {
+ continueBtn.addEventListener("click", () => {
+ if (!trial.require_response || input && input.value.trim() !== "") {
+ end_trial();
+ }
+ });
+ }
+ const end_trial = () => {
+ const rt = performance.now() - start_time;
+ const value = input ? Number.parseFloat(input.value) : null;
+ const trial_data = {
+ ...trial.metadata,
+ response: Number.isNaN(value ?? Number.NaN) ? null : value,
+ rt
+ };
+ display_element.innerHTML = "";
+ this.jsPsych.finishTrial(trial_data);
+ };
+ }
+ };
+ __publicField(BeadMagnitudePlugin, "info", info8);
+
+ // src/plugins/free-text.ts
+ var info9 = {
+ name: "bead-free-text",
+ parameters: {
+ prompt: {
+ type: 8,
+ // ParameterType.HTML_STRING
+ default: "Enter your response:"
+ },
+ stimulus: {
+ type: 8,
+ // ParameterType.HTML_STRING
+ default: ""
+ },
+ multiline: {
+ type: 0,
+ // ParameterType.BOOL
+ default: false
+ },
+ min_length: {
+ type: 2,
+ // ParameterType.INT
+ default: 0
+ },
+ max_length: {
+ type: 2,
+ // ParameterType.INT
+ default: 0
+ },
+ placeholder: {
+ type: 1,
+ // ParameterType.STRING
+ default: ""
+ },
+ rows: {
+ type: 2,
+ // ParameterType.INT
+ default: 4
+ },
+ require_response: {
+ type: 0,
+ // ParameterType.BOOL
+ default: true
+ },
+ button_label: {
+ type: 1,
+ // ParameterType.STRING
+ default: "Continue"
+ },
+ metadata: {
+ type: 12,
+ // ParameterType.OBJECT
+ default: {}
+ }
+ }
+ };
+ var BeadFreeTextPlugin = class {
+ constructor(jsPsych) {
+ __publicField(this, "jsPsych");
+ this.jsPsych = jsPsych;
+ }
+ trial(display_element, trial) {
+ const start_time = performance.now();
+ let html = '';
+ if (trial.prompt) {
+ html += `
${trial.prompt}
`;
+ }
+ if (trial.stimulus) {
+ html += `
${trial.stimulus}
`;
+ }
+ const maxAttr = trial.max_length > 0 ? ` maxlength="${trial.max_length}"` : "";
+ const placeholderAttr = trial.placeholder ? ` placeholder="${trial.placeholder}"` : "";
+ if (trial.multiline) {
+ html += `
`;
+ } else {
+ html += `
`;
+ }
+ if (trial.max_length > 0) {
+ html += `
0 / ${trial.max_length}
`;
+ }
+ const disabled = trial.require_response ? "disabled" : "";
+ html += `
+
+
+ ${trial.button_label}
+
+
+ `;
+ html += "
";
+ display_element.innerHTML = html;
+ const input = display_element.querySelector("#bead-free-text-input");
+ const continueBtn = display_element.querySelector("#bead-free-text-continue");
+ const charCount = display_element.querySelector("#bead-char-count");
+ if (input) {
+ input.addEventListener("input", () => {
+ const len = input.value.length;
+ if (charCount) charCount.textContent = String(len);
+ if (continueBtn) {
+ const meetsMin = len >= trial.min_length;
+ const hasContent = input.value.trim().length > 0;
+ continueBtn.disabled = trial.require_response && (!hasContent || !meetsMin);
+ }
+ });
+ input.focus();
+ }
+ if (continueBtn) {
+ continueBtn.addEventListener("click", () => {
+ end_trial();
+ });
+ }
+ const end_trial = () => {
+ const rt = performance.now() - start_time;
+ const trial_data = {
+ ...trial.metadata,
+ response: input ? input.value : "",
+ rt
+ };
+ display_element.innerHTML = "";
+ this.jsPsych.finishTrial(trial_data);
+ };
+ }
+ };
+ __publicField(BeadFreeTextPlugin, "info", info9);
+
+ // src/plugins/multi-select.ts
+ var info10 = {
+ name: "bead-multi-select",
+ parameters: {
+ prompt: {
+ type: 8,
+ // ParameterType.HTML_STRING
+ default: "Select all that apply:"
+ },
+ stimulus: {
+ type: 8,
+ // ParameterType.HTML_STRING
+ default: ""
+ },
+ options: {
+ type: 1,
+ // ParameterType.STRING
+ default: [],
+ array: true
+ },
+ min_selections: {
+ type: 2,
+ // ParameterType.INT
+ default: 1
+ },
+ max_selections: {
+ type: 2,
+ // ParameterType.INT
+ default: 0
+ },
+ require_response: {
+ type: 0,
+ // ParameterType.BOOL
+ default: true
+ },
+ button_label: {
+ type: 1,
+ // ParameterType.STRING
+ default: "Continue"
+ },
+ metadata: {
+ type: 12,
+ // ParameterType.OBJECT
+ default: {}
+ }
+ }
+ };
+ var BeadMultiSelectPlugin = class {
+ constructor(jsPsych) {
+ __publicField(this, "jsPsych");
+ this.jsPsych = jsPsych;
+ }
+ trial(display_element, trial) {
+ const start_time = performance.now();
+ let html = '';
+ if (trial.prompt) {
+ html += `
${trial.prompt}
`;
+ }
+ if (trial.stimulus) {
+ html += `
${trial.stimulus}
`;
+ }
+ html += '
';
+ for (let i = 0; i < trial.options.length; i++) {
+ html += `
+
+
+ ${trial.options[i]}
+
+ `;
+ }
+ html += "
";
+ const disabled = trial.require_response ? "disabled" : "";
+ html += `
+
+
+ ${trial.button_label}
+
+
+ `;
+ html += "
";
+ display_element.innerHTML = html;
+ const checkboxes = display_element.querySelectorAll(".bead-multi-select-checkbox");
+ const continueBtn = display_element.querySelector("#bead-multi-select-continue");
+ const updateButton = () => {
+ const checked = display_element.querySelectorAll(".bead-multi-select-checkbox:checked");
+ const count = checked.length;
+ if (trial.max_selections > 0 && count >= trial.max_selections) {
+ for (const cb of checkboxes) {
+ if (!cb.checked) cb.disabled = true;
+ }
+ } else {
+ for (const cb of checkboxes) {
+ cb.disabled = false;
+ }
+ }
+ if (continueBtn) {
+ continueBtn.disabled = trial.require_response && count < trial.min_selections;
+ }
+ };
+ for (const cb of checkboxes) {
+ cb.addEventListener("change", updateButton);
+ }
+ if (continueBtn) {
+ continueBtn.addEventListener("click", () => {
+ end_trial();
+ });
+ }
+ const end_trial = () => {
+ const rt = performance.now() - start_time;
+ const checked = display_element.querySelectorAll(".bead-multi-select-checkbox:checked");
+ const selected = [];
+ const selected_indices = [];
+ for (const cb of checked) {
+ selected.push(cb.value);
+ const idx = cb.getAttribute("data-index");
+ if (idx !== null) selected_indices.push(Number.parseInt(idx, 10));
+ }
+ const trial_data = {
+ ...trial.metadata,
+ selected,
+ selected_indices,
+ rt
+ };
+ display_element.innerHTML = "";
+ this.jsPsych.finishTrial(trial_data);
+ };
+ }
+ };
+ __publicField(BeadMultiSelectPlugin, "info", info10);
+
+ // src/gallery/gallery-bundle.ts
+ window.BeadRatingPlugin = BeadRatingPlugin;
+ window.BeadForcedChoicePlugin = BeadForcedChoicePlugin;
+ window.BeadBinaryChoicePlugin = BeadBinaryChoicePlugin;
+ window.BeadSliderRatingPlugin = BeadSliderRatingPlugin;
+ window.BeadClozeMultiPlugin = BeadClozeMultiPlugin;
+ window.BeadSpanLabelPlugin = BeadSpanLabelPlugin;
+ window.BeadCategoricalPlugin = BeadCategoricalPlugin;
+ window.BeadMagnitudePlugin = BeadMagnitudePlugin;
+ window.BeadFreeTextPlugin = BeadFreeTextPlugin;
+ window.BeadMultiSelectPlugin = BeadMultiSelectPlugin;
+
+})();
diff --git a/mkdocs.yml b/mkdocs.yml
index 98cbbfc..f31d346 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -102,4 +102,4 @@ nav:
- Setup: developer-guide/setup.md
- Testing: developer-guide/testing.md
- Contributing: developer-guide/contributing.md
- - Examples: examples/gallery.md
+ - Task Gallery: examples/gallery.md
From a036932b4a5860f74dba4c9a6463a25d87ddc280 Mon Sep 17 00:00:00 2001
From: Aaron Steven White
Date: Tue, 10 Feb 2026 12:11:23 -0500
Subject: [PATCH 07/11] Adds prompt span references, exponential magnitude
slider, version bump to 0.2.0, and TypeScript strict mode fixes.
---
CHANGELOG.md | 70 +-
bead/__init__.py | 2 +-
bead/deployment/jspsych/config.py | 22 +-
.../jspsych/src/gallery/gallery-bundle.ts | 12 +-
.../jspsych/src/lib/span-renderer.test.ts | 50 +-
.../jspsych/src/lib/span-renderer.ts | 20 +-
.../jspsych/src/lib/wikidata-search.ts | 14 +-
.../jspsych/src/plugins/binary-choice.ts | 15 +-
.../jspsych/src/plugins/categorical.ts | 19 +-
.../jspsych/src/plugins/forced-choice.ts | 14 +-
.../jspsych/src/plugins/free-text.ts | 23 +-
.../jspsych/src/plugins/magnitude.ts | 384 +-
.../jspsych/src/plugins/multi-select.ts | 45 +-
.../jspsych/src/plugins/plugins.test.ts | 97 +
bead/deployment/jspsych/src/plugins/rating.ts | 25 +-
.../jspsych/src/plugins/slider-rating.ts | 28 +-
.../jspsych/src/plugins/span-label.ts | 369 +-
.../jspsych/templates/experiment.css | 33 +
bead/deployment/jspsych/trials.py | 296 +-
bead/deployment/jspsych/ui/styles.py | 25 +
bead/items/span_labeling.py | 22 +
docs/developer-guide/setup.md | 4 +-
docs/examples/gallery.md | 333 +-
docs/gallery/css/gallery.css | 283 +-
docs/gallery/demos/binary-choice.html | 7 +-
docs/gallery/demos/categorical.html | 13 +-
docs/gallery/demos/cloze-dropdown.html | 15 +-
docs/gallery/demos/forced-choice.html | 15 +-
docs/gallery/demos/free-text.html | 12 +-
docs/gallery/demos/magnitude.html | 21 +-
docs/gallery/demos/multi-select.html | 24 +-
docs/gallery/demos/rating-likert.html | 11 +-
docs/gallery/demos/rating-slider.html | 14 +-
docs/gallery/demos/span-interactive.html | 5 +-
docs/gallery/demos/span-relations-fixed.html | 7 +-
.../demos/span-relations-wikidata.html | 2 +-
docs/gallery/demos/span-wikidata.html | 2 +-
docs/gallery/demos/span-with-binary.html | 30 +-
docs/gallery/demos/span-with-choice.html | 33 +-
docs/gallery/demos/span-with-freetext.html | 162 +
docs/gallery/demos/span-with-rating.html | 29 +-
docs/gallery/demos/span-with-slider.html | 31 +-
docs/gallery/js/gallery-bundle.js | 3909 +++++++++--------
docs/index.md | 2 +-
docs/user-guide/api/deployment.md | 30 +
docs/user-guide/api/items.md | 43 +
tests/deployment/jspsych/test_trials.py | 219 +
47 files changed, 4541 insertions(+), 2300 deletions(-)
create mode 100644 docs/gallery/demos/span-with-freetext.html
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f067f4c..636721d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,73 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [0.2.0] - 2026-02-09
+
+### Added
+
+#### Span Labeling Data Model (`bead.items`)
+
+- **Span**, **SpanLabel**, **SpanSegment** models for stand-off token-level annotation
+- **SpanSpec** for defining label vocabularies and relation types
+- **SpanRelation** for directed labeled relations between spans
+- `add_spans_to_item()` composability function for attaching spans to any item type
+- Prompt span references: `[[label]]` and `[[label:text]]` template syntax
+ - Auto-fills span token text or uses explicit display text
+ - Colors match between stimulus highlighting and prompt highlighting
+ - Resolved Python-side at trial generation; plugins receive pre-rendered HTML
+ - Early validation warning in `add_spans_to_item()`, hard validation at trial generation
+
+#### Tokenization (`bead.tokenization`)
+
+- **Token** model with `text`, `whitespace`, `index`, `token_space_after` fields
+- **TokenizedText** container with token-level access and reconstruction
+- Tokenizer backends: whitespace (default), spaCy, Stanza
+- Lazy imports for optional NLP dependencies
+
+#### jsPsych Plugins (`bead.deployment.jspsych`)
+
+- 8 new TypeScript plugins following the `JsPsychPlugin` pattern:
+ - **bead-binary-choice**: two-alternative forced choice with keyboard support
+ - **bead-categorical**: labeled category selection (radio buttons)
+ - **bead-free-text**: open-ended text input with optional word count
+ - **bead-magnitude**: numeric magnitude estimation with reference stimulus
+ - **bead-multi-select**: checkbox-based multi-selection with min/max constraints
+ - **bead-slider-rating**: continuous slider with labeled endpoints
+ - **bead-rating**: Likert-scale ordinal rating with keyboard shortcuts
+ - **bead-span-label**: interactive span highlighting with label assignment, relations, and search
+- **span-renderer** library for token-level span highlighting with overlap support
+- **gallery-bundle** IIFE build aggregating all plugins for standalone HTML demos
+- Keyboard navigation support in forced-choice, rating, and binary-choice plugins
+- Material Design styling with responsive layout
+
+#### Deployment Pipeline
+
+- `SpanDisplayConfig` with `color_palette` and `dark_color_palette` for consistent span coloring
+- `SpanColorMap` dataclass for deterministic color assignment (same label = same color pair)
+- `_assign_span_colors()` shared between stimulus and prompt renderers
+- `_generate_span_stimulus_html()` for token-level highlighting in deployed experiments
+- Prompt span reference resolution integrated into all 5 composite trial creators (likert, slider, binary, forced-choice, span-labeling)
+- Deployment CSS for `.bead-q-highlight`, `.bead-q-chip`, `.bead-span-subscript` in experiment template
+
+#### Interactive Gallery
+
+- 17 demo pages using stimuli from MegaAcceptability, MegaVeridicality, and Semantic Proto-Roles
+- Demos cover all plugin types and composite span+task combinations
+- Gallery documentation with tabbed Demo / Python / Trial JSON views
+- Standalone HTML demos with gallery-bundle.js (no build step required)
+
+#### Tests
+
+- 79 Python span-related tests (items, tokenization, deployment)
+- 42 TypeScript tests (20 plugin + 22 span-renderer)
+- Prompt span reference tests: parser, color assignment, resolver, integration
+
+### Changed
+
+- Trial generation now supports span-aware stimulus rendering for all task types
+- Forced-choice and rating plugins updated with keyboard shortcut support
+- Span-label plugin enhanced with searchable fixed labels, interactive relation creation, and relation cleanup on span deletion
+
## [0.1.0] - 2026-02-04
### Added
@@ -115,5 +182,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- CI/CD: GitHub Actions for testing, docs, PyPI publishing
- Read the Docs integration
-[Unreleased]: https://github.com/FACTSlab/bead/compare/v0.1.0...HEAD
+[Unreleased]: https://github.com/FACTSlab/bead/compare/v0.2.0...HEAD
+[0.2.0]: https://github.com/FACTSlab/bead/compare/v0.1.0...v0.2.0
[0.1.0]: https://github.com/FACTSlab/bead/releases/tag/v0.1.0
diff --git a/bead/__init__.py b/bead/__init__.py
index 2ad06c4..d16d8f1 100644
--- a/bead/__init__.py
+++ b/bead/__init__.py
@@ -6,6 +6,6 @@
from __future__ import annotations
-__version__ = "0.1.0"
+__version__ = "0.2.0"
__author__ = "Aaron Steven White"
__email__ = "aaron.white@rochester.edu"
diff --git a/bead/deployment/jspsych/config.py b/bead/deployment/jspsych/config.py
index 48e49bd..f928209 100644
--- a/bead/deployment/jspsych/config.py
+++ b/bead/deployment/jspsych/config.py
@@ -52,6 +52,20 @@ def _default_span_color_palette() -> list[str]:
]
+def _default_span_dark_palette() -> list[str]:
+ """Return default dark color palette for span subscript badges."""
+ return [
+ "#1565C0",
+ "#2E7D32",
+ "#E65100",
+ "#AD1457",
+ "#4527A0",
+ "#00838F",
+ "#558B2F",
+ "#F9A825",
+ ]
+
+
class SpanDisplayConfig(BaseModel):
"""Visual configuration for span rendering in experiments.
@@ -60,7 +74,10 @@ class SpanDisplayConfig(BaseModel):
highlight_style : Literal["background", "underline", "border"]
How to visually indicate spans.
color_palette : list[str]
- CSS color values for span highlighting.
+ CSS color values for span highlighting (light backgrounds).
+ dark_color_palette : list[str]
+ CSS color values for subscript label badges (dark, index-aligned
+ with color_palette).
show_labels : bool
Whether to show span labels inline.
show_tooltips : bool
@@ -77,6 +94,9 @@ class SpanDisplayConfig(BaseModel):
color_palette: list[str] = Field(
default_factory=_default_span_color_palette
)
+ dark_color_palette: list[str] = Field(
+ default_factory=_default_span_dark_palette
+ )
show_labels: bool = True
show_tooltips: bool = True
token_delimiter: str = " "
diff --git a/bead/deployment/jspsych/src/gallery/gallery-bundle.ts b/bead/deployment/jspsych/src/gallery/gallery-bundle.ts
index e34d6d2..042de7e 100644
--- a/bead/deployment/jspsych/src/gallery/gallery-bundle.ts
+++ b/bead/deployment/jspsych/src/gallery/gallery-bundle.ts
@@ -9,16 +9,16 @@
* @version 0.2.0
*/
-import { BeadRatingPlugin } from "../plugins/rating.js";
-import { BeadForcedChoicePlugin } from "../plugins/forced-choice.js";
import { BeadBinaryChoicePlugin } from "../plugins/binary-choice.js";
-import { BeadSliderRatingPlugin } from "../plugins/slider-rating.js";
-import { BeadClozeMultiPlugin } from "../plugins/cloze-dropdown.js";
-import { BeadSpanLabelPlugin } from "../plugins/span-label.js";
import { BeadCategoricalPlugin } from "../plugins/categorical.js";
-import { BeadMagnitudePlugin } from "../plugins/magnitude.js";
+import { BeadClozeMultiPlugin } from "../plugins/cloze-dropdown.js";
+import { BeadForcedChoicePlugin } from "../plugins/forced-choice.js";
import { BeadFreeTextPlugin } from "../plugins/free-text.js";
+import { BeadMagnitudePlugin } from "../plugins/magnitude.js";
import { BeadMultiSelectPlugin } from "../plugins/multi-select.js";
+import { BeadRatingPlugin } from "../plugins/rating.js";
+import { BeadSliderRatingPlugin } from "../plugins/slider-rating.js";
+import { BeadSpanLabelPlugin } from "../plugins/span-label.js";
declare global {
interface Window {
diff --git a/bead/deployment/jspsych/src/lib/span-renderer.test.ts b/bead/deployment/jspsych/src/lib/span-renderer.test.ts
index cdb80f6..54416b4 100644
--- a/bead/deployment/jspsych/src/lib/span-renderer.test.ts
+++ b/bead/deployment/jspsych/src/lib/span-renderer.test.ts
@@ -12,8 +12,8 @@ import {
type SpanDisplayConfig,
assignSpanColors,
computeTokenSpanMap,
- renderTokenizedText,
renderRelationArcs,
+ renderTokenizedText,
} from "./span-renderer.js";
const DEFAULT_CONFIG: SpanDisplayConfig = {
@@ -144,11 +144,7 @@ describe("assignSpanColors", () => {
{ span_id: "span_1", segments: [], label: { label: "Location" } },
];
- const colors = assignSpanColors(
- spans,
- ["#000000"],
- { "Person": "#CUSTOM1" },
- );
+ const colors = assignSpanColors(spans, ["#000000"], { Person: "#CUSTOM1" });
expect(colors.get("span_0")).toBe("#CUSTOM1");
expect(colors.get("span_1")).toBe("#000000"); // from palette
@@ -169,12 +165,7 @@ describe("assignSpanColors", () => {
describe("renderTokenizedText", () => {
test("renders tokens as span elements", () => {
- const el = renderTokenizedText(
- ["Hello", "world"],
- [true, false],
- [],
- DEFAULT_CONFIG,
- );
+ const el = renderTokenizedText(["Hello", "world"], [true, false], [], DEFAULT_CONFIG);
const tokens = el.querySelectorAll(".bead-token");
expect(tokens).toHaveLength(2);
@@ -183,12 +174,7 @@ describe("renderTokenizedText", () => {
});
test("adds space after tokens with space_after=true", () => {
- const el = renderTokenizedText(
- ["Hello", "world"],
- [true, false],
- [],
- DEFAULT_CONFIG,
- );
+ const el = renderTokenizedText(["Hello", "world"], [true, false], [], DEFAULT_CONFIG);
// Container should have: Hello " " world
const textContent = el.textContent;
@@ -205,12 +191,7 @@ describe("renderTokenizedText", () => {
},
];
- const el = renderTokenizedText(
- ["John", "sat"],
- [true, false],
- spans,
- DEFAULT_CONFIG,
- );
+ const el = renderTokenizedText(["John", "sat"], [true, false], spans, DEFAULT_CONFIG);
const highlighted = el.querySelectorAll(".highlighted");
expect(highlighted).toHaveLength(1);
@@ -227,24 +208,14 @@ describe("renderTokenizedText", () => {
},
];
- const el = renderTokenizedText(
- ["John"],
- [false],
- spans,
- DEFAULT_CONFIG,
- );
+ const el = renderTokenizedText(["John"], [false], spans, DEFAULT_CONFIG);
const token = el.querySelector(".bead-token");
expect(token?.getAttribute("title")).toBe("Person");
});
test("sets data-index on each token", () => {
- const el = renderTokenizedText(
- ["a", "b", "c"],
- [true, true, false],
- [],
- DEFAULT_CONFIG,
- );
+ const el = renderTokenizedText(["a", "b", "c"], [true, true, false], [], DEFAULT_CONFIG);
const tokens = el.querySelectorAll(".bead-token");
expect(tokens[0]?.getAttribute("data-index")).toBe("0");
@@ -253,12 +224,7 @@ describe("renderTokenizedText", () => {
});
test("does not add space between tokens with space_after=false", () => {
- const el = renderTokenizedText(
- ["don", "'t"],
- [false, false],
- [],
- DEFAULT_CONFIG,
- );
+ const el = renderTokenizedText(["don", "'t"], [false, false], [], DEFAULT_CONFIG);
// Should be "don't" with no space
const spans = el.querySelectorAll(".bead-token");
diff --git a/bead/deployment/jspsych/src/lib/span-renderer.ts b/bead/deployment/jspsych/src/lib/span-renderer.ts
index 5e86c2b..455e026 100644
--- a/bead/deployment/jspsych/src/lib/span-renderer.ts
+++ b/bead/deployment/jspsych/src/lib/span-renderer.ts
@@ -46,8 +46,14 @@ export interface SpanDisplayConfig {
}
const DEFAULT_PALETTE = [
- "#BBDEFB", "#C8E6C9", "#FFE0B2", "#F8BBD0",
- "#D1C4E9", "#B2EBF2", "#DCEDC8", "#FFD54F",
+ "#BBDEFB",
+ "#C8E6C9",
+ "#FFE0B2",
+ "#F8BBD0",
+ "#D1C4E9",
+ "#B2EBF2",
+ "#DCEDC8",
+ "#FFD54F",
];
/**
@@ -61,7 +67,7 @@ const DEFAULT_PALETTE = [
export function computeTokenSpanMap(
tokens: string[],
spans: SpanData[],
- elementName: string = "text",
+ elementName = "text",
): Map {
const map: Map = new Map();
@@ -146,7 +152,7 @@ export function renderTokenizedText(
spaceAfter: boolean[],
spans: SpanData[],
config: SpanDisplayConfig,
- elementName: string = "text",
+ elementName = "text",
): HTMLElement {
const container = document.createElement("div");
container.className = "bead-span-container";
@@ -173,7 +179,7 @@ export function renderTokenizedText(
if (spanIds.length === 1) {
tokenEl.style.backgroundColor = colorMap.get(spanIds[0] ?? "") ?? "#BBDEFB";
} else {
- const colors = spanIds.map(id => colorMap.get(id) ?? "#BBDEFB");
+ const colors = spanIds.map((id) => colorMap.get(id) ?? "#BBDEFB");
tokenEl.style.background = `linear-gradient(${colors.join(", ")})`;
}
} else if (config.highlight_style === "underline") {
@@ -188,8 +194,8 @@ export function renderTokenizedText(
// Tooltip
if (config.show_tooltips && spanIds.length > 0) {
const labels = spanIds
- .map(id => {
- const span = spans.find(s => s.span_id === id);
+ .map((id) => {
+ const span = spans.find((s) => s.span_id === id);
return span?.label?.label ?? id;
})
.join(", ");
diff --git a/bead/deployment/jspsych/src/lib/wikidata-search.ts b/bead/deployment/jspsych/src/lib/wikidata-search.ts
index 8fc0b3e..c3c3c3c 100644
--- a/bead/deployment/jspsych/src/lib/wikidata-search.ts
+++ b/bead/deployment/jspsych/src/lib/wikidata-search.ts
@@ -86,14 +86,12 @@ export async function searchWikidata(
}
const data = await response.json();
- const results: WikidataEntity[] = (data.search ?? []).map(
- (item: Record) => ({
- id: String(item["id"] ?? ""),
- label: String(item["label"] ?? ""),
- description: String(item["description"] ?? ""),
- aliases: Array.isArray(item["aliases"]) ? item["aliases"].map(String) : [],
- }),
- );
+ const results: WikidataEntity[] = (data.search ?? []).map((item: Record) => ({
+ id: String(item["id"] ?? ""),
+ label: String(item["label"] ?? ""),
+ description: String(item["description"] ?? ""),
+ aliases: Array.isArray(item["aliases"]) ? item["aliases"].map(String) : [],
+ }));
putCache(key, results);
return results;
diff --git a/bead/deployment/jspsych/src/plugins/binary-choice.ts b/bead/deployment/jspsych/src/plugins/binary-choice.ts
index 54b38c2..9a69c19 100644
--- a/bead/deployment/jspsych/src/plugins/binary-choice.ts
+++ b/bead/deployment/jspsych/src/plugins/binary-choice.ts
@@ -11,6 +11,9 @@
import type { JsPsych, JsPsychPlugin, PluginInfo } from "../types/jspsych.js";
+/** Position of the prompt relative to the stimulus */
+type PromptPosition = "above" | "below";
+
/** Bead item/template metadata */
interface BeadMetadata {
[key: string]: unknown;
@@ -24,6 +27,8 @@ export interface BinaryChoiceTrialParams {
stimulus: string;
/** Labels for the two choices */
choices: [string, string];
+ /** Position of the prompt relative to the stimulus */
+ prompt_position: PromptPosition;
/** Whether to require a response */
require_response: boolean;
/** Complete item and template metadata */
@@ -47,6 +52,10 @@ const info: PluginInfo = {
default: ["Yes", "No"],
array: true,
},
+ prompt_position: {
+ type: 1, // ParameterType.STRING
+ default: "above",
+ },
require_response: {
type: 0, // ParameterType.BOOL
default: true,
@@ -79,7 +88,7 @@ class BeadBinaryChoicePlugin implements JsPsychPlugin${trial.prompt}`;
}
@@ -87,6 +96,10 @@ class BeadBinaryChoicePlugin implements JsPsychPlugin${trial.stimulus}`;
}
+ if (trial.prompt && trial.prompt_position === "below") {
+ html += `${trial.prompt}
`;
+ }
+
html += '';
for (let i = 0; i < trial.choices.length; i++) {
html += `${trial.choices[i]} `;
diff --git a/bead/deployment/jspsych/src/plugins/categorical.ts b/bead/deployment/jspsych/src/plugins/categorical.ts
index fc93778..d267e9c 100644
--- a/bead/deployment/jspsych/src/plugins/categorical.ts
+++ b/bead/deployment/jspsych/src/plugins/categorical.ts
@@ -11,6 +11,9 @@
import type { JsPsych, JsPsychPlugin, PluginInfo } from "../types/jspsych.js";
+/** Position of the prompt relative to the stimulus */
+type PromptPosition = "above" | "below";
+
/** Bead item/template metadata */
interface BeadMetadata {
[key: string]: unknown;
@@ -24,6 +27,8 @@ export interface CategoricalTrialParams {
stimulus: string;
/** Category labels (unordered) */
categories: string[];
+ /** Position of the prompt relative to the stimulus */
+ prompt_position: PromptPosition;
/** Whether to require a response before continuing */
require_response: boolean;
/** Text for the continue button */
@@ -49,6 +54,10 @@ const info: PluginInfo = {
default: [],
array: true,
},
+ prompt_position: {
+ type: 1, // ParameterType.STRING
+ default: "above",
+ },
require_response: {
type: 0, // ParameterType.BOOL
default: true,
@@ -84,7 +93,7 @@ class BeadCategoricalPlugin implements JsPsychPlugin${trial.prompt}
`;
}
@@ -92,6 +101,10 @@ class BeadCategoricalPlugin implements JsPsychPlugin${trial.stimulus}`;
}
+ if (trial.prompt && trial.prompt_position === "below") {
+ html += `${trial.prompt}
`;
+ }
+
html += '';
for (let i = 0; i < trial.categories.length; i++) {
html += `${trial.categories[i]} `;
@@ -114,7 +127,9 @@ class BeadCategoricalPlugin implements JsPsychPlugin(".bead-categorical-button");
- const continueBtn = display_element.querySelector("#bead-categorical-continue");
+ const continueBtn = display_element.querySelector(
+ "#bead-categorical-continue",
+ );
for (const button of buttons) {
button.addEventListener("click", (e) => {
diff --git a/bead/deployment/jspsych/src/plugins/forced-choice.ts b/bead/deployment/jspsych/src/plugins/forced-choice.ts
index 7cf4a26..8045ef8 100644
--- a/bead/deployment/jspsych/src/plugins/forced-choice.ts
+++ b/bead/deployment/jspsych/src/plugins/forced-choice.ts
@@ -162,14 +162,12 @@ class BeadForcedChoicePlugin implements JsPsychPlugin(".bead-choice-button");
- for (const button of choice_buttons) {
- button.addEventListener("click", (e) => {
- const target = e.target as HTMLButtonElement;
- const indexAttr = target.getAttribute("data-index");
- const positionAttr = target.getAttribute("data-position") as Position | null;
+ // Add event listeners for card clicks (primary interaction)
+ const alternative_cards = display_element.querySelectorAll(".bead-alternative");
+ for (const card of alternative_cards) {
+ card.addEventListener("click", () => {
+ const indexAttr = card.getAttribute("data-index");
+ const positionAttr = card.getAttribute("data-position") as Position | null;
if (indexAttr !== null && positionAttr !== null) {
const index = Number.parseInt(indexAttr, 10);
select_choice(index, positionAttr);
diff --git a/bead/deployment/jspsych/src/plugins/free-text.ts b/bead/deployment/jspsych/src/plugins/free-text.ts
index 8cde921..cf6fc58 100644
--- a/bead/deployment/jspsych/src/plugins/free-text.ts
+++ b/bead/deployment/jspsych/src/plugins/free-text.ts
@@ -11,6 +11,9 @@
import type { JsPsych, JsPsychPlugin, PluginInfo } from "../types/jspsych.js";
+/** Position of the prompt relative to the stimulus */
+type PromptPosition = "above" | "below";
+
/** Bead item/template metadata */
interface BeadMetadata {
[key: string]: unknown;
@@ -32,6 +35,8 @@ export interface FreeTextTrialParams {
placeholder: string;
/** Number of rows for textarea */
rows: number;
+ /** Position of the prompt relative to the stimulus */
+ prompt_position: PromptPosition;
/** Whether to require a response */
require_response: boolean;
/** Text for the continue button */
@@ -52,6 +57,10 @@ const info: PluginInfo = {
type: 8, // ParameterType.HTML_STRING
default: "",
},
+ prompt_position: {
+ type: 1, // ParameterType.STRING
+ default: "above",
+ },
multiline: {
type: 0, // ParameterType.BOOL
default: false,
@@ -105,7 +114,7 @@ class BeadFreeTextPlugin implements JsPsychPlugin${trial.prompt}
`;
}
@@ -113,6 +122,10 @@ class BeadFreeTextPlugin implements JsPsychPlugin${trial.stimulus}`;
}
+ if (trial.prompt && trial.prompt_position === "below") {
+ html += `${trial.prompt}
`;
+ }
+
const maxAttr = trial.max_length > 0 ? ` maxlength="${trial.max_length}"` : "";
const placeholderAttr = trial.placeholder ? ` placeholder="${trial.placeholder}"` : "";
@@ -141,8 +154,12 @@ class BeadFreeTextPlugin implements JsPsychPlugin("#bead-free-text-input");
- const continueBtn = display_element.querySelector("#bead-free-text-continue");
+ const input = display_element.querySelector(
+ "#bead-free-text-input",
+ );
+ const continueBtn = display_element.querySelector(
+ "#bead-free-text-continue",
+ );
const charCount = display_element.querySelector("#bead-char-count");
if (input) {
diff --git a/bead/deployment/jspsych/src/plugins/magnitude.ts b/bead/deployment/jspsych/src/plugins/magnitude.ts
index 9fa0b0e..2a3f361 100644
--- a/bead/deployment/jspsych/src/plugins/magnitude.ts
+++ b/bead/deployment/jspsych/src/plugins/magnitude.ts
@@ -2,6 +2,8 @@
* bead-magnitude plugin
*
* jsPsych plugin for numeric magnitude input (bounded or unbounded).
+ * Supports true magnitude estimation with a reference stimulus and an
+ * exponential slider that maps linear position to exponential values.
*
* Follows the same metadata-spreading pattern as all other bead plugins.
*
@@ -16,21 +18,69 @@ interface BeadMetadata {
[key: string]: unknown;
}
+/** Prompt position relative to stimulus */
+type PromptPosition = "above" | "below";
+
+/** Input mode for magnitude estimation */
+type InputMode = "number" | "exp-slider";
+
+// ── Exponential slider math ─────────────────────────────────────────
+
+/** Compute the maximum internal x position from the reference value. */
+function computeXMax(referenceValue: number): number {
+ return 3 * 100 * Math.log(referenceValue + 1);
+}
+
+/** Map internal slider position x to display value. */
+function xToValue(x: number): number {
+ if (x <= 0) return 0;
+ return Math.exp(x / 100) - 1;
+}
+
+/** Map display value back to internal slider position. */
+function valueToX(value: number): number {
+ if (value <= 0) return 0;
+ return 100 * Math.log(value + 1);
+}
+
+/** Format a magnitude value for display. */
+function formatValue(value: number): string {
+ if (value >= 1_000_000) return "\u221E";
+ if (value >= 10_000) return Math.round(value).toLocaleString();
+ if (value >= 100) return Math.round(value).toString();
+ if (value >= 10) return value.toFixed(1);
+ if (value >= 1) return value.toFixed(2);
+ if (value > 0) return value.toFixed(3);
+ return "0";
+}
+
/** Magnitude trial parameters */
export interface MagnitudeTrialParams {
/** The prompt/question to display */
prompt: string;
- /** HTML stimulus to display */
+ /** HTML stimulus to display (the target) */
stimulus: string;
- /** Unit label displayed next to the input */
+ /** Where to place the prompt relative to the stimulus */
+ prompt_position: PromptPosition;
+ /** HTML for a reference stimulus (shown above target for magnitude estimation) */
+ reference_stimulus: string;
+ /** Numeric value assigned to the reference stimulus */
+ reference_value: number;
+ /** Unit label displayed next to the input or value display */
unit: string;
- /** Minimum allowed value (null = unbounded) */
+ /** Input mode: "number" for numeric input, "exp-slider" for exponential slider */
+ input_mode: InputMode;
+ /** Arrow key step in internal x-units (exp-slider mode only). Default: 3. */
+ arrow_step: number;
+ /** Starting internal x position (null = handle hidden until first interaction) */
+ slider_start: number | null;
+ /** Minimum allowed value (number mode only, null = unbounded) */
input_min: number | null;
- /** Maximum allowed value (null = unbounded) */
+ /** Maximum allowed value (number mode only, null = unbounded) */
input_max: number | null;
- /** Step size for the input */
+ /** Step size for the input (number mode only) */
step: number | null;
- /** Placeholder text for the input */
+ /** Placeholder text for the input (number mode only) */
placeholder: string;
/** Whether to require a response */
require_response: boolean;
@@ -52,10 +102,34 @@ const info: PluginInfo = {
type: 8, // ParameterType.HTML_STRING
default: "",
},
+ prompt_position: {
+ type: 1, // ParameterType.STRING
+ default: "above",
+ },
+ reference_stimulus: {
+ type: 8, // ParameterType.HTML_STRING
+ default: "",
+ },
+ reference_value: {
+ type: 2, // ParameterType.INT
+ default: 100,
+ },
unit: {
type: 1, // ParameterType.STRING
default: "",
},
+ input_mode: {
+ type: 1, // ParameterType.STRING
+ default: "number",
+ },
+ arrow_step: {
+ type: 3, // ParameterType.FLOAT
+ default: 3,
+ },
+ slider_start: {
+ type: 3, // ParameterType.FLOAT
+ default: null,
+ },
input_min: {
type: 3, // ParameterType.FLOAT
default: null,
@@ -101,29 +175,46 @@ class BeadMagnitudePlugin implements JsPsychPlugin${trial.prompt}`;
}
+ // Reference stimulus (for true magnitude estimation)
+ if (hasReference) {
+ html += '";
+ html += '';
+ html += `
${trial.reference_stimulus}
`;
+ html += "
";
+ }
+
+ // Target stimulus
if (trial.stimulus) {
+ if (hasReference) {
+ html += 'Target
';
+ }
html += `${trial.stimulus}
`;
}
- html += '";
// Continue button
const disabled = trial.require_response ? "disabled" : "";
@@ -139,9 +230,41 @@ class BeadMagnitudePlugin implements JsPsychPlugin";
+ if (trial.unit) {
+ html += `${trial.unit} `;
+ }
+ html += "";
+ return html;
+ }
+
+ private setupNumberInput(
+ display_element: HTMLElement,
+ trial: MagnitudeTrialParams,
+ start_time: number,
+ hasReference: boolean,
+ ): void {
const input = display_element.querySelector("#bead-magnitude-input");
- const continueBtn = display_element.querySelector("#bead-magnitude-continue");
+ const continueBtn = display_element.querySelector(
+ "#bead-magnitude-continue",
+ );
if (input) {
input.addEventListener("input", () => {
@@ -170,6 +293,228 @@ class BeadMagnitudePlugin implements JsPsychPlugin';
+ html += trial.slider_start !== null ? formatValue(xToValue(trial.slider_start)) : "--";
+ html += "";
+
+ // Track area with endpoints
+ html += '';
+ html += '
0 ';
+
+ // The track
+ html += '
';
+
+ // Filled portion
+ const startPct =
+ trial.slider_start !== null
+ ? (trial.slider_start / computeXMax(trial.reference_value)) * 100
+ : 0;
+ html += `
`;
+
+ // Reference tick at 1/3
+ html += '
';
+ html += `${trial.reference_value} `;
+ html += "
";
+
+ // Handle
+ const handleClass =
+ trial.slider_start !== null
+ ? "bead-magnitude-slider-handle"
+ : "bead-magnitude-slider-handle hidden";
+ html += `
`;
+
+ html += "
"; // track
+ html +=
+ '
\u221E ';
+ html += "
"; // track-area
+ html += ""; // wrapper
+ return html;
+ }
+
+ private setupExpSlider(
+ display_element: HTMLElement,
+ trial: MagnitudeTrialParams,
+ start_time: number,
+ hasReference: boolean,
+ ): void {
+ const xMax = computeXMax(trial.reference_value);
+ let currentX = trial.slider_start ?? -1;
+ let hasInteracted = currentX >= 0;
+
+ const track = display_element.querySelector("#bead-magnitude-slider-track");
+ const handle = display_element.querySelector("#bead-magnitude-slider-handle");
+ const fill = display_element.querySelector("#bead-magnitude-slider-fill");
+ const valueDisplay = display_element.querySelector(
+ "#bead-magnitude-slider-value",
+ );
+ const continueBtn = display_element.querySelector(
+ "#bead-magnitude-continue",
+ );
+
+ if (!track || !handle || !fill || !valueDisplay) return;
+
+ const updateUI = (): void => {
+ if (currentX < 0) return;
+ const pct = (currentX / xMax) * 100;
+
+ handle.style.left = `${pct}%`;
+ fill.style.width = `${pct}%`;
+
+ const value = xToValue(currentX);
+ let displayText = formatValue(value);
+ if (trial.unit) {
+ displayText += ` ${trial.unit}`;
+ }
+ valueDisplay.textContent = displayText;
+
+ track.setAttribute("aria-valuenow", String(Math.round(value)));
+
+ if (continueBtn && trial.require_response) {
+ continueBtn.disabled = false;
+ }
+ };
+
+ const setPosition = (x: number): void => {
+ currentX = Math.max(0, Math.min(xMax, x));
+ if (!hasInteracted) {
+ hasInteracted = true;
+ handle.classList.remove("hidden");
+ }
+ updateUI();
+ };
+
+ // Render initial position if slider_start was set
+ if (hasInteracted) {
+ updateUI();
+ }
+
+ // ── Mouse events ──
+
+ const onMouseDown = (e: MouseEvent): void => {
+ e.preventDefault();
+ const rect = track.getBoundingClientRect();
+ const px = e.clientX - rect.left;
+ const x = (px / rect.width) * xMax;
+ setPosition(x);
+ track.focus();
+
+ const onMouseMove = (ev: MouseEvent): void => {
+ const movePx = ev.clientX - rect.left;
+ setPosition((movePx / rect.width) * xMax);
+ };
+
+ const onMouseUp = (): void => {
+ document.removeEventListener("mousemove", onMouseMove);
+ document.removeEventListener("mouseup", onMouseUp);
+ };
+
+ document.addEventListener("mousemove", onMouseMove);
+ document.addEventListener("mouseup", onMouseUp);
+ };
+
+ track.addEventListener("mousedown", onMouseDown);
+
+ // ── Touch events ──
+
+ const onTouchStart = (e: TouchEvent): void => {
+ e.preventDefault();
+ const rect = track.getBoundingClientRect();
+ const touch = e.touches[0];
+ if (!touch) return;
+ const px = touch.clientX - rect.left;
+ setPosition((px / rect.width) * xMax);
+ track.focus();
+
+ const onTouchMove = (ev: TouchEvent): void => {
+ const t = ev.touches[0];
+ if (!t) return;
+ const movePx = t.clientX - rect.left;
+ setPosition((movePx / rect.width) * xMax);
+ };
+
+ const onTouchEnd = (): void => {
+ document.removeEventListener("touchmove", onTouchMove);
+ document.removeEventListener("touchend", onTouchEnd);
+ };
+
+ document.addEventListener("touchmove", onTouchMove, { passive: false });
+ document.addEventListener("touchend", onTouchEnd);
+ };
+
+ track.addEventListener("touchstart", onTouchStart, { passive: false });
+
+ // ── Keyboard events ──
+
+ track.addEventListener("keydown", (e: KeyboardEvent) => {
+ if (e.key === "ArrowRight" || e.key === "ArrowUp") {
+ e.preventDefault();
+ if (!hasInteracted) {
+ setPosition(xMax / 3);
+ } else {
+ setPosition(currentX + trial.arrow_step);
+ }
+ } else if (e.key === "ArrowLeft" || e.key === "ArrowDown") {
+ e.preventDefault();
+ if (!hasInteracted) {
+ setPosition(xMax / 3);
+ } else {
+ setPosition(currentX - trial.arrow_step);
+ }
+ } else if (e.key === "Home") {
+ e.preventDefault();
+ setPosition(0);
+ } else if (e.key === "End") {
+ e.preventDefault();
+ setPosition(xMax);
+ }
+ });
+
+ // Focus the track for keyboard interaction
+ track.focus();
+
+ // ── Continue button ──
+
+ if (continueBtn) {
+ continueBtn.addEventListener("click", () => {
+ if (!trial.require_response || hasInteracted) {
+ end_trial();
+ }
+ });
+ }
+
+ const end_trial = (): void => {
+ const rt = performance.now() - start_time;
+ const value = hasInteracted ? xToValue(currentX) : null;
+
+ const trial_data: Record = {
+ ...trial.metadata,
+ response: value !== null && Number.isFinite(value) ? Math.round(value * 1000) / 1000 : null,
+ response_x: hasInteracted ? Math.round(currentX * 100) / 100 : null,
+ rt: rt,
+ };
+
+ if (hasReference) {
+ trial_data["reference_value"] = trial.reference_value;
+ }
+
display_element.innerHTML = "";
this.jsPsych.finishTrial(trial_data);
};
@@ -177,3 +522,4 @@ class BeadMagnitudePlugin implements JsPsychPlugin o.length));
+ const useCompact = maxLen < 25 && trial.options.length <= 6;
+
// Build HTML
let html = '';
- if (trial.prompt) {
+ // Prompt (above)
+ if (trial.prompt && trial.prompt_position === "above") {
html += `
${trial.prompt}
`;
}
@@ -102,12 +116,19 @@ class BeadMultiSelectPlugin implements JsPsychPlugin
${trial.stimulus} `;
}
- html += '';
+ // Prompt (below)
+ if (trial.prompt && trial.prompt_position === "below") {
+ html += `
${trial.prompt}
`;
+ }
+
+ const compactClass = useCompact ? " bead-multi-select-compact" : "";
+ html += `
`;
for (let i = 0; i < trial.options.length; i++) {
+ const opt = trial.options[i] ?? "";
html += `
-
- ${trial.options[i]}
+
+ ${opt}
`;
}
@@ -128,11 +149,17 @@ class BeadMultiSelectPlugin implements JsPsychPlugin
(".bead-multi-select-checkbox");
- const continueBtn = display_element.querySelector("#bead-multi-select-continue");
+ const checkboxes = display_element.querySelectorAll(
+ ".bead-multi-select-checkbox",
+ );
+ const continueBtn = display_element.querySelector(
+ "#bead-multi-select-continue",
+ );
const updateButton = (): void => {
- const checked = display_element.querySelectorAll(".bead-multi-select-checkbox:checked");
+ const checked = display_element.querySelectorAll(
+ ".bead-multi-select-checkbox:checked",
+ );
const count = checked.length;
// Enforce max_selections
@@ -163,7 +190,9 @@ class BeadMultiSelectPlugin implements JsPsychPlugin {
const rt = performance.now() - start_time;
- const checked = display_element.querySelectorAll(".bead-multi-select-checkbox:checked");
+ const checked = display_element.querySelectorAll(
+ ".bead-multi-select-checkbox:checked",
+ );
const selected: string[] = [];
const selected_indices: number[] = [];
diff --git a/bead/deployment/jspsych/src/plugins/plugins.test.ts b/bead/deployment/jspsych/src/plugins/plugins.test.ts
index bd2fa90..d5a5d02 100644
--- a/bead/deployment/jspsych/src/plugins/plugins.test.ts
+++ b/bead/deployment/jspsych/src/plugins/plugins.test.ts
@@ -8,6 +8,7 @@ import { describe, expect, test, vi } from "vitest";
import type { JsPsych } from "../types/jspsych.js";
import { BeadClozeMultiPlugin } from "./cloze-dropdown.js";
import { BeadForcedChoicePlugin } from "./forced-choice.js";
+import { BeadMagnitudePlugin, computeXMax, formatValue, valueToX, xToValue } from "./magnitude.js";
import { BeadRatingPlugin } from "./rating.js";
import { BeadSpanLabelPlugin } from "./span-label.js";
@@ -183,3 +184,99 @@ describe("bead-cloze-multi plugin", () => {
});
});
});
+
+describe("bead-magnitude plugin", () => {
+ describe("info structure", () => {
+ test("has correct plugin name", () => {
+ expect(BeadMagnitudePlugin.info.name).toBe("bead-magnitude");
+ });
+
+ test("has required parameters", () => {
+ const params = BeadMagnitudePlugin.info.parameters;
+ expect(params["prompt"]).toBeDefined();
+ expect(params["stimulus"]).toBeDefined();
+ expect(params["reference_stimulus"]).toBeDefined();
+ expect(params["reference_value"]).toBeDefined();
+ expect(params["input_mode"]).toBeDefined();
+ expect(params["arrow_step"]).toBeDefined();
+ expect(params["slider_start"]).toBeDefined();
+ expect(params["input_min"]).toBeDefined();
+ expect(params["input_max"]).toBeDefined();
+ expect(params["require_response"]).toBeDefined();
+ expect(params["button_label"]).toBeDefined();
+ expect(params["metadata"]).toBeDefined();
+ });
+
+ test("has correct parameter defaults", () => {
+ const params = BeadMagnitudePlugin.info.parameters;
+ expect(params["reference_value"]?.default).toBe(100);
+ expect(params["input_mode"]?.default).toBe("number");
+ expect(params["arrow_step"]?.default).toBe(3);
+ expect(params["slider_start"]?.default).toBeNull();
+ expect(params["require_response"]?.default).toBe(true);
+ expect(params["button_label"]?.default).toBe("Continue");
+ });
+ });
+
+ describe("plugin instantiation", () => {
+ test("can be instantiated", () => {
+ const mockJsPsych = createMockJsPsych();
+ const plugin = new BeadMagnitudePlugin(mockJsPsych);
+ expect(plugin).toBeDefined();
+ });
+
+ test("has trial method", () => {
+ const mockJsPsych = createMockJsPsych();
+ const plugin = new BeadMagnitudePlugin(mockJsPsych);
+ expect(typeof plugin.trial).toBe("function");
+ });
+ });
+});
+
+describe("exponential slider math", () => {
+ test("computeXMax for reference_value=100", () => {
+ const xMax = computeXMax(100);
+ expect(xMax).toBeCloseTo(3 * 100 * Math.log(101), 5);
+ });
+
+ test("xToValue at x=0 returns 0", () => {
+ expect(xToValue(0)).toBe(0);
+ });
+
+ test("xToValue at reference position returns reference_value", () => {
+ const xRef = computeXMax(100) / 3;
+ expect(xToValue(xRef)).toBeCloseTo(100, 5);
+ });
+
+ test("xToValue at xMax returns very large number", () => {
+ const xMax = computeXMax(100);
+ const maxVal = xToValue(xMax);
+ expect(maxVal).toBeGreaterThan(1_000_000);
+ });
+
+ test("valueToX inverts xToValue", () => {
+ const testValues = [0, 1, 10, 100, 1000, 50000];
+ for (const v of testValues) {
+ expect(xToValue(valueToX(v))).toBeCloseTo(v, 5);
+ }
+ });
+
+ test("valueToX at 0 returns 0", () => {
+ expect(valueToX(0)).toBe(0);
+ });
+
+ test("reference_value maps to 1/3 of xMax", () => {
+ const xMax = computeXMax(100);
+ const xRef = valueToX(100);
+ expect(xRef / xMax).toBeCloseTo(1 / 3, 5);
+ });
+
+ test("formatValue handles all ranges", () => {
+ expect(formatValue(0)).toBe("0");
+ expect(formatValue(0.005)).toBe("0.005");
+ expect(formatValue(5.123)).toBe("5.12");
+ expect(formatValue(42.567)).toBe("42.6");
+ expect(formatValue(100)).toBe("100");
+ expect(formatValue(2_000_000)).toBe("\u221E");
+ });
+});
diff --git a/bead/deployment/jspsych/src/plugins/rating.ts b/bead/deployment/jspsych/src/plugins/rating.ts
index 44f149a..f8c1afc 100644
--- a/bead/deployment/jspsych/src/plugins/rating.ts
+++ b/bead/deployment/jspsych/src/plugins/rating.ts
@@ -17,6 +17,9 @@
import type { JsPsych, JsPsychPlugin, KeyboardResponseInfo, PluginInfo } from "../types/jspsych.js";
+/** Position of the prompt relative to the stimulus */
+type PromptPosition = "above" | "below";
+
/** Bead item/template metadata */
interface BeadMetadata {
[key: string]: unknown;
@@ -26,6 +29,10 @@ interface BeadMetadata {
export interface RatingTrialParams {
/** The prompt to display above the rating scale */
prompt: string | null;
+ /** HTML stimulus to display */
+ stimulus: string;
+ /** Position of the prompt relative to the stimulus */
+ prompt_position: PromptPosition;
/** Minimum value of the scale */
scale_min: number;
/** Maximum value of the scale */
@@ -54,6 +61,14 @@ const info: PluginInfo = {
type: 8, // ParameterType.HTML_STRING
default: null,
},
+ stimulus: {
+ type: 8, // ParameterType.HTML_STRING
+ default: "",
+ },
+ prompt_position: {
+ type: 1, // ParameterType.STRING
+ default: "above",
+ },
scale_min: {
type: 2, // ParameterType.INT
default: 1,
@@ -104,7 +119,15 @@ class BeadRatingPlugin implements JsPsychPlugin
// Create HTML
let html = '';
- if (trial.prompt !== null) {
+ if (trial.prompt !== null && trial.prompt_position === "above") {
+ html += `
${trial.prompt}
`;
+ }
+
+ if (trial.stimulus) {
+ html += `
${trial.stimulus}
`;
+ }
+
+ if (trial.prompt !== null && trial.prompt_position === "below") {
html += `
${trial.prompt}
`;
}
diff --git a/bead/deployment/jspsych/src/plugins/slider-rating.ts b/bead/deployment/jspsych/src/plugins/slider-rating.ts
index 8a6f66b..918fb89 100644
--- a/bead/deployment/jspsych/src/plugins/slider-rating.ts
+++ b/bead/deployment/jspsych/src/plugins/slider-rating.ts
@@ -11,6 +11,9 @@
import type { JsPsych, JsPsychPlugin, PluginInfo } from "../types/jspsych.js";
+/** Position of the prompt relative to the stimulus */
+type PromptPosition = "above" | "below";
+
/** Bead item/template metadata */
interface BeadMetadata {
[key: string]: unknown;
@@ -20,6 +23,10 @@ interface BeadMetadata {
export interface SliderRatingTrialParams {
/** The prompt/question to display */
prompt: string | null;
+ /** HTML stimulus to display */
+ stimulus: string;
+ /** Position of the prompt relative to the stimulus */
+ prompt_position: PromptPosition;
/** Minimum slider value */
slider_min: number;
/** Maximum slider value */
@@ -46,6 +53,14 @@ const info: PluginInfo = {
type: 8, // ParameterType.HTML_STRING
default: null,
},
+ stimulus: {
+ type: 8, // ParameterType.HTML_STRING
+ default: "",
+ },
+ prompt_position: {
+ type: 1, // ParameterType.STRING
+ default: "above",
+ },
slider_min: {
type: 2, // ParameterType.INT
default: 0,
@@ -103,7 +118,15 @@ class BeadSliderRatingPlugin implements JsPsychPlugin
${trial.prompt} `;
+ }
+
+ if (trial.stimulus) {
+ html += `${trial.stimulus}
`;
+ }
+
+ if (trial.prompt !== null && trial.prompt_position === "below") {
html += `${trial.prompt}
`;
}
@@ -143,7 +166,8 @@ class BeadSliderRatingPlugin implements JsPsychPlugin(".bead-slider-input");
const value_display = display_element.querySelector(".bead-slider-value");
- const continue_button = display_element.querySelector("#bead-slider-continue");
+ const continue_button =
+ display_element.querySelector("#bead-slider-continue");
if (slider) {
slider.addEventListener("input", () => {
diff --git a/bead/deployment/jspsych/src/plugins/span-label.ts b/bead/deployment/jspsych/src/plugins/span-label.ts
index 3762282..e4c7e53 100644
--- a/bead/deployment/jspsych/src/plugins/span-label.ts
+++ b/bead/deployment/jspsych/src/plugins/span-label.ts
@@ -12,9 +12,9 @@
* @version 0.2.0
*/
-import type { JsPsych, JsPsychPlugin, PluginInfo } from "../types/jspsych.js";
import { debouncedSearchWikidata } from "../lib/wikidata-search.js";
import type { WikidataEntity } from "../lib/wikidata-search.js";
+import type { JsPsych, JsPsychPlugin, PluginInfo } from "../types/jspsych.js";
/** Span segment data */
interface SpanSegmentData {
@@ -161,14 +161,26 @@ const info: PluginInfo = {
};
const DEFAULT_PALETTE = [
- "#BBDEFB", "#C8E6C9", "#FFE0B2", "#F8BBD0",
- "#D1C4E9", "#B2EBF2", "#DCEDC8", "#FFD54F",
+ "#BBDEFB",
+ "#C8E6C9",
+ "#FFE0B2",
+ "#F8BBD0",
+ "#D1C4E9",
+ "#B2EBF2",
+ "#DCEDC8",
+ "#FFD54F",
];
// Dark versions of palette colors for badge backgrounds (white text)
const DARK_PALETTE = [
- "#1565C0", "#2E7D32", "#E65100", "#AD1457",
- "#4527A0", "#00838F", "#558B2F", "#F9A825",
+ "#1565C0",
+ "#2E7D32",
+ "#E65100",
+ "#AD1457",
+ "#4527A0",
+ "#00838F",
+ "#558B2F",
+ "#F9A825",
];
/**
@@ -188,19 +200,18 @@ class BeadSpanLabelPlugin implements JsPsychPlugin 0
- ? trial.tokens
- : (trial.metadata.tokenized_elements ?? {});
- const spaceAfter = Object.keys(trial.space_after).length > 0
- ? trial.space_after
- : (trial.metadata.token_space_after ?? {});
+ const tokens =
+ Object.keys(trial.tokens).length > 0
+ ? trial.tokens
+ : (trial.metadata.tokenized_elements ?? {});
+ const spaceAfter =
+ Object.keys(trial.space_after).length > 0
+ ? trial.space_after
+ : (trial.metadata.token_space_after ?? {});
const spanSpec = trial.span_spec ?? trial.metadata.span_spec ?? null;
- const preSpans = trial.spans.length > 0
- ? trial.spans
- : (trial.metadata.spans ?? []);
- const preRelations = trial.relations.length > 0
- ? trial.relations
- : (trial.metadata.span_relations ?? []);
+ const preSpans = trial.spans.length > 0 ? trial.spans : (trial.metadata.spans ?? []);
+ const preRelations =
+ trial.relations.length > 0 ? trial.relations : (trial.metadata.span_relations ?? []);
const palette = trial.display_config?.color_palette ?? DEFAULT_PALETTE;
const isInteractive = spanSpec?.interaction_mode === "interactive";
@@ -247,54 +258,71 @@ class BeadSpanLabelPlugin implements JsPsychPlugin';
+ html +=
+ '';
html += '
';
- html += '
';
- html += '
';
- html += '
';
+ html +=
+ ' ';
+ html +=
+ '× ';
+ html +=
+ '
';
+ html += " ";
} else if (isInteractive && spanSpec?.labels && spanSpec.labels.length > 0) {
// Searchable fixed label panel (mirrors the Wikidata UX)
- html += '';
+ html +=
+ '
';
html += '
';
- html += '
';
- html += '
';
- html += '
';
+ html +=
+ '
';
+ html +=
+ '
× ';
+ html +=
+ '
';
+ html += "
";
}
// Relation controls and list
if (spanSpec?.enable_relations) {
if (isInteractive) {
html += '';
- html += 'Add Relation ';
+ html +=
+ 'Add Relation ';
html += ' ';
- html += 'Cancel ';
- html += '
';
+ html +=
+ 'Cancel ';
+ html += "";
// Relation label search (for choosing the label after source+target)
if (spanSpec.relation_label_source === "wikidata") {
- html += '';
+ html +=
+ '
';
html += '
';
- html += '
';
- html += '
';
- html += '
';
+ html +=
+ '
';
+ html +=
+ '
';
+ html += "
";
} else if (spanSpec.relation_labels && spanSpec.relation_labels.length > 0) {
- html += '';
+ html +=
+ '
';
html += '
';
- html += '
';
- html += '
';
- html += '
';
+ html +=
+ '
';
+ html +=
+ '
';
+ html += "
";
}
}
html += '
';
}
- // Continue button
- html += `
-
-
- ${trial.button_label}
-
-
- `;
+ // Bottom bar with continue button
+ html += '';
+ html += `
`;
+ html += `
`;
+ html += `${trial.button_label}`;
+ html += " ";
+ html += "
";
html += "";
display_element.innerHTML = html;
@@ -312,6 +340,15 @@ class BeadSpanLabelPlugin implements JsPsychPlugin 0) {
setupFixedLabelSearch();
}
+ // Cancel button handler
+ const searchCancelBtn =
+ display_element.querySelector("#bead-search-cancel");
+ if (searchCancelBtn) {
+ searchCancelBtn.addEventListener("click", () => {
+ cancelCurrentSelection();
+ });
+ }
+
if (spanSpec?.enable_relations) {
setupRelationHandlers();
}
@@ -388,19 +425,19 @@ class BeadSpanLabelPlugin implements JsPsychPlugin(
- `.bead-token[data-element="${elemName}"][data-index="${i}"]`
+ `.bead-token[data-element="${elemName}"][data-index="${i}"]`,
);
if (!t) continue;
// Check if any span covers both this token and its left neighbor
const leftKey = `${elemName}:${i - 1}`;
const leftSpanIds = tokenSpanMap.get(leftKey) ?? [];
- const hasLeftNeighbor = spanIds.some(id => leftSpanIds.includes(id));
+ const hasLeftNeighbor = spanIds.some((id) => leftSpanIds.includes(id));
// Check if any span covers both this token and its right neighbor
const rightKey = `${elemName}:${i + 1}`;
const rightSpanIds = tokenSpanMap.get(rightKey) ?? [];
- const hasRightNeighbor = spanIds.some(id => rightSpanIds.includes(id));
+ const hasRightNeighbor = spanIds.some((id) => rightSpanIds.includes(id));
if (hasLeftNeighbor && hasRightNeighbor) {
t.classList.add("span-middle");
@@ -415,12 +452,12 @@ class BeadSpanLabelPlugin implements JsPsychPlugin(
- `.bead-space[data-element="${elemName}"][data-after="${i}"]`
+ `.bead-space[data-element="${elemName}"][data-after="${i}"]`,
);
if (spaceEl) {
spaceEl.classList.add("highlighted");
// Use the shared span IDs for the space color
- const sharedIds = spanIds.filter(id => rightSpanIds.includes(id));
+ const sharedIds = spanIds.filter((id) => rightSpanIds.includes(id));
applySpanColor(spaceEl, sharedIds.length > 0 ? sharedIds : spanIds, spanColorMap);
}
}
@@ -428,16 +465,20 @@ class BeadSpanLabelPlugin implements JsPsychPlugin): void {
+ function applySpanColor(
+ el: HTMLElement,
+ spanIds: string[],
+ colorMap: Map,
+ ): void {
if (spanIds.length === 1) {
el.style.backgroundColor = colorMap.get(spanIds[0] ?? "") ?? palette[0] ?? "#BBDEFB";
} else if (spanIds.length > 1) {
// For overlapping spans, use striped gradient
- const colors = spanIds.map(id => colorMap.get(id) ?? palette[0] ?? "#BBDEFB");
+ const colors = spanIds.map((id) => colorMap.get(id) ?? palette[0] ?? "#BBDEFB");
const stripeWidth = 100 / colors.length;
- const stops = colors.map((c, ci) =>
- `${c} ${ci * stripeWidth}%, ${c} ${(ci + 1) * stripeWidth}%`
- ).join(", ");
+ const stops = colors
+ .map((c, ci) => `${c} ${ci * stripeWidth}%, ${c} ${(ci + 1) * stripeWidth}%`)
+ .join(", ");
el.style.background = `linear-gradient(135deg, ${stops})`;
}
}
@@ -487,7 +528,7 @@ class BeadSpanLabelPlugin implements JsPsychPlugin(
- `.bead-token[data-element="${lastToken.elem}"][data-index="${lastToken.idx}"]`
+ `.bead-token[data-element="${lastToken.elem}"][data-index="${lastToken.idx}"]`,
);
if (!tokenEl) continue;
@@ -532,10 +573,7 @@ class BeadSpanLabelPlugin implements JsPsychPlugin
- a.getBoundingClientRect().left - b.getBoundingClientRect().left,
- );
+ badges.sort((a, b) => a.getBoundingClientRect().left - b.getBoundingClientRect().left);
// Place badges one by one, shifting down if overlapping any already-placed badge
const placed: Array<{ el: HTMLElement; rect: DOMRect }> = [];
@@ -549,10 +587,8 @@ class BeadSpanLabelPlugin implements JsPsychPlugin p.rect.left - 3;
- const vOverlap =
- rect.top < p.rect.bottom + 1 && rect.bottom > p.rect.top - 1;
+ const hOverlap = rect.left < p.rect.right + 3 && rect.right > p.rect.left - 3;
+ const vOverlap = rect.top < p.rect.bottom + 1 && rect.bottom > p.rect.top - 1;
if (hOverlap && vOverlap) {
shift += p.rect.bottom - rect.top + 2;
badge.style.transform = `translateY(${shift}px)`;
@@ -660,7 +696,8 @@ class BeadSpanLabelPlugin implements JsPsychPlugin(".bead-label-button");
+ const labelButtons =
+ display_element.querySelectorAll(".bead-label-button");
for (const btn of labelButtons) {
btn.addEventListener("click", () => {
const label = btn.getAttribute("data-label") ?? "";
@@ -674,22 +711,60 @@ class BeadSpanLabelPlugin implements JsPsychPlugin(".bead-token");
+ for (const t of allTokens) {
+ t.classList.remove("selecting");
+ }
+ // Disable search panel
+ const labelPanel = display_element.querySelector("#bead-label-panel");
if (labelPanel) {
- const show = selectedIndices.length > 0;
- (labelPanel as HTMLElement).style.display = show ? "flex" : "none";
- // Focus the search input when showing
- if (show) {
- const searchInput = labelPanel.querySelector("input");
- if (searchInput) {
- setTimeout(() => searchInput.focus(), 0);
- }
+ labelPanel.classList.add("bead-search-disabled");
+ const searchInput = labelPanel.querySelector("input");
+ if (searchInput) {
+ searchInput.disabled = true;
+ searchInput.value = "";
+ searchInput.placeholder = "Select tokens to annotate...";
}
+ const resultsDiv = labelPanel.querySelector(
+ ".bead-label-search-results, .bead-wikidata-results",
+ );
+ if (resultsDiv) resultsDiv.style.display = "none";
+ const cancelBtn = labelPanel.querySelector(".bead-search-cancel");
+ if (cancelBtn) cancelBtn.style.display = "none";
+ }
+ }
+
+ function showLabelPanel(): void {
+ const labelPanel = display_element.querySelector("#bead-label-panel");
+ if (!labelPanel) return;
+
+ const hasSelection = selectedIndices.length > 0;
+ if (hasSelection) {
+ labelPanel.classList.remove("bead-search-disabled");
+ const searchInput = labelPanel.querySelector("input");
+ if (searchInput) {
+ searchInput.disabled = false;
+ searchInput.placeholder = "Search labels...";
+ setTimeout(() => searchInput.focus(), 0);
+ }
+ const cancelBtn = labelPanel.querySelector(".bead-search-cancel");
+ if (cancelBtn) cancelBtn.style.display = "";
+ } else {
+ cancelCurrentSelection();
}
}
function handleKeyDown(e: KeyboardEvent): void {
+ if (e.key === "Escape") {
+ if (selectedIndices.length > 0) {
+ cancelCurrentSelection();
+ return;
+ }
+ }
const num = Number.parseInt(e.key, 10);
if (!Number.isNaN(num) && num >= 1 && num <= 9) {
const labels = spanSpec?.labels ?? [];
@@ -701,7 +776,7 @@ class BeadSpanLabelPlugin implements JsPsychPlugin(
- `.bead-token[data-element="${elementName}"]`
+ `.bead-token[data-element="${elementName}"]`,
);
for (const t of tokenEls) {
const idx = Number.parseInt(t.getAttribute("data-index") ?? "0", 10);
@@ -717,16 +792,16 @@ class BeadSpanLabelPlugin implements JsPsychPlugin a - b),
- }],
+ segments: [
+ {
+ element_name: elemName,
+ indices: [...selectedIndices].sort((a, b) => a - b),
+ },
+ ],
label: spanLabel,
};
@@ -749,21 +824,12 @@ class BeadSpanLabelPlugin implements JsPsychPlugin(".bead-token");
- for (const t of allTokens) {
- t.classList.remove("selecting");
- }
-
- // Hide label panel
- const labelPanel = display_element.querySelector("#bead-label-panel");
- if (labelPanel) {
- (labelPanel as HTMLElement).style.display = "none";
- }
+ // Reset search panel to disabled state
+ cancelCurrentSelection();
}
function deleteSpan(spanId: string): void {
- const idx = activeSpans.findIndex(s => s.span_id === spanId);
+ const idx = activeSpans.findIndex((s) => s.span_id === spanId);
if (idx >= 0) {
activeSpans.splice(idx, 1);
// Also remove any relations involving this span
@@ -793,7 +859,7 @@ class BeadSpanLabelPlugin implements JsPsychPlugin {
@@ -814,8 +880,7 @@ class BeadSpanLabelPlugin implements JsPsychPlugin${entity.label} ${entity.id} ` +
- (entity.description ? `${entity.description}
` : "");
+ item.innerHTML = `${entity.label} ${entity.id}
${entity.description ? `${entity.description}
` : ""}`;
item.addEventListener("click", () => {
createSpanFromSelection(entity.label, entity.id);
input.value = "";
@@ -830,18 +895,40 @@ class BeadSpanLabelPlugin implements JsPsychPlugin("#bead-label-search-input");
- const resultsDiv = display_element.querySelector("#bead-label-search-results");
+ const resultsDiv = display_element.querySelector(
+ "#bead-label-search-results",
+ );
if (!input || !resultsDiv) return;
const allLabels = spanSpec?.labels ?? [];
let highlightedIdx = -1;
- function renderResults(query: string): void {
+ function fuzzyMatch(query: string, target: string): boolean {
+ const q = query.toLowerCase();
+ const t = target.toLowerCase();
+ let qi = 0;
+ for (let ti = 0; ti < t.length && qi < q.length; ti++) {
+ if (t[ti] === q[qi]) qi++;
+ }
+ return qi === q.length;
+ }
+
+ const renderResults = (query: string): void => {
resultsDiv.innerHTML = "";
const lower = query.toLowerCase();
- const filtered = lower === ""
- ? allLabels
- : allLabels.filter(l => l.toLowerCase().includes(lower));
+ const filtered =
+ lower === ""
+ ? allLabels
+ : allLabels
+ .filter((l) => fuzzyMatch(lower, l))
+ .sort((a, b) => {
+ // Prefix matches first, then fuzzy matches
+ const aPrefix = a.toLowerCase().startsWith(lower);
+ const bPrefix = b.toLowerCase().startsWith(lower);
+ if (aPrefix && !bPrefix) return -1;
+ if (!aPrefix && bPrefix) return 1;
+ return 0;
+ });
if (filtered.length === 0) {
resultsDiv.style.display = "none";
@@ -863,10 +950,7 @@ class BeadSpanLabelPlugin implements JsPsychPlugin` +
- `${label} ` +
- (shortcut ? `${shortcut} ` : "");
+ item.innerHTML = `${label} ${shortcut ? `${shortcut} ` : ""}`;
item.addEventListener("click", () => {
if (selectedIndices.length > 0) {
@@ -878,7 +962,7 @@ class BeadSpanLabelPlugin implements JsPsychPlugin {
@@ -1002,7 +1086,8 @@ class BeadSpanLabelPlugin implements JsPsychPlugin)._updateRelationUI = updateRelationUI;
+ (display_element as unknown as Record)["_updateRelationUI"] =
+ updateRelationUI;
// Click handler for span badges (delegated)
display_element.addEventListener("click", (e) => {
@@ -1021,14 +1106,21 @@ class BeadSpanLabelPlugin implements JsPsychPlugin 0 && spanSpec.relation_label_source !== "wikidata") {
+ if (
+ spanSpec?.relation_labels &&
+ spanSpec.relation_labels.length > 0 &&
+ spanSpec.relation_label_source !== "wikidata"
+ ) {
setupRelationLabelSearch();
}
@@ -1039,16 +1131,19 @@ class BeadSpanLabelPlugin implements JsPsychPlugin("#bead-relation-label-input");
- const resultsDiv = display_element.querySelector("#bead-relation-label-results");
+ const resultsDiv = display_element.querySelector(
+ "#bead-relation-label-results",
+ );
if (!input || !resultsDiv) return;
const allLabels = spanSpec?.relation_labels ?? [];
let highlightedIdx = -1;
- function renderResults(query: string): void {
+ const renderResults = (query: string): void => {
resultsDiv.innerHTML = "";
const lower = query.toLowerCase();
- const filtered = lower === "" ? allLabels : allLabels.filter(l => l.toLowerCase().includes(lower));
+ const filtered =
+ lower === "" ? allLabels : allLabels.filter((l) => l.toLowerCase().includes(lower));
if (filtered.length === 0) {
resultsDiv.style.display = "none";
@@ -1070,7 +1165,7 @@ class BeadSpanLabelPlugin implements JsPsychPlugin renderResults(input.value));
input.addEventListener("input", () => renderResults(input.value));
@@ -1082,12 +1177,14 @@ class BeadSpanLabelPlugin implements JsPsychPlugin("#bead-relation-wikidata-input");
- const resultsDiv = display_element.querySelector("#bead-relation-wikidata-results");
+ const input = display_element.querySelector(
+ "#bead-relation-wikidata-input",
+ );
+ const resultsDiv = display_element.querySelector(
+ "#bead-relation-wikidata-results",
+ );
if (!input || !resultsDiv) return;
const searchOptions = {
@@ -1134,8 +1235,7 @@ class BeadSpanLabelPlugin implements JsPsychPlugin${entity.label} ${entity.id} ` +
- (entity.description ? `${entity.description}
` : "");
+ item.innerHTML = `${entity.label} ${entity.id}
${entity.description ? `${entity.description}
` : ""}`;
item.addEventListener("click", () => {
createRelation({ label: entity.label, label_id: entity.id });
input.value = "";
@@ -1156,7 +1256,7 @@ class BeadSpanLabelPlugin implements JsPsychPlugin r.relation_id === relId);
+ const idx = activeRelations.findIndex((r) => r.relation_id === relId);
if (idx >= 0) {
activeRelations.splice(idx, 1);
events.push({
@@ -1207,8 +1307,8 @@ class BeadSpanLabelPlugin implements JsPsychPlugin s.span_id === rel.source_span_id);
- const targetSpan = activeSpans.find(s => s.span_id === rel.target_span_id);
+ const sourceSpan = activeSpans.find((s) => s.span_id === rel.source_span_id);
+ const targetSpan = activeSpans.find((s) => s.span_id === rel.target_span_id);
if (!sourceSpan || !targetSpan) continue;
const entry = document.createElement("div");
@@ -1233,7 +1333,7 @@ class BeadSpanLabelPlugin implements JsPsychPlugin)._updateRelationUI;
+ const updateUI = (display_element as unknown as Record)["_updateRelationUI"];
if (typeof updateUI === "function") {
(updateUI as () => void)();
}
@@ -1246,15 +1346,15 @@ class BeadSpanLabelPlugin implements JsPsychPlugin(
- `.bead-token[data-element="${seg.element_name}"][data-index="${idx}"]`
+ `.bead-token[data-element="${seg.element_name}"][data-index="${idx}"]`,
);
if (tokenEl) {
const rect = tokenEl.getBoundingClientRect();
@@ -1266,8 +1366,11 @@ class BeadSpanLabelPlugin implements JsPsychPlugin{template.task_spec.prompt}
'
+ task_prompt = template.task_spec.prompt
+ if has_spans and span_display:
+ color_map = _assign_span_colors(item.spans, span_display)
+ task_prompt = _resolve_prompt_references(
+ task_prompt, item, color_map
+ )
+ prompt += f'{task_prompt}
'
# Serialize complete metadata
metadata = _serialize_item_metadata(item, template)
@@ -444,6 +454,17 @@ def _create_slider_trial(
else:
stimulus_html = _generate_stimulus_html(item)
+ # Build prompt: stimulus HTML + resolved task prompt
+ prompt_html = stimulus_html
+ if template.task_spec and template.task_spec.prompt:
+ task_prompt = template.task_spec.prompt
+ if has_spans and span_display:
+ color_map = _assign_span_colors(item.spans, span_display)
+ task_prompt = _resolve_prompt_references(
+ task_prompt, item, color_map
+ )
+ prompt_html += f'{task_prompt}
'
+
# Serialize complete metadata
metadata = _serialize_item_metadata(item, template)
metadata["trial_number"] = trial_number
@@ -451,7 +472,7 @@ def _create_slider_trial(
return {
"type": "bead-slider-rating",
- "prompt": stimulus_html,
+ "prompt": prompt_html,
"labels": [config.min_label, config.max_label],
"slider_min": config.scale.min,
"slider_max": config.scale.max,
@@ -509,6 +530,10 @@ def _create_binary_choice_trial(
else "Is this sentence acceptable?"
)
+ if has_spans and span_display:
+ color_map = _assign_span_colors(item.spans, span_display)
+ prompt = _resolve_prompt_references(prompt, item, color_map)
+
return {
"type": "bead-binary-choice",
"prompt": prompt,
@@ -577,6 +602,8 @@ def _create_forced_choice_trial(
# For composite span tasks, render span-highlighted HTML into each alternative
alternatives: list[str] = list(item.options)
if has_spans and span_display:
+ color_map = _assign_span_colors(item.spans, span_display)
+ prompt = _resolve_prompt_references(prompt, item, color_map)
stimulus_html = _generate_span_stimulus_html(item, span_display)
prompt = stimulus_html + f"{prompt}
"
@@ -873,6 +900,77 @@ def create_instructions_trial(
}
+@dataclass(frozen=True)
+class SpanColorMap:
+ """Light and dark color assignments for spans."""
+
+ light_by_span_id: dict[str, str]
+ dark_by_span_id: dict[str, str]
+ light_by_label: dict[str, str]
+ dark_by_label: dict[str, str]
+
+
+def _assign_span_colors(
+ spans: list[Span],
+ span_display: SpanDisplayConfig,
+) -> SpanColorMap:
+ """Assign light and dark colors to spans.
+
+ Same label gets the same color pair. Unlabeled spans each get
+ their own color. Index-aligned light/dark palettes produce
+ matching background and badge colors.
+
+ Parameters
+ ----------
+ spans : list[Span]
+ Spans to assign colors to.
+ span_display : SpanDisplayConfig
+ Display configuration with light and dark palettes.
+
+ Returns
+ -------
+ SpanColorMap
+ Color assignments keyed by span_id and by label.
+ """
+ light_palette = span_display.color_palette
+ dark_palette = span_display.dark_color_palette
+
+ light_by_label: dict[str, str] = {}
+ dark_by_label: dict[str, str] = {}
+ light_by_span_id: dict[str, str] = {}
+ dark_by_span_id: dict[str, str] = {}
+ color_idx = 0
+
+ for span in spans:
+ if span.label and span.label.label:
+ label_name = span.label.label
+ if label_name not in light_by_label:
+ light_by_label[label_name] = light_palette[
+ color_idx % len(light_palette)
+ ]
+ dark_by_label[label_name] = dark_palette[
+ color_idx % len(dark_palette)
+ ]
+ color_idx += 1
+ light_by_span_id[span.span_id] = light_by_label[label_name]
+ dark_by_span_id[span.span_id] = dark_by_label[label_name]
+ else:
+ light_by_span_id[span.span_id] = light_palette[
+ color_idx % len(light_palette)
+ ]
+ dark_by_span_id[span.span_id] = dark_palette[
+ color_idx % len(dark_palette)
+ ]
+ color_idx += 1
+
+ return SpanColorMap(
+ light_by_span_id=light_by_span_id,
+ dark_by_span_id=dark_by_span_id,
+ light_by_label=light_by_label,
+ dark_by_label=dark_by_label,
+ )
+
+
def _generate_span_stimulus_html(
item: Item,
span_display: SpanDisplayConfig,
@@ -914,30 +1012,9 @@ def _generate_span_stimulus_html(
token_spans[idx] = []
token_spans[idx].append(span.span_id)
- # Assign colors
- span_colors: dict[str, str] = {}
- palette = span_display.color_palette
- color_idx = 0
- for span in item.spans:
- if span.label and span.label.label:
- # Use label_colors if available
- if (
- span_display.show_labels
- and hasattr(span, "label")
- and span.label
- ):
- label_name = span.label.label
- if label_name not in span_colors:
- span_colors[label_name] = palette[
- color_idx % len(palette)
- ]
- color_idx += 1
- span_colors[span.span_id] = span_colors[label_name]
- else:
- span_colors[span.span_id] = palette[
- color_idx % len(palette)
- ]
- color_idx += 1
+ # Assign colors (shared with prompt reference resolution)
+ color_map = _assign_span_colors(item.spans, span_display)
+ span_colors = color_map.light_by_span_id
html_parts.append(
f' 0:
classes.append("highlighted")
+ fallback = span_display.color_palette[0]
style_parts: list[str] = []
if n_spans == 1:
- color = span_colors.get(span_ids[0], palette[0])
+ color = span_colors.get(span_ids[0], fallback)
style_parts.append(f"background-color: {color}")
elif n_spans > 1:
# Layer multiple spans
colors = [
- span_colors.get(sid, palette[0]) for sid in span_ids
+ span_colors.get(sid, fallback) for sid in span_ids
]
gradient = ", ".join(colors)
style_parts.append(
@@ -991,6 +1069,164 @@ def _generate_span_stimulus_html(
return "".join(html_parts)
+# ── Prompt span reference resolution ──────────────────────────────
+
+_SPAN_REF_PATTERN = re.compile(r"\[\[([^\]:]+?)(?::([^\]]+?))?\]\]")
+
+
+@dataclass(frozen=True)
+class _SpanReference:
+ """A parsed ``[[label]]`` or ``[[label:text]]`` reference."""
+
+ label: str
+ display_text: str | None
+ match_start: int
+ match_end: int
+
+
+def _parse_prompt_references(prompt: str) -> list[_SpanReference]:
+ """Parse ``[[label]]`` and ``[[label:text]]`` references from a prompt.
+
+ Parameters
+ ----------
+ prompt : str
+ Prompt string potentially containing span references.
+
+ Returns
+ -------
+ list[_SpanReference]
+ Parsed references in order of appearance.
+ """
+ return [
+ _SpanReference(
+ label=m.group(1).strip(),
+ display_text=m.group(2).strip() if m.group(2) else None,
+ match_start=m.start(),
+ match_end=m.end(),
+ )
+ for m in _SPAN_REF_PATTERN.finditer(prompt)
+ ]
+
+
+def _auto_fill_span_text(label: str, item: Item) -> str:
+ """Reconstruct display text from a span's tokens.
+
+ Finds the first span whose label matches, collects its token
+ indices from the first segment's element, and joins them
+ respecting ``token_space_after``.
+
+ Parameters
+ ----------
+ label : str
+ Span label to look up.
+ item : Item
+ Item with spans, tokenized_elements, and token_space_after.
+
+ Returns
+ -------
+ str
+ Reconstructed text from the span's tokens.
+
+ Raises
+ ------
+ ValueError
+ If no span with the given label exists or tokens are unavailable.
+ """
+ target_span: Span | None = None
+ for span in item.spans:
+ if span.label and span.label.label == label:
+ target_span = span
+ break
+
+ if target_span is None:
+ available = [
+ s.label.label for s in item.spans if s.label and s.label.label
+ ]
+ raise ValueError(
+ f"Prompt references span label '{label}' but no span with "
+ f"that label exists. Available labels: {available}"
+ )
+
+ parts: list[str] = []
+ for segment in target_span.segments:
+ element_name = segment.element_name
+ tokens = item.tokenized_elements.get(element_name, [])
+ space_flags = item.token_space_after.get(element_name, [])
+ sorted_indices = sorted(segment.indices)
+ for i, idx in enumerate(sorted_indices):
+ if idx < len(tokens):
+ parts.append(tokens[idx])
+ if (
+ i < len(sorted_indices) - 1
+ and idx < len(space_flags)
+ and space_flags[idx]
+ ):
+ parts.append(" ")
+
+ return "".join(parts)
+
+
+def _resolve_prompt_references(
+ prompt: str,
+ item: Item,
+ color_map: SpanColorMap,
+) -> str:
+ """Replace ``[[label]]`` references in a prompt with highlighted HTML.
+
+ Parameters
+ ----------
+ prompt : str
+ Prompt template with ``[[label]]`` or ``[[label:text]]`` refs.
+ item : Item
+ Item with spans and tokenized_elements.
+ color_map : SpanColorMap
+ Pre-computed color assignments from ``_assign_span_colors()``.
+
+ Returns
+ -------
+ str
+ Prompt with references replaced by highlighted HTML.
+
+ Raises
+ ------
+ ValueError
+ If a reference points to a nonexistent label.
+ """
+ refs = _parse_prompt_references(prompt)
+ if not refs:
+ return prompt
+
+ available = {
+ s.label.label for s in item.spans if s.label and s.label.label
+ }
+ for ref in refs:
+ if ref.label not in available:
+ raise ValueError(
+ f"Prompt references span label '{ref.label}' but no span "
+ f"with that label exists. Available labels: "
+ f"{sorted(available)}"
+ )
+
+ result = prompt
+ for ref in reversed(refs):
+ display = (
+ ref.display_text
+ if ref.display_text is not None
+ else _auto_fill_span_text(ref.label, item)
+ )
+ light = color_map.light_by_label.get(ref.label, "#BBDEFB")
+ dark = color_map.dark_by_label.get(ref.label, "#1565C0")
+ html = (
+ f''
+ f"{display}"
+ f''
+ f"{ref.label} "
+ )
+ result = result[: ref.match_start] + html + result[ref.match_end :]
+
+ return result
+
+
def _create_span_labeling_trial(
item: Item,
template: ItemTemplate,
@@ -1028,6 +1264,10 @@ def _create_span_labeling_trial(
else "Select and label spans"
)
+ if item.spans:
+ color_map = _assign_span_colors(item.spans, span_display)
+ prompt = _resolve_prompt_references(prompt, item, color_map)
+
# Serialize span data for the plugin
spans_data = [
{
diff --git a/bead/deployment/jspsych/ui/styles.py b/bead/deployment/jspsych/ui/styles.py
index 6210492..65811a9 100644
--- a/bead/deployment/jspsych/ui/styles.py
+++ b/bead/deployment/jspsych/ui/styles.py
@@ -407,5 +407,30 @@ def generate_css(
.bead-choice-button {{
width: 100%;
}}
+
+/* Span-highlighted prompt references */
+.bead-q-highlight {{
+ position: relative;
+ padding: 1px 4px;
+ border-radius: 3px;
+ font-weight: 500;
+ margin-bottom: 0.6rem;
+}}
+
+.bead-q-chip {{
+ position: absolute;
+ bottom: -0.6rem;
+ right: -2px;
+ display: inline-flex;
+ align-items: center;
+ padding: 0px 5px;
+ border-radius: 0.6rem;
+ font-size: 0.6rem;
+ font-weight: 500;
+ color: white;
+ white-space: nowrap;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
+ line-height: 1.5;
+}}
"""
return css
diff --git a/bead/items/span_labeling.py b/bead/items/span_labeling.py
index 8726238..f1d8ff8 100644
--- a/bead/items/span_labeling.py
+++ b/bead/items/span_labeling.py
@@ -13,6 +13,8 @@
from __future__ import annotations
+import re
+import warnings
from collections.abc import Callable
from uuid import UUID, uuid4
@@ -25,6 +27,8 @@
from bead.tokenization.config import TokenizerConfig
from bead.tokenization.tokenizers import TokenizedText, create_tokenizer
+_SPAN_REF_PATTERN = re.compile(r"\[\[([^\]:]+?)(?::([^\]]+?))?\]\]")
+
def tokenize_item(
item: Item,
@@ -315,6 +319,24 @@ def add_spans_to_item(
# Validate spans
_validate_span_indices(spans, item.tokenized_elements)
+ # Warn if prompt contains [[label]] references to nonexistent span labels
+ prompt_text = item.rendered_elements.get("prompt", "")
+ if prompt_text:
+ all_spans = list(item.spans) + spans
+ span_labels = {
+ s.label.label for s in all_spans if s.label is not None
+ }
+ for match in _SPAN_REF_PATTERN.finditer(prompt_text):
+ ref_label = match.group(1)
+ if ref_label not in span_labels:
+ warnings.warn(
+ f"Prompt contains [[{ref_label}]] but no span with "
+ f"label '{ref_label}' exists. Available labels: "
+ f"{sorted(span_labels)}",
+ UserWarning,
+ stacklevel=2,
+ )
+
# Build new item with spans
data = item.model_dump()
# Merge existing spans with new ones
diff --git a/docs/developer-guide/setup.md b/docs/developer-guide/setup.md
index 06ecd76..2599ab5 100644
--- a/docs/developer-guide/setup.md
+++ b/docs/developer-guide/setup.md
@@ -123,7 +123,7 @@ This command:
```bash
# Check bead CLI installed
uv run bead --version
-# Output: bead, version 0.1.0
+# Output: bead, version 0.2.0
# Check development tools
uv run pytest --version
@@ -509,7 +509,7 @@ Run these commands to verify your development environment is fully functional:
```bash
uv run bead --version
-# Expected: bead, version 0.1.0
+# Expected: bead, version 0.2.0
```
### 2. Run Quick Test
diff --git a/docs/examples/gallery.md b/docs/examples/gallery.md
index 6d39d00..f443ceb 100644
--- a/docs/examples/gallery.md
+++ b/docs/examples/gallery.md
@@ -1,12 +1,12 @@
# Interactive Task Gallery
-Try each bead task interface below. Every demo is a live jsPsych experiment running in your browser. Examples use stimuli drawn from Aaron Steven White's psycholinguistics research, including MegaAcceptability, MegaVeridicality, Semantic Proto-Roles, and the Diverse Natural Language Inference Corpus.
+Try each bead task interface below. Every demo is a live jsPsych experiment running in your browser. Examples use stimuli from psycholinguistics research on acceptability, veridicality, semantic proto-roles, event typicality, and telicity.
-
+