From af75330b038b25da78f89e9c295771782520ea47 Mon Sep 17 00:00:00 2001 From: Jiho Kim <55632840+nghtctrl@users.noreply.github.com> Date: Fri, 12 Jun 2026 16:13:33 -0500 Subject: [PATCH 1/6] TT-7354 Trying to position parallelograms using JS is buggy, so use CSS (flexbox) instead + Refactor code for readability --- .../mobile/MobileWorkflowSteps.tsx | 329 +++++++++--------- 1 file changed, 155 insertions(+), 174 deletions(-) diff --git a/src/renderer/src/components/PassageDetail/mobile/MobileWorkflowSteps.tsx b/src/renderer/src/components/PassageDetail/mobile/MobileWorkflowSteps.tsx index 42d47470..5f46d38d 100644 --- a/src/renderer/src/components/PassageDetail/mobile/MobileWorkflowSteps.tsx +++ b/src/renderer/src/components/PassageDetail/mobile/MobileWorkflowSteps.tsx @@ -20,7 +20,7 @@ import { useSnackBar } from '../../../hoc/SnackBar'; import { sharedSelector, workflowStepsSelector } from '../../../selector'; import { shallowEqual, useSelector } from 'react-redux'; import { useWfLabel } from '../../../utils/useWfLabel'; -import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { IWorkflowStepsStrings, PassageD } from '../../../model'; import { toCamel } from '../../../utils/toCamel'; import { related } from '../../../crud/related'; @@ -67,6 +67,12 @@ export default function MobileWorkflowSteps() { workflowStepsSelector, shallowEqual ); + + // Refs used to scroll the current step/passage into view + const didMountRef = useRef(false); + const stepRefs = useRef(new Map()); + + // Ordered list of passages in the current section, excluding publishing-title rows, sorted by sequence number. const sectionPassages = useMemo(() => { const passRecIds = related(section, 'passages'); if (!Array.isArray(passRecIds)) return []; @@ -82,20 +88,13 @@ export default function MobileWorkflowSteps() { const [passageMenuAnchor, setPassageMenuAnchor] = useState(null); - const handleSelect = (id: string) => () => { - if (getGlobal('remoteBusy')) { - showMessage(ts.wait); - return; - } - if (!recording && !commentRecording && id !== currentstep) { - setCurrentStep(id); - } - }; - + // The display label of the currently workflow step const currentLabel = useMemo( () => workflow.find((w) => w.id === currentstep)?.label ?? '', [currentstep, workflow] ); + + // The tip text for the current workflow step const currentTip = useMemo(() => { if (!currentLabel) return ''; if (tool === ToolSlug.Prompt && showPromptAdmin) { @@ -107,25 +106,57 @@ export default function MobileWorkflowSteps() { : ''; }, [currentLabel, t, tool, showPromptAdmin]); - const dropdownRef = useRef(null); - const [dropdownWidth, setDropdownWidth] = useState(0); + const passageRef = (p?: PassageD) => + [p?.attributes?.book, p?.attributes?.reference].filter(Boolean).join(' '); - useLayoutEffect(() => { - const el = dropdownRef.current; - if (!el) return; - const update = () => - setDropdownWidth( - el.offsetWidth + parseFloat(window.getComputedStyle(el).marginRight) - ); - update(); - const ro = new ResizeObserver(update); - ro.observe(el); - return () => ro.disconnect(); - }, []); + const navigateToPassage = (p: PassageD) => { + const remId = p.keys?.remoteId ?? p.id; + rememberCurrentPassage(memory, remId); + passageNavigate(`/detail/${prjId}/${remId}`); + }; - const didMountRef = useRef(false); - const stepRefs = useRef(new Map()); + const handleSelect = (id: string) => () => { + if (getGlobal('remoteBusy')) { + showMessage(ts.wait); + return; + } + if (!recording && !commentRecording && id !== currentstep) { + setCurrentStep(id); + } + }; + + const isInteractionBlocked = () => { + if (recording || commentRecording) return true; + if (getGlobal('remoteBusy')) { + showMessage(ts.wait); + return true; + } + return false; + }; + // The step/passage data model for the parallelograms + const steps = isStepProgression + ? workflow.map((s) => ({ + id: s.id, + dataCy: 'workflow-step', + isCurrent: s.id === currentstep, + isComplete: stepComplete(s.id), + onClick: handleSelect(s.id), + })) + : sectionPassages.map((p) => ({ + id: p.id, + dataCy: 'passage-step', + isCurrent: p.id === passage?.id, + isComplete: + (p.attributes.sequencenum ?? 0) < + (passage?.attributes?.sequencenum ?? 0), + onClick: () => { + if (isInteractionBlocked()) return; + navigateToPassage(p); + }, + })); + + // Keep the current step/passage scrolled into view useEffect(() => { const currentId = isStepProgression ? currentstep : (passage?.id ?? ''); const el = stepRefs.current.get(currentId); @@ -145,18 +176,35 @@ export default function MobileWorkflowSteps() { ]); return ( - + + {/* Top row with the passage dropdown and parallelograms */} {/* Passage dropdown */} {!isStepProgression && currentTip && ( } - sx={{ whiteSpace: 'nowrap', minWidth: 'auto' }} + sx={{ + whiteSpace: 'nowrap', + minWidth: 'auto', + }} onClick={(e) => { if (recording || commentRecording) return; if (getGlobal('remoteBusy')) { @@ -183,11 +234,7 @@ export default function MobileWorkflowSteps() { }} data-cy="passage-dropdown" > - {isStepProgression - ? [passage?.attributes?.book, passage?.attributes?.reference] - .filter(Boolean) - .join(' ') || '' - : currentLabel} + {isStepProgression ? passageRef(passage) : currentLabel} { - const remId = p.keys?.remoteId ?? p.id; - rememberCurrentPassage(memory, remId); - passageNavigate(`/detail/${prjId}/${remId}`); + navigateToPassage(p); setPassageMenuAnchor(null); }} > - {[p.attributes.book, p.attributes.reference] - .filter(Boolean) - .join(' ')} + {passageRef(p)} )) : workflow.map((step) => ( @@ -225,145 +268,83 @@ export default function MobileWorkflowSteps() { ))} - {/* Workflow step parallelograms */} + + {/* Step/passage parallelograms */} - - {isStepProgression - ? workflow.map((step) => { - const isCurrent = step.id === currentstep; - return ( - { - if (el) stepRefs.current.set(step.id, el); - else stepRefs.current.delete(step.id); - }} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - } - }} - sx={{ - flex: '0 0 80px', - height: 30, - mr: -0.25, // Overlap adjacent parallelograms so their edges meet cleanly - backgroundColor: isCurrent - ? theme.palette.grey[700] - : stepComplete(step.id) - ? theme.palette.grey[400] - : theme.palette.grey[200], - clipPath: 'polygon(10% 0%, 100% 0%, 90% 100%, 0% 100%)', - cursor: - recording || commentRecording - ? 'not-allowed' - : 'pointer', - }} - /> - ); - }) - : sectionPassages.map((p) => { - const isCurrent = p.id === passage?.id; - const isComplete = - (p.attributes.sequencenum ?? 0) < - (passage?.attributes?.sequencenum ?? 0); - return ( - { - if (recording || commentRecording) return; - if (getGlobal('remoteBusy')) { - showMessage(ts.wait); - return; - } - const remId = p.keys?.remoteId ?? p.id; - rememberCurrentPassage(memory, remId); - passageNavigate(`/detail/${prjId}/${remId}`); - }} - tabIndex={0} - ref={(el) => { - if (el) stepRefs.current.set(p.id, el); - else stepRefs.current.delete(p.id); - }} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - } - }} - sx={{ - flex: '0 0 80px', - height: 30, - mr: -0.25, // Overlap adjacent parallelograms so their edges meet cleanly - backgroundColor: isCurrent - ? theme.palette.grey[700] - : isComplete - ? theme.palette.grey[400] - : theme.palette.grey[200], - clipPath: 'polygon(10% 0%, 100% 0%, 90% 100%, 0% 100%)', - cursor: - recording || commentRecording - ? 'not-allowed' - : 'pointer', - }} - /> - ); - })} - {/* Spacer to mirror the dropdown width so mx:auto centers the parallelograms */} - - - - - {(isStepProgression ? currentLabel : passage?.id) && ( - - {isStepProgression ? ( - currentTip ? ( - setTipOpen(true)} - data-cy="workflow-step-tip" + {steps.map((step) => { + const color = step.isCurrent + ? theme.palette.grey[700] + : step.isComplete + ? theme.palette.grey[400] + : theme.palette.grey[200]; + return ( + { + if (el) stepRefs.current.set(step.id, el); + else stepRefs.current.delete(step.id); + }} + onClick={step.onClick} sx={{ - borderRadius: 1, - fontWeight: 'inherit', - fontSize: 'inherit', + flex: '0 0 80px', + height: 30, + bgcolor: color, + mr: -0.25, + clipPath: 'polygon(10% 0%, 100% 0%, 90% 100%, 0% 100%)', + cursor: + recording || commentRecording ? 'not-allowed' : 'pointer', }} - aria-label={currentTip} - > - {getWfLabel(currentLabel) + '\u00A0'} - - - ) : ( - getWfLabel(currentLabel) - ) + /> + ); + })} + + + {/* Spacer to center the parallelograms in the top row on desktop screens */} + + + + {/* Bottom row with the labels */} + + {isStepProgression ? ( + currentTip ? ( + setTipOpen(true)} + data-cy="workflow-step-tip" + sx={{ + borderRadius: 1, + fontWeight: 'inherit', + fontSize: 'inherit', + }} + aria-label={currentTip} + > + {getWfLabel(currentLabel) + '\u00A0'} + + ) : ( - [passage?.attributes?.book, passage?.attributes?.reference] - .filter(Boolean) - .join(' ') - )} - - )} + getWfLabel(currentLabel) + ) + ) : ( + passageRef(passage) + )} + + + {/* Tip dialog */} setTipOpen(false)}> {getWfLabel(currentLabel)} {currentTip} From 8780a90372dd29d4df2432ea98a90fa0f3dc7d8b Mon Sep 17 00:00:00 2001 From: Jiho Kim <55632840+nghtctrl@users.noreply.github.com> Date: Fri, 12 Jun 2026 16:14:22 -0500 Subject: [PATCH 2/6] TT-7354 Remove test about spacer as it is no longer relevant --- .../mobile/MobileWorkflowSteps.cy.tsx | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/src/renderer/src/components/PassageDetail/mobile/MobileWorkflowSteps.cy.tsx b/src/renderer/src/components/PassageDetail/mobile/MobileWorkflowSteps.cy.tsx index 7262136d..1e4dd46a 100644 --- a/src/renderer/src/components/PassageDetail/mobile/MobileWorkflowSteps.cy.tsx +++ b/src/renderer/src/components/PassageDetail/mobile/MobileWorkflowSteps.cy.tsx @@ -540,20 +540,4 @@ describe('MobileWorkflowSteps', () => { cy.get('@setCurrentStep').should('not.have.been.called'); }); }); - - describe('spacer responsive behavior', () => { - it('hides the spacer below 840px to prevent extra horizontal scroll', () => { - cy.viewport(839, 600); - mountMobileWorkflowSteps({ isStepProgression: true }); - - cy.get('[data-cy="step-spacer"]').should('have.css', 'display', 'none'); - }); - - it('shows the spacer at 840px and above to preserve parallelogram centering', () => { - cy.viewport(840, 600); - mountMobileWorkflowSteps({ isStepProgression: true }); - - cy.get('[data-cy="step-spacer"]').should('have.css', 'display', 'block'); - }); - }); }); From 99669bbaa8b7e89a174a88f4c3690fc89ba08517 Mon Sep 17 00:00:00 2001 From: Jiho Kim <55632840+nghtctrl@users.noreply.github.com> Date: Tue, 16 Jun 2026 14:45:35 -0500 Subject: [PATCH 3/6] TT-7354 Center when parallelograms fit, scroll when it overflows (fixes edge case when there's only one parallelogram) --- .../PassageDetail/mobile/MobileWorkflowSteps.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/components/PassageDetail/mobile/MobileWorkflowSteps.tsx b/src/renderer/src/components/PassageDetail/mobile/MobileWorkflowSteps.tsx index 5f46d38d..2d186ee4 100644 --- a/src/renderer/src/components/PassageDetail/mobile/MobileWorkflowSteps.tsx +++ b/src/renderer/src/components/PassageDetail/mobile/MobileWorkflowSteps.tsx @@ -275,13 +275,13 @@ export default function MobileWorkflowSteps() { overflowX: 'auto', display: 'flex', flex: { xs: 1, md: 'none' }, - justifyContent: { - xs: 'flex-start', - md: 'center', - }, position: { md: 'absolute' }, left: { md: 0 }, right: { md: 0 }, + '&::before, &::after': { + content: '""', + margin: 'auto', + }, }} > {steps.map((step) => { From 637d8b1f3893a550b48940d04d4ca2f20d8bb911 Mon Sep 17 00:00:00 2001 From: Jiho Kim <55632840+nghtctrl@users.noreply.github.com> Date: Tue, 16 Jun 2026 14:49:10 -0500 Subject: [PATCH 4/6] TT-7354 Localize current step label on mobile passage dropdown button --- .../src/components/PassageDetail/mobile/MobileWorkflowSteps.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/components/PassageDetail/mobile/MobileWorkflowSteps.tsx b/src/renderer/src/components/PassageDetail/mobile/MobileWorkflowSteps.tsx index 2d186ee4..49fe5134 100644 --- a/src/renderer/src/components/PassageDetail/mobile/MobileWorkflowSteps.tsx +++ b/src/renderer/src/components/PassageDetail/mobile/MobileWorkflowSteps.tsx @@ -234,7 +234,7 @@ export default function MobileWorkflowSteps() { }} data-cy="passage-dropdown" > - {isStepProgression ? passageRef(passage) : currentLabel} + {isStepProgression ? passageRef(passage) : getWfLabel(currentLabel)} Date: Tue, 16 Jun 2026 15:54:17 -0500 Subject: [PATCH 5/6] TT-7354 Don't show dropdown options when there's just one item --- .../PassageDetail/mobile/MobileWorkflowSteps.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/PassageDetail/mobile/MobileWorkflowSteps.tsx b/src/renderer/src/components/PassageDetail/mobile/MobileWorkflowSteps.tsx index 49fe5134..0f3e1acd 100644 --- a/src/renderer/src/components/PassageDetail/mobile/MobileWorkflowSteps.tsx +++ b/src/renderer/src/components/PassageDetail/mobile/MobileWorkflowSteps.tsx @@ -72,7 +72,7 @@ export default function MobileWorkflowSteps() { const didMountRef = useRef(false); const stepRefs = useRef(new Map()); - // Ordered list of passages in the current section, excluding publishing-title rows, sorted by sequence number. + // Ordered list of passages in the current section, excluding publishing-title rows, sorted by sequence number const sectionPassages = useMemo(() => { const passRecIds = related(section, 'passages'); if (!Array.isArray(passRecIds)) return []; @@ -115,6 +115,10 @@ export default function MobileWorkflowSteps() { passageNavigate(`/detail/${prjId}/${remId}`); }; + // Check if the dropdown has more than one option to pick from + const dropdownOptions = isStepProgression ? sectionPassages : workflow; + const hasMultipleOptions = dropdownOptions.length > 1; + const handleSelect = (id: string) => () => { if (getGlobal('remoteBusy')) { showMessage(ts.wait); @@ -219,12 +223,13 @@ export default function MobileWorkflowSteps() { )}