summaryrefslogtreecommitdiff
path: root/packages/prosody-ui/src/zoom
diff options
context:
space:
mode:
Diffstat (limited to 'packages/prosody-ui/src/zoom')
-rw-r--r--packages/prosody-ui/src/zoom/FullText.tsx60
-rw-r--r--packages/prosody-ui/src/zoom/Paragraph.tsx60
-rw-r--r--packages/prosody-ui/src/zoom/Sentence.tsx46
-rw-r--r--packages/prosody-ui/src/zoom/SpacyClause.tsx125
-rw-r--r--packages/prosody-ui/src/zoom/animations.ts199
-rw-r--r--packages/prosody-ui/src/zoom/hooks/useZoom.tsx135
-rw-r--r--packages/prosody-ui/src/zoom/index.ts8
-rw-r--r--packages/prosody-ui/src/zoom/logic/types.ts53
-rw-r--r--packages/prosody-ui/src/zoom/spacy.css39
9 files changed, 725 insertions, 0 deletions
diff --git a/packages/prosody-ui/src/zoom/FullText.tsx b/packages/prosody-ui/src/zoom/FullText.tsx
new file mode 100644
index 0000000..ec85f09
--- /dev/null
+++ b/packages/prosody-ui/src/zoom/FullText.tsx
@@ -0,0 +1,60 @@
+import React from "react";
+import { motion, AnimatePresence } from "motion/react";
+import Paragraph from "./Paragraph";
+import { useZoom } from "./hooks/useZoom";
+import { containerVariants, buttonVariants } from "./animations";
+import { NLP } from "sortug-ai";
+
+interface TextFocusMorphProps {
+ text: string;
+ doc: NLP.Spacy.SpacyRes;
+}
+
+const FullText: React.FC<TextFocusMorphProps> = ({ text, doc }) => {
+ const { viewState, navigateBack, handleElementClick } = useZoom();
+ const { level } = viewState;
+
+ // Split text into paragraphs
+ const paragraphs = text
+ .split("\n\n")
+ .map((p) => p.trim())
+ .filter(Boolean);
+
+ return (
+ <div className="text-focus-morph">
+ {level !== "text" && (
+ <AnimatePresence>
+ <motion.button
+ className="back-button"
+ onClick={navigateBack}
+ variants={buttonVariants}
+ initial="initial"
+ animate="animate"
+ exit="exit"
+ >
+ ← Back
+ </motion.button>
+ </AnimatePresence>
+ )}
+
+ <motion.div
+ className="content-container"
+ variants={containerVariants}
+ initial="text"
+ animate={level}
+ >
+ {paragraphs.map((paragraph, idx) => (
+ <Paragraph
+ doc={doc}
+ key={paragraph + idx}
+ rawText={paragraph}
+ context={{ idx, parentText: text, segmented: paragraphs }}
+ idx={idx}
+ />
+ ))}
+ </motion.div>
+ </div>
+ );
+};
+
+export default FullText;
diff --git a/packages/prosody-ui/src/zoom/Paragraph.tsx b/packages/prosody-ui/src/zoom/Paragraph.tsx
new file mode 100644
index 0000000..c26f806
--- /dev/null
+++ b/packages/prosody-ui/src/zoom/Paragraph.tsx
@@ -0,0 +1,60 @@
+import React, { memo, useCallback, useEffect, useState } from "react";
+import { motion } from "motion/react";
+import type { ViewProps, LoadingStatus } from "./logic/types";
+import { NLP } from "sortug-ai";
+import Sentence from "./Sentence";
+import { paragraphVariants, createHoverEffect } from "./animations";
+import { useZoom } from "./hooks/useZoom";
+
+function Paragraph({ rawText, context, idx, doc }: ViewProps) {
+ const { viewState, handleElementClick } = useZoom();
+ const { level, pIndex } = viewState;
+ const selected = pIndex === idx;
+ const isFocused = level === "paragraph" && selected;
+
+ // State for sentences
+ const [loading, setLoading] = useState<LoadingStatus>("pending");
+
+ return (
+ <>
+ <motion.div
+ key={idx + rawText}
+ className={`paragraph-wrapper ${selected ? "selected" : ""}`}
+ custom={selected}
+ variants={paragraphVariants}
+ initial="text"
+ animate={level}
+ onClick={(e) => handleElementClick(e, idx)}
+ whileHover={
+ level === "text"
+ ? createHoverEffect(level, "text", "255, 255, 200")
+ : {}
+ }
+ >
+ {loading === "loading" && <div className="spinner" />}
+ {level === "text" || !selected || doc.segs.length === 0 ? (
+ <p className="paragraph">{rawText}</p>
+ ) : (
+ <div className="sentences-container">
+ {doc.segs.map((sentence, sentIdx) => (
+ <Sentence
+ key={sentence.text + sentIdx}
+ idx={sentIdx}
+ rawText={sentence.text}
+ spacy={sentence}
+ context={{
+ idx: sentIdx,
+ parentText: rawText,
+ segmented: doc.segs.map((s) => s.text),
+ }}
+ doc={doc}
+ />
+ ))}
+ </div>
+ )}
+ </motion.div>
+ </>
+ );
+}
+
+export default memo(Paragraph);
diff --git a/packages/prosody-ui/src/zoom/Sentence.tsx b/packages/prosody-ui/src/zoom/Sentence.tsx
new file mode 100644
index 0000000..1d90346
--- /dev/null
+++ b/packages/prosody-ui/src/zoom/Sentence.tsx
@@ -0,0 +1,46 @@
+import React, { memo } from "react";
+import { motion } from "motion/react";
+import type { ViewProps, LoadingStatus } from "./logic/types";
+import { NLP } from "sortug-ai";
+import SpacyClause from "./SpacyClause";
+import { sentenceVariants, createHoverEffect } from "./animations";
+import { useZoom } from "./hooks/useZoom";
+
+interface Props extends ViewProps {
+ spacy: NLP.Spacy.Sentence;
+ stanza?: NLP.Stanza.Sentence;
+}
+
+function Sentence({ spacy, stanza, context, idx }: Props) {
+ const { viewState, handleElementClick } = useZoom();
+ const { level, sIndex } = viewState;
+ const selected = sIndex === idx;
+ const isFocused = level === "sentence" && selected;
+
+ return (
+ <>
+ <motion.span
+ key={idx + spacy.text}
+ className={`sentence-wrapper ${selected ? "selected" : ""}`}
+ custom={selected}
+ variants={sentenceVariants}
+ initial="paragraph"
+ animate={level}
+ onClick={(e) => handleElementClick(e, idx)}
+ whileHover={
+ level === "paragraph"
+ ? createHoverEffect(level, "paragraph", "200, 220, 255")
+ : {}
+ }
+ >
+ {level === "paragraph" || !selected ? (
+ <span className="sentence">{spacy.text}</span>
+ ) : (
+ <SpacyClause sentence={spacy} />
+ )}
+ </motion.span>
+ </>
+ );
+}
+
+export default memo(Sentence);
diff --git a/packages/prosody-ui/src/zoom/SpacyClause.tsx b/packages/prosody-ui/src/zoom/SpacyClause.tsx
new file mode 100644
index 0000000..6b6f178
--- /dev/null
+++ b/packages/prosody-ui/src/zoom/SpacyClause.tsx
@@ -0,0 +1,125 @@
+import React, { memo, useState } from "react";
+import { motion } from "motion/react";
+import "./spacy.css";
+import { NLP } from "sortug-ai";
+// import { clauseVariants, createHoverEffect } from "./animations";
+// import { useZoom } from "./hooks/useZoom";
+
+function Grammar({ sentence }: { sentence: NLP.Spacy.Sentence }) {
+ const [hoveredClause, setHoveredClause] = useState<number | null>(null);
+
+ // Ref to manage the timeout for debouncing mouse leave
+ return (
+ <div className="clause-container">
+ {sentence.words.map((w, idx) => {
+ const isRoot =
+ w.ancestors.length === 0 || w.dep.toLowerCase() === "root";
+ const isSubj = NLP.Spacy.isChild(w, sentence.subj.id);
+ const isPred = !isSubj && !isRoot;
+ const predClass = isPred ? "pred" : "";
+ const relClass = isRoot ? "root" : `rel-${w.dep}`;
+ const ownClass = isRoot
+ ? ""
+ : isSubj
+ ? "subj"
+ : w.children.length === 0
+ ? ""
+ : `clause-${w.id}`;
+ const clase = w.ancestors.reduce((acc, item) => {
+ if (item === sentence.subj.id || item === sentence.root.id)
+ return acc;
+ else return `${acc} clause-${item}`;
+ }, ``);
+ const className = `suword ${relClass} ${ownClass} ${clase} ${predClass}`;
+ const isHovering =
+ !isRoot &&
+ !!hoveredClause &&
+ (w.id === hoveredClause || w.ancestors.includes(hoveredClause));
+ function handleClick(w: NLP.Spacy.Word) {
+ console.log("show the whole clause and all that", w);
+ }
+ return (
+ <ClauseSpan
+ word={w}
+ key={w.id}
+ className={className}
+ hovering={isHovering}
+ setHovering={setHoveredClause}
+ onClick={handleClick}
+ />
+ );
+ })}
+ </div>
+ );
+}
+
+const spanVariants: any = {
+ initial: {
+ // Base style
+ backgroundColor: "rgba(0, 0, 0, 0)", // Transparent background initially
+ fontWeight: "normal",
+ scale: 1,
+ zIndex: 0, // Default stacking
+ position: "relative", // Needed for zIndex to work reliably
+ // Add other base styles if needed
+ },
+ hovered: {
+ // Style when this span's group is hovered
+ backgroundColor: "rgba(255, 255, 0, 0.5)", // Yellow highlight
+ scale: 1.05,
+ zIndex: 1, // Bring hovered spans slightly forward
+ boxShadow: "0px 2px 5px rgba(0,0,0,0.2)",
+ // Add other hover effects
+ },
+};
+
+// Define the transition
+const spanTransition = {
+ type: "spring",
+ stiffness: 500,
+ damping: 30,
+ // duration: 0.1 // Or use duration for non-spring types
+};
+
+function ClauseSpan({
+ word,
+ className,
+ hovering,
+ setHovering,
+ onClick,
+}: {
+ word: NLP.Spacy.Word;
+ className: string;
+ hovering: boolean;
+ setHovering: (n: number | null) => void;
+ onClick: (w: NLP.Spacy.Word) => void;
+}) {
+ function handleMouseOver() {
+ setHovering(word.id);
+ // if (word.children.length > 0) setHovering(word.id);
+ // else setHovering(word.head);
+ }
+ function handleMouseLeave() {
+ setHovering(null);
+ }
+ function handleClick(e: React.MouseEvent) {
+ e.stopPropagation();
+ onClick(word);
+ }
+ return (
+ <motion.span
+ className={className}
+ variants={spanVariants}
+ initial="initial"
+ animate={hovering ? "hovered" : "initial"}
+ transition={spanTransition}
+ onMouseOver={handleMouseOver}
+ onMouseLeave={handleMouseLeave}
+ onClick={handleClick}
+ >
+ {word.text}
+ </motion.span>
+ );
+}
+
+export default memo(Grammar);
diff --git a/packages/prosody-ui/src/zoom/animations.ts b/packages/prosody-ui/src/zoom/animations.ts
new file mode 100644
index 0000000..6135e7f
--- /dev/null
+++ b/packages/prosody-ui/src/zoom/animations.ts
@@ -0,0 +1,199 @@
+import type { Variants } from "motion/react";
+
+// Base transition configurations for consistent animations
+const baseTransition = {
+ duration: 0.5,
+ ease: [0.43, 0.13, 0.23, 0.96], // Improved easing for smoother feel
+};
+
+export const fadeTransition = {
+ ...baseTransition,
+ duration: 0.3,
+};
+
+// Shared variants for different view levels
+export const containerVariants: Variants = {
+ text: {
+ opacity: 1,
+ transition: {
+ staggerChildren: 0.05,
+ delayChildren: 0.1,
+ },
+ },
+ paragraph: {
+ opacity: 1,
+ transition: {
+ staggerChildren: 0.05,
+ delayChildren: 0.1,
+ },
+ },
+ sentence: {
+ opacity: 1,
+ transition: {
+ staggerChildren: 0.05,
+ delayChildren: 0.1,
+ },
+ },
+ clause: {
+ opacity: 1,
+ transition: {
+ staggerChildren: 0.05,
+ delayChildren: 0.1,
+ },
+ },
+ word: {
+ opacity: 1,
+ transition: {
+ staggerChildren: 0.05,
+ delayChildren: 0.1,
+ },
+ },
+ syllable: {
+ opacity: 1,
+ transition: {
+ staggerChildren: 0.05,
+ delayChildren: 0.1,
+ },
+ },
+ phoneme: {
+ opacity: 1,
+ transition: {
+ staggerChildren: 0.05,
+ delayChildren: 0.1,
+ },
+ },
+};
+
+// Function to create element variants based on selection state
+export const createElementVariants = (
+ currentLevel: string,
+ nextLevel: string,
+ prevLevel: string,
+ selectedOpacity = 1,
+ unselectedOpacity = 0.1,
+ selectedScale = 1.05,
+ unselectedScale = 0.95,
+ selectedBlur = "0px",
+ unselectedBlur = "2px",
+ bgColor = "rgba(255, 255, 255, 0)", // Use rgba with 0 opacity instead of transparent
+): Variants => {
+ return {
+ [prevLevel]: {
+ opacity: 1,
+ scale: 1,
+ filter: "blur(0px)",
+ backgroundColor: "rgba(255, 255, 255, 0)", // Use rgba with 0 opacity
+ transition: baseTransition,
+ },
+ [currentLevel]: (isSelected: boolean) => ({
+ opacity: isSelected ? selectedOpacity : unselectedOpacity,
+ scale: isSelected ? selectedScale : unselectedScale,
+ filter: isSelected ? `blur(${selectedBlur})` : `blur(${unselectedBlur})`,
+ backgroundColor: isSelected ? bgColor : "rgba(255, 255, 255, 0)", // Use rgba with 0 opacity
+ transition: baseTransition,
+ }),
+ [nextLevel]: (isSelected: boolean) => ({
+ opacity: isSelected ? selectedOpacity : unselectedOpacity / 2,
+ scale: isSelected ? selectedScale : unselectedScale * 0.95,
+ filter: isSelected
+ ? `blur(${selectedBlur})`
+ : `blur(${parseInt(unselectedBlur) + 1}px)`,
+ backgroundColor: isSelected ? bgColor : "rgba(255, 255, 255, 0)", // Use rgba with 0 opacity
+ transition: baseTransition,
+ }),
+ };
+};
+
+// Pre-configured variants for each level
+export const paragraphVariants = createElementVariants(
+ "paragraph",
+ "sentence",
+ "text",
+ 1,
+ 0.1,
+ 1.05,
+ 0.95,
+ "0px",
+ "2px",
+ "rgba(200, 220, 255, 0.1)",
+);
+
+export const sentenceVariants = createElementVariants(
+ "sentence",
+ "clause",
+ "paragraph",
+ 1,
+ 0.1,
+ 1.1,
+ 0.95,
+ "0px",
+ "2px",
+ "rgba(200, 220, 255, 0.2)",
+);
+
+export const clauseVariants = createElementVariants(
+ "clause",
+ "word",
+ "sentence",
+ 1,
+ 0.1,
+ 1.1,
+ 0.95,
+ "0px",
+ "2px",
+ "rgba(220, 200, 255, 0.2)",
+);
+
+export const wordVariants = createElementVariants(
+ "word",
+ "syllable",
+ "clause",
+ 1,
+ 0.1,
+ 1.15,
+ 0.9,
+ "0px",
+ "2px",
+ "rgba(255, 200, 200, 0.2)",
+);
+
+export const syllableVariants = createElementVariants(
+ "syllable",
+ "phoneme",
+ "word",
+ 1,
+ 0.1,
+ 1.2,
+ 0.9,
+ "0px",
+ "2px",
+ "rgba(200, 255, 200, 0.2)",
+);
+
+// Button animations
+export const buttonVariants: Variants = {
+ initial: { opacity: 0, x: -20 },
+ animate: { opacity: 1, x: 0, transition: fadeTransition },
+ exit: { opacity: 0, x: -20, transition: fadeTransition },
+};
+
+// Hover effects
+export const createHoverEffect = (
+ level: string,
+ currentLevel: string,
+ color: string,
+) => {
+ if (level === currentLevel) {
+ return {
+ scale: 1.02,
+ backgroundColor: `rgba(${color}, 0.3)`,
+ transition: { duration: 0.2 },
+ };
+ }
+ return {
+ // Return empty animation with same properties to avoid errors
+ scale: 1,
+ backgroundColor: "rgba(255, 255, 255, 0)",
+ transition: { duration: 0.2 },
+ };
+};
diff --git a/packages/prosody-ui/src/zoom/hooks/useZoom.tsx b/packages/prosody-ui/src/zoom/hooks/useZoom.tsx
new file mode 100644
index 0000000..733ca06
--- /dev/null
+++ b/packages/prosody-ui/src/zoom/hooks/useZoom.tsx
@@ -0,0 +1,135 @@
+import React, {
+ createContext,
+ useState,
+ useContext,
+ type ReactNode,
+} from "react";
+import type { ViewLevel, ViewState } from "../logic/types";
+
+// Type definitions for the context
+interface ZoomContextType {
+ viewState: ViewState;
+ setLevel: (level: ViewLevel) => void;
+ setParagraphIndex: (idx: number | null) => void;
+ setSentenceIndex: (idx: number | null) => void;
+ setClauseIndex: (idx: number | null) => void;
+ setWordIndex: (idx: number | null) => void;
+ setSyllableIndex: (idx: number | null) => void;
+ setPhonemeIndex: (idx: number | null) => void;
+ navigateBack: () => void;
+ handleElementClick: (e: React.MouseEvent, idx: number) => void;
+}
+
+// Create the context with default empty values
+const ZoomContext = createContext<ZoomContextType>({
+ viewState: {
+ level: "text",
+ pIndex: null,
+ sIndex: null,
+ cIndex: null,
+ wIndex: null,
+ yIndex: null,
+ fIndex: null,
+ },
+ setLevel: () => {},
+ setParagraphIndex: () => {},
+ setSentenceIndex: () => {},
+ setClauseIndex: () => {},
+ setWordIndex: () => {},
+ setSyllableIndex: () => {},
+ setPhonemeIndex: () => {},
+ navigateBack: () => {},
+ handleElementClick: () => {},
+});
+
+// Provider component
+export const ZoomProvider: React.FC<{ children: ReactNode }> = ({
+ children,
+}) => {
+ const [viewState, setViewState] = useState<ViewState>({
+ level: "text",
+ pIndex: null,
+ sIndex: null,
+ cIndex: null,
+ wIndex: null,
+ yIndex: null,
+ fIndex: null,
+ });
+
+ // Helper functions to update individual parts of the state
+ const setLevel = (level: ViewLevel) =>
+ setViewState((prev) => ({ ...prev, level }));
+ const setParagraphIndex = (pIndex: number | null) =>
+ setViewState((prev) => ({ ...prev, pIndex }));
+ const setSentenceIndex = (sIndex: number | null) =>
+ setViewState((prev) => ({ ...prev, sIndex }));
+ const setClauseIndex = (cIndex: number | null) =>
+ setViewState((prev) => ({ ...prev, cIndex }));
+ const setWordIndex = (wIndex: number | null) =>
+ setViewState((prev) => ({ ...prev, wIndex }));
+ const setSyllableIndex = (yIndex: number | null) =>
+ setViewState((prev) => ({ ...prev, yIndex }));
+ const setPhonemeIndex = (fIndex: number | null) =>
+ setViewState((prev) => ({ ...prev, fIndex }));
+
+ // Handle navigation levels
+ const navigateBack = () => {
+ const { level } = viewState;
+
+ if (level === "paragraph") {
+ setViewState((prev) => ({ ...prev, level: "text", pIndex: null }));
+ } else if (level === "sentence") {
+ setViewState((prev) => ({ ...prev, level: "paragraph", sIndex: null }));
+ } else if (level === "clause") {
+ setViewState((prev) => ({ ...prev, level: "sentence", cIndex: null }));
+ } else if (level === "word") {
+ setViewState((prev) => ({ ...prev, level: "clause", wIndex: null }));
+ } else if (level === "syllable") {
+ setViewState((prev) => ({ ...prev, level: "word", yIndex: null }));
+ } else if (level === "phoneme") {
+ setViewState((prev) => ({ ...prev, level: "syllable", fIndex: null }));
+ }
+ };
+
+ // Handle clicks on elements to navigate forward
+ const handleElementClick = (e: React.MouseEvent, idx: number) => {
+ e.stopPropagation();
+ const { level } = viewState;
+
+ if (level === "text") {
+ setViewState((prev) => ({ ...prev, level: "paragraph", pIndex: idx }));
+ } else if (level === "paragraph") {
+ setViewState((prev) => ({ ...prev, level: "sentence", sIndex: idx }));
+ } else if (level === "sentence") {
+ setViewState((prev) => ({ ...prev, level: "clause", cIndex: idx }));
+ } else if (level === "clause") {
+ setViewState((prev) => ({ ...prev, level: "word", wIndex: idx }));
+ } else if (level === "word") {
+ setViewState((prev) => ({ ...prev, level: "syllable", yIndex: idx }));
+ } else if (level === "syllable") {
+ setViewState((prev) => ({ ...prev, level: "phoneme", fIndex: idx }));
+ }
+ };
+
+ return (
+ <ZoomContext.Provider
+ value={{
+ viewState,
+ setLevel,
+ setParagraphIndex,
+ setSentenceIndex,
+ setClauseIndex,
+ setWordIndex,
+ setSyllableIndex,
+ setPhonemeIndex,
+ navigateBack,
+ handleElementClick,
+ }}
+ >
+ {children}
+ </ZoomContext.Provider>
+ );
+};
+
+// Custom hook to use the zoom context
+export const useZoom = () => useContext(ZoomContext);
diff --git a/packages/prosody-ui/src/zoom/index.ts b/packages/prosody-ui/src/zoom/index.ts
new file mode 100644
index 0000000..baf5db1
--- /dev/null
+++ b/packages/prosody-ui/src/zoom/index.ts
@@ -0,0 +1,8 @@
+export { ZoomProvider, useZoom } from "./hooks/useZoom";
+import Paragraph from "./Paragraph";
+import FullText from "./FullText";
+import Sentence from "./Paragraph";
+import SpacyClause from "./SpacyClause";
+import type * as Types from "./logic/types";
+
+export { Paragraph, FullText, Sentence, SpacyClause, Types };
diff --git a/packages/prosody-ui/src/zoom/logic/types.ts b/packages/prosody-ui/src/zoom/logic/types.ts
new file mode 100644
index 0000000..bea68ff
--- /dev/null
+++ b/packages/prosody-ui/src/zoom/logic/types.ts
@@ -0,0 +1,53 @@
+import type { NLP } from "sortug-ai";
+
+export type ViewLevel =
+ | "text"
+ | "paragraph"
+ | "sentence"
+ | "clause"
+ | "word"
+ | "syllable"
+ | "phoneme";
+export interface ViewState {
+ level: ViewLevel;
+ pIndex: number | null;
+ sIndex: number | null;
+ cIndex: number | null;
+ wIndex: number | null;
+ yIndex: number | null;
+ fIndex: number | null;
+}
+
+export interface ViewProps {
+ idx: number;
+ rawText: string;
+ context: Context;
+ doc: NLP.Spacy.SpacyRes;
+}
+export type Context = {
+ parentText: string;
+ segmented: string[];
+ idx: number;
+};
+
+export type WordData = {
+ confidence: number;
+ frequency: number | null;
+ id: number;
+ ipa: Array<{ ipa: string; tags: string[] }>;
+ spelling: string;
+ type: ExpressionType;
+ syllables: number;
+ lang: string;
+ prosody: any;
+ senses: Sense[];
+};
+export type ExpressionType = "word" | "expression" | "syllable";
+export type Sense = {
+ etymology: string;
+ pos: string;
+ forms: Array<{ form: string; tags: string[] }>;
+ related: any;
+ senses: Array<{ glosses: string[]; links: Array<[string, string]> }>;
+};
+export type LoadingStatus = "pending" | "loading" | "success" | "error";
diff --git a/packages/prosody-ui/src/zoom/spacy.css b/packages/prosody-ui/src/zoom/spacy.css
new file mode 100644
index 0000000..0077119
--- /dev/null
+++ b/packages/prosody-ui/src/zoom/spacy.css
@@ -0,0 +1,39 @@
+.suword {
+ margin-left: 0.5ch;
+ margin-right: 0.5ch;
+}
+
+/* .suword.pred { */
+/* color: gold; */
+/* } */
+
+/* Clause level */
+.clause-container {
+ max-width: 600px;
+ white-space: normal !important;
+ hyphens: auto;
+
+ padding: 2px;
+ border-radius: 4px;
+ cursor: pointer;
+ transition: background-color 0.2s ease;
+ will-change: transform, opacity, filter, background-color;
+
+ span {
+ white-space: normal !important;
+ }
+}
+
+.clause-container.selected {
+ background-color: rgba(220, 200, 255, 0.2);
+ z-index: 3;
+}
+
+.suword.subj {
+ color: blue;
+ /* border-bottom: 2px solid blue; */
+}
+
+.suword.root {
+ color: darkred;
+} \ No newline at end of file