Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions _specs/mobile-horizontal-layout.md
Original file line number Diff line number Diff line change
@@ -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.
69 changes: 69 additions & 0 deletions src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
73 changes: 73 additions & 0 deletions src/components/FlashcardSession.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down Expand Up @@ -72,6 +88,63 @@ export function FlashcardSession({terms, cardColors, onBack, title, description}
const fg = contrastColor(bg);
const playbackUrl = getPlaybackUrl();

if (isMobileHorizontal) {
return (
<div className="flashcard-session">
<div className="fcs-landscape">
<div className="fcs-landscape__controls">
<button className="btn-back" onClick={onBack}>← Back</button>
<div className="flashcard-card" style={{backgroundColor: bg, color: fg}}>
<span className="flashcard-term">{localTerms[currentIndex].term}</span>
</div>
<p className="flashcard-position">{currentIndex + 1} / {localTerms.length}</p>
<div className="flashcard-nav fcs-landscape__nav">
<button className="btn-nav" onClick={goPrev}>← Prev</button>
<button className="btn-nav" onClick={goNext}>Next →</button>
<button className="btn-nav" onClick={handleShuffle}>⇄ Shuffle</button>
</div>
</div>
<div className="fcs-landscape__video">
<div className="flashcard-video">
{playbackUrl ? (
<ReactPlayer
className="flashcard-video-iframe"
title="ASL sign video"
src={playbackUrl}
autoPlay={true}
controls={true}
muted={true}
width="100%"
height="100%"
onError={playbackError}
/>
) : (
<div className="flashcard-video-placeholder">
No video available
</div>
)}
</div>
</div>
<div className="fcs-landscape__termlist">
<select
ref={selectRef}
size={20}
className="term-select"
onChange={e => setCurrentIndex(Number(e.target.value))}
value={currentIndex}
>
{sortedTerms.map(({term, i, fix}) => (
<option key={i} value={i} className={fix ? 'term-option--needs-fix' : undefined}>
{fix ? `[fix] ${term}` : term}
</option>
))}
</select>
</div>
</div>
</div>
);
}

return (
<div className="flashcard-session">
<button className="btn-back" onClick={onBack}>← Back</button>
Expand Down
75 changes: 47 additions & 28 deletions src/components/TermInput.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand All @@ -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: ""},
Expand All @@ -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&times=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&times=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) {
Expand Down
Loading