feat: implement interactive pedagogical reasoning with compact tooltips
- Add DecompositionWithReasons component with interactive term groups - Implement current step highlighting with amber glow animation - Create compact tooltips with step-by-step breakdowns and expansion reasoning - Remove visual noise: no default term styling, only current step and group hover - Add unified hover targets spanning entire term groups with reasoning context - Integrate with tutorial context for synchronized step progression 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -26,6 +26,7 @@
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@radix-ui/react-label": "^2.0.2",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.0.3",
|
||||
"@radix-ui/react-radio-group": "^1.1.3",
|
||||
"@radix-ui/react-select": "^2.0.0",
|
||||
|
||||
323
apps/web/src/components/tutorial/DecompositionWithReasons.tsx
Normal file
323
apps/web/src/components/tutorial/DecompositionWithReasons.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
'use client'
|
||||
|
||||
import React, { useMemo, useState, createContext, useContext } from 'react'
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import { ReasonTooltip } from './ReasonTooltip'
|
||||
import type { UnifiedStepData } from '../../utils/unifiedStepGenerator'
|
||||
import { useTutorialContext } from './TutorialContext'
|
||||
import './decomposition-reasoning.css'
|
||||
|
||||
export type PedagogicalRule = 'Direct' | 'FiveComplement' | 'TenComplement' | 'Cascade'
|
||||
|
||||
export interface SegmentDecision {
|
||||
rule: PedagogicalRule
|
||||
conditions: string[]
|
||||
explanation: string[]
|
||||
}
|
||||
|
||||
export interface PedagogicalSegment {
|
||||
id: string
|
||||
place: number
|
||||
digit: number
|
||||
a: number
|
||||
L: number
|
||||
U: 0 | 1
|
||||
goal: string
|
||||
plan: SegmentDecision[]
|
||||
expression: string
|
||||
termIndices: number[]
|
||||
termRange: { startIndex: number; endIndex: number }
|
||||
}
|
||||
|
||||
export interface TermReason {
|
||||
termIndex: number
|
||||
segmentId: string
|
||||
rule: PedagogicalRule
|
||||
shortReason: string
|
||||
bullets?: string[]
|
||||
}
|
||||
|
||||
// Context for tracking active terms to highlight related segments
|
||||
interface DecompositionContextType {
|
||||
activeTerms: Set<number>
|
||||
activeSegmentId: string | null
|
||||
addActiveTerm: (termIndex: number, segmentId?: string) => void
|
||||
removeActiveTerm: (termIndex: number, segmentId?: string) => void
|
||||
}
|
||||
|
||||
const DecompositionContext = createContext<DecompositionContextType>({
|
||||
activeTerms: new Set(),
|
||||
activeSegmentId: null,
|
||||
addActiveTerm: () => {},
|
||||
removeActiveTerm: () => {}
|
||||
})
|
||||
|
||||
interface DecompositionWithReasonsProps {
|
||||
fullDecomposition: string
|
||||
termPositions: Array<{ startIndex: number; endIndex: number }>
|
||||
segments?: PedagogicalSegment[]
|
||||
termReasons?: TermReason[]
|
||||
steps?: UnifiedStepData[]
|
||||
}
|
||||
|
||||
interface TermSpanProps {
|
||||
termIndex: number
|
||||
text: string
|
||||
segment?: PedagogicalSegment
|
||||
reason?: TermReason
|
||||
isCurrentStep?: boolean
|
||||
}
|
||||
|
||||
function TermSpan({
|
||||
termIndex,
|
||||
text,
|
||||
segment,
|
||||
reason,
|
||||
isCurrentStep = false
|
||||
}: TermSpanProps) {
|
||||
const { activeSegmentId } = useContext(DecompositionContext)
|
||||
const rule = reason?.rule ?? segment?.plan[0]?.rule
|
||||
|
||||
// Only show styling for terms that have pedagogical reasoning
|
||||
if (!rule) {
|
||||
return <span className="term term--plain">{text}</span>
|
||||
}
|
||||
|
||||
// Determine CSS classes based on current step only
|
||||
const cssClasses = [
|
||||
'term',
|
||||
isCurrentStep && 'term--current' // New class for current step highlighting
|
||||
].filter(Boolean).join(' ')
|
||||
|
||||
return (
|
||||
<span className={cssClasses}>
|
||||
{text}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Component for rendering a segment group with unified hover target
|
||||
interface SegmentGroupProps {
|
||||
segment: PedagogicalSegment
|
||||
fullDecomposition: string
|
||||
termPositions: Array<{ startIndex: number; endIndex: number }>
|
||||
termReasons?: TermReason[]
|
||||
steps: UnifiedStepData[]
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
function SegmentGroup({ segment, fullDecomposition, steps, children }: SegmentGroupProps) {
|
||||
const [tooltipOpen, setTooltipOpen] = useState(false)
|
||||
const { addActiveTerm, removeActiveTerm } = useContext(DecompositionContext)
|
||||
|
||||
// Calculate the original term that was expanded
|
||||
// digit * 10^place gives us the original value (e.g., digit=5, place=1 -> 50)
|
||||
const originalValue = (segment.digit * Math.pow(10, segment.place)).toString()
|
||||
|
||||
const handleTooltipChange = (open: boolean) => {
|
||||
setTooltipOpen(open)
|
||||
// Activate/deactivate all terms in this segment
|
||||
if (open) {
|
||||
segment.termIndices.forEach(termIndex => addActiveTerm(termIndex, segment.id))
|
||||
} else {
|
||||
segment.termIndices.forEach(termIndex => removeActiveTerm(termIndex, segment.id))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ReasonTooltip
|
||||
termIndex={segment.termIndices[0]} // Use first term for tooltip ID
|
||||
segment={segment}
|
||||
originalValue={originalValue}
|
||||
steps={steps}
|
||||
open={tooltipOpen}
|
||||
onOpenChange={handleTooltipChange}
|
||||
>
|
||||
<span
|
||||
className="segment-group"
|
||||
onMouseEnter={() => handleTooltipChange(true)}
|
||||
onMouseLeave={() => handleTooltipChange(false)}
|
||||
onFocus={() => handleTooltipChange(true)}
|
||||
onBlur={() => handleTooltipChange(false)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</ReasonTooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export function DecompositionWithReasons({
|
||||
fullDecomposition,
|
||||
termPositions,
|
||||
segments,
|
||||
termReasons,
|
||||
steps = []
|
||||
}: DecompositionWithReasonsProps) {
|
||||
const [activeTerms, setActiveTerms] = useState<Set<number>>(new Set())
|
||||
|
||||
// Get current step index from tutorial context
|
||||
const { state } = useTutorialContext()
|
||||
const currentStepIndex = state.currentMultiStep
|
||||
|
||||
// Build segment boundaries and ranges
|
||||
const segmentRanges = useMemo(() => {
|
||||
if (!segments) return []
|
||||
return segments.map(seg => ({
|
||||
segment: seg,
|
||||
startIndex: Math.min(...seg.termIndices.map(i => termPositions[i]?.startIndex ?? Infinity)),
|
||||
endIndex: Math.max(...seg.termIndices.map(i => termPositions[i]?.endIndex ?? 0))
|
||||
})).sort((a, b) => a.startIndex - b.startIndex)
|
||||
}, [segments, termPositions])
|
||||
|
||||
// Build a quick lookup: termIndex -> segment
|
||||
const termIndexToSegment = useMemo(() => {
|
||||
const map = new Map<number, PedagogicalSegment>()
|
||||
segments?.forEach(seg => seg.termIndices.forEach(i => map.set(i, seg)))
|
||||
return map
|
||||
}, [segments])
|
||||
|
||||
// Determine which segment should be highlighted based on active terms
|
||||
const activeSegmentId = useMemo(() => {
|
||||
if (activeTerms.size === 0) return null
|
||||
|
||||
// Find the segment that contains any of the active terms
|
||||
for (const termIndex of activeTerms) {
|
||||
const segment = termIndexToSegment.get(termIndex)
|
||||
if (segment) {
|
||||
return segment.id
|
||||
}
|
||||
}
|
||||
return null
|
||||
}, [activeTerms, termIndexToSegment])
|
||||
|
||||
const addActiveTerm = (termIndex: number, segmentId?: string) => {
|
||||
setActiveTerms(prev => new Set([...prev, termIndex]))
|
||||
}
|
||||
|
||||
const removeActiveTerm = (termIndex: number, segmentId?: string) => {
|
||||
setActiveTerms(prev => {
|
||||
const next = new Set(prev)
|
||||
next.delete(termIndex)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// Slice the decomposition string using termPositions
|
||||
const pieces: React.ReactNode[] = []
|
||||
let cursor = 0
|
||||
|
||||
// Render elements with segment groupings
|
||||
const renderElements = () => {
|
||||
const elements: React.ReactNode[] = []
|
||||
let cursor = 0
|
||||
let currentSegmentIndex = 0
|
||||
|
||||
for (let termIndex = 0; termIndex < termPositions.length; termIndex++) {
|
||||
const { startIndex, endIndex } = termPositions[termIndex]
|
||||
const segment = termIndexToSegment.get(termIndex)
|
||||
const reason = termReasons?.find(r => r.termIndex === termIndex)
|
||||
|
||||
// Add connector text before this term
|
||||
if (cursor < startIndex) {
|
||||
elements.push(
|
||||
<span key={`connector-${cursor}`}>
|
||||
{fullDecomposition.slice(cursor, startIndex)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Check if this term starts a new segment
|
||||
if (segment && segment.termIndices[0] === termIndex) {
|
||||
// This is the first term of a segment - wrap all segment terms
|
||||
const segmentElements: React.ReactNode[] = []
|
||||
let segmentCursor = startIndex
|
||||
|
||||
for (const segTermIndex of segment.termIndices) {
|
||||
const segPos = termPositions[segTermIndex]
|
||||
if (!segPos) continue
|
||||
|
||||
// Add connector within segment
|
||||
if (segmentCursor < segPos.startIndex) {
|
||||
segmentElements.push(
|
||||
<span key={`seg-connector-${segmentCursor}`}>
|
||||
{fullDecomposition.slice(segmentCursor, segPos.startIndex)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const segText = fullDecomposition.slice(segPos.startIndex, segPos.endIndex)
|
||||
const segReason = termReasons?.find(r => r.termIndex === segTermIndex)
|
||||
|
||||
segmentElements.push(
|
||||
<TermSpan
|
||||
key={`seg-term-${segTermIndex}`}
|
||||
termIndex={segTermIndex}
|
||||
text={segText}
|
||||
segment={segment}
|
||||
reason={segReason}
|
||||
isCurrentStep={segTermIndex === currentStepIndex}
|
||||
/>
|
||||
)
|
||||
|
||||
segmentCursor = segPos.endIndex
|
||||
}
|
||||
|
||||
elements.push(
|
||||
<SegmentGroup
|
||||
key={`segment-${segment.id}`}
|
||||
segment={segment}
|
||||
fullDecomposition={fullDecomposition}
|
||||
termPositions={termPositions}
|
||||
termReasons={termReasons}
|
||||
steps={steps}
|
||||
>
|
||||
{segmentElements}
|
||||
</SegmentGroup>
|
||||
)
|
||||
|
||||
// Skip ahead past all terms in this segment
|
||||
const lastSegTermIndex = segment.termIndices[segment.termIndices.length - 1]
|
||||
const lastSegPos = termPositions[lastSegTermIndex]
|
||||
cursor = lastSegPos?.endIndex ?? endIndex
|
||||
termIndex = lastSegTermIndex // Will be incremented by for loop
|
||||
|
||||
} else if (!segment) {
|
||||
// Regular term not in a segment
|
||||
const termText = fullDecomposition.slice(startIndex, endIndex)
|
||||
elements.push(
|
||||
<TermSpan
|
||||
key={`term-${termIndex}`}
|
||||
termIndex={termIndex}
|
||||
text={termText}
|
||||
segment={segment}
|
||||
reason={reason}
|
||||
isCurrentStep={termIndex === currentStepIndex}
|
||||
/>
|
||||
)
|
||||
cursor = endIndex
|
||||
}
|
||||
// If this term is part of a segment but not the first, it was already handled above
|
||||
}
|
||||
|
||||
// Add trailing text
|
||||
if (cursor < fullDecomposition.length) {
|
||||
elements.push(
|
||||
<span key="trailing">
|
||||
{fullDecomposition.slice(cursor)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return elements
|
||||
}
|
||||
|
||||
return (
|
||||
<DecompositionContext.Provider value={{ activeTerms, activeSegmentId, addActiveTerm, removeActiveTerm }}>
|
||||
<Tooltip.Provider delayDuration={200} skipDelayDuration={100}>
|
||||
<div className="decomposition">
|
||||
{renderElements()}
|
||||
</div>
|
||||
</Tooltip.Provider>
|
||||
</DecompositionContext.Provider>
|
||||
)
|
||||
}
|
||||
177
apps/web/src/components/tutorial/ReasonTooltip.tsx
Normal file
177
apps/web/src/components/tutorial/ReasonTooltip.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import type { PedagogicalRule, PedagogicalSegment, TermReason } from './DecompositionWithReasons'
|
||||
import type { UnifiedStepData } from '../../utils/unifiedStepGenerator'
|
||||
|
||||
interface ReasonTooltipProps {
|
||||
children: React.ReactNode
|
||||
termIndex: number
|
||||
segment?: PedagogicalSegment
|
||||
reason?: TermReason
|
||||
originalValue?: string
|
||||
steps?: UnifiedStepData[]
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function ReasonTooltip({
|
||||
children,
|
||||
termIndex,
|
||||
segment,
|
||||
reason,
|
||||
originalValue,
|
||||
steps,
|
||||
open,
|
||||
onOpenChange
|
||||
}: ReasonTooltipProps) {
|
||||
const rule = reason?.rule ?? segment?.plan[0]?.rule
|
||||
const shortReason = reason?.shortReason
|
||||
const bullets = reason?.bullets
|
||||
|
||||
if (!rule) {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
const getRuleInfo = (rule: PedagogicalRule) => {
|
||||
switch (rule) {
|
||||
case 'Direct':
|
||||
return {
|
||||
emoji: '✨',
|
||||
name: 'Direct Move',
|
||||
description: 'Simple bead movement',
|
||||
color: 'green'
|
||||
}
|
||||
case 'FiveComplement':
|
||||
return {
|
||||
emoji: '🤝',
|
||||
name: 'Five Friend',
|
||||
description: 'Using pairs that make 5',
|
||||
color: 'blue'
|
||||
}
|
||||
case 'TenComplement':
|
||||
return {
|
||||
emoji: '🔟',
|
||||
name: 'Ten Friend',
|
||||
description: 'Using pairs that make 10',
|
||||
color: 'purple'
|
||||
}
|
||||
case 'Cascade':
|
||||
return {
|
||||
emoji: '🌊',
|
||||
name: 'Chain Reaction',
|
||||
description: 'One move triggers another',
|
||||
color: 'orange'
|
||||
}
|
||||
default:
|
||||
return {
|
||||
emoji: '💭',
|
||||
name: 'Strategy',
|
||||
description: 'Abacus technique',
|
||||
color: 'gray'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ruleInfo = getRuleInfo(rule)
|
||||
const contentClasses = `reason-tooltip reason-tooltip--${ruleInfo.color}`
|
||||
|
||||
return (
|
||||
<Tooltip.Root open={open} onOpenChange={onOpenChange} delayDuration={300}>
|
||||
<Tooltip.Trigger asChild>
|
||||
{children}
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content
|
||||
className={contentClasses}
|
||||
sideOffset={8}
|
||||
side="top"
|
||||
align="center"
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="reason-tooltip__content">
|
||||
<div className="reason-tooltip__header">
|
||||
<span className="reason-tooltip__emoji">{ruleInfo.emoji}</span>
|
||||
<div className="reason-tooltip__title">
|
||||
<h4 className="reason-tooltip__name">{ruleInfo.name}</h4>
|
||||
<p className="reason-tooltip__description">{ruleInfo.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{shortReason && (
|
||||
<div className="reason-tooltip__explanation">
|
||||
<p>{shortReason}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show expansion reasoning from segment plan */}
|
||||
{segment?.plan && segment.plan.length > 0 && (
|
||||
<div className="reason-tooltip__reasoning">
|
||||
<h5 className="reason-tooltip__section-title">Why this expansion?</h5>
|
||||
{segment.plan.map((decision, idx) => (
|
||||
<div key={idx} className="reason-tooltip__decision">
|
||||
{decision.explanation.map((explain, explainIdx) => (
|
||||
<p key={explainIdx} className="reason-tooltip__explanation-text">
|
||||
{explain}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bullets && bullets.length > 0 && (
|
||||
<div className="reason-tooltip__details">
|
||||
<ul>
|
||||
{bullets.map((bullet, idx) => (
|
||||
<li key={idx}>
|
||||
<span className="reason-tooltip__bullet">•</span>
|
||||
<span>{bullet}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show step-by-step breakdown for multi-step segments */}
|
||||
{segment && steps && segment.stepIndices && segment.stepIndices.length > 1 && (
|
||||
<div className="reason-tooltip__steps">
|
||||
<h5 className="reason-tooltip__section-title">Step-by-step breakdown:</h5>
|
||||
<ol className="reason-tooltip__step-list">
|
||||
{segment.stepIndices.map((stepIndex, idx) => {
|
||||
const step = steps[stepIndex]
|
||||
if (!step) return null
|
||||
|
||||
return (
|
||||
<li key={stepIndex} className="reason-tooltip__step">
|
||||
<code className="reason-tooltip__step-term">{step.mathematicalTerm}</code>
|
||||
<span className="reason-tooltip__step-instruction">{step.englishInstruction}</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{originalValue && segment?.expression && (
|
||||
<div className="reason-tooltip__formula">
|
||||
<div className="reason-tooltip__expansion">
|
||||
<span className="reason-tooltip__original">{originalValue}</span>
|
||||
<span className="reason-tooltip__arrow">→</span>
|
||||
<code className="reason-tooltip__expanded">{segment.expression}</code>
|
||||
</div>
|
||||
<div className="reason-tooltip__label">
|
||||
{originalValue} becomes {segment.expression}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Tooltip.Arrow className="reason-tooltip__arrow" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { calculateBeadDiffFromValues } from '../../utils/beadDiff'
|
||||
import { generateUnifiedInstructionSequence } from '../../utils/unifiedStepGenerator'
|
||||
import { TutorialProvider, useTutorialContext } from './TutorialContext'
|
||||
import { PedagogicalDecompositionDisplay } from './PedagogicalDecompositionDisplay'
|
||||
import { DecompositionWithReasons } from './DecompositionWithReasons'
|
||||
import { useAbacusDisplay } from '@/contexts/AbacusDisplayContext'
|
||||
|
||||
// Helper function to find the topmost bead with arrows
|
||||
@@ -199,6 +200,7 @@ function TutorialPlayerContent({
|
||||
const isProgrammaticChange = useRef(false)
|
||||
const [showHelpForCurrentStep, setShowHelpForCurrentStep] = useState(false)
|
||||
|
||||
|
||||
// Use tutorial context instead of local state
|
||||
const {
|
||||
state,
|
||||
@@ -252,7 +254,7 @@ function TutorialPlayerContent({
|
||||
}
|
||||
|
||||
// Define the static expected steps using our unified step generator
|
||||
const { expectedSteps, fullDecomposition, isMeaningfulDecomposition } = useMemo(() => {
|
||||
const { expectedSteps, fullDecomposition, isMeaningfulDecomposition, pedagogicalSegments, termPositions } = useMemo(() => {
|
||||
try {
|
||||
const unifiedSequence = generateUnifiedInstructionSequence(currentStep.startValue, currentStep.targetValue)
|
||||
|
||||
@@ -267,16 +269,23 @@ function TutorialPlayerContent({
|
||||
termPosition: step.termPosition // Add the precise position information
|
||||
}))
|
||||
|
||||
// Extract term positions from steps for DecompositionWithReasons
|
||||
const positions = unifiedSequence.steps.map(step => step.termPosition).filter(Boolean)
|
||||
|
||||
return {
|
||||
expectedSteps: steps,
|
||||
fullDecomposition: unifiedSequence.fullDecomposition,
|
||||
isMeaningfulDecomposition: unifiedSequence.isMeaningfulDecomposition
|
||||
isMeaningfulDecomposition: unifiedSequence.isMeaningfulDecomposition,
|
||||
pedagogicalSegments: unifiedSequence.segments,
|
||||
termPositions: positions
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
expectedSteps: [],
|
||||
fullDecomposition: '',
|
||||
isMeaningfulDecomposition: false
|
||||
isMeaningfulDecomposition: false,
|
||||
pedagogicalSegments: [],
|
||||
termPositions: []
|
||||
}
|
||||
}
|
||||
}, [currentStep.startValue, currentStep.targetValue])
|
||||
@@ -1101,7 +1110,7 @@ function TutorialPlayerContent({
|
||||
Guidance
|
||||
</p>
|
||||
|
||||
{/* Pedagogical decomposition with current term highlighted */}
|
||||
{/* Pedagogical decomposition with interactive reasoning */}
|
||||
{fullDecomposition && isMeaningfulDecomposition && (
|
||||
<div className={css({
|
||||
mb: 4,
|
||||
@@ -1120,14 +1129,16 @@ function TutorialPlayerContent({
|
||||
letterSpacing: 'tight',
|
||||
lineHeight: '1.5'
|
||||
})}>
|
||||
<PedagogicalDecompositionDisplay
|
||||
variant="guidance"
|
||||
decomposition={renderHighlightedDecomposition()}
|
||||
<DecompositionWithReasons
|
||||
fullDecomposition={fullDecomposition}
|
||||
termPositions={termPositions}
|
||||
segments={pedagogicalSegments}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<div className={css({
|
||||
fontSize: 'sm',
|
||||
color: 'amber.800',
|
||||
|
||||
401
apps/web/src/components/tutorial/decomposition-reasoning.css
Normal file
401
apps/web/src/components/tutorial/decomposition-reasoning.css
Normal file
@@ -0,0 +1,401 @@
|
||||
/* CSS styling for DecompositionWithReasons component */
|
||||
|
||||
.decomposition {
|
||||
display: inline;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Monaco', 'Consolas', monospace;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.term {
|
||||
display: inline-block;
|
||||
padding: 2px 4px;
|
||||
margin: 0 1px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.term--plain {
|
||||
cursor: default;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
|
||||
.term:focus {
|
||||
outline: none;
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
border-color: rgba(59, 130, 246, 0.5);
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
/* Active term - the one being directly hovered/clicked */
|
||||
.term--active {
|
||||
background: rgba(59, 130, 246, 0.25);
|
||||
border-color: rgba(59, 130, 246, 0.6);
|
||||
font-weight: 600;
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
/* Current step - the term that matches the current tutorial step */
|
||||
.term--current {
|
||||
background: rgba(245, 158, 11, 0.2);
|
||||
border-color: rgba(245, 158, 11, 0.6);
|
||||
border-width: 2px;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.3);
|
||||
animation: currentStepGlow 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Grouped terms - terms that belong to pedagogical segments */
|
||||
.term--grouped {
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
|
||||
/* Reason tooltip styling */
|
||||
.reason-tooltip {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
min-width: 200px;
|
||||
max-width: 240px;
|
||||
z-index: 50;
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
||||
}
|
||||
|
||||
.reason-tooltip__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.reason-tooltip__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.reason-tooltip__emoji {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.reason-tooltip__title {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.reason-tooltip__name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 4px 0;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.reason-tooltip__description {
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
color: #6b7280;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.reason-tooltip__explanation {
|
||||
font-size: 14px;
|
||||
color: #374151;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.reason-tooltip__explanation p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.reason-tooltip__details ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.reason-tooltip__details li {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
font-size: 13px;
|
||||
color: #4b5563;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.reason-tooltip__details li:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.reason-tooltip__bullet {
|
||||
color: #9ca3af;
|
||||
font-weight: bold;
|
||||
margin-top: 1px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.reason-tooltip__formula {
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.reason-tooltip__expansion {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
padding: 8px;
|
||||
background: #fafbfc;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.reason-tooltip__original {
|
||||
background: #fff2cc;
|
||||
border: 1px solid #ffd93d;
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Monaco', 'Consolas', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.reason-tooltip__arrow {
|
||||
color: #6b7280;
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.reason-tooltip__expanded {
|
||||
background: #f0f9ff;
|
||||
border: 1px solid #7dd3fc;
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Monaco', 'Consolas', monospace;
|
||||
font-size: 12px;
|
||||
color: #1e40af;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.reason-tooltip__section-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
margin: 0 0 8px 0;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.reason-tooltip__reasoning {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.reason-tooltip__decision {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.reason-tooltip__explanation-text {
|
||||
font-size: 12px;
|
||||
color: #4b5563;
|
||||
margin: 0 0 4px 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.reason-tooltip__steps {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.reason-tooltip__step-list {
|
||||
margin: 0;
|
||||
padding: 0 0 0 16px;
|
||||
list-style: decimal;
|
||||
}
|
||||
|
||||
.reason-tooltip__step {
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.reason-tooltip__step:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.reason-tooltip__step-term {
|
||||
background: #f3f4f6;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 3px;
|
||||
padding: 2px 6px;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Monaco', 'Consolas', monospace;
|
||||
font-size: 11px;
|
||||
color: #1f2937;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.reason-tooltip__step-instruction {
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
line-height: 1.3;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.reason-tooltip__label {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.reason-tooltip__code {
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 4px;
|
||||
padding: 4px 6px;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Monaco', 'Consolas', monospace;
|
||||
font-size: 12px;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
/* Radix tooltip arrow */
|
||||
.reason-tooltip .reason-tooltip__arrow {
|
||||
fill: #ffffff;
|
||||
stroke: #e5e7eb;
|
||||
stroke-width: 2px;
|
||||
}
|
||||
|
||||
/* Color variants */
|
||||
.reason-tooltip--green {
|
||||
border-color: #10b981;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f0fdf4 100%);
|
||||
}
|
||||
|
||||
.reason-tooltip--green .reason-tooltip__name {
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.reason-tooltip--green .reason-tooltip__emoji {
|
||||
filter: hue-rotate(120deg);
|
||||
}
|
||||
|
||||
.reason-tooltip--blue {
|
||||
border-color: #3b82f6;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #eff6ff 100%);
|
||||
}
|
||||
|
||||
.reason-tooltip--blue .reason-tooltip__name {
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.reason-tooltip--purple {
|
||||
border-color: #8b5cf6;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f5f3ff 100%);
|
||||
}
|
||||
|
||||
.reason-tooltip--purple .reason-tooltip__name {
|
||||
color: #5b21b6;
|
||||
}
|
||||
|
||||
.reason-tooltip--orange {
|
||||
border-color: #f59e0b;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #fffbeb 100%);
|
||||
}
|
||||
|
||||
.reason-tooltip--orange .reason-tooltip__name {
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.reason-tooltip--gray {
|
||||
border-color: #6b7280;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f9fafb 100%);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes groupPulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 0 0 1px rgba(147, 51, 234, 0.4);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes currentStepGlow {
|
||||
0% {
|
||||
box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.5), 0 0 8px rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
/* Segment group styling */
|
||||
.segment-group {
|
||||
display: inline;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
padding: 2px 4px;
|
||||
margin: 0 1px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.segment-group:hover {
|
||||
background: rgba(147, 51, 234, 0.1);
|
||||
box-shadow: 0 0 0 2px rgba(147, 51, 234, 0.2);
|
||||
}
|
||||
|
||||
/* Accessibility improvements */
|
||||
.term:focus-visible {
|
||||
outline: 2px solid #2563eb;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.segment-group:focus-visible {
|
||||
outline: 2px solid #8b5cf6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.term {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.reason-popover {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user