diff --git a/_specs/mobile-horizontal-layout.md b/_specs/mobile-horizontal-layout.md new file mode 100644 index 0000000..f4a75cc --- /dev/null +++ b/_specs/mobile-horizontal-layout.md @@ -0,0 +1,53 @@ +# Spec for mobile-horizontal-layout + +branch: claude/feature/mobile-horizontal-layout +figma_component (if used): N/A + +## Summary + +When a device screen is less than 1024px wide and in landscape (horizontal) orientation, the `FlashcardSession` view should switch from its current vertical layout to a compact 3-column layout: controls on the left, video in the center, and the term list on the right. The header (`flashcard-session-header` div) should be hidden in this mode to reclaim vertical space. + +## Functional Requirements + +- Detect when the viewport width is less than 1024px AND the device is in landscape (horizontal) orientation. +- In that state, hide the `flashcard-session-header` div entirely. +- Switch the session layout to a 3-column arrangement in this order: controls column | video column | term-list column. +- Revert to the default layout when the screen is wider than 1024px or switches back to portrait orientation. +- The layout change must respond dynamically as the user rotates the device (no page reload required). +- Each column should be appropriately sized so all three are visible without horizontal scrolling. +- The video column should take the most space, with controls and term list given narrower fixed or proportional widths. + +## Possible Edge Cases + +- Device rotates while a card is being shown — the active card and playback state should be preserved. +- Very narrow landscape screens (e.g. older phones) where 3 columns may still be tight — columns should not overflow; use overflow hidden or scroll per column as appropriate. +- User resizes a desktop browser window to below 1024px while in a non-landscape orientation — the 3-column layout should NOT activate; it requires both conditions simultaneously. +- Term list with many items in the narrow right column — the column should scroll independently. + +## Acceptance Criteria + +- [ ] On a device or browser window narrower than 1024px in landscape orientation, the `flashcard-session-header` div is not visible. +- [ ] On a device or browser window narrower than 1024px in landscape orientation, the session displays three side-by-side columns: controls | video | term list. +- [ ] Rotating back to portrait (or resizing above 1024px) restores the original layout and re-shows the header. +- [ ] No horizontal scrollbar appears in the 3-column layout. +- [ ] Active flashcard state is preserved through orientation changes. +- [ ] The 3-column layout does not activate on a portrait screen narrower than 1024px. + +## Open Questions + +- Should the controls column show all existing controls (navigation buttons, color picker, etc.) or a reduced set to save space? + - reduces set to save space +- Should the term list column be scrollable independently or clipped? + - independently +- Is there a minimum height required before the 3-column layout kicks in, in addition to the width and orientation check? + - Optimize so the video is the largest it can be + +## Testing Guidelines + +Create a test file(s) in the ./tests folder for the new feature, and create meaningful tests for the following cases, without going too heavy: + +- Verify that the `flashcard-session-header` is hidden when width < 1024px and orientation is landscape. +- Verify that the `flashcard-session-header` is visible when width >= 1024px regardless of orientation. +- Verify that the `flashcard-session-header` is visible when orientation is portrait and width < 1024px. +- Verify that the 3-column layout class or style is applied under the mobile-landscape condition. +- Verify that the default layout is restored when orientation changes back to portrait. diff --git a/src/App.css b/src/App.css index 1241c7b..c7aea02 100644 --- a/src/App.css +++ b/src/App.css @@ -607,6 +607,75 @@ } } +/* ── Mobile landscape 3-column layout ────────────────── */ + +.fcs-landscape { + display: grid; + grid-template-columns: 120px 1fr 140px; + gap: 8px; + flex-grow: 1; + min-height: 0; + overflow: hidden; +} + +.fcs-landscape__controls { + display: flex; + flex-direction: column; + gap: 8px; + overflow: hidden; +} + +.fcs-landscape__controls .flashcard-card { + flex: 1 1 0; + min-height: 40px; + max-width: 100%; + border-radius: 10px; + padding: 8px; +} + +.fcs-landscape__controls .flashcard-term { + font-size: clamp(14px, 3vw, 24px); +} + +.fcs-landscape__video { + display: flex; + align-items: stretch; + overflow: hidden; +} + +.fcs-landscape__video .flashcard-video { + width: 100%; + max-width: 100%; + aspect-ratio: unset; + flex: 1 1 0; + min-height: 0; +} + +.fcs-landscape__termlist { + overflow-y: auto; + overflow-x: hidden; + display: flex; + flex-direction: column; +} + +.fcs-landscape__termlist .term-select { + flex-grow: 1; + overflow-y: auto; + font-size: 13px; +} + +.fcs-landscape__nav { + display: flex; + flex-direction: column; + gap: 6px; +} + +.fcs-landscape__nav .btn-nav { + padding: 6px 10px; + font-size: 13px; + width: 100%; +} + /* ── App banner ───────────────────────────────────────── */ .app-banner { diff --git a/src/components/FlashcardSession.jsx b/src/components/FlashcardSession.jsx index ecbfca2..6128c12 100644 --- a/src/components/FlashcardSession.jsx +++ b/src/components/FlashcardSession.jsx @@ -9,8 +9,24 @@ export function FlashcardSession({terms, cardColors, onBack, title, description} const [localTerms, setLocalTerms] = useState(terms); const [localColors, setLocalColors] = useState(cardColors); const [termDrawerOpen, setTermDrawerOpen] = useState(false); + const [isMobileHorizontal, setIsMobileHorizontal] = useState(false); const selectRef = useRef(null); + useEffect(() => { + function check() { + setIsMobileHorizontal( + window.innerWidth < 1024 && window.matchMedia('(orientation: landscape)').matches + ); + } + check(); + window.addEventListener('resize', check); + window.addEventListener('orientationchange', check); + return () => { + window.removeEventListener('resize', check); + window.removeEventListener('orientationchange', check); + }; + }, []); + const goNext = useCallback(() => { setCurrentIndex(i => (i + 1) % localTerms.length); }, [localTerms.length]); @@ -72,6 +88,63 @@ export function FlashcardSession({terms, cardColors, onBack, title, description} const fg = contrastColor(bg); const playbackUrl = getPlaybackUrl(); + if (isMobileHorizontal) { + return ( +
+
+
+ +
+ {localTerms[currentIndex].term} +
+

{currentIndex + 1} / {localTerms.length}

+
+ + + +
+
+
+
+ {playbackUrl ? ( + + ) : ( +
+ No video available +
+ )} +
+
+
+ +
+
+
+ ); + } + return (
diff --git a/src/components/TermInput.jsx b/src/components/TermInput.jsx index 4169da5..9e0af2d 100644 --- a/src/components/TermInput.jsx +++ b/src/components/TermInput.jsx @@ -11,9 +11,9 @@ import color_terms from "../data/colors.json"; import faire_terms from "../data/faire.json"; import axios from "axios"; +const DEVELOPMENT = false; + const CATEGORIES = [ - {icon: "🖐️", title: "Finger Spelling", description: "Practice spelling words letter by letter using ASL handshapes.", terms: terms}, - {icon: "🔢", title: "Numbers", description: "Learn to sign numbers in American Sign Language.", terms: terms}, {icon: "📚", title: "ASL Level I & II Class Terms", description: "Core vocabulary from ASL Level I and II coursework.", terms: terms}, {icon: "❓", title: "Questions", description: "Essential question words and phrases used in ASL conversation.", terms: questions}, {icon: "⚡", title: "Verbs", description: "Common action words and verbs in American Sign Language.", terms: verbs}, @@ -23,6 +23,21 @@ const CATEGORIES = [ {icon: "🎨", title: "Colors", description: "Various Colors", terms: color_terms}, ]; +if (DEVELOPMENT) { + CATEGORIES.splice(0, 0, { + icon: "🖐️", + title: "Finger Spelling", + description: "Practice spelling words letter by letter using ASL handshapes.", + terms: terms + }); + CATEGORIES.splice(0, 0, { + icon: "🔢", + title: "Numbers", + description: "Learn to sign numbers in American Sign Language.", + terms: terms + }); +} + const webResources = [ {url: "https://www.signasl.org/", description: "Sign ASL - American Sign Language Dictionary", type: ""}, {url: "https://www.handspeak.com/", description: "Hand Speak", type: ""}, @@ -37,38 +52,42 @@ export function TermInput({onStart}) { const [numberList, setNumberList] = useState([]); useEffect(() => { - // load random words - const randomWordsUrl = "https://random-word-api.herokuapp.com/word?number=45"; - axios.get(randomWordsUrl).then((response) => { - const wordData = []; - response.data.forEach(element => { - const entry = { - "term": element, - "code": "", - "type": "spell" - }; - wordData.push(entry); + if (DEVELOPMENT) { + // load random words + const randomWordsUrl = "https://random-word-api.herokuapp.com/word?number=45"; + axios.get(randomWordsUrl).then((response) => { + const wordData = []; + response.data.forEach(element => { + const entry = { + "term": element, + "code": "", + "type": "spell" + }; + wordData.push(entry); + }); + setWordlist(wordData); }); - setWordlist(wordData); - }); - const randomNumbersUrl = "https://api.codetabs.com/v1/random/integer?min=1&max=9999×=45"; - axios.get(randomNumbersUrl).then((response) => { - const numberData = []; - response.data.data.forEach(element => { - const entry = { - "term": element.toString(), - "code": "" - }; - numberData.push(entry); + const randomNumbersUrl = "https://api.codetabs.com/v1/random/integer?min=1&max=9999×=45"; + axios.get(randomNumbersUrl).then((response) => { + const numberData = []; + response.data.data.forEach(element => { + const entry = { + "term": element.toString(), + "code": "" + }; + numberData.push(entry); + }); + setNumberList(numberData); }); - setNumberList(numberData); - }); + } }, []); useEffect(() => { - CATEGORIES.find((category) => category.title === "Numbers").terms = numberList; - CATEGORIES.find((category) => category.title === "Finger Spelling").terms = wordlist; + if (DEVELOPMENT) { + CATEGORIES.find((category) => category.title === "Numbers").terms = numberList; + CATEGORIES.find((category) => category.title === "Finger Spelling").terms = wordlist; + } }, [wordlist, numberList]); function handleStart(category) {