From 0973f24c8fada3783cd797906052b8e5fe98e757 Mon Sep 17 00:00:00 2001 From: clacina Date: Wed, 29 Apr 2026 09:37:02 -0700 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20passphrase-gated=20fr?= =?UTF-8?q?ont=20page=20with=20app=20banner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restrict access to the app behind a passphrase so the landing page is not immediately visible to anyone who opens the URL. The gate persists for the current browser tab session via sessionStorage. Passphrase is read from the VITE_PASSPHRASE env variable to keep it out of source files. Co-Authored-By: Claude Sonnet 4.6 --- _specs/passphrase-front-page.md | 45 +++++++++++++++ src/App.css | 96 +++++++++++++++++++++++++++++++ src/App.jsx | 12 +++- src/components/AppBanner.jsx | 9 +++ src/components/PassphrasePage.jsx | 50 ++++++++++++++++ 5 files changed, 211 insertions(+), 1 deletion(-) create mode 100644 _specs/passphrase-front-page.md create mode 100644 src/components/AppBanner.jsx create mode 100644 src/components/PassphrasePage.jsx diff --git a/_specs/passphrase-front-page.md b/_specs/passphrase-front-page.md new file mode 100644 index 0000000..bd13c8e --- /dev/null +++ b/_specs/passphrase-front-page.md @@ -0,0 +1,45 @@ +# Spec for passphrase-front-page + +branch: claude/feature/passphrase-front-page + +## Summary +Add a passphrase-gated front page that is the first thing a user sees when they open the application. The page displays an application banner and a passphrase entry field. Only after the correct passphrase is entered does the user proceed to the existing landing page. The `Footer` component is included on this page. + +## Functional Requirements +- The app opens to the new front page instead of the landing page. +- The front page displays an application banner (app name/logo/title). +- The front page displays a passphrase input field (type password) and a submit control. +- When the user submits the correct passphrase (`DororthASL`), the app transitions to the existing landing page. +- When the user submits an incorrect passphrase, an error message is shown and the input is cleared; the user remains on the front page. +- The `Footer` component is rendered at the bottom of the front page. +- The passphrase is not displayed in plain text at any point. +- The passphrase should not be stored in `localStorage`, `sessionStorage`, or any persistent client-side store — it only needs to gate the current session. + +## Possible Edge Cases +- Submitting an empty passphrase should show an error (not silently fail). +- The passphrase comparison is case-sensitive (`DororthASL` is the only valid value). +- Once the user has passed the gate, navigating back (browser back button) should not re-show the front page within the same session. +- The passphrase should not appear in the page source, git history, or bundle in a way that is trivially discoverable — consider keeping it out of the component render logic if possible (e.g. an env variable or a hashed comparison). + +## Acceptance Criteria +- [ ] Opening the app shows the front page with a banner and passphrase field. +- [ ] Entering `DororthASL` and submitting advances the user to the landing page. +- [ ] Entering any other value shows an inline error and clears the input. +- [ ] Submitting an empty field shows an error. +- [ ] The `Footer` component is visible on the front page. +- [ ] The front page is not shown again after a successful passphrase entry within the same session. +- [ ] The passphrase input masks the entered text. + +## Open Questions +- Should the banner be a new component or a simple styled heading on the front page? + - new component +- Should session-level gating use React state only (resets on page reload) or `sessionStorage` (survives reload within the tab)? + - use sessionStorage + +## Testing Guidelines +Create a test file in the `./tests` folder for this feature and create meaningful tests for the following cases: +- Renders the front page (banner + passphrase input + footer) on initial load. +- Entering the correct passphrase and submitting transitions to the landing page view. +- Entering an incorrect passphrase shows an error message and does not advance. +- Submitting with an empty passphrase shows a validation error. +- The passphrase input field has `type="password"`. diff --git a/src/App.css b/src/App.css index 704c892..1241c7b 100644 --- a/src/App.css +++ b/src/App.css @@ -606,3 +606,99 @@ max-width: 100%; } } + +/* ── App banner ───────────────────────────────────────── */ + +.app-banner { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + margin-bottom: 32px; +} + +.app-banner__icon { + font-size: 48px; + line-height: 1; +} + +.app-banner__title { + margin: 0; + font-size: clamp(28px, 5vw, 42px); + font-weight: 700; + letter-spacing: -0.02em; + color: var(--text-h); +} + +.app-banner__subtitle { + margin: 0; + font-size: 16px; + color: var(--text); +} + +/* ── Passphrase page ──────────────────────────────────── */ + +.passphrase-page { + min-height: 100svh; + display: flex; + flex-direction: column; + padding: 28px 12px; + + @media (max-width: 640px) { + padding: 32px 16px; + } +} + +.passphrase-page__body { + flex-grow: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.passphrase-page__form { + display: flex; + flex-direction: column; + gap: 12px; + width: 100%; + max-width: 360px; + background: var(--code-bg); + border: 1px solid var(--border); + border-radius: 12px; + padding: 28px 24px; + box-shadow: var(--shadow); +} + +.passphrase-page__label { + font-size: 15px; + font-weight: 600; + color: var(--text-h); +} + +.passphrase-page__input { + width: 100%; + padding: 10px 12px; + font-size: 15px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg); + color: var(--text-h); + box-sizing: border-box; + + &:focus { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-color: var(--accent); + } +} + +.passphrase-page__error { + margin: 0; + font-size: 14px; + color: var(--color-needs-fix); +} + +.passphrase-page__submit { + align-self: flex-end; +} diff --git a/src/App.jsx b/src/App.jsx index 8a4c743..f186d51 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -3,11 +3,12 @@ import { CardColors } from './data/card-colors' import { LandingPage } from './components/LandingPage' import { FlashcardSession } from './components/FlashcardSession' import { Footer } from './components/Footer' +import { PassphrasePage } from './components/PassphrasePage' import './App.css' import {Toaster} from "react-hot-toast"; function App() { - const [view, setView] = useState('input') + const [view, setView] = useState(() => sessionStorage.getItem('asl-unlocked') ? 'input' : 'gate') const [terms, setTerms] = useState([]) const [cardColors, setCardColors] = useState([]) const [categoryTitle, setCategoryTitle] = useState('') @@ -27,6 +28,15 @@ function App() { setView('input') } + function handleUnlock() { + sessionStorage.setItem('asl-unlocked', '1') + setView('input') + } + + if (view === 'gate') { + return + } + return (
{view === 'input' ? ( diff --git a/src/components/AppBanner.jsx b/src/components/AppBanner.jsx new file mode 100644 index 0000000..7afcafb --- /dev/null +++ b/src/components/AppBanner.jsx @@ -0,0 +1,9 @@ +export function AppBanner() { + return ( +
+
🤟
+

ASL Flashcards

+

American Sign Language practice made easy

+
+ ) +} diff --git a/src/components/PassphrasePage.jsx b/src/components/PassphrasePage.jsx new file mode 100644 index 0000000..c1194fb --- /dev/null +++ b/src/components/PassphrasePage.jsx @@ -0,0 +1,50 @@ +import { useState } from 'react' +import { AppBanner } from './AppBanner' +import { Footer } from './Footer' + +export function PassphrasePage({ onUnlock }) { + const [value, setValue] = useState('') + const [error, setError] = useState('') + + function handleSubmit(e) { + e.preventDefault() + if (!value.trim()) { + setError('Please enter the passphrase.') + return + } + if (value === import.meta.env.VITE_PASSPHRASE) { + setError('') + onUnlock() + } else { + setError('Incorrect passphrase.') + setValue('') + } + } + + return ( +
+
+ +
+ + { setValue(e.target.value); setError('') }} + autoComplete="off" + autoFocus + /> + {error &&

{error}

} + +
+
+
+ ) +}